3. 問題2:価格提案

3.1. 取り組む問題

Mercari Price Suggestion Challenge

3.1.1. 背景

メルカリは日本の会社で,コミュニティ主導のマーケットプレイスを主な製品としています.ユーザーはほぼすべてのものを売りに出すことができ,適切と思われる価格で取引することができます.しかし,商品のカテゴリーによっては価格の傾向が不明な場合等,ユーザーが商品の価値を判断するのが難しいこともあります.メルカリはこのようなユーザーが価格を決定する際にそれを提案する方法を作りたいと考えています.

3.1.2. タスク

ユーザーが作成したアイテムの説明文,アイテムのタイプ情報,アイテムの状態が提供されます.この情報を使ってアイテムの販売価格を予測してください.

3.1.3. データ

  • train.csv - メルカリの出品データからなる学習データ.

  • test.csv - 販売価格を予測するためのテストデータ.

  • sample_submission.csv - 予測値を提出するフォーマット.

3.2. セットアップ

以下のセルは必要なデータをダウンロードし,ノートブックで使用する環境を設定するためのものです.

3.2.1. データセット

Kaggleはコンペティションと簡単にやり取りできるAPIを提供しています.このAPIを使ってデータをダウンロードし,予測結果をアップロードします.

このAPIを使用する最初のステップは,ユーザー認証です.APIトークンはユーザー名とKaggleが生成したキーを含むファイルです.トークンはアカウントページからダウンロードすることができ,通常 kaggle.json という名前のファイルです.APIトークンは,ユーザーとしてAPIにアクセスするために必要なものなので,個人のGoogle Driveフォルダに安全に保管してください.

このノートブックはGoogle Driveフォルダ内のkaggle.jsonというKaggle APIトークンを検索します.トークンをGoogle Driveに置いたことを確認し,プロンプトが表示されたらこのノートブックがトークンにアクセスすることを許可してください.

from google.colab import drive
import os
import json

drive.mount("/content/drive", force_remount=True)
fin = open("/content/drive/MyDrive/kaggle/token/kaggle.json", "r")
json_data = json.load(fin)
fin.close()
os.environ["KAGGLE_USERNAME"] = json_data["username"]
os.environ["KAGGLE_KEY"] = json_data["key"]

認証後,参加したすべてのコンペティションにアクセスできます.データのダウンロードにエラーが発生した場合は,Kaggle API トークンが有効であること,コンペティションのルールに同意していることを確認してください.

%%bash
kaggle competitions download -c mercari-price-suggestion-challenge
if [ $? -ne 0 ]; then
    echo "データのダウンロードに問題があったようです."
    echo "競技規則に同意し,APIキーが有効であることを確認してください."
else
    mkdir -p /content/kaggle
    unzip -o /content/mercari-price-suggestion-challenge.zip -d /content/kaggle
    unzip -p /content/kaggle/test_stg2.tsv.zip > /content/kaggle/test.tsv
    unzip -p /content/kaggle/sample_submission_stg2.csv.zip > /content/kaggle/sample_submission.csv
    7z e -so /content/kaggle/train.tsv.7z > /content/kaggle/train.tsv
    rm /content/kaggle/*.zip /content/kaggle/*.7z
fi
wget -q -P /tmp https://noto-website-2.storage.googleapis.com/pkgs/NotoSansCJKjp-hinted.zip
unzip -o /tmp/NotoSansCJKjp-hinted.zip -d /usr/share/fonts/NotoSansCJKjp

3.2.2. 計算環境

import os
import pandas as pd
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.preprocessing import OneHotEncoder
from sklearn.model_selection import cross_val_score
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_log_error, make_scorer
import lightgbm as lgb
from tqdm.auto import tqdm

font_path = '/usr/share/fonts/NotoSansCJKjp/NotoSansMonoCJKjp-Regular.otf'
matplotlib.font_manager.fontManager.addfont(font_path)
prop = matplotlib.font_manager.FontProperties(fname=font_path)
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = prop.get_name()
os.chdir('/content/kaggle')

3.3. 探索的データ解析

このコンペでは,トレーニングデータを含むファイルはひとつだけです.データのほとんどはアイテム名,カテゴリー,説明などのテキストなので,視覚化するものがほとんどないように思えるかもしれません.しかし,いくつかの基本的な前処理を行うことで,これらをより簡単に視覚化できる他の値に変換することができます.

3.3.1. train.tsv

data = pd.read_csv('/content/kaggle/train.tsv', index_col='train_id', sep='\t')
data

メルカリに出品されている商品は一般的に中古品であり,データにはitem_condition_idという状態に関する指標が含まれています.値が小さいほど状態が良いことを表します.

sns.barplot(data, x='item_condition_id', y='price');

中古でないものの価格は高く,中古品の価格は低いと考えるのが普通ですが,データではそうなっていません.商品の状態は価格にほとんど影響を与えないようです.

また,category_nameの値は,単に/文字で区切られた3つの階層的なカテゴリーであることがわかります.より詳細な情報を得るために,元のカテゴリーを抽出することができます.

print(f"オリジナルカテゴリ:{len(data['category_name'].unique())}")
data[['level1_category_name', 'level2_category_name', 'level3_category_name']] = data['category_name'].str.split('/', n=2, expand=True)
print(f"階層の一番目のレベル:{len(data['level1_category_name'].unique())}")
print(f"階層の二番目のレベル:{len(data['level2_category_name'].unique())}")
print(f"階層の三番目のレベル:{len(data['level3_category_name'].unique())}")
sns.countplot(data, x='level1_category_name')
plt.xticks(rotation=65);

このデータによると,出品数が圧倒的に多いカテゴリーはWomenです.

sns.countplot(data[data['level1_category_name']=='Women'], x='level2_category_name')
plt.xticks(rotation=65);

このカテゴリーに含まれる商品のほとんどが婦人服であることがわかります.

最後のカラムは「ブランド名」です.これは販売価格の良い指標になるかもしれません.なぜなら,ブランドによっては格安志向のものもあれば,高級志向のものもあるからです.生データを見たとき,この列にはいくつかNaNの項目がありました.

print(f'brand_nameのデータの{data["brand_name"].isna().sum() * 100 / data.shape[0]:.2f}%はNaNである.')

値のかなりの部分がNaNですが,この欄には有益な情報が含まれています.価格の中央値でソートした上位ブランドを見て,ブランドが価格にどのような影響を与えるかを検証します.100件未満のブランドは除外します.

top_brands = data.groupby('brand_name')['price'].agg(['count', np.median]).sort_values(by='median', ascending=False)
top_brands = top_brands[top_brands['count']>=100].reset_index()
sns.barplot(top_brands.iloc[:30], x='median', y='brand_name');

この図は,予想が概ね正しかったことを示しています.最も高価なブランドの多くは,高級衣料品や宝飾品ブランドです.これらに加えて,エレクトロニクス・ブランドもあり,これらのアイテムは他のカテゴリーに比べ総じて高価である可能性があります.

3.4. モデリング

3.4.1. 学習

予測モデルを作成するために,比較的単純なアプローチを取ります.最初に,データを表示します.

data.head()

ほとんどの機械学習モデルはテキストの特徴を直接使用することができないため,以下のカラムを変換するか破棄する必要があります: namecategory_name,また,上で作成した階層的なカテゴリーカラムである brand_nameitem_description です.

ここで,nameitem_descriptionは非常に重要な情報を含んでいる可能性が高いですが,処理が簡単ではないかもしれません.手始めに,これらのフィールドに価格が元々含まれているかどうかをチェックし,各フィールドの長さを決定します.価格に関連する他のキーワードがあるかもしれないので,手作業で探してみても良いでしょう.これに関してはより複雑で有効な方法があるので,このノートの最後に紹介します.

上記の可視化のセクションで,brand_nameを使用することで,価格の傾向が異なるブランドの種類を識別できることを観察しました.以前と同様のアプローチで,一定回数出現するブランド名を抽出します.category_nameについては,3つのカテゴリーレベルを抽出するが,第1レベルと第3レベルのみを使用します.

カテゴリー特徴は数値でエンコードする必要があります.これは基本的に各カテゴリーに1つの列を作成し,1または0を使ってカテゴリーの有無を示します.この方法の欠点は,作成される列の数によるメモリ使用量です.データセットによっては,この方法は効果的でもスケーラブルでもありません.

train_data = data.copy().sample(frac=0.25).reset_index(drop=True)
brand_name_ohe = OneHotEncoder(sparse_output=False, dtype=np.int8, handle_unknown='ignore')
top_brands = data.groupby('brand_name')['price'].agg(['count', 'median'])
top_brands = top_brands[top_brands['count']>=100].sort_values(by='median', ascending=False)
top_brands = top_brands.reset_index()[['brand_name']]
brand_name_ohe.fit(top_brands)
category_name_ohe = OneHotEncoder(sparse_output=False, dtype=np.int8, handle_unknown='ignore')
category_name_ohe.fit(train_data[['level1_category_name', 'level3_category_name']])
train_data = pd.concat(
    [
        train_data,
        pd.DataFrame(data=brand_name_ohe.transform(train_data[['brand_name']]), columns=brand_name_ohe.get_feature_names_out()),
        pd.DataFrame(data=category_name_ohe.transform(train_data[['level1_category_name', 'level3_category_name']]), columns=category_name_ohe.get_feature_names_out())
    ],
    axis=1
)
train_data['price_in_name'] = train_data['name'].str.contains('[rm]', regex=False).fillna(False).astype(np.int8)
train_data['price_in_item_description'] = train_data['item_description'].str.contains('[rm]', regex=False).fillna(False).astype(np.int8)
train_data['name_len'] = train_data['name'].str.len()
train_data['item_description_len'] = train_data['item_description'].str.len()
train_data = train_data.drop(columns=['name', 'brand_name', 'category_name', 'item_description', 'level1_category_name', 'level2_category_name', 'level3_category_name', 'brand_name'])
train_data = train_data.fillna(0)
train_data

データを前処理すると,1000を超えるカラムが含まれるようになります.約13GBのRAMしか持たない標準的なグーグルコラボラトリーのランタイムでは,モデルのトレーニング中にメモリ不足がほぼ確実に発生します.これを避けるため,初期データの25%のみをトレーニングに使用します.高メモリのランタイムが利用できる場合は,データセット全体を使用してみて,モデルのスコアにどのような影響が出るかを確認してください. 以下で使用するモデルはLightGBMで,scikit-learnで実装された他のツリーベースのモデルと比較した場合,一般的に優れた性能を示すツリーベースのモデルです.一般的に優れたパフォーマンスに加えて,LightGBM は通常 scikit-learn のツリーベースのモデルよりも高速に学習が可能で,より少ないメモリで済みます.同様の性能を持つ他のモデルには,XGBoost や CatBoost があります.

def custom_rmsle_scorer(preds, eval_data):
    preds[preds < 0] = 0
    return ('rmsle', mean_squared_log_error(eval_data.get_label(), preds, squared=False), False)

params = {
    'objective': 'regression',
    'num_iterations': 1000,
    'early_stopping_rounds': 10,
    'force_col_wise': True
}
lgb_train_data = lgb.Dataset(
    data=train_data.drop(columns=['price']).values,
    label=train_data['price'].values
)
cvbooster = lgb.cv(
    params=params,
    train_set=lgb_train_data,
    metrics='rmsle',
    feval=custom_rmsle_scorer,
    stratified=False,
    return_cvbooster=True
)
print(f"平均rmsle:{np.min(cvbooster['valid rmsle-mean']):.3f}")

3.4.2. 予測

トレーニングが完了したら,テストデータセットの販売価格を予測するためにモデルを使います.前と同じようにデータの前処理を行いますが,メモリ不足にならないように小ロットで行います.

lgb.cvはクロスバリデーションフォールドごとに1つのモデルを学習させるので,テストデータの各行に対して複数の価格予測を行うことになります.予測値の平均を最終的な予測値として使用します.

テストデータのサイズが大きいので,このセルは標準的なランタイム(2コア)で30分かかるかもしれません.

chunksize = 100_000
test_ids = []
preds = []
for test_data in tqdm(pd.read_csv('/content/kaggle/test.tsv', index_col='test_id', sep='\t', chunksize=chunksize), total=int(np.ceil(3460725/chunksize))):
    test_ids.append(test_data.index.values)
    test_data = test_data.reset_index(drop=True)
    test_data[['level1_category_name', 'level2_category_name', 'level3_category_name']] = test_data['category_name'].str.split('/', n=2, expand=True)
    test_data = pd.concat(
        [
            test_data,
            pd.DataFrame(data=brand_name_ohe.transform(test_data[['brand_name']]), columns=brand_name_ohe.get_feature_names_out()),
            pd.DataFrame(data=category_name_ohe.transform(test_data[['level1_category_name', 'level3_category_name']]), columns=category_name_ohe.get_feature_names_out())
        ],
        axis=1
    )
    test_data['price_in_name'] = test_data['name'].str.contains('[rm]', regex=False).fillna(False).astype(np.int8)
    test_data['price_in_item_description'] = test_data['item_description'].str.contains('[rm]', regex=False).fillna(False).astype(np.int8)
    test_data['name_len'] = test_data['name'].str.len()
    test_data['item_description_len'] = test_data['item_description'].str.len()
    test_data = test_data.drop(columns=['name', 'brand_name', 'category_name', 'item_description', 'level1_category_name', 'level2_category_name', 'level3_category_name', 'brand_name'])
    test_data = test_data.fillna(0)
    cvpreds = cvbooster['cvbooster'].predict(test_data)
    preds.append(np.mean(cvpreds, axis=0))

最後に,すべての予測値を 1 つの DataFrame にまとめ,submission.csv に保存します.

submission = pd.DataFrame(
    data=np.array([
        np.concatenate(test_ids),
        np.concatenate(preds),
    ]).T,
    columns=['test_id', 'price']
).astype({'test_id': np.int32})
submission.to_csv('submission.csv', index=False)

3.4.3. Kaggleへのアップロード

このコンペティションは「カーネルコンペティション」であり,参加者は通常テストデータ全体を見ることはなく,データファイルの代わりにコードファイルを提出します.しかし,コンペティション終了後に全テストデータが公開されたため,半自動化されたアプローチで予測を提出することができます.

まず,Kaggle API を使用してユーザー用のプライベートデータセットを作成し,アップロードします.その後このセルを実行するとデータセットが更新されます.

Kaggle API はデータセットへのリンクを出力します.それをクリックしてデータセットにアクセスし,正常に作成されたことを確認してください.

%%bash
dataset_name="mercari-project-dataset-$KAGGLE_USERNAME"
dataset_dir="/content/kaggle/$dataset_name"
dataset_meta_path="$dataset_dir/dataset-metadata.json"
mkdir -p "$dataset_dir"
cp submission.csv "$dataset_dir"
kaggle datasets init -p "$dataset_dir"
sed -i "s/INSERT_TITLE_HERE/$dataset_name/g" "$dataset_meta_path"
sed -i "s/INSERT_SLUG_HERE/$dataset_name/g" "$dataset_meta_path"
dataset_exists=$(kaggle datasets list -m -s "$dataset_name" | grep "$dataset_name")
dataset_exists=$?
if [ $dataset_exists -eq "0" ]
then
    echo "データセットの更新"
    kaggle datasets version -p "$dataset_dir" -m "バージョンメッセージ"
else
    echo "データセットの作成"
    kaggle datasets create -p "$dataset_dir"
fi

以下の手順を手動で行う必要があります.

  1. 予測データを送信するカーネルを作成.

  2. データセットをリンク.

  3. カーネルを修正し,コンペティションに提出.

  4. アップデート.

以下の画像を参考にしてください.

3.4.3.1. ステップ1

予測データを投稿するためのカーネルを作成します.最初に,競技会提出ページにアクセスする.次に,「Late Submission」をクリックします.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-01.png?raw=1

「Create New Notebook」をクリックします.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-02.png?raw=1

新しいタブが開き,新しく作成されたノートブックが表示されます.ノートブックにはKaggleによって生成された名前とテンプレートコードが表示されます.ノートブックの名前をもっと分かりやすいものに変更することをお勧めしますが,そのままでも問題ありません.

3.4.3.2. ステップ 2

データセットをリンクします.「Add Input」をクリックします.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-03.png?raw=1

データセットのタイトルは mercari-project-dataset-KAGGLE_USERNAME のようになります.データセットの名前がわからない場合は,データセットがKaggleにアップロードされたこのセクションの最初に行き,データセットへのリンクをクリックしてください.

データセットが検索結果の一番上にあるかもしれませんが,そうでない場合は「Your Work」をクリックして探してください.

まず,データセットの横にある 「+」をクリックしてノートブックに追加します. それから,「X」をクリックして,「Add Data」タブを閉じます.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-04.png?raw=1

「Input」セクションにデータセットが表示されていることを確認します.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-05.png?raw=1

3.4.3.3. ステップ3

カーネルを修正して大会に提出します.以下のセルを実行してください.競技カーネルで使われる2行のPythonコードが出力されます.このコードは,このノートブックで以前に作成したカスタムデータセットから投稿ファイルをコピーしているだけです.このファイルはカーネルが実行されるディレクトリにコピーされ,そこで Kaggle は submission.csv という名前のファイルを検索します.

%%bash
dataset_name="mercari-project-dataset-$KAGGLE_USERNAME"
echo "import shutil"
echo "shutil.copy('/kaggle/input/$dataset_name/submission.csv', 'submission.csv')"

コードをコピーし,大会ノートブックに貼り付けます.次に「Submit」をクリックします.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-06.png?raw=1

これはKaggleが予測を採点する前の最後のステップです.複数の投稿を区別しやすくするために,バージョン名と説明を追加することをお勧めします.「Submit」をクリックします.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-07.png?raw=1

これで予測結果は提出され,Kaggleによって採点されます.スコアはコンペティションの提出ページで見ることができます.

注意: この方法でノートブックを提出すると,Kaggleはまずエラーが発生しないことを確認するためにノートブックを実行します.正常に実行されると,Kaggleはノートブックを再実行し,出力を採点します.このため,スコアが表示されるまでに1分ほど時間がかかる場合があります.

Hint

提出の部分でやらなければならないことがひと手間分増えています.NotebookのInputで自作のデータを追加する必要があります.

3.4.3.4. ステップ 4

モデルを変更し,予測値を再採点したい場合は,データセットとコンペノートブックを更新する必要があります.

まず,「Kaggleへのアップロード」セクションに行き,最初のセルを実行してデータセットを更新します.このステップは完全に自動化されており,他にやることはありません.

次に,カーネルが使用しているデータセットを更新し,古いデータを送信しないようにします.コンペティションの投稿ページの最新の投稿をクリックしてノートブックに戻ります.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-08.png?raw=1

「Edit」をクリックします.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-09.png?raw=1

ノートブックが表示されます.データセットの名前にカーソルを合わせると,縦に3つの点が現れます.点をクリックしてください.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-10.png?raw=1

「Check for updates」をクリックします.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-11.png?raw=1

「Update」をクリックします.

https://github.com/yamada-kd/dxi-kaggle/blob/main/image/kaggle-problem-2-12.png?raw=1

ノートブックで使用しているデータセットが最新版に更新されます.新しいバージョンを提出するには,「Submit」ボタンを押して,ステップ3と同じように指示に従ってください.

3.5. 性能向上のための提案

3.5.1. メモリ使用率

一般的に,学習済みモデルがテストデータセット上でどのように動作するかにのみ関心があります.しかし,学習プロセスを評価することも良いアイデアです.この場合,ワンホットエンコーディングはカテゴリー数が多いため,データサイズが大幅に増加します.エラーのないモデルを訓練するために,データのかなりの部分を破棄することを選択しましたが,これは一般的に良いアイデアではありません.代替案としては,より強力なシステムをトレーニングに使用することが考えられますが,これはデータセットによっては現実的ではありません.

そのため,通常はスコアを最小化(または最大化)したいと考えますが,より少ない特徴量や少ない計算リソースで同じスコアを達成することも価値のある努力です.

3.5.2. 自然言語処理技術

我々はアイテムの名前と説明フィールドから多くの情報を抽出しませんでしたが,これらのフィールドはおそらく最も重要な情報を含んでいます.実際,このコンペティションの入賞ソリューションは,scikit-learnで実装されているTF-IDFと呼ばれる手法を使用しています.このノートブックにTF-IDFを追加するのは比較的簡単で,スコアの大幅な向上が期待できるはずです.

コンペティションの時点では,ディープラーニングベースの言語モデルは現在ほど容易に利用できるものではありませんでした.これらのモデルは,おそらく最小限の前処理で高いスコアを達成できると思われます.