跨模态表示与对齐
假设你的电脑里有几千张照片。你记得其中一张拍的是“雨天里的一把红伞”,却忘了文件名。输入这句话,能不能直接找到那张照片?
在第 32 章,我们已经用文字检索过文字:把问题和资料变成向量,再比较它们的相似程度。现在,资料换成了图片。图片里没有现成的词元,只有像素;一句话与一张照片也没有相同的长度。两者怎样才能放在一起比较?
这一章就来搭这座桥。先把图片变成一串向量,再让图片和描述学会使用可以互相比较的表示。最后,我们会从零训练一个很小的图文检索模型,亲眼看看“找到相关图片”和“认准每张图片”之间的差别。
1. 不同的输入,可以回答同一个问题
看看“蓝色方块”这四个字,再看看一张蓝色方块的图片。两者存储的内容很不一样:文字告诉你颜色与形状,却没有指定方块在画面里的位置;图片呈现了位置、大小、边缘和背景,却没有直接写下“蓝色”这个词。
我们把文字、图像、声音等不同形式的信息称为模态。跨模态任务要在这些形式之间建立联系。例如,用一句话找图片、根据图片回答问题,或者根据语音找出对应的视频片段。
这几件事需要的能力并不完全相同。找出“有自行车的图片”,可以只比较整张图与一句话是否相符;回答“自行车左边的路牌写了什么”,还需要找到局部、看清小字,并组织答案。本章先解决前一种问题。
一个常见做法是分别准备两个编码器:
- 图像编码器读取像素,输出一个向量。
- 文本编码器读取词元,也输出一个向量。
让两个向量有相同的维数,只是第一步。两个随机网络也能各输出 16 个数,但这些数未必能互相比较。对齐要靠训练建立:匹配的图片与文字应当得到较高的相似度,不匹配的配对应当得到较低的相似度。
这个“共同空间”不要求每一维都有固定的中文名称。我们通常不能指着第 3 维说“这里就是红色”。有用的是向量之间的关系,以及这些关系能否支持任务。
2. 把图片切成块,Transformer 就有了输入
在第 15 章,卷积网络通过局部窗口处理图片。另一条路线是把图片切成一块块,再把这些块送给 Transformer。Vision Transformer,简称 ViT,采用了这样的基本结构。ViT 原论文
每块先变成一个向量
设图片高为 、宽为 ,每个像素有 个通道。RGB 彩色图片的 。把图片切成边长为 的不重叠方块,在高、宽都能整除 时,块数为:
本章使用 的小图片,每块为 ,因此共有 块。每块包含 个数。
按约定顺序把一个块里的数排成一行,得到向量 。再让所有块共用一个线性层:
本例中, 是 192 维, 的形状为 ,所以每块输出 32 维。这与把一个词元映射为词向量的结果很像:Transformer 接下来看到的都是一串向量。区别在于,图块的向量由连续的像素计算出来,不需要先给每种图块分配一个词典编号。
图 1:本章图像编码器的实际形状。图中的小图片来自测试集;切块不会先识别物体边界。
代码用卷积实现这一步:卷积核大小为 8,步长也为 8,输入通道为 3,输出通道为 32。这恰好等价于“逐块展开,再应用同一个线性层”,并不是又额外加了一套卷积网络。
图片的位置也需要告诉模型
如果把一张人脸的图块重新排列,眼睛可能跑到下巴下面。块里的像素还在,整体含义却变了。因此,每个块向量还要加上位置表示。
本例为 16 个图块分别学习位置向量,并在开头放一个可学习的 CLS 向量。它起初不对应任何像素;经过自注意力后,它可以从其他位置汇总信息。于是输入序列从 16 个位置变成 17 个位置,每个位置仍为 32 维。
这串向量经过两层 Transformer,最后取出 CLS 所在位置,经过层归一化和投影,得到 16 维图像表示。CLS 是一种汇总方法;也可以设计平均池化等其他汇总方式,不必把它看作唯一选择。
这里的注意力没有因果掩码。图片已经完整给出,左上角的块可以直接关注右下角的块;没有“不能偷看尚未生成的下一个词元”这个限制。
分辨率增加,代价也会变化
保持图块大小不变,把高、宽都放大两倍,块数就增加到四倍。密集自注意力的分数矩阵有 个元素,因此只看图块之间的分数,元素数会增加到原来的 16 倍。这里是在数矩阵大小,实际运行时间还受投影、内存和实现方式影响。
反过来,把图片缩得很小虽然省计算,却可能让路牌的小字彻底消失。裁剪还可能把描述中的物体裁掉。图文系统的输入处理会改变模型实际能看到什么,不能只把它当作文件格式转换。
3. 用方向比较图片与文字
两个编码器可以有不同的内部结构,也可以先输出不同的维数;只要最终投影到相同维数,就能比较。本例把两端都投影到 16 维,再做长度归一化:
是第 张图片, 是第 条文字;、 包含各自的编码器与投影。、 是需要学习的参数。公式假设分母非零,代码中的 F.normalize 使用一个很小的下限,避免除以零。
归一化后,两个向量的点积就是余弦相似度:
余弦相似度关注方向。比如, 与 的长度都是 5,余弦为 ;把第一个向量整体放大十倍,余弦不会改变。这样可以避免只因某个向量的数值更大,就给它更高的相似分。
对于单位向量,还有一个熟悉的关系:
所以,余弦越大,单位球面上这两个点的欧氏距离越小。实际检索只需排序,不必真的把高维空间画出来。
但这里的“近”仍由训练数据和任务决定。两张都带有蓝色背景的图片可能很近,模型却未必正确理解了前景物体。接下来需要训练信号告诉它,哪些相似值得保留。
4. 把正确配对放回对角线
对比学习的一种思路,是让模型在多个候选中找出匹配项。对图文数据,我们可以拿一批原本配好的图片与描述,让每张图寻找自己的描述,同时让每条描述寻找自己的图。
CLIP 是这一思路的代表:它联合训练图像与文本编码器,用双向的对比目标学习图文表示,再用文字描述构造分类候选。本章实验沿用这个训练思路,规模与文本编码器都作了大幅简化。CLIP 原论文
一批配对,得到一张得分表
设这一批有 对数据,第 张图片对应第 条描述。计算所有图片与所有描述的相似度,再除以正的温度 :
是 的矩阵。第 行是在问:“第 张图应该配哪条文字?”第 列是在问:“第 条文字应该配哪张图?”
按批次的排列方式,正确答案都在对角线上。对第 张图片,正确文字的 softmax 概率为:
因此,图找文的平均损失是:
把矩阵转置,便得到文找图的同类问题。分别计算两个方向的交叉熵,再取平均:
这就是代码中的两次 F.cross_entropy。传入的是得分矩阵,不必先手动做 softmax;这个接口会完成相应计算。
用两对数据算一次
假设两张图片与两条文字的余弦矩阵为:
先取 。第一张图找到正确文字的概率为:
第二张图和反向查询的结果相同,因此总损失为 。
如果所有图片与文字都被编码成同一个单位向量,矩阵里就全是 1。每个候选的概率都为 ,损失为 。虽然每一对都很相似,模型却完全分不清谁配谁。对比目标要的是相对区分能力,不是把所有相似度一起推高。
温度控制 softmax 对分数差异的敏感程度。减小温度会放大差异,使分布更尖锐;它也会改变训练梯度。对于固定的向量与候选集合,除以同一个正数不会改变排序。本例固定 ;CLIP 原始方法学习相应的缩放参数。原论文训练说明
对角线以外,真的都是错误答案吗?
现在把两张不同的蓝色方块放进同一批,并都配上 blue square。从语义看,这两张图都符合这两条文字。如果还坚持“一张图只认自己的那一列”,就会把另一个同样正确的描述当成负样本。
对于完全相同的两个文本向量,两列分数也完全相同。在只有这两列时,图找文的概率只能各占一半。这不是再训练几轮就能消除的问题,而是目标把同义项当成了竞争对手。
本例每批只取七种不同组合各一张图,避免批内出现重复描述。更一般的数据可以使用多个正例、语义分组或适合任务的去重规则。与此同时,也要提供有区分价值的候选:蓝色方块、蓝色圆形和红色方块,比一组完全不同的随机图片更能检验模型有没有同时注意颜色与形状。
图像增强也应当保留文字的含义。轻微移动一个方块通常没问题,把“红色方块”随机变成蓝色再保留原描述,就会制造相互矛盾的训练信号。
5. 从配对分数到检索
训练好编码器后,可以提前把图库中的每张图片编码、归一化并保存。用户输入文字时,只需编码这条文字,与图库向量做点积,再返回得分最高的图片。图找文也一样:提前编码候选描述,来了图片就比较。
如果候选是“猫的照片”“狗的照片”“自行车的照片”,这种检索就可以用来分类。更换候选描述,不必重新训练一个固定类别的输出层。通常所说的零样本分类,是指没有再用目标分类任务的标注样本训练分类器;不代表模型从来没有接触过相关概念,也不保证任何新类别都能识别。
还要分清相似度和可信度。余弦为 0.8,不等于“有 80% 的概率答对”。把候选得分送进 softmax,也只是得到相对于当前候选集合的分布。如果候选只有“猫”,即使输入是一辆车,softmax 仍会给“猫”分配概率 1。
实际系统若需要说“没有合适结果”,应在包含不相关查询的验证数据上设计并检查拒答规则。不能拿一个看起来顺眼的余弦阈值,就把它当作跨数据集通用的正确率保证。
6. 动手:只用颜色与形状,学一次图文对齐
接下来从零开始训练,不下载预训练模型,也不使用外部图片。程序生成三种颜色与三种形状的组合,共九类:
| 颜色 | 圆形 | 方形 | 三角形 |
|---|---|---|---|
| 红色 | 训练可见 | 训练可见 | 留出 |
| 绿色 | 训练可见 | 训练可见 | 训练可见 |
| 蓝色 | 训练可见 | 留出 | 训练可见 |
每张图都会改变位置、大小、小角度旋转、背景亮度和少量像素噪声。描述只有两个英文词,例如 green circle。这里使用英文是为了让六个词的处理过程一眼可见,并不意味着对齐方法只能处理英文。
红色三角形和蓝色方块既不进入训练,也不进入验证。训练仍然能见到“红色”“三角形”“蓝色”“方形”,只是没有见过这两种搭配。测试时,我们才把九种描述一起作为候选,观察模型能否把学过的组成部分重新组合起来。
数据按不同随机种子分别生成:
| 划分 | 图片数 | 用途 |
|---|---|---|
| 训练 | 更新参数 | |
| 验证 | 在已见组合上选择轮次 | |
| 测试 | 报告两个方向的检索结果 |
这三个划分没有重复图片。它们都来自同一个合成器,因此这仍是很窄的教学任务,不能代表自然照片中的泛化能力。
两端都很小,但确实一起学习
图像端就是图 1 的小型 ViT:32 维内部表示、两层 Transformer、每层四个注意力头。文本端更简单:六个词各有一个可学习的 32 维向量,把颜色词和形状词的向量取平均,再投影到 16 维。
两端一共 25,120 个参数,全部从随机值开始训练。文本端没有语言模型,也没有处理词序:“blue square”两个词交换顺序,表示不会改变。它只适合这份小词表,不能用来理解“方块在圆形左边”。
训练使用 AdamW,学习率为 0.001,权重衰减为 0.01,共运行 40 轮。每批七对,每种已见组合恰好出现一次;每轮每张训练图片使用一次。随机种子为 2026,CPU 单线程。
第 1 轮和之后每 5 轮,在验证集上比较图片与七条已见描述的交叉熵,选取验证损失最低的参数。选轮次时不使用两个留出组合,也不使用测试成绩。测试时增加到九条候选,因此验证和测试面临的候选集合并不相同。
运行完整实验
使用前面章节的 Python 3.12 环境即可:
python3 -m pip install -r requirements.txt
python3 src/examples/cross_modal_alignment.py
默认会实际生成数据并重新训练,然后打印结果。下面是完整代码,也可以下载脚本与依赖清单。
展开完整实现:图块编码、双向对比训练与检索评价
"""第 40 章:从像素和两个词开始,训练一个小型图文检索模型。"""
from argparse import ArgumentParser
from copy import deepcopy
from hashlib import sha256
from pathlib import Path
import json
import platform
import numpy as np
import torch
from torch import nn
from torch.nn import functional as F
COLORS = ["red", "green", "blue"]
SHAPES = ["circle", "square", "triangle"]
CAPTIONS = [f"{color} {shape}" for color in COLORS for shape in SHAPES]
RGB = np.array([[.85, .18, .16], [.15, .68, .30], [.15, .35, .88]])
HELD_OUT = [2, 7] # red triangle、blue square 不进入训练或验证。
SEEN = [i for i in range(9) if i not in HELD_OUT]
SIZE, PATCH, WIDTH, DIM = 32, 8, 32, 16
SEED, EPOCHS, TEMPERATURE = 2026, 40, .10
REPORT = Path(__file__).with_name("data") / "cross-modal-alignment.json"
def make_data(split):
counts = {"train": 48, "validation": 16, "test": 24}
seeds = {"train": 101, "validation": 202, "test": 303}
rng = np.random.default_rng(seeds[split])
labels = range(9) if split == "test" else SEEN
yy, xx = np.mgrid[:SIZE, :SIZE]
images, targets, ids = [], [], []
for label in labels:
for index in range(counts[split]):
cx, cy = rng.uniform(12, 20, 2)
radius, angle = rng.uniform(6, 10), rng.uniform(-.3, .3)
dx, dy = xx - cx, yy - cy
u = np.cos(angle) * dx + np.sin(angle) * dy
v = -np.sin(angle) * dx + np.cos(angle) * dy
if label % 3 == 0:
mask = u*u + v*v <= radius*radius
elif label % 3 == 1:
mask = np.maximum(abs(u), abs(v)) <= radius * .88
else:
mask = (v >= -radius) & (v <= radius) & (abs(u) <= (v+radius)/2)
background = rng.uniform(.86, .99)
color = np.clip(RGB[label//3] + rng.normal(0, .025, 3), 0, 1)
image = np.full((SIZE, SIZE, 3), background)
image[mask] = color
image = np.clip(image + rng.normal(0, .01, image.shape), 0, 1)
images.append(image.transpose(2, 0, 1).astype("float32"))
targets.append(label)
ids.append(f"{split}-{label}-{index:02d}")
return torch.from_numpy(np.stack(images)), torch.tensor(targets), ids
def text_ids(labels):
# 六个词;词表只表达颜色与形状,不支持自由句子。
return torch.stack([labels // 3, 3 + labels % 3], dim=-1)
class TinyImageEncoder(nn.Module):
def __init__(self):
super().__init__()
self.patch = nn.Conv2d(3, WIDTH, PATCH, stride=PATCH)
self.cls = nn.Parameter(torch.randn(1, 1, WIDTH) * .02)
self.position = nn.Parameter(torch.randn(1, 17, WIDTH) * .02)
self.blocks = nn.ModuleList([
nn.TransformerEncoderLayer(WIDTH, 4, WIDTH*2, dropout=0,
activation="gelu", batch_first=True,
norm_first=True) for _ in range(2)
])
self.norm = nn.LayerNorm(WIDTH)
self.projection = nn.Linear(WIDTH, DIM, bias=False)
def forward(self, images):
x = self.patch(images * 2 - 1).flatten(2).transpose(1, 2)
x = torch.cat([self.cls.expand(len(x), -1, -1), x], dim=1)
x = x + self.position
for block in self.blocks:
x = block(x) # 图像可以同时看全部块,没有语言生成的因果掩码。
return self.projection(self.norm(x[:, 0]))
class DualEncoder(nn.Module):
def __init__(self):
super().__init__()
self.image = TinyImageEncoder()
self.words = nn.Embedding(6, WIDTH)
self.text_projection = nn.Linear(WIDTH, DIM, bias=False)
def encode_image(self, images):
return F.normalize(self.image(images), dim=-1)
def encode_text(self, ids):
return F.normalize(self.text_projection(self.words(ids).mean(1)), dim=-1)
def forward(self, images, ids):
return self.encode_image(images) @ self.encode_text(ids).T / TEMPERATURE
def contrastive_loss(logits):
if logits.ndim != 2 or logits.shape[0] != logits.shape[1] or len(logits) < 2:
raise ValueError("需要至少两个一一配对的样本,得分矩阵必须为方阵")
target = torch.arange(len(logits), device=logits.device)
return (F.cross_entropy(logits, target) +
F.cross_entropy(logits.T, target)) / 2
@torch.inference_mode()
def scores(model, images, labels):
model.eval()
vectors = torch.cat([model.encode_image(x) for x in images.split(64)])
return vectors @ model.encode_text(text_ids(labels)).T
def retrieval_metrics(similarity, image_labels, caption_labels):
# 多张图可与同一句描述相关;按语义标签判断,不能只认矩阵对角线。
i_order = similarity.argsort(dim=1, descending=True, stable=True)
t_order = similarity.T.argsort(dim=1, descending=True, stable=True)
result = {}
for name, order, gallery, query in [
("i2t", i_order, caption_labels, image_labels),
("t2i", t_order, image_labels, caption_labels),
]:
relevant = gallery[order] == query[:, None]
for k in [1, 3]:
result[f"{name}_hit{k}"] = relevant[:, :k].any(1).float().mean().item()
return result
def evaluate(model, data):
images, labels, ids = data
captions = torch.arange(9)
similarity = scores(model, images, captions)
prediction = similarity.argmax(1)
known = torch.tensor([int(x) in SEEN for x in labels])
result = retrieval_metrics(similarity, labels, captions)
result.update({
"seen_i2t_hit1": (prediction[known] == labels[known]).float().mean().item(),
"heldout_i2t_hit1": (prediction[~known] == labels[~known]).float().mean().item(),
"per_class_correct": [int(((prediction == label) & (labels == label)).sum())
for label in range(9)],
"ids": ids, "labels": labels.tolist(), "predictions": prediction.tolist(),
"similarities": similarity.tolist(),
})
return result
def tensor_digest(tensor):
return sha256(tensor.contiguous().numpy().tobytes()).hexdigest()
def search_images(model, query, images, k=3):
if query not in CAPTIONS:
raise ValueError("本例只接受九种颜色与形状组合,例如 blue square")
similarity = scores(model, images, torch.tensor([CAPTIONS.index(query)]))[:, 0]
order = similarity.argsort(descending=True, stable=True)[:k]
return [(int(index), float(similarity[index])) for index in order]
def run():
torch.set_num_threads(1)
torch.manual_seed(SEED)
torch.use_deterministic_algorithms(True)
# 关闭推理专用 fast path,使训练与评估使用同一注意力实现路径。
torch.backends.mha.set_fastpath_enabled(False)
train, validation, test = [make_data(s) for s in ["train", "validation", "test"]]
model = DualEncoder()
initial = evaluate(model, test)
optimizer = torch.optim.AdamW(model.parameters(), lr=.001, weight_decay=.01)
generator = torch.Generator().manual_seed(SEED + 1)
image_bank = train[0].reshape(7, 48, 3, SIZE, SIZE)
labels = torch.tensor(SEEN)
best, best_loss, best_epoch = None, float("inf"), 0
history = []
for epoch in range(1, EPOCHS + 1):
model.train()
orders = torch.stack([torch.randperm(48, generator=generator) for _ in SEEN])
total = 0.
for step in range(48):
# 每批七对,每种语义只出现一次,避免同义描述被当成负样本。
images = image_bank[torch.arange(7), orders[:, step]]
loss = contrastive_loss(model(images, text_ids(labels)))
optimizer.zero_grad()
loss.backward()
optimizer.step()
total += loss.item()
if epoch == 1 or epoch % 5 == 0:
val_logits = scores(model, validation[0], labels) / TEMPERATURE
val_targets = torch.searchsorted(labels, validation[1])
val_loss = F.cross_entropy(val_logits, val_targets).item()
history.append({"epoch": epoch, "train_loss": total/48,
"validation_i2t_ce": val_loss})
if val_loss < best_loss:
best, best_loss, best_epoch = deepcopy(model.state_dict()), val_loss, epoch
model.load_state_dict(best)
final = evaluate(model, test)
state_hash = sha256()
for name, tensor in sorted(best.items()):
state_hash.update(name.encode())
state_hash.update(tensor.contiguous().numpy().tobytes())
report = {
"config": {"seed": SEED, "epochs": EPOCHS, "temperature": TEMPERATURE,
"size": SIZE, "patch": PATCH, "width": WIDTH, "dimension": DIM,
"batch_size": 7, "learning_rate": .001, "weight_decay": .01,
"held_out": HELD_OUT, "selection": "minimum seen validation i2t CE"},
"environment": {"python": platform.python_version(), "torch": torch.__version__,
"numpy": np.__version__, "device": "cpu", "threads": 1},
"source_sha256": sha256(Path(__file__).read_bytes()).hexdigest(),
"data_sha256": {name: tensor_digest(data[0]) for name, data in
zip(["train", "validation", "test"], [train, validation, test])},
"model_sha256": state_hash.hexdigest(),
"parameter_count": sum(p.numel() for p in model.parameters()),
"selected_epoch": best_epoch, "history": history,
"initial": initial, "trained": final,
}
return model, report
def print_summary(report):
print("合成图文:训练 336 张,验证 112 张,测试 216 张;候选描述 9 条。")
print("留出组合:red triangle、blue square;训练与验证均不使用。")
print(f"参数 {report['parameter_count']};按验证损失选择第 {report['selected_epoch']} 轮。")
for key, name in [("initial", "随机初始化"), ("trained", "对比训练后")]:
row = report[key]
print(f"{name}:图找文 Hit@1={row['i2t_hit1']:.3f};"
f"文找图 Hit@1={row['t2i_hit1']:.3f};Hit@3={row['t2i_hit3']:.3f}")
print(f" 已见组合={row['seen_i2t_hit1']:.3f};"
f"未见组合={row['heldout_i2t_hit1']:.3f}")
print("每类图找文正确数(各 24 张):", report["trained"]["per_class_correct"])
print("这些结果只描述合成图库;模型不支持自由句子,也不会生成回答。")
def main():
parser = ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, help="另存本次训练的完整报告")
parser.add_argument("--checkpoint", type=Path, help="另存模型权重")
parser.add_argument("--query", choices=CAPTIONS, help="训练后用两个词检索测试图库")
args = parser.parse_args()
model, report = run()
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
if args.checkpoint:
args.checkpoint.parent.mkdir(parents=True, exist_ok=True)
torch.save(model.state_dict(), args.checkpoint)
restored = DualEncoder()
restored.load_state_dict(torch.load(args.checkpoint, weights_only=True))
torch.testing.assert_close(scores(model, make_data("test")[0], torch.arange(9)),
scores(restored, make_data("test")[0], torch.arange(9)),
rtol=0, atol=0)
print_summary(report)
if args.query:
images, labels, ids = make_data("test")
print(f"查询:{args.query}")
for index, similarity in search_images(model, args.query, images):
print(f" {ids[index]};实际 {CAPTIONS[int(labels[index])]};余弦 {similarity:.4f}")
if __name__ == "__main__":
main()
在 Python 3.12.12、NumPy 2.5.4、PyTorch 2.14.1 的 CPU 环境中,输出为:
合成图文:训练 336 张,验证 112 张,测试 216 张;候选描述 9 条。
留出组合:red triangle、blue square;训练与验证均不使用。
参数 25120;按验证损失选择第 10 轮。
随机初始化:图找文 Hit@1=0.051;文找图 Hit@1=0.111;Hit@3=0.222
已见组合=0.065;未见组合=0.000
对比训练后:图找文 Hit@1=0.681;文找图 Hit@1=1.000;Hit@3=1.000
已见组合=0.762;未见组合=0.396
每类图找文正确数(各 24 张): [15, 14, 18, 15, 17, 24, 21, 1, 22]
这些结果只描述合成图库;模型不支持自由句子,也不会生成回答。
随机初始化这一行来自同一个模型训练前的实际测量,并不是人为填入的 。 是在九类均衡数据上均匀随机猜类别的期望正确率;一个固定随机网络的预测未必均匀,有限样本上的结果也未必等于这个期望。
运行时可以另外保存报告与权重:
python3 src/examples/cross_modal_alignment.py \
--out runs/my-alignment.json \
--checkpoint runs/my-alignment.pt \
--query "blue square"
保存权重时,程序会重新加载,并检查整个测试集的相似度是否保持一致。随书报告保留了各轮损失、全部测试图片 ID、逐项相似度、预测、数据与模型指纹;图片本身由代码和固定种子重新生成。
7. 训练损失很漂亮,为什么还会认错?
先看训练过程:
图 2:真实训练记录。两张图的损失定义不同,分开绘制;重点是各自随训练的变化,不把上下曲线的差值当成泛化间隙。
训练损失从第 1 轮的约 1.122 降到第 40 轮的约 0.023,验证损失却在第 10 轮之后回升。模型越来越能分清训练图片,没有同步改善新图片的检索。这与前面章节见过的过拟合现象相通;“训练配对越来越准”也需要在新数据上复核。
选中的第 10 轮参数,在 216 张测试图片中有 147 张找到了正确描述。拆开看:
| 测试图片 | 正确 / 总数 | Hit@1 |
|---|---|---|
| 训练见过的七种组合 | 128/168 | 76.2% |
| 留出的红色三角形 | 18/24 | 75.0% |
| 留出的蓝色方块 | 1/24 | 4.2% |
同样是未见组合,结果相差很大。不能只看两个留出组合合起来的 39.6%,就说模型已经掌握或完全没有掌握“组合能力”。
再取三张蓝色图片,分别与三条蓝色描述比较:
图 3:固定取 test-6-00、test-7-00、test-8-00 三张图片,只展示九个候选中的三个蓝色描述。数字来自模型的 16 维表示,不是手画的理想配对矩阵。
中间那张蓝色方块,与“蓝圆”的余弦约为 0.644,与“蓝方”约为 0.540。模型选错了。训练中,它见过蓝色圆形,也见过其他颜色的方形,但这些经验没有保证它对这张蓝色方块组合正确。
从这一例还不能断言网络内部究竟使用了哪条规则。要判断是不是过度依赖颜色、面积或某些边缘,需要设计只改变一个因素的额外实验。这里先保留错误本身,不替模型编造一段解释。
8. 文找图全对,并不等于图库全认对
本例的 Hit@k 表示:对每个查询,看前 个结果中是否至少有一个相关项,再对所有查询取平均。相关性的定义是“颜色与形状都相同”,不要求命中某一张唯一图片。
对于图找文,一共有 216 次查询,每次从九条描述里选;对于文找图,只有九次查询,每次从 216 张图片里选,其中同类的 24 张都算相关。
因此,“文找图 Hit@1 为 100%”只说明九条描述各自排在第一的图片都属于正确类别。它不要求同类的 24 张都排在前面,也不要求每张图反过来选择这条描述。两个方向的最近邻关系不必互为一一对应。
实际查询 blue square,前三个结果为:
查询:blue square
test-7-22;实际 blue square;余弦 0.7017
test-6-19;实际 blue circle;余弦 0.6753
test-7-11;实际 blue square;余弦 0.6565
图 4:第一名正确,所以这条查询的 Hit@1 和 Hit@3 都记为成功;但前 3 张并不全对。
若问“前三个结果里有多少是相关的”,这条查询的 Precision@3 为 。若问“图库中 24 张相关图片找回了多少”,这里的 Recall@3 为 。
有些图文检索论文把“前 个结果中至少命中一个”的比例称为 Recall@k。阅读结果表时要看清作者的计算定义。本例特意使用 Hit@k,避免把它与“找回全部相关项的比例”混在一起。
这个小例子还提醒我们:指标分母很小的时候,一次成功很容易显得漂亮。九条短描述无法代表自然语言查询的多样性。若继续发展系统,需要加入不同说法、不相关查询、更多图片来源,以及更细的关系描述。
9. 图文相似之后,还有哪些路要走?
到这里,我们已经能把文字当作图库的查询入口。实际使用还会遇到几类问题。
首先,整图向量会压缩细节。一张复杂图片里可能同时有三个人、一辆车和很多小字。单个向量适合快速筛选,却不保证保留每一个可被问到的事实。检索出候选后,可以再用能够细看图文关系的模型重排或回答。
其次,数据配对本身可能不准确。一段网页文字可能描述的是整篇文章,并非旁边那张图;一条简短说明也可能漏掉画面里的重要对象。配对能提供训练信号,并不意味着文字已经完整标注了图片。
最后,检索模型只会给候选打分。本章程序不会根据图片生成解释,更不会自行回答“方块在圆的哪一边”。下一章将把视觉表示接到语言模型上,看看模型怎样在看到图片之后继续生成文字,以及图像里的小字、位置与布局为何会带来新的困难。
10. 试着算一算,改一改
- 一张 的 RGB 图片切成 的块,会得到多少块?每块展开后有多少个数?加入一个 CLS 后序列长度是多少?
- 保持块大小不变,把图片高、宽都加倍。块数和密集图块注意力分数的元素数分别变为几倍?为什么不能直接宣布运行时间也增加同样倍数?
- 对于单位向量,余弦为 0.6 时,欧氏距离的平方是多少?
- 本章的两对数据算例中,若两个正确配对的分数相同、两个错误配对的分数也相同,且正确分数比错误分数高 2,正确项的 softmax 概率是多少?平均双向损失是多少?
- 为什么把两张蓝色方块和两条相同的
blue square放进同一批,再要求只匹配各自对角线,会产生问题? - 对某条查询,图库共有 20 张相关图片,前 5 个结果中有 3 张相关。Hit@5、Precision@5、Recall@5 分别是多少?
- 运行
--query "blue square",根据返回的图片 ID 找到报告中的行。检查第一名是否也在“图找文”方向选择blue square。两个方向必须互相选中吗? - 保持划分、网络和其他设置不变,分别使用三个训练种子,记录已见组合与两个留出组合各自的成绩。比较波动;不要只挑最好的一次,也不要用留出成绩来选轮次。
- 增加一种实验:固定颜色和形状,只改变物体位置或大小,比较相似度与预测怎样变化。先写下打算检验的问题,再生成这组额外图片;不要拿同一批新图片反复调整模型后,仍把它称为独立测试。
- 进阶:把文字端换成能处理词序的编码器,并加入“圆在方块左边”与“方块在圆左边”的图片。除了网络结构,数据与评价规则还需要怎样改变?
参考答案与实验提示
- 块,每块有 个数;加入 CLS 后为 197 个位置。
- 块数为四倍,图块间分数元素数为 16 倍。投影、其他算子、硬件利用率和内存访问也会影响实际时间;加入 CLS 后的精确矩阵边长还需另算。
- 。
- 概率为 ,损失为 。这里的“分数”已经包含温度缩放。
- 相同描述给出相同文本向量,两列相同;目标却要求把其中一列当正例、另一列当负例。可以避免批内重复语义,也可以改成承认多个正例的训练目标。
- Hit@5 为 1,Precision@5 为 ,Recall@5 为 。
- 分别沿该文字对应的列、该图片对应的行排序。两个方向在不同候选集合里竞争,不要求互相选中;以实际记录为准。
- 保持数据种子不变,只改变模型初始化与批次排列的训练种子。分别列出结果,再报告均值与范围。这检查的是训练随机性,不等于更换数据分布的稳健性测试。
- 可以画出“水平位置—正确描述相似度”曲线,同时记录最高分候选。相似度降低与预测变化是两种不同现象,都值得观察。
- 图片中需要多个对象与可核对的位置关系;文本需要保留词序,训练候选要包含左右关系相反但词汇相同的描述。数据划分还要防止重复布局泄漏,评价不能只检查颜色和形状是否出现。
继续阅读
- An Image is Worth 16×16 Words:对照图块投影、位置表示和 CLS,阅读 ViT 的输入结构。
- Learning Transferable Visual Models From Natural Language Supervision:关注双向配对目标、文字候选分类及实验局限。
- CLIP 官方实现:查看两个编码器、归一化与相似度计算。原模型的文本端并不是本章的词向量平均。
- A Simple Framework for Contrastive Learning of Visual Representations:比较图像内部不同增强视图的对比学习,思考“正例怎样定义”会怎样改变学到的表示。
本章把图片和文字放进了可比较的空间。下一章,我们继续追问:如果不仅想找到图,还想围绕图说出一句有根据的话,需要再接上什么?