做一个人脸多属性识别项目,多数时候并不是为了炫技,而是业务侧真的需要一个“一次推理、同时返回年龄、性别、人种”的统一接口。如果按老思路给三个属性各训练一个模型,线上就得同时维护三套推理链路,内存、显存、延迟都会被放大三倍,这在真实工程环境里是很难接受的。我这次在 TensorFlow 下基于 ResNet-50 + 多任务学习实现的 CNN 人脸多属性识别模型,就是冲着“高效”这两个字去的——一个骨干网络共享特征,三个分支分别输出 age、gender、race 三类结果。这篇文章把我从模型设计、数据准备、损失函数配比到训练踩坑的全过程都整理出来,代码思路和 TensorFlow 实现细节可以直接参考。
先说几个读者需要提前知道的关键词:ResNet-50 残差网络、多任务学习、共享卷积主干、人脸对齐、动态损失加权。这篇文章适合两类人看。一类是刚接触人脸属性分析、想用一个模型搞定多属性输出的算法工程师;另一类是有单任务模型训练经验、但对多任务学习中的 loss 配比和训练稳定性问题比较头疼的开发同学。我会尽量把为什么这么做、不这么做会踩什么坑讲透,而不是只丢一份能跑的代码。
1. 为什么用“ResNet-50 + 多任务”这个组合
1.1 从三个独立模型到一个共享网络
先说一个很现实的对比。传统做法是分别训练三个人脸属性模型:性别二分类模型输入一张人脸图,输出 male/female;年龄模型做回归或者年龄段分类;人种模型再做一次多分类。每个模型都需要完整的输入 pipeline、独立的特征提取层、独立部署文件。我见过有些团队就是这么干的,结果线上 GPU 推理时三份前向计算全部跑一遍,显存占用和单次请求延迟直接翻了两三倍。更麻烦的是,如果业务后续要新增一个“是否戴眼镜”的属性,又得重新训练第四个模型,整个系统越来越臃肿。
多任务学习解决的就是这个问题。用一个共享的卷积骨干提取通用的人脸特征,然后在特征之上分支出三个轻量分类/回归头,分别预测年龄、性别、人种。这样一次前向计算,三个结果同时出来,参数量相比三个独立模型大幅下降。而且在训练阶段还有额外的好处:三个任务在共享层上互相约束、互相补充,性别任务学到的局部纹理特征可以帮助年龄任务判断皮肤状态,年龄任务学到的全局结构信息也能辅助人种分类。这种“隐式数据增强”的效果,在很多实验里都被证实过。
1.2 ResNet-50 作为骨干网络的取舍
ResNet-50 不是最新的网络,也不是最轻的网络,但在这个场景下它是我认为最稳的骨干选择。它的残差结构解决了深层网络退化问题,50 层的深度对“人脸这种结构相对固定的图像”来说已经能提取到足够丰富的语义特征。相比 VGG16 这种纯堆叠卷积的经典网络,ResNet-50 的参数量更少,计算量也明显更低,训练速度和推理速度都更有优势。相比 MobileNet 这类轻量网络,ResNet-50 的特征表达能力又更强,准确率上限更高,特别是在年龄、人种这种需要精细纹理和全局结构信息的任务上。
我实际测试过三组对比:用 ResNet-50 做共享主干,性别准确率能到 97% 左右,年龄绝对误差(MAE)在公开数据集上可以压到 4.5 岁上下;换成 MobileNetV3 做主干,年龄误差会升到 5.5 岁左右;换 VGG16 的话训练时间多出将近一倍,准确率提升却微乎其微。综合来看,ResNet-50 是“效率与精度平衡点”上的最优解。而且 TensorFlow 对 ResNet-50 的支持非常成熟,可以用在 ImageNet 上预训练好的权重做迁移学习,这在数据量有限的时候非常关键。
1.3 三个属性在任务形态上的差异
设计多任务模型的第一步,是搞清楚每个属性到底是什么类型的任务。性别识别是标准的二分类,年龄可以是回归也可以是分桶分类,人种识别是多分类。这三者的 loss 形态天然不同,网络输出层的设计也完全不一样。
年龄用回归还是用分类,我纠结过一阵。直接回归连续值,用 MAE Loss,模型在中间年龄段表现还行,但对 0-10 岁和 60 岁以上这种边界样本的预测误差特别大——标注本身就不精确,回归对标签噪声非常敏感。后来我改用“年龄分桶分类”:把 0-100 岁按每 1 岁一个桶,用 softmax 输出,推理时对概率分布做加权期望得到预测年龄。这种方式对噪声标注的容忍度高很多,因为相邻桶之间的 softmax 概率可以互相分摊,训练稳定性也更好。性别就是标准二分类,输出 2 维。人种识别分成 5 类,输出 5 维。三种输出头的结构完全不同,但在共享特征层之上,它们可以很好地共存。
2. 数据准备:人脸多属性识别的“地基”
2.1 数据集选择与清洗
这个项目的数据来自两个部分。一部分是公开的人脸属性数据集,当初找数据集的时候重点看了看 UTKFace、FairFace 这类自带年龄、性别、人种标注的公开数据,因为它们的标签形式和我的任务完全一致,省去了自己标注的麻烦。另一部分是业务方提供的真实场景人脸图,这部分数据是经过匿名化脱敏处理的,只有人脸框和属性标签,不保留任何可识别个人身份的信息。两部分数据混合起来作为训练集,公开数据负责提高泛化能力,业务数据负责适配真实场景的分布。
数据清洗是整个流程里最容易被低估的环节。公开数据集里经常出现标签错乱的情况——一张明显是儿童的脸被打上“40 岁”的标签,一张女性面孔的性别标注是 male。如果直接拿这些脏数据训练,模型会被带偏,特别是年龄这种回归/分类任务,个别离谱的标注就可能导致预测结果出现明显偏移。我当时的做法是:先用一个已有人脸分类模型对全量数据做一次预标注,然后对比原始标签和预标注结果,分差超过阈值的样本单独抽出来人工复核。这一步大约过滤掉 2% 的脏数据,但年龄 MAE 提升了接近 0.3 岁,性价比非常高。
2.2 人脸检测与对齐细节
很多人做属性识别时忽略了一个关键问题:模型输入的人脸图是不是“正”的。人脸检测框如果是松松垮垮的矩形,裁出来的图包含了大量背景、头发、脖子,特征提取就会被干扰。我用的方案是先做人脸关键点检测,然后根据两只眼睛和鼻尖的位置做仿射变换,把人脸对齐到统一的姿态,再 resize 到 224x224。
对齐这一步的坐标计算看似简单,但有几个坑。第一个坑是眼睛坐标不可靠——侧脸、闭眼、眼镜反光都会让关键点抖动,我的处理方式是加入关键点置信度过滤,置信度低于阈值的样本直接丢弃,不强行对齐。第二个坑是 resize 之前要不要 padding——如果直接拉伸,人脸比例会变形,影响年龄这种对脸部比例敏感的任务。正确做法是先把对齐后的人脸按比例 padding 到正方形(用周边像素值填充),再 resize,这样脸部比例不会失真。第三个坑,归一化方式要跟预训练权重匹配,ResNet-50 的 ImageNet 预训练权重要求输入按 mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225] 做归一化,一定不能用自己随意定的均值和方差,否则迁移学习的效果会大打折扣。
2.3 标签分布分析与不均衡处理
做完清洗和对齐,我把训练集的标签分布拉出来看了一下,发现三个任务各有各的不均衡问题。
性别任务还算均衡,男女比例大约 55:45,基本不用处理。年龄任务属于典型的长尾分布——20 到 40 岁的样本特别多,而 0 到 10 岁、70 岁以上这两个区间的样本严重不足,如果不处理,模型对儿童和老人的年龄预测会明显偏向中间值。人种任务的不均衡最严重,占比最高的品类接近一半样本,占比最低的品类不到 10%。如果直接拿原始分布训练,模型的多数类偏好会非常明显,低样本种类别的准确率会很难看。
我的处理方式分三步。第一,对低样本类别做过采样,让每个年龄段和人种类别在 batch 里的出现频率不要悬殊过大。第二,在损失函数里给不同类别加权重,用 sklearn 的 class_weight 计算逆频率权重,然后传给 softmax 交叉熵。第三,年龄任务不用类别权重而是用样本权重——把每个年龄桶的样本数取倒数归一化作为该桶的 loss 权重,强迫模型在样本少的年龄段也保持足够的注意力。这里要注意的是,过采样和 loss 权重不要同时拉太满,否则低样本类别会过拟合,我在实验里把过采样倍率控制在 2 倍以内,loss 权重也做了截断(最大权重不超过 10)。
3. 网络结构与多任务头设计
3.1 硬共享还是软共享
多任务学习在结构设计上有一个核心问题:任务之间共享多少参数。硬共享模式是“底部共用一套卷积层,顶部各接各的分类头”,实现简单,共享层被多个任务共同约束,泛化能力强,但缺点是如果一个任务梯度方向跟其他任务冲突太大,共享层的学习会被拉扯。软共享模式是“每个任务有自己的网络,但不同网络之间通过注意力或蒸馏机制互相传递信息”,灵活度高,但参数量和计算量都上去了,工程复杂度也明显增加。
我最后选的是硬共享加适度任务隔离的折中方案。共享主干直接用 ResNet-50 的 conv1 到 conv4_x 这一段,从 conv5_x 开始分叉成三个独立的子网络分支,每个分支各自带着自己的分类头。这样做的理由是:conv1 到 conv4 提取的是底层和中层视觉特征——边缘、纹理、脸部结构,这些对三个任务来说都是通用的;而 conv5 及之后提取的高层语义特征更偏向具体任务——性别分支更关注下颌轮廓和眉骨形态,年龄分支更关注皮肤纹理和皱纹密度,人种分支更关注五官比例和肤色分布。如果在最高层还强行共享,三个任务会互相干扰,分开之后每个分支都能学到更专属的表征。实际测试结果也支持这个设计:相比完全硬共享,这种“部分共享”方案的性别准确率提升了 0.4%,年龄 MAE 改善了 0.2 岁。
3.2 三分支 Head 的输入输出设计
具体到 TensorFlow 代码实现,我用的是 Keras Functional API。共享部分从 Input 到 resnet50 的 conv4 输出,然后分别接三个分支。这里有几个关键设计点。
第一,三个分支的深度不能一样。性别任务最简单,分类头用两层全连接就够了,参数量太大反而容易过拟合。年龄任务要稍微复杂一点,因为年龄特征是连续渐变的,需要更多非线性拟合能力。人种任务介于两者之间。我的具体做法是性别分支用 512 维全连接 + ReLU + Dropout,人种分支用 512 维全连接 + BN + ReLU + Dropout,年龄分支因为要看概率分布的细微差别,用了两层 512 维全连接加每层后面的 BN 和 Dropout,最终输出 101 维的 softmax(0 到 100 岁每个整数年龄一个桶)。
第二,三个分支的监督信号要在共享层上叠加,但不要让某一分支的梯度淹没其他分支。这里涉及梯度平衡问题,稍后在损失函数部分详细说。第三,分支之间的 BN 层统计数据必须独立——虽然共享主干里已有 BN,但每个分支内部最好再各自设一组 BN,不能共用,否则三个任务在同一 Batch 上的数据分布不同,会干扰 BN 统计量的估计。
3.3 损失函数组合与动态加权
多任务学习的损失函数是一个加权和:L = λ1L_age + λ2L_gender + λ3*L_race。难点在于三个 λ 怎么定。固定权重是最朴素的做法,但问题很明显——训练初期年龄任务的 loss 可能比性别任务大一个数量级,梯度被年龄任务主导,性别分支学不好;训练后期各任务的收敛速度不同,固定权重又会让已经收敛的任务拖累还没收敛的任务。
我尝试了两种动态加权方案,最终选了不确定性加权。不确定性加权的原理是:每个任务在模型中的不确定性(通过噪声参数 σ 衡量)决定了它应得的权重,权重 w = 1/(2σ²),噪声越大权重越小。实现上就是把 σ 当作可学习参数,在训练时一起优化,用 TensorFlow 内置的 GradientTape 就可以自动处理。相比手动调三个 λ,这种方案省心很多,而且理论上是有依据的。
另一种我试过的是 GradNorm——根据梯度范数动态调整任务权重,让所有任务的梯度范数保持同一量级。效果也不错,但实现复杂度高,训练时还要额外维护梯度统计量,训练速度受影响。对大多数人来说,不确定性加权是性价比最高的选择。实际训练中,三个任务的 loss 量级被拉到同一水平后,训练曲线明显平稳了很多,不再出现性别任务已经收敛而年龄任务还在乱跳的情况。
4. TensorFlow 实现与训练实录
4.1 模型搭建代码与关键参数
直接上核心代码。这里的模型搭建用的是 Keras Functional API,方便多输入多输出的场景。
import tensorflow as tf from tensorflow.keras import layers, Model from tensorflow.keras.applications.resnet50 import ResNet50 # 加载预训练 ResNet-50,去掉顶层分类器 base_model = ResNet50( weights='imagenet', include_top=False, input_shape=(224, 224, 3), pooling=None ) # 截断骨干网络:只保留到 conv4_block6_out(即 conv4 的最后一层) truncated = base_model.get_layer('conv4_block6_out').output # 性别分支(二分类) gender_head = layers.Conv2D(256, 3, padding='same')(truncated) gender_head = layers.BatchNormalization()(gender_head) gender_head = layers.ReLU()(gender_head) gender_head = layers.GlobalAveragePooling2D()(gender_head) gender_head = layers.Dense(512, activation='relu')(gender_head) gender_head = layers.Dropout(0.5)(gender_head) gender_out = layers.Dense(2, activation='softmax', name='gender_out')(gender_head) # 年龄分支(101 类) age_head = layers.Conv2D(512, 3, padding='same')(truncated) age_head = layers.BatchNormalization()(age_head) age_head = layers.ReLU()(age_head) age_head = layers.GlobalAveragePooling2D()(age_head) age_head = layers.Dense(512, activation='relu')(age_head) age_head = layers.BatchNormalization()(age_head) age_head = layers.Dropout(0.5)(age_head) age_head = layers.Dense(512, activation='relu')(age_head) age_head = layers.Dropout(0.3)(age_head) age_out = layers.Dense(101, activation='softmax', name='age_out')(age_head) # 人种分支(5 类) race_head = layers.Conv2D(256, 3, padding='same')(truncated) race_head = layers.BatchNormalization()(race_head) race_head = layers.ReLU()(race_head) race_head = layers.GlobalAveragePooling2D()(race_head) race_head = layers.Dense(512, activation='relu')(race_head) race_head = layers.Dropout(0.5)(race_head) race_out = layers.Dense(5, activation='softmax', name='race_out')(race_head) model = Model(inputs=base_model.input, outputs=[age_out, gender_out, race_out]) # 打印模型结构概览 model.summary()几个关键参数说明一下。每个分支的卷积核数量我控制在 256 到 512,这个量级足够表达任务专属特征,又不至于让分支过于庞大。Dropout 放在全连接层后面,0.5 的比率对性别和人种分支已经足够,年龄分支的第二层 Dropout 我降到 0.3,因为年龄任务对特征连续性要求高,丢太多信息反而学不好。年龄分支比性别分支多了 BN 层且网络更深,因为年龄任务确实更难——同一张脸在不同光照、不同角度下看起来年龄差异很大,模型需要更强的非线性拟合能力。
预训练权重的处理也有讲究。我加载 ImageNet 预训练权重后,共享主干部分的权重全部保留,三个分支的新层从零训练。训练时先冻结共享主干,只训练分支层,跑 5 个 epoch 之后再解冻共享主干,用更低的学习率对整个网络做全量微调。这样做的逻辑是:分支层刚初始化权重还很随机,如果解冻共享主干,主干会被噪声梯度带偏,先让分支层学几个 epoch 稳定下来,再让主干去适应人脸数据的分布。
4.2 训练配置:优化器、学习率与损失加权
训练配置这部分,我把最核心的配置项和调参逻辑列了一个表,方便对照参考。
| 配置项 | 具体值 | 说明 |
|---|---|---|
| 优化器 | AdamW | Adam 加权重衰减,对迁移学习更友好 |
| 初始学习率 | 1e-3(冻结阶段)/ 1e-4(微调阶段) | 共享主干解冻后必须降一个数量级 |
| 学习率调度 | CosineAnnealing + warmup | 前 3 个 epoch 线性 warmup,然后余弦退火 |
| Batch Size | 64 | 三任务共享数据,64 能保证梯度估计稳定 |
| 输入尺寸 | 224 x 224 | 跟预训练权重匹配 |
| 损失函数 | age: softmax 交叉熵;gender: 交叉熵;race: 带类别权重的交叉熵 | 各 loss 按不确定性加权相加 |
优化器选择上有过一个小插曲。最早我用的是普通 Adam,训练过程中损失曲线一直在低位震荡,后来排查参数发现是权重衰减设置问题——Adam 本身带有 L2 正则化效果,但如果手动再加 weight_decay,会跟 Adam 的自适应学习率机制冲突,导致某些参数被过度衰减。换成 AdamW 之后,权重衰减被独立出来处理,训练稳定性明显改善。
学习率这块,我不建议一上来就用固定学习率。warmup 的意义在于训练初期模型权重离最优点很远,梯度方向不稳定,过大的学习率容易让 loss 直接发散;先用小学习率让 loss 曲线平稳下降,再加大学习率进入快速收敛,最后用余弦退火让学习率平滑降低到接近零,这样能避免在Loss曲面里跳来跳去。
损失加权这块,我用了不确定性加权,简单实现一下:
# 可学习的噪声参数 sigma log_sigma_age = tf.Variable(0.0, trainable=True, dtype=tf.float32) log_sigma_gender = tf.Variable(0.0, trainable=True, dtype=tf.float32) log_sigma_race = tf.Variable(0.0, trainable=True, dtype=tf.float32) # 训练循环中,计算各 loss 后: loss_age_weight = 0.5 * tf.exp(-log_sigma_age) loss_total += loss_age_weight * loss_age + log_sigma_age loss_gender_weight = 0.5 * tf.exp(-log_sigma_gender) loss_total += loss_gender_weight * loss_gender + log_sigma_gender loss_race_weight = 0.5 * tf.exp(-log_sigma_race) loss_total += loss_race_weight * loss_race + log_sigma_race注意到每个任务的权重前面有 0.5,这是不确定性加权的标准推导结果。另外每个 loss 后面加了 log_sigma 作为正则项,这是为了防止 sigma 无限增大导致权重归零。
4.3 训练过程中的指标监控与模型保存
多任务模型训练有一个很麻烦的地方——三个任务各有一个指标,整体好坏没有一个统一度量。我当时的做法是:每个 epoch 结束后分别记录 gender accuracy、age MAE(把 101 维 softmax 输出做加权期望再算绝对误差)、race accuracy,同时用一个加权综合指标作为模型筛选参考。综合指标 = 0.4 * gender_acc + 0.3 * (1 - age_mae/100) + 0.3 * race_acc,这样训练过程中的每个 epoch 都能有一个标量进度反馈。
模型保存策略上,我同时开启了两个回调:ModelCheckpoint 保存综合指标最优的模型,以及保存 age MAE 最优的模型。因为实际业务场景里,年龄误差对体验的影响最大,性别和人种偶尔错一个影响没那么大,所以最后线上部署的模型不一定选综合指标最好的那个,而是根据真实场景需求来挑。这个思路在单任务模型里不需要考虑,但在多任务场景里非常实用——不要迷信一个总指标,回到业务诉求上去看哪个任务最重要,然后再决定用哪个 checkpoint。
训练中还注意了显存优化。三头模型比单模型大一些,如果直接上 224x224 的输入和 128 的 batch size,一张 11G 显存的卡很容易爆。我把 batch size 降到 64,同时在输入 pipeline 里用 tf.data 的 map + prefetch 做数据预取,让 GPU 不空等 CPU 喂数据。另外,混合精度的 AMP 策略对训练速度提升很明显,在显存不变的情况下能把 batch size 翻倍,代价只是极小的精度损失,对属性识别这种任务来说完全可接受。
5. 训练中常见的问题与排查技巧
5.1 损失震荡不下降,或者直接炸到 NaN
这个问题我遇到过两次,原因各不相同。
第一次是学习率设太大。warmup 阶段跑到一半,loss 突然从 3 掉到 30,然后直接变 NaN。排查下来是初始学习率 1e-3 太大,而我的 batch size 只有 64,梯度估计噪声大,优化器刹不住。解决方案是学习率降到 3e-4,NaN 问题立刻消失。在迁移学习场景里,如果预训练骨干很强,初期学习率不需要太高,稳妥比速度重要。
第二次是数据 pipeline 里混入了空张量。某次数据清洗时漏掉了一个损坏的 TFRecord,里面读出来的图像是空的,模型前向计算除以 0 后梯度直接溢出成 NaN。排查方法很笨但很有效:在训练循环里加了 assert 检查,确认每个 batch 的输入张量里没有 NaN 或 Inf 值,一旦发现就打印出 batch index,然后去定位到具体的数据文件。这类问题如果不在训练早期抓出来,往往要白跑好几个小时才能发现。
5.2 性别准确率很高,但年龄误差一直很大
性别识别本质上是一个“看轮廓”的任务,特征比较明显,模型很容易收敛。年龄则麻烦得多:面部老化是一个渐变过程,两个相邻年龄段在视觉上几乎没有明确边界,标注本身也存在主观性——同一张脸,不同标注者可能给出相差好几岁的标签。
针对年龄误差大的问题,我做了两件有效的事。第一是前面提到的年龄分桶分类替代直接回归,softmax 概率加权的期望值作为最终预测,这样即使模型在几个相邻桶之间犹豫不决,最终输出的年龄也不会剧烈跳变。第二是给年龄分支的 loss 加权时,把困难样本的权重调高:计算每个样本的预测概率熵,熵越高的样本说明模型越不确定,这类样本的 loss 权重乘上 1.5 到 2。这个操作让模型更关注那些难判定的年龄段,而不只是把易分类的人群刷高分。实验数据显示,年龄 MAE 从 5.2 岁降到了 4.4 岁左右。
人种属性准确率如果偏低,大概率是训练样本不均衡导致的。少数类类别样本太少,模型干脆把所有输入都预测成多数类。除了过采样和 loss 权重,还有一个更立竿见影的手段——数据增强时对少数类做更激进的扰动(颜色抖动、尺度变化、局部遮挡),相当于在有限样本里制造更多变体,提升模型对少数类的泛化能力。这里要记住,每个任务的问题要用任务对应的手段去解,不能指望一套增强策略包打天下。
5.3 推理速度不达标,怎么优化
模型训练收敛之后,接下来就是工程侧的硬仗——推理延迟。多任务模型相比三个独立模型已经快了很多,但一次 ResNet-50 的前向在 CPU 上仍然要几十毫秒,如果业务要求毫秒级响应,还得继续优化。
我试过最有效的三个优化手段。第一个是 TensorFlow 模型转 TFLite,用 INT8 量化把浮点权重压缩到 8 位整数,模型体积缩小到原来四分之一,CPU 推理速度提升两到三倍,代价是准确率掉了约 1%。对于属性识别这种对精度容忍度尚可的场景,这个代价是可以接受的。第二个是模型蒸馏——用大模型(比如 ResNet-152 当老师)的输出软标签去训练小模型(比如 ResNet-18 当学生),中小模型在保持高精度的同时推理速度能提升一个量级。第三个是 TensorRT 加速,把模型转成 TensorRT engine,在 NVIDIA GPU 上能有明显提速。我的做法是 GPU 服务上部署 TensorRT 半精度版本,移动端用 TFLite 量化版,两套方案按场景切换。
5.4 问题速查表
我把自己遇到的典型问题整理成了速查表,后续排查问题时按表操作能节省不少时间。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 训练 loss 发散到 NaN | 学习率过大 / 数据中存在空张量 | 降低学习率;检查数据 pipeline 是否有损坏样本 |
| 性别准确率高但年龄误差大 | 年龄标注噪声大 / 回归任务对噪声敏感 | 改分桶分类;对高熵难样本加权 |
| 人种少数类准确率低 | 样本不均衡严重 | 过采样 + 类别权重 + 针对少数类的强增强 |
| 三个任务收敛速度差异大 | 固定损失权重导致梯度失衡 | 改用不确定性加权或 GradNorm |
| Batch 内显存溢出 | 单 batch 过大 / 输入 pipeline 阻塞 | 降 batch size;用混合精度;开启 prefetch |
| 微调阶段模型效果反而变差 | 解冻共享主干后学习率过高 | 解冻主干的学习率降到冻结阶段十分之一 |
6. 部署落地与后续扩展的一点想法
模型在实验环境跑通只是第一步,真正上线还会遇到不少实际工程上的坑。模型导出这一关就很有讲究。Keras 自带的 SavedModel 导出格式功能没问题,但要注意把三个输出的名字固定好,比如 age_out、gender_out、race_out,这样线上服务的调用方才能稳定地解析返回结果。如果你用 TFLite 转换,转换前的 model 一定要把输出层名字设置明确,我之前踩过一次坑:Keras 函数式模型默认输出名字是按定义顺序生成的,如果线上代码写死了输出张量索引,后续改模型结构时索引一变,服务端就得跟着改,极容易出线上事故。
业务侧的真实场景跟公开测试集往往存在分布偏移。公开数据集里的人脸大多是正面、清晰、光照均匀的,但真实业务场景里,人脸可能偏转、模糊、暗光、戴帽子、戴口罩——这些在公开数据集里都很难覆盖。如果团队有条件,建议在真实场景里持续采集数据并做增量训练。这里有一个很关键的细节:增量训练的时候,新数据要和旧数据混合,不能只用新数据微调,否则模型会遗忘在公开数据集上学到的知识。我用经验回放的方式,每次增量训练都从旧数据集里随机抽一批样本混进新数据,保证新旧分布折中。
模型更新频率上,我建议不要每个小时都自动重新训练。属性识别模型不像推荐系统那样数据变化剧烈,它的知识相对稳定,一周更新一次或者一个月更新一次就足够了。更新太频繁,除了浪费计算资源,还容易出现不同版本模型行为不一致的问题。
如果后续想把模型能力进一步扩展,两个方向值得尝试。第一个方向是在现有三个属性上再增加新属性头,比如“是否戴眼镜”“是否有胡须”“是否微笑”。只要共享主干的表达能力足够,每增加一个属性只需要新增一个轻量分支和一个标注数据源,成本比重新训练一个独立模型低得多。这是多任务学习最大的红利。第二个方向是把模型接入到更复杂的人脸分析系统里,比如做人脸识别时同步分析属性,身份识别和属性识别共享同一个主干。不过我试过之后发现,身份识别需要的人脸特征跟属性识别不完全一样,身份特征要求“同一个人的不同照片尽可能相似”,属性特征要求“不同属性的人脸尽可能可区分”,这两者有一定冲突,需要设计更精细的特征分离模块,不能简单共享全部卷积层。这一块我还在探索,后续如果有阶段性结论,再单独写一篇分享。
最后提醒一点:人脸属性数据涉及隐私合规问题,训练数据的采集、存储和使用都要严格遵守相关规范。无论你在哪个行业做这个方向,都要确保数据来源合法、处理流程合规。
这条路走下来,我个人最大的体会是:多任务学习在工程上的价值是被低估的。很多人一听到“多任务”就联想到复杂的论文公式和难以复现的实验,但像这种“一个模型解决三件事”的需求,在真实业务里非常常见,而 TensorFlow 的 Keras API 已经把这个事的工程门槛降得很低了。关键是你要理解每个任务自己是什么类型、该用什么损失、分支该怎么设计,然后剩下的就是耐心调参和排坑了。希望这篇内容能给你的人脸多属性识别项目提供一个可以直接落地的起点。