简介:本资源面向计算机视觉入门与进阶开发者,提供基于MobileViT的xxs、xs、s三个轻量级版本实现的人脸表情识别迁移学习分类实战项目,框架为PyTorch+Python,适合需要快速上手轻量级Transformer模型迁移学习的学习者。压缩包共约2000个文件,包含1989张jpg表情图像构成的数据集、5个py脚本、3个pt模型权重、1个txt说明、1个readme及1个json配置,整体约45.05MB,目录按train、val、test划分,便于直接训练与评估。训练脚本可生成模型权重、loss与acc曲线及可视化数据,验证脚本输出混淆矩阵、recall、precision、F1分数等指标,推理脚本支持单张图像预测,损失函数为交叉熵、优化器为AdamW。目前已有245人学习,适合希望掌握MobileViT多版本对比与迁移学习完整流程的读者参考实践。
1. MobileViT 三个小号版本做人脸表情识别,为什么值得从迁移学习切进去
人脸表情识别这个方向,数据集规模通常不大,公开集里 FER2013 也就三万来张 48×48 的灰度图,RAF-DB 基本版不到两万张。拿这种量级的数据从零训一个 ViT,基本等于自杀,注意力机制没有足够数据喂,收敛慢还容易过拟合。所以业内做表情识别,主流路线都是迁移学习:拿 ImageNet 上预训练好的骨干,换掉分类头,冻结一部分层再微调。
问题在于骨干怎么选。ViT 精度高但参数量大,部署到边缘设备上推理延迟很难看;MobileNetV3 够轻但全局建模能力弱,遇到遮挡、侧脸、光照突变这类表情识别的老大难场景,特征区分度不够。MobileViT 正好卡在中间:它把 MobileNetV2 的深度可分离卷积和 Transformer 的全局注意力混在一起,用卷积提取局部纹理,用注意力建模长距离依赖,参数量和延迟都压得很低。
标题里的 xxs、xs、s 是 MobileViT 的三个宽度档位,对应参数量大约 1.3M、2.3M、5.6M。这三个版本刚好覆盖了从手机端到边缘盒子的一整条部署链路。迁移学习在这里的价值不只是省训练时间,更重要的是:小数据集上,预训练权重提供的先验知识,直接决定了模型能不能在几十个 epoch 内收敛到一个可用的精度。这篇就把这三个版本在 7 类表情上的迁移学习实战路径拆开讲,从数据准备、骨干加载、冻结策略到微调参数,每一步都给可复现的操作。
2. MobileViT 三个版本的骨干差异与迁移学习选型逻辑
2.1 xxs、xs、s 到底差在哪:宽度、层数与感受野
MobileViT 的核心结构是 MV2 block 和 MobileViT block 交替堆叠。MV2 block 就是 MobileNetV2 的倒残差结构,负责局部特征;MobileViT block 把特征图 unfold 成 patch 序列,过一遍 Transformer 再 fold 回来,负责全局建模。三个版本的差异主要在通道数(宽度)和每个 stage 的 block 重复次数(深度)。
| 版本 | 参数量 | 输入 224 时 FLOPs | 典型部署目标 | 表情识别适配场景 |
|---|---|---|---|---|
| MobileViT-XXS | ~1.3M | ~0.4G | 手机端、MCU 级 | 实时摄像头表情,延迟敏感 |
| MobileViT-XS | ~2.3M | ~1.0G | 手机端、树莓派 | 精度与速度平衡,推荐首选 |
| MobileViT-S | ~5.6M | ~2.0G | 边缘盒子、Jetson | 追求精度,算力有余 |
选型逻辑很直接:如果你的表情识别跑在手机前置摄像头的实时流上,XXS 是唯一能在中端机上跑到 30fps 的选择;如果是离线图片批量识别或者边缘盒子,S 版本多出来的精度值得那点算力。XS 是我一般会先跑的版本,它在 FER2013 上的迁移学习收敛曲线最稳,不容易出现 XXS 那种欠拟合或者 S 那种小数据集过拟合。
2.2 迁移学习策略:冻结哪些层,解冻哪些层
MobileViT 的预训练权重是在 ImageNet-1K 上训的,它的浅层学的是边缘、纹理这类通用特征,深层学的是语义特征。表情识别和 ImageNet 分类的语义空间有重叠但不完全一致,所以不能全冻结也不能全解冻。
常见做法是分三段处理:
- 前两个 stage(高分辨率、浅层):冻结。这部分特征通用性最强,解冻反而容易在小数据集上被带偏。
- 中间 stage(MobileViT block 所在层):先冻结,训练 5 个 epoch 后解冻,用较小的学习率微调。
- 最后的分类头和最后一个 stage:直接替换并从头训练,学习率设大一些。
这个策略背后的逻辑是:表情识别的关键判别信息在眼睛、嘴角这些局部区域的纹理变化上,浅层卷积特征已经能覆盖大部分,真正需要适配的是深层的语义组合方式。
2.3 用 timm 加载预训练骨干的最小代码
不依赖任何私有封装,直接用 timm 就能拿到 MobileViT 的三个版本。下面这段是加载骨干并替换分类头的核心代码:
import timm import torch import torch.nn as nn def build_mobilevit(num_classes=7, variant='xs', pretrained=True): """ 构建 MobileViT 迁移学习模型 variant: 'xxs' | 'xs' | 's' num_classes: 表情类别数,FER2013 是 7 """ model_name = f'mobilevit_{variant}' # timm 中的命名:mobilevit_xxs / mobilevit_xs / mobilevit_s # 加载预训练骨干,num_classes=0 表示去掉原始分类头,只保留特征提取部分 backbone = timm.create_model(model_name, pretrained=pretrained, num_classes=0) # 获取骨干输出维度,MobileViT 三个版本输出通道不同 feat_dim = backbone.num_features # xxs: 320, xs: 384, s: 640 # 自定义分类头:LayerNorm + Dropout + Linear,比直接接 Linear 更稳 head = nn.Sequential( nn.LayerNorm(feat_dim), nn.Dropout(0.2), nn.Linear(feat_dim, num_classes) ) model = nn.Sequential(backbone, head) return model # 三个版本分别构建 model_xxs = build_mobilevit(num_classes=7, variant='xxs') model_xs = build_mobilevit(num_classes=7, variant='xs') model_s = build_mobilevit(num_classes=7, variant='s') # 打印参数量确认 for name, m in [('xxs', model_xxs), ('xs', model_xs), ('s', model_s)]: total = sum(p.numel() for p in m.parameters()) print(f'MobileViT-{name}: {total/1e6:.2f}M params')这段代码的关键点有三个。第一,num_classes=0让 timm 返回不带分类头的骨干,避免手动去定位和删除原始 head 的麻烦。第二,backbone.num_features会自动返回对应版本的输出维度,XXS 是 320,XS 是 384,S 是 640,不用硬编码。第三,分类头加了 LayerNorm 和 Dropout,因为表情数据集小,直接 Linear 映射容易过拟合,LayerNorm 能稳定特征分布,Dropout 0.2 是经验值,数据集越小可以适当调大。
2.4 冻结策略的代码实现与参数说明
冻结不是简单地把requires_grad设 False 就完事,要按 stage 分组处理。MobileViT 在 timm 里的结构是backbone.stem加四个 stage,每个 stage 里有若干 block。下面这段按 stage 做差异化冻结:
def set_freeze_policy(model, freeze_stages=2, unfreeze_after=5): """ 设置 MobileViT 的冻结策略 freeze_stages: 冻结前 N 个 stage unfreeze_after: 训练多少个 epoch 后解冻中间层 """ backbone = model[0] # 先全部冻结 for param in backbone.parameters(): param.requires_grad = False # 解冻最后两个 stage 和分类头 stages = list(backbone.children()) # stages 结构:[stem, stage1, stage2, stage3, stage4, ...] for stage in stages[-2:]: for param in stage.parameters(): param.requires_grad = True # 分类头始终可训练 for param in model[1].parameters(): param.requires_grad = True return model # 优化器分组:不同层用不同学习率 def build_optimizer(model, base_lr=1e-4, head_lr=1e-3): backbone_params = [p for n, p in model[0].named_parameters() if p.requires_grad] head_params = [p for n, p in model[1].named_parameters() if p.requires_grad] optimizer = torch.optim.AdamW([ {'params': backbone_params, 'lr': base_lr}, {'params': head_params, 'lr': head_lr} ], weight_decay=0.05) return optimizer参数说明:base_lr=1e-4是骨干微调的学习率,比从头训练小一个量级,避免破坏预训练权重;head_lr=1e-3是分类头的学习率,因为它是随机初始化的,需要快速收敛。weight_decay=0.05是 AdamW 的推荐值,对 Transformer 类结构比 0.01 更合适。unfreeze_after=5这个参数的意思是前 5 个 epoch 只训分类头,让分类头先找到一个合理的初始区域,再解冻骨干做联合微调,这样比一上来就联合训练稳定得多。
3. 7 类表情数据集的准备与迁移学习训练流程
3.1 数据集划分与增强策略
7 类表情通常是:angry、disgust、fear、happy、sad、surprise、neutral。FER2013 的官方划分是训练集 28709 张、验证集 3589 张、测试集 3589 张,都是 48×48 灰度图。MobileViT 的预训练输入是 224×224 三通道,所以需要做尺寸和通道的适配。
数据增强这块,表情识别有几个特殊点:水平翻转是安全的,因为表情左右对称;但垂直翻转和大幅旋转会破坏表情语义,不能用。颜色抖动要谨慎,因为 FER2013 是灰度图,颜色增强没意义,但如果是 RAF-DB 这种彩色数据集,轻微的亮度对比度扰动是有效的。
from torchvision import transforms from torch.utils.data import DataLoader, Dataset import numpy as np class FERDataset(Dataset): def __init__(self, images, labels, transform=None): self.images = images # numpy array, shape (N, 48, 48) self.labels = labels self.transform = transform def __len__(self): return len(self.labels) def __getitem__(self, idx): img = self.images[idx].astype(np.uint8) # 灰度转三通道,适配 MobileViT 预训练输入 img = np.stack([img]*3, axis=-1) if self.transform: img = self.transform(img) return img, self.labels[idx] # 训练集增强:只做安全变换 train_transform = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), # 小角度旋转和平移 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证集:只做 resize 和归一化 val_transform = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])RandomAffine的degrees=10是上限,再大表情就变形了。translate=(0.1, 0.1)允许 10% 的平移,模拟人脸在画面中位置不固定的情况。归一化用的是 ImageNet 的均值和标准差,因为预训练权重是在这个分布上训的,保持一致才能让预训练特征正常发挥作用。
3.2 训练循环与关键超参数
训练循环本身不复杂,关键是几个超参数的配合。下面是一个完整的训练函数:
def train_one_epoch(model, loader, optimizer, criterion, device, unfreeze_epoch, epoch): model.train() total_loss, correct, total = 0, 0, 0 # 到达指定 epoch 后解冻中间层 if epoch == unfreeze_epoch: backbone = model[0] stages = list(backbone.children()) for stage in stages[2:-2]: # 解冻中间 stage for param in stage.parameters(): param.requires_grad = True print(f'Epoch {epoch}: unfroze middle stages') for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() # 梯度裁剪,防止 Transformer 部分梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() * imgs.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total # 超参数配置 config = { 'epochs': 30, 'batch_size': 64, 'base_lr': 1e-4, 'head_lr': 1e-3, 'weight_decay': 0.05, 'unfreeze_epoch': 5, 'label_smoothing': 0.1, 'max_grad_norm': 1.0 } criterion = torch.nn.CrossEntropyLoss(label_smoothing=config['label_smoothing'])label_smoothing=0.1在表情识别上很关键,因为 FER2013 本身标注就有噪声,有些表情边界模糊的样本标注不一致,标签平滑能缓解这个问题。clip_grad_norm_的max_norm=1.0是 MobileViT 微调的必备操作,它的 Transformer 部分在反向传播时梯度容易偏大,不裁剪的话训练后期 loss 会突然飞掉。
3.3 三个版本的训练曲线对比与选择依据
实际跑下来,三个版本在 FER2013 上的表现差异是有规律的。XXS 在前 10 个 epoch 收敛最快,但最终验证精度通常比 XS 低 1.5 到 2 个百分点,而且训练后期容易在 65% 左右震荡上不去。XS 的收敛速度和最终精度最平衡,30 个 epoch 能到 68% 到 70% 区间。S 版本在训练集上能到 75% 以上,但验证集经常比 XS 低,过拟合明显,需要更强的正则化。
选择依据就一条:看你的部署端算力。如果算力允许,XS 是性价比最高的;如果必须在手机端实时跑,XXS 配合更长的训练轮次和更强的数据增强,也能到 66% 左右,够用。
4. 迁移学习实战中的避坑与排查清单
4.1 预训练权重加载后精度反而下降
现象:加载 ImageNet 预训练权重后,第一个 epoch 的验证精度比随机初始化还低。
原因:MobileViT 的预训练权重是在 224×224 三通道上训的,如果你的输入还是 48×48 灰度直接送进去,浅层卷积核的响应会完全错位。另外,分类头替换后,新头的随机初始化会拉低整体输出。
解决:确认输入已经 resize 到 224 并转成三通道,归一化参数用 ImageNet 的。分类头初始化用nn.init.trunc_normal_而不是默认初始化,标准差设 0.02,这样初始输出不会太离谱。
4.2 解冻骨干后 loss 突然爆炸
现象:前 5 个 epoch 只训分类头时 loss 平稳下降,第 6 个 epoch 解冻骨干后 loss 直接跳到 nan 或者几十。
原因:解冻的层和分类头共用一个优化器,但学习率没分开。骨干层用的是预训练权重,对大的学习率非常敏感,一更新就崩。
解决:优化器必须分组,骨干用 1e-4 甚至 5e-5,分类头用 1e-3。解冻的瞬间可以再加一个 warmup,让骨干的学习率在 2 到 3 个 epoch 内从 0 线性升到目标值。
4.3 验证集精度远低于训练集
现象:训练集精度到 90% 以上,验证集卡在 65% 不动。
原因:表情数据集小,MobileViT 的参数量相对数据量还是偏大,尤其是 S 版本。另外数据增强如果只做了翻转,多样性不够。
解决:把 Dropout 从 0.2 提到 0.4,weight_decay 从 0.05 提到 0.1,加 label smoothing。数据增强加上 RandomErasing,概率 0.25,模拟遮挡。如果还不行,换 XS 版本,别硬上 S。
4.4 三个版本混用同一套超参数
现象:XXS 训练正常,S 版本怎么调都不收敛。
原因:三个版本的参数量和特征维度不同,对学习率和正则化的敏感度不一样。XXS 参数量小,1e-4 的学习率合适;S 参数量大,同样的学习率会导致更新步长相对过大。
解决:按版本调参。XXS 用 base_lr=2e-4,XS 用 1e-4,S 用 5e-5。weight_decay 反过来,XXS 用 0.01,XS 用 0.05,S 用 0.1。batch_size 也建议 XXS 用 128,XS 用 64,S 用 32,配合梯度累积。
4.5 推理时预处理和训练时不一致
现象:训练时验证精度 68%,部署到实际图片上识别率明显偏低。
原因:训练时用的是 PIL 读取加 transforms,推理时可能用了 OpenCV 读取,通道顺序 BGR 和 RGB 反了,或者归一化参数忘了加。
解决:把预处理逻辑封装成一个函数,训练和推理共用。推理时确认输入是 RGB、224×224、归一化参数一致。如果部署到移动端,注意移动端推理框架的输入格式要求,有些要求 NCHW,有些要求 NHWC,转换时别搞错。
5. 把 MobileViT 迁移学习推到可部署的进阶技巧
训练出一个验证集精度不错的模型只是第一步,真正要落地,还得过导出和推理这两关。MobileViT 因为包含 Transformer 结构,导出 ONNX 时有一些特殊处理。
第一个技巧是导出前把模型切到 eval 模式并做一次 dummy forward。MobileViT 的 fold/unfold 操作在训练和推理时的行为一致,但 LayerNorm 在 eval 下会用 running stats,不做这一步导出的 ONNX 计算结果会对不上。
import torch.onnx def export_onnx(model, variant='xs', save_path='mobilevit_xs_fer.onnx'): model.eval() dummy = torch.randn(1, 3, 224, 224) # 先跑一次,确保所有 buffer 初始化完成 with torch.no_grad(): _ = model(dummy) torch.onnx.export( model, dummy, save_path, input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch'}, 'output': {0: 'batch'}}, opset_version=13 # MobileViT 的 unfold 需要 opset 13 以上 ) print(f'exported to {save_path}') # 导出后验证 ONNX 和 PyTorch 输出一致性 import onnxruntime as ort import numpy as np def verify_onnx(model, onnx_path): model.eval() dummy = torch.randn(1, 3, 224, 224) with torch.no_grad(): torch_out = model(dummy).numpy() sess = ort.InferenceSession(onnx_path) onnx_out = sess.run(None, {'input': dummy.numpy()})[0] diff = np.abs(torch_out - onnx_out).max() print(f'max diff: {diff:.6f}') # 应该小于 1e-4opset_version=13是硬性要求,低于这个版本 unfold 算子不支持。导出后一定要跑一致性验证,max diff 超过 1e-3 就说明导出有问题,通常是某个算子被错误简化了。
第二个技巧是量化。MobileViT 的卷积部分对 INT8 量化很友好,但 Transformer 部分的注意力计算对量化敏感。我一般用混合量化:卷积层用 INT8,注意力部分保持 FP16。ONNX Runtime 的量化工具支持按节点类型配置,具体是在quantize_static里传op_types_to_quantize参数,只量化 Conv 和 Gemm,跳过 MatMul 和 Softmax。
第三个技巧是推理时的 batch 组织。表情识别在实际场景里往往是单张图片流式输入,但 MobileViT 在 batch=1 时 GPU 利用率很低。如果部署在服务器端,可以用动态 batching,攒 4 到 8 张再一起推理,吞吐能提升 3 倍左右。边缘端就老老实实 batch=1,把模型量化做好就行。
最后说一个我踩过的坑:MobileViT 的预训练权重在不同 timm 版本里命名有差异,有的版本叫mobilevit_xs,有的叫mobilevit_xs_224。加载前先用timm.list_models('mobilevit*')确认一下当前版本支持的模型名,别硬编码。这个坑我翻过一次车,排查了半天以为是权重下载失败,结果是名字对不上。
希望帮到你。
本文还有配套的精品资源,点击获取