俵言

しがない社会人が書く、勉強とかのこと。最近は機械学習や kaggle 関連がメイン。

初めての画像分類コンペでめっちゃ頑張って上位まで行ったが、閾値を攻め過ぎて大爆死した

9/11 - 10/26 (おおよそ1ヶ月半) にかけて、以下の 「熱帯低気圧(台風等)検出アルゴリズム作成」コンペ に参加しました。

signate.jp

得るものは本当に沢山(DNNの実装や学習の経験・ノウハウなど)あったのですが、結果として最終提出の順位は 5位(public) => 207位(private) に転落しました。

覚悟の上での行動ではありましたがやっぱり1月半の努力が全部無に帰ったのはとてもつらくて(しかも一人で全力で完走した初めての分析コンペだった)、少しでも自分がやったことを形として残そうと思ってめちゃくちゃ久しぶりにブログを書いています。

きちんと書こうとするといつまで経っても公開できない気がするので(過去何度も繰り返したケース)、大雑把な内容にはなりますがご容赦ください。

はじめに:参加動機

前々から kaggle などの分析コンペにちゃんと取り組みたいと思っていたのですが、家の計算資源を言い訳にデータが軽いものをちょろっとやるぐらいしかしたことがありませんでした。(引越し需要予測 はまさにこのパターン)

この言い訳を潰すべく一念発起して8月ごろに大枚はたいて自宅にGPUマシンを導入したものの、ちょっと別件で忙しかったのでしばらく持て余していました。(これも言い訳に過ぎない。)


そんな中、丁度シンプルかつデータもたくさんある画像分類コンペとして台風コンペが公開されたので、DNNの実装と学習の練習と自宅のGPUマシンの試運転を兼ねて参加を決定、一月半走ることになります。

画像については過去に学習済みVGG16をいじったことはあったのですが(※会社での話) そのときは純粋な画像分類ではなくまたData Augmentation などの画像ならではのテクニックも使えていなかったので、今回で習得しようと思ったのも動機だったりします。

実装したモデル: 改変版 WideResNet

ResNet が優秀だという話は前から聞いていたので、Web に公開されているCNN 関連のスライドやらGitHubを参考にしてシンプルかつ結構強力らしい WideResNet を実装しました。

qiita.com

他にも PyramidNet とか ResNeXt とか DenseNet とか見かけたんですが、なんかちょっと複雑そうだったのでとりあえず入門編ということで実装した感じ。

オリジナル (Wide Residual Networks: Zagoruyko et. al. BMCV2016) からの変更点は以下。

  • Residual Block Group の初めに 「BN => ReLU => Conv」 を挟むことで skip connection における zero padding もしくは projection (1×1 Conv ) を回避

    • こちらはメテオサーチチャレンジの優勝者の方の手法を大変参考にさせて頂きました(マンガもとても面白い)。WideResNetを採用したのもこのスライドの影響がでかい。

      speakerdeck.com

    • 実は projection も実装してたのですが、僕が今回行っていた実験の中では BN => ReLU => Conv を挟む方が性能良かったのでこれからもお世話になりそうです。

  • Residual Block Group を全て通した後の層を「BN => ReLU => Average Pooling => Linear」 から 「BN => ReLU => Conv => Average Pooling」 に変更.

    • こちらの方の実装を参考にさせて頂きました。他のNetWork も一杯実装しててすごE.

      github.com

    • 何でこれにしたのかと聞かれると困るところですが、Linear だと画像サイズに依存した実装になるかなーと思ってこちらにしました。

    • 因みに原論文はこの部分の記述が曖昧で(まあその分野の人ならわかるのかも) 結局GitHub見るまで(ResBlockの数など含め)詳細を理解できてなかったです。


てなわけで、以下のような構造になりました。

f:id:Tawara:20181030014733p:plain

  • 基本的には  (N_1, N_2, N_3)-k の4つの整数を弄って良さげな構造を探索

    • 終結果だけ見ると、0にならず生き残った提出の中で良かったものは (7,7,7) - 4 (public: 0.65695, private: 0.61910) と (4,4,4) - 6 (public: 0.65175, private: 0.61944)
    • WideResNet の論文中で良い結果が出てたものは (4,4,4) - 10 (※skip connection とか変更してるので構造は少し違う)なのですが、k = 10 だと学習させるのに時間がかかり過ぎてあまり試せてません

  • 出力の次元は1で、softmax は未使用

    • 理由としては今回のタスクはどちらかというとランキングのタスクだと個人的には捉えていて( precision@(recall=0.79) を見るのは precision@k みたいなものかなって)、Learning to Lank をやりたかったのもあってこうしていました。
    • 結局学習に時間がかかっちゃうとか諸事情(迷子になった)で取り掛かるのが遅すぎて断念しましたが...
    • また、softmax だと正例 score と負例 score から相対的に確率を出すので、Learning to Rank をしないにしてもランキングとしては 1次元の方が良いのかなと考えてました

  • 追加の構造変更の検討

    • Single ReLU
      • ResBlock 内部を 「BN=>ReLU => Conv => BN => ReLU => Conv」から「BN => Conv => BN => ReLU => Conv (=> BN)」にすることで性能が上がるらしいという情報を何か所かで見かけたのでやってみました。(PyramidNetが初出らしい?)
      • しかしあまり効果は出ず。「層を深くするほど効果が出る(要出典)」らしいので、それが原因かもしれないです。
    • SE Module
      • ILSVRC 2017 で優勝したモデルの構造でかつ非常に実装が容易だったので検討
      • validation に対しては微妙に score が上がったのですが public では下がったりしたので過学習してる?と考えて不採用
      • ただ、別の参加者の方が「めっちゃ効果があった」と言っていたので、これも層を深く(以下略)

データの分割(train, validation)に関して

とりあえずどうするか決めるために、id 順に pos の数を集計した histgram (15年分とのことなのでbinの数を15にしてみた)は以下.

f:id:Tawara:20181030012157p:plain

まあ、差はあるもののめっちゃ極端に少ないところはないっぽい。

で、考えたこととしては

  • 最新の2年の予測をするので、出来るだけ新しい方が良いのでは?
  • 仮にid 順が時系列順であれば、出来るだけ末尾の方を取れば良い
  • もしid が時系列でない(つまりシャッフルされている) のであれば、まあそれはそれで末尾からとって良いっしょ

という雑な考えの下で、train data の id が末尾の方から validation set を作りました。 数については、train data 中の正例・負例の比と test data 中の正例・負例の比が等しいという大雑把な仮定 のもとで、

  299135(\verb|test|) \times 71779(\verb|train pos|) / 2244223(\verb|train|) \approx 9567

だけ validation set 用に正例を用意しました。

負例については、本来は validation set が test data と同じ数になるように

 299135 - 9567 = 289568

だけ用意するべきだと思うのですが(最初はそのようにしていた)、これだと学習に時間がかかって仕方なかったので途中から正例と同じ数にしています。

また train については当初は負例をかなり削っていましたが、コンペの後半あたりから増やすほどに汎化性能上がってスコア上がるっぽいことに気づき、最終的には全部使いました。

因みにtrain data 中の正例で1枚だけなぜか全ての値が1.5になっている画像があり、それは除外して学習を行っています。

学習のさせ方

validation に用いる score

recall = 0.79 となる閾値での precision としました。 auc でも良いかなーと思いましたが、一番わかりやすいし評価指標に沿った値なので採用してます。

上で述べたようにvalidationの正例と負例の比を1:1にしているので、Leaderboard の score と比較したい場合は neg の数が30倍になったと仮定したときの score に変換して比較してました。ずれはありますが大まかには合ってたので良しとしてます。

不均衡データの対策

流石に正例:負例 = 1:35 の状態でやろうとは思わなかったので mini batch 内の 正例:負例=1:1 にして学習。loss を弄るのも検討したのですが、結局画像が多いのもあってかこれが一番うまく行っていた感じです。

学習率など
SGD でゆっくり落とすのが最も良いとアドバイスを受けたのですが、時間の都合上 Momentum に頼ることに。画像が沢山あったのと、グレースケールや64×64に強引に合わせるのが面倒だったことから pre-trained model は使ってないです。

設定は以下。WideResNet の論文とか読んで定番っぽいものを選びました。

  • optimizer : SGD + Nesterov の Momentum
  • 学習率:0.1
  • モメンタム: 0.9
  • WeightDecay: 1e-04
  • 学習率のスケジュール:全エポック数の50%, 75%, 90% で0.2倍する
  • 総学習エポック数:200
    • エポックの基準は正例側にしており、1epoch は 975 iteration ぐらい

例として(7,7,7) - 4 の学習曲線は以下のような感じです。なんかもうちょいepoch数増やしても良さそうですが、100, 150, 180, 200, 300 あたりを試して一番良かった200でやってました。

f:id:Tawara:20181030040506p:plain

前処理, Data Augmentation, TTA

前処理

  • いちいち画像を読み込むのがしんどいので一旦読み込んだものを np.array にして .npy ファイルとして保存
    • train neg は量を調整できるように35分割して保存してました
  • 正規化については、 pixel ごとの 平均値を各画像から引いています

Data Augmentation
台風に向きは関係ないと思ったのと後ろに写ってる陸地の影響を除きたかったので、画像の向きを変える系は left-right flip, up-down flip, 90 rotation をそれぞれ1/2の確率で適用。

回転はもっと角度のバリエーションを与える案もありましたが、暗黒領域を埋めるのが少しめんどうだったのでやめました。

これらに加えて、random crop と random erase も行っています。

Test Time Augmentation
上記のうち、random 要素の入らない lr flip, ud flip, 90 rotation で8通りの入力を作って出力値の平均を取りました。 因みにこれに 5 crop も加えて40通り作ったりもしたのですがいまいち効果が感じられず、しかもめっちゃ時間がかかるのでやめました。

アンサンブル

最後の悪あがきとして実行。 最終日ぎりぎりまで学習を行い以下のモデルを準備してました。1080Ti 一枚なので中々ぎりぎりだった..(まあ結局爆死するんですが。)

id WideResNet の構造:  (N_1, N_2, N_3)-k 学習に使用した負例 (対正例)
1 (2, 2, 2) - 4 15
2 (3, 3, 3) - 5 15
3 (4, 4, 4) - 6 15
4 (5, 5, 5) - 7 15
5 (6, 6, 6) - 6 15
6 (7, 7, 7) - 5 15
7 (8, 8, 8) - 4 15
8 (2, 2, 2) - 4 35
9 (3, 3, 3) - 4 35
10 (4, 4, 4) - 4 35
11 (5, 5, 5) - 4 35
12 (6, 6, 6) - 4 35
13 (7, 7, 7) - 4 35
14 (8, 8, 8) - 4 35
15 (9, 9, 9) - 4 35
16 (10,10,10) - 4 35
17 (11,11,11) - 4 35

なぜ使用した負例の数が違うのかというと、残り1週間になって「やっぱり全部使った方が良さそう..」と思って学習し直したため。35の方の k が全て4なのは増やすと学習が間に合わないと思ったためです。

ただ全部のモデルをアンサンブルするのもあまりよくなさそうだったので(構造が近いとほとんど出力が変わらない可能性もありうる)、public score が単体で一番良かった 13番((7, 7, 7) - 4) を基準に出力の相関値が比較的低いモデルを選び、最終提出は 4番、7番、13番、15番のアンサンブルを提出しました。(そして爆死した。)


何故かアンサンブルだと閾値をより攻められる傾向があり(※あくまで public score)、最終提出の validation に対する recall は 0.764 でした(因みに一番攻めた場合は0.762)。

今思えば「そりゃ爆死するでしょ」って話なんですが、validation と test で明らかに傾向の差があったので行けるかなーと思ってしまったのが全ての敗因。 しかも結果としてアンサンブルでの提出のprivate score は全て 0になっており(これは閾値を攻めていたせいもある)、本当にアンサンブルの効果があったのか検証できないのがちょっと痛いです。

クライアントのデータだからずっと使えないのは仕方ないけど、コンペ後しばらくは提出できるようにしといてくれると復習という意味でいいのになあって思います。あと recall も最終的に見せて欲しかったですよね。

おわりに:得られたものとか今後とか

参加して一番良かったこと

シンプルなタスクだったのも理由ではありますが、真っ当に論文などの調査・実装・データの前処理・学習を行えばちゃんとそれなりの順位が出せることがわかったことです。(一時の夢だったとはいえ)今まで単独で4位になったなんてことは無かったので、コンペに取り組む自信につながったと思います。

そりゃkaggle とかになったら人数多いのでもっとも~っと順位落ちるでしょうけど、きちんとやれば順位はともかくちゃんとした score を出せるんじゃないかなあと思います。

一人でコンペを本当に完走したのは初めてだったので(※最初の2週間は放置してたけど)、これも非常に良い経験でした。これで順位残せたら完璧だったんですけど207位かあ....

あと、機械学習関連のTwitterのフレンド・フォロワーが増えたのも良かったことですよね。

実装面で得たこと

  • 以前はVGG16を写経したのですが、今回は一からWideResNet の実装をしたこと。(もちろんあくまでフレームワーク上での実装。本当にChainer様々です。)
  • 前は不十分だったData Augmentation を実装し、かつその効果を実感できたこと。
  • Schedule Learning とか色々試して少しは学習のコツを学べたこと。

やってみたかったけど出来なかったこと

  • ちょっとだけやろうとしていた Pairwise Loss による Learning to Rank
  • Knowledge Distillation
  • GAN (何に使うかはともかく触ってみたかった)

今後

もう二度とこの種の指標のコンペには出たくないです。コンペに何回も出てたらまた別だったんでしょうけど初めて全力で取り組んだコンペでこれは本当にショックが大きすぎた。

不幸中の幸い(?)は top 10 のうち6人が落ちたことでむしろ笑えてくる状況になったこと。一人だけ落ちてたらもうしばらくコンペ出ませんとかになってたかもしれない。

いやこんなん言ってましたけどまさか本当にこんな状況になるとは....


と言いつつも、いつか同じような指標でコンペが出たときまたやるのかもしれないです。

さて、本当はこのコンペ終わったら一旦画像から手を引いて別のやつやろうと思ってましたが、流石にこのままは悔しすぎるので引き続き土砂崩れコンペに出てリベンジしようと思います。

signate.jp

次こそは絶対に生き残って見せる..


参考にさせて頂いた文献、スライド、web ページなど

大変参考になりました。ありがとうございました。



おまけ: 雑なコンペ取り組み日記

データだけダウンロードする (8/27)

とりあえず、面白そうだなーと思って data をダウンロード。めちゃくちゃ時間かかった。

「」(8/28 - 9/10)

気付けば二週間の時が流れていた...

データを見たり、前処理などを書いて学習の準備を進める (9/11 - 9/15)

流石にやばいと思って準備に乗り出す。この時にラベルの分布とか輝度の分布とかを少し見ていた。

とりあえず ResNet50 で学習開始 (9/16)

まだ本番構造の検討中だったが当たりをつけるために実施.

  • Chainer にデフォルトで実装されている ResNet50 の __call__ だけを override して使用
  • ImageNet で pre-train されているため (3, 224. 224) に合わせる必要がある. => (64, 64) を (224, 224) にリサイズした上で3つのチャネルに同じものをコピーする作戦にした
  • SGDで 学習率:0.01、WeightDecay:1e-05 とかなり雑に設定

初めての提出(9/17)

  • 一回出してみたかったので学習途中で保存されたモデルで inference => 0.34630(27位)
  • (数時間後) 学習が終わった奴を出してみる => 0.46907 (9位)
  • この辺りから、「もしかしたらいけるかも」と希望を持ち始める。まあ待っていたのは絶望だったんですけども。

schedule learning 等の検討 (9/18 - 22)

  • 閾値もうちょっとせめる => 0.50184 (8位)
  • これ以上はこれでは無理と思い、どう学習させるかについて考え始める
  • 検討し始めたこと
    • Adam でかなり alpha を落としてやる (+ AmsGrad)
    • SGD でゆっくり学習
    • SGD + Momentum + schedule learning
  • => 最終的には schedule learning をすることになった

手法の調査、検討 (9/11-9/22)

  • ResNet50 をとりあえず回している間に調査と実装を進める.
    • 特に「畳み込みニューラルネットワークの研究動向」や本文でも言っていたメテオサーチチャレンジの資料がめちゃくちゃ参考になった。
    • 本文にも書いたがシンプルかつ強力そうな WideResNet を実装することに決定。検索して出てきた Chainer 実装と原論文の実装が違って戸惑ったりなんてこともあった。

WideResNet の実装完了・導入(9/23 - 9/25)

  • ついに実装が完了し、一から学習させる。
  • 0.5337(7位) => 0.54662 => 0.55104 とスコアが上昇。
  • このままスコアを上げていけると思っていたが...

f:id:Tawara:20181030053027p:plain

2週間の迷子 (9/26 - 10/9)

  • この期間の間、一切提出が出来なかった。理由は内部の validation でscore が更新できなかったため。
  • data augmentation、Single ReLU、SE Module などを導入したり、構造を変更したりしたがスコアが全く伸びず、正直泣きそうだった。
  • なんやかんや時間だけが過ぎ、気づけば15位くらいになっていた。

実装ミスの発覚、一筋の光明が差す (10/10 - 10/14)

  • ふと「流石になんかおかしくない?」と思い始める
  • そこで、ちょっと問題をシンプルにするために train を正例:負例 = 1: 1 にして学習してみる
  • なんじゃこりゃああああ

f:id:Tawara:20181030054746p:plain

  • おかしい.. Data Augmentation してるはずなのに何でこんな過学習してんの?もしかして... => Data Augmentation を入れてるところが実行されてないことが発覚する
    • 実はちょっとカッコつけて SerialIterator に Data Augmentationを仕込もうとしてたのが原因だった。いややるのは良いけど動作確認しましょうよ...
    • 因みに過学習に気づけなかった原因の一つとして、負例が沢山あるために epoch ごとに異なる負例がミニバッチに入ったから過学習を抑えてくれてたっぽい
      • このことから負例もっと増やしても良さそうという発想になった

5 位まで駆け上がる (10/15 - 10/20)

  • おそらくこのコンペ中で一番楽しかった期間
  • 今までうまく行ってなかった奴がうまく行くようになり、層やチャネルを増やせば性能が上がる fever time
    • ただ、Single ReLU と SE Module はなんか微妙な結果だった
  • そんなこんなで、越えられると思ってなかった 0.6 も超えて、気づけば5位になっていた

f:id:Tawara:20181030055921p:plain

悪あがきと 駆け引き (10/21 - 10/26)

  • あと1週間を切り、もうこれしかないと思ってアンサンブルの準備を開始。正例:負例 = 1:35 のモデルを4日間ほどかけて学習させた
  • 途中でちょこちょこアンサンブルモデルを出しつつ0.66の壁を超えられた。めっちゃ嬉しかった.。めっちゃ嬉しかったが...

f:id:Tawara:20181030060406p:plain

  • 最後の提出については会社から帰って残り5時間めっちゃじっくり考えた結果を提出。
  • 最後の駆け引きについては結構皆さん上下させてた模様(僕もやることなくなったら0 にしたりして遊んでた)。残り30分でもうみんな動かさなくなったので、本当に少しだけ余裕を持たせて(5位に下げた)提出を決定し、審判を待つこととなった。

 .

 .

 .

 .

 .

 .

 .

 .

 .

 .

 .

 .

 .

 .

大爆死 (10/27)

ああ、こんな、こんなことって....

f:id:Tawara:20181030060857p:plain

まあ振り返ってみると、迷子になってしまって時間使っちゃったのと一気に駆け上がってしまったせいで冷静さを欠いてたなとは思います。1週間? せめて3日ぐらいは冷静に提出をどうするか考える時間を持つべきでした。でもあの状況で7 - 8 位あたりまでスコアをわざと落とそうとは全く思えなかったし、運命は変えられなかったんだろうなって。

根拠のない自信でここまで来たけど砕かれるとつらくて仕方なかったんですが(というかこれを書いてる最中につらくなっている)、同時に悔しくて仕方ないので次こそは勝ってやる....!