人工智能大作业实战:看图说话与微表情识别的Encoder-Decoder与光流方案

发布时间:2026/10/11 22:06:57
人工智能大作业实战:看图说话与微表情识别的Encoder-Decoder与光流方案
简介面向人工智能导论课程学习者一套围绕看图说话与表情识别两个典型视觉任务的完整源码与报告包基于TensorFlow与Keras构建并适配在线笔记本环境下载后即可打开运行。内容涵盖图像描述生成、人脸检测与情绪分类两大模块既适合完成课程报告或期末设计也可以作为计算机视觉入门项目的参考基线。压缩包共9个文件大小约5.78MB主要文件包括3个源代码笔记本、课程论文报告文档、两份说明文件、人脸检测级联模型以及许可证等文件用途清晰、结构不臃肿。已有338人浏览学习资源热度良好。学习者可结合论文报告理解模型选择与实验设置再通过源码复现从图像输入到文字描述输出、从人脸定位到情绪识别的完整流程并掌握级联检测器与卷积神经网络配合使用的方法整体是一份理论扎实、代码可运行的优质课程资源。1. 人工智能导论大作业里的这个 zip看图说话是入门微表情识别才是分水岭拿到《人工智能导论—看图说话微表情识别.zip》这个交付包的人多半是要交人工智能大作业或准备课程设计答辩。包名把两个任务并列实际上难度差了一个量级看图说话是给一张图生成一句描述属于经典的序列生成问题微表情识别是从不到一秒的视频片段里抓住转瞬即逝的情绪变化属于小样本时序分类。前者有公开数据集和成熟预训练权重照着跑就能出结果后者光是把数据整理干净、划分对就能刷掉一批人。多数人能跑通看图说话却在微表情识别上拿不到像样的指标——这不是能力问题而是没认清它的数据瓶颈和时序特性。这篇笔记就从课程设计的视角讲清两个任务怎么做、参数怎么设、哪些地方会翻车让你拿到这个 zip 能看懂结构换成自己的数据也能跑起来。2. 看图说话Encoder-Decoder 是骨架注意力机制才是提分点2.1 任务定性与模型选型为什么不能只用 CNN看图说话Image Captioning不是图像分类它要求模型同时理解画面内容并组织出一句通顺的话。纯 CNN 只能输出类别标签无法生成任意长度的句子所以主流的做法是拆成两段先用 CNN 把图片编码成特征再用序列模型把特征解码成单词串。这就是 Encoder-Decoder 架构课程大作业里最常见的实现是 Show, Attend and Tell 这条路线也就是 ResNet 提取图像特征 LSTM 逐词生成 注意力机制。模型选型直接决定你能跑到什么程度。我用过几组搭配最稳的是 ResNet18 或 ResNet34 做 EncoderLSTM 做 Decoder注意力用 Bahdanau 加性注意力。ResNet50 不是不行但在课程设计的算力和训练时间下提升很小反而容易过拟合。注意力机制是关键没有注意力时 Decoder 只能看到整张图的全局平均特征生成到第三个词就开始跑偏加了注意力后每个时间步都能回到图片上挑重要的区域去“看”BLEU 能明显涨一截。模块常见选择我的建议理由EncoderVGG16 / ResNet18 / ResNet50ResNet18特征图尺寸适中训练快不易过拟合DecoderLSTM / GRULSTM序列建模稳定课程作业资料多踩坑好排查AttentionBahdanau / Luong / 自注意力Bahdanau加性注意力在小数据集上更容易收敛评估指标BLEU / ROUGE / CIDErBLEU-1~4作业至少报 BLEU再加一个 CIDEr 更有说服力选型还有个隐性要求Encoder 一般用 ImageNet 预训练权重初始化特征提取部分可以冻结只训练 Decoder 和注意力层。这么做的原因是图像底层特征边缘、纹理、颜色是通用的不需要从头学。你只需要把 ResNet 的fc层去掉拿倒数第二层的特征图作为 Decoder 的输入来源。2.2 Encoder 特征提取从图片到特征序列图片在进入网络前要经过预处理缩放到 224x224做 ImageNet 的均值和方差归一化。ResNet 输出的是(batch, 2048, 7, 7)或(batch, 512, 7, 7)的特征图对 ResNet18 来说512是通道数7x7是空间尺寸。我们会把空间维度展平得到(batch, 49, 512)这 49 个位置就对应图片上的 49 个区域注意力机制会在这 49 个区域上分配权重。import torch import torch.nn as nn from torchvision import models class ImageEncoder(nn.Module): 用 ResNet18 提特征输出每个空间区域的特征向量 def __init__(self, embed_dim256): super().__init__() resnet models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 去掉全局池化和全连接层保留卷积特征图 self.backbone nn.Sequential(*list(resnet.children())[:-2]) # 1x1 卷积把 512 维压到 embed_dim减小 Decoder 输入维度 self.proj nn.Conv2d(512, embed_dim, kernel_size1) def forward(self, images): # images: (batch, 3, 224, 224) feat self.backbone(images) # (batch, 512, 7, 7) feat self.proj(feat) # (batch, embed_dim, 7, 7) batch, dim, h, w feat.shape feat feat.view(batch, dim, h * w) # (batch, embed_dim, 49) return feat.permute(0, 2, 1) # (batch, 49, embed_dim)这段代码里有三个关键点。第一list(resnet.children())[:-2]把 ResNet 最后的全局平均池化和全连接层去掉保留到 conv 层为止的特征图。第二1x1卷积不改变空间尺寸只改变通道数作用是让 Encoder 输出维度与 Decoder 的词嵌入维度对齐。第三最后把特征图展平成 49 个区域向量每个向量描述图片的一个局部区域注意力就是在这 49 个候选区域上做加权平均。embed_dim一般取 256太大增加注意力计算量太小区域特征表达不够。图片尺寸保持 224x224 即可不需要更大因为 ResNet 下采样 32 倍后224 对应 7x7 特征图已经足够分辨主体和背景。2.3 Decoder 与注意力逐词生成时每步都在“看图”Decoder 的输入包括两部分上一时间步生成的单词或训练时的真实词和当前上下文向量。上下文向量由注意力机制对 49 个区域特征加权求和得到。训练时通常用教师强制Teacher Forcing也就是不管模型上一步预测什么都拿真实词作为下一步输入推理时没有真实词可用只能把上一步的输出喂回去这时要用 beam search 来减少错误累积。class CaptionDecoder(nn.Module): LSTM Bahdanau Attention输出每个时间步的词表分布 def __init__(self, embed_dim256, hidden_dim512, vocab_size5000): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim) self.lstm nn.LSTM(embed_dim, hidden_dim, batch_firstTrue) # 注意力打分网络 self.attn_proj nn.Linear(embed_dim, hidden_dim, biasFalse) # 图像特征投影 self.attn_hidden nn.Linear(hidden_dim, hidden_dim, biasFalse) # 隐状态投影 self.attn_score nn.Linear(hidden_dim, 1, biasFalse) self.fc nn.Linear(hidden_dim, vocab_size) def forward(self, image_features, captions): # image_features: (batch, 49, embed_dim) # captions: (batch, seq_len) batch, seq_len captions.shape embeddings self.embedding(captions) # (batch, seq_len, embed_dim) h torch.zeros(1, batch, 512) c torch.zeros(1, batch, 512) outputs [] for t in range(seq_len): # 注意力得分 score(h_t, image_features) # 这里简化起见直接用当前隐状态算权重 attn_w torch.softmax(self.attn_score( torch.tanh(self.attn_hidden(h[-1]) self.attn_proj(image_features)) ).squeeze(-1), dim-1) # (batch, 49) context torch.bmm(attn_w.unsqueeze(1), image_features) # (batch, 1, embed_dim) lstm_input torch.cat([embeddings[:, t:t1, :], context], dim-1) # 实际 LSTM 输入维度为 embed_dim*2需要调整线性层 lstm_out, (h, c) self.lstm(lstm_input, (h, c)) outputs.append(self.fc(lstm_out.squeeze(1))) # (batch, vocab_size) return torch.stack(outputs, dim1) # (batch, seq_len, vocab_size)这个代码是简化的教学版本有一处需要调整LSTM 输入拼接了词向量和上下文向量输入维度是embed_dim * 2所以实际使用中要么把self.lstm的input_size设为embed_dim * 2要么把注意力上下文单独过一个线性层再相加。我一般选择前者改动最小。注意attn_hidden(h[-1])这里h[-1]是最后一层 LSTM 的隐状态attn_proj(image_features)对整个特征序列做投影。两个投影结果相加后过 tanh再过attn_score得到每个区域一个分数softmax 后就是注意力权重。torch.bmm做加权求和得到的 context 向量相当于“模型现在正在看图片的哪一部分”。推理时 beam search 的做法是每一步保留概率最高的前 k 条候选序列而不是只取概率最高的一个词。k 一般取 3 或 5。k 越大生成的句子越流畅但解码时间线性增长而且太小的数据集上 k 大会加剧重复生成。2.4 训练数据组织从图片路径到批次张量的三个转换数据组织是新手最容易写乱的部分。一张训练样本是“图片文件 一句或多句描述”要变成模型能吃的(images, captions)张量中间有三次转换读图并归一化、对句子分词并映射成索引、对批次内的句子做 padding 并生成 mask。代码层面通常写一个自定义 Dataset再配合 collate_fn 处理变长句子。from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class CaptionDataset(Dataset): 读取图片和对应描述返回图片与词索引序列 def __init__(self, image_paths, captions, vocab): self.image_paths image_paths self.captions captions self.vocab vocab self.transform T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image Image.open(self.image_paths[idx]).convert(RGB) image self.transform(image) # 句子转成 [sos, w1, w2, ..., eos] tokens [sos] self.captions[idx].split() [eos] caption [self.vocab.get(w, self.vocab[unk]) for w in tokens] return image, torch.tensor(caption, dtypetorch.long) def collate_fn(batch): 处理变长句子padding 到批次内最长长度并生成 mask images, captions zip(*batch) images torch.stack(images, dim0) lengths [len(c) for c in captions] max_len max(lengths) padded torch.full((len(captions), max_len), vocab[pad], dtypetorch.long) for i, cap in enumerate(captions): padded[i, :len(cap)] cap # mask 标记哪些位置是真实词1哪些是 padding0 mask torch.zeros_like(padded, dtypetorch.bool) for i, l in enumerate(lengths): mask[i, :l] True return images, padded, maskcollate_fn里最容易踩坑的是 padding 位置没有在 loss 中被 mask 掉。如果直接对 padded 序列计算交叉熵模型会去学习预测pad导致验证时频繁输出空词。正确做法是把mask传入损失函数只计算真实词位置的 loss。另外词表大小建议控制在 5000 以内超过一万会明显增大 Decoder 最后一层全连接的参数量训练速度下降。低频词统一映射到unk句子里的数字和标点要么保留要么过滤保持词表干净比什么都留更重要。3. 微表情识别为什么现成 CNN 在它面前不灵3.1 微表情和普通表情的本质区别它依赖时域运动信息普通表情识别FER在静态图片上就能做人脸区域的纹理特征已经足够分类。微表情不一样它的持续时间通常在 1/25 到 1/5 秒之间面部肌肉运动幅度很小单帧图像上肉眼几乎分辨不出和中性表情的差别。如果拿训练好的 FER 模型直接套在微表情单帧上准确率会接近随机猜测。根本原因在于微表情的判别信息不在“某一帧长什么样”而在“从 onset 到 apex 这段时间里肌肉怎么动”。所以微表情识别的主流做法都围绕运动建模展开。最朴素的路线是把相邻帧之间的光流Optical Flow算出来把运动信息编码成一张图再喂给 CNN进阶路线是直接上 3D-CNN 或 Video Transformer 做端到端时序建模但课程设计用 3D-CNN 很容易因为数据量不够而严重过拟合。光流法虽然手工干预多一些但训练稳定、数据需求量小是作业级别的首选。对比维度普通表情识别微表情识别输入单张人脸图视频片段通常取 onset 到 apex 帧关键信息纹理、形状帧间运动幅度与方向数据规模数万张级别数百到数千个视频片段主流模型ResNet / ViT光流CNN / 3D-CNN / LSTM常见坑遮挡、姿态变化类不平衡、标注时长不精确、样本重叠数据集方面常见的 CASME 系列、SAMM、SMIC 等公开数据集都有专门的微表情标注但样本总量都不大。这一点决定了你在训练时不能直接用 ImageNet 式的大模型模型容量必须压缩数据增强必须做足否则验证集指标再好看测试集一换就露馅。3.2 光流特征提取把“肌肉怎么动”变成一张图光流描述了相邻两帧之间每个像素点的运动向量水平分量和垂直分量分别记录在两张单通道图里。对微表情识别来说单帧的 grayscale 到后的光流还不足以表达完整的运动过程常见做法是取起始帧和峰值帧或末端帧也就是 onset 到 apex 之间的总运动量计算一次光流后把水平分量、垂直分量和幅值拼成三通道图当作普通 RGB 图输入 CNN。import cv2 import numpy as np def compute_flow_map(onset_frame, apex_frame, resize(224, 224)): 输入起始帧和峰值帧BGR 或灰度输出三通道光流图。 通道0: 水平运动分量 u 通道1: 垂直运动分量 v 通道2: 运动幅值 magnitude gray1 cv2.cvtColor(onset_frame, cv2.COLOR_BGR2GRAY) gray2 cv2.cvtColor(apex_frame, cv2.COLOR_BGR2GRAY) # Farneback 稠密光流 flow cv2.calcOpticalFlowFarneback( gray1, gray2, None, pyr_scale0.5, levels3, winsize21, iterations3, poly_n7, poly_sigma1.5, flags0 ) # flow: (h, w, 2)[..., 0] 是水平分量[..., 1] 是垂直分量 u flow[..., 0] v flow[..., 1] magnitude, _ cv2.cartToPolar(u, v) # 幅值和角度 # 归一化到 0~255 区间 def normalize(channel): c cv2.normalize(channel, None, 0, 255, cv2.NORM_MINMAX) return c.astype(np.uint8) flow_map np.stack([normalize(u), normalize(v), normalize(magnitude)], axis-1) flow_map cv2.resize(flow_map, resize, interpolationcv2.INTER_LINEAR) return flow_map # (224, 224, 3)这里有几个参数值得说。pyr_scale0.5表示每层金字塔缩小一半图像运动大的场景可以提高层数来追踪winsize21是窗口大小窗口过大运动细节会被平滑掉过小又会产生噪声iterations3是每层金字塔的迭代次数越大越精确但越慢。微表情运动幅度极小如果winsize取太大光流几乎全为 0所以实际调参时应该先观察生成的 flow_map 是否有可见结构再决定要不要改小窗口。这里有一个重要注意点cv2.normalize用的是NORM_MINMAX会把最大运动映射到 255。如果视频里偶尔有一帧因为人脸微动产生较大噪声整个光流图的对比度会被这帧拉低。我一般会在归一化前掐掉上下 1% 的极端值比如把通道低于 1 百分位和高于 99 百分位的像素截断再归一化这样微表情的小幅运动才能被拉伸出来。3.3 数据集划分的三种错误做法时间泄漏比过拟合更致命微表情数据集划分是大作业里最容易翻车的地方而且翻得很隐蔽。每个视频片段来自同一个被试同一被试的多个片段在情绪类型上往往有较强的相关性。如果划分时不分被试直接随机切分同一被试的面部特征就同时出现在训练集和验证集里验证指标会虚高。这不是模型的功劳是模型记住了特定被试的人脸结构。第一种错误是样本级随机划分。比如数据共 300 个视频片段直接train_test_split(test_size0.2, random_state42)完全没看被试 ID。第二种错误是视频级划分但划分前没按顺序去重比如同一被试的多个片段被分到两边。第三种错误是光流计算时取了重叠帧区间训练集和验证集的实际输入帧有重叠。三种错误都会造成验证集指标失真。from sklearn.model_selection import GroupShuffleSplit # 假设 df 包含两列video_id片段ID和 subject_id被试ID # 关键划分单位是 subject而不是 video 或 sample splitter GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(splitter.split(df, groupsdf[subject_id])) train_df df.iloc[train_idx] val_df df.iloc[val_idx] # 强行检查验证集里不能出现训练集里的任何 subject overlap set(train_df[subject_id]) set(val_df[subject_id]) assert len(overlap) 0, f被试泄漏: {overlap}GroupShuffleSplit按groups参数分块划分这里传入subject_id就能保证同一个被试的所有视频片段都落在同一侧。如果你用的是深度学习框架自带的切分工具要确认它没有group参数有的话优先用没有就手动实现按被试分组的索引切分。另外微表情数据集存在类别不均衡最常见的正类往往是“厌恶”和“惊讶”而“恐惧”“悲伤”样本很少。划分时还要保证训练集和验证集的类别分布接近必要时在训练集里做类别加权采样而不是简单地过采样复制少数类样本因为复制容易让模型对同一视频的特定帧产生过拟合。4. 避坑专章训练里五个必翻车的设置每个都是血泪经验4.1 损失函数不降反升或反复横跳先查学习率再查标签现象看图说话训练前几百步 loss 直接冲高到 9 以上或者 loss 在 4 到 5 之间剧烈震荡不下降微表情识别那边则出现训练集 loss 下降但验证集 loss 反而上升的经典过拟合曲线。原因看图说话最常见的两个原因是学习率太大和词表 padding 位置没有 mask。学习率 1e-3 对 ResNetLSTM 来说往往偏大LSTM 对学习率非常敏感尤其是词嵌入层刚初始化时梯度很大。loss 下不去还可能是交叉熵统计了pad位置的预测模型花了一半精力在学“预测空词”训练目标被污染。解决先给 Decoder 单独设置学习率 1e-4 到 3e-4Encoder 如果微调就再低一个量级。用 AdamW 优化器weight decay 取 1e-4。第一个 epoch 做 warmup从 1e-5 线性升到目标学习率之后按余弦退火降到峰值的一成。同时确认 loss 计算时传入了 masknn.CrossEntropyLoss的ignore_index设为pad的词索引一劳永逸。4.2 看图说话验证集 BLEU 不错生成句子却像复读机现象BLEU-4 有 20 多分但 beam search 实际输出的句子全是同一套模板比如“a man is standing in front of a building”换一张图还是这句。更糟的情况是模型不断重复“a a a a”。原因教师强制训练让 Decoder 习惯了以真实词为输入一旦推理时上一步生成了错误词错误会像滚雪球一样传下去这就叫暴露偏差。句子里的高频词a、the、is在损失函数里占主导模型发现输出这些词能稳住 loss于是收敛到安全但平庸的策略。解决训练里每 5 步做一次计划采样Scheduled Sampling即以 10% 的概率用模型上一步的输出替换真实词作为输入让 Decoder 见到自己的错误。推理时 beam width 设 3并给重复 n-gram 加惩罚或者直接禁用重复三元组。对短句和长句分开看结果如果 beam search 出来的句子普遍偏短把最大长度上限提高到 30 并加长度奖励。4.3 微表情验证集 acc 很高但盲测直接翻车现象按样本全随机划分后验证集准确率到了 90% 以上你兴冲冲拿去跑一段新录的测试视频结果五个情绪几乎全猜错。原因十有八九是数据泄漏。前面提到的按被试划分问题在微表情识别上极其常见因为微表情数据集里同一被试会在不同拍摄批次里出现如果你只按视频文件名去重而不按subject_id就会被同一批被试的不同片段钻空子。解决严格按照 subject 划分如果数据集没有标注被试 ID用文件名前缀提取前提是数据集命名有规律。最终评估建议直接报留一被试交叉验证LOSO的均值和方差这是微表情论文里通用的评估口径比单次划分更令人信服。再补一个 sanity check训练前把 train 和 val 的 subject 求交集必须为空。4.4 光流计算慢到怀疑人生瓶颈不在 GPU 而在 DataLoader现象GPU 利用率只有 40%每个 epoch 却要跑一个多小时。NVIDIA 的进程 View 里 CUDA 有负载但 nvidia-smi 显示显存占用不高CPU 核心全满。原因如果你在 Dataset 的__getitem__里实时调用cv2.calcOpticalFlowFarneback每取一个样本都要算一次稠密光流Farneback 是 CPU 密集计算多进程 DataLoader 的 worker 全部卡在光流计算上GPU 只能等着干活。这是微表情识别项目最典型的性能瓶颈。解决离线预处理把每个视频片段的光流图算好存成npy或jpg文件Dataset 只负责读盘和增强。或者启动时一次性读入内存缓存微表情数据集总量不大几百 MB 内存完全能扛住。如果新增了样本想快速验证也可以用 GPU 版本的光流模型比如 RAFT 代替 Farneback但这会引入额外依赖作业阶段不划算。4.5 一调大 batch size 就 OOM注意力中间变量比想象中多吃显存现象看图说话 batch size 从 64 调到 128 直接 CUDA out of memory微表情识别那边 batch size 32 都跑不动。原因Decoder 每个时间步都要保留所有 batch 样本的注意力权重矩阵、上下文向量和隐状态序列长度为 30 时中间变量就翻 30 倍。微表情识别如果输入是多帧堆叠或光流序列显存消耗同样随序列长度线性增长。解决首选梯度累积用accumulation_steps2模拟 batch size 翻倍。其次把关卡设在输入尺寸上看图说话图片从 224 降到 160微表情从 224 降到 128特征图和注意力矩阵的显存占用会按平方下降。最后试试半精度训练在 PyTorch 里加torch.cuda.amp.autocast()和GradScaler大多数机器显存能省接近一半。注意半精度下 LSTM 的隐状态可能有精度问题如果 loss 出现 NaN回退到 FP32。5. 训练配置与评估口径给这个 zip 一份能交代的指标5.1 看图说话学习率、beam search 和损失函数怎么配看图说话的训练配置其实很固定照着下面这组参数先跑再根据曲线微调。图片尺寸 224x224batch size 64 到 128Encoder 冻结前 5 层Decoder 训练学习率 2e-4Encoder 微调学习率 5e-5weight decay 1e-4warmup 占总训练步数的 5%。参数推荐值说明图片尺寸224x224特征图 7x749 个区域Decoder 隐层维度512256 太小表达不足1024 容易过拟合词嵌入维度256与 Encoder 投影维度一致beam width35 可能更流畅但解码慢作业选 3最大生成长度30覆盖绝大多数描述BLEU 报告口径BLEU-1/2/4必报 BLEU-4BLEU-1 看选词训练轮数30~50看验证 BLEU 是否饱和损失函数用带padmask 的交叉熵。实现上我一般把 Decoder 输出的(batch, seq_len, vocab_size)和真实词(batch, seq_len)直接传给nn.CrossEntropyLoss(ignore_indexvocab[pad])PyTorch 会自动忽略 padding 位置的 loss。注意真实词序列要整体左移一位也就是输入[sos, w1, w2]输出目标是[w1, w2, eos]这一步对齐错位会让 loss 永久不降。5.2 微表情识别光流参数与类不平衡的处理微表情识别的最小配置是人脸对齐后裁剪到 224x224计算 onset 到 apex 的光流图输入一个 2D CNNResNet18 或更小的自定义 CNN输出五类或七类情绪。训练轮数不用多20 轮以内就能收敛因为光流图信息密度低模型容量太大反而过拟合。参数推荐值说明光流算法Farnebackwinsize15~21iterations3光流输入通道水平、垂直、幅值三通道拼成伪 RGB人脸区域只保留眼部以下微表情主要集中在嘴部和脸颊CNN 结构ResNet18 去掉最后两层参数量小拟合小数据损失函数CrossEntropy LabelSmoothing平滑系数 0.1缓解过拟合评估指标宏平均 F1 每类召回率只看 acc 会被多数类掩盖类别不平衡是这个任务的核心矛盾。如果数据集的“惊讶”类占了 40%“恐惧”类只有 5%模型会学到“全都猜惊讶”也能达到 40% 准确率。我建议在采样器里给每个类别设置采样权重少数类权重高多数类权重低让每个 epoch 里每类样本出现的次数接近。同时报告宏平均 F1它会平等对待所有类别比 accuracy 诚实得多。训练结束前用验证集画混淆矩阵重点看少数类是不是被识别成相邻情绪比如“恐惧”被识别成“惊讶”在微表情里很常见因为两者都有眼睛睁大的运动特征。5.3 断点续训与模型保存别让 GPU 白跑一夜大作业训练动辄两三个小时一旦中途掉线全部重来心态直接崩。PyTorch 的 checkpoint 保存和恢复并不复杂但要保存完整状态不只是模型权重。import torch def save_checkpoint(model, optimizer, scheduler, epoch, best_bleu, path): 保存完整训练状态支持断点续训 torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), best_bleu: best_bleu, vocab: vocab, # 词表必须和模型一起保存 }, path) def load_checkpoint(model, optimizer, scheduler, path): 恢复训练状态并返回 epoch 和 best_bleu ckpt torch.load(path, map_locationcuda) model.load_state_dict(ckpt[model_state_dict]) optimizer.load_state_dict(ckpt[optimizer_state_dict]) scheduler.load_state_dict(ckpt[scheduler_state_dict]) start_epoch ckpt[epoch] 1 best_bleu ckpt[best_bleu] return start_epoch, best_bleu这里有两个细节值得注意。第一vocab要随 checkpoint 保存推理时如果没有词表模型预测的索引无法映射回单词。第二load checkpoint 后要确认scheduler恢复到正确的步数否则学习率会重新从初始值开始导致 loss 在恢复训练后突然上扬。每隔 5 个 epoch 覆盖保存一次best_bleu对应的模型比每个 epoch 都存更省磁盘也不会丢失最优结果。评估时还要分清楚哪个模型是“最终上交版”。我一般保存两个文件一个是训练过程和状态的完整 checkpoint用于继续训练另一个是只含state_dict的推理模型用于测试和演示。后者文件小加载快也不会带上 optimizer 状态导致环境不兼容。6. 交付与验证这个 zip 怎么整理答辩时才能让人挑不出毛病拿到你的 zip 准备交付前先做一件事把整个项目在干净环境下从零跑一遍推理。具体操作是激活全新的虚拟环境只装requirements.txt里的依赖然后运行python demo.py --image demo.jpg看它能不能输出一句描述再运行python demo.py --video demo.mp4看它能否正确输出情绪标签。如果这一步要手动改三处路径才能跑通说明 README 写得不够完整而一个打不开的项目指标再高也拿不到高分。实验设计上建议保留至少三组对比看图说话对比有无注意力机制的效果差异微表情识别对比单帧表情分类和光流法分类的准确率差异再对比不同光流窗口参数对结果的影响。这些对比不需要多复杂的实验设置关键是能证明你理解每个组件在系统中的作用而不是只会调别人写好的代码。尤其微表情识别部分如果能说明“为什么静态分类只有 40% 准确率、光流法能到 70%”这就是答辩现场最好的展示素材。上面提到答辩这里多给一个技巧在 demo 脚本里故意留一个“坏样本”按钮挑一张光线特别差或表情极不明显的测试图当场演示模型会失败。比起只展示成功案例讲出失败原因和分析思路反而更能说明你踩过坑、理解边界。最后一章收个惯例提醒把训练好的权重、checkpoint、光流缓存这些大文件单独放一个weights/目录并在 README 里注明下载方式和存放路径zip 包本身只保留源码和必要配置。这个习惯我每次交付项目都用省去了大量“跑不起来”的沟通成本。希望帮到你。本文还有配套的精品资源点击获取