約三ヶ月ぶりの投稿です。
前回の投稿(ファインチューニング(3))から、いろいろなことがあったのですが、一番大きな変化として、ファインチューニングのベースモデルをResNet50からEfficientNetV2Lに変更しています。
今回は、その辺に関して触れていきたいと思います。
なお、ファインチューニング自体に関する変更点とは別に、開発環境に関しても大きな変化がありました。
今まではエディタとしてVimを使用し、Gemini先生に生成してもらったコードを切り貼りしていましたが、現在はCursor(model=Auto)先生を使用して、直接的にコードを生成・編集してもらっています。
ただ、Cursor先生も超優秀ながらポカも多いので、怪しい部分に関しては引き続きGemini先生にセカンドオピニオンを求めたりしています。
データ追加(ハードポジティブ・ハードネガティブ)
前回の投稿で予告したように、まずは学習用データを追加しました。
1つのラベルに関する別画像を5枚用意しています。
同一ラベルの別画像に関しては、撮影ごとに被写体とカメラの距離を変えたり、フラッシュの使ってみたりして、見た目(歪み方や色合い)が変わるようにしています。
また、ラベルの選択に際しては、見た目のよく似た別ラベルが存在することを前提とし、それら別ラベルに関しても同様に5枚の画像を用意しています。
つまり、よく似たラベルA,B,Cに関する画像各5枚があった場合、A1に対してA2〜A5がハードポジティブ(見た目が違う同じラベルの画像)となり、B1〜B5,C1〜C5がハードネガティブ(見た目が似ている別のラベルの画像)になることを狙っている訳です。
上記方針に準じて112点のラベルを選択し、計560(112×5)枚の画像を用意しました(I君、ご協力感謝します)。
F1スコア
モデルの精度確認方法に関しても見直しました。
今までは、ポジティブやネガティブの分布状況を表やグラフにして確認を行なってきましたが、何となく説得力に欠けるというか、稚拙な印象が拭い去れませんでした。
ということで、改めて精度確認方法に関して調べてみたところ、F1スコアなるものを使用して判断するのが良い(というか標準的)ということがわかりました。
なお、F1スコアに関する解説(Gemini先生談)は以下の通り。
F1スコアは、機械学習モデルの性能を評価するための指標の一つで、特に分類問題で使われます。
これは「適合率 (Precision)」と「再現率 (Recall)」という2つの指標のバランスを評価するために用いられます。
適合率は、モデルが「陽性(正しい)」と予測したもののうち、実際に正しかった割合で、誤検知の少なさを示します。
再現率は、実際に陽性であるもの(見つけたいもの)のうち、モデルが正しく「陽性」と予測できた割合で、見逃しの少なさを示します。
F1スコアは、適合率と再現率の調和平均として計算されます。
F1スコア = 2 * (適合率 * 再現率) / (適合率 + 再現率)
調和平均を用いることで、適合率と再現率のどちらか一方が極端に低いと、F1スコア全体も低くなるため、両方をバランス良く高めることが求められます。
とのことですが、調和平均に関して補足しておきますと、
調和平均は、「率」や「速度」の平均を求めるのに適した平均値の算出方法です。
調和平均は、「数値の逆数の算術平均の、さらに逆数」として定義されます。
n 個の値 a1,a2,...an がある場合、調和平均は以下の式で計算されます。
調和平均 = n / (1/a1 + 1/a2 + ... + 1/an)
と言うことらしいです。
a1=適合率、a2=再現率、n=2 として上記解説にある式を変換すると、確かに先の「F1スコア = 2 * (適合率 * 再現率) / (適合率 + 再現率)」になります。
「数値の逆数の算術平均の、さらに逆数」を使用すると何が良いのかと言う点に関しては少々モヤッとしていますが、少なくとも適合率と再現率が指標として適している点は理解できるため、精度評価にはF1スコアを使用していきます。
ロジックの改修とハイパーパラメータの追加
前回投稿時と比較して、損失計算に若干の改修を行なっています。
with tf.GradientTape() as tape:
# 1. 埋め込みの取得 (callメソッド内で正規化済みを想定)
anchor_embedding, positive_embedding, negative_embedding = self(inputs, training=True)
# 2. 距離と類似度の計算 (コサイン距離)
sim_ap = tf.reduce_sum(anchor_embedding * positive_embedding, axis=1)
sim_an = tf.reduce_sum(anchor_embedding * negative_embedding, axis=1)
# コサイン距離: 1.0 - 類似度 (距離が小さいほど類似度が高い)
pos_dist_cos = 1.0 - sim_ap
neg_dist_cos = 1.0 - sim_an
# 3. トリプレット損失の計算 (L_triplet) - 標準的な実装
triplet_loss_value = tf.maximum(0.0, pos_dist_cos - neg_dist_cos + self.margin)
triplet_loss = tf.reduce_mean(triplet_loss_value) # バッチ平均
# 4. ペア損失の計算 (L_pair)
# ペア損失は、ポジティブペアの距離 pos_dist_cos を直接最小化する
pair_loss = tf.reduce_mean(pos_dist_cos) # バッチ平均
# 5. 総合損失の計算 (L_total)
total_loss = triplet_loss + self.pair_loss_weight * pair_loss
以前はトリプレット損失のみを採用していましたが、現在はトリプレット損失とペア損失から総合的な損失を判断しています。
ペア損失はアンカーとポジティブのコサイン距離のみに着目しています。
これにより、ポジティブに対してネガティブを十分遠ざけるということだけでなく、ポジティブとアンカーの距離を近づけることに対して、今まで以上に圧力が掛かるようになります。
なお、総合的な損失の計算に際しては、トリプレット損失に対してペア損失に一定の係数(0.1〜0.3程度)を掛けたものを加えています。
重心はトリプレット損失側に置いて、ペア損失側で補正する形をとっている訳ですが、この係数(pair_loss_weight)が新たなハイパーパラメータとなります。
また、build_encoderでモデル(エンコーダ)を構築する際にDropoutを追加しました。
outputs = base_model(inputs)
outputs = keras.layers.Dropout(dropout_rate)(outputs)
outputs = keras.layers.UnitNormalization(axis=1)(outputs)
encoder = tf.keras.Model(inputs, outputs, name="encoder")
上記のようにすることにより、各訓練ステップ(1バッチごと)でランダムに異なるニューロンが一時的に無効化されます。
一部のニューロンを無効化するということは、本来の(全てのニューロンが有効な)ニューラルネットワークに対するサブネットワークを構築する行為と言ってよく、加えて無効化されるニューロンがランダムに決定されることから、1バッチごとに異なる構造のサブネットワークに対して訓練を行なっているような状態になります。
その結果、最終的な学習結果は、個別のサブネットワークの学習結果を統合(平均化)したような形となり、単一ネットワークとして学習した場合よりも高い汎化能力や精度の向上が期待できるようです。
なお、Dropoutの引数として、無効化するニューロンの割合を指定できるため、この値(dropout_rate)も新たなハイパーパラメータとして管理するようにします。
ResNet50での学習結果
改めて、約一ヶ月間に渡ってResNet50のチューニングを行いました。
チューニング対象(操作するハイパーパラメータ)は従来のmarginに加えて、今回追加したpair_loss_weightとdropout_rateです。
一方で、前回の投稿では訓練可能(trainable)レイヤを操作しましたが、その後の試行錯誤を通して、結局は「conv5_block1_out」を指定した場合が最も安定的に良い結果が得られそうだったので、今回のチューニングにおける訓練可能レイヤは「conv5_block1_out」に戻してあります。
学習後の各モデルの評価に関しては、まずは今回の学習用に用意した画像(560枚)を全てエンベディングし、閾値を0.01単位で変更しつつ、各画像(アンカー)と他の画像(ポジティブ・ネガティブ)とのコサイン距離が同閾値以下となった数を集計し、そこから適合率、再現率、F1スコアを計算します。
その上で、最良のF1スコアとなった際の閾値と、その時のポジティブ数、ネガティブ数、適合率、再現率、F1スコアを当該モデルの性能(精度)を示す値として採用します。
なお、アンカーとポジティブ・ネガティブそれぞれとの組み合わせの総数は、ポジティブが2240(560×4)、ネガティブが310800(560x111x5)となります。
上記トライアルの結果、最もF1スコアが高かったモデルの各数値は以下の通りです。
蛇足ながら、最良の結果となった際のハイパーパラメータは、margin=0.38、pair_loss_weight=0.25、dropout_rate=0.15 でした。
参考値として、ResNet50をチューニングなしで使用した場合の数値も併せて載せておきます。
| 閾値 | ポジティブ | ネガティブ | 適合率 | 再現率 | F1スコア | |
| ベースモデル | 0.1 | 1678 | 2244 | 0.4278 | 0.7491 | 0.5446 |
| 学習済モデル | 0.13 | 1746 | 604 | 0.7430 | 0.7795 | 0.7608 |
ベースモデルと比較して、かなり性能向上していることは間違いありません。
ただ、個人的にはF1スコアとして0.8以上(理想的には0.9以上)を期待していたので、少々物足りない結果でした。
特に、適合率が0.7430(誤検知が25%程度ある)という状況は、少々許容し難いです。
なお、試行回数は31回で、大半の結果がF1スコアで0.65から0.72くらいの間に分布しており、上記0.76は極めてレアなケースです。
現時点で気づいていないチューニングポイントがあれば別ですが、少なくとも現在の方針で学習を続けていても大幅な改善は望めない印象です。
上記のような状況から、ハイパーパラメータの操作以外も含めて精度向上方法を相談している中で提案されたのが、ベースモデルの変更でした。
EfficientNetV2Lでの学習結果
最初、Cursor先生は「EfficientNetB7」を推してきましたが、Gemini先生のアドバイスで「EfficientNetV2L」を採用しました。
EfficientNetB7は初代EfficientNetシリーズの中でも最上位のモデルで、高精度ではあるものの、対象画像サイズが600×600と大きく、学習に多大な時間を要するようです(Macレベルでは、メモリ容量的に厳しく、学習自体が不可能と予想)。
一方で、EfficientNetV2Lは「V2」ということで、EfficientNetシリーズの次世代モデルであり、「L」はLargeを意味し、EfficientNetV2シリーズの上位モデル(最上位にはXLなるものがあるらしい)を意味します。
EfficientNetB7に匹敵する精度を、より短時間の学習で実現できるようにしたもの、とのこと。
なお、Gemini先生にEfficientNetB7とEfficientNetV2Lの違いを簡単に説明してもらったところ、以下のような回答をいただきました。
一言で言えば、EfficientNetB7が「時間をかけてでも究極の精度を目指した旧世代の職人」であるのに対し、EfficientNetV2Lは「学習の速さと高精度を両立させた最新の旗艦モデル」です。
同様に、ResNet50とEfficientNetV2Lの違いに関しては、
一言で言えば、ResNet50が「古き良き安定の土台」であるのに対し、EfficientNetV2Lは「最新の高精度・高効率エンジン」です。
とのことです。
雰囲気だけは伝わってくるような、こないような…
なお、EfficientNetV2Lは入力画像サイズが384×384になります。
ResNet50(224×224)と比較して、解像度が約3倍に向上しているため、それだけでもエンベディングの精度を高める要因になりそうです。
一方で、エンベディング結果の次元数としては、ResNet50が2048次元であったのに対して、EfficientNetV2Lは1280次元です。
安直に次元数が多い方が精度が高そうに思っていたのですが、次元数と精度に相関関係はないようです。
上記2点に事前対応した上で、早速EfficientNetV2Lでの学習を行なってみました。
まずは、お試しということで、Cursor先生の判断により、ハイパーパラメータは、margin=0.38、pair_loss_weight=0.1、dropout_rate=0.2 としました。
結果は以下の通り。
ResNet50のケースと同様、ベースモデルと比較する形で記載しています。
| 閾値 | ポジティブ | ネガティブ | 適合率 | 再現率 | F1スコア | |
| ベースモデル | 0.1 | 1118 | 1846 | 0.3772 | 0.4991 | 0.4297 |
| 学習済モデル | 0.15 | 2084 | 606 | 0.7747 | 0.9304 | 0.8454 |
最低限の目標としていた「F1スコア 0.8越え」を呆気なく達成しました!
一方で、ベースモデルの結果がResNet50よりも悪い点も興味深いです。
学習前後での変化量が大きいということは、やり方次第で様々なケースに高精度で適用できる可能性がある訳で、ファインチューニングに適したモデルかもしれません。
なお、適合率は0.7747であり、この点だけ見ればResNet50の0.7430と大きくは変わりません。
ただ、閾値を若干下げ、0.11とした場合の結果は以下のようになります。
| 閾値 | ポジティブ | ネガティブ | 適合率 | 再現率 | F1スコア | |
| 学習済モデル | 0.11 | 1844 | 340 | 0.8443 | 0.8232 | 0.8336 |
適合率=0.8443は、かなり良い感じです。
再現率が低くなった影響でF1スコアとしては0.8336と若干下がるのですが、それでも再現率=0.8232で、こちらも0.8を超えた状態を維持しています。
適合率と再現率の、どちらが重要かと考えた場合、適合率であるように思います。
ラベルを撮影し、その画像からワインを特定するようなアプリを考えた場合、もし撮影したラベル画像と既に登録済みの同ラベルの画像が照合できなければ、撮影をリトライする(別の画像を用意する)ことでリカバリできる可能性があります。
無論、リトライが頻繁に発生するようでは使い勝手が悪いので再現率も無視はできませんが。
一方で、適合率が低いということは、別のワインと誤認される可能性が高いということなので、こちらは可能な限り避けたいです。
そのように考えると、単にF1スコアの高い閾値を採用するのではなく、もう少し適合率と再現率のバランスを考えても良いかもしれません。
少し補足
データ追加に際しては、ハードポジティブ・ハードネガティブとなるデータが含まれるように考慮して被写体や撮影方法を選択していますが、得られた画像が本当に「ハード」であるかは、実際に距離を測定してみないとわかりません。
逆に、ネガティブに関しては、想定していなかったものがハードネガティブとなる場合もあり得ます。
よって、先に示したように、まずはベースモデルで全画像に対してエンベディングを実施し、F1スコアから閾値を決定した上で、各画像(アンカー)に対して距離が閾値以上のポジティブ(ハードポジティブ)と閾値以下のネガティブ(ハードネガティブ)を組み合わせてトリプレットを構成しています。
その結果、約6000のトリプレットが生成されますが、これらを、学習用4000、評価用1000、テスト用1000くらいの割合で使い分けています。
また、学習に要する時間はResNet50で1ステップ(1バッチ)が1秒未満、1EPOCHが110〜120秒でした。
一方で、EfficientNetV2Lでは1ステップに12秒程度かかっていて、全てのトリプレットに関して学習を実施すると、1EPOCHで30分程度要します。
昨今の学習において、EarlyStoppingのpatienceは概ね20で実施しているので、1回の学習に少なくとも10時間以上は必要になります。
直近としてはハイパーパラメータの調整が最優先課題ですが、上記のように時間がかかっては効率が悪過ぎます。
よって、アンカー単位の最大学習数(現在は5)を指定し、1つのアンカーを含むトリプレット数が前述の値を超える場合は、指定数のトリプレットをランダムに選択して、それらのみを学習に使用するようにしています。
このようにすることで、1EPOCHを800秒、1回の学習を4〜5時間程度に短縮しています。
訓練可能レイヤの選択も悩ましい所で、現在は「block7a_expand_conv」以降を訓練可能としています。
これはEfficientNetV2Lとして最も深い(出力に近い)層であり、つまりは訓練可能なパラメータ数としては最も少ない選択となります。
Gemini先生の見解では「block5a_expand_conv」が適当ではないかとのことですが、block7a_expand_convでもメモリ負荷が結構高く、先に示したように学習時間としても厳しい状況なので、層を浅くする(訓練可能なパラメータ数を増やす)選択は慎重に行なう必要がありそうです。
まとめ
ベースモデルとしてEfficientNetV2Lを採用するとの方針変換は、間違いなく正解だったと言えます。
学習時間が長くなったり、メモリ負荷が増えたりと、デメリットもありますが、まずは精度向上が果たせなければ意味がありませんので。
今後、しばらくはハイパーパラメータの調整を行い、数値が改善しなくなった辺りで訓練可能レイヤの変更を模索していく予定ですが、出だしとしては良好なので、希望を持って作業を進めて行けそうです。
目指せ、F1スコア(+適合率)0.9以上!