![[Amazon Bedrock] マルチモーダル埋め込み3モデルとDINOv2を比べて、外観検査のファインチューニングが効く条件を実測してみました](https://devio2024-media.developers.io/image/upload/f_auto,q_auto,w_3840/v1788565339/user-gen-eyecatch/daxrqctwqrwqrjum5dnt.png)
[Amazon Bedrock] マルチモーダル埋め込み3モデルとDINOv2を比べて、外観検査のファインチューニングが効く条件を実測してみました
1 はじめに
製造ビジネステクノロジー部の平内(SIN)です。
2024年7月に、「Amazon Titan Multimodal Embeddings で外観検査をやってみた記事」を公開しました。ベルトコンベアの上を流れるゴム製のアヒルを撮影し、正常画像1枚とのコサイン類似度が0.9以下なら不良、という構成です。
不良画像を学習に使わずに済むのがベクトル検査の利点ですが、当時は「微細な異常が拾えない」という課題が残りました。画像全体を1本のベクトルに潰しているので、小さな傷が埋もれてしまうのだろう、と考えていました。
そこで今回は、2年ぶりに同じアヒルを撮り直して、埋め込みモデルのファインチューニングでこの課題が解決するのかを実測してみました。
先に結論を述べると、今回試した範囲では、ファインチューニングが効いたのは「元の精度が低いとき」だけでした。撮影条件を整えて元の精度が十分に高い状態では、ファインチューニングはむしろ判定の余裕を減らす結果になってしまいました。

一方で、前記事の残課題そのものである「微細な傷 × 学習にない照明」という最も難しい条件では、ファインチューニングで検出率が 67% から 100% に上がりました。ただし後述するとおり、この改善は毎回再現できたわけではないです。
以下は今回の環境(アヒル67枚・自宅の机・Web カメラ)での実測値です。他の対象や環境で同じ傾向になるとは限らないことを予めご了承ください。
| 施策 | 判定の余裕(後述)の変化 |
|---|---|
| 照明3条件を撮る | −5.25σ → +12.18σ |
| モデルを Bedrock から DINOv2 に替える | −3.18σ → +12.18σ |
| パッチ単位にする | 破綻時に −5.25σ → −2.83σ |
| ファインチューニング(元の精度が低いとき) | −2.09σ → +0.00σ(検出率 67% → 100%) |
| ファインチューニング(元の精度が高いとき) | +11.63σ → +3.39σ(悪化) |
先に正直に書いておきますが、私の試行では、ファインチューニングで思ったような改善は得られませんでした。条件を絞れば効く場面はあったものの、「これを入れれば精度が上がる」と言えるほどの結果には至っていません。私の試し方に考慮不足があった可能性が十分にあると思っています。お気づきの点があれば、ぜひ教えていただけると嬉しいです。
なお、本稿の実験は私の自宅の机の上で撮影した67枚のデータに基づくものです。あくまで特定環境での一例として捉えてください。
撮影は、Web カメラをアーム式スタンドで真上から固定して行いました。背景の布は、シワが出ないようにマスキングテープで机に貼り付けています。

使用したコードとデータは、GitHub に置きました。
2 2年で変わったこと
本題に入る前に、2024年7月(前記事)と2026年8月(現在)の変化について整理します。
| 役割 | 2024年7月(前記事) | 2026年8月(現在) |
|---|---|---|
| マルチモーダル埋め込み | Titan MME G1 | Nova 2 MME、Cohere Embed v4 が追加 |
| 東京リージョン | — | Titan / Nova は非対応、Cohere Embed v4 のみ対応 |
| 画像バックボーン | CLIP / ImageNet 事前学習 CNN | DINOv2、DINOv3(2025年8月)、SigLIP 2(2025年2月) |
| 異常検知の定石 | 1画像=1ベクトル + コサイン類似度 | パッチ単位 + メモリバンク |
| 疑似欠陥の合成 | — | CutPaste → DRAEM → GLASS(ECCV 2024) |
| ベンチマーク | MVTec AD | MVTec AD 2、Real-IAD |
選択肢は増えましたが、東京リージョンで使えるマルチモーダル埋め込みは Cohere Embed v4 だけでした。
$ aws bedrock list-foundation-models --region ap-northeast-1 \
--by-output-modality EMBEDDING --query 'modelSummaries[].modelId' --output text
# => cohere.embed-v4:0
# amazon.titan-embed-text-v1
# amazon.titan-embed-text-v2:0
# cohere.embed-english-v3
# cohere.embed-multilingual-v3
amazon.titan-embed-image-v1 も amazon.nova-2-multimodal-embeddings-v1:0 も一覧にありません。前記事で使った Titan MME は、2026年8月時点で東京リージョンから使えないようでしたので、本稿では us-east-1 で計測しました。
なお、Nova の Multimodal Embeddings はモデルIDが世代交代しており、現在は amazon.nova-2-multimodal-embeddings-v1:0 でした。
(次元数は 256 / 384 / 1024 / 3072 の4種類)
3 実験の設計と評価指標
(1) 不良画像を使わない
本稿のコンセプトは「本番の準備に不良画像を1枚も使わない」ことです。(試験・評価でのみ、不良画像を使用します)
| 場面 | 不良画像 |
|---|---|
| メモリバンクへの登録 / 学習 | 使わない |
| 疑似欠陥の設計 | 使わない(実物を見て似せると漏洩します) |
| 閾値の決定 | 使わない |
| 決めた構成の採点・比較 | 使う(試験) |
閾値を決めるために正常スコアの分布が必要になりますが、学習用の正常画像を「学習用」と「閾値校正用」に個体単位で分けて対応しました。不良画像は使っていません。
(2) AUROC だけでは差が見えない
評価指標をどうするかで、かなり悩みました。
異常検知では AUROC が定番のようですが、本稿の評価セットは正常30枚・異常10枚しか準備できていません。この規模だと、AUROC の刻みがかなり粗くなります。
正常と異常の組み合わせは 30 × 10 = 300 通りしかないので、AUROC が取りうる値は 1/300 = 0.0033 刻みです。異常1枚の順位が正常5枚分ずれるだけで AUROC は 0.017 動きます。手法の差がこの程度であれば、順位のわずかな入れ替わりに埋もれてしまいます。
実際、後述する実験では AUROC が 1.000 で並ぶ条件がいくつも出てきました。これでは手法の差が見えません。そこで、別の見方を用意することにしました。
(3) 主指標は「閾値をどれだけ自由に選べるか」
そこで、別の指標を主軸に据えました。
閾値は、モデルを準備する時点で決める必要はありません。運用に入る段階で、現場が実測値を見て決めればよいものです。そう考えると、モデルに求められるのは特定の1点の成績ではなく、「OK/NG の閾値の幅」になります。
本稿ではこれを運用ウィンドウと呼び、「過検出0%かつ検出100%を同時に満たす閾値の範囲の幅」として定義しました。正なら「その幅のどこに閾値を引いても成立する」ことを意味します。

上段(ケースA)が、正常と異常がきれいに分かれている状態です。正常の最大値と異常の最小値の間に隙間があり、その隙間のどこに閾値を引いても、正常を1枚も弾かず・異常を1枚も見逃しません。この隙間の幅が運用ウィンドウで、広いほど現場での調整に余裕が生まれます。
下段(ケースB)は、正常の最大値が異常の最小値を追い越してしまった状態です。閾値をどこに置いても、見逃しか過検出のどちらかが必ず発生します。本稿ではこの「足りない量」を負の値で表しています。
(4) 単位の「σ」について
本稿では運用ウィンドウの単位に σ を使っています。これは正常画像のスコアのばらつき(標準偏差)を1単位とした目盛りです。
なぜこうしたかというと、手法ごとにスコアの絶対値がまったく違うためです。
| 手法 | 正常のスコア | 異常のスコア |
|---|---|---|
| 1画像=1ベクトル | 0.02 付近 | 0.03 付近 |
| パッチ+メモリバンク | 0.29 付近 | 0.43 付近 |
このまま「差が 0.01 と 0.14 だからパッチが14倍良い」とは言えません。スコアの物差しそのものが違うからです。そこで、正常画像のスコアを基準に次の式で換算しています。
σ 換算値 =(そのスコア − 正常の平均)÷ 正常の標準偏差
たとえば正常のスコアが平均 0.10・標準偏差 0.02 のとき、スコア 0.20 の画像は
(0.20 − 0.10)÷ 0.02 = +5.0σ
となり、「正常のばらつき5個分だけ離れている」と読めます。これなら手法が違っても同じ物差しで比較できます。
したがって、運用ウィンドウ +12.18σ は「閾値を引ける範囲が、正常のばらつき12個分ある」という意味になります。
正常の分布 異常の分布
↓ ↓
… 最大 +5.6σ ┃←── 12.18σ ──→┃ 最小 +18.8σ …
└─ この幅のどこに閾値を引いても成立 ─┘
逆に −5.25σ は、両立する閾値が存在せず、正常のばらつき5個分だけ足りないという状態を表します。
なお、標準化に使う「正常のスコア」は、学習に使っていない正常画像から求めています。学習に使った画像で計算すると、自分自身がメモリバンクに入っているぶん楽観的な値になってしまうためです。
この指標による判断で、AUROC が全条件で 1.000 でも、運用ウィンドウは12倍違うという状況がありました。
4 データの準備
(1) 撮影セット
前記事はベルトコンベアの上を流れるアヒルを撮っていましたが、今回は静置に変更しました。理由は、前記事のつまずき(オートフォーカスが間に合わない、回転補正を断念)を排除して、手法の効果を純粋に測るためです。
Web カメラ(Logicool C920)をアーム式スタンドで真上から固定し、緑の布をマスキングテープで机に貼り付けました。
布を貼り付けているのは、シワを作らないためです。布がたるんでいると、置き直すたびにシワの位置が変わります。今回は正常画像のばらつきを「個体差」と「照明」に絞りたかったので、背景が動く要因はあらかじめ潰しておきました。撮影中に布がずれない効果もあります。

当初は QuickTime Player でカメラ映像を表示し、その領域をスクリーンショットする案を考えていました。実装は簡単ですが、画面表示は縮小された画像なので、それを撮り直すと情報を捨てることになります。そこで、カメラから直接読む構成にしました。
(2) 解像度の設計
微細な傷を扱うので、解像度の設計は先に固めておく必要があります。
DINOv2 ViT-S/14 に 518×518 で入力すると、パッチは14ピクセルなので 37×37 = 1,369 パッチになります。アヒルの実寸を定規で測ると 36mm でした。ROI 900ピクセルの中にアヒルが660ピクセルで写るので、1パッチが見ているのは実寸で約1.3mmという計算になります。

ここで一つ、正直に書いておきます。当初はアヒルを「約5cm」と仮定して設計を進めていました。解像度の計算はすべてこの数字に乗っているので、後から定規で測って36mmだと分かったとき、関連する数値をすべて計算し直すことになりました。被写体の実寸は、設計を始める前に測るべきでした。
この計算から、「難」レベルの傷は1mmではなく2〜3mmで作ることにしました。1mmの傷は1パッチ未満に埋もれるので、検出できなくても「手法が悪いのか解像度が足りないのか」を切り分けられなくなります。
(3) 照明3条件
外観検査で照明が効くのは経験的に分かっていたので、条件を3つ用意しました。ただし、ここで想定と違うことが起きました。
最初は「通常 / 暗い / 明るい」で分けるつもりでしたが、部屋の蛍光灯を消すと C920 が解像しなくなりました。暗所ではシャッター速度が落ちてブレるためです。ピントの合い具合を数値化して測ると(指標は後述します)、合焦時が0.18〜0.27に対して、暗所では0.006でした。
そこで、「暗くする」のをやめて「光の向きを変える」 方針に変えました。

| 条件 | 光源 | 背景で最も暗い象限 | 明るさ |
|---|---|---|---|
| 通常 | 蛍光灯のみ | 左上(79.9) | 119.9 |
| 側光 | デスクライトのみ(横から) | 右上(81.4) | 122.7 |
| 明るい | 両方 | 打ち消し合う(左上90.5 / 右上90.6) | 122.7 |
全体の明るさはほぼ同じですが、影の位置が左上から右上へ入れ替わっています。

結果的に、当初の「暗い」より良い条件になったと思います。明るさだけを変える条件は、モデルにとっては単純な輝度シフトでしかありません。影の位置が変わる方が、実際の分布シフトとして本質的です。
(4) 撮影データ
アヒル20個に番号を振り、以下のように撮影しました。合計67枚です。
| 用途 | 枚数 | 個体 |
|---|---|---|
| 学習用(正常のみ) | 27 | #01〜#09 × 照明3条件 |
| 評価用・正常 | 30 | #10〜#19 × 照明3条件 |
| 評価用・異常 | 10 | #10〜#19(難易度3段階) |


学習用と評価用は個体レベルで分離しています。また、#10〜#19 は無傷の状態を先に撮ってから傷をつけているので、同一個体の before / after として比較できます。

「難」レベルは、幅1mm以下の細い線です。上段が無傷、下段が傷ありで、差分は傷だけになります。
なお、「易」と一部の「中」は、2年前に作った不良品を再利用しました。同一ロットの同じ製品です。

前記事の不良を見返すと、すべて油性マジックで大きく描いたものでした。今回、細い線を入れた「難」レベルは、2年前には試していなかった難易度ということになります。
(5) 撮影アプリ
67枚を同じ ROI で撮る必要があるので、簡単な撮影アプリを作りました。

左がライブプレビューで、赤枠が切り出す ROI です。上部のバーはピントの状態を表しています。右のパネルには進捗(67枚中の撮影済み枚数)と、次に保存されるファイル名を出しています。
操作はキーボードで完結します。
| キー | 動作 |
|---|---|
| ドラッグ | ROI を指定(設定ファイルに保存され、次回も同じ枠になります) |
| Space | 撮影 → 次の個体番号へ自動で進む |
| a / d | 個体番号 |
| 1 / 2 / 3 | 照明条件 |
| o / n | OK(無傷)/ NG(傷あり) |
個体番号から保存先を自動で決めるようにしたので、#01〜#09 は学習用、#10〜#19 は評価用へ振り分けられます。手作業でフォルダ分けをする必要がなく、67枚を撮り終えるまでアヒルを置き換えて Space を押すだけで済みました。
Github scripts/capture_app.py
def sharpness(rgb):
"""コントラストで正規化したラプラシアン分散。(要点抜粋)
素のラプラシアン分散は暗いと値が下がるため、照明条件を変えると
ピントが合っていても閾値を割ってしまう。
輝度の標準偏差で割ることで、明るさに依存しない指標になる。
"""
g = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY).astype(np.float64)
sd = g.std()
if sd < 1e-6:
return 0.0
return float(cv2.Laplacian(g / sd, cv2.CV_64F).var())
ピントについて、2点補足します。
C920 のフォーカスは macOS の OpenCV から固定できません。cap.set(cv2.CAP_PROP_FOCUS, x) が False を返します。カメラ任せのオートフォーカスになり、合焦までに約2秒かかります。これは前記事のつまずき「動作中のオートフォーカスでのピント合わせが間に合わない」と同じ現象です。前記事では AE/AF ロックで対処する想定でしたが、Web カメラではロックできないので、「鮮鋭度を常時監視して、未合焦なら撮影をブロックする」という別の方法にしました。
もう1点、鮮鋭度の指標が明るさに依存していた点です。素のラプラシアン分散を使っていたところ、照明を暗くしたときに、ピントが合っていても閾値を割るようになりました。上記のとおり輝度の標準偏差で正規化して解決しています。
不良を作る前に品質チェックを通す運用にしました。傷をつけた後では、その個体は元に戻せないためです。もっとも、今回は手元にアヒルが50個ほどあったので、仮に撮り直しになっても代えは効く状況ではありました。
Github scripts/check_dataset.py
$ python scripts/check_dataset.py --sheet
# => 枚数 : 67
# サイズ : すべて (900, 900)
# 鮮鋭度 : min 0.118 / 平均 0.238 / max 0.331 (必要 0.1 以上)
# 最小余白 : 47px (必要 40px 以上)
# 警告のある画像 : 0 枚
サイズ・ピント・露出・余白・重複・個体差をまとめて点検します。実際、余白不足や異物の写り込みをここで2回検出して撮り直しました。
5 前記事の再現と、モデルを替えてみる
(1) 前記事の構成を測り直す
まず、前記事とまったく同じ構成を再現しました。基準画像1枚とのコサイン類似度を取り、0.9を閾値にします。
Github scripts/run_bedrock.py
| モデル | 正常の類似度の最小 | 異常の類似度の最大 | 検出 | 過検出 |
|---|---|---|---|---|
| Titan MME | 0.8866 | 0.9443 | 8/10 | 1/30 |
| Nova 2 MME | 0.9084 | 0.9438 | 8/10 | 0/30 |
| Cohere Embed v4 | 0.8541 | 0.9281 | 9/10 | 5/30 |
ここで分かったことがあります。正常の類似度の最小(0.8866)より、異常の類似度の最大(0.9443)の方が高いのです。つまり、今回のデータではどこに閾値を引いても完全には分離できない状態でした。0.9という値は、たまたまその重なりの中に置かれていたことになります。
前記事で「微細な異常が拾えない」と感じていたのは、閾値の調整不足ではなく、そもそも分離できていなかったためでした。
(2) DINOv2 と比べてみる
次に、同じ「1画像=1ベクトル」の方式で、オープンモデルの DINOv2 と比較しました。
| モデル | AUROC | 運用ウィンドウ |
|---|---|---|
| DINOv2 CLS | 1.000 | +12.18σ |
| Titan MME | 0.993 | −3.18σ |
| Cohere Embed v4 | 0.990 | −5.03σ |
| Nova 2 MME | 0.973 | −5.03σ |

今回のデータでは、DINOv2 だけが完全に分離でき、Bedrock の3モデルはいずれも運用ウィンドウが負という結果でした。
ここで注目したいのは AUROC です。Titan MME は 0.993 と、数字だけ見れば悪くありません。しかし運用ウィンドウは負で、過検出0%かつ検出100%を満たす閾値が存在しない状態です。AUROC を見ているだけでは、この差に気づけません。
差が出た理由は、モデルの設計目的の違いにあるのかもしれません。Titan / Nova / Cohere はいずれも意味的な検索に最適化された埋め込みです。「同じアヒルで傷がある / ない」は意味的にはほぼ同じ対象なので、区別が難しいとしても不思議ではないように思いました。一方 DINOv2 は自己教師あり学習で視覚的な特徴を保持するため、微細な差に敏感なのかもしれません。あくまで今回のアヒルという対象での話で、他の被写体でも同じ順序になるかは分かりません。
(3) ただし DINOv2 は Bedrock からは使えません
ここで、運用面の違いを補足します。
DINOv2 は Bedrock では提供されていません。Meta が公開しているオープンモデル(Apache-2.0)なので、自分でホストする必要があります。
| Bedrock(Titan / Nova / Cohere) | DINOv2 | |
|---|---|---|
| 提供形態 | マネージド API | 自前ホスト(SageMaker / EC2 など) |
| 東京リージョン | Cohere Embed v4 のみ | 制約なし(自前のため) |
| 運用の手間 | 少ない | エンドポイントの管理が必要 |
| 今回の運用ウィンドウ | −3.18σ 〜 −5.03σ | +12.18σ |
ただし、今回使った DINOv2 ViT-S/14 は 22.1M パラメータと小さく、67枚の埋め込み抽出はローカルの MacBook で30秒程度でした。GPU を常時起動しておく必要がある規模ではないので、バッチ推論やサーバーレス推論でも十分かと思います。
6 1画像=1ベクトルをやめてみる
前記事の課題は「画像全体を1本のベクトルに潰しているから微細な傷が埋もれる」と考えていました。そこで、パッチ単位で扱う方式も試しました。
(1) パッチ単位とは
DINOv2 のような ViT(Vision Transformer)は、画像をそのまま扱うのではなく、14×14 ピクセルの小さなマス(パッチ)に分割してから処理します。518×518 の入力なら 37×37 = 1,369 個のマスになり、マス 1 つにつき 1 本のベクトル(384 次元)が作られます。

前記事の方式は、このうち「画像全体を 1 本に要約したベクトル(CLS トークン)」だけを使っていました。左の図です。幅 1mm の傷は画像全体の 0.07% しかないので、1 本に潰すと埋もれてしまいます。
一方、1,369 本すべてを使うのが右の図です。マスごとに「正常画像の似た場所と比べて違わないか」を判定するので、傷が乗ったマスだけが反応します。周りの 1,362 個が正常でも埋もれません。
具体的には、正常画像の全パッチをメモリバンクに貯めておき、検査画像の各パッチについて「最も似ている正常パッチとの距離」を求めます。その最大値を画像の異常スコアにします(AnomalyDINO や PatchCore の系統)。2026年時点の異常検知では、こちらが定石になっています。
(2) ただし処理は重くなります
パッチ単位にすると、DINOv2 の推論回数そのものは変わりません(画像 1 枚につき 1 回)。増えるのは、その後の照合処理とメモリです。
| 1画像=1ベクトル | パッチ単位 | |
|---|---|---|
| DINOv2 の推論(画像1枚) | 166 ms | 166 ms(同じ) |
| 保持するベクトル(正常27枚分) | 0.04 MB | 56.8 MB(1,369倍) |
| 照合の計算(検査1枚あたり) | 0.05 ms | 91 ms(約2,000倍) |
| 合計(検査1枚あたり) | 約 166 ms | 約 257 ms |
照合部分だけを見ると2,000倍ですが、DINOv2 の推論時間が支配的なので、全体では 1.5 倍程度に収まりました(私の MacBook での実測値です)。
とはいえ、正常画像を増やすとメモリバンクが線形に膨らみ、照合時間もそれに比例します。正常画像を1,000枚登録すれば 2GB を超える計算になるので、実運用ではメモリバンクの間引き(PatchCore の coreset など)が必要になりそうです。
また、Bedrock のマルチモーダル埋め込みではこの方式は使えません。API が返すのは画像1枚につき1本のベクトルで、パッチ単位の特徴を取り出せないためです。パッチ単位を使うなら、自前でモデルをホストすることになります。
(3) 実際に試した結果
パッチ単位にすると、どこを異常と見たかを可視化できるという利点もあります。

「難」レベル(右端)の細い線を、正確に捉えているのが分かります。
ただし、素朴にパッチ化しただけでは足りませんでした。パッチをそのままメモリバンクに入れた場合は運用ウィンドウが +1.00σ でしたが、PatchCore の局所近傍集約(3×3の平均プーリング)を加えると +4.08σ まで広がりました。1パッチ単体では受容野が狭くノイズに振られるため、周辺を混ぜた特徴にする必要があるようです。
なお、アヒルのデータでは、撮影条件を揃えた状態だと1画像=1ベクトルの方が運用ウィンドウが広い(+12.18σ 対 +4.08σ)という結果でした。条件が安定していれば、わざわざパッチ化しなくても足りるということかと思います。
7 撮影条件を揃える
ここまでの実験で、撮影条件を揃えた状態では 3手法すべてが AUROC 1.000、過検出0%で検出100% という結果になりました。手法の差が出ません。
そこで、学習データの側を崩してみました。学習を normal(蛍光灯のみ)の9枚に限定し、評価は3条件すべてで行います。
| 学習データ | 手法 | AUROC | 運用ウィンドウ | 側光の正常スコア |
|---|---|---|---|---|
| 3条件 27枚 | 1画像=1ベクトル | 1.000 | +12.18σ | +0.4σ |
| 3条件 27枚 | パッチ+近傍集約 | 1.000 | +4.08σ | +1.1σ |
| normal 9枚 | 1画像=1ベクトル | 0.980 | −5.25σ | +15.2σ |
| normal 9枚 | パッチ+近傍集約 | 0.937 | −2.83σ | +8.3σ |

学習に無い照明(側光)の正常品が、+15.2σ という異常スコアを出しました。異常の最小値(+15.94σ)とほぼ同じ値です。運用ウィンドウは +12.18σ から −5.25σ へと符号が反転しました。
つまり今回の条件では、破綻の原因は傷の細かさではなく、照明の分布シフトの方にあったようです。
(1) 正常画像は何枚必要か
あわせて、正常画像の枚数についても調べました。
| 構成 | 学習枚数 | 1画像=1ベクトル | パッチ+近傍集約 |
|---|---|---|---|
| 実写 9枚(3個体 × 3照明) | 9 | +13.72σ | +5.70σ |
| 実写 27枚(9個体 × 3照明) | 27 | +12.18σ | +4.08σ |
| 実写 9枚 + 拡張 → 27枚 | 27 | +11.25σ | +4.16σ |
3個体(9枚)と9個体(27枚)に差がありませんでした。一方、照明条件を落とすと破綻しています。今回のデータに限れば、正常画像は「枚数」より「条件の網羅」の方が効いているように見えました。対象の個体差が今回より大きい場合は、枚数の方が効く可能性もあるかと思います。
8 ファインチューニングは効くのか
いよいよ本題です。
(1) 最初の実装は失敗しました
不良画像を使わない前提なので、学習に使えるのは正常画像だけです。2つの方法を試しました。
- 手段A: 正常のみの自己教師あり適応(SimSiam 風に stop-gradient を入れて埋め込み崩壊を防ぐ)
- 手段B: CutPaste 相当の疑似欠陥合成。正常画像の一部を切り取って別の位置に貼り、正常と疑似欠陥を分離するよう学習
DINOv2 の最終2ブロック(3.6M / 22.1M)を30エポック更新したところ、結果はこうなりました。
| FT前 | 手段A後 | 手段B後 | |
|---|---|---|---|
| 1画像=1ベクトル | +12.18σ | +1.00σ | +2.24σ |
| パッチ+近傍集約 | +4.08σ | +2.36σ | +4.48σ |
ファインチューニング前が最も良いという結果でした。正常と異常の両方が悪化しており、分離が壊れているように見えました。
なお、この4条件すべてで AUROC は 1.000 でした。AUROC だけを見ていたら「変わらない」と誤読していたところです。
(2) 失敗の原因を4つに切り分けました
なぜ悪化したのか、心当たりを整理しました。
| # | 原因 | 対策 |
|---|---|---|
| A | 評価が学習目標とズレていた。分類器を学習しておきながら、評価ではヘッドを捨てて埋め込みだけ使っていた | 分類ヘッドの出力を直接スコアにする |
| B | 学習が強すぎた。27枚に対して3.6M × 30エポックは過剰 | バックボーンとヘッドで学習率を分ける(1e-6 / 1e-3)、最終1ブロックのみ更新 |
| C | 疑似欠陥の形が実物と違う。矩形パッチ vs 幅1mmの線 | CutPaste-Scar(細長い矩形)を混ぜる |
| D | CLS で分類していた。画像全体の要約で局所的な傷を判定していた | パッチ単位で分類する |
特に A は、いま振り返ると当然の話でした。CutPaste の原論文(CVPR 2021)や SimpleNet は、学習した識別器の出力をそのまま異常スコアに使います。埋め込みだけ取り出す使い方は中途半端だったと思います。
D については、CutPaste で貼り付けた位置は分かっているので、どのパッチが異常かというラベルを機械的に作れます。
Github scripts/train_ft2.py
def patch_label(box, size, grid, patch=14):
"""貼り付けた矩形と重なるパッチだけを 1 にしたラベルを作る。(要点抜粋)
CLS(画像全体の要約)で判定すると、幅 1mm の傷は全体の中に埋もれる。
パッチ単位なら「その場所が異常か」を直接学習できる。
"""
dy, dx, ph, pw = box
m = torch.zeros(grid, grid)
y0, y1 = dy // patch, min(grid - 1, (dy + ph) // patch)
x0, x1 = dx // patch, min(grid - 1, (dx + pw) // patch)
m[y0:y1 + 1, x0:x1 + 1] = 1.0
return m.reshape(-1)
加えて、埋め込みベースと分類ヘッドは得意分野が逆であることが分かりました。埋め込みは傷に敏感ですが照明の変化に弱く、分類ヘッドは照明に強いものの傷の検出は劣ります。そこで、両者をσ正規化してから足し合わせるアンサンブルも試しました。
対策を積み上げた結果が、以下です(分布シフト条件)。

今回の条件で最も効き目があったのは A(評価と学習目標を一致させる)と D(パッチ単位で分類) でした。
(3) 元の精度が高いと、効きません
改善版で、撮影条件を揃えたデータ(27枚)を再度試しました。
| 運用ウィンドウ | |
|---|---|
| FT前 | +11.63σ |
| FT後(最良のアンサンブル) | +3.39σ |
やはり FT 前が最良でした。FT 前の時点で AUROC 1.000、過検出0%で検出100%なので、そもそも改善の余地がない状態だったことになります。
(4) 元の精度が低いと、効きます
そこで、前記事の残課題そのものである条件に絞りました。難易度「難」(幅1mm以下の細い線)だけを対象にし、かつ学習にない照明が来る状況です。
| AUROC | 運用ウィンドウ | 過検出0%での検出率 | |
|---|---|---|---|
| FT 前 | 0.889 | −2.09σ | 67% |
| FT 後 | 1.000 | +0.00σ | 100% |
運用ウィンドウが負から0以上に転じました。つまり、過検出ゼロかつ検出100%を満たす閾値が存在する状態になりました。ただし幅は0なので、「ちょうど1点だけ引ける」という際どい状態ではあります。
ただし、この評価に使った異常はわずか3枚です。第3章で「異常10枚では AUROC が0.174も振れる」と書いたとおり、3枚ではさらに不安定になります。そこで、乱数のシードを5通り変えて再現性を確認しました。
| seed | FT前 AUROC | FT後 AUROC | FT前 検出率 | FT後 検出率 | 運用ウィンドウ |
|---|---|---|---|---|---|
| 0 | 0.889 | 1.000 | 67% | 100% | +0.00σ |
| 1 | 0.889 | 0.989 | 67% | 67% | −3.15σ |
| 2 | 0.889 | 1.000 | 67% | 100% | +0.00σ |
| 3 | 0.889 | 1.000 | 67% | 100% | +0.00σ |
| 4 | 0.889 | 0.967 | 67% | 67% | −0.95σ |
AUROC は5回すべてで改善しましたが、完全分離できたのは5回中3回でした。今回の試行では改善の方向に働いたと言えそうですが、この規模のデータでは結果が安定しないというのが正直なところです。
(5) ハイパーパラメータの選び方について
正直に書いておくと、エポック数やアンサンブルの重みは、アヒルの異常画像を含む評価結果を見て選びました。これは「本番の準備に不良画像を使わない」というコンセプトに厳密には反します。
本来であれば、これらの値も不良画像を見ずに決めるべきです。学習データの正常画像から作った疑似欠陥をどれだけ検出できるかで早期終了を判断する、といった方法が考えられますが、今回はそこまで手が回りませんでした。この設定が他の対象でも有効かどうかは確認できていません。
(6) SageMaker は使いませんでした
当初は SageMaker でファインチューニングする想定で、東京リージョンの単価(ml.g6.xlarge が $1.634/h)まで調べていました。しかし、最終1ブロックのみの更新であれば、ローカルの MacBook(MPS)で40〜150秒で終わりました。
今回の規模では SageMaker を使う必然性がありませんでした。使うとしたら、再現性・成果物管理・スポットインスタンスの活用といった運用面の理由になるかと思います。もっと大きなモデルや学習データを扱う場合は、また事情が変わってくるはずです。
なお、Bedrock で Titan MME 自体をファインチューニングする方法も検討しましたが、カスタマイズのバッチサイズが最小256で、今回の規模とは噛み合わないため見送りました。
(7) 試したこと・試せなかったこと
この章でやってみたことを整理しておきます。
| 試したこと | 結果 |
|---|---|
| 手段A(正常のみの自己教師あり適応) | 悪化 |
| 手段B(CutPaste 相当の疑似欠陥) | 悪化 |
| 分類ヘッドの出力を直接スコアに | 改善 |
| 学習率をバックボーンとヘッドで分離 | 悪化が止まった |
| CutPaste-Scar の追加 | 単独の効果は不明瞭 |
| パッチ単位での分類 | 改善(最も効いた) |
| 埋め込みと分類ヘッドのアンサンブル | 改善 |
一方、試せていないことも残っています。
- GLASS(ECCV 2024)のような、より作り込まれた疑似欠陥の合成
- 複数層の特徴を結合する方式(Dinomaly や SuperADD の系統)
- LoRA など、更新するパラメータをさらに絞る手法
- 学習データを増やした場合(今回は最大27枚)
正直なところ、ここまで試しても思ったような結果には届きませんでした。条件を絞れば効く場面はありましたが、安定して効く設定を見つけるには至っていません。上記の未検証項目を含め、私の考慮が足りていない点にお気づきの方がいらっしゃれば、ぜひ教えていただけると嬉しいです。
9 最後に
今回の検証で私が感じたことを3点にまとめます。いずれも私が試した1つの環境・1つの対象での結果であり、一般化できる知見として書いているものではありません。
1. 今回の試行では、ファインチューニングが効いたのは「元の精度が低いとき」でした
撮影条件を揃えて運用ウィンドウが +11.63σ ある状態では、ファインチューニングは効果がなく、むしろ余裕を減らす方向に働きました。一方、微細な傷を学習にない照明で検出するという最も難しい条件では、検出率が 67% から 100% に上がりました。ただし5回中3回の再現にとどまっています。
この結果から私が感じたのは、手法を試す前に、まず自分の条件で元の精度を測ってみるとよさそうだということです。元が十分に良ければ、ファインチューニングの出番はないかもしれません。
2. 手法を変える前に、撮影条件を疑ってみるべきだったと感じました
今回の環境では、照明3条件を撮るだけで運用ウィンドウが −5.25σ から +12.18σ に変わりました。ファインチューニングの改善幅(+2.09σ)と比べると、かなり大きな差です。2年前に「微細な異常が拾えない」と感じた原因の少なくとも一部は、コンベア上で照明・ピント・向きが安定していなかったことにあったのではないか、と考えています。
3. 今回のデータでは、AUROC だけでは手法の差が見えませんでした
本稿の主要な条件は、いずれも AUROC が 1.000 でした。それでも「閾値をどれだけ自由に選べるか」という視点で測ると12倍の差がありました。評価セットが小さい場合は、AUROC 以外の見方も併用した方がよさそうだと感じています。
ファインチューニングについては、今回の試行の範囲では効く条件と効かない条件が分かれたというところまでが言えることかと思います。同じ条件でも5回中3回しか完全分離できていないので、結果が安定するとは言えないという留保が付きます。
当初は「ファインチューニングで精度がこれだけ上がりました」と書けることを期待していたのですが、思ったような成果には至りませんでした。手法の選び方、疑似欠陥の作り方、学習の回し方など、私の詰めが甘かった部分は少なくないと思います。「ここはこうした方がよいのでは」という点にお気づきでしたら、ぜひご指摘いただけると嬉しいです。
繰り返しになりますが、本稿の数値はすべてアヒル67枚・自宅の机・Web カメラという1つの環境での実測値です。対象物・カメラ・照明・異常の種類が変われば、結果も変わってくるはずです。同じ手順で試しても同じ数字にはならない可能性が高い、という前提でお読みいただければと思います。
そのうえで、外観検査を検討されている方には、まず撮影環境を固めて、条件のバリエーションを正常画像で網羅してみるところから始めるのも一つの手ではないか、と思いました。それでも取りこぼす領域が残ったときに、ファインチューニングを検討するという順序が、今回の私の実感には合っています。
この記事で使用したコードとデータは、以下に置きました。アヒルの67枚は CC BY 4.0 で同梱しているので、clone するだけで同じ実験を再現できます。
GitHub: sagemaker-embedding-ft-visual-inspection







