Titanicから始めよう:ベースラインの作成とユーティリティースクリプトの記述:僕たちのKaggle挑戦記
Kaggleの世界に足を踏み出すべく、Titanicコンペティションに参加してみました。今回はベースラインモデルの作成とユーティリティースクリプトの記述を中心に見てみます。
ご挨拶
こんにちは。Deep Insider編集部のかわさきです。Deep Insiderでは主としてPythonやVisual Studio Codeの記事を手がけています。たまには機械学習の記事も書いていますけど。そんなことから、もう1人の編集者である一色さんとは違って、もっとレベルの低いところから始める「機械学習初心者」といってもよいでしょう。そんな人間がKaggleで悪戦苦闘する様をお楽しみいただければ幸いです。
本連載は「Kaggle初心者のためのコンペガイド ― Titanicの先へ」という記事で始まっていますが、初心者らしくボクはTitanicから始めてみることにしました。
スタンスとしては「取りあえずDNN(Deep Neural Network)に突っ込んでみる」ところから始めてみようと思っています。また、たがいのスキルや知識、それから好みの差から、一色さんの記事とは違った感じの記事になることでしょう。
ディープラーニングを中心にKaggleに挑戦したり機械学習に実践したりすることに興味がある人に楽しめる記事になりそうですね。(一色)
TitanicとはKaggleにおけるHello Worldともいえる「Titanic - Machine Learning from Disaster」のこと。訓練データとして用意されている乗客のさまざまな属性(名前、性別、年齢、チケット種別、etc)と生死の情報から、他の乗客の生死を推測してみるというものです。このコンペティションに参加しながら、Kaggleや機械学習で必要なことを体感してみるのが今回から続く数回の主なテーマとなるはずです。が、コードを見直していて、既に違う方向で話したいことが出てきました(笑)。
今回やったこと
まずは今回の記事を書くまで、どんなことをしてきたかをざっくりと表にまとめました。書いてみるとたいしたことはしていないような気がします。
バージョン | 説明 | パブリックスコア | 大ざっぱな順位 |
---|---|---|---|
0 | ベースライン | 0.73444 | 13200位 |
2 | k-fold交差検証を導入 | 0.74162 | 13000位 |
3 | エポック数を500から1000に | 0.72727 | 低下(一番ひどい……) |
4 | モデルを3種類に | 0.76794 | 9900位 |
今回やってきたこと |
ベースラインのモデルは3層の全結合型DNNです。これに訓練データを突っ込んで学習をさせているだけです。それだけですから、順位も低いに決まっています。
上の表を見ると、バージョン0があってバージョン1がない辺りから筆者がKaggle素人であることがよく分かります(なんでそうなっているのか、全く記憶にございません)。
バージョン1はないのか……あはは……。それはそうと、初心者のうちは、すごい人のノートブックを参考に最初から高いスコアと順位で始めるよりも、そのすごいノートブックに到達するまでの過程を自分で体験できるように、今回のように真っさらなところから始めてみるのもよいと思います。その方がスコアを上げやすくてモチベーションを保ちやすいと思うので。
次のバージョンでは、k-fold交差検証を行うために、指定した数のフォールドにPyTorchのデータセットを分割する関数(とそのヘルパー関数)を定義したり、PyTorchのネットワークモジュール(nn.Moduleクラス)を継承するDNNクラスを定義したり、学習と検証、推測を行うための関数を定義したりして、それらをKaggleの「ユーティリティースクリプト」として登録しました。スコアは少しだけ上昇しましたが、それよりも頻繁に使うであろうクラスや関数をPythonのスクリプトとして括り出したことで、ノートブックのコードがシンプルになったことが良かった点だと思っています。
バージョン3では、スコアが少し上がったことに気をよくして「もっと学習させればスコアがもっと上向くんじゃ?」とエポック数を増やしてみたのが逆に悪影響したのか、スコアが低下してしまいました。反省。
過学習ですかね。
過学習かなーと思いました。
バージョン2では(なぜそうしたのか筆者も覚えがないのですが)なぜか4層の全結合型のDNNを定義していました。そこでバージョン4は、これに加えて、中間層を持たない(線形回帰を行う)モデルと、3層の全結合型のDNNを作成して、それら3つのモデルを使ってk-fold交差検証を行うようにしています。この結果、スコアはそれなりに上昇して下から3分の1程度のラインまで到達しました。先はまだまだ長いでしょうが、以下ではここに至るまでの道のりを簡単にまとめます。
といっても詳しいコードの説明までしていると、いつまで経ってもこの原稿が終わりません。ノートブックを公開したので詳しくはそちらを参照してください。
なお、自分がこれまでにしてきたサブミッション(推測結果の提出)はミッションのページにある[My Submissions]をクリックすると参照できます。
バージョン0
既に述べた通り、「取りあえずDNNに突っ込んでみる」のがスタンスですから、ここでは全結合型のDNNをPyTorchで作成して、そこにデータを入力してみることにしました(出力は0〜1の範囲の値を1つとして、その値が0.5以上なら生存と、0.5未満なら亡くなったものと判断することにしました)。なお、以下でご覧いただくコードは上記ノートブックから関連する部分をまとめたもので、個別のセルの内容とは異なっています。
class Net(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super().__init__()
self.fc1 = nn.Linear(input_size, hidden_size)
self.fc2 = nn.Linear(hidden_size, output_size)
def forward(self, x):
y = self.fc1(x)
y = torch.sigmoid(y)
y = self.fc2(y)
y = torch.sigmoid(y)
return y
INPUT_SIZE = 6
HIDDEN_SIZE = 32
OUTPUT_SIZE = 1
net = Net(INPUT_SIZE, HIDDEN_SIZE, OUTPUT_SIZE)
また訓練データおよびテストデータの前処理はpandasを使い、不要と思われる列のドロップ、性別の数値化、欠損値の補填(チケット料金を表すFare列、年齢を表すAge列で欠損値をその列の平均値で埋めることにしました)などだけを行いました。
dataframe = pd.read_csv('../input/titanic/train.csv')
print(sum(dataframe['Survived']) / len(dataframe))
drop_rows = ['PassengerId', 'Name', 'Ticket', 'Cabin', 'Embarked']
dataframe = dataframe.drop(drop_rows, axis=1)
average_age = dataframe['Age'].mean()
print('average ages:', average_age)
dataframe['Age'] = dataframe['Age'].fillna(average_age)
dataframe = dataframe.replace('male', 0).replace('female', 1)
これらの処理を行った上で、PyTorchのDatasetクラスの派生クラスを定義して、データフレームを渡し、データセットオブジェクトを作成しました。
class TitanicDataset(IterableDataset):
def __init__(self, df):
super().__init__()
self.X = df.drop(['Survived'], axis=1)
self.y = df['Survived']
def __len__(self):
return len(self.X)
def __getitem__(self, idx):
X = self.X.iloc[idx, :].values
y = self.y.iloc[idx]
return X, y
dataset = TitanicDataset(dataframe)
train_dataset, val_dataset = torch.utils.data.random_split(dataset, [640, 251])
trainloader = DataLoader(train_dataset, batch_size=32, shuffle=True)
valloader = DataLoader(val_dataset, batch_size=32, drop_last=True)
覚えておいてほしいのは、torch.utils.data.random_split関数です。これは与えられたデータセットを、第2引数に指定した反復可能オブジェクトの個数(上のコードなら2つ)に分割するものです。そして、1つは640個の要素で、もう1つは251個の要素で構成されるようになります。
実際に学習を行うコードは以下のようなものです。PyTorchの典型的なコードなのであまり説明する箇所はありません。
criterion = nn.BCELoss()
optimizer = optim.Adam(net.parameters(), lr=0.01)
EPOCHS = 500
losses = []
for epoch in range(EPOCHS + 1):
for X, y in trainloader:
optimizer.zero_grad()
pred = net(X.float())
loss = criterion(pred.reshape(-1), y.float())
loss.backward()
optimizer.step()
losses.append(loss.detach().numpy())
if epoch % 50 == 0:
print(f'epoch: {epoch}, loss: {loss}')
この他にも検証を行うコードや推測を行うコードももちろん記述しています。テストデータを使って推測を行った結果(パブリックスコア)は、既に述べたように0.73444とあまりよくはないものでした。が、まずはここをスタート地点としてコードを洗練させていく準備ができたといえます。
バージョン2
バージョン0(バージョン1)ができたところで、本来は性別をone-hot表現にするとか、家族の有無を表す新たな特徴量を導入するとかに進むのが普通です(Webをいろいろと検索した限りは)。ですが、筆者としてはそれよりも「学習や検証を行うコードを関数化して再利用ができるようにした方が、コードが見やすくなるんじゃない?」とか、「k-fold交差検証をしてみたい」とか、そっちの欲求の方が高まってきたので、コードの整理をすることにしました。
一色さんとの(密ではないオンライン)ミーティングで「ユーティリティースクリプトって知ってる? 結構便利だよ」と教えてもらったのがきっかけです。ありがとうございました。
ユーティリティースクリプトは便利だよね。こういったTips的な内容はあまり情報がないから、何か便利機能を発見したら、この連載でも紹介していければうれしいです。
ユーティリティースクリプトは、KaggleのWeb UIにある[File]メニューから[New Notebook]を選択して、ノートブックを作成した後に、その[File]メニューから[Set as Utility Script]を選択し、さらに[File]メニューから[Editor Type]−[Script]を選択することで編集できます。
このようにして作成したユーティリティースクリプトには、先ほど見たようなTitanicDatasetクラスやDNNを表すクラス、各種の関数などを記述します。今回はデータセットを分割するためのコードをkfoldds.pyファイルに、学習や検証などで使用するクラスや関数をtitanic_nn.pyファイルに記述しました。
例として、短めなkfoldds.pyファイルを以下に示します(titanic_nn.pyファイルについてはこちらを参照してください)。
import random
from random import sample
from torch.utils.data import IterableDataset, Subset
def make_kfold_range(length, k):
random.seed(2)
quotient = length // k
remainder = length % k
trains = [] # kfolded train data. [[data0], [data1], ...]
vals = [] # kfolded val data. [[data0], [data1], ...]
indices = sample(range(length), length) # shuffle indices
if k <= 1:
return [(indices, [])]
for num in range(k):
start, end = 0, quotient
tmp = []
for cnt in range(k):
if num == cnt:
end += remainder
vals.append(indices[start:end])
else:
tmp.extend(range(start, end))
start, end = end, end + quotient
r: list[int] = [indices[idx] for idx in tmp]
trains.append(r)
return list(zip(trains, vals))
def get_kfold_datasets(dataset, k):
datasets = []
if not hasattr(dataset, '__len__'):
raise TypeError(f'{dataset} does not have a __len__ attr')
else:
ds_length = len(dataset) # ok since dataset has a __len__ attr
for trainidx, validx in make_kfold_range(ds_length, k):
datasets.append((Subset(dataset, trainidx), Subset(dataset, validx)))
return datasets
このファイルでは、2つの関数を定義しています。make_kfold_range関数はデータ数と分割数を与えると、range(データ数)をランダムに並べ替えて、それらを指定した数に分割した結果を返してくれます。この結果はデータセットに対するインデックスとして使用することを念頭に置いたものです。
そして、get_kfold_datasets関数はmake_kfold_range関数を使って、データセットを指定した数に分割するものです。
Webを「pytorch kfold」などとして検索すると、scikit-learnと組み合わせる例がたくさん見つかります。でも、PyTorchを使うんならPyTorchだけでやりたいじゃん? というのがこんなコードを書き始めた理由です。それ以上の意味はありません。そして、詳しくは後述しますが、実はこんなコードを書かなくても恐らくは……(泣)。
データ分割の部分にだけ、scikit-learnのたった1つの関数を呼び出す気持ち悪さは分かりますね……。まぁしかし、永続的にコードをメンテナンスするアプリ開発ならまだしも、Kaggleのように何度も作り直すのが前提のコードであれば、すっきり感よりも雑然としていても効率重視で、部分的に使えるライブラリ機能を所々で五月雨的に使って済ませてしまってもいいのかなと思います。そこを割り切れるかどうかは、実装者の性格によるところがあるかもしれません。
ユーティリティースクリプトをノートブックで使用するには、[File]メニューから[Add utility script]を選択します。
これによりダイアログが表示されるので、作成したユーティリティースクリプトを追加します。このとき、気を付けたいのは元のファイル名の末尾に「_py」というサフィックスが追加されることです(上の画像の右側を参照してください)。インポートを行うときには、そちらをモジュール名として使用する必要があります。
というわけで、バージョン0のコード(をさらに改修したもの)を外部ファイルにまとめることで、ノートブックのコードはかなり短いものになりました。コード全体は公開しているものを参照していただくものとして、バージョン0のノートブックとバージョン2のノートブックを以下に示すので、マウスカーソルがある付近のスクロールバーの大きさを比較して、後者のノートブックをずいぶんと短くできたことだけ確認してください(もちろん、その背後には上記の2つのユーティリティースクリプトがあるわけですが)。
先ほども述べましたが、バージョン2では以下のような4層の全結合型のDNNを使用しています。
class DNN(nn.Module):
def __init__(self, input_size, hidden_size1, hidden_size2, output_size):
super().__init__()
self.fc1 = nn.Linear(input_size, hidden_size1)
self.fc2 = nn.Linear(hidden_size1, hidden_size2)
self.fc3 = nn.Linear(hidden_size2, output_size)
def forward(self, x):
y = torch.sigmoid(self.fc1(x))
y = torch.sigmoid(self.fc2(y))
y = torch.sigmoid(self.fc3(y))
return y
そして、先ほど見たget_kfold_datasets関数を用いて分割された訓練データと検証データを使って、以下のコードで学習を行っています。ここではDNNクラスのインスタンスを3つ作成して変数modelsに代入していることを覚えておいてください。
datasets = get_kfold_datasets(dataset, 3) # データセットを3分割
# 訓練データと検証データのローダーの組を要素とするリスト
loaders = [(DataLoader(t, batch_size=32, shuffle=True),
DataLoader(v, batch_size=32)) for t, v in datasets]
INPUT_SIZE = 6
HIDDEN_SIZE1 = 32
HIDDEN_SIZE2 = 16
OUTPUT_SIZE = 1
models = [DNN(INPUT_SIZE, HIDDEN_SIZE1, HIDDEN_SIZE2, OUTPUT_SIZE) for _ in range(3)]
train_loss = {}
val_results = {}
for cnt, (x, t) in enumerate(datasets): # 以下を参照
t_loader, v_loader = loaders[cnt]
model = models[cnt]
print(f'train #{cnt} start.')
train_loss[cnt] = train(model, t_loader)
print(f'validate #{cnt} start.')
val_results[cnt] = validate(model, v_loader)
よく見ると、学習を行うループではループ変数xとtを使っていません。これは以下のようなコードにすべきでした(将来にバージョン5となるノートブックで動作を確認)。
train_loss = {}
val_results = {}
for cnt, (model, (t_loader, v_loader)) in enumerate(zip(models, loaders)):
print(f'train #{cnt} start.')
train_loss[cnt] = train(model, t_loader, epochs=500)
print(f'validate #{cnt} start.')
val_results[cnt] = validate(model, v_loader)
Kaggleでバージョン管理をしていなければ原稿を書く段階でのコード修正も自由に行えるのですが、ここでは古いバージョンとしてきっちり保存されているので、自分のミスが露わになるのはちょっと恥ずかしいですね。ローカルにコードを書いた方がいいのかなぁ。
上のコードで学習したモデルを使って推測を行うには以下のコードを使います。
with torch.no_grad():
results = [predict(model, testdataframe) for model in models]
for item in results:
item[item >= 0.5] = 1
item[item < 0.5] = 0
result = results[0] + results[1] + results[2]
result[result < 2] = 0
result[result > 1] = 1
result = result.squeeze().int().tolist()
ここでpredict関数は単純に学習済みのモデルにテストデータ(testdataframe)を与えて、その結果を得るだけの関数です。変数resultsには3つのモデルを使って推測した結果が格納されています。そして、3つの配列の各要素について値が0.5以上なら1に、0.5未満なら0にした後、それらの配列の要素を足し合わせます。これは多数決と同じ効果を持ちます。
つまり、加算の結果、同じインデックス位置にある値が1となっている配列が2つ以上あれば、変数resultのそのインデックス位置の値は2以上になります。逆に同じインデックス位置にある値が1となっている配列が1つ以下しかなければ、変数resultのそのインデックス位置の値は1以下になります。このことを利用して、変数resultの値を操作して、多数決を取り、最終的な結果を得るようにしているということです。
パブリックスコアについては既に述べたように少々の向上が見られました。なお、バージョン3については、特筆することがないので説明はありません。
バージョン4
バージョン2では、DNNクラスのインスタンスを3つ作成して、それぞれを異なる訓練データと検証データで学習させていました。が、バージョン4(に対応したtitanic_nn.pyファイル)では以下のように3つのクラスを定義しました(バージョン2のDNNクラスは、バージョン4ではDNN2クラスとなっているので、後方互換性を考えると「DNN = DNN2」行があった方が適切でしょう)。
class LinearRegression(nn.Module):
def __init__(self, input_size, output_size):
super().__init__()
self.fc1 = nn.Linear(input_size, output_size)
def forward(self, x):
y = torch.sigmoid(self.fc1(x))
return y
class DNN1(nn.Module):
def __init__(self, input_size, hidden_size1, output_size):
super().__init__()
self.fc1 = nn.Linear(input_size, hidden_size1)
self.fc2 = nn.Linear(hidden_size1, output_size)
def forward(self, x):
y = torch.sigmoid(self.fc1(x))
y = torch.sigmoid(self.fc2(y))
return y
class DNN2(nn.Module):
def __init__(self, input_size, hidden_size1, hidden_size2, output_size):
super().__init__()
self.fc1 = nn.Linear(input_size, hidden_size1)
self.fc2 = nn.Linear(hidden_size1, hidden_size2)
self.fc3 = nn.Linear(hidden_size2, output_size)
def forward(self, x):
y = torch.sigmoid(self.fc1(x))
y = torch.sigmoid(self.fc2(y))
y = torch.sigmoid(self.fc3(y))
return y
これらを使うには、上で見た変数modelsへモデルを代入するコードを以下のようにするだけです(基本的には)。
INPUT_SIZE = 6
HIDDEN_SIZE1 = 32
HIDDEN_SIZE2 = 16
OUTPUT_SIZE = 1
m1 = LinearRegression(INPUT_SIZE, OUTPUT_SIZE)
m2 = DNN1(INPUT_SIZE, HIDDEN_SIZE1, OUTPUT_SIZE)
m3 = DNN2(INPUT_SIZE, HIDDEN_SIZE1, HIDDEN_SIZE2, OUTPUT_SIZE)
models = [m1, m2, m3]
後は基本的にバージョン2のコードと同様に学習、検証、推測を行うだけです。これにより、5桁の順位から4桁の順位へとジャンプアップしました。
だが、しかし
ところで、バージョン0では以下のようにしてデータセットをランダムに分割していました。
train_dataset, val_dataset = torch.utils.data.random_split(dataset, [640, 251])
ここで第2引数の反復可能オブジェクトに3つ以上の値を渡せるのではないでしょうか(というか、できます)。ということは、kfoldds.pyファイルで定義したmake_kfold_range関数を使わなくても、random_split関数を使うだけでデータセットを3分割、4分割……とできそうです。
えええっ!
というわけで、次回はこれがホントにできるかを試したり、その他の細々としたことをやってみたりすることにしました。
今回のポイントは「k-fold交差検証はやるといいかも」「ユーティリティースクリプトを活用するといろいろ捗るかもしれない」ってことですね。後は「コードを書くのはやっぱり楽しいよね」かな。ただ、コードを修正しようとすると、やはりちゃんとしたエディタがほしくなります。そういうわけで、Kaggleのコードを編集/実行するローカル環境の整備にも将来的には手を出したいところです。最初はオーソドックスな方向に進もうかと思っていたのですが、そこはまあいきあたりバッタリでやってみましょう。
Copyright© Digital Advantage Corp. All Rights Reserved.