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

LLaVAの仕組みと概念モデルの実装

代替テキスト
(※画像はAI画像生成モデルを用いて作成されたアーキテクチャ概念図です)

LLaVAの概要

LLaVA(Large Language and Vision Assistant)は、Microsoft Research、ウィスコンシン大学マディソン校、コロンビア大学などの共同研究チームによって2023年4月に発表された、オープンソースの最先端大規模マルチモーダルモデル(VLM: Vision-Language Model)です。

論文 Visual Instruction Tuning で提案されたこのモデルは、テキストだけでなく「画像」を同時に理解し、それについて人間と自然言語で高度に対話できるのが最大の特徴であり、商用でクローズドなOpenAIのGPT-4Vなどに対する「オープンソース版(OSS版)の強力な選択肢」として世界中で非常に大きな注目を集めました。

本記事では、画像とテキストを同時に理解するオープンソースのマルチモーダルAI「LLaVA」の全体像を解説します。

画像に対する複雑な指示にも的確に応答できる 「高い推論能力(特徴)」、「既存の優秀なAI(CLIPとVicuna)をシンプルな接続層で融合させた効率的な仕組み(アーキテクチャ)」、そしてGPT-4を巧みに使ってブレイクスルーを起こした「革新的なデータ生成手法(Visual Instruction Tuning)」について紐解き、具体的な活用分野 までをコンパクトにまとめてご紹介します。

LLaVAの処理概要

処理概要
(画像は、Geminiで作成されたものです)
LLaVA(Large Language and Vision Assistant)のモデル構築および運用のプロセスは、上図のように「2段階の学習フロー(事前学習・ファインチューニング)」と、実際にタスクを実行する「予測(推論)フロー」の大きく3つのフェーズに分かれています。

  1. 学習フロー:Phase 1: Pre-training(事前学習) 事前学習フェーズでは、LLaVAの特徴である「目(視覚)」と「脳(言語)」を繋ぐ「ビジュアル・プロジェクション層(プロジェクタ)」の最適化を集中的に行います。 入力された画像データは、まずフリーズ(重みを固定)された視覚エンコーダ(CLIP)によって特徴量へと変換されます。この段階では、視覚特徴量のデータ次元と言語モデル(LLM)が理解できる埋め込み空間の次元が一致していません。 そこで、プロジェクタ(線形層など)のみを学習対象とし、画像の特徴配列をLLMのテキストトークンと同じ空間へマッピング(整列)させる処理を行います。この段階では、LLM側の重みも固定された状態に保たれます。

  2. 学習フロー:Phase 2: Fine-tuning(ファインチューニング) 続くファインチューニングフェーズでは、モデル全体を指示に従わせるための「エンドツーエンドのビジュアル・インストラクション・チューニング」を実施します。 ここでは「タスク・画像・回答」がセットになった高品質な命令追従データ(Instruction Data)を使用します。Phase 1とは異なり、プロジェクタだけでなく、大規模言語モデル(VicunaやLlama-2など)の重みも同時に更新(解放)して微調整を行います。 これにより、モデルは単に画像の内容を認識するだけでなく、「画像の特定の指示に従って論理的に思考し、適切なテキストを出力する」という、高度なマルチモーダル推論能力を獲得します。

  3. 予測(推論)フロー(モデルの予測プロセス) 実際にユーザーからの質問に答える予測(推論)フェーズでは、入力された「画像」と「テキスト指示(プロンプト)」が並列でプロセシングされます。

画像データの処理: 入力画像は画像エンコーダ(CLIP)を通り、続いて学習済みのビジュアル・プロジェクション層を介して「視覚埋め込み(Visual Embedding)」へと変換されます。

テキスト指示の処理: ユーザーからのテキスト指示は、通常のLLMと同様にトークナイズされ、「LLM埋め込み(Text Embedding)」へと変換されます。

マルチモーダル入力の構成: これら2つの埋め込みデータは、[指示埋め込み, 視覚埋め込み]の順で1つのシークエンスとして結合(マルチモーダル入力の構成)され、学習済みLLMへと入力されます。

最終段のLLM(重みは凍結状態)は、この結合された埋め込み情報を基に推論処理を行い、「この画像には、本棚、ソファ、そして一人の人が座っているのが写っています。」といった、文脈に沿った自然で的確な予測回答を出力します。

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

具体的なコード例を記述します。

簡易カスタムLLaVAモデルの定義

以下のコードでは、PyTorchとHugging Faceの transformers ライブラリを用いて、LLaVAのコアとなるアーキテクチャをシミュレートした簡易的なカスタムマルチモーダルモデルを実装します。

軽量化モデルによる学習の体験

実際のLLaVAは言語モデルにLLaMAベースの巨大なモデル(7Bや13B)を使用しますが、本チュートリアルでは手元のPCでもメモリ不足(OOM)を起こさず数秒で学習ループを体験できるよう、あえて超軽量な gpt2 と clip-vit-base に差し替えて実装しています。アーキテクチャの仕組み自体は全く同じです。

このモデルは、画像特徴量を抽出する CLIP(Vision Encoder)と、テキスト生成を担当する GPT-2(LLM)を、線形層であるプロジェクタを介して接続した設計になっており、入力画像とテキスト指示を結合したマルチモーダルな埋め込みベクトルを作成してテキストを予測します。

import io
import torch
import torch.nn as nn
from transformers import CLIPVisionModel, AutoModelForCausalLM, AutoTokenizer, AutoImageProcessor
import requests
from PIL import Image
from torch.optim import AdamW
import json
import matplotlib.pyplot as plt

# 簡易的なLLaVAモデルの定義クラス
class SimpleHuggingFaceLLaVA(nn.Module):
def __init__(self, clip_model_id="openai/clip-vit-base-patch32", llm_model_id="gpt2"):
super().__init__()
# 画像の特徴量を抽出するフリーズ対象のVision Encoder (CLIP)
self.vision_encoder = CLIPVisionModel.from_pretrained(clip_model_id)
# テキスト生成を担当する causal言語モデル (ここでは軽量化のためGPT-2)
self.llm = AutoModelForCausalLM.from_pretrained(llm_model_id)

# 各エンコーダの出力次元数を取得して、プロジェクタを定義
vision_hidden_size = self.vision_encoder.config.hidden_size
text_hidden_size = self.llm.config.hidden_size

# ビジュアル特徴量(画像次元)をテキスト特徴量(言語次元)に変換する「ビジュアル・プロジェクタ」
self.projector = nn.Linear(vision_hidden_size, text_hidden_size)

def forward(self, pixel_values, input_ids, labels=None):
# 1. Vision Encoderから画像の特徴マップを抽出
vision_outputs = self.vision_encoder(pixel_values=pixel_values)
# 2. 特徴マップをプロジェクタに通してテキスト空間へ投影: 次元は (バッチ数, 画像トークン数, 言語モデル次元) になる
visual_embeds = self.projector(vision_outputs.last_hidden_state)
# 3. 言語モデルの埋め込み層からテキストの埋め込みベクトルを取得
text_embeds = self.llm.get_input_embeddings()(input_ids)

# 4. 画像とテキストの埋め込みベクトルをシーケンス(時系列)方向に結合して、マルチモーダル入力を構成
multimodal_embeds = torch.cat([visual_embeds, text_embeds], dim=1)

# 5. ラベル(正解データ)がある場合、画像部分の損失計算をスキップするように処理
if labels is not None:
batch_size = labels.shape[0]
num_image_tokens = visual_embeds.shape[1]
# 画像トークンの位置に対応するラベルには、クロスエントロピー損失で無視される特別な値 -100 を敷き詰める
ignore_labels = torch.full((batch_size, num_image_tokens), -100, dtype=labels.dtype, device=labels.device)
# 画像用のダミーラベルと、本物のテキストラベルを結合
labels = torch.cat([ignore_labels, labels], dim=1)

# 結合した埋め込みベクトルと言い換えラベルを言語モデルに流して順伝播
return self.llm(inputs_embeds=multimodal_embeds, labels=labels)

def generate(self, pixel_values, input_ids, max_new_tokens=30):
# 推論(テキスト生成)用の処理。forwardと同様に埋め込みベクトルを結合
vision_outputs = self.vision_encoder(pixel_values=pixel_values)
visual_embeds = self.projector(vision_outputs.last_hidden_state)
text_embeds = self.llm.get_input_embeddings()(input_ids)
multimodal_embeds = torch.cat([visual_embeds, text_embeds], dim=1)

# 結合したマルチモーダル特徴量を入力として自動トークン生成を実行
generated_ids = self.llm.generate(
inputs_embeds=multimodal_embeds,
max_new_tokens=max_new_tokens,
pad_token_id=self.llm.config.eos_token_id
)
return generated_ids

# Phase 1 用の設定(プロジェクタのみ学習)
# 画像エンコーダと言語モデルの重みを固定(勾配計算をオフ)し、接続層であるプロジェクタのみを更新可能にします
def setup_for_pretraining(self):
for param in self.vision_encoder.parameters(): param.requires_grad = False
for param in self.llm.parameters(): param.requires_grad = False
for param in self.projector.parameters(): param.requires_grad = True

# Phase 2 用の設定(プロジェクタ + LLM を学習)
# 画像エンコーダはフリーズしたまま、プロジェクタと言語モデルの両方を更新できるように解放します
def setup_for_finetuning(self):
for param in self.vision_encoder.parameters(): param.requires_grad = False
for param in self.projector.parameters(): param.requires_grad = True
for param in self.llm.parameters(): param.requires_grad = True

SimpleHuggingFaceLLaVA クラスは、PyTorchの nn.Module を継承し、画像エンコーダ(CLIP)と言語モデル(GPT-2)を線形層(プロジェクタ)で繋いだカスタムLLaVAモデルの定義です。

  • 初期化(init): Hugging Faceの CLIPVisionModelAutoModelForCausalLM からそれぞれ openai/clip-vit-base-patch32gpt2 を読み込みます。画像エンコーダの出力次元数(vision_hidden_size)と言語モデルの隠れ状態の次元数(text_hidden_size)を、nn.Linear を用いたビジュアル・プロジェクション層である self.projector を通じてマッピングし、次元数を一致させます。

  • 順伝播(forward): 入力画像 pixel_values を画像エンコーダに通して得られた最後の隠れ状態を、プロジェクタで変換して視覚埋め込みベクトル visual_embeds を得ます。同時に、テキスト入力 input_ids をLLMの埋め込み層からテキスト埋め込みベクトル text_embeds に変換します。これら2つのテンソルを torch.cat でバッチ軸以降のシーケンス方向(dim=1)に結合し、マルチモーダル入力 multimodal_embeds を構成してLLMへ入力します。

  • ラベルの作成と損失計算: 画像の部分には正解ラベルを与えないよう、labels の画像シーケンスに対応する位置に -100 を埋め込み(PyTorchのクロスエントロピー損失で無視される値)、テキスト部分のラベルと結合しています。

  • 学習フェーズの設定(setup_for_pretraining / setup_for_finetuning): Phase 1(事前学習)ではプロジェクタのみ、Phase 2(ファインチューニング)ではプロジェクタとLLM本体の双方の requires_gradTrue に設定し、学習対象のパラメータを動的に切り替えます。

モデルの初期化

以下のコードでは、簡易カスタムLLaVAモデルを動かすための事前準備として、最適な計算デバイスの自動判別、モデルのインスタンス化、画像およびテキスト用のプロセッサやトークナイザの読み込み、およびテスト用画像と学習データの準備を行います。Wikipediaから取得した猫の画像を、モデルへの入力に適したテンソル形式へと変換する処理までを実装します。

# 利用可能な最高速の計算デバイス(GPU、Apple Silicon用のMPS、またはCPU)を自動判別
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
print(f"使用デバイス: {device}")

# 定義した簡易LLaVAモデルのインスタンスを生成し、選択したデバイスへ転送
model = SimpleHuggingFaceLLaVA().to(device)

# 画像をCLIPが理解できるテンソルへ正規化するプロセッサのロード
image_processor = AutoImageProcessor.from_pretrained("openai/clip-vit-base-patch32")
# GPT-2のテキストトークナイザのロード
tokenizer = AutoTokenizer.from_pretrained("gpt2")
# GPT-2には標準でパディングトークンがないため、EOS(終了トークン)で代用
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token

# 【注意】テストに使用している猫の画像はWikimedia Commonsのコンテンツです。
# クリエイティブ・コモンズ(CC)ライセンスに基づき、記事の末尾などで
# クレジット表記(作者名、ライセンス種別)を行う必要があります。
print("テスト用画像を読み込み中...")
wiki_image_url = "https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Cat_August_2010-4.jpg/1920px-Cat_August_2010-4.jpg"

headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
response = requests.get(wiki_image_url, headers=headers)

# 取得したバイナリデータをメモリ上で画像に変換
image = Image.open(io.BytesIO(response.content)).convert('RGB')

# 過学習させるための手作りテキスト
# Phase 1用(単なる説明文)
caption = "壁の上で寝ている猫です。"
# Phase 2用(対話形式)
human_text = "この画像に写っている動物は何ですか?"
gpt_text = "壁の上で寝ている猫です。"

image_inputs = image_processor(images=image, return_tensors="pt").to(device)

この初期化スクリプトでは、まずdeviceを決定し、定義したSimpleHuggingFaceLLaVAをデバイスへ配置します。 次に、Hugging Faceの openai/clip-vit-base-patch32 に適合した画像前処理用の AutoImageProcessor と、gpt2 に対応する AutoTokenizer をロードします。 GPT-2はパディングトークンを標準で持たないため、tokenizer.pad_tokentokenizer.eos_token(文末トークン)を代用として設定し、パディング時にエラーが起きないよう処理します。

続いて、requestsを用いてWikipediaから猫の画像(Cat_August_2010-4.jpg)をバイナリとしてダウンロードし、PIL.Imageに変換したうえでRGBにデコードします。 さらに、手作りの学習データとして、Phase 1用のキャプション(caption)と、Phase 2用の対話データ(human_textgpt_text)を定義します。 最後に、ロードした画像をimage_processorに入力してモデルに適したテンソル(pixel_values)へ変換し、選択したデバイスに転送します。

Phase1 プロジェクタの学習

以下のコードでは、LLaVAの学習プロセスの第1段階である「事前学習(Phase 1: Pre-training)」を実装します。ここでは、視覚エンコーダ(CLIP)と言語モデル(GPT-2)の重みを固定した状態で、2つの表現空間を繋ぐ線形プロジェクタのみを学習対象とし、画像に対応する説明文(キャプション)データを用いて画像特徴量をテキスト空間にアラインメント(整列)させます。

print("\n=== Phase 1: 事前学習(プロジェクタのみ最適化)を開始 ===")
model.setup_for_pretraining()
optimizer_p1 = AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3) # 過学習させるため少し高め

model.train()
for epoch in range(20): # 確実に記憶させるため20回ループ
text_inputs = tokenizer(caption, return_tensors="pt", truncation=True, max_length=512).to(device)

outputs = model(pixel_values=image_inputs["pixel_values"], input_ids=text_inputs["input_ids"], labels=text_inputs["input_ids"])
loss = outputs.loss

loss.backward()
optimizer_p1.step()
optimizer_p1.zero_grad()

if (epoch + 1) % 5 == 0: # ログが長くなるので5回ごとに表示
print(f"[Phase 1 - Epoch {epoch+1}/20] Loss: {loss.item():.4f}")

Phase 1 の事前学習ステップでは、視覚とテキストの次元の位置合わせを行うため、プロジェクタ部分を集中的に学習します。

  • モデルとオプティマイザの準備: model.setup_for_pretraining() を呼び出し、プロジェクタ以外の重みの勾配計算を無効化(requires_grad = False)します。AdamW オプティマイザには勾配計算が必要なプロジェクタのパラメータのみ(filter(lambda p: p.requires_grad, model.parameters()))を渡し、アラインメントを速やかに進めるため、やや高めの学習率(1e-3)を設定します。
  • データの準備とトークナイズ: 先ほどWikipediaからダウンロードし前処理を施した猫の画像テンソル(image_inputs["pixel_values"])と、手動で定義したキャプション(caption)を使用します。キャプションをトークナイザでエンコードしたテンソル text_inputs を作成し、学習用の入力と正解ラベルの双方として使用します。
  • ロス計算と逆伝播: トークナイズされたテキストと、前処理された画像情報をモデルへ入力します。model() 内で、テキスト次元に射影された画像特徴量とテキスト埋め込みが結合され、言語モデルによって次のトークン予測が行われます。算出された交差エントロピー損失(loss)を元に、loss.backward()optimizer_p1.step() を実行して、プロジェクタの重みのみを更新します。

Phase2 ファインチューニング

以下のコードでは、学習の第2段階である「視覚指示チューニング(Phase 2: Visual Instruction Tuning)」を実装します。このフェーズでは、プロジェクタに加えて言語モデル(GPT-2)の重みも学習可能な状態(解放)にし、ユーザーからの質問とアシスタントの回答が対話形式(プロンプトテンプレート適用)になったデータを用いて、モデルが画像の文脈を考慮した高度な対話を行えるようにエンドツーエンドで微調整を行います。

print("\n=== Phase 2: ファインチューニング(プロジェクタ+LLM全体を最適化)を開始 ===")
model.setup_for_finetuning()
optimizer_p2 = AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4) # ここも少し高めに

model.train()
for epoch in range(20):
prompt = f"USER: {human_text}\nASSISTANT: {gpt_text}"
text_inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512).to(device)

outputs = model(pixel_values=image_inputs["pixel_values"], input_ids=text_inputs["input_ids"], labels=text_inputs["input_ids"])
loss = outputs.loss

loss.backward()
optimizer_p2.step()
optimizer_p2.zero_grad()

if (epoch + 1) % 5 == 0:
print(f"[Phase 2 - Epoch {epoch+1}/20] Loss: {loss.item():.4f}")

Phase 2(ビジュアル指示チューニング)では、画像特徴量を言語モデルにマッピングするだけでなく、ユーザーの命令(インストラクション)に従って高度な対話を行えるようにモデル全体を微調整します。

  • モデルのセットアップと最適化対象の変更: model.setup_for_finetuning() を呼び出し、画像エンコーダ(CLIP)のみをフリーズした状態で、線形プロジェクタと言語モデル(GPT-2)の双方のパラメータを更新可能(requires_grad = True)に設定します。オプティマイザ AdamW には学習率 1e-4 を指定し、更新対象となるすべてのパラメータを渡します。
  • プロンプトフォーマットの適用: LLaVAの対話フォーマットを模擬し、USER: {human_text} ASSISTANT: {gpt_text} というプロンプトテンプレートを作成します。これを tokenizer でトークン化し、モデルへの入力と損失計算用の正解ラベル(labels)に設定します。
  • モデルの訓練ループ: 順伝播で画像テンソルとトークン化された対話プロンプトをモデルに入力します。モデル内で画像埋め込みとテキスト埋め込みが結合され、言語モデルによって対話の文脈に合わせた予測が行われます。予測のクロスエントロピー損失(loss)を求め、逆伝播(loss.backward())を行って、プロジェクタとGPT-2の双方の重みを更新(optimizer_p2.step())します。これを20エポック繰り返すことで、猫の画像に対する特定の対話パターンを完全に学習(過学習)させます。

テストサンプルの予測(簡易検証のため学習サンプルを予測)

以下のコードでは、2段階の学習(事前学習とファインチューニング)を終えた簡易カスタムLLaVAモデルを用いて、実際に「画像」と「質問テキスト」を入力し、回答を生成する予測(推論)処理をテストします。入力画像を matplotlib で可視化しながら、モデルがユーザーからの問いかけに対して正解のデータセットに近いテキストを出力できるかを検証します。

print("\n=== 予測(推論)テストを開始 ===")
model.eval()

plt.figure(figsize=(5, 5))
plt.imshow(image)
plt.axis('off')
plt.title("Input Image (Wikipedia)")
plt.show()

# 質問文(ASSISTANTの回答を空にして予測させる)
test_prompt = f"USER: {human_text}\nASSISTANT:"
test_text_inputs = tokenizer(test_prompt, return_tensors="pt").to(device)

with torch.no_grad():
generated_ids = model.generate(
pixel_values=image_inputs["pixel_values"],
input_ids=test_text_inputs["input_ids"],
max_new_tokens=20
)

generated_text = tokenizer.decode(generated_ids[0], skip_special_tokens=True)
output_text = generated_text.split("ASSISTANT:")[-1].strip()

print(f"【質問】\n{human_text}")
print(f"\n【正解(学習したテキスト)】\n{gpt_text}")
print(f"\n↓↓↓ AIの予測結果 ↓↓↓\n{output_text}")

学習を終えた簡易カスタムLLaVAモデルの性能を評価するため、対話の生成推論(テスト)を行います。

  • 評価モードの適用と可視化: model.eval() を呼び出してモデルを評価(推論)モードに切り替えます。matplotlib.pyplot を用いて、入力されたWikipediaの猫の画像を表示します。
  • 推論用プロンプトの構成: 推論時にはアシスタントの回答部分を空白にするため、プロンプトを USER: {human_text} ASSISTANT: と定義し、トークン化します。これにより、モデルは ASSISTANT: に続く自然な回答テキストを自動的に生成することが求められます。
  • 推論の実行とデコード: 評価時は不要なメモリ消費を抑え、勾配の計算を無効にするため with torch.no_grad() ブロック内で実行します。モデルの generate メソッドを呼び出し、画像特徴量とトークン化されたプロンプトを入力して、次のトークン群を最大20トークン(max_new_tokens=20)まで自己回帰的に生成します。
  • 出力のパースと結果の出力: 生成されたトークンID列(generated_ids)を tokenizer.decode によって人間が読める文字列に復元します。出力全体からプロンプト部分を排除し、ASSISTANT: 以降のアシスタントによる回答テキスト(output_text)のみを抽出して、質問内容、正解(期待される解答)、およびモデルの予測結果とともに表示します。

実行結果

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

簡易モデルの学習ログと推論結果の考察

簡易モデル(GPT-2 + CLIP)の動作ログを見ると、学習が進むにつれて予測の Loss が順調に減少していることがわかります。最初は事前学習(Phase 1)の開始時に高かった損失が、接続層プロジェクタを整列させる学習および全体を微調整するインストラクションチューニング(Phase 2)を経て、最終的に 0.0502 という非常に低い値まで順調に低下しています。

そして予測テストでは、「この画像に写っている動物は何ですか?」という画像に関する日本語の質問に対し、AIの予測結果として見事に 壁の上で寝ている猫です。 という正解データに等しい回答を生成できています。 わずか2件の学習データと超軽量モデル、20エポックという極限の状況下でありながら、線形プロジェクタを用いて画像埋め込みを言語空間にマッピングするだけで、画像特徴と言語の文脈情報を正しくアライメント(結合・理解)できたことを美しく証明しています。

※なお、出力テキストの末尾に「何で」という余分な文字列が繋がってしまっているのは、軽量化のために極小モデル(GPT-2)をそのまま使用しているためであり、本来本番モデルが備えている「Repetition Penalty(繰り返し生成のペナルティ)」やプロンプトテンプレートの強制終了トークンの制御(ストップ・トークン)を施していないためです。コアとなるマルチモーダルの接続メカニズム自体の正確性は、最初の1文の正しさによって十分に証明されています。

使用デバイス: cuda

テスト用画像を読み込み中...

=== Phase 1: 事前学習(プロジェクタのみ最適化)を開始 ===
[transformers] `loss_type=None` was set in the config but it is unrecognized. Using the default loss: `ForCausalLMLoss`.
[Phase 1 - Epoch 5/20] Loss: 5.5496
[Phase 1 - Epoch 10/20] Loss: 4.0730
[Phase 1 - Epoch 15/20] Loss: 4.1008
[Phase 1 - Epoch 20/20] Loss: 3.4197

=== Phase 2: ファインチューニング(プロジェクタ+LLM全体を最適化)を開始 ===
[Phase 2 - Epoch 5/20] Loss: 2.4626
[Phase 2 - Epoch 10/20] Loss: 0.6448
[Phase 2 - Epoch 15/20] Loss: 0.1243
[Phase 2 - Epoch 20/20] Loss: 0.0502

=== 予測(推論)テストを開始 ===

入力画像

【質問】
この画像に写っている動物は何ですか?

【正解(学習したテキスト)】
壁の上で寝ている猫です。

↓↓↓ AIの予測結果 ↓↓↓
壁の上で寝ている猫です。何で

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

LLaVA 1.0のシンプルなアーキテクチャで概念を学んだところで、実際に学習済みモデルを動かしてみましょう。

ここでは、現在Hugging Faceの公式ライブラリ(transformers)で標準サポートされており、より表現力が向上したアップデート版である「LLaVA 1.5」(70億パラメータを持つ巨大モデル: llava-hf/llava-1.5-7b-hf)を使用して推論テストを行います。

なぜLLaVA 1.5を使用するのか?

Hugging Face公式の非常に便利な専用クラスである LlavaForConditionalGeneration は、LLaVA 1.5の「2層MLPプロジェクタ」というアーキテクチャを前提として後からライブラリに追加されました。 そのため、公式のModel Hubに論文著者のLLaVA 1.0の重み(liuhaotian/LLaVA-13b-delta-v0など)は公開されているものの、Hugging Faceの標準機能(数行のコード)で1.0を読み込もうとすると、プロジェクタの層の数が合わずエラーになってしまいます。

LLaVA 1.0を動かすためには、Hugging Faceを介さず論文著者のGitHubリポジトリから巨大なソースコードをクローンし、独自の複雑な環境構築を行う必要があります。本チュートリアルでは、手軽に数行のコードで本物のマルチモーダル推論を体験できるよう、標準サポートされているLLaVA 1.5を採用しています。

以下のコードでは、ライブラリ公式の LlavaForConditionalGeneration クラスと、画像プロセッサ・トークナイザをラップした AutoProcessor を用いて実用的な推論を行います。

import torch
import requests
import io # ←修正①: ioモジュールをインポート
from PIL import Image
import matplotlib.pyplot as plt
from transformers import AutoProcessor, LlavaForConditionalGeneration

# ==========================================
# 1. 準備:Hugging Face公式モデル(LLaVA 1.5)の読み込み
# ==========================================
device = "cuda" if torch.cuda.is_available() else "cpu"

model_id = "llava-hf/llava-1.5-7b-hf"
# プロセッサ(画像の前処理とテキストのトークナイズを両方自動で行ってくれます)
processor = AutoProcessor.from_pretrained(model_id)

# モデル本体(メモリ節約のため float16 で読み込みます)
model = LlavaForConditionalGeneration.from_pretrained(
model_id,
torch_dtype=torch.float16,
low_cpu_mem_usage=True,
).to(device)

# ==========================================
# 2. データの準備:商用利用可能なフリー素材(Wikipedia)を取得
# ==========================================
# 【注意】テストに使用している猫の画像はWikimedia Commonsのコンテンツです。
# クリエイティブ・コモンズ(CC)ライセンスに基づき、記事の末尾などで
# クレジット表記(作者名、ライセンス種別)を行う必要があります。
print("\nテスト用画像を読み込み中...")
wiki_image_url = "https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Cat_August_2010-4.jpg/1920px-Cat_August_2010-4.jpg"
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
response = requests.get(wiki_image_url, headers=headers)

# 取得したバイナリデータをメモリ上で画像に変換
# ←修正②: 変数名を test_image に統一
test_image = Image.open(io.BytesIO(response.content)).convert('RGB')

# AIへの質問文
# ※LLaVAのフォーマットに従い、画像が挿入される位置に <image> タグを含めます
human_text = "<image>\nこの画像に写っている動物について詳しく説明してください。"

# 画像の表示(matplotlibを使用)
plt.figure(figsize=(5, 5))
plt.imshow(test_image)
plt.axis('off')
plt.title("Input Image (Wikipedia)")
plt.show()

# ==========================================
# 3. 推論フロー(予測の実行)
# ==========================================
# プロンプトの構築(Hugging Faceのフォーマットに合わせる)
prompt = f"USER: {human_text}\nASSISTANT:"

# プロセッサを使って、画像とテキストを一括でモデルに入力できる形式(テンソル)に変換
inputs = processor(text=prompt, images=test_image, return_tensors="pt").to(device, torch.float16)

# 推論の実行(AIに回答を生成させる)
with torch.no_grad():
generated_ids = model.generate(**inputs, max_new_tokens=100)

# ==========================================
# 4. 結果のデコードと表示
# ==========================================
# 生成された数値をテキストに戻す
generated_text = processor.decode(generated_ids[0], skip_special_tokens=True)

# プロンプト部分を除外し、AIが生成した回答のみを抽出
output_text = generated_text.split("ASSISTANT:")[-1].strip()

# 質問文から <image> タグを消して綺麗に表示
print(f"【質問】\n{human_text.replace('<image>', '').strip()}")
print(f"\n【LLaVA公式モデルの予測結果】\n{output_text}")

本物の LLaVA モデルをロードして、公式に訓練されたビジュアル対話パイプラインの動作を検証します。

  • 公式モデルのロード: LlavaForConditionalGeneration クラスは、LLaVAの本来の構造である CLIP(Vision Encoder)、プロジェクタ、および大規模言語モデル(Vicunaベース等)が統合されたものです。非常に巨大なモデル(7B)であるため、ビデオメモリを削減するために torch_dtype=torch.float16 を指定して半精度でロードし、初期読み込み負荷を下げるために low_cpu_mem_usage=True を有効化しています。
  • 統合型プロセッサ(AutoProcessor)の使用: 先ほどの簡易実装では画像の前処理(Image Processor)とテキストのトークナイズ(Tokenizer)を個別に行っていましたが、Hugging Face公式の LLaVA 実装では、これらを統合した AutoProcessor を用いることで、画像とプロンプトテキストをまとめて単一の inputs テンソル群へ一括変換できます。
  • 推論生成処理: 入力テンソル群を展開(**inputs)して model.generate に引き渡します。生成されたトークンID列を processor.decode にかけ、プロンプトに続くアシスタントの高度な解答を抽出してコンソールに出力します。

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

本物(公式)のLLaVA 1.5 7Bモデルによる推論結果の考察

本格的な公式の学習済み重みを持つモデル(LLaVA 1.5 7B)の予測結果を見ると、入力画像および質問「この画像に写っている動物について詳しく説明してください。」に対して、AIは即座に この画像には、猫が描かれています。猫は、白い壁に乗っているように、壁の上に寝ています。猫は、目を瞑っているように、目を閉じています。 と完全に流暢かつ自然なテキストで、非常に詳細な解説を出力できています。

この回答は、猫が単に写っていることだけでなく、それが「白い壁の上に寝ている」「目を閉じている」といった細かい文脈や状態までも視覚特徴から正確に捉え、豊かな表現力で説明できています。

簡易自作モデル(GPT-2)で発生していた文末の唐突な単語の乱れや文脈の破綻も解消されており、大規模言語モデル(Vicunaベース)の推論表現力と、2段階アプローチ(プロジェクタ+視覚指示チューニング)が実用レベルで獲得した強力なマルチモーダル処理能力が遺憾なく発揮されています。

テスト用画像を読み込み中...

入力画像

【質問】
この画像に写っている動物について詳しく説明してください。

【LLaVA公式モデルの予測結果】
この画像には、猫が描かれています。猫は、白い壁に乗っているように、壁の上に寝ています。猫は、目を瞑っているように、目を閉じています。猫は、壁の上に��

まとめ

本記事では、画像とテキストを等しく理解して高度な対話を実現する大規模マルチモーダルモデル LLaVA(Large Language and Vision Assistant) について、その理論からスクラッチを模した概念実装、およびHugging Face公式モデルを用いた実践までを解説しました。

記事を通じて、以下の内容を学習・体験しました。

  • LLaVAの2段階学習アプローチの理解: 画像空間とテキスト空間をアラインさせる「Phase 1: 事前学習(プロジェクタのみ)」と、対話形式でモデル全体を洗練させる「Phase 2: ビジュアル指示チューニング(ファインチューニング)」の仕組みを整理しました。
  • 概念的なシンプルなモデルの実装: CLIP(ビジュアル)とGPT-2(言語)という実用的な軽量コンポーネントを接続層(プロジェクタ)で結びつけ、PyTorchを用いて次元のマッピングから順伝播、学習ルーチンまでをスクラッチで実装しました。
  • 公式LLaVA-1.5モデルによる推論: transformers ライブラリの LlavaForConditionalGenerationAutoProcessor を用い、本物の70億パラメータ(7B)モデルをロードして高性能な視覚応答を実際に動かして検証しました。

LLaVAが切り拓いた、軽量なプロジェクタ(接続層)で強力な既存モデルをマッピングするアプローチは、現在のVision-Languageモデルにおけるデファクトスタンダードとなっています。ぜひこの効率的な仕組みを理解した上で、独自のタスクへのファインチューニングやLLMアプリケーション開発に役立ててください。


画像出典: Wikimedia Commons, by Thegreenj, CC BY-SA 3.0

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