見出し画像

機械学習初心者がKaggleに挑戦してみた

データサイエンスや機械学習を学び始めたばかりの人で、いつかKaggleのようなコンペに挑戦してみたいと考える人は少なくないと思います。一方、機械学習初心者にとって敷居が高く感じられ、何から始めればよいのか迷う場合もあるのではないでしょうか。本記事では、初心者が初めてKaggleに挑戦してみた感想を、学習の経緯や挑戦した課題・実装内容とともに、ご紹介します。Kaggleを利用するメリットについても触れていますので、データサイエンスや機械学習を学び始めたばかりの人やこれから学ぼうと考えている人はぜひ参考にしてみて下さい。

I. はじめに

これまでの仕事で、ExcelやAccess(ごくたまにSPSSやJMP等の統計ツール)を使ったデータ分析やモデリングを行うことがあり、いわゆるビッグデータを扱えるPythonやRを使ったデータ分析に興味を持っていました。

とはいえ、プログラミングについてはほぼ初心者で、社会人1年目に研修やOJTでJavaに触れたことがある程度。当時受験した「基本情報技術者試験」でも、プログラミング問題はほとんど解けませんでした。

そんな中、コロナ禍で在宅ワーク中心になったことや、昨今のリスキリングの流れに背中を押され、2023年3月より、オンラインでAI/DXの学習サービスを提供するAidemyにて、ずっと興味を持っていたPythonを使ったデータ分析が学べる「データ分析」講座の受講を開始しました。今回は、約3ヶ月(約100時間)かけて「データ分析」講座を受講した私が、過去のKaggleのコンペに挑戦してみました。

II. Kaggleとは

Kaggle(カグル)は世界中の機械学習・データサイエンスに携わる人々が集まる一大コミニティーで、2021年時点で世界194ヶ国から800万人以上がユーザーとして登録しています。世界中の企業や研究者がデータを投稿し、統計の専門家やデータサイエンティストがその最適モデルを競い合うプラットフォームとなっています。2017年にGoogleがKaggle社の買収を発表し、現在はGoogleの子会社となっています。

Kaggleの最大の特徴は、Competition(コンペ)の存在です。Competitionは、企業や政府がコンペ形式で課題を提示し、賞金と引き換えに最も精度の高い分析モデルを買い取るという一種のクラウドソーシングのような仕組みです。Competitionは、機械学習の専門家が課題解決をおこなうだけでなく、機械学習を学ぶ人が、習熟度に応じて、公開されているコードから学びを深めたり、Discussionで質問や議論をしたり、実用的なデータで実践を積むことができる素晴らしい環境です。一見すると初心者には敷居が高過ぎるように感じられますが、今回挑戦してみてその価値を実感しましたので、機械学習初心者の皆さんもぜひ覗いてみてください。

III. 今回挑戦したコンペについて

今回私が挑戦したのは、Kaggle上で過去に実際行われた "Mercari Price Suggestion Challenge" というコンペです。これは、日本でサービスをスタートし、現在は日本とアメリカでフリマアプリを運営するメルカリが、2017年に公開したコンペで、出品者が投稿した出品情報をもとに、最適な価格を提案するというものです。出品情報には、以下の情報が含まれています。

  • Train ID / Test ID: 出品ID

  • Name: 出品する商品の商品名

  • Item Condition ID: 出品者が設定した商品の状態

  • Category Name: 出品する商品のカテゴリー

  • Brand Name: 出品する商品のブランド名

  • Price: 実際に購入された価格(米ドル表示)。※このコンペで予測する情報のため、学習データにのみ含まれます。

  • Shipping: 配送料の負担に関するフラグ。「1」は出品者負担(送料込み)、「0」は購入者負担(着払い)。

  • Item Description: 出品する商品の商品説明。

ちなみに、私がこのコンペを選んだ理由は2つあります。1つは、与えられたデータと課題が初心者にも分かりやすかったこと、もう1つは、これまでの仕事でマーケティング・販売戦略や価格戦略に関わってきたため、このコンペで学んだことを今後のキャリアに活かせるのではないかと考えたことです。とくに、価格はマーケティング・販売活動ひいてはビジネス全般において重要かつ普遍的なテーマです。売り手として価格を決定する、買い手として適正価格を把握する、経営者として売れる価格を予測しビジネスケースを作成する、など様々な場面で価格予測のノウハウを活かせそうだと思い、このコンペに挑戦することに決めました。

IV. 実行環境

Kaggle上では最低限の実行環境が無料で提供されており、Kaggle Notebookを使用すれば、代表的なライブラリがインストールなしで使用可能です。そのため、Kaggleのコンペに参加する際は、Kaggle Notebookを使ってデータへのアクセス、コードの記述や実行、ファイルの提出までを行うことが一般的です。しかし、今回はこれからKaggle以外でもデータ分析を行う可能性を考慮して、Kaggle Notebookは使用せず、以下の環境で実装しました。

  • OS: Windows 10 Home (64-bit) / メモリ: 8GB / プロセッサ: Pentium Gold 4415Y @1.6 GHz

  • Google Drive

  • Google Colaboratory

  • python 3.10.11

また、Pythonで以下のライブラリを使って実装しました。

  • os

  • google-colab 1.0.

  • pandas 1.5.3

  • numpy 1.22.4

  • matplotlib 3.7.1

  • seaborn 0.12.2

  • scikit-learn 1.2.2

  • gensim 4.3.1

  • nltk 3.8.1

  • string

  • re

V. データ分析の流れ

  1. データの準備 - Kaggle上で提供されるファイル(圧縮ファイル)をダウンロードして、Google Driveに格納。圧縮ファイルを解凍し、そのうちtrain.tsv / test.tsv をPandasのDataFrameとして読み込みます。

  2. データの確認 - まず、各データフレーム (train / test) の列数・行数、データタイプや値を確認して、データの概要をつかみます。また、統計量、欠損値、ユニーク値の数、train / test データの重複について調べ、モデル作成の方針を固めていきます。

  3. ベースラインのモデル作成 - まずは、エンコーディング(文字列データを数値に変換)のみを行い、すべての特徴量を説明変数に含めて、ベースラインのモデルを作成します。なお、このコンペではモデル性能を測る評価指標としてRMSLE (Root Mean Squared Logarithmic Error) が指定されています。

  4. モデルの改善 (1) - 次に、文字列データの処理を工夫してみます。具体的には、商品カテゴリーを大・中・小カテゴリーに分け、商品名や商品情報といった文字列に自然言語処理を行いベクトル化して、説明変数に含めました。

  5. モデルの改善 (2) - ここまでのモデルは、すべての特徴量を説明変数に含めてモデルの学習を行いましたが、ここでは各特徴量の価格との相関係数や重要度をもとに、説明変数を取捨選択していきます。

  6. モデルの改善 (3) - 最後に、scikit-learnで用意されたいくつかの回帰モデルを試して、最も性能の高いモデルを採用します。また、予測された価格を確認します。 

  7. 提出用ファイルの作成 - 最終的に採用したモデルで、test.tsv から価格を予測します。その後、test_id と price を結合したデータセットをcsvに書き出し、提出用ファイルを作成します。

VI. 実装内容(Pythonコード)

最初に、必要となるライブラリやモジュールをインポートします。

import pandas as pd
import numpy as np

# ファイル解凍時に使用
!pip install pyunpack
!pip install patool

import os
from pyunpack import Archive

# データの可視化に使用
import matplotlib.pyplot as plt
import seaborn as sns
from google.colab import data_table
data_table.enable_dataframe_formatter()

# エンコーディングに使用
from sklearn.preprocessing import LabelEncoder

# 自然言語処理に使用
!pip list | grep gensim
!pip list | grep word2vec 

import string
import nltk
import re
from nltk.tokenize import word_tokenize, sent_tokenize
from nltk.corpus import stopwords
from gensim.models import Word2Vec
from gensim.models.doc2vec import Doc2Vec, TaggedDocument

nltk.download('punkt')
nltk.download('stopwords')

# モデルの学習・評価時に使用
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.metrics import mean_squared_log_error
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import Ridge
from sklearn.linear_model import Lasso
from sklearn.linear_model import ElasticNet
from sklearn.decomposition import PCA

1. データの準備
今回は、Google Drive / Google Colaboratoryを使ってデータ分析を行うので、Google ColaboratoryにGoogle Driveをマウントします。準備ができたら、Kaggleのコンペのページの「Data」タブから7zファイルをダウンロードし、Google Driveに格納します。格納場所と解凍先を指定したのち、osライブラリを用いて圧縮ファイルを解凍します。最後に、解凍したファイルのうち、train.tsv / test.tsv(TSVファイル)を読み込んで、PandasのDataFrameに格納したら、データの準備は完了です。

# Google Driveをマウントする
from google.colab import drive
drive.mount('/content/drive')

# 圧縮ファイルの解凍・格納先を指定する
PATH = '/content/drive/MyDrive/Colab Notebooks/Mercari Price Suggestion/Input/'

# 圧縮ファイルの格納先
ZIPPED_PATHs = [f'{PATH}train.tsv.7z', f'{PATH}test.tsv.7z']

# 解凍ファイルの格納先
UNZIPPED_PATH = PATH

# 圧縮ファイルを解凍する
if not os.path.exists(UNZIPPED_PATH):
    os.makedirs(UNZIPPED_PATH)
for ZIPPED_PATH, UNZIPPED_PATH in zip(ZIPPED_PATHs, \
 [UNZIPPED_PATH for _ in range(len(ZIPPED_PATHs))]):
  print(ZIPPED_PATH, UNZIPPED_PATH)
  Archive(ZIPPED_PATH).extractall(UNZIPPED_PATH)
  
for dirname, _, filenames in os.walk(ZIPPED_PATH):
    for filename in filenames:
        print(os.path.join(dirname, filename))

# 解凍したファイルを読み込み、DataFrameに格納する
train = pd.read_csv(f'{PATH}train.tsv', sep='\t', encoding='utf-8')
test = pd.read_csv(f'{PATH}test.tsv', sep='\t', encoding='utf-8')

2. データの確認
さっそく、TrainデータとTestデータがどのようなデータセットなのか見ていきます。まずは、データサイズ(列数・行数)とデータタイプを調べます。

訓練データであるTrainデータは、今回のコンペで予測する価格情報 (Price) と出品IDおよび6項目の出品情報で構成される1,482,535行8列のデータセット、テストデータであるTestデータは、出品IDおよび6項目の出品情報で構成される693,359行7列のデータセットとなっています。6項目の出品情報のうち、Item Condition ID、Shippingの2項目は数値型、それ以外の4項目は文字列型の値が入っています。

### Train / Testのデータを確認する
# データサイズを確認する
print("Trainデータサイズ")
print(train.shape)
print(" ")
print("Testデータサイズ")
print(test.shape)
print(" ")

# データタイプを確認する
print("Trainデータタイプ")
print(train.dtypes)
print(" ")
print("Testデータタイプ")
print(test.dtypes)
Trainデータサイズ
(1482535, 8)
 
Testデータサイズ
(693359, 7)
 
Trainデータタイプ
train_id               int64
name                  object
item_condition_id      int64
category_name         object
brand_name            object
price                float64
shipping               int64
item_description      object
dtype: object
 
Testデータタイプ
test_id               int64
name                 object
item_condition_id     int64
category_name        object
brand_name           object
shipping              int64
item_description     object
dtype: object

TrainデータとTestデータの先頭行を表示して、内容を確認してみます。

  • Name: 複数の文章や単語で記述されています。

  • Item Condition ID: 値は1, 2, 3のみ確認できます。

  • Category Name: "/"で区切られたカテゴリー情報(大カテゴリー、中カテゴリー、小カテゴリー)が入っています。

  • Brand Name: NaNが散見されます。

  • Shipping: 値は0と1のみ確認できます。

  • Item Description: 複数の文章や単語で記述されています。Trainデータの1行目が"No description yet"となっており、自然言語処理をする際に取り除く必要がありそうです。

# Trainデータの先頭行を表示し、各列の値を確認する
train.head()
# Testデータの先頭行を表示し、各列の値を確認する
test.head()
Trainデータの先頭行
Testデータの先頭行

TrainデータおよびTestデータの統計量も確認しておきます。文字列データと数値データを別々に表示します。文字列データについては、Countから欠損値の数が、Uniqueからユニーク値の数が、Top / Freqから最頻値とその出現回数が確認できます。数値データについては、Countから欠損値の数が、Mean / Stdから平均と標準偏差が、Min / Maxから最小値・最大値が、50%から中央値が確認できます。 

文字列データをみると、Category Name、Brand NameはTrain / Testデータともに欠損値があり、Item DescriptionはTrainデータにのみ欠損値があることがわかります。また、Category NameやBrand Nameはユニーク値が少なく、NameやItem Descriptionはユニーク値が多いことが見て取れます。

一方の数値データをみると、すべての列に欠損値がないことがわかりました。Item Condition IDは1~5、Shippingは0または1、Priceは0~2009の値をとります。ただし、Item Condition IDは1~3の値が、Priceは0~29ドルがデータの75%を占めています。

# 統計量を確認する
print("Trainデータ統計量")
display(train.describe(exclude='number'))
display(train.describe())
print("Testデータ統計量")
display(test.describe(exclude='number'))
display(test.describe())
Trainデータの統計量
Testデータの統計量

ここで改めて、前処理で重要となる欠損値の有無を、Trainデータ、Testデータそれぞれで確認します。どちらのデータセットもCategory NameとBrand Nameで欠損値が存在します。また、TrainデータのみItem Descriptionに4件の欠損があります。

#欠損値を確認する,
print("Trainデータ欠損")
print(train.isnull().sum())
print(" ")
print("Testデータ欠損")
print(test.isnull().sum())
Trainデータ欠損
train_id                  0
name                      0
item_condition_id         0
category_name          6327
brand_name           632682
price                     0
shipping                  0
item_description          4
dtype: int64
 
Testデータ欠損
test_id                   0
name                      0
item_condition_id         0
category_name          3058
brand_name           295525
shipping                  0
item_description          0
dtype: int64

次に、文字列データ4項目がTrainデータとTestデータで重複しているかを調べます。NameとItem DescriptionはTrain / Testデータ間でほとんど重複がなく、Brand Nameも重複しない値が多くみられます。Category Nameについては、ほとんどの値がTrain / Testデータの両データセットに存在します。

# Train / Testデータの重複を確認する
import matplotlib.pyplot as plt
from matplotlib_venn import venn2

fig, axes = plt.subplots(figsize=(6,6),ncols=2,nrows=1)

for col_name, ax in zip(
    ['category_name', 'brand_name'],
    axes.ravel()
    ):
    venn2(
        subsets=(set(train[col_name].unique()), set(test[col_name].unique())),
        set_labels=('Train', 'Test'),
        ax=ax
    )
    ax.set_title(col_name)
Train / Testデータの重複

データを確認して分かったこととモデル作成の方針

  • Train ID / Test ID: 数値型(整数)。欠損なし。出品IDであり、それぞれのデータセットにおいてすべてユニーク値(キー)となっている。

  • Name: 文字列型。欠損なし。複数の文章や単語で記述されており、約80%がユニーク値。そのため、"Bundle"など一部の値以外は、Train / Testデータで重複しない。→自然言語処理をおこなう。

  • Item Condition ID: 数値型(整数)。欠損なし。値は1~5の数値で表される。→エンコードしてカテゴリカルデータとして使用する。

  • Category Name: 文字列型。Train / Testデータともに一部欠損あり。"/"で区切られたカテゴリー情報(大カテゴリー、中カテゴリー、小カテゴリー)で構成され、1223個のユニーク値が存在する。Train / Testデータ間で重複しないカテゴリーも存在する。→大カテゴリー、中カテゴリー、小カテゴリーに分けた後、エンコードしてカテゴリカルデータとして使用する。

  • Brand Name: 文字列型。Train / Testデータともに半数近くが欠損している。4809個のユニーク値が存在しするが、Train / Testデータ間で重複していないブランドも多数。→ブランド情報の有無をバイナリデータに変換して使用する。

  • Shipping: 数値型(整数)。欠損なし。値は0または1で表される。→エンコードしてカテゴリカルデータとして使用する。

  • Item Description: 文字列型。Trainデータのみ4件の欠損あり。複数の文章や単語で記述されており、ほとんどがユニーク値。そのため、"No description yet"など一部の値以外は、Train / Testデータで重複しない。→自然言語処理をおこなう。

  • Price: 数値型(小数)。欠損なし。今回のコンペで予測する目的変数のため、Trainデータにのみ含まれる。


3. ベースラインのモデル作成
データの確認が終わったら、ベースラインとなるモデルを作成します。ベースラインモデルでは、とりあえず文字列データ4項目をエンコードして数値に変換します。それら4つの特徴量と2つの数値型の特徴量を説明変数とし、Priceを対数化したものを目的変数として、モデルを学習させます。なお、モデルには各特徴量の重要度の算出ができるRandom Forest Regressorを使用しました。

  • Name、Category Name、Brand Name、Item Descriptionの4項目はエンコードして、数値型のname_id, category_name_id, brand_name_id, item_description_idに変換する。

  • Item Condition ID、Shippingは、数値型のため、そのまま説明変数として使用する。

データの前処理をはじめる前に、Train / Testデータを一時的に結合させ、加工用のDataFrame (Comb) を作成します。その際、Train / Testデータの見分けがつくように、フラグとしてis_train列を追加します。また、Priceは1を足してeを底とする対数を取ります。
もとのデータ量が多いので、今回は処理時間を短縮するために、Trainデータから150,000行、Testデータから70,000行(約10%)のみを取り出して、Combデータを作成しました。実際にKaggle上でsubmitする場合は、すべてのTestデータについて価格を予測しなければいけないので、その点注意が必要です。

# trainとtestのidカラム名を変更する
train = train.rename(columns = {'train_id':'id'})
test = test.rename(columns = {'test_id':'id'})

# is_train(1: trainのデータ、0: testのデータ)を列に追加する
train['is_train'] = 1
test['is_train'] = 0

# 処理時間短縮のため、一部のデータのみ使用する
train_small = train[0:150000]
test_small = test[0:70000]

# TrainデータとTestデータを連結する
comb = pd.concat([train_small, test_small],axis=0)
comb = comb.reindex()

# Priceは、1を足してeを底とする対数を取る
comb['price'] = np.log1p(comb['price'])

Combデータの準備ができたら、前処理をはじめます。前処理として、文字列型データをエンコードし、数値型に変換した値を、新しい列 (name_id, category_id, brand_name_id, item_description_id) に代入します。

#ラベル列をエンコードする
le = LabelEncoder()
comb['name_id'] = le.fit_transform(comb['name'])
comb['category_name_id'] = le.fit_transform(comb['category_name'])
comb['brand_name_id'] = le.fit_transform(comb['brand_name'])
comb['item_description_id'] = le.fit_transform(comb['item_description'])

説明変数として使用しない列を削除したデータセットを作成し、説明変数のセット (X_train) を準備します。ここで、先程エンコードした文字列型の4項目の新しい列 (name_id, category_id, brand_name_id, item_description_id) のみ残し、もとの列 (name, category, brand_name, item_description) を削除します。また、IDやPriceも説明変数としては使用しないため、同様に削除します。
もともとTrainデータに含まれていたPriceは、目的変数として使用するためにy_trainに代入します。

# 学習に使用しない列を削除する
drop_col = ['price', 'id', \
            'name', \
            'category_name', \
            'brand_name', \
            'item_description']
comb0 = comb.drop(drop_col, axis=1)

print("after dropping cols")
display(comb0.head())

# combをTrain / Testデータに分離して、X_train / X_testを準備する
# is_trainフラグでcombをTestとTrainに切り分ける
X_train = comb0.loc[comb0['is_train'] == 1]
X_test = comb0.loc[comb0['is_train'] == 0]
 
# is_trainをTrain / Testデータから削除する
X_train = X_train.drop(['is_train'], axis=1)
X_test = X_test.drop(['is_train'], axis=1)

# y_trainを準備する
y_train = comb['price'].loc[comb['is_train'] == 1]

このコンペでは、TrainデータのみにPriceが含まれるため、TestデータでPriceを予測してSubmitする前に学習したモデルの性能を評価するために、X_train / y_trainを訓練データと評価データ(評価データの割合0.2)に分割します。

# データ全体(Xとy)を訓練データと評価データに分割する
from sklearn.model_selection import train_test_split, cross_val_score
X_train, X_val, y_train, y_val = \
train_test_split(X_train, y_train, test_size=0.2, shuffle=True, random_state=0)

いよいよ、訓練データを使ってモデルを学習させます。その後、評価データを使ってモデル性能を測定します。このコンペでは、モデルの評価指標としてRSMLE (Root Squared Mean Logarithmic Error) を用います。ベースラインモデル (Random Forest Regressor) の結果は RSMLE 1.6556 となりました。

# ベースライン - RandomForestReggressor
# モデルを学習させる
model0 = RandomForestRegressor(n_jobs=-1, min_samples_leaf=10, n_estimators=200)
model0.fit(X_train, y_train)
 
# モデルから評価データのyを予測する
y_pred1p = model0.predict(X_val)
y_pred = abs(np.expm1(y_pred1p))

# 評価データにおけるRMSLEを算出する
RMSLE0 = np.sqrt(mean_squared_log_error(y_val, y_pred))
print("ベースライン RMSLE:", RMSLE0)
ベースライン RMSLE: 1.6556199802941345

4. モデルの改善 (1)
次は前処理で文字列データの処理を工夫し、ベースラインより性能の高いモデルの作成を試みます。

  • Category Nameは、'/'で区切り、category1, category2, category3を列に追加する。category1, category2, category3は、エンコードして数値型に変換する。

  • Brand Nameは、値の有無をバイナリーで表したbrand_name_binを列に追加する。

  • Item Descriptionは、'No description yet'をスペースで置き換える。文字列を正規表現に置換し、文章をDoc2Vecでベクトル化して、desc_vecを列に追加する。

  • Nameは、文字列を正規表現に置換し、文章をDoc2Vecでベクトル化して、name_vecを列に追加する。

  • Item Condition ID、Shippingは、数値型のため、そのまま説明変数として使用する。

Category Nameを'/'で区切り、大カテゴリー、中カテゴリー、小カテゴリーに分け、新しい列 (category1, category2, category3) に代入します。それらの列をエンコードして数値化します。

# Category Nameを'/'で区切り、category1, 2, 3を作成する
comb['category_name'].fillna('//', inplace=True)
comb['category1'] = comb.category_name.apply(lambda x : x.split('/')[0].strip())
comb['category2'] = comb.category_name.apply(lambda x : x.split('/')[1].strip())
comb['category3'] = comb.category_name.apply(lambda x : x.split('/')[2].strip())   

#ラベル列をエンコードする
for col in ['category1', 'category2', 'category3']:
    le = LabelEncoder()
    comb[col] = le.fit_transform(comb[col])

新しい列brand_name_binに、Brand Nameが'NaN'またはnullの行に0、それ以外の行に1を、代入します。

# Brand Nameが欠損する行は0、それ以外は1となる新たな列 (Brand Name Bin)を追加する
print(comb.brand_name.isnull().sum())
comb.brand_name = comb.brand_name.fillna("missing")
comb.loc[comb.brand_name == "NaN", 'brand_name'] = "missing"
comb.loc[comb.brand_name == "missing", 'brand_name_bin'] = 0
comb.loc[comb.brand_name != "missing", 'brand_name_bin'] = 1

Item Descriptionは、まず'No description yet'をスペースで置き換えます。

# Item Descriptionの'No description yet'をスペースに置き換える
comb.item_description = comb.item_description.fillna("")

欠損値を処理したら、NameとItem Descriptionの自然言語処理をはじめます。まずは、文字列を正規表現に置換します。

# Item DescriptionとNameの値を正規表現にする
def clean_str(text): 
    try:
        text = ' '.join([w for w in text.split()] ) 
        # アルファベットを小文字に揃える       
        text = text.lower()
        # アルファベットを正規表現に置き換える
        text = re.sub(u"é", u"e", text)
        text = re.sub(u"ē", u"e", text)
        text = re.sub(u"è", u"e", text)
        text = re.sub(u"ê", u"e", text)
        text = re.sub(u"à", u"a", text)
        text = re.sub(u"â", u"a", text)
        text = re.sub(u"ô", u"o", text)
        text = re.sub(u"ō", u"o", text)
        text = re.sub(u"ü", u"u", text)
        text = re.sub(u"ï", u"i", text)
        text = re.sub(u"ç", u"c", text)
        text = re.sub(u"\u2019", u"'", text)
        text = re.sub(u"\xed", u"i", text)
        text = re.sub(u"w\/", u" with ", text)
        text = re.sub(u"[^a-z0-9]", " ", text)
        text = u" ".join(re.split('(\d+)',text) )
        text = re.sub( u"\s+", u" ", text ).strip()
        text = ''.join(text)
        # Punctuationを取り除く
        reg_exp = re.compile('['+re.escape(string.punctuation)+'0-9\\r\\t\\n]')
        text = reg_exp.sub(" ", text)
    except:
        text = ""
    return text

# Item DescriptionとNameの各列に対して、clean_strを実行する
comb["item_description"] = comb["item_description"].apply(lambda s: clean_str(s))
comb["name"] = comb["name"].apply(lambda s: clean_str(s))

次に、Item Descriptionの文字列を単語に分割し、Doc2Vecで100次元のベクトルに変換します。そのベクトルを主成分分析 (PCA) で2次元に圧縮してから、新しい列 (desc_vec1, desc_vec2) に代入します。

stop = set(stopwords.words('english'))

# Item Descriptionの値をcol_values1に入れる
col_values1 = comb["item_description"]
sentences1 = []

# col_values1の値を単語に分けてsentences1に入れる
for s in col_values1:
    if s == "":
      sentences1.append("")
    else:
      s = s.split(" ")
      # Stopword('a', 'the', 'in'等のよく使われる単語)以外をリストに入れる
      filtered1 = list(filter(lambda t: t not in stop, s))
      # アルファベットのみをリストに入れる
      filtered2 = [w for w in filtered1 if re.search('[a-zA-Z]', w)]
      # アルファベット3文字以上の単語のみをリストに入れる
      filtered3 = [w.lower() for w in filtered2 if len(w)>=3]
      sentences1.append(filtered3)

# Item DescriptionをDoc2Vecでベクトル化する
documents = [TaggedDocument(doc, [i]) for i, doc in enumerate(sentences1)]
model = Doc2Vec(documents, vector_size=100, window=5, min_count=1, workers=4)
desc_vec = model.dv.vectors
print(desc_vec.shape)

# ベクトルをPCAで2次元に次元削減する
pca = PCA(n_components=2)
desc_vec_pca = pca.fit_transform(desc_vec)
print(desc_vec_pca.shape)

# Item Descriptionの2次元ベクトルを列に追加する
comb['desc_vec1'] = desc_vec_pca[:, 0]
comb['desc_vec2'] = desc_vec_pca[:, 1]

同様に、Nameの文字列を単語に分割し、Doc2Vecで100次元のベクトルに変換します。そのベクトルを主成分分析 (PCA) で2次元に圧縮してから、新しい列 (name_vec1, name_vec2) に代入します。

# Nameの値をcol_values1に入れる
col_values2 = comb["name"]
sentences2 = []

# 文章を文に、文を単語に分割する
for s in col_values2:
    if s == "":
      sentences2.append("")
    else:
      s = s.split(" ")
      # Stopword('a', 'the', 'in'等のよく使われる単語)以外をリストに入れる
      filtered1 = list(filter(lambda t: t not in stop, s))
      # アルファベットのみをリストに入れる
      filtered2 = [w for w in filtered1 if re.search('[a-zA-Z]', w)]
      # アルファベット3文字以上の単語のみをリストに入れる
      filtered3 = [w.lower() for w in filtered2 if len(w)>=3]
      sentences2.append(filtered3)

# NameをDoc2Vecでベクトル化する
documents = [TaggedDocument(doc, [i]) for i, doc in enumerate(sentences2)]
model = Doc2Vec(documents, vector_size=100, window=5, min_count=1, workers=4)
name_vec = model.dv.vectors
print(name_vec.shape)

# ベクトルをPCAで2次元に次元削減する
pca = PCA(n_components=2)
name_vec_pca = pca.fit_transform(name_vec)
print(name_vec_pca.shape)

# Nameの2次元ベクトルを列に追加する
comb['name_vec1'] = name_vec_pca[:, 0]
comb['name_vec2'] = name_vec_pca[:, 1]

説明変数として使用しない列を削除したデータセットを作成し、説明変数のセット (X_train) を準備します。もともとTrainデータに含まれていたPriceは、目的変数として使用するためにy_trainに代入します。

# 学習に使用しない列を削除する
drop_col = ['price', 'id', \
            'name', 'name_id', \
            'category_name', 'category_name_id', \
            'brand_name', 'brand_name_id', \
            'item_description', 'item_description_id']
comb1 = comb.drop(drop_col, axis=1)

print("after dropping cols")
display(comb1.head())

# combをTrain / Testデータに分離して、説明変数を準備する
# is_trainフラグでcombをTestとTrainに切り分ける
X_train = comb1.loc[comb1['is_train'] == 1]
X_test = comb1.loc[comb1['is_train'] == 0]
 
# is_trainをTrain / Testデータから削除する
X_train = X_train.drop(['is_train'], axis=1)
X_test = X_test.drop(['is_train'], axis=1)

# y_trainを準備する
y_train = comb['price'].loc[comb['is_train'] == 1]

ベースラインと同様に、X_train / y_trainを訓練データと評価データ(評価データの割合0.2)に分割して、モデル学習をおこないます。モデルの改善 (1)の結果は、RMSLE: 1.6541となりました。自然言語処理に骨を折ったわりに大きな改善は見られませんでした。

# データ全体(Xとy)を学習データと評価データに分割する
from sklearn.model_selection import train_test_split, cross_val_score
X_train, X_val, y_train, y_val = \
train_test_split(X_train, y_train, test_size=0.2, shuffle=True, random_state=0)

# モデルの改善 (1) - RandomForestReggressor
# モデルを学習させる
model1 = RandomForestRegressor(n_jobs=-1, min_samples_leaf=10, n_estimators=200)
model1.fit(X_train, y_train)
 
# モデルから評価データのyを予測する
y_pred1p = model1.predict(X_val)
y_pred = abs(np.expm1(y_pred1p))

# 評価データにおけるRMSLEを算出する
RMSLE1 = np.sqrt(mean_squared_log_error(y_val, y_pred))
print("モデルの改善1 RMSLE:", RMSLE1)
モデルの改善1 RMSLE: 1.6540910679694858

5. モデルの改善 (2) 
ここまでのモデルは、すべての特徴量を説明変数に含めてモデル学習を行いましたが、ここからは次の指標をもとに説明変数を取捨選択していきます。

  • Random Forest Regressorで算出される特徴量の重要度

  • 特徴量と価格の相関係数

a. 特徴量の重要度
まずは、Random Forestの機能であるimportanceを使って、各特徴量の重要度を確認します。

# RandomForestReggressorで特徴量の重要度を算出する
# 特徴量の重要度を取得する
importances = model1.feature_importances_
print(importances)

# 特徴量の名前を取得する
labels = X_train.columns[0:]

#特徴量を重要度順(降順)で表示する
indices = np.argsort(importances)[::-1]

for i in range(len(importances)):
    print(str(i+1)+"   "+\
          str(labels[indices[i]])+"   "+\
          str(importances[indices[i]]))

plt.title('Feature Importance')
plt.bar(range(len(importances)),importances[indices],color='lightblue',align='center')
plt.xticks(range(len(importances)), labels[indices], rotation=90)
plt.xlim([-1, len(importances)])
plt.tight_layout()
plt.show()
[0.05526947 0.09716395 0.06260303 0.17138515 0.17708624 0.06184799
 0.09350078 0.09931672 0.09150901 0.09031766]
1   category3   0.1770862382626644
2   category2   0.1713851469124496
3   desc_vec2   0.09931672259955543
4   shipping   0.09716394843936559
5   desc_vec1   0.09350077921027285
6   name_vec1   0.09150900855395948
7   name_vec2   0.0903176647893451
8   category1   0.06260302524262969
9   brand_name_bin   0.06184799407554384
10   item_condition_id   0.055269471914214066
特徴量の重要度

Importanceからcategory3とcategory2の重要度が高く、category1とbrand_name_bin、item_condition_idの重要度が低いという結果になりました。ただし、Random Forestで特徴量の重要度が大きいということは、「その変数を使って決定木のノードを分けた場合に不純度(うまく分けられたかを表す指標)が大きく減少する」というだけであって、重回帰モデルの偏回帰係数のように「その変数が目的変数に与える影響度」を意味するわけではありません。あくまでも一つの指標として捉え、まずは重要度の高かったcategory2、category3、desc_vec2、shippingの4つの特徴量を説明変数にしてモデルを学習させてみます。

# 学習に使用しない列を削除する
drop_col = ['price', 'id', \
            'name', 'name_id', 'name_vec1', 'name_vec2', \
            'item_condition_id', \
            'category_name', 'category_name_id', 'category1', \
            'brand_name', 'brand_name_bin', 'brand_name_id', \
            'item_description', 'item_description_id', 'desc_vec1']
comb2a = comb.drop(drop_col, axis=1)

print("after dropping cols")
display(comb2a.head())

# combをTrain / Testデータに分離して、説明変数を準備する
# is_trainフラグでcombをTestとTrainに切り分ける
X_train = comb2a.loc[comb2a['is_train'] == 1]
X_test = comb2a.loc[comb2a['is_train'] == 0]
 
# is_trainをTrain / Testデータから削除する
X_train = X_train.drop(['is_train'], axis=1)
X_test = X_test.drop(['is_train'], axis=1)

# y_trainを準備する
y_train = comb['price'].loc[comb['is_train'] == 1]

これまでと同様、X_train / y_trainを訓練データと評価データ(評価データの割合0.2)に分割して、モデル学習をおこないます。Random Forest Regressorの特徴量重要度をもとにしたモデルの改善 (2)の結果はRMSLE 1.6539となりました。思ったより大きな改善はみられませんでした。

# データ全体(Xとy)を学習データと評価データに分割する
from sklearn.model_selection import train_test_split, cross_val_score
X_train, X_val, y_train, y_val = \
train_test_split(X_train, y_train, test_size=0.2, shuffle=True, random_state=0)

# モデル性能の改善2a - RandomForestReggressor
# モデルを学習させる
model2a = RandomForestRegressor(n_jobs=-1, min_samples_leaf=10, n_estimators=200)
model2a.fit(X_train, y_train)
 
# モデルから評価データのyを予測する
y_pred1p = model2a.predict(X_val)
y_pred = abs(np.expm1(y_pred1p))

# 評価データにおけるRMSLEを算出する
RMSLE2a = np.sqrt(mean_squared_log_error(y_val, y_pred))
print("モデルの改善2a RMSLE:", RMSLE2a)
モデルの改善2a RMSLE: 1.65389452621066

b. 特徴量と価格の相関係数
次に、各特徴量間の相関係数をヒートマップとして表示し、各特徴量と価格の相関を調べてみます。

# 各特徴量間の相関係数(ヒートマップ)を調べる
plt.subplots(figsize=(20,15))
sns.heatmap(comb[['item_condition_id', \
                  'shipping', \
                  'category1', 'category2', 'category3', \
                  'brand_name_bin', \
                  'desc_vec1', 'desc_vec2', \
                  'name_vec1', 'name_vec2', \
                  'price']].corr(), vmax=1, vmin=1, annot=True)
ヒートマップ: 特徴量間の相関係数

ヒートマップをみると、全体的に各特徴量とPriceとの相関は弱く、相関の高い順に、shipping、brand_name_bin、category1、desc_vec2となっています。うち、shippingは負の相関関係(出品者負担を表す1のほうが価格が低い)、brand_name_binは正の相関関係(ブランド情報ありを表す1のほうが価格が高い)があります。今回は、相関係数が比較的高かったshipping、brand_name_bin、category1、desc_vec2の4つの特徴量を説明変数にしてモデルを学習させます。

# 学習に使用しない列を削除する
drop_col = ['price', 'id', \
            'name', 'name_id', 'name_vec1', 'name_vec2', \
            'item_condition_id', \
            'category_name', 'category_name_id', 'category2', 'category3', \
            'brand_name', 'brand_name_id', \
            'item_description', 'item_description_id', 'desc_vec1']
comb2b = comb.drop(drop_col, axis=1)

print("after dropping cols")
display(comb2b.head())

# combをTrain / Testデータに分離して、説明変数を準備する
# is_trainフラグでcombをTestとTrainに切り分ける
X_train = comb2b.loc[comb2b['is_train'] == 1]
X_test = comb2b.loc[comb2b['is_train'] == 0]
 
# is_trainをTrain / Testデータから削除する
X_train = X_train.drop(['is_train'], axis=1)
X_test = X_test.drop(['is_train'], axis=1)
 
# y_trainを準備する
y_train = comb['price'].loc[comb['is_train'] == 1]

X_train / y_trainを訓練データと評価データ(評価データの割合0.2)に分割して、モデル学習をおこないます。Priceとの相関係数をもとにしたモデルの改善 (2)の結果はRMSLE 1.6457となりました。先程より大きな改善が見られました!

# データ全体(Xとy)を学習データと評価データに分割する
from sklearn.model_selection import train_test_split, cross_val_score
X_train, X_val, y_train, y_val = \
train_test_split(X_train, y_train, test_size=0.2, shuffle=True, random_state=0)

# モデル性能の改善2b - RandomForestReggressor
# モデルを学習させる
model2b = RandomForestRegressor(n_jobs=-1, min_samples_leaf=10, n_estimators=200)
model2b.fit(X_train, y_train)
 
# 評価データを用いてモデルの性能を評価する
y_pred1p = model2b.predict(X_val)
y_pred = abs(np.expm1(y_pred1p))

# 評価データにおけるRMSLEを算出する
RMSLE2b = np.sqrt(mean_squared_log_error(y_val, y_pred))
print("モデルの改善2b RMSLE:", RMSLE2b)
モデルの改善2b RMSLE: 1.6456967809750203

6. モデルの改善 (3) 
最後に、先程選定した4つの特徴量 (shipping、brand_name_bin、category1、desc_vec2) を説明変数として、scikit-learnで用意されたいくつかの回帰モデルを試してみます。また、それぞれのモデルで予測された価格を確認します。

  • Ridge: リッジ回帰は、L2正則化(係数が大きくなりすぎないように制限する手法)を行いながら線形回帰の適切なパラメータを設定する回帰モデルです。

  • Lasso: ラッソ回帰は、L1正則化(予測に影響を及ぼしにくいデータにかかる係数をゼロに近づける手法)を行いながら線形回帰の適切なパラメータを設定する回帰モデルです。

  • Elastic Net: Elastic Net回帰は、ラッソ回帰(L2正則化)とリッジ回帰(L1正則化)を組み合わせて正則化項を作るモデルです。

a. リッジ回帰
Ridgeを適用してモデル性能を確認します。Ridgeモデルの結果はRMSLE 1.6290で、Random Forest Regressorより性能が上がっています。評価データで予測した価格(先頭の5行)も妥当と言えそうです。

# モデルの改善3a - Ridge
# モデルの学習
model3a = Ridge()
model3a.fit(X_train, y_train)

# 評価データを用いてモデルの性能を評価する
y_pred1p = model3a.predict(X_val)
y_pred = abs(np.expm1(y_pred1p))

# モデルから評価データのyを予測する
print("Train Score:", model3a.score(X_train, y_train))
print("Val Score:", model3a.score(X_val, y_val))

# 評価データにおけるRMSLEを算出する
RMSLE3a = np.sqrt(mean_squared_log_error(y_val, y_pred))
print("モデルの改善3a RMSLE:", RMSLE3a)

# モデルで予測した価格を確認する
series3a = pd.Series(y_pred)
print("Predicted Prices:")
print(series3a.head())
モデルの改善3a RMSLE: 1.6290433425935034
Predicted Prices:
0    19.015995
1    16.441856
2    23.879886
3    16.901419
4    12.407445
dtype: float64

b. ラッソ回帰
次は、Lassoを適用してモデル性能を確認します。Lassoモデルの結果はRMSLE 1.6231で、先程のRidgeよりさらに性能が上がっています。しかし、評価データで予測した価格が先頭の5行すべての値が18.616867となっており、モデルの妥当性が疑われます。

# モデルの改善3b - Lasso
# モデルの学習
model3b = Lasso()
model3b.fit(X_train, y_train)

# 評価データを用いてモデルの性能を評価する
y_pred1p = model3b.predict(X_val)
y_pred = abs(np.expm1(y_pred1p))

# 評価データにおけるRMSLEを算出する
RMSLE3b = np.sqrt(mean_squared_log_error(y_val, y_pred))
print("モデルの改善3b RMSLE:", RMSLE3b)

# モデルで予測した価格を確認する
series3b = pd.Series(y_pred)
print("Predicted Prices:")
print(series3b.head())
モデルの改善3b RMSLE: 1.6231040878741065
Predicted Prices:
0    18.616867
1    18.616867
2    18.616867
3    18.616867
4    18.616867
dtype: float64

これはLassoのL1正則化によって、予測にほとんど影響を与えない特徴量がモデルから取り除かれた結果、すべての予測値が同じ結果になったと考えられます。Lassoのハイパーパラメータalphaは、この正則化の強さ(正則化項のペナルティーの強さ)を制御でき、指定しない場合はalpha=1となっています。今回はL1正則化が強すぎると考えられるため、alpha=0.001を指定してもう一度モデルの学習を試してみます。その結果、RMSLE 1.6288となり、デフォルトのalpha=1で学習した場合と比べるとRMSLEは上がります(性能は下がります)が、予測価格は妥当と言えます。

# モデルの改善3b - Lasso
# モデルの学習
model3b = Lasso(alpha=0.001)
model3b.fit(X_train, y_train)

# 評価データを用いてモデルの性能を評価する
y_pred1p = model3b.predict(X_val)
y_pred = abs(np.expm1(y_pred1p))

# 評価データにおけるRMSLEを算出する
RMSLE3b = np.sqrt(mean_squared_log_error(y_val, y_pred))
print("モデルの改善3b RMSLE:", RMSLE3b)

# モデルで予測した価格を確認する
series3b = pd.Series(y_pred)
print("Predicted Prices:")
print(series3b.head())
モデルの改善3b RMSLE: 1.6287887726330463
Predicted Prices:
0    18.993403
1    16.375068
2    23.744022
3    16.987548
4    12.417302
dtype: float64

c. Elastic Net回帰
最後に、Elastic Netを適用してモデル性能を確認します。Elastic Netのハイパーパラメータl1_ratioは0.3を指定し、L1正則化を30%、L2正則化を70%の割合で効かせます。その結果はRMSLE 1.6229で、これまでで最も性能の高いモデルとなりました。しかし、予測価格の先頭5行はすべて18.39~18.73の値になっており、Lassoと同様L1正則化の影響が強く出ています。

# モデルの改善3c - ElasticNet
# モデルの学習
model3c = ElasticNet(l1_ratio=0.3)
model3c.fit(X_train, y_train)

# 評価データを用いてモデルの性能を評価する
y_pred1p = model3c.predict(X_val)
y_pred = abs(np.expm1(y_pred1p))

# 評価データにおけるRMSLEを算出する
RMSLE3c = np.sqrt(mean_squared_log_error(y_val, y_pred))
print("モデルの改善3c RMSLE:", RMSLE3c)

# モデルで予測した価格を確認する
series3c = pd.Series(y_pred)
print("Predicted Prices:")
print(series3c.head())
モデルの改善3c RMSLE: 1.622944455257602
Predicted Prices:
0    18.732480
1    18.393167
2    18.586340
3    18.393167
4    18.393167
dtype: float64

Lassoと同様、L1正則化を弱めるため、alpha=0.001を指定してもう一度モデルの学習をおこないます。その結果、RMSLE 1.6289となり、デフォルトのalpha=1で学習した場合と比べるとRMSLEは上がり(性能は下がり)、Lassoモデルの性能を下回りますが、予測価格は妥当と言えます。

# モデルの改善3c - ElasticNet
# モデルの学習
model3c = ElasticNet(l1_ratio=0.3, alpha=0.001)
model3c.fit(X_train, y_train)

# 評価データを用いてモデルの性能を評価する
y_pred1p = model3c.predict(X_val)
y_pred = abs(np.expm1(y_pred1p))

# 評価データにおけるRMSLEを算出する
RMSLE3c = np.sqrt(mean_squared_log_error(y_val, y_pred))
print("モデルの改善3c RMSLE:", RMSLE3c)

# モデルで予測した価格を確認する
series3c = pd.Series(y_pred)
print("Predicted Prices:")
print(series3c.head())
モデルの改善3c RMSLE: 1.6289157895655182
Predicted Prices:
0    18.732480
1    18.393167
2    18.586340
3    18.393167
4    18.393167
dtype: float64

7. 提出用ファイルの作成
ここまでのモデル性能の推移をまとめます。ベースラインから3ステップの改善を経て、徐々にモデルの性能が上がって(RSMLEが下がって)いることが見て取れます。

ベースラインのモデル作成 - 文字列データのエンコーディングのみ行い、すべての特徴量を説明変数に含め、Random Forest Regressorモデルを作成 --- RSMLE 1.6556 
モデルの改善 (1)
- 文字列データの処理を工夫して、すべての特徴量を説明変数に含めてRandom Forest Regressorモデルを作成 --- RMSLE: 1.6541
モデルの改善 (2)
- 説明変数を取捨選択してモデルを作成。
a. 特徴量の重要度をもとに、説明変数をcategory2、category3、desc_vec2、shippingの4つに絞り、Random Forest Regressorモデルを作成 --- RMSLE 1.6539
b. 特徴量と価格の相関係数をもとに、説明変数をshipping、brand_name_bin、category1、desc_vec2の4つに絞り、Random Forest Regressorモデルを作成 --- RMSLE 1.6457
モデルの改善 (3)
- 説明変数をshipping、brand_name_bin、category1、desc_vec2の4つにして、異なる回帰モデルを試行。
a. Ridgeを適用 --- RMSLE 1.6290
b. Lassoをalpha=0.001に指定して適用 --- RMSLE 1.6288(alpha=1ではRMSLE 1.6231)
c. Elastic Netをalpha=0.001に指定して適用 --- RMSLE 1.6289(alpha=1ではRMSLE 1.6229)

RMSLEを指標とすると、alpha=1を指定してL1正則化を強くしたElastic Netとに軍配が上がります。しかし、皮肉にも、そのモデルから予測される価格はほぼ一定となるため、不採用としました。alpha=0.001としてL1正則化を緩めると、4つの回帰モデルのなかで、Lassoが最も性能のよい回帰モデルになりました。
Lassoモデルを採用し、Testデータに対する価格を予測します。最後に、Test IDとそれに対するPriceの2項目のみ含めた提出用ファイル(CSVファイル)を出力します。

# モデルにX_testを入れて予測する
y_pred1p = model3b.predict(X_test)
y_pred = abs(np.expm1(y_pred1p)) 

# Numpy配列からPandos Seriesへ変換
series = pd.Series(y_pred)
 
# テストデータのIDと予測値を連結
submit = pd.concat([test_small.id, series], axis=1)
 
# カラム名をメルカリ指定の名前をつける
submit.columns = ['test_id', 'price']
 
# 提出ファイルとしてCSVへ書き出し
PATH = '/content/drive/MyDrive/Colab Notebooks/Mercari Price Suggestion/Input/'
submit.to_csv(f'{PATH}submission.csv', index=False)

これで提出用ファイルが作成できました。あとはKaggle上でNotebookを作成し、上記のコードをコピペして、提出するのみです。


VII. さいごに

今回、Kaggleのコンペに挑戦してみて感じたのは、アプローチ方法・解き方の(機械学習者の伸びしろも)「可能性は無限大だ」ということです。課題に対するアプローチの方法やモデル・パラメータの選択は無限にあり、どの方法をどれくらい試してみるかは、その人次第です。言い換えると、コンペはスキルと時間の勝負であり、初心者でも時間をかけていろいろなアプローチを試してみれば、一定の成果をあげられる可能性があります。実際に、今回の挑戦では予定をはるかに超えて、完了まで約4週間を要しましたが、時間をかけるほどモデル性能をあげられることが分かりました。さらに時間をかければ、ハイパーパラメータをチューニングしたり、KFold(k-分割交差検証)を用いて予測モデルの汎化性能を検証したり、さらによいモデルが作れたのではと思います。一方で、データ分析や機械学習のスキルがあがれば「勝ち筋」がみえてくるため、より少ない時間で成果をあげられると思います。まずは、どんどんKaggle上に公開されているコードから学んだり、Discussionで質問して、スキル向上を目指したいと思います。

いかがでしたでしょうか。今回は、機械学習初心者がKaggleのコンペに初挑戦したときのデータ分析の流れや実装内容を書いてきました。あくまでも初心者ですので、お気づきの点がございましたら是非コメント頂ければと思います!


この記事が気に入ったらサポートをしてみませんか?