먼저, 이 데이터는 2018년에 진행되었던 제5회 L-Point Big-Data Competition의 자료입니다. 때문에 개인적인 분석 이외의 목적으로는 활용할 수 없습니다.
당시 이 공모전에 참가하였는데 머신러닝에 대해 무지할 때라 만족스러운 결과를 얻지 못했던 것이 아쉬움으로 남아 이번 과제를 계기로 다시 한 번 다루어보려고 합니다.
1-2. 분석 목표
데이터 셋이 다양하기 때문에 많은 인사이트를 도출할 수 있겠지만, 이번 분석에서는 Session 데이터를 바탕으로 잠재고객을 예측하는 모델을 세워보려고 합니다.
여기서 잠재고객이란, 온라인 페이지에 접속하여 실제 구매를 진행한 고객입니다.
분석에서는 CLNT_ID를 하나의 고객으로 정의하며 Target variable는 구매여부입니다. Feature는 주로 Session Data의 변수를 사용할 예정입니다.
2. 데이터 소개
01.Product
구매가 발생했을 때의 고객 ID 정보와 해당 제품의 정보를 담고 있는 구매내역 데이터.
아쉬운 점은 구매날짜에 대한 데이터가 없다는 것.
CLNT_ID
기기, 디바이스, 브라우저 체제에 따라 다른 방문자로 인식되기 때문에 동일 고객이 여러 개의 CLNT_ID를 가지고 있다고 볼 수 있으나 분석에서는 그냥 이것을 고객 개개인으로 정의함.
SESS_ID
Wep/App에 접속 후 세션이 시작될 때 부여된 고유 ID로 하나의 클라이언트 ID에 여러 개가 발급될 수 있다. Session ID는 다음과 같은 세 가지 경우에 변경될 수 있다.
사이트 간 이동이 있는 경우(닷컴, 마트, 슈퍼...) 세션 재할당
활동이 30분동안 없는 경우 만료.
자정 이후 만료
In [642]:
product
Out[642]:
1048575 rows × 8 columns
02.Search_1
검색어와 그 빈도를 CLNT_ID별로 보여줌.In [643]:
search_1
Out[643]:
1048575 rows × 4 columns
03.Search_2
검색어와 그 빈도를 날짜별로 보여줌.In [644]:
search_2
Out[644]:
1048575 rows × 3 columns
04.Customer
고객 정보를 담고 있는 데이터In [645]:
cust
Out[645]:
671679 rows × 3 columns
05.Session
세션에 대한 데이터로 세션이 발생한 일자와, 페이지 조회건수, 머무른 시간 등에 대한 정보를 담고 있다.In [646]:
session
Out[646]:
1048575 rows × 9 columns
06.Master
상품코드와 해당 상품에 대한 정보를 담고 있는 상품 데이터In [647]:
master
Out[647]:
847652 rows × 5 columns
3. Analysis Process
➀ Create Target Variable
➁ Feature Engineering
➂ Missing value and outlier treatment
➃ Visualization
➄ Sampling
➅ Modeling
그럼 분석 시작합니다 ~ ~ ~ Start ~ ~ ~ ~ ~ ! ! !
Step 1. Create Target valriable
가장 중요한 부분으로 이 데이터는 TARGET 값이 존재하지 않기 때문에 직접 만들어주어야 합니다!
1. PRODUCT와 SESSION 데이터 셋을 이용하여 만들 수 있습니다.
SESSION은 LOTTE 계열 홈페이지(롯데마트, 롯데몰, 롯데닷컴...)에 접속한 기록을 담은 데이터 셋이고
PRODUCT는 그 중 구매로 이어진 경우 누가 샀고, 그 상품은 무엇인지에 대한 정보를 담은 데이터 셋입니다.
2. 두 Data Set을 Full join 합니다. (key = CLNT_ID, SESS_ID)
그러면 다음과 같은 세 가지 경우가 생깁니다.
➀ PRODUCT변수와 SESSION 변수 모두 가지고 있는 경우
: 세션 검색 결과가 구매로 이어진 케이스로, 이 경우 class 값을 1(구매O)으로 줌.
➁ SESSION변수는 가지고 있으나 PRODUCT 변수는 가지고 있지 않은 경우
: 세션 검색 결과는 존재하나 구매로 이어지지 않은 케이스로, 이 경우 class 값을 0(구매X)으로 줌.
➂ PRODUCT변수는 가지고 있으나 SESSION 변수는 가지고 있지 않은 경우
: SESSION 데이터를 만들면서 삭제된 케이스로, 이 경우는 어떠한 경우에도 해당되지 않으므로 해당 row를 모두 삭제함.
3. CLNT_ID, SESS_ID는 다음과 같이 정의합니다.
➀ CLNT_ID는 한 고객의 다른 디바이스마다 각각 다르게 부여되나 분석의 편의성을 위해 한 명의 고객으로 가정합니다.
➁ SESS_ID는 다양한 상황에서 바뀔 수 있으나 대부분의 경우에서 SESS_ID가 다른 경우는 다른 날짜에 접속했을 때 였습니다. 따라서 SESS_ID는 같은 고객이지만 상이한 날짜에 접속한 경우로 가정합니다.
➂ 대부분의 경우 다른 CLNT_ID 끼리는 같은 SESS_ID를 가질 수 없으나 가지는 경우도 보임. 따라서 CLNT_ID + SESS_ID의 조합을 기본키로 이용해야 합니다.
4. 최종 Target Variable 목표
각 row는 최종적으로 해당 SESS_ID에서 구매가 이루어졌는지에 대한 정보가 class 0 or class 1로 이루어져 있음.
PRODUCT & SESSION FULL JOIN - SAS SQL 사용
procsql;createtableml.prod_sess_full asselect b.*, a.*from ml.'01PRODUCT'n as a full join ml.'05SESSION'n as bon a.clnt_id = b.clnt_id and a.sess_id = b.sess_id;quit;
In [648]:
#SAS로 JOIN한 데이터를 불러와 prod_sess_full에 저장.prod_sess_full = pd.read_csv('PROD_SESS_FULL.csv',encoding='cp949')
In [649]:
prod_sess_full #180만개로 늘어났다. 무지막지하다.
Out[649]:
1828924 rows × 15 columnsIn [650]:
# 접속하여 구매로 이어진 케이스로 class 1으로 지정.a = prod_sess_full.loc[prod_sess_full['CLNT_ID'].notnull()]#CLNT_ID의 값도 있어야 하고a = a.loc[a['PD_C'].notnull()]#PRODUCT에만 있는 변수인 PD_C의 값도 있어야 함.
In [651]:
a.shape#Session, Product 데이터 셋 둘 다 존재하는 row는 40만개. (class 1)
Out[651]:
(403714, 15)
In [652]:
# 접속만 하고 구매로는 이어지지 않은 케이스로 class 0으로 지정.a = prod_sess_full.loc[prod_sess_full['CLNT_ID'].notnull()]a = a.loc[a['PD_C'].isnull()]
In [653]:
a.shape#Session 데이터 셋에는 존재하지만 Product에는 존재하지 않는 row는 78만개.
Out[653]:
(780349, 15)
In [654]:
#Product에만 존재하는 데이터로, 분석에 이용할 수 없으므로 추후 제거함.prod_sess_full['CLNT_ID'].isnull().sum()
Out[654]:
644861
In [655]:
#따라서 최종적으로 분석에 이용할 데이터는 약 118만개이다. (class 비율은 약 2:1이다.)prod_sess_full['CLNT_ID'].notnull().sum()
Out[655]:
1184063
In [656]:
# 필요없는 row를 삭제합시다. # product에만 존재하는 row들을 삭제하는 것은 CLNT_ID가 null이지 않은 row만 불러와 저장한다는 의미와 일맥상통prod_sess = prod_sess_full.loc[prod_sess_full['CLNT_ID'].notnull()]prod_sess
Out[656]:
1184063 rows × 15 columns
In [657]:
#target_variable인 class 변수를 만들어봅시다.#먼저 prod_sess 데이터 셋에 새로운 변수 class를 만들고 일단 1을 할당prod_sess['class']=1
C:\ProgramData\Anaconda3\lib\site-packages\ipykernel_launcher.py:3: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
This is separate from the ipykernel package so we can avoid doing imports until
In [658]:
#PD_C가 null이 아니라면 구매로 이어진 case로 class 값을 1로 할당하며prod_sess_class_a = prod_sess.loc[prod_sess['PD_C'].notnull()]prod_sess_class_a['class']=1#PD_C가 null이라면 구매로 이어지지 않은 case로 class 값을 0으로 할당함.prod_sess_class_b = prod_sess.loc[prod_sess['PD_C'].isnull()]prod_sess_class_b['class']=0#이후 위의 두 개를 합치면~ 짜잔 class 값이 저장되어 있는 최종 데이터 셋 완성!prod_sess_class = pd.concat([prod_sess_class_a,prod_sess_class_b],axis=0)
import sys
In [659]:
prod_sess_class#class 값을 할당한 최종 데이터 셋 prod_sess_class!
Out[659]:
1184063 rows × 16 columns
Step 2. Feature Engineering
1. 불필요한 변수를 삭제합시다
➀ PRODUCT
CLNT_ID, SESS_ID
ROW를 구별하기 위한 변수로 ID이기 때문에 변수에서는 제외한다. ***(삭제)***
HITS_SEQ
히트일련번호. 구매가 일어나기까지 몇 번의 행위가 있었는지에 대한 지표로 분석에 적극응용하려고 했으나 구매를 하지 않은 경우에 대해서는 모두 결측이기 때문에 사용할 수 없다. ***(삭제)***
추가로 덧붙이자면 HITS_SEQ의 경우 PRODUCT 데이터셋에만 존재하기 때문에 구매하지 않은 경우(class 0)에 대해서는 모두 결측이며 구매한 경우(class 0)에 대해서만 양수의 값을 가짐. 그러므로 분석에 의미가 없다고 판단하여 삭제합니다.
PD_C, PD_ADD_NM, PD_BRA_NM, PD_BUY_NM, PD_BUY_CT
구매한 상품 정보에 대한 데이터. SESSION에는 없기 때문에 삭제하여야 함. ***(삭제)***
➁ SESSION
SESS_SEQ
해당 세션 아이디가 같은 CLNT_ID에 몇 번째로 할당되었는지에 대한 지표로 이 값이 클수록 LOTTE 관련 페이지에 많이 접속했다는 뜻으로 해석할 수 있음.
SESS_DT
날짜는 이 자체로 변수로 이용할 수는 없고 분기 변수로 바꾼 후에 더미화 하여 분석에 사용한다.
TOT_PAG_VIEW_CT, TOT_SESS_HR_V
세션 내 총 페이지 뷰 수와 시간 수로 아주 중요한 변수로 작용
DVC_CTG_NM
기기 유형으로 더미변수로 바꾸어 사용
ZON_NM, CITY_NM
지역에 대한 대분류, 중분류로 더미변수로 변환한들 너무 많아지고 지역이 크게 구매여부에 영향을 미치지 않을거라 판단하여 삭제함 ***(삭제)***
2. 나머지 데이터 셋도 그냥 두긴 아쉽지! 피처 엔지니어링 할 게 있나 보자
➀ SEARCH_1
- KWD_NM : 검색창에 입력한 검색 키워드. 하나의 SESS_ID에도 여러 개가 있을 수 있음.
- SEARCH_CNT : 해당 검색어 검색량으로 그 검색 키워드에 대해 롯데 페이지에서 얼마나 깊게 찾아보았는가 정도로 해석할 수 있다.
➜ SESS_ID에 대한 SEARCH_CNT의 합을 구한 SUM_SEARCH를 생성하여 Feature로 이용할 수 있다.
➁ SEARCH_2
날짜별로 해당 검색키워드가 몇 번 검색되었는지에 대한 데이터 셋으로, 나에겐 필요 없으니 여기선 아무것도 하지 않는다.
➂ CUSTOM
고객 정보에 대한 데이터로 GENDER와 연령대는 매우 중요한 변수로 작용할 수 있다.
때문에 이 부분은 JOIN을 통해 feature 데이터 셋에 투입하며 결측치는 적절히 대치할 방안을 찾아보자.
➃ MASTER
PRODUCT 데이터 셋에 있는 PD_C에 대한 자세한 정보를 담고 있는 데이터 셋으로 카테고리를 이용한 분석을 진행할 시 굉장히 유용하겠지만
시간적 한계가 있는 나의 현재 분석에서는 이용하지 않는다.
3. Summary! 그래서 지금 뭘 해야한다고~?
➀ 다음의 불필요한 변수를 삭제하자.
: CLNT_ID, SESS_ID, PD_C, HITS_SEQ, PD_ADD_NM, PD_BRA_NM, PD_BUY_AM, PD_BUY_CT, ZON_NM, CITY_NM
그런데 이때! CLNT_ID, SESS_ID는 추후에 데이터 셋 끼리 join할 때의 중요한 key이므로 모델링 직전에 삭제합니다!
➁ Feature Engineering을 통해 새 변수를 투입하자
SESS_DT : session 날짜로 분기로 바꾼 후에 더미화하자.
DVC_CTG_NM : 세 가지 기기유형에 대한 변수(mobile, desktop, tablet)로 더미화하자.
SEARCH_CNT : SESS_ID를 기준으로 SUM하여 새로운 변수 SEARCH_CNT를 생성하자.
➂ 결측치와 이상치를 확인하자!
: CLNT_GENDER & CLNT_AGE (고객정보) : join 하는 과정에서 결측치가 생길 수 있으니 주의!
1. 필요없는 변수를 삭제하자.
In [660]:
#먼저 위에서 만든 가장 최신의 데이터셋은 prod_sess_class였다.prod_sess_class
Out[660]:
1184063 rows × 16 columnsIn [661]:
prod_sess_del = prod_sess_class.drop(['PD_C', 'HITS_SEQ', 'PD_ADD_NM', 'PD_BRA_NM', 'PD_BUY_AM', 'PD_BUY_CT', 'ZON_NM', 'CITY_NM'], axis=1)
prod_sess_del#삭제하니까 너무 초라해졌다..... 큼큼........ 어서 빨리 새로운 변수를 넣어야겠다.#다시 말하지만 CLNT_ID와 SESS_ID는 추후 다른 데이터셋과 join시에 필요하기 때문에 남겨둔다.
Out[661]:
1184063 rows × 8 columns
2. Feature Engineering
원래 SESS_DT를 이용하여 분기 변수 만들기 였으나 Problem이 발생했음.
왜냐 SESS_DT의 범위는 2018.04~2018.09 사이였기 때문!
이는 분기의 의미가 없으므로 "계절"의 의미를 담는 것이 더 좋을 것이라 판단.
4월, 5월 : 봄
6월, 7월, 8월 : 여름
9월 : 가을
이렇게 더미화 시켜봅시다!In [662]:
type(prod_sess_del['SESS_DT'][0])#날짜변수가 str형태로 저장되어 있군요! 이를 날짜형식으로 바꾸어 줍시다.
Out[662]:
str
In [663]:
#to_datetime을 이용해 변환하는 중입니다prod_sess_del['SESS_DT']= pd.to_datetime(prod_sess_del['SESS_DT'], format='%Y-%m-%d')
In [664]:
type(prod_sess_del['SESS_DT'][0])#Timestamp 형식으로 바뀌었다!
Out[664]:
pandas._libs.tslibs.timestamps.Timestamp
In [665]:
prod_sess_del['SESS_DT_M']= prod_sess_del['SESS_DT'].dt.monthprod_sess_del['SESS_DT_M']#month 값을 저장한 새로운 변수 SESS_DT_M 생성!
#결과를 출력해봅시다.
print("SUM_SEARCH is not null given class 1", prod_search2[prod_search2["class"] == 1].loc[prod_search2['SUM_SEARCH'].notnull()].shape)
print("SUM_SEARCH is not null given class 0", prod_search2[prod_search2["class"] == 0].loc[prod_search2['SUM_SEARCH'].notnull()].shape)
print("SUM_SEARCH is null given class 1", prod_search2[prod_search2["class"] == 1].loc[prod_search2['SUM_SEARCH'].isnull()].shape)
print("SUM_SEARCH is null given class 0", prod_search2[prod_search2["class"] == 0].loc[prod_search2['SUM_SEARCH'].isnull()].shape)
SUM_SEARCH is not null given class 1 (83562, 13)
SUM_SEARCH is not null given class 0 (138050, 13)
SUM_SEARCH is null given class 1 (320152, 13)
SUM_SEARCH is null given class 0 (642299, 13)
SUM_SEARCH는 ....
sum_search 값은 전체의 18%만 존재함. 즉, SUM_SEARCH는 전체의 82%가 전부 다 결측치...TT
class가 1인 경우 sum_search 값은 20% 존재함.
class가 0인 경우 sum_search 값은 17% 존재함.
==> 불행 중 다행! 그래도 class별 비율은 어느정도 맞다!
But...
외부 사이트(네이버쇼핑 등과 같은 기타 검색 엔진)를 통해 유입된 경우엔 따로 검색이 필요하지 않기 때문에 sum_search 값이 0일 것.
따라서 sum_search 값이 없는 경우는 외부 사이트로부터의 유입이라고 간주하고 검색 기록이 없다는 의미로 사용될 수 있도록 0으로 대치한다!
Step3. Missing value and outlier treatment
In [678]:
#이제 SUM_SEARCH 값의 결측치를 0으로 대치해보자
prod_search2["SUM_SEARCH"] = prod_search2["SUM_SEARCH"].fillna(0)
In [679]:
prod_search2.isnull().sum()
#SUM_SEARCH의 결측값이 모두 없어졌다.
#클래스 별 결측치 비율을 알아보자
print("CLNT_GENDER is null given class 1", prod_sess_cust[prod_sess_cust["class"] == 1].loc[prod_sess_cust['CLNT_GENDER'].isnull()].shape)
print("CLNT_GENDER is null given class 0", prod_sess_cust[prod_sess_cust["class"] == 0].loc[prod_sess_cust['CLNT_GENDER'].isnull()].shape)
#총 데이터 수를 감안하면 이 정도는 비슷하다고 할 수 있음.
CLNT_GENDER is null given class 1 (84259, 15)
CLNT_GENDER is null given class 0 (132572, 15)
위에서 확인한 네 가지 변수 결측값 대치하기
지금까지 중 가장 어려운 대목이다, 결측값을 무엇으로 바꿔주어야할까?In [684]:
prod_sess_cust
Out[684]:
1184063 rows × 15 columns
TOT_PAG_VIEW_CT :: 세션 내의 총 페이지 뷰 수
평균 값으로 대치하되, 클래스 별로 다른 값을 사용하자!In [685]:
prod_sess_class['TOT_PAG_VIEW_CT'].describe()
Out[685]:
count 1.183949e+06
mean 8.799512e+01
std 9.015523e+01
min 1.000000e+00
25% 2.900000e+01
50% 5.700000e+01
75% 1.120000e+02
max 4.990000e+02
Name: TOT_PAG_VIEW_CT, dtype: float64
In [686]:
#class가 1인 경우의 평균 값은 98.082325이다.
prod_sess_cust[prod_sess_cust["class"] == 1]['TOT_PAG_VIEW_CT'].describe()
Out[686]:
count 403668.000000
mean 98.082325
std 99.159731
min 1.000000
25% 31.000000
50% 63.000000
75% 128.000000
max 499.000000
Name: TOT_PAG_VIEW_CT, dtype: float64
In [687]:
#class가 0인 경우의 평균 값은 82.776632이다.
prod_sess_cust[prod_sess_cust["class"] == 0]['TOT_PAG_VIEW_CT'].describe()
Out[687]:
count 780281.000000
mean 82.776632
std 84.653447
min 1.000000
25% 28.000000
50% 54.000000
75% 104.000000
max 499.000000
Name: TOT_PAG_VIEW_CT, dtype: float64
특징이 명확하게 분리되지 않았습니다.
남성 검색어 4위에는 여성이, 여성 검색어 4위에는 남성이 들어가 있는 것 부터 등골이 쎄했지만
그래도 혹시나 하는 마음에 일단 진행해 본 것이었는데 이제는 놓아줄 때가 되었나 봅니다.
검색어를 통한 GENDER 결측값 대치는 사실 상 불가능한 것이라고 판단, 스폰지밥을 보내주겠습니다.
CLNT_GEDNER, CLNT_AGE 결측값이 존재하는 ROW를 삭제합시다
가장 빈번하게 나온 값으로 대치하는 방법도 생각해보았지만, 일단 데이터가 118만개이기 때문에 대치보다는 그냥 해당 row를 삭제하는 것이 더 좋다고 판단했습니다.In [722]:
#float형인 AGE는 STR로 바꾸어준다. (get_dummies할 때 한번에 바뀔 수 있도록)
prod_sess_drop['CLNT_AGE'] = prod_sess_drop['CLNT_AGE'].astype(str)
In [725]:
prod_sess = pd.get_dummies(prod_sess_drop)
prod_sess
#성별과 연령대 모두 더미화된 것을 확인할 수 있다.
Out[725]:
967232 rows × 23 columns
연속형 변수의 이상치를 확인하자!
In [726]:
#한글 폰트 깨짐 방지
plt.style.use('seaborn')
plt.rc('font', family='Malgun Gothic')
plt.rc('axes', unicode_minus=False)
In [936]:
#TOT_PAG_VEIW_CT, TOT_SESS_HR_V
plt.scatter(x='TOT_PAG_VIEW_CT',y='TOT_SESS_HR_V', data=prod_sess, color='#00a8cc')
plt.xlabel("총 페이지 뷰 수")
plt.ylabel("세션 내 총 시간")
plt.show()
In [728]:
#SESS_SEQ, SUM_SEARCH
plt.scatter(x='SESS_SEQ',y='SUM_SEARCH', data=prod_sess, color='#8ac6d1')
plt.xlabel("할당 된 세션 번호")
plt.ylabel("세션 내 검색 횟수")
plt.show()
#선형관계가 보입니다.
이상치는 없다고 판단하고 다음으로 넘어갑니다.
산점도가 선형으로 나타나는데 그 이유는
SESS_SEQ(할당된 세션 번호)는 CLNT_ID별로 첫 세션이 발급되었을 때 1, 그리고 이후 방문마다 증가합니다.
따라서 SESS_SEQ가 높을수록 더 페이지에 자주 들리는 것이라고 생각할 수 있으며, 이는 일종의 충성도라고 할 수 있습니다.
이것이 커지면서 검색 횟수는 줄어드는 것을 볼 수 있는데, 자주 페이지에 들리는 사람들은 어떠한 물건을 검색하기 위해 들어오는 것이 아니라
습관적으로 페이지에 들어오기 때문에 SESS_SEQ가 증가할수록 SUM_SEARCH(검색횟수)는 감소하는 것으로 해석할 수 있습니다.
Step4. Visualization
feature도 완성이 되었습니다! 그래도 분석 전에 데이터가 어떻게 생겼는지는 봐야겠죠?
1. DVC_CTG_NM(사용 디바이스), SESS_DT_M(계절)
이 두가지 변수는 더미화하기 전인 데이터셋을 이용해서 확인해보겠습니다. (CLNT_GENDER, CLNT_AGE의 결측값을 삭제하기 전 데이터셋이라 118만개입니다.)In [729]:
prod_sess_drop['CLNT_AGE'] = prod_sess_drop['CLNT_AGE'].astype(str) #명목형 변수로 바꾸어주기
age_group = prod_sess_drop['CLNT_AGE'].value_counts().sort_index()
plt.bar(age_group.index,age_group, color=['#35495e', '#347474', '#63b7af'])
plt.title("연령대 별 비율")
plt.show()
#연령대는 30대와 40대가 가장 많았다.
3. 연속형 변수는 클래스 별로 어떻게 분포할까?
In [735]:
#클래스 별 총 페이지 뷰 수와 세션 내 시간 분포
sns.lmplot(x='TOT_PAG_VIEW_CT', y='TOT_SESS_HR_V', hue='class', data=prod_sess, scatter_kws={"s": 1})
plt.title('클래스 별 총 페이지 뷰 수 & 세션 내 시간')
plt.show()
조금 징그러운 그래프이다. 회귀선이 희미하게 보인다. 비슷한데 아주 살짝 class 1이 위에 있는 것으로 보아
세션 내 시간과 세션 내 페이지 뷰 수가 클수록 구매가 조금 더 잘 일어난다고 볼 수 있다.In [736]:
#클래스 별 SESS_SEQ 분포
sns.kdeplot(prod_sess[prod_sess['class'] == 1][prod_sess['SESS_SEQ'] < 1000]['SESS_SEQ'], color = "#faafff",shade= True, label="Yes")
sns.kdeplot(prod_sess[prod_sess['class'] == 0][prod_sess['SESS_SEQ'] < 1000]['SESS_SEQ'], color = "#bbcfff",shade= True, label="No")
plt.xlabel("SESS_SEQ")
plt.ylabel("Ratio")
plt.show()
#SESS_SEQ가 1인 비율은 구매를 하지 않은 사람들이 더 많았다.
Step5. Sampling
샘플링은 두 가지로 진행합니다.
Train 0.8 / Test 0.2로 나눈 데이터 셋 : 복잡하지 않은 Single model을 돌릴 때 사용합니다.
Train 0.05 / Test 0.95로 나눈 데이터 셋 : Ensemble, Gridsearchcv 등을 돌릴 때 사용합니다.
from sklearn.model_selection import train_test_split
#1. Train 0.8, Test 0.2로 데이터 나누기
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=77)
In [748]:
X_train.shape # 64만개
Out[748]:
(773785, 20)
In [749]:
X_test.shape #31만개
Out[749]:
(193447, 20)
In [750]:
#2. Train 0.05, Test 0.95로 데이터 나누기
X_train2, X_test2, y_train2, y_test2 = train_test_split(X, y, test_size=0.95, random_state=77)
dt4.score(X_test2_std, y_test2)
#정확도가 많이 낮아졌다 ㅠㅠ 과연 나는 0.7을 넘을 수 있을까?
Out[786]:
0.5764933271373239
Logistic Regression
In [788]:
from sklearn.linear_model import LogisticRegression
lr = LogisticRegression()
lr.fit(X_train_std, y_train)
lr.score(X_test_std, y_test)
#이것 또한 DT와 비슷하다.
Out[788]:
0.6673714247313217
In [790]:
# Train 0.05 Test 0.95 data를 이용해보자
lr2 = LogisticRegression()
lr2.fit(X_train2_std, y_train2)
lr2.score(X_test2_std, y_test2)
#로지스틱 결과는 유사하게 나왔다!
Out[790]:
0.669133099205438
K-Means Clustering
In [891]:
from sklearn.cluster import KMeans
model = KMeans(n_clusters=2,algorithm='auto')
model.fit(X_train_std, y_train)
# K-means는 이 데이터 셋과 상극인가 보다! 여기엔 발도 들이지 말아야겠다!
accuracy_score(y_test, y_pred)
Out[894]:
0.4984414335709523
2. Ensemble 해봅시다 ~ !
In [795]:
from sklearn.linear_model import ElasticNet, Lasso, BayesianRidge, LassoLarsIC
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.kernel_ridge import KernelRidge
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.model_selection import GridSearchCV
import xgboost as xgb
import lightgbm as lgb
Fitting 5 folds for each of 6 candidates, totalling 30 fits
[Parallel(n_jobs=4)]: Using backend LokyBackend with 4 concurrent workers.
[Parallel(n_jobs=4)]: Done 30 out of 30 | elapsed: 5.2min finished
C:\ProgramData\Anaconda3\lib\site-packages\sklearn\model_selection\_search.py:823: FutureWarning: The parameter 'iid' is deprecated in 0.22 and will be removed in 0.24.
"removed in 0.24.", FutureWarning
Fitting 5 folds for each of 12 candidates, totalling 60 fits
[Parallel(n_jobs=4)]: Using backend LokyBackend with 4 concurrent workers.
[Parallel(n_jobs=4)]: Done 42 tasks | elapsed: 4.2min
[Parallel(n_jobs=4)]: Done 60 out of 60 | elapsed: 6.8min finished
C:\ProgramData\Anaconda3\lib\site-packages\sklearn\model_selection\_search.py:823: FutureWarning: The parameter 'iid' is deprecated in 0.22 and will be removed in 0.24.
"removed in 0.24.", FutureWarning
# step2 : Tune Regularization parameters
xgb_p = {
'gamma':[i/10.0 for i in range(0,5)],
'reg_alpha':[1e-5, 1e-2, 0.1, 1, 100]
}
#이때 max_depth와 min_child_weight는 위에서 찾은 최적의 파라미터로 대체할 것.
xgb_tune2 = GridSearchCV(estimator = xgb.XGBClassifier(learning_rate =0.1, n_estimators=500, subsample=0.8, colsample_bytree=0.8,
max_depth=3,min_child_weight=3, objective= 'binary:logistic', nthread=4, scale_pos_weight=1, seed=27),
param_grid = xgb_p, scoring='accuracy',n_jobs=4, iid=False, cv=5, verbose = True)
xgb_tune2.fit(X_train2_std, y_train2)
xgb_tune2.best_params_, xgb_tune2.best_score_
Fitting 5 folds for each of 25 candidates, totalling 125 fits
[Parallel(n_jobs=4)]: Using backend LokyBackend with 4 concurrent workers.
[Parallel(n_jobs=4)]: Done 42 tasks | elapsed: 3.0min
[Parallel(n_jobs=4)]: Done 125 out of 125 | elapsed: 8.6min finished
C:\ProgramData\Anaconda3\lib\site-packages\sklearn\model_selection\_search.py:823: FutureWarning: The parameter 'iid' is deprecated in 0.22 and will be removed in 0.24.
"removed in 0.24.", FutureWarning
Fitting 5 folds for each of 6 candidates, totalling 30 fits
[Parallel(n_jobs=4)]: Using backend LokyBackend with 4 concurrent workers.
[Parallel(n_jobs=4)]: Done 30 out of 30 | elapsed: 1.9min finished
C:\ProgramData\Anaconda3\lib\site-packages\sklearn\model_selection\_search.py:823: FutureWarning: The parameter 'iid' is deprecated in 0.22 and will be removed in 0.24.
"removed in 0.24.", FutureWarning
from sklearn.base import BaseEstimator, TransformerMixin, RegressorMixin, clone
class AveragingModels(BaseEstimator, RegressorMixin, TransformerMixin):
def __init__(self, models):
self.models = models
# we define clones of the original models to fit the data in
def fit(self, X, y):
self.models_ = [clone(x) for x in self.models]
# Train cloned base models
for model in self.models_:
model.fit(X, y)
return self
#Now we do the predictions for cloned models and average them
def predict(self, X):
predictions = np.column_stack([
model.predict(X) for model in self.models_
])
return np.mean(predictions, axis=1)
스태킹에 사용할 모델은 가장 score가 높게 나왔던 세 가지로 합니다
DT Sigle model : dt // 0.6864929412190419
Gradient Boost Default model : gbrt // 0.6713706276506713
XGBBoost Default model : xgb_c // 0.6711616755779647
[결론] 그냥 dt default model로 예측했을 때가 가장 높다. 이것은 하이퍼 파라미터 튜닝의 문제보다는 데이터 자체의 문제라는 생각이 든다.
Step 7. What's the problem?
열심히 하이퍼 파라미터 튜닝을 해 보았지만 성능은 전혀 나아지지 않고 있다.
어떻게 하면 더 좋은 성능을 낼 수 있을지에 대한 고민 끝에 다음과 같은 네 가지 가설을 세울 수 있었다.
1. Feature가 부족하다.
: 피쳐가 부족한 건 처음부터 우려했던 부분이었다.
더 만들 수 있는 변수는 어떤 것이 있을까 생각해본 결과
PRODUCT 데이터 셋을 이용하여 '구매이력' 변수를 만들 수 있겠다 생각했음.
[구매이력] SESS_ID를 기준으로, 데이터 기간 내 다른 날짜에 구매한 이력을 COUNT하여 생성
2. 나의 Hyper Parameter Tuning의 능력이 부족하다.
: 하이퍼 파라미터 문제에 앞서 모델 선정 능력 또한 부족한 것은 아닐까 생각해보았고
대안으로 AutoML과 Scikit-optimize를 생각해보았으나
현재 이 구린 성능의 원인은 모델과 하이퍼 파라미터의 문제보다는 데이터 셋 자체의 문제가 더 크다고 생각해서 일단 Pass
3. 애초에 Accuracy만이 좋은 지표로 작용하는가?
: 이 부분에 대해서도 많이 생각해보았다. Confusion matrix에서 예측이 잘못된 부분은 False Nagative, False Positive로 나눌 수 있는데 자꾸 헷갈려서 적어놓겠다.
False Nagative (confusion matrix의 2행 1열) : 예측은 0이라고 했는데 실제 1인 것들의 숫자 ---> Recall Score와 관련
False Positive (confusion matrix의 1행 2열) : 예측은 1이라고 했는데 실제 0인 것들의 숫자 ---> Precision Score와 관련
이 중에서도 나의 데이터에서 더 중요한 지표는 False Nagative와 연관된 Recall Score라고 생각했다. 구매를 한 사람들을 안 했다고 예측한다면 추후에도 잠재고객을 놓쳐버릴 수 있기 때문에 Accuracy와 더불어 Recall 값 또한 성능에 중요한 영향을 미친다. 반대로 Precision Score의 경우 잠재고객이라고 예측했으나 실제 구매하지 않은 케이스에 대한 지표로 상대적으로 중요도가 떨어진다. 따라서 추후 분석에서는 Accuracy와 Recall, Precision의 적절한 조화를 찾아보도록 하겠다.
4. 모든 데이터에 대해 고려하다보니 생긴 일?
: 나의 모델 성능에 가장 많이 영향을 미친 부분이라고 생각한다.
애초에 시각화 파트에서 보면 알겠지만, 30, 40대 여성의 내역이 데이터의 80%이상을 차지하고 이는 다시 말해 롯데 계열사의 주고객이 30, 40대 여성이라는 뜻이기 때문에
모든 데이터를 고려했을 때 성능이 좋다면 문제가 되지 않으나, 지금과 같은 상황이 발생한 경우 주고객층의 class라도 잘 예측하자는 의미에서 데이터 셋을 분리할 필요가 있다고 본다.
Step 8. 30, 40대 여성의 경우만 고려하자!
In [909]:
#위의 단계 중 하나였던 데이터 셋을 이용한다.
#이는 clnt_gender, clnt_age의 결측치 row를 모두 제거하고 더미화하기 '전'의 데이터이다!
prod_sess_drop
recall 값을 보면 오히려 앙상블 기법을 적용한 게 훨씬 점수가 떨어져 나온다! 샘플링 크기의 차이 때문일까?
차라리 이런 큰 데이터들은 앙상블 하려고 train set 크기를 줄이는 것보다 많은 train 가지고 간단한 모델에 적합시키는 게 나을 수도 있겠다고 생각했다.
Step 9. 구매이력 변수를 새로 만들자
너무 멀리까지 와버려서 솔직히 변수를 새로 만드는 게 겁이 난다. 또 얼만큼 더 해야하는걸까?
하지만 첫 단추를 잘못끼웠다면 다시 처음부터 끼우는 게 맞다....
또, 추가적으로 처음에 제거하였던 HITS_SEQ도 분석에 포함시켜보고자 한다.
[HITS_SEQ] Web/App에서 페이지 또는 화면 클릭, 이벤트 참여, 검색 등 방문자의 행위에 대해 순서대로 배열된 일련번호. 세션 내에서 발생되며, 첫번째 행위에 대해서는 1로 설정됨
(즉 몇 번째 행위에서 구매가 일어났는지에 대한 지표로, 이는 product에만 있으므로 구매가 일어나지 않은 경우에 대해서는 0으로 결측값 처리하자.)
How! 구매이력 변수는 어떻게 만들까?
PRODUCT 데이터 셋에는 구매가 발생했을 때 해당 CLNT_ID, SESS_ID와 어떤 상품을 구매하였는지에 대한 정보가 있다.
이를 COUNT하여 구매이력(BUY_CNT)변수로 이용하자는 것이 기본 아이디어
주의할 점
그런데 이때, 구매 시 두 개의 다른 상품을 구매하였다면 구매 한 건에 대해 ROW가 두 개 생긴다.
==> SESS_ID가 같다면 동일한 구매로 간주, SESS_ID가 다른 경우에만 다른 날짜에 구매한 것으로 간주하여 이 경우 BUY_CNT를 1증가시킴.
session 정보는 있으나 구매로 이어진 적 없는 경우에는 0, 첫 번째 구매 시 buy_cnt는 1, 두 번째 구매 시 buy_cnt는 2로 둔다.
필요없는 나머지 변수는 전부 드랍합니다.
이제 이 데이터셋을 CLNT_ID와 SESS_ID를 기준으로 합쳐봅시다!
여기서부터 전처리 다시 하는 과정이오니 넘겨주세요
그저 다른 점 하나는 HITS_SEQ가 있다는 것.....In [972]:
#거의 태초의 상태로 돌아왔다. 이 모습을 보니 살짝 멘탈이 흔들린다.
prod_sess_class2 = prod_sess_full.loc[prod_sess_full['CLNT_ID'].notnull()]
prod_sess_class2
Out[972]:
1184063 rows × 15 columnsIn [974]:
#y값 생성하기
prod_sess_class2['class']=1
#PD_C가 null이 아니라면 구매로 이어진 case로 class 값을 1로 할당하며
prod_sess_class_a = prod_sess_class2.loc[prod_sess_class2['PD_C'].notnull()]
prod_sess_class_a['class']=1
#PD_C가 null이라면 구매로 이어지지 않은 case로 class 값을 0으로 할당함.
prod_sess_class_b = prod_sess_class2.loc[prod_sess_class2['PD_C'].isnull()]
prod_sess_class_b['class']=0
#이후 위의 두 개를 합치면~ 짜잔 class 값이 저장되어 있는 최종 데이터 셋 완성!
prod_sess_class2 = pd.concat([prod_sess_class_a,prod_sess_class_b],axis=0)
C:\ProgramData\Anaconda3\lib\site-packages\ipykernel_launcher.py:1: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
"""Entry point for launching an IPython kernel.
prod_buy = prod_sess_buy.merge(prod_buy_sort, on=["CLNT_ID", "SESS_ID"], how="left")
prod_buy[:20] #두 데이터 셋을 left join 하여 합칩니다.
Out[1251]:
20 rows × 25 columns
문제가 생겼다 ......
데이터가 약 5만개 늘어났습니다.
left join을 하면 left쪽, 즉 prod_sess_buy의 row 수인 967232개로 생성이 되어야 하는데 왜이럴까요?
바로.. key가 고유하지 않아서 입니다. (같은 sess_id를 가진 row가 a셋에 2개, b셋에 2개있다면, 그 두 개의 모든 조합인 4개가 결과 셋에 생성된 것입니다.)
이는 product 데이터 셋이 구매 건수로 생성되지 않고 구매물품을 기준으로 생성되어서 그렇습니다.
예를 들어 하나의 SESS_ID 77이라는 사람이 양말과 신발을 구매하였다면 Product에는
77 양말
77 신발
로 들어가기 때문에 결국 이 조합은 key로서의 역할을 하지 못하는 것이지요.
애초에 Product 셋을 보고 중복된 값에 대해 먼저 처리를 해주었으면 좋았겠지만, 미처 생각하지 못한 제 잘못입니다.
분석 처음부터 잘못되었다고 생각할 수 있겠네요. 모든 join에서 저는 CLNT_ID, SESS_ID의 조합이 KEY의 역할을 하도록 중복을 없애는 전처리를 먼저 수행해주었어야 했습니다.
그걸 잊은 채 열심히 join 해버렸더니 결국 이 사단이 났네요 ㅠㅜ
다시 처음으로 돌아가기엔 저는 6주차 과제를 해야해서요 ㅎㅎ,,
일단 아쉬운 마음은 뒤로 하고 이 상황을 어떻게 극복할 수 있을까 하다가, 중복 행에 대해서 삭제해주는 함수 duplicate를 이용해보기로 하였습니다.In [1252]:
prod_buy.isnull().sum()
#join하면서 새롭게 추가한 함수 BUY_CNT에 결측치가 생겼으므로 처리해줍니다.
god = LogisticRegression()
god.fit(X_train_std, y_train)
god.score(X_test_std, y_test)
Out[1266]:
1.0
말도 안 되는 값이 나왔습니다.
아무래도 새로 넣은 변수 HITS_SEQ, BUY_CNT가 구매 한 고객으로부터만 얻을 수 있는 값이기 때문인 것 같습니다.
HITS_SEQ는 구매까지의 페이지 창 클릭수라고 볼 수 있고
BUY_CNT는 구매한 고객의 구매이력 변수이나, 이 또한 PRODUCT 데이터 셋에서만 만들 수 있었기 때문에 SESSION 값만 있는 고객에 대해서는 0이 들어갑니다.
각각 BUY_CNT만 없앴을 때, HITS_SEQ만 없앴을 때를 비교해 보았는데 둘 중 어느 하나만 있더라도 설명력은 1.0 혹은 0.99가 나왔으며
두 변수 모두 없었을 때만 지금까지의 분석 결과와 비슷한 SCORE가 나왔습니다.
결과적으로 모델 자체의 성능은 올랐지만.... 좋은 결과는 아니라고 볼 수 있었습니다.
하지만 좋은 경험이라고 생각하겠습니다.
god = LogisticRegression()
god.fit(X_train_std, y_train)
god.score(X_test_std, y_test)
Out[1270]:
0.7379508458346633
그래도 마지막 분석에서 ACCURACY 0.7을 넘었습니다!!!! 위에서 열심히 했던 분석과 다른 게 하나 있다면 중복행을 제거한 것 뿐입니다.
아무래도 JOIN 과정에서 잘못되어 중복된 행이 여러 개 생긴 것도 큰 영향을 미친 것 같습니다.
느낀점
리얼한 데이터를 다루는 것은 너무나도 어렵다. 그러니 각오하고 시작하자..
데이터셋이 이처럼 분할되어 있는 경우 JOIN 할 때에도 주기적으로 상황을 체크하여 이번(KEY값으로 인한 중복행 발생)같은 사태가 발생하지 않도록 해야한다.
큼직한 성능 자체는 모델선정과 하이퍼파라미터 튜닝보다는 데이터 셋 자체 (피쳐 개수 등)의 문제로 이어진다.
피쳐 엔지니어링 시에 구현 능력이 중요하게 다가왔다. 알고리즘 문제 열심히 풀어야겠다.
다시 한 번 느끼지만 인생은 내 생각처럼 흘러가는 법이 없는 것 같다....
지금까지 배웠던 내용을 적용해볼 수 있었던 좋은 시간이었습니다.
목표 SCORE는 0.7이었는데 어쩌다보니 결국 넘기긴 했네요 ....
마지막 중복 행에 대해 제거만 했더니 이렇게 나온 걸 보니 허무하기도 합니다만...
그래도 전처리에서 어떻게 결측값을 처리할지, 모델은 어떻게 선정할 지, 하이퍼 파라미터 튜닝은 어떤 식으로 진행할 지,
모델 성능이 잘 안나온다면 원인은 무엇일지에 대해 고민해본 과정과 시간들은 헛되지 않았다고 생각합니다
밑 부분엔 멘탈이 나가서 주석이 별로 없는 점 양해부탁드리고요,, 읽으시느라 고생 많으셨습니다!