ランダムの森

20代エンジニアです。プログラミングについて主に書いてます。

pythonのscikit-learnを用いた機械学習モデルの作り方

前置き

非エンジニアの方で、AIとか機械学習とかって実際何しているんだ?と疑問に思うことは一度はあったはずです。
あるいは駆け出しエンジニアの方で、python勉強し始めたけど予測モデルとかどのように作られていかイメージできないという方少なからずいるはずです。

本屋で売られている参考書はnumpyのランダム関数使って適当にデータを作っているものが多く生のデータを扱っているものが少ないように感じます。(中上級者向けならあるかもですが。)

そういった疑問解消に少しでも貢献できればと思い生のデータを渡されてから予測モデルを作るまでの一連の流れを整理して見たいと思います。

よって、本記事は非エンジニア駆け出しエンジニアの方向けです。
非エンジニアの方はコードの上の日本語を追って見てください。コードが分からなくても理解できると思います。
駆け出しエンジニアの方はコードを追って見てください。大して難しいことはしていないのですぐに実践できる技術レベルだと思います。

概要説明

今回はKaggleで有名なタイタニックの生存者予測の問題を扱っています。
英題→「Titanic : Machine Learning from Disaster」
Kaggleとは企業が自社で持つデータを開示してそのデータを使って何をしたいか問題として公開しているサイトです。参加は自由で世界中のデータサイエンティストがこぞって予測モデルの精度などを競い合い、成績上位者には結構な賞金も用意されたりしています。
各問題のデータ取得には無料アカウント登録が必要なので、コードを書いてモデルを作って見たいという方はまずは登録してみてください。→Kaggle: Your Home for Data Science
タイタニック問題のページはこちらです。
Titanic: Machine Learning from Disaster | Kaggle
以下のデータが今回の予測モデル作成に使うデータです。
・train.csv
・test.csv

それぞれのデータにはタイタニック号に乗っていた人の年齢や性別などのデータが入っており、trainデータでは一人一人の生死情報もついています。一方testデータには生死情報はありません。trainデータで生死の予測モデルを作りtestデータで実際に予測をして見て、Kaggleに提出するという流れです。

モデリングにあたっては以下のような手順で進みます。

データ確認→データクリーニング→モデル作成→予測

また、今回は各モデルやDataFrameの扱いに関する細かい説明については省いており、あくまでデータ取得からモデル構築までの流れを整理することにフォーカスしています。

それではデータの中身から見ていきます。

データ確認

まず(天下り的ですが)必要なパッケージをインポートします。

# データ分析に必要なもの
import pandas as pd
import numpy as np
import random as rnd

# 可視化ツール
import seaborn as sns
import matplotlib.pyplot as plt
%matplotlib inline

# 機械学習用モデリング手法
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.ensemble import RandomForestClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.linear_model import Perceptron
from sklearn.tree import DecisionTreeClassifier

次にデータを取り込みます。

gender = pd.read_csv("gender_submission.csv", "r")
train_df = pd.read_csv("train.csv", error_bad_lines=False)
test_df = pd.read_csv("test.csv", error_bad_lines=False)

内容を確認しましょう。
head()を使うと最初の5列だけ表示されます。

train_df.head()

>output
f:id:doreikaiho:20181224114101p:plain
それぞれの項目の和訳は以下です。
PassengerID:乗客ID
ID Survived: 生存結果 (1: 生存, 2: 死亡) 
Pclass: 乗客の階級 1が一番位が高い層
Name:名前
Sex:性別
Age:年齢
SibSp:同乗していた兄弟、配偶者の数。
Parch:同乗していた両親、子供の数。
Ticket:チケット番号
Fare: 乗船料金
Cabin: 部屋番号
Embarked: 乗船した港(Cherbourg、Queenstown、Southampton)

次にnul値の確認です。生のデータはデータが欠損していることが多くあまりにも欠損値が多いものは使えないでしょう。

for i in range(0,len(train_df.columns)):
    print(train_df.columns[i])
    print(train_df[train_df.columns[i]].isnull().sum())

>output
PassengerId
0
Survived
0
Pclass
0
Name
0
Sex
0
Age
177
SibSp
0
Parch
0
Ticket
0
Fare
0
Cabin
687
Embarked
2

Cabin: 部屋番号 の欠損値が非常に多いですね。
データの情報は以下のようにして抜き出します。

train_df.info()
print('_'*40)
test_df.info()

output>

RangeIndex: 891 entries, 0 to 890
Data columns (total 12 columns):
PassengerId 891 non-null int64
Survived 891 non-null int64
Pclass 891 non-null int64
Name 891 non-null object
Sex 891 non-null object
Age 714 non-null float64
SibSp 891 non-null int64
Parch 891 non-null int64
Ticket 891 non-null object
Fare 891 non-null float64
Cabin 204 non-null object
Embarked 889 non-null object
dtypes: float64(2), int64(5), object(5)
memory usage: 83.6+ KB
________________________________________

RangeIndex: 418 entries, 0 to 417
Data columns (total 11 columns):
PassengerId 418 non-null int64
Pclass 418 non-null int64
Name 418 non-null object
Sex 418 non-null object
Age 332 non-null float64
SibSp 418 non-null int64
Parch 418 non-null int64
Ticket 418 non-null object
Fare 417 non-null float64
Cabin 91 non-null object
Embarked 418 non-null object
dtypes: float64(2), int64(4), object(5)
memory usage: 36.0+ KB

左側にある数字がそれぞれのデータ数になっています。上記のようにCabin: 部屋番号のデータ数はかなり少ないようです。

データクリーニング

機械学習では目的変数(生存有無)を説明変数(その他パラメータ)の関係性を整理して、ある程度相関関係のある説明変数をモデルに突っ込む必要があります。例えば、動物園の入場者数(目的変数)の予測に対して天気のパラメータ(説明変数)はなんとなく効いてきそう(相関がある)ですね。一方、その日の動物園内のアリの数は関係なさそう(相関がない)ですね。このように、相関の有無を整理して、必要ならば少しアレンジしてモデル作成時に説明変数として突っ込みます。この作業がデータクリーニングです。(データクレンジング) この作業が機械学習によるモデル作りの9割くらいを占めます。
実際になにをするのか見ていきます。

まずは乗客の階級と生存の関係確認。

train_df[['Pclass','Survived']].groupby(['Pclass'],as_index=False).mean().sort_values(by='Survived', ascending=False)

>output
f:id:doreikaiho:20181224132517p:plain:w180

次に性別と生存の関係確認。

train_df[["Sex","Survived"]].groupby(['Sex'],as_index=False).mean().sort_values(by='Survived',ascending=False)

>output
f:id:doreikaiho:20181224132725p:plain:w180

次に同乗した兄弟の数と生存の関係確認。

train_df[["SibSp","Survived"]].groupby(['SibSp'],as_index=False).mean().sort_values(by='Survived',ascending=False)

>output
f:id:doreikaiho:20181224133005p:plain:w180

次に同乗した両親、子供の数と生存の関係確認。

train_df[["Parch", "Survived"]].groupby(['Parch'], as_index=False).mean().sort_values(by='Survived', ascending=False)

>output
f:id:doreikaiho:20181224133109p:plain:w180

次に年齢と生存の関係確認。

g = sns.FacetGrid(train_df, col='Survived')
g.map(plt.hist, 'Age', bins=20)

>output
f:id:doreikaiho:20181224133239p:plain:w420

次に階級と年齢と生存の関係をまとめて見てみます。

grid = sns.FacetGrid(train_df, col='Survived', row='Pclass', size=2.2, aspect=1.6)
grid.map(plt.hist, 'Age', alpha=.5, bins=20)
grid.add_legend();

>output
f:id:doreikaiho:20181224133408p:plain:w450

次に階級と搭乗港と性別と生存の関係をまとめて見てみます。

grid = sns.FacetGrid(train_df, row='Embarked', size=2.2, aspect=1.6)
grid.map(sns.pointplot, 'Pclass', 'Survived', 'Sex', palette='deep')
grid.add_legend()

>output
f:id:doreikaiho:20181224133701p:plain:w360

次に乗船料金と搭乗港と性別と生存の関係をまとめて見てみます。

grid = sns.FacetGrid(train_df, row='Embarked', col='Survived', size=2.2, aspect=1.6)
grid.map(sns.barplot, 'Sex', 'Fare', alpha=.5, ci=None)
grid.add_legend()

>output
f:id:doreikaiho:20181224134058p:plain:w420

ここまで一通り生存との関係を確認していきました。それでは変数を少しずつアレンジしていきます。その前に、今回の説明変数としてチケット番号(今回の現象と関係なさそう)と部屋番号(欠損値が多すぎる)は使えなさそうなので削除します。また、このあとtrainデータとtestデータは一緒にいじっていくのでリストにまとめます。

train_df = train_df.drop(['Ticket', 'Cabin'], axis=1)
test_df = test_df.drop(['Ticket', 'Cabin'], axis=1)
combine = [train_df, test_df]

次に名前の呼ばれ方(ミスターとかドクターとか)をグループ分けして生存との関係を見てみます。

for dataset in combine:
    dataset['Title'] = dataset.Name.str.extract(' ([A-Za-z]+)\.', expand=False)

pd.crosstab(train_df['Title'], train_df['Sex'])

>output
f:id:doreikaiho:20181224135104p:plain:w180
上のうち単体の個数が少ない呼ばれ方をまとめてRareとします。

for dataset in combine:
    dataset['Title'] = dataset['Title'].replace(['Lady', 'Countess','Capt', 'Col',\
 	'Don', 'Dr', 'Major', 'Rev', 'Sir', 'Jonkheer', 'Dona'], 'Rare')

    dataset['Title'] = dataset['Title'].replace('Mlle', 'Miss')
    dataset['Title'] = dataset['Title'].replace('Ms', 'Miss')
    dataset['Title'] = dataset['Title'].replace('Mme', 'Mrs')
    
train_df[['Title', 'Survived']].groupby(['Title'], as_index=False).mean()

>output
f:id:doreikaiho:20181224135514p:plain:w180

上記で作った呼ばれ方をデータフレームに新しいカラム('Title')として突っ込みます。
ただし、データは文字列とはせず数字にします。
修正したデータフレームを確認します。

title_mapping = {"Mr": 1, "Miss": 2, "Mrs": 3, "Master": 4, "Rare": 5}
for dataset in combine:
    dataset['Title'] = dataset['Title'].map(title_mapping)
    dataset['Title'] = dataset['Title'].fillna(0)

train_df.head()

>output
f:id:doreikaiho:20181224135928p:plain

名前とIDのカラムはもう使わないので削除します。

train_df = train_df.drop(['Name', 'PassengerId'], axis=1)
test_df = test_df.drop(['Name'], axis=1)
combine = [train_df, test_df]

性別を数字に変換します。

for dataset in combine:
    dataset['Sex'] = dataset['Sex'].map( {'female': 1, 'male': 0} ).astype(int)

train_df.head()

>output
f:id:doreikaiho:20181224140201p:plain:w450

次に年齢データの欠損値を補います。
ここでは性別+階級のグループで分けて、それぞれのグループの中間値を欠損値に突っ込みます。

grid = sns.FacetGrid(train_df, row='Pclass', col='Sex', size=2.2, aspect=1.6)
grid.map(plt.hist, 'Age', alpha=.5, bins=20)
grid.add_legend()

>output
f:id:doreikaiho:20181224140627p:plain:w420

#データ補う値を入れる箱を作成
guess_ages = np.zeros((2,3))

#それぞれのグループの中間値を選ぶ
for dataset in combine:
    for i in range(0, 2):
        for j in range(0, 3):
            guess_df = dataset[(dataset['Sex'] == i) & \
                                  (dataset['Pclass'] == j+1)]['Age'].dropna()

            age_guess = guess_df.median()
            guess_ages[i,j] = int( age_guess/0.5 + 0.5 ) * 0.5

    #それぞれのグループの中間値を欠損値に突っ込む
    for i in range(0, 2):
        for j in range(0, 3):
            dataset.loc[ (dataset.Age.isnull()) & (dataset.Sex == i) & (dataset.Pclass == j+1),\
                    'Age'] = guess_ages[i,j]

    dataset['Age'] = dataset['Age'].astype(int)

train_df.head()

>output
f:id:doreikaiho:20181224140857p:plain:w420

次に年齢層をグループに分けます。(年齢の数字個々に見ていると細かいため大きく括ります。)

train_df['AgeBand'] = pd.cut(train_df['Age'], 5)
train_df[['AgeBand', 'Survived']].groupby(['AgeBand'], as_index=False).mean().sort_values(by='AgeBand', ascending=True)

>output
f:id:doreikaiho:20181224141201p:plain:w180

Ageカラムを年齢層によって書き換えてその後AgeBandカラムは削除します。

for dataset in combine:    
    dataset.loc[ dataset['Age'] <= 16, 'Age'] = 0
    dataset.loc[(dataset['Age'] > 16) & (dataset['Age'] <= 32), 'Age'] = 1
    dataset.loc[(dataset['Age'] > 32) & (dataset['Age'] <= 48), 'Age'] = 2
    dataset.loc[(dataset['Age'] > 48) & (dataset['Age'] <= 64), 'Age'] = 3
    dataset.loc[ dataset['Age'] > 64, 'Age']
train_df.head()

>output
f:id:doreikaiho:20181224141301p:plain:w450

train_df = train_df.drop(['AgeBand'], axis=1)
combine = [train_df, test_df]
train_df.head()

>output
f:id:doreikaiho:20181224142002p:plain:w450

次に同乗家族の有無をデータ化します。

for dataset in combine:
    dataset['IsAlone'] = 0
    dataset.loc[dataset['FamilySize'] == 1, 'IsAlone'] = 1

train_df[['IsAlone', 'Survived']].groupby(['IsAlone'], as_index=False).mean()

>output
f:id:doreikaiho:20181224142214p:plain:w180

家族関係のデータは家族の同乗の有無のみを残します。

train_df = train_df.drop(['Parch', 'SibSp'], axis=1)
test_df = test_df.drop(['Parch', 'SibSp'], axis=1)
combine = [train_df, test_df]

train_df.head()

>output
f:id:doreikaiho:20181224142343p:plain:w400

次に搭乗港のデータの欠損値が3列存在しますが、これは一番値の大きい港名に置き換えます。
3列のみなので大勢には影響しないでしょう。

freq_port = train_df.Embarked.dropna().mode()[0]
freq_port

>output
'S'
もっとも数の多い港はS港らしいので欠損値に’S’を突っ込みます。

for dataset in combine:
    dataset['Embarked'] = dataset['Embarked'].fillna(freq_port)
    
train_df[['Embarked', 'Survived']].groupby(['Embarked'], as_index=False).mean().sort_values(by='Survived', ascending=False)

>output
f:id:doreikaiho:20181224142813p:plain:w180

次に港名の文字列を整数型に変換します。

for dataset in combine:
    dataset['Embarked'] = dataset['Embarked'].map( {'S': 0, 'C': 1, 'Q': 2} ).astype(int)

train_df.head()

>output
f:id:doreikaiho:20181224142914p:plain:w400

テストデータに乗船料金の欠損値があるので補完が必要です。
といっても、欠損値は一列だけなのでデータ全体の中央値を入れておきます。(ベストではないかもしれません。)

test_df['Fare'].fillna(test_df['Fare'].dropna().median(), inplace=True)

次に年齢同様、乗船料金に対しても価格帯でグループ分けして新しいカラムとしてデータフレームに突っ込みます。

train_df['FareBand'] = pd.qcut(train_df['Fare'], 4)
train_df[['FareBand', 'Survived']].groupby(['FareBand'], as_index=False).mean().sort_values(by='FareBand', ascending=True)

>output
f:id:doreikaiho:20181224144051p:plain:w180

上の価格幅で乗船料金のデータを作り直します。

for dataset in combine:
    dataset.loc[ dataset['Fare'] <= 7.91, 'Fare'] = 0
    dataset.loc[(dataset['Fare'] > 7.91) & (dataset['Fare'] <= 14.454), 'Fare'] = 1
    dataset.loc[(dataset['Fare'] > 14.454) & (dataset['Fare'] <= 31), 'Fare']   = 2
    dataset.loc[ dataset['Fare'] > 31, 'Fare'] = 3
    dataset['Fare'] = dataset['Fare'].astype(int)

#元の乗船料金データのカラムは削除します。
train_df = train_df.drop(['FareBand'], axis=1)
combine = [train_df, test_df]
    
train_df.head(10)

>output
f:id:doreikaiho:20181224144321p:plain:w420

これでデータのクリーニングは終わりです。
このクリーニング作業でモデルの精度かなり変わってくるので、時間をかけていいところです。
では、ここまで修正したデータを使ってモデリングを行います。

モデル作成

今回は教師あり学習(trainデータの生死データが教師)の分類問題(生きたか死んだかの判定)となります。モデル作成手法はたくさんありますが、教師あり学習分類問題ということで以下の手法を試して見たいと思います。(今回は前述の通り一個一個の細かい解説はしていません。)
・ロジスティック回帰
サポートベクターマシン
・ナイーブベイズ
・決定木
・ランダムフォーレスト
パーセプトロン
一個一個のアルゴリズムを作っていくのは大変ですが、pythonではscikit-learnという最強の機械学習パケージがありこれを使えば数行でモデリングができてしまいます。

では、モデリングしていきましょう。
まずは、目的変数(生存)と説明変数(その他変数)に分けていきます。

X_train = train_df.drop("Survived", axis=1)
Y_train = train_df["Survived"]
X_test  = test_df.drop("PassengerId", axis=1).copy()

ロジスティック回帰から使っていきます。fitによってモデルを作りpredictでモデルを使った予測をします。scoreによってその精度(何%合ってるか)を出力します。

logreg = LogisticRegression()
logreg.fit(X_train, Y_train)
Y_pred = logreg.predict(X_test)
acc_log = round(logreg.score(X_train, Y_train) * 100, 2)
acc_log

>output
78.56
精度は8割弱のようです。

続いてサポートベクターマシンです。
コードは同様に4行で終わります。

svc = SVC()
svc.fit(X_train, Y_train)
Y_pred = svc.predict(X_test)
acc_svc = round(svc.score(X_train, Y_train) * 100, 2)
acc_svc

>output
83.5

続いてナイーブベイズです。

gaussian = GaussianNB()
gaussian.fit(X_train, Y_train)
Y_pred = gaussian.predict(X_test)
acc_gaussian = round(gaussian.score(X_train, Y_train) * 100, 2)
acc_gaussian

>output
76.99

続いてパーセプトロンです。

perceptron = Perceptron()
perceptron.fit(X_train, Y_train)
Y_pred = perceptron.predict(X_test)
acc_perceptron = round(perceptron.score(X_train, Y_train) * 100, 2)
acc_perceptron

>output
78.34

続いて決定木です。

decision_tree = DecisionTreeClassifier()
decision_tree.fit(X_train, Y_train)
Y_pred = decision_tree.predict(X_test)
acc_decision_tree = round(decision_tree.score(X_train, Y_train) * 100, 2)
acc_decision_tree

>output
86.76

続いてランダムフォーレストです。

random_forest = RandomForestClassifier(n_estimators=100)
random_forest.fit(X_train, Y_train)
Y_pred = random_forest.predict(X_test)
random_forest.score(X_train, Y_train)
acc_random_forest = round(random_forest.score(X_train, Y_train) * 100, 2)
acc_random_forest

>output
86.76

以上でモデル作成は完了です。最後に作成したモデルを使って予測します。

予測

上で作成したモデルでもっとも精度がよかったものを使います。
モデルの比較をまとめてみます。

models = pd.DataFrame({
    'Model': ['Support Vector Machines', 'Logistic Regression', 
              'Random Forest', 'Naive Bayes', 'Perceptron', 
              'Decision Tree'],
    'Score': [acc_svc,  acc_log, 
              acc_random_forest, acc_gaussian, acc_perceptron, 
              acc_decision_tree]})
models.sort_values(by='Score', ascending=False)

>output
f:id:doreikaiho:20181224155500p:plain:w240

決定木の精度が一番良さそうなので、決定木のモデルを使用します。

Y_pre = decision_tree.predict(X_test)
final = pd.DataFrame({
        "PassengerId": test_df["PassengerId"],
        "Survived": Y_pre
    })

これをCSVファイルにしてKaggleに提出すれば完了です。

final.to_csv('./final.csv', index=False)


いかがでしたでしょうか。以上でモデリング完了です。
本来はtrainデータを7:3くらいに分けて、学習と検証を行ったりモデルの説明変数の寄与度を調べて、説明変数から外したりすることで予測精度を高めたりしますが、今回はデータ取得から大雑把にモデル作成までの流れを整理してみました。

ちなみに上記の結果はタイタニック問題に取り組む約10000人中の中で真ん中くらいの順位です。
世界には100%予測を当てている強者も何人かいます。。。。

参考文献
本文はKaggleのKernelを参考にしています。