云镜收藏

稍后阅读

清单保存在当前浏览器,方便下次回来继续阅读。

清单还是空的

在笔记卡片或正文页点击“加入稍后阅读”即可收藏。

卷3 · 多模态理解 VLM

3.2 LLaVA 范式精读与复现:两阶段训练全流程

约 7 分钟阅读
全书目录

展开本章目录与同卷章节1. 架构:简单到令人不安2. 输入怎么拼:inputs_embeds 拼接(复现的核心技巧)3. 两阶段训练(必考,也是你复现的目录)阶段一:特征对齐(pretrain projector)阶段二:视觉指令微调(SFT)之后的演化(知道即可)4. 迷你复现路线(学生算力版,大三下的第一个大项目)5. 失败模式速查(复现时的排障表)6. 自测题7. 动手作业(大三下里程碑第一段)图解与案例 · 两阶段管线与”一条样本的一生”8. 延伸资源附录 · 完整可运行脚本:llava_mini.py(教学骨架)同卷章节3.1 多模态总览:把视觉接进语言模型的三条路线3.2 LLaVA 范式精读与复现:两阶段训练全流程3.3 Connector 设计空间:Flamingo、BLIP-2 与开源双雄的答案3.4 视觉 Tokenizer:VQ-VAE 与 VQGAN——离散化是生成路线的地基3.5 开源双雄精读:Qwen-VL 与 InternVL 系列技术报告3.6 文档理解与 OCR-free:让 VLM 读懂 PDF3.7 多模态评测体系与陷阱3.8 数据为中心的 VLM:具体案例研读

本章目标:吃透 LLaVA——结构最简、血统最正、最适合复现的 VLM 骨架。几乎所有开源 VLM 都是它的变体。学完标准:能讲清两阶段各自冻什么训什么、为什么这样设计,并能亲手跑一次迷你版两阶段训练。

1. 架构:简单到令人不安

LLaVA-1.5 的全部组件:

  • 视觉塔:CLIP ViT-L/14 @336px → 576 个视觉特征(336/14=24,24×24=576,取 patch token,CLS 弃用);
  • Connector:一个两层 MLP(Linear(1024→d) → GELU → Linear(d→d))——把 CLIP 特征投影到 LLM 词嵌入空间;
  • LLM:Vicuna/Qwen(任意 decoder-only)。

设计哲学:把复杂度从架构转移到数据和训练。后来者不断证明”简单 connector + 好数据 > 复杂 connector”(3.3 的 Q-Former 之死),这是本领域最重要的经验教训之一。

2. 输入怎么拼:inputs_embeds 拼接(复现的核心技巧)

占位符方案:文本里写 <image> 占位 token(占 576 个位置),过 embedding 层后,把这几个位置的嵌入向量替换成 projector 输出的视觉特征,再喂给 LLM:

embeds = llm.get_input_embeddings()(input_ids)          # (B, L, d) 文本嵌入
img_feats = proj(vision_tower(pixel_values))             # (B, 576, d) 视觉特征
embeds[img_mask] = img_feats.flatten(0, 1)               # 占位位置替换
out = llm(inputs_embeds=embeds, attention_mask=mask)     # 一套注意力统一处理

之后的一切(causal mask、next-token loss、SFT 的 loss mask)与卷2 完全相同。没有新机制——这就是 LLaVA 的力量所在。

3. 两阶段训练(必考,也是你复现的目录)

阶段一:特征对齐(pretrain projector)

  • 冻结:视觉塔 ❄️ + LLM ❄️;训练:仅 projector(约几十 M 参数);
  • 数据:约 558K 图文 caption(原版用 LCS558K,你复现用小规模 caption 集);
  • 为什么这样设计:CLIP 特征是”检索友好”的,但 LLM 词嵌入空间不认识它。此阶段的唯一任务是让 projector 学会翻译——把视觉特征平移到 LLM 嵌入空间中”有意义的位置”。LLM 冻结保证了翻译目标稳定(空间不动,只学映射)。

阶段二:视觉指令微调(SFT)

  • 冻结:视觉塔 ❄️(原版);训练:projector + LLM 全开;
  • 数据:665K 指令数据(多轮对话、VQA、详细描述、推理题,用 GPT-4 从 caption/框标注合成的”学术任务数据”——又是数据为中心的胜利);
  • 为什么 LLM 要解冻:模型要学会使用视觉信息回答问题(视觉条件下的语言能力),这是行为层的学习,需要整个 LLM 参与。

之后的演化(知道即可)

LLaVA-NeXT(AnyRes):高分辨率图切成网格块分别过 ViT + 一张缩略图——动态分辨率的标准解法,Qwen-VL 的动态 patch 同属此思路的家族;LLaVA-OneVision:图文视频统一训练。你复现的迷你版做到阶段二即可称为完整 LLaVA。

4. 迷你复现路线(学生算力版,大三下的第一个大项目)

配置:视觉塔用 open_clip 的 ViT-B/16(冻结);LLM 用 Qwen2.5-0.5B-Instruct(0 元档友好);projector 两层 MLP。

阶段一(免费卡可跑):

  • 数据:COCO caption 子集 5–10 万条(HF HuggingFaceM4/COCO 或本地裁剪);
  • 训练:仅 projector,bs 尽量大、epoch 1、lr 1e-3(新模块学习率可以大);
  • 验收标准:让模型对没见过的图做 caption,输出应”像话”——如果胡言乱语,对齐没成。

阶段二(租 4090 数小时):

  • 数据:LLaVA-Instruct 的一个子集(1–3 万条)或自己蒸馏的领域指令数据(接你导师方向);
  • 训练:projector + LLM(LoRA 或全参,0.5B 全参单卡毫无压力),lr 2e-5(LoRA 则 1e-4),epoch 1–2;
  • 验收标准:VQA 问答合理、能指图说话(grounding 描述)、无严重幻觉。

数据格式注意:<image>\n{question} 里占位符 expansion 由框架处理——用 LLaMA-Factory 时它的 llava template 已封装(visual_inputs 传图);用 transformers 的 AutoProcessor 系(Qwen2-VL 类)则走 apply_chat_template + image_token。第一次跑通建议直接用 LLaMA-Factory 的多模态配置,第二次再用裸 transformers 自己拼 inputs_embeds——前者学工程,后者学原理,两个都要做。

5. 失败模式速查(复现时的排障表)

症状嫌疑人
阶段一后 caption 全是重复短语projector 学习率过大 / 数据太少 / LLM 没冻结住(意外被更新)
阶段二后”失明”(回答与图无关)视觉特征没替换进正确位置(占位符 mask bug)/ projector 输出 dtype 错
图像越精细回答越差分辨率处理不一致(训练 336 推理 224 之类)——预处理必须逐位一致
loss 正常但回答是英文模板腔指令数据语言分布 / LLM 底座太小
显存爆视觉 token 数 × 序列长——max_seq_len 和图片分辨率先降一档

6. 自测题

  1. 阶段一为什么冻结 LLM?阶段二为什么解冻?(对齐目标稳定 / 学习视觉条件下的行为)
  2. 576 个视觉 token 从哪来?(336px ÷ 14px patch → 24×24)
  3. 为什么 CLIP 特征不能直接当词嵌入用?(检索空间 ≠ 词嵌入空间,尺度与语义结构不匹配——projector 是翻译官)
  4. AnyRes 解决什么?(高分辨率:切块保留细节 + 缩略图保全局)
  5. LLaVA 的经验教训对科研选择有什么启示?(数据与训练质量 > 架构炫技——“简单基线调强”是顶会认可的路径)

7. 动手作业(大三下里程碑第一段)

  1. 完成迷你版两阶段训练,repo 里放:训练前(阶段0:未对齐)→ 阶段一 → 阶段二 的同一批图片的回答对比图——这个对比是你理解”对齐”的最好证据,也是面试杀手锏;
  2. 消融一个变量(选一):阶段一数据量减到 1 万看对齐质量退化;或把 MLP projector 换成单层 Linear 对比;或解冻视觉塔观察是否过拟合;
  3. 用你的模型跑 8.1 作业里挑的名画——写下它”看画”时的具体错误,这是卷8.5(VLM 看画)的第一手素材;
  4. 训练日志 + 显存曲线进 README(用 0.4 的四件套)。

图解与案例 · 两阶段管线与”一条样本的一生”

flowchart LR
  subgraph S1["阶段一:对齐(只训 projector)"]
    I["图像"] --> V["视觉塔 ❄️"] --> P["projector 🔥"] --> M1["caption loss"]
    M2["55.8万 caption"] --> M1
  end
  P --> OUT1["学到了:翻译能力"]
  subgraph S2["阶段二:视觉指令微调(projector + LLM 解冻)"]
    OUT1 --> D2["指令数据"] --> T2["projector 🔥 + LLM 🔥<br>视觉塔仍 ❄️"]
  end
  T2 --> OUT2["学到了:看图说话的行为"]

案例:一条训练样本的一生

输入:一张 336×336 油画 + 问句”这幅画的流派是什么?”

  1. 图像过冻结 CLIP → 576 个特征 → projector → 576 个”外语 token”;
  2. 问句 tokenizer 后约 12 个文本 token;序列中 <image> 占位符所在的 576 个位置被视觉嵌入顶替;
  3. 最终序列:[视觉×576][问句×12][回答×8],整条过一次 causal attention——视觉 token 对 LLM 就是不认识但可以学的外来词;
  4. loss mask:问句部分标签 = −100(不学提问),只有回答的约 8 个 token 计入 loss;
  5. 阶段一时 LLM 冻结 → 8 个 token 的梯度只流向 projector(学翻译);阶段二解冻 → 梯度涌向整个 LLM(学行为)。

一段话总结:VLM 训练没有新机制,只是”把一批特殊 token 塞进序列 + 规定哪些位置算 loss”。

8. 延伸资源

  • LLaVA 原论文 + LLaVA-1.5 论文——连读,看”简单架构如何一步步调强”
  • llava 官方 repo(LLaVA-VL)——llava/train/train.py 对照本章逐行读
  • LLaMA-Factory 多模态文档——工程捷径

附录 · 完整可运行脚本:llava_mini.py(教学骨架)

依赖:torch>=2.2 transformers>=4.44 open_clip_torch pillow tqdm。数据:jsonl,每行 {"image": "path.jpg", "question": "...", "answer": "..."}。这是教学骨架(跳过 chat template,模板问题见 2.5)——先跑通,再换成 LLaMA-Factory 正式管线。

# llava_mini.py —— 迷你 LLaVA:视觉塔❄️ + projector + Qwen2.5-0.5B
import argparse, json, math, torch, torch.nn as nn
from PIL import Image
from torch.utils.data import Dataset, DataLoader
from transformers import AutoModelForCausalLM, AutoTokenizer
import open_clip

class MiniLLaVA(nn.Module):
    def __init__(self, device="cuda"):
        super().__init__()
        self.clip, _, _ = open_clip.create_model_and_transforms("ViT-B-16", pretrained="openai")
        self.clip = self.clip.to(device).eval()
        for p in self.clip.parameters(): p.requires_grad = False     # 视觉塔永久冻结
        d_llm = AutoModelForCausalLM.from_config(
            __import__("transformers").AutoConfig.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")).config.hidden_size
        self.proj = nn.Sequential(nn.Linear(512, d_llm), nn.GELU(), nn.Linear(d_llm, d_llm))
        self.llm = AutoModelForCausalLM.from_pretrained(
            "Qwen/Qwen2.5-0.5B-Instruct", torch_dtype=torch.bfloat16).to(device)
        self.tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
        self.device = device
    def vision_tokens(self, pixel):                                   # (B,3,224,224) → 49 个 token
        f = self.clip.encode_image(pixel, normalize=False)            # (B,512,7,7) 前置投影后
        return f.flatten(2).transpose(1, 2)                           # (B,49,512)
    def forward(self, pixel, q_ids, a_ids):
        vis = self.proj(self.vision_tokens(pixel).to(torch.bfloat16)) # (B,49,d)
        qe = self.llm.get_input_embeddings()(q_ids)                   # (B,Lq,d)
        ae = self.llm.get_input_embeddings()(a_ids)                   # (B,La,d)
        inputs = torch.cat([vis, qe, ae], dim=1)
        Lq, La = q_ids.size(1), a_ids.size(1)
        labels = torch.cat([torch.full((inputs.size(0), 49 + Lq), -100,
                             device=inputs.device, dtype=torch.long), a_ids], dim=1)
        return self.llm(inputs_embeds=inputs, labels=labels)          # HF 内部算 shift loss

class DS(Dataset):
    def __init__(self, path, clip_pre, tok, max_q=24, max_a=48):
        self.rows = [json.loads(l) for l in open(path, encoding="utf-8")]
        self.pre, self.tok, self.mq, self.ma = clip_pre, tok, max_q, max_a
    def __len__(self): return len(self.rows)
    def __getitem__(self, i):
        r = self.rows[i]
        img = self.pre(Image.open(r["image"]).convert("RGB"))
        q = self.tok(r["question"], add_special_tokens=False).ids[:self.mq]
        a = self.tok(r["answer"] + self.tok.eos_token, add_special_tokens=False).ids[:self.ma]
        return img, torch.tensor(q), torch.tensor(a)

def collate(batch, pad):
    pixel = torch.stack([b[0] for b in batch])
    def padto(seqs):
        m = max(len(s) for s in seqs)
        return torch.stack([torch.cat([s, torch.full((m-len(s),), pad)]) for s in seqs])
    return pixel, padto([b[1] for b in batch]), padto([b[2] for b in batch])

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--stage", type=int, required=True, choices=[1, 2])
    ap.add_argument("--data", required=True); ap.add_argument("--resume")
    ap.add_argument("--epochs", type=int, default=1); ap.add_argument("--bs", type=int, default=8)
    args = ap.parse_args()
    dev = "cuda" if torch.cuda.is_available() else "cpu"
    model = MiniLLaVA(dev)
    if args.resume: model.proj.load_state_dict(torch.load(args.resume, map_location=dev))
    if args.stage == 1:                                            # 对齐:LLM 冻结,只训 projector
        for p in model.llm.parameters(): p.requires_grad = False
        lr = 1e-3
    else:                                                          # 行为:projector+LLM 解冻
        lr = 2e-5
    params = [p for p in model.parameters() if p.requires_grad]
    print(f"可训练参数:{sum(p.numel() for p in params)/1e6:.2f}M")
    opt = torch.optim.AdamW(params, lr=lr, weight_decay=0.01)
    ds = DS(args.data, None, model.tok)
    dl = DataLoader(ds, batch_size=args.bs, shuffle=True,
                    collate_fn=lambda b: collate(b, model.tok.pad_token_id))
    model.train()
    for ep in range(args.epochs):
        for i, (pixel, q, a) in enumerate(dl):
            pixel, q, a = pixel.to(dev), q.to(dev), a.to(dev)
            out = model(pixel, q, a)
            opt.zero_grad(set_to_none=True)
            out.loss.backward()
            torch.nn.utils.clip_grad_norm_(params, 1.0)
            opt.step()
            if i % 20 == 0: print(f"ep{ep} step{i} loss={out.loss.item():.3f}")
    tag = "stage1.pt" if args.stage == 1 else "final.pt"
    torch.save(model.proj.state_dict(), tag)
    print("saved →", tag, "(验收:3.2 §5 的三段对比图,训练前后各截 10 例)")

if __name__ == "__main__":
    main()