機械学習によるメルカリの適正価格推定

#はじめに

物の本当の価値を知るのは簡単ではないと思います。
詳細が少ないと、価格に大きな違いが生じることがあります。たとえば、これらのセーターの1つは335ドル、もう1つは9.99ドルです。どっちがどれなのか推測できますか？

![スクリーンショット 2019-12-25 1.52.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/32da0580-7f23-d373-89fa-461ea4a017a5.png)

オンラインで販売されている製品の数を考慮すると、製品の価格設定はさらに難しくなります。衣料品は季節ごとの価格動向が強く、ブランド名の影響を強く受けますが、電子機器は製品の仕様に基づいて価格が変動します。

日本最大のコミュニティ駆動のショッピングアプリは、この問題を深く知っています。売り手はメルカリのマーケットプレイスに何でも、またはあらゆるものを置くことができるので、売り手に適切な価格提案を提供するのは難しいです。

#Mercari Price Suggestion Challengeについて
![スクリーンショット 2019-12-25 1.49.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/a3f4ae0c-90c6-1d67-ba15-81cfeb5ea0f9.png)

Mercari Price Suggestion Challengeとは、実際に出品された商品データから商品の「適正価格」を推定するコンペです。商品データには商品名、商品説明、商品の状態、さらにブランド名やカテゴリ名などが含まれており、これらを基に機械学習を用いて販売価格の予測をします。

商品のデータセットは北米版メルカリより公開されているので誰でも手に入ります。
https://www.kaggle.com/c/mercari-price-suggestion-challenge/data

今回このデータを用いて適正価格の推定を行いたいと思います。
#データの種類

![スクリーンショット 2019-12-23 20.58.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/2f9003b4-4e11-21c9-fcd5-69e16bfa1dd7.png)

train.tsvには実際に出品された150万件の商品データがあります。北米版メルカリのデータのため表記は全て英語です。商品は8つのカラムから説明されております。

|カラム|説明|
|:----|:---|
|train_id|ユーザー投稿のID|
|name|商品名|
|item_condition_id|商品の状態|
|category_name|商品のカテゴリ|
|brand_name|ブランド名|
|price|販売価格（ドル）|
|shipping|送料負担（出品者or購入者）|
|item_description|商品の説明|

これらのデータをtrainとtestに分け、機械学習により販売価格の予想をします。

#システム構成

1. データの取得
    1. trian.tsv (データファイル)
2. データの前処理
    1. 欠損処理と型変換
    2. 商品名、カテゴリー名から出現回数によるベクトル化
    3. 商品説明の特徴量抽出
    4. ブランド名のラベリング
    5. 商品状態、送料負担の量的変数化
3. モデル構築
    1. ハイパーパラメータの最適化
    2. Ridge + LightGBM 
4. モデル評価
    1. データフレーム化
    2. 可視化
    3. 全体評価

実行環境はGoogle Colaboratory上で行います。データ数がものすごく多いのでGPU環境でないと時間がかかってしまいます。

Goggle Colboratoryについてはこちらを参考にしてください。
[Google Colaboratory概要と使用手順(TensorFlowもGPUも使える)](https://qiita.com/FukuharaYohei/items/18bc4465923bdda81826)

#精度評価方法
![スクリーンショット 2019-12-25 1.05.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/39b2d550-e6ad-10dd-ffe3-25194ebf247c.png)

RSMLEは**対数正規分布**に近い分布、実測値と予測値の**誤差を幅ではなく比率や割合**として表現したい場合に用いられる。

上の図を見てみると商品価格のヒストグラムは対数正規分布のような形をしてます。
また、例えば
(1000, 5000)と(100000, 104000)の誤差の幅はお互い4000ですが、誤差の比率は異なり、この違いは大きいです。

という点から推定価格はRMSLEによる評価方法が向いてそうです。
    
#データの前処理

train.tsvだけでなくtest.tsvも公開されているのですが、それには正解ラベルがないため、train.tsvから1万件ほど取り除いたデータをテストデータとします。

全体のデータ(*1482535, 8*)　->　(train_df(*1472535, 8*),　test_df(*10000, 7*))

##欠損処理と型変換
カテゴリ、ブランド、商品説明には空欄が多く存在します。機械学習では欠損処理をするのが普通なので以下の関数で空欄を埋めます。欠損処理を行った結果、ブランドの"missing"は全体の42%を占めていました。

```python
def handle_missing_inplace(dataset):
    dataset['category_name'].fillna(value="Other", inplace=True)
    dataset['brand_name'].fillna(value='missing', inplace=True)
    dataset['item_description'].fillna(value='None', inplace=True)
```

型変換行う前にブランドのカッティング処理を行います。ブランドの種類は約5000種類ほど存在するので出現回数が極端に少ないブランド名は学習する上であまり役に立たないので空欄と同じ"missing"を入れます。



```python
pop_brands = df["brand_name"].value_counts().index[:NUM_BRANDS]
df.loc[~df["brand_name"].isin(pop_brands), "brand_name"] = "missing"
```
半分ほど削ったところ4回を最低限出現回数となりました。

![スクリーンショット 2019-12-23 21.47.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/5f6ef346-ec95-1a7d-8506-29a506f6d56e.png)

テキストデータをカテゴリー型へ変換させます。これは後の処理でダミー変数化などを行うためです。

```python
def to_categorical(dataset):
    dataset['category_name'] = dataset['category_name'].astype('category')
    dataset['brand_name'] = dataset['brand_name'].astype('category')
    dataset['item_condition_id'] = dataset['item_condition_id'].astype('category')
```
##CountVectrizerによるテキスト特徴量抽出
商品名、カテゴリ名に対してCountVectorizerを適応します。CountVectorizerとは、簡単に言うと出現回数に応じてベクトル化されます。例えば 'MLB Cincinnati Reds T Shirt Size XL'、'AVA-VIV Blouse'、'Leather Horse Statues'という３つの商品名に対してCountVectorizerを行うと以下のようにベクトル化されます。

![スクリーンショット 2019-12-23 22.23.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/19414813-2d52-2a57-de09-0dbc461a21f1.png)

また、商品名は出品者入力のため単語の誤字脱字や、特定の文章しか出てこない固定の単語や数字が存在する可能性があります。これらを考慮し、CountVectorizerにオプションmin_dfを追加します。min_dfとは出現回数がmin_df%以下の単語は排除するといったものになります。

```python
count_name = CountVectorizer(min_df=NAME_MIN_DF)
X_name = count_name.fit_transform(df["name"])

count_category = CountVectorizer()
X_category = count_category.fit_transform(df["category_name"])
```

##TfidfVectorizerによるテキスト特徴量抽出
TfidfVectorizerとはCountVectorizerとは違い、単語の出現回数だけでなく単語のレア度も考慮します。例えば「です」「ます」といったどの文章にも存在する単語だったり、英語では「a」「the」のような冠詞は出現回数が大きく、CountVectorizerではこのような単語に大きく引きずられてしまいます。そうではなく単語の重要度に着目してベクトル化を行いたい場合に用いられます。

つまり、TfidfVecotrizerとは「ある文書内での出現頻度が高く、かつ、他の文書での出現頻度が低い語に高い重要度を与えるように重み付けを行う」ことです。

以上の点から、商品説明にはTfidfVectorizerによるベクトル化を行います。

![スクリーンショット 2019-12-18 15.28.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/9bc5cce9-03d8-08e0-3032-4bc29cba26ab.png)

すると上図のような表になり、冠詞や接続詞に強くtfidf値がついてしまっています。このような単語はやはり学習する上で意味を持たないので`stop_word='english'`を指定します。


次に左図はtfidf値の下位10個を表示しています。
tfidf値が極端に小さい値はあまり意味がないので削除します。また、単語を一つに対してtfidfをとるのではなく連続した単語に対してtfidfをとるようにします。
例えば「an apple a day keeps the doctor away」(一日一個のリンゴで医者いらず)ということわざでn-gram設定してみます。

n-gram(1, 2)

```text
{'an': 0, 'apple': 2, 'day': 5, 'keeps': 9, 'the': 11,'doctor':7,'away': 4,
 'an apple': 1, 'apple day': 3, 'day keeps': 6, 'keeps the': 10,
 'the doctor': 12, 'doctor away': 8}
```
n-gram(1, 3)

```text
{'an': 0, 'apple': 3, 'day': 7, 'keeps': 12, 'the': 15, 'doctor': 10,'away': 6,
 'an apple': 1, 'apple day': 4, 'day keeps': 8, 'keeps the': 13,'the doctor': 16,
 'doctor away': 11, 'an apple day': 2, 'apple day keeps': 5, 'day keeps the': 9,
 'keeps the doctor': 14, 'the doctor away': 17}
```
このようにn-gram範囲が増えるほどより細かく文章の特徴を捉え、有為なデータの取得をします。こうしてオプション追加により右図のようになりました。

![スクリーンショット 2019-12-24 15.14.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/a49ae072-181d-d868-5c16-5d3978a6e947.png)

最終的に出来上がったtfidfが下図のようになります。tfidf値が一番高いものが”description”となっており、これは”Not description yet”(概要記載なし)が影響していることがわかります。"new"や"used"といった新品、中古も値段に影響を与えていることがわかります。

![スクリーンショット 2019-12-18 16.44.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/4281ad9d-94b7-dc9c-5010-3f21def4abca.png)


```python
tfidf_descp = TfidfVectorizer(max_features = MAX_FEAT_DESCP,
                              ngram_range = (1,3),
                              stop_words = "english")
X_descp = tfidf_descp.fit_transform(df["item_description"])
```
##LabelBinarizerによる2値化
先ほども述べましたがブランドの種類は約5000種類存在し、カッティング処理の結果、ブランドは約2500種類となりました。
これらを 0 or 1 でラベリングします。データ数が多いので`sparse_output=True`にして実行します。


```python
label_brand = LabelBinarizer(sparse_output=True)
X_brand = label_brand.fit_transform(df["brand_name"])
```
##ダミー変数化
ダミー変数とは、数字ではないデータを数字に変換する手法のことです。具体的には、数字ではないデータを「0」と「1」だけの数列に変換します。
ここでは商品の状態、送料負担についてダミー変数化を行います。

```python
X_dummies = scipy.sparse.csr_matrix(pd.get_dummies(df[[
    "item_condition_id", "shipping"]], sparse = True).values, dtype=int)
```

以上で全てのカラムについて処理が行えましたので全ての配列を結合し、モデルにかけます。

```python
X = scipy.sparse.hstack((X_dummies,
                         X_descp,
                         X_brand,
                         X_category,
                         X_name)).tocsr()
```
#モデル学習
##パラメーター説明
全てのパラメータは説明仕切れないので一部のパラメータを簡単にまとめます。

###Ridgeパラメータ
|option|desc|
|:------|:-----|
|alpha|過学習を防ぐ正規化の度合い|
|max_iter|学習の反復の最大回数|
|tol|tol以上のスコア上昇を条件とする|

alpha

与えられたデータに過度に適合してしまい、与えた学習データに対しては小さな誤差となるモデルが構築できるが、未知データに対する適切な予測がうまくできないことを「**過学習**」と言います。
そこで、パラメータの学習に制限を設けることで過学習を防ぐことができます。そのような制限を「**正規化**」と言います。

###LightGBMパラメータ

|option|description|
|:------|:-----|
|n_esimators|決定木の構成数|
|learning_rate|各木の重み|
|max_depth|各木の最大の深さ|
|num_leaves|葉の数|
|min_child_samples|末端ノードに含まれる最小のデータ数|
|n_jobs|並列処理数|

learning_rate

- 一般的にあげると精度上昇するが過学習しやすくなる
- 小さすぎると計算負荷が大きく処理に時間がかかる。

n_estimatiors　

- ランダムフォレストでもっとも大切なパラメータ

##ハイパーパラメータの最適化
###Ridge

まずはalphaの最適値を探索します。
alphaを0.05~75の範囲で動かし、精度への影響を可視化

![スクリーンショット 2019-12-20 1.26.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/d2cb6ede-9864-5ba0-4f82-669153c62070.png)

図よりalpha=3.0のとき最小値 *RMSLE 0.4745938085035464* が得られました。


次に最大探索回数max_iterをあらゆる範囲で検証してみた結果、精度上昇が得られなかった。また、tol値も上げればあげるほど精度はよくなかったです。

![スクリーンショット 2019-12-20 3.06.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/ac848298-0cd4-fb15-ac84-808020744014.png)

以上からRidgeのパラメータはalpha=3でモデル作成します。


###LGBM
LGBMのパラメータ調整はドキュメントなどを参考にしました。
https://lightgbm.readthedocs.io/en/latest/Parameters-Tuning.html

LGBMのパラメータ調整の第一歩としてlearning_rateとn_estimatiorsの設定から始めるのが定石らしいです。
精度を高めるにはlearning_rateを小さく、n_estimatiorsを大きくするそうです。
learning_rateを0.05~0.7の範囲で動かしn_estimatiorsを調節します。

次に、learning_rateとn_estimatiorsを設定したらnum_leavesを動かしていきます。

(num_leaves = 20)
**RMSLE 0.4620242411418184**
　　　　　　　↓
(num = 31)
**RMSLE 0.4569169142862856**
　　　　　　　↓
(num = 40)
**RMSLE 0.45587232757584967**

全体的にnum_leavesを増やすと精度もよくなることがわかりました。ここでいう全体的にとは他のパラメータを調節した場合でもということです。

しかし、各パラメータを調節していく中で、num_leavesをあげすぎると**過剰適合**を起こし、返って良いスコアが出ないことがあった。他のパラメータとうまく調節する必要がありました。

learning_rate = 0.7 max_depth = 15, num_leaves = 30のとき
**RMSLE 44.650714399639845**

最終的に出来上がったLGBMモデルは以下のようになります

```python
lgbm_params = {'n_estimators': 1000, 'learning_rate': 0.4, 'max_depth': 15,
               'num_leaves': 40, 'subsample': 0.9, 'colsample_bytree': 0.8,
               'min_child_samples': 50, 'n_jobs': 4}
```
#モデル評価
予測値の算出にはRideg+LGBMで行います。RidgeよりもLGBMの方がスコアが良いのですが二つのモデルを組み合わせるのとで、より精度をあげることができます。
Ridge
**RMSL error on dev set: 0.47459370995217937**

LGBM
**RMSL error on dev set: 0.45317097672035855**

Ridge + LGBM
**RMSL error on dev set: 0.4433081424824549**

この精度は30ドルの商品に対して、推定値の誤差範囲は18.89 ~ 47.29です。

priceがRidge+LGBMによる予測値、real_priceが実測値になります。誤差が10ドル以内に収まったものは10000件のテストデータ中7553件ほどありました。

![スクリーンショット 2019-12-19 17.51.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/3a91aff5-aec0-430b-5914-966b3a3a2b06.png)

---

logをとった残差プロット
![スクリーンショット 2019-12-15 16.16.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/fe6c20b9-2433-ac4c-62ed-8ba0a595b0c9.png)

---

実際の価格と予測価格の分布
![スクリーンショット 2019-12-20 4.28.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/2577f071-da0d-dd65-0a68-6acbffdcf873.png)

---

単純に差分をとっただけですが、予測値と実測値に100ドル以上の差額がある商品は90件ほど存在します。このデータセットは2年前のデータなのでApple Watchなどは比較的新しい商品であるためデータ数が少なくうまく予測できていないことがわかります。
また、、個人の価値観による値段設定のため必ずしも全てはうまく予測できないです。実際にcoachのバッグが9ドルほどで売られていました…

![スクリーンショット 2019-12-20 5.04.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/485870/e071127e-ccfe-e3b4-be06-c1601e5113c9.png)

#完成したコード
```python
import numpy as np
import pandas as pd
import scipy

from sklearn.linear_model import Ridge
from lightgbm import LGBMRegressor
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.preprocessing import LabelBinarizer

NUM_BRANDS = 2500
NAME_MIN_DF = 10
MAX_FEAT_DESCP = 10000

print("Reading in Data")
df = pd.read_csv('train.tsv', sep='\t')

print('Formatting Data')
shape = df.shape[0]
train_df = df[:shape-10000]
test_df = df[shape-10000:]

target = test_df.loc[:, 'price'].values
target = np.log1p(target)

print("Concatenate data")
df = pd.concat([train_df, test_df], 0)

nrow_train = train_df.shape[0]
y_train = np.log1p(train_df["price"])

def handle_missing_inplace(dataset):
    dataset['category_name'].fillna(value="Othe", inplace=True)
    dataset['brand_name'].fillna(value='missing', inplace=True)
    dataset['item_description'].fillna(value='None', inplace=True)

print('Handle missing')
handle_missing_inplace(df)

def to_categorical(dataset):
    dataset['category_name'] = dataset['category_name'].astype('category')
    dataset['brand_name'] = dataset['brand_name'].astype('category')
    dataset['item_condition_id'] = dataset['item_condition_id'].astype('category')

print('Convert categorical')
to_categorical(df)

print('Cut')
pop_brands = df["brand_name"].value_counts().index[:NUM_BRANDS]
df.loc[~df["brand_name"].isin(pop_brands), "brand_name"] = "missing"

print("Name Encoders")
count_name = CountVectorizer(min_df=NAME_MIN_DF)
X_name = count_name.fit_transform(df["name"])

print("Category Encoders")
count_category = CountVectorizer()
X_category = count_category.fit_transform(df["category_name"])

print("Descp encoders")
tfidf_descp = TfidfVectorizer(max_features = MAX_FEAT_DESCP,
                              ngram_range = (1,3),
                              stop_words = "english")
X_descp = tfidf_descp.fit_transform(df["item_description"])

print("Brand encoders")
label_brand = LabelBinarizer(sparse_output=True)
X_brand = label_brand.fit_transform(df["brand_name"])

print("Dummy Encoders")
X_dummies = scipy.sparse.csr_matrix(pd.get_dummies(df[[
    "item_condition_id", "shipping"]], sparse = True).values, dtype=int)

X = scipy.sparse.hstack((X_dummies,
                         X_descp,
                         X_brand,
                         X_category,
                         X_name)).tocsr()

print("Finished to create sparse merge")

X_train = X[:nrow_train]
X_test = X[nrow_train:]

model = Ridge(solver='auto', fit_intercept=True, alpha=3)

print("Fitting Rige")
model.fit(X_train, y_train)

print("Predicting price Ridge")
preds1 = model.predict(X_test)

def rmsle(Y, Y_pred):
    assert Y.shape == Y_pred.shape
    return np.sqrt(np.mean(np.square(Y_pred - Y )))

print("Ridge RMSL error on dev set:", rmsle(target, preds1))

def rmsle_lgb(labels, preds):
    return 'rmsle', rmsle(preds, labels), False

train_X, valid_X, train_y, valid_y = train_test_split(X_train, y_train, test_size=0.3, random_state=42)

lgbm_params = {'n_estimators': 1000, 'learning_rate': 0.4, 'max_depth': 15,
               'num_leaves': 40, 'subsample': 0.9, 'colsample_bytree': 0.8,
               'min_child_samples': 50, 'n_jobs': 4}

model = LGBMRegressor(**lgbm_params)
print('Fitting LGBM')
model.fit(train_X, train_y,
          eval_set=[(valid_X, valid_y)],
          eval_metric=rmsle_lgb,
          early_stopping_rounds=100,
          verbose=True)

print("Predict price LGBM")
preds2 = model.predict(X_test)

print("LGBM RMSL error on dev set:", rmsle(target, preds2))

preds = (preds1 + preds2) / 2

print("Ridge + LGBM RMSL error on dev set:", rmsle(target, preds))

test_df["price1"] = np.expm1(preds1)
test_df['price2']=np.exp(preds2)
test_df['price']= np.expm1(preds)
test_df['real_price'] = np.expm1(target)
```
#まとめ

適正価格の推定を行った結果、予想以上に良いスコアが得られました。前処理の部分でmin_dfの値や、n-gramの範囲設定などを変えたり、文章をただtfidfにかけるだけでなくもっと細かな修正を加えたりすれば精度はもう少しよくなったのかなと思います。また、商品の価値観は人それぞれ違うのである程度しか予測はできないですよね。
少しでも参考になりましたらグッドボタンをお願いします！