본문 바로가기
공모전/창원시 빅데이터 공모전

창원시 안전지수 평가

by 23 안세윤 2023. 12. 5.

이병철, 도우진, 안세윤

 

 

분석 목적 

코로나로 잠잠했던 강력범죄가 최근 급증하며 사람들의 불안감을 고조시키고 있다.

그에 대한 예시로, 2023 7월부터 시작된 신림역 칼부림, 서현역, 관악산, 홍대입구역, 광명역 흉기난동으로 인한 피해와 그에 따른 관련 기사가 사이트 전반에 퍼졌다.

이에 유동인구가 많은 지역에 경찰을 배치하는 시민들의 안전을 위해 힘을 썼지만 강력범죄가 나의 주변에서 일어날 있다는 사회 전반의 불안감을 불러왔다.

이러한 사건들과 언론 매체를 접한 시민들은 평소 다니던 길이 범죄 발생 시에 신속한 대처와 보호가 되는 지에 불안감을 느끼고 호신용품을 구비해 다니는 모습까지 보였다.

 

이러한 시민들의 불안감을 덜어주고자 범죄율 감소에 유의미한 통계가 있는 가로등, CCTV, 경찰서, 공원 등의 정보를 이용해 창원시의 구역별 안전지수를 측정하고, 안전지수를 통해 이용자가 안전한 경로로 목적지를 가는 소프트웨어에 사용될 수 있게 하고자 하였다. 또한 창원시에서 데이터를 이용해 안전지수가 낮은 지역에 보호 시설을 체계적으로 보수할 있도록 했다.

 

 

데이터 수집

석상묵, 권회윤, 송기성, 이하경, 황정래. 범죄예방 정책 의사결정 지원을 위한 지역방범지수(RCPI) 개발. 한국측량학회 학술대회자료집

 

 

논문에 따르면, 오른쪽의 가중치를 통해 지역 방범 지수를 결정한다. (물론 많은 항목들이 있다.) 여기서 우리가 수집할 있는 창원시의 데이터 종류를 파악해 수집 데이터의 종류를 창원시 가로등, 경찰서, CCTV, 주차장, 공원 위치로 정했고, 해당 데이터를 수집했다.

 

데이터 가공

from geopy.geocoders import Nominatim geo_local = Nominatim(user_agent='South Korea') # 위도, 경도 반환하는 함수 def geocoding(address): try: geo = geo_local.geocode(address) x_y = [geo.latitude, geo.longitude] return x_y except: return [0,0]

위에서 말한 데이터 CSV파일에는 도로명 주소가 기재되어 있기도 하고 위도 경도 데이터가 기재되어 있기도 했다. 도로명 주소 또는 위도 경도 데이터 하나로 통일 시켜야 함은 분명하다. 프로젝트의 목표는 창원시의 구역별 안전 지수를 측정하는 것인데, 구역을 위도 경도 기준으로 나누기 때문에 도로명 주소를 위도 경도 데이터로 바꾸기로 했다.

 

 

분석 환경 설정

창원시의 구역을 나누기 위해 위도와 경도의 범위를 정해야 했다. 10m*10m처럼 너무 작은 범위로 경우 안전지수가 제대로 매겨지지 않는 문제가 발생하기 때문에 적당한 범위를 구하는 것이 관건이었다. 경찰서와 편의점, 공원, 주차장 데이터를 유의미하게 사용하기 위해서 격자의 크기를 위도와 경도 0.0025 했다. 미터로 환산 280m*200m 정도 되는 아파트 단지 하나 크기의 격자라 예상보다 커진 감이 있지만, 시각화했을 가로등, 방범등의 데이터만 활용되는 문제를 고치기 위해 0.0025 정하게 되었다.

 

또한 초기에는 창원시 전체를 담기 위해 초록색 격자 범위를 이용하려 했으나, 외곽에 있는 산지와 바다 부분을 줄이기 위해 파란색 격자로 줄여서 진행했다

코드 분석

라이브러리 불러오기 

 

분석에 사용할 라이브러리를 불러온다. pandas numpy 라이브러리를 각각 불러온다. 

import pandas as pd
import numpy as np

 

데이터 파일 경로 설정 

 

사용할 데이터 파일의 경로를 각각 변수로 설정한다. 

# 가로등 데이터 CSV 파일 경로
streetlight_csv_file = '경상남도 창원시_가로등_20220314.csv'

# CCTV 데이터 XLSX 파일 경로
cctv_xlsx_file = '/content/경상남도 창원시_CCTV설치 현황.xlsx'  # CCTV 데이터 파일 경로를 적절히 수정

# 경찰서 데이터 XLSX 파일 경로
police_station_xlsx_file = '/content/경찰관서 _위도_경도.xlsx'  # 경찰서 데이터 파일 경로를 적절히 수정

# 공원 데이터 XLSX 파일 경로
park_xlsx_file = '/content/경상남도 창원시_도시공원 현황.xlsx'  # 공원 데이터 파일 경로를 적절히 수정

# 주차장 데이터 CSV 파일 경로
parking_csv_file = '/content/경상남도 창원시_주차장_위도 현황_20211231.csv'  # 주차장 데이터 파일 경로를 적절히 수정

 

 

데이터 파일 읽기 

 

pd.read_csv, pd.read_excel 함수를 사용하여 각각 CSV 파일, XLSX 파일을 DataFrame으로 읽어들인다. 해당 파일의 인코딩 방식을 확인해 인코딩을 'cp949'로 설정하여 한글 깨짐 방지한다. 

# CSV 파일을 DataFrame으로 읽기 (인코딩을 'cp949' 또는 'euc-kr'로 지정)
df_streetlight = pd.read_csv(streetlight_csv_file, encoding='cp949')  # 가로등 데이터
df_cctv = pd.read_excel(cctv_xlsx_file, engine='openpyxl')  # CCTV 데이터 (XLSX 형식, openpyxl 엔진 사용)

# 경찰서 데이터 읽기
df_police_station = pd.read_excel(police_station_xlsx_file, engine='openpyxl')  # 경찰서 데이터 (XLSX 형식, openpyxl 엔진 사용)

# 공원 데이터 읽기
df_park = pd.read_excel(park_xlsx_file, engine='openpyxl')  # 공원 데이터 (XLSX 형식, openpyxl 엔진 사용)

# 주차장 데이터 CSV 파일을 DataFrame으로 읽기 (인코딩을 'cp949' 또는 'euc-kr'로 지정)
df_parking = pd.read_csv(parking_csv_file, encoding='cp949')  # 주차장 데이터

 

 

columns 속성을 사용하여 각각의 데이터마다 생성한 DataFrame의 열 이름을 확인한다. 

# DataFrame의 열 이름 확인
print("가로등 데이터 열 이름:", df_streetlight.columns)
print("CCTV 데이터 열 이름:", df_cctv.columns)
print("경찰서 데이터 열 이름:", df_police_station.columns)
print("공원 데이터 열 이름:", df_park.columns)
print("주차장 데이터 열 이름:", df_parking.columns)
>>>
가로등 데이터 열 이름: Index(['위도', '경도'], dtype='object')
CCTV 데이터 열 이름: Index(['위도', '경도'], dtype='object')
경찰서 데이터 열 이름: Index(['위도', '경도', 'Unnamed: 2', 'Unnamed: 3', 'Unnamed: 4', 'Unnamed: 5',
       'Unnamed: 6', 'Unnamed: 7', 'Unnamed: 8', 'Unnamed: 9', 'Unnamed: 10',
       'Unnamed: 11', 'Unnamed: 12', 'Unnamed: 13'],
      dtype='object')
공원 데이터 열 이름: Index(['위도', '경도', 'Unnamed: 2', 'Unnamed: 3', 'Unnamed: 4', 'Unnamed: 5',
       'Unnamed: 6', 'Unnamed: 7', 'Unnamed: 8', '공원보유시설(유희시설)',
       '공원보유시설(편익시설)', '공원보유시설(교양시설)', '공원보유시설(기타시설)'],
      dtype='object')
주차장 데이터 열 이름: Index(['위도', '경도'], dtype='object')

 

 

각 데이터 프레임마다 위도와 경도 데이터를 나타내는 열이름이 다르기에 위도 데이터가 있는 열이름을 'latitude'로, 경도는 'longitude'로 통일해 준다. 

# 가로등 데이터의 열 이름 변경 (실제 열 이름에 따라 수정 필요)
df_streetlight = df_streetlight.rename(columns={'위도': 'latitude', '경도': 'longitude'})

# CCTV 데이터의 위도와 경도 열 이름 수정
df_cctv = df_cctv.rename(columns={'WGS84위도': 'latitude', 'WGS84경도': 'longitude'})

# 경찰서 데이터의 위도와 경도 열 이름 수정
df_police_station = df_police_station.rename(columns={'위도': 'latitude', '경도': 'longitude'})

# 공원 데이터의 위도와 경도 열 이름 수정
df_park = df_park.rename(columns={'위도': 'latitude', '경도': 'longitude'})

# 주차장 데이터의 열 이름 변경 (실제 열 이름에 따라 수정 필요)
df_parking = df_parking.rename(columns={'위도': 'latitude', '경도': 'longitude'})

 

경계 박스 설정 

 

창원이 포함된 곳을 기준으로 네모난 경계 박스를 설정한다. 창원시 경계를 정확히 나누어 분석을 진행하고 싶었지만 실력 부족으로 창원이 안에 있는 네모난 박스를 경계로 설정하였다.

min_latitude, max_latitude = 35.12559998123399, 35.26717660249106
min_longitude, max_longitude = 128.54585456199158, 128.72258807888167

 

min_latitude, max_latitude, min_longitude, max_longitude를 사용하여 특정 지역의 경계를 정의한다.

 

격자 크기 설정 

 

위에서 설정한 경계 박스를 격자 무늬로 나누기 위해 grid_size라는 변수에 우리가 원하는 격자의 크기를 설정한다. 우리는 0.0025도 단위로 격자를 생성할 것이다.

grid_size = 0.0025  # 격자 크기 설정 (예: 0.0025도 단위)

 

 

격자 생성 및 번호 할당

이중 for문을 사용하여 경계 박스 내에 격자를 생성하고,  생성된 격자마다 번호를 부여한다.

grid_numbers = {}  # 빈 딕셔너리를 생성하여 격자 번호를 저장
grid_number = 1  # 격자 번호 초기값 설정

# 두 개의 for 루프를 사용하여 경계 내의 격자를 생성하고 번호를 할당
for lat in np.arange(min_latitude, max_latitude, grid_size):
    for lon in np.arange(min_longitude, max_longitude, grid_size):
        grid_numbers[(lat, lon)] = grid_number
        grid_number += 1

 

각 데이터프레임에 격자 번호 할당

 

주어진 경도와 위도 좌표에 대해 격자 번호를 할당하는 함수를 생성한다. 각 데이터프레임(df_cctv, df_streetlight, df_police_station, df_park, df_parking)에 대해 위에서 만든 assign_grid_number_* 함수를 이용하여 격자 번호를 할당한다.

def assign_grid_number_cctv(row):
    latitude, longitude = row['latitude'], row['longitude']  # 열 이름을 올바르게 수정
    if (min_latitude <= latitude < max_latitude) and (min_longitude <= longitude < max_longitude):
        for lat in np.arange(min_latitude, max_latitude, grid_size):
            for lon in np.arange(min_longitude, max_longitude, grid_size):
                if lat <= latitude < lat + grid_size and lon <= longitude < lon + grid_size:
                    return grid_numbers[(lat, lon)]
    return -1  # 경계 밖의 데이터는 -1로 처리

df_cctv['격자번호'] = df_cctv.apply(assign_grid_number_cctv, axis=1)

# 각 격자별 CCTV 개수 계산
cctv_counts = df_cctv.groupby('격자번호').size().reset_index(name='CCTV수')

# 각 가로등 데이터에 격자 번호 할당
def assign_grid_number_streetlight(row):
    latitude, longitude = row['latitude'], row['longitude']  # 열 이름을 올바르게 수정
    if (min_latitude <= latitude < max_latitude) and (min_longitude <= longitude < max_longitude):
        for lat in np.arange(min_latitude, max_latitude, grid_size):
            for lon in np.arange(min_longitude, max_longitude, grid_size):
                if lat <= latitude < lat + grid_size and lon <= longitude < lon + grid_size:
                    return grid_numbers[(lat, lon)]
    return -1  # 경계 밖의 데이터는 -1로 처리

df_streetlight['격자번호'] = df_streetlight.apply(assign_grid_number_streetlight, axis=1)

# 경찰서 데이터에도 격자 번호 할당
def assign_grid_number_police_station(row):
    latitude, longitude = row['latitude'], row['longitude']
    if (min_latitude <= latitude < max_latitude) and (min_longitude <= longitude < max_longitude):
        for lat in np.arange(min_latitude, max_latitude, grid_size):
            for lon in np.arange(min_longitude, max_longitude, grid_size):
                if lat <= latitude < lat + grid_size and lon <= longitude < lon + grid_size:
                    return grid_numbers[(lat, lon)]
    return -1

df_police_station['격자번호'] = df_police_station.apply(assign_grid_number_police_station, axis=1)

# 공원 데이터에도 격자 번호 할당
def assign_grid_number_park(row):
    latitude, longitude = row['latitude'], row['longitude']
    if (min_latitude <= latitude < max_latitude) and (min_longitude <= longitude < max_longitude):
        for lat in np.arange(min_latitude, max_latitude, grid_size):
            for lon in np.arange(min_longitude, max_longitude, grid_size):
                if lat <= latitude < lat + grid_size and lon <= longitude < lon + grid_size:
                    return grid_numbers[(lat, lon)]
    return -1

df_park['격자번호'] = df_park.apply(assign_grid_number_park, axis=1)

# 주차장 데이터에도 격자 번호 할당
def assign_grid_number_parking(row):
    latitude, longitude = row['latitude'], row['longitude']
    if (min_latitude <= latitude < max_latitude) and (min_longitude <= longitude < max_longitude):
        for lat in np.arange(min_latitude, max_latitude, grid_size):
            for lon in np.arange(min_longitude, max_longitude, grid_size):
                if lat <= latitude < lat + grid_size and lon <= longitude < lon + grid_size:
                    return grid_numbers[(lat, lon)]
    return -1

df_parking['격자번호'] = df_parking.apply(assign_grid_number_parking, axis=1)

 

 

격자마다 포함하는 데이터 계산하기

CCTV 데이터프레임을 격자번호로 그룹화하고 격자별로 CCTV 개수를 계산하여 'CCTV수' 열을 추가한 후, 새로운 데이터프레임으로 저장한다. 마찬가지로 나머지 데이터들도 동일한 작업을 수행한다. 

# 각 격자별 CCTV 개수 계산
cctv_counts = df_cctv.groupby('격자번호').size().reset_index(name='CCTV수')

# 각 격자별 가로등 개수 계산
streetlight_counts = df_streetlight.groupby('격자번호').size().reset_index(name='가로등수')

# 각 격자별 경찰서 개수 계산
police_station_counts = df_police_station.groupby('격자번호').size().reset_index(name='경찰서수')

# 각 격자별 공원 개수 계산
park_counts = df_park.groupby('격자번호').size().reset_index(name='공원수')

# 각 격자별 주차장 개수 계산
parking_counts = df_parking.groupby('격자번호').size().reset_index(name='주차장수')

 

 

모든 격자 번호를 포함하는 데이터프레임을 생성한다.

all_grid_numbers = set(range(1, grid_number))  # 모든 격자 번호 범위
all_grid_df = pd.DataFrame({'격자번호': list(all_grid_numbers)})

 

앞서 계산한 격자별로 포함되어 있는 데이터 개수들을 모두 하나의 데이터프레임으로 합친다.

all_grid_info = pd.merge(all_grid_df, cctv_counts, on='격자번호', how='left')
all_grid_info = pd.merge(all_grid_info, streetlight_counts, on='격자번호', how='left')
all_grid_info = pd.merge(all_grid_info, police_station_counts, on='격자번호', how='left')
all_grid_info = pd.merge(all_grid_info, park_counts, on='격자번호', how='left')
all_grid_info = pd.merge(all_grid_info, parking_counts, on='격자번호', how='left')

 

결측치(격자안에 CCTV, 가로등, 경찰서, 공원, 주차장 데이터가 없는 경우)를 0으로 채워 데이터프레임을 업데이트한다.

all_grid_info['CCTV수'] = all_grid_info['CCTV수'].fillna(0).astype(int)
all_grid_info['가로등수'] = all_grid_info['가로등수'].fillna(0).astype(int)
all_grid_info['경찰서수'] = all_grid_info['경찰서수'].fillna(0).astype(int)
all_grid_info['공원수'] = all_grid_info['공원수'].fillna(0).astype(int)
all_grid_info['주차장수'] = all_grid_info['주차장수'].fillna(0).astype(int)

 

안전지수 평가 가중치 설정하기

각 항목별(CCTV, 가로등... 등)로 안전지수를 평가할 때 사용될 가중치를 설정한다. 각 격자마다 안전지수를 평가한다.weights는 각 항목에 대한 가중치를 나타내는 리스트이다. 그리고 all_grid_info 데이터프레임에 새로운 열인 '점수'를 추가하고, 각 격자별로 가중치가 적용된 종합 점수를 저장한다.

# 가중치 설정
weights = [0.1692, 0.4302, 0.2162, 0.0802, 0.1042]

# 각 격자별 점수 계산
all_grid_info['점수'] = (
    all_grid_info['CCTV수'] * weights[0] +
    all_grid_info['가로등수'] * weights[1] +
    all_grid_info['경찰서수'] * weights[2] +
    all_grid_info['공원수'] * weights[3] +
    all_grid_info['주차장수'] * weights[4]

 

최종 결과를 CSV 파일로 저장한다.

all_grid_info.to_csv('격자별1_데이터.csv', index=False, encoding='utf-8-sig')

 

지도생성하기 

folium 라이브러리를 불러오고 지도를 생성한다.

map_center는 초기 지도의 중심 좌표를 나타내고. zoom_start는 초기 확대 수준을 나타낸다.

import folium

# ...
map_center = [35.196388291862526, 128.6342213204366]
# Folium 지도 객체 생성
m = folium.Map(location=map_center, zoom_start=13)  # zoom_start는 초기 확대 수준을 나타냅니다.

 

격자를 지도에 나타내기

grid_numbers에 저장된 각 격자의 좌표를 사용하여 Folium의 Rectangle을 생성하고, 지도에 추가한다.

for (lat, lon), grid_number in grid_numbers.items():
    rect = folium.Rectangle(
        bounds=[(lat, lon), (lat + grid_size, lon + grid_size)],
        color='blue',
        fill=True,
        fill_opacity=0.2,
    )
    rect.add_to(m)
  • bounds는 이전에 설정한 경계 좌표를 설정힌다.
  • color는 사각형의 테두리 색상을 나타낸다.
  • fill과 fill_opacity는 사각형 내부를 채우는 옵션 및 투명도를 나타낸다.
  •  

격자에 점수 마커로 표시

all_grid_info에 저장된 격자의 점수를 가져와서 해당 격자에 대한 마커를 생성한다.마커의 위치는 격자의 중심 좌표로 설정하였다. folium.DivIcon을 사용하여 마커에 숫자로 표시할 텍스트를 나타낸다.

if grid_number in all_grid_info['격자번호'].values:
    score = all_grid_info[all_grid_info['격자번호'] == grid_number]['점수'].values[0]
    marker = folium.Marker(
        location=[lat + grid_size / 2, lon + grid_size / 2],
        icon=folium.DivIcon(
            html=f'<div style="font-size: 12px; background-color: white;">{score:.2f}</div>',
            icon_size=(36, 36),
        ),
    )
    marker.add_to(m)

격자와 격자속 수치가 새겨진 모습이다.

 

지도를 HTML 파일로 저장

m.save('map_grid_score.html')

 

격자의 점수별로 격자 색 지정하기 

지도를 생성하기 위해 사용한 코드를 다시 가져온다. 

map_center = [35.196388291862526, 128.6342213204366]
# Folium 지도 객체 생성
m = folium.Map(location=map_center, zoom_start=13)  # zoom_start는 초기 확대 수준을 나타냅니다.

for (lat, lon), grid_number in grid_numbers.items():
    # 격자 경계를 사각형으로 표시
    rect = folium.Rectangle(
        bounds=[(lat, lon), (lat + grid_size, lon + grid_size)],
        color='blue',
        fill=True,
        fill_opacity=0.2,
    )
    rect.add_to(m)

 

 

각 격자에 대해 all_grid_info에서 해당 격자의 점수를 가져와서 마커로 표시한다. 점수에 따라 마커의 색상을 다르게 지정하고, 마커의 위치와 크기 등을 설정한다. 그리고 생성된 지도를 HTML 파일로 저장한다. 

# 격자에 점수를 마커로 표시 (all_grid_info 데이터프레임에서 해당 격자의 점수를 가져와서 표시)
    if grid_number in all_grid_info['격자번호'].values:
        score = all_grid_info[all_grid_info['격자번호'] == grid_number]['점수'].values[0]

        # Define color based on score range
        if score < 0.1:
            marker_color = 'red'
        elif score < 0.5:
            marker_color = 'orange'
        elif score < 1.0:
            marker_color = 'yellow'
        elif score < 5.0:
            marker_color = 'blue'
        else:
            marker_color = 'white'

        # Create marker with the corresponding color
        marker = folium.Marker(
            location=[lat + grid_size / 2, lon + grid_size / 2],
            icon=folium.DivIcon(
                html=f'<div style="font-size: 12px; background-color: {marker_color};">{score:.2f}</div>',
                icon_size=(36, 36),
            ),
        )
        marker.add_to(m)
m.save("map_grid_color.html")
print(m)

구간 별로 색을 다르게 설정하여 한눈에 위험도를 살펴볼 수 있게 만들었다. 실행해보면

격자별로 색깔이 입력된것을 볼 수 있다. 또한 0인 공간은 인적이 드문곳이란 의미인 검정색으로 표현하였다. 산지와 물가 쪽은 검정색으로 나온것으로 보아,  우리의 구상이 정확하게 구현된 것이 증명되었다. 

공장지대는 검정색, 도시쪽은 다양한 색으로 나타나있다.

 

전체적인 지도로 확인해보면 이제 색깔의분포가 한눈에 보인다. 

 

이러한 분포에서 일부지역의 쏠림현상이 보인다. 검정색(산)을 제외한 부분에서 푸른색(안전)의 분포와 붉은(위험)의 분포가 구역별로 나뉘는 것이 보인다.

 

결론

상대적으로 의창구에 많은시설들이 분포되어 있음을 알 수 있었다. 

 

창원시는 통합된 도시이다보니 아무래도 기존 지역간의 갈등과 불균형의 문제는 항상 해결해야할 문제였다

 

불균형에는 이러한 시설물의 분포도 포함됨이 시각화로 나타나었다. 

 

따라서 시각화로 부족한 지역의 추이를 파악하고 보충할 시설의 장소를 찾는 것이 중요하다.

 

 미흡한 분석으로 격자구간 설정의 근거부족과, 위험수치 계산공식 근거부족, 또 완벽한 창원시의 면적

분석에 한계를 느껴 사각형형태로 분석한 점 등 보완할 점이 많았다.

 

하지만 이러한 것들을 보충하고 시간마다 달라지는 안전요소를 추가하여, 실시간으로 데이터를 입력하는 파일과 연동시킨다면, 실시간 안전지수변화를 지켜볼 수 있고,

 

다른 기술과 연동하여 우리가 목표로하는 안전길 찾기를 구현해낼 수 있을 것이다.