视觉语言模型
把一张取货单递给模型,问它:“几点取货?”这次,我们不满足于找到一张相关图片,而是希望模型读出单据上的时间,用文字回答。
这一步看起来只是多说一句话,内部却有一个重要变化:图片不再只参与最后的相似度排序,它还要成为语言模型生成回答时能够使用的上下文。
上一章把图片与文字映射到可以比较的空间。这一章把视觉表示接到语言模型上,观察视觉语言模型,也常简称 VLM,怎样一边利用图片,一边预测下一个词元。我们会先拆开连接与训练过程,再请一个真实小模型读单据、看柱状图、判断图形位置。
1. 语言模型要读到的是向量
语言模型的 Transformer 实际接收的是嵌入向量。文字先经过分词与嵌入查表,再变成这些向量;图片也可以经过视觉编码器和连接层,变成能够放进同一序列的向量。
设视觉编码器为一张图输出 个特征,每个特征为 维,记为矩阵 。语言模型每个输入位置的宽度为 。最简单的连接方法是共享一个线性投影:
其中 的形状为 ,投影后 的形状为 。也可以增加偏置、使用多层感知机,或通过额外模块压缩视觉位置的数量。
这里常把 的各行称为视觉 Token。在这类模型里,它们是连续向量,并不一定是从一个图像词典中选出的离散编号。聊天模板中的 <image> 则是标记:处理器和模型约定在相应位置放入图像特征。只把 <image> 这几个字符输入普通文字模型,并不会让它看见图片。
原始 LLaVA 采用了连接视觉编码器与语言模型的投影方案;BLIP-2 则使用可学习的查询模块来提取适合语言模型使用的视觉信息。不同模型并不共享唯一一种连接结构。LLaVA 论文、BLIP-2 论文
为什么不只保留一个整图向量?
上一章做整图检索时,一个汇总向量就能参与排序。但若问题是“右上角的订单号是什么”,模型需要利用局部信息。保留多个视觉位置,可以给语言模型更多机会访问不同区域的特征。
这些位置经过视觉自注意力后也会混合全图信息,所以不能简单地把每个向量解释成一个互不相干的小方格。位置对应关系是理解结构的起点,不是对模型内部含义的完整说明。
看看本章真实模型的尺寸
后面的实验使用 SmolVLM-500M-Instruct。对于本章固定的单幅 输入,它的视觉图块边长为 16,先得到 个位置,每个位置 768 维。
连接器把相邻 个位置的特征拼接起来,得到 64 个较宽的向量,每个为 维;随后线性投影到语言模型的 960 维。这里是重排并拼接特征,再做投影,不是简单地把 16 个向量取平均。
图 1:根据固定模型配置和实际执行路径绘制。推理时记录到连接器输出形状为 [1, 64, 960];最前面的 1 是批量大小。
压缩位置数能减少后续语言模型处理的序列长度,但也改变了细节如何进入语言模型。给一页密密麻麻的表格保留多少视觉位置,是计算成本与任务需求之间的具体选择。
这些数字属于本章固定配置。模型仓库的默认处理器支持将高分辨率图片拆成多幅;本例为了方便在 CPU 上观察,关闭了这种拆分。不能把这里的“64 个视觉位置”推广为所有分辨率、所有 VLM 的固定规定。固定模型配置、对应版本连接器实现
2. 看图回答,仍然在预测下一个词元
把视觉向量与问题的文字向量放入模型后,回答仍然按自回归方式生成:
是图片, 是问题, 是回答的第 个词元, 是此前已经生成的回答。图片与问题提供条件,语言模型继续决定下一个词元。
这也解释了两件容易混淆的事。
第一,VLM 并不一定先把图片完整翻译成一段描述,再把描述交给语言模型。图 1 中,进入语言模型的是视觉特征本身。OCR 加语言模型则是另一种可以显式拆开的路线:先识别文字及位置,再组织答案。两种路线都可以用于文档任务,也可以结合使用。
第二,能写出流畅答案,并不自动说明答案来自图片。语言模型也可能根据问题中的暗示、训练中常见的模式或先前对话,生成一个听起来合理的回答。因此,看图任务需要检查图片变化时答案是否随之变化。
3. 多模态指令微调,监督哪些位置?
回想第 26 章的指令微调:问题作为输入,主要在助手回答的位置计算损失。加入图片后,这个原则仍然适用。
一条训练样本可以包含“图片、问题、回答”,也可以包含围绕同一张图的多轮对话。模型读入视觉特征和已有文字,在需要生成的回答位置预测正确词元。
设 是参与监督的回答位置集合,包含希望模型学会的结束标记。一个常见的平均损失写作:
视觉位置、用户问题以及填充位置不需要被当作本次回答的目标。在代码中,可以把它们的标签设为 -100,交给交叉熵的 ignore_index 跳过。注意,模型库是否在内部移动标签、聊天模板怎样标记轮次,需要核对,不能重复移动一次。
忽略视觉位置的标签,不是切断视觉梯度
下面是一条极小序列:
BOS,视觉0,视觉1,视觉2,视觉3,问题,回答开始,红,EOS
只有“红”和 EOS 是监督标签。但由于模型用当前位置的输出预测下一个词元,预测“红”的分数来自“回答开始”位置,预测 EOS 的分数来自“红”位置。
图 2:输入位置从 0 开始。标签放在位置 7、8;移动一位以后,用位置 6、7 的分数计算损失。
回答能够关注前面的视觉向量,所以回答损失仍会通过注意力向视觉连接层传递梯度。-100 只是不要求模型在这些标签位置“猜出一个视觉词”,不代表模型不能利用图片。
进一步说,即使冻结语言模型的参数,梯度仍然可以穿过语言模型,传回连接层。冻结是“不更新这些参数”,而不是“这段计算不需要求导”。如果把整个语言模型前向放进 torch.no_grad(),连接层的训练路径也会被切断。
用一个小程序亲手核对
下面的程序用两个 的纯色图,先得到四个 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:先把整张取货单缩成 。note-crop:按已知版式,人工指定取货时间所在的长条区域。
两者在这次实验里仍然都答对。原图、缩小图、裁剪图的答案都一样,所以这里没有测到准确率改善。不能把这个结果写成“裁剪一定提升效果”,也不能推断所有小字都能在低分辨率下读清。
还有一个实现细节值得亲自检查:本例关闭图片拆分后,所用处理器最终会把输入重采样为 。因此,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 图片。
第一张回答正确,第二张错误。仅凭第一张,容易以为模型已经会判断左右;加入交换位置的对照以后,这个结论就站不住了。当然,两次回答也不能证明模型“永远只会答左边”,它们只暴露了当前设置中的一次失败。
坐标框是另一个任务
如果希望模型指出“红色方块在哪里”,可以要求它返回矩形框:
先约定原点在左上角, 向右增加, 向下增加。对于宽 、高 的图片,可用像素坐标,也可分别除以宽、高得到归一化坐标。例如:
必须说明采用哪一种,否则 0.5 与 256 可能表示同一个位置,也可能完全不是一回事。
图 6:参考框来自绘图程序。方块覆盖 x=64~183、y=176~295 的像素,使用右、下边界不包含在内的框约定,写作 [64, 176, 184, 296]。
本章向真实模型请求四个像素坐标,它返回了 [1, 1].。这既不满足四个数的要求,也不是合法的纯 JSON 数组,所以先记为格式失败,不给它编造一个预测框。
输出格式正确以后,才有必要计算几何误差。常用的交并比 IoU 为:
是预测框, 是参考框,竖线表示面积。两框重合时为 1,没有重叠时为 0。它衡量的是面积重合,不是模型回答的自信程度。
用一个教学算例:参考框是边长 120 的正方形;假设预测框大小相同,只向右偏了 20 像素,那么交集宽为 100、高为 120:
这个偏移框是用来解释指标的假设,不是模型本次输出。本次格式不合法,记录中的 IoU 为 None,不把“没得到框”和“得到合法但不重叠的框”混成同一种情况。
若图像经过裁剪、缩放或旋转,还必须把坐标映射回约定的图像空间。不能把裁剪图上的 100 像素,直接标到原图的第 100 像素上。
8. 把“会看图”拆成可以检查的小问题
这组实验没有汇总成一个总准确率。读取时间、比较柱高、判断左右、输出坐标与空图拒答的要求不同;只报一个分数,很容易掩盖哪一环出了问题。
| 要检查的事情 | 可以怎样做对照 |
|---|---|
| 回答是否依赖图片 | 保持问题,替换图片中的关键事实 |
| 是否读清局部文字 | 比较原图、缩小图与已知区域裁剪 |
| 是否理解图表关系 | 改变数值、交换系列、去掉直接标值 |
| 是否理解空间关系 | 交换对象位置,保持问题与对象不变 |
| 是否能准确定位 | 先检查坐标格式与范围,再计算 IoU |
| 信息不足时是否乱猜 | 加入空白、遮挡或缺失关键字段的图片 |
本章只运行了 11 个固定、自编的问题,且模型规模、语言和预处理方式都固定。它们适合帮助我们理解执行过程与设计对照,不能代替完整基准或应用验收。
如果把这样的模型接入文档助手,还应让结果能够回到具体页面和区域。用户更需要的是“这个时间来自哪一行”,而不是模型写出一段无法核对的解释。遇到模糊、缺页或互相矛盾的信息时,询问补充信息也应成为可测试的行为。
9. 动手检查与扩展
- 一张 图片切成 图块,有多少位置?若每 个位置拼成一个,位置数变成多少?
- 本章真实模型拼接前每个位置为 768 维。拼接后为什么是 12288 维?投影到 960 维的无偏置线性层有多少个权重?
- 图 2 中,为什么监督标签在位置 7、8,却用位置 6、7 的分数计算损失?
- 冻结语言模型与把它放进
torch.no_grad()有什么区别?哪一种还能把回答损失的梯度传回连接层? - 修改
visual_prefix.py中的回答词元,检查较早位置的输出是否改变。因果掩码应当保证什么? - 一个 的参考框与同样大小的预测框完全重合、互不相交时,IoU 分别是多少?为什么无效坐标应先单独处理?
- 将位置图的红方块和蓝圆交换,再换成另外两种颜色与形状。列出每次原图、问题与回答,不只保存成功的例子。
- 删除柱顶数字后重新提问,再与保留数字的版本比较。只改这一项,其他刻度和柱高保持相同。结果能帮助排除哪些解释,又不能证明什么?
- 给取货单增加“ORDER TIME”和“PICKUP TIME”两个不同时间,检查模型是否能根据问题选择正确字段。再遮住其中一个字段,记录它是否仍然给出无依据的值。
- 进阶:用少量有清晰字段标注的自编文档微调一个预训练模型。先按文档或模板分组留出测试数据,比较微调前后、正常页与缺失字段页;同时检查普通文字任务是否退化,并记录训练成本。
参考答案与实验提示
- 先有 个位置,分组后为 个。
- 一组包含 16 个向量,拼接后为 维。无偏置投影有 个权重。
- 自回归模型在当前位置预测下一位置;“回答开始”预测第一个回答词元,第一个回答词元再预测 EOS。
- 冻结参数只禁止更新这些参数,输入到输出的导数仍可用于连接层训练;
no_grad会让这段前向不被自动微分记录,从而切断该路径。 - 改变位置 7、8 的输入,不应改变位置 0~6 的输出。测试时关闭 dropout 等随机因素,并保持其他输入相同。
- 分别为 1、0。无效坐标可能没有定义正面积矩形,直接代入可能得到无意义数值;格式失败与合法框不重叠也代表不同的问题。
- 记录全部结果,并保证各次对话独立。增加对照以后,结论仍应限定在实际测试的图形、布局与模型设置上。
- 能检查模型对直接数字标签的依赖;单次成功仍不能证明它理解任意图表、单位或多系列关系。
- 预先写好每个问题的正确字段与答案,不把答案放进提示。遮挡后若不可读,应把“无法从图中确定”作为需要检查的行为,而不是用原来的隐藏答案评分。
- 首先确认处理器展开的图像位置与模板一致,只在目标回答和结束标记上监督。微调后的形式更整齐,不代表字段内容更准确,两个方面要分开比较。
继续阅读
- Visual Instruction Tuning:对照视觉投影、回答位置损失与两阶段训练,理解 LLaVA 的早期设计。
- BLIP-2:比较查询式连接模块与直接投影,思考怎样让冻结的两端协作。
- SmolVLM 技术报告:阅读小型视觉语言模型对结构和数据的取舍,不把本章的 11 道题等同于论文评估。
- Transformers 4.57.3 的 Idefics3 文档源码:核对本章使用的处理器、图像拆分与模型接口。
到这里,模型已经能把图片作为生成文字的条件。下一章把方向转过来:如果希望模型根据一句话生成图片,训练目标与生成过程又要怎样改变?