본문 바로가기

데이터와 함께 탱고를/커널 공부하기

[Predict Future Sales] playground 커널 리뷰 1

이 글은 Future Sales Prediction: playground 커널의 리뷰입니다.
코드 및 아이디어는 모두 커널의 원 제작자에게 있으며, 이 글은 해당 커널을 좀 더 이해하기 쉽게하기 위한 리뷰입니다.

1. Load data and library

가장 먼저 해야할 일은 당연히, 라이브러리들과 데이터를 가져오는 것입니다.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn import ensemble, metrics

sns.set_style('darkgrid')
pd.options.display.float_format = '{:,.3f}'.format

기본적인 라이브러리들을 가져왔으면, 데이터를 로드합니다.

parser = lambda date: pd.to_datetime(date, format='%d.%m.%Y')

train = pd.read_csv('../input/sales_train.csv', parse_dates=['date'], date_parser=parser)
test = pd.read_csv('../input/test.csv')
items = pd.read_csv('../input/items.csv')
item_cats = pd.read_csv('../input/item_categories.csv')
shops = pd.read_csv('../input/shops.csv')

print('train:', train.shape, 'test:', test.shape)
print('items:', items.shape, 'item_cats:', item_cats.shape, 'shops:', shops.shape)

각 데이터들이 어떻게 생겼는지 확인해봅니다.
이전에, "[Predict Future Sales] 대회 및 데이터 소개" 에서 한 번 살펴본 적이 있으므로,
이 글에서는 train 만 확인해보겠습니다.

train.head()

2. Data analysis

데이터에서 우리가 관심있는 변수만 먼저 간략히 살펴보겠습니다.

print(train['date_block_num'].max())
33

2013년 1월-2015년 10월이 0-33으로 잘 들어와있는 것을 알 수 있습니다.

print(train['item_cnt_day'].describe())
count   2,935,849.000
mean            1.243
std             2.619
min           -22.000
25%             1.000
50%             1.000
75%             1.000
max         2,169.000
Name: item_cnt_day, dtype: float64

우리가 예측해야 하는 값인 item_cnt_day 을 간략히 살펴보면, -22~2169 값의 범위를 가지고 있습니다.
하위 75% 까지 1인데, max가 2169 인 것을 보면, 굉장히 skewd 한 분포임을 알 수 있습니다.
상위, 25개 값만 살펴보면,

train['item_cnt_day'].nlargest(25).values
array([2169., 1000.,  669.,  637.,  624.,  539.,  533.,  512.,  508.,
        504.,  502.,  501.,  500.,  500.,  480.,  412.,  405.,  401.,
        401.,  343.,  325.,  313.,  313.,  300.,  299.])

299부터 2169로 되어있고, 극단 값 안에서도 굉장히 skewd 하다는게 보입니다.

test_only = set(test['item_id'].unique()) - set(train['item_id'].unique())
print('test only items:', len(test_only))
test only items: 363

한편, test data 에는 있지만 train data 에는 없는 상품이 363개나 있습니다.
모델을 구성할 때, (샵, 상품) 단위로 각각 모델을 구성하면 안된다는 것을 알 수 있습니다. (train data 에는 없으므로)

3. Preprocessing

혹시 train 데이터에 같은 데이터가 중복으로 있을 수도 있으니, 이를 제거해줍니다.

# drop duplicates
subset = ['date','date_block_num','shop_id','item_id','item_cnt_day']
print(train.duplicated(subset=subset).value_counts())
train.drop_duplicates(subset=subset, inplace=True)
False    2935825
True          24
dtype: int64

24개의 중복데이터가 있었고, 이를 제거해주었습니다.

다음으로, 우리는 test data 상품이 팔린 수들을 예측해야 하므로, test data에 있는 상품만 예측하면 됩니다.
따라서, test 데이터에 없는 상품은 train 데이터에서 거릅니다.

# drop shops&items not in test data
test_shops = test.shop_id.unique()
test_items = test.item_id.unique()
train = train[train.shop_id.isin(test_shops)]
train = train[train.item_id.isin(test_items)]

print('train:', train.shape)
train: (1224429, 6)

train 데이터가 2935825 에서 1224429 으로 대략 절반넘게 줄었습니다.
test 데이터를 예측함에 있어 train 데이터의 절반은 필요가 없습니다.

마지막으로, 모델 input 으로 들어갈 기본 데이터프레임의의 형태를 만들어 놓습니다.
(월 / 상점 / 아이템 ) 을 기준으로 데이터프레임을 구성합니다.

from itertools import product

# create all combinations
block_shop_combi = pd.DataFrame(list(product(np.arange(34), test_shops)), columns=['date_block_num','shop_id'])
shop_item_combi = pd.DataFrame(list(product(test_shops, test_items)), columns=['shop_id','item_id'])
all_combi = pd.merge(block_shop_combi, shop_item_combi, on=['shop_id'], how='inner')

# group by monthly
train_base = pd.merge(all_combi, train, on=['date_block_num','shop_id','item_id'], how='left')
train_base['item_cnt_day'].fillna(0, inplace=True)
train_grp = train_base.groupby(['date_block_num','shop_id','item_id'])
train_base.head()

train_base 가 생긴 모습. 월, 상점, 아이템 기준으로 일단 모든 경우의 수를 고려하여 데이터 프레임을 만들어 놓습니다.

train_base.shape
(7907070, 6)

train 데이터 모양이 기존에 1224429 에서 7907070으로 확 부풀려졌습니다.
전 기간의월, 모든 상점, 모든 상품의 조합으로 데이터프레임을 만들었기 때문입니다.
데이터프레임이 커지긴 했지만, 기존의 데이터프레임을 부풀린 것으로 이전보다 sparse 할 것입니다.

train_base는 이후에, 만들 Features 들의 데이터프레임을 한 곳에 합치는데 사용됩니다.

4. Feature Creation

이제 본격적으로 Feature 들을 생성해보겠습니다.
Feature 는 결과적으로 예측하려는 값에 영향을 줄 것 같은 변수입니다.
따라서 이를 고려하여 하나씩 생성해보겠습니다.

먼저, 우리는 지금 '15년 6월의 모든 상점에서의 각 상품들이 팔린 갯수' 를 예측하려고 하고있습니다.
즉, 시간은 '월' 단위가 기본이며, (상점, 상품) 단위로 데이터를 조작해야함을 생각해볼 수 있습니다.
따라서, 다음과 같이 (월, 상점, 상품) 단위로 데이터프레임을 하나 만들어 줍니다.

이 때, 한 달 단위의 팔린 갯수 및 주문 수도 같이 만들어줍니다.
또, item_cnt에 극단값들이 많았으므로, 좀 더 robust 한 모델을 만들기 위해, 극단값들을 0-20의 값들로 바꿔줍니다.

# summary count by month
train_monthly = pd.DataFrame(train_grp.agg({'item_cnt_day':['sum','count']})).reset_index()
train_monthly.columns = ['date_block_num','shop_id','item_id','item_cnt','item_order']
print(train_monthly[['item_cnt','item_order']].describe())

# trim count
train_monthly['item_cnt'].clip(0, 20, inplace=True)

train_monthly.head()
           item_cnt    item_order
count 7,282,800.000 7,282,800.000
mean          0.222         1.086
std           3.325         0.725
min          -4.000         1.000
25%           0.000         1.000
50%           0.000         1.000
75%           0.000         1.000
max       2,253.000        31.000

train_monthly 의 모습. (월, 상점, 아이템) 별로 총 팔린 갯수와 주문건수의 합을 새로운 열로 추가했다.

4.1. 아이템 그룹 Feature 생성

itemsitem_cats 를 이용하여, 아이템 그룹을 생성해보려고 합니다.
먼저 이 두 변수는 아래와 같이 생겼습니다.

items.head()

items. 상품의 이름과 카테고리 id를 담고있습니다.

item_cats.head()

item_cats. 아이템 카테고리 id에 해당하는 이름을 담고있습니다.

이 때, item_cats 에서 item_category_name 을 보면, 카테고리 이름 맨 앞에 해당하는 단어가 조금 더 큰 범주의 카테고리임을 알 수 있습니다. 이 값을 아이템 그룹이라고 하고, 이것만 따로 빼내서 하나의 열로 만들어 준 뒤, 기존의 items와 합쳐봅시다.

# pickup first category name
item_grp = item_cats['item_category_name'].apply(lambda x: str(x).split(' ')[0])
item_cats['item_group'] = pd.Categorical(item_grp).codes
items = pd.merge(items, item_cats.loc[:,['item_category_id','item_group']], on=['item_category_id'], how='left')

items.head()

기존의 items 에 item_group 이 추가되었습니다.

4.2. 도시 Feature 생성

이번엔 shop에 있는 상점 이름을 통해 상점이 속한 지역을 담는 Feature를 만들어보려고 합니다.
먼저 shop은 아래와 같이 생겼습니다.

shops.head()

shops. 각 상점 id에 해당하는 상점이름 담고있습니다.

잘 보면, shop_name 에서 첫 번째 단어가 해당 상점의 지역입니다.
예를 들어, '마포구 스타벅스' 이런 식으로 상점 이름이 적혀있고, 여기서 '마포구'만 빼오면 그게 곧 지역이 됩니다.
따라서, 이 정보로 city 라는 새로운 열을 shops 에 추가시켜보겠습니다.

city = shops.shop_name.apply(lambda x: str.replace(x, '!', '')).apply(lambda x: x.split(' ')[0])
shops['city'] = pd.Categorical(city).codes
shops.head()

shops 에 city 가 추가되었습니다.

4.3. 수치 대표 값들로 Feature 생성

이번에는 각 상점의 상품들의 일반적인 대표값을 Feature 로 두려고 합니다.
먼저, 상품들의 팔린 갯수의 평균, 중간, 편차값을,
그리고 상품 주문 수의 평균을 뽑아서 새로 데이터프레임을 만들어보겠습니다.

# By shop,item
grp = train_monthly.groupby(['shop_id', 'item_id'])
train_shop = grp.agg({'item_cnt':['mean','median','std'],'item_order':'mean'}).reset_index()
train_shop.columns = ['shop_id','item_id','cnt_mean_shop','cnt_med_shop','cnt_std_shop','order_mean_shop']
print(train_shop[['cnt_mean_shop','cnt_med_shop','cnt_std_shop']].describe())

train_shop.head()
       cnt_mean_shop  cnt_med_shop  cnt_std_shop
count    214,200.000   214,200.000   214,200.000
mean           0.188         0.054         0.381
std            0.608         0.509         0.773
min            0.000         0.000         0.000
25%            0.000         0.000         0.000
50%            0.029         0.000         0.171
75%            0.147         0.000         0.431
max           20.000        20.000        10.055

train_shop

이번에는 상품 그 자체가 아닌, '상품 그룹'의 평균을 사용하여 데이터프레임을 만들어보겠습니다.

# By shop,item_group
train_cat_monthly = pd.merge(train_monthly, items, on=['item_id'], how='left')
grp = train_cat_monthly.groupby(['shop_id', 'item_group'])
train_shop_cat = grp.agg({'item_cnt':['mean']}).reset_index()
train_shop_cat.columns = ['shop_id','item_group','cnt_mean_shop_cat']
print(train_shop_cat.loc[:,['cnt_mean_shop_cat']].describe())

train_shop_cat.head()
       cnt_mean_shop_cat
count            546.000
mean               0.925
std                2.172
min                0.000
25%                0.029
50%                0.149
75%                0.467
max               13.382

train_shop_cat

4.4. 지난 달의 값들을 Feature 로 생성

이번 달의 매출에는 저번 달의 매출과 연관이 있을 수도 있습니다.
이번에는 이러한 아이디어를 가지고 Feature 를 생성합니다.
구체적으로는, 해당 상점, 상품의 한 달전, 두 달전, 12달 전의 팔린 갯수와 주문 수를 이번 달의 Feature 로 가져옵니다.

# By month,shop,item At previous
train_prev = train_monthly.copy()
train_prev['date_block_num'] = train_prev['date_block_num'] + 1
train_prev.columns = ['date_block_num','shop_id','item_id','cnt_prev','order_prev']

for i in [2,12]:
    train_prev_n = train_monthly.copy()
    train_prev_n['date_block_num'] = train_prev_n['date_block_num'] + i
    train_prev_n.columns = ['date_block_num','shop_id','item_id','cnt_prev' + str(i),'order_prev' + str(i)]
    train_prev = pd.merge(train_prev, train_prev_n, on=['date_block_num','shop_id','item_id'], how='left')

train_prev.head()

train_prev

한편, 마찬가지로 이번에는 각 상점의 아이템 그룹을 기준으로, 한 달전의 팔린 갯수의 평균을 Feature 로 가져옵니다.

# By month,shop,item_group At previous
grp = pd.merge(train_prev, items, on=['item_id'], how='left').groupby(['date_block_num','shop_id','item_group'])
train_cat_prev = grp['cnt_prev'].mean().reset_index()
train_cat_prev = train_cat_prev.rename(columns={'cnt_prev':'cnt_prev_cat'})

train_cat_prev.head()

train_cat_prev

4.5. 이동평균값과 MACD, Signal Feature 생성

이번에는, 한 달전의 상품의 팔린 갯수의 이동평균값과 MACD, Signal 을 Feature 로 두려고 합니다.
이동평균, MACD, Signal이 뭔지 전혀 모르시는 분은 아래 링크를 참조하시면 금방아실 수 있습니다.

이동평균법
펀펀 강의 유튜브 - MACD 분석기법

먼저, 행에는 (상점, 상품)을, 열에는 월(0~33) 을 둔 pivot_table 에, 각 월별 해당 상점에서 각 상품이 팔린 총 갯수를 데이터프레임으로 만듭니다.

train_piv = train_monthly.pivot_table(index=['shop_id','item_id'], columns=['date_block_num'], values='item_cnt', aggfunc=np.sum, fill_value=0)
train_piv = train_piv.reset_index()
train_piv.head()

train_piv

이후, 각 이동평균 값(윈도우 사이즈 4, 12, 26) 들과 MACD, Signal 값을 계산하여 데이터프레임을 새로 만듭니다.

# MACD At previous
col = np.arange(34)
pivT = train_piv[col].T
ema_s = pivT.ewm(span=4).mean().T
ema_m = pivT.ewm(span=12).mean().T
ema_l = pivT.ewm(span=26).mean().T
macd = ema_s - ema_l
sig = macd.ewm(span=9).mean()

ema_list = []
for c in col:
    sub_ema = pd.concat([train_piv.loc[:,['shop_id','item_id']],
        pd.DataFrame(ema_s.loc[:,c]).rename(columns={c:'cnt_ema_s_prev'}),
        pd.DataFrame(ema_m.loc[:,c]).rename(columns={c:'cnt_ema_m_prev'}),
        pd.DataFrame(ema_l.loc[:,c]).rename(columns={c:'cnt_ema_l_prev'}),
        pd.DataFrame(macd.loc[:,c]).rename(columns={c:'cnt_macd_prev'}),
        pd.DataFrame(sig.loc[:,c]).rename(columns={c:'cnt_sig_prev'})], axis=1)
    sub_ema['date_block_num'] = c + 1
    ema_list.append(sub_ema)

train_ema_prev = pd.concat(ema_list)
train_ema_prev.head()

위 코드를 처음에 보면 바로 이해가 안될 수도 있습니다.
코드 하나씩 구체적으로 까보면, 이해가 되긴 될 것입니다만...
지금 바로 이해하기 힘들다면, 아무튼 이렇게 해서 각 (상점, 상품) 의 한달 전 이동평균, MACD, Siganl 값을 Feature 로 생성해둔다. 정도만 아시면 되겠습니다.
여하튼 위와같이 코딩하여 다음과 같은 새로운 데이터프레임을 만들었습니다.

train_ema_prev

보시면, (월, 상점, 상품) 을 기준으로 각 Feature 들이 새로 생긴 것을 알 수 있습니다.
월(date_block_num) 이 1부터 시작하는 이유는, 첫 번째 달(date_block_num = 0 인 달)은 이전 달의 값이 없기 때문입니다.
따라서 두 번째 달(date_block_num = 1)부터 한 달 전의 Feature 값들을 잡아올 수 있습니다.

4.6. 할인율 Feature 생성

이번에는 해당 상점, 상품의 그 달의 할인율을 Feature 로 넣어보려 합니다.
그런데 할인율은 어떻게 구할 수 있을까요?

여기서는 다음과 같은 방법을 생각합니다.

  1. 일반적으로, 상품의 가격이 데이터 전 기간동안 오르지는 않았을 것이다.
  2. 따라서, 전 기간에서, 상품의 가격 중 가장 비쌌던 가격을 찾는다. 이 가격을 정가라고 본다.
  3. 이 가격을 기준으로, 매월 각 상점의 상품 가격을 비교해본다.
  4. 즉, 상품 A의 정가가 1000원이었는데, 어떤 상점에서 해당 달의 상품 A의 가격이 500원이었다면, 할인율은 500/1000이 된다.

생각해보면, 어렵지 않은 논리입니다.
일단 할인율을 구하려면, 각 상품의 매달 가격을 알아야 합니다.

그런데 문제가 있습니다.
아까 한참 위에서, test data 에만 존재하는 상품이 363개나 있습니다. 즉 이 상품들의 가격은 우리가 알지 못합니다.
또, 모든 상품의 가격이 train dataset 에 제대로 있는게 아닙니다. NaN 값이 들어있는 경우가 꽤 있습니다.

따라서, 우리는 상품의 가격을 얻으려면, 예측을 통해 얻어야 합니다.
예측 문제를 풀다보면, Feature 에 결측치가 많은 경우, 이 Feature 값을 또 예측하는 경우가 종종있는데, 지금이 그런 경우입니다.

4.6.1. 비어있는 가격 예측

먼저, 가격이 존재하는 상품들만 가져와보겠습니다.

# Price mean by month,shop,item
train_price = train_grp['item_price'].mean().reset_index()
price = train_price[~train_price['item_price'].isnull()]

price.head()

price. (월, 상점, 상품) 기준으로 해당 상품의 그 달의 평균 가격을 담는다.

여기서, 우리는 비교적 최근의 1달 가격을 예측하려고 하는 것이니, 가격도 가장 최근의 것을 가져다 쓰려고 합니다.
다음과 같이 가장 최근의 가격만 가져옵니다.

# last price by shop,item
last_price = price.drop_duplicates(subset=['shop_id', 'item_id'], keep='last').drop(['date_block_num'], axis=1)

이제, 우리가 예측해야 하는 test dataset 의 가격이 무엇이 있는지 보겠습니다.

# null price by shop,item
uitem = price['item_id'].unique()
pred_price_set = test[~test['item_id'].isin(uitem)].drop('ID', axis=1)

print(pred_price_set.shape)
pred_price_set.head()
(16128, 2)

pred_price_set

16128개의 (상점, 상품) 의 가격을 예측해야 합니다.

기존의 items 와 합쳐, items 를 features 로 두고, ExtraTreesRegressor 모델을 사용하여 예측해보겠습니다.
그 후, 가격까지 다 포함된 새로운 데이터프레임을 만들겠습니다.

if len(pred_price_set) > 0:
    train_price_set = pd.merge(price, items, on=['item_id'], how='inner')
    pred_price_set = pd.merge(pred_price_set, items, on=['item_id'], how='inner').drop(['item_name'], axis=1)
    reg = ensemble.ExtraTreesRegressor(n_estimators=25, n_jobs=-1, max_depth=15, random_state=42)
    reg.fit(train_price_set[pred_price_set.columns], train_price_set['item_price'])
    pred_price_set['item_price'] = reg.predict(pred_price_set)

test_price = pd.concat([last_price, pred_price_set], join='inner')
test_price.head()

test_price

4.6.2. 할인율 계산

이제, 본격적으로 할인율을 계산해보겠습니다.
먼저, 상품별로, 상품의 정가를 담고있는 데이터프레임을 하나 만듭니다.

price_max = price.groupby(['item_id']).max()['item_price'].reset_index()
price_max.rename(columns={'item_price':'item_max_price'}, inplace=True)
price_max.head()

price_max

이제, train dataset 과 test dataset 을 위한 할인율 데이터프레임을 각각 만듭니다.

train_price_a = pd.merge(price, price_max, on=['item_id'], how='left')
train_price_a['discount_rate'] = 1 - (train_price_a['item_price'] / train_price_a['item_max_price'])
train_price_a.drop('item_max_price', axis=1, inplace=True)
train_price_a.head()

train_price_a. discount_rate 이 추가되었습니다.

test_price_a = pd.merge(test_price, price_max, on=['item_id'], how='left')
test_price_a.loc[test_price_a['item_max_price'].isnull(), 'item_max_price'] = test_price_a['item_price']
test_price_a['discount_rate'] = 1 - (test_price_a['item_price'] / test_price_a['item_max_price'])
test_price_a.drop('item_max_price', axis=1, inplace=True)
test_price_a.head()

test_price_a. discount_rate 이 추가되었습니다.

Feature 생성은 이걸로 끝났습니다.
다음 글부터는 이제 지금까지 만든 모든 Feature 데이터 프레임을 합치고,
본격적으로 모델을 훈련시키고 테스트 해본 뒤, 결과를 보도록 하겠습니다.