「ラベル画像のトリミング(3)」では、弊社ECサイトから取得したラベル画像を使用して学習を実施しましたが、成果は今ひとつでした。
原因として考えられるものは以下の4点です。
- 学習に使用している画像セット数が少ない
- 学習に使用している画像におけるラベル部分の占有面積が大きすぎる
- モデルの構造に問題がある
- 学習時に指定しているハイパーパラメータが不適切(最適でない)
上記のうち、敷居の高そうな3,4は一旦保留とし、1,2の改善を行います。
今回用意したのは、画像全体に対するラベル部分の占有面積が30%(最大でも50%)程度の画像200点です。
これを学習用160点と評価用40点に分けて使用します。
性能改善
前回は512×512の画像80点を使用して学習を行い、1epochの所要時間は300秒程度でした。
今回は画像数が倍になるので、同等のペースで学習を行った場合、200epoch実行するのに単純計算で33時間以上掛かることになります。
これでは試行錯誤が難しいため、以下の2点で所要時間の短縮を行います。
画像サイズの縮小
前回は512×512のサイズでしたが、今回は256×256に縮小します。
面積的には4分の1になるため、相応の負担軽減が期待されます。
一方で、画像が持つ情報量は減るため、それが学習にどのように影響するかが懸念されます。
また、今回用意してもらった画像の元のサイズは1200×1200であるため、この状態でアノテーションを行い、元画像およびアノテーション結果(マスク画像)のそれぞれを前述のサイズに縮小しています。
その結果、元画像に波のような模様(モアレと言うらしいですが)が生じたり、マスク画像のジャギーが目立つようになっていたりしており、この辺の影響も気になります。
上記のような懸念材料はあるものの、まずはサイズ縮小による高速化を目指します。
GPU利用
GPUを使用することによるAI学習の高速化は常識と言って良いレベルの話かと思いますが、今までの投稿においてこの点に触れてこなかったのは、実際にGPUを使用できていなかったためです。
まず、昨今まで開発に使用していたPCはインテルチップのMac miniでしたが、同Mac miniではTensorflowからGPUを使用すること自体ができないようでした。
その後、4月末頃に最新のMac mini(M4チップ)を入手したので改めてGPUの使用を試みましたが、処理が早くなるどころか、プロセスが終了しない状態になってしまいました。
全くの勘ですが、メモリ不足のような印象があります。
Mac miniでは「ユニファイドメモリ」なる仕組みによって、CPUとGPUが同じ物理メモリを共有するようになっている模様で、つまりは同Mac miniのメモリ(24GB)の一部をGPUのVRAMとして使用するようです(Gemini先生談)。
CPUだけで学習を行っていた段階で必要なメモリ量(約50GB程度)は物理メモリのサイズを超えており、スワップ領域を利用しながら何とか実行している状態でしたので、メモリ不足の状況は変わっていないように思いますが、GPUを使用する場合、より多くのメモリを必要とするか、スワップを使えない要因があるのかもしれません。
今回、処理対象の画像サイズを4分の1にしたことで改めてGPUの利用にチャレンジしたところ、無事GPUを使用することができました。
なお、Mac mini(M4チップ)でTensorflowからGPUを使用するためには専用のパッケージ「tensorflow-metal」が必要になるようなので、これをインストールします。
# pip install tensorflow-metal
改善の効果
上記施策により、1epoch(画像160点による学習および40点による評価)の所要時間が30秒程度まで改善されました。
画像点数が倍になっていることも考慮すると、単純計算で20倍の高速化が実現できたことになります。
検証
まずは、前回と同様に200epochの学習を…と思って実行しましたが、結果がかなりぶっ飛んだことになりました。
一般的に、lossやval_lossは若干の増減を繰り返しながら、全体的には緩やかな収束曲線を描くように推移します。
しかし、突然一時的に、異常なまでに大きく跳ね上がったり、あるいは急激に低下したりする現象が発生することがあります。
このような現象を「スパイク」と呼ぶようで、そのような名称が定義されていることから、この現象自体は今回用いているU-Netモデルを含むディープラーニング系の学習においては特殊なことではないようです。
実際、以前の学習においてもある程度は発生していましたが、発生頻度もあまり多くなく、発生後も比較的速やかに元の収束傾向に戻っていたため、あまり気にしていませんでした。
しかし、今回の検証では、特にval_lossの値が乱高下し、もはやスパイクと呼んでいいレベルではないような状況でした。
画像のサイズか内容か、いずれが影響したかは分かりませんが、前回までの学習とはかなり違った状況になりました。
と言うことで、今までとは異なるアプローチが必要になりそうです。
検証1:epoch=1000(学習率=0.001)
まずは、単純にepochを増やしてみました(ある程度学習が進んでいく中で状況が落ち着く可能性もあるので)。
性能改善できたことで調子に乗って、一気に1000epochにチャレンジします。
所要時間は8時間強ですが、帰りがけに実行しておけば翌日には結果が出ているので、これくらい試してみても良いかと。
結果は以下の通り。
loss

val_loss

最初にも書いた、200epochまでの段階で発生していたval_lossの乱高下は、その後も変わらずに継続し、少なくとも見た目の印象としては収束傾向があるようには見えません。
単に、epochを増やすと言う方法での解決は難しそうです。
検証2:epoch=1000, 学習率=0.0005
前述のような状況をGemini先生に相談したところ、学習率を下げてみることを提案されました。
「学習率」とは、以下のようなものです(Gemini先生談)。
学習率(Learning Rate)は、機械学習の最適化アルゴリズム(オプティマイザ)において、モデルのパラメータ(重みやバイアス)を更新する際のステップの大きさを制御するハイパーパラメータです。簡単に言うと、モデルが新しい情報をどれだけ素早く、またはゆっくりと学習するかを決定します。
つまり、学習率が大きいと1回の学習におけるパラメータの変化量が大きくなるようで、その結果として損失関数の値も大きく変動しそうな印象があります。
逆に言えば、学習率を小さくすることで、val_lossの乱高下を抑制する効果が期待できそうです。
と言うことで、学習率を調整してみましょう。
現在使用しているオプティマイザ(Adam)の学習率のデフォルト値は「0.001」らしいので、半分の「0.0005」にしてみます。
結果は以下の通り。
loss

val_loss

loss, val_loss両方に関して、スパイク(?)の発生頻度や変化の度合いが少なくなっているように見えます。
学習率の調整は有効と考えて良さそうです。
検証3:epoch=1000, 学習率=0.0001
さらに学習率を「0.0001」まで下げてみました。
結果は以下の通り。
loss

val_loss

かなり良くなってきたのではないでしょうか!!!
特にval_lossの方が(多少のスパイクを含みながら)右下がりの線として「視認」できる状況になっています。
検証結果の整理
上記3回の検証結果を整理してみましょう。
loss

上記のように3つのケースの結果を重ねてみると、やはり学習率0.0001の安定感が印象的です。
パラメータの変化が小さくなることから学習の進度は緩やかですが、堅実性が感じられます。
なお、学習過程においては、最新の結果が必ずしもベストの状態ではないことは上記グラフからも明らかですが、TensorFlow/Kerasの学習メソッド(model.fit)では、特定の指標(lossやval_loss)の値が最も良かったモデルを最終的な学習結果として残す方法もあるため、極端に言えば、指標となる値に収束傾向があろうが無かろうが、全epochを通じて最も小さな値に至った学習方法を「ベターな方法」と考えるのもありかもしれません(素人判断)。
と言うことで、各epochの段階に対して、それまでのlossの最小値(そのepochでのlossではない)との関係をグラフ化してみました。

後半の方は値が小さくなり過ぎて比較しづらいので、500epoch以降を尺度を変えてグラフ化しました。

600epoch以前の段階で既に学習率0.0005のケースが学習率0.001のケースよりも良い結果になっていますが、学習率0.0001の進度が明らかに良いので、いずれは学習率0.0001の結果が最も良い状態になりそうな予感がします。
val_loss

val_lossに関しては乱高下が酷過ぎて、3つの結果を重ねると視認しづらいことこの上無いですね。
と言うことで、lossと同様に最小値との関係を見てみましょう。

こちらも500epoch以降を拡大してみます。

既に学習率0.001の結果が最も悪い状況ですね。
勢いから言えば、こちらも学習率0.0001の結果が最も良くなりそうです。
と言うことで、loss, val_lossの値が乱高下しながらも最終的には優れた結果を残すと言うような学習方法はあまり期待できず、ある程度安定した収束傾向を示す堅実な学習方法でないと良い結果は残せないようです。
なお、学習率0.0001の1000epochの状況を見ると、loss, val_lossともに発展途上の雰囲気を醸し出しているので、追加でepochを2000まで増やして学習を実施してみました。
追加検証:epoch=2000, 学習率=0.0001
まずはlossの結果から。

val_lossの結果。

1000epoch以降の状況を、スパイクを無視するように尺度を変えてグラフ化してみます。

lossの方は比較的安定的に収束傾向を維持しているように見えます。
val_lossの方は細かく増減していますが、全体的には収束傾向はあるように見えます。
最小値をグラフ化してみましょう。

やはり、緩やかに減り続けているようです。
さらにepochを増やせば、より良い結果が得られそうな予感はします。
2000epochでも16時間以上要していますので、なかなか気軽にチャレンジできませんが…
蛇足ながら、学習率0.0001のval_lossの遷移に関してepoch1000とepoch2000の前半の結果の比較も行なってみました。

スパイクの発生タイミングが違っていますが、全体的にみると、想像以上に推移が類似しています。
学習にはある程度ランダム性があって、同じ学習データ、同じハイパーパラメータを使用しても結果がある程度異なると認識していたので、あくまでval_lossの値のみの話だとしても、これほど類似した傾向になったのは意外でした。
各ケースの最小値
以下に、各ケースにおいてval_lossが最小となった状況をまとめておきます。
0.001 | 0.0005 | 0.0001(1000) | 0.0001(2000) | |
epoch | 803 | 940 | 999 | 1864 |
loss | 0.0165 | 0.0157 | 0.0174 | 0.0083 |
val_loss | 0.0546 | 0.0338 | 0.0447 | 0.0326 |
学習率0.001では803epoch以降に最小値が更新されていないので、学習が行き詰まっている印象です。
loss, val_lossの値としては、いずれも学習率0.0001(2000)が最も良い値ですが、1000epochの3ケースに関しては大差ないですし、小差ながら学習率0.0001(1000)よりも学習率0.0005の方が良い値です。
学習率0.0001(1000)の方は緩やかに学習を進めている途中と言うことなのでしょう。
セグメンテーション結果の比較
上記までは、あくまで損失関数の値(つまりは数値データ)に基づいた結果確認でしたが、実際に生成されたモデルによるセグメンテーションの結果に関しても確認してみたいと思います。
なお、セグメンテーションに用いるのは先の各検証において構築したモデルですが、途中で少し触れたように、学習時に特定の指標が最も良かったモデルを残すという指定ができるため、今回使用するモデルとしてはval_lossが最も小さかったモデルを採用しています。
以下、代表的なケースに関して上げてみました。
なお、先のval_lossの遷移などからも予測できたことですが、0.0001(2000)の結果が総合的には最も良いので、以下のコメントは、特に触れない限りは0.0001(2000)の結果に対するものです。
まずは、理想に近いケースから。
元画像 | マスク画像 | 0.001 | 0.0005 | 0.0001(1000) | 0.0001(2000) | |
1 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
2 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
3 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
1のケースは、ほぼ理想通りの内容になっています。
2のケースも、若干の汚れが残っているものの、かなり理想に近い結果です。
加えて、学習率0.001の状態から徐々に精度が向上していく過程が分かりやすいです。
3のケースは少々特殊で、本来のラベルの向かって左上に別のシールが貼られていて、アノテーション結果ではそのシール部分は含まないようにしていたのですが、セグメンテーション結果は、いずれもシール部分を含む内容となっています。
期待通りでは無いのですが、「ラベルと思われる部分を独自に判断し、抽出する」というセグメンテーションの本分から考えると、妙に納得する結果です。
また、ラベルの中にはかなり特殊な形状のものも含まれており、それらの結果に関しても確認してみたいと思います。
元画像 | マスク画像 | 0.001 | 0.0005 | 0.0001(1000) | 0.0001(2000) | |
1 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
2 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
3 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
4 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
5 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
6 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
7 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
4,5辺りは若干苦戦しているようですが、形状の特殊性を考えると、全般的にかなり頑張っている印象です。
一方で、まだまだ課題ありの状況も見受けられます。
元画像 | マスク画像 | 0.001 | 0.0005 | 0.0005(1000) | 0.0001(2000) | |
1 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
2 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
3 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
4 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
5 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
6 | ![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
1,2は単純に精度が今ひとつですね。
元画像を見る限り、他のラベルと比較して特に難易度が高くなるような要素は見受けられないのですが、この辺がAIの奥深いところです。
3,4に関しては、0.0001(1000)の結果のみ、向かって右上に格子模様のようなノイズが発生しています。
0.0001(2000)では改善されているので良いですが、元画像の右上に特殊性はない(白い壁に薄く影が写っているだけ)ので、この部分の何が反映されたのかが謎です。
5,6に関しては、0.0001(1000)に対して0.0001(2000)の結果が劣化しています。
他のセグメンテーション結果全般を見比べて、0.0001(1000)と0.0001(2000)の内容は概ね同等の水準で、当然ながら0.0001(2000)の方の出来が良いケースも多いのですが、一方で本ケースのように0.0001(2000)でダメになっているケースも点々と見受けられ、様々なラベルに対して等しく良い精度でセグメンテーションを行えるモデルを構築することの難しさを感じます。
なお、上記でセグメンテーションを実施した各モデルに関する、学習時のloss, val_lossの値については先に「各ケースの最小値」としてまとめておきましたが、それらの値とセグメンテーション結果を比較すると、かなりの違和感があります。
0.0005と0.0001(1000)ではloss, val_lossともに00005の方が良かったですし、val_lossの値に関しては0.0005と0.0001(2000)の差もほとんどありませんでした。
しかし、セグメンテーション結果を見ると、0.0005の結果は0.0001(1000)や0.0001(2000)の結果と比較して、明らかに見劣りします。
さらに、0.001の結果はloss, val_lossの値を疑いたくなるレベルで出来が悪いです。
ラベル以外の部分の判断としては問題ないですが、ラベル部分の判断がかなり怪しく、全般的に黒っぽい画像になっています。
今までは、val_lossの値が0.1を下回れば十分優秀な結果だと考え、その辺りを目標の一つに考えていたのですが、どうもそのような判断基準にはなり得ないようです。
そもそもval_lossの値は本当に適切に計算された結果なのかを疑いたくなる状況ですが、モデルの精度アップ自体は良い方向に進んでいるので、この点に関しては一旦保留にします。
処理改修内容
途中でも触れましたが、今回の検証に際して、モデルの学習に2点の変更を加えました。
- オプティマイザの学習率の操作
- val_lossの値が最小となった学習結果を最終的な学習結果とする
上記を反映したプログラムの内容を示しておきます。
import tensorflow as tf
from tensorflow.keras import backend as K
from tensorflow.keras.callbacks import ModelCheckpoint
def dice_coef(y_true, y_pred, smooth=1):
intersection = K.sum(y_true * y_pred, axis=[1, 2, 3])
union = K.sum(y_true, axis=[1, 2, 3]) + K.sum(y_pred, axis=[1, 2, 3])
dice = K.mean((2. * intersection + smooth) / (union + smooth), axis=0)
return dice
def dice_loss(y_true, y_pred):
return 1 - dice_coef(y_true, y_pred)
def train_model(model, dataset, epochs, batch_size,
validation_data=None,
model_path_to_save="best_model.keras",
learning_rate=0.001):
optimizer = tf.keras.optimizers.Adam(learning_rate=learning_rate)
model.compile(optimizer=optimizer, loss=dice_loss, metrics=[dice_coef])
print("model.compile complete.")
if validation_data is not None:
callbacks = [
ModelCheckpoint(filepath=model_path_to_save,
monitor='val_loss',
save_best_only=True,
verbose=1)
]
model.fit(dataset,
epochs=epochs,
batch_size=batch_size,
validation_data=validation_data,
callbacks=callbacks)
else:
callbacks_no_val = [
ModelCheckpoint(filepath=model_path_to_save,
monitor='loss',
save_best_only=True,
verbose=1)
]
model.fit(dataset,
epochs=epochs,
batch_size=batch_size,
callbacks=callbacks_no_val)
print("model.fit complete.")
オプティマイザ(Adam)に関しては、従来はmodel.compileの引数として、その名称のみを渡していましたが、今回の改修では、学習率を指定しつつオプティマイザを生成し、そのオプティマイザをmodel.compileの引数として渡しています。
また、model.fitの引数として新たにcallbacksを渡すようにしており、そこでModelCheckpointを指定しています。
ModelCheckpointの引数では、monitorにval_loss(評価データがない場合はloss)を指定することでチェック対象となる指標を示し、save_best_onlyをTrueとすることで当該指標がベストであった場合のみ学習結果を保持しておくことを示しています。
これにより学習完了時には指定した指標が最も良かった学習結果が採用されるようになります。
なお、ModelCheckpointの引数にverbose=1を指定することで、学習過程で画面に表示される内容に上記指標のベスト更新情報が追加されます。
Epoch 1864: val_loss improved from 0.03314 to 0.03262, saving model to best_model.keras
まとめ
今回はハイパーパラメータには手を出さない、と最初に宣言しながら、結局はハイパーパラメータ「学習率」を操作することになりましたが、結果はかなり満足できるものになりました。
無論、改善すべき点はまだまだあると思いますが、「改善できる」との感触が得られたことと、その成果が実用レベルにかなり近づいた(と思える)ことが大きいです。
次の改善ポイントとしては、引き続き「学習率」に着目していきたいと思います。
現状の0.0001がベストであるとは限りませんので、もう少し値を操作してみたいですが、学習率を小さくすると学習の進行が遅くなるため、単純に実施しようとした場合は現状以上のepochを実行する必要が生じます。
軽く調べた限りでは、今回のように固定の学習率を使用するのではなく、状況に応じて学習率を変動させる方法もあるようなので、次回はその辺にチャレンジしたいと思います。