简介:一套基于双流浅层网络的面部微表情识别实战源码包,面向计算机视觉研究者和深度学习开发者,适用于情感计算、人机交互、安全监控等场景。算法通过并行空间流与时间流网络,分别提取静态表情特征和动态变化信息,在保持低计算成本的同时实现有效识别。压缩包内共十个文件,核心为七个Python源码脚本,涵盖数据预处理、网络定义、训练与保存流程;另含一个pt权重文件、一个依赖说明文档和一个README目录说明,整包约1.22MB。目录结构清晰,train.py可直接启动训练,preprocess.py负责数据整理,network.py实现双流网络搭建,便于对照源码理解每个环节。目前已有139人学习下载,对希望快速上手微表情识别或复现双流浅层网络方法的开发者而言,是一份轻量实用的参考实现。
1. 微表情识别项目实战:双流浅层网络为什么是当前最稳的选择
微表情识别(Micro-expression Recognition)在人脸分析里是公认难啃的硬骨头:正常人脸表情持续 0.5 到 4 秒,而微表情通常在 1/4 秒内完成,肌肉位移只有几个像素,拍下来压缩后几乎就是噪声。更麻烦的是公开数据集很小,CASME II、SAMM、SMIC 全加起来不过几百段视频,把 ImageNet 上那套五六十层的网络直接搬过来只会过拟合,训练集涨到 95% 以上,验证集宏 F1 却长期在 0.5 上下晃荡。双流浅层网络的价值在于:一条流读静态纹理,一条流读帧间差分,两层到三层的卷积子网络就够用,参数少、不容易崩,也方便在项目里快速调通。这篇笔记面向刚要复现这类微表情识别项目源码的开发者和研究生,我按结构选型、数据准备、训练落地、踩坑排错到验证部署的顺序,把整条项目实战路径拆给你。
2. 微表情识别双流浅层网络结构拆解:空间纹理流与时间差分流的选型逻辑
微表情识别里“双流”这个说法很容易让人联想到行为识别里的经典双流网络,但微表情场景下的双流有完全不同的设计目标。这里第一件事是把两条流各自输入什么想清楚,再决定网络要多深、融合放在哪里。顺序反了,你会白白多调很多参数还看不到效果。
2.1 微表情的时空特性与深层网络不适配的根本原因
微表情可以拆成“空间纹理”和“时间变化”两部分信息。空间纹理是指某一帧里眉间皱纹、眼睛开合度、嘴角形状这些静态特征;时间变化是指这些特征在相邻帧之间出现的差异。两者信息量极不均衡,静态纹理很容易被身份特征干扰,时间变化又微弱到几乎淹没在传感器噪声里,因此设计模型时必须刻意强调时间信息,而不是让网络自己从头学。
把深层网络直接拿来当特征提取器是一个常见误用。深层结构的优势区间是类别数多、样本量大的任务,它的感受野逐层扩大,越往后越依赖全局上下文来补全信息。而微表情发生区域集中、位移幅度极小,深层卷积的平移不变性和池化操作会把这些细微位移逐渐抹平。我在本地用 ResNet18 跑过一次 CASME II,训练 loss 很快降下去,但留一被试交叉验证的 UAR 始终在 0.45 到 0.55 之间徘徊,和随机猜差不了多少。换成 3D CNN 也一样,训练集指标漂亮,验证时该混淆的还是混淆。
更大的问题是样本量撑不起深层网络的参数量。微表情领域最大的 CASME II 只有 247 段样本,SAMM 159 段,SMIC 164 段。即便把三段合并成一个大训练集,样本总数也只有千级以内,而 ResNet18 的参数量是 1100 万以上。参数越多,越容易把训练集里的身份信息、光照信息当成表情特征学进去。浅层网络则天然做了约束:卷积层数少、通道数少,模型能表达的假设空间被压缩,反而逼迫它去学真正稳定的区分特征。
2.2 空间纹理流与时间差分流的输入设计:怎么选帧和差分
空间流建议输入窗口内的中间帧或者 apex 帧,不建议取整个窗口的均值帧。均值操作会平滑掉眉毛和嘴角的微小纹理,微表情识别的关键特征恰恰就藏在这些局部纹理里。中间帧的好处是位置固定,不需要依赖标注质量;apex 帧是表情幅度最大的时刻,信息量最足,但 apex 标注本身存在人工不一致的问题,后面我会专门讲这个坑。
时间流建议输入差分图而不是光流图。光流固然能表达运动方向,但微表情位移只有几个像素,光流估计会把噪声放大成虚假运动,而且光流计算的开销在几百段样本的项目里很不划算。差分图实现简单,frame[i+1] - frame[i]就能得到每一对相邻帧的变化,再在时间方向上做聚合。聚合方式常用两种:最大值聚合和均值聚合。最大值聚合能捕捉到某个瞬间的峰值运动,适合微表情这种短暂爆发;均值聚合会把运动平滑掉,适合描述稳定的渐变过程。我在项目里默认用最大值聚合,效果比均值稳定。
两条流的输入通道保持一致,空间流和时间流在预处理阶段都转成单通道灰度图,统一 resize 到 64×64。这个分辨率对微表情足够,CASME II 原图人脸区域高也不过 100 像素左右,64×64 不会丢太多信息,又能明显加快训练速度。
2.3 常见微表情识别方案对比与双流浅层网络的位置
我把用过的主流方案放在一起对比,方便你判断什么场景该选哪条路:
| 方案 | 输入形式 | 参数量级 | 小样本鲁棒性 | 预处理成本 | 适合阶段 |
|---|---|---|---|---|---|
| LBP-TOP 手工特征 | 三个正交平面的纹理特征 | 无 | 中等 | 低 | 快速 baseline |
| 光流 + CNN | 光流图堆叠 | 百万级 | 差 | 高 | 不推荐单独使用 |
| 3D CNN | 连续帧序列 | 千万级 | 差 | 高 | 大数据量再考虑 |
| 双流浅层网络 | 中间帧 + 差分图 | 几十万级 | 好 | 低 | 最推荐 |
在实际项目中,我一般先用 LBP-TOP 或简单线性分类器跑一版结果,确认数据标注没有严重问题,再上双流浅层网络。双流浅层的参数量在三四十万这个量级,训练一轮只需要十几秒,一台不带 GPU 的机器也能跑完整个 LOSO 流程。
选型时可以直接做一个参数组合快速筛选,用小规模的验证集跑一遍,看哪组输入方式最稳。这个代码块可以直接嵌进你的训练脚本里:
# 输入组合快速筛选:只跑 4 组配置,各训练 10 个 epoch combos = [ ("center", "max"), # 空间流取中间帧,时间流差分最大值聚合 ("center", "mean"), # 空间流取中间帧,时间流差分均值聚合 ("apex", "max"), # 空间流取 apex 帧,时间流差分最大值聚合 ("mean", "max"), # 空间流取窗口均值,时间流差分最大值聚合 ] results = {} for spatial_mode, temporal_mode in combos: train_dl = make_loader(train_set, spatial_mode=spatial_mode, temporal_mode=temporal_mode) val_dl = make_loader(val_set, spatial_mode=spatial_mode, temporal_mode=temporal_mode) model = DualStreamMERNet(num_classes=len(classes)) # 训练 10 个 epoch,记录验证集准确率 acc = run_mini_experiment(model, train_dl, val_dl, epochs=10) results[f"{spatial_mode}_{temporal_mode}"] = acc这段代码里的spatial_mode和temporal_mode就是两个输入维度的策略开关。10 个 epoch 足够看出趋势,不用等到完整收敛。我在两个数据集上跑下来,“center + max”基本都是最优或者次优,而且稳定性最好,关键是不依赖 apex 标注质量,适合作为默认配置。
3. 微表情数据集准备:从标注格式到差分样本生成
数据准备决定了模型 60% 以上的上限。微表情数据集不像 ImageNet 那样下载完就能直接丢给网络,你需要处理标注字段、采样窗口和差分样本生成三个环节。任何一个环节出错,后面训练出来的模型都会带着系统性偏差。
3.1 数据集选型:CASME II / SAMM / SMIC 的标注字段与划分协议
CASME II 是目前用得最多的微表情数据集,分辨率低但标注相对规整,每段样本记录了 onset、apex、offset 三个时间点,还附带 Action Unit 编码。SAMM 样本量少但被试多样,跨入种泛化测试一般拿它当验证集。SMIC 只有情绪类别标签,没有 apex 标注,做双流浅层网络时可以用中间帧代替。项目源码里最常见的做法是用 CASME II 做主要实验,SAMM 做跨数据集泛化验证。
标注格式通常是 CSV 或 Excel,列名各家略有不同,但核心字段逃不出这几项:
| 字段 | 含义 |
|---|---|
| subject / subject_id | 被试编号,LOSO 划分的依据 |
| onset | 表情开始帧号 |
| apex | 表情幅度最大帧号 |
| offset | 表情结束帧号 |
| label / emotion | 类别标签,CASME II 常用 7 类或 5 类 |
| video | 对应视频文件名 |
拿到项目源码第一步要做的不是跑训练,而是用 pandas 打开标注文件,确认 subject 字段有没有被正确解析。我在实际项目里遇到过标注文件的 subject 列和视频文件名里的编号不一致的情况,导致 LOSO 划分时同一个人的样本被拆到训练和验证两边,最终指标虚高但完全不可信。
3.2 峰值帧对齐与采样窗口:固定 9 帧窗口的参数怎么定
微表情的完整时长从 onset 到 offset 一般在 50 到 200 帧之间,但不同样本长度差异很大。直接整段输入会让网络学到“长度”这个无关特征,而且 batch 内无法对齐。常见做法是固定窗口采样,窗口大小取 9 帧或 11 帧。9 帧的意思是取 apex 前 4 帧和 apex 后 4 帧,加上 apex 本身。如果 onset 到 offset 实在太短,不足 9 帧,就把这个样本丢弃或者用边缘复制补齐,我一般倾向丢弃,短样本里的运动信息不足,强扩出来反而增加噪声。
窗口大小的选择要匹配差分计算。时间流需要对窗口内相邻帧做差分,窗口太小差分次数太少,时间信息不够;窗口太大则会把动作前后无关的背景变化也包含进来。实测下来 9 帧是性价比最高的配置,差分图有 8 次差分的机会,空间流拿中间帧也基本落在 apex 附近。如果你的数据集本身帧率特别高,比如 200fps,可以把窗口放大到 15 帧,给差分更大的时间跨度。
预处理参数我通常固定为一组:灰度图、resize 到 64×64、像素归一化到 0 到 1。归一化必须放在差分之后还是之前?我建议先归一化再做差分。原因是原始像素值范围是 0 到 255,不同视频的曝光差异会让差分的绝对值偏离,先归一会把亮度差异压到 0 到 1 的区间内,差分结果的可比性更好。
3.3 用 Python 读取视频并生成双流输入样本
这一步把原始视频抽帧、裁剪、归一化,最终生成三个文件:空间流图像、时间流差分图、对应的标签和 subject 编号。缓存成 npz 文件可以避免每次训练都重新读视频。
pip install opencv-python numpy pandas torch依赖装好的前提下,下面的脚本可以直接跑:
import cv2 import numpy as np import pandas as pd import os def load_video_frames(video_path, size=64): """读取视频并转成灰度图序列,统一缩放到 size x size""" cap = cv2.VideoCapture(video_path) frames = [] while cap.isOpened(): ret, frame = cap.read() if not ret: break frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) frame = cv2.resize(frame, (size, size)) frames.append(frame) cap.release() return np.stack(frames, axis=0) # 返回 [T, H, W] 的 uint8 数组 def window_to_inputs(window): """把一个固定窗口转成双流输入。 window 形状为 [T, H, W],T 通常取 9。 返回 spatial 中间帧和 temporal 差分聚合图。 """ seq = window.astype(np.float32) / 255.0 spatial = seq[seq.shape[0] // 2] # 取窗口中间帧 diff = np.abs(np.diff(seq, axis=0)) # 逐帧差分,形状 [T-1, H, W] temporal = diff.max(axis=0) # 在时间维度上聚合 return spatial, temporal def build_samples(csv_path, video_root, window_size=9): meta = pd.read_csv(csv_path) samples = [] for _, row in meta.iterrows(): frames = load_video_frames(os.path.join(video_root, row["video"])) center = (row["onset"] + row["offset"]) // 2 half = window_size // 2 start = center - half if start < 0 or start + window_size > len(frames): continue # 序列太短直接跳过,避免采样越界 window = frames[start:start + window_size] spatial, temporal = window_to_inputs(window) samples.append({ "spatial": spatial, "temporal": temporal, "label": int(row["label"]), "subject": str(row["subject"]) }) return samples # 调用示例 samples = build_samples("casme2_annotation.csv", "CASME2/") np.savez_compressed("mer_samples.npz", samples=samples)这段代码有三处需要注意。第一,center = (onset + offset) // 2用中点而不是直接依赖 apex 标注,是为了规避不同标注者的 apex 偏差,后续做消融实验时可以再换成真正的 apex 比较。第二,diff.max(axis=0)在 T-1 个差分图上逐像素取最大值,能保留整个窗口里运动最剧烈的峰值,但如果你发现某个类别的动作是渐进式变化,可以把 max 改成 mean 再对比一轮。第三,subject被单独保留成字符串,是为了后面 LOSO 拆分的可靠性,切数据时绝对不能丢掉这个字段。
4. 用 PyTorch 构建双流浅层网络并跑通训练
结构和数据都就位后,训练环节其实就很机械了。双流浅层网络用 PyTorch 实现非常干净,核心代码加起来不到 100 行。这一章给出可以直接复制的模型定义、损失函数和 LOSO 训练循环。
4.1 模型定义:双流共享浅层卷积的代码实现
双流浅层网络的设计要点是“浅”和“双”。浅指的是每个分支只有两到三个卷积块,通道数从 32 到 64 递增;双指的是空间流和时间流各自独立卷积,最后在分类头之前融合。两个分支不共享权重,因为它们输入的数据性质完全不同,一个读静态纹理,一个读时间差分,共享反而会互相干扰。
import torch import torch.nn as nn class ShallowBlock(nn.Module): """浅层卷积块:3x3 卷积 + BN + ReLU + 可选池化""" def __init__(self, in_ch, out_ch, pool=True): super().__init__() self.conv = nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False) self.bn = nn.BatchNorm2d(out_ch) self.relu = nn.ReLU(inplace=True) self.pool = nn.MaxPool2d(2) if pool else nn.Identity() def forward(self, x): return self.pool(self.relu(self.bn(self.conv(x)))) class DualStreamMERNet(nn.Module): """双流浅层微表情识别网络。 spatial_in: [B, 1, 64, 64] temporal_in: [B, 1, 64, 64] """ def __init__(self, num_classes=8, hidden=64, dropout=0.3, fuse="concat"): super().__init__() # 空间纹理流 self.spatial_stream = nn.Sequential( ShallowBlock(1, 32), ShallowBlock(32, hidden), ShallowBlock(hidden, hidden, pool=False), ) # 时间差分流 self.temporal_stream = nn.Sequential( ShallowBlock(1, 32), ShallowBlock(32, hidden), ShallowBlock(hidden, hidden, pool=False), ) self.fuse = fuse cls_in = hidden * 2 if fuse == "concat" else hidden self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(dropout), nn.Linear(cls_in, num_classes), ) def forward(self, spatial, temporal): fs = self.spatial_stream(spatial) ft = self.temporal_stream(temporal) if self.fuse == "concat": h = torch.cat([fs, ft], dim=1) elif self.fuse == "add": h = fs + ft else: raise ValueError("fuse 参数只能取 concat 或 add") return self.classifier(h)这段代码把网络拆成三个逻辑段。前两个卷积块都用 MaxPool2d 下采样,把 64×64 的特征图逐级压到 16×16,第三层不做池化,目的是保留足够空间分辨率给后续的全局平均池化。通道数取 32 到 64 是经验值,超过 128 在 247 个样本的数据集上就会出现过拟合的苗头。fuse参数控制融合方式,concat 保留两条流各自的语义,add 强制特征逐元素相加,对两个分支的一致性要求更高。默认用 concat,后面做消融时可以切到 add 看性能变化。
4.2 损失函数与类别权重:解决类别不均衡的三种手段
微表情数据集的类别分布严重不均衡,CASME II 里 “happiness” 和 “surprise” 往往只有几十个样本,而 “others” 或 “repression” 类的样本数是它们的两三倍。直接用普通交叉熵会让网络偏向多数类,UAR 和宏 F1 都会很难看。
最简单的处理方式是给损失函数加类别权重。权重按样本数的反比计算,多数类权重小,少数类权重大。代码实现如下:
import numpy as np import torch def compute_class_weight(labels): """按样本数反比计算每个类别的权重。 少数类权重高,多数类权重低。 """ labels = np.asarray(labels) counts = np.bincount(labels) total = counts.sum() n_cls = len(counts) # 用总样本数除以类别数再除以每个类别的样本数 weight = total / (n_cls * counts.astype(np.float32)) return torch.from_numpy(weight).float()这段代码的逻辑是让每个类别的贡献在损失函数里大体均衡。比如 happy 类有 30 个样本,neutral 有 80 个,总样本 247,类别数 5,那 happy 的权重就是 247 除以 5 再除以 30,约 1.65;neutral 的权重则是 0.62。调用时直接传给交叉熵:nn.CrossEntropyLoss(weight=class_weight)。如果你的数据集个别类别只有个位数样本,可以把权重加一个平滑项,限制最大权重不超过 5,避免少数类主导梯度更新。
另一种手段是对过采样。在 Dataset 的__getitem__里做概率采样,让样本数少的类别被重复抽到。缺点是没有新信息,只是重复迭代,容易过拟合,所以我在项目里更偏向权重方案。还可以叠加标签平滑,把标签平滑系数设为 0.05 到 0.1,防止网络对训练集过于自信,对 UAR 往往有 2 到 3 个百分点的提升。
4.3 训练主循环与留一被试交叉验证的实现
微表情识别的性能评估不能简单随机划分训练验证集,同一个人的不同视频在训练和验证集里出现会导致指标严重虚高。标准协议是留一被试交叉验证(Leave-One-Subject-Out,LOSO),每次取一个被试的全部样本做测试,其余被试做训练,轮转一遍后合并预测结果,计算 UAR 和宏 F1。
先看训练主循环:
import torch import torch.nn.functional as F def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0.0, 0, 0 for spatial, temporal, labels in loader: spatial = spatial.to(device) temporal = temporal.to(device) labels = labels.to(device) logits = model(spatial, temporal) loss = criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * labels.size(0) correct += (logits.argmax(1) == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total训练函数里的核心动作就三件事:前向算损失、反向清梯度、更新参数。criterion拿的是带类别权重的交叉熵,注意每次迭代都要optimizer.zero_grad(),否则梯度会跨 batch 累积,导致训练不收敛。返回值是平均 loss 和准确率,方便在日志里观察是否过拟合。
接着是 LOSO 主循环:
from sklearn.metrics import recall_score, f1_score pred_all, label_all = [], [] model = DualStreamMERNet(num_classes=len(classes)).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) criterion = nn.CrossEntropyLoss(weight=class_weight).to(device) # 一个被试作为验证集,其余全部作为训练集,轮转全部被试 for test_sub in sorted(meta["subject"].unique()): train_ds = MERDataset(subset(samples, exclude=test_sub)) test_ds = MERDataset(subset(samples, include=test_sub)) train_dl = DataLoader(train_ds, batch_size=16, shuffle=True, num_workers=0) test_dl = DataLoader(test_ds, batch_size=32, shuffle=False, num_workers=0) best_f1 = 0.0 for epoch in range(60): train_one_epoch(model, train_dl, optimizer, criterion, device) preds, labels = evaluate(model, test_dl, device) f1 = f1_score(labels, preds, average="macro") if f1 > best_f1: best_f1 = f1 best_state = model.state_dict().copy() model.load_state_dict(best_state) preds, labels = evaluate(model, test_dl, device) pred_all.extend(preds) label_all.extend(labels) uar = recall_score(label_all, pred_all, average="macro") macro_f1 = f1_score(label_all, pred_all, average="macro") print(f"UAR={uar:.4f} 宏F1={macro_f1:.4f}")这里每轮 LOSO 都保留验证集宏 F1 最高的模型,再重新评估一次,而不是直接用最后一个 epoch 的模型。微表情样本少,验证集指标波动大,取最优模型能保证结果不被某一次糟糕的尾部训练影响。lr=1e-3和weight_decay=1e-4是我调过几轮后认为比较稳的组合,学习率可以按数据集规模适当降到 5e-4,权重衰减不建议超过 1e-3,否则浅层网络学不到任何细节。
5. 训练与评估的避坑记录:5 个让微表情模型翻车的典型问题
这个环节我踩的坑最多,每条都是花两三个通宵换来的。照前面代码直接跑不会报错,但指标很可能虚高或崩盘,问题往往藏在数据划分和预处理细节里。下面按“现象 → 原因 → 解决”写清楚。
5.1 坑一,数据泄露:同一个人的不同视频被分到训练和验证两边
现象:LOSO 里的某轮验证准确率异常高,达到 90% 以上,UAR 和宏 F1 也都偏高。原因:数据划分时按视频而不是按被试切分,同一个人的不同视频出现在训练集和验证集里。微表情视频虽然不同,但同一个人的人脸结构和表情习惯高度相似,模型见过这个人的特征后自然认得出来。解决:严格按subject字段分组,测试集只包含没在训练集出现过的被试。
def subject_split(samples, test_subjects): """按被试编号划分,确保同一被试不会同时出现在两边""" train = [s for s in samples if s["subject"] not in test_subjects] test = [s for s in samples if s["subject"] in test_subjects] return train, test这段代码的判断逻辑非常简单,但容易在不知不觉中犯错。常见翻车点是标注文件里的 subject 字段有空格或者大小写不统一,'Subject01'和'subject01'被当成两个人,导致划分失效。拿到数据先执行set(subject)归一化,再打印训练集和测试集的被试交集,确保为空才继续。
5.2 坑二,差分流的均值偏移:网络学到的是亮度不是动作
现象:训练几十个 epoch 后验证集 UAR 在 0.5 附近波动,无论如何调参都上不去。原因:从原始视频直接计算差分,相邻帧之间除了表情动作还包含光照变化和摄像头自动曝光的整体亮度漂移,差分图里的非零值主要来自亮度变化而不是面部肌肉运动。解决:差分前做像素归一化,再对每个差分图减去其全局均值,把整体偏移清零。
seq = window.astype(np.float32) / 255.0 diff = np.abs(np.diff(seq, axis=0)) # 减去每个差分图的全局均值,抑制亮度漂移 diff = diff - diff.mean(axis=(1, 2), keepdims=True) temporal = diff.max(axis=0)这里多出来的两行代码就是我血泪教训的浓缩。diff.mean(axis=(1, 2), keepdims=True)算的是每张差分图的像素均值,减掉之后模型看到的就只剩局部相对变化。这个操作对 UAR 的影响大概在 5 到 8 个百分点之间,效果非常明显。
5.3 坑三,样本不平衡下的指标失效:只看准确率会被骗
现象:训练日志里准确率到 65%,看起来还行,但宏 F1 只有 0.3 到 0.4。原因:多数类别占比超过一半,准确率高是因为模型把所有样本都预测成了多数类,少数类的预测全错。解决:在评估阶段同时看 UAR 和宏 F1,用带权重的损失函数配合少数类过采样。
UAR 是全类别召回率的算术平均,不受类别样本数影响。宏 F1 同理,它把每个类别的 F1 先算出来再平均。在微表情这种少数类样本只有几十个的分布下,这两个指标才是真实水平。如果发现 UAR 和准确率差距超过 10 个百分点,说明模型严重偏向多数类,先把类别权重的计算逻辑检查一遍。
5.4 坑四,过拟合与早停:浅层网络也会过拟合
现象:训练集准确率三天之内冲到 100%,验证集 UAR 停留在 0.55,训练集和验证集之间的差距越拉越大。原因:样本量太小,即便浅层网络也有足够的容量把全部训练样本背下来。解决:减少训练轮数并保存验证集最优模型,开启早停。
best_f1, best_state = 0.0, None patience, no_improve = 15, 0 for epoch in range(80): train_one_epoch(...) _, val_f1 = evaluate(model, val_dl, device) if val_f1 > best_f1: best_f1, no_improve = val_f1, 0 best_state = model.state_dict().copy() else: no_improve += 1 if no_improve >= patience: break # 15 个 epoch 没涨就提前停,防止继续背训练集早停时机选在验证集 F1 连续 15 个 epoch 不创新高就中断,并把最优模型恢复出来。你还可以把 dropout 从 0.3 提到 0.5,代价是训练收敛变慢但泛化更稳。浅层网络的过拟合不会像大网络那么严重,但它还是会发生,尤其当你把训练跑到 60 epoch 以上时。
5.5 坑五,apex 标注不一致导致训练集噪声
现象:用 apex 帧做空间流输入时,LOSO 的结果上下跳动幅度很大,同一个被试换一轮重新训练,UAR 波动超过 10 个百分点。原因:apex 是人工标注的,不同标注者判断的幅度最大帧可能相差 3 到 5 帧,用这些有噪声的帧做空间流会给分类器喂错误信息。解决:默认用(onset + offset) // 2代替 apex,或者对窗口内的相邻帧做插值平均稳定的表示。
# 不用原始 apex,改用 onset 和 offset 的中点 center = (row["onset"] + row["offset"]) // 2这个改动的本质是用对称性消除标注偏移。onset 和 offset 的定位通常比 apex 更可靠,它们标记的是表情开始和结束的边界,中线就是表情变化最集中的区域。如果你的数据集里 apex 标注是由两位以上标注者一致确认过的,可以放心用 apex;否则切到中点是更稳妥的选择。
6. 微表情模型落地前的验证技巧:从混淆矩阵查到分支诊断
把 LOSO 跑完拿到一个数字不是终点。我通常会再花半天时间做三件事:查混淆矩阵、诊断双流分支的独立贡献、把模型导出为部署格式。
混淆矩阵首先要看类别之间的误判方向。在 CASME II 上最常见的混淆是 surprise 和 fear 互相误判,以及 disgust 和 anger 互相误判。这些类别的面部动作区域有重叠,如果误判集中在语义相近的类别,说明模型学到了合理特征,只是边界没分开;如果误判毫无规律,说明预处理环节可能还有问题,回头检查差分图和空间流图像是否对齐。
分支诊断用特征可视化。双流浅层网络的好处是两条流的特征图可以直接用 hook 取出来看:
def get_stream_outputs(model, spatial, temporal): """返回空间流和时间流在融合前的特征图,用于诊断分支质量""" fs = model.spatial_stream(spatial) ft = model.temporal_stream(temporal) return fs.detach().cpu().numpy(), ft.detach().cpu().numpy()我一般会把两个分支的特征图分别做平均池化,再对每个类别统计激活强度。如果发现时间流的激活值整体接近零,说明网络没有利用运动信息,纯粹的纹理分支在硬扛,这时候你去调差分参数而不是调网络结构。反过来,如果空间流几乎不起作用,说明人脸对齐和裁剪出了问题。
导出部署格式我用 PyTorch 自带的 ONNX 导出,推理时只需要前处理加一次前向,CPU 上单张 64×64 输入耗时不到 10 毫秒:
dummy_s = torch.randn(1, 1, 64, 64) dummy_t = torch.randn(1, 1, 64, 64) torch.onnx.export(model, (dummy_s, dummy_t), "mer.onnx", input_names=["spatial", "temporal"], output_names=["logits"], dynamic_axes={"spatial": [0], "temporal": [0]})这些验证做完之后,我才会认为一个微表情识别项目真正有了可交付的模型。回想踩过的所有坑,最难的不是网络结构本身,而是承认自己在数据划分和预处理上犯了低级错误。希望这套方法能帮你少走几段弯路,把时间花在真正有价值的方向上,希望帮到你。
本文还有配套的精品资源,点击获取