7주차 월요일, 드디어 개인 EDA 프로젝트 발표가 있었는데요. 주제 구상 - 데이터 수집, 분석, 시각화 - 인사이트 도출 - 관련 논문 분석 - 모델링 후 논문 검증 - 인사이트 도출 - 발표자료 구성 - 그리고 이 모든 작업을 몇 번이고 되돌아 반복하는 일련의 과정을 통해, 정말 많은 것을 배우고 성장할 수 있었던 기회였습니다.

이번 포스팅에서는 개인 EDA 프로젝트를 준비하면서 스스로 기특하고 자랑스러웠던 점, 그리고 스스로 확인한 나의 강점을 먼저 작성해 보겠습니다. 그리고 이어서 개선할 점을 이어서 적어보겠습니다.


칭찬할 점

첫째, 끊임 없이 피드백을 찾아 나섰습니다.

저희 ASAC 강사님께서는 이번 개인 EDA 발표를 준비하는 25명의 동기들을 모두 혼자서 멘토링하시느라 정말 바쁘셨는데요. 다르게 말씀드리면, 이번 프로젝트에서 강사님 1분을 제외하고는 조언을 구할 다른 멘토가 단 한 분도 계시지 않았습니다. 강사님께서는 몸이 열 개라도 모자라 보이셨고 야근을 자주 하셨어요. 그렇게 원하는 만큼 피드백을 얻기 어려웠던 (나름 열악했던) 상황 속에서, 저는 계속해서 현직자들과 네트워킹을 시도하며 프로젝트에 대한 가이드를 스스로 찾아 나섰습니다. 주로 일일 세미나를 오셨던 강사분들께 부탁을 드렸는데요. 쉬는 시간에 냅다 노트북을 가지고 가서 프로젝트를 보여드리고 조언을 구했습니다. 아니면 이메일이나 링크드인으로 자기 소개를 보내고 강의에 대해 느낀점을 말씀드린 뒤 정중하게 피드백 부탁을 드렸습니다.

쉬운 일은 아니었습니다. 바쁘신 분들께 초면에 그런 부탁을 드린다는 게 생각보다 많은 용기가 필요했어요. 제가 부족한 만큼 정성스럽고 따뜻한 피드백보다는 타격감 있는 직설적인 피드백들을 주로 받게 되었고, 그래서 자존심도 속도 많이 상하기도 했는데요. 그래도 정말 감사한 마음으로 모두 수용했고, 피드백을 찾아 나서는 일을 절대 멈추지 않았습니다. 나 혼자였다면 몰랐거나 흐린눈 하며 내버려 뒀을 어설프고 엉성한 부분들을 열심히 뜯어고치고 보완하면서 프로젝트의 완성도를 올릴 수 있었습니다.

문제상황을 적극적으로 해결하고자 전문가에게 도움을 청할 용기를 낸 나 자신을 정말 칭찬하고 싶습니다. 이번 프로젝트를 통해 네트워킹의 중요성을 다시한 번 확인했습니다. 사람들이 왜 협업을 통해 일을 진행하는지 알 수 있었던 좋은 기회였어요. 앞으로도 이렇게 스스로 배움을 찾아 나서는 적극적인 자세를 통해 더 많이 성장하고 발전하는 제가 되겠다고 다짐했어요. 

둘째, 어려운 과제 속에서 포기하거나 타협하지 않았습니다. 원하는 목표를 달성할 수 있었던 가장 큰 이유였습니다.

저는 25명의 동기들 중에서 이번 개인 EDA 프로젝트에 유일하게 AI 모델링 작업을 포함하여 발표했습니다. 아직 머신러닝 수업을 듣기 전이었지만, 제가 데이터 사이언스와 엔지니어링을 통한 문제해결에 큰 뜻과 욕심이 있다는 걸 아시는 강사님께서 제가 원하는 방향으로 프로젝트를 수립하도록 적극적으로 도와주셨기 때문에 가능한 일이였습니다. 또, CNN을 통한 Image Classification이 프로젝트의 메인 소재였는데, 평소 열심히 Neural Network의 원리를 탐구하고 Tensorflow Keras나 PyTorch와 같은 프레임워크들을 적극적으로 연습하고 실습한 덕분에 원하는 대로 프로젝트를 준비할 수 있었습니다.

딥러닝 과정에서 원하는 목표 성능이 확보 될 때 까지 여러 방법으로 모델을 수정하고 학습시키고 평가하는 일을 수없이 반복했습니다. 마음처럼 일이 풀리지 않아 마음이 흔들릴 때마다 매번 잘 다잡으려고 노력했어요. 그렇게 복잡하지 않고 간단할 거라고 생각했던 것도 예상대로 진행되지 않는 것을 보면서 실무에서는 더 어려운 일이 많이 일어나겠구나 싶었습니다. 여하간, 힘들고 답답할 때마다 타협하지 않고 목표한 일들을 무사히 수행해낸 나의 집념에 박수를 보내고 싶습니다.

앞으로 더 열심히 공부하고 지식 수준을 높여서 더 어렵고 복잡한 모델도 학습시켜 보고 싶습니다. 인공지능은 정말 너무 어려운데, 어려운 만큼 또 너무 재밌어요. 현재 ASAC 과정에서 머신 러닝 모델링 방법들을 여러 가지 배우기 시작했는데요. 평소 몰두했던 인공신경망(Neural Network) 뿐만 아니라 다른 모델링 학습 법도 이렇게 다양했다니 매일이 놀라움의 연속이더라고요. 즐겁게 배우고 있어요. 언제나 새로운 것들을 proactive한 자세로 배우고 탐구하는 제가 되도록 하겠습니다.


개선할 점

데이터 분석의 전문성을 높이기 위해서는 통계학적 지식을 보완할 필요가 있음을 깨달았습니다.

이번 프로젝트는 주제의 특성상 저의 도메인 지식을 적극 활용할 수 있었던 덕분에 통계학적 접근이 크게 필요하지 않았다고 생각했는데요. 그럼에도 불구하고 통계학적으로 분석한 근거가 함께 수반되었다면 더 논리적인 프로젝트 진행이 가능했겠다는 아쉬움이 남습니다. 

저는 지금 고려사이버대학교 소프트웨어공학과 학생으로 이번 3학년 1학기에 확률과 통계 수업을 듣고 있는데요, 수업 내용을 다시 한 번 정리하고 복습해서 다음 프로젝트에 꼭 적용해 보기로 했습니다. 또, 이번 여름 계절 학기에도 통계 관련 수업을 선택해서 연속적으로 수업을 듣기로 했습니다. 앞으로 통계학적 지식을 보충해서  자신뿐만 아니라 다른 사람들 역시 데이터 기반의 의사결정을 내릴 수 있도록 돕는 데이터 전문가가 될 수 있도록 노력하겠습니다.


 

이것으로 첫 번째 프로젝트(개인 EDA) 에 대한 회고 작성을 마치도록 하겠습니다. 개인 프로젝트 깃허브 링크가 궁금하시다면 댓글을 달아주시거나 niceonesuri@gmail.com으로 연락주세요. 감사합니다.

 

 

오늘 포스팅에는 유명한 Kaggle 신용카드 사기 감지 데이터셋(Credit Card Fraud Detection)을 가지고 데이터 전처리/분석/머신러닝을 하는 과정을 기록할 것입니다. 데이터 EDA를 진행하고 적절한 전처리를 해준 후 머신러닝 모델링을 수행하고 성능 지표를 비교하는 일련의 과정을 전부 담을 예정인데요, 의식의 흐름대로 작성할 예정이라 중간 중간 Tmi도 많고 삽질하는 내용까지도 필터링 없이 기록할 것임을 미리 알려드립니다.

 

1. 데이터 불러오기, 컬럼/결측치/데이터 타입 확인

https://www.kaggle.com/datasets/mlg-ulb/creditcardfraud/data

 

Credit Card Fraud Detection

Anonymized credit card transactions labeled as fraudulent or genuine

www.kaggle.com

!kaggle datasets download -d mlg-ulb/creditcardfraud
!unzip "/content/creditcardfraud.zip"

먼저 캐글 신용카드 사기 감지 데이터셋을 다운로드받아서 가지고 옵니다. 저는 API Command를 복사하여 실행하고 코랩에 다운받아진 파일을 unzip해주는 형식으로 간단히 데이터를 불러왔습니다. 이렇게 하면 파일을 직접 다운로드해서 가져오는것보다 훨씬 빠릅니다.

 준비된 데이터프레임에 총 31개의 column, 284807개의 row를 확인했습니다.

card.info()

info 메소드를 통해 컬럼의 데이터타입과 결측치 여부를 확인했는데, 다행히 모든 컬럼에 결측치 없이 데이터가 잘 들어가 있었고, 정수타입의 Class 컬럼을 제외한 모든 열은 float64 타입임을 확인했습니다. 컬럼명에 들어있는 V1~V28의 경우 어떤 속성인지 알 수는 없고, Amount는 해당 row의 결제 금액, Class는 정상/사기 이진 분류(binary classification) 결과(label) 컬럼에 해당합니다.

만약 object 타입의 컬럼이 있었다면 라벨 인코딩(Label Encoding)이나 원핫 인코딩(One-Hot Encoding) 등의 작업을 통해 값을 숫자로 변환하는 작업이 필요합니다. 다행히 모두 숫자로 이루어져 있기 때문에 따로 인코딩 작업은 들어가지 않아도 될 것 같습니다 :-)

또, NaN 등의 결측치가 있는 경우 적절한 근거를 가지고 대표값으로 결측치를 채워 넣거나 해당 row를 삭제하는 등의 작업을 통해 결측치를 제거해 주어야 합니다. 다행히도 우리의 친절한 캐글 신용카드 데이터셋은 우리에게 그런 노가다를 요구하지 않고 있습니다... (흡족)

 

2. 결제 금액 분포도 그래프로 확인

결제 금액을 나타내는 Amount 컬럼의 분포도를 그래프로 확인해 보겠습니다.

import seaborn as sns
import matplotlib.pyplot as plt

plt.style.use('fivethirtyeight')
plt.figure(figsize = (10, 4))
plt.xticks(range(0, 40000, 1000), rotation = 45)
sns.histplot(card['Amount'], bins = 100, kde = True)
plt.show()

Tmi지만 저는 matplotlib의 테마 중에서 fivethirtyeight를 가장 좋아합니다. 왜인지는 모르겠습니다. 그래서 웬만하면 그래프를 그릴 때 fivethirtyeight으로 테마를 변경한 다음에 그래프를 뽑는 편입니다. 이런 각박한 작업 속에서도 내 취향이란 걸 반영할 수 있다는 게 전 재밌더라구요^_^...;;;

plt.figure(figsize = (8, 4))
plt.xlim(0, 1000)
plt.xticks(range(0, 1000, 50), rotation = 45)
sns.histplot(card['Amount'], bins = 500, kde = True)
plt.show()

다시 본론으로 돌아와서... 0부터 약 500달러 미만의 결제 금액이 차지하는 비율이 압도적으로 많은 것을 확인할 수 있었습니다. 이 그래프를 확인한 후 저는

과하게 치우친 Amount 컬럼의 값을 로그변환하여 보정해 주면 모델의 성능 지표가 상승할 것이다

라는 첫 번째 가설을 세우게 됩니다.

 

3. Amount 컬럼 박스 플롯 그리기

결제 금액 분포도를 살펴본 이후 저는 Amount 컬럼의 박스 플롯을 그려서 이상치를 확인해 봐야겠다는 생각이 들었는데요.

sns.boxplot(data = card, y = 'Amount')

쩝... 네... 첫 번째 삽질 보여드립니다. 방금 위에서 데이터가 쏠려 있음을 확인해놓고 박스플롯이 예쁘게 그려지리라고 생각한 제가 좀 바보같네요. 결제 금액의 박스 플롯은 큰 의미가 없는 것 같으니 다음으로 데이터 프레임의 상관 계수를 확인해 보도록 하겠습니다.

 

4. 상관 계수 시각화하기

card.corr()

corr() 메소드를 이용해서 모든 컬럼 사이의 상관계수를 나타내어 보았습니다. 한 눈에 들어오질 않으니 일단 쉐입을 확인해 볼게요...

card_corr = card.corr()
card_corr.shape
# (31, 31)

31개의 컬럼 사이의 상관계수가 (31, 31) 정사각 쉐입의 데이터프레임으로 이쁘게 반환되었습니다. 저는 저렇게 e어쩌고로 나타내진 숫자값들은 봐도 봐도 적응이 안되더라구요. 어쨌든 이 상태로는 전체 분포를 한 눈에 알아보기 힘들기 때문에 seaborn의 heatmap을 이용해서 그래프로 시각화를 해 보도록 하겠습니다.

두 번째 Tmi... 저는 또 이렇게 색상이 중요한 그래프를 그릴 때 괜히 컬러 팔레트 고르느라 1-2분을 더 낭비하는 것을 좋아합니다.. 역시 각박한 일상 속에서 찾아내는 저만의 소소한 작은 행복입니다 (^_^*) 이번에는 따뜻한 색감의 노랑-브릭 계열의 팔레트를 골라 보았습니다.

plt.figure(figsize = (8, 8))
sns.heatmap(card_corr, cmap = "YlOrBr")
plt.tight_layout()
plt.savefig("corr.png")

그래프를 큼직하게 뽑아서 괜히 savefig를 이용해 png파일로 다운로드까지 해 보았습니다. 이것도 자주 안하면 계속 까먹어요. ㅋ

상관관계 히트맵에서 양의 상관관계가 높을수록 색깔이 진한 갈색에 가깝고, 음의 상관관계가 높을수록 연한 노란색에 가깝습니다. 그래프의 변두리에 희끗희끗하게 보이는 밝은 부분들이 음의 상관관계가 높은 지점입니다. 

이 때, 레이블에 해당하는 Class컬럼과 가장 낮은 상관관계를 보이는 컬럼은 V14와 V17인것으로 확인되는데요.

이렇게 레이블과 관계 없는 컬럼의 이상치를 제거하면 모델의 성능 지표가 상승할 것이다

라는 두 번째 가설을 세우게 됩니다. 

 

5. [before] baseline 모델링

(5-1) train, test 구분하고 레이블 비율 확인해 보기

card.drop('Time', axis = 1, inplace = True)

먼저 큰 의미가 없는 Time 컬럼은 drop을 통해 삭제를 해 주고 시작하겠습니다.

def train_test(df):
    card_copy = card.copy()
    X_features = card_copy.iloc[:, :-1] # label에 해당하는 마지막 'Class'컬럼 제외
    y_label = card_copy.iloc[:, -1]     # label에 해당하는 마지막 'Class'컬럼만

    from sklearn.model_selection import train_test_split
    X_train, X_test, y_train, y_test = train_test_split(X_features,
                                                        y_label,
                                                        test_size = 0.2,
                                                        random_state = 1004,
                                                        stratify = y_label)
    
    return X_train, X_test, y_train, y_test

X_train, X_test, y_train, y_test = train_test(card)

card 데이터프레임의 마지막 'Class' 컬럼을 기준으로 feature과 label을 구분했구요. 싸이킷런의 train_test_split을 통해 8:2의 비율로 train과 test를 분할했습니다. 3번째 Tmi...인데.. 저는 random_state로 항상 1004(천사)를 사용합니다. ^^;;;;;

len(X_train), len(X_test), len(y_train), len(y_test)
# (227845, 56962, 227845, 56962)

이렇게 나눠졌구요. 그럼 데이터에서 레이블 비율(0과 1의 비율)이 어떻게 되는지 살펴볼까요?

y_train.value_counts(normalize = True).apply(lambda x: str(round(x * 100,4)) + '%')
y_test.value_counts(normalize = True).apply(lambda x: str(round(x * 100,4)) + '%')

와우! 사기에 해당하는 1번 레이블이 약 0.17%에 해당하는 극소수로 확인되었습니다. 하긴, 사기 거래가 2~30%씩 차지하고 있으면 그것도 말이 안되겠네요. 저는 이렇게 뭐든 시각화하면서 가지고 있는 데이터와 친숙해지는 작업이 꼭 필요하다고 생각합니다. 그렇지 않으면 의미 없는 숫자놀음에 그치게 된다고 생각해요,,, 어쨌거나 저쨌거나 이 비율 확인 후 저는 

평가 지표(metrics)로 Accuracy가 아닌 다른 지표들을 전부 다 확인할 필요가 있음

이라는 결론을 내렸고, 다양한 평가 지표를 모두 확인해 보기로 결정했습니다.

(5-2) 모델 학습 후 예측 성능 평가

def train_eval(model, f_tr = None, f_test= None, t_tr= None, t_test= None):
    # 모델 학습
    model.fit(f_tr, t_tr)

    # 예측 
    pred = model.predict(f_test)
    pred_proba = model.predict_proba(f_test)[:, 1]

    # 평가 (1) confusion matrix 출력
    from sklearn.metrics import confusion_matrix
    confusion = confusion_matrix(t_test, pred)
    print("----- confusion matrix -----")
    print(confusion)
    
    # 평가 (2) accuracy / precision / recall / f1 / roc_score 출력
    from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score
    accuracy = accuracy_score(t_test, pred)
    precision = precision_score(t_test, pred)
    recall = recall_score(t_test, pred)
    f1 = f1_score(t_test, pred)
    roc_auc = roc_auc_score(t_test, pred_proba)
    print("----- evaluation score -----")
    print(f"accuracy : {accuracy:.4f}")
    print(f"precision : {precision:.4f}")
    print(f"recall : {recall:.4f}")
    print(f"f1 : {f1:.4f}")
    print(f"ROC_SCORE : {roc_auc:.4f}")

 

먼저 baseline 모델을 학습시키고 예측을 수행한 다음 confusion matrix와 여러 평가 지표를 출력하는 함수를 작성했습니다.

from lightgbm import LGBMClassifier
from xgboost import XGBClassifier

lgbm = LGBMClassifier(n_estimators = 1000,
                      num_leaves = 64,
                      n_jobs = -1,
                      boost_from_average = False)

xgb = XGBClassifier(n_estimators = 1000,
                    learning_rate = 0.05,
                    max_depth = 3, 
                    eval_metric = 'logloss')

이후 XGBM, LightGBM 모델 2가지를 학습하고 예측 평가를 수행했습니다.

train_eval(lgbm, X_train, X_test, y_train, y_test)
train_eval(xgb, X_train, X_test, y_train, y_test)

(좌) LGBM / (우) XGB

결과값은 위와 같이 나왔는데요. Accuracy는 대체로 동일하고, precision과 recall, f1은 LightBGM 모델이 더 높았고, roc_auc 점수는 XGB 모델이 더 높게 측정되었습니다.

 

6. [after] 가설 2가지 적용하기

이 번엔 데이터 분석을 통해 세웠던 2가지의 가설을 검증해보는 작업을 수행해 보겠습니다.

레이블과 관계 없는 컬럼의 이상치를 제거하고 Amount 컬럼의 값을 로그변환하여 보정한다면 모델의 성능 지표를 상승시킬 수 있을 것이다

(6-1) 이상치 찾아내기

def find_outliers(df, column):
    # 해당 컬럼의 사기 데이터 series 선언
    frauds = df[df['Class'] == 1][column]
    
    # IQR 구하기
    import numpy as np
    Q1 = np.percentile(frauds.values, 25)
    Q3 = np.percentile(frauds.values, 75)
    IQR = Q3 - Q1

    # 1QR에 1.5를 곱해서 최댓값, 최솟값 구하기
    lowest_value = Q1 - IQR * 1.5
    highest_value = Q3 + IQR * 1.5

    # 이상치 데이터 찾아서 인덱스만 리스트로 뽑아내기
    outliers = frauds[(frauds < lowest_value) | (frauds > highest_value)].index

    # 이상치 인덱스 리스트 리턴
    return outliers

데이터 프레임과 원하는 컬럼명을 인자로 받아서 해당 컬럼의 이상치 인덱스를 리스트로 반환하는 함수를 작성했습니다 :) 처음부터 바로 함수를 작성한 건 아니고, 컬럼 하나를 지정해서 하나하나 실행해본 다음 깔끔하게 함수로 정리하는 작업을 통해 함수를 완성했습니다.

find_outliers(card, "V14")
# Index([8296, 8615, 9035, 9252], dtype='int64')

find_outliers(card, "V17")
# Index([], dtype='int64')

아까 히트맵을 통해 확인했을 때 Class컬럼과 가장 낮은 상관관계를 보이는 컬럼은 V14와 V17인것으로 확인되었었는데요, find_outliers 함수를 통해 확인해 본 결과 V14 컬럼의 경우 이상치가 4개 발견되었고, V17 컬럼의 경우 이상치가 발견되지 않았습니다 :)

 따라서 저는 V14 컬럼의 이상치 데이터 4개를 삭제해 보겠습니다.

 

(6-2) 이상치 제거, 로그 변환 후 train, test 분리

def train_test_no_outliers(df):
    card_copy = df.copy()
    # outlier 제거
    outliers = find_outliers(card_copy, "V14")
    card_copy.drop(outliers, axis = 0, inplace = True)

    # Amount 컬럼 로그변환
    import numpy as np
    card_copy['Amount'] = np.log1p(card_copy['Amount'])
    
    # 데이터셋 나누기
    X_features = card_copy.iloc[:, :-1] # label에 해당하는 마지막 'Class'컬럼 제외
    y_label = card_copy.iloc[:, -1]     # label에 해당하는 마지막 'Class'컬럼만

    from sklearn.model_selection import train_test_split
    X_train, X_test, y_train, y_test = train_test_split(X_features,
                                                        y_label,
                                                        test_size = 0.2,
                                                        random_state = 1004,
                                                        stratify = y_label)
    
    return X_train, X_test, y_train, y_test

X_train_af, X_test_af, y_train_af, y_test_af = train_test_no_outliers(card)

train, test 분리하는 함수에 Amount 컬럼 로그변환과 outlier rows 제거하는 과정을 추가한 다음 분리 작업을 실시했습니다.

(6-3) 모델 학습 후 예측 성능 평가

train_eval(lgbm, X_train_af, X_test_af, y_train_af, y_test_af)
train_eval(xgb, X_train_af, X_test_af, y_train_af, y_test_af)

(좌) LGBM / (우) XGB

다음과 같이 결과가 나왔습니다.

 

7.  모델 4개 비교

지금까지 학습시킨 모델들의 성능 지표를 표로 작성하여 한눈에 비교해 보겠습니다.

  Baseline(XGB) Baseline(LGBM) After(XGB) After(LGBM)
Accuracy 0.9996 0.9996 0.9996 0.9996
Precision 0.9419 0.9405 0.9405 0.9302
Recall 0.8265 0.8061 0.8061 0.8163
F1 0.8804 0.8681 0.8681 0.8696
ROC Score 0.9853 0.9863 0.9882 0.9793

저는 이상치 제거와 로그 변환의 전처리 과정을 거친 다음 학습한 After 모델들의 모든 성능 지표가 다 개선될 것이라고 생각했습니다. 하지만 실습 결과 Precision / Recall / F1의 경우 오히려 Baseline의 수치가 더 높게 나온것을 확인할 수 있었습니다. 역시 인생이란 건 그렇게 호락호락하지 않습니다. 원하는 대로 결과가 안 나와서 좀 찝찝하긴 한데요...

다만, After XGB 모델 ROC-AUC 스코어의 경우 Baseline 모델들보다 점수가 더 상승한 것을 확인할 수 있었습니다. 이번에 다룬 Kaggle 신용카드 사기감지 데이터셋의 경우, 기존에 미리 확인한 대로 전체 데이터의 약 0.17%만이 사기에 해당하는 극도로 치우친 값을 가지고 있었습니다. 이렇게 과하게 편향된 데이터셋의 경우 ROC-AUC 스코어가 유의미하므로, 만약 4가지 모델 중 한 가지를 골라야 한다면 저는 ROC-AUC 스코어가 가장 높게 나온 After(XGB) 모델을 고르는 것도 나쁘지 않겠다는 생각을 했습니다.

 

8. 마무리

이번 포스팅은 여기에서 실습을 마치도록 할텐데요. 위의 4가지 모델에서 성능 지표를 더 올리기 위해서는 0.17%에 해당하는 사기 데이터를 펌핑해서 수를 늘려 주는 방법을 사용하면 좋을 것 같습니다. 마음같아서는 지금 당장 하고싶지만 할일이 많아서 일단 여기에서 끊겠습니다만, 좀 찝찝하기 때문에 시간여유가 생기면 모델 개선 작업을 추가로 수행해서 추가 포스팅을 하도록 하겠습니다.

이번 실습을 통해서 앞으로 다가올 머신러닝 팀프로젝트에 어떤 식으로 데이터 EDA를 진행하고 모델링을 개선해나갈 수 있을 지 좋은 가이드라인이 되었습니다. 

읽어주셔서 감사합니다 :-)

 

 
서울시 공중화장실 공공데이터를 가지고 아주 간단한 데이터 시각화, 분석 실습을 해 보려고 합니다.
먼저 이번 포스팅에는 파이썬 pandas 라이브러리를 이용해서 데이터 전처리 작업한 것을 간단히 정리해 보았습니다. 
데이터 시각화, 분석은 태블로 프로그램을 이용하여 마친 뒤 다음 포스팅에 이어서 올리도록 하겠습니다.
 
 
https://data.seoul.go.kr/dataList/OA-1370/S/1/datasetView.do

열린데이터광장 메인

데이터분류,데이터검색,데이터활용

data.seoul.go.kr

 
사용한 데이터 링크입니다.
 


 

1. pandas : 필요없는 컬럼 삭제, 인덱스 지정

import pandas as pd

t = pd.read_csv("toilet.csv", engine='python', encoding = "cp949")
t

 
pd.read_csv를 이용해서 데이터를 불러오는데 한글 깨짐이 좀 있어서 encoding = "cp949"를 이용해주니 깔끔하게 불러오기가 잘 되었습니다.

 
먼저 value_counst()를 이용해서 대략적으로 확인해보니 별 다른 정보가 담겨있지 않은 관계로 새주소명, 생성일 컬럼은 삭제하기로 결정했습니다. 또, 고유번호에 중복값이 없는것을 확인한 관계로 고유번호를 인덱스로 지정해주겠습니다.

# 새주소명, 생성일 컬럼 드랍(삭제)
t = t.drop('새주소명', axis=1)
t.drop('생성일', axis = 1, inplace = True)

# 고유번호 컬럼 중복값 없는지 확인
len(t['고유번호'].unique()) == len(t['고유번호'])  # True

# 고유번호 인덱스화
t.set_index('고유번호', inplace = True)

 
필요없는 컬럼을 삭제하고 인덱스를 고유번호로 바꾸어 주니 어느정도 보는 게 깔끔해졌습니다.
이제 구명과 법정동명을 확인해보려고 하는데요.
 
 

2. 구명

t['구명'].value_counts()

 
value_counts()를 이용해서 확인해 보니

  1. 끝에 '구'가 붙어 있지 않은 구
  2. 오타 작렬한 구
  3. 빌딩이 왜 여기서 나와? 갈암구는 또 어디야? 갈현송방차풀소는 뭐야?

이것들을 해결해줘야 할 것 같습니다.
먼저 구가 안붙어있는 것들에 구를 붙여줘 보기로 했습니다. (예:노원 > 노원구)

t['구명'] = t['구명'].apply(lambda x: x + '구' 
                          if x in ['동작', '금천', '강서', '양천', '노원', '관악', '영등포', '서대문'] 
                          else x)

 
'구명'이 동작/금천/강서/양천/노원/관악/영등포/서대문 중 하나인 경우
컬럼에 apply와 lambda 함수를 이용해서 끝에 '구'를 붙여 줬습니다. 해당사항이 없는 경우는 그냥 놔두도록 처리했습니다.

t['구명'].value_counts()


그 외 갈현송방차풀소~남서울빌딩에 해당하는 row들은 그냥 제거하겠습니다.

# 이것들에 해당하는 '구명'을 가진 row들을 제외한(~) 줄만 남겨서 t에 재할당
t = t[~t['구명'].isin(['갈현송방차풀소', '송북구', '송파ㅜ', '영등로구', 
                      '영등표구', '송파두성빌딩', '갈암구', '구로수', '남서울빌딩'])]
len(t['구명'].value_counts().index)
# 25

 
혹시 몰라 확인해 보니 총 25개의 구가 있는것이 잘 확인되었습니다. 검색해보니 서울에는 25개 자치구와 426개 행정동이 있다고 하네요! 서울 살면서도 계속 까먹어요... 상식으로 외워둬야지.
 
법정동은 426개를 일일이 확인하기 불가능 + 의미가 없는 것 같아서 일단 놔두도록 하겠습니다. 다음 포스팅에는 태블로를 이용하여 간단한 시각화를 해서 가져오도록 하겠습니다!
 

 

 


 
파이썬 requests, BeautifulSoup 라이브러리를 이용한 웹크롤링 후 데이터분석 실습을 해보았습니다 :-) 
 
연금복권720+은 제가 한달에 2-3회정도 꾸준하게 구매하는 최애 복권인데요. 슬프게도 지금까지 제대로 당첨된 적은 단 한번도 없지만, 앞으로도 저는 꾸준히 구매를 할 예정인 아주아주 매력적인 복권입니다. 1등에 당첨이 되면 (세전) 700만원을 매월 20년동안 수령할 수 있어요. 동행복권 온라인 사이트에서 간단히 온라인 구매를 할 수도 있구요. 1등 번호는 온라인 1명, 오프라인 1명 총 2명이 당첨될 수 있습니다. 자세한 복권 구조는 동행복권 홈페이지를 참고해 보시구요.
 
복권의 경우 통계를 공부해보신 분들께는 아주 친숙한 소재이실텐데요. (저는 아닙니다.ㅋㅋㅋ) 동행복권 사이트에서는 복권 당첨번호를 엑셀파일로도 제공하고 통계 자료를 따로 분석해서 메뉴도로 제공하고 있습니다. 다만 저는 철저히 requests 라이브러리를 이용한 웹크롤링에 익숙해지기 위해서 엑셀 파일이나 통계자료를 건드리지 않고 처음부터 끝까지 혼자 힘으로 본 실습을 했습니다! 
 

 
[참고] 본 포스팅은 수리링 본인의 공부 기록을 목적으로 작성하였습니다. 해당 라이브러리에 대해 전혀 모르시는 분께서 보면서 따라하시기엔 많이 불친절하게 느껴질 수 있습니다. 참고하시고 봐 주시면 감사드리겠습니다 :-)
 
[참고] 본 포스팅은 책, 강의, 다른 사람의 포스팅을 참고하지 않은 스스로의 창작물입니다! 참고하여 포스팅 하시는 경우 출처 밝혀주심 감사드리겠습니다!
 


 
[실습 목차]

  1. 206회차로 모의 실습
  2. 원하는 회차 구간을 입력하면 모든 정보를 담아 데이터프레임으로 리턴하는 함수 작성
  3. 데이터프레임으로 간단한 데이터분석 (은근 재밌으니 귀찮으시면 이것만 보고 가세요...^^)

1-1. 숨은 URL 찾아내기

 
동행복권 사이트의 회차별 당첨번호 페이지(클릭)에 가 봅니다.
 

 
 
회차 바로가기 메뉴를 통해 원하는 회차를 선택해서 당첨 결과를 볼 수 있었습니다.
 

 
 
그런데 기본 URL에 회차 정보가 드러나지 않고 숨어 있어요. 206회를 조회해도, 200회를 조회해도 계속 같은 URL이 유지됩니다. 따라서 회차를 특정하여 정보를 뽑아낼 수가 없는 상황입니다. 우리는 회차를 조회할 수 있는 상세URL을 알아내야 해요.
 
문제상황을 해결하기 위해 크롬 웹브라우저의 inspection(개발자 도구) 메뉴의 Network 탭을 확인해 봅시다.
 

 
 
위와 같이 네트워크 탭을 켜둔 상태로 조회 버튼을 눌러봅니다. Name 탭의 맨 첫 번째 gameResult 어쩌구를 클릭한 다음 Payload를 확인합니다. (누가 봐도 수상한) Round: 206 이라는 정보를 확인했습니다. 기존 url 뒤에 &Round=206을 붙여 주면 될 것 같다는 합리적 의심을 해봅니다.
 

https://dhlottery.co.kr/gameResult.do?method=win720&amp;amp;Round=205

 
 
주소 뒤에 &Round=205 를 붙여넣고 엔터를 치니 205회 당첨결과 페이지로 잘 이동합니다 ㅎㅎ 찾았다 요놈! 이제 상세 url주소를 찾았으니 코드를 작성하면서 원하는 데이터를 뽑아내 보겠습니다.
 


 

1-2. requests, BeautifulSoup 라이브러리

* 본 실습에서 해당 라이브러리에 대한 상세 설명은 생략합니다

import requests
from bs4 import BeautifulSoup as BS

 
먼저 requests와 BeautifulSoup 라이브러리를 임포트해줍니다.

url = "https://dhlottery.co.kr/gameResult.do?method=win720&Round=206"
res = requests.get(url)
soup = BS(res.text, "html.parser")

 
우리가 찾아낸 url을 선언해 준 다음 차례대로 라이브러리에 넣어서 html 자료를 뽑아냅니다.

 
soup을 실행해 보니 html 정보가 잘 들어왔습니다 :)
저는 html 코드를 하나하나 뜯어보면서 원하는 정보를 뽑아내 봤어요.

nums = rows[0].find_all("span", {"class":"num"})

#조, 당첨번호
group = int(nums[0].find("span").text)
n_1 = int(nums[1].find("span").text)
n_2 = int(nums[2].find("span").text)
n_3 = int(nums[3].find("span").text)
n_4 = int(nums[4].find("span").text)
n_5 = int(nums[5].find("span").text)
n_6 = int(nums[6].find("span").text)

print(f"{group}조 {n_1}, {n_2}, {n_3}, {n_4}, {n_5}, {n_6}")
# '3조 4, 8, 9, 0, 7, 5'

 
먼저 제일 중요한 1등 조, 6개의 당첨번호를 뽑아봤습니다. 

# 등위(등수명) 
# rows[0]이므로 첫번째 1등을 구함 -> 나중에 인덱스를 바꾸어 다른 등수의 이름도 구할 수 있음
rank = rows[0].find_all("td")[0].text
rank
# '1등'

 
등수명도 뽑아봤어요. 이정도는 그냥 작성해도 되지만 연습삼아서 뽑아봤습니다 :)

# 당첨결과(매)
rank_counts = int(rows[0].find_all("td", {"class":"ta_right"})[-1].text.strip())
rank_counts
# 2

 
1등의 당첨 매수를 뽑아봤습니다. 206회차는 1등이 2명입니다. 연금복권 1등은 온라인/오프라인 각1명씩 최대 2명이 나올 수 있습니다. 가끔 1등이 1명밖에 없을 때도 많아요. 아주 드물게 0명일 때도 있는 거 같아요.

# 보너스 당첨번호 6자리
bonus_nums = []
for i in range(6):
    bonus_num = rows[7].find_all("span", {"class" : "num"})[i].find("span").text
    bonus_num = int(bonus_num)
    bonus_nums.append(bonus_num)

print(bonus_nums)
# [5, 8, 7, 6, 9, 5]

 
보너스 당첨번호 6자리도 뽑아봤습니다.

# 보너스 당첨결과(매)
bonus_counts = int(rows[7].find_all("td", {"class":"ta_right"})[-1].text.strip())
bonus_counts
# 10

 
10명이나 당첨됐네요.
 


 

2-1. 회차를 입력할 수 있는 함수로 작성해보기

206회차를 가지고 적당히 연습을 해 봤으니, 원하는 회차를 입력하면 하나씩 모두 조회해서 딕셔너리로 담아 리턴하는 함수를 작성해 보았습니다.

def win720(round):
	# 입력받은 회차 번호로 url을 만들고 정보를 받아냅니다.
    url = f"https://dhlottery.co.kr/gameResult.do?method=win720&Round={round}"
    res = requests.get(url)
    soup = BS(res.text, "html.parser")
    rows = soup.find("tbody").find_all("tr")
	
    # data_dict에 앞으로 하나씩 정보를 추가할 겁니다. 먼저 라운드 값을 첫 번째로 넣어줬습니다.
    data_dict = {"round":round}
    nums = rows[0].find_all("span", {"class":"num"})

    # 1등 조, 당첨번호
    group = int(nums[0].find("span").text)
    n_1 = int(nums[1].find("span").text)
    n_2 = int(nums[2].find("span").text)
    n_3 = int(nums[3].find("span").text)
    n_4 = int(nums[4].find("span").text)
    n_5 = int(nums[5].find("span").text)
    n_6 = int(nums[6].find("span").text)

    data_dict["group"] = group
    data_dict["n_1"] = n_1
    data_dict["n_2"] = n_2
    data_dict["n_3"] = n_3
    data_dict["n_4"] = n_4
    data_dict["n_5"] = n_5
    data_dict["n_6"] = n_6

    # 1-7등 당첨자수
    for i in range(7):
        rank_counts = rows[i].find_all("td", {"class":"ta_right"})[-1].text.strip()
        rank_counts = re.sub(",","", rank_counts)
        rank_counts = int(rank_counts)
        column_name = f"rank{i+1}"
        data_dict[column_name] = rank_counts

    # 보너스 당첨번호 6개
    for i in range(6):
        bonus_num = rows[7].find_all("span", {"class" : "num"})[i].find("span").text
        column_name = f"bonus_{int(i)+1}"
        data_dict[column_name] = bonus_num

    # 보너스 당첨자수
    bonus_counts = int(rows[7].find_all("td", {"class":"ta_right"})[-1].text.strip())
    data_dict["bonus"] = bonus_counts

    return data_dict

 
더럽게 길지만 그래도 잘 작동했습다^_^;;;; 
너무 길어져서 쓰면서 불길했는데 그래도 오류 수정 2-3번만에 원하는 대로 값이 나와서 다행이였어요

 
205회차로 테스트를 해 봤는데요. 조, 1등 넘버 6자리, 등수별 당첨매수, 보너스 번호 6자리, 보너스 당첨매수가 딕셔너리로 제대로 들어온 것을 확인했습니다 :) 이게 뭐라고 너무 재밌었어요 (ㅋㅋㅋㅋ)
 


 

2-2. 회차 구간을 설정하고 데이터프레임을 리턴하는 함수 작성하기

 
위에서 작성한 win720()함수를 가지고 원하는 회차 구간의 모든 정보를 담은 데이터프레임을 반환하는 함수를 작성해 주었습니다.

def lucky_chart(start, end):
    lucky_results = []

    for i in range(start, end+1):
        win = win720(i)
        values = list(win.values())
        print(values)
        print(len(values))
        lucky_results.append(values)

    columns = list(win.keys())
    print(columns)
    print(len(columns))
    
    import pandas as pd
    df = pd.DataFrame(lucky_results, columns = columns)
    
    return df.set_index("round")

 
중간 중간에 있는 print 함수들은 제가 함수를 작성하면서 중간 과정을 시각화하기 위해 굳이 넣어줬구요, 깔끔하게 없애줘도 됩니다.

history = lucky_chart(190, 206)

 
190회부터 206회차까지 럭키차트 함수를 돌려보았습니다.

 
요런식으로 진행상황을 시각화 하기 위해 print 함수를 넣어줬습니다. (중간에 오류가 있었어서 저런식으로 시각화 하면서 수정해줬어요!)
 

history

 
알흠다운 판다스 데이터프레임이 완성되었어요 ❤️
 


 

3. 간단 데이터분석

마지막으로 데이터분석은 1회차부터 206회차까지로 구간을 늘려서 진행했습니다! (1등은 제껍니다.)
 

  • 1등 조 비율이 어떻게 될까?
# 조별 value_counts() 구하기
group_counts = history['group'].value_counts()

# matplotlib 임포트
import matplotlib.pyplot as plt

# 차트 작성
plt.pie(group_counts,
        labels = group_counts.index,
        shadow = True,
        autopct = '%1.2f%%')
plt.title("Rank 1 groups ratio")
plt.legend()
plt.show()

 
아주.. 흥미로운.. 결과입니댜... 연금복권720+ 1회차부터 206회차까지 모든 데이터들을 살펴본 결과... 지금까지 가장 많은 1등을 배출한 조는 4조였습니다. 4조 > 1조 > 3조 > 5조 > 2조 순이네요. 
 
연금복권을 아는 분들께서는 이해를 하실텐데, 저는 혹시라도 번호 6개를 다 맞췄지만 조가 다를 때 2등이라도 당첨되도록 + 혹시라도 1등이 되면 2등도 동시 당첨되도록(ㅋㅋㅋ) 번호 6개를 고르고 나면 모든 조(1~5조)로 총 5줄(5,000원)을 구매해버립니다. 솔직히 이게 더 확률이 낮을 것 같기는 한데.......... 만약 특정 조를 골라서 구매해야 한다면 앞으로 저는 1조 또는 4조를 고르겠습니댜.
 

  • 지금까지 역대 2등은 몇명이 나왔을까?
import seaborn as sns

sns.set_style("darkgrid")
sns.set_palette("bright")

sns.barplot(history["rank2"].value_counts())
history['rank2'].agg(['min', 'max', 'mean'])
# min     0.0
# max     8.0
# mean    4.5

 
1등에 가려 2등의 당첨자 수는 사실 잘 확인해 본 적이 없는데요. 2등에 당첨되면 매달 100만원씩 10년간 수령할 수 있거든요. 2등이라도 당첨시켜 주신면 제가 굉장히 감사할텐데요. 몇 명이나 당첨되나 보니, 역대 2등 당첨자 수는 최대 8명, 최소 0명(ㅋㅋㅋㅋㅋㅋ), 평균 4.5명이 나왔다고 합니다. 그래프로 확인해 보니 2등이 한 명도 나오지 않은 회차가 20번이 넘네요? 실화냐?
 

  • 그럼 1등이 한 번도 안 나온 회차도 있을까?
history['rank1'].value_counts()
# 1    101
# 2     63
# 0     42
sns.countplot(data = history,
              x = 'rank1',
              legend = 'full')

아니 미친... 1등이 0명인 회차가 40회가 넘는다고? 1등이 2명 다 나온 적보다 1명밖에 안 나온 적이 더 많다고? 여러분 빨리 연금복권 사세요! 저거 다 우리돈이라고(흥분)
 

  • 1번부터 6번까지 각 자리마다 번호가 몇번씩 나왔을까
df = pd.DataFrame()

for i in range(1, 7):
    col = f"n_{i}"
    df[col] = history[col].value_counts().sort_index()

df
for i in range(1, 7):
    col = f"n_{i}"
    print(f"{i}번째 자리에서 가장 많이 나온 숫자는 {df[col].idxmax()}")

 
각 자리에서 가장 많이 나온 숫자는 순서대로 4 - 4 - 9 - 0 - 5 - 6 이었습니다. 이게 엄청 큰 의미가 있을지는 모르겠지만, 해당 자리에 어떤 숫자를 고를지 고민되신다면 이 정보도 참고해 봐도 좋을 것 같습니다.

for i in range(1, 7):
    col = f"n_{i}"
    print(f"{i}번째 자리에서 가장 조금 나온 숫자는 {df[col].idxmin()}")

 
반대로 각 자리별로 가장 조금 나온 숫자도 구해봤어요. 두 정보를 종합하면, 숫자 0 4 7 9가 자주 보이네요. 반대로 0 4 7 9를 제외한 1 2 3 5 6 8 을 고르는 것도 안전하게 갈 수 있는(?) 방법일 수도 있을 것 같고.. 복권의 세계는 정말 어렵네요.
 
tmi지만 저는 항상 첫자리를 6으로 구매를 하는데, 첫자리 6은 꼴찌 0에 이어서 두 번째로 나온 횟수가 적네요. 전략을 바꿔야 하나.... 고민이 되지만 (ㅋㅋㅋㅋㅋㅋㅋㅋㅋ) 복권 통계는 재미일 뿐이라고 생각합니다.
 
 
 
 
여기까지 간단 분석을 마쳐 보겠습니다! :-)
감사합니다.
 

 
 

+ Recent posts