☰
PAtt-Lite轻量人脸表情识别:Patch注意力与端侧部署
2026/11/5 4:31:12 网站建设 项目流程

人脸表情识别这条线,这两年明显分成两派:一派在公开榜单上死磕零点几个点,骨干越堆越大;另一派在做端侧落地,天天跟参数量、延迟、内存较劲。PAtt-Lite 这个题目一眼就能看出它站哪一边——Patch 加 Attention 的轻量 MobileNet,目标是"challenging"场景下的表情识别。我第一次看到这个组合时的直觉是:这不是在发明新算子,而是在用极低的参数预算,把表情识别最要命的两件事——局部形变和判别性通道——显式地喂给网络。这篇内容适合三类人:正在做表情识别课程设计或毕业设计的学生、要把表情能力塞进手机或嵌入式设备里的工程同学、以及想搞清楚"轻量化到底该往哪儿省"的算法从业者。下面我按设计思路、结构拆解、复现实操、踩坑排查、评估落地五个层面,把 PAtt-Lite 这类方案从头到尾讲透,参数怎么算、模块放哪儿、训练怎么调、部署怎么不掉点,都给你摊开。

1. 轻量、局部、注意力:这套组合到底在解决什么

1.1 表情识别在真实场景里难在哪

先说清楚"challenging"这个词的分量。实验室环境下拍出来的正脸、均匀光照、夸张表情,随便一个预训练骨干微调一下都能到九十几。真正难的是野外的那些脸:侧转三十度、半边脸被口罩或者手挡住、分辨率只有 48×48、逆光、低对比度、以及最恶心的——标注本身就有歧义。一个人嘴角微微上扬,到底算"中性"还是"高兴"?不同标注者给出的标签可能都不一样,同一个人隔一周再标,结果也可能变。

我把这些难点归成四类,后面所有设计决策都是围着它们转的:

  • 局部性:表情的本质是局部肌肉形变。皱眉是眉心和眼睑,微笑是嘴角和法令纹,惊讶是眉毛抬高加嘴张开。全局平均池化会把这些稀疏的、小面积的信号和整张脸的平均亮度搅在一起,直接稀释掉。
  • 类间差异小、类内差异大:中性、轻蔑、轻微难过这三种在某些人脸上几乎无差别;而同一个人笑的幅度差异可能比"微笑"和"大笑"两个类别之间的差异还大。
  • 长尾分布:野外数据集里"高兴"能占三成,"恐惧""厌恶"可能只有百分之几。模型很快学会偏向头部类别。
  • 算力预算死线:端侧要的是十几毫秒出结果、几 MB 内存占用,任何超过 5M 参数、几百 M FLOPs 的方案在低端设备上都没法常态化运行。

这四条摆在一起,你就会发现:单纯把骨干换小必然掉点,所以要拿"结构上的先验"去换参数。PAtt-Lite 的整个思路就是这笔交易——用 Patch 分支把局部先验塞进去,用注意力把通道预算集中到判别性特征上,用 MobileNet 把参数和算力的底价压到最低。

1.2 PAtt-Lite 的整体设计思路拆解

拆开来看,这套结构是"一主一辅一融合":

主干负责整体上下文和语义,选的是 MobileNet 系列(倒残差 + 深度可分离卷积),输入常规取 112×112,下采样到 1/16 或 1/32 拿全局特征。辅助分支是 Patch 分支,把早期特征图切成网格,每个格子在低维空间里单独编码,再用轻量注意力在 patch 之间做一次信息交换,最后和主干特征在通道维拼接或者相加。融合后再加一层通道+空间的混合注意力,压掉背景和人脸无关区域,最后全局池化接分类头。

为什么是"早期特征切 patch"而不是"原图切 patch"?这是很多人第一版会走弯路的地方。原图 112×112 切成 16 个 28×28 的块,每块独立过一个卷积 stem,算力其实不便宜,而且低层纹理信息冗余极高。我更推荐在主干 stage2 或 stage3 的输出上切,也就是 28×28×32 或者 14×14×64 这一层——此时感受野已经覆盖了几十像素,patch 之间的语义差异才明显,"眼角区域"和"背景区域"的特征才真的不一样。省算力,效果还更好。

还有一个容易被忽略的点:Patch 分支和主干是共享底层的。也就是说它并不是一个独立的第二条网络,而是从主干中间"分叉"出来的一根细枝,参数量可能只有几万。这是它能在 1.3M 级别参数下还带得住局部建模的根本原因。你在论文里看到的"双分支",实际落地时如果真做成两条独立通路,参数量和延迟会直接翻倍,得不偿失。

1.3 三条设计主线的取舍账本

我把几个常见方案放在一起对比,你就能看出 PAtt-Lite 的位置:

方案参数量(约)FLOPs@112局部建模注意力端侧延迟适用判断
ResNet18 微调11.2M1.8G隐式无80-120ms服务器端可接受,端侧偏重
MobileNetV2 直接微调2.2M90M弱无12-20ms快但局部细节丢失明显
MobileNetV3-Small + SE1.5M60M弱通道级10-18ms通道注意力有,空间局部性仍不足
轻量 ViT(patch 化)3-5M200M+强全局40-70ms小数据上易过拟合
PAtt-Lite 类方案1.0-1.5M70-120M显式通道+patch10-20ms精度与延迟平衡点

这张表里最关键的一行其实是倒数第二行:轻量 ViT 证明了 patch 化对表情任务有效,但它把自注意力做成了全局的、多层的,算力立刻失控;而表情识别在小的受控数据集上样本量往往只有几万张,太深的注意力堆叠非常容易过拟合。PAtt-Lite 的做法是把 ViT 里最值钱的那部分——patch 化和 patch 间的轻量交互——抠出来,只保留一层甚至一个线性近似,剩下的交回卷积。这是一种很务实的"取一半"策略。

2. 结构拆解:每个模块该放哪、参数怎么算

2.1 主干选型:把深度可分离卷积的账算明白

为什么是 MobileNet 而不是 ShuffleNet 或者 GhostNet?我不打算给你一个"因为它经典"的糊弄答案,直接算账。

标准 3×3 卷积,输入通道 M、输出通道 N、特征图尺寸 Df×Df,计算量是 Dk·Dk·M·N·Df·Df。换成深度可分离卷积,先做逐通道的 3×3(Dk·Dk·M·Df·Df),再做 1×1 的逐点卷积(M·N·Df·Df),两者之比约等于 1/N + 1/Dk²。取 Dk=3、N=256,就是 1/256 + 1/9 ≈ 1/8.7,也就是算力降到原来的 11% 左右。参数量更直观:标准卷积是 3·3·M·N = 9MN,可分离是 9M + MN,M=128、N=256 时,前者 294912,后者 33920,差了将近 9 倍。

但账不能只看乘法。深度可分离卷积的访存效率比标准卷积差——它是两次小算子而不是一次大算子,在 ARM CPU 上往往吃不满流水线,实际加速比常常只有理论值的 50%-70%。这就是为什么有些方案在 FLOPs 上看着很漂亮,真机跑起来反而不如预期。所以我在选主干时有个习惯:FLOPs 只用来筛,最终一定看真机单线程延迟。

至于 V2 还是 V3,我的实践结论是:如果你要做通道注意力,V2 的倒残差结构更"干净",插 SE 模块不会和 V3 自带的 SE(hard-swish 版本)打架;如果追求极致延迟,V3-Small 的 h-swish 在中端 ARM 上有专门的指令优化,会更快,但小模型上用 h-swish 有时反而掉点,需要实测。我一般先用 V2 宽度 1.0 做基线,跑通了再换 V3 对比。

主干还有一个必做的裁剪:砍掉最后的 1×1 升维层和 1000 类分类头。MobileNetV2 最后那个 1280 维的卷积层参数量接近 0.4M,对表情任务完全是浪费——7 类或 8 类的任务根本不需要 1280 维特征。我通常把主干输出定在 96 或 128 通道,分类头直接 128→类别数,这一刀能省下三成参数,精度几乎不掉。

2.2 Patch 分支:把一张脸切成"表情器官拼图"

Patch 分支的设计要点就三个:切多大、在哪儿切、怎么交互。

切多大。表情的判别区域大致在眼睛、眉毛、嘴巴三块,占整张脸的面积比例在 15%-25%。如果输入 112×112,切 4×4=16 个 28×28 的 patch,每个 patch 大致对应一个局部器官或者一块脸颊;切 2×2 太粗,等于没切;切 7×7 太细,单个 patch 里只剩纹理,还平白多了 token 数量。我实测下来4×4 或者 3×3 是甜点区,而且 patch 之间留不留重叠影响不大——重叠会增加计算,收益通常不超过 0.3 个点。

在哪儿切。前面说过,在主干 stride 8 或 stride 16 的特征图上切。以 112 输入、MobileNetV2 为例,stage2 输出是 28×28×32(stride 4),stage3 是 14×14×64(stride 8),stage4 是 7×7×96(stride 16)。我推荐stride 8 的 14×14×64这一层:切 2×2 得 4 个 7×7 的 patch,或者切 7×7 得 49 个 2×2 的 patch。前者语义强但太粗,后者太碎。折中做法是切 2×2 后每个 patch 再做一次 1×1 压缩到 32 维,然后做 patch 间交互。

另一个更省事的做法是在 28×28×32 上按 7×7 网格切,得到 16 个 4×4×32 的 patch,通道 32 已经很低,投影到 48 维就够。这一层的好处是空间分辨率还高,器官级别的对应关系更准。两种我都试过,后者在小数据集上更稳,前者在大规模数据上更好,你可以按你手上的数据量选。

怎么交互。这是 Patch 分支唯一有"注意力"成分的地方。最轻的做法是给每个 patch 学一个标量权重(相当于 patch 维度的 SE),然后加权求和;中等做法是单头自注意力,token 数 16、维度 48,这一层的计算量几乎可以忽略(16×16×48 的矩阵乘,几十万次乘加);重一点的做法是两层 Transformer block,我没必要,一层足够,甚至单头就够——多头在小 token 数下收益极小,还多了一堆 reshape 开销。

代码骨架大概是这样:

import torch import torch.nn as nn class PatchBranch(nn.Module): def __init__(self, in_ch=32, dim=48, grid=4, heads=1): super().__init__() self.grid = grid self.proj = nn.Conv2d(in_ch, dim, kernel_size=1, bias=False) self.norm = nn.LayerNorm(dim) self.attn = nn.MultiheadAttention(dim, heads, batch_first=True) self.out = nn.Conv2d(dim, 128, kernel_size=1, bias=False) def forward(self, x): # x: [B, in_ch, H, W],H=W=28 时 grid=4 => patch 7x7 B, C, H, W = x.shape p = H // self.grid # 用 unfold 切 patch,避免 python 循环 patches = x.unfold(2, p, p).unfold(3, p, p) # [B,C,g,g,p,p] patches = patches.permute(0, 2, 3, 1, 4, 5).reshape(B, self.grid**2, C, p, p) tokens = self.proj(patches.flatten(0, 1)).mean((-2, -1)) # 全局池化到 token tokens = tokens.view(B, self.grid**2, -1) tokens = self.norm(tokens) attn_out, _ = self.attn(tokens, tokens, tokens) # 单层轻量自注意力 tokens = tokens + attn_out feat = tokens.view(B, self.grid, self.grid, -1).permute(0, 3, 1, 2) # 上采样回主干特征尺寸再融合 return self.out(feat)

这段代码里那个mean((-2,-1))是关键取舍:把每个 patch 池化成一个 token,而不是保留 patch 内的空间维度去做 attention。好处是 token 数固定为 16,attention 复杂度可控;代价是丢掉了 patch 内的细节。如果你要保留,可以改成把 patch 展平成 token 序列(16×7×7=784 个 token),但那样 attention 立刻变贵。我的建议是:参数预算 1.5M 以下就别保留,老老实实池化。

2.3 注意力模块:放在哪、放几个、压缩比怎么定

注意力这块,很多人一上来就想上 CBAM 全套、坐标注意力、或者自注意力,结果参数量糊上去,延迟也上去了。我的原则是:小模型上,通道注意力优先,空间注意力补位,自注意力只在 patch 分支里出现一次。

为什么通道优先?小模型的通道数被压得很低(96、128),每一层通道的信息密度都很高,冗余相对少,但判别性通道和噪声通道混在一起时,全局平均池化会把噪声通道也均进来。SE 做的就是给每个通道学一个 0-1 的权重,让网络自己决定"这一层我该听哪几个通道的"。压缩比 r 是唯一要调的参数:r=4 参数多、表达强;r=8 是默认;r=16 更省但有时欠拟合。1.3M 这个级别的模型,我建议r=8,且在倒残差块的"扩张-深度卷积-投影"之后、残差相加之前插入,也就是 MobileNetV3 的位置,不要插在扩张层之前,否则会削弱深度卷积的输入多样性。

放几个?主干一共 17 个 bottleneck(V2 宽度 1.0),全插 SE 会多出约 0.08M 参数和 5% 左右的延迟。我的做法是只在 stride 8 之后的块插,也就是后 8-10 个块,前面的浅层感受野小、通道少(16、24),插了收益不明显。

空间注意力用在哪儿?我放在融合之后、全局池化之前,用最便宜的版本:通道维做 max 和 avg 拼成 2 通道,过 7×7 卷积再 sigmoid。参数量只有 2×1×7×7=98 个,几乎免费。它的作用是压掉背景——野外数据集里大量图片背景是复杂场景,人脸只占中间一块,这个 98 参数的小模块能带来 0.5-1 个点的稳定提升,性价比极高。

融合方式我用一张表对比过:

融合方式参数量增量精度(7类,同一数据)备注
直接相加0基线要求通道数一致
通道拼接 + 1×1 降维约 12K+1.2pt我最终选这个
逐元素相乘(门控)约 8K+0.6pt训练初期不稳定
加权求和(可学习标量)2+0.3pt最省,适合极限压缩

拼接加 1×1 降维是我用得最多的,因为两个分支的特征分布差异大,直接相加会让主干特征被 patch 特征"污染",而 1×1 卷积正好学一个线性组合。门控相乘在训练前 10 个 epoch 容易出现梯度消失,需要把门控初始化为接近 1 的值。

2.4 分类头与整体参数预算

分类头千万别复杂。我的标配是:GAP → Dropout(0.2) → Linear(128, num_classes)。加 BN 也行,但在小 batch 下端侧微调时 BN 统计量不稳定,我一般用 LayerNorm 或者干脆不加。

把整个模型的参数预算列一下,你就知道钱花在哪了:

模块参数量(约)占比
MobileNetV2 主干(裁掉尾部,宽度 1.0)1.05M78%
Patch 分支(投影 + 单层注意力 + 输出卷积)0.06M4.5%
SE 模块(后 10 个块,r=8)0.08M6%
融合 1×1 + 空间注意力0.015M1%
分类头0.001M0.1%
合计约 1.2M100%

FLOPs 方面,112×112 输入下主干约 75M,patch 分支约 8M,注意力约 3M,总计 85-90M。这个量级在四核 ARM A53 上单线程大概 25-40ms,在中端手机的大核上 8-15ms,做 15fps 的实时表情分析完全够用。

注意:参数量和 FLOPs 一定要自己用工具重新算一遍,别信论文里的数字。不同实现里 BN 的 running stats、bias 算不算参数,Fold BN 前后差异能有 5%。

3. 从零复现:数据、训练、调参的完整流程

3.1 数据准备与增强:表情任务不能用通用增强那套

数据这块我先说一个反直觉的结论:ImageNet 那套增强里,有一半对表情识别是有害的。

有害的是哪几个?大幅度的 RandAugment(特别是旋转超过 15 度、剪切、色彩反转)、CutMix、以及大比例的 RandomErasing。原因很直接:表情类别是靠局部几何关系区分的,你把嘴角那块擦掉,或者把两张脸拼一起,标签语义就崩了。我试过 CutMix 在 8 类任务上直接把精度拉低 2 个点,而且训练 loss 曲线看着还很"健康",特别有欺骗性。

有效的增强是这些:水平翻转(唯一可以放心用的几何变换,因为表情左右对称)、小角度旋转(±10 度以内)、尺度抖动(0.9-1.1)、亮度对比度微调(±0.2)、以及小面积遮挡(RandomErasing p=0.25,面积比例 0.02-0.1)。遮挡这个要保留,因为它模拟了口罩、手、头发,对"challenging"场景很有价值,但比例一定要小。

人脸对齐这一步不能省。用五点或者 106 点关键点做人脸检测 + 相似变换对齐,把眼睛对齐到固定位置,然后按 1.3-1.5 倍人脸框裁剪。对齐带来的提升在跨数据集测试时特别明显——我做过对比,同样的模型,对齐前后跨域精度能差 4-6 个点。原因是对齐消除了姿态和尺度变化,让模型专心学表情本身的形变。

还有一个实操细节:灰度化还是保留彩色。表情识别严格来说不需要颜色信息,转灰度能省 2/3 的输入通道计算,还能减少肤色和光照的干扰。但转灰度会损失"脸红""苍白"这类微弱线索。我的做法是保留三通道,但在第一层卷积后用 1×1 把通道压到 16,成本差不多,信息还多留一点。

3.2 损失函数与长尾处理:别一上来就上 Focal Loss

类别不平衡是这类数据集的老毛病。头部类别占三成、尾部类别占 2%,模型只要把所有样本都预测成头部类,准确率就有 30%,但它毫无用处。

处理顺序我建议这样排:先重采样,再标签平滑,最后才考虑 Focal Loss 或者 logit adjustment。重采样用类别平衡采样器(每个 batch 按类别倒数概率采样),实现最简单,效果立竿见影;标签平滑取 0.05-0.1,主要作用是缓解标注歧义带来的过拟合——表情标签本身就是软性的,"微笑"和"大笑"之间的边界模糊,硬标签会逼模型过度自信。

Focal Loss 我放在最后考虑,因为它会放大难样本的权重,而表情任务里的"难样本"很多其实是标注错误或者歧义样本,放大它们等于放大噪声。如果非要用,γ 取 1.0 就够了,别用检测任务里的 2.0。

另外分享一个我觉得很有用但论文里很少写的技巧:软标签蒸馏自洽。先用一个稍大的模型(比如 ResNet18)在同样数据上训一遍,把它的 softmax 输出(温度 T=2)作为软标签,去蒸馏小模型。小模型本身容量有限,硬标签下它会去拟合噪声,软标签提供了类别间的相似性结构(比如"恐惧"和"惊讶"的相似度),对小模型特别友好。我在 1.2M 模型上试过,能补回 1.5-2 个点,而且不增加任何推理成本。

3.3 训练配置与代码骨架

超参不用花里胡哨,这套配置我在多个数据集上验证过,可以直接抄:

项目取值说明
优化器AdamWbetas=(0.9, 0.999)
学习率主干 1e-3,新模块 5e-3分层学习率,新模块需要更快收敛
权重衰减0.05小模型容易过拟合,wd 略大
调度Cosine + 5 epoch warmupwarmup 比例 0.05
batch size64小于 32 时 BN 不稳
epoch60-100看数据量,早停 patience=15
输入尺寸112×112训练和推理必须一致
标签平滑0.1
混合精度开端侧训练卡也能加速

分层学习率这一点值得展开说。主干是预训练权重,已经在 ImageNet 上学到了通用特征,用大学习率会把它们冲掉;而 Patch 分支和融合层是随机初始化的,用小学习率会学得太慢,前期 loss 下降很慢,容易让人误以为结构有问题。我一般设成 1:5 的比例,如果数据量很小(每类不到 500 张),主干干脆冻结前两个 stage。

训练代码的关键片段:

import torch import torch.nn as nn from torch.optim.lr_scheduler import CosineAnnealingLR def build_param_groups(model, base_lr=1e-3, new_lr=5e-3): backbone, new = [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue (backbone if name.startswith('backbone') else new).append(p) return [ {'params': backbone, 'lr': base_lr}, {'params': new, 'lr': new_lr}, ] criterion = nn.CrossEntropyLoss(label_smoothing=0.1) optimizer = torch.optim.AdamW(build_param_groups(model), weight_decay=0.05) scheduler = CosineAnnealingLR(optimizer, T_max=epochs - warmup, eta_min=1e-5) for epoch in range(epochs): model.train() for img, label in loader: img, label = img.cuda(non_blocking=True), label.cuda(non_blocking=True) with torch.cuda.amp.autocast(): logits = model(img) loss = criterion(logits, label) optimizer.zero_grad(set_to_none=True) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) scaler.step(optimizer) scaler.update() scheduler.step()

有个坑要提醒:CosineAnnealingLR的T_max如果设成总 epoch,warmup 那几轮也会被算进余弦周期,导致前期学习率下降得比预期快。正确做法是用LambdaLR手动拼 warmup + cosine,或者用带 warmup 的封装。这个细节不注意,前 10 个 epoch 的收敛曲线会很难看。

3.4 参数量、FLOPs 与真机延迟实测

训练完别急着庆祝,先做三件事:算参数、算 FLOPs、测延迟。前两个用现成工具几行代码搞定:

pip install thop onnx onnxruntime
import torch from thop import profile model = PAttLite(num_classes=8).eval() dummy = torch.randn(1, 3, 112, 112) macs, params = profile(model, inputs=(dummy,), verbose=False) print(f"Params: {params/1e6:.2f}M, MACs: {macs/1e6:.1f}M")

延迟测试必须用真机,而且要注意几个陷阱:

  • batch=1 单线程,这才是端侧真实场景,不要用 batch=32 的平均值去推算。
  • 预热 20 次再取中位数,第一次推理包含内存分配和算子编译,耗时可能翻三倍。
  • 区分单核和大核,中端手机上大小核性能差 3-4 倍,绑核结果完全不同。
  • 深度可分离卷积的实际加速比达不到理论值,前面提过,访存瓶颈明显。

我之前做过一组对照:同一个模型,在 PC 上用 PyTorch 测是 3ms,导出 ONNX 用 onnxruntime 在四核 ARM 板子上测,单线程 32ms,四线程 12ms。如果只看 PC 数字去估端侧,会错得离谱。所以我的建议是:模型设计阶段看 FLOPs,选型阶段必须上目标硬件实测。

4. 常见问题与排查技巧实录

4.1 训练不收敛、精度抖动的排查路径

这套结构里最容易出问题的三个地方,我按出现频率排个序:

第一个是 Patch 分支的初始化和学习率。因为它从主干中间分叉出来,如果初始权重过大,会在早期就把主干特征带偏,表现为 loss 前几个 epoch 震荡、验证精度上不去。解决办法是把 patch 分支最后一个 1×1 卷积初始化为接近零(比如nn.init.zeros_或者乘以 0.01),让它在训练初期近似"旁路",随着训练慢慢起作用。这一招在 ResNet 的零初始化残差里被验证过,搬到多分支结构上同样有效。

第二个是 BN 与 batch size 的冲突。小模型 + 小 batch 时,BN 的统计量方差很大,训练 loss 会周期性抖动。要么把 batch 提到 64 以上,要么改用 GroupNorm(但小模型上 GN 通常比 BN 掉点 1 个左右),要么用梯度累积模拟大 batch。

第三个是数据管线的隐性 bug,这个最坑。比如对齐时关键点检测失败返回了默认全零,导致整张图被裁成黑块;或者标签映射表和文件名对不上,某些类别永远采样不到。我遇到过最离谱的一次是数据加载器的 shuffle 在验证集上也开着,导致验证精度虚高 6 个点,白高兴了两天。

排查清单我整理成表:

现象高概率原因处理办法
loss 前 10 epoch 震荡不降patch 分支初始权重过大输出层零初始化,学习率降到 1e-3
训练精度高、验证精度低 5pt+过拟合或验证集泄漏检查增强强度、关闭验证集 shuffle、加 dropout
某一类召回率恒为 0类别映射错位或采样器 bug打印混淆矩阵,逐类核对样本数
训练后期精度突然崩塌学习率余弦到 0 后数值不稳eta_min 设 1e-5,别真降到 0
换数据集精度掉 15pt+域偏移,人脸尺度/光照差异目标域 BN 重估计 + 少量样本微调
模型输出全是同一类标签平滑过度或 class weight 失衡平滑降到 0.05,去掉 class weight 试一次

4.2 部署侧掉点与量化问题

训练环境跑通,导出 ONNX 部署到端侧,这里才是真正掉点的地方。

导出时最常见的坑是动态 shape 和算子不支持。unfold在部分推理引擎里支持不好,我建议在导出前把 patch 切分用reshape + permute重写一遍,或者干脆在模型里固定输入尺寸 112×112,把相关维度写死。ONNX 导出后一定要用onnxruntime跑一遍数值比对,输入同一张图,PyTorch 和 ORT 的输出最大绝对误差应该在 1e-4 以内,超过就说明有算子数值不一致。

量化是掉点重灾区。8bit 后训练量化(PTQ)在表情任务上通常会掉 2-4 个点,比分类任务严重,因为表情的特征响应幅度差异很大——"惊讶"的激活值可能是"中性"的好几倍,量化 scale 一旦被大值主导,小值的分辨率就没了。我的处理办法有三条:一是对 Patch 分支的 attention 相关层不做量化(混合精度,保留 FP16);二是用几百张校准图做逐层敏感度分析,把掉点最狠的两层排除;三是如果掉点超过 3 个点,直接上量化感知训练(QAT),多花一天时间,能救回来大部分。

还有一个容易被忽略的:输入预处理的一致性。训练时用的是(x - 0.5) / 0.5还是 ImageNet 的 mean/std,部署端必须完全一致。这个错犯一次能掉 10 个点,而且很难从日志上看出来,因为模型就是"正常地"输出了错误结果。

4.3 几条用血换来的避坑清单

  • 别在 112 输入上直接套 224 预训练权重的位置编码。如果用了任何带位置参数的结构,尺寸变化必须重采样,否则精度断崖。
  • patch 分支的 token 数要能被 batch 整除,写死 grid 值比动态计算安全,动态计算在遇到奇数尺寸特征图时会崩。
  • 跨数据集测试一定要先做人脸检测的域适配,一个在受控数据上训练好的检测器,到野外数据上漏检率可能高达 20%,这批漏检样本会直接进入"未检测"分支,让整体指标虚高。
  • 混淆矩阵比总准确率重要十倍。表情任务里"中性-难过""恐惧-惊讶"这两对永远是主要混淆来源,看清楚哪一对在恶化,才知道该往哪个方向补数据。
  • 微调时先冻 BN,尤其是目标域样本少于 1000 张时,BN 统计量会被小样本带偏,冻结后只训 gamma/beta 或者干脆不训。

5. 评估与落地:怎么判断这套东西值不值得用

5.1 指标选择:准确率之外的三个视角

只看总体准确率会骗人。我评估表情模型时固定看四组数字:总体准确率、宏平均 F1、混淆矩阵、以及跨数据集泛化落差。

宏平均 F1 比准确率更能反映长尾表现,因为它对每个类别一视同仁。如果准确率 78%、宏 F1 只有 0.62,说明头部类别吃了大部分功劳,尾部类别基本废了。这时候去优化整体准确率是自欺欺人,应该先把重采样和损失函数调明白。

跨数据集泛化落差是我最看重的指标。在一个受控采集的 7 类数据上训到 88%,换到野外 8 类数据上测,能剩多少?如果只剩 55%,说明模型学的是数据集特征而不是表情特征——肤色、光照、拍摄设备都可能成为捷径特征。这个落差在 20 个点以内算正常,超过 30 个点就要考虑域适应手段了,比如目标域的 BN 重估计(用无标签目标数据过一遍前向,只更新 BN 统计量,一行代码的事,通常能救回 3-5 个点)。

还有一个小技巧:做类别混淆的"软化"评估。把"恐惧"预测成"惊讶"和预测成"高兴",错误的严重程度显然不同。评估时可以给混淆矩阵加一个语义距离权重,虽然不用于发布指标,但能帮你判断模型是不是"错得离谱"还是"错得合理"。

5.2 端侧落地的现实约束

真正部署时,除了精度和延迟,还有几个硬约束要考虑。

内存峰值比参数量重要得多。1.2M 参数的模型,FP32 只占 5MB,但推理过程中的中间激活,在 112×112 输入下峰值可能在 15-25MB。低端设备上这个数字会直接影响能否和其他模块共存。降低峰值最有效的办法是算子融合(Conv+BN+ReLU 融合,推理引擎一般自动做)和避免在模型里保存大尺寸中间特征图。

首帧延迟。端侧应用往往不是连续推理,而是检测到人脸后才触发。这时候模型加载、内存分配、算子初始化的开销都算在首帧里,可能是稳态延迟的 5-10 倍。解决办法是预热——应用启动时用一张空白图跑个 3-5 次,把延迟摊掉。

输入分辨率是一把双刃剑。从 112 提到 128,FLOPs 涨 30%,精度可能只涨 0.8 个点;从 112 降到 96,FLOPs 降 27%,精度掉 1.5 个点。如果你的场景是近距离、人脸占据画面主体,96 就够了;如果是远距离监控,人脸只有 60 像素,那再高的分辨率也救不回来,应该在检测阶段就做超分或者直接放弃该帧。

模型更新与版本管理。表情识别的标签体系在不同场景下不一样(有的要 7 类,有的只要 3 类:正/中/负),分类头要设计成可替换的。我的做法是把主干和 patch 分支导出成固定的特征提取器,分类头单独一个小文件,换场景只换头,重新训练成本从几小时降到几分钟。

6. 我在这类轻量方案上的几点实际体会

踩过几轮之后,我对"轻量化"这件事的看法变了不少。最开始我追求参数量数字好看,把模型压到 0.5M,结果真机延迟只降了 3ms,精度掉了 4 个点,完全是赔本买卖。后来才明白,端侧的瓶颈往往不在参数,而在访存和算子调度——一个 1.2M 的模型如果算子都是常见卷积,跑起来可能比一个 0.8M 但塞了一堆自定义算子的模型还快。

还有一个体会是:结构上的先验比堆数据更划算。Patch 分支本质上就是告诉网络"往局部看",这个先验在数据量只有几万张时价值极高,因为它是免费的归纳偏置。你在数据上多标两万张,可能也就换来 1-2 个点,但一个设计合理的局部建模分支能稳定给到这个数,还不增加推理成本。

最后说一个我觉得最容易被低估的点:对齐和裁剪的质量,比模型结构重要。同一个 PAtt-Lite 结构,用粗糙的人脸框裁剪和对齐后的紧致裁剪,精度差距能到 5 个点以上。如果你现在的结果不理想,先别急着改网络,把数据管线翻出来,随机抽 50 张图可视化一下预处理结果,十有八九能发现问题。这个习惯帮我省下的时间,比读十篇结构改进的论文都多。

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

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

立即咨询