実はまったく初めてというわけではないけど、いつもコードを書いて「なんかうまくいかないな」となりつつ、修正方法がわからずそこで終わることが多かった。というわけで MENTA に課金してやり方を教わりつつ進めた。
お題
こういう感じで、「¥10,000」とある画像から「¥」の部分を切り抜く ... というのをお題にした。もともと別のお題があったんだけど、そっちはちょっと大変そうなので値段のところだけ切り出した。
¥の部分を切り抜くモチベーションは OCR の精度向上で「¥10,000」の画像を読ませると「10,000」と読んでくれるときもあるし「410,000」とか「110,000」
summary
7種類のフォントについて、各種50枚前後のデータを用意。これをさらに left/center/right-aligned と horizontal flip をかけて x6 のデータ量に水増し。resnet 18 をベースのモデルを作って、¥の x 軸の境界を学習させたところ、90% 弱の IoU が出せて、OCR の精度も向上した。
type 1 167 type 2 60 type 3 60 type 4 38 type 5 60 type 6 72 type 7 48
data prep
これらの値段の画像は、こういう↓感じの表になってる画像の中から集めたもので、アノテーション用の streamlit app を作った
データ量について、フォントの種類はまず 7個あって*、メンターからは
- 最低でも 10枚/種類
- 30枚/種類はほしい
- 100枚あるとリッチ
と教わった。表の画像を各種1-3枚集めて切り抜いた結果、種類ごとに↓のようなデータ量になった (type 1 だけ量が多いのは、なぜかこれだけパフォーマンスがやたら悪かったから)
*もともと10種類くらいあったけど、一部画質が悪かったのでこれらは除いた
type 1 167 type 2 60 type 3 60 type 4 38 type 5 60 type 6 72 type 7 48
データの整理としてはかんたんに下記の通り実施した。
- 表の画像はファイル名
type1_1
type1_2
のように設定 - 切り抜きは、
type1_1/00_00.jpg
のように、表の画像ごとにフォルダを作成して<row>_<col>.jpg
と設定 - ¥マークの右端の位置をアノテーションし、表の画像のフォルダの中に
type1_1/annotations.csv
のように CSV を作成 - モデルに食わせるときには、表の画像ごとのフォルダを flat にして、1つのフォルダに
type1_1_00_00.jpg
のようなファイル名にして CSV は一個にマージ
app_data/ ┣ inputs/ ┣ type1_1.jpg # フォントの種類が type1 ┣ type1_2.jpg ┣ ... ┣ crops/ ┣ type1_1/ ┣ 00_00.jpg ┣ 00_01.jpg ┣ ... ┗ annotations.csv ┣ type1_2/ ┣ 00_00.jpg ┣ ... ┗ annotations.csv
data/ ┣ type1_1_00_00.jpg ┣ type1_1_00_01.jpg ┣ ... ┣ type1_2_00_00.jpg ┣ ... ┗ annotations.csv
step 2: 一回これでやってみる
... というのが初期設定だったんだど、これで学習させところ、しょっぱい結果かつ「OCR の精度向上」には繋がらなかった。
メンターに相談したところ、「¥マークじゃなくて『画像の左端を切り抜く』と学習してそう」とのことだった
コードを見てもらったところ、一個めちゃくちゃまずいことをしているのがわかった。なにかというと一層目のレイヤーを変えていたことで、白黒画像を ResNet 18 に食わせるためにこういうコードを書いていた↓
model.conv1 = torch.nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)
これの何がまずいかというと、 せっかく pre-trained モデルを使っているのに first layer を捨てることで、pre-trained モデルが学習した特徴の検出を使ってないことになるから。なので、resnet とあわせて 3-channel 画像を入れたいけど、一方で今回の学習だと色、さらには「白と黒の間の微妙なグレー」というのもノイズになるので、binary の白黒画像にしたい。白黒画像を 3枚積み重ねると白黒の 3-channel 画像ができるので、これを resnet に食わせる。
このほかの修正としては、optimizer を脳死で SGD を使っていたけど、adam をおすすめされた。SGD はクラシックなもので、一方 adam は最近ポピュラーな optimizer。adam を使ってうまくいかなかったら他を疑う.. というテンションで使うのものらしい。adam の特徴は gradient boost で、これは local minimum に近づき gradient が小さくなったら、ブーストをかけて gradient を大きく見せるという手法。
step 3: data prep v2
データの準備を修正して
- 各切り抜きに対して左寄せ・中央寄せ・右寄せの画像をつくって、これらを左右反転させた画像も用意 (i.e. 1枚の切り抜きに対して6枚がつくれる)
- 左右反転させた画像をつくるので「¥の左端」だとアノテーションができない → 「¥の両端」をアノテーションする (作業やり直しだった...)
- ただの grayscale じゃなくて binary にする
- channel 数は 1 じゃなくて 3 にする
画像サイズに関しては、dataset の中から max_width, max_height を算出してすべての画像がそのサイズになるように padding した (黒背景・白文字の画像は先に手動で反転させておく)
step 4: もう一回学習
あとは、 optimizer を SGD から adam に変えて、fc layer も 1 から 2 に変更して学習させて、 IoU の指標も一緒に出すようにした。
loss curve と IoU のプロット↓
学習が終わるごとに、test data 1つずつに対する IoU を出して、こういう感じでシートにまとめてみた
最終的にうまくいったパラメータは learning rate = 1e-3, epochs = 100 で、IoU が 0.85 を超えたら early stopping を入れた。なお、この 0.85 の数字は OCR の方にあててみて「90% 前後になっていれば精度向上が見込める」ということを確認して使った。
まとめ
model
今回はデータ量が少ないので、ResNet の中でも一番小さい ResNet18 を使う。大きいモデル = パラメータ数が多い → ちゃんと学習させるためにはデータ量もたくさん必要。
LR = 1e-3, epochs = 100 で最終的に落ち着いた。
loss function
- optimizer はとりあえず使うなら adam; adam で学習崩壊が起きたら ada-grad とか rmsprop とかがある
- SGD は素直な optimizer
- %を出すなら cross-entropy の方がいい
- 外れ値が多いなら Huber
early stopping について
今回のような 100 epochs < 1h 程度の学習なら、この段階で early stopping を入れるのはもったいない。かわりに 300 epochs くらい回して、学習ごとにモデルとそのモデルの指標 (今回なら IoU) を保存しておく → 全 epochs 回ってから一番いいものを選ぶ、という手もある。
data prep
前提: ResNet を使うなら 3-channel かつすべての画像が同じ入力サイズである必要がある
- データ量的には今回のケースだと、minimum = 10, optimal = 30, very good = 100 という感覚
- 今回のようにターゲットが画像の特定の位置に偏るようなデータだと (円マークは画像の左端にある)、 left/center/right align, horizontal flip を入れることでモデルに「画像の左端」じゃなくて「円マーク」を学習させやすくなる
- 画質の悪いぼやけた画像は、ふつうだと人工的に作ってモデルをロバストにするために入れる
- 色の情報だけでなくグレーというのもノイズになりうるので、binary も検討しよう
その他
- 学習結果を記録するには tensorboard を使おう