Kaggleの氷山コンペに参加してみた

Chainer Python ディープラーニング 画像認識

今更ですが、明けましておめでとうございます。

表題の通り、Kaggleの氷山コンペにソロで参加していましたので、簡単にですが、その時の手法とか結果とか書いてみます。

コンペ自体は、1月末の時点で終了しています。

- https://www.kaggle.com/c/statoil-iceberg-classifier-challenge

タスク

簡単にいえば、人工衛星から撮られた海の写真に、船か氷山かが写っており、それがどちらなのかを分類するタスクです。(多分w)

ただし、格納されている衛星写真のデータは普通の画素値ではなく、信号値のようなもので入っています。

衛星画像のwikiに書いてあるバンド?というものが入っているようです。

学習用データセットには、これに、氷山であるかそうでないかの値が0-1で振り分けられています。

画像分類ではありますが、信号値というデータであるため、グレースケールの1チャンネルとか、RGBの3チャンネルとかでなく、2チャンネル?分しか値が入っていません。

また、値も0-255値ではないため、一般的な画像分類とは少し異なる印象でした。

予測には、氷山かそうでないかの1-0を振るのではなく、氷山である確率を算出して提出する形になります。

やってみた手法

コードは以下。

GitHub: https://github.com/Gin04gh/datascience/tree/master/kaggle_compe_statoil_c-core_lceberg_classifier_challenge

普通に、元が画像とのことなので、まずは簡単に畳込みニューラルネットワークで解いてみました。

軽く試してみて、見込みがなければ早々に次にいこうと思っていたのですが、普通に学習していそうでしたので、この路線で頑張ってみることに。

ただ、衛星画像などの知識がないため、画素値ではなく信号値であるなど、普段と異なる画像データであるところに苦戦しました。

ひとまず、0-255ではない辺りは、0-1にスケールすることで、対応します。(これは普通の画素値でも同じですが)

チャンネルが2チャンネルしかないので、畳み込みの入力チャンネル数を2にしてみて、以下のような適当なモデルを組んでみました。

import chainer
import chainer.functions as F
import chainer.links as L

class Model(chainer.Chain):
    def __init__(self):
        super(Model, self).__init__(
            conv1 = L.Convolution2D(2, 32, 3, 1, 1),
            conv2 = L.Convolution2D(32, 64, 3, 1, 1),
            fc1 = L.Linear(None, 1024),
            fc2 = L.Linear(1024, 2),
            bn1 = L.BatchNormalization(32),
            bn2 = L.BatchNormalization(64),
            bn3 = L.BatchNormalization(1024)
        )

    def __call__(self, x):
        h = F.dropout(self.bn1(F.relu(self.conv1(x))), ratio=0.1)
        h = F.dropout(self.bn2(F.relu(self.conv2(h))), ratio=0.1)
        h = F.dropout(self.bn3(F.relu(self.fc1(h))), ratio=0.5)
        y = self.fc2(h)
        return y

後々から知ったのですが、BatchNormalizationとDropoutは併用すると効果が落ちるという研究結果があるようで、そこは失敗でした。

今回はこんな感じのモデルで、ある程度学習するようでしたが、次のように、2チャンネルの値を組み合わせて、3チャンネル目のデータを作成して、3チャンネルで解くのも効果がありました。

b1 = np.array([np.array(b).astype(np.float32).reshape(75, 75) for b in train['band_1']])
b2 = np.array([np.array(b).astype(np.float32).reshape(75, 75) for b in train['band_2']])
b3 = b1+b2
#b3 = (b1+b2)/2
b1_n = (b1-b1.min())/(b1.max()-b1.min())
b2_n = (b2-b2.min())/(b2.max()-b2.min())
b3_n = (b3-b3.min())/(b3.max()-b3.min())
#b1_n = (b1-b1.min())/(b1.max()-b1.min())
#b2_n = (b2-b2.min())/(b2.max()-b2.min())
#b3_n = (b3-b3.min())/(b3.max()-b3.min())
train_x = np.concatenate((b1_n[:,:,:,np.newaxis], b2_n[:,:,:,np.newaxis], b3_n[:,:,:,np.newaxis]), axis=-1).astype(np.float32)
train_y = train['is_iceberg'].as_matrix().astype(np.int32)

ここを、ただのスケーリングにするのか、標準化にするのかなど、色々試しています。

ちなみにチャンネルごとの値の分布は、割と綺麗に釣鐘型の分布をしていたため、対数をとるなどの処理は特に行いませんでした。

今回は、学習用データの件数が1800件程度、テスト用データ件数が8000件程度と、差がありました。(逆なら良かったのに...)

少ない学習データセットから特徴を捉えられるように、augmentationを加えます。

import chainer

def augmentate(x, hf_prob=0.5, vf_prob=0.5):
    # horizontal flip
    if np.random.random() < hf_prob:
        x = x[:, ::-1, :]
    # vertical flip
    if np.random.random() < vf_prob:
        x = x[::-1, :, :]
    return x

class TrainDataset(chainer.dataset.DatasetMixin):
    def __init__(self, data):
        self.data = data

    def __len__(self):
        return len(self.data)

    def get_example(self, i):
        x, y = self.data[i][0], self.data[i][1]
        x = augmentate(x)
        x = x.transpose(2, 0, 1)
        return x, y

class ValidDataset(chainer.dataset.DatasetMixin):
    def __init__(self, data):
        self.data = data

    def __len__(self):
        return len(self.data)

    def get_example(self, i):
        x, y = self.data[i][0], self.data[i][1]
        x = x.transpose(2, 0, 1)
        return x, y

ただし、ここが普段と異なる衛星画像という点であまり色々出来ませんでした。

回転や拡大、ガウシアンノイズなどは、衛星画像に対してそもそも与えて大丈夫なものなのかが判断できませんでした。

クロップも、元画像をプロットしてみると、船か氷山からしきものが、画像の端っこに写っているものもたくさん見られたため、むやみに処理することができませんでした。(0-1にしてから plt.imshow() でなんとなく形がプロットできます)

実はいくつか施してみて学習精度も確認してみたのですが、回転などを加えるとあまり学習してくれなくなってしまったため、結局、上記の上下、左右の反転のみのaugmentationになってしまっています。

これに対して、モデルも交差検証で学習させて、アンサンブル予測するようにしました。

from sklearn.model_selection import StratifiedKFold

skf = StratifiedKFold(n_splits=10)
skf.get_n_splits(train_x, train_y)

model_id = 0
for train_index, test_index in skf.split(train_x, train_y):
    train(model_id=model_id, train_x=train_x[train_index], train_y=train_y[train_index], valid_x=train_x[test_index], valid_y=train_y[test_index])
    model_id += 1
import chainer
from chainer.training import extensions
import chainer.functions as F
import chainer.links as L
from sklearn.metrics import confusion_matrix
from sklearn.metrics import accuracy_score

def train(model_id, train_x, train_y, valid_x, valid_y):
    print('model_id: {}'.format(model_id))

    model = L.Classifier(Model())

    optimizer = chainer.optimizers.Adam(alpha=1e-6)
    optimizer.setup(model)
    optimizer.add_hook(chainer.optimizer.WeightDecay(1e-6))

    model.to_gpu(gpu)

    dataset_train = TrainDataset([(x, y) for x, y in zip(train_x, train_y)])
    dataset_valid = ValidDataset([(x, y) for x, y in zip(valid_x, valid_y)])
    train_iter = chainer.iterators.SerialIterator(dataset_train, batch_size)
    test_iter = chainer.iterators.SerialIterator(dataset_valid, batch_size, repeat=False, shuffle=False)

    updater = chainer.training.StandardUpdater(train_iter, optimizer, device=gpu)
    trainer = chainer.training.Trainer(updater, (epoch_num, 'epoch'), out='result')
    trainer.extend(extensions.Evaluator(test_iter, model, device=gpu))
    trainer.extend(extensions.LogReport())
    trainer.extend(extensions.PrintReport(['epoch', 'main/loss', 'validation/main/loss', 'main/accuracy', 'validation/main/accuracy', 'elapsed_time']))
    trainer.extend(extensions.PlotReport(['main/loss', 'validation/main/loss'], 'epoch', file_name='loss{}.png'.format(model_id)))
    trainer.extend(extensions.PlotReport(['main/accuracy', 'validation/main/accuracy'], 'epoch', file_name='accuracy{}.png'.format(model_id)))
    trainer.extend(extensions.snapshot_object(model, filename='model{}'.format(model_id)), trigger=(epoch_num, 'epoch'))
    trainer.run()

    y_preds, y_trues = [], []
    for i in range(len(dataset_valid)):
        x, y_true = dataset_valid.get_example(i)
        x = x[np.newaxis, :]
        x = chainer.cuda.to_gpu(x)

        with chainer.using_config('train', False):
            y_pred = model.predictor(x)

        y_pred = F.argmax(F.softmax(y_pred, axis=1), axis=1)
        y_pred = chainer.cuda.to_cpu(y_pred.data.squeeze())
        y_preds.append(y_pred)
        y_trues.append(y_true)

    y_preds = np.array(y_preds, dtype=np.int32)
    y_trues = np.array(y_trues, dtype=np.int32)
    accuracy = accuracy_score(y_trues, y_preds)

    cm = confusion_matrix(y_trues, y_preds)

    print(accuracy, cm)

ちなみにImageNetなどの学習済みモデルも試してみましたが、画像形式で読み込み直すところや、VGG16などのモデルだと、RGBの平均値を引くなどが悪さをしているのか、全く学習してくれませんでした。

ここも普段と異なる画像という点でうまくいかなかった点です。

以上のような、簡単なモデルで、あとはレイヤー数や次元数を色々調整するなどをして、その後、別の方のカーネルで優秀な結果を収めているデータと再びアンサンブルで予測することで、結果を提出しました。

結果

結果としては、上位13%程度のスコアで終了しました。

上位10%以上に入れば、銅メダル獲得だったのですが、一歩届きませんでした...。

やはり学習データよりも圧倒的にテストデータが多いため、学習データでは含まれていないようなパターンを学習できていないような印象を受けました。

ちなみに、氷山である確率の出力なので、0.5(どっちとも言えない)だったり、アンサンブルした際に分散している(自信がない)などの画像を中心的にプロットして確認もしてみました。

どういった画像を間違えていそうなのかとその対策のヒントになるかと思ったのですが、正直自分が見てもどっちなのか全く分からず笑

信号値で入っている関係なのか、画像自体が残像っぽいものもあり、そういったものはよく間違えている印象でした。

今回は割と真面目に取り組んだコンペでしたので、メダルまで届かずに終わってしまって、少し残念でした。

だけど、最近はKaggleのコンペも活発になってきて、楽しくなってきましたので、今後も色んなコンペに取り組んでいきたいです。

コメント