9
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

初心者によるKaggleメモ#2 将来の売り上げ予測

Last updated at Posted at 2018-07-30

前回のタイタニックチュートリアルの記事が中途半端ですが、仕事で時系列データによる将来の予測のタスクが来たので、勉強がてら他の時系列のKaggleコンテストを触ってみます。

対象のコンテスト

Store Item Demand Forecasting Challenge | Predict 3 months of item sales at different stores

コンテスト内容

  • 与えられるのは5年分の各ショップの売り上げデータ。
  • ショップ数10、商品数50。
  • 3か月先の各ショップ別・商品別の売り上げを予測するのが目的。
  • 賞金などはなく、スキルアップなどが目的のコンペ。

データを確認してみる

KaggleのAPIを使ってみる

APIがあるようなので、データを落としたりが楽にできないか少し触ってみます。(クラウド上のカーネルでの対応が楽になるのを期待しつつ)

とりあえず、Azure Notebooks環境で進めてみます。

ライブラリをインストール。

!pip install kaggle
Successfully installed kaggle-1.4.2

認証用のファイルをダウンロードします。
Kaggleのアカウントのページ(https://www.kaggle.com/<ユーザー名>/account)にアクセスします。

APIのところの「Create New API Token」をクリック。トークンのJSONファイルがダウンロードされます。

20180721_1.png

Kaggleのライブラリモジュールをimportすると、「ここに認証用のファイルを設置してね」とか「JSONの権限を600に変えてね」といったエラー内容や警告がでるので、そちらの指示に従って対応します。

Azure Notebooksの場合、「/home/nbuser/.kaggle/」以下に設置するように指示が出ました。

import kaggle
import os
os.makedirs('/home/nbuser/.kaggle/')
!cp ../auth/kaggle.json /home/nbuser/.kaggle/kaggle.json
!chmod 600 /home/nbuser/.kaggle/kaggle.json

(クラウド環境だと、消えてしまったりしそうなので、あとでスクリプト書いておこうと考えつつ・・)

データダウンロード関係のドキュメントを見てみます。
どうやらコマンドライン経由で扱う模様・・

-c 引数に対象コンペのURLに含まれるID的な文字列(今回は https://www.kaggle.com/c/demand-forecasting-kernels-only の demand-forecasting-kernels-only )、-p引数にダウンロード先のディレクトリを指定します。

!kaggle competitions download -c demand-forecasting-kernels-only -p ./
Downloading sample_submission.csv.zip to .
  0%|                                                | 0.00/101k [00:00<?, ?B/s]
100%|█████████████████████████████████████████| 101k/101k [00:00<00:00, 486kB/s]
Downloading test.csv.zip to .
  0%|                                                | 0.00/135k [00:00<?, ?B/s]
100%|█████████████████████████████████████████| 135k/135k [00:00<00:00, 398kB/s]
Downloading train.csv.zip to .
 97%|████████████████████████████████████▉ | 3.00M/3.08M [00:00<00:00, 3.64MB/s]
100%|███████████████████████████████████████| 3.08M/3.08M [00:04<00:00, 804kB/s]

zipで落ちてくる模様。
解凍しておきます。

import zipfile
file_list = [
    'sample_submission.csv.zip',
    'train.csv.zip',
    'test.csv.zip']
for file_name in file_list:
    with zipfile.ZipFile(file=file_name) as target_zip:
        target_zip.extractall()

train.csv

import pandas as pd
train_df = pd.read_csv('./train.csv')
train_df.head()
date store item sales
0 2013-01-01 1 1 13
1 2013-01-02 1 1 11
2 2013-01-03 1 1 14
3 2013-01-04 1 1 13
4 2013-01-05 1 1 10
print(train_df.tail().to_html())
date store item sales
912995 2017-12-27 10 50 63
912996 2017-12-28 10 50 59
912997 2017-12-29 10 50 74
912998 2017-12-30 10 50 62
912999 2017-12-31 10 50 82

約91万行のデータ。

train_df.describe().loc[:, ['sales']]
sales
count 913000.000000
mean 52.250287
std 28.801144
min 0.000000
25% 30.000000
50% 47.000000
75% 70.000000
max 231.000000
train_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 913000 entries, 0 to 912999
Data columns (total 4 columns):
date     913000 non-null object
store    913000 non-null int64
item     913000 non-null int64
sales    913000 non-null int64
dtypes: int64(3), object(1)
memory usage: 27.9+ MB

欠損値などはない模様。

test.csv

test_df = pd.read_csv('test.csv')
test_df.head()
id date store item
0 0 2018-01-01 1 1
1 1 2018-01-02 1 1
2 2 2018-01-03 1 1
3 3 2018-01-04 1 1
4 4 2018-01-05 1 1
test_df.tail()
id date store item
44995 44995 2018-03-27 10 50
44996 44996 2018-03-28 10 50
44997 44997 2018-03-29 10 50
44998 44998 2018-03-30 10 50
44999 44999 2018-03-31 10 50
test_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 45000 entries, 0 to 44999
Data columns (total 4 columns):
id       45000 non-null int64
date     45000 non-null object
store    45000 non-null int64
item     45000 non-null int64
dtypes: int64(3), object(1)
memory usage: 1.4+ MB

sample_submission.csv

sample_submission_df = pd.read_csv('./sample_submission.csv')
sample_submission_df.head()
id sales
0 0 52
1 1 52
2 2 52
3 3 52
4 4 52
sample_submission_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 45000 entries, 0 to 44999
Data columns (total 2 columns):
id       45000 non-null int64
sales    45000 non-null int64
dtypes: int64(2)
memory usage: 703.2 KB

Prophetを使ってみる

知り合いからFacebookのライブラリのProphet良さそうと伺ったのと、シンプルで馴染むAPIだったので、一旦Prophetを使ってみます。

インストール周り

!pip install fbprophet

pystanに依存するため、一度エラーが出ますがそのままpystanのインストールも含め、インストールが進んでいきました。

ModuleNotFoundError: No module named 'pystan'
...
Installing collected packages: pystan, fbprophet

※2017/9/26現在ではAnacondaのfbprohetがver0.1.1なのでpip install推奨 一回エラー出るかもしれないけど、pystanが入ればインストール成功します。
Prophet利用方法

インストールに少し時間がかかります。

Successfully installed fbprophet-0.3.post2 pystan-2.17.1.0

一部のストア・商品で動かしてみる

ストアID = 1, 商品ID = 1 のデータフレームで、Prophetを動かしてみます。

store_1_df = train_df[
    (train_df.store == 1) & (train_df.item == 1)]
store_1_df.head(n=3)
date store item sales
0 2013-01-01 1 1 13
1 2013-01-02 1 1 11
2 2013-01-03 1 1 14
store_1_df.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 1826 entries, 0 to 1825
Data columns (total 4 columns):
date     1826 non-null object
store    1826 non-null int64
item     1826 non-null int64
sales    1826 non-null int64
dtypes: int64(3), object(1)
memory usage: 151.3+ KB
renamed_store_1_df.head(n=2)
ds y
0 2013-01-01 13
1 2013-01-02 11

一旦、引数でのパラメーター調整などせずに対応します。

from fbprophet import Prophet
m = Prophet()
m.fit(df=renamed_store_1_df)

3か月分、90行分必要なので、periods=90と指定します。

future_df = m.make_future_dataframe(periods=90)
forecast_df = m.predict(df=future_df)

yhatというカラム名で、推論結果が設定されます。

forecast_df.loc[:, ['ds', 'yhat', 'yhat_lower', 'yhat_upper']].tail()
ds yhat yhat_lower yhat_upper
1911 2018-03-27 20.606651 14.875017 26.001466
1912 2018-03-28 21.284127 15.653048 26.926783
1913 2018-03-29 21.995847 16.337424 27.573980
1914 2018-03-30 23.612987 18.062670 28.965417
1915 2018-03-31 25.626790 20.105935 31.187498

ドキュメントに、

as well as columns for components and uncertainty intervals.
Quick Start

とあり、yhat_lowerとyhat_upperの範囲は信頼区間的なもののようです。

matplotlibのfigureを返す関数が用意されているので、そちらでプロットしてみます。

m.plot(fcst=forecast_df)

image.png

使い方がシンプルでいいですね。

同様に、全ストア・全商品で試してみる

store_id_arr = train_df.store.unique()
store_id_arr
array([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10])
item_id_arr = train_df.item.unique()
item_id_arr
array([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16, 17,
       18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34,
       35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50])

推論結果などの保存先のディレクトリを用意しておきます。

import os

CSV_DIR = './csv/'
PLOT_DIR = './plot/'

if not os.path.exists(CSV_DIR):
    os.makedirs(CSV_DIR)
if not os.path.exists(PLOT_DIR):
    os.makedirs(PLOT_DIR)

対象のストア・商品の推論結果とプロットを保存する関数を用意しておきます。

from datetime import datetime

def predict_and_save_result(store_id, item_id):
    """
    対象のストア、商品に対して推論を行い、結果のデータ
    のCSVとプロットの保存を行う。
    
    CSV保存先 : ./csv/<ストアのID>_<商品のID>.csv
    プロット保存先 : ./plot/<ストアのID>_<商品のID>.png
    
    Parameters
    ----------
    store_id : int
        推論対象のストアのID。
    item_id : int
        推論対象の商品のID。
    """
    print(
        datetime.now(), 'store_id :', store_id,
        'item_id :', item_id, 'prediction started.')
    
    csv_path = CSV_DIR + '%s_%s.csv' % (store_id, item_id)
    plot_path = PLOT_DIR + '%s_%s.png' % (store_id, item_id)
    
    # すでに結果が存在すれば処理は行わない。
    if (os.path.exists(csv_path) and
            os.path.exists(plot_path)):
        return
    
    target_df = train_df[
        (train_df.store == store_id) & (train_df.item == item_id)]
    renamed_df = target_df.rename(
        columns={'date': 'ds', 'sales': 'y'})
    renamed_df = renamed_df.loc[:, ['ds', 'y']]
    m = Prophet()
    m.fit(df=renamed_df)
    future_df = m.make_future_dataframe(periods=90)
    forecast_df = m.predict(df=future_df)
    figure = m.plot(fcst=forecast_df)
    
    forecast_df.to_csv(
        csv_path, index=False, encoding='utf-8')
    figure.savefig(fname=plot_path)

試しにstore_id=1, item_id=1で動かしてみて、結果を表示してみます。

!pip install pillow
Requirement already satisfied: pillow in /home/nbuser/anaconda3_501/lib/python3.6/site-packages (4.3.0)
predict_and_save_result(store_id=1, item_id=1)
img = Image.open(fp=PLOT_DIR + '1_1.png')
img

image.png

img.close()

saved_df = pd.read_csv(CSV_DIR + '1_1.csv')
saved_df.tail().loc[:, ['ds', 'yhat']]
ds yhat
1911 2018-03-27 20.606651
1912 2018-03-28 21.284127
1913 2018-03-29 21.995847
1914 2018-03-30 23.612987
1915 2018-03-31 25.626790

大丈夫そうです。
大雑把に計った感じでは、一通り処理が終わるまでは何時間も必要になりそうな印象です。
仕事だと並列化などを検討するところですが、今回は手間なので処理が終わるまで待つようにします。

for store_id in store_id_arr:
    for item_id in item_id_arr:
        predict_and_save_result(
            store_id=store_id, item_id=item_id)

各商品まとめた画像を作ってみる

変な推論になっているデータがないか大雑把に確認するため、各ストアごとに各プロットを小さく縦10 x 横5でまとめた画像を生成してみてみます。

プロット画像のサイズ確認 :

from PIL import Image
img = Image.open(PLOT_DIR + '1_1.png')
img.width
720
img.height
432
img.close()

縦横の比率を保ったまま、1画像あたり幅180px高さ108pxとし、合計で幅900px、高さ1080pxの画像をストアごとに作ります。

def make_store_all_plot_img(store_id):
    """
    対象のストアの各商品ごとのプロットをまとめた1つの
    画像を生成する。
    
    Parameters
    ----------
    store_id : int
        対象のストアのID(1~10)。
    
    Returns
    -------
    img : Image
        対象のストアの50商品分のプロットをまとめた画像。
        縦10 x 横5画像で、W900px, H1080pxの画像となる。
    """
    unit_width = 180
    unit_height = 108
    img = Image.new(
        mode='RGB',
        size=(unit_width * 5, unit_height * 10),
        color='#FFFFFF')
    for i, item_id in enumerate(item_id_arr):
        unit_plot_file_path = PLOT_DIR + '%s_%s.png' % \
            (store_id, item_id)
        unit_img = Image.open(fp=unit_plot_file_path)
        unit_img = unit_img.resize(
            size=(unit_width, unit_height),
            resample=Image.BILINEAR)
        x_index = i % 5
        y_index = int(i / 5)
        img.paste(
            im=unit_img,
            box=(x_index * unit_width, y_index * unit_height))
        unit_img.close()
    return img
make_store_all_plot_img(store_id=1)

image.png

ここでは省略しますが、一応他のショップのものも確認しておきます。

大雑把にですが、大丈夫そうなので、次のステップに進みます。

提出用のデータを用意する。

提出用のデータは、idとsalesカラムのみ必要となります。
test.csvには、id, date, store, itemカラムがあり、推論結果はstoreとitemごとに、各日と推論結果を含んだCSVとなっています。

そのため、提出用のCSVを用意するために、date, store, itemの値からidを取得できるように、多次元の連想配列を用意しておきましょう。

dict[date][store][item] = id といった具合に、各次元のキーにそれぞれ日付、ストアID、商品ID、値にidカラムの値を設定します。
通常の辞書の場合、存在しないキーの部分に毎回新しい辞書を追加したりと手間なので、defaultdictを使います。

from collections import defaultdict
test_data_defaultdict = defaultdict(
    lambda: defaultdict(lambda: defaultdict(int)))

また、データフレームのままループを回すと遅いので、一旦ndarrayに変換してからループを回します。(そうすることで、一瞬で辞書への値の設定が終わります)

test_arr = test_df.as_matrix()
test_arr
array([[0, '2018-01-01', 1, 1],
       [1, '2018-01-02', 1, 1],
       [2, '2018-01-03', 1, 1],
       ...,
       [44997, '2018-03-29', 10, 50],
       [44998, '2018-03-30', 10, 50],
       [44999, '2018-03-31', 10, 50]], dtype=object)
for target_row_arr in test_arr:
    id_ = int(target_row_arr[0])
    date = str(target_row_arr[1])
    store =  int(target_row_arr[2])
    item = int(target_row_arr[3])
    
    test_data_defaultdict[date][store][item] = id_

用意した辞書を参照する形で、各推論結果のデータフレームにidカラムを追加し、ストア・商品別に分かれている分を一つのデータフレームにまとめます。

def get_target_data_id(date, store_id, item_id):
    """
    対象の日付、ストアのID、商品のIDから、該当するidを
    取得する。
    
    Parameters
    ----------
    date : str or Timestamp
        対象の日付。
    store_id : int
        対象のストアのID。
    item_id : int
        対象の商品のID。
        
    Returns
    -------
    id_ : int
        該当する(提出用のデータに紐づく)id。
        該当する値が存在しない場合は-1が返却される。
    """
    date = str(date)
    store_id = int(store_id)
    item_id = int(item_id)
    if not test_data_defaultdict[date][store_id].has_key(item_id):
        return -1
    return test_data_defaultdict[date][store_id][item_id]

predicted_df_list = []
for store_id in store_id_arr:
    print(datetime.now(), 'store_id :', store_id, 'started.')
    for item_id in item_id_arr:
        
        target_store_csv_path = CSV_DIR + '%s_%s.csv' % \
            (store_id, item_id)
        target_store_predicted_df = pd.read_csv(
            target_store_csv_path)
        target_store_predicted_df['id'] = target_store_predicted_df['ds'].apply(
            get_target_data_id,
            store_id=store_id, item_id=item_id)
        predicted_df_list.append(target_store_predicted_df)
2018-07-28 07:02:40.583967 store_id : 1 started.
2018-07-28 07:02:47.156170 store_id : 2 started.
2018-07-28 07:02:54.077372 store_id : 3 started.
2018-07-28 07:03:00.129491 store_id : 4 started.
2018-07-28 07:03:05.877288 store_id : 5 started.
2018-07-28 07:03:13.579948 store_id : 6 started.
2018-07-28 07:03:21.454486 store_id : 7 started.
2018-07-28 07:03:28.574409 store_id : 8 started.
2018-07-28 07:03:35.764472 store_id : 9 started.
2018-07-28 07:03:44.597948 store_id : 10 started.
overall_df = pd.concat(
    predicted_df_list, ignore_index=True)

推論対象の日付範囲のデータのみにスライスします。

overall_df = overall_df[overall_df.ds >= '2018-01-01']
len(overall_df)
45000

必要なカラムのみにスライスします。

overall_df = overall_df.loc[:, ['yhat', 'id']]

test.csvのデータとidカラムを使って結合します。

submission_df = pd.merge(
    left=test_df, right=overall_df, how='inner', on='id')

yhatとなっているカラム名を、提出用にsalesとリネームします。

submission_df.rename(columns={'yhat': 'sales'}, inplace=True)

提出用のCSVがidとsalesカラムのみ必要なので、そのようにスライスします。

submission_df = submission_df.loc[:, ['id', 'sales']]

salesカラムが推論結果のままで、浮動小数点数を含んでいるので整数にキャストしておきます。

import numpy as np

submission_df.sales = submission_df.sales.astype(
    np.int, copy=False)

結果を確認してみます。

submission_df.head()
id sales
0 0 12
1 1 15
2 2 16
3 3 16
4 4 18
submission_df.describe()
id sales
count 45000.000000 45000.000000
mean 22499.500000 50.478556
std 12990.525394 25.119587
min 0.000000 8.000000
25% 11249.750000 30.000000
50% 22499.500000 47.000000
75% 33749.250000 67.000000
max 44999.000000 149.000000

train.csvのほうのデータ全体だと、平均52、25%パーセンタイルが30、中央値47、75%パーセンタイルが70となっており、推論結果と比較してみてもとりあえずは大丈夫そうです。(少なくとも間違ったカラムを連結して桁が違うといったことは無さそうです)

提出!

submission_df.to_csv('submission.csv', index=False, encoding='utf-8')

今回のコンテストではCSVのほかにカーネルの提出も必要なので、用意したノートを調整し、そちらも提出します。(やり方を調べつつ・・)

New Kernelのボタンを押すとKaggleのKernelのノートが起動するようです。

20180728_1.png

右上の方にある雲のアイコンで、ノートファイルをアップできるので、そちらでここまでに書いたノートをアップします。

20180728_2.png

データのダウンロードをAPIを利用していたので、そこをKaggle上のカーネルで参照できるように設定します。

右のほうにある Add Dataset ボタンを押すと、コンテストごとのアップしたいデータセットを検索できるようです。(便利!)

20180728_3.png

データセットを設定した後は、../input/というフォルダで対象のコンテストのデータセットが格納されるようなので、ノートのCSV読み込みの個所をそちらに調整します。

train_df = pd.read_csv('../input/train.csv')
...
test_df = pd.read_csv('../input/test.csv')
...
sample_submission_df = pd.read_csv('../input/sample_submission.csv')

なお、ProphetやPILなどはすでにKaggleカーネルにインストール済みなようです。楽でいいですね。

また、メモリも17GB、ディスクもそのカーネルで1GBまで、GPUも使えるようで、Colaboratoryレベル・・と驚きましたが、よく調べたらKaggleはGoogleが買収済みなようで・・納得です:sweat:
このスペックだったら最初からKaggle Kernel上で作業すればよかった。

提出の際にカーネルが実行される必要があり、二度手間なので、次回からは積極的にこちらのカーネルも使っていこうと思います・・

ほかになにか調整が必要であれば調整を行い、そののちにCommit & Runボタンを押します。
なお、提出方法の資料のところに、「Publish」ボタンを押してね、といった記載がありましたが、どうやら古い記述のようで、現在はCommit & Runボタンになっているようです。(しばらくPublishボタンを探してしまった・・)

あとは、保存済みのsubmission.csvを、APIを使って投稿します。(認証周りは前述したデータダウンロード部分のものを参考にしてください。)

!kaggle competitions submit -c demand-forecasting-kernels-only -f submission.csv -m "The Prophet libs predictions"

※補足 : 上記コマンド実行後、「Successfully submitted to Store Item Demand Forecasting Challenge」と出るものの、My Submissionsページのリストに反映されない・・
提出方法のページのスクショが古く該当するUIが見つからないのと、提出関係のページには「Use the kaggle API to make a submission」と説明が・・
現在調査中。
(まあでも、勉強用ですし、ここまででも一旦はいいような気もします・・)

9
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?