☰
人工智能导论大作业实战:看图说话+微表情识别全流程解析
2026/10/1 3:45:20 网站建设 项目流程

简介:这是一份面向人工智能导论课程实验的配套资源包,主要实现“看图说话”与“微表情识别”两个任务:前者利用深度学习模型为图片生成文字描述,后者在人脸检测基础上进一步判断情绪。资源包含TensorFlow/Keras编写的Python源码与完整的课程论文报告,适合人工智能初学者、高校学生完成课程设计或复现基础视觉模型时参考。压缩包共9个文件,其中包括3个Jupyter Notebook(覆盖数据预处理、模型构建与训练评估)、2个Markdown说明文档、1个Word课程论文报告、1个用于人脸检测的haarcascade XML分类器及License文件等,整体仅5.78MB,结构清晰易查找。目前已有338人学习下载,可作为相关实验的路线参考。读者可对照代码与报告快速理解图像描述和人脸表情识别的基本流程,也能直接复用人脸检测XML与模型代码,节省调试和撰写报告的时间。

1. 人工智能导论大作业:为什么“看图说话+微表情识别”是性价比最高的选题

如果你正在上《人工智能导论》这门课,期末大作业通常有两种做法:交一篇3000字的综述,或者交一个能跑通的demo。前者稳妥但不出彩,后者一旦通了,答辩时能直接现场演示,老师基本不会追问原理。我今天要拆的这个选题——看图说话(Image Captioning)配微表情识别(Micro-Expression Recognition)——正是很多同学选过的“人工智能大作业”经典组合,一份代码里同时覆盖了CNN、RNN、注意力机制、多分类、时序特征五个考点,写完就是一份拿得出手的“人工智能项目实战”案例。

这个方向的核心价值在于:它不需要多模态大模型,也不需要几十G的预训练权重。用一张消费级显卡甚至CPU都能把训练流程跑完,模型原理也完全落在导论课本范围内。看图说话解决的是“给定一张图,输出一句自然语言描述”;微表情识别解决的是“给定一段人脸视频或一张人脸图,判断表情类别”。两件事一个生成一个判别,正好凑成一份完整的技术闭环。适合的人群很明确:《人工智能导论》课程大作业、人工智能相关专业毕业设计前期预研、想用“人工智能学习路径”里最经典的视觉任务练手的新手工程师。

2. 看图说话:用预训练CNN榨干图像特征,再交给LSTM生成句子

2.1 为什么选VGG16+LSTM,而不是直接上Transformer

看图说话主流的实现框架是编码器-解码器架构。编码器负责把图像像素压缩成一个固定维度的向量,解码器负责把这个向量展开成句子。在导论课大作业这个场景下,编码器我一般选VGG16,而不是ResNet或EfficientNet。原因很简单:VGG16的最后一个全连接层输出4096维,这个维度跟LSTM的输入维度对齐时,参数换算非常直观;ResNet的2048维也很好用,但ResNet有残差连接和BatchNorm,调参时多出好几个变量,对第一次做这个任务的人来说不够友好。

解码器用LSTM,不用GRU,也不是因为LSTM一定更好,而是因为大部分教材和公开课里都拿LSTM当例子,答辩时老师问起来你能说得清“遗忘门、输入门、输出门分别干什么”。Transformer在长文本生成上更强,但导论课的机器跑不起大规模预训练,而且Transformer的收敛速度对数据和超参更敏感,大作业没必要冒这个险。

这里还有一个关键细节:VGG16用哪一层做特征输出。我见过有人直接取fc2层(最后一个全连接层)的输出,也有人取pool5层。区别在于fc2层已经做了非线性变换,特征更“抽象”,但也丢失了空间结构;pool5层保留了7×7的空间布局,后续想加注意力机制时还有余地。我的做法是取pool5层输出,展平后过一个自己加的全连接层,把维度压到embedding_size(256或512)。这样后面接注意力模块不用改骨架。

2.2 数据集准备:用Flickr8k跑通流程,别一上来就啃COCO

看图说话最常用的数据集是Flickr8k、Flickr30k和MS COCO。我强烈建议导论大作业用Flickr8k,它有8000张图片,每张配5句人工描述。FLickr30k是3万张,COCO是12万张,单是下载和预处理就够折腾半天,训练一轮的时间更是成倍上涨。Flickr8k的缺点是词汇量不大,生成出来的句子会有点套路化,但这恰恰说明模型训练充分,答辩演示效果反而好。

数据准备流程分三步:下载图片、解析每张图的5句描述、建立词表。词表构建时要把所有句子转成小写,按空格分词,滤掉标点,然后统计词频。一般取词频大于等于2的词进词表,其余全部映射到UNK。Flickr8k的词表大概在2000到3000个词之间。

数据划分按官方给的建议:6000张训练,1000张验证,1000张测试。如果你需要更细的划分代码,常见做法是直接读官方的txt划分文件,而不是自己随机切,这样后续跟别人论文里的指标对比时,数据口径一致。

2.3 最小可跑通的训练代码:Dataset、collate_fn和训练循环

这里给出一个精简到能跑通、又保留了核心细节的PyTorch实现。模型部分用VGG16做编码器,LSTM做解码器。

import torch import torch.nn as nn import torchvision.models as models from torch.utils.data import Dataset, DataLoader from PIL import Image import numpy as np class CaptionDataset(Dataset): def __init__(self, img_dir, captions, word2idx, transform=None): self.img_dir = img_dir # 图片根目录 self.captions = captions # dict: {image_id: [句子1, 句子2, ...]} self.word2idx = word2idx # 词到索引的映射表 self.transform = transform self.image_ids = list(captions.keys()) def __len__(self): return len(self.image_ids) def __getitem__(self, idx): img_id = self.image_ids[idx] # 随机选一句描述,避免模型死记某一句 cap = np.random.choice(self.captions[img_id]) # 句子转索引序列,开头加<start>,结尾加<end> tokens = [self.word2idx['<start>']] tokens += [self.word2idx.get(w, self.word2idx['<unk>']) for w in cap.lower().split()] tokens.append(self.word2idx['<end>']) # 加载图片并做统一缩放 img = Image.open(f"{self.img_dir}/{img_id}.jpg").convert('RGB') if self.transform: img = self.transform(img) return img, torch.tensor(tokens, dtype=torch.long) def collate_fn(batch): # 按句子长度降序排序,方便后面pack_padded_sequence imgs, caps = zip(*batch) cap_lens = [len(c) for c in caps] sorted_idx = sorted(range(len(cap_lens)), key=lambda i: cap_lens[i], reverse=True) imgs = torch.stack([imgs[i] for i in sorted_idx]) caps = [caps[i] for i in sorted_idx] cap_lens = [cap_lens[i] for i in sorted_idx] # 补齐到batch内最长句子的长度 targets = torch.zeros(len(caps), max(cap_lens), dtype=torch.long) for i, c in enumerate(caps): targets[i, :len(c)] = c return imgs, targets, cap_lens

代码里有三个关键决策点。第一,每张图每次训练随机选一句描述而不是固定取第一句,等于把数据量乘以5,能明显缓解过拟合。第二,collate_fn里做排序是为了后用pack_padded_sequence,它能跳过PAD位置的计算,既省显存又避免模型去预测无效的PAD。第三,词表里必须留出<start>、<end>、<unk>三个特殊token,否则解码时无法判断句子何时结束,遇到词表外单词会直接崩溃。

编码器和训练循环的核心代码更短:

class EncoderCNN(nn.Module): def __init__(self, embed_size): super().__init__() vgg = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) # 取到pool5层,输出7x7x512的特征图 self.features = vgg.features # 自定义一个映射层,把7x7x512压成embed_size维 self.linear = nn.Linear(7*7*512, embed_size) # 冻结前13层卷积,只训练最后几层和linear层 for i, p in enumerate(self.features.parameters()): p.requires_grad = i >= 20 def forward(self, images): feat = self.features(images) # [B,512,7,7] feat = feat.view(feat.size(0), -1) # [B,512*7*7] feat = torch.relu(self.linear(feat)) # [B, embed_size] return feat class DecoderLSTM(nn.Module): def __init__(self, embed_size, hidden_size, vocab_size): super().__init__() self.embed = nn.Embedding(vocab_size, embed_size) self.lstm = nn.LSTM(embed_size, hidden_size, batch_first=True) self.fc = nn.Linear(hidden_size, vocab_size) def forward(self, features, captions, lengths): cap_embed = self.embed(captions) # [B, L, embed] # 把图片特征拼到句子的起始位置 cap_embed = torch.cat((features.unsqueeze(1), cap_embed), dim=1) cap_embed = nn.utils.rnn.pack_padded_sequence( cap_embed, lengths, batch_first=True, enforce_sorted=True) out, _ = self.lstm(cap_embed) out, _ = nn.utils.rnn.pad_packed_sequence( out, batch_first=True) return self.fc(out)

这段代码里的参数需要解释清楚。embed_size是图片特征和词嵌入的统一维度,我设256,hidden_size设512,这两个值在Flickr8k上是我试出来训练速度和生成质量最平衡的组合。embed_size小于200会生成大量语法不通的句子,大于512则训练时间涨一截但BLEU并没有显著提升。冻结前20层卷积参数是有讲究的——VGG16前面几层学的是边缘、纹理这类通用特征,微调它们既费时间又容易破坏预训练权重,只需要训练后面的高层语义特征和最后新增的linear层就够了。enforce_sorted=True必须配合collate_fn里的排序操作,如果排序逻辑改了,这里要改成False,否则会直接报错。

训练循环里的关键超参:batch_size设64,学习率设3e-4,优化器用Adam,训练25到30个epoch。loss用交叉熵,但记得把targets里的PAD位置ignore掉,PyTorch里直接传ignore_index=0就行。每两个epoch在验证集上跑一次贪心解码,肉眼看一下生成的句子是否通顺,比只看loss值直观得多。

2.4 推理阶段:从贪心解码换成Beam Search,生成质量立刻上一个台阶

训练完成后,推理时的默认做法是贪心解码——每步取当前概率最大的词作为下一个词。但贪心的问题在于它只看局部最优,容易生成“the the the”这种重复文本。一个实现成本极低、效果立竿见影的改进是Beam Search。它每步维护K条候选序列(K一般取3到5),最终选择整体概率最高的那一条。

Beam Search的代码核心并不长,在已有训练好的模型上直接替换推理函数即可:

def beam_search_decode(model, image_feature, word2idx, idx2word, beam_width=3, max_len=20): # 候选序列用 (概率对数, [token列表]) 表示 start_token = word2idx['<start>'] end_token = word2idx['<end>'] seqs = [(0.0, [start_token], model.init_hidden())] for _ in range(max_len): all_candidates = [] for score, tokens, hidden in seqs: if tokens[-1] == end_token: all_candidates.append((score, tokens, hidden)) continue lstm_out = model.decode_step(image_feature, tokens[-1], hidden) log_probs = torch.log_softmax(lstm_out, dim=-1) top_k = torch.topk(log_probs, beam_width) for i in range(beam_width): new_token = top_k.indices[i].item() new_score = score + top_k.values[i].item() new_tokens = tokens + [new_token] new_hidden = (lstm_out, hidden) # 简写,实际传LSTM状态 all_candidates.append((new_score, new_tokens, new_hidden)) # 只保留全局得分最高的beam_width条 seqs = sorted(all_candidates, key=lambda x: x[0], reverse=True)[:beam_width] if all(s[-1] == end_token for s in seqs[0][1]): break best_seq = seqs[0][1] return [idx2word[t] for t in best_seq if t not in (start_token, end_token)]

这段代码里的得分用的是对数概率累加,原因是概率连乘会数值下溢,对数化之后变成加法,数值稳定且代码更简洁。topk取beam_width个候选是每步扩展一次的常见做法。真正的坑在hidden状态的处理——LSTM的hidden和cell是两个张量,推理时每个候选分支的hidden必须独立维护,如果所有候选共享同一个hidden,生成的K条句子会逐渐趋同,最终退化成贪心解码。我见过好几次有人在这上面翻车,代码看起来没问题,但Beam Search的BLEU反而比贪心还低,就是这个原因。

K值的选择也有讲究。K=1就是贪心解码,K=2效果提升最明显,K=3到5提升趋缓且耗时增加。Flickr8k这种小词表场景,K=3是性价比最高的设置。在答辩演示时,可以用K=1和K=3各跑一张图,对比两段描述,这个差异本身就是很好的展示素材。

3. 微表情识别:两手方案,一套保底,一套加分

3.1 微表情和普通表情的差别,决定了你不能直接套用表情识别模型

微表情识别是表情识别的一个特殊分支。普通表情持续时间在500毫秒到4秒,幅度明显,肉眼容易分辨;微表情持续时间只有1/25到1/5秒,强度低,往往是压抑真实情绪时露出的马脚。这给模型带来了两个直接的麻烦:帧间差异太小,特征不明显;公开数据集太少且标注成本极高。

CASME II是目前微表情识别最常用的数据集,但它的样本量只有不到300个视频片段,而且下载需要申请。如果是课程大作业,不一定能及时拿到审批。更现实的做法是“两步走”:用CK+这个静态表情数据集来跑通一个CNN分类模型作为保底方案,再在这个基础上做微表情时序增强——比如把视频帧序列输入LSTM,或者对帧间差分图做处理——把这个作为“微表情”的加分项。

先说明白:CK+是刻意表演出来的表情,和真正的微表情有本质差别。但课程大作业的评分标准看的是“技术方案是否完整”,而不是“研究结论是否有突破”。你用CK+证明了方案可行,再用一个微型自采数据集(比如录自己几段面部视频,截取片段)展示模型能对上微表情的时间特性,这在答辩里已经是一个完整的故事线了。

3.2 人脸检测与对齐:这个步骤决定了你的准确率上限

微表情识别的第一个坑不是模型,而是人脸预处理。原图上的人脸位置、角度、光照各不相同,直接输给CNN会引入大量与表情无关的噪声。我通常在dlib和OpenCV DNN人脸检测器之间选OpenCV DNN,因为dlib的HOG检测器在侧脸和大角度旋转时鲁棒性差,而OpenCV DNN模型可以拿到边界框置信度,方便过滤误检。

人脸对齐的经典做法是用OpenCV内置的Eyes Cascade detector检测双眼中心,然后算一个仿射变换矩阵,把眼睛旋转到水平位置,再统一裁剪到224×224。对微表情任务来说,对齐比检测更关键——因为表情的肌肉运动幅度小,如果眼睛位置偏移几个像素,CNN看到的可能是眉毛和眼睑的差异,而不是表情本身的差异。

import cv2 import numpy as np def align_face(image, detector_path, eye_cascade_path): # 加载模型 detector = cv2.FaceDetectorYN_create( detector_path, "", (224, 224), score_threshold=0.7) face, detections = detector.detect(image) if detections is None: return None # 取置信度最高的人脸框 x, y, w, h = detections[0][:4].astype(int) face_img = image[y:y+h, x:x+w] gray = cv2.cvtColor(face_img, cv2.COLOR_BGR2GRAY) eye_cascade = cv2.CascadeClassifier(eye_cascade_path) eyes = eye_cascade.detectMultiScale(gray, 1.1, 5) if len(eyes) < 2: return cv2.resize(face_img, (224, 224)) # 按x坐标排序,取出左右眼中心 eyes = sorted(eyes, key=lambda e: e[0]) left_center = (eyes[0][0] + eyes[0][2]//2, eyes[0][1] + eyes[0][3]//2) right_center = (eyes[1][0] + eyes[1][2]//2, eyes[1][1] + eyes[1][3]//2) # 计算旋转角度并做仿射变换 angle = np.degrees(np.arctan2( right_center[1] - left_center[1], right_center[0] - left_center[0])) M = cv2.getRotationMatrix2D(left_center, angle, 1.0) aligned = cv2.warpAffine(face_img, M, (face_img.shape[1], face_img.shape[0])) return cv2.resize(aligned, (224, 224))

这段代码的行为逻辑是:先用OpenCV的深度学习人脸检测器定位人脸,再检测双眼位置,计算两眼连线与水平线的夹角,旋转人脸使得眼睛对齐到水平。这是一种最朴素的人脸对齐,但足够解决大多数大作业场景的问题。score_threshold设0.7可以过滤掉大量背景误检,如果发现漏检严重再调低到0.5。如果眼睛检测失败(闭眼、侧脸等情况),代码会退化成直接缩放原人脸框,这个fallback逻辑保证了流程不中断。

3.3 模型实现:用ResNet18迁移学习,改最后一层

表情分类的模型选型我一般用ResNet18。ResNet18在ImageNet上有预训练权重,迁移到表情这种小数据集上收敛快,同时模型参数只有1100万左右,CPU推理一张图也就100毫秒上下。对比VGG16,ResNet18的参数量只有它的四分之一,却因为残差连接在精度上不落下风,现场演示时不掉链子。

import torch.nn as nn import torchvision.models as models class MicroExpressionNet(nn.Module): def __init__(self, num_classes=7, freeze_until=3): super().__init__() self.backbone = models.resnet18( weights=models.ResNet18_Weights.IMAGENET1K_V1) # 冻结前3个stage,保留低层通用特征 layers = list(self.backbone.children()) for i, p in enumerate(self.backbone.parameters()): if i < freeze_until * 10: # 经验值:粗略按层数冻结 p.requires_grad = False # 替换最后一层全连接 in_features = self.backbone.fc.in_features self.backbone.fc = nn.Sequential( nn.Dropout(0.5), nn.Linear(in_features, num_classes) ) def forward(self, x): return self.backbone(x)

这里的关键参数有两个。freeze_until决定从第几个stage开始解冻,我一般设3,即只训练最后一个stage和新增的fc层。数据集更小的时候(比如只有几百张)设5,让前面全冻住,只训fc层。Dropout(0.5)放在fc层之前,对表情这种数据量小的任务很关键——我在CK+上做过对比,加了Dropout的验证集准确率能高5到8个百分点。损失函数用交叉熵,优化器还是Adam,学习率从1e-4起步,每10个epoch衰减0.1倍。

训练时数据增强至少要加随机水平翻转、随机旋转±10度、随机亮度对比度扰动。表情识别里还有一个特殊的增强手段——随机擦除,随机遮挡人脸的一个小方块,强迫模型学会利用局部特征而不是只盯着一块区域。

如果你想让方案更接近“微表情”而不是“表情”,有一个轻量级改进:对视频片段做帧间差分,把差分图(后一帧减前一帧的绝对值)和原始帧拼接成双通道输入。这样模型能够捕捉到肌肉运动的幅度信息,而微表情的动静恰恰是“幅度小但方向明确”。代价是训练数据需要按视频组织,CK+正好有一小段视频,所以这条路是可复现的。

3.4 评估指标别看准确率,看混淆矩阵

表情识别有一个典型陷阱:CK+数据集的类别分布很不均衡,开心(Happy)类样本多、厌恶(Disgust)类样本少。如果只盯着总准确率,模型会倾向于把所有样本都预测成数量多的类别,得到看似不错的准确率,但实际没有学会识别少数类。

我一般训练结束后必须跑三件事:绘制混淆矩阵、计算每类召回率、打印加权F1。混淆矩阵能直观看到哪些类别容易互相混淆——在CK+上,恐惧(Fear)和惊讶(Surprise)的混淆率极高,生化上有解释,这两个表情的眼部和嘴部运动模式确实接近。

如果少数类别效果实在太差,优先用类别权重而不是简单的过采样。PyTorch里给CrossEntropyLoss传一个weight参数即可,数值设为1/类别样本数。这比复制少数类样本的做法更稳,因为过采样容易让模型对少数类的具体某几张图过拟合。

4. 把两个任务塞进一个zip:导论大作业的代码组织、报告结构和答辩演示

4.1 一个能直接解压运行的目录结构长什么样

大作业最后是要提交一个zip包的,评审老师在解压后第一眼看到的就是目录结构。如果里面全是没头没尾的.ipynb和一团乱麻的final_v2_最终版.py,还没跑代码印象分就掉了一半。我一般会用下面的结构组织:

ai_intro_project/ ├── README.md # 怎么装依赖、怎么跑、结果长什么样 ├── requirements.txt # torch, torchvision, opencv-python 等 ├── code/ │ ├── caption/ │ │ ├── dataset.py # 看图说话的数据集类 │ │ ├── model.py # 编码器和解码器 │ │ ├── train.py # 训练入口 │ │ └── inference.py # Beam Search推理 │ └── expression/ │ ├── preprocess.py # 人脸检测+对齐 │ ├── train.py # 微表情分类训练 │ └── evaluate.py # 混淆矩阵和F1 ├── data/ │ ├── flickr8k/ # 图片和标注(小样本演示版) │ └── ckplus/ # CK+裁剪后的人脸图 ├── models/ # 训练好的权重文件说明 └── report/ ├── 实验报告.md └── 答辩演示PPT大纲.md

README是很多人会忽略但我觉得最应该认真写的地方。不需要写长作文,就写三件事:用什么Python版本和显卡环境、每条命令按什么顺序执行、预期输出是什么。比如python code/caption/train.py --epochs 30跑完会生成models/caption_30epochs.pth,再跑inference.py会输出测试集BLEU分数。有了这个,评审老师复现起来顺畅,自己也免得答辩前忘记怎么跑。

data/目录我只建议放一个子集,比如Flickr8k里挑20张图做冒烟测试,完整数据集压缩包太大,上传到课程平台常被限制。README里写清楚完整数据集的下载方式和放到哪个路径即可。

4.2 把训练流程串起来:一个脚本搞定全部

课程作业经常给你一台没有图形界面的服务器,所有操作都要在命令行完成。我会在code/下放一个run_all.sh,把全流程串起来:

#!/bin/bash set -e # 任何一步失败就终止,避免带错往下跑 # 第1步:验证数据集目录是否存在 if [ ! -d "data/flickr8k" ]; then echo "ERROR: 请先下载Flickr8k数据集到data/flickr8k" exit 1 fi # 第2步:训练看图说话模型(30个epoch,约2小时) python code/caption/train.py --epochs 30 --batch_size 64 --lr 3e-4 # 第3步:用测试集评估BLEU python code/caption/inference.py --split test --beam_width 3 # 第4步:训练微表情分类模型 python code/expression/train.py --lr 1e-4 --freeze_until 3 # 第5步:生成混淆矩阵图 python code/expression/evaluate.py --output report/confusion_matrix.png

set -e这个细节很多人会忽略,但非常重要。没有它的话,第2步训练中断了,脚本还会继续跑第3步,然后因为找不到权重文件抛出另一个莫名其妙的错误。加上set -e后,任何一步失败都会立即终止,日志里能直接看到哪一步出了问题。这在答辩现场调试时能救命。

训练过程要留日志。最简单的做法是用Python的logging模块打点,记录每个epoch的loss、BLEU或准确率。答辩时老师问“训练到第几个epoch开始收敛”,你直接翻日志就能答上来,这比“我记得大概10几个epoch吧”这种回答专业得多。

4.3 答辩时最能加分的对比实验表和演示顺序

答辩PPT里放一张实验对比表,比放十张网络结构图都实在。我建议至少做三行对比:

  1. 看图说话:贪心解码 vs Beam Search(K=3)的BLEU-1/BLEU-4对比
  2. 微表情识别:只训练fc层 vs 解冻最后一整个stage的准确率对比
  3. 微表情识别:不加数据增强 vs 加数据增强的F1对比

这三组对比不需要额外做太多实验,训练过程中顺手保存一个中间模型就能拿到数据。但它们合在一起能讲出一个完整的故事:我在哪些环节做了探索、每个改进带来了多少提升、为什么是这些改进有效。这恰恰是《人工智能导论》这门课想考察的工程素养。

现场演示的顺序也值得设计。先演示看图说话,因为效果最直观——上传一张没见过的图片,等一两秒,屏幕打出一句描述。然后切到微表情识别,拿手机拍一段自己的视频,实时检测并标注表情。如果条件允许,两个模型可以在同一个窗口中并排显示,一个处理静态图,一个处理视频流,这个视觉冲击力在答辩现场是很加分的。

5. 避坑指南:看图说话和微表情识别最常翻车的5个现场

5.1 现象:看图说话的BLEU跑出来只有0.05,几乎是随机水平

原因:绝大多数是数据预处理出了问题。最常见的是把分词的split()用成了空格分词,但原数据集里的句子带英文逗号、句号,比如a dog is running , on the grass,逗号被split()切出来后单独成词,词表里会出现大量逗号、句号这种“伪词”。这些伪词既浪费词表容量,又会让LSTM学着生成带奇怪空格的句子。

解决:分词前统一处理标点。我一般先做re.sub(r'[^\w\s]', '', text)把标点全部删掉,再做lower().split()。还有一个隐蔽的问题:句子里如果带数字,比如2 dogs,2会单独成词,但测试集里出现2的频率很低。更稳的做法是把所有数字替换成<num>特殊token。这两个处理做完,BLEU一般能翻一倍。

5.2 现象:微表情模型训练集准确率95%,验证集只有30%,过拟合非常严重

原因:数据集太小。CK+一共只有593个序列、约980张标注表情图,而ResNet18的最后一层有上万个参数,完全能把训练集背下来。很多人直接拿预训练模型在冻结层之外全量微调,等于用上万参数去拟合一千个样本,不炸才怪。

解决:三个手段组合。一是更激进的冻结策略,把freeze_until调到5,只训练fc层;二是加更强的数据增强,随机擦除和光度扰动都用上;三是训练时间提前停止,用验证集准确率做早停,别闷头训满50个epoch。我在CK+上实测下来,最有用的是第一个手段——只训fc层时验证集准确率反而比全量微调高10个百分点以上。

5.3 现象:LSTM训练过程中loss死活不降,一直是4点几

原因:学习率过大。LSTM对学习率比CNN敏感得多,Adam在3e-3以上时梯度震荡剧烈,loss会横跳;另一个常见错误是词嵌入维度设太小,比如embed_size=128时词向量信息容量不足,也会导致loss下不去。

解决:把学习率降到3e-4到1e-4区间,embed_size提到256。同时可以给LSTM加梯度裁剪,PyTorch里一行代码:torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5)。梯度裁剪不会直接提升精度,但能避免训练中途因梯度爆炸被NaN打断——那种情况只能整个模型重训,是真正的血泪教训。

5.4 现象:人脸检测框在视频里跳来跳去,导致识别结果抖动

原因:逐帧独立检测,帧间的检测框没有时序关联。人脸轻微移动几像素,检测框就会被重新回归,导致裁剪出来的人脸区域内容跳变。

解决:给检测框做一个滑动平均。维护一个全局变量,记录最近N帧的检测框坐标,每帧的检测结果跟历史值加权平均。更优雅的做法是使用cv2.TrackerKCF——第一帧检测到人脸后交给跟踪器,跟踪器丢失目标时再重新检测。在演示场景里,这个改进能让识别结果看起来“稳”很多,虽然对单帧精度没有本质影响。

5.5 现象:模型训练完,推理时输出全部是<unk>或者空句子

原因:词表里<unk>被赋予的索引是0,而在LSTM里索引0是合法的,模型学到用UNK乱填充。更麻烦的是,训练时PAD用的也是0的话,模型会学着把UNK和PAD混在一起。空句子则是Beam Search的终止条件写错了——break条件没有检查候选是否包含<end>。

解决:词表构建时固定三个特殊token的索引:<pad>=0, <start>=1, <end>=2, <unk>=3。训练时交叉熵的ignore_index设<pad>的索引0;推理时遇到<end>立即停止该候选序列的扩展。这两处统一后,UNK乱填充的问题基本消失。

6. 进阶玩法:从“跑通”到“能写进简历”的验证与可视化

这套大作业如果只做到能跑,答辩分数已经有了,但如果你想把它沉淀成作品集里的一条项目经历,还需要补齐三个细节:指标计算的严谨性、可视化解释性、以及一套可复现的实验记录。

第一,BLEU的计算不能用训练集。很多人为了省事直接在测试集上跑推理,但大作业里很容易出现训练集和测试集没切干净的情况,导致BLEU虚高。严谨的做法是单独写一个评估脚本,只读测试集目录,用PyTorch的torch.no_grad()跑Beam Search。BLEU的计算建议用nltk.translate.bleu_score,注意corpus_bleu和sentence_bleu的差别——前者对整批句子求总和,后者逐句算再平均,两者的数值能差出0.05以上。报告里注明用的是哪一种衡量方式,否则后续复现的人会产生困惑。

第二,给CNN做可视化。微表情模型训练完了,用GradCAM生成注意力热力图,叠在人脸图上——你会看到模型在关注眼睛、眉毛和嘴角区域,这跟心理学里微表情的AU定义是吻合的。答辩时放一张热力图对比原图,比说十句“我的模型学到了表情特征”都有说服力。PyTorch里用已有的库或手写反向传播钩子都能实现,整个代码量不超过30行。这个可视化还有个额外用处:如果热力图集中在人脸背景上,说明模型学到的是背景区分度而不是表情特征,可以据此回头修数据。

第三,给自己留一套实验记录模板。每跑完一组实验,把时间、超参、loss曲线、最终指标记在一个Markdown文件里。这套大作业的调参空间不大,但记录本身是让你从“调参玄学”走向“工程方法”的第一步。我自己的习惯是每改一个变量只跑一次完整实验,不并行开多个实验,因为GPU内存不够,两三个实验挤在一起反而全都跑不快。

微表情识别方面,如果你的余力允许,可以试一下把静态CNN的特征换成AU编码——用OpenFace提取脸部动作单元强度序列,再送入LSTM分类。这样“微表情”的“微”字就真正落到技术里了:因为它捕捉的是肌肉运动单元的微小动态变化,而不是静态表情模式。这条路需要多写一个数据转换脚本,但工程上它和前面所有代码完全兼容,只替换了输入特征。

最后想跟你说一个我自己的教训:早期做大作业时,我总把时间花在换更大更新的模型上面,觉得模型越高级分数越高。后来发现课程作业的评分核心是“方案完整度和结果可复现”,你用一个链路完整、有对比实验、能稳定复现的VGG16+LSTM方案,远好于一个训练了三天不出结果的Transformer。先把小方案跑通,再在有余力时做改进,这才是这门课最值得学会的节奏。希望这篇笔记能帮你少走一点弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询