ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于ResNet-50的人脸多属性识别:多任务学习工程实践与踩坑复盘

基于ResNet-50的人脸多属性识别:多任务学习工程实践与踩坑复盘

需求方最开始给的需求只有一句话:给一张正脸照片,同时返回年龄、性别、人种三个属性。团队里很快分成两派,一派认为老老实实训三个模型最稳,另一派想把三个任务塞进同一个网络里用多任务学习一把搞定。最终我们选了后者,基于 ResNet-50 作为共享主干,在 TensorFlow 里实现了年龄、性别、人种三个分支同时输出。这个方案从训练开销、部署复杂度和实际效果来看,都明显优于三个独立模型,但过程中踩的坑也不少。这篇文章是完整的技术复盘,包括网络结构设计、数据清洗、损失函数平衡、训练策略和部署量化,适合正在做多属性人脸识别或者想了解多任务学习工程细节的开发者参考。我会尽量把每个“为什么这么选”讲清楚,而不是只丢结论。

1. 为什么把三个属性塞进同一个模型:先算清这笔工程账

1.1 三个任务依赖同一组底层特征

人脸属性识别的输入是一张已经对齐过的人脸图,输出是年龄、性别、人种三个标签。乍看是三个互不相干的分类或回归问题,但实际上它们在特征层面高度重叠:无论是判断性别、估计年龄还是判断人种,卷积网络最先学到的东西都是一致的——脸型轮廓、五官比例、皮肤纹理、眼睛鼻子嘴巴的相对位置。这些低层次和中层次的特征,根本不需要为每个任务单独训练一遍。

硬参数共享(所有任务共用同一套主干参数,只有最后的分支各自独立)正是建立在“底层特征复用”这个前提上。换个说法:人的视觉系统也不会分别准备三套线路来看年龄、性别和人种,而是同一眼扫过去综合判断。多任务网络做的事情和这个逻辑一样,只不过把“同一眼”落实成了共享卷积层,“综合判断”落实成了三个并行输出头。

1.2 三个独立模型看着简单,账算下来全是成本

如果把三个任务拆开,最直接的方案是三个 ResNet-50 各跑各的。我来给你算一笔具体的账,假设输入都是 224×224 的图像:

  • 参数量:一个 ResNet-50 主干约 2360 万参数,三个模型仅主干就是 7000 多万;而共享主干的多任务模型,主干只保留一份,三个分支加起来通常不到 100 万参数,总数大约 2400 万。
  • 推理开销:三个模型需要三次前向计算,多任务模型只需要一次前向就拿到三个结果,GPU 上的吞吐差距就是三倍级别。
  • 训练开销:三个模型各自要吃一遍数据,多任务模型同一批数据喂一次,三个任务同时更新,训练时间省一大截。
  • 部署维护:一个服务接口和一个模型文件,远比三个模型三个版本管理起来省心,尤其是后面还要做量化、灰度、回滚的时候。

这还只是工程账。从效果角度,多任务学习还有一个隐性红利:共享主干等于用三个任务的标签共同约束同一组参数,相当于一种隐式的数据增强和正则化。在人脸属性数据集标签本身就有噪声的情况下,这种“一个样本同时提供三份监督信号”的做法,通常能让每个单任务都比单独训练略好一点。

1.3 哪些场景不适合硬参数共享

多任务不是万能药。如果两个任务的输入分布差异很大,或者任务之间的相关性很低,强行共享主干反而会互相拖累。举个反例:一个是近景高清人脸的年龄识别,另一个是远距离人脸的性别判断,前者依赖皮肤纹理细节,后者更多依赖整体轮廓,共享底层会让两个任务都学不痛快。

我在动手之前先做了一个小实验:把单任务模型在中间层的特征向量取出来算相关性,确认三个属性任务的特征相关性足够高,才决定使用硬参数共享。如果相关度不够,我会改成软参数共享,也就是每个任务保留一小部分独立参数层,而不是硬塞进同一个主干。

2. 主干为什么选 ResNet-50:不是因为它最强,而是因为它最稳

2.1 残差连接解决的是“能不能训深”的问题

深度网络大规模应用之后,视觉任务但凡要上深度模型,基本绕不开 ResNet 这个选择。它的核心贡献是残差连接:让每一层学习的是输入和输出之间的残差 F(x),而不是完整的映射 H(x)。在反向传播时,恒等映射那条通路保证了梯度可以几乎无损地回传到浅层,所以网络深度增加带来的退化问题被压住了。

相比之下,纯堆叠类的卷积网络一旦层数多了,训练就很难稳定收敛。在人脸属性这种中等规模数据集上,ResNet-50 的深度已经足够表达人脸特征,训练速度比 ResNet-101 快不少,精度差距通常在 1 个点以内,很多时候甚至没有差距。所以我的选型原则很朴素:先选一个最稳、最不容易出问题的主干,把其他变量控制住,而不是一上来就追最新的结构。

2.2 ResNet-50 和更轻、更新模型的取舍

我也认真考虑过更轻量化的 MobileNet 系列和精度上限更高的 EfficientNet 系列。MobileNet 的优势是参数量和计算量小,适合部署在低算力设备上;EfficientNet 精度更高,但调参敏感,需要更精细的训练配置。人脸属性识别这个场景尴尬在哪呢?它不像通用人脸识别那样动辄千万级数据,数据集通常只有几万到几十万张,模型太大容易过拟合,模型太小又学不到足够的纹理细节。

ResNet-50 正好落在“够用且不浪费”的甜区。如果将来要上移动端,再考虑用蒸馏把 ResNet-50 的知识迁移到轻量网络上,而不是一开始就赌轻量网络能训出同样的效果。这里补充一句,很多人的第一反应是直接用换主干来“追赶精度”,但在我做的几组对比里,主干从 ResNet-50 换成 ResNet-101,三个任务的指标几乎没有变化,倒是训练时间实打实增加了将近一倍,性价比很低。

2.3 预训练权重和输入预处理:两个最容易被忽略的细节

使用在通用图像数据集上预训练好的 ResNet-50 权重,是最省事也最有效的起点。但有两个细节很容易翻车。第一,加载预训练权重后,输入归一化必须和预训练时保持一致,通常是用固定的 mean 和 std 把像素值缩放到标准范围,不能想当然地认为模型自己会适应。第二,人脸要先对齐,再缩放,最后送入网络。

人脸对齐包含检测人脸和定位五个关键点(双眼、鼻尖、嘴角),然后做仿射变换把脸摆正。我第一版偷懒,直接把人脸检测框裁剪后 resize 成 224×224,性别准确率一直上不去,后来加上五点对齐并统一把眼睛位置放到固定坐标,指标才明显改善。这个环节对最终效果的影响,比换一个更新的主干大得多,建议做这个项目的同学先把对齐管线稳定下来再碰网络结构。

3. 网络结构:一个 ResNet-50 主干,三个“性格不同”的任务头

3.1 最简结构:GAP 之后直接分叉

先给一个可以直接抄的骨架,用 TensorFlow 的 Keras 接口写:

import tensorflow as tf from tensorflow.keras import layers inputs = layers.Input(shape=(224, 224, 3), dtype=tf.float32) base = tf.keras.applications.ResNet50( include_top=False, weights=None, input_tensor=inputs, pooling=None, ) x = base.output # (7, 7, 2048) x = layers.GlobalAveragePooling2D()(x) x = layers.BatchNormalization()(x) x = layers.Dropout(0.3)(x) gender_head = layers.Dense(1, activation="sigmoid", name="gender")(x) age_head = layers.Dense(10, activation="softmax", name="age")(x) race_head = layers.Dense(num_races, activation="softmax", name="race")(x) model = tf.keras.Model(inputs, [gender_head, age_head, race_head])

这个结构的思路是:主干输出 7×7×2048 的特征图,经过全局平均池化变成 2048 维向量,再做一次 BN 和 Dropout,然后直接分叉。三个任务头之间除了共享这个向量之外,没有任何相互依赖关系。

为什么不加一层共享全连接再分叉?我试过在 GAP 后面再接一个 512 维共享 FC,效果不升反降:中间层和数据集规模不匹配,多出来的参数量在几十万张图的规模下只会放大过拟合,Dropout 都压不住。去掉之后,验证集上三个任务的表现都有小幅提升。所以在数据集不是特别大的时候,分叉之前的结构越简单越好。

3.2 三个任务头为什么长得不一样

  • 性别头:二分类问题,我用单个神经元加 sigmoid,输出值在 0 到 1 之间,0 表示女性,1 表示男性。相比两个神经元加 softmax,少一个参数,训练时数值更稳定,评估时阈值也可以灵活调整。
  • 年龄头:输出 10 个区间的概率分布,预测时取期望得到最终年龄。为什么不直接输出一个连续值?因为直接回归的年龄模型很容易被标签噪声带偏,而且容易“学到均值”,后面数据工程那节会详细讲。用区间分布的好处是模型只需要判断“这个人大概在哪个年龄段”,同时还能通过 softmax 的分布看出模型的不确定度。
  • 人种头:多分类 softmax,类别数量取决于业务定义。我的实现里先按常见的四到五类做,训练好之后再根据混淆矩阵决定是否需要合并尾部类别。

三个头都建好之后,用 tf.keras.Model 把输入和三路输出绑定,编译的时候 loss 传一个字典,把每个头的损失分别指定就行。

3.3 我试过但最终放弃的两个结构

第一个是辅助监督。我在 ResNet 第三阶段的输出上额外接了一个性别分类头,想让中间层早点学到性别相关的判别特征。实验结果是最终精度几乎没变化,训练时间却长了约 15%。后来分析,中间层本来就已经包含足够多的性别信息,辅助头只是把同样的梯度多传了一份,属于重复劳动。

第二个是分支间交互,比如让年龄分支把性别分支的输出当作额外输入,或者在三个分支之间加注意力。在小数据集上这种结构训练非常不稳定,收敛速度慢,而且很容易出现某一个分支主导整个模型的情况。我的结论是:三个属性任务的相关性已经在共享主干里被充分吸收了,分支之间再加显式交互属于过度设计。先把最简结构跑通、跑稳,再考虑加花。

4. 数据工程:三份标签的清洗、对齐和建模方式

4.1 多数据源合并时的标签统一

现实中没有一份现成的数据集同时带年龄、性别、人种三份干净标签,通常要把多个公开的人脸属性数据集合并使用。合并第一步是对齐标签格式,这一步特别容易出问题。有的数据集年龄给的是出生年份和拍摄年份,需要做减法;有的给的是整理好的整数年龄;性别有的用 0/1,有的用字符串;人种类别的定义更是五花八门。

我最后统一成了这样一套内部规范:性别用 0、1 表示女、男;年龄范围 0 到 100,统一取整数;人种按业务定义分成若干类别。任何无法确定含义的标签,宁可去掉也不用。因为一条脏标签在训练里产生的破坏力远大于它本身的价值,尤其是在多任务模型里,一条脏标签会同时污染三个任务的梯度。

4.2 标签噪声清洗

年龄和人种的标签噪声最让人头疼。年龄是被标注者猜出来的,同一张脸标成 28 岁和 32 岁都很正常;人种标签本质上是基于外观的主观分类,边界本身就是模糊的。我的清洗策略分三层:第一层用开源人脸检测模型跑一遍所有样本,用检测置信度、模糊度和遮挡程度过滤掉质量太差的图;第二层做标签交叉检查,比如同一个人出现在多个样本里,年龄标签却相差超过 10 岁,这一组样本就标记为可疑,人工抽检后决定修正还是删除;第三层按年龄段分布做统计,如果某个年龄段的样本数量出现明显异常的尖峰,大概率是标注时出现了按批复制标签的情况,这类样本直接丢弃。

清洗听起来繁琐,但回报非常直接。我之前做过对比:清洗前和清洗后各训一轮,性别准确率提升不到 1 个点,但年龄 MAE 下降了 0.6 左右,人种尾类召回率提升了将近 3 个点。原因是年龄和人种对噪声更敏感,清洗对它们的帮助比对性别大得多。

4.3 年龄任务的三条技术路线

年龄建模有几种做法,我实际对比过,差别很大。

方案优点缺点我的判断
直接回归实现最简单,输出连续年龄对噪声敏感,预测容易收拢到均值附近不推荐
区间多分类鲁棒,能表达不确定性桶边界有误差,桶内无法区分推荐
序数回归利用年龄顺序关系实现和调参成本较高进阶可试

我采用的是区间多分类加期望值:把 0 到 100 岁切成 10 个桶,每个桶用一个神经元表达“这张脸属于这个年龄段的概率”,预测时用每个桶的中心年龄加权求和作为最终输出。这里还有一个进阶技巧叫软标签:不要用 one-hot 硬标签,而是给相邻桶分配高斯权重。比如真实年龄是 32 岁,落在 30-34 那个桶,就把 28-30 和 34-36 两个桶也分一点概率。这样模型不会被边界搞得太死,拟合更平滑,实测在年龄段交界处的误差明显下降。

4.4 人种类别不平衡处理

人种分类的样本分布通常是不平衡的,头部的类别可能占一半以上,尾部的几个类别加起来不到百分之十。如果不做处理,模型会把所有不确定的样本都推给头部类别,整体准确率看着还行,尾部类别几乎废掉。

我的处理方式是组合拳:训练集里对尾部类别做过采样,损失函数里按类别样本数的反比加权,同时保留一个完全真实分布的验证集来评估。这里要特别提醒,调整加权之后,训练集上的整体准确率会下降,这是正常的,因为模型不再“无脑猜头部类别”了,真实的业务指标反而会变好。

关于人种属性,多说一句:这个标签本身是主观且带有社会争议的概念,本文讨论的只是基于视觉外观的机器学习分类任务。如果要把这类能力放到生产环境,必须经过公平性评估和数据合规审查,不能用于任何对人的歧视性决策。

5. 损失函数与训练策略:怎么让三个任务不在一个锅里打架

5.1 损失函数组合与权重设置

三个任务的损失类型不一样:性别是二分类,用二元交叉熵;年龄是区间多分类,用多分类交叉熵;人种也是多分类交叉熵。总损失就是三个损失的加权和。难就难在权重怎么定。

我的经验是先各自训一个只有单任务头的模型,记录每个损失收敛后的量级,然后把初始权重设为量级反比,再结合实际训练曲线微调。这个项目里最后用的是 w_age=1.0、w_gender=0.8、w_race=1.2。原因也很直接:年龄最难,人种类别最不平衡,所以权重大一些;性别相对简单,权重小一点可以防止它对共享特征产生过强的主导。注意这只是 loss 层面的数值,不代表它们在总损失里的实际贡献比例,具体还要看训练时的下降曲线,但至少给了个合理的起点。

5.2 分阶段训练:先让任务头学会读特征,再放开主干

多任务模型最忌讳一上来就全参数微调。主干是预训练好的,三个任务头是随机初始化的,如果第一步就让所有参数一起更新,任务头的梯度噪声会一路传回主干,预训练特征会被快速破坏,还可能出现训练初期某个任务的 loss 先把主干拽向一个方向,另一个任务一直在追赶的情况。

我用的策略是分三阶段:

  1. 冻结主干全部参数,只训练三个任务头,学习率 1e-3,Adam 优化器。这个阶段让任务头快速学会如何从 2048 维共享特征里读取自己要的信息。
  2. 解冻主干最后一阶段,学习率降到 1e-4,让靠近输出层的特征重新适配任务。
  3. 解冻全部层,学习率降到 1e-5,配合余弦退火,做整体精调。

每个阶段之间我会跑一遍验证集,确认没有掉点再进入下一阶段。这个方法看起来笨,但在多任务场景里非常稳,能避免掉大部分“三个 loss 互相打架”的问题。优化器我用了 Adam,beta1 取 0.9,beta2 取 0.999,梯度裁剪阈值 1.0。训练时还开了混合精度,整体训练速度大约提升三到四成,指标没有明显变化。

5.3 梯度冲突的高级话题:什么时候才需要用

多任务论文里经常强调梯度冲突,也就是不同任务的梯度方向不一致,导致共享参数更新互相抵消。常见解法有动态调整任务权重的 GradNorm 方法,也有把冲突梯度投影后再合并更新的 PCGrad 方法。我在这个项目里都实际试过。GradNorm 确实能让 loss 收敛得更稳,但训练时间大约增加两成;PCGrad 实现起来稍麻烦,在三个高度相关的任务上收益并不明显。

我的工程结论是:当任务之间的相关性足够高,权重设置和分阶段训练都做对的情况下,固定权重就够用了。如果哪天业务加了一个跟人脸属性毫无关系的任务,再把这些高级方法捡起来也不迟。先把基础做扎实,比盲目上高级优化器更重要。

6. 训练踩坑实录:多属性识别最容易翻车的三个地方

6.1 性别分支学到了“发型捷径”

第一个坑是性别分支在偷偷用“头发长度”做判断。数据集中长发样本绝大多数是女性,模型发现这个规律之后,就不再认真学脸部结构特征了,结果一到真实场景,短发女性、戴帽子的女性、长发男性全部翻车。这是典型的捷径学习。

我的解决方法有两层:一是数据增强里加入随机水平翻转、随机擦除和随机裁剪,打破“长发等于女性”这种虚假关联,尤其是随机擦除可以让模型不能只依赖某一个局部区域;二是在数据层面刻意给长发男性和短发女性多一点采样权重。修复之后,短发女性的误判率从大约 9% 降到了 4.5%,效果非常直观。

6.2 年龄模型在均值附近“躺平”

第二个坑出现在年龄任务上。最初用直接回归做年龄,收敛之后 MAE 看起来不错,但把预测值分布画出来才发现,几乎所有样本的预测都集中在 25 到 40 岁之间,老年人被预测成中年人,儿童也被预测成少年。原因是回归损失会天然倾向于把不确定的样本推向训练集的均值,这是一种风险最小的行为。

改成区间多分类加期望值之后,这种“躺平”明显缓解,因为模型必须对十个桶都给出概率,不再允许把所有不确定性压缩到一个单点输出。评估年龄模型时我也多留了一招:除了看 MAE,还要看年龄段混淆矩阵,观察是不是每个年龄段都分布在正确位置附近,这比单一指标可靠得多。

6.3 整体准确率会骗人,指标口径要提前对齐

第三个坑是指标口径。人种分类的头部类别占了数据一半以上,模型只要学会“全猜头部”,整体准确率就能到 85% 以上,看起来很好,但尾部类别的召回率可能只有 40%。所以我对外报的指标改用 balanced accuracy 和 macro recall,同时在训练时就用这些指标做模型选择,而不是看整体准确率。

这里有个容易忽略的点:评估指标和训练数据分布必须对齐。如果训练数据里的类别分布和业务上线后的真实分布不一样,再好看的指标也没有意义。上线前一定要拿一份接近真实业务分布的数据重新评估,这一步不能省。

7. 从训练到上线:导出、量化和吞吐优化

7.1 导出 SavedModel:把归一化也装进模型里

训练完的 Keras 模型不能直接丢给服务端,我做的第一件事是重新组装一个推理模型,把预处理归一化也作为一层放进计算图里,这样上游只需要传原始图像,不再需要知道 mean 和 std 这些细节。代码大致是这样:

serving_input = layers.Input(shape=(224, 224, 3), dtype=tf.uint8) cast = tf.cast(serving_input, tf.float32) norm = (cast - mean) / std # 这里的 mean/std 和训练时一致 features = trained_model(norm) outputs = { "gender": features["gender"], "age_dist": features["age"], "race": features["race"], } serving_model = tf.keras.Model(serving_input, outputs) tf.saved_model.save(serving_model, "model_dir")

导出之后还要注意签名管理。我会给性别和人种直接输出概率,给年龄同时输出年龄段概率分布和期望年龄两个字段,这样下游可以自己决定用哪个,省得服务端再做一次转换。模型文件也要做版本号管理,方便回滚。

7.2 FP16 和 INT8 量化的实测差异

多任务模型在部署时优势更明显,一次前向就出三个结果,但模型体量仍然有约 2400 万参数,推理优化还是要做。我实际测试了两条路线:

  • FP16 量化:GPU 上几乎不影响精度,性别和人种准确率基本不变,年龄 MAE 变化也在 0.2 以内,推理吞吐大约提升三到五成,属于无脑收益,直接上。
  • INT8 量化:需要用一小部分训练数据做校准集。实测性别和人种受影响很小,但年龄的概率分布会变得略微集中,期望年龄的计算会发生可见偏差。原因是年龄头对输出概率的微小变化很敏感,量化误差被软标签放大。如果业务对年龄精确度要求高,INT8 需要更谨慎地挑选校准集,或者只量化主干,保留任务头为 FP16。

7.3 批量推理是吞吐提升的最大杠杆

最后一个建议是,千万不要一次只送一张图进模型。人脸属性识别在相册分类、客流统计这类业务里通常都是批量场景,动态 batching 是吞吐提升最大的杠杆。同样的模型,单张推理和一个 batch 32 的推理,单张平均耗时能相差两三倍,因为 GPU 的并行度被更充分地利用起来。

TensorFlow 服务端可以开启动态 batch,再加上预取队列,高峰期的吞吐从每秒一两百张提升到五六百张很常见,具体数字取决于显卡和输入图大小,但方向肯定是对的。如果上游是异步任务队列,还可以直接把批量大小设为固定值,进一步降低调度开销。

最后聊一点个人体会。这个项目做下来,我最大的感受是:在人脸多属性识别里,标签质量对结果的影响大于模型结构,数据工程上花的时间永远值得。另一个体会是多任务学习在中小规模数据下特别划算,一个样本喂进去同时给三个任务提供监督信号,等于变相扩大了有效数据量,这是单模型结构很难获得的红利。如果让我重做一遍,我不会急着升级主干或者换注意力结构,而是先把年龄软标签的平滑参数、人种族别的定义和类权重重新调一遍,这些细节带来的提升比换模型更明显。最后再分享一个实用小技巧:训练启动后的前 20 个 step,我会盯着三个 loss 是否都在同步下降,如果有一个任务始终不动,先停下来检查是这个分支的学习率有问题,还是共享特征被另一个任务带偏了,别等跑了几小时再发现。多任务模型的排查,越早介入越省事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进