EN Last sync: 2026-01-15

第2章: Vision-Languageモデル

CLIP、BLIP、LLaVA - クロスモーダルアーキテクチャを理解する

読了時間: 35-40分 コード例: 10 演習: 4

2.1 Vision-Languageモデル入門

Vision-Languageモデル(VLM)は、画像とテキストの両方を理解し推論できるマルチモーダルAIシステムです。画像キャプションから視覚的質問応答まで、AIの重要な進歩を代表しています。

VLMの主要な能力

2.2 CLIP: 対照的言語画像事前学習

CLIPは2021年にOpenAIがリリースし、大規模な対照学習が強力なクロスモーダル表現を作成できることを実証し、Vision-Languageモデルに革命をもたらしました。

CLIPアーキテクチャ

graph TB subgraph Input I[画像] T[テキスト] end subgraph Encoders VE[ビジョンエンコーダー
ViT/ResNet] TE[テキストエンコーダー
Transformer] end subgraph Embeddings IE[画像埋め込み
512/768次元] TXE[テキスト埋め込み
512/768次元] end I --> VE T --> TE VE --> IE TE --> TXE IE --> CS[コサイン類似度] TXE --> CS CS --> CL[対照学習損失] style VE fill:#e3f2fd style TE fill:#fff3e0 style CS fill:#9b59b6,color:white

対照学習の目的

CLIPは、マッチする画像-テキストペア間の類似度を最大化し、マッチしないペアとの類似度を最小化することで学習します。

InfoNCE損失

$$\mathcal{L}_{\text{CLIP}} = -\frac{1}{N}\sum_{i=1}^{N}\left[\log\frac{\exp(\text{sim}(v_i, t_i)/\tau)}{\sum_{j=1}^{N}\exp(\text{sim}(v_i, t_j)/\tau)}\right]$$

ここで \(\text{sim}(v, t)\) はコサイン類似度、\(\tau\) は温度パラメータです。

CLIPの実装

# CLIPによるゼロショット画像分類
from transformers import CLIPProcessor, CLIPModel
from PIL import Image
import requests
import torch

# CLIPモデルの読み込み
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")

# 画像の読み込み
url = "https://images.unsplash.com/photo-1560807707-8cc77767d783?w=400"
image = Image.open(requests.get(url, stream=True).raw)

# 候補ラベルの定義
labels = ["犬の写真", "猫の写真", "鳥の写真"]

# 入力の処理
inputs = processor(
    text=labels,
    images=image,
    return_tensors="pt",
    padding=True
)

# 類似度スコアの取得
outputs = model(**inputs)
logits_per_image = outputs.logits_per_image
probs = logits_per_image.softmax(dim=1)

# 結果の表示
for label, prob in zip(labels, probs[0]):
    print(f"{label}: {prob.item():.2%}")
# 出力: "犬の写真: 98.5%"

CLIPによる画像-テキスト検索

# CLIPを使った画像-テキスト検索
import torch
from transformers import CLIPProcessor, CLIPModel

class CLIPRetriever:
    def __init__(self, model_name="openai/clip-vit-base-patch32"):
        self.model = CLIPModel.from_pretrained(model_name)
        self.processor = CLIPProcessor.from_pretrained(model_name)
        self.image_embeddings = None
        self.images = []

    def index_images(self, images):
        """画像データベースの埋め込みを事前計算"""
        self.images = images
        inputs = self.processor(images=images, return_tensors="pt", padding=True)

        with torch.no_grad():
            self.image_embeddings = self.model.get_image_features(**inputs)
            self.image_embeddings = self.image_embeddings / self.image_embeddings.norm(dim=-1, keepdim=True)

    def search(self, query_text, top_k=5):
        """テキストクエリに最も類似した画像を検索"""
        inputs = self.processor(text=[query_text], return_tensors="pt", padding=True)

        with torch.no_grad():
            text_embedding = self.model.get_text_features(**inputs)
            text_embedding = text_embedding / text_embedding.norm(dim=-1, keepdim=True)

        # 類似度を計算
        similarities = (text_embedding @ self.image_embeddings.T).squeeze()
        top_indices = similarities.argsort(descending=True)[:top_k]

        return [(self.images[i], similarities[i].item()) for i in top_indices]

# 使用例
retriever = CLIPRetriever()
retriever.index_images(image_database)
results = retriever.search("海に沈む夕日")

2.3 BLIPとBLIP-2: ブートストラップVision-Language

BLIP(Bootstrapping Language-Image Pre-training)は、対照学習と生成目標を組み合わせることでCLIPを改良しています。

BLIP-2アーキテクチャ: Q-Former

graph TB subgraph Frozen["凍結コンポーネント"] VE2[ビジョンエンコーダー
ViT-G/14] LLM[大規模言語モデル
OPT/FlanT5] end subgraph Trainable["学習可能なQ-Former"] QT[学習クエリ
32トークン] SA[セルフアテンション] CA[クロスアテンション
視覚特徴へ] end I2[画像] --> VE2 VE2 --> VF[視覚特徴] VF --> CA QT --> SA SA --> CA CA --> QO[クエリ出力] QO --> FC[線形射影] FC --> LLM T2[テキストプロンプト] --> LLM LLM --> OUT[生成テキスト] style QT fill:#9b59b6,color:white style SA fill:#9b59b6,color:white style CA fill:#9b59b6,color:white

Q-Former: 主要なイノベーション

Q-Former(Querying Transformer)は凍結された画像エンコーダーとLLMを橋渡しします。

# BLIP-2による画像キャプション生成
from transformers import Blip2Processor, Blip2ForConditionalGeneration
from PIL import Image
import torch

# BLIP-2モデルの読み込み
processor = Blip2Processor.from_pretrained("Salesforce/blip2-opt-2.7b")
model = Blip2ForConditionalGeneration.from_pretrained(
    "Salesforce/blip2-opt-2.7b",
    torch_dtype=torch.float16
)
model.to("cuda")

# 画像の処理
image = Image.open("example.jpg")
inputs = processor(image, return_tensors="pt").to("cuda", torch.float16)

# キャプション生成
generated_ids = model.generate(**inputs, max_new_tokens=50)
caption = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
print(f"キャプション: {caption}")

BLIP-2による視覚的質問応答

# BLIP-2による視覚的QA
def ask_about_image(image_path, question):
    image = Image.open(image_path)

    # QA用のプロンプトフォーマット
    prompt = f"Question: {question} Answer:"

    inputs = processor(image, prompt, return_tensors="pt").to("cuda", torch.float16)

    generated_ids = model.generate(
        **inputs,
        max_new_tokens=30,
        num_beams=5,
        early_stopping=True
    )

    answer = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
    return answer.strip()

# 使用例
answer = ask_about_image("street_scene.jpg", "この画像には何人いますか?")
print(f"回答: {answer}")  # "この画像には5人います"

2.4 LLaVA: 視覚的指示チューニング

LLaVA(Large Language and Vision Assistant)は、シンプルなアーキテクチャでも視覚的指示チューニングにより印象的な結果を達成できることを実証しています。

LLaVAアーキテクチャ

graph LR subgraph Vision["ビジョンエンコーダー(CLIP)"] IMG[画像] --> CLIP[CLIP ViT-L/14] CLIP --> VT[視覚トークン
576トークン] end subgraph Projection["射影層"] VT --> MLP[2層MLP
GELU活性化] MLP --> PV[射影された視覚
LLM次元] end subgraph LLM["言語モデル"] PV --> Vicuna[Vicuna/LLaMA] TXT[テキストトークン] --> Vicuna Vicuna --> OUT2[応答] end style MLP fill:#9b59b6,color:white

LLaVAの学習戦略

段階 データ 学習対象 目的
事前学習 558K画像-キャプションペア 射影層のみ 視覚特徴をLLM空間にアライン
指示チューニング 158K視覚指示 全モデル 視覚的指示に従うことを学習

LLaVAの実装

# LLaVAによる視覚的会話
from transformers import LlavaProcessor, LlavaForConditionalGeneration
from PIL import Image
import torch

# LLaVAモデルの読み込み
model_id = "llava-hf/llava-1.5-7b-hf"
processor = LlavaProcessor.from_pretrained(model_id)
model = LlavaForConditionalGeneration.from_pretrained(
    model_id,
    torch_dtype=torch.float16,
    device_map="auto"
)

def chat_with_image(image_path, user_message):
    image = Image.open(image_path)

    # LLaVA会話フォーマット
    conversation = [
        {
            "role": "user",
            "content": [
                {"type": "image"},
                {"type": "text", "text": user_message}
            ]
        }
    ]

    prompt = processor.apply_chat_template(conversation, add_generation_prompt=True)
    inputs = processor(prompt, image, return_tensors="pt").to("cuda")

    output = model.generate(**inputs, max_new_tokens=200)
    response = processor.decode(output[0], skip_special_tokens=True)

    return response

# マルチターン会話
response1 = chat_with_image("kitchen.jpg", "この画像に何が見えますか?")
print(response1)

response2 = chat_with_image("kitchen.jpg", "見える材料で何が作れますか?")
print(response2)

2.5 視覚的トークン化戦略

画像がトークンに変換される方法は、VLMの性能に大きく影響します。

パッチベースのトークン化(ViTスタイル)

# パッチベースの視覚的トークン化
import torch
import torch.nn as nn

class PatchEmbedding(nn.Module):
    def __init__(self, img_size=224, patch_size=16, in_channels=3, embed_dim=768):
        super().__init__()
        self.img_size = img_size
        self.patch_size = patch_size
        self.n_patches = (img_size // patch_size) ** 2  # 224/16で196

        # パッチを埋め込み次元に射影
        self.proj = nn.Conv2d(
            in_channels,
            embed_dim,
            kernel_size=patch_size,
            stride=patch_size
        )

        # 学習可能な位置埋め込み
        self.pos_embed = nn.Parameter(torch.randn(1, self.n_patches + 1, embed_dim))
        self.cls_token = nn.Parameter(torch.randn(1, 1, embed_dim))

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

        # パッチ埋め込みを作成: (B, C, H, W) -> (B, embed_dim, H/P, W/P)
        x = self.proj(x)

        # フラット化: (B, embed_dim, n_patches^0.5, n_patches^0.5) -> (B, n_patches, embed_dim)
        x = x.flatten(2).transpose(1, 2)

        # CLSトークンを追加
        cls_tokens = self.cls_token.expand(B, -1, -1)
        x = torch.cat([cls_tokens, x], dim=1)

        # 位置埋め込みを追加
        x = x + self.pos_embed

        return x  # (B, n_patches + 1, embed_dim)

# 例: 224x224画像 -> 197トークン(196パッチ + 1 CLS)
patch_embed = PatchEmbedding()
image = torch.randn(1, 3, 224, 224)
tokens = patch_embed(image)
print(f"視覚トークンの形状: {tokens.shape}")  # (1, 197, 768)

トークン化アプローチの比較

手法 トークン数/画像 解像度 使用モデル
ViT-B/32パッチ 49 + 1 CLS 224x224 CLIP
ViT-L/14パッチ 256 + 1 CLS 224x224 LLaVA
Q-Formerクエリ 32(固定) 任意 BLIP-2
高解像度ViT 576+ 336x336+ LLaVA-1.5

2.6 VLMにおけるクロスアテンションメカニズム

クロスアテンションにより、一方のモダリティが他方から情報を照会できます。

# クロスアテンションの実装
class CrossAttention(nn.Module):
    def __init__(self, dim, num_heads=8):
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = dim // num_heads
        self.scale = self.head_dim ** -0.5

        # 一方のモダリティからクエリ、他方からキー/バリュー
        self.q_proj = nn.Linear(dim, dim)
        self.k_proj = nn.Linear(dim, dim)
        self.v_proj = nn.Linear(dim, dim)
        self.out_proj = nn.Linear(dim, dim)

    def forward(self, query_tokens, kv_tokens):
        """
        query_tokens: (B, N_q, D) - 例: テキストトークン
        kv_tokens: (B, N_kv, D) - 例: 画像トークン
        """
        B, N_q, D = query_tokens.shape
        N_kv = kv_tokens.shape[1]

        # Q, K, Vに射影
        Q = self.q_proj(query_tokens).view(B, N_q, self.num_heads, self.head_dim).transpose(1, 2)
        K = self.k_proj(kv_tokens).view(B, N_kv, self.num_heads, self.head_dim).transpose(1, 2)
        V = self.v_proj(kv_tokens).view(B, N_kv, self.num_heads, self.head_dim).transpose(1, 2)

        # アテンション: テキストクエリが画像キー/バリューにアテンド
        attn = (Q @ K.transpose(-2, -1)) * self.scale
        attn = attn.softmax(dim=-1)

        # バリューを集約
        out = (attn @ V).transpose(1, 2).reshape(B, N_q, D)
        return self.out_proj(out)

# 例: テキストトークン(10)が画像トークン(196)にアテンド
cross_attn = CrossAttention(dim=768)
text_tokens = torch.randn(1, 10, 768)
image_tokens = torch.randn(1, 196, 768)
attended = cross_attn(text_tokens, image_tokens)
print(f"出力形状: {attended.shape}")  # (1, 10, 768)

2.7 統一埋め込み空間

VLMは画像とテキストを直接比較できる共有埋め込み空間を作成します。

graph TB subgraph "分離された空間" IS[画像空間] TS[テキスト空間] end subgraph "統一空間" US[共有埋め込み空間] IP[画像ポイント] TP[テキストポイント] end IS --> |対照学習| US TS --> |対照学習| US IP --> |類似| TP style US fill:#9b59b6,color:white
# CLIP統一埋め込み空間の可視化
import numpy as np
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt

def visualize_clip_space(images, texts, model, processor):
    """画像とテキストがCLIP空間でどのようにクラスタリングされるかを可視化"""

    # 画像埋め込みを取得
    image_inputs = processor(images=images, return_tensors="pt", padding=True)
    with torch.no_grad():
        image_embeds = model.get_image_features(**image_inputs)
        image_embeds = image_embeds / image_embeds.norm(dim=-1, keepdim=True)

    # テキスト埋め込みを取得
    text_inputs = processor(text=texts, return_tensors="pt", padding=True)
    with torch.no_grad():
        text_embeds = model.get_text_features(**text_inputs)
        text_embeds = text_embeds / text_embeds.norm(dim=-1, keepdim=True)

    # 結合して次元削減
    all_embeds = torch.cat([image_embeds, text_embeds], dim=0).numpy()
    tsne = TSNE(n_components=2, perplexity=min(30, len(all_embeds)-1))
    reduced = tsne.fit_transform(all_embeds)

    # プロット
    n_images = len(images)
    plt.figure(figsize=(10, 8))
    plt.scatter(reduced[:n_images, 0], reduced[:n_images, 1], c='blue', label='画像', s=100)
    plt.scatter(reduced[n_images:, 0], reduced[n_images:, 1], c='red', label='テキスト', s=100)

    # マッチするペア間に線を引く
    for i in range(min(n_images, len(texts))):
        plt.plot([reduced[i, 0], reduced[n_images+i, 0]],
                 [reduced[i, 1], reduced[n_images+i, 1]], 'g--', alpha=0.5)

    plt.legend()
    plt.title("CLIP統一埋め込み空間")
    plt.show()

2.8 まとめ

第2章の重要ポイント

演習

演習1: ゼロショット分類

CLIPを使用してカスタムカテゴリ(例:「プロの写真」、「アマチュアの写真」、「AI生成画像」)に画像を分類してください。異なるテキストプロンプトの定式化で結果を比較してください。

演習2: Q-Formerの分析

なぜBLIP-2は画像解像度に関係なく固定数のクエリトークン(32)を使用するのでしょうか?この設計選択のトレードオフを説明してください。

演習3: LLaVA会話

画像についてフォローアップの質問をするマルチターンの視覚的会話を実装してください。以前のターンからのコンテキストが回答にどう影響するでしょうか?

演習4: 埋め込み空間の可視化

異なるカテゴリの画像に対するCLIP埋め込みがどのようにクラスタリングされるかを示す可視化を作成してください。意味的に類似した画像は一緒にクラスタリングされますか?