Qwen2-VLの仕組みと概念モデルの実装

(※画像はAI画像生成モデルを用いて作成されたアーキテクチャ概念図です)
Qwen2-VLの概要
Qwen2-VLは、Alibaba Cloudの通義千問(Tongyi Qianwen)チームが2024年に開発・発表した、非常に強力かつ最先端のオープンソース・マルチモーダル(視覚言語)モデルです(論文は Qwen2-VL: Enhancing Vision-Language Model's Perception of the World at Any Resolution です)。
最大の特徴は、自由な解像度やアスペクト比の画像、さらには長尺の動画にいたるまで、視覚情報をそのままのクオリティで理解できる「動的解像度(Dynamic Resolution)」と、時空間の3次元位置情報を正確に処理する高いマルチモーダル理解能力を実現している点にあります。
Qwen2-VLでは、入力された視覚特徴を抽出する強力な画像エンコーダー(Qwen2VisionTransformer)と、高度なテキスト処理を担う大規模言語モデル(Qwen2VLModel)をシームレスに統合しています。アーキテクチャの核心として、エンコーダーの末尾に「PatchMerger」と呼ばれる効率的なトークン圧縮ネットワークを配置。これにより、高解像度な画像から抽出された膨大な視覚情報を、言語モデル(LLM)が最も効率よく処理できる形式(4分の1のトークン数)へと圧縮・伝達すると同時に、独自の「M-RoPE(マルチモーダル回転位置埋め込み)」によって、時間・高さ・幅の3次元的な位置関係をLLMの内部で完璧にアラインメント(整列)して処理することを可能にしています。
Qwen2-VLの処理概要

(画像は、Geminiで作成されたものです)
Qwen2-VLは上図のように、画像や動画を処理する強力なビジョンエンコーダ(Visual)と、高度なテキスト処理を担うLLMデコーダ(model)をシームレスに統合し、高解像度かつ自由なアスペクト比のマルチモーダルな入力を効率的に処理するモデルです。
- ビジョンエンコーダ(Visual)
図の左側に示すビジョンエンコーダの領域では、入力された画像や動画の情報を高次元の視覚トークンへと変換します。
-
PatchEmbed(PatchEmbed クラス):入力された画像やビデオは、まずこのコンポーネントに送られます。Qwen2-VLでは固定サイズへの縮小や歪みを与えることなく、「入力解像度に基づく動的なパッチ数への変換」をここで行います。これにより、どのような解像度やアスペクト比の入力でも、そのままのクオリティで柔軟にパッチ化されます。
-
VisionBlock(Qwen2VLVisionBlock クラス):パッチ化されたデータは、深度D(複数レイヤー)のVisionBlockを通過します。ブロック内部には、図の通りVisionAttention(実行環境に応じて eager、flash_attention_2、sdpa のオプションから選択可能)と、特徴量を変換するVisionMlpが配置されており、画像やビデオ内の豊かな視覚特徴をディープに抽出します。また、このビジョン処理の全体に対して、VisionRotaryEmbedding クラスによる rotary_pos_emb が適用されます。
-
トークン圧縮の鍵:PatchMerger(PatchMerger クラス)
ビジョン層の末尾に位置するのが、Qwen2-VLの計算効率と高速性を支える最大の鍵である PatchMerger です。 高解像度な画像や動画になればなるほどパッチ数は膨大になり、そのままではLLM側の計算コスト(コンテキスト長)を圧迫してしまいます。PatchMergerは、抽出された高解像度特徴を、LLMに引き渡す前に最適なサイズへとダウンサンプリング(トークン圧縮)します。これにより、視覚的な情報を維持したまま、メモリ消費量と計算コストを劇的に削減し、LLMへの橋渡しを行います。 -
LLMデコーダ(model)とマルチモーダル統合
図の右側に示すLLMデコーダ(model)では、圧縮された「視覚埋め込み(Visual Embeddings)」と、ユーザーからの「テキスト入力(tokens)」が統合され、1つの長大なシーケンス(inputs_embeds)として処理されます。
-
nn.Embedding(embed_tokens): ユーザーが入力したテキスト(tokens)は、embed_tokens層によって「テキスト埋め込み」(言語ベクトル)へと変換されます。そして、PatchMergerから送られてきた「視覚埋め込み」と直列に結合(図中の inputs_embeds のカラーバーのように配置)され、統合されたマルチモーダル特徴量となります。
-
Qwen2VLDecoderLayer: 言語モデルの本体は、レイヤー数Nのデコーダー層の積み重ねで構成されています。各レイヤーの内部には、以下の3つのコンポーネントが緻密に配置されています。
- Self-Attention(Qwen2VLAttention): マルチモーダルな依存関係を計算します。
- MLP(Qwen2MLP): 特徴量のマッピングと表現力の向上を担います。
- Qwen2RMSNorm(norm): ネットワーク内の入力を正規化し、安定した学習と推論を支えます。
💡 進化した位置エンコーディング「3D RoPE」
事前知識:MRoPE(3D RoPE)の基礎3D RoPE(M-RoPE)の理論的な背景や、1D/2D RoPEとの本質的な違い、アテンションスコアの可視化による比較などを詳細に学びたい方は、あらかじめ解説記事 MRoPEとは?(多次元回転位置埋め込みの仕組み) をお読みいただくことを強くおすすめします。
Qwen2-VLの表現力を極限まで高めているのが、図の左下に描かれている独自の「3D RoPE(多モーダル回転位置埋め込み) / apply_multimodal_rotary_pos_emb 関数」技術です。
従来のLLMのような1次元的な順番の記録とは異なり、視覚トークンの位置情報を「Temporal(時間)」「Height(高さ)」「Width(幅)」の3軸に分割して管理し、テキスト(1D)と位置インデックスを効率的に共有します。 この高度な位置エンコーディングにより、図にある通り「統一的に位置情報をエンコード」することが可能となり、言語モデル内部で入力データの「時空間的文脈を正確に把握」できるようになります。自由な解像度でパッチ化された画像が、全体のどこに位置しているのかをLLMが正しく空間的・時間的に理解できるのは、この3D RoPEのおかげです。
- 出力生成(Output Generation)
すべてのコンテキスト(視覚情報+テキスト情報)を網羅して処理されたベクトルは、最終的に最外層の出力ヘッドである nn.Linear(lm_head) へと送られます。 ここでは、巨大な語彙空間へとマッピングが行われ、logits(予測確率スコア)を出力します。このスコアを元に、次トークン予測を繰り返す「generate(自己回帰的な生成)」のサイクルが回り、画像や動画の内容を正確に反映した自然な回答テキストが生成されます。
Qwen2-VLの実装(概念的なシンプルな実装)
概念モデル MiniQwen2VL の実装
Qwen2-VLのアーキテクチャの主要部分(Visionエンコーダ、PatchMergerによるトークン圧縮、M-RoPEを利用したデコーダ)を統合し、PyTorchで直感的に理解できるように構成した全体のラッパーモデル MiniQwen2VL の定義です。
# ==============================================================================
# 本記事の概念モデル実装コードは、Apache License 2.0 で公開されている Qwen2-VL の
# 公式モデリングコードおよび、Hugging Face 上の zhouyik/DenseLabelDev の実装を
# 一部参照・改変して作成されています。
# ==============================================================================
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.optim import AdamW
import torchvision.transforms as T
from datasets import load_dataset
from transformers import AutoTokenizer
import matplotlib.pyplot as plt
from PIL import Image
import math
class MiniQwen2VL(nn.Module):
def __init__(self, vocab_size, vision_dim=256, llm_dim=512, nhead=4):
super().__init__()
# 画像パッチから特徴を抽出するモジュールを定義
self.patch_embed = DynamicPatchEmbedding(embed_dim=vision_dim)
# 視覚トークンをマージして圧縮し、LLMの入力次元に合わせるモジュールを定義
self.merger = PatchMerger(in_dim=vision_dim, out_dim=llm_dim)
# テキストトークンをベクトル埋め込みに変換する層
self.text_embedding = nn.Embedding(vocab_size, llm_dim)
# M-RoPEのセクション分割 (head_dim = 128 = 32 + 48 + 48) -> *2すると 64, 96, 96 (元の仕様に準拠)
# ※head_dim // 2 に対して分割を定義します。ここでは head_dim=128 なので、半分は64。
# 64を Temporal, Height, Width に割り当てます: [16, 24, 24]
self.mrope_section = [16, 24, 24]
# マルチモーダル回転位置埋め込み(M-RoPE)の計算用クラス
self.rotary_emb = Qwen2VLRotaryEmbedding(dim=llm_dim // nhead)
# トランスフォーマーデコーダー層を4層作成
self.layers = nn.ModuleList([
CustomTransformerLayer(d_model=llm_dim, nhead=nhead, mrope_section=self.mrope_section)
for _ in range(4)
])
# 最終層出力の正規化層
self.norm = nn.LayerNorm(llm_dim)
# 最終出力を語彙空間のロジットに変換する全結合層
self.lm_head = nn.Linear(llm_dim, vocab_size)
def get_3d_position_ids(self, img_grid_size, text_len, device, batch_size=1):
"""画像のH, Wグリッドとテキスト長から3D position IDsを生成"""
H, W = img_grid_size
img_len = H * W
# 1. Vision用のPosition ID (T, H, W)
t_pos = torch.zeros(img_len, dtype=torch.long) # 静止画なので時間軸Tは0で固定
h_pos = torch.arange(H).view(-1, 1).expand(H, W).flatten() # 高さ(Y座標)方向のインデックスを生成
w_pos = torch.arange(W).view(1, -1).expand(H, W).flatten() # 幅(X座標)方向のインデックスを生成
vision_pos_ids = torch.stack([t_pos, h_pos, w_pos]) # 3つの軸の位置情報を結合して (3, img_len) にする
# 2. Text用のPosition ID (T, H, W は同じ値を取る)
# 画像トークンの次のインデックスからテキストのインデックスを開始する
start_idx = vision_pos_ids.max() + 1
text_pos_ids = torch.arange(start_idx, start_idx + text_len).expand(3, text_len) # 各軸共通で割り当て (3, text_len)
# 3. 視覚とテキストの位置IDをシーケンス方向に結合: (3, seq_len)
position_ids = torch.cat([vision_pos_ids, text_pos_ids], dim=1).to(device)
# バッチ次元 (B) を追加して (3, B, seq_len) の形状にして返す
return position_ids.unsqueeze(1).expand(3, batch_size, -1)
def forward(self, image, text_seq):
# 1. ビジョンエンコーダに画像を送り、動的解像度パッチ特徴とグリッドサイズ(H, W)を抽出
img_feats, grid_size = self.patch_embed(image)
# 2. 視覚特徴トークンを1/4に圧縮し、LLMの次元(llm_dim)にマッピング
merged_img_feats, merged_grid_size = self.merger(img_feats, grid_size)
# 3. 入力されたテキストトークンIDを埋め込みベクトルに変換
text_feats = self.text_embedding(text_seq)
# 4. 圧縮された画像特徴とテキスト特徴をシーケンス方向(dim=1)に直列結合
multimodal_inputs = torch.cat([merged_img_feats, text_feats], dim=1)
total_len = multimodal_inputs.size(1)
B = image.size(0) # バッチサイズを取得
# 5. 自己回帰的なデコードのための因果マスク(Causal Mask)を生成
mask = nn.Transformer.generate_square_subsequent_mask(total_len).to(image.device)
# 6. 位置IDを生成し、対応するRoPE(M-RoPE)のsin/cos周波数を計算
position_ids = self.get_3d_position_ids(merged_grid_size, text_seq.size(1), image.device, batch_size=B)
cos, sin = self.rotary_emb(position_ids) # (3, B, seq_len, head_dim)
# 7. トランスフォーマー層を順番に適用
hidden_states = multimodal_inputs
for layer in self.layers:
hidden_states = layer(hidden_states, cos, sin, mask)
# 8. 正規化と出力層の適用
hidden_states = self.norm(hidden_states)
logits = self.lm_head(hidden_states) # (B, seq_len, vocab_size)
img_len = merged_img_feats.size(1) # 画像由来のトークン数を返す(出力の切り出しに必要)
return logits, img_len
この MiniQwen2VL クラスは、マルチモーダル入力を処理する一連の流れを管理します。
- 初期化 (init):
- DynamicPatchEmbedding を作成し、画像を動的解像度の視覚パッチトークンへと変換する準備をします。
- PatchMerger を作成し、視覚トークン長を 4分の1 にマージし、言語モデルの次元(512)に投影する準備をします。
- 3D位置情報を管理するための mrope_section(頭部次元を時間、高さ、幅にそれぞれ 16, 24, 24 で分割)を設定し、それに対応する Qwen2VLRotaryEmbedding(周波数生成器)を定義します。
- Qwen2-VLの仕様では、1ヘッドあたりの次元数 head_dim をベースに計算を行います。今回の概念モデルでは、LLMの次元数 llm_dim = 512、アテンションのヘッド数 nhead = 4 としているため、1ヘッドあたりの次元数は以下のようになります。
- RoPE(回転位置埋め込み)は、2次元(次元のペア)ごとに回転を適用する特性上、内部の周波数計算(inv_freq)は head_dim の半分()のサイズで管理されます。この 64次元 を、時空間の3つの軸(Temporal, Height, Width)に割り振ります。本実装では、元の仕様の比率(時間軸よりも空間軸を重視する構成)に準拠し、以下のように分割しています。Temporal(時間): Height(高さ): Width(幅): ※ となり、ぴったり一致します。コード内の apply_multimodal_rotary_pos_emb 関数でこれらを2倍([32, 48, 48])に拡張することで、最終的に合計128次元の head_dim 全体に位置情報が行き渡る仕組みになっています。
- Qwen2-VLの仕様では、1ヘッドあたりの次元数 head_dim をベースに計算を行います。今回の概念モデルでは、LLMの次元数 llm_dim = 512、アテンションのヘッド数 nhead = 4 としているため、1ヘッドあたりの次元数は以下のようになります。
- layers に CustomTransformerLayer を4層積み重ねてデコーダを構成します。
- 3D Position ID の生成 (get_3d_position_ids):
- 静止画(時間軸 )と、高さ・幅の2次元グリッドのインデックスから、3次元の座標を表す Position ID を構築します。テキストトークンに対しては、画像シーケンスの直後から始まる位置インデックスを3軸すべてで共通で割り当てます。これにより、3次元(時間、高さ、幅)それぞれの軸における相対的な位置をエンコードします。
- 順伝播 (forward):
- 画像処理: 入力画像からパッチ特徴量とグリッドサイズを取り出します。
- トークン圧縮: PatchMerger を通して、パッチサイズをマージし、トークン長を 4分の1 に圧縮します。
- テキスト処理: テキスト入力を nn.Embedding 層で言語ベクトル化します。
- マルチモーダル結合: 画像トークンとテキストトークンをシーケンス方向に torch.cat で直列結合します。
- 因果マスクの適用: 入力テンソルの全体長に基づいた因果マスク(Causal Mask)を生成します。
- 位置情報の適用: get_3d_position_ids で 3D Position ID を生成し、rotary_emb を使って回転位置エンコーディング用の正弦・余弦波(cos、sin)を計算します。
- デコーダー処理: デコーダ層(layers)に入力し、M-RoPE による位置情報を反映させたアテンション計算を行い、最後に lm_head からロジット(logits)を出力します。
視覚エンコーダ(DynamicPatchEmbedding と PatchMerger)の実装
画像のアスペクト比を損なわずにパッチ特徴を抽出する DynamicPatchEmbedding と、視覚トークンの数を 4分の1 に圧縮する PatchMerger という、Qwen2-VLの視覚処理における2大中核コンポーネントの実装コードです。
class DynamicPatchEmbedding(nn.Module):
def __init__(self, in_channels=3, embed_dim=256, patch_size=14):
super().__init__()
self.patch_size = patch_size
# 重なりのない畳み込み層を使い、画像をパッチに分割すると同時に特徴を抽出
self.proj = nn.Conv2d(in_channels, embed_dim, kernel_size=patch_size, stride=patch_size)
def forward(self, images):
# images形状: [Batch, Channels, Height, Width]
x = self.proj(images) # 出力形状: [Batch, embed_dim, H_p, W_p]
H_p, W_p = x.shape[2], x.shape[3] # 畳み込み後のグリッドサイズ(高さ・幅)を保持
# [Batch, embed_dim, H_p, W_p] -> [Batch, embed_dim, H_p * W_p] -> [Batch, H_p * W_p, embed_dim]
x = x.flatten(2).transpose(1, 2)
return x, (H_p, W_p)
class PatchMerger(nn.Module):
def __init__(self, in_dim=256, out_dim=512):
super().__init__()
# 結合前の入力を正規化するための層
self.ln_q = nn.LayerNorm(in_dim)
# 2x2(=4個)のトークンをチャネル結合した 4*in_dim 次元を、LLMの次元である out_dim に圧縮投影するMLP
self.mlp = nn.Sequential(
nn.Linear(in_dim * 4, in_dim * 4),
nn.GELU(),
nn.Linear(in_dim * 4, out_dim)
)
def forward(self, x, grid_size):
B, L, C = x.shape
H_p, W_p = grid_size
# 1次元のシーケンス形状を元の2次元のグリッド形状 [Batch, H_p, W_p, C] に変形
x = x.view(B, H_p, W_p, C)
x = self.ln_q(x)
# 2x2のローカルグリッドトークンをまとめるため、テンソルを細分化して変形
# H_p // 2 と W_p // 2 のグリッドに分割し、それぞれ2ピクセルずつのセクションを作成
x = x.view(B, H_p // 2, 2, W_p // 2, 2, C)
# 各ブロック内の2x2(=4個)のピクセルが連続して結合されるように、並び替えを実施
# 置換後の形状: [Batch, H_p//2, W_p//2, 2, 2, C]
x = x.permute(0, 1, 3, 2, 4, 5).contiguous()
# 2x2=4個のトークン特徴(チャネル数 C)を平坦化して 4*C 次元のチャンネルにマージ
# 形状は [Batch, (H_p // 2) * (W_p // 2), 4 * C] になり、トークン数が 1/4 に圧縮されます
x = x.view(B, (H_p // 2) * (W_p // 2), 4 * C)
# マージした 4*C 次元の特徴量を MLP を通してアラインメントと次元削減
merged_features = self.mlp(x)
# 圧縮後の特徴量と、1/2 になった新しいグリッドサイズを返す
return merged_features, (H_p // 2, W_p // 2)
- DynamicPatchEmbedding:
- nn.Conv2d を用いて、kernel_size および stride を patch_size(14)と同じに設定します。これにより、重なりのない ピクセルのローカルなパッチ領域から特徴ベクトルを抽出します。
- 出力特徴マップの高さ・幅方向の次元をグリッドサイズ(H_p, W_p)として保持しつつ、テンソルを平坦化(flatten)および転置(transpose)して、トランスフォーマーが受け取れるシーケンス形式
[Batch, Length, Channel]に変換します。
- PatchMerger:
- init で入力特徴を正規化する LayerNorm と、4倍に結合されたチャンネル(in_dim * 4)をLLMの次元(out_dim)へ射影するための MLP を定義します。
- forward では、1次元のシーケンス形状を元の2次元グリッド
(H_p, W_p)に戻し、隣接する縦横 2 × 2 の領域がチャンネル次元でマージされるようにテンソルの形状を変形・置換します。 - 平坦化した 2 × 2 トークン(計4倍の次元数)に MLP を適用してアラインメントと次元圧縮を行い、トークン数を元の 4分の1 に削減して出力します。
LLMデコーダとカスタム M-RoPE Attention の実装
3次元回転位置エンコーディング(M-RoPE)をアテンション計算の内部で適用するカスタムアテンション機構 MRopeAttention と、それを取り入れた Transformer デコーダ層 CustomTransformerLayer の実装コードです。
class MRopeAttention(nn.Module):
def __init__(self, d_model, nhead, mrope_section):
super().__init__()
self.d_model = d_model
self.nhead = nhead
self.head_dim = d_model // nhead
self.mrope_section = mrope_section
# Query, Key, Value への射影線形層を定義
self.q_proj = nn.Linear(d_model, d_model)
self.k_proj = nn.Linear(d_model, d_model)
self.v_proj = nn.Linear(d_model, d_model)
# アテンションの出力を結合し、元の次元に戻す線形層
self.out_proj = nn.Linear(d_model, d_model)
def forward(self, x, cos, sin, mask=None):
B, L, C = x.shape
# Q, K, V を計算し、マルチヘッドの形状に変換
# [Batch, Length, C] -> [Batch, Length, Heads, Head_dim] -> [Batch, Heads, Length, Head_dim]
q = self.q_proj(x).view(B, L, self.nhead, self.head_dim).transpose(1, 2)
k = self.k_proj(x).view(B, L, self.nhead, self.head_dim).transpose(1, 2)
v = self.v_proj(x).view(B, L, self.nhead, self.head_dim).transpose(1, 2)
# M-RoPEを適用し、Query と Key に対して時空間の3次元回転位置情報を注入
q, k = apply_multimodal_rotary_pos_emb(q, k, cos, sin, self.mrope_section, unsqueeze_dim=1)
# スケールド・ドットプロダクト・アテンションの重みを計算
attn_weights = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim)
# 因果マスク等がある場合、アテンションの重みに加算(未注目位置は負の無限大方向へシフト)
if mask is not None:
attn_weights = attn_weights + mask
# ソフトマックスにより確率分布へ変換
attn_probs = F.softmax(attn_weights, dim=-1)
# 重みに基づいて Value ベクトルを加算
attn_output = torch.matmul(attn_probs, v)
# マルチヘッドの出力を元の次元に再構成
# [Batch, Heads, Length, Head_dim] -> [Batch, Length, Heads, Head_dim] -> [Batch, Length, C]
attn_output = attn_output.transpose(1, 2).contiguous().view(B, L, C)
# 最終的な全結合層を通す
return self.out_proj(attn_output)
class CustomTransformerLayer(nn.Module):
def __init__(self, d_model, nhead, mrope_section):
super().__init__()
# カスタム M-RoPE アテンションブロック
self.attn = MRopeAttention(d_model, nhead, mrope_section)
# 前段の正規化(Pre-LN)
self.norm1 = nn.LayerNorm(d_model)
# 特徴量の表現力を拡大する2層のMLP
self.mlp = nn.Sequential(
nn.Linear(d_model, d_model * 4),
nn.GELU(),
nn.Linear(d_model * 4, d_model)
)
# 後段の正規化(Pre-LN)
self.norm2 = nn.LayerNorm(d_model)
def forward(self, x, cos, sin, mask=None):
# 1. 前正規化 -> アテンション計算 -> 残差接続
x = x + self.attn(self.norm1(x), cos, sin, mask)
# 2. 前正規化 -> MLP計算 -> 残差接続
x = x + self.mlp(self.norm2(x))
return x
- MRopeAttention:
- Query, Key, Value をそれぞれ線形変換したあと、マルチヘッドアテンションの形状
[Batch, Heads, Length, Head_dim]に変形・転置します。 - 計算された Query(q)と Key(k)に対して、apply_multimodal_rotary_pos_emb を適用します。これにより、時空間の3次元位置情報(M-RoPE)が各ヘッドの Query と Key に直接エンコードされます。
- 回転位置情報が埋め込まれた q と k を用いてドット積アテンションを計算し、必要に応じて因果マスクを適用したあと、Value(v)を掛け合わせ、線形層 out_proj を経て出力を生成します。
- Query, Key, Value をそれぞれ線形変換したあと、マルチヘッドアテンションの形状
- CustomTransformerLayer:
- プレ・レイヤーノーマライゼーション(Pre-LN)のアーキテクチャを採用しています。
- 入力を norm1 で正規化したあと MRopeAttention に入力し、残差接続を追加します。
- その後、norm2 で正規化してから MLP(線形層 → GELU → 線形層)に通し、再度残差接続を追加して次の層へ引き渡します。
M-RoPE(3D RoPE)位置埋め込み処理の実装
Query と Key に対して、時間・高さ・幅の3次元(Multimodal)回転位置情報を周波数に基づいて正確に埋め込むためのユーティリティ関数と埋め込みモジュールの実装コードです。
def rotate_half(x):
"""特徴次元の半分にマイナスを付与して入れ替えることで、複素数空間の90度回転を再現"""
x1 = x[..., : x.shape[-1] // 2] # 前半の次元を取得
x2 = x[..., x.shape[-1] // 2 :] # 後半の次元を取得
return torch.cat((-x2, x1), dim=-1) # 後半に負号をつけ、前半と結合
def apply_multimodal_rotary_pos_emb(q, k, cos, sin, mrope_section, unsqueeze_dim=1):
"""
QueryとKeyにMultimodal RoPEを適用する関数
cos, sin: (3, B, seq_len, head_dim) の形状を想定
"""
# 割り当てられたセクション(時間, 高さ, 幅)の次元数を2倍にする(cos/sinの次元はすでにペア化され2倍拡張されているため)
mrope_section = [s * 2 for s in mrope_section]
# 時間(T), 高さ(H), 幅(W)の各軸ごとの周波数に分割
cos_parts = cos.split(mrope_section, dim=-1)
sin_parts = sin.split(mrope_section, dim=-1)
# 各スプリットから時間、高さ、幅の対応する成分を選択してチャネル方向に再結合
# これにより、3つの軸の位置情報が混ざり合った統合位置埋め込み(cos/sin)が作成されます
cos = torch.cat([cos_parts[i][i % 3] for i in range(len(cos_parts))], dim=-1).unsqueeze(unsqueeze_dim)
sin = torch.cat([sin_parts[i][i % 3] for i in range(len(sin_parts))], dim=-1).unsqueeze(unsqueeze_dim)
# オイラーの公式に基づく回転公式を適用: (q * cos) + (rotate_half(q) * sin)
q_embed = (q * cos) + (rotate_half(q) * sin)
k_embed = (k * cos) + (rotate_half(k) * sin)
return q_embed, k_embed
class Qwen2VLRotaryEmbedding(nn.Module):
def __init__(self, dim, max_position_embeddings=2048, base=10000.0):
super().__init__()
self.dim = dim
self.base = base
# 逆周波数(inv_freq)を算出(周波数の幾何級数的なステップを定義)
inv_freq = 1.0 / (self.base ** (torch.arange(0, dim, 2).float() / dim))
# 逆周波数をバッファとして登録(学習パラメータではないが保存・復元できるようにする)
self.register_buffer("inv_freq", inv_freq, persistent=False)
def forward(self, position_ids):
# position_idsの形状: (3, B, seq_len) - 時間、高さ、幅の3次元座標
# self.inv_freqの形状: (dim // 2)
# position_ids の末尾に次元を追加し、逆周波数 inv_freq と要素積を取って回転角を算出
# freqs の形状: (3, B, seq_len, dim // 2)
freqs = position_ids.unsqueeze(-1).float() * self.inv_freq
# 1組の角度(freqs, freqs)をチャネル方向に結合して、(3, B, seq_len, dim) の形状にする
emb = torch.cat((freqs, freqs), dim=-1)
# それぞれ余弦(cos)と正弦(sin)を計算して返す
return emb.cos(), emb.sin()
- rotate_half:
- テンソルの最後の次元(特徴次元)を半分に分割し、片方にマイナスを付与して結合し直すことで、複素数空間における 90 度の回転をシミュレートします。これは回転位置エンコーディング(RoPE)の数式をテンソル計算で高速に実行するための標準的なヘルパー関数です。
- apply_multimodal_rotary_pos_emb:
- 時間・高さ・幅の各軸に対して設定された mrope_section(それぞれ 2倍して 32, 48, 48 次元)に基づいて、埋め込み用の正弦・余弦波(cos、sin)を分割します。
- それぞれの軸に対応する cos/sin をチャンネル次元で直列結合し、Query(q)および Key(k)と同じ次元数にします。
- 回転の公式 を適用し、時空間の位置情報を直接特徴ベクトルに反映します。
- Qwen2VLRotaryEmbedding:
- 各座標インデックスに対応する回転周波数を計算します。
- 位置 ID(position_ids の3軸情報)と逆周波数(inv_freq)の外積を計算し、周波数の余弦(cos)と正弦(sin)のペアをテンソル形式で生成して返します。
複数画像の結合処理と過学習による訓練ループの実装
マルチイメージVQAデータセットからアスペクト比を維持して画像をリサイズ・結合し、作成した MiniQwen2VL モデルに対して特定のデータサンプルを用いた過学習(フィッティング確認)を行うための、前処理および学習ループの実装コードです。
def combine_images_with_aspect_ratio(images, target_height=224, patch_multiple=28):
"""
複数画像をアスペクト比を保持したまま結合する関数。
各画像を target_height に縮小し、幅はパッチサイズの倍数に丸めます。
"""
processed_images = []
for img in images:
w_orig, h_orig = img.size
# アスペクト比を考慮して新しい幅を計算
w_new = int(target_height * (w_orig / h_orig))
# 幅が patch_multiple (28) の倍数になるように丸める (最小値は 28)
w_rounded = max(patch_multiple, round(w_new / patch_multiple) * patch_multiple)
# 指定されたサイズに高品質リサイズ
resized_img = img.resize((w_rounded, target_height), Image.Resampling.LANCZOS)
processed_images.append(resized_img)
# 結合後の画像の合計幅を計算
total_width = sum(img.size[0] for img in processed_images)
# 1つの横長画像を新規作成 (RGB)
combined_img = Image.new('RGB', (total_width, target_height))
# 各画像を横方向に隙間なく貼り付け
x_offset = 0
for img in processed_images:
combined_img.paste(img, (x_offset, 0))
x_offset += img.size[0]
return combined_img
# 利用可能な実行デバイス (GPU/CPU) の選択
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
# トークナイザのロードと語彙サイズの取得
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2-VL-2B-Instruct")
vocab_size = len(tokenizer)
# 【注意】本記事で使用する「JA-Multi-Image-VQA」データセットは、Sakana AIによって提供されています。
# 画像のライセンスは Unsplash License、テキスト等のデータは Apache License 2.0 に基づいています。
# 詳細は記事の末尾に記載しています。
# データセットのロード (JA-Multi-Image-VQA)
ds = load_dataset("SakanaAI/JA-Multi-Image-VQA", split="test")
sample_indices = [0, 6] # 過学習実験に用いるサンプルインデックス
prepared_data = []
# 画像テンソル変換と標準化設定
transform = T.Compose([
T.ToTensor(),
T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
# サンプルデータの前処理ループ
for i in sample_indices:
sample = ds[i]
# 各画像データを RGB 形式に変換
raw_images = [img.convert("RGB") for img in sample['images']]
# 複数画像を1枚にアスペクト比維持結合
combined_image = combine_images_with_aspect_ratio(raw_images)
question = sample['question']
answer = sample['answer']
print(f"\n================ Index {i} ================")
print(f"Original Images count : {len(raw_images)}")
print(f"Combined Image Size : {combined_image.size} (W x H)")
print(f"Question : {question}")
print(f"Ground Truth : {answer}")
# 画像を可視化して確認
plt.figure(figsize=(4 * len(raw_images), 3))
plt.imshow(combined_image)
plt.title(f"Dataset Index {i} (Aspect Ratio Preserved)")
plt.axis("off")
plt.show()
# チャットフォーマットに基づくプロンプト文と正解データの結合
prompt_text = (
f"<|im_start|>system\nあなたは優秀なAIアシスタントです。<|im_end|>\n"
f"<|im_start|>user\n質問: {question}<|im_end|>\n"
f"<|im_start|>assistant\n回答: "
)
train_text = prompt_text + f"{answer}<|im_end|>"
# テキストをトークンIDに変換
token_ids = tokenizer(train_text, return_tensors="pt").input_ids.to(device)
prompt_ids = tokenizer(prompt_text, return_tensors="pt").input_ids.to(device)
# 訓練用データを格納 (input_idsは最後のトークンを除外し、target_idsは最初のトークンを除外:次トークン予測用)
prepared_data.append({
"index": i,
"image_tensor": transform(combined_image).unsqueeze(0).to(device),
"input_ids": token_ids[:, :-1],
"target_ids": token_ids[:, 1:],
"prompt_ids": prompt_ids,
"answer": answer
})
# 概念モデルを初期化してデバイスへ転送
model = MiniQwen2VL(vocab_size=vocab_size).to(device)
# オプティマイザの定義
optimizer = AdamW(model.parameters(), lr=1e-3)
# 損失関数の定義 (多クラス分類のためクロスエントロピーを使用)
criterion = nn.CrossEntropyLoss()
print(f"\n--- Training Start (Overfitting on indices {sample_indices}) ---")
model.train() # モデルを訓練モードに設定
epochs = 40
for epoch in range(epochs):
optimizer.zero_grad() # 勾配の初期化
total_loss = 0
for data in prepared_data:
# モデルの順伝播を実行。戻り値はロジット全体と画像パッチトークン長
logits, img_len = model(data["image_tensor"], data["input_ids"])
# 画像トークンを除去し、テキスト部分のロジットのみを切り出す
pred_logits = logits[:, img_len:, :]
# クロスエントロピー損失の計算
loss = criterion(pred_logits.reshape(-1, vocab_size), data["target_ids"].reshape(-1))
loss.backward() # 勾配計算 (逆伝播)
total_loss += loss.item()
optimizer.step() # 重みの更新
# 10エポックごとに進捗(平均損失)を出力
if (epoch + 1) % 10 == 0:
avg_loss = total_loss / len(sample_indices)
print(f"Epoch [{epoch+1}/{epochs}], Average Loss: {avg_loss:.4f}")
- combine_images_with_aspect_ratio:
- VQAデータセットに含まれる複数の画像を、アスペクト比を維持したまま指定の高さ(224)にリサイズし、パッチサイズの倍数(28の倍数)になるように幅を調整して、1つの長い画像に水平方向に結合します。
- データセットの準備:
- load_dataset を用いて、日本語のマルチイメージVQAデータセット (
JA-Multi-Image-VQA) をロードし、質問文と回答文を取得します。 - チャットテンプレートのフォーマットに沿ってテキストトークン ID を生成し、画像は ToTensor および Normalize でテンソル化して準備します。
- load_dataset を用いて、日本語のマルチイメージVQAデータセット (
- 学習ループ (Training Loop):
- MiniQwen2VL モデルをロードし、AdamW オプティマイザと CrossEntropyLoss 損失関数を定義します。
- 40エポックにわたる訓練ループを実行します。モデルの出力 logits から、画像パッチトークン長(img_len)以降の部分(すなわちテキスト部分のロジット)を取り出し、正解ラベルである target_ids とのクロスエントロピー損失を計算して逆伝播し、モデルのパラメータを更新します。これにより、特定のサンプル画像に対してモデルが過学習(フィッティング)することを確認します。
自己回帰生成(Inference)による予測と評価
学習を終えた概念モデルを用いて、新しい画像とプロンプトテキストが入力された際、自己回帰的(Auto-regressive)に次のトークンを1つずつ予測・生成して日本語の回答テキストを出力する推論処理のコードです。
print("\n--- Inference (Auto-regressive Generation) ---")
model.eval() # モデルを評価モードに設定
stop_token_id = tokenizer.convert_tokens_to_ids("<|im_end|>") # 生成終了を判断するための特殊トークンID
for data in prepared_data:
print(f"\n[Inferencing Index {data['index']}...]")
# システムプロンプト+ユーザー質問までのトークンIDを初期配列としてコピー
generated_ids = data["prompt_ids"][0].tolist()
with torch.no_grad(): # 推論中につき勾配計算を無効化
# 最大 30 トークンの自己回帰生成ループ
for _ in range(30):
# 現在の生成トークンIDシーケンスをテンソル化して転送
input_seq = torch.tensor([generated_ids], dtype=torch.long).to(device)
logits, img_len = model(data["image_tensor"], input_seq)
# シーケンスの最後尾(最新のトークン予測)における確率分布ロジットを取得
next_token_logits = logits[0, -1, :]
# 最も確率(スコア)が高いトークンIDを選択
next_token_id = torch.argmax(next_token_logits).item()
# 予測したトークンIDを履歴に追加
generated_ids.append(next_token_id)
# 終了トークン(<|im_end|>)が予測された場合は、ループを終了
if next_token_id == stop_token_id:
break
prompt_length = data["prompt_ids"].shape[1] # プロンプト(質問)部分のトークン長を取得
# 生成されたトークン全体からプロンプト部分を除外し、回答テキストのみをデコード
generated_text = tokenizer.decode(generated_ids[prompt_length:], skip_special_tokens=True)
print(f"Ground Truth : {data['answer']}")
print(f"Model Output : {generated_text}")
- 自己回帰生成アルゴリズム:
- モデルを eval() 評価モードに設定し、推論中は勾配計算を無効化(torch.no_grad())します。
- 開始プロンプトトークン ID の配列からスタートし、最大 30 トークンの生成ループを回します。
- ループ内では、現在のトークンシーケンスと画像をモデルに入力し、出力された最後のトークンロジット(
logits[0, -1, :])に対して argmax を適用することで、最も確率の高い次のトークン ID を選択します。 - 選択したトークン ID をシーケンスの末尾に追加し、再びモデルに入力する、という処理を繰り返します。
- 特殊な終了トークン
<|im_end|>(のID) が生成された時点で、予測ループを即座に終了(break)します。 - 生成されたトークン ID 配列から元の入力プロンプト部分を差し引き、tokenizer.decode で人間が読める日本語の文字列へとデコードして出力し、正解データ(Ground Truth)と比較します。
実行結果
上記のコードを実行すると以下の結果が得られます。
Using device: cuda
================ Index 0 ================
Original Images count : 2
Combined Image Size : (560, 224) (W x H)
Question : <image> <image> すべての画像を合わせると何匹の犬が見えますか?
Ground Truth : 3匹の犬が写っています。

================ Index 6 ================
Original Images count : 3
Combined Image Size : (420, 224) (W x H)
Question : <image> <image> <image> 何枚目の写真の猫が一番飼い猫の可能性が高いですか?理由も一緒に簡潔に教えてください。
Ground Truth : 2枚目の写真の猫です。理由は人と一緒に寝ているからです。

--- Training Start (Overfitting on indices [0, 6]) ---
Epoch [10/40], Average Loss: 0.9393
Epoch [20/40], Average Loss: 0.0936
Epoch [30/40], Average Loss: 0.0101
Epoch [40/40], Average Loss: 0.0034
--- Inference (Auto-regressive Generation) ---
[Inferencing Index 0...]
Ground Truth : 3匹の犬が写っています。
Model Output : 3匹の犬が写っています。
[Inferencing Index 6...]
Ground Truth : 2枚目の写真の猫です。理由は人と一緒に寝ているからです。
Model Output : 2枚目の写真の猫です。理由は人と一緒に寝ているからです。
実際のHugging Faceモデルを用いた推論テスト
Hugging Faceの transformers ライブラリを使用して、実際に事前学習済みの Qwen2-VL-2B-Instruct モデルをロードし、マルチモーダル推論テストを行うためのコードです。
import torch
from datasets import load_dataset
from PIL import Image
from transformers import AutoProcessor, Qwen2VLForConditionalGeneration
import matplotlib.pyplot as plt
# 複数画像をアスペクト比を維持したまま、横一列に統合する関数
def combine_images_with_aspect_ratio(
images, target_height=224, patch_multiple=28
):
processed_images = []
for img in images:
w_orig, h_orig = img.size
# アスペクト比を維持した新しい幅を算出
w_new = int(target_height * (w_orig / h_orig))
# 畳み込み処理のパッチサイズの倍数に適合するように幅を調整
w_rounded = max(
patch_multiple, round(w_new / patch_multiple) * patch_multiple
)
# 高品質リサンプリングを用いて画像をリサイズ
resized_img = img.resize(
(w_rounded, target_height), Image.Resampling.LANCZOS
)
processed_images.append(resized_img)
# 結合用キャンバスの幅(全画像の合計幅)を計算
total_width = sum(img.size[0] for img in processed_images)
# 横長の白紙キャンバス画像を新規作成
combined_img = Image.new("RGB", (total_width, target_height))
x_offset = 0
# 各画像を横にスライドさせながら貼り付け
for img in processed_images:
combined_img.paste(img, (x_offset, 0))
x_offset += img.size[0]
return combined_img
# 1. 2Bパラメータの事前学習済みQwen2-VL指示チューニングモデルとプロセッサをロード
model_id = "Qwen/Qwen2-VL-2B-Instruct"
model = Qwen2VLForConditionalGeneration.from_pretrained(
model_id,
torch_dtype=torch.bfloat16, # 計算メモリ節約と実行安定化のためにbfloat16を選択
device_map="auto" # 利用可能な最速デバイスに各レイヤーを自動で割り当て
)
processor = AutoProcessor.from_pretrained(model_id)
# 2. 推論を行うサンプルデータのロードと前処理
dataset = load_dataset("SakanaAI/JA-Multi-Image-VQA", split="test")
i = 1 # 1番目のサンプルを選択
sample = dataset[i]
images = sample["images"]
question = sample["question"]
print(f"質問: {question}")
print(f"元の画像枚数: {len(images)}枚")
# 複数画像を前処理して1枚のパノラマ画像にアラインメント
combined_image = combine_images_with_aspect_ratio(images)
print(f"統合後の画像サイズ: {combined_image.size}")
# 画像を画面に表示
plt.figure(figsize=(4 * len(images), 3))
plt.imshow(combined_image)
plt.title(f"Dataset Index {i} (Aspect Ratio Preserved)")
plt.axis("off")
plt.show()
# 3. チャット形式の構造テンプレートに画像枠と質問のプロンプトを挿入
messages = [
{
"role": "user",
"content": [
{"type": "image"}, # 画像を1枚に統合したため、イメージスロットは1つで処理します
{"type": "text", "text": question},
],
}
]
# モデル特有 of 会話タグ (<|im_start|>, <|im_end|>) を適用してプレーンテキストに展開
text = processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# プロセッサを利用してテキストトークン化と、ビジョン(統合画像)の処理を統合テンソルに変換
inputs = processor(
text=[text], images=combined_image, padding=True, return_tensors="pt"
).to(model.device)
# 4. モデルによる自己回帰生成の実行
with torch.no_grad(): # 推論のため評価モード&勾配計算オフ
generated_ids = model.generate(**inputs, max_new_tokens=512)
# 出力されたトークンID履歴から、入力プロンプト部分を差し引いて回答文の部分のみにトリミング
generated_ids_trimmed = [
out_ids[len(in_ids) :] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
]
# トークンIDから文字列テキストへとデコード
output_text = processor.batch_decode(
generated_ids_trimmed,
skip_special_tokens=True, # 特殊制御用トークンをスキップ
clean_up_tokenization_spaces=False,
)
print("\nモデルの出力:")
print(output_text[0])
print(f"\n(参考)データセットに用意されている正解: {sample['answer']}")
このスクリプトは、Hugging Faceの transformers ライブラリを使い、事前学習済みの Qwen2-VL-2B-Instruct モデルを用いて実際に複数画像とテキストのマルチモーダル推論テストを実行する完全なコードです。
- 画像の動的統合処理 (
combine_images_with_aspect_ratio):- 渡された画像リストの各画像について、アスペクト比を保ったままターゲットの高さ(224)にリサイズし、さらに幅をパッチサイズ(28)の倍数に丸めます。それらを水平方向に結合(Image.new および combined_img.paste)して1つの横長画像を作成します。これにより、複数の画像を1つの大きなコンテキストとしてシームレスに処理します。
- モデルとプロセッサの初期化:
- Qwen2VLForConditionalGeneration.from_pretrained を使用し、モデルをロードします。その際、torch.bfloat16 を指定することで、計算精度を高く保ちつつメモリ消費を大幅に節約します。
- AutoProcessor.from_pretrained によって、テキストのトークナイザおよびビジョンの画像プロセッサを包含する統合前処理プロセッサ(processor)を準備します。
- データセットのロードと画像の統合:
- load_dataset で日本語のマルチイメージVQAデータセットである JA-Multi-Image-VQA を読み込み、インデックス1 of データを取り出します。
- 質問文 question と画像リストを取得し、先ほど定義した combine_images_with_aspect_ratio 関数を使って複数画像を1枚の統合画像 combined_image にアラインメントします。
- プロンプトフォーマットとチャットテンプレートの適用:
- 対話テンプレートのフォーマットに沿ってメッセージ(messages)をリスト形式で作成します。画像は1枚にマージされているため、コンテンツ内のタイプは単一の
{"type": "image"}のみ指定し、テキストに question を渡します。 - processor.apply_chat_template を用いることで、ユーザーとアシスタントの構造タグ(
<|im_start|>や<|im_end|>)を含むモデル固有のテキストプロンプト(text)へ自動変換します。
- 対話テンプレートのフォーマットに沿ってメッセージ(messages)をリスト形式で作成します。画像は1枚にマージされているため、コンテンツ内のタイプは単一の
- 入力前処理とデバイス転送:
- 構築した text と統合画像 combined_image を processor に引き渡し、モデルが入力として直接受け取れるテンソル形式(inputs)へと変換し、モデルと同じデバイスへ転送(
.to(model.device))します。
- 構築した text と統合画像 combined_image を processor に引き渡し、モデルが入力として直接受け取れるテンソル形式(inputs)へと変換し、モデルと同じデバイスへ転送(
- 自己回帰生成とデコード:
- 勾配計算を無効化した上で、model.generate を呼び出し、max_new_tokens=512を指定して、推論を実行します。
- 得られた生成 ID 配列から、入力プロンプト部分(inputs.input_ids の長さ分)をトリミングし、アシスタントの純粋な回答部分だけを取り出します。
- 最後に processor.batch_decode を使用してテキストにデコードし、画面に出力してデータセット内の正解ラベル(sample['answer'])と比較します。
コードを実行すると以下の結果が得られます。
質問: <image> <image> どちらの画像にも写っている動物は何ですか?一言で答えてください。
元の画像枚数: 2枚
統合後の画像サイズ: (560, 224)
モデルの出力:
犬
(参考)データセットに用意されている正解: 犬

ライセンスとデータセットのクレジットについて
- 本実装で使用している日本語VQAデータセット JA-Multi-Image-VQA の画像は Unsplash License、テキスト等のデータは Apache License 2.0 に基づいて提供されています。
- 本実装で使用している Qwen2-VL-2B-Instruct モデルは Apache License 2.0 の下で提供されています。
- 本記事の概念モデル実装コードは、Hugging Face 上の zhouyik/DenseLabelDev で公開されている Qwen2-VL モデリングコードを一部参照して作成されています。
まとめ
Qwen2-VLは、アスペクト比を維持したまま動的に高解像度画像を処理できる画期的なビジョンエンコーダ(Qwen2VisionTransformer)と、トークン圧縮を行う PatchMerger、精度を高める 3D RoPE(時空間回転位置エンコーディング)を取り入れた先進的な言語デコーダ(Qwen2VLModel)を組み合わせることで、極めて高度な画像・動画認識能力を驚異的な計算効率で実現しています。
本記事で紹介した詳細なPyTorch実装のステップを通じて、3次元的な位置エンコードや空間トークンのマージ、自己回帰的なトークン生成アルゴリズムなど、その優れたアーキテクチャの動作原理を直観的に理解していただけたかと思います。
特に、データセットを用いた検証プロセスや、アスペクト比を保ったまま複数画像を1枚にマージして入力する combine_images_with_aspect_ratio のような実用的な前処理の実装例は、Qwen2-VLが持つマルチモーダル表現力を実環境で活かすための重要なテクニックです。
オープンソースでありながら最先端の推論モデル(Qwen2-VL-2B-Instruct など)をHugging Face APIから容易にロードして活用できるため、今後エッジAIやエージェントシステムへの高度なマルチモーダル機能の組み込みにおいて非常に強力な選択肢となるでしょう。
本記事の文章・構成の一部に生成AIを使用しています。