▣ 프로젝트 개요 및 역할

 본 프로젝트의 목표는 식중독 발생 확률을 예측하는 모델을 구축하여, 일반 시민과 정부 부처가 식중독을 사전에 예방할 수 있도록 지원하는 것이다.
 필자는 데이터 분석 및 모델링을 담당하였으며, 주요 역할은 다음과 같다:
1) 데이터 수집 및 전처리: 식품의약품안전처의 Open API 데이터를 활용하여 식중독 현황을 분석하고, 관련 변수를 정리하였다.
2) 가설 설정: 식중독 발생에 지역축제, 사회 인프라가 영향을 미칠 것이라는 가설을 설정하였다. 관련 요인들을 정의하고, 이에 대한 데이터를 수집하여 모델링에 활용하였다.
3. 모델링 및 성과 평가: 여러 분류 모델을 비교하고, Random Forest 모델을 통해 70%대 중후반의 성능을 기록하여 식중독 발생 예측의 정확성을 높였다.
 결과적으로, 지역별 식중독 발생 위험을 예측할 수 있는 모델을 성공적으로 구축하였으며, 이를 통해 맞춤형 예방 대책의 필요성이 제기되었다. 향후 통합 데이터와 일별 예측 모델을 통해 더욱 정교한 분석이 가능할 것으로 기대된다.

A. 배경

 식중독(food poision)이란 식품위생법 제2조제14항에 따라 식품의 섭취로 인하여 인체에 유해한 미생물 또는 유독 물질에 의하여 발생하였거나 발생한 것으로 판단되는 감영성 또는 독소형 질환을 말한다. 일반적으로 구토, 설사, 발열 등의 증상을 나타내며 원인물질에 따라 잠복기와 증상의 정도가 다르게 나타난다. 비교적 가벼운 증상으로 나타지만, 면연력이 약한 어린이, 노인, 인산부의 경우에는 생명의 위협을 받을 수 있다. 2022년 6월 식품의약품안전처에서 발표한 보도자료에 따르면 2016년부터 2018년까지 식중독으로 인한 연간 손실비용이 연간 1조 8,532억원에 달하며 개인 손실비용이 1조 6,418억원(88.6%)을 차지한다고 밝혔다. 개인의 신체적 고통과 개인 비용 뿐만 아니라 사회적 비용을 줄일 필요성이 존재한다. 이에 이번 프로젝트에서는 기후, 인구밀도 등 다양한 외부 변수를 기반으로 지역별 식중독 발생확률을 예측하는 모델을 구축하고 일반시민 또는 관련 정부부처에서 식중독을 사전에 예방하는데 도움이 되고자 식중독 발생 예측 지도를 생성하고자 한다.

B. 데이터 준비

 식품의약처안전처에서 제공하는 Open API 형식의 '지역별','원인물질별' 식중독 현황 데이터를 사용하였다. 지역별 데이터와 원인물질별 데이터를 결합할 수 있는 PK가 없어 정보공개청구 창구를 사용하여 지역별, 원인물질별, 원인시설별 식중독 발생 현황에 대한 자료를 요청하였지만, 식약처의 국정감사기간과 겹치며 기존 자료 수령일보다 10일 연기되어 자료 수령이 제 시간내에 불가능하게 되었다. 따라서 Open API 데이터를 각각 활용하여 2개의 테이블에 대한 데이터 전처리, 모델링 등이 이루어졌다.

식중독 지역별 현황 식중독 원인물질별 현황

 

 OCCRNC_VIRS에서 원인물질은 총 15개로 집계된다. 이 중 의미를 부여할 수 없는 '원인 불명을 비롯하여 발생 비율이 1% 미만인 원인 물질들을 제외하고 9가지 주요 원인 물질을 선택하여 진행하였다.

범례 원인물질별 식중독 발생건수 비율 최종 원인물질별 식중독 발생건수

 

 식중독균별 특성을 이해하고, 그에 따른 발생 확률을 증가시키는 요인에 대해 크게 3가지로 나누어 각 가설을 세워 추가 변수들에 대한 데이터를 수집하였다. 가설과 데이터는 다음과 같다.

요인 가설 관련 데이터
원인물질 발현 증가 고온 다습해질 수록 박테리아성 식중독 발생율은 증가할 것이다. 기후(온도, 습도, 해면 기압 등) 데이터
노출빈도 증가 인구 밀도가 높을 수록 식중독 발생율은 증가할 것이다. 인구수 데이터, 집단급식소 데이터
축제 등 이벤트 발생이 많으면 식중독 발생율은 증가할 것이다. 지역 축제 데이터, 황금연휴 데이터
예방/관리 소홀 사회 인프라가 잘 갖춰지지 않을 수록 식중독 발생율이 증가할 것이다. 소비자물가지수(CPI) 데이터

 

추가 변수들을 생성할 수 있는 데이터들을 '년', '월' , '지역' 등에 따라 각 데이터 테이블을 생성하였다. 

지역별 데이터 원인물질별 데이터


 

 다층분류 분석을 하고자 하였으나, 유의미한 결과가 나타나지 않아 발생 유무를 예측하는 방법으로 프로젝트 방향을 잡고, 이진 변수로 사용할 수 있는 칼럼들은 이진 변수로 사용하였다. 또한, 통계청의 데이터 이슈로 인해 추가 변수들의 데이터를 2022년까지 구할 수 있어 2002년부터 2022년까지 20년간의 데이터를 사용하였다. 세종특별자치시의 경우, 2012년 7월 충청도에서 출범한 지역이다 보니 다른 지역과 다르게 각 부처마다 데이터 적재시점이 다르다. 이에 따라 세종의 날씨 데이터와 CPI 데이터는 정합성과 적합성 평가를 통해 인근지역인 충청북도의 데이터를 이용해 선형회귀 모델로 보완하였다. 

 

C. 데이터 분포 및 기본 특성 

 아래 그래프에서 볼 수 있듯 지역별, 원인물질별 데이터 불균형이 강하게 나타났다. 모델링 시 이를 고려하여, 오버샘플링(Oversampling) 혹은 SMOTE 기법을 사용할 수 있을 것이다. 

지역별 히스토그램 원인물질별 히스토그램

 

 식중독 발생에 대한 기본적인 통계를 살펴보면 다음과 같은 결과를 알 수 있다. 2002년부터 2022년까지 연도별 식중독 발생건수 및 환자 수를 살펴보면 특정 년도에 총환자수가 급증하는 것을 볼 수 있는데, 발생 건수 또한 급증한 것은 아니기에 대량 감염 사건이 발생했을 것이라 예상할 수 있다.

연도별 식중독 발생건수로 2018년 급증하는 환자수와 2020년 급락하는 환자수를 볼 수 있다.

 

 예상과 같이 2018년 학교 급식에서 초코케이크 살모넬라 식중독으로 인해 약 2,200여명의 대규모 환자가 발생한 사건이 있었다. 또한, 2020년에는 COVID-19로 인한 보건정책 및 위생관리 강화로 환자수가 전년대비 36.88%나 감소하는 결과를 보인다.

연도별 식중독 발생건수 대비 환자 수 연도별 원인물질별 식중독 발생건수 대비 환자 수
 연도별 지역별 발생 건수 변화  원인물질별 발생건수 대비 환자 수 비율

월별 원인물질별 총 발생 건수

 

 2000년대 초반 이후 식중독 발생건수가 점점 줄어 들며, 전국적으로 발생 건수가 적었다. 2018년 일명 초코 케이크 사건이라 불리는 식중독 집단 감염 사건의 경우, 전국적으로 살모넬라균으로 인해 발생하였기 때문에 해당 시기에 급증한 그래프를 확인할 수 있다. 월별 식중독균별 발생건수를 살펴보면, 노로바이러스는 겨울철 정점에 도달하였다 점점 감소하여 여름에 최저 발생 건수를 기록하는 양상을 보인다. 반면, 병원성 대장균, 살모넬라, 그리고 캠필로박터제주니는 봄부터 증가하여 여름철에 최다 발생률을 보인다. 각 원인물질별에 따라 계절성이 나타난다는 것으로 해석할 수 있다. 지역별로 살펴보았을 경우, 서울-경기권이 전체 식중독 발생 건수의 35% 가량을 차지하였으며, 그 이후로는 부산, 경남, 강원, 인천 등의 순으로 나타났다. 서울-경기권이 타 지역에 비해 발생건수가 지속적으로 높게 나타나는 것을 알 수 있다.

 

D. 식중독 발생 확률 예측 모델링

분류 예측 모델링 워크 플로우

 

 PyCaret을 활용하여 다양한 분류모델의 기본 성능을 평가하였다. 초기 모델 성능을 확인하고, 성능 개선을 목표로 4개의 후보 모델을 선정하였다. 지역별, 원인물질별 초기 좋은 성능을 보인 모델은 Random Forest, Gradient Boosting, LightGBM, XGBoost 이었다. 이 모델들은 기본적으로 성능 지표가 높게 나오기도 하였으나 비선형 패턴을 학습하는 능력, 변수 중요도 파악, 그리고 과적합을 효과적으로 제어할 수 있다. 

 본 프로젝트의 특이점은 일반적인 모델 성능평가 지표인 Accuracy 혹은 F1-Score보다 Recall(재현율)을 우선시하여 모델 성능을 평가하였다. Recall은 실제 양성 사례를 놓지지 않고 정확히 예측하는 능력을 나타내는 지표로, 특히 헬스케어 분야, 바이오 분야에서는 질병 진단이나 이상 징후를 조기에 감지하는데 중요한 역할을 한다. 우리 프로젝트의 주요 목적은 식중독 발생 가능성을 정확히 예측하여 사전에 예방 조치를 취할 수 있도록 하는 것이다. Recall 지표는 발생하지 않을 확률보다 발생할 확률을 잘 예측함으로 모델 성능을 평가하는데 적합하다고 판단할 수 있다.

 

 먼저 선형회귀 모델 가능 여부 판단을 위해 선형회귀 모형의 기본가정인 선형성, 정규성, 등분산성, 독립성을 검토해본 결과, 사피로 검정에서 p-value 값이 0.05보다 작아서 정규성을 충족하지 않는 것으로 나타났으며, 다른 항목에서도 완전한 충족이 이루어지지 않아 비선형회귀모형, 시계열 모형, 분류 모형 등 다양한 모형을 시도하였다.

선형회귀모델 가능여부 검토

MinMax Scaler를 이용한 표준화 및 종속변수 BoxCox 변환 Random OverSampling 적용

 

 회귀모형과 분류모형은 각 발생 건수와 식중독 발생여부를 종속변수로 모델링하였다. 처음 Train Data, Test Data로 분리시키고, MinMax Scaler와 BoxCox 변환을 통해 이상치의 영향은 줄이고 변수들 간의 비선형성을 개선하고자 하였다. 또한, 분류모델에서는 발생 여부를 나타내는 0과 1의 불균형으로 인해 모델이 하나의 결과에 편향되는 것을 방지하고자 SMOTE, Borderline-SMOTE 등 다양한 OverSampling 기법을 이용해 보았으나 Random OverSampling의 결과가 가장 좋았다. 이 후 GridSearchCV를 통해 모델별 validation data 기준 성능이 가장 우수한 하이퍼 파라미터를 선택하여 모델링을 실시하였다. 모델의 결과는 아래와 같다.

각 분석별 모델를 가장 설명하는 기법 선택

 

 보시다시피 회귀모형, 시계열 모형, 딥러닝까지 다양한 모델들의 성능 평가 결과를 살펴보았을 때 분류모형을 제외한 다른 모형들 중 다중회귀 모형이 가장 좋은 성능을 보였다. 하지만 이 경우 최대 R2 score는 약 0.4로 전반적인 모델 성능이 모두 낮았다. 반면, 분류모형 중 가장 좋은 성능을 보인 Random Forest 분류모형은 최대 Accuracy 0.78로 양호한 성능을 보였다. 분류모형의 경우 기본 모형과 하이퍼 파라미터 튜닝모형의 성능 차이가 크지 않아 하이퍼 파라미터 튜닝 대신 데이터 분할 후 개별 모델링을 검토하였다.

 

지역별 식중독 발생 예측 모델링 결과
원인물질별 식중독 발생 예측 모델링 결과

 

 각 지역별로 예측모델링을 구축하였고, Oversampling, Feature Engineering, Hyperparameter tuning을 통해 70%대 중후반에서 90%대까지 성능이 제법 향상된 것을 확인할 수 있다. 다만, 광주, 세종과 같은 일부 지역은 데이터의 불균형이 너무 심하기 때문에 Oversampling을 하고 tuning을 해줘도 성능 향상에 한계가 있다.

변수 미래값 예측 모델 (Prophet 모델)

 

 지역별 모델링과 동일하게 원인물질별  및 변수별로도 예측모델 구축을 완료하였다. 특히 변수의 미래 예측값은 모두 시계열 모델인 Prophet으로 모델링하였다. 시계열모델인 만큼 '유치원생의 비율', '소비자물가지수'와 같이 변동값이 크지 않은 변수들과 '평균온도', ' 해상기압', '습도'와 같이 계절성을 나타내는 변수의 R2 score 값이 대부분 1에 가깝게 몰려 있음이 확인된다. 그러나 황금연휴와 같이 특별한 주기성을 띄지 않는 변수들은 낮은 R2 score 값에 분포되어 있는 것으로 확인되었다. 해당 값들은 feature importance에서 비교적 낮은 중요도로 측정되는 변수로 모델 성능 저하에 지대한 영향을 미칠 것이라 확언하기 어렵다.

2023년 식중독 발생 예측 흐름

 

 앞서 구축한 모델을 바탕으로 2023년 식중독 발생 확률을 예측하였다. 시계열 모델을 활용하여 2023년 1월부터 12월까지의 변수들의 값을 예측하여 생성하였다. 변수 예측값을 각각 지역별 모델과 물질별 모델에 입력하여 지역별 및 원인물질별 식중독 발생 예측 결과를 얻었다. 이미 2023년의 식중독 발생 기록을 보유하고 있기 때문에 예측 결과와 실제 발생 기록을 비교할 수 있다. 

예측값 ROC Curve

 통합된 예측값에 대한 ROC Curve를 도출한 결과 지역별 예측 모델의 Recall 값은 0.71, AUC는 0.72 값이 산출되었다. 원인물질별 예측 모델의 경우 Recall 값은 반올림하여 0.77, AUC 값은 0.74로 기록된다. AUC값은 실제 양성과 실제 음성을 잘 구별할 수 있다는 의미로, 모델이 얼마나 잘 예측하였는지를 나타내는 지표이다.

 Open API에서 얻은 데이터 자체가 발생 식중독 건수를 월 단위로 집계하여 제공하고 있어 데이터의 n 수가 적고, 정확한 분석에 한계가 있다. 해당 상황에서 나름 준수한 성능을 보였다고 판단된다.

 

D. 인사이트

변수 중요도를 활용한 원인물질별 발생 위험 지역

 

 분석 모형에서 추출한 모형별 변수 중요도(feature importance)를 이용하여 원인물질별 발생 위험 지역을 식별하였다. 모형별 상위 5대 중요변수가 유사한 지역과 원인물질을 서로 결합한 결과 부산은 병원성 대장균, 경상남도는 황색포도상구균과 유사성을 보였다. 해당 지역은 유사성을 보인 원인물질에 따라서 맞춤형 예방대책이 필요할 것이라고 진단할 수 있다. 다만, 이 시점에서 해당 모델이 지역, 시설, 원인물질 정보가 통합되어 있는 데이터셋을 기반으로 만들어진 것이 아니고, n수가 적다는 사실을 다시금 상기시킬 필요가 있다. 향후 통합데이터, 더 많은 정보를 담고 있는 데이터셋을 이용한다면 일별 데이터 예측하고, 추가 별수들의 일별 데이터를 적용하여 더욱 정교한 모델이 만들어 것이라 기대한다. 아울러 지금처럼 지역과 원인물질별 식중독 발생 예측 결과를 별개의 분석결과로 보는 것이 아닌, 서로 연동되어 '특정 월 또는 특정 환경에서는 어느 지역에서 어떤 원인물질의 발생확률이 증가한다.'와 같이 더욱 깊이 있는 예측 결과를 도출할 수 있을 것이다. 더 나아가 '그 지역에서 어떤 방법을 취해야한다.'와 같은 추가적인 인사이트를 유저들에게 제공할 수 있으리라 기대된다.

E. 대시보드 제작

 이와 관련한 예측지도 대시보드를 제작하였다. 일반 국민, 보건 의료 실무자 및 관리자, 보건의료 정책 입안 담당자를 대상으로 현황 모니터링, 시뮬레이션 분석, 향후 12개월 예측, 총 3가지의 기능을 넣었다. 효과적인 정보전달을 위해 식중독 지도, 원인물질별 식중독 막대그래프, SHAP을 활용한 식중독 주요 요인 그래프, 식중독 추이 및 예측값 비교 선그래프 등을 사용하였으며 간결하고 명확한 정보 제공에 주안점을 두었다.

대시보드 예시 페이지

 

A. 목적

택시기사들에게 전달할 파일로, 코로나와 같은 팬데믹 상황이 왔을 때 대비할 수 있게 정보를 전달하고자 한다
상세: 2019년부터 2024년까지 매년 12월 데이터를 통해 뉴욕 옐로우 택시 이용 패턴을 확인하여 택시 기사들의 수익 극대화를 도모한다.

 

B. 데이터 선택

뉴욕은 미국의 최대 도시이자 가장 유명한 관광지이다. 뉴욕의 슬로건이 ‘The City that Never Sleeps’ 절대 잠들지 않는 도시인 것만 보아도 한눈에 알 수 있다. 어떤 계절이건 늘 관광객으로 가득하다. 특히 12월의 뉴욕은 크리스마스를 보내는 사람들, 새해 카운트를 세고자 하는 사람들로 가득찬다. 특정 기간, 시간에 많은 사람들이 모이는 곳에서의 택시 운행 행태를 파악하기 위하여 2019년부터 2023년까지 5개년치의 12월 데이터를 선택하였다.

세부사항: NYC 에서 제공하는 택세 데이터 중 예약없이 길거리에서 잡아 탑승할 수 있는 옐로우 택시 데이터를 기준으로 분석하고자 한다

 

C. 데이터 전처리

기초 통계량 및 각 데이터 별 수치를 살펴보고 이상치, 결측치 값 처리 방법을 정하였다.

기준: 각 년도 12월 데이터가 아닌 값, 승객 및 여행 정보에서 딕셔너리에 기재되어 있지 않은 값 처리, 결제 및 요금의 음수값 처리 및 이에 따른 총 결제액 재계산. 등등

 

1. 1차 전처리 코드

import pandas as pd

# 파일 경로 리스트
file_paths = [ 
    'yellow_tripdata_2019-12.parquet',
    'yellow_tripdata_2020-12.parquet',
    'yellow_tripdata_2021-12.parquet',
    'yellow_tripdata_2022-12.parquet',
    'yellow_tripdata_2023-12.parquet'
]

# 파일명에 해당하는 연도 리스트
years = [2019, 2020, 2021, 2022, 2023]

# 전처리 함수 정의
def preprocess_and_save(df, year):
    # 2023년 파일일 경우 'Airport_fee' 컬럼명을 'airport_fee'로 변경
    if year == 2023:
        df.rename(columns={'Airport_fee': 'airport_fee'}, inplace=True)

    # 컬럼 타입 변경
    df['tpep_pickup_datetime'] = pd.to_datetime(df['tpep_pickup_datetime'])
    df['tpep_dropoff_datetime'] = pd.to_datetime(df['tpep_dropoff_datetime'])

    # 연도 필터링: tpep_pickup_datetime과 tpep_dropoff_datetime 모두 해당 연도에 맞는 데이터만 남기기
    df = df[(df['tpep_pickup_datetime'].dt.year == year) & (df['tpep_dropoff_datetime'].dt.year == year)]

    # 결측치 처리
    df = df.dropna(subset=['passenger_count', 'RatecodeID', 'store_and_fwd_flag', 'congestion_surcharge'])
    df = df[df['payment_type'] != 0]

    # 승객 및 여행 정보 이상치 전처리
    df = df[df['VendorID'] != 5]
    df = df[df['tpep_pickup_datetime'].dt.month == 12]
    df = df[df['tpep_dropoff_datetime'].dt.month == 12]
    df['passenger_count'] = df['passenger_count'].apply(lambda x: 4 if x >= 5 else x)
    df = df[df['trip_distance'] >= 0]

    # 결제 및 요금 정보 전처리
    df = df[df['payment_type'].isin([1, 2])]
    df = df[df['total_amount'] > 0]
    df['mta_tax'] = 0.5
    df['improvement_surcharge'] = 0.3
    df['congestion_surcharge'] = df['congestion_surcharge'].apply(lambda x: 2.5 if x == -2.5 else x)
    df = df[df['fare_amount'] > 0]
    df['extra'] = df['extra'].abs()

    # 승객수 0명인 경우 평균값 2명으로 대치
    df['passenger_count'] = df['passenger_count'].replace(0, 2)

    # RatecodeID 값이 6 이하인 것만 유지
    df = df[df['RatecodeID'] <= 6]

    # tip_amount: 0 이상인 값만 유지
    df = df[df['tip_amount'] >= 0]

    # tolls_amount: 0 이상인 값만 유지
    df = df[df['tolls_amount'] >= 0]

    # total_amount 값 변환
    df['total_amount'] = df['fare_amount'] + df['extra'] + df['mta_tax'] + df['improvement_surcharge'] + df['tolls_amount'] + df['congestion_surcharge'] + df['airport_fee'].fillna(0)

    # CSV 파일로 저장
    csv_file_name = f'D:/Bootcamp/3rd_project/yellow_taxi_data_{year}.csv'
    df.to_csv(csv_file_name, index=False)

    # 저장 완료 메시지 출력
    print(f"CSV 파일 저장 완료: '{csv_file_name}'")

# 모든 파일에 대해 전처리 및 저장 실행
for file_path, year in zip(file_paths, years):
    df = pd.read_parquet(file_path)
    preprocess_and_save(df, year)

 

2. 문제 사항 발견

1) trip distance 값과 total amount 값에 처리되지 않은 이상치 발견

실제값에 매우 큰 값들이 있고, 데이터는 제대로 예측하지 못함.

 

2) Trip distance 값, Total amount 값에서 극단값 발견

위치 정보 전처리 방법 가장 멀리 있는 레코드 두개의 거리 측정 후 그 거리 이상의 값 삭제 처

해당 방법으로 삭제 처리하기 위하여 NYC에서 제공하는 Yellow Taxi Zone 데이터 활용

nyc_taxi_zones.json
3.63MB
taxi_zones.csv
3.58MB

 

 

 

3) 2차 전처리 코드

import pandas as pd
from shapely import wkt
from shapely.geometry import MultiPolygon

# 데이터 읽기
map_ny2 = pd.read_csv('taxi_zones.csv')

# MULTIPOLYGON에서 경도와 위도를 추출하는 함수
def extract_centroid(geom):
    try:
        # MULTIPOLYGON 형식의 문자열을 Shapely 객체로 변환
        polygon = wkt.loads(geom)
        
        # 다각형의 중심점(centroid) 계산
        centroid = polygon.centroid
        return centroid.y, centroid.x  # 위도, 경도 반환
    except Exception as e:
        return None, None  # 에러 시 None 반환

# 위도와 경도를 추출하여 새로운 열에 추가
map_ny2['Latitude'], map_ny2['Longitude'] = zip(*map_ny2['the_geom'].apply(extract_centroid))

# 경도와 위도가 있는 행만 필터링
map_ny2 = map_ny2.dropna(subset=['Latitude', 'Longitude'])

# 결과 CSV 저장
map_ny2[['LocationID', 'Latitude', 'Longitude']].to_csv('taxi_zones_latlong.csv', index=False)

print("변환이 완료되었습니다. 결과는 taxi_zones_latlong.csv에 저장되었습니다.")

taxi_zones_latlong.csv
0.01MB

 

 

 

4) 지리적 극단값 처리 코드

기존 데이터와 zone의 위도,경도 데이터를 병합한 후 승차 위치를 기준으로 지리적 거리를 측정하였다.

# LocationID와 위도/경도 정보가 들어있는 location_df 로드
location_df = pd.read_csv('taxi_zones_latlong.csv')  # LocationID, Latitude, Longitude가 포함된 데이터

# PULocationID를 기준으로 위도와 경도를 병합 (픽업 지점)
df = df.merge(location_df[['LocationID', 'Latitude', 'Longitude']], left_on='PULocationID', right_on='LocationID', how='left')
df.rename(columns={'Latitude': 'PULatitude', 'Longitude': 'PULongitude'}, inplace=True)
df.drop(columns=['LocationID'], inplace=True)

# DOLocationID를 기준으로 위도와 경도를 병합 (드롭오프 지점)
df = df.merge(location_df[['LocationID', 'Latitude', 'Longitude']], left_on='DOLocationID', right_on='LocationID', how='left')
df.rename(columns={'Latitude': 'DOLatitude', 'Longitude': 'DOLongitude'}, inplace=True)
df.drop(columns=['LocationID'], inplace=True)

# 병합 후 NaN 값이 있는지 확인
print(df.isnull().sum())

# NaN 값이 있는지 확인하고 제거
df_cleaned = df.dropna(subset=['PULatitude', 'PULongitude', 'DOLatitude', 'DOLongitude'])

from geopy.distance import geodesic

# 지리적 거리를 계산하는 함수 정의 (마일 단위로 계산)
def calculate_distance_miles(pickup_coords, dropoff_coords):
    return geodesic(pickup_coords, dropoff_coords).miles  # 마일 단위로 계산

# 픽업과 드롭오프 지점의 좌표로부터 거리 계산
df_cleaned['distance_miles'] = df_cleaned.apply(
    lambda row: calculate_distance_miles((row['PULatitude'], row['PULongitude']),
                                         (row['DOLatitude'], row['DOLongitude'])), axis=1)

# 가장 멀리 떨어진 두 지점 찾기
max_distance_record = df_cleaned.loc[df_cleaned['distance_miles'].idxmax()]
max_distance = max_distance_record['distance_miles']

print(f"가장 멀리 있는 두 지점 사이의 거리: {max_distance} miles")


# 그 거리 이상인 레코드를 필터링해서 제거
df_filtered = df_cleaned[df_cleaned['distance_miles'] <= max_distance]

print(f"필터링 후 남은 레코드 수: {len(df_filtered)}")
df_filtered.to_csv("df_filtered_distance.csv")

 

그러나 1시간이 지나도 해당 결과를 보지 못하였고, 반복해서 MemoryError가 발생하였다.

이에 맨해튼 거리방법으로 해당 극단값을 해결해보고자 하였다.

 

5) 맨해튼 거리

# 맨해튼 거리 계산
df['manhattan_distance'] = abs(df['Latitude_pickup'] - df['Latitude_dropoff']) + abs(df['Longitude_pickup'] - df['Longitude_dropoff'])

# 최대 거리 설정
max_distance = df['manhattan_distance'].max() 

# 맨해튼 거리 이상인 레코드를 필터링하여 제거
df_filtered = df[df['manhattan_distance'] <= max_distance]
df = df_filtered.copy()

 

그러나 해당 방법으로 값을 제거하여도 여전히 극단값이 존재하였다.

이에 10만 마일 이상인 값을 제거하는 방법으로 진행하였다.

10만 마일 미만의 값 중 최대값, 최소값

6) total amount 극단값 처리

해당 total amount 열의 극단값은 가능할 수 있는 값이라 보고 남겨두기로 했다.

이제와서 생각해보면 이 값도 삭제했어야 했다.

 

7) 택시 구역의 위도, 경도가 포함되지 않은 LocationID를 파악한 후 삭제 처리

# 택시 구역의 위도, 경도 및 LocationID가 포함된 CSV 파일 로드
taxi_zones_df = pd.read_csv('taxi_zones_latlong.csv')

# taxi_zones_df의 Latitude, Longitude 복제
taxi_zones_df['Latitude_copy'] = taxi_zones_df['Latitude']
taxi_zones_df['Longitude_copy'] = taxi_zones_df['Longitude']

# pickup location
df = df.merge(
    taxi_zones_df[['LocationID', 'Latitude_copy', 'Longitude_copy']]
    .rename(columns={'Latitude_copy': 'pickup_latitude', 'Longitude_copy': 'pickup_longitude'}), 
    how='left', 
    left_on='PULocationID', 
    right_on='LocationID'
)

# dropoff location
df = df.merge(
    taxi_zones_df[['LocationID', 'Latitude_copy', 'Longitude_copy']]
    .rename(columns={'Latitude_copy': 'dropoff_latitude', 'Longitude_copy': 'dropoff_longitude'}), 
    how='left', 
    left_on='DOLocationID', 
    right_on='LocationID'
)

df = df.merge(
    taxi_zones_df[['LocationID', 'Latitude', 'Longitude']], 
    how='left', 
    left_on='DOLocationID', 
    right_on='LocationID'
)

df = df.drop(columns=['LocationID_x', 'LocationID_y'])
print(df.columns)

 

결측치 확인

8) 결측치의 LocationID 확인

# PULocationID가 location_df에 없는 경우 확인
pulocation_missing = df[~df['PULocationID'].isin(location_df['LocationID'])]

# DOLocationID가 location_df에 없는 경우 확인
dolocation_missing = df[~df['DOLocationID'].isin(location_df['LocationID'])]

# 결과 출력
print(f"LocationID가 없는 PULocationID 레코드 수: {len(pulocation_missing)}")
print(f"LocationID가 없는 DOLocationID 레코드 수: {len(dolocation_missing)}")

# PULocationID가 location_df에 없는 고유 값 확인
missing_pulocation_ids = pulocation_missing['PULocationID'].unique()
print(f"LocationID가 없는 PULocationID 목록: {missing_pulocation_ids}")

# DOLocationID가 location_df에 없는 고유 값 확인
missing_dolocation_ids = dolocation_missing['DOLocationID'].unique()
print(f"LocationID가 없는 DOLocationID 목록: {missing_dolocation_ids}")

# 제외할 LocationID 목록
exclude_locations = [264, 265, 57, 105]

# PULocationID와 DOLocationID에서 제외하는 필터링 조건 적용
df = df[~df['PULocationID'].isin(exclude_locations) & ~df['DOLocationID'].isin(exclude_locations)]

 

 

LocationID를 살펴보고 승차 및 하차 위치 코드에 없는 번호를 찾아 삭제 처리하였다.

 

C. 분석 모형

- 목적: 택시기사에게 최대 수익을 가져다 줄 수 있는 승객의 탑승 위치를 예측하고자 한다.

- 기본 형식 지정

df = pd.read_csv('yellow_taxi_data_mega_df.csv')

# 컬럼 타입 변경
df['tpep_pickup_datetime'] = pd.to_datetime(df['tpep_pickup_datetime'])
df['tpep_dropoff_datetime'] = pd.to_datetime(df['tpep_dropoff_datetime'])

# 'pickup_day' 열을 새로 생성 (요일 정보 추출)
df['pickup_day'] = df['tpep_pickup_datetime'].dt.dayofweek  # 0: 월요일, 6: 일요일
df['hour'] = df['tpep_pickup_datetime'].dt.hour

 

1. KMeans를 활용한 분석

- 모델 사용 이유: 승차 위치 그룹화를 통해 효율적인 운영 전략을 도출하기 위해  최적의 승차 위차를 찾고자 함.(위치 기반 시스템을 개선하고자 함)

1) KMeans 코드

# 위도와 경도를 이용한 클러스터링
locations = df[['Latitude', 'Longitude']].dropna().drop_duplicates()

# KMeans 클러스터링 적용
kmeans = KMeans(n_clusters=10, random_state=42).fit(locations)

# 클러스터링 대상이 된 데이터프레임의 인덱스 추출 (dropna를 고려한)
valid_indices = df[['Latitude', 'Longitude']].dropna().index

# 각 승차 위치에 클러스터 할당 (dropna된 행에 대해 클러스터 예측)
df.loc[valid_indices, 'cluster'] = kmeans.predict(df.loc[valid_indices, ['Latitude', 'Longitude']])

# 특정 시간대와 요일에 대해 클러스터별 평균 수익 계산
filtered_data = df[(df['hour'] == hour_input) & (df['pickup_day'] == day_input)]

# NaN 값을 제거한 후, 클러스터별 평균 수익을 계산
cluster_revenues = filtered_data.groupby('cluster')['total_amount'].mean().reset_index()

# 수익이 높은 순으로 클러스터 정렬
cluster_recommendations = cluster_revenues.sort_values(by='total_amount', ascending=False)

# 상위 3개의 클러스터 추천
top_clusters = cluster_recommendations.head(3)
print("추천 클러스터:", top_clusters)

# 해당 클러스터의 대표 승차 위치 추천
for cluster in top_clusters['cluster']:
    location_in_cluster = df[df['cluster'] == cluster][['PULocationID', 'Latitude', 'Longitude']].drop_duplicates().head(5)
    print(f"클러스터 {cluster}의 추천 승차 위치:")
    print(location_in_cluster)
추천 클러스터:    cluster  total_amount
2      2.0     80.937500
7      7.0     56.521240
9      9.0     50.388571
클러스터 2.0의 추천 승차 위치:
        PULocationID   Latitude  Longitude
23055              1  40.691831 -74.174000
33390              5  40.552659 -74.188484
65674             44  40.525495 -74.233534
65675             23  40.606448 -74.170887
224371           118  40.586555 -74.132979
클러스터 7.0의 추천 승차 위치:
      PULocationID   Latitude  Longitude
13             132  40.646985 -73.786533
1488            10  40.678953 -73.790986
3006           180  40.675595 -73.847043
6016           216  40.676154 -73.819460
7382           219  40.662185 -73.764506
클러스터 9.0의 추천 승차 위치:
        PULocationID   Latitude  Longitude
18423              6  40.600324 -74.071771
19967             11  40.604273 -74.007488
30813             14  40.624835 -74.029892
49596            221  40.618769 -74.073704
103323            67  40.619619 -74.013801

상위 5개 승차 위치의 위도와 경도를 산점도로 시각화, 수익에 따라 점 크기 조절

 

2) 클러스터링 시각화 코드 및 결과

# 클러스터링 결과 시각화
plt.figure(figsize=(10, 8))

# 클러스터별로 색상 구분하여 시각화
plt.scatter(df['Longitude'], df['Latitude'], c=df['cluster'], cmap='viridis', alpha=0.5)

# 클러스터의 중심점도 시각화
centers = kmeans.cluster_centers_
plt.scatter(centers[:, 1], centers[:, 0], c='red', s=200, alpha=0.75, marker='x')

plt.title('Taxi Pickup Location Clusters')
plt.xlabel('Longitude')
plt.ylabel('Latitude')
plt.show()

2. Elbow Method 를 활용한 최적의 클러스터 수 및 군집 분석

# 산점도: 위도와 경도를 기준으로 클러스터에 따라 색을 다르게 표현
sns.scatterplot(
    x='Longitude', 
    y='Latitude', 
    hue='location_cluster',  # 클러스터에 따라 색상을 다르게 설정
    data=filtered_data,  # 데이터프레임을 random_sample_100k로 수정
    palette='viridis',
    legend='full'
)

# 클러스터 중심 계산
centroids = filtered_data.groupby('location_cluster')[['Latitude', 'Longitude']].mean().reset_index()

# 클러스터 중심에 마커 추가
plt.scatter(
    centroids['Longitude'], 
    centroids['Latitude'], 
    s=100,  # 마커 크기
    c='red',  # 마커 색상
    label='Centroids',  # 범례 이름
    marker='X'  # 마커 형태 (X 모양으로 표시)
)

# 그래프 설정
plt.title('Cluster Visualization of Taxi Pickups')
plt.xlabel('Longitude')
plt.ylabel('Latitude')
plt.legend(title='Cluster')

# 그래프 출력
plt.show()

1. Cluster 0 (보라색):
   - 위치: 대부분의 데이터 포인트가 뉴욕의 서쪽에 위치해 있다. 주로 로어 맨해튼 또는 허드슨 강 근처일 가능성이 있다. 
   - 특성: 이 클러스터는 다른 클러스터들보다 좀 더 넓은 범위에 퍼져있으며, 도시 외곽에 더 가깝다.  
 
2. Cluster 1 (파란색):  
   - 위치: 뉴욕 시 맨해튼의 중심부 근처에 집중되어 있다.  
   - 특성: 이 클러스터는 맨해튼 북부 또는 중부 지역의 택시 픽업 위치를 나타낼 수 있다. 도시 내에서 더 밀집된 픽업 패턴을 보여주고 있다.  
 
3. Cluster 2 (녹색):
   - 위치: 뉴욕 시의 동쪽, 특히 퀸즈나 브루클린으로 추정되는 지역에서 주로 발생하는 픽업을 나타낸다.
   - 특성: 이 군집은 도시 중심에서 떨어진 곳에서 주로 형성된 클러스터이다.  

4. Cluster 3 (노란색): 
   - 위치: 맨해튼 남부와 허드슨 강에 가까운 지역. 
   - 특성: 맨해튼의 하단부에서 매우 밀집된 픽업 위치가 많으며, 뉴욕의 핵심 중심지에 있는 클러스터일 가능성이 크다.  
 
5. 군집 특성 분석:  
- 지리적 범위: 각 클러스터가 나타내는 위치에 따라 주요 택시 픽업 지역의 밀집도를 보여준다. Cluster 0과 Cluster 2는 도시 외곽에서 픽업되는 빈도가 높은 반면, Cluster 1과 3은 맨해튼의 중심부에서 발생하는 픽업이다. 
- 클러스터 중심: 각 클러스터의 중심은 군집의 평균적인 픽업 위치를 나타내며, 픽업 활동이 활발한 지역을 확인하는 데 유용하다.  

 

3. Random Forast Regressor 를 활용한 분석

- 모델 사용 이유: 여러 개의 의사결정트리를 사용해 과적합 방지 및 예측 성능을 향상하기 위해 사용함.

- 회귀분석: 운행 거리와 총 수익간의 관계를 분석하고 회귀 직선을 시각화 하기 위해 진행함.

 

1) 머신러닝 모델 코드

# 모델에 사용할 특성
features = random_sample_100k[['pickup_day_of_week', 'pickup_hour', 'location_cluster', 'manhattan_distance', 'total_amount']]
target = random_sample_100k['RatecodeID']

# 데이터를 학습용과 테스트용으로 분할
X_train, X_test, y_train, y_test = train_test_split(features, target, test_size=0.2, random_state=42)

# 랜덤 포레스트 모델로 학습
rf_model = RandomForestRegressor(n_estimators=100, random_state=42)
rf_model.fit(X_train, y_train)

# 테스트 데이터를 통해 예측
y_pred = rf_model.predict(X_test)

# 모델 성능 평가 (RMSE)
mse = mean_squared_error(y_test, y_pred)
rmse = mse ** 0.5
print(f'평균 제곱근 오차(RMSE): {rmse:.2f}')
# 평균 제곱근 오차(RMSE): 13.81

- Accuracy 0.99인 값이 측정되었다.

 

2) 실제값과 예측값의 산점도

수치가 작은 값들은 잘 맞지만, 적절한 모델은 아닌 것으로 보인다.

 

4. LightGBM 사용하여 재분석

# 클러스터링을 위한 위도, 경도 기반 KMeans 적용
kmeans = KMeans(n_clusters=4, random_state=42)  # 클러스터 수는 4로 설정
df['location_cluster'] = kmeans.fit_predict(df[['Latitude', 'Longitude']])

# 추가 피처로 수익성 관련 칼럼 선택
X = df[['pickup_day_of_week', 'pickup_hour','pickup_latitude','pickup_longitude','dropoff_latitude','dropoff_longitude']] 
y = df['total_amount']  

# 데이터 분리 (학습 데이터와 테스트 데이터로 나누기)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# 훈련 및 테스트 데이터 분할
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# LightGBM 회귀 모델 정의
lgb_model = lgb.LGBMRegressor(
    n_estimators=1000,
    learning_rate=0.05,
    max_depth=10,
    random_state=42,
    n_jobs=-1
)

# 모델 학습 - early stopping 및 학습 진행 상황 출력
lgb_model.fit(
    X_train, y_train,
    eval_set=[(X_test, y_test)], 
    eval_metric='rmse',  # 평가 메트릭 설정
    callbacks=[
        lgb.early_stopping(stopping_rounds=100),  # early stopping 콜백
        lgb.log_evaluation(50)  # 50번마다 학습 로그 출력
    ]
)

# 예측
y_pred = lgb_model.predict(X_test)

# 모델 성능 평가 (RMSE)
rmse = mean_squared_error(y_test, y_pred, squared=False)
print(f"RMSE: {rmse}")
# 예측값이 연속형이므로 회귀 평가지표 사용
print("RMSE:", mean_squared_error(y_test, y_pred, squared=False))  # Root Mean Squared Error
print("MAE:", mean_absolute_error(y_test, y_pred))  # Mean Absolute Error
print("R² Score:", r2_score(y_test, y_pred))  # R^2 Score (결정 계수)

# RMSE: 6.15423934688325
# MAE: 3.534872188639418
# R² Score: 0.8479843617006366

5. 승하차 지역 중 가장 많은 지역 Top20

1) Top 20 코드

DOtemp = df['DOLocationID'].value_counts().head(20).reset_index()
PUtemp = df['PULocationID'].value_counts().head(20).reset_index()
DOtemp = DOtemp.rename(columns={'DOLocationID': 'LocationID'})
PUtemp = PUtemp.rename(columns={'PULocationID': 'LocationID'})
data = pd.merge(DOtemp['LocationID'],PUtemp['LocationID'],on='LocationID', how='outer')

 

2) Top 20 시각화

nyc_map = folium.Map(location=[40.7128, -74.0060], zoom_start=12)

for index, row in top_locations_with_coords.iterrows():
    folium.Marker([row['Latitude'], row['Longitude']],
                  popup=f"Location ID: {row['LocationID']}").add_to(nyc_map)

 

- 해당 위치와 클러스터의 중심이 비슷한 것으로 보아 Top 20 곳 혹은 Top10 위치로 범위를 좁혀 진행하였다.

4. 승하차 지역 중 가장 많은 지역 Top10 데이터를 활용한 머신러닝

1) 데이터 준비

# Top10 안에 들어가는 값만 추출
filtered_data = df[(df['PULocationID'].isin(top_locations_with_coords['LocationID'])) |
                     (df['DOLocationID'].isin(top_locations_with_coords['LocationID']))]
filtered_data.describe()

 

2) 클러스터링을 위한 위도, 경도 기반 KMeans 적용 및 피쳐, 타겟  변수 변경

# 클러스터링을 위한 위도, 경도 기반 KMeans 적용
kmeans = KMeans(n_clusters=4, random_state=42)  # 클러스터 수는 4로 설정
df['location_cluster'] = kmeans.fit_predict(df[['Latitude', 'Longitude']])

# 추가 피처로 수익성 관련 칼럼 선택
X = df[['pickup_day_of_week', 'pickup_hour','pickup_latitude','pickup_longitude','dropoff_latitude','dropoff_longitude']] 
y = df['total_amount']  

# 훈련 및 테스트 데이터 분할
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# LightGBM 회귀 모델 정의
lgb_model = lgb.LGBMRegressor(
    n_estimators=1000,
    learning_rate=0.05,
    max_depth=10,
    random_state=42,
    n_jobs=-1
)

# 모델 학습 - early stopping 및 학습 진행 상황 출력
lgb_model.fit(
    X_train, y_train,
    eval_set=[(X_test, y_test)], 
    eval_metric='rmse',  # 평가 메트릭 설정
    callbacks=[
        lgb.early_stopping(stopping_rounds=100),  # early stopping 콜백
        lgb.log_evaluation(50)  # 50번마다 학습 로그 출력
    ]
)

# 예측
y_pred = lgb_model.predict(X_test)

# 모델 성능 평가 (RMSE)
rmse = mean_squared_error(y_test, y_pred, squared=False)
print(f"RMSE: {rmse}")

# 예측값이 연속형이므로 회귀 평가지표 사용
print("RMSE:", mean_squared_error(y_test, y_pred, squared=False))  # Root Mean Squared Error
print("MAE:", mean_absolute_error(y_test, y_pred))  # Mean Absolute Error
print("R² Score:", r2_score(y_test, y_pred))  # R^2 Score (결정 계수)

결과는 아래와 같다.

R 스퀘어 값이 0.8로 지나치게 높아 보인다.

3) 실제값과 예측 값 산점도를 그려본다면 아래와 같은 결과가 나타난다.

- 실제값과 예측값 간의 산점도를 기본적으로 한 번 보고, 로그변환을 하여 보고, 축범위를 제한하여 보았다. 대부분의 그래프에서 낮은 값에 대해 높은 정확도를 보여주고 있다. 

 

D. 마무리

 모델의 초기 분석에서는 충분한 인사이트를 도출하지 못했지만, 이를 통해 추가적인 분석 방향을 설정할 수 있었다. 특히 클러스터링을 통해 도출한 추천 승차 위치와 위도·경도 정보를 보면, 이 값들이 프로젝트의 목표 달성에 중요한 역할을 할 수 있었을 것이라는 생각이 든다. 그러나 분석 과정에서 데이터를 충분히 이해하지 못한 채 기계적으로 분석한 결과, 프로젝트를 중도에 마무리하게 된 것 같다.
 앞으로는 고객의 하차 지점에서 다음 고객의 목적지를 예측해, 더 높은 수익을 낼 수 있는 고객을 어떻게 태울 수 있을지에 대한 분석을 진행하고자 한다. 택시 운행은 하나의 사이클로 볼 수 있다. 손님을 내려준 후 빈 차로 돌아오는 상황을 줄이지 않으면 수익에 손해가 발생한다. 따라서 빈 차로 도로를 달리는 시간을 최소화하고, 더 높은 수익을 낼 수 있는 고객의 목적지를 예측하는 모델을 구축하고 싶다.
 이를 위해 고객의 목적지와 다음 승객의 예상 위치를 함께 고려한 최적 경로 예측 모델을 탐색하여 택시 기사의 운영 효율성을 향상시키는 방안을 마련할 계획이다.

A. 데이터셋

1. 데이터 소스:
https://www.kaggle.com/datasets/quangvinhhuynh/marketing-and-retail-analyst-e-comerce

 

Marketing and Retail Analyst E-comerce

 

www.kaggle.com

해당 데이터는 전자 상거래 플랫폼의 소매 부문 판매 채널과 관련된 것이다. 가장 정확한 보고서를 작성하기 위해서는 수익과 이익을 지난달 및 전년도 같은 기간과 비교하여 효과적인 마케팅 전략을 수립하는 것이 중요하다. 또한, 이익을 기준으로 상위 성과 제품을 식별하고 이러한 제품에 대한 세부 분석을 진행해야 하며, 미래 개발을 위한 성장 가능성이 있는 제품도 함께 평가해야 한다.

해당 데이터는 orders, order_items, customers, payments, products 의 총 5개 개별 파일로 하나의 데이터로 만들어 사용할 예정이다. cleaned csv 파일이 있지만 어떤 기준으로 어떤 방식으로 정제되었는지 모르기 때문에 사용하지 않는다.

 

2. 칼럼 확인

orders - order_id(PK): 주문의 고유 식별자, 이 테이블의 기본 키 역할
- customer_id: 고객의 고유 식별자
- order_status: 주문 상태. 예: 배송됨, 취소됨, 처리 중 등
- order_purchase_timestamp: 고객이 주문을 한 시점의 타임스탬프
- order_approved_at: 판매자가 주문을 승인한 시점의 타임스탬프
- order_delivered_timestamp: 고객 위치에 주문이 배송된 시점의 타임스탬프
- order_estimated_delivery_date: 주문할 때 고객에게 제공된 예상 배송 날짜
order_items - order_id(PK): 주문의 고유 식별자
- order_item_id(PK):  각 주문 내 항목 번호. 이 컬럼과 함께 order_id가 이 테이블의 기본 키 역할
- product_id: 제품의 고유 식별자
- seller_id: 판매자의 고유 식별자
- price: 제품의 판매 가격
- shipping_charges: 제품의 배송에 관련된 비용
customers - customer_id(PK): 고객의 고유 식별자, 이 테이블의 기본 키 역할
- customer_zip_code_prefix: 고객의 우편번호
- customer_city: 고객의 도시
- customer_state: 고객의 주
payments - order_id: 주문의 고유 식별자, 이 테이블에서 이 컬럼은 중복될 수 있다
- payment_sequential: 주어진 주문에 대한 결제 순서 정보를 제공
- payment_type: 결제 유형 예: 신용카드, 직불카드 등
- payment_installments: 신용카드 결제 시 할부 회차
- payment_value: 거래 금액
products - product_id: 각 제품의 고유 식별자, 이 테이블의 기본 키 역할
- product_category_name: 제품이 속한 카테고리 이름
- product_weight_g: 제품 무게 (그램)
- product_length_cm: 제품 길이 (센티미터)
- product_height_cm: 제품 높이 (센티미터)
- product_width_cm: 제품 너비 (센티미터)

 

3. 데이터 하나의 파일로 합치기

customers_df = customers_df.drop_duplicates()

# orders_df와 customers_df를 customer_id를 기준으로 병합
merged_df = pd.merge(orders_df, customers_df, on='customer_id', how='left')

# merged_df와 order_items_df를 order_id를 기준으로 병합
merged_df = pd.merge(merged_df, order_items_df, on='order_id', how='left')

# merged_df와 payments_df를 order_id를 기준으로 병합
merged_df = pd.merge(merged_df, payments_df, on='order_id', how='left')

# merged_df와 products_df를 product_id를 기준으로 병합
final_df = pd.merge(merged_df, products_df, on='product_id', how='left')

# 결과를 CSV 파일로 저장
# final_df.to_csv("merged_data_final.csv", index=False)
merge_df = final_df

 

B. Exploratory Data Analysis (EDA)

1. 데이터 톺아보기

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 119160 entries, 0 to 119159
Data columns (total 24 columns):
 #   Column                         Non-Null Count   Dtype  
---  ------                         --------------   -----  
 0   order_id                       119160 non-null  object 
 1   customer_id                    119160 non-null  object 
 2   order_status                   119160 non-null  object 
 3   order_purchase_timestamp       119160 non-null  object 
 4   order_approved_at              118982 non-null  object 
 5   order_delivered_timestamp      115738 non-null  object 
 6   order_estimated_delivery_date  119160 non-null  object 
 7   customer_zip_code_prefix       119160 non-null  int64  
 8   customer_city                  119160 non-null  object 
 9   customer_state                 119160 non-null  object 
 10  order_item_id                  118325 non-null  float64
 11  product_id                     118325 non-null  object 
 12  seller_id                      118325 non-null  object 
 13  price                          118325 non-null  float64
 14  shipping_charges               118325 non-null  float64
 15  payment_sequential             119157 non-null  float64
 16  payment_type                   119157 non-null  object 
 17  payment_installments           119157 non-null  float64
 18  payment_value                  119157 non-null  float64
 19  product_category_name          117893 non-null  object 
 20  product_weight_g               118305 non-null  float64
 21  product_length_cm              118305 non-null  float64
 22  product_height_cm              118305 non-null  float64
 23  product_width_cm               118305 non-null  float64
dtypes: float64(10), int64(1), object(13)
memory usage: 21.8+ MB

 

- 수치형 변수 및 범주형 변수 나누기

numerical_cols = ['order_item_id', 'price', 'shipping_charges', 'payment_sequential', 'payment_installments', 'payment_value',
                           'product_weight_g', 'product_length_cm', 'product_height_cm', 'product_width_cm']
categorical_cols = ['order_status', 'order_purchase_timestamp', 'order_approved_at', 'order_delivered_timestamp', 
                             'order_estimated_delivery_date', 'customer_zip_code_prefix', 'customer_city', 'customer_state',
                             'payment_type', 'product_category_name']

 

2. 수치형 시각화

- 방법: histoplot(), kdeplot(),displot(), boxplot(), pairplot()

boxplot()
pairplot()

- 모든 column에서 데이터의 왼쪽 쏠림 현상 발견 → 데이터의 표준화 필요 예상

- 이상치 정제 필요 예상.

 

3. 범주형 시각화

- barlplot(), countplot(), boxplot()

barplot()

C. 데이터 정제

1. 결측치 처리

# 각 컬럼의 중앙값으로 결측치를 채우기 [product_weight_g, product_length_cm, product_height_cm, product_width_cm]
merge_df['product_weight_g'].fillna(merge_df['product_weight_g'].median(), inplace=True)
merge_df['product_length_cm'].fillna(merge_df['product_length_cm'].median(), inplace=True)
merge_df['product_height_cm'].fillna(merge_df['product_height_cm'].median(), inplace=True)
merge_df['product_width_cm'].fillna(merge_df['product_width_cm'].median(), inplace=True)

# 결측치를 제거할 열 목록
columns_to_drop_null= [
    'order_approved_at', 
    'order_item_id', 
    'product_id', 
    'seller_id', 
    'price', 
    'shipping_charges', 
    'payment_sequential', 
    'payment_type', 
    'payment_installments', 
    'payment_value', 
    'product_category_name'
]

# 지정된 열들에서 결측치가 있는 행을 제거
merge_df = merge_df.dropna(subset=columns_to_drop_null)

# 분석 프로젝트에 필요 없다고 판단한 컬럼 제거
columns_to_drop = ['payment_sequential']
merge_df = merge_df.drop(columns=columns_to_drop)

merge_df.isna().sum()

 

2. 이상치 처리

# 변수 이진화 함수
def binarize(df, column_name):
    df[column_name] = df[column_name].apply(lambda x: 1 if x == 1 else 0)
    return df

# IQR 제거 방식
def remove_outliers_iqr(df, column_name):
    Q1 = df[column_name].quantile(0.25)
    Q3 = df[column_name].quantile(0.75)
    IQR = Q3 - Q1

    lower_bound = Q1 - 1.5 * IQR
    upper_bound = Q3 + 1.5 * IQR

    df_cleaned = df[(df[column_name] >= lower_bound) & (df[column_name] <= upper_bound)]
    
    return df_cleaned

# 빈도수 기반 필터링 함수
def remove_low_frequency_outliers(df, column_name, min_frequency=0.01):
    value_counts = df[column_name].value_counts()
    proportions = value_counts / len(df)
    
    valid_values = proportions[proportions >= min_frequency].index
    
    df_cleaned = df[df[column_name].isin(valid_values)]
    
    return df_cleaned

def remove_outliers_by_threshold(df, column_name, threshold):
    df_cleaned = df[df[column_name] <= threshold]
    
    return df_cleaned


# order_item_id 1개는 1로, 2개 이상은 0으로 이진화하는 코드
merged_df_cleaned = binarize(merge_df, 'order_item_id')

# price, shipping_charges, payment_value IQR 방법으로 처리
merged_df_cleaned = remove_outliers_iqr(merged_df_cleaned, 'price')
merged_df_cleaned = remove_outliers_iqr(merged_df_cleaned, 'shipping_charges')
merged_df_cleaned = remove_outliers_iqr(merged_df_cleaned, 'payment_value')

# payment_installments 12개월 초과는 필터링
merged_df_cleaned = remove_outliers_by_threshold(merged_df_cleaned, 'payment_installments', 12)

# product_weight_g, product_length_cm, product_height_cm, product_width_cm IQR 방법으로 처리
merged_df_cleaned = remove_outliers_iqr(merged_df_cleaned, 'product_weight_g')
merged_df_cleaned = remove_outliers_iqr(merged_df_cleaned, 'product_length_cm')
merged_df_cleaned = remove_outliers_iqr(merged_df_cleaned, 'product_height_cm')
merged_df_cleaned = remove_outliers_iqr(merged_df_cleaned, 'product_width_cm')

 

3. 이상치 처리 결과

boxplot()

4. 변수 스케일링

1) 수치형 변수 스케일링

현재 수치형 변수가 오른쪽으로 꼬리가 긴 right-skewed 분포를 가지고 있다. 이에 로그 변환 방법으로 변수들을 조금 더 정규분포에 가깝게 만들어 비대칭성을 줄이고, 정규화(Nomalization)을 통해 데이터의 범위를 일정한 구간으로 변환하여 모델의 학습속도와 성능을 높이고자 한다.

# 'product_length_cm', 'product_height_cm', 'product_width_cm' 곱해서 'volume' 컬럼 생성
merged_df_cleaned['volume'] = merged_df_cleaned['product_length_cm'] * merged_df_cleaned['product_height_cm'] * merged_df_cleaned['product_width_cm']

# 기존의 세 개의 컬럼 드랍
merged_df_cleaned = merged_df_cleaned.drop(columns=['product_length_cm', 'product_height_cm', 'product_width_cm'])

merged_df_cleaned['repeat_order'] = merged_df_cleaned.groupby('customer_id')['order_id'].transform('nunique')
merged_df_cleaned['total_price'] = merged_df_cleaned.groupby('customer_id')['price'].transform('sum')
merged_df_cleaned['avg_price'] = merged_df_cleaned.groupby('customer_id')['price'].transform('mean')
merged_df_cleaned['total_payment_value'] = merged_df_cleaned.groupby('customer_id')['payment_value'].transform('sum')
merged_df_cleaned['avg_payment_value'] = merged_df_cleaned.groupby('customer_id')['payment_value'].transform('mean')

# 수치형, 범주형 변수 재정의

numerical_cols = ['price', 'shipping_charges', 'payment_value', 'product_weight_g', 'volume']
categorical_cols = ['order_status','customer_zip_code_prefix', 'customer_city', 'customer_state',
                    'order_item_id', 'payment_type', 'payment_installments', 'product_category_name']
                    
# 수치형 변수 로그변환 함수

def log_transform(df, cols):
    df_log_transformed = df[cols].apply(lambda x: np.log1p(x))
    
    return df_log_transformed

def normalize(df, cols):
    scaler = MinMaxScaler()
    df_normalized = pd.DataFrame(scaler.fit_transform(df[cols]), columns=cols, index=df.index)
    
    return df_normalized

# 로그 변환
log_transformed_df = log_transform(merged_df_cleaned, numerical_cols)

# 정규화 수행
normalized_df = normalize(log_transformed_df, numerical_cols)

# 결과를 원래 데이터프레임에 반영
merged_df_cleaned[numerical_cols] = normalized_df

 

2) 범주형 변수 인코딩

해당 데이터는 범주형 변수가 많고, 해당 변수의 값들 또한 많기 때문에 One-Hot Encoding 방식은 적절하지 않을 것으로 사료된다. 이에 범주형 변수의 카테고리를 줄인 후에 Label-Encoding를 하고자 한다. 또한 위치 정보를 나타내는 칼럼들은 5개의 큰 구역으로 묶는 절차를 거친다.

# state로 5개의 지방으로 묶기
state_to_region = {
    'AC': '북부 지방', 'AL': '북동부 지방', 'AP': '북부 지방', 'AM': '북부 지방',
    'RR': '북부 지방', 'RO': '북부 지방', 'PA': '북부 지방', 'PB': '북동부 지방',
    'MA': '북동부 지방', 'PI': '북동부 지방', 'PE': '북동부 지방', 'RN': '북동부 지방',
    'CE': '북동부 지방', 'SE': '북동부 지방', 'BA': '북동부 지방', 'DF': '중서부 지방',
    'TO': '북부 지방', 'GO': '중서부 지방', 'MS': '중서부 지방', 'MT': '중서부 지방',
    'RJ': '남동부 지방', 'SP': '남동부 지방', 'MG': '남동부 지방', 'ES': '남동부 지방',
    'RS': '남부 지방', 'SC': '남부 지방', 'PR': '남부 지방'
}

# state 컬럼을 기준으로 지방으로 변환
merged_df_cleaned['region'] = merged_df_cleaned['customer_state'].map(state_to_region)

# customer_zip_code_prefix, customer_city, customer_state 컬럼 drop
columns_to_drop = ['customer_zip_code_prefix', 'customer_city', 'customer_state']
merged_df_cleaned = merged_df_cleaned.drop(columns=columns_to_drop)

# 레이블 인코딩
label_encoder = LabelEncoder()

# 'region' 컬럼을 레이블 인코딩
merged_df_cleaned['region'] = label_encoder.fit_transform(merged_df_cleaned['region'])
merged_df_cleaned['region'].value_counts()

# product_category_name 종류별로 묶어서 카테고리 줄이기

category_mapping = {
    '가구/인테리어': [
        'furniture_decor', 'furniture_living_room', 'furniture_bedroom', 
        'furniture_mattress_and_upholstery', 'kitchen_dining_laundry_garden_furniture', 
        'la_cuisine', 'flowers', 'cool_stuff', 'perfumery', 'party_supplies', 
        'bed_bath_table', 'market_place', 'home_construction', 'christmas_supplies'
    ],
    '패션': [
        'fashion_underwear_beach', 'fashion_bags_accessories', 'fashion_shoes', 
        'fashion_male_clothing', 'fashion_sport', 'fashion_childrens_clothes', 
        'fashio_female_clothing', 'housewares', 'watches_gifts'
    ],
    '전자제품': [
        'telephony', 'computers_accessories', 'audio', 'tablets_printing_image', 
        'cine_photo', 'musical_instruments', 'consoles_games', 'dvds_blu_ray', 
        'music', 'electronics', 'air_conditioning', 'small_appliances', 
        'home_appliances', 'home_appliances_2', 'small_appliances_home_oven_and_coffee', 
        'home_comfort_2', 'signaling_and_security', 'security_and_services', 
        'fixed_telephony'
    ],
    '건설/공구': [
        'construction_tools_construction', 'costruction_tools_garden', 
        'construction_tools_safety', 'construction_tools_lights', 
        'costruction_tools_tools', 'garden_tools'
    ],
    '생활용품': [
        'baby', 'diapers_and_hygiene', 'health_beauty', 'home_confort', 
        'luggage_accessories', 'auto', 'food', 'drinks', 'food_drink', 
        'sports_leisure', 'pet_shop', 'agro_industry_and_commerce'
    ],
    '문구/사무용품': [
        'stationery', 'office_furniture', 'books_technical', 
        'books_general_interest', 'books_imported', 'arts_and_craftmanship', 
        'art', 'industry_commerce_and_business'
    ],
    '장난감': ['toys']
}

# 카테고리 매핑을 수행하는 함수
def map_category(category_name):
    for main_category, subcategories in category_mapping.items():
        if category_name in subcategories:
            return main_category
    return '기타'  

# `product_category_name` 컬럼을 매핑하여 새로운 컬럼 추가
merged_df_cleaned['product_category_group'] = merged_df_cleaned['product_category_name'].apply(map_category)

# 'product_category_name' 컬럼 삭제
merged_df_cleaned = merged_df_cleaned.drop(columns='product_category_name')

merged_df_cleaned['product_category_group'].value_counts()

# product_category_name 레이블 인코딩

label_encoder = LabelEncoder()

# 'product_category_group' 컬럼을 레이블 인코딩
merged_df_cleaned['product_category_group'] = label_encoder.fit_transform(merged_df_cleaned['product_category_group'])

# 변환된 데이터 확인
merged_df_cleaned['product_category_group'].value_counts()

 

3) dtype 변경

날짜 변수들의 dtype을 datetime으로 변경한다.

# 범주형 변수들 처리 전 날짜 변수들의 dtype datetime으로 변경.
date_columns = ['order_purchase_timestamp', 'order_approved_at', 
                'order_delivered_timestamp', 'order_estimated_delivery_date']

for col in date_columns:
    merged_df_cleaned[col] = pd.to_datetime(merged_df_cleaned[col])

merged_df_cleaned[date_columns].dtypes

 

4) 2번째 인코딩

# order_status, payment_type 인코딩

# 매핑 딕셔너리 정의
order_status_mapping = {
    'delivered': 0,
    'shipped': 1,
    'canceled': 2,
    'unavailable': 3,
    'processing': 4,
    'invoiced': 5,
    'created': 6,
    'approved': 7
}

payment_type_mapping = {
    'credit_card': 0,
    'wallet': 1,
    'voucher': 2,
    'debit_card': 3,
    'not_defined': 4
}

# 직접 레이블 인코딩 수행
merged_df_cleaned['order_status_encoded'] = merged_df_cleaned['order_status'].map(order_status_mapping)
merged_df_cleaned['payment_type_encoded'] = merged_df_cleaned['payment_type'].map(payment_type_mapping)

# 원본 컬럼 제거
merged_df_cleaned = merged_df_cleaned.drop(columns=['order_status', 'payment_type'])

 

D. 군집 모델 

1. 군집 모델 나누기

cluster_1 = merged_df_cleaned[['repeat_order','total_price', 'avg_price', 'total_payment_value', 'avg_payment_value']]

# 군집 2개로 나누기
custoemr_kmeans2 = KMeans(n_clusters=2, init='k-means++', max_iter=300, random_state=42)
custoemr_kmeans2.fit(cluster_1)

cluster_1['cluster2'] = custoemr_kmeans2.labels_

# 군집 3개로 나누기
custoemr_kmeans3 = KMeans(n_clusters=3, init='k-means++', max_iter=300, random_state=42)
custoemr_kmeans3.fit(cluster_1)

cluster_1['cluster3'] = custoemr_kmeans3.labels_

# 군집 4개로 나누기
custoemr_kmeans4 = KMeans(n_clusters=4, init='k-means++', max_iter=300, random_state=42)
custoemr_kmeans4.fit(cluster_1)

cluster_1['cluster4'] = custoemr_kmeans4.labels_

# 실루엣 계수

from sklearn.metrics import silhouette_score

labels = custoemr_kmeans2.fit_predict(cluster_1)

silhouette_1 = silhouette_score(cluster_1, custoemr_kmeans2.labels_)
print(f'클러스터 개수 2개일 때: Silhouette Score = {silhouette_1:.6f}')

from sklearn.metrics import silhouette_score

labels = custoemr_kmeans3.fit_predict(cluster_1)

silhouette_2 = silhouette_score(cluster_1, custoemr_kmeans3.labels_)
print(f'클러스터 개수 3개일 때: Silhouette Score = {silhouette_2:.6f}')

from sklearn.metrics import silhouette_score

labels = custoemr_kmeans4.fit_predict(cluster_1)

silhouette_3 = silhouette_score(cluster_1, custoemr_kmeans4.labels_)
print(f'클러스터 개수 4개일 때: Silhouette Score = {silhouette_3:.6f}')

wcss_2 = custoemr_kmeans2.inertia_
print(f'클러스터 개수 3개일 때: WCSS = {wcss_2:.4f}')


wcss_3 = custoemr_kmeans3.inertia_
print(f'클러스터 개수 3개일 때: WCSS = {wcss_3:.4f}')

wcss_4 = custoemr_kmeans4.inertia_
print(f'클러스터 개수 3개일 때: WCSS = {wcss_4:.4f}')
클러스터 개수 2개일 때: Silhouette Score = 0.765187
클러스터 개수 3개일 때: Silhouette Score = 0.575237
클러스터 개수 4개일 때: Silhouette Score = 0.559589

클러스터 개수 3개일 때: WCSS = 3528533448.0313
클러스터 개수 3개일 때: WCSS = 2497989545.3095
클러스터 개수 3개일 때: WCSS = 1931635348.2375

 

2. 최적의 군집 수 확인하기

def elbow(df):
    sse = []
    for i in range(1,15):
        km = KMeans(n_clusters= i, init='k-means++', random_state=42)
        km.fit(df)
        sse.append(km.inertia_)
    
    plt.plot(range(1,15), sse, marker = 'o')
    plt.xlabel('cluster count')
    plt.ylabel('SSE')
    plt.show()

elbow(cluster_1)

3. 군집 결과 해석

# 원본 자료는 살리기 위해 복사하기
merged_clu_df = merged_df_cleaned.copy()

# 클러스트 항목 설정
cluster_1 = merged_df_cleaned[['repeat_order','total_price', 'avg_price', 'total_payment_value', 'avg_payment_value']]

# 학습하기
# 군집 3개로 나누기
custoemr_kmeans3 = KMeans(n_clusters=3, init='k-means++', max_iter=300, random_state=42)
custoemr_kmeans3.fit(cluster_1)

# 군집된 결과 저장
merged_clu_df['cluster'] = custoemr_kmeans3.labels_


# 컬럼 지우는 함수
def del_cols(df):
    del df['order_id']
    del df['customer_id']
    del df['seller_id']
    del df['product_id']
    del df['order_approved_at']
    del df['order_delivered_timestamp']
    del df['order_estimated_delivery_date']
    
# 군집 특성 확인하는 함수
def character_visual(df):
    plt.figure(figsize=(20,20))

    for i in range(len(df.columns)):
    
        cols = list(df.columns)[i]
    
        plt.subplot(4,6,i+1)
        sns.histplot(df, x=cols, palette='RdYlGn')
        plt.title(cols)

 

1) 1번 군집

# 1번 군집만 설정
merged_clu1_df = merged_clu_df[merged_clu_df['cluster'] == 0]

del_cols(merged_clu1_df)
character_visual(merged_clu1_df)

 

2) 2번 군집

merged_clu2_df = merged_clu_df[merged_clu_df['cluster'] == 1]

del_cols(merged_clu2_df)
character_visual(merged_clu2_df)

 

3) 3번 군집

merged_clu3_df = merged_clu_df[merged_clu_df['cluster'] == 2]

del_cols(merged_clu3_df)
character_visual(merged_clu3_df)

 

4) 분기별 시각화

merged_df = merged_clu_df
merged_df['order_purchase_timestamp'] = pd.to_datetime(merged_df['order_purchase_timestamp'])
merged_df['quarter'] = merged_df['order_purchase_timestamp'].dt.to_period('Q')

# 모든 subplot에서 동일한 x축 레이블을 사용하기 위해 고유한 quarter 값을 얻음
unique_quarters = sorted(merged_df['quarter'].unique())

# 분기별 군집
rows = []
for quarter in sorted(merged_df['quarter'].unique()):
    row = {
        'Quarter': quarter,
        'Cluster 0 Count': merged_df[(merged_df['quarter'] == quarter) & (merged_df['cluster'] == 0)].shape[0],
        'Cluster 1 Count': merged_df[(merged_df['quarter'] == quarter) & (merged_df['cluster'] == 1)].shape[0],
        'Cluster 2 Count': merged_df[(merged_df['quarter'] == quarter) & (merged_df['cluster'] == 2)].shape[0]
    }
    rows.append(row)

# 데이터프레임으로 변환
summary_df = pd.DataFrame(rows)

# 분기 열 str로 타입 변경
summary_df['Quarter'] = summary_df['Quarter'].astype(str)

# 시각화
plt.figure(figsize=(12, 6))

plt.plot(summary_df['Quarter'], summary_df['Cluster 0 Count'], marker='o', label='Cluster 0')
plt.plot(summary_df['Quarter'], summary_df['Cluster 1 Count'], marker='o', label='Cluster 1')
plt.plot(summary_df['Quarter'], summary_df['Cluster 2 Count'], marker='o', label='Cluster 2')

plt.xlabel('Quarter')
plt.ylabel('Count')
plt.title('Quarterly Distribution of Orders by Cluster')
plt.xticks(rotation=45)
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()

5) 군집 해석

  • 1번 군집: 일회성 구매 고객이 많고, 현금 결제가 많았다. 부피는 평균치지만 무게가 적은 상품을 구매하여 배송비가 세 집단 중 가장 작은 편이며, 장난감, 생활 관련 구매 고객이 다른 집단에 비해 많은 편이다.
  • 2번 군집: 다른 집단에 비해 재구매 고객이 많고, 특히 다른 집단에 비해 남동부 지방 외 고객이 많다. 결제 금액이 높고, 할부 횟수가 많은 편이다.
  • 3번 군집: 가장 최근에 주문한 고객들이 많았고, 재구매 고객이 가장 적다. 또한 전자 제품 구매가 많다는 특징이 있다.

4. 월별 평균 판매 가치의 변화

plt.rcParams['font.family'] = 'AppleGothic'

daily_payment = df.groupby(df['order_purchase_timestamp'].dt.date)['payment_value'].mean()

plt.figure(figsize=(14, 7))

plt.plot(daily_payment.index, daily_payment.values, label='Average Daily Payment Value', color='skyblue')

plt.scatter(daily_payment.index, daily_payment.values, color='red', s=10, label='Payment Value Points')

plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
plt.gca().xaxis.set_major_locator(mdates.MonthLocator())
plt.gcf().autofmt_xdate()

plt.title('월별 평균 판매 가치의 변화')
plt.xlabel('Date')
plt.ylabel('Average Payment Value')
plt.legend()
plt.grid(True)
plt.show()

 

E. 페르소나 설정

유아 및 생활 제품 구매 관련이 높은 자녀가 있는 가정

마케팅 전략:

- 우수 리뷰자 상품 증정 이벤트
장난감 및 생활 용품 구매가 많은 것을 보아 안전한 상품을 구매하고 싶을 것으로 추정하여, 실구매자의 리뷰를 이끌어내 이를 바탕으로 그 신뢰를 쌓고자 한다.

- 정액 할인 쿠폰 프로모션
현금 구매 및 할부를 이용하는 고객이 적다. 쿠폰을 통해 결제 금액을 줄여 가성비 쇼핑이라 느끼게 한다. 이를 통해 반복 구매를 유도하고자 한다.
무겁고 부피가 큰 상품을 여러 개, 여러 번 구매하는 고객

마케팅 전략:

- 배송 할인 쿠폰 제공
무겁고 부피가 큰 상품은 배송비를 많이 지불해야 함으로 더 많은 구매를 유도하기 위하여 배송 할인 쿠폰을 지급하고자 한다.

- 구매 횟수에 따른 포인트 제공
재구매 횟수가 다른 집단에 비해 많은 편이다. 이들을 충성 고객으로 전환하기 위해 방문횟수가 일정 횟수 이상 늘어날 때마다 포인트를 제공하고자 한다..
전자제품에 관심과 수요가 많은 2030 MZ세대 청년층

마케팅 전략:

- 전자 제품 구매 시 악세사리 제공
전자 제품에 따른 악세사리를 제공하여 구매 결정을 쉽게 내릴 수 있도록 한다.

- SNS 광고
청년들이 주로 이용하는 SNS을 통한 광고를 통해 소비를 촉진하고자 한다.

- 블랙프라이데이, 크리스마스, 신년과 같은 특별한 날이 많은 4분기, 그 다음 해 1분기에 집중적으로 활용할 생각이다.

 

A. 데이터 시각화

1. 각 칼럼별 시각화

graph = df.value_counts(['Age'])
graph.plot(kind= 'bar', figsize=(10,10),stacked = False, color = colors)

# 제목과 레이블 추가
plt.title('Age')
plt.xlabel('Age')
plt.ylabel('Frequency')
plt.xticks(rotation=0, ha='center')

# x축 ticks 설정 (명명하기)
new_labels = ['20-35', '12-20', '35-60']  # 새로운 레이블
plt.xticks(ticks=range(len(new_labels)), labels=new_labels, rotation=0, ha='center')

 

1) Age, Gender



사용자 연령대 사용자 성별

- 20~35세 사이의 주사용자이며, 여성 비율이 압도적으로 많다.

 

2) Genre, Time

음악 장르 선호도 음악 감상 시간

- Melody 장르를 압도적으로 선호한다.

- Pop 과 Classical 장르도 상대적으로 높은 빈도를 보였으나 Melody에 비해 현저히 적은 빈도를 나타낸다.

- 주로 밤에 스포티파이를 이용해 음악을 감상한다.

 

3) About Spotify Subscription

현재 스포티파이 구독 방법 스포티파이 구독 기간

추후 구독 의사 구독료 지불 의사

- 무료 사용자(81.8%)가 무료(광고 지원)버전을 사용하고 있으며, 프리미어 사용자는 18.2%이다.

- '2년 이상' 사용한 사용자 수가 가장 많다.

- 응답자의 64.1%가 프리미엄 구독을 계속하지 않겠다고 응답하였으며, 35.9%가 계속 구독할 의향이 있다 답하였다.

 - 프리미엄 구독을 원하지 않는 사용자가 가장 많았고, 그 뒤를 이어 개인 플랜과 학생 플랜이 비슷한 빈도로 선택되었다.

 

4) User Preference

음악 감상 분위기

음악 감상 시 사용 장치
 
음악 듣는 상황 추천 음악 만족도

- 'Relaxation and stree relief' 기분일 때 Spotify 이용에 가장 큰 선호도를 보인다. 이 감정일 때 다양한 음악 장르가 인기있다.

- 'Uplifting and motivational' 기분일 때 또한 높은 선호도를 나타낸다.

- '스마트폰'으로 Spotify를 이용하는 사용자가 가장 많다. '컴퓨터 또는 노트북'과 '스마트 스피커 또는 음성 비서'가 그 뒤를 따라온다.

- 여행 중 또는 여가 시간에 음악을 듣는 비율이 가장 높다.

- 음악 추천 점수는 3.0에서 4.0 사이에 집중되어 있다.

 

4) Information

사용자 연령별 선호 장르 시간대별 감상 장르
상황별 음악 장르 선택
사용자 연령별 시간대별 감상 장르 음악 플레이스트 선정 방식
장르별 음악 플레이스트 선정 방법 구독 유형별 음악 추천 평균 점수
시간대별 음악 감상 장치

 

- 대부분의 사람들이 음악을 밤에 가장 많이 듣는다.

- 20~35세 연령대에서 선호하는 음악 장르는 다양하다.

- 20~35세 연령대는 밤 시간대에 K-POP 및 Melody 장르에서 가장 많은 수치를 기록하고 있고, 다른 시간대 보다 더 다양한 장르의 음악이 소비한다.

- 다양한 장르가 야간에 청취되는 비율이 높아, 사용자의 음악 취향의 폭이 넓다는 점을 나타난다.

- Kpop장르는 Melody를 통해 새로운 음악을 가장 많이 발견되고 있다.

- Pop장르 역시 Playlist 와 Recommendations 를 통해 많이 발견되고 있다.

- 'Smartphone'을 가장 많이 사용하며, 특히 밤 시간대의 청취량이 높다.

 

B. 인사이트

 음악소비패턴과 사용자 선호도에 대한 5가지 인사이트를 도출해낼 수 있다.

 

 첫째, 연령대와 음악장르 선호에 대한 분석 결과, 20~35세 연령대에서 Kpop과 Melody 장르에 대한 선호도가 특히 두드러졌다. 이들은 다양한 음악장르에서 높은 선호도를 보이며, 주로 밤 시간대에 음악을 소비하는 경향이 있다. 이는 일이나 학업을 마친 후 여가 시간에 음악을 듣고자 하는 욕구와 관련이 있는 것으로 보인다. 반면 12~20세와 35~60세 연령대는 상대적으로 낮은 선호도를 보이며, 특정 장르에 대한 선호가 분산되어 있는 경향이 있다. 마케팅 전략이나 콘텐츠 개발 시 20~35세 사용자를 주요 타겟으로 삼는 것이 중요하다는 것을 시사한다.

 

 둘째, Spotify 사용기기에 대한 분석 결과, 스마트폰이 300회 이상으로 가장 많은 선택을 받았다. 이는 사용자들이 언제 어디서든 쉽게 음악을 청취할 수 있는 환경을 제공하고 있음을 나타낸다. 특히 젊은 세대는 이동 중에도 스마트폰을 통해 음악을 소비하며, Kpop과 트렌딩 송이 가장 인기 있는 자리로 자리 잡고 있다.

 

 셋째, 구독계획에 대한 분석에서는 81.8%의 사용자가 무료(광고 지원) 버전을 사용하고 있으며, 프리미어 사용자는 18.2%에 불과하다는 결과가 나타났다. 이는 광고가 포함된 무료 서비스 대다수가 사용자에게 선호되고 있음을 보여준다. 많은 사용자가 프리미엄 서비스에 대한 필요성을 느끼지 못하고 있으며, 사용자 경험을 개선하고 프리미엄 서비스의 가치를 높이기 윟나 추가적인 조치가 필요하다. 이를 위해 가격, 필요성, 기능 등을 고려한 사용자 의견 조사 등이 추가적으로 진행되어야 하며, 소비자들이 프리미엄 서비스로 전환하도록 유도하는 다양한 프로모션 전략과 혜택 제공이 있어야 한다.

 

 넷째, 사용자는 주로 스트레스 해소와 긴장감 완화를 위해 음악을 듣는 경향이 있으며, 이는 긍정적인 기분을 유도하는 음악 장르를 강조하는 마케팅 전량이 효과적일 수 있음을 시사한다. 편안한 음악이나 명상 음악을 추천하는 켐페인은 사용자들이 더 많은 음악을 소비하게 할 가능성이 높다. 현대 사회에서 스트레스가 증가하는 사회 분위기 속 음악이 사람들에게 힐링의 역할을 할 수 있는 기회를 마련하는 것이 중요하다.

 

 마지막으로, 프리미엄 지속 의향에 대한 분석에 따르면 응답자의 64.1%가 프리미엄 구독을 계속하지 않겠다고 응답하였다. 이는 고객 이탈의 위험 신호로, 추가적인 기능이나 혜택을 제공하여 사용자 만족도를 높이는 것이 중요하다. 독점 콘텐츠 제공, 사용자 맞춤형 추천 시스템 등이 이에 도움이 될 수 있다. 주 소비층이 소셜미디어 사회에 민감한 20~35세라는 것은 감안할 때, 앱 사용자간의 네트워크를 강화하고, 그들을 위한 차별화된 콘덴츠를 제공하는 것이 필요하다. 인기 아티스타의 라이브 공연 재생, 독점 인터뷰 등을 앱 전용으로 제공하는 방법이 있다. 소비자들은 독점 콘텐츠에 높은 가치를 두는 경향성이 있으므로 앱 활용도가 증가할 것이고, 스마트폰앱에서만 제공되는 콘텐츠는 사용자 충성도를 높이는데 기여할 것이다.

 또한 챌린지 콘텐츠는 사용자 참여를 유도하고 자연스러운 마케팅 효과를 창출하는데 중요한 역할을 할 수 있다. 유명 인플루언서들이 참여할 경우, 그 효과는 더 거대해질 것이다. 사용자들이 공유한 콘텐츠는 타인에게 영향을 미치고 스포티파이만의 새로운 커뮤니티를 형성할 수 있는 기회를 제공할 것이다. 정적인 챌린지에서부터 노래, 춤 등의 동적인 챌린지까지 다양한 콘텐츠를 제공함으로써, 사용자들은 자신의 음악 청취 순간을 공유하고 더 많은 상호작용을 할 수 있다.

 

C. 결론

 해당 분석은 Spotify의 음악 소비 패턴과 사용자 선호도를 이해하는데 중요한 기초자료가 될 것이다. 특히, 사용자 참여를 중심으로 한 챌린지 콘텐츠는 Spotify가 음악 소비의 새로운 패러다임을 선도하는데 중요한 기여를 할 것으로 기대된다. 젊은 세대의 다양한 음악 장르 선호와 스마트폰 사용의 용이함을 바탕으로, Spotify 사용자 간의 커뮤니티 형성을 촉진하고 자연스러운 마케팅 효과를 누릴 수 있다.

 앞으로 지속적인 데이터 분석과 소비자 피드백 반영을 통해 유입 고객과 충성 고객의 비율을 높이고 이들의 이탈 비율이 줄여 나가는 것이 필요하다.

 

D. 여담

 스포티파이는 이미 사용자추천 시스템이 매우 잘되어 있는 플랫폼으로 알고 있다. 한국에서 이들이 제대로 된 성과를 내지못한다는 것은 사용자 추천 시스템만의 문제가 아닐 것이다. 전 세계적으로 보았을 때  Spotify는 세계 1위 음악 플랫폼이다. 그렇다면 한국에서는 왜이리 주춤하고 있는 것일까. 필자는 멜론 등 한국에서 음악 플랫폼이 생성되기 시작할 때 함께 시작하지 못했기 때문이라 생각한다. 음악 플랫폼은 마치 은행과도 같다. 소비자들이 플랫폼을 쉽사리 바꾸지 못하고, 매우 사용하기 불편해질 때야 비로소 사용자가 움직이기 때문이다. 나도 여러번 음악 플랫폼을 바꾸어 보았지만, 내 취향에 맞는 음악들로 플레이리스트를 만들 때까지 꽤 많은 에너지를 쏟아야 했다. 이미 대부분의 음악 플랫폼은 이미 사용자 취향에 맞는 추천 시스템이 그들을 너무 잘 알기 때문에, 새 플랫폼에 내 정보를 주고 기존 사용하던 플랫폼과 비슷한 수준의 서비스를 제공받기 위해서는 줠대적 시간이 필요하다. 이 사이 사용자는 이 시간과 노력을 들여 플랫폼을 옮겨야 하는가에 대한 의문이 들 수 있고, 다시 한 번 플랫폼 변경에 대해 저울질할 것이다.

 

 이때  스포티파이는 한국 유저들을 사로잡기 위한 매력적인 포인트가 아쉽게도 불충분하다. 멜론과 같이 아티스트들과 독점 컨텐츠, 콘서트 표 매매 등의 콘텐츠들이 있는 것도 아니며, youtube music 과 같이 프리미엄 구독을 하면 광고로 끊김 없이 영상과 음악 모두 즐길 수 있다는 유혹적인 제안도 없다. 

 

 스포티파이가 아무리 사용자 추천 시스템이 잘 되어 있다고 하더라도 사용자가 느끼기에 국내 기존의 플랫폼에서도 거의 비슷한 수준의 서비스를 제공하고 있다면 스포티파이는 한국에서 살아남기 위한 새로운 모험을 떠나야 할 것으로 보인다.

 

fin.

A. Spotify data 데이터 정리하기

- 우선 podcast 관련 열은 사용하지 않을 것임으로 해당 열을 삭제한다.

df = df.drop(columns= ['pod_lis_frequency'
        ,'fav_pod_genre'
        ,'preffered_pod_format'
        ,'pod_host_preference'
        ,'preffered_pod_duration'
        ,'pod_variety_satisfaction'])
df.info()

- 'music_recc_rating'을 제외하고 모두 object type로 통일하였다.

 

B. 결측치 처리하기

- 결측치가 있는 ' preffered_premium_plan ' 열을 살펴보자

- ' preffered_premium_plan  ' 열은 구독을 했거나 구독을 할 의향이 있는 사람들에게 얼마를 지불할 것인가에 대한 수치이다.

df.value_counts('preffered_premium_plan', dropna=False, normalize=True)

 

- NaN 값이 40%로 데이터 대부분의 자리를 차지하고 있다.

- 이 값은 구독을 취소하거나 구독하지 않을 사용자임을 의미하는 것인지, 선택하지 않은 것인가에 대한 구분이 필요하다.

- NaN값이 어떤 의미인지를 알아볼 필요가 있다.

df.value_counts('spotify_subscription_plan', dropna=False)
df.value_counts('premium_sub_willingness', dropna=False)

- 'spotify_subscription_plan' : 현재 어떤 Spotify 구독 계획을 갖고 있는가

- 'premium_sub_willingness' :  프리미엄 구독을 하겠는가 혹은 앞으로도 프리미엄 구독을 계속할 의향이 있는가


용어 정리 및 가정

- 구독 중인 사용자: 총 사용자 중 구독중인 사용자를 의미한다.

- 구독 의향이 있는 사용자 : 신규 구독을 하거나 계속 구독할 의향이 있는 사용자를 포함하여 의미한다.

▷ 무료 사용자 424 명 중 일부는 구독 의향이 있을 수 있다고 가정함

▷  유료 사용자 96명은 모두 구독 중인 사용자로 가정함

1) 구독 중인 사용자 = 96명

2) 신규 구독을 하거나 계속 구독을 이어나갈 의향이 있는 사용자 = 186명

3) 구독을 하였거나 구독할 사용자 = 구독 중인 사용자 + 구독 의향이 있는 사용자 = 96명 + 186명 = 312명

☆ 구독 중인 사용자는 유료 사용자로만 고려함

☆ 무료 사용자 중 구독 의향이 있는 사용자만 포함하여 계산

4) 구독을 취소하거나 구독하지 않을 사용자 = 전체 사용자 - 구독을 하였거나 구독할 사용자 = 520명 - 312명 = 208명

결론

NaN 값과 구독을 취소하거나 구독하지 않을 사용자 값과 같다.

∴ NaN = 구독을 취소하거나 구독하지 않을 사용자 수


df['preffered_premium_plan'] = df['preffered_premium_plan'].fillna('No')

- 결측치가 있는 열은 ' preffered_premium_plan ' 전체 결측치에 'No' 문자값을 넣었다.

C. 이상치 확인하기

- 해당 데이터는 문자열로 구성되어 있다.

- 문자형 값을 고유한 수치로 변환하는 방법에 대해 숙지하지 못하여 열의 값을 모두 확인하는 방법으로 이상치를 확인하였다.

for column in df.columns:
    print(df[column].value_counts())
    print("\n")

 

더보기
더보기

각 컬럼별 값 확인

Age
20-35    422
12-20     71
35-60     23
6-12       3
60+        1
Name: count, dtype: int64


Gender
Female    391
Male      114
Others     15
Name: count, dtype: int64


spotify_usage_period
More than 2 years     169
1 year to 2 years     141
6 months to 1 year    119
Less than 6 months     91
Name: count, dtype: int64


spotify_listening_device
Smartphone                                                                              300
Computer or laptop                                                                       54
Smartphone, Computer or laptop                                                           48
Smart speakers or voice assistants                                                       43
Computer or laptop, Smart speakers or voice assistants                                   14
Smartphone, Smart speakers or voice assistants                                           10
Smartphone, Wearable devices                                                              8
Smartphone, Computer or laptop, Smart speakers or voice assistants, Wearable devices      8
Wearable devices                                                                          8
Smartphone, Computer or laptop, Smart speakers or voice assistants                        7
Smartphone, Computer or laptop, Wearable devices                                          5
Computer or laptop, Wearable devices                                                      5
Smartphone, Smart speakers or voice assistants, Wearable devices                          5
Computer or laptop, Smart speakers or voice assistants, Wearable devices                  3
Smart speakers or voice assistants, Wearable devices                                      2
Name: count, dtype: int64


spotify_subscription_plan
Free (ad-supported)            424
Premium (paid subscription)     96
Name: count, dtype: int64


premium_sub_willingness
No     334
Yes    186
Name: count, dtype: int64


preffered_premium_plan
No                                208
Individual Plan- Rs 119/ month     95
Student Plan-Rs 59/month           94
Duo plan- Rs 149/month             84
Family Plan-Rs 179/month           39
Name: count, dtype: int64


preferred_listening_content
Music      410
Podcast    110
Name: count, dtype: int64


fav_music_genre
Melody                       259
classical                     87
Pop                           85
Rap                           55
Electronic/Dance              16
All                            6
Rock                           4
Kpop                           4
Classical & melody, dance      2
Old songs                      1
trending songs random          1
Name: count, dtype: int64


music_time_slot
Night        312
Afternoon    117
Morning       91
Name: count, dtype: int64


music_Influencial_mood
Relaxation and stress relief                                                                                     195
Uplifting and motivational                                                                                        67
Sadness or melancholy                                                                                             55
Relaxation and stress relief, Uplifting and motivational                                                          44
Relaxation and stress relief, Uplifting and motivational, Sadness or melancholy, Social gatherings or parties     35
Relaxation and stress relief, Sadness or melancholy                                                               33
Relaxation and stress relief, Uplifting and motivational, Sadness or melancholy                                   22
Social gatherings or parties                                                                                      16
Relaxation and stress relief, Uplifting and motivational, Social gatherings or parties                            14
Relaxation and stress relief, Social gatherings or parties                                                        13
Uplifting and motivational, Sadness or melancholy                                                                 12
Relaxation and stress relief, Sadness or melancholy, Social gatherings or parties                                  8
Uplifting and motivational, Social gatherings or parties                                                           4
Sadness or melancholy, Social gatherings or parties                                                                1
Uplifting and motivational, Sadness or melancholy, Social gatherings or parties                                    1
Name: count, dtype: int64


music_lis_frequency
While Traveling                                                                111
leisure time                                                                    87
While Traveling, leisure time                                                   65
While Traveling, Workout session, leisure time                                  48
Workout session                                                                 33
Study Hours                                                                     19
Office hours                                                                    16
While Traveling, Workout session                                                16
Office hours, While Traveling                                                   12
Office hours, While Traveling, leisure time                                     12
Office hours, While Traveling, Workout session                                  10
Study Hours, While Traveling, leisure time                                      10
Study Hours, While Traveling                                                     9
Office hours, Study Hours, While Traveling, Workout session, leisure time        7
Study Hours, While Traveling, Workout session                                    7
Office hours, Study Hours, While Traveling                                       7
Study Hours, Workout session                                                     6
Office hours, leisure time                                                       6
Workout session, leisure time                                                    6
Study Hours, While Traveling, Workout session, leisure time                      4
Study Hours, Workout session, leisure time                                       4
Study Hours, leisure time                                                        4
Office hours, While Traveling, Workout session, leisure time                     3
Office hours, Study Hours, While Traveling, leisure time                         3
Office hours, Workout session                                                    3
Office hours, While Traveling,                                                   2
Office hours, Study Hours, Workout session                                       2
While Traveling, Workout session, leisure time, Night time, when cooking         1
Social gatherings                                                                1
Random                                                                           1
Office hours, Workout session, leisure time                                      1
While Traveling, Before bed                                                      1
Office hours, Study Hours, While Traveling, Workout session                      1
Office hours, Study Hours, While Traveling, Workout session, leisure time,       1
Office hours,Study Hours, While Traveling, leisure time                          1
Name: count, dtype: int64


music_expl_method
recommendations                              113
Playlists                                    112
recommendations, Playlists                    86
Others                                        55
Radio                                         51
Playlists, Radio                              18
recommendations, Playlists, Others            18
recommendations, Others                       15
recommendations, Playlists, Radio             13
Playlists, Others                              9
Radio, Others                                  7
recommendations, Radio                         6
Playlists, Radio, Others                       6
recommendations, Radio, Others                 4
recommendations, Playlists, Radio, Others      2
recommendations,Others, Social media           1
Others, Social media                           1
Others, Friends                                1
recommendations, Others, Social media          1
Others, Search                                 1
Name: count, dtype: int64


music_recc_rating
3    190
4    174
5     86
2     56
1     14
Name: count, dtype: int64

- 'Age' 칼럼의 6-12 범위, 60+ 범위값을 이상치라 둔다.

- Spotify 일반적으로 성인(18세 이상)을 대상으로 하는 서비스이다.

- 6~12세의 경우, 부모와 함께 사용할 수도 있지만 값의 개수가 3개이고, 그 비율이 높지 않으므로 이상치로 본다.

- 60세 이상의 사용자가 극소수라는 점에서 이상치로 분류하였다.

 

df.drop(df[df['Age'].isin(["6-12", "60+"])].index, inplace=True)

- 이상치 제거를 통해 아래와 같이 칼럼이 변경되었다.

D. 최종 데이터 info()

 

A. 데이터 변수의 이름, 설명 확인하기

- Spotify 사용자 행동 데이터 세트

- 고객 개인정보, 사용장치, 행동에 대한 데이터 존재

- 각 컬럼별 이름 설명

번호 컬럼명 설명
1 Age 사용자의 연령층은 어떻게 되는가
2 Gender 사용자의 성별은 어떻게 되는가
3 spotify_usage_period Spotify를 사용한 지 얼마나 되는가
4 spotify_listening_device Spotify를 청취할 때 주로 사용하는 장치는 무엇인가
5 spotify_subscription_plan 현재 어떤 Spotify 구독 계획을 갖고 있는가
6 premium_sub_willingness  프리미엄 구독을 하겠는가 혹은 앞으로도 프리미엄 구독을 계속할 의향이 있는가
7 prefered_premium_plan 프리미엄이거나 프리미엄을 구독할 의향이 있는 경우 구독료를 얼마를 지불하겠는가
8 preferred_listening_content 무엇을 더 듣고 싶은가
9 fav_music_genre  어떤 장르의 음악을 가장 좋아하는가
10 music_time_slot 가장 좋아하는 음악 감상 시간은 언제인가
11 music_Influencial_mood 음악을 들을 때 다음 중 어떤 기분이나 상황이 음악 선택에 가장 큰 영향을 미치는가
12 music_lis_frequency 언제 음악을 더 자주 듣는가
13 music_expl_method Spotify에서 새로운 음악을 어떤 방법으로 접하는가
14 music_recc_rating Spotify 음악 추천은 몇 점인가
15 pod_lis_frequency 팟캐스트를 얼마나 자주 듣는가
16 fav_pod_genre 팟캐스트 중 어떤 장르를 가장 좋아하는가
17 preffered_pod_format 일반적으로 선호하는 팟캐스트 형식은 무엇인가
18 pod_host_preference 무명인이 호스팅하는 팟캐스트 혹은 유명인이 호스팅하는 팟캐스트를 선호하는가
19 prefered_pod_duration 짧은 팟캐스트 에피소드(30분 미만) 또는 긴 에피소드(30분 이상)를 선호하는가
20 pod_variety_satisfaction  Spotify의 팟캐스트의 다양성과 가용성에 만족하는가

 

B. Spotify 데이터 구성 확인

df.info()
df.isnull().sum()

 

- value_counts() 함수를 사용하면 아래와 같이 모든 정보가 뜨지만 티스토리에서 보기 어려움으로 한 행만 불러오기로 한다.

df.value_counts()

 

- 50번째 행의 데이터를 보면 다음과 같다.

df.iloc[50, :]

 

- 'preffered_premium_plan', 'fav_pod_genre', 'preffered_pod_format' , 'pod_host_preference', 'preffered_pod_duration' 은 null 값을 포함하고 있다.

- podcast 의 경우 결측치가 많고, 이를 대신할 수 있는 데이터가 없으므로 관련 열은 제외하고 데이터 분석에 임한다.

- 'prefferd_premium_plan'의 null값은 어떤 값을 의미하는지 아직 알 수 없으므로 문자 'NA'로 대체하는 방법을 고려한다.

 

df.describe()

- 위와 같이 'music_recc_rating'  음악 추천과 관련된 점수의 기초통계를 확인할 수 있다.

- describe() 의 경우, 수치형 데이터를 기반으로 기초 통계를 출력한다.

- 다만 info()에서 확인하였듯 수치형 데이터는 음악 추천 점수열 밖에 없다.

 

df.describe(include=object)

- object type의 기초 통계도 출력하면 아래와 같다.

- 열이 너무 많아 분할하여 올린다.

 

 

- 기초 통계에서 알 수 있는 사항은 다음과 같다.

  •  20-35세의 응답자가 422명으로 가장 많으며, Spotify의 주요 소비층이다.
  • 음악 스트리밍 서비스가 젊은 세대에게 특히 인기가 있음을 나타낸다.
  • 그 중 여성 소비자가 391명으로 전체의 75%를 차지하고 있다.
  • 2년 이상 사용한 응답자가 169명으로, 오랜 기간 동안 서비스를 이용하고 있는 사람이 32.5%이다.
  • 스마트폰을 사용하는 응답자가 300명으로, 해당 기기가 음악 소비의 주요 플랫폼임을 알 수 있다.
  • 무료(광고지원) 구독을 사용하는 응답자가 424명으로, 유료 구독자가 상대적으로 적다.

- 여기서 이끌어낼 수 있는 인사이트는 아래와 같다.

  • 젊은 소비자들은 최신음악과 트랜드에 민감하게 반응하며, 디지털 플랫폼을 적극적으로 활용하기에 이들의 선호도를 충족시키는 것이 중요하다.
  • 여성 소비자가 선호하는 콘텐츠나 특정 장르에 맞춘 마케팅 전략이 필요하다.
  • 사용자의 충성도 및 서비스에 대한 만족도가 높으므로 장기적인 사용자 확보를 위해 지속적인 서비스 개선과 사용자 경험 향상이 중요하다.
  • 모바일 중심의 서비스 제공이 필수적이다.
  • 약 82%에 달하는 사용자가 무료 서비스를 선호하기에 유료 구독으로의 전환을 유도하기 위해서는 추가적인 가치 제안을 고려해야 한다.

 

 

A. ON AIR 분석절차를 기반으로 한 프로젝트

1) Objective (목표)

- 프로젝트 목표:  음악 플랫폼(Spotify) 유저 행동 데이터를 분석하여 서비스 현황을 확인하고 개선점을 제시하고자 한다.

- 부가적인 목표: 기초 프로젝트인만큼 데이터 EDA를 진행하고 결과를 도출하여 발표하는 것을 가장 큰 목표를 두고 있다.

 

2) Necessary data (데이터)

- 데이터 소스(유형, 범위 명시) 와 수집 계획을 고려한다.

- 이에 따라 kaggle에 있는 Spotify User Behavior Dataset 자료를 활용하고자 한다.

- 데이터 출처:  https://www.kaggle.com/datasets/meeraajayakumar/spotify-user-behavior-dataset/data

 

3) Analytics (분석)

- 데이터 전처리 및 시각화하여 인사이트를 도출하는 계획을 제시한다.

- EDA를 통해 이상치/결측치를 처리하고 시각화한다.

 

4) Interpretation (해석)

- 분석 결과 해석하고 비즈니스에 어떻게 적용할지 제언을 제하고자 한다.

 

5) Report (보고)

- 인사이트가 잘 돋보이게 보고서를 작성한다.

 

B. 프로젝트 계획서

구분 작성내용
프로젝트 명 음악 플랫폼 유저 행동 데이터 분석을 통한 유료 구독자 수 증진 방안
프로젝트 목표 사용자 패턴을 이해하고 데이터 분석을 통한 서비스개선 및 유료 구독자 수 증진
프로젝트 핵심내용 사용자 행동 데이터를 수집 · 분석하여 유료 구독 유도를 위한 전략 개발

 

 

C. EDA (Exploratory Data Analysis, 탐색적 데이터분석)

https://www.dropbase.io/post/data-preparation-101

- 정의: 수집한 데이터를 다양한 각도에서 관찰하고 이해하는 과정으로 데이터를 분석하기 전에 그래프나 통계적인 방법으로 자료를 직관적으로 바라보는 과정

 

-  과정

1) 분석의 목적과 변수가 무엇이 있는지, 개별 변수의 이름이나 설명을 갖는지 확인한다.

2) 데이터를 전체적으로 살펴보며 이상치, 결측치 등을 확인한다.

3) 데이터의 개별 속성 값이 예측한 범위와 분포를 갖는지 확인하며, 아닌 경우 그 이유를 확인한다.

4) 개별 속성 관찰에서 찾아내지 못했던 패턴을 상관관계, 시각화 등을 통해 나타낸다.

+ Recent posts