メインコンテンツまでスキップ

CLIPとは?

CLIPの概要
(画像は、Google AI Studioの「Nano Banana 2」モデルを用いて作成されたものです)

CLIPの概要

CLIP(Contrastive Language-Image Pre-training)は、OpenAIによって2021年に開発・発表された革新的なマルチモーダルモデルです(論文はLearning Transferable Visual Models From Natural Language Supervisionです)。

従来の画像分類モデルは、あらかじめ決められた特定のクラス(例えば10種類の動物)のみを識別するように学習されており、学習データに含まれない未知のクラス(11番目の新しい動物など)を分類することはできませんでした。これに対しCLIPは、インターネット上の膨大な「画像とそれを説明するテキスト(自然言語)」のペアを使用し、両者を共通の特徴空間上で近づける対照学習(Contrastive Learning)を行います。

画像と自然言語を直接対応付けるこのアプローチにより、CLIPは事前に特定のラベルで再学習を行うことなく、任意のテキスト指示を与えるだけで未知の画像分類や検索を行うZero-Shot(ゼロショット)分類を可能にしました。

CLIPの処理概要

CLIPの処理の流れ
(画像は、Geminiを用いて作成されたものです)

CLIPは上図のように、入力された画像とテキストのペアから独立したエンコーダを用いて特徴量を抽出し、最終的にそれらのコサイン類似度が最大化するようにコントラスティブ学習(対照学習)を行うモデルです。

画像エンコーダにはModifiedResNetまたはVisionTransformerが選択でき、前者は通常のGlobal Average Poolingの代わりに、クエリ・キー・バリュー(QKV)を用いたAttentionPool2dを採用しているのが特徴です。一方のテキストエンコーダには標準的なTransformerが採用されており、未来の単語への注目を防ぐ因果マスク(Causal Mask)を適用しながら処理を行います。テキスト特徴量は、文章の終わりを示すEOTトークンの位置からピンポイントで抽出され、線形投影(Text Projection)によって画像と同じ次元数へと変換されます。

こうして得られた両者の特徴量はL2正規化され、バッチ内のすべての組み合わせについて内積を計算することで、巨大な類似度行列(Logits Matrix)が構築されます。正しいペア(対角成分)の類似度は高く、異なるペア(非対角成分)の類似度は低くなるよう、双方向の交差エントロピー損失を計算してモデルを最適化します。また、CLIPの実装では、これら一連のエンコードから類似度行列の計算にいたるまで、モデル全体をFP16(半精度)に変換して行うことで、高速かつ省メモリな演算を実現しています。

CLIPの実装(概念的なシンプルな実装)

実際のCLIPでは、画像エンコーダとして改良版のResNet(ModifiedResNet)またはVision Transformer(ViT)のいずれかを選択できますが、本記事の実装例では構造がシンプルなVision Transformer(ViT)を採用して実装します。

CLIPの全体像

以下のコードでは、画像エンコーダとテキストエンコーダを統合し、CLIPの主要な機能であるマルチモーダルな類似度(Logits)を計算する簡易的なモデルMiniCLIPを定義します。

import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
from datasets import load_dataset
from transformers import AutoTokenizer
import requests
from PIL import Image
from io import BytesIO
import matplotlib.pyplot as plt
import japanize_matplotlib


class MiniCLIP(nn.Module):
def __init__(self, embed_dim=128, vocab_size=256, pad_token_id=0):
super().__init__()
self.image_encoder = ViTEncoder(embed_dim=embed_dim)
# pad_token_idを渡す
self.text_encoder = TextTransformer(vocab_size=vocab_size, embed_dim=embed_dim, pad_token_id=pad_token_id)
self.temperature = nn.Parameter(torch.ones([]) * 0.07)

def forward(self, images, texts):
image_embeds = self.image_encoder(images)
text_embeds = self.text_encoder(texts)

image_embeds = F.normalize(image_embeds, p=2, dim=-1)
text_embeds = F.normalize(text_embeds, p=2, dim=-1)

logits = (image_embeds @ text_embeds.T) * torch.exp(self.temperature)
return logits, image_embeds, text_embeds

このコードでは、画像とテキストを共通の特徴空間に埋め込み、その類似度を計算するMiniCLIPクラスを実装しています。

初期化メソッドであるinitでは、画像の特徴量を抽出するViTEncoderと、テキストの特徴量を抽出するTextTransformerをそれぞれサブモジュールとして定義しています。また、類似度を正規化するためのスケーリングパラメータであるself.temperatureを定義し、初期値を0.07としてnn.Parameterで登録しています。

forwardメソッドの処理手順は以下の通りです。

  1. 入力された画像テンソルimagesとテキストトークンtextsを、それぞれのエンコーダに通して特徴量(image_embedstext_embeds)を抽出します。
  2. 抽出された特徴量に対して、F.normalizeを用いてL2正規化(p=2, dim=-1)を適用し、ノルムが1の単位ベクトルへと変換します。
  3. 正規化された画像とテキストの特徴量同士を行列積(image_embeds @ text_embeds.T)によって内積(コサイン類似度)を計算します。
  4. 計算された類似度行列に対し、温度パラメータの指数関数値(torch.exp(self.temperature))を掛け合わせることで、類似度スコアのコントラスト(分布のシャープさ)を調整したロジット行列(logits)を生成して返します。

ViTEncoderの実装

以下のコードでは、入力された画像を複数のパッチに分割し、Transformer Encoderを通して画像全体の代表的な特徴ベクトルを出力するViTEncoderクラスを定義します。

class ViTEncoder(nn.Module):
def __init__(self, image_size=32, patch_size=8, in_chans=3, d_model=128, nhead=4, num_layers=2, embed_dim=128):
super().__init__()
self.patch_embed = nn.Conv2d(in_chans, d_model, kernel_size=patch_size, stride=patch_size)
num_patches = (image_size // patch_size) ** 2
self.pos_embed = nn.Parameter(torch.randn(1, num_patches, d_model) * 0.02)
encoder_layer = nn.TransformerEncoderLayer(d_model=d_model, nhead=nhead, batch_first=True, activation="gelu")
self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
self.output_proj = nn.Linear(d_model, embed_dim)

def forward(self, x):
x = self.patch_embed(x)
x = x.flatten(2).transpose(1, 2)
x = x + self.pos_embed
x = self.transformer(x)
x = x.mean(dim=1)
return self.output_proj(x)

このコードでは、Vision Transformer(ViT)の仕組みに基づいた画像エンコーダであるViTEncoderクラスを実装しています。

初期化メソッドであるinitでは、まず入力画像(チャネル数in_chans)からパッチを切り出しつつ特徴次元数(d_model)に投影するための畳み込み層self.patch_embedを定義します(kernel_size=patch_size, stride=patch_size)。また、各パッチの位置情報を保持するための学習可能なパラメータである位置エンコーディングself.pos_embedをテンソルサイズ(1, num_patches, d_model)で定義します。アテンション計算部には、nn.TransformerEncoderLayerとそれらを重ねたnn.TransformerEncoderを使用し、最終的にモデルの共通特徴空間の次元数(embed_dim)へと線形変換する全結合層self.output_projを設けています。

フォワードパス(forward)の処理手順は以下の通りです。

  1. 入力画像xself.patch_embed(x)に通し、(Batch, d_model, H_patch, W_patch)の次元にします。
  2. これをx.flatten(2).transpose(1, 2)で処理することで、(Batch, パッチ数, d_model)の二次元シーケンスデータへ平坦化および転置します。
  3. 平坦化したテンソルに位置エンコーディングであるself.pos_embedを加算(ブロードキャスト)し、パッチの空間的配置情報を与えます。
  4. self.transformerに入力してパッチ間のSelf-Attention処理を行った後、x.mean(dim=1)によって空間(パッチ)次元の平均を取ることで、画像全体を集約したグローバルな特徴量を抽出します。
  5. 最後にself.output_projによって、画像・テキストで共通の表現次元数へと射影して特徴ベクトルを出力します。

TextTransformerの実装

以下のコードでは、入力されたテキストトークンに対して位置エンコーディングと因果マスクを適用し、文末トークン(EOT)の位置から代表的なテキスト特徴量を抽出するTextTransformerクラスを定義します。

class TextTransformer(nn.Module):
def __init__(self, vocab_size, max_seq_len=32, d_model=128, nhead=4, num_layers=2, embed_dim=128, pad_token_id=0):
super().__init__()
self.token_embed = nn.Embedding(vocab_size, d_model)
self.pos_embed = nn.Parameter(torch.randn(1, max_seq_len, d_model) * 0.02)

encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model, nhead=nhead, batch_first=True, activation="gelu"
)
self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
self.output_proj = nn.Linear(d_model, embed_dim)

# パディングトークンのID(EOTトークンの位置を探すために必要)
self.pad_token_id = pad_token_id

def forward(self, x):
# x: [B, seq_len]
B, seq_len = x.shape

# 1. Causal Mask(下三角行列)の作成
# PyTorchの組み込み関数を使い、未来のトークンへのAttentionを -inf(無視)にします
causal_mask = nn.Transformer.generate_square_subsequent_mask(seq_len).to(x.device)

# 2. 埋め込みとTransformerの適用
embeds = self.token_embed(x) + self.pos_embed[:, :seq_len, :]

# ★ ここで mask=causal_mask を渡すことでDecoderのように振る舞う
# is_causal=True をつけるとPyTorch 2.0以降で高速化(FlashAttention)が効きます
out = self.transformer(embeds, mask=causal_mask, is_causal=True)

# パディングされていない「一番最後の有効なトークン」のインデックスを探します
eot_indices = (x != self.pad_token_id).sum(dim=1) - 1

# バッチごとに、文末トークンの最終層の出力を抽出する -> [B, d_model]
eot_features = out[torch.arange(B, device=x.device), eot_indices]

# 4. 最終射影
return self.output_proj(eot_features)

このコードでは、テキスト情報から特徴量を抽出するTextTransformerクラスを実装しています。

テキストエンコーダの実装における重要な技術的ポイントとして、PyTorchのnn.TransformerDecoderではなく、nn.TransformerEncoderを使用している点が挙げられます。通常、GPTなどの因果モデル(未来のトークンをマスクするデコーダモデル)を実装する際にはnn.TransformerDecoderを使用することが多いですが、PyTorchのデコーダモジュールはエンコーダからの出力(memory)を入力として受け取るCross-Attentionの計算を前提とした設計になっています。今回のテキストエンコーダ単体でのアテンション処理においては、エンコーダからの参照入力(memory)が不要です。そのため、本実装ではnn.TransformerEncoderを使用し、未来のトークンへのアクセスを遮断する因果マスク(Causal Mask)を明示的に適用することで、デコーダと同等の動作をシンプルかつ効率的に実現しています。

また、文章の終わりを示すEOTトークンに相当する位置の特徴をピンポイントで抽出する設計になっていることも、CLIP特有のアプローチです。

初期化メソッドであるinitでは、トークンIDをベクトルに変換するself.token_embednn.Embedding)、位置情報を埋め込むself.pos_embed、アテンションブロックを重ねるためのself.transformernn.TransformerEncoder)、および共通の特徴量次元に射影する全結合層self.output_projを定義しています。また、パディングトークンを無視してEOT(End of Text)を検出するためのself.pad_token_idを保持します。

フォワードパス(forward)の処理手順は以下の通りです。

  1. 入力されたテキストトークンIDのテンソルに対し、nn.Transformer.generate_square_subsequent_maskを用いて、未来の情報を参照させないための因果マスク(下三角行列)であるcausal_maskを生成します。
  2. トークン埋め込みに位置情報を足し合わせた特徴量(embeds)を計算し、これを因果マスクとともにself.transformerへと入力します。この際、is_causal=Trueを指定することで、PyTorchの内部実装による高速アテンション計算(FlashAttention)の最適化を効かせています。
  3. 入力されたトークンIDxから、パディングでない有効なトークン数を各バッチごとにカウントし((x != self.pad_token_id).sum(dim=1) - 1)、文末トークン(EOT)の位置を指すインデックスeot_indicesを取得します。
  4. Transformerの出力テンソルから、各バッチのEOTトークンに相当する位置の特徴量のみをout[torch.arange(B), eot_indices]によってピンポイントで抽出します。
  5. 抽出した文末の特徴量をself.output_projによって共通の次元数へと投影し、テキストの特徴ベクトルを出力します。

データセット読み込みの補助関数の定義

以下のコードでは、日本語キャプション付きの画像データセット「DEJIMA」からデータを読み込む際、各データが持つURLから画像をインターネット経由で動的にダウンロードし、テキストをトークナイズしてバッチを構築するカスタム関数collate_fnを定義します。

def collate_fn(batch, tokenizer, transform):
images = []
texts = []

for item in batch:
try:
# 1. URLから画像をダウンロード
# timeoutを設定して、応答がないサーバーで学習が止まるのを防ぐ
response = requests.get(item['url'], timeout=3)
response.raise_for_status() # 404エラーなどを弾く

image = Image.open(BytesIO(response.content)).convert('RGB')
images.append(transform(image))

# 2. 'caption' キーからテキストを取得
texts.append(item['caption'])

except Exception as e:
# リンク切れや画像でないデータはスキップする
continue

# 全てのURLがリンク切れ等でバッチが空になった場合はNoneを返す
if len(images) == 0:
return None, None

images = torch.stack(images)
text_tokens = tokenizer(
texts,
padding='max_length',
truncation=True,
max_length=32,
return_tensors='pt'
)['input_ids']

return images, text_tokens

このコードは、DataLoaderがデータセットから各バッチを構築する際に呼び出されるカスタムバッチ生成関数であるcollate_fnの実装です。インターネット上のURLから直接画像をダウンロードして処理するため、リンク切れなどのエラー処理が組み込まれています。

関数内の処理フローは以下の通りです。

  1. バッチ内の各アイテムについて、辞書のurlキーから画像のURLを取り出し、requests.getを実行してインターネット経由で画像をダウンロードします。この際、応答のないサーバーによって学習全体がフリーズするのを防ぐためにtimeout=3(秒)を設定しています。
  2. ダウンロードに成功した場合、Image.openで読み込み、convert('RGB')によってRGB形式のPIL画像に変換した上で、引数で与えられた前処理transformを適用してリストに格納します。また、正解のテキストとしてcaptionキーから日本語テキストをリストへ追加します。
  3. ダウンロードエラーや無効なデータが発生した場合は、try-exceptブロック内でキャッチしてスキップし、他の正常なデータのみでバッチの処理を継続します。
  4. バッチ内の画像がすべて取得できなかった場合はNone, Noneを返し、それ以外の場合はリスト化された画像テンソルをtorch.stackで1つのバッチテンソルに結合します。
  5. テキストリストを引数のtokenizerに渡し、最大トークン数32(max_length=32)でパディング(padding='max_length')と切り捨て(truncation=True)を行い、PyTorchテンソル形式のトークンID(input_ids)として取得します。これらを画像テンソルとペアにして返します。

CLIPモデルの学習

データセットの利用ライセンスについて

本記事で使用する「DEJIMA」データセットは、クリエイティブ・コモンズ(CC BY-SA 4.0)ライセンスの下で提供されています。ライセンス要件に従い、適切なクレジット表記(原著者、論文情報、ライセンスへのリンク)が必要となります。詳細は記事の末尾に記載しています。

本記事の学習および推論テストで使用する「DEJIMA」データセットは、勝部氏らの研究チームによって作成された、画像キャプションおよび視覚的質問応答(VQA)用の大規模日本語データセットです(論文は DEJIMA: A Novel Large-scale Japanese Dataset for Image Captioning and Visual Question Answering です)。

以下のコードでは、日本語トークナイザや「DEJIMA」データセットのサブセットを読み込み、定義したモデルと最適化器を用いて、画像とテキストの類似度を双方向から最大化する対照学習(コントラスティブ学習)のループ処理を実行します。

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")

print("Loading Japanese Tokenizer...")
tokenizer = AutoTokenizer.from_pretrained("cl-tohoku/bert-base-japanese-v3")

print("Loading DEJIMA dataset (subset)...")
dataset = load_dataset("MIL-UT/DEJIMA-dataset", "cap-simple", split="train[:1000]")

transform = transforms.Compose([
transforms.Resize((32, 32)),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])

trainloader = DataLoader(
dataset,
batch_size=32,
shuffle=True,
collate_fn=lambda b: collate_fn(b, tokenizer, transform)
)

# トークナイザから pad_token_id を取得してモデルに渡す
model = MiniCLIP(
embed_dim=128,
vocab_size=tokenizer.vocab_size,
pad_token_id=tokenizer.pad_token_id # ★追加
).to(device)

optimizer = torch.optim.Adam(model.parameters(), lr=3e-4)
loss_fn = nn.CrossEntropyLoss()

print("\n--- Training Start (DEJIMA dataset) ---")
model.train()

for batch_idx, batch_data in enumerate(trainloader):
images, text_tokens = batch_data

# リンク切れでバッチ内の全画像が取得できなかった場合はスキップ
if images is None:
continue

images = images.to(device)
text_tokens = text_tokens.to(device)

optimizer.zero_grad()
logits, _, _ = model(images, text_tokens)

targets = torch.arange(len(images), device=device)
loss_i2t = loss_fn(logits, targets)
loss_t2i = loss_fn(logits.T, targets)
loss = (loss_i2t + loss_t2i) / 2.0

loss.backward()
optimizer.step()

# ロスの表示(有効なデータが取得できたバッチのみ表示されます)
print(f"Batch {batch_idx}, Loss: {loss.item():.4f}, Valid Samples: {len(images)}")

このコードでは、構築したMiniCLIPモデルを日本語画像キャプションデータセット「DEJIMA」を用いて訓練するループを実装しています。

前処理として、東北大学が提供する日本語BERTのトークナイザ(cl-tohoku/bert-base-japanese-v3)をロードし、データセットのサブセット(先頭の1,000件)を取得しています。画像は32x32ピクセルにリサイズし、正規化を適用した上で、カスタムバッチ生成関数であるcollate_fnをDataLoaderに登録しています。

訓練ループ(for)内の処理は以下の通りです。

  1. DataLoaderから、動的に取得された画像テンソルとトークン化されたテキストIDを取得し、指定のデバイス(GPUまたはCPU)へ転送します。
  2. optimizer.zero_grad()で前ステップの勾配を初期化した後、画像とテキストをモデルに入力して各ペア間のコサイン類似度行列であるlogitsを計算します。
  3. コントラスティブ学習の損失(Loss)の計算では、バッチサイズに応じた正解インデックスのテンソルであるtargetstorch.arange(len(images)))を作成します。これは、対角成分(同じインデックスのペア)が正解であることを示します。
  4. 「画像からテキストを予測する損失(loss_i2t)」と「テキストから画像を予測する損失(loss_t2i)」をそれぞれnn.CrossEntropyLossを用いて計算します。後者は類似度行列を転置(logits.T)して計算します。
  5. 双方向の損失の平均値(loss = (loss_i2t + loss_t2i) / 2.0)を最終損失とし、loss.backward()で誤差逆伝播を行い、optimizer.step()で重みを更新します。

学習結果の確認

ここでは、DEJIMAデータセットを用いて訓練を行った際のコンソール出力を示しています。

バッチごとのログを確認すると、初期のBatch 0ではLoss: 3.1365だった損失値が、学習が進むにつれて徐々に低下し、最終バッチであるBatch 31ではLoss: 1.9515まで順調に減少していることがわかります。これは、画像と対応するテキストペアのコサイン類似度が高まる方向へ、モデルが正しく最適化されていることを示しています。

また、各バッチの有効サンプル数を示すValid Samplesが、データローダのバッチサイズである32よりも少ない値(例: 23や25など)で推移しています。これは、前述のcollate_fnによって、データセット内の画像URLがリンク切れやタイムアウトなどのエラーになった際に、学習を中断させずに自動でスキップした結果です。さらに、最後のBatch 31Valid Samples: 7となっているのは、全1,000件のデータを32件ずつ処理した際の端数とリンク切れによるスキップが重なったためです。

--- Training Start (DEJIMA dataset) ---
Batch 0, Loss: 3.1365, Valid Samples: 23
Batch 1, Loss: 3.2154, Valid Samples: 25
Batch 2, Loss: 3.1925, Valid Samples: 24
Batch 3, Loss: 3.2548, Valid Samples: 26
Batch 4, Loss: 3.2464, Valid Samples: 26
Batch 5, Loss: 2.9835, Valid Samples: 20
Batch 6, Loss: 3.2733, Valid Samples: 27
Batch 7, Loss: 3.1501, Valid Samples: 24
Batch 8, Loss: 3.0292, Valid Samples: 21
Batch 9, Loss: 2.9589, Valid Samples: 20
Batch 10, Loss: 3.2284, Valid Samples: 25
Batch 11, Loss: 2.8891, Valid Samples: 19
Batch 12, Loss: 3.1281, Valid Samples: 23
Batch 13, Loss: 3.1995, Valid Samples: 24
Batch 14, Loss: 3.1601, Valid Samples: 24
Batch 15, Loss: 3.1709, Valid Samples: 25
Batch 16, Loss: 3.0598, Valid Samples: 22
Batch 17, Loss: 3.2912, Valid Samples: 27
Batch 18, Loss: 3.1431, Valid Samples: 25
Batch 19, Loss: 2.8667, Valid Samples: 19
Batch 20, Loss: 3.1593, Valid Samples: 26
Batch 21, Loss: 3.0455, Valid Samples: 23
Batch 22, Loss: 3.2474, Valid Samples: 26
Batch 23, Loss: 3.0557, Valid Samples: 23
Batch 24, Loss: 3.2328, Valid Samples: 27
Batch 25, Loss: 3.1560, Valid Samples: 25
Batch 26, Loss: 3.0584, Valid Samples: 22
Batch 27, Loss: 3.0400, Valid Samples: 22
Batch 28, Loss: 3.1218, Valid Samples: 24
Batch 29, Loss: 3.1243, Valid Samples: 25
Batch 30, Loss: 3.1202, Valid Samples: 25
Batch 31, Loss: 1.9515, Valid Samples: 7

CLIPモデルの評価

以下のコードでは、学習済みのモデルを用いて、テスト用の任意の画像に対して、複数の日本語テキスト候補の中から最も類似度(確率)が高いものを判定するZero-Shot推論のデモンストレーションを実行します。

def test_inference(model, dataset, tokenizer, device, transform):
print("\n--- Zero-Shot Inference Test ---")
model.eval()

# 1. 有効な画像(リンク切れでないもの)を1枚取得する
valid_item = None
pil_image = None
for item in dataset:
try:
response = requests.get(item['url'], timeout=3)
response.raise_for_status()
pil_image = Image.open(BytesIO(response.content)).convert('RGB')
valid_item = item
break # 取得できたらループを抜ける
except:
continue

if valid_item is None:
print("テスト用の有効な画像が見つかりませんでした。")
return

true_caption = valid_item['caption']

# 2. 推論用のテキスト候補を作成(正解+ダミーの選択肢)
candidate_texts = [
"美味しいラーメンの写真",
"可愛い猫の写真",
"青空と綺麗な海",
"車の写真",
true_caption # 正解のキャプション
]

# 3. 前処理(画像はバッチ次元[1, C, H, W]を追加)
image_tensor = transform(pil_image).unsqueeze(0).to(device)
text_tokens = tokenizer(
candidate_texts,
padding='max_length',
truncation=True,
max_length=32,
return_tensors='pt'
)['input_ids'].to(device)

# 4. モデルによる類似度計算
with torch.no_grad():
logits, _, _ = model(image_tensor, text_tokens)
probs = F.softmax(logits, dim=-1).squeeze() # 確率に変換

# 5. 結果をコンソールに表示
print(f"【正解キャプション】: {true_caption}\n")
print("【予測確率】:")
probs_np = probs.cpu().numpy()

# 確率が高い順にソートして表示
results = sorted(zip(candidate_texts, probs_np), key=lambda x: x[1], reverse=True)
for text, prob in results:
print(f"{prob*100:>5.1f}% : {text}")

# 6. 画像の表示
plt.figure(figsize=(5, 5))
plt.imshow(pil_image)
plt.axis('off')

# Matplotlibのデフォルトでは日本語が文字化けするため、タイトルは英語にしつつ
# コンソール出力(print)で日本語の予測結果を確認する設計にしています。
best_text = results[0][0]
plt.title("Test Image (Check Console for Prediction)")
plt.show()


test_inference(model, dataset, tokenizer, device, transform)

このコードでは、データセットからテスト用の有効な画像(リンク切れでないもの)を1枚取得し、その画像と複数のテキスト候補(正解キャプションおよび無関係なダミーテキスト)との類似度を計算して分類するZero-Shot推論の実装です。

推論関数であるtest_inferenceの処理は以下の通りです。

  1. データセット内のアイテムからURL経由で正常にダウンロードできる画像を探し、最初に見つかった有効な画像と正解のキャプション(true_caption)を取得します。
  2. 推論時にモデルへ入力するテキスト候補として、全く関係のないダミーの文字列(ラーメン、猫、海、車の説明文など)を定義したリスト(candidate_texts)を作成し、その中に正解キャプションを含めます。
  3. 画像をリサイズ・正規化し、バッチ次元を追加するためのunsqueeze(0)を適用してモデルに入力可能なテンソルを作成します。また、テキスト候補リストもまとめてトークナイズしてIDテンソルにします。
  4. torch.no_grad()によって勾配の計算を抑制した評価モードで、モデルから画像とテキスト間の類似度ロジット(logits)を計算します。これにF.softmaxを適用して、各テキスト候補の予測確率に変換します。
  5. 得られた確率値をテキストのラベルと結合し、確率の高い順にソートしてパーセンテージ形式でコンソールに表示します。また、予測された最も高いラベルのテキストをタイトルとして設定し、matplotlibでテスト用画像を表示します。

ここでは、学習済みのモデルを用いて任意の画像に対するZero-Shot推論テストを実行した結果を示しています。

出力結果を確認すると、正解のキャプションである「ねぶた山車灯籠のイラスト」に対する予測確率が30.7%となり、他のダミー候補(猫、車、ラーメン、海などの写真)を抑えて最も高い確率で予測できていることがわかります。

これは、モデルが今回の簡易的な対照学習を通じて、画像内の特徴(ねぶたのイラスト)と、それを表す日本語の自然言語テキストとの関連性を正しく学習し、未知の画像に対して高精度なZero-Shot分類を実行できたことを示しています。

--- Zero-Shot Inference Test ---
【正解キャプション】: ねぶた山車灯籠のイラスト

【予測確率】:
30.7% : ねぶた山車灯籠のイラスト
24.1% : 可愛い猫の写真
21.2% : 車の写真
13.6% : 美味しいラーメンの写真
10.4% : 青空と綺麗な海

テスト画像

実際のHugging Faceモデルを用いた推論テスト

ここまではCLIPの仕組みを理解するためにシンプルな概念モデルをゼロから実装してきましたが、実際の開発ではHugging Faceのtransformersライブラリを利用することで、事前学習済みの強力なモデルを数行のコードで呼び出して利用することができます。

以下のコードでは、Hugging Faceのtransformersライブラリを使用して、事前学習済みの本物のCLIPモデルとプロセッサをロードし、ダウンロードした任意の画像に対してZero-Shot分類を実行する実装を示します。

from transformers import CLIPProcessor, CLIPModel
from PIL import Image
import requests
import matplotlib.pyplot as plt


# 1. モデルとプロセッサの読み込み
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")

# 2. フリー素材サイトの画像URLを指定
# 例として、Pixabayのフリー素材(猫の画像)の直接リンクを使用しています。
# ここをご自身の好きな画像のURLに差し替えてください。
url = "https://cdn.pixabay.com/photo/2014/11/30/14/11/cat-551554_1280.jpg"

print("画像をダウンロードしています...")

# 3. サーバーにBotと判定されて弾かれないよう、User-Agentを偽装してリクエスト
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
}
image = Image.open(requests.get(url, stream=True).raw)
texts = ["a photo of a cat", "a photo of a dog"]

# 4. 前処理と推論(今回解説した処理が裏側で走ります)
inputs = processor(text=texts, images=image, return_tensors="pt", padding=True)
outputs = model(**inputs)

# 5. 類似度スコア(Logits)の取得
logits_per_image = outputs.logits_per_image # 画像から見たテキストの類似度
probs = logits_per_image.softmax(dim=-1) # 確率に変換
for i in range(len(texts)):
print(f"{texts[i]} : {probs[0,i]:.1%}")

# 6. サンプル画像の可視化
plt.figure(figsize=(6, 6))
plt.imshow(image)
plt.axis("off")
plt.tight_layout()
plt.show()

このコードでは、Hugging Faceのtransformersライブラリでロードした、公式の事前学習済みCLIPモデルを用いてZero-Shot分類を実行しています。

  1. CLIPModel.from_pretrainedおよびCLIPProcessor.from_pretrainedを使用して、OpenAIが提供する標準的な事前学習済みモデル(openai/clip-vit-base-patch32)とその前処理モジュールをロードします。
  2. requests.get(url, stream=True).rawを用いて指定のURLから画像データを取得し、Image.openで画像オブジェクトとして展開します。また、画像の分類基準となるラベル候補(猫と犬のテキスト)を定義します。
  3. processorに対して画像とテキストをまとめて渡すことで、画像のリサイズや正規化、テキストのトークン化とパディング(padding=True)を自動で適用し、モデル入力用のテンソル(inputs)を構築します。
  4. model(**inputs)を実行し、出力から画像に対する各テキストの類似度ロジットであるoutputs.logits_per_imageを抽出します。
  5. 類似度ロジットにsoftmaxを適用することで各ラベル候補の適合確率を計算し、最後にmatplotlibを用いて元の画像を描画しています。

上記のコードを実行すると以下の結果が得られます。

画像をダウンロードしています...
a photo of a cat : 99.6%
a photo of a dog : 0.4%

サンプル画像

実行結果を確認すると、画像に対して「a photo of a cat(猫の写真)」というテキスト候補の適合確率が99.6%と判定され、対照的な「a photo of a dog(犬の写真)」の確率(0.4%)を大きく引き離して圧倒的な最高スコアで正解していることがわかります。

モデルに対して「猫」や「犬」を明示的に分類するための特別なファインチューニング(再学習)を一切施していないにもかかわらず、自然言語による指示テキストと画像の特徴量を正しくアラインメントし、Zero-Shotによる高度な画像識別が機能していることがこの出力から実証されています。

まとめ

本記事では、画像と自然言語を組み合わせた画期的なマルチモーダルモデルであるCLIPについて、その基本概念からPyTorchによるスクラッチ実装、日本語データセットを用いた学習実験、そして公式の事前学習済みモデルを用いた実践的な推論方法までを網羅して解説しました。

記事を通じて学習した主なポイントは以下の通りです。

  • 対照学習(Contrastive Learning)の仕組み: 画像とテキストのペアから抽出されたそれぞれの特徴量を正規化し、正しいペアの類似度を最大化する双方向の交差エントロピー損失を用いた最適化について理解しました。
  • Zero-Shot(ゼロショット)分類の強力さ: 従来の固定クラス分類モデルとは異なり、自然言語を用いた柔軟なラベル指定によって、未知のクラスに対しても追加の再学習なしで高精度な画像分類ができる有用性を学びました。
  • モジュールのスクラッチ実装: Vision TransformerをベースとしたViTEncoder、因果マスクとEOTトークン抽出を備えたTextTransformer、およびリンク切れを考慮した動的画像取得用のcollate_fnなど、CLIPの中核となるモジュールをPyTorchで再現しました。
  • 公式ライブラリの実践的な活用: Hugging Faceのtransformersライブラリを利用し、事前学習済みの本物のCLIPモデル(openai/clip-vit-base-patch32)と専用プロセッサをロードして、実用的な画像分類や結果の可視化を行う手順を習得しました。

CLIPの登場により、画像認識と自然言語処理の境界がなくなり、その技術はその後の画像生成AI(Stable Diffusionのテキストエンコーダなど)や大規模マルチモーダルモデル(LMM)の基礎として広く応用されています。本記事で学んだ概念理解とライブラリでの実用の両輪が、マルチモーダルAI技術を深く理解する手助けとなれば幸いです。


出典・ライセンスについて

  • DEJIMAデータセット: 勝部氏らによる日本語キャプション付きデータセット DEJIMA-dataset を使用しています。このデータセットおよび関連する論文は CC BY-SA 4.0 ライセンスの下で提供されています。
  • 推論テスト用の画像: Pixabayが提供する商用利用可能なフリー素材を使用しています。

本記事の文章・構成の一部に生成AIを使用しています。