# LAB 14 · 图像问答:视觉条件与问题先验 按问题继续 RGB+问题输入、question-only对照、同图换问与同问换图 对应课堂(1 页) 可先阅读已有结果,再决定是否运行。重算时请核对各节记录的数据、参数、环境与日期。 **学习路径:** [图像怎样约束句子与回答](/course/study/caption-vqa.html) 同图换问题,模型读什么会变;同问换图片,答案也应跟着变。先读[P54](/course/pages/l3-r2-p54.html),再看第2节按图切分和第4节逐问题结果。 开头字典例是已知属性oracle,主体才是读取RGB像素与问题ID的CNN,并单独训练question-only。三类问题为数量、左色、右形,答案为有限词表;这不等于开放语言问答。[完整控制和证据](/course/blocks/vqa-pixel-evidence.html)。 读完应能解释question-only为何约44.44%,并从同图换问/同问换图各拿一个例子支持“模型使用了条件”。只读可先看2026-09-22保存结果,运行则按全部代码顺序;本次整理不改历史成绩。若答案需要一个区域而非类别,继续[LAB 15](https://deepnote.com/project/38755e4e-23f6-4f5f-8c68-8a2e4114b1c1/notebook/7dc83786d067465aa76264475db2c62d)。 ![同图换问与同问换图](/assets/display/2f7b2168f5b21ee2095709fa.webp) 前两幅共享形状、位置和随机噪声,只改左侧颜色;第三幅只用于展示另一个问题条件,不是与前两幅只改一项。想让答案直接成为一个区域,继续 [LAB 15 · 语言指代小练习:目标、参照与关系](https://deepnote.com/workspace/ise3309jingao-5ba66b0a-4283-4f5a-ae06-3f68d3201e99/project/38755e4e-23f6-4f5f-8c68-8a2e4114b1c1/notebook/7dc83786d067465aa76264475db2c62d?utm_source=openai&utm_medium=mcp&utm_campaign=openaimcp&utm_content=7dc83786d067465aa76264475db2c62d&utm_term=get_notebook)。 [此段独立阅读](/course/notebook-blocks/9efe941652bc4525b09d4348dcef8323.html) ## 0. 先给原例子正确命名 原 `with-image` 分支读取字典中的 `cups` 与 `left`。这些已是答案属性,因此此处保留为 **known-attribute oracle**。它能说明问题选择不同字段,却没有完成视觉感知。下面打印原例,再与真正从像素学习的模型区分。 [此段独立阅读](/course/notebook-blocks/4919dea7730a4e19823525d3b77d89c9.html)展开完整实现代码 ``` # Detached readonly runs write only to an isolated ephemeral directory. import os, tempfile _LAB_WORKDIR = tempfile.mkdtemp(prefix="lecture03-lab14-") os.chdir(_LAB_WORKDIR) print("ephemeral workdir:", _LAB_WORKDIR) cases=[{'cups':2,'left':'red','q':'count'},{'cups':2,'left':'red','q':'left-color'},{'cups':2,'left':'blue','q':'left-color'}] for c in cases: prior='2' if c['q']=='count' else 'red' oracle=str(c['cups']) if c['q']=='count' else c['left'] print(c,'question-only rule',prior,'known-attribute oracle',oracle) # No image was read above. The learned visual path begins below. ``` [此段独立阅读](/course/notebook-blocks/ee681c33c81c4de98ecea27d981ddb73.html) ## 1. 环境与有限问题接口 依赖 NumPy、Pillow、Matplotlib、PyTorch;Restart & Run All 后无需别册内存。输出保存在 `outputs/lab14/`。2026-09-22 本地 CPU 已执行,PyTorch 2.14.0+cpu,约十余秒;不代表 Deepnote 云端执行。 我们定义三种英文模板,用 ID 0/1/2 输入嵌入层。文字是模板的可读形式,模型不支持任意自然语言改写。答案空间为 one、two、red、blue、green、square、circle。这是封闭词表 VQA,足以检验条件依赖,但不能证明开放问答、常识推理或语言理解。 [此段独立阅读](/course/notebook-blocks/3c10b348d7d1412b80d07fc5583e1a27.html)展开完整实现代码 ``` from pathlib import Path import json, hashlib, platform, sys, time from datetime import datetime, timezone import numpy as np from PIL import Image, ImageDraw import matplotlib if 'get_ipython' not in globals(): matplotlib.use('Agg') import matplotlib.pyplot as plt import torch from torch import nn import torch.nn.functional as F OUT=Path('outputs/lab14');OUT.mkdir(parents=True,exist_ok=True) torch.set_num_threads(1);torch.manual_seed(1403);np.random.seed(1403);START=time.perf_counter() COLORS={'red':(220,55,55),'blue':(45,95,225),'green':(35,170,75)} QUESTIONS=['how many objects?','what color is the leftmost object?','what shape is the rightmost object?'] ANSWERS=['one','two','red','blue','green','square','circle']; A={x:i for i,x in enumerate(ANSWERS)} print('CPU',torch.__version__,'seed 1403; answer vocabulary:',ANSWERS) ``` [此段独立阅读](/course/notebook-blocks/924df89109464fed8b7817f3ebf2b233.html) ## 2. 先按图像切分,避免同一图的不同问题泄漏 每张图含一个或两个带颜色的方形/圆形。所有图都提出同样三个问题:数量、最左物体颜色、最右物体形状;只有一个物体时,它同时是最左与最右。目标由生成器标注,这是监督数据的来源,不是推理接口。`forward(pixels,question_id)` 无法读取场景字典。 训练图 seed 14031、保留图 seed 14032;先得到 720/180 张互不相同的图,再扩展为 2160/540 个问答。训练左侧颜色以 75% 概率采红,实际本次为 73.61%;保留集三色各占三分之一。数量与形状平衡设计下各有两种可能。语言模型因此能在训练颜色问题上靠红色先验得高分,却没有随图改变的证据。 预测:只看问题,三个任务的保留集最佳常量回答分别约为 1/2、1/3、1/2,平均为 4/9≈44.4%。这比把七种答案混在一起说“随机率 1/7”更符合任务结构。 [此段独立阅读](/course/notebook-blocks/20b57075949948ac8d6711cd4621f5b6.html)展开完整实现代码 ``` def draw_scene(count,left_color,right_shape,rng): img=Image.new('RGB',(40,32),(244,242,236));d=ImageDraw.Draw(img) slots=[10] if count==1 else [10,30] for j,cx in enumerate(slots): cy=16+int(rng.integers(-3,4));r=int(rng.integers(4,7));cx+=int(rng.integers(-1,2)) color=left_color if j==0 else str(rng.choice(list(COLORS))) shape=right_shape if j==len(slots)-1 else str(rng.choice(['square','circle'])) box=(cx-r,cy-r,cx+r,cy+r) if shape=='square':d.rectangle(box,fill=COLORS[color]) else:d.ellipse(box,fill=COLORS[color]) arr=np.array(img,dtype=np.float32)/255 return np.clip(arr+rng.normal(0,.014,arr.shape),0,1).astype(np.float32) def make_vqa(n,seed,biased): rng=np.random.default_rng(seed);images=[];answers=[];meta=[] # Training has a 75% red prior. Test covers all 12 condition combinations equally. combos=[(c,col,sh) for c in [1,2] for col in COLORS for sh in ['square','circle']] for i in range(n): if biased: count=int(rng.choice([1,2]));color=str(rng.choice(list(COLORS),p=[.75,.125,.125]));shape=str(rng.choice(['square','circle'])) else:count,color,shape=combos[i%len(combos)] images.append(draw_scene(count,color,shape,rng)) answers.append([A['one' if count==1 else 'two'],A[color],A[shape]]) meta.append({'count':count,'left_color':color,'right_shape':shape}) return torch.tensor(np.stack(images)).permute(0,3,1,2),torch.tensor(answers),meta Xtr,Ytr,train_meta=make_vqa(720,14031,True) Xte,Yte,test_meta=make_vqa(180,14032,False) train_hashes={hashlib.sha256(x.numpy().tobytes()).hexdigest() for x in Xtr} assert not train_hashes.intersection(hashlib.sha256(x.numpy().tobytes()).hexdigest() for x in Xte) # Metadata remains only in this data/label construction and plot captions. # model.forward receives tensors (pixels, question_id), never these dictionaries. np.savez_compressed(OUT/'lab14-data.npz',train_images=Xtr.numpy(),train_answers=Ytr.numpy(),test_images=Xte.numpy(),test_answers=Yte.numpy()) print('train/test image counts:',len(Xtr),len(Xte),'pairs:',Ytr.numel(),Yte.numel()) print('train left-color prior:',{c:sum(m['left_color']==c for m in train_meta)/len(train_meta) for c in COLORS}) print('test left-color prior:',{c:sum(m['left_color']==c for m in test_meta)/len(test_meta) for c in COLORS}) ``` [此段独立阅读](/course/notebook-blocks/ef7e95916f594784b1269ebf22f89911.html) ## 3. 模型、损失与公平比较范围 像素分支是两层 CNN 与保留布局的全连接表示;问题分支是 12 维可学习嵌入。拼接后输出七个答案的 logits,以交叉熵 $-\log p(a\mid I,q)$ 训练。question-only 使用相同问题嵌入维度、答案空间与训练样本,但省去 CNN。两者都真正更新参数,不能把手填“red”冒充训练的语言基线。 完整模型参数更多,因此二者差距本身不是严格的等参数容量结论。我们在同一个完整模型上又做像素置零与错配,固定参数再移除正确图像条件。该消融更直接检验本次预测是否使用图像。 [此段独立阅读](/course/notebook-blocks/62876c0daff44e6f9b582bcaa0d0dd00.html)展开完整实现代码 ``` class VQA(nn.Module): def __init__(self,visual): super().__init__();self.visual=visual self.question=nn.Embedding(len(QUESTIONS),12) if visual: self.cnn=nn.Sequential(nn.Conv2d(3,12,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),nn.Conv2d(12,24,3,padding=1),nn.ReLU(),nn.MaxPool2d(2),nn.Flatten(),nn.Linear(24*8*10,64),nn.ReLU()) self.answer=nn.Sequential(nn.Linear(64+12 if visual else 12,64),nn.ReLU(),nn.Linear(64,len(ANSWERS))) def forward(self,pixels,question_id): q=self.question(question_id) features=torch.cat([self.cnn(pixels),q],-1) if self.visual else q return self.answer(features) full=VQA(True);question_only=VQA(False) curve=[] for label,net in [('image+question',full),('question-only',question_only)]: optimizer=torch.optim.Adam(net.parameters(),lr=.002) for epoch in range(28): order=torch.randperm(Ytr.numel());net.train();loss_sum=0. for pair_ids in order.split(72): image_ids=pair_ids//3;qids=pair_ids%3 logits=net(Xtr[image_ids],qids);target=Ytr[image_ids,qids] loss=F.cross_entropy(logits,target) optimizer.zero_grad();loss.backward();optimizer.step();loss_sum+=float(loss.detach())*len(pair_ids) if epoch in [0,5,13,27]:print(label,'epoch',epoch+1,'CE',round(loss_sum/Ytr.numel(),4)) curve.append({'model':label,'epoch':epoch+1,'train_ce':loss_sum/Ytr.numel()}) torch.save(net.state_dict(),OUT/('lab14-'+('visual' if net.visual else 'question-only')+'-weights.pt')) ``` [此段独立阅读](/course/notebook-blocks/7f07825ae8a84326b6a7a768b26c7caf.html) ## 4. 逐问题读结果,别只看总准确率 | 保留集条件 | 数量 | 左侧颜色 | 右侧形状 | 全部 | | --- | --- | --- | --- | --- | | Image + question | 100.0% | 100.0% | 100.0% | 100.0% | | Question-only | 50.0% | 33.3% | 50.0% | 44.4% | | 同一模型,像素置零 | 50.0% | 33.3% | 50.0% | 44.4% | | 同一模型,图像循环错配 | 83.3% | 50.0% | 0.0% | 44.4% | 循环错配不是各属性独立重抽:数据按组合排列,所以相邻样本常保留数量,却总改变形状。于是 83.3%/50%/0% 不是异常;它正提醒我们干预协议必须说清。不要把这个排列解释为各任务的随机机会水平。 ![相同保留问答上的视觉、语言、零图像与错配图像比较](/assets/display/fe08ead67458f0103fca897b.webp) 同图换问展示问题控制“读什么”,同问换图展示视觉控制“答什么”。先查控制图中红/蓝只变颜色的一对,再看代码记录的九条问答,判断本次证据是否同时满足这两个方向。 [此段独立阅读](/course/notebook-blocks/4eb1163b8bc64dd5ab088d22e10318bb.html)展开完整实现代码 ``` @torch.no_grad() def answer_all(net,images): net.eval() return torch.stack([net(images,torch.full((len(images),),q)).argmax(-1) for q in range(3)],1) normal=answer_all(full,Xte);qo=answer_all(question_only,Xte) zero=answer_all(full,torch.zeros_like(Xte));shuffled=answer_all(full,Xte[torch.roll(torch.arange(len(Xte)),1)]) train_full=answer_all(full,Xtr);train_qo=answer_all(question_only,Xtr) def score(p,y):return {'overall':float(p.eq(y).float().mean()),**{QUESTIONS[q]:float(p[:,q].eq(y[:,q]).float().mean()) for q in range(3)}} metrics={'test_image_question':score(normal,Yte),'test_question_only':score(qo,Yte),'test_zero_pixels':score(zero,Yte),'test_shuffled_pixels':score(shuffled,Yte),'train_image_question':score(train_full,Ytr),'train_question_only':score(train_qo,Ytr)} print(json.dumps(metrics,indent=2)) # Recreate two images with matched nuisance randomness, only left color differs. control_specs=[(2,'red','square'),(2,'blue','square'),(1,'green','circle')] controls_x=torch.tensor(np.stack([draw_scene(*s,np.random.default_rng(444)) for s in control_specs])).permute(0,3,1,2) controls_pred=answer_all(full,controls_x);controls_qo=answer_all(question_only,controls_x) controls=[{'image_condition':list(s),'question':q,'image_question':ANSWERS[int(controls_pred[i,j])],'question_only':ANSWERS[int(controls_qo[i,j])]} for i,s in enumerate(control_specs) for j,q in enumerate(QUESTIONS)] print('same image / changed question and same question / changed image:') for row in controls:print(row) ``` [此段独立阅读](/course/notebook-blocks/3ce456f62e2a4489be2b14a56d1b568e.html) ## 5. 学习曲线与可以被推翻的结论 ![图像问答与只读问题模型的实际训练交叉熵](/assets/display/d154401bc0457c19795d01aa.webp) 完整模型在这个有限合成分布上达到 100%,不等于解决真实 VQA。颜色、边缘和对象数都容易辨别,问题模板也固定。只改一个条件:保持图像不变,把未知改写“which hue is on the left?”输入当前 ID 接口会发生什么?正确结论是它根本没有这个文本编码能力,不应把手工映射改写到 ID 的功能归功于网络。 另一项可运行改动是把训练颜色先验改为均匀,保留模型与测试不变。先预测 question-only 的训练颜色分数会下降,而保留集颜色分数仍约为三分之一。模型可以减少训练与测试的先验落差,却仍需要看图才能逐例回答。 [此段独立阅读](/course/notebook-blocks/7bb1e0b0f13f4a8ca3f9aa6b7115edc8.html)展开完整实现代码 ``` fig,axes=plt.subplots(1,3,figsize=(11,4)) for i,ax in enumerate(axes): ax.imshow(controls_x[i].permute(1,2,0));ax.axis('off') rows=['count: ','left color: ','right shape: '] text='\n'.join(rows[j]+ANSWERS[int(controls_pred[i,j])]+' [Q-only: '+ANSWERS[int(controls_qo[i,j])]+']' for j in range(3)) ax.set_title(text,fontsize=10) fig.suptitle('LAB14: same questions, changed image; three questions per image',fontsize=13) fig.tight_layout();fig.savefig(OUT/'lab14-vqa-controls.png',dpi=160);plt.show();plt.close(fig) fig,ax=plt.subplots(figsize=(10,3.6));labels=['image + Q','Q only','zero pixels','shuffled pixels'];keys=['test_image_question','test_question_only','test_zero_pixels','test_shuffled_pixels'];values=[metrics[k]['overall'] for k in keys] ax.bar(labels,values,color=['#356b91','#ad8251','#9da6ad','#9da6ad']);ax.set(ylim=(0,1.1),ylabel='answer accuracy (540 held-out pairs)') for i,v in enumerate(values):ax.text(i,v+.02,f'{v:.1%}',ha='center') fig.tight_layout();fig.savefig(OUT/'lab14-vqa-ablation.png',dpi=160);plt.show();plt.close(fig) fig,ax=plt.subplots(figsize=(8,3.2)) for label in ['image+question','question-only']: r=[v for v in curve if v['model']==label];ax.plot([v['epoch'] for v in r],[v['train_ce'] for v in r],label=label) ax.set(xlabel='epoch',ylabel='answer cross entropy');ax.legend();fig.tight_layout();fig.savefig(OUT/'lab14-vqa-learning.png',dpi=160);plt.show();plt.close(fig) result={'notebook_id':'11d9f74871544a77b42024a9fe06b81a','execution':'local CPU, fresh process; not a Deepnote cloud run','created_utc':datetime.now(timezone.utc).isoformat(),'elapsed_seconds':time.perf_counter()-START,'environment':{'python':sys.version,'torch':torch.__version__,'numpy':np.__version__,'platform':platform.platform()},'split':{'train_seed':14031,'test_seed':14032,'train_images':720,'test_images':180,'questions_per_image':3,'split_before_question_expansion':True,'train_red_prior':.75,'test_color_prior':'uniform by construction'},'metrics':metrics,'curve':curve,'controls':controls,'predictions':[{'truth':[ANSWERS[int(v)] for v in Yte[i]],'image_question':[ANSWERS[int(v)] for v in normal[i]],'question_only':[ANSWERS[int(v)] for v in qo[i]]} for i in range(len(Xte))],'files_sha256':{p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(OUT.glob('*.pt'))},'data_sha256':hashlib.sha256((OUT/'lab14-data.npz').read_bytes()).hexdigest()} (OUT/'lab14-results.json').write_text(json.dumps(result,indent=2));print('saved',OUT,'seconds',round(result['elapsed_seconds'],2)) ``` [此段独立阅读](/course/notebook-blocks/040689f0e3594ff988d47a70c68dbab6.html) ## 结果文件、来源与回链 `lab14-data.npz` 保存像素与答案;两个 `.pt` 文件对应两个真实训练模型;`lab14-results.json` 保存每类准确率、逐轮损失、九条受控问答、逐图保留预测及环境/数据哈希。场景元数据只用于监督标签与解读,未传入网络。 [VQA 原论文,作者 arXiv 页面](https://arxiv.org/abs/1505.00468) 给出图像与问题到答案的任务定义与基线比较。[VQA 官方项目](https://visualqa.org/) 提供真实数据、评价与后续 VQA v2;本实验的精确分类准确率不是官方多人答案共识评分。我们的合成颜色先验是教学构造,不是该数据集的实测统计。 回到 [CLS 03](https://deepnote.com/project/38755e4e-23f6-4f5f-8c68-8a2e4114b1c1/notebook/680da245145447dd88983aabd136fd24):CLIP 排候选描述,caption 自回归生成,VQA 在问题条件下输出答案。它们共享视觉与语言,输出空间和训练目标却不同,不能因为界面都接受文字就互相替代。 [此段独立阅读](/course/notebook-blocks/193cc04bc38242b7be21838002bf9285.html)