Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

视觉语言模型

把一张取货单递给模型,问它:“几点取货?”这次,我们不满足于找到一张相关图片,而是希望模型读出单据上的时间,用文字回答。

这一步看起来只是多说一句话,内部却有一个重要变化:图片不再只参与最后的相似度排序,它还要成为语言模型生成回答时能够使用的上下文。

上一章把图片与文字映射到可以比较的空间。这一章把视觉表示接到语言模型上,观察视觉语言模型,也常简称 VLM,怎样一边利用图片,一边预测下一个词元。我们会先拆开连接与训练过程,再请一个真实小模型读单据、看柱状图、判断图形位置。

1. 语言模型要读到的是向量

语言模型的 Transformer 实际接收的是嵌入向量。文字先经过分词与嵌入查表,再变成这些向量;图片也可以经过视觉编码器和连接层,变成能够放进同一序列的向量。

设视觉编码器为一张图输出 MM 个特征,每个特征为 dvd_v 维,记为矩阵 HvH_v。语言模型每个输入位置的宽度为 dld_l。最简单的连接方法是共享一个线性投影:

Hv∈RM×dv,Zv=HvW. H_v\in\mathbb{R}^{M\times d_v}, \qquad Z_v=H_vW.

其中 WW 的形状为 dv×dld_v\times d_l,投影后 ZvZ_v 的形状为 M×dlM\times d_l。也可以增加偏置、使用多层感知机,或通过额外模块压缩视觉位置的数量。

这里常把 ZvZ_v 的各行称为视觉 Token。在这类模型里,它们是连续向量,并不一定是从一个图像词典中选出的离散编号。聊天模板中的 <image> 则是标记:处理器和模型约定在相应位置放入图像特征。只把 <image> 这几个字符输入普通文字模型,并不会让它看见图片。

原始 LLaVA 采用了连接视觉编码器与语言模型的投影方案;BLIP-2 则使用可学习的查询模块来提取适合语言模型使用的视觉信息。不同模型并不共享唯一一种连接结构。LLaVA 论文、BLIP-2 论文

为什么不只保留一个整图向量?

上一章做整图检索时,一个汇总向量就能参与排序。但若问题是“右上角的订单号是什么”,模型需要利用局部信息。保留多个视觉位置,可以给语言模型更多机会访问不同区域的特征。

这些位置经过视觉自注意力后也会混合全图信息,所以不能简单地把每个向量解释成一个互不相干的小方格。位置对应关系是理解结构的起点,不是对模型内部含义的完整说明。

看看本章真实模型的尺寸

后面的实验使用 SmolVLM-500M-Instruct。对于本章固定的单幅 512×512512\times512 输入,它的视觉图块边长为 16,先得到 32×32=102432\times32=1024 个位置,每个位置 768 维。

连接器把相邻 4×44\times4 个位置的特征拼接起来,得到 64 个较宽的向量,每个为 16×768=1228816\times768=12288 维;随后线性投影到语言模型的 960 维。这里是重排并拼接特征,再做投影,不是简单地把 16 个向量取平均。

图 1:根据固定模型配置和实际执行路径绘制。推理时记录到连接器输出形状为 [1, 64, 960];最前面的 1 是批量大小。

压缩位置数能减少后续语言模型处理的序列长度,但也改变了细节如何进入语言模型。给一页密密麻麻的表格保留多少视觉位置,是计算成本与任务需求之间的具体选择。

这些数字属于本章固定配置。模型仓库的默认处理器支持将高分辨率图片拆成多幅;本例为了方便在 CPU 上观察,关闭了这种拆分。不能把这里的“64 个视觉位置”推广为所有分辨率、所有 VLM 的固定规定。固定模型配置、对应版本连接器实现

2. 看图回答,仍然在预测下一个词元

把视觉向量与问题的文字向量放入模型后,回答仍然按自回归方式生成:

p(y∣I,q)=∏t=1Tp(yt∣I,q,y<t). p(y\mid I,q) =\prod_{t=1}^{T}p(y_t\mid I,q,y_{<t}).

II 是图片,qq 是问题,yty_t 是回答的第 tt 个词元,y<ty_{<t} 是此前已经生成的回答。图片与问题提供条件,语言模型继续决定下一个词元。

这也解释了两件容易混淆的事。

第一,VLM 并不一定先把图片完整翻译成一段描述,再把描述交给语言模型。图 1 中,进入语言模型的是视觉特征本身。OCR 加语言模型则是另一种可以显式拆开的路线:先识别文字及位置,再组织答案。两种路线都可以用于文档任务,也可以结合使用。

第二,能写出流畅答案,并不自动说明答案来自图片。语言模型也可能根据问题中的暗示、训练中常见的模式或先前对话,生成一个听起来合理的回答。因此,看图任务需要检查图片变化时答案是否随之变化。

3. 多模态指令微调,监督哪些位置?

回想第 26 章的指令微调:问题作为输入,主要在助手回答的位置计算损失。加入图片后,这个原则仍然适用。

一条训练样本可以包含“图片、问题、回答”,也可以包含围绕同一张图的多轮对话。模型读入视觉特征和已有文字,在需要生成的回答位置预测正确词元。

设 AA 是参与监督的回答位置集合,包含希望模型学会的结束标记。一个常见的平均损失写作:

L=−1∣A∣∑t∈Alog⁡pθ(yt∣I,q,y<t). L=-\frac{1}{|A|} \sum_{t\in A} \log p_\theta(y_t\mid I,q,y_{<t}).

视觉位置、用户问题以及填充位置不需要被当作本次回答的目标。在代码中,可以把它们的标签设为 -100,交给交叉熵的 ignore_index 跳过。注意,模型库是否在内部移动标签、聊天模板怎样标记轮次,需要核对,不能重复移动一次。

忽略视觉位置的标签,不是切断视觉梯度

下面是一条极小序列:

BOS,视觉0,视觉1,视觉2,视觉3,问题,回答开始,红,EOS

只有“红”和 EOS 是监督标签。但由于模型用当前位置的输出预测下一个词元,预测“红”的分数来自“回答开始”位置,预测 EOS 的分数来自“红”位置。

图 2:输入位置从 0 开始。标签放在位置 7、8;移动一位以后,用位置 6、7 的分数计算损失。

回答能够关注前面的视觉向量,所以回答损失仍会通过注意力向视觉连接层传递梯度。-100 只是不要求模型在这些标签位置“猜出一个视觉词”,不代表模型不能利用图片。

进一步说,即使冻结语言模型的参数,梯度仍然可以穿过语言模型,传回连接层。冻结是“不更新这些参数”,而不是“这段计算不需要求导”。如果把整个语言模型前向放进 torch.no_grad(),连接层的训练路径也会被切断。

用一个小程序亲手核对

下面的程序用两个 8×88\times8 的纯色图,先得到四个 8 维特征,再投影到 16 维。视觉编码器和微型语言模型都使用随机参数并被冻结,只让连接层做一次梯度更新。

它的用途很窄:核对形状、因果掩码、监督位置和梯度传递。随机语言模型没有预训练知识,不能把这次损失下降解释成“它已经学会看图”。

python3 src/examples/visual_prefix.py
展开完整示例:视觉前缀与回答损失
"""随机微型网络:只核对视觉前缀、回答掩码和连接层梯度,不展示看图能力。"""

import torch
from torch import nn
from torch.nn import functional as F


class TinyLanguageModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.embedding = nn.Embedding(8, 16)
        self.position = nn.Embedding(16, 16)
        self.block = nn.TransformerEncoderLayer(16, 2, 32, dropout=0,
                    batch_first=True, norm_first=True, activation="gelu")
        self.head = nn.Linear(16, 8)

    def forward(self, embeddings):
        length = embeddings.shape[1]
        x = embeddings + self.position(torch.arange(length))[None]
        causal = torch.ones(length, length, dtype=torch.bool).triu(1)
        return self.head(self.block(x, src_mask=causal))


def pack(visual, language_model, answers):
    batch = len(visual)
    # 词元 ID:BOS=1,问题=2,回答开始=3,红=4,EOS=5,蓝=6。
    before = torch.ones(batch, 1, dtype=torch.long)
    after = torch.stack([torch.full_like(answers, 2), torch.full_like(answers, 3),
                         answers, torch.full_like(answers, 5)], dim=1)
    embeddings = torch.cat([language_model.embedding(before), visual,
                            language_model.embedding(after)], dim=1)
    labels = torch.full(embeddings.shape[:2], -100, dtype=torch.long)
    labels[:, -2] = answers
    labels[:, -1] = 5
    return embeddings, labels


def answer_loss(logits, labels):
    # 当前位置预测下一个词元;最后一个位置的分数不参加这次损失。
    return F.cross_entropy(logits[:, :-1].reshape(-1, logits.shape[-1]),
                           labels[:, 1:].reshape(-1), ignore_index=-100)


def experiment():
    torch.set_num_threads(1)
    torch.manual_seed(2026)
    torch.backends.mha.set_fastpath_enabled(False)
    vision = nn.Conv2d(3, 8, 4, stride=4)
    language = TinyLanguageModel().eval()
    bridge = nn.Linear(8, 16)
    vision.requires_grad_(False)
    language.requires_grad_(False)
    images = torch.zeros(2, 3, 8, 8)
    images[0, 0] = 1.; images[1, 2] = 1.
    answers = torch.tensor([4, 6])
    with torch.no_grad():
        features = vision(images).flatten(2).transpose(1, 2)
    visual = bridge(features)
    visual.retain_grad()
    embeddings, labels = pack(visual, language, answers)
    logits = language(embeddings)
    loss = answer_loss(logits, labels)
    loss.backward()
    gradient = bridge.weight.grad.norm().item()
    visual_gradient = visual.grad.norm().item()
    frozen_clean = all(p.grad is None for p in [*vision.parameters(), *language.parameters()])
    before = loss.item()
    torch.optim.SGD(bridge.parameters(), lr=.2).step()
    with torch.no_grad():
        embeddings, _ = pack(bridge(features), language, answers)
        after = answer_loss(language(embeddings), labels).item()
    return {"features": list(features.shape), "visual": list(visual.shape),
            "sequence": list(embeddings.shape), "supervised": int((labels != -100).sum()),
            "labels": labels.tolist(), "before": before, "after": after,
            "bridge_gradient": gradient, "visual_gradient": visual_gradient,
            "frozen_clean": frozen_clean}


def main():
    result = experiment()
    print("随机微型网络:这里只核对连接方式与梯度,不测看图能力。")
    print(f"视觉特征 {result['features']} → 连接后 {result['visual']}")
    print(f"拼接序列 {result['sequence']};监督位置共 {result['supervised']} 个(含 EOS)。")
    print("标签:", result["labels"])
    print(f"损失:{result['before']:.6f} → {result['after']:.6f}")
    print(f"连接层梯度范数 {result['bridge_gradient']:.6f};视觉位置梯度范数 {result['visual_gradient']:.6f}")
    print("冻结参数没有梯度:", result["frozen_clean"])


if __name__ == "__main__":
    main()

在前面章节的 PyTorch 2.14.1 CPU 环境中,输出为:

随机微型网络:这里只核对连接方式与梯度,不测看图能力。
视觉特征 [2, 4, 8] → 连接后 [2, 4, 16]
拼接序列 [2, 9, 16];监督位置共 4 个(含 EOS)。
标签: [[-100, -100, -100, -100, -100, -100, -100, 4, 5], [-100, -100, -100, -100, -100, -100, -100, 6, 5]]
损失:2.608265 → 2.607474
连接层梯度范数 0.044757;视觉位置梯度范数 0.024002
冻结参数没有梯度: True

从接通到会用,还需要数据

实际训练通常利用已经学过图片和文字的模型。可以先让连接层适应已有表示,再按任务需要训练连接层与语言模型的部分或全部参数;视觉编码器是否解冻也要根据数据与预算决定。原始 LLaVA 的两个阶段分别训练连接层、再训练连接层与语言模型,同时保持视觉编码器冻结。这是一种具体方案,不是所有 VLM 的必经步骤。原论文训练部分

训练数据还应当要求模型真的使用图片。例如,同一个问题搭配不同图片时,答案应随可见信息改变;图片不包含答案时,要提供合适的“不知道”或信息不足示例。如果训练里“这是什么颜色?”总配上“红色”,模型完全可能不看图也拿到不错的损失。

划分训练与测试时,同一张图片的多个问答也不应随意拆到两边。否则模型可能已经见过测试图片,只是换了一种问法。文档任务还要留意同一模板、同一来源或近乎相同的页面,避免把记住版式误当作泛化。

4. 请一个真实小模型来做题

接下来使用已训练好的 SmolVLM-500M-Instruct。它的模型说明标注主要使用英语,因此实验问题与图片文字都用英语,正文逐项解释;这里不顺带评价中文能力。模型说明

我们自绘了取货单、柱状图和几何图形,没有使用真实订单或外部照片。答案由绘图时的内容与坐标决定,评分时才使用;模型输入只有图片和问题。

实验固定模型修订 a7da5b986cb59b408707209984f360a5f4ad7e47,使用 Python 3.12.12、Transformers 4.57.3、PyTorch 2.14.1、Pillow 12.0.0,CPU FP32、4 线程、eager attention。每道题独立开始,不把上一题的回答留在对话里;贪心生成,最多 32 个新词元,本次全部在 EOS 结束。

模型权重文件约 1.02 GB,首次运行需要下载;FP32 加载后的内存还包括转换后的权重、中间张量和缓存,不能用文件大小代替运行内存。

先看保存记录,再自行运行

不加载模型,也能复核随书保存的回答:

python3 src/examples/visual_language.py

想重新运行时,安装视觉实验依赖,使用新输出文件:

python3 -m pip install -r src/examples/requirements-vision.txt
python3 src/examples/visual_language.py \
  --run --out runs/my-visual-language.json

模型已经缓存后,可以增加 --local-only。脚本复用同目录的题目与评分模块,建议保留仓库目录结构运行。九张图片随书提供;若需重新生成,可运行:

python3 src/examples/vlm_cases.py
展开完整推理与记录代码
"""第 41 章:固定小型视觉语言模型的真实看图记录。默认只复核随书结果。"""

from argparse import ArgumentParser
from hashlib import sha256
from pathlib import Path
import json
import platform
import time

from vlm_cases import CASES, IMAGE_DIR, score

MODEL = "HuggingFaceTB/SmolVLM-500M-Instruct"
REVISION = "a7da5b986cb59b408707209984f360a5f4ad7e47"
REPORT = Path(__file__).with_name("data") / "visual-language.json"
MAX_NEW_TOKENS = 32


def digest(path):
    result = sha256()
    with Path(path).open("rb") as stream:
        for block in iter(lambda: stream.read(1024*1024), b""):
            result.update(block)
    return result.hexdigest()


def run(output, local_only=False):
    import torch
    import transformers
    import PIL
    from PIL import Image
    from huggingface_hub import snapshot_download
    from transformers import AutoProcessor, AutoModelForImageTextToText

    torch.set_num_threads(4)
    torch.manual_seed(2026)
    snapshot = Path(snapshot_download(MODEL, revision=REVISION,
        local_files_only=local_only,
        allow_patterns=["*.json", "*.txt", "model.safetensors", "README.md"]))
    processor = AutoProcessor.from_pretrained(snapshot, trust_remote_code=False, use_fast=False)
    # 固定为单幅、最长边 512;不使用仓库默认的多块高分辨率设置。
    processor.image_processor.size = {"longest_edge": 512}
    processor.image_processor.do_image_splitting = False
    model = AutoModelForImageTextToText.from_pretrained(snapshot, dtype=torch.float32,
        attn_implementation="eager", trust_remote_code=False).eval()
    report = {
        "complete": False, "model": MODEL, "revision": REVISION,
        "source_sha256": digest(__file__), "cases_sha256": digest(Path(__file__).with_name("vlm_cases.py")),
        "weights_sha256": digest(snapshot/"model.safetensors"),
        "weights_bytes": (snapshot/"model.safetensors").stat().st_size,
        "environment": {"python": platform.python_version(), "torch": torch.__version__,
                        "transformers": transformers.__version__, "pillow": PIL.__version__,
                        "platform": platform.platform(), "device": "cpu", "dtype": "float32", "threads": 4},
        "config": {"max_new_tokens": MAX_NEW_TOKENS, "do_sample": False,
                   "attention": "eager", "image_size": 512, "image_splitting": False,
                   "image_seq_len": processor.image_seq_len,
                   "image_token_id": model.config.image_token_id,
                   "vision_patch_size": model.config.vision_config.patch_size,
                   "vision_hidden_size": model.config.vision_config.hidden_size,
                   "scale_factor": model.config.scale_factor,
                   "text_hidden_size": model.config.text_config.hidden_size,
                   "eos_token_id": model.generation_config.eos_token_id},
        "processor": processor.image_processor.to_dict(),
        "rows": [],
    }
    output.parent.mkdir(parents=True, exist_ok=True)
    observed = []
    handle = model.model.connector.register_forward_hook(
        lambda module, args, result: observed.append(list(result.shape)))
    try:
        for case in CASES:
            image_path = IMAGE_DIR / case["image"]
            with Image.open(image_path) as source:
                image = source.convert("RGB")
            messages = [{"role": "user", "content": [
                {"type": "image"}, {"type": "text", "text": case["question"]}]}]
            prompt = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
            inputs = processor(text=prompt, images=[image], return_tensors="pt")
            length = inputs.input_ids.shape[1]
            observed.clear()
            started = time.perf_counter()
            with torch.inference_mode():
                generated = model.generate(**inputs, max_new_tokens=MAX_NEW_TOKENS, do_sample=False,
                                           use_cache=True)
            elapsed = time.perf_counter()-started
            token_ids = generated[0, length:].tolist()
            answer = processor.decode(token_ids, skip_special_tokens=True)
            eos = model.generation_config.eos_token_id
            eos_ids = [eos] if isinstance(eos, int) else eos
            row = {**case, "image_sha256": digest(image_path), "image_size": list(image.size),
                   "prompt": prompt, "input_ids": inputs.input_ids[0].tolist(),
                   "input_tokens": length,
                   "visual_tokens": int((inputs.input_ids == model.config.image_token_id).sum()),
                   "pixel_values_shape": list(inputs.pixel_values.shape),
                   "valid_pixels": int(inputs.pixel_attention_mask.sum()),
                   "connector_shapes": list(observed),
                   "output_ids": token_ids, "answer": answer, "seconds": elapsed,
                   "stop_reason": "eos" if token_ids and token_ids[-1] in eos_ids else "limit",
                   "score": score(case, answer)}
            report["rows"].append(row)
            output.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n")
            print(f"{case['id']}: {answer!r};{row['score']}", flush=True)
    finally:
        handle.remove()
    report["complete"] = True
    output.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n")
    return report


def checked_report(path=REPORT):
    report = json.loads(path.read_text())
    assert report["complete"] and report["model"] == MODEL and report["revision"] == REVISION
    assert report["source_sha256"] == digest(__file__)
    assert report["cases_sha256"] == digest(Path(__file__).with_name("vlm_cases.py"))
    assert report["config"]["max_new_tokens"] == MAX_NEW_TOKENS
    assert len(report["rows"]) == len(CASES)
    for row, case in zip(report["rows"], CASES):
        assert all(row[key] == value for key, value in case.items())
        assert row["image_sha256"] == digest(IMAGE_DIR/case["image"])
        assert row["score"] == score(case, row["answer"])
        assert row["input_tokens"] == len(row["input_ids"])
        assert row["visual_tokens"] == row["input_ids"].count(report["config"]["image_token_id"])
        assert 0 < len(row["output_ids"]) <= MAX_NEW_TOKENS
        assert row["seconds"] > 0
    return report


def print_summary(report):
    print("随书保存的真实图文回答;默认不下载、不加载模型:")
    for row in report["rows"]:
        print(f"{row['id']}: {row['answer']!r};{row['score']}")
    print("固定 11 道自编题,单次贪心生成;不是通用视觉能力基准。")


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--run", action="store_true", help="实际加载权重并重新生成")
    parser.add_argument("--out", type=Path, help="新实验的保存路径")
    parser.add_argument("--local-only", action="store_true", help="只使用本机模型缓存")
    args = parser.parse_args()
    if args.run:
        if args.out is None:
            parser.error("--run 需要 --out,避免无意覆盖随书记录")
        run(args.out, args.local_only)
    else:
        print_summary(checked_report())


if __name__ == "__main__":
    main()

默认模式的输出如下。引号内保留模型实际返回的空格与标点:

随书保存的真实图文回答;默认不下载、不加载模型:
note-a: ' 17:30.';{'exact': True}
note-b: ' 18:45.';{'exact': True}
note-small: ' 17:30.';{'exact': True}
note-crop: ' 17:30.';{'exact': True}
note-blank: ' 1:00.';{'invented_time': True}
chart-a: ' B.';{'exact': True}
chart-b: ' A.';{'exact': True}
chart-value: ' 7.';{'exact': True}
position-a: ' Left.';{'exact': True}
position-b: ' Left.';{'exact': False}
box-a: ' [1, 1].';{'format_valid': False, 'iou': None}
固定 11 道自编题,单次贪心生成;不是通用视觉能力基准。

完整记录还包含图片指纹、模板展开后的提示、输入与输出词元、连接器形状、处理器配置及生成耗时。短答案题只忽略首尾空白、结尾句点和英文字母大小写;不会把一个含糊长回答自动改成正确答案。

5. 读到时间,是否就理解了单据?

先看这张取货单:

图 3:自绘测试图片。问题是“What is the pickup time on this note?”,即询问取货时间,并要求只返回时间。

模型回答 17:30.。把图上的时间改成 18:45,其余内容保持不变,模型也改答 18:45.。这组对照比只展示一次正确答案更有信息:在这两张图上,回答确实跟随时间字段变化。

但这还只是单字段读取。真实文档还可能要求区分“下单时间”“付款时间”和“取货时间”,跨页寻找订单号,或者把商品名与同一行的数量对应起来。识别出字符不等于理解字段关系,更不等于完成跨页核对。

把图缩小,再把字段裁出来

我们还做了两种输入变换:

  • note-small:先把整张取货单缩成 64×6464\times64。
  • note-crop:按已知版式,人工指定取货时间所在的长条区域。

两者在这次实验里仍然都答对。原图、缩小图、裁剪图的答案都一样,所以这里没有测到准确率改善。不能把这个结果写成“裁剪一定提升效果”,也不能推断所有小字都能在低分辨率下读清。

还有一个实现细节值得亲自检查:本例关闭图片拆分后,所用处理器最终会把输入重采样为 512×512512\times512。因此,64 像素的小图会被放大,长条裁剪图也会被拉成方形;它们都对应 64 个视觉位置。放大并不会恢复缩小时丢失的信息,而长条变方形还会改变字形比例。固定版本的图像处理实现

这次裁剪区域由我们预先指定,没有让模型自己发现字段。它检验的是“已给定区域以后能否读取”,不能当作自动文档定位的成绩。

空白图上的“1:00”

最后,把输入换成纯白图片,保留同一个取货时间问题。模型回答了 1:00.。

图片里没有这个时间。这是一次具体的无依据回答:模型完成了“输出一个时间”的形式要求,却没有做到“只回答图片支持的内容”。

脚本中的 invented_time 只检查空白图回答中是否出现类似 1:00 的时间格式,不自动评价所有自由回答。一个完整系统还需要设计信息不足时的行为,并用真实困难样本检验它,不能因正常单据答对了两次,就省略这一步。

6. 柱状图:读出数字与理解关系

再看一张很简单的图:

图 4:三个柱子上方直接写出数值,横轴使用 A、B、C 标签。

询问“哪根柱子最高”,模型回答 B。把 A、B 的高度与数值交换,答案变成 A。再单独问 B 的数值,回答为 7。

这些答案在当前图上都是正确的。但由于柱子上方已经写了数字,模型可以通过读数字完成比较;我们并没有单独证明它会根据刻度估计没有标值的柱高。若要区分这两种能力,可以另做去掉柱顶数字的对照。

更复杂的图表还会引入单位、图例、多条曲线、双纵轴和截断坐标轴。评价时最好拆开问:选对了哪条系列?读对了哪个数?单位是否一致?计算是否正确?这样一个最终错误才容易追到具体环节。

对需要精确计算的任务,也可以让系统先提取结构化数值,再交给普通代码计算,并保留它所依据的图表位置。后面的算术即使完全正确,也不能补救前面读错的数字。

7. 说对“左边”,还没完成定位

同一个位置问题,分别配上下面两张图片:

图 5:问题和模型设置完全相同,只交换红方块与蓝圆的位置。为便于比较图形,插图展示两张原图的中间区域;模型实际接收完整的 512×512 图片。

第一张回答正确,第二张错误。仅凭第一张,容易以为模型已经会判断左右;加入交换位置的对照以后,这个结论就站不住了。当然,两次回答也不能证明模型“永远只会答左边”,它们只暴露了当前设置中的一次失败。

坐标框是另一个任务

如果希望模型指出“红色方块在哪里”,可以要求它返回矩形框:

b=(xmin⁡,ymin⁡,xmax⁡,ymax⁡). b=(x_{\min},y_{\min},x_{\max},y_{\max}).

先约定原点在左上角,xx 向右增加,yy 向下增加。对于宽 WW、高 HH 的图片,可用像素坐标,也可分别除以宽、高得到归一化坐标。例如:

x~=x/W,y~=y/H. \tilde{x}=x/W,\qquad \tilde{y}=y/H.

必须说明采用哪一种,否则 0.5 与 256 可能表示同一个位置,也可能完全不是一回事。

图 6:参考框来自绘图程序。方块覆盖 x=64~183、y=176~295 的像素,使用右、下边界不包含在内的框约定,写作 [64, 176, 184, 296]。

本章向真实模型请求四个像素坐标,它返回了 [1, 1].。这既不满足四个数的要求,也不是合法的纯 JSON 数组,所以先记为格式失败,不给它编造一个预测框。

输出格式正确以后,才有必要计算几何误差。常用的交并比 IoU 为:

IoU⁡(Bp,Bg)=∣Bp∩Bg∣∣Bp∪Bg∣. \operatorname{IoU}(B_p,B_g)= \frac{|B_p\cap B_g|}{|B_p\cup B_g|}.

BpB_p 是预测框,BgB_g 是参考框,竖线表示面积。两框重合时为 1,没有重叠时为 0。它衡量的是面积重合,不是模型回答的自信程度。

用一个教学算例:参考框是边长 120 的正方形;假设预测框大小相同,只向右偏了 20 像素,那么交集宽为 100、高为 120:

IoU⁡=100×1202×1202−100×120=57≈0.714. \begin{aligned} \operatorname{IoU} &=\frac{100\times120} {2\times120^2-100\times120}\\ &=\frac57\approx0.714. \end{aligned}

这个偏移框是用来解释指标的假设,不是模型本次输出。本次格式不合法,记录中的 IoU 为 None,不把“没得到框”和“得到合法但不重叠的框”混成同一种情况。

若图像经过裁剪、缩放或旋转,还必须把坐标映射回约定的图像空间。不能把裁剪图上的 100 像素,直接标到原图的第 100 像素上。

8. 把“会看图”拆成可以检查的小问题

这组实验没有汇总成一个总准确率。读取时间、比较柱高、判断左右、输出坐标与空图拒答的要求不同;只报一个分数,很容易掩盖哪一环出了问题。

要检查的事情可以怎样做对照
回答是否依赖图片保持问题,替换图片中的关键事实
是否读清局部文字比较原图、缩小图与已知区域裁剪
是否理解图表关系改变数值、交换系列、去掉直接标值
是否理解空间关系交换对象位置,保持问题与对象不变
是否能准确定位先检查坐标格式与范围,再计算 IoU
信息不足时是否乱猜加入空白、遮挡或缺失关键字段的图片

本章只运行了 11 个固定、自编的问题,且模型规模、语言和预处理方式都固定。它们适合帮助我们理解执行过程与设计对照,不能代替完整基准或应用验收。

如果把这样的模型接入文档助手,还应让结果能够回到具体页面和区域。用户更需要的是“这个时间来自哪一行”,而不是模型写出一段无法核对的解释。遇到模糊、缺页或互相矛盾的信息时,询问补充信息也应成为可测试的行为。

9. 动手检查与扩展

  1. 一张 512×512512\times512 图片切成 16×1616\times16 图块,有多少位置?若每 4×44\times4 个位置拼成一个,位置数变成多少?
  2. 本章真实模型拼接前每个位置为 768 维。拼接后为什么是 12288 维?投影到 960 维的无偏置线性层有多少个权重?
  3. 图 2 中,为什么监督标签在位置 7、8,却用位置 6、7 的分数计算损失?
  4. 冻结语言模型与把它放进 torch.no_grad() 有什么区别?哪一种还能把回答损失的梯度传回连接层?
  5. 修改 visual_prefix.py 中的回答词元,检查较早位置的输出是否改变。因果掩码应当保证什么?
  6. 一个 120×120120\times120 的参考框与同样大小的预测框完全重合、互不相交时,IoU 分别是多少?为什么无效坐标应先单独处理?
  7. 将位置图的红方块和蓝圆交换,再换成另外两种颜色与形状。列出每次原图、问题与回答,不只保存成功的例子。
  8. 删除柱顶数字后重新提问,再与保留数字的版本比较。只改这一项,其他刻度和柱高保持相同。结果能帮助排除哪些解释,又不能证明什么?
  9. 给取货单增加“ORDER TIME”和“PICKUP TIME”两个不同时间,检查模型是否能根据问题选择正确字段。再遮住其中一个字段,记录它是否仍然给出无依据的值。
  10. 进阶:用少量有清晰字段标注的自编文档微调一个预训练模型。先按文档或模板分组留出测试数据,比较微调前后、正常页与缺失字段页;同时检查普通文字任务是否退化,并记录训练成本。
参考答案与实验提示
  1. 先有 32×32=102432\times32=1024 个位置,分组后为 8×8=648\times8=64 个。
  2. 一组包含 16 个向量,拼接后为 16×768=1228816\times768=12288 维。无偏置投影有 12288×960=1179648012288\times960=11796480 个权重。
  3. 自回归模型在当前位置预测下一位置;“回答开始”预测第一个回答词元,第一个回答词元再预测 EOS。
  4. 冻结参数只禁止更新这些参数,输入到输出的导数仍可用于连接层训练;no_grad 会让这段前向不被自动微分记录,从而切断该路径。
  5. 改变位置 7、8 的输入,不应改变位置 0~6 的输出。测试时关闭 dropout 等随机因素,并保持其他输入相同。
  6. 分别为 1、0。无效坐标可能没有定义正面积矩形,直接代入可能得到无意义数值;格式失败与合法框不重叠也代表不同的问题。
  7. 记录全部结果,并保证各次对话独立。增加对照以后,结论仍应限定在实际测试的图形、布局与模型设置上。
  8. 能检查模型对直接数字标签的依赖;单次成功仍不能证明它理解任意图表、单位或多系列关系。
  9. 预先写好每个问题的正确字段与答案,不把答案放进提示。遮挡后若不可读,应把“无法从图中确定”作为需要检查的行为,而不是用原来的隐藏答案评分。
  10. 首先确认处理器展开的图像位置与模板一致,只在目标回答和结束标记上监督。微调后的形式更整齐,不代表字段内容更准确,两个方面要分开比较。

继续阅读

到这里,模型已经能把图片作为生成文字的条件。下一章把方向转过来:如果希望模型根据一句话生成图片,训练目标与生成过程又要怎样改变?