서울시의 핫플레이스는 어디? (1/2)

jihwanyoon·2023년 1월 31일
0

visualization

목록 보기
1/2

서울 시의 유동인구, 가게, SNS 데이터 등을 활용하여 시각화해보는 프로젝트이다.

1. Introduction


시각화를 시작해보자

서울시에서 꽤 오래 살았지만 아직 가보지 못한 지역들이 많다.

대한민국 문화의 중심지인만큼 수많은 즐길거리가 있고 사람들이 특히 모이는 일명
'핫플레이스’가 서울시에 수없이 많이 있다.

그렇다면 ‘데이터로 서울시의 핫플레이스를 보여줄 수 있지 않을까?’ 하는 생각이 들었다.

지금부터 서울시의 1) 유동인구, 2) 가게, 3) 인스타그램 데이터 를 활용하여 서울시의 핫플레이스는 어디인지 확인해보는 시간을 가져보고자 한다.

2. 유동인구


핫플레이스엔 사람이 많지 않을까……

사실, 핫플레이스라고 사람이 무조건 많은 것은 아니긴 하다.

소위 우리가 말하는 핫플레이스는 단기간에 TV에서 급부상하거나, 혹은 인스타같은 SNS에서 핫하다고 입소문이 난 가게나 놀 장소를 말한다.

물론 그중에는 롱런하는 경우도 있지만 반짝(?)하는 경우가 대부분일 것이다.

하지만 그런 단기적인 경우는 캐치하기 쉽지 않다.

😆 그래서 ‘사람이 많이 몰리는 곳이 핫플레이스이지 않을까?’ 라는 생각에서 출발해보았다.

서울시 생활이동 데이터 시각화


다음은 서울시의 생활이동 데이터를 이용해 시각화한 결과물이다.

대상연월요일도착시간출발 행정동 코드도착 행정동 코드성별나이이동유형평균 이동 시간(분)이동인구(합)
202201911010531101053F0EE1027.77
202201911010531101053F10EE105.96
202201911010531101053F15EE104.07
202201911010531101053F20EE104.38
202201911010531101053F25HH10*
202201911010531101053F25HE10*

이렇게 생긴 데이터를 간단하게 시각화해놓은 결과인 것이다.

처음의 목표는 비슷하게 새로 구현해보자…. 였지만 명색이 ‘핫플레이스’를 시각화해보자 인데 기존의 시각화는 구 단위로 어떻게 이동했는지 만을 보여주고 있다.

그래서 기존과는 좀 다르게, 하지만 현실적인 문제와 타협하며 데이터를 수정했다. 그 결과,

1) 기간은 2022년 1월부터 6월

2) 사용한 컬럼은 대상연월, 요일, 시간, 출발 및 도착 행정동 코드, 이동유형, 이동인구(합)

3) 시간대는 6개의 시간대로 합치고 나이대는 2030, 집과 직장이 목적인 이동유형을 제외하고 남은 유형(EE, HE, WE)만 사용하였다.

4) 구 단위가 아닌 행정동 단위로 groupby 해주었다.(이것이 세분화의 한계지만 여전히 핫플레이스로 보기에 어렵긴 하다..)

5) 웹사이트로 올리는 과정에서 메모리의 한계가 발생하여 여성 데이터만 활용하고 성별 컬럼을 없앴고 핫플레이스와 관련 없어 보이는 새벽 시간대와 이동인구 3 이하 데이터는 삭제해주었다.

Pydeck & Dash


시각화를 위해 사용한 라이브러리는 pydeck, dash와 이 둘을 합친 dash_deck이다.

pydeck은 강력한 시각화 라이브러리이고 dash는 interactive한 화면을 만들어주는 대시보드 라이브러리이다.

dash_deck은 이 둘을 연결해주는 라이브러리이다.

기존의 시각화는 산뜻한 느낌이였다면 이번엔 pydeck 특유의 검고 세련된 느낌을 주고자했다.

mapbox api token은 mapbox 홈페이지에서 가입하면 쉽게 받을 수 있다.

Maps, geocoding, and navigation APIs & SDKs | Mapbox

import pandas as pd
import numpy as np
import os
import pickle
import geopandas as gpd
from tqdm import tqdm 
os.chdir('F:/Competitions/SeoulHotPlace')
mapbox_api_token = 'pk.eyJ1IjoiYm94Ym94NCIsImEiOiJjbDdoY2J1bm8wNzlrM3BycDQzYmduNTJtIn0.Q7koz2UNld3b1xmqF7-KXA'

Preprocessing


1) 행정동 경계 파일로 기준 좌표 설정하기

layer를 그리기 위하여 각 행정동의 가운데 좌표를 구해 출발점과 도착점으로 활용하였다.

### 1. 대한민국 행정동 경계 파일 
# https://github.com/vuski/admdongkor/tree/master/ver20220401

geo_data = 'dataset/HangJeongDong_ver20220401.txt'
with open(geo_data,encoding="UTF-8") as json_file:
    df = gpd.read_file(json_file)

df = df.iloc[:,[0,1,2,10]]

def multipolygon_to_coordinates(x):
    lon, lat = x[0].exterior.xy
    return [[x, y] for x, y in zip(lon, lat)]
df['coordinates'] = df['geometry'].apply(multipolygon_to_coordinates)
del df['geometry']

## 각 행정동 별 가운데 좌표 생성 
lst = []
for i in df['coordinates']:
    idx_1 = 0
    idx_2 = 0
    for j in i:
        idx_1 += j[0]
        idx_2 += j[1]
    lst.append([np.round(idx_1 / len(i),5), np.round(idx_2 / len(i), 5)])

df['MiddlePoint'] = lst
middle = df[['adm_cd', 'adm_nm', 'MiddlePoint']]
middle['adm_cd'] = pd.to_numeric(middle['adm_cd'])

번외로, json 형식으로 되어있는 저 txt파일을 geopandas 형태로 불러오는 과정에서 꽤 애를 먹었는데

txt를 불러서 gpd로 변환하지 말고 바로 gpd.read_file()로 불러오면 쉽게 불러와진다!

2) 데이터 filtering & concat

앞에서 언급한 기준대로 데이터를 filtering하여 전부 합쳐준다. 원천 데이터가 꽤 커서 시간이 좀 소요된다.

## 데이터 규합 
def preprocess(df):
    data = df.copy()
    data = data[(data['출발 행정동 코드'] >= 1100000) & 
                (data['출발 행정동 코드'] <= 1200000) & 
                (data['도착 행정동 코드'] >= 1100000) & 
                (data['도착 행정동 코드'] <= 1200000)] # 서울시 내부 이동 필터링 

    data.loc[data['이동인구(합)'] == '*', '이동인구(합)'] = 3  # * 표시는 3명 이하라는 뜻이므로 3으로 대체 
    data = data[data['이동유형'].isin(['EE', 'HE', 'WE'])] # 집과 직장이 목적인 경우 제외하고 나머지 이동 경로만
    data = data[data['나이'].isin([20,25,30,35])] 
    data.drop(['평균 이동 시간(분)', '이동유형', '나이'], axis=1, inplace=True)
    data.reset_index(drop=True, inplace=True)
    
    return data

def month_fit(path):
    df_list = os.listdir(path)
    final = pd.DataFrame()
    for i in tqdm(df_list):
        data = pd.read_csv(path + '\{}'.format(i), encoding='cp949')
        data = preprocess(data)
        final = pd.concat([final, data])
        del data 
    conditionlist = [    
        (final['도착시간'].isin([3,4,5,6])), (final['도착시간'].isin([7,8,9,10])),
        (final['도착시간'].isin([11,12,13,14])), (final['도착시간'].isin([15,16,17,18])),
        (final['도착시간'].isin([19,20,21,22])), (final['도착시간'].isin([23,0,1,2]))]
    choicelist = ['새벽(03-06)', '아침(07-10)', '점심(11-14)', '오후(15-18)', '저녁(19-22)', '밤(23-02)']
    final['time'] = np.select(conditionlist, choicelist, default='Not Specified')
    final['이동인구(합)'] = pd.to_numeric(final['이동인구(합)'])
    final = final.groupby(['대상연월', '요일', 'time','출발 행정동 코드', 
                           '도착 행정동 코드', '성별'])['이동인구(합)'].sum().reset_index()
    final = pd.merge(final, middle, left_on='출발 행정동 코드', right_on='adm_cd',how='left').dropna(axis=0)
    final = pd.merge(final, middle, left_on='도착 행정동 코드', right_on='adm_cd',how='left').dropna(axis=0)
    final = final.rename(columns = {'MiddlePoint_x' : 'start_point', 'MiddlePoint_y' : 'end_point'})
    final.drop(['adm_cd_x', 'adm_cd_y'], axis=1, inplace=True)
    
    return final

path = 'F:/Competitions/SeoulHotPlace/dataset/data'
path_list = os.listdir(path)
final = pd.DataFrame()
for idx in tqdm(path_list):
    path_2 = path+'\{}'.format(idx)
    dat = month_fit(path_2)
    final = pd.concat([final, dat])
    del dat
final.reset_index(drop=True, inplace=True)

3) 데이터 축소 및 저장

데이터 크기를 조금이라도 축소시키기 위해 처리해준다. csv로 관리하면 오류가 많아 pickle 파일로 저장한다.

## 데이터 크기 확인 함수
def mem_usage(pandas_obj):
    if isinstance(pandas_obj,pd.DataFrame):
        usage_b = pandas_obj.memory_usage(deep=True).sum()
    else: # we assume if not a df it's a series
        usage_b = pandas_obj.memory_usage(deep=True)
    usage_mb = usage_b / 1024 ** 2 # convert bytes to megabytes
    return "{:03.2f} MB".format(usage_mb)

## 이산형 데이터 사이즈 축소 함소
def int_memory_reduce(data) :
    data_int = data.select_dtypes(include=['int'])
    converted_int = data_int.apply(pd.to_numeric,downcast='unsigned')
    print(f"Before : {mem_usage(data_int)} -> After : {mem_usage(converted_int)}")
    data[converted_int.columns] = converted_int
    return data

## 연속형 데이터 사이즈 축소 함소
def float_memory_reduce(data) :
    data_float = data.select_dtypes(include=['float'])
    converted_float = data_float.apply(pd.to_numeric,downcast='float')
    print(f"Before : {mem_usage(data_float)} -> After : {mem_usage(converted_float)}")
    data[converted_float.columns] = converted_float
    return data

final = float_memory_reduce(int_memory_reduce(final))

idx = final[(final['성별'] == 'M') |
         (final['time'] == "새벽(03-06)") |
         (final['이동인구(합)'] == 3)].index
final.drop(idx, inplace=True)
final.drop(['출발 행정동 코드', '도착 행정동 코드', '성별'], axis=1, inplace=True)

with open('./final_ver2.pickle', 'wb') as f:
    pickle.dump(final, f, pickle.HIGHEST_PROTOCOL)

4) 서울시 경계면 데이터 처리

서울시가 메인이므로 강조해주기 위해 pydeck layer로 쌓아줄 경계면 데이터를 처리해준다.

dff = gpd.read_file('https://raw.githubusercontent.com/heumsi/geo_data_visualisation_introduction/master/data/older_seoul.geojson')
def multipolygon_to_coordinates(x):
    lon, lat = x[0].exterior.xy
    return [[x, y] for x, y in zip(lon, lat)]
dff['coordinates'] = dff['geometry'].apply(multipolygon_to_coordinates)
del dff['geometry'], dff['인구'], dff['남자'], dff['여자']
dff = pd.DataFrame(dff)

with open('./seoul_boundary.pickle', 'wb') as f:
    pickle.dump(dff, f, pickle.HIGHEST_PROTOCOL)

Mapping


가장 중요한 layer 그리기이다. 목표는 년월, 요일, 시간대, 상위 N개 data를 고르면 그에 맞는 arc layer를 그려주는 것이다.

dash 라이브러리로 interactive하게 input을 입력받아 pydeck 라이브러리로 layer를 쌓아 지도위에 그리는 방식으로 해결하였다.

import dash
import os
from dash import dcc, html
from dash.dependencies import Input, Output
import pickle
import dash_deck
import pydeck as pdk
os.chdir('F:/Competitions/SeoulHotPlace')
mapbox_api_token = 'pk.eyJ1IjoiYm94Ym94NCIsImEiOiJjbDdoY2J1bm8wNzlrM3BycDQzYmduNTJtIn0.Q7koz2UNld3b1xmqF7-KXA'
with open('./final_ver2.pickle', 'rb') as f:
    df = pickle.load(f)
with open('./seoul_boundary.pickle', 'rb') as f:
    dff = pickle.load(f)

1) pydeck layer

dash로 입력받은 input들을 내부에서 filtering하여 normalized된 이동인구(합) 데이터를 출발→도착 행정동을 기준으로 3d 곡선을 그려준다.

출발은 빨간색, 도착은 초록색이다. boundary layer는 앞에서 처리한 서울시 경계 layer로 서울시를 구분해주는 역할을 한다.

def create_map(month, day, time, top):
    new_df = df[(df['대상연월'] == month) &
                (df['요일'] == day) &
                (df['time'] == time)]
    new_df = new_df.sort_values('이동인구(합)', ascending=False)
    new_df['normalized_이동인구'] = new_df['이동인구(합)'] / new_df['이동인구(합)'].max()
    new = new_df.iloc[0:top,:].copy()
    
    # boundary layer 
    boundary_layer = pdk.Layer(
        'PolygonLayer',
        dff,
        get_polygon = 'coordinates',
        get_fill_color = '[128, 128, 128]',
        pickable = True,
        auto_highlight = True,
        opacity = 0.05
    )
    
    # arc layer
    arc_layer = pdk.Layer(
        "ArcLayer",
        new,
        get_source_position='start_point',
        get_target_position='end_point',
        get_tilt=15,
        get_width='1 + 100 * normalized_이동인구',
        get_source_color='[255, 0, 0]',
        get_target_color='[0, 255, 0]',
        pickable=True,
        auto_highlight=True,
    )
    center = [126.986, 37.565]
    view_state = pdk.ViewState(longitude=center[0], latitude=center[1])
    view_state.zoom = 11
    view_state.bearing = -15
    view_state.pitch = 45

    r = pdk.Deck(layers = [boundary_layer, arc_layer], initial_view_state=view_state, mapbox_key= mapbox_api_token)
    
    return r

이 layer를 지도에 띄우는 것 자체만으로도 상당한 시간이 소요되었는데 주된 오류는,

  1. mapbox api token 값을 layer에 적용시키는 것 ⇒ 환경 변수로 저장해 불러오는 방법도 있지만 그냥 속편하게 직접 선언해주자.

  2. 추후 dash와의 연동에서 dash_deck과 mapbox api token값을 연동시키는데 애를 먹었다. ⇒ pydeck 버전 0.5.0으로 다운그레이드 하여 해결하였다.
    pydeck과 dash_deck 코드들은 모두 0.5.0 버전 기준으로 작성되었다.

2) dash - app.layout

프론트엔드를 간접적으로 경험하였다. html 언어를 python에서 쉽게 짤수 있게 도와주는 툴인데 상상하는데로 구조를 짜기가 익숙해지기 전엔 힘들었다.

버튼들을 위쪽에 일자로 이쁘게 만드는 것이 목표였는데 버튼구조 하나하나당 추가적인 html.Div로 감싸주어야 한다는 사실을 안 것은 거의 끄트머리였다….

구조는 크게 html.Div 안에 해당 네모박스를 꾸밀 style과 app.callback과 연결되어 input값을 조정할 children 파트로 나뉜다.

하나만 가져와보면 이런식으로 Div(네모박스) 안에 또다른 Div(네모박스)를 만들고 style을 정하고 dcc.~~~ 로 어떤 버튼을 만들지를 정한다.

html.Div(children = [
            html.H4('Month', style = {'color' : colors['text']}),
            html.Div(style = {"width" : "100%", "display" : "inline-block", 
                              "vertical-align" : "top", 'color' : colors['text']}, children = [
                dcc.Slider(
                    min = df["대상연월"].min(),
                    max = df["대상연월"].max(),
                    step = None,
                    value = df["대상연월"].min(),
                    marks = {int(month) : str(month) for month in df["대상연월"].unique()},
                    id = "month_slider")
            ]),
        ], style = {"display" : "inline-block", 'width' : '25%', "vertical-align" : "top"}),

colors = {
    'background': '#111111',
    'text': '#ffffff'
}
app = dash.Dash(__name__)
app.layout = html.Div(
    style={'backgroundColor': colors['background'], 'width' : '100%'}, children = [
        html.Div(children = [
            html.H1('ㅤA Floatting Map of Seoul', style = {'color' : colors['text']})
            ], style = {"display" : "inline-block", 'width' : '30%', "vertical-align" : "top"}),
        
        html.Div(children = [
            html.H4('Month', style = {'color' : colors['text']}),
            html.Div(style = {"width" : "100%", "display" : "inline-block", 
                              "vertical-align" : "top", 'color' : colors['text']}, children = [
                dcc.Slider(
                    min = df["대상연월"].min(),
                    max = df["대상연월"].max(),
                    step = None,
                    value = df["대상연월"].min(),
                    marks = {int(month) : str(month) for month in df["대상연월"].unique()},
                    id = "month_slider")
            ]),
        ], style = {"display" : "inline-block", 'width' : '25%', "vertical-align" : "top"}),
                                  
        html.Div(children = [
            html.H4('Weekday', style = {'color' : colors['text'], 'textAlign': 'top'}),
            html.Div(style = {"width" : "80%", "display" : "inline-block", "vertical-align" : "top"}, children = [
                dcc.Dropdown([{'label':'월(Mon)', 'value' : '월'},
                              {'label':'화(Tue)', 'value' : '화'},
                              {'label':'수(Wed)', 'value' : '수'},
                              {'label':'목(Thur)', 'value' : '목'},
                              {'label':'금(Fri)', 'value' : '금'},
                              {'label':'토(Sat)', 'value' : '토'},
                              {'label':'일(Sun)', 'value' : '일'}], '월', id = "day_dropdown")
            ]),
        ], style = {"display" : "inline-block", 'width' : '10%', "vertical-align" : "top"}),
        
        html.Div(children = [
            html.H4('Time', style = {'color' : colors['text'], 'textAlign': 'top'}),    
            html.Div(style = {"width" : "80%", "display" : "inline-block", "vertical-align" : "top"}, children = [
                dcc.Dropdown(['아침(07-10)', '점심(11-14)', '오후(15-18)', '저녁(19-22)', '밤(23-02)'], '점심(11-14)', 
                             id = "time_dropdown")
            ]),
        ], style = {"display" : "inline-block", 'width' : '10%', "vertical-align" : "top"}),
        
        html.Div(children = [
            html.H4('Top N of data', style = {'color' : colors['text'], 'display': 'inline-block', "vertical-align" : "top"}),
            html.Div(style = {"width" : "80%", 'display': 'inline-block', "vertical-align" : "top"}, children = [
                dcc.Input(id = "max_rows", value = 500, type = "number", style = {"width" : "80%"})
            ]),
        ], style = {"display" : "inline-block", 'width' : '10%', "vertical-align" : "top"}),
        
        html.Div(children = [
            html.H4('Color Description', style = {'color' : colors['text'], 'display': 'inline-block', "vertical-align" : "top"}),
            html.Div(style = {"width" : "80%", "display" : "inline-block", "vertical-align" : "bottom", 'color' : colors['text'],
                              "height" : "50px"}, children = [
                dcc.Markdown('''
                      source color : red      
                      target color : green 
                ''')
                ])
        ], style = {"display" : "inline-block", 'width' : '15%', "vertical-align" : "top"}),

        html.Br(),
        html.Br(),
        html.Div(
            id = 'deck-gl',
            style={
                "width": "90%",
                "padding-left" : "5%",
                "padding-right" : "5%",
                "height": "83vh",
                "display": "inline-block",
                "position": "relative",
                },
            children = []
            )
])

년월은 슬라이더 형태로, 요일과 시간대는 dropdown 형태로, top n 은 직접 입력하는 형태로 버튼을 만들어준다.

3) dash - app.callback

layout과 바로 밑에 붙어있는 함수의 input과 output이 각각 어떻게 연동할지를 id를 통해 선언해준다.

앞에서 년월, 요일, 시간대, top n개 를 조정할 것이라고 했으니 당연히 함수의 input도 4개이다.

다만 모든 input을 받아서 하나의 지도에 전부 합쳐서 그릴 예정이니 output은 하나이다.

@app.callback(
    Output(component_id = "deck-gl", component_property = "children"),
    Input(component_id = "month_slider", component_property = "value"),
    Input(component_id = "day_dropdown", component_property = "value"),
    Input(component_id = "time_dropdown", component_property = "value"),
    Input(component_id = "max_rows", component_property = "value")
)
def update_contents(month, day, time, top):
    r = create_map(month, day, time, top)
    return dash_deck.DeckGL(r.to_json(), id = 'deck_gl', tooltip=True, mapboxKey=r.mapbox_key)

if __name__ == "__main__":
    app.run_server(debug=True)

여기서도 꽤 많이 헤맸다. 지도만 딱 나오고 layer가 그려지지 않는다거나, 알 수 없는 오류들이 뜬다거나, time out이 된다거나…

결국엔 pydeck의 버전을 0.5.0 으로 낮추고 dash_deck.DeckGL 함수를 update_contents()의 return 값으로 주어 해결하였다.

pydeck과 dash를 동시에 사용할 사람이 또 있다면 헤매지 말고 바로 저렇게 쓰길 바란다.

Visualization


이제 다 만들었으니 확인해볼 차례다.

conda prompt 창을 열어서 mapping을 위해 만든 py 파일을 python floating_mapping.py 이렇게 치고 열면 이런식으로 뜬다.

Dash is running on http://127.0.0.1:8050/

 * Serving Flask app 'floating_mapping' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: on

저 링크주소를 인터넷 창에 입력하면 드디어 interactive한 지도가 등장하는 것이다.

2022년 4월, 일요일 저녁 시간대(19시-22시) 서울 시의 상위 500개 유동인구의 형태이다.

집이나 회사가 관련된 형태의 이동유형은 뺐으니 순전히 놀거나 약속을 잡거나 하는 형태의 움직임들 것으로 기대한다. (야근러들에겐 미안하다…)

대략적으로 살펴보면, 여의도에서 출발하는 경우도 많이 보이고 용산, 이태원, 홍대, 신촌, 건대로 많이 들어가고 있다. 역삼, 서초 쪽은 유입, 유출 인구가 매우 많다.

대부분의 핫플레이스 예상 지역들이 잘 보이고 있음을 알 수 있다.

여기서 시간대만 밤으로 바꾸면,

이런식으로 바뀐다. 저녁 시간대보다 더 많은 사람들이 홍대와 강남으로 이동하고 빠져나오고 있음을 알 수 있다.

UI는 상당히 간단하고 조잡하지만 interactive하게 구현했다는데 매우 뿌듯했다.

이제 이 지도를 새로 만들 사이트에 집어넣기만 하면 된다!

생각보다 길어져서 2부로 넘기려고 한다.

References

admdongkor/ver20220401 at master · vuski/admdongkor

서울 생활이동

[ Python ] 정형데이터 용량 줄이는 함수 소개 (연속형, 이산형, 문자형)

0개의 댓글