본문 바로가기
2. Data Science Basics/Python

데이콘 쇼핑몰 지점별 매출액 예측 경진대회 (TOP 10%)

by Mojito 2022. 8. 8.

 

개인 스터디중 재밋고 쉬워보이는 대회가 있어서 참가하게 되었습니다. 대회는 시계열대회 예측이였으며 링크는 아래에 있습니다. 개인적으로 기간이 짧아서 아쉬움이 남긴 하지만, 코드를 공유하며 정리했던 생각을 남겨둘려 합니다.

 

STEP 1. Understanding the Problem

    - 시계열 예측 문제로, 쇼핑몰에서 모은 시계열 데이터를 이용하여 다음 주 매출 (Weekly_Sales)을 예측하는 문제입니다. 데이터는 아래와 같이 구성되어 있습니다.

  • id : 샘플 아이디
  • Store : 쇼핑몰 지점
  • Date : 주 단위(Weekly) 날짜
  • Temperature : 해당 쇼핑몰 주변 기온
  • Fuel_Price : 해당 쇼핑몰 주변 연료 가격
  • Promotion 1~5 : 해당 쇼핑몰의 비식별화된 프로모션 정보
  • Unemployment : 해당 쇼핑몰 지역의 실업률
  • IsHoliday : 해당 기간의 공휴일 포함 여부
  • Weekly_Sales : 주간 매출액 (목표 예측값)

시계열 예측을 위하여 몇가지 전략을 세워봤으며 그 전략들은 아래와 같습니다.

  1. 전통적인 시계열 예측방법 (ARIMA, SARIMA) 등을 이용하여 추세 예측해보기

  2. 딥러닝을 사용하여 예측해보기

  3. ML만을 사용하여 예측해보기

  4. 위 모델들 앙상블하기

 

위 전략들을 세웠을 때, 앙상블이 가장 높은 정확도를 나타내어 앙상블 모델에 대한 코드를 정리하려 합니다.

 

train = pd.read_csv("../train.csv", index_col=0)
test = pd.read_csv("../test.csv", index_col=0)
train.head()

STEP 2. Data Cleaning

  먼저 Boolean인 휴일정보와 날짜정보를 수정해 주겠습니다. 

def holiday_to_number(isholiday):
    if isholiday == True:    # 휴일일경우 1
        number = 1
    else:
        number = 0
    return number


def preprocessing(data):
    data = data.copy()
    
    # 날짜 형식에 대해 데이터타입 적용
    data.Date = pd.to_datetime(data.Date, format="%d/%m/%Y")
    
    # 날짜 데이터를 주, 일, 월 달 로 분리
    data['Week'] = data.Date.dt.isocalendar().week.apply(lambda x: int(x))
    data['Day'] = data.Date.dt.day.apply(lambda x: int(x))
    data['Year'] = data.Date.dt.year.apply(lambda x: int(x))
    data['Month'] = data.Date.dt.month.apply(lambda x: int(x))
    
    # True/False => 1/0
    data['NumberHoliday'] = data['IsHoliday'].apply(holiday_to_number)
    
    return data

 

train = preprocessing(train)
test = preprocessing(test)

 

 

STEP 3. Data Analysis

 

 - Store 별 판매량 그려보기

fig = plt.figure(figsize=(30,60))
train_df = train
for store in range(1,46):
    storeset = train_df[train_df.Store==store]
    storeset_2010 = storeset[(storeset.Year==2010) & (storeset.Week<=43)]
    storeset_2011 = storeset[(storeset.Year==2011) & (storeset.Week<=43)]
    storeset_2012 = storeset[(storeset.Year==2012) & (storeset.Week<=43)]
    
    ax = fig.add_subplot(12, 4, store)
    
    plt.title(f"store_{store}")
    ax.plot(storeset_2010.Week, storeset_2010.Weekly_Sales, label="2010", alpha=0.3)
    ax.plot(storeset_2011.Week, storeset_2011.Weekly_Sales, label="2011", alpha=0.3)
    ax.plot(storeset_2012.Week, storeset_2012.Weekly_Sales, label="2012", color='r')
    #ax.plot(test_pred_store.Week, test_pred_store.Before_Weekly_Sales, label="2012-pred", color='b')
    ax.legend()
    
plt.show()

위 그래프에서 확인할 수 있듯이, 대부분 특정일자에 크게 판매량이 증가하는 증상을 보이며, 몇몇 점포들 제외하고선 비슷한 판매양상을 보입니다. 먼저 추세를 확인하기 위해, Arima 모델을 사용하여 각 Store 별로 Hierarchical Model 을 생성 할 생각이며, 그 이후 유의미한 Feature 들을 사용하여  XGboost 를 사용하여 각각 스토어별 모델을 만들고 앙상블로 Arima 의 시계열 추세와 XGBoost 모델을 합쳐줄 생각입니다.

STEP 4. ARIMA

# 필요한 Library Import
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from statsmodels.tsa.stattools import adfuller
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from pandas.plotting import autocorrelation_plot
import statsmodels.api as sm
from sklearn.metrics import mean_squared_error
df = pd.read_csv("../train.csv", index_col = 'Date')

#Stationary Check Using ADF
result = adfuller(df[df.Store==1].Weekly_Sales)
print('ADF Statistic: %f' % result[0])
print('p-value: %f' % result[1])

p-value 가 0.05보다 작으므로 Store 1의 데이터는 Stationary 하다고 볼 수 있다. 하여 d = 0 으로 두고 학습해도 된다.

스토어가 총 45개인것에 비해 데이터가 많지않으므로 그냥 0~3 사이의 모든 정수를 하이퍼파라미터로 두고 Grid search 학습 방법을 택하기로 정했다. 1개의 Store의 시계열만 볼 경우 ADF, AR 등을 직접 보고 골라가는게 좋을수 있지만, 45개의 Store 을 다 보기에는 시간이 걸리기 때문이다. 먼저 Arima를 학습하는 Function 을 만들어 주겠습니다.

 

def train_arima(df, store, col, p, d, q):
    df = df[df['Store'] == store]
    model = ARIMA(df[col], order=(p,d,q))
    results_ARIMA = model.fit()
    return results_ARIMA

Train Test Split: 

  - (각 스토어별 데이터수는 총 139 개인데 그 중 125개를 학습, 14개를 테스트 데이터로 나누어 주었습니다.)

train_size = int(len(df[df['Store'] == 1])*.9)
test_size = len(df[df['Store'] == 1]) - train_size
print(f'train: {train_size}, test: {test_size}')  # train: 125, test: 14

def hierarchical_train_test_split():
    train_list = []
    test_list = []
    for i in range(1,max(df.Store)+1):
        train = df[df.Store==i].iloc[:train_size]
        test = df[df.Store==i].iloc[train_size:]
        train_list.append(train)
        test_list.append(test)
    train_list = pd.concat(train_list)
    test_list = pd.concat(test_list)
    return train_list, test_list
    
    train, test = hierarchical_train_test_split()
# Grid Search

p_values = range(0,4)
d_values = range(0,1)
q_values = range(0,4)

# grid_dict = {}
grid_list = []
for store in range(1, max(df.Store)+1):
    for p in p_values:
        for d in d_values:
            for q in q_values:
                warnings.filterwarnings("ignore")
                pred = train_arima(train, store, 'Weekly_Sales', p,d,q).forecast(14)
                rmse = mean_squared_error(pred, test[test.Store==store].Weekly_Sales, squared=False)
                grid_list.append([store,p,d,q,rmse])
                print(f'{store}: {p},{d},{q},{rmse}')

위와 같이 모든 가능한 수를 넣어주게 되면, 각 RMSE를 계산해서 Return 해주게 만들어 보았다. 이제 각 Store 별 RMSE 가 가장 낮은 p,d,q만 따로 뽑아보겠다.

grid_np = np.array(grid_list).reshape(45,16,5)    # shape 을 스토어별로 바꾸어줌
rmse_min = []
for i in range(len(grid_np)):
    idx = np.argmin(grid_np[i], axis=0)[-1]    # return last value minimum array
    rmse_min.append(grid_np[i][idx])

이제 나눈데이터 말고 실제 경진대회 Train data로 모델을 fit 하고 fitted value 와 test value 를 저장하겠다.

# train and test for real_data
X = pd.read_csv(r"C:\Users\Eric\Downloads\dataset\train.csv")
y = pd.read_csv(r"C:\Users\Eric\Downloads\dataset\test.csv")
sales_pred = []
sales_forecast = []


for i in range(0, max(X.Store)):
    store, p,d,q,rmse = rmse_min[i]
    print(f'Store: {store}, {p}, {d}, {q}')
    pred = train_arima(X, store, 'Weekly_Sales', int(p),int(d),int(q)).fittedvalues
    forecast = train_arima(X, store, 'Weekly_Sales', int(p),int(d),int(q)).forecast(4)
    
    sales_pred.append(pred)
    sales_forecast.append(forecast)
# Fitted value 저장
arima_pred = pd.DataFrame(np.array(sales_pred).reshape(-1), columns=['Weekly_Sales'])
arima_pred.index +=1
arima_pred.index.name = 'id'
arima_pred.to_csv('../arima_fittedvalue')

# forecast 저장
arima_pred = pd.DataFrame(np.array(sales_forecast).reshape(-1), columns=['Weekly_Sales'])
arima_pred.index +=1
arima_pred.index.name = 'id'
arima_pred.to_csv('../arima_forecast')

 

STEP 5. Feature Selection

  유의미한 Feature를 사용하기위해 이 글의 방법을 따라하였습니다. https://mojitos.tistory.com/75

 

[논문분석] 머신러닝에서 유의미한 Feature 쉽게 구분해내기

참고: https://www.jmlr.org/papers/volume3/stoppiglia03a/stoppiglia03a.pdf  [서론] 빅데이터를 다루다보면 많은 feature들을 마주하게 됩니다. 많은 feature 들이 다 유의미한 값을 가져서 target값을 예측하..

mojitos.tistory.com

위 방법으로 Feature 를 살펴 보았을때, Temperature 및 Fuel Price 을 빼는것이 차원을 좀더 낮추고 좋은 결과를 얻을 수 있다는것을 찾아냈습니다. 그리하여, 몇개의 Feature 만 사용하여 XGBoost를 년도별로 진행하여 보았습니다.

import xgboost

features = ['Store', 'Promotion1', 'Promotion2', 'Promotion3',
            'Promotion4', 'Promotion5', 'Unemployment',
            'Week', 'Day','Month', 'Year', 'NumberHoliday']
            
# 데이터 전처리
trainset = preprocessing(train)
testset = preprocessing(test)

 

STEP 6. ML

XGBoost Train 하는 부분은 공유해주신 코드를 참조하였습니다.

models = []
models_pred = []


model_params = {
    "n_estimators": 60,
    "min_child_weight": 3,
    "max_depth": 6
}

for store in range(1,max(trainset.Store)+1):
    train_store = trainset[trainset.Store==store]
    
    # 2010, 2011, 2012 년도 별로 데이터 분리
    # 2012-09에 대해 예측하려고 하기 때문에 2012년도는 9월을 포함하지 않음
    train_store_2010 = train_store[(train_store.Year==2010) & (train_store.Month<=9)]
    train_store_2011 = train_store[(train_store.Year==2011) & (train_store.Month<=9)]
    train_store_2012 = train_store[(train_store.Year==2012) & (train_store.Month<9)]
    
    # 2011, 2010 년도를 제외한 데이터 생성
    train_store_2010_2012 = pd.concat([train_store_2010, train_store_2012])
    train_store_2011_2012 = pd.concat([train_store_2011, train_store_2012])
    
    # 각각의 모델 학습
    model_2010_2012 = xgboost.XGBRegressor(**model_params)
    model_2010_2012.fit(train_store_2010_2012[features],
                        train_store_2010_2012.Weekly_Sales)
    
    model_2011_2012 = xgboost.XGBRegressor(**model_params)
    model_2011_2012.fit(train_store_2011_2012[features],
                        train_store_2011_2012.Weekly_Sales)
    
    # 2012년도 9월에 대해서 예측
    x_test = train_store[(train_store.Year==2012) & (train_store.Month==9)]
    pred_2010_2012 = model_2010_2012.predict(x_test[features])
    pred_2011_2012 = model_2011_2012.predict(x_test[features])
    
    # 예측 결과 평가
    rmse_2010_2012 = mean_squared_error(pred_2010_2012, x_test.Weekly_Sales, squared=False)
    rmse_2011_2012 = mean_squared_error(pred_2011_2012, x_test.Weekly_Sales, squared=False)

    
    # 오차가 더 적은 연도를 선택
    similar_year = 2010
    if rmse_2010_2012 > rmse_2011_2012:
        similar_year = 2011

    print(f"{store:02}", similar_year, rmse_2010_2012, rmse_2011_2012)
    
    # 전체 데이터로 학습
    train_store_target = pd.concat([
        train_store[(train_store.Year==similar_year) & (train_store.Month<=10)],
        train_store[(train_store.Year==2012)]
    ])

    model = xgboost.XGBRegressor(**model_params)
    model.fit(train_store_target[features], train_store_target.Weekly_Sales)
    models_pred.append(model.predict(train_store[features]))
        
    models.append(model)
# prediction
model_pred = pd.DataFrame(np.array(models_pred).reshape(-1), columns=['Weekly_Sales'])
model_pred.index += 1
model_pred.index.name = 'id'
model_pred.to_csv('../xgboost_fittedvalue.csv')

# Forecast
pred = []
for store in range(1, max(trainset.Store)+1):
    test_store = testset[testset.Store==store]
    
    y = models[store-1].predict(test_store[features])
    pred += y.tolist()

model_pred = pd.DataFrame(np.array(pred).reshape(-1), columns=['Weekly_Sales'])
model_pred.index += 1
model_pred.index.name = 'id'
# model_pred.to_csv('../xgboost_forecast.csv')

 

STEP 7. Test

 

마지막으로 두가지 모델을 Linear Regression 모델로 Ensemble 모델을 만들어 줄것입니다.

관련 논문 참조: https://arxiv.org/pdf/2201.02034.pdf

from sklearn.linear_model import LinearRegression

pred_all = []
store = 1
start_col = 0
end_col = 139
test_start_col = 0
test_end_col = 4

while store <= 45:

    lr = LinearRegression()
    lr.fit(trainX[start_col:end_col], prophet[start_col:end_col].Weekly_Sales)
    
    final_pred = lr.predict(test[test_start_col:test_end_col])
    pred_all.append(final_pred)

    print(f'{store}, {start_col}, {end_col}, {test_start_col}, {test_end_col}')
    
    start_col += 139
    end_col += 139
    store += 1
    test_start_col += 4
    test_end_col += 4

각 스토어 별로 마지막 데이터 후 4주씩 예측을 해줍니다. 그 후 결과를 저장해줍니다.

model_pred = pd.DataFrame(np.array(pred_all).reshape(-1), columns=['Weekly_Sales'])
model_pred.index += 1
model_pred.index.name = 'id'
model_pred.to_csv('../XgboostArima.csv')

Private 약 40000대 RMSE로 약 4X등의 결과를 거두었습니다.

짧은기간동안 데이터 분석보단 기술적으로 모델의 성능을 높이는데 집중을 하였습니다.

관련 공부했던 논문들을 적용시켜볼 기회가 되어서 좋은 경험이였습니다.

 

[결론]

  시계열 데이터에서 Prophet, Arima, Sarima등의 전통 분석 방법을 사용하면 시계열 추세를 잘 따라주긴 하나 이상치들을 예측하는데는 어려움이 있었습니다. 이를 위해, XGBoost, LightGBM등 각종 다른 모델들을 사용하여 이상치를 잡아내기 위해 노력하였고, 그중 가장 성능이 좋았던 XGBoost 를 사용하여 Arima(추세) + XGBoost(이상치) 의 Ensemble 모델을 고안해 보았습니다. 결과는 각각의 모델들보다 성능이 더 띄어났으며, 데이터의 프로모션등 다른 Nan값들의 전처리 방법이 충분히 고안된다면 위 모델을 사용하여 더 좋은 성능을 내는 모델을 만들 수 있을것같습니다.

 

대회: https://dacon.io/competitions/official/235942/overview/description

데이터: https://drive.google.com/file/d/1TEwm7wxyviepHUDe81Eqzwy_g0tPXuNh/view

 
반응형

댓글