好みを学ぶ画像分類でAUC0.99|正体は時代差のリーク

好みを学ぶ画像分類でAUC0.99|正体は時代差のリーク

自分が目で選んできた画像の履歴から、「私ならこれを承認する」を当てる小さなモデルを作りました。

生成した画像をストック素材に回すかどうかを、これまで一枚ずつ手で決めてきたからです。

交差検証の AUC は 0.992 と出ました。

ところがこの数字は、私の好みではなく、画像が作られた時期の違いを当てていただけでした。

高すぎる精度をどう疑い、何を回して確かめたのかを、手元で測った数値と一緒に残します。

何を学習させようとしたのか

材料は、Stable Diffusion 系で生成した画像を、私が承認か却下に振り分けてきた履歴です。

承認が 2,226 枚、却下が 1,137 枚で、合わせて 3,363 件の台帳になりました。

承認した画像は全期間分がローカルに残っていて、古いものは 2024年12月までさかのぼります。

一方の却下画像は保管先の設定で自動的に消えるので、手元にあったのは 2026年9月7日〜15日の分だけでした。

しかも途中で生成モデルを DreamShaper から FLUX 系へ切り替えていて、古い画像と新しい画像では絵柄がかなり違います。

正例と負例で、集めた時期がまるで違うことが、あとで効いてきます。

大きなモデルは学習させず、上に小さな分類器を置く

最初は、RAG のように自分の基準を AI に渡せないかと考えました。

ただ、私の基準を文章にすると数行で終わってしまい、検索して渡すほどの材料がありません。

そこで、学習済みの画像モデルは中身を変えずに使い、出てくるベクトルの上に小さな分類器を置く形にしました。

画像モデルは SigLIP(ViT-B-16-SigLIP・webli の重み)で、open_clip 経由で手元の RTX 4060 で動かしています。

GitHub - mlfoundations/open_clip: An open source implementation of CLIP.

SigLIP は Google の研究チームが発表した画像とテキストの事前学習モデルで、詳しくは論文 Sigmoid Loss for Language Image Pre-Training にあります。

埋め込みを取り出す部分は、次のような短いコードです。

import open_clip, torch
model, _, preprocess = open_clip.create_model_and_transforms(
    "ViT-B-16-SigLIP", pretrained="webli", device="cuda")
model.eval()

def embed(images):
    x = torch.stack([preprocess(im) for im in images]).to("cuda")
    with torch.no_grad(), torch.autocast("cuda"):
        f = model.encode_image(x).float()
    return (f / f.norm(dim=-1, keepdim=True)).cpu().numpy()

分類器は k 近傍法(kNN)とロジスティック回帰の 2 つで、どちらも scikit-learn で書いています。

4,000 件ほどなら再学習は 1 秒もかからないので、承認ボタンを押すたびに学習し直せます。

画像モデルそのものを微調整する方式では、押すたびに学習し直す運用は現実的ではありません。

埋め込みと台帳の作り方

4K の JPEG をそのまま読むと前処理が重いので、Pillow の draft で読み込みの段階から縮小しています。

from PIL import Image
im = Image.open(path)
im.draft("RGB", (512, 512))   # JPEG は読み込みの段階で縮小してデコードできる
im = im.convert("RGB")

この形で 3,303 枚をバッチ 48・fp16 で埋め込み、かかった時間は 177 秒でした。

台帳は、1 行 1 画像の JSON Lines と、L2 正規化した float16 のベクトルを入れた npz の 2 ファイルです。

ルールベースの足切りで機械的に落とした画像は、学習から外しました。

機械の判定をもう一度機械に学ばせると、私の好みではなく足切りのルールを覚え直すだけになるからです。

AUC 0.99 が出て、疑った

全件で 5 分割の交差検証をすると、kNN(k=15・コサイン距離)は正解率 0.957・AUC 0.992 でした。

ロジスティック回帰でも、正解率 0.913・AUC 0.975 が出ています。

私自身が迷う画像も多いのに、ここまで当たるのは不自然です。

そこで承認画像だけを取り出し、ラベルの代わりに「2026年9月1日以降に作った画像か」を当てさせてみました。

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

ap = y == 1                      # 承認画像だけ
era = (dates[ap] >= "2026-09-01").astype(int)
auc = cross_val_score(LogisticRegression(max_iter=2000, class_weight="balanced"),
                      x[ap], era, cv=5, scoring="roc_auc").mean()
print("era AUC", auc)   # 0.5 に近ければ安心、0.9 なら時代差を学習している

結果は AUC 0.87(kNN)〜0.90(ロジスティック回帰)で、新旧の絵柄ははっきり見分けられていました。

却下画像はすべて 9 月の新しい世代なので、分類器は「好みか」ではなく「新しい世代か」を見て高得点を出していたことになります。

こうした見かけの高精度は珍しい話ではありません。

機械学習を使った研究を調べた Leakage and the Reproducibility Crisis in ML-based Science は、17 分野・329 本の論文でリークによる誤りが見つかったと報告しています。

評価用のデータが学習側に紛れ込んで点数がふくらむ「ベンチマーク汚染」も、高すぎる点数を疑うという点で同じ構図です。

ベンチマーク汚染とは?AIの高得点を疑うための基礎知識

同じ時期のデータだけで測り直す

時期のずれを消すため、承認と却下を同じ期間に絞って測り直しました。

  • 9 月だけ(承認 30・却下 1,137)… ロジスティック回帰の AUC 0.80
  • 8 月以降(承認 153)… AUC 0.92〜0.93
  • 6 月以降(承認 525)… AUC 0.94〜0.96、kNN の適合率 0.95・再現率 0.76

期間を今の世代に寄せるほど AUC は下がり、9 月だけでは 0.80 まで落ちています。

学習と評価を日付で分ける方法も試しましたが、9 月 9 日以降の評価側に承認画像が 3 枚しか残らず、評価として成り立ちませんでした。

9 月の承認率は 30 / 1,167 で、およそ 2.6%です。

つまり本当の律速は分類器の出来ではなく、今の世代の承認画像が 30 枚しか無いことでした。

負例を消さず、使い方は控えめにする

1 つ目の対策は、判定した瞬間に埋め込みとラベルを台帳へ追記することです。

却下画像が保管期限で消えても、ベクトルとラベルが残れば同じ時期の負例を積み上げられます。

2 つ目は、今の段階では自動で判定させないことです。

同じ世代の承認画像が 150〜200 枚そろうまでは、「承認しそうな順」に並べ替えるヒントとして使うつもりでいます。

この枚数は私の見立てで、まだ確かめていません。

9 月のペース(2 週間で 30 枚)だと、そろうまでに 2〜3 か月かかる計算です。

3 つ目は、透かしや顔の崩れのような致命的な欠陥を、ルールベースの足切りに残しておくことです。

こうした欠陥はめったに出ないので、分類器が学べるだけの量がなかなか集まりません。

承認率が 2.6% という数字は、分類器よりも生成側の歩留まりを上げるほうが効くことも示していました。

生成側に自分の売れ筋を手がかりとして戻すと別の偏りが生まれることは、別の記事にまとめました。

生成AIのフィードバックループ|自分と市場の実績を分ける

最後に

正例と負例を別々の経路・別々の時期から集めると、分類器はラベルではなく集め方の違いを覚えてしまいます。

高すぎる精度が出たら、ラベルを隠して「どちらの経路から来たデータか」を当てるプローブを 1 本回してみてください。

それが当たるうちは、どれほど高い AUC も評価には使えません。

以上です。

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

CAPTCHA