4. 결론 - 지하철 이용 계획 수립

위에서 도출한 편차 그래프를 통해 최적의 지하철 통학 시간대를 파악해보겠습니다. 오렌지 색깔로 된 그래프는 집에서 학교로 통학하는 경우이며 아래 빨간색 그래프는 학교에서 집으로 가는 경우입니다. 

4.1 주중 지하철 통학 스케줄

기상 시간을 고려했을 때 최소 7시부터 집에서 출발이 가능합니다. 7시 이후부터 수업시간대인 14시 30분 전까지 가장 혼잡도가 낮은 시간대는 아침 10시 경입니다. 10시에서 10시 30분 사이에 지하철을 이용하는 것이 가장 합리적으로 보입니다. 한편, 학교에서 집으로 올 때에는 18시 30분부터 혼잡도가 급격히 하락하긴 하지만 20시 30분에서 21시 사이에 지하철을 이용하는 것이 최선으로 생각됩니다. 

4.2 주말 지하철 통학 스케줄

출근 시간대를 기점으로 피크를 찍고 내려오는 주중과 달리 주말에는 혼잡도가 점심 시간대까지 지속적으로 증가하는 것을 볼 수 있습니다. 따라서, 가능하다면 일찍 출발할수록 더 유리하다고 할 수 있습니다. 하지만 8시를 기점으로 혼잡도가 급격히 증가하기 때문에 8시 직전인 7시 30분 정도가 가장 최적의 시간대라고 할 수 있습니다. 반대로 학교에서 집으로 돌아올 때에는 19시 이후에 지하철을 이용하는 것이 좋으며 21시가 가장 좋은 선택이 될 것으로 보입니다. 

3. 대책 수립

3.1 최적의 통학 시간대 파악

이전의 분석을 통해 지하철의 혼잡한 시간대를 파악할 수 있었습니다. 지금부터는 평균 혼잡도와의 편차로 변환하여 최적의 지하철 통학 시간대를 찾아보도록 하겠습니다

3.1.1 집 -> 학교

집에서 학교로 통학하는 경우를 확인해보겠습니다. 학교로 통학하는 길에 2호선(내선)과 1호선(상선)을 이용하게 되는데 네이버 지도를 기준으로 약 55분이 소요됩니다. 도보 25분 + 2호선 5분 + 1호선 19분 정도로 실질적으로 지하철을 이용하는 시간은 25분 정도입니다. 지하철 환승 시간과 대기 시간을 포함한다면 최대 35분으로  할 수 있습니다.

분석에 사용된 데이터를 살펴보면 30분 단위로 혼잡도를 나타내고 있습니다. 정각을 기준으로 30분 간격인데 실제 지하철 이용 시간이 35분이기 때문에 복잡한 경우의 수 없이 분석이 가능할 것으로 생각됩니다. 우선, 집에서 학교로 가는 경로에 해당하는 2호선(내선)과 1호선(상선) 데이터를 결합하도록 하겠습니다. weekday_toschool과 weekend_toschool 각각은 주중과 주말 학교로 가는 노선을 나타낸다고 할 수 있습니다. 

# 집에서 학교로 이동하는 경우를 분석하기 위해 2호선(내선)과 1호선(상선) 데이터를 결합하겠습니다. 
weekday_toschool = df_wd_up_1.join(df_wd_in_2).T
weekend_toschool = df_we_up_1.join(df_we_in_2).T

데이터 조작이 용이하도록 인덱스를 리셋하였습니다.

# 데이터프레임 조작이 용이하도록 인덱스 리셋 
weekday_toschool = weekday_toschool.reset_index(drop=True)
weekend_toschool = weekend_toschool.reset_index(drop=True)

1호선과 2호선의 혼잡도를 더하여 혼잡도 합계(Total) 행을 추가하고 혼잡도 합계의 평균을 mean 변수에 저장하도록 하겠습니다. 그리고 평균을 통해 각 시간대별 혼잡도 합계의 편차를 구하여 혼잡도 편차(Deviate) 행을 추가하겠습니다. 

# 시간대별 평균 혼잡도, 혼잡도 편차 집계
weekday_toschool.loc['Total', :] = weekday_toschool.sum()
mean = weekday_toschool.loc['Total', :].mean()
print('주중 혼잡도 평균: ', mean)
weekday_toschool.loc['Deviate', :] = weekday_toschool.loc['Total', :] - mean

# 시간대별 평균 혼잡도, 혼잡도 편차 집계
weekend_toschool.loc['Total', :] = weekend_toschool.sum()
mean = weekend_toschool.loc['Total', :].mean()
print('주말 혼잡도 평균: ', mean)
weekend_toschool.loc['Deviate', :] = weekend_toschool.loc['Total', :] - mean

위에서 구한 편차(Deviate) 행을 가지고 시각적으로 비교가 가능하도록 편차 그래프를 그려보겠습니다. 지하철 이용 시간대를 결정하기 위해서는 시간 제약 조건을 고려해야 하기 때문에 주중 혼잡도 편차 그래프에 학교 수업 시간대를 점선으로 추가하였습니다.

## 주중 집 -> 학교 지하철 혼잡도 편차 그래프 ##
a = weekday_toschool.loc['Deviate', :].plot.bar(figsize = (20, 5), rot = 60, color='#ff7f0e') # 편차 행에 대한 바그래프
a.set_title('주중 혼잡도 편차 (집 -> 학교)', fontweight = 'semibold')
a.set_xlabel('시간')
a.set_ylabel('혼잡도 편차')

axis_range = a.axis() # 축 정보
a.axvline(19, axis_range[2], axis_range[3], color='red', linestyle='--', linewidth=2)
a.axvline(19, axis_range[2], axis_range[3], color='red', linestyle='--', linewidth=2)
a.axvline(26, axis_range[2], axis_range[3], color='red', linestyle='--', linewidth=2)
a.text(21, axis_range[3] - 10, '수업 시간대', fontweight = 'semibold')

plt.show()
## 주말 집 -> 학교 지하철 혼잡도 편차 그래프 ##
a = weekend_toschool.loc['Deviate', :].plot.bar(figsize = (20, 5), rot = 60, color='#ff7f0e') # 편차 행에 대한 바그래프
a.set_title('주말 혼잡도 편차 (집 -> 학교)', fontweight = 'semibold')
a.set_xlabel('시간')
a.set_ylabel('혼잡도 편차')

plt.show()

주중, 주말 혼잡도 편차 그래프를 그려보면 아래와 같이 나타나게 됩니다. 

3.1.2 학교 -> 집

학교에서 집으로 가는 경우도 위와 유사하기 때문에 추가적인 설명 없이 지나가도록 하겠습니다. 집에서 학교로 가는 경우와 반대로 1호선(하선)과 2호선(외선)을 이용하게 되며 동일하게 약 55분 소요됩니다. 지하철을 이용하는 시간 또한 35분으로 비슷하기 때문에 전반적인 분석 과정도 앞 절과 동일하다고 할 수 있습니다.

# 학교에서 집으로 이동하는 경우를 분석하기 위해 1호선(하선)과 2호선(외선) 데이터를 결합
weekday_tohome = df_wd_down_1.join(df_wd_out_2).T
weekend_tohome = df_we_down_1.join(df_we_out_2).T

# 인덱스 리셋
weekday_tohome = weekday_tohome.reset_index(drop=True)
weekend_tohome = weekend_tohome.reset_index(drop=True)

# 시간대별 전체 혼잡도, 혼잡도 편차 집계
weekday_tohome.loc['Total', :] = weekday_tohome.sum()
mean = weekday_tohome.loc['Total', :].mean()
print('주중 혼잡도 평균: ', mean)
weekday_tohome.loc['Deviate', :] = weekday_tohome.loc['Total', :] - mean

# 시간대별 전체 혼잡도, 혼잡도 편차 집계
weekend_tohome.loc['Total', :] = weekend_tohome.sum() # Total
mean = weekend_tohome.loc['Total', :].mean()
print('주말 혼잡도 평균: ', mean)
weekend_tohome.loc['Deviate', :] = weekend_tohome.loc['Total', :] - mean
## 주말 학교 -> 집 지하철 혼잡도 편차 그래프 ##
a = weekday_tohome.loc['Deviate', :].plot.bar(figsize = (20, 5), rot = 60, color='#d62728')
a.set_title('from 학교 to 집 평일 혼잡도 편차', fontweight = 'semibold')
a.set_xlabel('시간')
a.set_ylabel('혼잡도 편차')

axis_range = a.axis() # 축 정보
a.axvline(19, axis_range[2], axis_range[3], color='red', linestyle='--', linewidth=2)
a.axvline(19, axis_range[2], axis_range[3], color='red', linestyle='--', linewidth=2)
a.axvline(26, axis_range[2], axis_range[3], color='red', linestyle='--', linewidth=2)
a.text(21, axis_range[3] - 10, '수업 시간대', fontweight = 'semibold')

plt.show()
## 주말 학교 -> 집 지하철 혼잡도 편차 그래프 ##
a = weekend_tohome.loc['Deviate', :].plot.bar(figsize = (20, 5), rot = 60, color='#d62728')
a.set_title('from 학교 to 집 주말 혼잡도 편차', fontweight = 'semibold')
a.set_xlabel('시간')
a.set_ylabel('혼잡도 편차')

plt.show()

위의 코드를 통해 아래와 같은 그래프를 그려볼 수 있습니다. 

3.2 지하철 이용 계획 수립

위에서 도출한 편차 그래프를 통해 최적의 지하철 통학 시간대를 파악해보겠습니다. 오렌지 색깔로 된 그래프는 집에서 학교로 통학하는 경우이며 아래 빨간색 그래프는 학교에서 집으로 가는 경우입니다. 

3.2.1 주중 지하철 통학 스케줄

기상 시간을 고려했을 때 최소 7시부터 집에서 출발이 가능합니다. 7시 이후부터 수업시간대인 14시 30분 전까지 가장 혼잡도가 낮은 시간대는 아침 10시 경입니다. 10시에서 10시 30분 사이에 지하철을 이용하는 것이 가장 합리적으로 보입니다. 한편, 학교에서 집으로 올 때에는 18시 30분부터 혼잡도가 급격히 하락하긴 하지만 20시 30분에서 21시 사이에 지하철을 이용하는 것이 최선으로 생각됩니다. 

3.3.2 주말 지하철 통학 스케줄

출근 시간대를 기점으로 피크를 찍고 내려오는 주중과 달리 주말에는 혼잡도가 점심 시간대까지 지속적으로 증가하는 것을 볼 수 있습니다. 따라서, 가능하다면 일찍 출발할수록 더 유리하다고 할 수 있습니다. 하지만 8시를 기점으로 혼잡도가 급격히 증가하기 때문에 8시 직전인 7시 30분 정도가 가장 최적의 시간대라고 할 수 있습니다. 반대로 학교에서 집으로 돌아올 때에는 19시 이후에 지하철을 이용하는 것이 좋으며 21시가 가장 좋은 선택이 될 것으로 보입니다. 

 

2. 문제 파악

2.1 데이터 전처리

본격적인 문제 파악에 앞서 데이터 전처리 작업을 진행하겠습니다. 먼저, 필요한 라이브러리를 불러오고 환경설정을 해줍니다.

from IPython.core.display import display, HTML
display(HTML("<style>.container {width:80% !important;}</style>"))
%matplotlib inline

import pandas as pd
import matplotlib as mpl
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
from scipy import stats
import warnings
warnings.filterwarnings('ignore')

mpl.rcParams['figure.figsize'] = (8,6)  #시각화 figure default 설정
mpl.rcParams['font.family'] = 'NanumGothic' #폰트 디폴트 설정
mpl.rcParams['font.size'] = 20    #폰트 사이즈 디폴트 설정
plt.rcParams['axes.unicode_minus'] = False
%config InlineBackend.figure_format='retina' # 그래프 글씨 뚜렷

수집한 csv파일을 데이터프레임으로 불러와 확인해보겠습니다. 데이터에는 총 1704개의 인스턴스와 43개의 컬럼으로 구성되어 있습니다. 전체 컬럼 중 앞의 6개 열은 '연번', '조사일자', '호선', '역번호', '역명', '구분'이며 이를 제외한 나머지는 5시 30분부터 23시 30분까지 30분 단위의 시간 간격입니다. 

# csv 파일을 불러와 데이터프레임에 저장
df = pd.read_csv('서울교통공사_지하철혼잡도정보_20211231.csv', encoding = 'cp949')

전체 컬럼 중에서 '연번', '역번호'는 필요가 없기 때문에 삭제하도록 하겠습니다. 그리고 '조사일자'에서 '토요일'과 '일요일' 값은 '평일'값과 통일성을 주기 위해 모두 '주말'로 변경하겠습니다. 

# 불필요한 '연번', '역번호' 컬럼 제거
df = df.drop(['연번', '역번호'], axis = 1)
# '조사일자'의 '토요일'과 '일요일'은 주말로 변경
df.replace('토요일', '주말', inplace = True)
df.replace('일요일', '주말', inplace = True)

2.2 1호선 혼잡도 분석

전처리 작업을 마쳤으니 본격적으로 분석을 진행 해보겠습니다. 우선, 어떤 내용을 확인하고 싶은지 구체화하여 정리해보고 이에 따라 데이터를 정리해보겠습니다. 최적의 지하철 통학 시간대를 결정하기 위해서는 시간대별 지하철 혼잡도를 파악해야 합니다. 이때, 정확한 분석을 위해서는 데이터를 범주에 따라 그룹화하여 살펴보아야 합니다.

첫 번째로 '평일'과 '주말'에 따른 그룹입니다. 사실 '요일'별로 혼잡도를 파악하는 것이 보다 정확한 분석이 되겠지만 현재 가지고 있는 데이터에서는 '평일'과 '주말' 정도로 데이터가 수집되어 있습니다. '평일'과 '주말' 지하철 이용객의 수가 각각 다를 수 있기 때문에 분리해서 분석하도록 하겠습니다.

두 번째로 '호선'('역')에 따른 그룹입니다. 원본 데이터는 1호선부터 8호선까지 모든 호선의 데이터를 포함하고 있습니다. 이 중 제가 이용하는 1호선과 2호선만 따로 분리해서 확인해야 할 필요가 있습니다. 이때 '호선' 뿐만 아니라 실재 이용하는 '역'에 대해서만 데이터를 필터링하여 살펴보겠습니다.

세 번째로 '방향'에 따른 그룹입니다. 원본 데이터를 보면 같은 역이라도 방향에 따라 '상선/하선', '외선/내선'으로 구분되어 있습니다. 집에서 학교를 갈 때와 학교에서 집으로 올 때 각각 이용하는 방향이 다르기 때문에 이 부분도 분석에서 구분되어야 할 중요한 요소일 것입니다.

먼저, 원본 데이터 중 1호선에 대한 데이터를 따로 분리하여 df_1에 저장하겠습니다. 이때 이용하는 역을 필터링하여 필요한 역만 추출하겠습니다.

# 1호선 역명 확인
df[df['호선'] == 1]['역명'].unique()

# 2호선 역명 확인
df[df['호선'] == 2]['역명'].unique()
# 전체 역 중 실제 이용하는 역을 리스트화
station = ['이대', '아현', '충정로', '시청', '종각', '종로3가','종로5가', '동대문', '동묘앞', '신설동', '제기동', '청량리', '회기']

# 1호선 중 실제 이용하는 역만 필터링하여 새로운 데이터프레임 생성
df_1 = df[(df['호선'] == 1) & (df['역명'].isin(station))]

그리고 데이터를 '시간(조사일자)'과 '방향(구분)'에 따라 그룹화하도록 하겠습니다. 그러면 아래와 같이 '주말/평일' 그리고 '상선/하선'에 따라 데이터가 그룹화되어 정리될 것입니다. 

# 요일(조사일자), 방향(구분)을 기준으로 그룹화
df_1 = df_1.groupby(['조사일자', '구분']).mean()

공간 상 일부 컬럼이 생략되었습니다

그룹화 한 데이터를 각 행별로 분리해서 따로 저장하겠습니다. 나중에 통학 시간대를 분석하기 위한 단계에서 조금 더 작업이 수월해질 수 있기 때문입니다. 그러면 아래와 같이 'df_wd_up_1(주중-상선-1)'과 같은 데이터 프레임이 총 4개가 생성될 것입니다.

(왜 그랬는지 모르겠지만 이때부터 '평일'이라는 단어를 '주중'이라고 표현하였습니다. 아마 '주말'과 통일성 있는 단어를 사용하려다 보니 바뀌게 된 것 같은데 이때 데이터에 있는 '평일'이라는 용어를 바꿨어야 했는데 그러지 못했습니다. 제 불찰이지만 너그러이 이해해주시면 감사하겠습니다.)

df_wd_up_1 = pd.DataFrame(df_1.iloc[2,:]) #주중 1호선 상선 혼잡도 
df_wd_down_1 = pd.DataFrame(df_1.iloc[3,:]) #주중 1호선 하선 혼잡도 

df_we_up_1 = pd.DataFrame(df_1.iloc[0,:]) #주말 1호선 상선 혼잡도 
df_we_down_1 = pd.DataFrame(df_1.iloc[1,:])#주말 1호선 하선 혼잡도
df_we_down_1.T.iloc[:,:16]

2.3 1호선 혼잡도 시각화

앞서 그룹화한 데이터를 살펴보게 되면 1) 조건(날짜, 방향)에 따라 혼잡도 차이가 있는 것으로 보이고 또한 2)시간대별로도 혼잡도 차이가 있는 것으로 파악됩니다. 하지만 너무 많은 수치가 있고 수치간 scale차이가 크지가 않다보니 명확하게 차이가 있다고 말하기가 어렵다고 느껴집니다. 이번에는 위의 표 데이터들을 시각적으로 확인할 수 있도록 그래프로 그려보겠습니다. '주중'과 '주말'에 대한 데이터 각각에 대해 막대 그래프를 그려보았고 방향을 label로 하여 막대를 2개씩 표현하였습니다. 말로 설명하기 어려운데 아래 그래프를 직접 보면 이해가 수월할 것입니다. 

### 주중/주말 1호선 평균 혼잡도 그래프 ###
plt.figure(figsize = (20, 15)) #subplot 2개를 그리기 위한 크기 지정

## 주중 1호선 평균 혼잡도 그래프 ##

index = np.arange(len(df_wd_up_1.index)) #가로축 index 개수만큼 array 생성
x1 = df_wd_up_1.index 
ya = df_wd_up_1.values.flatten() # 데이터프레임의 2차원 value값을 1차원 배열로 변환
yb = df_wd_down_1.values.flatten() # 데이터프레임의 2차원 value값을 1차원 배열로 변환
mean1 = ((np.mean(df_wd_up_1.values) + np.mean(df_wd_down_1.values)) / 2).round(1) # 주중 상선/하선 혼잡도 평균

plt.subplot(2,1,1) # 1X2 subplot의 첫번째 그래프
p1 = plt.bar(index - 0.2, ya, color = '#1f77b4', alpha = 0.8, width = 0.4) # 상선에 대한 바그래프
p2 = plt.bar(index + 0.2, yb, color = '#1f77b4', alpha = 0.4, width = 0.4) # 하선에 대한 바그래프

plt.legend((p1[0], p2[0]), ('상선', '하선'), loc = 'upper right', fontsize=20) # 범례 지정

plt.xticks(index, x1, rotation = 60) #xticks 레이블 60도 회전
plt.title('주중 1호선 평균 혼잡도')
plt.xlabel('시간')
plt.ylabel('혼잡도(%)')
plt.ylim(0, 60) # y축 범위 지정

plt.axhline(34, color='grey', linestyle=':', linewidth=2) # 만석 기준점(34%) 수평선 표시
plt.text(-2, 35, '만석 기준점 = 34.0%') # 만석 기준점(34%) 레이블 표시

plt.axhline(mean1, color='#d62728', linestyle='--', linewidth=2) # 혼잡도 평균 수평선 표시
plt.text(-2, mean1 + 1, f'평균 = {mean1}%') # 혼잡도 평균 레이블 표시

## 주말 1호선 평균 혼잡도 그래프 ##

index = np.arange(len(df_we_up_1.index)) #가로축 index 개수만큼 array 생성
x1 = df_we_up_1.index
ya = df_we_up_1.values.flatten() # 데이터프레임의 2차원 value값을 1차원 배열로 변환
yb = df_we_down_1.values.flatten() # 데이터프레임의 2차원 value값을 1차원 배열로 변환
mean2 = ((np.mean(df_we_up_1.values) + np.mean(df_we_down_1.values)) / 2).round(1) # 주중 상선/하선 혼잡도 평균

plt.subplot(2,1,2) # 1X2 subplot의 두번째 그래프
# plt.bar(x2, y2, color = '#2ca02c')
p1 = plt.bar(index - 0.2, ya, color = '#1f77b4', alpha = 0.8, width = 0.4) # 상선에 대한 바그래프
p2 = plt.bar(index + 0.2, yb, color = '#1f77b4', alpha = 0.4, width = 0.4) # 하선에 대한 바그래프

plt.legend((p1[0], p2[0]), ('상선', '하선'), loc = 'upper right', fontsize=20) # 범례 지정

plt.xticks(index, x1, rotation = 60) #xticks 레이블 60도 회전
plt.title('주말 1호선 평균 혼잡도')
plt.xlabel('시간')
plt.ylabel('혼잡도(%)')
plt.ylim(0, 60) # y축 범위 지정

plt.axhline(34, color='grey', linestyle=':', linewidth=2) # 만석 기준점(34%) 수평선 표시
plt.text(-2, 35, '만석 기준점 = 34.0%') # 만석 기준점(34%) 레이블 표시

plt.axhline(mean2, color='#d62728', linestyle='--', linewidth=2) # 혼잡도 평균 수평선 표시
plt.text(-2, mean2 + 1, f'평균 = {mean2}%') # 혼잡도 평균 레이블 표시

plt.tight_layout()
plt.show()

2.4 1호선 혼잡도 분석 결과

1호선 혼잡도 그래프를 살펴보면 다음과 같은 사실을 확인할 수 있습니다.

1) '주중'과 '주말' 지하철 혼잡도 정도(양)의 차이가 존재하는 것으로 보입니다. '평균 혼잡도'에 있어서 '주중', '주말' 각각 21.1%, 17.5%로 3.6%p 차이가 있습니다. 또한, 만석 기준점인 34.0%를 기준으로 했을 때 '주중'에는 기준점을 상회하는 시간대가 다수(하루 전체 중 총 2시간 정도)있지만 '주말'에는 모든 시간대에서 기준점 아래인 것을 확인할 수 있습니다. 따라서, '주말'보다 '주중' 지하철 이용 승객이 평균적으로 더 많은 편이고 그에 따라 지하철 내부가 훨씬 더 혼잡하다고 정리할 수 있습니다.

2) '주중'과 '주말' 지하철 혼잡도 분포의 차이가 있다는 것을 확인할 수 있습니다. '주중'에는 주로 출근 시간대(7시 30분 ~ 9시)와 퇴근 시간대(17시 ~ 18시 30분)에 혼잡도가 높은 편이며 반대로 '주말'에는 점심과 저녁 사이(12시 ~ 18시) 사이에 혼잡도가 높은 편입니다. 이는 '주중'과 '주말' 사람들의 생활 양식에 차이가 있기 때문에 생기는 차이라고 생각됩니다.

3) '주중'과 '주말' 각각 '방향'에 따라 지하철 혼잡도의 차이가 있는 것으로 보입니다. 먼저 '주중' 그래프 살펴보면 아침 시간대에 '하선'이 '상선'보다 혼잡도가 더 높고 반대로 저녁 시간대에는 '상선'이 '하선'보다 혼잡도가 더 높습니다. 이 점은 그래프를 그려보기 전에는 파악하지 못했던 점인데 굉장히 흥미로운 인사이트라고 여겨집니다. 그래프의 중요성을 깨달을 수 있는 기회였고 비록 이번 분석에서는 이 부분에 대해 확장해서 다루지는 않았지만 추후에 기회가 되면 '방향'에 따라 혼잡도 차이가 발생하는 이유에 대해 분석해보도록 하겠습니다.

2.5 2호선 혼잡도 분석

2호선에 대한 분석은 1호선과 동일하기 때문에 설명은 생략하도록 하겠습니다.

station = ['이대', '아현', '충정로', '시청', '종각', '종로3가','종로5가', '동대문', '동묘앞', '신설동', '제기동', '청량리', '회기']

# 2호선 중 실제 이용하는 역만 필터링하여 새로운 데이터프레임 생성
df_2 = df[(df['호선'] == 2) & (df['역명'].isin(station))]

# 요일(조사일자), 방향(구분), 2호선을 기준으로 그룹화
df_2 = df_2.groupby(['조사일자', '구분', '호선']).mean()

df_wd_in_2 = pd.DataFrame(df_2.iloc[2,:])
df_wd_out_2 = pd.DataFrame(df_2.iloc[3,:])

df_we_in_2 = pd.DataFrame(df_2.iloc[0,:])
df_we_out_2 = pd.DataFrame(df_2.iloc[1,:])

2.6 2호선 혼잡도 시각화

2호선에 대한 시각화 또한 1호선과 동일하기 때문에 설명은 생략하도록 하겠습니다.

### 주중/주말 2호선 평균 혼잡도 그래프 ###
plt.figure(figsize = (20, 15)) #subplot 2개를 그리기 위한 크기 지정

## 주중 2호선 평균 혼잡도 그래프 ##

index = np.arange(len(df_wd_up_1.index)) #가로축 index 개수만큼 array 생성
x1 = df_wd_in_2.index 
ya = df_wd_in_2.values.flatten() # 데이터프레임의 2차원 value값을 1차원 배열로 변환
yb = df_wd_out_2.values.flatten() # 데이터프레임의 2차원 value값을 1차원 배열로 변환
mean1 = ((np.mean(df_wd_in_2.values) + np.mean(df_wd_out_2.values)) / 2).round(1) # 주중 상선/하선 혼잡도 평균

plt.subplot(2,1,1) # 1X2 subplot의 첫번째 그래프
p1 = plt.bar(index - 0.2, ya, color = '#2ca02c', alpha = 0.8, width = 0.4) # 상선에 대한 바그래프
p2 = plt.bar(index + 0.2, yb, color = '#2ca02c', alpha = 0.4, width = 0.4) # 하선에 대한 바그래프

plt.legend((p1[0], p2[0]), ('내선', '외선'), loc = 'upper right', fontsize=20) # 범례 지정

plt.xticks(index, x1, rotation = 60) #xticks 레이블 60도 회전
plt.title('주중 2호선 평균 혼잡도', fontweight = 'semibold')
plt.xlabel('시간')
plt.ylabel('혼잡도(%)')
plt.ylim(0, 90) # y축 범위 지정

plt.axhline(34, color='grey', linestyle=':', linewidth=2) # 만석 기준점(34%) 수평선 표시
plt.text(-2, 35, '만석 기준점 = 34.0%') # 만석 기준점(34%) 레이블 표시

plt.axhline(mean1, color='#d62728', linestyle='--', linewidth=2) # 혼잡도 평균 수평선 표시
plt.text(-2, mean1 - 3, f'평균 = {mean1}%') # 혼잡도 평균 레이블 표시


## 주말 2호선 평균 혼잡도 그래프 ##

index = np.arange(len(df_we_up_1.index)) #가로축 index 개수만큼 array 생성
x1 = df_we_in_2.index
ya = df_we_in_2.values.flatten() # 데이터프레임의 2차원 value값을 1차원 배열로 변환
yb = df_we_out_2.values.flatten() # 데이터프레임의 2차원 value값을 1차원 배열로 변환
mean2 = ((np.mean(df_we_in_2.values) + np.mean(df_we_out_2.values)) / 2).round(1) # 주중 상선/하선 혼잡도 평균

plt.subplot(2,1,2) # 1X2 subplot의 두번째 그래프
# plt.bar(x2, y2, color = '#2ca02c')
p1 = plt.bar(index - 0.2, ya, color = '#2ca02c', alpha = 0.8, width = 0.4) # 상선에 대한 바그래프
p2 = plt.bar(index + 0.2, yb, color = '#2ca02c', alpha = 0.4, width = 0.4) # 하선에 대한 바그래프

plt.legend((p1[0], p2[0]), ('내선', '외선'), loc = 'upper right', fontsize=20) # 범례 지정

plt.xticks(index, x1, rotation = 60) #xticks 레이블 60도 회전
plt.title('주말 2호선 평균 혼잡도', fontweight = 'semibold')
plt.xlabel('시간')
plt.ylabel('혼잡도(%)')
plt.ylim(0, 70) # y축 범위 지정

plt.axhline(34, color='grey', linestyle=':', linewidth=2) # 만석 기준점(34%) 수평선 표시
plt.text(-2, 35, '만석 기준점 = 34.0%') # 만석 기준점(34%) 레이블 표시

plt.axhline(mean2, color='#d62728', linestyle='--', linewidth=2) # 혼잡도 평균 수평선 표시
plt.text(-2, mean2 + 1, f'평균 = {mean2}%') # 혼잡도 평균 레이블 표시

plt.tight_layout()
plt.show()

2.7 2호선 혼잡도 분석 결과

2호선 혼잡도 그래프를 살펴보면 다음과 같은 사실을 확인할 수 있습니다.

1) 1호선 보다 2호선의 혼잡도가 높은 편입니다. 평균 혼잡도를 비교했을 때 1호선은 21.1%, 17.5%인데 반해 2호선은 30.8%, 24.3%로 각각 9.7%p, 6.8%p 더 높습니다. 두 노선의 열차 칸 수와 수용량이 동일하기 때문에 혼잡도가 높다는 것은 그만큼 하루 평균 더 많은 승객이 탑승했다고 해석할 수 있습니다. 특히, 주중 2호선 8시 시간대에 혼잡도가 90% 가까이 육박하는 것을 볼 수 있는데 이는 지하철 수용량 인원인 160명에 가까운 인원이 타고 있다는 것을 의미합니다. 1호선의 평균 혼잡도의 최대치가 60%가 안 된다는 점과 비교했을 때 지하철 2호선의 출근 시간은 정말 혼잡하다라고 할 수 있습니다. 물론 지금의 분석은 2호선 전체 중 일부 구간(이대~시청)이기 때문에 전체를 일반화할 수는 없습니다. 다만, 제 경우에 이 시간대만큼은 가능한 피하는 것이 원활한 통학에 유리할 것으로 보입니다.

2) '주중'과 '주말' 지하철 혼잡도 분포의 차이가 있다는 것을 확인할 수 있습니다. '주중'이 '주말' 보다 혼잡도가 더 높은데 이는 1호선에서도 볼 수 있는 패턴이었습니다. 다만, 1호선과 비교했을 때 2호선의 경우 '주말' 저녁 시간대(18시~23시)에도 혼잡도가 높은 편이고 낮시간대에서 그다지 떨어지지 않는 양상을 보이고 있습니다. 이는 주말 저녁에 혼잡도가 급격히 떨어지는 1호선과는 다른 모습이라고 할 수 있습니다.  정확한 분석을 위해서는 하락율을 계산해서 비교해 보아야 겠지만 육안 상으로도 혼잡도가 계속 유지되고 있는 것을 확인할 수 있습니다. 2호선의 경우 저녁 시간대에도 혼잡도가 높은 것은 강남, 신촌 등 서울의 주요 유흥 지역들이 2호선에 위치해 있기 때문이라고 생각됩니다. 기회가 된다면 이 부분에 대해서도 추후에 다루어 보도록 하겠습니다.

1. 문제 정의 

1.1 프로젝트 배경

2학기부터 모든 수업이 대면으로 전환되면서 원치 않게 통학을 시작하게 되었습니다. 오랜 시간 기숙사 생활을 하다 코로나 기간에 맞추어 자취를 시작하게 된터라 먼거리를 통학하는 것이 아직 익숙하지는 않은 편입니다. 그간 친구를 통해서만 전해 듣던 통학의 매운 맛을 직접 맛보게 되니 전국의 모든 통학러들과 직장인들이 새삼 대단하다 느껴졌습니다.

통학을 시작한지 2달이 되어가고 있는 시점에서 이제는 그만 하고 싶은 마음이 밀려오고 있습니다. 매일 아침 만원인 지하철 안에서 1시간을 보내다 보면 종종 현타가 오고 하루가 금방 피로해지곤 합니다. 사실 통학 자체가 어렵고 힘든 것은 아닙니다. 이동하는 시간과 거리 보다는 이동할 때의 상황이 문제라고 보입니다. 널널하게 앉아서 가게 되면 그리 힘들다고 느껴지지 않는데 같은 거리를 사람들 사이에 끼어 타게 되면 체감되는 시간도 길어지고 피로도도 급격히 증가합니다.

통학을 아예 안 하는 것은 현실적으로 불가능합니다. 학교를 다녀야 하는 입장에서 통학은 불가피한 상황이고 다만 가능한 편하게 통학할 수 있는 방법을 고려해볼 수는 있을 것입니다. 지하철을 이용해 통학을 하기 때문에 지하철이 혼잡하지 않은 시간대에 맞추어 이동하면 될 것 같다는 생각이 들었고 이러한 이유로 다음과 같은 프로젝트 아닌 프로젝트를 진행하게 되었습니다. 

1.2 문제 정의

문제를 정의하기 전 다음과 같은 질문을 해볼 수 있습니다. 가능한 혼잡하지 않은 시간대에 지하철을 이용할 수 있을까? 이 질문에 대해 상식적인 선에서 우리는 출퇴근 시간을 피하라는 답변을 할 수 있을 것입니다. 누구나 예상할 수 있는 해결책입니다. 하지만 이는 우리의 경험에서 비롯된 직관적인 답변에 불과합니다. 사실일 가능성이 매우 높지만 정확한 검증이 필요합니다.

그리고 단순히 피해야 하는 시간대를 알아내는 것에 그치는 것이 아닌 가장 혼잡하지 않은 시간대까지 알아내는 것이 필요합니다. 또한, 우리는 이동 가능한 시간대에 제약이 있습니다. 가령, 그 제약에는 이동이 불가능한 수업 시간대가 있을 것입니다. 따라서, 최적의 통학 시간대를 파악하여 스케줄을 관리하는 것이 목표가 되며 이를 위해 해결해야 하는 문제는 지하철이 가장 여유로운 시간대가 언제인지 파악하는 것입니다. 이때, '최적의 통학 시간대'에서의 '최적'의 의미는 제약조건 하에서 혼잡도가 최저인 경우라고 정의할 수 있을 것입니다.

- 목표 : 가장 최적화된 지하철 통학 스케줄 관리

- 문제 : 지하철이 가장 여유로운 시간대 파악 (혼잡도가 최저인 경우)

- 분석 지표 : 혼잡도

1.3 제약 조건 확인

시간 제약 조건 : 현재 수업이 있는 화, 수, 목을 포함해 주 4일~5일 학교로 통학하고 있습니다. 주말에는 수업이 없지만 종종 스터디를 위해 학교에 가고 있습니다. 화요일은 오후 4시 ~ 6시, 수요일은 오후 3시 ~ 6시, 목요일은 오전 9시 ~ 오후 6시 수업이 있으며 나머지 요일은 자율적으로 시간 활용이 가능합니다.

경로 제약 조건 : 학교에 가기 위해서는 2가지 방법(지하철만 이용 시)이 있습니다. 방법 1은 이대역에서 시청역까지 2호선, 시청역에서 회기역까지 1호선을 이용하는 것입니다. 방법 2는 이대역에서 왕십리역까지 1호선, 왕십리역에서 회기역까지 경의중앙선을 이용하는 것입니다. 둘 다 네이버 지도 기준 소요되는 시간은 약 55분으로 동일합니다. 그래서 두 가지 경우의 수를 고려할 수 있지만 이번 분석에서는 방법1의 경우만 고려하겠습니다. 왜냐하면 경의중앙선은 배차간격이 길고 불규칙하다는 변수가 있기 때문에 최적의 통학 스케줄에는 적절하지 않기 때문입니다. 

1.4 데이터 수집

문제에 적합한 데이터를 수집하는 것은 데이터 분석에 있어 매우 중요한 과정입니다. ('적합하다'는 것은 여러 의미가 있을 것입니다. 이에 대해서는 나중에 시간 내어 다루어 보도록 하겠습니다.) 수집된 데이터에 따라 엉뚱한 결과가 나오기도 하고 결과가 달라질 수도 있기 때문입니다. 원하는 정보를 얻기 위해서는 문제를 표현할 수 있는 내용을 담은 데이터가 필요하며 이 과정에서 수 개의 데이터를 수집하여 이를 통합하는 과정이 요구되기도 합니다.

하지만 정말 운이 좋게도 이번 분석에 필요한 데이터를 단번에 구할 수 있었습니다. 서울교통공사에서 제공하는 '지하철 혼잡도 정보'인데 정확히 제가 원하는 분석 지표를 포함한 데이터입니다. 만약 이 데이터가 없었더라면 '시간대별 지하철 이용객' 이나 '호선별 지하철 수용량' 등의 데이터를 구해 분석 지표를 집계하여 새로운 데이터를 만들어야 했을 것입니다. 물론, 수집해야 할 데이터 수가 많아짐에 따라 필요한 데이터를 구하지 못하는 경우도 발생했을 것입니다. 여하튼, 필요한 데이터를 수집했으니 데이터에 대한 설명을 확인해보겠습니다.

데이터 설명 : 서울교통공사 1-8호선 30분 단위 평균 혼잡도로 30분간 지나는 열차들의 평균 혼잡도(정원대비 승차인원으로, 승차인과 좌석수가 일치할 경우를 혼잡도 34%로 산정) 입니다.(단위: %). 서울교통공사 혼잡도 데이터는 조사일자(평일, 토요일, 일요일), 호선, 역번호, 역명, 상하선구분, 30분단위 별 혼잡도 데이터로 구성되어 있습니다. (2년 단위 업데이트 자료 입니다.)

혼잡도 추가 설명 : 지하철 1칸의 정원은 160명으로 이를 기준 삼아 혼잡도(%)를 산정하게 됩니다. 즉, 지하철 1칸에 160명이 차게 되면 혼잡도 100%입니다. 한편, 지하철 1칸에는 7인용 좌석 6개, 노약자석 3인용 4개가 있어 총 54개의 좌석이 있습니다. 따라서, 모든 좌석에 한 사람씩 앉아 54명이 찼을 경우 34%의 혼잡도가 나오게 됩니다.

Warning) 아래 코드들은 버전이 바뀌어 정상적으로 작동되지 않을 수도 있습니다. 따로 버전을 명시하지 않았기 때문에 에러가 발생되면 조금씩 수정해 나가면서 사용하시길 바랍니다. 또한, 형편없는 저의 실력으로 코드들이 다소 비효율적일 수 있음을 미리 말씀드립니다. 우연히 이 글을 보게 되신 분들은 참고해주시기 바랍니다. 

text-preprocessing-for-textanlytics.ipynb
0.31MB


STEP 4. 빈도 분석을 위한 텍스트 전처리

STEP3에서는 이전를 날리는 일이 없도록 하기 위해 제품별 url을 하나의 리스트로 묶어 반복 실행하지 않고 일부러 따로 따로 독립적으로 수집하였습니다.

(1) 라이브러리 import 및 파일 불러오기

import pandas as pd
import re

df = pd.read_csv('/content/drive/MyDrive/Github/review-topic-modeling-project/output_file/all_review.csv')
df['Review']

8개 제품에d은 같기 때문에 2개의 코드만 가져왔고 이와 같은 방식을 8번 진행했다고 보시면 됩니다. 수집 결과 아래 사진과 같이 총 8개의 csv 파일이 만들어지게 됩니다. 

(2) 특수문자 제거

df['Review'] = df['Review'].str.replace(pat=r'[^\w]', repl= r' ', regex=True)  # replace all special symbols to space
df['Review'] = df['Review'].str.replace(pat=r'[\s\s+]', repl= r' ', regex=True)  # replace multiple spaces with a single space

df

def extract_symbol(text):
    text = text.str.replace(pat=r'[^\w]', repl= r' ', regex=True)
    result = text.str.replace(pat=r'[\s\s+]', repl= r' ', regex=True)
    return result

(3) 한글 표현만 남기기

#한글 표현만 남기기 
def extract_word(text):
    hangul = re.compile('[^가-힣]') 
    result = hangul.sub(' ', text) 
    return result
print("Before Extraction : ",df['Review'][17480])
print("After Extraction : ", extract_word(df['Review'][17480]))

print("Before Extraction : ",df['Review'][1494])
print("After Extraction : ", extract_word(df['Review'][1494]))

df['Review'] = df['Review'].apply(lambda x:extract_word(x))

(4) 띄어쓰기 처리

pip install git+https://github.com/haven-jeon/PyKoSpacing.git
from pykospacing import Spacing

spacing = Spacing()
print("Before Fixing : ",df['Review'][324])
print("After Fixing : ", spacing(df['Review'][324]))
print("Before Fixing : ",df['Review'][14454])
print("After Fixing : ", spacing(df['Review'][14454]))

 

df['Review'] = df['Review'].apply(lambda x:spacing(x))

df['Review'][324]
#띄어쓰기 처리 
from pykospacing import Spacing
def extract_word(text):
    spacing = Spacing()
    result = text.apply(lambda x:spacing(x))

 

(5) 형태소 분석

!pip install konlpy

from konlpy.tag import Okt
okt = Okt()
# 리뷰 텍스트 정규화 처리
df['Review'] = df['Review'].apply(lambda x: okt.normalize(x))
df['Review']

df.to_csv('/content/drive/MyDrive/Github/review-topic-modeling-project/output_file/processed_all_review.csv', index = False)

지금까지 수집한 파일들은 output_file 폴더에 저장하였고 총 8개 파일이 있습니다. 파일들이 다 파편적으로 분리가 되어 있으면 텍스트 분석을 진행하는데 있어 번거로움이 발생합니다. 따라서, 파일들을 하나로 병합하도록 하겠습니다. 그리고 파일 병합을 진행하기 전에는 중복된 파일들이 있는지 확인하고 중복된 경우에는 제거하도록 하겠습니다.

words = " ".join(df['Review'].tolist())
words = okt.morphs(words, stem=True)
words
len(words)

실 정확하게 스크래핑을

(6) 한글자 처리 및 불용어 제거

path = '/content/drive/MyDrive/Github/review-topic-modeling-project/output_file'

one_word = [x for x in words if len(x) == 1]
one_word_set = set(one_word)
print(one_word_set)

#특정 한글자를 제외한 나머지 한글자는 drop
remove_one_word = [x for x in words if len(x) > 1 or x in ['맛', '술', '양', '짱']]
len(remove_one_word)

#각 단어별 빈도수 확인
from collections import Counter
frequent_words = Counter(remove_one_word).most_common()
frequent_words

with open('/content/drive/MyDrive/Github/review-topic-modeling-project/stopwords.txt', encoding='cp949') as f:
    stopwords_list = f.readlines()
stopwords = stopwords_list[0].split(",")
len(stopwords)

remove_stopwords = [x for x in remove_one_word if x not in stopwords]
len(remove_stopwords)

Counter(remove_stopwords).most_common()

words_frequency = dict(Counter(remove_stopwords))
words_frequency

(7) 빈출 키워드 워드 클라우드 생성

#!sudo apt-get install -y fonts-nanum
#!sudo fc-cache -fv
#!rm ~/.cache/matplotlib -rf

import matplotlib.pyplot as plt
from wordcloud import WordCloud
# jupyter notebook 내 그래프를 바로 그리기 위한 설정
%matplotlib inline

# unicode minus를 사용하지 않기 위한 설정 (minus 깨짐현상 방지)
plt.rcParams['axes.unicode_minus'] = False
import matplotlib.pyplot as plt

plt.rc('font', family='NanumGothic') 

df = pd.DataFrame(remove_stopwords, columns=['Word'])

wc = WordCloud(font_path='NanumGothic', colormap = 'summer', background_color = 'black', width=800, height=400, scale=2.0, max_font_size=250)
gen = wc.generate_from_frequencies(words_frequency)
plt.figure(figsize = (10, 8))
plt.style.use('seaborn-whitegrid')
plt.imshow(gen)
plt.grid(False)
plt.axis('off')
#wordcloud 이미지 저장
plt.savefig('/content/drive/MyDrive/Github/review-topic-modeling-project/output_file/wordcloud.png', dpi= 300)

후에는 중복된 리뷰 데이터들을 제거하겠습니다. 중복이 제거된 데이터프레임은 다시 새로운 파일로 저장하겠습니다. 

중복 제거까지 진행한 결과 최종 수집된 데이터는 19445개로 당초 예상했던 2만개에는 조금 못 미치는 정도입니다. 중복된 내용 때문에 계획한 샘플 수를 충족시키지는 못했지만 결과에는 큰 영향을 주지 않을 것 같아 그냥 진행하도록 하겠습니다. 

 

조금은 쓸데없는 과정이기는 하지만 쇼핑몰별로 수집된 리뷰 데이터의 수를 확인해보도록 하겠습니다. 앞서 프로젝트 계획 단계에서 편향되지 않은 분석 결과를 도출하기 위해 가능한 다양한 쇼핑몰에서의 리뷰를 가져오겠다고 말씀드린 적이 있습니다. 사실 리뷰의 출처가 되는 쇼핑몰이 다양할수록 리뷰 또한 다양하고 편향되지 않는다고 말할 수는 없습니다. 쇼핑몰과 리뷰 내용의 편향성은 상관관계가 높지 않을 것입니다. 하지만 한쪽 쇼핑몰의 리뷰만 가져올 경우에는 분명 해당 쇼핑몰만의 문제들이 분석 결과에 다소간 영향을 끼칠 수도 있는 있을 것입니다. 때문에 제 생각에는 쇼핑몰의 다양성을 유지하는 것이 그리 나쁜 선택은 아닌 것 같습니다. 

아래 결과를 보면 이건 쫌... 이라는 생각이 드실 것 같습니다. 생각보다 쇼핑몰간 편차가 커 보입니다. SSG닷컴이 거의 전체 샘플 데이터의 절반 이상을 차지하고 있고 11번가까지 정도만 천단위 샘플이 수집되었고 나머지는 거의 미미한 수준입니다. 한편, 쿠팡이나 마켓컬리와 같이 최근 많은 매출을 기록하고 있는 쇼핑 플랫폼의 데이터가 없는 것이 아쉬움이 남습니다. 쿠팡 같은 경우는 네이버 쇼핑과 연동이 되지 않아 따로 수집해야 되는 것으로 보입니다. 여튼 분포 결과가 샘플의 균형을 맞추기에는 다소 부족한 부분이 있지만 실제로 결과물을 가져와 보아야 정확히 알 수 있을 듯 싶습니다. 

 

다음 단계에서는 이번 프로젝트의 첫번째 핵심이라고 할 수 있는 텍스트 전처리를 진행하도록 하겠습니다. 전처리 수준에 따라 결과물의 내용이 달라질 수 있기 때문에 많은 심혈을 기울여야 될 것 입니다. 다음 스텝에서 뵙도록 하겠습니다.

 

 

 

Warning) 아래 코드들은 버전이 바뀌어 정상적으로 작동되지 않을 수도 있습니다. 따로 버전을 명시하지 않았기 때문에 에러가 발생되면 조금씩 수정해 나가면서 사용하시길 바랍니다. 또한, 형편없는 저의 실력으로 코드들이 다소 비효율적일 수 있음을 미리 말씀드립니다. 우연히 이 글을 보게 되신 분들은 참고해주시기 바랍니다. 

file-merge.ipynb
0.07MB


STEP 3. 데이터 수집하기

STEP3에서는 이전 단계에서 만든 스크래핑 함수를 사용해 데이터를 수집하도록 하겠습니다. 그리고 파일 형태로 출력한 데이터들을 일부 수정하고 파일들을 합쳐 하나의 데이터 파일로 만들어 보겠습니다. 이번 스텝에서는 인내를 가지고 오랜 시간을 기다리는 것이 관건입니다. 잘 기억은 나지 않지만 저의 경우 한 개 제품에 대해 스크래핑을 진행할 때 8분 정도 소요되었던 것으로 기억합니다. 그래서 8개 제품을 진행했을 때 대략 50분 정도 걸렸다고 할 수 있습니다. 수집하게 되는 샘플 데이터 수가 적은 편이라 일반적인 텍스트 분석 프로젝트에서 데이터를 수집하는 과정에서 소요되는 시간에 비해서는 세발이 피이지만 저한테는 쉽지 않았던 과정이었습니다. 런타임이 갱신되어 도중에 중단된 적도 몇번 있었습니다. 그래서 이후에도 다시 말씀드리겠지만 중간에 발생하는 오류로 데이터를 날리는 일이 없도록 하기 위해 제품별 url을 하나의 리스트로 묶어 반복 실행하지 않고 일부러 따로 따로 독립적으로 수집하였습니다.

(1) 제품별 리뷰 데이터 수집

8개 제품에 대한 네이버 쇼핑 url을 가져와 평점 리스트와 함께 shopping_review_scraping() 함수에 넣었습니다. 그리고 함수의 결과로 반환되는 데이터프레임을 csv 파일로 변환하여 저장하였습니다. 아래는 제품별로 스크래핑을 진행한 코드들입니다. url과 평점 리스트를 제외한 나머지 부분은 같기 때문에 2개의 코드만 가져왔고 이와 같은 방식을 8번 진행했다고 보시면 됩니다. 수집 결과 아래 사진과 같이 총 8개의 csv 파일이 만들어지게 됩니다.  

#풀무원 얇은피 꽉찬속 땡초만두
pulmuone_ddaengcho_url = 'https://search.shopping.naver.com/catalog/21554501906?NaPm=ct%3Dl77a62js%7Cci%3D5a5bdf33fddab8a692dc7dc7ad7b111851a8415a%7Ctr%3Dslsl%7Csn%3D95694%7Chk%3D3b59e90756e0c9de00247557f9d560f3908617a1'
pulmuone_ddaengcho_df = shopping_review_scraping(pulmuone_ddaengcho_url, [1,2,3,4,5])
pulmuone_ddaengcho_df.to_csv('pulmuone_ddaengcho_review.csv', index=False, encoding = 'utf-8-sig')​
#CJ제일제당 비비고 수제김치만두 400g×2
bibigo_url = 'https://search.shopping.naver.com/catalog/24088993894?query=%EB%B9%84%EB%B9%84%EA%B3%A0%20%EA%B9%80%EC%B9%98%EB%A7%8C%EB%91%90&NaPm=ct%3Dl75tunb4%7Cci%3D81e459f36f72ca8812ff811e28306c972e961483%7Ctr%3Dslsl%7Csn%3D95694%7Chk%3D923a154dbf1e412d62c5544e1e9435573a05215a'
bibigo_review_df = shopping_review_scraping(bibigo_url,[3,4,5])
bibigo_review_df.to_csv('bibigo_review.csv', index=False, encoding = 'utf-8-sig')


(2) 중복된 데이터 제거 후 파일 병합

지금까지 수집한 파일들은 output_file 폴더에 저장하였고 총 8개 파일이 있습니다. 파일들이 다 파편적으로 분리가 되어 있으면 텍스트 분석을 진행하는데 있어 번거로움이 발생합니다. 따라서, 파일들을 하나로 병합하도록 하겠습니다. 그리고 파일 병합을 진행하기 전에는 중복된 파일들이 있는지 확인하고 중복된 경우에는 제거하도록 하겠습니다.

사실 정확하게 스크래핑을 진행했다면 중복된 데이터가 없어야 하긴 합니다. 그러나 제가 만든 스크래핑 함수 중간에 페이지 이동이 원할하지 않은 부분이 있어서인지 csv 파일을 확인해본 결과 중복 데이터들이 존재하였습니다. 깔끔하게 수집이 되었으면 하는 아쉬움이 있었지만 여튼 중복을 제거하는 것은 간단한 일이니 함께 진행해보도록 하겠습니다. 

#output_file 폴더에 있는 리뷰 데이터 파일 리스트를 가져오기
path = './output_file'
file_list = os.listdir(path)
print ("file_list: {}".format(file_list))

#새로운 데이터 프레임 생성
df_all_review = pd.DataFrame()

#각 파일을 불러와 읽어낸 후 df_all_review에 결합
for file_name in file_list:
    df_new = pd.read_csv(path + '/' + file_name)
    df_all_review = pd.concat([df_all_review, df_new], ignore_index= True)

8개 파일에 있는 내용들을 새로운 데이터프레임 한 곳에 모은 후에는 중복된 리뷰 데이터들을 제거하겠습니다. 중복이 제거된 데이터프레임은 다시 새로운 파일로 저장하겠습니다. 

#중복된 리뷰 제거
df_all_review.drop_duplicates(subset=None, keep='first', inplace=True, ignore_index=True)
df_all_review = df_all_review.reset_index(drop=True)
df_all_review = df_all_review.rename(columns={'Reveiw':'Review'})

#병합한 데이터프레임을 새로운 파일로 저장
df_all_review.to_csv('./output_file/all_review.csv', index=False)

중복 제거까지 진행한 결과 최종 수집된 데이터는 19445개로 당초 예상했던 2만개에는 조금 못 미치는 정도입니다. 중복된 내용 때문에 계획한 샘플 수를 충족시키지는 못했지만 결과에는 큰 영향을 주지 않을 것 같아 그냥 진행하도록 하겠습니다. 


(3) 쇼핑몰별 리뷰 데이터 수 확인

조금은 쓸데없는 과정이기는 하지만 쇼핑몰별로 수집된 리뷰 데이터의 수를 확인해보도록 하겠습니다. 앞서 프로젝트 계획 단계에서 편향되지 않은 분석 결과를 도출하기 위해 가능한 다양한 쇼핑몰에서의 리뷰를 가져오겠다고 말씀드린 적이 있습니다. 사실 리뷰의 출처가 되는 쇼핑몰이 다양할수록 리뷰 또한 다양하고 편향되지 않는다고 말할 수는 없습니다. 쇼핑몰과 리뷰 내용의 편향성은 상관관계가 높지 않을 것입니다. 하지만 한쪽 쇼핑몰의 리뷰만 가져올 경우에는 분명 해당 쇼핑몰만의 문제들이 분석 결과에 다소간 영향을 끼칠 수도 있는 있을 것입니다. 때문에 제 생각에는 쇼핑몰의 다양성을 유지하는 것이 그리 나쁜 선택은 아닌 것 같습니다. 

#all_review 파일을 불러와 'Mall'에 대해 value_counts()를 적용
df_all_review.to_csv('./output_file/all_review.csv', index=False)
df_mall = pd.DataFrame(df_all_review['Mall'].value_counts())
print(df_mall)

#쇼핑몰별 리뷰 수에 대해 수평 막대 그래프 그리기
df = df_mall.sort_values(by=['Mall'], ascending=True)
df.plot.barh(y = 'Mall',
             figsize=(8, 8),
             xlabel="Mall Name",
             title="Reveiw Counts from Each Mall",
             width=0.8, 
             color=['#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'], 
             legend=False)
plt.tight_layout()
plt.show()

아래 결과를 보면 이건 쫌... 이라는 생각이 드실 것 같습니다. 생각보다 쇼핑몰간 편차가 커 보입니다. SSG닷컴이 거의 전체 샘플 데이터의 절반 이상을 차지하고 있고 11번가까지 정도만 천단위 샘플이 수집되었고 나머지는 거의 미미한 수준입니다. 한편, 쿠팡이나 마켓컬리와 같이 최근 많은 매출을 기록하고 있는 쇼핑 플랫폼의 데이터가 없는 것이 아쉬움이 남습니다. 쿠팡 같은 경우는 네이버 쇼핑과 연동이 되지 않아 따로 수집해야 되는 것으로 보입니다. 여튼 분포 결과가 샘플의 균형을 맞추기에는 다소 부족한 부분이 있지만 실제로 결과물을 가져와 보아야 정확히 알 수 있을 듯 싶습니다. 

다음 단계에서는 이번 프로젝트의 첫번째 핵심이라고 할 수 있는 텍스트 전처리를 진행하도록 하겠습니다. 전처리 수준에 따라 결과물의 내용이 달라질 수 있기 때문에 많은 심혈을 기울여야 될 것 입니다. 다음 스텝에서 뵙도록 하겠습니다. 

Warning) 아래 코드들은 버전이 바뀌어 정상적으로 작동되지 않을 수도 있습니다. 따로 버전을 명시하지 않았기 때문에 에러가 발생되면 조금씩 수정해 나가면서 사용하시길 바랍니다. 또한, 형편없는 저의 실력으로 코드들이 다소 비효율적일 수 있음을 미리 말씀드립니다. 우연히 이 글을 보게 되신 분들은 참고해주시기 바랍니다. 

naver_shopping_review_scraping.ipynb
0.09MB

STEP 2. 네이버 쇼핑 리뷰 데이터 스크래핑 준비

STEP2에서는 분석 대상인 리뷰 데이터를 수집할 수 있도록 웹스크래핑을 준비하겠습니다. 풀무원, CJ 비비고, 동원 F&B, 노브랜드 4개 브랜드의 8개 제품에 대한 리뷰 데이터를 스크래핑하였으며 1점부터 5점까지의 리뷰를 골고루 가져올 수 있도록 하였습니다. 

(1) Selenium 및 웹 드라이버 설치

먼저, 동적 스크래핑을 위해 Selenium을 설치하고 크롬 웹브라우저를 통해 데이터를 수집할 수 있도록 web driver을 설치하겠습니다. 

!pip install Selenium
!apt-get update
!apt install chromium-chromedriver
!cp /usr/lib/chromium-browser/chromedriver /usr/bin

import sys
sys.path.insert(0, '/usr/lib/chromium-browser/chromedriver')

(2) 필요한 라이브러리 import 및 chrome_options 설정

수집한 데이터를 데이터프레임 형식으로 정리하고 이를 csv파일로 저장하기 위해 pandas를 import하고 pandas 외에 추가적으로 필요한 라이브러리들을  import하겠습니다. 그리고 스크래핑 과정에서 불필요한 창 띄우기를 없애기 위해 웹브라우저에 대한 옵션들을 사전에 지정하겠습니다. 

#필요한 라이브러리 import
import time
import pandas as pd
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException, ElementNotInteractableException, NoAlertPresentException, UnexpectedAlertPresentException
from selenium.webdriver.common.by import By
import random

#chrome 웹드라이버 옵션 지정
chrome_options = webdriver.ChromeOptions()

chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')

(3) page_review_scrap(): 해당 페이지 스크랩 함수 

본격적으로 스크래핑을 진행하는 함수를 정의하도록 하겠습니다. 총 2가지 함수를 정의하게 될텐데 첫 번째는 한 페이지에 대한 스크래핑을 진행하는 정적 스크래핑 함수입니다. page_review_scrap()는 한 페이지 내에 있는 20개의 리뷰를 수집하는 함수입니다. 네이버 쇼핑의 경우 아래 사진과 같이 1번, 2번, 3번 등 각 페이지에 20개씩의 리뷰가 있습니다. 따라서, 모든 리뷰를 계속 가져오기 위해서는 페이지를 넘겨가며 해당 페이지에 있는 리뷰를 긁어와야 합니다. 이를 위해 페이지 내 20개의 리뷰를 가져오는  page_review_scrap() 함수와 페이지를 넘겨가며 사전에 정의한 page_review_scrap()을 적용하는 shopping_review_scraping()함수를 각각 따로 정의해주었습니다. 

다시 위 사진을 보게 되면 네이버에서 제공하는 리뷰 데이터에는 평점, 쇼핑몰, 아이디, 날짜, 리뷰 내용, 사진 등의 정보가 담겨 있습니다. 이 중에서 쇼핑몰, 리뷰 내용, 평점 이렇게 3가지 정보만 수집해보도록 하겠습니다. 이후에 진행할 텍스트 분석에서는 리뷰 내용만을 사용하게 되기 때문에 쇼핑몰 정보와 평점 정보는 불필요한 정보이긴 하지만 혹시 모를 상황을 대비해 3가지 정보 모두 수집하였습니다. 

쇼핑몰, 리뷰 내용, 평점에 대한 각각의 XPATH를 확인하여 이에 대한 데이터를 각각의 리스트(mall_list, review_list, rating_list)에 넣어주고 이를 데이터프레임 형식으로 정리하여 최종 20개의 리뷰에 대한 스크래핑 결과를 가져오는 방식으로 진행하였습니다. 

def page_review_scrap(wd):

  try:
    alert = wd.switch_to.alert
    print(alert.text)
    alert.dismiss()
    time.sleep(random.uniform(1, 2))
  
  except:
    pass

  #수집할 데이터를 넣어줄 빈 데이터프레임과 리스트 지정
  review_df = pd.DataFrame()
  mall_list = []
  review_list = []
  rating_list = []
  
  #쇼핑몰, 리뷰 내용, 평점에 대한 각각의 XPATH를 통해 데이터를 수집
  malls = wd.find_elements(By.XPATH, '//*[@id="section_review"]/ul/li/div[1]/span[2]')
  mall_list += [mall.text for mall in malls]
  #print(mall_list)

  reviews = wd.find_elements(By.XPATH, '//*[@id="section_review"]/ul/li/div[2]/div/p')
  review_list += [review.text for review in reviews]
  #print(publication_list)

  ratings = wd.find_elements(By.XPATH, '//*[@id="section_review"]/ul/li/div[1]/span[1]')
  rating_list += [rating.text[-1] for rating in ratings]
  #print(review_list)
  
  #20개 리뷰에 대해 수집한 데이터를 데이터프레임 형식으로 변환
  new_review_df = pd.DataFrame({'Mall': mall_list,
                                'Reveiw': review_list,
                                'Rating' : rating_list})
  review_df = pd.concat([review_df, new_review_df], ignore_index = True)
  return review_df

(4) shopping_review_scraping: 쇼핑몰 리뷰 스크래핑 함수

앞서 정의한 page_review_scrap() 함수가 단일 페이지에 대한 정적 스크래핑 함수였다면 이제 정의하게 되는 shopping_review_scraping() 함수는 페이지를 이동하며 각 페이지에서 page_review_scrap()함수를 적용하는 동적 스크래핑 함수입니다. 이때, 페이지를 이동하기에 앞서 1점부터 5점까지의 리뷰를 골고루 가져오기 위해 각 평점에 대한 페이지로 먼저 이동하도록 하겠습니다. 만약, 각 평점 페이지로 이동하는 것을 따로 정의하지 않게 되면 5점 리뷰에 대해서만 데이터를 수집하게 되기 때문에 추후에 텍스트를 분석할 때 편향된 결과를 얻게 될 수 있습니다. 따라서, 긍정적인 리뷰, 부정적인 리뷰 모두를 분석할 수 있도록 다양한 평점 리뷰를 가져오는 프로세스를 적용해주어야 합니다. 

네이버 쇼핑에서 데이터를 수집할 때 유의해야 할 점 한 가지를 짚고 넘어가겠습니다. 네이버 쇼핑에서는 각 제품에 대한 리뷰를 2000개만 제공하고 있습니다. 따라서, 해당 제품에 대해 리뷰가 2000개 이상이더라도 소비자가 볼 수 있는 리뷰는 2000개로 한정되게 됩니다. 위 사진처럼 전체 리뷰 수가 44,477개이더라도 모든 리뷰를 다 확인할 수는 없다는 것입니다. 스크래핑 과정에서도 이러한 제약이 적용되어 마찬가지로 한 제품 당 2000개의 리뷰만 가져올 수 있게 됩니다. 아마 무분별한 웹크롤링으로 트래픽이 과부화되는 것을 막기 위해서 이러한 제약을 두는 것이 아닐까 생각됩니다. 저도 본격적으로 데이터 수집을 진행하기 전에는 이 부분에 대해 잘 인지하지 못했던 터라 계속 에러가 발생하는 것 때문에 고생하였습니다. 중간 중간 time.sleep()을 랜덤으로 주어가며 수정을 해보기도 했지만 해결이 되지 않다가 결국 수동으로 계속 페이지를 넘어가며 이와 같은 사실을 발견하였습니다. 여튼, 아쉽게도 모든 데이터를 가져오기는 어렵기 때문에 아래 코드에서도 페이지 이동을 100페이지로 제한을 두었습니다. 한 페이지에 20개의 리뷰가 있으니 100페이지면 우리가 가져올 수 있는 2000개 리뷰 제약에 딱 부합하게 됩니다.

def shopping_review_scraping(url, selectable_rate):
  wd = webdriver.Chrome('chromedriver', options = chrome_options)
  wd.implicitly_wait(3)

  # 해당 url로 이동
  wd.get(url)
  time.sleep(random.uniform(1, 2))

  total_review_df = pd.DataFrame()

  selectable_rate = [7-i for i in selectable_rate]
  for rating in selectable_rate:
    rating_xpath = f'//*[@id="section_review"]/div[2]/div[2]/ul/li[{rating}]'
    rating_bt = wd.find_element(By.XPATH, rating_xpath)
    rating_bt.click()
    print('\n****{}점 리뷰페이지로 이동****'.format(7-rating))
    time.sleep(random.uniform(1, 2))

    page_no = 0

    print("[스크래핑 시작]")
    while page_no <= 100:
      try:
        for num in range(1, 12):
          next_xpath = f'//*[@id="section_review"]/div[3]/a[{num}]'
          review_bt = wd.find_element(By.XPATH, next_xpath)
          review_bt.click()
          time.sleep(random.uniform(1, 2))
          page_no += 1
          print('[{}페이지로 이동]'.format(page_no)) #해당 페이지의 리뷰 스크래핑이 완료되면 다음 페이지로 이동
          review_df = page_review_scrap(wd)
          total_review_df = pd.concat([total_review_df,review_df], ignore_index=True)

        while page_no <= 100:
          page_no += 1
          for num in range(3, 13):
            next_xpath = f'//*[@id="section_review"]/div[3]/a[{num}]'
            review_bt = wd.find_element(By.XPATH, next_xpath)
            review_bt.click()
            time.sleep(random.uniform(1, 2))
            page_no += 1
            print('[{}페이지로 이동]'.format(page_no))
            review_df = page_review_scrap(wd)
            total_review_df = pd.concat([total_review_df,review_df], ignore_index=True)

      except ElementNotInteractableException as ex:
        print(ex)
        print("[모든 스크래핑이 완료되었습니다.]")
        break

      except NoSuchElementException as ex:
        review_df = page_review_scrap(wd)
        total_review_df = pd.concat([total_review_df,review_df], ignore_index=True)
        print("[모든 스크래핑이 완료되었습니다.]")
        break
  
  return total_review_df

이상으로 STEP2 리뷰 데이터를 수집하기 위한 사전 준비를 마치도록 하겠습니다. 다음 스텝에서는 지금까지 준비한 함수를 활용해서 4개 브랜드의 8개 제품에 대한 스크래핑을 본격적으로 진행하도록 하겠습니다. 

 

Warning) 아래 코드들은 버전이 바뀌어 정상적으로 작동되지 않을 수도 있습니다. 따로 버전을 명시하지 않았기 때문에 에러가 발생되면 조금씩 수정해 나가면서 사용하시길 바랍니다. 또한, 형편없는 저의 실력으로 코드들이 다소 비효율적일 수 있음을 미리 말씀드립니다. 우연히 이 글을 보게 되신 분들은 참고해주시기 바랍니다. 


STEP 1. 프로젝트 계획

(1) 분석 목적과 목표

평소에 텍스트 분석에 관심이 많아 유튜브 강의나 책을 통해 조금씩 공부를 하고 있습니다. 대학에서는 경영학을 전공하고 있고 특히 서비스, 마케팅과 관련된 수업을 많이 들었던 터라 지금 공부하고 있는 데이터 사이언스가 서비스 기획이나 마케팅에서 어떻게 활용될 수 있을지 항상 고민을 해왔습니다.

텍스트 마이닝(Text Mining) 또는 텍스트 분석(Text Analysis)이 경영과 접점을 이룰 수 있는 부분은 고객들의 데이터를 활용하는 것이지 않을까 생각합니다. 특히, 고객 데이터 중에서 회사 내부 DB에 저장되는 정형화된 데이터(구매 내역, 사이트 활동 시간 등) 외에 소셜이나 웹사이트에서 얻을 수 있는 비정형 텍스트 데이터에 대한 관심이 날로 커져가는 것 같습니다. 저 또한 고객들이 인터넷 상에 남기는 텍스트 데이터에서 어떻게 하면 인사이트를 발견할 수 있을지 많은 관심을 가지고 있습니다.

따라서, 이번 토이 프로젝트에서는 고객 리뷰 데이터를 가지고 텍스트 분석을 진행하여 상품 기획이나 마케팅 전략 수립에 대한 간단한 제언을 하는 연습을 해보았습니다. 이후 설명 과정에서도 말씀 드리겠지만 분석 결과가 그리 유의미하지는 않으며 상식적인 선에서도 충분히 알 수 있는 결과가 도출되었습니다. 때문에 프로젝트 결과물에 대한 기대는 하기 어렵다고 말씀드릴 수 있습니다. 다만, 빈도 분석과 LDA 토픽 모델링을 적용해보았다는 점, 그리고 한글 텍스트 전처리 과정을 직접 경험해보았다는 점에서 개인적인 의의를 두고 있습니다. 

이번에 활용한 텍스트 데이터는 냉동 만두 구매 고객들의 쇼핑몰 리뷰 데이터입니다. 냉동 만두 리뷰를 선택한 것은 별다른 이유는 없고 이 프로젝트를 기획하던 당시 풀무원 만두를 먹고 있었기 때문입니다. 물론 비비고, 풀무원 브랜드의 냉동 만두 사례가 학교 수업 시간에 포지셔닝, 브랜딩 파트에서 많이 활용되는 것을 익히 알고 있기 때문에 이러한 도메인 지식을 활용하면 좋은 분석 결과가 나오지 않을까 하는 기대가 있기도 하였습니다. 

결론적으로 이번 토이 프로젝트의 목표은 냉동 만두 구매 고객들의 리뷰 데이터를 분석하여 쇼핑몰에서 만두를 구매하여 소비하는 고객들의 행동과 상황을 확인하는 것입니다. 그리고 이를 활용해 향후 제품 개발과 마케팅 전략에 어떻게 활용할 수 있을지 살펴보는 것입니다.


(2) 데이터 수집 계획

국내에 정말 많은 온라인 쇼핑몰이 있긴 하지만 데이터 수집을 용이하게 하기 위해서는 몇 개의 쇼핑몰로 제한할 필요가 있습니다. 그리고 충분한 sample 수를 얻기 위해 가능한 큰 규모의 온라인몰에서 데이터를 수집하는 것이 좋습니다. 국내 대형 온라인몰 중에는 쿠팡, SSG, 롯데몰 등 대형 유통회사들이 꽤 많지만 저는 네이버쇼핑에서 리뷰 데이터를 수집하였습니다.  한 쇼핑몰의 데이터만 가져오기보다는 여러 쇼핑몰의 데이터를 수집하는 것이 더 객관적인 sampling이 될 수 있을 거라 생각되었기 때문입니다. 하지만, 막상 데이터를 수집한 결과 몇몇 온라인 몰에 집중되어 있어 네이버 쇼핑 데이터를 통해 수집한 의미가 퇴색되긴 하였습니다.  이에 대해서는 추후에 다시 말씀드리도록 하겠습니다.

텍스트 분석을 위해 어느정도의 데이터가 있으면 좋은 지에 대해서는 기준은 없습니다. 많은 데이터가 있으면 그만큼 좋은 결과를 얻을 수 있긴 하지만 수집과 전처리 과정에서 그만큼 많은 시간이 소요됩니다. 사실 제가 많이 고민되었던 부분은 얼만큼의 sample을 수집할지였습니다. 일반적으로 토픽 모델링에 많이 활용되는 뉴스 텍스트는 다양한 토픽들과 다채로운 단어들이 사용되기에 분석 결과의 객관성(?)을 확보하기에 유리합니다. 하지만 리뷰 데이터의 경우 그 길이가 매우 짧고 내용이 한정되어 있어 적은 데이터 sample로는 편향된 결과가 도출되지 않을까 하는 걱정이 되었습니다. 가령, 리뷰 데이터에는 '정말 좋습니다^^', '잘 쓸게요~' 등 단순한 표현들이 많고 리뷰들간 중복되는 것들도 정말 많습니다. 그래서 충분히 많은 데이터를 수집하고자 하는 욕심이 있었습니다. 실제로 리뷰에 대해 토픽 모델링을 시행한 몇몇 사례들을 살펴보니 보통 10만개 이상의 데이터를 수집하는 경우가 많았습니다. 그러나 너무 많은 데이터를 수집하기에는 중간 전처리에 대한 부담이 있어 2만개 데이터를 수집하는 것으로 타협하였습니다.


(3) 분석 방법 선정

3-1) 빈도 분석을 통한 WordCloud 생성

전처리 과정을 거친 텍스트 데이터에 대해서 단어별 빈도를 확인하고 WordCloud를 생성하도록 하겠습니다. WordCloud를 통해 전체 리뷰 데이터에서 많이 언급된 단어들을 확인하고 냉동 만두 구매 과정에서 생기는 다양한 생각과 경험들을 한눈에 살펴보도록 하겠습니다.

3-2) LDA 토픽 모델링으로 주요 토픽과 핵심 단어 추출

텍스트 데이터에 LDA 토픽 모델링을 적용하여 전체 리뷰 텍스트의 주요 토픽들을 도출하겠습니다. 그리고 각 토픽별 핵심 단어들을 추출하여 앞서 WordCloud를 통해 얻어낸 인사이트를 조금 더 구체화하여 보겠습니다. WordCloud도 마찬가지이지만 토픽 모델링의 경우 분석된 결과에 대해 추가적인 해석의 과정이 필요합니다. 이는 분석하는 사람의 몫이며 도메인 지식과 개인의 분석 역량에 따라 다양하고 주관적인 해석 결과가 나올 수 있습니다. 불행 중 다행인지는 모르겠지만 이번에 도출된 결과물의 경우에는 도메인 지식 없이도 누구나 충분히 해석이 가능한 것으로 보입니다. 


(4) 프로젝트 플로우 차트 (Flow Chart)

+ Recent posts