AI Quest2021 アセスメント23位の解法

こんにちは。私は経営工学を専攻している学部3年生で、shu421という名前で活動しております。この記事では、2021/8/9に締め切られたAI Quest2021アセスメントの解法について解説します。これはSIGNATEの過去コンペと同様のものでした。私は最終的に

- cv: 103.5750774
- lb: 144.0033488
- 964人中23位

という結果でした。拙い部分もあるとは思いますが、この記事が皆さんのお役に立てればなと思います。
Twitterもやっているので、ぜひフォローお願いします。また、全コードをGitHubに公開したので、そちらもご覧になってください！
Twitter: (https://twitter.com/shu421_)
GitHub: (https://github.com/shu421/AIQuest_2021) : main/nb001.ipynbが今回の最終サブコードです。

# この記事について
この記事では、GitHubにあげたアセスメントの解法を簡単に解説します。特に、amenitiesというカラムの前処理についてわからない方が多いように感じたので、少し丁寧に書いたつもりです。

# 目次
- タスク
- データ
- 前処理
- モデリング
- 最後に

# タスク
アメニティの種類やベッドの数、レビュー日から宿泊施設の価格(y)を予測するコンペでした。全てのカラムは以下の通りです。

```Python
train_df.columns

Index(['id', 'accommodates', 'amenities', 'bathrooms', 'bed_type', 'bedrooms',
       'beds', 'cancellation_policy', 'city', 'cleaning_fee', 'description',
       'first_review', 'host_has_profile_pic', 'host_identity_verified',
       'host_response_rate', 'host_since', 'instant_bookable', 'last_review',
       'latitude', 'longitude', 'name', 'neighbourhood', 'number_of_reviews',
       'property_type', 'review_scores_rating', 'room_type', 'thumbnail_url',
       'zipcode', 'y'],
      dtype='object')
```

# 前処理
### ライブラリインポート
```Python
import pandas as pd
import numpy as np
import gc
import matplotlib.pyplot as plt
import seaborn as sns
pd.options.display.max_columns = 100
%matplotlib inline

from sklearn.preprocessing import LabelEncoder
from category_encoders.target_encoder import TargetEncoder

from sklearn.preprocessing import MultiLabelBinarizer

from sklearn.model_selection import KFold
from sklearn.metrics import mean_squared_error

import lightgbm as lgb
```

基本的に、trainとtestには同様の前処理をするので、先にこれら二つをまとめてから処理して行きます。

```Python
train_df = pd.read_csv('../input/train.csv')
test_df = pd.read_csv('../input/test.csv')
train = train_df.copy() # 念の為、オリジナルのtrain_dfは残しておく
test = test_df.copy()
target = train.pop('y') # yを抽出し、trainから削除
all_df = pd.concat([train, test], axis=0) # 結合
all_df = all_df.drop(['id'], axis=1) # id列は使わない。これがあるとうまく学習できない
all_df = all_df.reset_index(drop=True)# indexを揃える
```

## カテゴリ型

```Python
all_df.select_dtypes(include=object).columns

Index(['amenities', 'bed_type', 'cancellation_policy', 'city', 'cleaning_fee',
       'description', 'first_review', 'host_has_profile_pic',
       'host_identity_verified', 'host_response_rate', 'host_since',
       'instant_bookable', 'last_review', 'name', 'neighbourhood',
       'property_type', 'room_type', 'thumbnail_url', 'zipcode'],
      dtype='object')
```

この中でも、amenities, description, first_review, host_since, last_reviewが少し癖があるので、解説します。

### amenities

```Python
all_df['amenities'].head() # 0~5行目を抽出

0    {TV,"Wireless Internet",Kitchen,"Free parking ...
1    {TV,"Cable TV",Internet,"Wireless Internet","A...
2    {TV,Internet,"Wireless Internet",Kitchen,"Indo...
3    {TV,"Cable TV",Internet,"Wireless Internet","A...
4    {TV,Internet,"Wireless Internet","Air conditio...
Name: amenities, dtype: object`
```
上の結果より、amenitiesの一つのセルには、「{}」で囲まれており、「"」がついているデータとついていないデータが混在していることがわかります。また、「,」によって単語が区切られています。そこで、「{}」と「"」を削除し、「,」によって単語をわけます。

```Python
# amenities
# dict → one-hot
def processing_amenities(x): 
#all_df['amenities']の各要素をapplyによって順に入力していく
# 入力xの例:  {TV,"Wireless Internet",Kitchen}

    x = x.replace('{','') # 「{」を空白''で置き換えることで削除
    x = x.replace('}','')
    x = x.replace('"','')
    x = x.split(',') # ,で分ける
    return x # [TV, Wireless Internet, Kitchen]のような形で出力される
all_df['amenities'] = all_df['amenities'].apply(processing_amenities)
```

以上により、汚かったデータがリスト形式になりました。これでall_df['amenities']の各要素には、`[TV, Wireless Internet, Kitchen]`という形でデータが保存されています。次に、これらのリストをone-hot化していきます。

```Python
mlb = MultiLabelBinarizer(sparse_output=True)
all_df = all_df.join(
            pd.DataFrame.sparse.from_spmatrix(
                mlb.fit_transform(all_df.pop('amenities')) # popにすることで、amenities列を同時に削除
                index=all_df.index,
                columns=mlb.classes_))
```

以上のコードを書くことで、`TV`や`Wireless`列が作成され、それらを含む行には1が代入され、含まない場合には0が代入されました。
<img width="848" alt="Screen Shot 2021-08-31 at 14 02 38" src="https://user-images.githubusercontent.com/71954051/131444910-e92164d0-0bca-4da2-bd1a-6e4d309cbbef.png">

これでamenitiesの処理は終了です。

### description
desriptionには、宿泊施設の説明が記載されていました。例えば、「豪華」や、「高価」などの単語が含まれていれば重要な特徴量になると考えたので、特徴的な単語を見つける、[TF-IDF](https://qiita.com/ground0state/items/155b77f4c07e1a509a14)という手法を試しました。
しかし、精度は改善されなかったので、純粋に文章の長さだけを特徴とし、元の列は落としました。

```Python
# description
# length of sentence
all_df['n_description'] = all_df['description'].apply(lambda x: len(x))
all_df = all_df.drop('description', axis=1)
```

### first_review, host_since, last_review
これらは、年-月-日にちという形のデータです。

```Python
train_df['first_review']

0        2016-07-27
1        2016-09-12
2        2016-06-15
3        2014-03-15
4        2015-08-05
            ...    
55578    2013-02-27
55579    2015-11-29
55580    2016-03-02
55581    2016-10-18
55582           NaN
Name: first_review, Length: 55583, dtype: object
```

こららは、それぞれに関して年月日のカラムを作成しました。

```Python
# date to one-hot
def convert_date(df_, col):
    df = df_.copy()
    df[col] = pd.to_datetime(df[col]) # datetime型に変換
    df[f'{col}_year'] = df[col].map(lambda x: x.year)
    df[f'{col}_month'] = df[col].map(lambda x: x.month)
    df[f'{col}_day'] = df[col].map(lambda x: x.day)
    df = df.drop(col, axis=1)
    return df
all_df = convert_date(all_df, 'first_review')
all_df = convert_date(all_df, 'host_since')
all_df = convert_date(all_df, 'last_review')
```

### Encoding
純粋なカテゴリ型のカラムは、label encodingとtarget encodingしました。target encodingによって、若干スコアが改善された記憶があります(どのくらい上がったかは忘れました)。

```Python
# categorical columns

# Label Encoding
cat_col = ['bed_type', 'cancellation_policy', 'city',
            'cleaning_fee', 'host_has_profile_pic',
            'host_identity_verified', 'instant_bookable',
            'name', 'neighbourhood', 'property_type', 'room_type',
            'thumbnail_url']
le = LabelEncoder()
for col in cat_col:
    all_df[col] = le.fit_transform(all_df[col])
for col in cat_col:
    train[col] = le.fit_transform(train[col])
for col in cat_col:
    test[col] = le.fit_transform(test[col])

# Target Encoding
te = TargetEncoder(smoothing=0.1)
for col in cat_col:
    train[f'te_{col}'] = te.fit_transform(train[col], target)
for col in cat_col:
    test[f'te_{col}'] = te.transform(test[col])
```

## 数値型

### 欠損値
LightGBMは賢いので、基本的に数値型カラムはそのままです。欠損値は埋めなくても大丈夫ですが、-9999で埋めることで若干精度が改善されました。

### 集約特徴量
kaggleやSIGNATEなどのコンペでは、ある列をキーにした集約特徴量がよく採用されているので今回も作ってみました。価格帯によって特徴が異なると予想できるので、それをうまく表現するキーを見つけるの重要です。私は、

- knnによる価格帯の予測値
- kmeansによるクラスタリングされたクラス

をキーにして集約特徴量を作ってみましたが、精度は改善されませんでした。そこで都市ごとに宿泊施設の値段は変わると予想し、`city` をキーにするとスコアが改善されました。集約統計量としては、z得点(zscore)、偏差(deviation)、標準偏差(std)を採用しました。`mean`、`sum`、`median`、`count`ではスコアが改善されませんでした。後から考えると、deviationやstdによって、価格帯の散らばりをうまく表現できていたのかなと思います。
集約特徴量の作成方法については、[過去コンペのディスカッション](https://signate.jp/competitions/449/discussions/lgbmknn-lb06567-1)を参考にしました

```Python
# aggregated features
group_key = 'city'
group_values = ['accommodates',
                'bathrooms',
                'bed_type',
                'bedrooms',
                'beds',
                'cancellation_policy',
                # 'city',
                'cleaning_fee',
                'host_has_profile_pic',
                'host_identity_verified',
                'host_response_rate',
                'instant_bookable',
                'latitude',
                'longitude',
                'name',
                'neighbourhood',
                'number_of_reviews',
                'property_type',
                'review_scores_rating',
                'room_type',
                'thumbnail_url',
                'zipcode']
agg_methods = [
                'deviation',
                 'zscore',
                 'std',
                #  'mean',
                #  'count',
                #  'sum',
                #  'median'
                 ]
# GroupFeatureExtractorについては、GitHubのmain/nb001.ipynbに記載しています。
gfe = GroupFeatureExtractor(
    group_key=group_key,
    group_values=group_values,
    agg_methods=agg_methods)
df_ = gfe.fit_transform(all_df)
all_df = pd.concat([all_df, df_], axis=1)
```

# その他
結果論ですが、以下の特徴量重要度は0でしたので、計算スピードを上げるために落としています。落としても精度は変わらないです。

```Python
# drop features: importance=0
drop_features =['Handheld shower head', 'Table corner guards', 'Host greets you',
       'Bath towel', 'Cleaning before checkout', 'Accessible-height bed',
       'Beachfront', 'Waterfront', 'Hot water kettle', 'Body soap',
       'Air purifier', 'Changing table', 'Accessible-height toilet',
       'Bathtub with shower chair', 'Washer / Dryer', 'Lake access',
       'Toilet paper', 'Hand or paper towel',
       'agg_zscore_host_identity_verified_grpby_city',
       'Wide clearance to shower & toilet', 'Disabled parking spot',
       'agg_std_host_has_profile_pic_grpby_city', 'Ground floor access',
       'agg_std_beds_grpby_city', 'Grab-rails for shower and toilet',
       'agg_std_bed_type_grpby_city', 'Pocket wifi', 'Hand soap',
       'Private bathroom', 'Ski in/Ski out', 'Refrigerator',
       'Fixed grab bars for shower & toilet', 'Flat',
       'Roll-in shower with chair', 'Flat smooth pathway to front door',
       'Single level home', 'agg_std_host_identity_verified_grpby_city',
       'agg_std_host_response_rate_grpby_city',
       'Path to entrance lit at night', 'agg_zscore_cleaning_fee_grpby_city',
       'Other', 'Dishes and silverware', 'Wide clearance to shower and toilet',
       'agg_zscore_instant_bookable_grpby_city', 'Free parking on street',
       'Wide entryway', 'Wide hallway clearance',
       'agg_std_longitude_grpby_city', 'Paid parking off premises',
       'agg_std_room_type_grpby_city',
       'agg_std_review_scores_rating_grpby_city', 'Step-free access',
       'agg_std_neighbourhood_grpby_city', 'agg_std_name_grpby_city',
       'Firm matress']
```

# モデリング
5 [KFold](https://upura.hatenablog.com/entry/2018/12/04/224436)のLightGBMを使用しています。よく見かけるコードかもしれませんが、一応載せておきます。

```Python
n_splits = 5
kf = KFold(n_splits=n_splits, shuffle=True, random_state=71)
X = train
y = target

# 各Foldの結果を格納する
models = []
scores = []
preds = np.zeros((len(test),n_splits))

for i, (train_index, valid_index) in enumerate(kf.split(X, y)): # enumerateと書くことで、iにFoldの番号を代入できる(0-4)

    X_train = X.iloc[train_index]
    y_train = y.iloc[train_index]
    X_valid = X.iloc[valid_index]
    y_valid = y.iloc[valid_index]

    lgb_train = lgb.Dataset(X_train.values, y_train)
    lgb_valid = lgb.Dataset(X_valid.values, y_valid)

    params ={'objective':'regression',
            'seed':71,
            'verbose':-1,
            'metrics':'rmse',
            'learning_rate':0.1}
    model = lgb.train(params, lgb_train, verbose_eval=False, early_stopping_rounds=100, num_boost_round=9999,
                        valid_names=['train','test'], valid_sets=[lgb_train, lgb_valid])

    # 学習の評価
    valid_pred = model.predict(X_valid)
    score = mean_squared_error(y_valid, valid_pred, squared=False)
    
    # テストデータの予測
    test_pred = model.predict(test)

    # Foldの結果を格納
    models.append(model)
    scores.append(score)
    preds[:,i] = test_pred
    print(f'Fold{i}:', score)

print('cv:', np.mean(scores))
```
# まとめ
精度向上に貢献した要因を一部記載します。

### 効いたもの
- target encoding
- 各種集約特徴量(zscore, deviation, std)
- descriptionの長さ

### 効かなかったもの
- knn, kmeansの結果をキーにした集約特徴量
- knn score
- 各カラムのbinning
- optuna hyperparameter tuning(やり方が間違ってたかも)
- low learning rate(例: 0.01)
- count encoding
- Fold数を増やす(10, 15...)
    - cvは改善しましたが、過学習していたようでlbは悪化しました。
- bedroomあたりのbed数

# 最後に
特にamenitiesの処理方法について知りたい方が多い気がしたので、今回の記事を作成しました。全コードの中で説明していない部分も多くありますので、わからない点や解説してほしい箇所がありましたら、[Twitter](https://twitter.com/shu421_)のDMやslackで遠慮なく聞いてください。また再喝にはなりますが、全コードは[GitHub](https://github.com/shu421/AIQuest_2021)にあげてあります。そちらもぜひご覧になってください。半年間、一緒に切磋琢磨して行けたら嬉しいです！！