서울 지하철 역별 시간대별 혼잡도 분석 및 시각화¶
1. 환경 설정¶
- 한글 폰트 설정
In [ ]:
!sudo apt-get install -y fonts-nanum
!sudo fc-cache -fv
!rm ~/.cache/matplotlib -rf
- 구글 드라이브를 사용하는 경우
In [ ]:
from google.colab import drive
drive.mount('/content/drive')
Mounted at /content/drive
2. 데이터 변환¶
In [ ]:
import pandas as pd
df = pd.read_csv("/content/drive/MyDrive/Project/Data_viz/역별시간대별_혼잡도/서울교통공사_역별시간대별혼잡도_20221231.csv", encoding='euc-kr')
In [ ]:
# 시간 컬럼들의 이름을 확인하여 변환 대상을 파악
time_columns = df.columns[6:] # 시간 데이터가 시작하는 컬럼부터 선택
# 시간 형식 변환 함수 정의
def convert_time_format(time_str):
# "시"와 "분"을 기준으로 시간과 분을 분리
hour, minute = time_str.split('시')
minute = minute.rstrip('분')
# 시간 형식을 "HH:MM"으로 변환
return f"{int(hour):02d}:{int(minute):02d}"
# 시간 컬럼들의 이름을 새로운 형식으로 변환
new_time_columns = [convert_time_format(col) for col in time_columns]
# 데이터프레임의 컬럼 이름을 업데이트
df.columns = list(df.columns[:6]) + new_time_columns
# 변환된 데이터의 처음 몇 줄을 출력하여 확인
df.head()
Out[ ]:
연번 | 요일구분 | 호선 | 역번호 | 출발역 | 상하구분 | 05:30 | 06:00 | 06:30 | 07:00 | ... | 20:00 | 20:30 | 21:00 | 21:30 | 22:00 | 22:30 | 23:00 | 23:30 | 00:00 | 00:30 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 평일 | 1 | 150 | 서울역 | 상선 | 7.3 | 18.1 | 18.1 | 30.9 | ... | 15.6 | 17.1 | 17.7 | 14.9 | 13.7 | 17.2 | 10.4 | 8.8 | 8.8 | 1.2 |
1 | 2 | 평일 | 1 | 150 | 서울역 | 하선 | 11.5 | 11.0 | 13.2 | 21.4 | ... | 35.3 | 36.1 | 35.0 | 29.4 | 39.2 | 26.1 | 17.1 | 11.8 | 8.5 | 0.0 |
2 | 3 | 평일 | 1 | 151 | 시청 | 상선 | 6.6 | 15.4 | 14.7 | 25.0 | ... | 19.1 | 20.7 | 21.8 | 17.5 | 18.5 | 18.4 | 14.9 | 11.5 | 7.0 | 2.8 |
3 | 4 | 평일 | 1 | 151 | 시청 | 하선 | 9.0 | 9.1 | 14.6 | 20.0 | ... | 30.5 | 32.4 | 31.1 | 28.5 | 30.2 | 22.2 | 15.4 | 10.1 | 6.5 | 0.8 |
4 | 5 | 평일 | 1 | 152 | 종각 | 상선 | 6.3 | 14.4 | 10.7 | 17.6 | ... | 26.8 | 25.2 | 29.7 | 22.6 | 26.1 | 24.0 | 19.7 | 14.6 | 9.6 | 4.2 |
5 rows × 45 columns
In [ ]:
df.shape
Out[ ]:
(1658, 45)
In [ ]:
In [ ]:
# "요일구분"이 "평일"인 데이터만 필터링
weekday_df = df[df['요일구분'] == '평일']
In [ ]:
# 시간대별 컬럼 리스트 생성
time_columns = weekday_df.columns[6:]
# 결과를 저장할 빈 DataFrame 생성
results_df = pd.DataFrame(columns=['시간대', '호선', '출발역(상하구분)', '혼잡도'])
# 각 시간대별로 반복
for time in time_columns:
for line in range(1, 10): # 1호선부터 9호선까지
# 현재 시간대와 호선에 해당하는 행들 필터링
line_df = weekday_df[weekday_df['호선'] == line]
if not line_df.empty:
# 혼잡도가 가장 높은 행 찾기
max_row = line_df.loc[line_df[time].idxmax()]
# 결과 데이터프레임에 추가
results_df = results_df.append({
'시간대': time,
'호선': f"{line}호선",
'출발역(상하구분)': f"{max_row['출발역']}({max_row['상하구분']})",
'혼잡도': max_row[time]
}, ignore_index=True)
results_df
In [ ]:
results_df[results_df['시간대'] == '05:30']
Out[ ]:
시간대 | 호선 | 출발역(상하구분) | 혼잡도 | |
---|---|---|---|---|
0 | 05:30 | 1호선 | 신설동(하선) | 20.2 |
1 | 05:30 | 2호선 | 대림(내선) | 82.5 |
2 | 05:30 | 3호선 | 무악재(하선) | 43.7 |
3 | 05:30 | 4호선 | 성신여대입구(하선) | 67.0 |
4 | 05:30 | 5호선 | 양평(하선) | 42.6 |
5 | 05:30 | 6호선 | 망원(하선) | 35.0 |
6 | 05:30 | 7호선 | 어린이대공원(하선) | 109.0 |
7 | 05:30 | 8호선 | 문정(상선) | 40.2 |
3. 데이터 애니메이션 시각화¶
3-1. 레이싱 바 차트¶
In [ ]:
import plotly.express as px
import plotly.graph_objects as go
In [ ]:
# 호선별 색상 매핑
color_map = {
'1호선': '#0052A4',
'2호선': '#00A84D',
'3호선': '#EF7C1C',
'4호선': '#00A5DE',
'5호선': '#996CAC',
'6호선': '#CD7C2F',
'7호선': '#747F00',
'8호선': '#E6186C',
'9호선': '#BDB092'
}
In [ ]:
# 혼잡도의 최댓값 계산
max_congestion = results_df['혼잡도'].max()
In [ ]:
# 가로 바 차트 생성 및 애니메이션 적용
fig = px.bar(results_df, y='호선', x='혼잡도', color='호선',
animation_frame='시간대', orientation='h', range_x=[0, max_congestion+5],
color_discrete_map=color_map, text='출발역(상하구분)',
category_orders={"순위": list(range(1, 11))})
# 출발역(상하구분) 텍스트 추가 및 y축 라벨 수정
fig.update_layout(xaxis_title='혼잡도',
yaxis=dict(title='호선', categoryorder='total descending', autorange='reversed'),
# title=dict(text='지하철 혼잡도 순위', font=dict(size=50), automargin=True, yref='paper'),
legend_title_text='호선',
plot_bgcolor='rgba(0,0,0,0)', # 배경색 변경
paper_bgcolor='rgba(0,0,0,0)', # 배경색 변경
font=dict(
family="NanumGothic", # 글꼴 설정
size=12, # 기본 글꼴 크기
color="RebeccaPurple" # 글꼴 색상
), )
# 그리드 라인 스타일 조정
fig.update_xaxes(showgrid=True, gridwidth=1, gridcolor='RebeccaPurple')
# Y축 레이블 숨기기 및 눈금 라벨 제거
fig.update_layout(yaxis_title="")
fig.update_yaxes(showticklabels=False)
fig.layout.updatemenus[0].buttons[0].args[1]["frame"]["duration"] = 1000 # 여기서 1000은 1000밀리초, 즉 1초를 의미합니다.
fig.show()
In [ ]:
fig.write_html("racing_bar_plot.html")
3-2. 지도 차트¶
In [ ]:
df_map_xy = pd.read_csv("https://raw.githubusercontent.com/henewsuh/subway_crd_line_info/main/%EC%A7%80%ED%95%98%EC%B2%A0%EC%97%AD_%EC%A2%8C%ED%91%9C.csv", encoding='cp949')
In [ ]:
df_map_xy.head()
Out[ ]:
역이름 | y | x | |
---|---|---|---|
0 | 가락시장 | 37.492522 | 127.118234 |
1 | 종로3가 | 37.571607 | 126.991806 |
2 | 오금 | 37.502162 | 127.128111 |
3 | 동대문 | 37.571420 | 127.009745 |
4 | 동대문역사문화공원 | 37.565138 | 127.007896 |
In [ ]:
gis_results_df = results_df.copy()
In [ ]:
# 출발역(상하구분) 컬럼의 값을 활용하여 해당 역의 x 및 y 좌표를 찾아서 부동소수점(float)으로 변환하여 저장합니다.
def get_x_coordinate(station_name):
station_row = df_map_xy[df_map_xy['역이름'] == station_name]
if len(station_row) > 0:
return float(station_row["x"].values[0])
else:
return None
def get_y_coordinate(station_name):
station_row = df_map_xy[df_map_xy['역이름'] == station_name]
if len(station_row) > 0:
return float(station_row["y"].values[0])
else:
return None
gis_results_df["X"] = gis_results_df["출발역(상하구분)"].apply(lambda x: get_x_coordinate(x[:x.find('(')]))
gis_results_df["Y"] = gis_results_df["출발역(상하구분)"].apply(lambda x: get_y_coordinate(x[:x.find('(')]))
In [ ]:
gis_results_df[gis_results_df["X"].isna()]
Out[ ]:
시간대 | 호선 | 출발역(상하구분) | 혼잡도 | X | Y | |
---|---|---|---|---|---|---|
48 | 08:30 | 1호선 | 서울역(상선) | 82.7 | NaN | NaN |
64 | 09:30 | 1호선 | 서울역(상선) | 52.3 | NaN | NaN |
72 | 10:00 | 1호선 | 서울역(상선) | 36.9 | NaN | NaN |
88 | 11:00 | 1호선 | 서울역(상선) | 30.9 | NaN | NaN |
96 | 11:30 | 1호선 | 서울역(상선) | 30.9 | NaN | NaN |
112 | 12:30 | 1호선 | 서울역(상선) | 39.2 | NaN | NaN |
136 | 14:00 | 1호선 | 서울역(하선) | 34.2 | NaN | NaN |
152 | 15:00 | 1호선 | 서울역(하선) | 42.3 | NaN | NaN |
168 | 16:00 | 1호선 | 서울역(하선) | 48.7 | NaN | NaN |
176 | 16:30 | 1호선 | 서울역(하선) | 54.5 | NaN | NaN |
184 | 17:00 | 1호선 | 서울역(하선) | 62.7 | NaN | NaN |
192 | 17:30 | 1호선 | 서울역(하선) | 70.2 | NaN | NaN |
200 | 18:00 | 1호선 | 서울역(하선) | 107.8 | NaN | NaN |
208 | 18:30 | 1호선 | 서울역(하선) | 82.2 | NaN | NaN |
216 | 19:00 | 1호선 | 서울역(하선) | 46.5 | NaN | NaN |
224 | 19:30 | 1호선 | 서울역(하선) | 37.1 | NaN | NaN |
232 | 20:00 | 1호선 | 서울역(하선) | 35.3 | NaN | NaN |
240 | 20:30 | 1호선 | 서울역(하선) | 36.1 | NaN | NaN |
264 | 22:00 | 1호선 | 서울역(하선) | 39.2 | NaN | NaN |
In [ ]:
import numpy as np
# 결측치인 행을 찾습니다.
missing_rows = gis_results_df[gis_results_df["X"].isna()]
# 결측치인 행에 대해 "X" 및 "Y" 값을 지정하여 입력합니다.
for index, row in missing_rows.iterrows():
station_name = row["출발역(상하구분)"][:row["출발역(상하구분)"].find('(')]
x_coordinate = get_x_coordinate(station_name)
y_coordinate = get_y_coordinate(station_name)
gis_results_df.at[index, "X"] = 37.554648
gis_results_df.at[index, "Y"] = 126.972559
# 결과 확인
print(gis_results_df)
시간대 호선 출발역(상하구분) 혼잡도 X Y
0 05:30 1호선 신설동(하선) 20.2 127.025087 37.575297
1 05:30 2호선 대림(내선) 82.5 126.895801 37.492970
2 05:30 3호선 무악재(하선) 43.7 126.950291 37.582299
3 05:30 4호선 성신여대입구(하선) 67.0 127.016403 37.592624
4 05:30 5호선 양평(하선) 42.6 126.885778 37.525648
.. ... ... ... ... ... ...
307 00:30 4호선 한성대입구(상선) 18.3 127.006221 37.588458
308 00:30 5호선 영등포구청(상선) 26.7 126.895951 37.524970
309 00:30 6호선 동묘앞(하선) 12.8 127.016429 37.572627
310 00:30 7호선 용마산(상선) 23.8 127.086727 37.573647
311 00:30 8호선 송파(하선) 12.1 127.112183 37.499703
[312 rows x 6 columns]
In [ ]:
gis_results_df[gis_results_df["X"].isna()]
Out[ ]:
시간대 | 호선 | 출발역(상하구분) | 혼잡도 | X | Y |
---|
In [ ]:
# 서울특별시 경계선을 포함한 GeoJSON 파일을 불러옵니다.
import geopandas as gpd
# GeoJSON 파일을 읽어옵니다.
seoul_boundary_gdf = gpd.read_file('https://blog.kakaocdn.net/dn/dzgBUs/btrDtibaqaT/aYboMA5dCPJiEMBI15OSA1/SIDO_MAP_2022.json?attach=1&knm=tfile.json')
In [ ]:
seoul_boundary_gdf = seoul_boundary_gdf[seoul_boundary_gdf['CTP_KOR_NM'] == '서울특별시']
In [ ]:
seoul_boundary_gdf
Out[ ]:
CTPRVN_CD | CTP_ENG_NM | CTP_KOR_NM | geometry | |
---|---|---|---|---|
0 | 11 | Seoul | 서울특별시 | POLYGON ((126.98400 37.63600, 126.94800 37.657... |
In [ ]:
# 호선별 색상 매핑
color_map = {
'1호선': '#0052A4',
'2호선': '#00A84D',
'3호선': '#EF7C1C',
'4호선': '#00A5DE',
'5호선': '#996CAC',
'6호선': '#CD7C2F',
'7호선': '#747F00',
'8호선': '#E6186C',
'9호선': '#BDB092'
}
In [ ]:
# 출발역의 호선 정보에 대한 색상을 매핑합니다.
gis_results_df['색상'] = gis_results_df['호선'].map(color_map)
In [ ]:
gis_results_df
Out[ ]:
시간대 | 호선 | 출발역(상하구분) | 혼잡도 | X | Y | 색상 | |
---|---|---|---|---|---|---|---|
0 | 05:30 | 1호선 | 신설동(하선) | 20.2 | 127.025087 | 37.575297 | #0052A4 |
1 | 05:30 | 2호선 | 대림(내선) | 82.5 | 126.895801 | 37.492970 | #00A84D |
2 | 05:30 | 3호선 | 무악재(하선) | 43.7 | 126.950291 | 37.582299 | #EF7C1C |
3 | 05:30 | 4호선 | 성신여대입구(하선) | 67.0 | 127.016403 | 37.592624 | #00A5DE |
4 | 05:30 | 5호선 | 양평(하선) | 42.6 | 126.885778 | 37.525648 | #996CAC |
... | ... | ... | ... | ... | ... | ... | ... |
307 | 00:30 | 4호선 | 한성대입구(상선) | 18.3 | 127.006221 | 37.588458 | #00A5DE |
308 | 00:30 | 5호선 | 영등포구청(상선) | 26.7 | 126.895951 | 37.524970 | #996CAC |
309 | 00:30 | 6호선 | 동묘앞(하선) | 12.8 | 127.016429 | 37.572627 | #CD7C2F |
310 | 00:30 | 7호선 | 용마산(상선) | 23.8 | 127.086727 | 37.573647 | #747F00 |
311 | 00:30 | 8호선 | 송파(하선) | 12.1 | 127.112183 | 37.499703 | #E6186C |
312 rows × 7 columns
In [ ]:
gis_results_df
In [ ]:
# 출발역(상하구분)과 색상을 딕셔너리로 변환
color_dict = dict(zip(gis_results_df['출발역(상하구분)'], gis_results_df['색상']))
print(color_dict)
{'신설동(하선)': '#0052A4', '대림(내선)': '#00A84D', '무악재(하선)': '#EF7C1C', '성신여대입구(하선)': '#00A5DE', '양평(하선)': '#996CAC', '망원(하선)': '#CD7C2F', '어린이대공원(하선)': '#747F00', '문정(상선)': '#E6186C', '낙성대(외선)': '#00A84D', '독립문(하선)': '#EF7C1C', '한성대입구(하선)': '#00A5DE', '광나루(하선)': '#996CAC', '강동구청(하선)': '#E6186C', '사당(외선)': '#00A84D', '길동(상선)': '#996CAC', '중곡(하선)': '#747F00', '몽촌토성(하선)': '#E6186C', '동묘앞(하선)': '#CD7C2F', '지축(하선)': '#EF7C1C', '홍제(하선)': '#EF7C1C', '서울역(상선)': '#0052A4', '창신(상선)': '#CD7C2F', '철산(상선)': '#747F00', '장지(상선)': '#E6186C', '동대문(하선)': '#00A5DE', '남태령(상선)': '#00A5DE', '교대(외선)': '#00A84D', '경복궁(하선)': '#EF7C1C', '군자(하선)': '#747F00', '송파(상선)': '#E6186C', '방배(외선)': '#00A84D', '혜화(하선)': '#00A5DE', '옥수(하선)': '#EF7C1C', '안국(하선)': '#EF7C1C', '종로5가(하선)': '#0052A4', '교대(상선)': '#EF7C1C', '서초(외선)': '#00A84D', '종로3가(상선)': '#0052A4', '서울역(하선)': '#0052A4', '강남(내선)': '#00A84D', '압구정(하선)': '#EF7C1C', '동대문역사문화공원(상선)': '#00A5DE', '방배(내선)': '#00A84D', '신사(상선)': '#EF7C1C', '길동(하선)': '#996CAC', '합정(상선)': '#CD7C2F', '건대입구(상선)': '#747F00', '압구정(상선)': '#EF7C1C', '남태령(하선)': '#00A5DE', '시청(하선)': '#0052A4', '안국(상선)': '#EF7C1C', '동대문(상선)': '#00A5DE', '군자(상선)': '#747F00', '서초(내선)': '#00A84D', '독립문(상선)': '#EF7C1C', '가산디지털단지(하선)': '#747F00', '석촌(하선)': '#E6186C', '혜화(상선)': '#00A5DE', '가락시장(상선)': '#E6186C', '경복궁(상선)': '#EF7C1C', '강동구청(상선)': '#E6186C', '내방(하선)': '#747F00', '잠실(상선)': '#E6186C', '어린이대공원(상선)': '#747F00', '종로5가(상선)': '#0052A4', '동묘앞(상선)': '#0052A4', '사당(내선)': '#00A84D', '홍제(상선)': '#EF7C1C', '한성대입구(상선)': '#00A5DE', '망원(상선)': '#CD7C2F', '을지로4가(내선)': '#00A84D', '영등포구청(상선)': '#996CAC', '신설동(상선)': '#0052A4', '교대(내선)': '#00A84D', '지축(상선)': '#EF7C1C', '용마산(상선)': '#747F00', '송파(하선)': '#E6186C'}
In [ ]:
import plotly.graph_objects as go
import plotly.express as px
# Scattermapbox 그래프를 생성합니다.
fig = px.scatter_mapbox(gis_results_df, lat="Y", lon="X", size_max=100,
size="혼잡도", color="출발역(상하구분)",
animation_frame="시간대", animation_group="출발역(상하구분)",
zoom=10, center={"lat": 37.57, "lon": 127.0},
mapbox_style="open-street-map",
title="출발역 혼잡도 시각화",
color_discrete_map=color_dict)
# 원의 색상과 크기를 설정합니다.
fig.update_traces(marker=dict(opacity=0.7))
# Choroplethmapbox 그래프를 생성하여 서울시 경계를 지도에 추가합니다.
fig.add_trace(go.Choroplethmapbox(
geojson=seoul_boundary_gdf.__geo_interface__,
locations=seoul_boundary_gdf.index,
z=seoul_boundary_gdf.index,
colorscale="Viridis",
marker_opacity=0.3,
hoverinfo='location+z'
))
# 폰트 지정
fig.update_layout(font=dict(
family="NanumGothic",
size=12,
color="RebeccaPurple"
))
# 범례 레이아웃 투명하게 설정
fig.update_layout(legend=dict(
yanchor="top",
y=0.99,
xanchor="right",
x=0.99,
bgcolor='rgba(255,255,255,0.8)'
))
# 애니메이션 슬라이더의 단계 설정
steps = []
for i, hour in enumerate(gis_results_df['시간대'].unique()):
step = dict(
method="animate",
args=[[hour], {"frame": {"duration": 3000, "redraw": True}, "mode": "immediate"}],
label=f"{hour}"
)
steps.append(step)
# 그래프의 크기와 애니메이션 속도 설정
fig.update_layout(
width=1000, # 그래프의 너비
height=800, # 그래프의 높이
sliders=dict(
currentvalue=dict(
prefix="애니메이션 속도: ",
font=dict(size=16),
visible=True,
xanchor="center",
yanchor="top", # y축 위치 조정
y=-0.1 # y축 위치 조정
),
steps=[]
)
)
fig.show()
In [ ]:
import plotly.graph_objects as go
import plotly.express as px
# Scattermapbox 그래프를 생성합니다.
fig = px.scatter_mapbox(gis_results_df, lat="Y", lon="X", size_max=100,
size="혼잡도", color="출발역(상하구분)",
animation_frame="시간대", animation_group="출발역(상하구분)",
zoom=10, center={"lat": 37.57, "lon": 127.0},
mapbox_style="open-street-map",
title="출발역 혼잡도 시각화",
color_discrete_map=color_dict)
# 원의 색상과 크기를 설정합니다.
fig.update_traces(marker=dict(opacity=0.7))
# Choroplethmapbox 그래프를 생성하여 서울시 경계를 지도에 추가합니다.
fig.add_trace(go.Choroplethmapbox(
geojson=seoul_boundary_gdf.__geo_interface__,
locations=seoul_boundary_gdf.index,
z=[1]*len(seoul_boundary_gdf), # 임의의 값 할당
colorscale="Viridis",
marker_opacity=0.3,
hoverinfo='location+z',
))
# 폰트 지정
fig.update_layout(font=dict(
family="NanumGothic",
size=12,
color="RebeccaPurple"
))
# 범례 레이아웃 설정
fig.update_layout(legend=dict(
yanchor="top",
y=0.99,
xanchor="right",
x=0.99,
bgcolor='rgba(255,255,255,0.8)',
itemsizing='constant', # 범례 아이템 크기 고정
itemwidth=100, # 각 범례 아이템의 너비 설정
font=dict(size=16) # 폰트 크기 설정
))
# 애니메이션 슬라이더의 단계 설정
steps = []
for i, hour in enumerate(gis_results_df['시간대'].unique()):
step = dict(
method="animate",
args=[[hour], {"frame": {"duration": 3000, "redraw": True}, "mode": "immediate"}],
label=f"{hour}"
)
steps.append(step)
# 그래프의 크기와 애니메이션 속도 설정
fig.update_layout(
width=1000, # 그래프의 너비
height=800, # 그래프의 높이
sliders=dict(
currentvalue=dict(
prefix="애니메이션 속도: ",
font=dict(size=16),
visible=True,
xanchor="center",
yanchor="top", # y축 위치 조정
y=-0.1 # y축 위치 조정
),
steps=[]
)
)
fig.show()
In [ ]:
fig.write_html("animation_plot.html")
* 브라우저 및 디바이스에 따라 시각화 애니메이션이 잘 안 보일 수 있습니다.
'IT > 데이터분석' 카테고리의 다른 글
[시각화][애니메이션] 유튜버의 미래는 밝을까? - 2024 문화여가활동 분석 (0) | 2024.03.01 |
---|---|
[시각화] MZ세대의 문해력은 정말로 낮을까? - 성인문해능력조사 분석 (0) | 2024.02.07 |