1. 项目缘起与整体设计思路
人脸颜值打分这个题目,乍一听像是那种“娱乐向”的玩具项目,但真正动手做一遍就会发现,它其实是一个相当典型的图像回归/分类任务,涉及数据采集、人脸检测对齐、卷积神经网络搭建、训练调参、模型评估与部署推理的完整链路。我第一次在AI Studio上看到类似项目的时候,心里想的是“这不就是个二分类或者回归问题吗”,结果真正踩进去才发现,人脸数据的特殊性让这个任务比想象中要复杂得多。
先说说这个项目到底在做什么。简单讲,就是给一张人脸照片,让模型输出一个“颜值分数”。这个分数可以是0到10之间的连续值(回归任务),也可以拆成若干档位做分类(比如1到5分五档)。标题里写的是“基于卷积神经网络分类方法”,所以核心路线是分类而非回归。分类的好处是训练更稳定,坏处是分数粒度粗,需要合理设计档位划分策略。
为什么选卷积神经网络而不是传统机器学习方法?因为人脸颜值这个东西,它的特征太抽象了。你很难用HOG、LBP或者SIFT这种手工特征去描述“好看”这件事。颜值高低涉及五官比例、皮肤质感、对称性、轮廓线条等高度非线性的组合模式,传统特征工程基本无能为力。CNN的卷积核可以自动从低级边缘、纹理逐步组合到高级语义特征,天然适合这种任务。
那为什么选百度飞桨AI Studio平台?几个很实际的原因:第一,它提供免费的GPU算力额度,对于学生党或者个人开发者来说非常友好;第二,平台内置了PaddlePaddle框架和大量预训练模型,可以直接调用;第三,数据集管理、Notebook环境、模型训练和部署都在一个平台里完成,省去了自己搭环境的麻烦。我实测下来,在AI Studio上用V100或者V100级别的算力跑一个中等规模的CNN,训练时间完全可以接受。
整体设计思路可以拆成这么几步:数据获取与清洗 → 人脸检测与对齐 → 标签体系设计 → 数据增强 → CNN模型搭建 → 训练与调参 → 模型评估 → 推理部署。每一步都有坑,后面我会逐个拆开讲。
注意:颜值打分本身带有强烈的主观性,不同人对同一张脸的评分可能差异很大。所以这个项目的定位应该是“技术练手”和“完整链路实践”,而不是追求绝对准确的评分。理解这一点,后面的调参和评估心态会好很多。
2. 数据准备与标签体系设计
2.1 数据来源与采集策略
做颜值打分,第一个卡脖子的问题就是数据。公开的人脸数据集不少,比如CelebA、FFHQ、LFW这些,但它们大多只有身份标注或属性标注,没有颜值分数。那分数从哪来?
常见的做法有这么几种:一是用公开的颜值评分数据集,比如SCUT-FBP5500,这个数据集包含5500张人脸,每张有60个人工评分,最终取平均分作为标签。二是自己爬取带评分的数据,比如某些评分网站上的数据,但这种方式涉及版权和合规问题,需要谨慎。三是用预训练模型生成伪标签,比如用一个已经训练好的颜值模型去给大量无标签人脸打分,然后拿这些伪标签来训练自己的模型。这种方式适合做数据蒸馏或者半监督学习。
我在AI Studio上做这个项目的时候,优先推荐用SCUT-FBP5500或者类似公开数据集。如果找不到,可以退而求其次,用CelebA的人脸图片,然后通过众包或者小规模人工标注的方式生成分数。但人工标注成本高,而且一致性难保证,所以更实际的做法是:先用小规模高质量标注数据训练一个基线模型,再用这个模型去给大规模无标签数据打伪标签,最后用伪标签数据做微调。
2.2 人脸检测与对齐的必要性
拿到原始图片之后,千万不要直接resize就丢进网络。人脸在图片中的位置、角度、尺度差异极大,如果不做检测和对齐,模型会浪费大量容量去学习“人脸在哪”这种低级问题,而不是“脸好不好看”这种高级语义。
人脸检测可以用MTCNN或者RetinaFace,这两个在PaddlePaddle生态里都有现成的实现。检测到人脸框之后,做关键点对齐,通常是用5点或68点关键点做仿射变换,把眼睛、鼻子、嘴角对齐到一个标准位置。对齐之后的人脸再裁剪成固定尺寸,比如224x224或者112x112。
这一步的实操心得是:对齐的质量直接决定模型的上限。如果对齐歪了,眼睛不在同一水平线上,模型学到的特征就是错的。我试过不做对齐直接训练,准确率比做了对齐低了将近8个百分点。所以这一步千万别省。
2.3 标签体系设计与分数离散化
前面说了,这个项目走的是分类路线。那怎么把连续的颜值分数变成类别?
最直接的方式是等宽分箱。比如分数范围是1到5,分成5档,每0.8分一档。但这样分有个问题:中间分数段的人脸特别多,两端特别少,导致类别极度不平衡。另一种方式是等频分箱,按分位数切分,保证每个类别样本数大致相等。这种方式训练更稳定,但档位的物理意义会变得模糊。
我个人的建议是:如果数据量在1万张以下,用等频分箱分5档;如果数据量在5万张以上,可以尝试等宽分箱分10档。分5档的好处是每档样本充足,模型容易收敛;分10档的好处是分数粒度细,但需要更多数据支撑。
还有一个细节:标签平滑。因为颜值评分本身有噪声,相邻档位之间的边界很模糊。用label smoothing(比如0.1的平滑系数)可以防止模型对边界样本过度自信,提升泛化能力。这个技巧在图像分类里很常用,颜值打分任务尤其适合。
2.4 数据增强策略
人脸数据的增强和普通图像分类不太一样,有些增强会破坏人脸的语义。比如随机裁剪可能把眼睛裁掉,颜色抖动太大会让肤色失真。所以增强策略要克制。
我常用的增强组合是:随机水平翻转(概率0.5)、小角度旋转(±10度)、轻微颜色抖动(亮度、对比度、饱和度各±0.1)、随机擦除(概率0.2,擦除面积不超过10%)。不做垂直翻转,因为倒过来的人脸没有意义;不做大角度旋转,因为对齐之后人脸基本是正的。
提示:数据增强只在训练时做,验证和测试时只做resize和归一化。另外,增强后的图片要检查一下,确保人脸没有被裁掉或者严重变形。
3. 卷积神经网络模型搭建与选型
3.1 backbone选择:从轻量到重量
在AI Studio上搭CNN,backbone的选择很关键。不是越深越好,要看数据量和算力。
如果数据量在1万张以下,建议用轻量级网络,比如MobileNetV3、ShuffleNetV2或者PaddlePaddle自带的PP-LCNet。这些网络参数量小,不容易过拟合,训练也快。我实测过,在5000张数据上用ResNet50,验证集准确率卡在60%上不去,换成MobileNetV3之后直接到了68%。
如果数据量在5万到10万之间,可以用ResNet50或者ResNet101。如果数据量超过10万,可以尝试EfficientNet系列或者Vision Transformer。但ViT对数据量要求极高,小数据上基本训不动。
还有一个很实用的技巧:用预训练权重。在ImageNet上预训练的模型,底层卷积核已经学会了边缘、纹理、颜色等通用特征,这些对人脸任务同样有效。用预训练权重初始化,然后fine-tune,比从头训练收敛快得多,最终精度也更高。AI Studio上可以直接加载PaddlePaddle的预训练模型,非常方便。
3.2 自定义分类头设计
backbone之后要接分类头。最简单的做法是全局平均池化(GAP)之后接一个全连接层,输出类别数。但颜值打分任务有个特点:类别之间是有序的。1分和2分的差距,小于1分和5分的差距。普通分类损失(交叉熵)不考虑这种序关系,所以可以做一些改进。
一种改进是序数回归(Ordinal Regression),把分类问题转化成多个二分类问题。比如5个类别,就训练4个二分类器,分别判断“分数是否大于1”、“是否大于2”……最后累加得到最终分数。这种方式保留了序信息,效果通常比普通交叉熵好。
另一种改进是混合损失,把交叉熵和回归损失(比如MSE)结合起来。分类头输出类别概率,同时输出一个回归值,两个损失加权求和。这样既有分类的稳定性,又有回归的细粒度。
我在项目里用的是序数回归的思路,实现起来也不复杂:把全连接层的输出维度设为1,然后用sigmoid激活,标签做归一化处理。损失函数用MSE或者Huber损失。实测下来,这种方式比普通5分类的准确率高了3到5个百分点。
3.3 模型结构代码示例
下面是一个基于PaddlePaddle的模型定义示例,backbone用MobileNetV3,分类头用序数回归:
import paddle import paddle.nn as nn from paddle.vision.models import mobilenet_v3_small class FaceBeautyModel(nn.Layer): def __init__(self, num_classes=5, pretrained=True): super().__init__() self.backbone = mobilenet_v3_small(pretrained=pretrained) # 去掉原来的分类头 in_features = self.backbone.classifier[0].in_features self.backbone.classifier = nn.Identity() # 自定义分类头 self.head = nn.Sequential( nn.Linear(in_features, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, 1) # 序数回归输出1维 ) self.num_classes = num_classes def forward(self, x): features = self.backbone(x) out = self.head(features) return out这个结构很简洁,但效果不错。关键点在于:Dropout放在全连接层之后,而不是卷积层之后。卷积层本身有正则化效果,再加Dropout反而会拖慢收敛。全连接层参数量大,容易过拟合,加Dropout很有必要。
3.4 损失函数与优化器选择
序数回归的损失函数,我推荐用Huber损失而不是MSE。Huber损失对异常值更鲁棒,颜值评分里难免有一些标注噪声很大的样本,MSE会被这些样本带偏,Huber则稳定得多。
优化器方面,AdamW是首选。学习率设1e-3到1e-4之间,weight decay设1e-4到1e-2之间。如果用了预训练权重,backbone的学习率可以设小一点(比如1e-4),分类头的学习率设大一点(比如1e-3),这种分层学习率的策略在fine-tune时很有效。
学习率调度用余弦退火(CosineAnnealing)或者带热重启的余弦退火。我一般用余弦退火,训练30到50个epoch,前5个epoch做warmup,学习率从1e-6线性升到初始值,然后再余弦下降。这样训练曲线很平滑,不容易震荡。
4. 训练流程与调参实操
4.1 数据划分与批处理
数据划分不能随便随机分。因为颜值打分数据里,同一个人的多张照片可能同时存在,如果随机划分,同一个人可能出现在训练集和验证集里,导致验证集精度虚高。正确的做法是按人划分,同一个人只出现在一个集合里。
划分比例一般是训练集70%、验证集15%、测试集15%。如果数据量小,可以用5折交叉验证,但训练成本会翻倍。
批大小(batch size)根据显存来定。AI Studio上V100的显存是32G,MobileNetV3这种轻量网络,batch size可以设128甚至256。如果显存不够,用梯度累积(gradient accumulation)来模拟大batch。大batch的好处是梯度估计更准,训练更稳定,但学习率要相应调大。
4.2 训练过程监控与早停
训练过程中要监控几个指标:训练损失、验证损失、验证准确率(或者MAE)。如果训练损失持续下降但验证损失开始上升,说明过拟合了,要早停或者加正则化。
早停的patience一般设5到10个epoch。比如验证损失连续10个epoch没有下降,就停止训练,取验证损失最低的那个checkpoint。
还有一个细节:保存最佳模型而不是最后一个模型。因为最后一个epoch的模型可能已经过拟合了,最佳checkpoint才是泛化最好的。
4.3 超参数调优经验
超参数调优是个体力活,但有几个经验可以分享:
| 超参数 | 推荐范围 | 调优优先级 | 备注 |
|---|---|---|---|
| 学习率 | 1e-4 ~ 1e-3 | 最高 | 先用大学习率找大致范围,再细化 |
| Weight decay | 1e-4 ~ 1e-2 | 高 | 过拟合严重时调大 |
| Dropout率 | 0.2 ~ 0.5 | 中 | 数据量小则调大 |
| Batch size | 64 ~ 256 | 中 | 受显存限制 |
| 数据增强强度 | 弱 ~ 中 | 中 | 人脸数据不宜过强 |
| 序数回归vs分类 | 序数回归优先 | 高 | 序数回归通常更好 |
调参的顺序建议是:先调学习率,再调weight decay,然后调Dropout,最后微调数据增强。每次只调一个参数,固定其他参数,否则无法判断哪个参数起了作用。
4.4 训练代码框架
下面是一个简化的训练循环示例:
import paddle from paddle.io import DataLoader from paddle.optimizer import AdamW from paddle.optimizer.lr import CosineAnnealingDecay # 数据加载 train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=128, shuffle=False, num_workers=4) # 模型、损失、优化器 model = FaceBeautyModel(num_classes=5, pretrained=True) criterion = paddle.nn.HuberLoss(delta=1.0) scheduler = CosineAnnealingDecay(learning_rate=1e-3, T_max=50) optimizer = AdamW(learning_rate=scheduler, parameters=model.parameters(), weight_decay=1e-4) # 训练循环 best_val_loss = float('inf') patience = 10 counter = 0 for epoch in range(50): model.train() for batch in train_loader: images, labels = batch preds = model(images) loss = criterion(preds, labels) loss.backward() optimizer.step() optimizer.clear_grad() scheduler.step() # 验证 model.eval() val_loss = 0 with paddle.no_grad(): for batch in val_loader: images, labels = batch preds = model(images) val_loss += criterion(preds, labels).item() val_loss /= len(val_loader) if val_loss < best_val_loss: best_val_loss = val_loss paddle.save(model.state_dict(), 'best_model.pdparams') counter = 0 else: counter += 1 if counter >= patience: print(f'Early stopping at epoch {epoch}') break这个框架很基础,但涵盖了训练的核心要素:数据加载、模型定义、损失函数、优化器、学习率调度、早停、模型保存。你可以根据实际情况调整。
5. 模型评估与常见问题排查
5.1 评估指标选择
分类任务常用的指标是准确率(Accuracy),但颜值打分任务里,准确率有点苛刻。因为相邻档位之间的差异很小,模型把3分预测成4分,其实不算大错。所以除了准确率,还要看平均绝对误差(MAE)和相邻准确率(Adjacent Accuracy)。
MAE是预测分数和真实分数的平均绝对差,越小越好。相邻准确率是预测档位和真实档位相差不超过1的比例,这个指标更宽松,也更符合实际感受。
还有一个指标是Spearman相关系数,衡量预测分数和真实分数的排序一致性。颜值打分本质上是个排序问题,Spearman相关系数能反映模型的排序能力。
5.2 常见问题与排查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 训练损失不下降 | 学习率太小或太大 | 打印梯度范数 | 调整学习率,加warmup |
| 验证损失震荡 | batch size太小 | 增大batch size | 用梯度累积 |
| 验证准确率远低于训练 | 过拟合 | 对比训练/验证曲线 | 加Dropout、weight decay、数据增强 |
| 所有样本预测同一类 | 类别不平衡 | 统计类别分布 | 用加权损失或重采样 |
| 人脸检测失败 | 图片质量差 | 可视化检测结果 | 换检测模型或手动筛选 |
| 对齐后人脸变形 | 关键点检测错误 | 可视化对齐结果 | 换关键点模型或过滤异常样本 |
| 推理速度慢 | 模型太大 | 测FLOPs | 换轻量backbone或量化 |
5.3 独家避坑技巧
第一个坑:不要用准确率作为唯一指标。我一开始只看准确率,结果模型把所有样本都预测成中间档,准确率也有40%多,但MAE很高,实际体验很差。后来加了MAE和相邻准确率,才真正反映模型好坏。
第二个坑:验证集要按人划分。这个前面提过,但真的很重要。我试过随机划分,验证准确率比按人划分高了10个百分点,但实际部署时效果很差,因为模型“见过”验证集里的人了。
第三个坑:数据增强不要过度。人脸数据很敏感,颜色抖动太大会让肤色失真,随机裁剪可能把五官裁掉。我试过用AutoAugment这种强增强策略,结果验证准确率反而下降了。后来改成手动设计的弱增强,效果才好起来。
第四个坑:序数回归的标签归一化。如果用序数回归,标签要归一化到0到1之间,否则sigmoid输出和标签范围不匹配,损失会很大。这个细节很容易忽略。
第五个坑:模型保存要保存state_dict而不是整个模型。保存整个模型虽然方便,但兼容性差,换一个PaddlePaddle版本可能就加载不了。保存state_dict更稳妥。
5.4 模型可解释性分析
颜值打分模型到底学到了什么?可以用Grad-CAM可视化一下。把模型关注的区域热力图叠加到原图上,看看模型是不是真的在看五官区域,而不是背景或者头发。
我做过这个分析,发现模型主要关注眼睛、鼻子、嘴巴和下巴轮廓区域,这和人类判断颜值的关注点基本一致。但也有少数样本,模型关注了背景里的某些纹理,说明模型还是学到了一些虚假相关。这时候可以通过数据增强或者增加数据多样性来缓解。
6. 推理部署与项目扩展
6.1 模型导出与推理
训练完之后,要把模型导出成推理格式。PaddlePaddle支持动态图和静态图两种模式。动态图方便调试,静态图推理速度快。导出的时候用paddle.jit.to_static把动态图模型转成静态图,然后保存成model.pdmodel和model.pdiparams两个文件。
推理的时候,加载模型,预处理输入图片(检测、对齐、resize、归一化),然后前向计算,得到分数。如果是序数回归,输出是一个0到1之间的值,乘以分数范围(比如1到5)就得到最终分数。
推理代码示例:
import paddle import cv2 import numpy as np # 加载模型 model = FaceBeautyModel(num_classes=5, pretrained=False) model.set_state_dict(paddle.load('best_model.pdparams')) model.eval() # 预处理 def preprocess(image_path): img = cv2.imread(image_path) # 人脸检测和对齐(省略具体实现) face = detect_and_align(img) face = cv2.resize(face, (224, 224)) face = face.astype('float32') / 255.0 face = (face - [0.485, 0.456, 0.406]) / [0.229, 0.224, 0.225] face = face.transpose(2, 0, 1) return paddle.to_tensor(face).unsqueeze(0) # 推理 with paddle.no_grad(): pred = model(preprocess('test.jpg')) score = pred.item() * 4 + 1 # 映射到1到5 print(f'颜值分数: {score:.2f}')6.2 部署到AI Studio在线服务
AI Studio支持把模型部署成在线服务。部署之后会得到一个API接口,可以通过HTTP请求调用。这种方式适合做demo或者小规模应用。
部署的时候要注意:输入图片的尺寸和格式要和训练时一致,预处理逻辑要完全复现。我见过很多人部署之后效果变差,就是因为预处理不一致,比如训练时用了归一化,推理时忘了。
6.3 项目扩展方向
这个项目做完之后,可以往几个方向扩展:
一是多任务学习。除了颜值分数,还可以同时预测性别、年龄、表情等属性。多任务学习可以共享底层特征,提升泛化能力。
二是个性化颜值模型。不同人对颜值的偏好不同,可以收集用户的评分数据,做个性化微调。这个方向比较前沿,但数据收集是个难点。
三是视频颜值打分。把单张图片扩展到视频,做实时颜值打分。这个对推理速度要求高,需要模型量化或者剪枝。
四是对抗样本鲁棒性。研究颜值打分模型对对抗扰动的鲁棒性,这个偏学术,但很有意思。
6.4 最后的经验分享
这个项目我从头到尾做了大概三周,踩了不少坑,也学到了很多东西。最大的体会是:数据质量比模型结构重要得多。我试过用很复杂的模型,但数据没清洗干净,效果还不如简单模型加干净数据。
另外,颜值打分这个任务本身有很强的主观性,不要追求绝对准确。把它当成一个完整的机器学习项目练手,把数据采集、清洗、增强、模型搭建、训练、评估、部署这一整套流程走通,比追求几个百分点的准确率提升更有价值。
最后分享一个小技巧:用预训练模型的特征做可视化。把backbone输出的特征用t-SNE降维画出来,看看不同颜值档位的样本在特征空间里是不是分开了。如果分开了,说明模型学到了有判别力的特征;如果混在一起,说明模型还没学好,需要继续调。这个分析比看损失曲线直观得多。