粗大メモ置き場

個人用,たまーに来訪者を意識する雑記メモ

KaggleのTitanicデータに対してsklearnの決定木を試してみる

(注)コンペ目的でない素人の備忘録です。参考になるかは不明ですがコメントは歓迎します。

Kaggleとは

www.kaggle.com

KaggleはHPにあるとおり機械学習/データサイエンスに携わる人や企業などのコミュニティです。

正直始めたてなのでざっくりとしかわからないですが,Competitionという、企業や政府などがコンペ形式で課題を提示し、賞金と引き換えに最も制度の高い分析モデルを買い取るといったシステムが有名です。

勉強用としてのKaggle(Titanic)

機械学習は教科書で深層学習やパーセプトロンなどをざっくり読んだだけなのでコンペに参加することはしばらくないでしょうが,勉強用にはいくつか使えそうな点があります。

例えば,練習用としてTitanicというタイタニック号事件のデータが提供されています。 構造もシンプルで初学者にとっても分かりやすいです。

また,人によってはnotebookをわかりやすくコメント付きで投稿してくれていたりします。 www.kaggle.com

他にも,ブラウザ上からアクセスするnotebook形式ではデフォルトでsklearnやlightBGMなどのライブラリがインストールされてる状態で始まるので環境構築の心配が現状なさそうです。

なお,真面目に入門するなら以下の解説が懇切丁寧でよかったです。(英語)

www.kaggle.com

私個人の目的としてはスプラトゥーン2の編成格差問題を自前のデータから解析してみようかなと思っています。(別記事にて記載予定)

メモ:Notebookの作成

初めてKaggleに登録して開くと何をしていいのか本当によくわからなくなりますね。 コンペを探してそれに参加するというのが本来でしょうが,今回はKaggleの環境とデータで練習をするという目的でNotebookを作ります。なお,PythonやPandasなどの知識はどこか別のところで勉強してください。

コンペから探して作成

上のSearchでtitanicを検索すればtitanicのページに移動します。

f:id:ossyaritoori:20200419162920p:plain
Titanicでサーチした結果のnotebookタブ

右上の「New Notebook」から始めます。

なお,notebookのタブには他の人のnotebookがありさんこうになります。

新規作成→Notebookにあとからデータを追加

f:id:ossyaritoori:20200420011520p:plain
Competition Dataから追加します。



sklearnの分類木ライブラリを用いた分類

決定木の説明は教科書等にに投げます。(Qiitaなら以下のリンクなんかが読みやすいのでは?)

[入門]初心者の初心者による初心者のための決定木分析 - Qiita

今回はsklearnというライブラリを用いて決定木のうち,分類木 (classification tree)を用いて学習します。

import pandas as pd
import numpy as np
import warnings
warnings.filterwarnings('ignore')

train = pd.read_csv('../input/titanic/train.csv')
test = pd.read_csv('../input/titanic/test.csv')
sample_submission = pd.read_csv('../input/titanic/gender_submission.csv')

trainは訓練用,testは評価用のデータです。

f:id:ossyaritoori:20200419180806p:plain
trainの中身はこんな感じ。氏名とか公開されてるんですね。

データの前処理

まずはデータの前処理をします。 機械学習のデータ前処理備忘録 - Qiita

主に,以下のようなことをします。

  • 訓練に必要なデータの抜き出し,加工
  • 空白データの補間,削除

データ形式の変換と不要なデータの削除

# データの変換
##maleを0に、femaleを1に変換
train["Sex"] = train["Sex"].map({"male":0,"female":1})
test["Sex"] = test["Sex"].map({"male":0,"female":1})
# EmbarkedのOne-Hotエンコーディング
train = pd.get_dummies(train, columns=['Embarked'])
test = pd.get_dummies(test, columns=['Embarked'])

## 不要な列の削除
train.drop(['PassengerId', 'Name', 'Cabin', 'Ticket'], axis=1, inplace=True)
test.drop(['PassengerId', 'Name', 'Cabin', 'Ticket'], axis=1, inplace=True)
  • One-Hotエンコーディング:[A,B,C]のような3つ以上の値のある要素をAの有無としてBoolean値に変換する。

f:id:ossyaritoori:20200419184512p:plain
必要そうなデータを抜き出したあとのテストデータ。

欠損データの確認と対処

ところで,データにはところどころ欠損(NaN)があり,学習に悪影響を及ぼすので取り除いたり穴埋めをしたいです。 pandasの機能でどの量に欠損があるのか簡単に割り出せます。 例えばNanを確認して取り除く場合は以下のように書くことができます。

# NaN の存在確認 と 除去
print(train.isnull().sum())

train2 = train.dropna()
test2 = test.dropna()

一応結果を示すと「年齢」の項が抜けているのがわかりますね。

Survived        0
Pclass          0
Sex             0
Age           177
SibSp           0
Parch           0
Fare            0
Embarked_C      0
Embarked_Q      0
Embarked_S      0
dtype: int64

他にも中央値や平均値,ランダム値などで埋める手法があるようです。 参考:平均値でNanを埋めるタイプの方

学習

さて,いよいよ学習のフェーズです。まずは与えられたデータを入出力に分けます。ここでの出力(=予測したい値)は生存したかどうかなのでSurvivedの列を抽出します。

from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score


# devide train input and output
X_train = train2.drop(['Survived'], axis=1)  # X_trainはtrainのSurvived列以外
y_train = train2['Survived']  # y_trainはtrainのSurvived列

# X_trainとY_trainをtrainとvalidに分割
train_x, valid_x, train_y, valid_y = train_test_split(X_train, y_train, test_size=0.20, random_state=0)

最後の実行では訓練用のデータを更に分けて4:1に分けて訓練の評価をします。 訓練用データ内でのフィッティング結果と実際の評価用データ内でのフィッティング結果を分けたいがためです。(過学習とかで検索してください。)

訓練用データを2つに分けるのをホールドアウト法,3つ以上に分けるのを交差検証法と言います。(引用

これで訓練用の入力と出力を分けられたのでsklearnの分類木ライブラリの学習で学習します。

import sklearn.tree as tree

# 分類木だからClassifier,Regressorもある
clf = tree.DecisionTreeClassifier(max_depth=4)

# データを用いて学習
model = clf.fit(train_x, train_y)

# データを用いて予測
predicted = model.predict(valid_x)
print(accuracy_score(predicted,valid_y))

なんと,これだけです。 結果は0.7902097902097902になりました。約8割程度の評価です。これは訓練用データに8割程度の正答率で合致しているという解釈になります。

補足:交差検証法による評価

先程の結果はデータのどの部分を訓練用に使うかにより左右されそうです。

したがってよりきちんとやる場合は,交差検証法で訓練データと評価用データをうまくローテーションして平均値などでモデルを評価します。

# 3分割交差検証を指定し、インスタンス化
from sklearn.model_selection import KFold
kf = KFold(n_splits=3, shuffle=True, random_state=0)

# スコアとモデルを格納するリスト
score_list = []
models = []

# 各分割ごとに評価
for fold_, (train_index, valid_index) in enumerate(kf.split(X_train, y_train)):    
    print(f'fold{fold_ + 1} start')
    train_x = X_train.iloc[train_index]
    valid_x = X_train.iloc[valid_index]
    train_y = y_train.iloc[train_index]
    valid_y = y_train.iloc[valid_index]
    
    ## 分割データで学習・予測・評価
    clf = tree.DecisionTreeClassifier(max_depth=4)
    model = clf.fit(train_x, train_y)
    
    # データを用いて予測,記録
    predicted = model.predict(valid_x)
    score_list.append(accuracy_score(predicted,valid_y))
    models.append(model)
print(score_list, '平均score', round(np.mean(score_list), 3))

結果は以下の通り,データによってばらつきがあるということがわかりますね。

[0.8067226890756303, 0.7647058823529411, 0.819327731092437] 平均score 0.797

混合行列による予測結果の視覚化

予測結果の成否をよりわかりやすくするために混合行列を用いた評価ができます。

from sklearn.metrics import confusion_matrix

#混同行列の作成
cmatrix = confusion_matrix(valid_y,predicted)

#pandasで表の形に
df = pd.DataFrame(cmatrix,index=["actual_died","actual_survived"],columns=["pred_died","pred_survived"])

print(df)
                 pred_died  pred_survived
actual_died            131             14
actual_survived         29             64

ざっくりというと,死んだという予測は18.1%で間違っており,生きていたという予測は17.9%で間違っていたということになります。 誤差のようなものですが,生きていたという予測のほうがかろうじて得意と言えそうです。

木構造の図示

学習した木を評価する手法にはいくつかあります。まずは木の構造を見たいと思うので木を図示します。

# 図示その1
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(30, 30))  # whatever size you want
tree.plot_tree(model, ax=ax)
plt.show()

ちょっと見づらい感じになります。

f:id:ossyaritoori:20200419234211p:plain

より良く見せたいならば,graphvizdtreevizなどを使うと良いと思います。

木構造のかっこいい視覚化

Kernelに以下のモジュールをインストールします。

!pip install dtreeviz pydotplus
# graphvizによる視覚化
import pydotplus as pdp

file_name = "./tree_visualization.png"
dot_data = tree.export_graphviz(model, # 決定木オブジェクトを一つ指定する
                                out_file=None, # ファイルは介さずにGraphvizにdot言語データを渡すのでNone
                                filled=True, # Trueにすると、分岐の際にどちらのノードに多く分類されたのか色で示してくれる
                                rounded=True, # Trueにすると、ノードの角を丸く描画する。
                                feature_names=train_x.columns, # これを指定しないとチャート上で特徴量の名前が表示されない
                                class_names=['Dead','Sruvived'], # これを指定しないとチャート上で分類名が表示されない
                                special_characters=True # 特殊文字を扱えるようにする
                                )
graph = pdp.graph_from_dot_data(dot_data)
graph.write_png(file_name)

# dtreevizによる視覚化
from dtreeviz.trees import dtreeviz

viz = dtreeviz(
    model,
    train_x, 
    train_y,
    target_name='alive',
    feature_names=train_x.columns,
    class_names=['Dead','Sruvived']
) 

#viz.view() # 外窓で画像を開くのでKaggleのKernelではエラーがでる?
viz.save(”dtreeviz.svg”)

f:id:ossyaritoori:20200421003827p:plain
graphvizによる可視化

f:id:ossyaritoori:20200421004012p:plain
dtreevizによる視覚化:どの様な分類をしたのかかなり分かりやすいですね。

木構造の解析:変数の重要性

では,解析の結果どの変数が生存に大きな影響を及ぼしたのでしょうか。 関数を叩くと一発ですが,以下のワードくらいは抑えておくとします。

  • ジニ不純度: ノードによる分類のバラケ方(≒エントロピーみたいな)を数値化したもの→半々で最大。「1 - 各分類木のノードの分類割合の2乗和 」の値。CARTで使われている。→ sklearnでのデフォルト。
  • 変数重要度:”ある特徴量で分割することでどれくらいジニ不純度を下げられるか。”を表す量。分割後のジニ不純度のサンプル数の分の和が,分割前のジニ不純度のサンプル数和よりどの程度小さくなったかを表す。

詳細な説明はこちらの記事に投げます。


treeオブジェクトでは変数重要度は.feature_importances_というメンバに入っています。

# 重要度を表示
print(dict(zip(train_x.columns, model.feature_importances_)))

# bar plot
fig, ax = plt.subplots()
plt.grid()
ax.bar(train_x.columns,model.feature_importances_)
fig.autofmt_xdate() # make space for and rotate the x-axis tick labels
plt.show()

f:id:ossyaritoori:20200420003916p:plain
1に性別,2に乗客の等位,3に年齢となっていますね。

確かタイタニックでは女性と子供が優先的に救命ボートに乗せられたはずなのでこの洞察は合っていると言えるでしょう。

ただし重要度は,如何にクラスを上手く抽出できたかを表す無方向性の値なので,年齢が幼い方が良いのか女性の方が良いのかはわかりません。どうすればよいかちょっと調べてみます。(TODO)

テスト提出

とりあえず提出用のデータを作ります。

# テスト  これを提出

## test の中身
print(test.isnull().sum())
## 中央値で埋める
test = test.fillna(test.median())

## 予測結果
test_predicted = model.predict(test)

## 提出用データ
sample_submission['Survived'] = test_predicted
sample_submission.to_csv('submission.csv',index=False)

このcsvをダウンロードしてSubmit Predictionの該当箇所にUploadします。

f:id:ossyaritoori:20200420014616p:plain
Upload中の様子

本当はNotebookでSubmitしたいんですが,Submitできそうなボタンがないような?(@2020年4月20日)なんでやろ?

f:id:ossyaritoori:20200420015626p:plain
提出した結果は74%くらい。訓練時より低く出ました。そりゃそうか。

スコアボードを見ると全く同じスコアの人々がいるので多分テストコードを走らせただけの人々がそれなりにいるのでしょう。 なお,正答率100%の人々もかなりいます。だって答えわかってるもんね。

最後に一応My Notebookを載せます。大してきちんと書いてないですけどね。

www.kaggle.com

補足・TODO

気分がアガり次第書きます。スプラトゥーンの記事は書いたまま塩漬けになってるので今度ちゃんと書き起こします…

TODOs:

今回は最も単純な分類木を用いましたが,

  • スプラトゥーンの記事との連携
  • ランダムフォレストやlightBGMなどの実装と評価
  • SVM等の他の手法に関する考察

補足:今後の参考

素晴らしい記事をありがとうございます。後ほど参考にさせて頂く予定です。

maruo51.com

qiita.com

pythondatascience.plavox.info

ボツデータ:欠損値をどのように埋めるか問題

欠損値をどのように埋めるかですが,3番めに重要と思われるAgeが抜けておりました。

絶対適当に埋めるべきではない気がするのですが,中央値,平均値,それとも削除して無視するのどれが良いのか比較しようと試みました。

結論から言うと結果は全く変わらず。なんか違いが出ると思ったんですがね…

# 欠損値除去 VS 補填 (なお,比較のためここでやっているが,本来はデータの前処理の段階でやるべき)

## train_yのうちtrain_xがNanを含む行をdropする
train_y_dropna = train_y.drop(train_x[train_x['Age'].isnull()].index.tolist())
train_x_dropna = train_x.dropna()

## 埋める方 shallow copy
train_x_fillmed = train_x
train_x_fill0 = train_x

train_x_fillmed["Age"] = train_x_fillmed["Age"].fillna(train_x["Age"].median())
train_x_fill0["Age"] = train_x_fill0["Age"].fillna(0)

# それぞれFit
model_dropna = clf.fit(train_x_dropna, train_y_dropna)
model_fillmed = clf.fit(train_x_fillmed, train_y)
model_fill0 = clf.fit(train_x_fill0, train_y)

# 予測

## validの欠損値補間はMedianで
valid_x["Age"] = valid_x["Age"].fillna(valid_x["Age"].median())

pred_dropna = model_dropna.predict(valid_x)
pred_fillmed = model_fillmed.predict(valid_x)
pred_fill0 = model_fill0.predict(valid_x)

## 評価
print(accuracy_score(pred_dropna,valid_y),
      accuracy_score(pred_fillmed,valid_y),
      accuracy_score(pred_fill0,valid_y))

追記:ちゃんと変化しました。Medianで適当に埋めるほうが少し精度が下がりそうです。

[0.8148148148148148, 0.7811447811447811, 0.7542087542087542] 平均score 0.783

f:id:ossyaritoori:20200420102911p:plain
Medianで適当にNanを埋めて学習した時の重要度。他の変数にも重みが振られていることがわかります。