画像分類をやろうとして CLIP のゼロショットを試し、「思ったより当たらないな」で止まった人向けの話です。
手元の作業は自分で作ったツールで回していますが、この記事に載せるコードは PyTorch と OpenAI CLIP、Pillow だけで動きます。
結論から書きます。CLIP のゼロショットは、プロンプトで書ける軸には強く、書けない軸には当たりません。
ただしその軸を線形に読み出せる情報は埋め込みに残っていることが多いので、
線形層を 1 枚載せて数十枚のラベルで学習させると、ゼロショットを上回ります。
実際に測った数字がこれです。3 クラス・検証 45 枚。分割を 10 回引き直した平均です。
| 手法 | 正解率 | 最小 | 最大 |
|---|---|---|---|
| ゼロショット(1 クラスあたり 4 文の ensemble) | 0.536 | 0.467 | 0.622 |
| 線形プローブ 1 クラス 5 枚 | 0.742 | 0.644 | 0.822 |
| 線形プローブ 1 クラス 10 枚 | 0.762 | 0.711 | 0.889 |
| 線形プローブ 1 クラス 20 枚 | 0.760 | 0.600 | 0.911 |
| 線形プローブ 1 クラス 34 枚 | 0.807 | 0.733 | 0.889 |
1 クラス 5 枚、合計 15 枚のラベルで 20.6 ポイント上がりました。
10 回の分割すべてで線形プローブがゼロショットを上回っています(ゼロショットの最大 0.622 に対し、
1 クラス 5 枚の最小が 0.644)。以下、何を測ってどう作ったかを書きます。
1. プロンプトで書ける軸と、書けない軸
CLIP のゼロショット分類は、クラス名を文にして画像との類似度を取るだけです。だから
その軸が自然言語でうまく書けるかどうかに、精度がまるごと依存します。
書ける軸の例です。これはよく当たります。
- 犬か猫か
- 屋内か屋外か
- 料理の写真かどうか
書けない軸の例です。実務で必要になるのは、だいたいこっちです。
- 自社のカテゴリ体系(「A 区分」「B 区分」に意味を持たせているのは社内だけ)
- 掲載してよいかどうかの社内基準
- 何個写っているか
- 品質の合否(ブレ、欠け)
今回はこの「書けない軸」の代表として、投稿画像の人物の写り込みを分類してみます。
ユーザ投稿を受けるサービスなら、肖像権の確認のために一度は考える軸だと思います。
| クラス | 基準 |
|---|---|
none |
人が写っていない |
anonymous |
人は写っているが、顔から個人を判別できない(後ろ姿、遠景、シルエット、手足だけ) |
identifiable |
顔の特徴が判別できる大きさで写っている |
ポイントは、この 3 つの境界は自分で決めるものだということです。「遠景のどこからが判別できないのか」
に正解はなく、決めた基準に沿ってラベルを付けるしかありません。既製のモデルが当たらないのは当然で、
そのモデルは私の基準を知らないからです。
素材には Unsplash の公開写真 416 枚を使い、自分で 3 クラスに振りました
(none 190 / anonymous 177 / identifiable 49)。ラベル付けは自作のツールでやりました。
この画像は anonymous(後ろ姿で顔が判別できない)に振ったものです。
この判断を 416 回繰り返したものが、以下すべての土台になります。
2. まずゼロショットで測る
測らずに「当たらない」と言っても仕方がないので、測ります。
ゼロショット側に不利を作らないよう、1 クラスにつき 4 文を用意して平均しました(prompt ensembling)。
画像は images/<クラス名>/*.jpg の形に並べてあるものとします。
from pathlib import Path
import random
import torch
import clip
from PIL import Image
CLASSES = ["none", "anonymous", "identifiable"]
dataset = [(p, c) for c in CLASSES for p in sorted(Path("images", c).glob("*.jpg"))]
dev = ("cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu")
model, preprocess = clip.load("ViT-B/32", device=dev)
model.eval()
# 埋め込みは 1 回だけ作って使い回す
feats, ys = [], []
with torch.no_grad():
for path, label in dataset:
im = preprocess(Image.open(path).convert("RGB")).unsqueeze(0).to(dev)
f = model.encode_image(im).float()
feats.append((f / f.norm(dim=-1, keepdim=True)).cpu()[0])
ys.append(CLASSES.index(label))
X, y = torch.stack(feats), torch.tensor(ys)
PROMPTS = {
"none": ["a photo with no people in it",
"a photo of a scene without any person",
"an object photographed with nobody present",
"a landscape with no human in it"],
"anonymous": ["a photo of a person seen from behind",
"a photo of a person whose face is not visible",
"a distant silhouette of a person",
"a photo showing only someone's hands or legs"],
"identifiable": ["a portrait where the person's face is clearly visible",
"a close-up photo of a person's face",
"a photo of someone looking at the camera",
"a headshot of a person"],
}
with torch.no_grad():
tw = []
for c in CLASSES:
e = model.encode_text(clip.tokenize(PROMPTS[c]).to(dev)).float()
e = e / e.norm(dim=-1, keepdim=True)
tw.append(e.mean(0)) # 4 文の平均を、そのクラスの代表ベクトルにする
tw = torch.stack(tw)
tw = (tw / tw.norm(dim=-1, keepdim=True)).cpu()
pred = (X @ tw.T).argmax(1)
print(f"zero-shot 全 {len(y)} 枚: {(pred == y).float().mean():.3f}")
416 枚での結果は 正解率 0.584。混同行列がこうなりました。
| 正解 \ 予測 | none | anonymous | identifiable |
|---|---|---|---|
| none (190) | 91 | 86 | 13 |
| anonymous (177) | 41 | 135 | 1 |
| identifiable (49) | 0 | 32 | 17 |
ここから読めることが 2 つあります。
1 つ目。identifiable の recall は 0.347 です。 顔が判別できる 49 枚のうち 32 枚を
「顔が見えない」側に入れてしまう。プロンプトを 4 文に増やしても効きませんでした。
「人が写っているか」までは当たるのに、「顔が判別できるか」で落ちます。
そして、この軸が実際に問うているのは後者の方です。
2 つ目。none の recall は 0.479 です。 人が写っていない 190 枚のうち 86 枚を
「人がいる」側に寄せています。ゼロショットは人の有無すら安定しません。
「プロンプトを工夫すれば」と思うところですが、工夫のしようがないのが問題です。
「顔の幅が短辺の 1/10 以上で、目鼻が判別できる」を英文にしても、CLIP はその文を
そういう意味では扱ってくれません。
3. 埋め込みには情報がある。足りないのは読み出し方
ここで諦める前に、確かめるべきことがあります。
当たらないのは、埋め込みに情報が無いからなのか、読み出し方が悪いだけなのか。
ゼロショットがやっているのは、512 次元の空間で「クラス名の文」の方向との内積を取ることだけです。
つまり 決め打ちの 3 方向しか見ていない。もし正しい方向が別にあるなら、それを探せばいい。
探し方が線形プローブです。埋め込みを固定したまま、その上に線形層 1 枚だけを学習します。
CLIP 本体は 1 バイトも更新しません。
4. 線形プローブを載せる
埋め込みは §2 で作ってあるので、あとは線形層を回すだけです。
VAL_PER = 15
def one_split(seed):
rnd = random.Random(seed)
val_idx, pool = [], {}
for k in range(len(CLASSES)):
idx = (y == k).nonzero().flatten().tolist()
rnd.shuffle(idx)
val_idx += idx[:VAL_PER] # クラスごとに枚数を揃える
pool[k] = idx[VAL_PER:]
return val_idx, pool
def train_probe(tr, Xva, yva, seed):
torch.manual_seed(seed)
lin = torch.nn.Linear(X.shape[1], len(CLASSES))
opt = torch.optim.AdamW(lin.parameters(), lr=1e-3, weight_decay=1e-4)
for _ in range(200):
opt.zero_grad()
torch.nn.functional.cross_entropy(lin(X[tr]), y[tr]).backward()
opt.step()
with torch.no_grad():
return (lin(Xva).argmax(1) == yva).float().mean().item()
学習部分は実質 5 行です。CPU で数秒で終わります。
重いのは埋め込みの計算だけで、それは 1 回で済みます。
測り方で 1 つ気をつけたことがあります。 検証が 45 枚しかないので、1 回の分割では数字が
0.05 以上動きます。 1 回だけ測ると、都合のいい分割を引いただけなのか区別できません。
そこで分割を 10 回引き直して平均を取りました。
zs_scores, probe_scores = [], {n: [] for n in (5, 10, 20, 34)}
for s in range(10):
val_idx, pool = one_split(s)
Xva, yva = X[val_idx], y[val_idx]
zs_scores.append(((Xva @ tw.T).argmax(1) == yva).float().mean().item())
rnd = random.Random(1000 + s)
for n in probe_scores:
tr = sum([rnd.sample(pool[k], n) for k in range(len(CLASSES))], [])
probe_scores[n].append(train_probe(tr, Xva, yva, s))
5. 結果
冒頭の表を再掲します。ゼロショットも線形プローブも、同じ 10 個の分割で測っています。
| 手法 | 平均 | 最小 | 最大 |
|---|---|---|---|
| ゼロショット | 0.536 | 0.467 | 0.622 |
| 線形プローブ 1 クラス 5 枚 | 0.742 | 0.644 | 0.822 |
| 線形プローブ 1 クラス 10 枚 | 0.762 | 0.711 | 0.889 |
| 線形プローブ 1 クラス 20 枚 | 0.760 | 0.600 | 0.911 |
| 線形プローブ 1 クラス 34 枚 | 0.807 | 0.733 | 0.889 |
読みどころは 2 つあります。
1 クラス 5 枚で 20.6 ポイント上がります。 合計 15 枚です。しかもゼロショットの最大(0.622)より、
1 クラス 5 枚の最小(0.644)のほうが高い。分割の引きに関係なく差がついています。
この軸を線形に読み出せる情報が埋め込みに含まれていて、ゼロショットはそれを読み出せていなかった、ということになります。
そこから先の伸びは鈍い。 5 枚から 34 枚へ 7 倍にして、上がったのは 6.5 ポイントです。
10 枚と 20 枚は誤差の範囲で並んでいます。ラベルを 10 倍にしても精度は 10 倍にならないので、
ここから先はラベルを増やすより、エンコーダを変える・head を非線形にする・軸の定義を見直すほうが
効く可能性があります。300 枚作る前にこれが分かるのは大きいと思います。
6. 精度の次に要るもの
正解率が 0.8 だとして、そのまま自動処理に流せるかというと流せません。
残り 2 割をどうするかを決めないと運用になりません。
クラスごとに見る
全体の正解率は、クラスごとの内訳を隠します。同じ 450 件をクラス別に見るとこうなります。
| クラス | 件数 | ゼロショットの recall | 線形プローブの recall | 線形プローブの precision |
|---|---|---|---|---|
none |
150 | 0.467 | 0.833 | 0.822 |
anonymous |
150 | 0.807 | 0.733 | 0.710 |
identifiable |
150 | 0.327 | 0.847 | 0.888 |
ゼロショットがいちばん外していたクラスを、線形プローブがいちばんよく当てています。
identifiable の recall は 0.327 から 0.847 へ、52 ポイント上がりました。
一方で下がったクラスもあります。 anonymous の recall はゼロショットの 0.807 から 0.733 へ落ちています。
これはゼロショットが優秀だったのではなく、§2 の混同行列のとおり
迷ったものを anonymous に寄せていたためです。何でも anonymous と答えれば、そのクラスの recall だけは上がります。
クラス別に見ないと、この種の見かけ倒しを取り違えます。
どこまで自動に回せるか
確信度の絶対値では切れません。 softmax の値は学習の回し方(学習率、ステップ数、正則化)で動くので、
「0.9 以上」のような閾値は、別の設定で学習し直すと意味が変わります。
そこで確信度の順に並べて、上位から何割を自動確定に回すかで見ます。
| 自動確定に回す割合 | 件数 | その中の一致率 | 見逃した identifiable
|
人が見る件数 |
|---|---|---|---|---|
| 上位 25% | 112 | 0.973 | 0 件 | 338 |
| 上位 50% | 225 | 0.938 | 1 件 | 225 |
| 上位 75% | 338 | 0.885 | 4 件 | 112 |
| 全件(自動確定のみ) | 450 | 0.804 | 23 件 | 0 |
上位 25% では identifiable の見逃しが 0 件でした。 ここには identifiable が 51 件入っていて、
その全部を当てています。上位 50% まで広げると 89 件が入り、見逃しは 1 件です。
ここで言えるのは「自動化できる」ではなく、**「どこまで自動に回すと、何件見逃すかが分かる」**ということです。
肖像権の確認のように見逃しのコストが高い用途では、上位帯も自動確定の候補として扱い、
一定割合を人が抜き取り監査するのが妥当です。上位 25% で一致率 0.973 ということは、
112 件のうち 3 件は違うということでもあります。
全体の正解率という 1 つの数字より、この表の方が設計に使えます。
温度校正
確信度をそのまま確率として読むには温度校正が要ります。検証データでスカラー 1 つ(温度 T)を当てはめ、
推論時に logits を T で割るだけです。argmax は変わらないので予測は変わらず、確率の目盛りだけが動きます。
校正が改善するかは当てはめたデータ次第なので、入れたら測り直す必要があります。
上で順位を使ったのは、この手当てをしていない生の softmax を絶対値で比べても意味が無いからです。
閾値を決めるところは、自分のツール側に持たせています。
各閾値の横に出ているのは、学習に使っていない画像で実測した一致率です
(この画面の数字はツール側の学習設定によるもので、上の表とは学習の回し方が違います)。
「0.80 以上なら 91.3%、それで 120 件が自動で埋まる」と分かった上で決められます。
7. 増え続ける画像をどう回すか
ここまでは 1 回の学習の話でした。実際のサービスでは画像が毎日増えるので、
「新しく来た画像に候補を付ける → 低い帯だけ人が確定する → 増えたラベルで学習し直す」 を回すことになります。
この一連(ラベル付け → データセット → 学習 → 候補付け)を手元で回すために作っているのが xima です。
上の実験もそのまま通してあり、記事と同じ分割の 1 つで val_acc 0.822 でした。
手書きの線形プローブ(1 クラス 34 枚で平均 0.807、最大 0.889)と同じ範囲です。
「候補 269 件が未確認」は、ラベルを付けていない残りに候補が付いた状態です。確定したものだけがラベルになり、
次の学習に入ります。この 269 枚は運用の流れを見せるためのもので、精度の評価には使っていません
(§6 のとおり、ここには identifiable の正解が入っていないためです)。
まだ無いものも書いておくと、学習したものを 1 つのファイルに固めて他所へ持ち出す機能はありません。
いまは .pt と JSON をそのまま持ち出す形です。
まとめ
- CLIP のゼロショットは、プロンプトで書ける軸には強く、書けない軸には当たらない
- 当たらない理由は「埋め込みに情報が無い」ではなく「読み出し方が固定されている」ことが多い
- 線形層 1 枚を 1 クラス 5 枚のラベルで学習させるだけで、20 ポイント上がることがある
- そこから先の伸びは鈍い。 早く分かるので、ラベルを大量に作る前に方針を決められる
- 検証が数十枚なら、分割を引き直して平均を取る。1 回では 0.05 以上動く
- 評価データに全クラスの正解が入っているかを確かめる。 この記事は初出で外して、公開後に直した
- 全体の正解率はクラス別の内訳を隠す。いちばん見逃したくないクラスの recall を見る
- 確信度は絶対値で切らない。順に並べて、上位から何割を自動に回すかで決める
自分の分類軸を持っているなら、まずゼロショットで測って、次に線形プローブで測る。
この 2 つの数字の差が、そのまま「自分でラベルを付ける価値」になります。
使ったもの
- OpenAI CLIP(ViT-B/32)
- 写真は Unsplash の公開素材
- 手元で回すのに使ったツール: xima


