# LAB 13 · 看图生成一句话:训练、解码与消融 按问题继续 真实CNN–GRU条件训练与greedy/beam生成、遮图/错图消融 对应课堂(2 页) 可先阅读已有结果,再决定是否运行。重算时请核对各节记录的数据、参数、环境与日期。 **学习路径:** [图像怎样约束句子与回答](/course/study/caption-vqa.html) **同样以`BOS a`开头,换一张图能否生成不同句子?** 先读[P51](/course/pages/l3-r2-p51.html)与[逐词目标表](/course/blocks/caption-shift-and-stop.html),再对照本册第2节输入/标签、第3节视觉进入路径、第4/5节实际生成和消融。 本册确实用RGB图像训练CNN+GRU并接续自身输出。2026-09-22本地基准保存了正确图、像素置零和循环错图的结果;18种语义组合在训练/保留两侧都出现,检验的是新渲染样本,不是未见组合或自然照片泛化。[结果和完整协议](/course/blocks/caption-visual-controls.html)。 读完应能拿一组图片与生成句子说明视觉是否影响输出,以及为何teacher-forced交叉熵不能替代生成评价。动手复现时按原代码顺序执行;GRU内部与beam搜索可留后续课,不影响先理解图像条件。另一个输出接口见[LAB 14 问答](https://deepnote.com/project/38755e4e-23f6-4f5f-8c68-8a2e4114b1c1/notebook/11d9f74871544a77b42024a9fe06b81a)。 ![固定前缀下的真实生成](/assets/display/9ebe76d49a449b9940b6470f.webp) 图中句子由已训练模型生成。下一项问题是“同图换问会怎样”,进入 [LAB 14 · 图像问答:视觉条件与问题先验](https://deepnote.com/workspace/ise3309jingao-5ba66b0a-4283-4f5a-ae06-3f68d3201e99/project/38755e4e-23f6-4f5f-8c68-8a2e4114b1c1/notebook/11d9f74871544a77b42024a9fe06b81a?utm_source=openai&utm_medium=mcp&utm_campaign=openaimcp&utm_content=11d9f74871544a77b42024a9fe06b81a&utm_term=get_notebook)。 [此段独立阅读](/course/notebook-blocks/71a055b4f2af4411829bd08c5f697050.html) ## 先拆开两个容易混淆的问题 训练的目标是 $p_\theta(w_t\mid I,w_{', '', '', 'a', 'red', 'blue', 'green', 'square', 'circle', 'on', 'left', 'right'] W = {s:i for i,s in enumerate(VOCAB)} PAD, BOS, EOS = 0, 1, 2 COLORS = {'red':(220,55,55), 'blue':(45,95,225), 'green':(35,170,75)} print('CPU', torch.__version__, 'seed 1303; no downloaded data or weights') ``` [此段独立阅读](/course/notebook-blocks/1543326fd7f749d382b10cc97d767713.html) ## 2. 图像先生成,目标句子只进入损失 三种颜色 × 两种形状 × 三个位置,得到 18 种组合。每图随机改变小幅平移、尺寸和像素噪声。中心图像的描述较短,如 `a red square`;侧边图像则为 `a red square on left`,因此本实验真的存在 PAD,而不是摆一个从来不被使用的 mask。 训练与保留图分别由 seed 13031 和 13033 生成,并检查像素哈希不重复。语义组合在两边都出现;这是新渲染样本的泛化,不是没见过组合的组合泛化。所有保留图的统计只用于最终报告。初期结构调试用 seed 13032 的开发图,另存于历史记录,不能混称独立测试。 [此段独立阅读](/course/notebook-blocks/b16f97bb031341f8b373532196bd0ca8.html)展开完整实现代码 ``` def render_object(color, shape, position, rng): canvas = Image.new('RGB', (32,32), (244,242,236)) painter = ImageDraw.Draw(canvas) cx = {'left':8, 'center':16, 'right':24}[position] + int(rng.integers(-1,2)) cy = 16 + int(rng.integers(-4,5)); radius = int(rng.integers(4,7)) box = (cx-radius, cy-radius, cx+radius, cy+radius) if shape == 'square': painter.rectangle(box, fill=COLORS[color]) else: painter.ellipse(box, fill=COLORS[color]) arr = np.array(canvas).astype(np.float32) / 255 arr = np.clip(arr + rng.normal(0,.018,arr.shape),0,1).astype(np.float32) return arr def make_caption_data(n, seed): rng = np.random.default_rng(seed); images=[]; sequences=[]; descriptions=[] conditions = [(c,s,p) for c in COLORS for s in ['square','circle'] for p in ['left','center','right']] for i in range(n): color, shape, pos = conditions[i % len(conditions)] words = ['a',color,shape] + ([] if pos=='center' else ['on',pos]) sequence = [BOS] + [W[w] for w in words] + [EOS] sequences.append(sequence+[PAD]*(7-len(sequence))) images.append(render_object(color,shape,pos,rng)); descriptions.append(' '.join(words)) return torch.tensor(np.stack(images)).permute(0,3,1,2), torch.tensor(sequences), descriptions Xtrain, Ytrain, train_text = make_caption_data(720,13031) Xtest, Ytest, test_text = make_caption_data(180,13033) # Independent random pixels/geometry; no duplicate image crosses the split. train_hashes = {hashlib.sha256(x.numpy().tobytes()).hexdigest() for x in Xtrain} assert not train_hashes.intersection(hashlib.sha256(x.numpy().tobytes()).hexdigest() for x in Xtest) np.savez_compressed(OUT/'lab13-data.npz', train_images=Xtrain.numpy(), train_tokens=Ytrain.numpy(), test_images=Xtest.numpy(), test_tokens=Ytest.numpy()) print('images:', tuple(Xtrain.shape), tuple(Xtest.shape), 'tokens:', tuple(Ytrain.shape)) print('teacher input:', Ytrain[:3,:-1].tolist()) print('shifted target:',Ytrain[:3,1:].tolist()) print('loss mask:',Ytrain[:3,1:].ne(PAD).int().tolist()) ``` [此段独立阅读](/course/notebook-blocks/61b73cd52382467c8d10d1143808ad17.html) ## 3. 视觉特征怎样真正进入语言模型 输入是 `[B,3,32,32]`。两次卷积和池化产生空间特征,展平后映射为 48 维视觉向量;这里不用全局平均池化,因为“左/右”依赖空间布局。每个词嵌入为 24 维,与同一视觉向量拼接后送入共享的 GRU;视觉向量还初始化 64 维隐藏状态。输出 logits 为 `[B,T,12]`。 $L=-\frac{\sum_{i,t}\mathbf 1[y_{it}\ne PAD]\log p_\theta(y_{it}\mid I_i,y_{i, a`,红方块左侧换成蓝圆右侧,哪个词最先应改变?随后检查 `same_prefix_controls`,不要把数据生成器的标签当成模型生成。 [此段独立阅读](/course/notebook-blocks/fab7a942c9ab4a2ca8dc8d8c5d2877d9.html)展开完整实现代码 ``` @torch.no_grad() def greedy(pixels, prefix=(BOS,), max_new=8): model.eval(); seq=torch.tensor(prefix).repeat(len(pixels),1); finished=torch.zeros(len(pixels),dtype=torch.bool) for _ in range(max_new): nxt=model(pixels,seq)[:,-1].argmax(-1) nxt=torch.where(finished,torch.tensor(EOS),nxt) seq=torch.cat((seq,nxt[:,None]),1); finished|=nxt.eq(EOS) if bool(finished.all()): break return seq.tolist() @torch.no_grad() def beam_one(pixel, width=3, max_new=8): beams=[([BOS],0.0,False)] for _ in range(max_new): candidates=[] for seq,score,done in beams: if done: candidates.append((seq,score,True)); continue lp=F.log_softmax(model(pixel[None],torch.tensor([seq]))[0,-1],dim=-1) values,indices=lp.topk(width) for value,idx in zip(values.tolist(),indices.tolist()): candidates.append((seq+[idx],score+value,idx==EOS)) beams=sorted(candidates,key=lambda b:b[1],reverse=True)[:width] if all(b[2] for b in beams): break # Ended hypotheses only if at least one ended; otherwise max-length fallback. ended=[b for b in beams if b[2]] return max(ended or beams,key=lambda b:b[1]) def words(ids): out=[] for idx in ids: if idx==EOS: break if idx not in [BOS,PAD]: out.append(VOCAB[idx]) return ' '.join(out) pred=[words(s) for s in greedy(Xtest)] beam_pred=[words(beam_one(x)[0]) for x in Xtest] zero_pred=[words(s) for s in greedy(torch.zeros_like(Xtest))] # A deterministic derangement shifts test images; target captions stay fixed. shuffle_index=torch.roll(torch.arange(len(Xtest)),1) shuffle_pred=[words(s) for s in greedy(Xtest[shuffle_index])] exact=lambda p: float(np.mean([a==b for a,b in zip(p,test_text)])) metrics={'heldout_images':len(Xtest),'greedy_exact_match':exact(pred),'beam3_exact_match':exact(beam_pred),'zero_pixels_exact_match':exact(zero_pred),'shuffled_pixels_exact_match':exact(shuffle_pred),'final_train_token_ce':curve[-1]['train_token_ce'],'final_heldout_teacher_forced_ce':curve[-1]['heldout_teacher_forced_ce']} print(json.dumps(metrics,indent=2)) # Hold the language prefix fixed; change only the input image. rng=np.random.default_rng(999) control_specs=[('red','square','left'),('blue','circle','right'),('green','square','center')] control_pixels=torch.tensor(np.stack([render_object(*spec,rng) for spec in control_specs])).permute(0,3,1,2) control_pred=[words(s) for s in greedy(control_pixels,prefix=(BOS,W['a']))] controls=[{'condition':list(spec),'fixed_prefix':' a','generated':p} for spec,p in zip(control_specs,control_pred)] print('same prefix, changed image:',json.dumps(controls,indent=2)) ``` [此段独立阅读](/course/notebook-blocks/c21718fad7a840a380c87175f7067cf5.html) ## 5. 读真实图、真实曲线与失败边界 | 解码/干预 | 保留集整句准确率 | | --- | --- | | Greedy,自回归 | 100.0% | | Beam,宽度 3 | 100.0% | | 相同模型,所有像素置零 | 5.6% | | 相同模型,图像循环错配 | 0.0% | ![保留图像、真值描述及greedy和beam真实生成句子](/assets/display/a9bc909f343835d4ec673d45.webp) ![训练和保留逐词交叉熵及图像干预后的整句准确率](/assets/display/b3d5136b8b5c8bfe245797e9.webp) 100% 只覆盖小词表、单对象和熟悉的语义组合;大小变化、噪声幅度都有限。下一步只改变一个条件:把 `radius` 范围扩大,或让图像包含两个对象,并先明确描述目标是否也应改变。不要在同一次改动中同时加对象、换语料和换评分,否则失败难以归因。 [此段独立阅读](/course/notebook-blocks/16ef050e2f094a6eaf6b7c8f4f14dc32.html)展开完整实现代码 ``` fig,axes=plt.subplots(2,3,figsize=(10,6)) chosen=[0,3,7,10,14,17] for ax,i in zip(axes.flat,chosen): ax.imshow(Xtest[i].permute(1,2,0)); ax.axis('off') ax.set_title('truth: '+test_text[i]+'\ngreedy: '+pred[i]+'\nbeam: '+beam_pred[i],fontsize=9) fig.suptitle('LAB13: held-out images; model-generated captions',fontsize=13); fig.tight_layout() fig.savefig(OUT/'lab13-caption-predictions.png',dpi=160); plt.show(); plt.close(fig) fig,axes=plt.subplots(1,2,figsize=(10,3.4)) axes[0].plot([r['epoch'] for r in curve],[r['train_token_ce'] for r in curve],label='train'); axes[0].plot([r['epoch'] for r in curve],[r['heldout_teacher_forced_ce'] for r in curve],label='held-out'); axes[0].set(xlabel='epoch',ylabel='mean non-pad token CE');axes[0].legend() labels=['greedy','beam 3','zero image','shuffled image'];values=[metrics[k] for k in ['greedy_exact_match','beam3_exact_match','zero_pixels_exact_match','shuffled_pixels_exact_match']] axes[1].bar(labels,values,color=['#356b91','#40868e','#a9adb0','#a9adb0']);axes[1].set(ylim=(0,1.12),ylabel='exact caption accuracy (180 images)') for i,v in enumerate(values):axes[1].text(i,v+.025,f'{v:.1%}',ha='center') fig.tight_layout();fig.savefig(OUT/'lab13-caption-controls.png',dpi=160);plt.show();plt.close(fig) fig,axes=plt.subplots(1,3,figsize=(9,3)) for ax,x,row in zip(axes,control_pixels,controls): ax.imshow(x.permute(1,2,0));ax.axis('off');ax.set_title('prefix: a\n'+row['generated'],fontsize=10) fig.tight_layout();fig.savefig(OUT/'lab13-same-prefix.png',dpi=160);plt.show();plt.close(fig) result={'notebook_id':'3da47f3ee3554ee0a36e0bb6a2fdc61f','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':13031,'test_seed':13033,'train_images':720,'test_images':180,'description':'independent noisy renders; same 18 semantic combinations on both sides; no compositional/general-photo claim'},'metrics':metrics,'curve':curve,'same_prefix_controls':controls,'predictions':[{'truth':t,'greedy':p,'beam3':b,'zero':z,'shuffled':s} for t,p,b,z,s in zip(test_text,pred,beam_pred,zero_pred,shuffle_pred)],'weight_sha256':hashlib.sha256((OUT/'lab13-caption-weights.pt').read_bytes()).hexdigest(),'data_sha256':hashlib.sha256((OUT/'lab13-data.npz').read_bytes()).hexdigest()} (OUT/'lab13-results.json').write_text(json.dumps(result,indent=2)) print('saved',OUT,'seconds',round(result['elapsed_seconds'],2)) ``` [此段独立阅读](/course/notebook-blocks/1a6a0f13eab24338b41badd5b17c9678.html) ## 保存、来源与下一步 `lab13-data.npz` 保存真实训练/保留像素与 token;`lab13-caption-weights.pt` 保存该次模型;`lab13-results.json` 保存分割种子、逐轮损失、每条预测、环境、时间和权重/数据 SHA-256。下载的执行版 Notebook 含实际输出,完整代码在每格内。本册的训练和推理已在本地全新 Python 进程运行;这与原 Deepnote 云端历史 run 分开记录。 [Show and Tell,CVPR 2015,论文作者/CVF 原文](https://www.cv-foundation.org/openaccess/content_cvpr_2015/html/Vinyals_Show_and_Tell_2015_CVPR_paper.html) 是图像条件最大似然描述生成的来源。本册保留这一因子分解与 CNN→循环解码思路,但使用小 GRU、自造图形和简化评价;不是原论文复现。 回到 [第四讲 · 从条件多解走向生成模型](https://deepnote.com/project/6f83a923-d155-47be-be58-680db701ff7f/notebook/694b7a43ea4948f5a197c014c2fbad3b?utm_source=openai&utm_medium=mcp&utm_campaign=openaimcp&utm_content=694b7a43ea4948f5a197c014c2fbad3b&utm_term=get_notebook):caption 已经在生成。下一讲继续研究的是怎样表达更复杂的条件分布、目标与采样,而不是第一次出现生成。 [此段独立阅读](/course/notebook-blocks/a41054827a0d49cdb9c0041dd9a7be4b.html)