我接手这个项目时最深的感触是:年龄和性别预测看起来是计算机视觉里的老面孔,真要做起来却处处是坑。IMDB-WIKI 数据集本身质量参差不齐,文件结构复杂,网上大量教程停留在“研究背景 + ResNet 网络结构”的阶段,一落地就卡壳。这篇博文就把我的完整思路和实践过程拆开写清楚——从数据集下载与清洗、环境搭建、模型加载与训练,到测试阶段遇到的各类问题,围绕 Python 和 IMDB-WIKI 数据集这条主线逐步展开。无论你是刚接触人脸属性分析的新手,还是想快速跑通一个可演示项目的开发者,这篇内容都能提供直接可参考的实操路径。
1. 项目准备与环境搭建:IMDB-WIKI 数据集的获取和常见阻碍
1.1 “无数据集”这三个字的真实含义
项目标题里写明“无数据集”,这其实意味着我们需要自己去获取原始数据。IMDB-WIKI 数据集由两个来源组成:IMDB 电影演员照片和 WIKI 百科人物照片,官方发布的压缩包包含图片、标签文件、Matlab 格式的标注文件等。实际动手之前,你要搞清楚一个情况:这个数据集并不提供统一的图片包下载,而是提供图片链接列表,官方脚本再根据链接批量抓取。很多链接随时间失效,数据不完整几乎是必然的。
我在准备阶段统计过,原始数据中有相当比例的图片早已无法下载,替代方案要么是找第三方镜像打包好的完整图片集,要么接受缺失并用现有图片训练。整理之后,我使用约 26 万张可用图片,其中带有较清晰年龄标签的约占八成。年龄和性别预测模型对数据量的敏感度很高,如果只拿到几万张图,训练效果会明显下降,尤其是年龄分布不均匀的情况下,模型会偏向样本量多的年龄段。
1.2 环境选用:Python 版本、深度学习框架与硬件配置
这个项目我使用的核心环境如下,给各位一个直接参考:
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| Python | 3.8 或 3.9 | 兼容性最稳,3.10 以上个别依赖容易出问题 |
| PyTorch | 1.10~2.0 | 本项目的代码使用 PyTorch 生态 |
| torchvision | 0.11~0.15 | 与 PyTorch 版本对应 |
| OpenCV | 4.5 以上 | 主要用于图片读取和预处理 |
| NumPy | 1.21 以上 | 数据处理基础库 |
| Matplotlib | 3.5 以上 | 可视化训练曲线和预测结果 |
| GPU 驱动 | CUDA 11.3+ | 无 GPU 也能跑,但训练会慢很多 |
安装时最容易卡的坑是 torch 和 torchvision 版本不一致导致无法调用 CUDA,建议用官方 pip 命令统一安装,不要分开装最新版。
1.3 数据集目录结构与标注文件解析
拿到 IMDB-WIKI 数据集后,目录内主要有以下内容:
imdb.mat和wiki.mat:包含性别、年龄、图片路径、脸部矩形框坐标等标注信息;- 按人名或 ID 组织的图片文件夹;
- 官方提供的下载脚本(如果选择自己抓图)。
解析mat文件是第一步,这里我直接给出可用的加载代码:
import scipy.io import numpy as np def load_mat(file_path): data = scipy.io.loadmat(file_path) meta = data['imdb'][0, 0] # 若读取wiki.mat,改成data['wiki'][0, 0] dob = meta['dob'][0] # 出生日期 photo_taken = meta['photo_taken'][0] # 拍摄年份 full_path = meta['full_path'][0] # 图片路径 gender = meta['gender'][0] # 0为女,1为男 face_score = meta['face_score'][0] # 人脸检测分数 face_location = meta['face_location'][0] # 人脸矩形框 records = [] for i in range(len(dob)): if face_score[i] > 1.0 and not np.isnan(dob[i]) and not np.isnan(gender[i]): age = photo_taken[i] - dob[i] if 0 <= age <= 100: records.append({ 'path': str(full_path[i][0]), 'age': float(age), 'gender': int(gender[i]), 'face_location': face_location[i][0] if len(face_location[i]) > 0 else None }) return records为什么设置face_score > 1.0这个阈值?因为数据集中很多图片人脸检测分数很低,不是正脸或模糊不清,硬塞进训练集只会增加噪声。也有不少dob或gender字段是 NaN,这类记录需要过滤掉。年龄计算用拍摄年份减去出生年份,默认得到的是周岁整数。
2. 数据清洗与人脸预处理:决定模型效果的上游环节
2.1 过滤、裁剪与归一化流程
拿到原始标注后,不能直接交给模型。需要先根据face_location把人脸区域裁剪出来,同时做适当的边缘扩展,避免人脸刚好顶满边界导致特征缺失。扩展比例一般取 0.2 到 0.3,两侧补齐之后再做缩放和归一化。数据集中有些图片是从电影剧照里裁剪出来的,画质和光线差异非常大。年龄预测模型对输入图像的分辨率要求并不算极端,但稳定缩放到 224×224 或 256×256 是比较常见的选择。如果为了速度和显存考虑,也可以把输入尺寸降到 160×160,实测精度损失在可接受范围内。
人脸裁切这一步要注意图片是 BGR 还是 RGB。OpenCV 默认读入的顺序是 BGR,而 PyTorch 预训练模型是基于 RGB 训练的,读图后用cv2.cvtColor(img, cv2.COLOR_BGR2RGB)做个通道转换,否则颜色信息是反的。这个细节不急的话不会立刻暴露,但会稳定拉低模型精度。
2.2 年龄标签的处理策略
IMDB-WIKI 的年龄标签噪声非常大,主要体现在三点:
- 出生日期精确到年,和拍摄年份相减会带来最多 1 岁的误差;
- 部分演员角色年龄与实际年龄不同,剧照数据天然有噪声;
- 老人和儿童的样本量相对较少,类别分布不平衡。
年龄建模有两种常见策略:作为回归问题(输出一个连续值)和作为分类问题(按年龄段分桶)。直接回归年龄,模型常会在中位数附近“躺平”,预测结果偏保守;按年龄分桶也有问题,比如相邻年龄段边界处容易混淆。我在项目中把年龄分成 0-2、3-6、7-12、13-19、20-30、31-45、46-60、60 以上共 8 个区间,每个区间内的样本重新加权,让年龄段之间的数量差异不至于压扁网络的学习重心。
2.3 性别标签重编码与样本均衡
性别标记在源数据里本身是 Binary 类型,直接转成int即可,0 表示女性、1 表示男性。训练时常用CrossEntropyLoss或带权重的二分类损失函数。IMDB-WIKI 中男女比例大概是 55:45,没有到严重失衡的程度,所以不额外做重采样也能跑。但如果你的清洗逻辑把很多女性样本筛掉了,就需要考虑对少数类别加权。
我的处理方式是统计清洗后的性别分布,不手动改数据,只在 Loss 里给少数类乘上系数,计算方式很简单:
weights = torch.tensor([female_count / total, male_count / total]) criterion = torch.nn.CrossEntropyLoss(weight=weights)这样比直接复制少数类样本更平滑,也不容易过拟合。
2.4 数据增强的有效性与尺度问题
人脸年龄预测对几何形变非常敏感。过度旋转或拉伸会让人脸比例失衡,年龄特征被破坏。我实际验证下来,比较可靠的数据增强手段包括:
- 水平翻转(人脸对称,不影响年龄判断);
- 轻微随机旋转(±8 度以内);
- 亮度、对比度扰动;
- 随机裁切。
不建议对年龄模型使用过强的颜色抖动,因为肤色和发色虽然对性别判断有影响,但对年龄判断属于边缘信息,过度扰动会导致颜色通道成为干扰项。尺度方面,IMDB-WIKI 人脸框的尺寸差异很大,有的对得紧,有的带大片额头或背景,干脆统一在裁切时做一次中心对齐,再裁剪为正方形区域,缩放至输入尺寸。
3. 模型构建与训练:从预训练权重到多任务输出的完整实现
3.1 为什么用预训练网络而非从头训练
IMDB-WIKI 数据集虽然有几十万张图片,但和 ImageNet 那种千万级通用数据集相比仍然不够大,而且标签噪声高。年龄和性别预测任务需要的底层特征——眼睛、嘴巴、皮肤纹理、发际线——在 ImageNet 预训练模型中已经具备较强的表达能力。因此,最佳方案是加载预训练的 ResNet 或 EfficientNet,替换掉最后的全连接层,同时输出性别分数和年龄预测。
实际测试中,ResNet-50在精度和推理速度之间相对平衡,EfficientNet-B4精度更高但训练更慢。显存不够时优先用 ResNet-50,可以在 backbone 之后接入一个全局平均池化,再分两条分支:一条做性别二分类,一条做年龄回归或年龄段分类。这种多任务结构让两个任务共享人脸特征提取器,比单独训练两个模型节省大量计算资源,也能互相提供正则效果。
3.2 网络结构实现代码
这里给出一个可直接运行的 PyTorch 模型定义,同时输出年龄和性别:
import torch import torch.nn as nn import torchvision.models as models class AgeGenderNet(nn.Module): def __init__(self, num_age_classes=8, backbone='resnet50'): super(AgeGenderNet, self).__init__() if backbone == 'resnet50': self.backbone = models.resnet50(pretrained=True) in_features = self.backbone.fc.in_features self.backbone.fc = nn.Identity() else: raise ValueError('Unsupported backbone') self.age_classifier = nn.Sequential( nn.Linear(in_features, 512), nn.BatchNorm1d(512), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(512, num_age_classes) ) self.gender_classifier = nn.Sequential( nn.Linear(in_features, 256), nn.BatchNorm1d(256), nn.ReLU(inplace=True), nn.Dropout(0.2), nn.Linear(256, 2) ) def forward(self, x): feat = self.backbone(x) age_out = self.age_classifier(feat) gender_out = self.gender_classifier(feat) return age_out, gender_outBatchNorm1d放在全连接层之间非常关键,尤其是两个分支共享同一个特征向量时,它能避免内部协变量偏移,让训练更稳。
3.3 训练超参数设置与个人经验
训练时我最常用的配置是这样的:
| 参数 | 数值 | 说明 |
|---|---|---|
| 优化器 | Adam | 初始学习率 1e-4,权重衰减 1e-4 |
| 学习率调整 | StepLR | 每 5 个 epoch 乘以 0.5 |
| Batch Size | 128 | 输入 224×224,8G 显存合适 |
| Epoch | 25 | 过大会过拟合,过小精度不够 |
| 年龄损失权重 | 1.0 | 与性别损失相加 |
| 性别损失权重 | 0.5 | 性别任务简单,降权重避免干扰年龄 |
为什么不把两个 Loss 都设为 1.0?性别分类任务的收敛速度远快于年龄预测,若权重一致,性别梯度会占主导,导致共享 backbone 对年龄特征的表达被压制。把性别损失权重降到 0.5,或者加入年龄损失的在线困难样本挖掘逻辑,模型对年龄段的敏感度会有明显提升。
训练过程中我还发现一个常规教程很少提及的现象:直接用pretrained=True的 ResNet 加载后会有一个短暂的“退化期”,前 1-2 个 epoch 验证集精度反而低于随机初始化,这是因为网络内部 BatchNorm 统计量还在适应人脸数据分布。遇到这种情况不需要调参,继续训练,第三四个 epoch 后会自然回升。
3.4 完整训练循环与模型保存
下面是一个实用的训练循环框架,包含早停逻辑和最优模型保存:
from torch.utils.data import DataLoader, Dataset import torch.optim as optim import os class FaceDataset(Dataset): def __init__(self, records, transform=None): self.records = records self.transform = transform def __len__(self): return len(self.records) def __getitem__(self, idx): item = self.records[idx] image = cv2.imread(item['path']) if image is None: return self.__getitem__((idx + 1) % len(self.records)) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) if item['face_location'] is not None: x, y, w, h = item['face_location'] margin = int(max(w, h) * 0.2) x1 = max(0, int(x) - margin) y1 = max(0, int(y) - margin) x2 = min(image.shape[1], int(x + w) + margin) y2 = min(image.shape[0], int(y + h) + margin) image = image[y1:y2, x1:x2] if self.transform: image = self.transform(image) age_label = torch.tensor(item['age_bucket'], dtype=torch.long) gender_label = torch.tensor(item['gender'], dtype=torch.long) return image, age_label, gender_label训练主循环中除了常规的前向反向过程,还要记录每个 epoch 的年龄分类准确率和性别分类准确率:
def train_one_epoch(model, dataloader, optimizer, criterion_age, criterion_gender, device): model.train() total_age_loss = 0.0 total_gender_loss = 0.0 correct_age = 0 correct_gender = 0 total = 0 for images, age_labels, gender_labels in dataloader: images = images.to(device) age_labels = age_labels.to(device) gender_labels = gender_labels.to(device) optimizer.zero_grad() age_out, gender_out = model(images) loss_age = criterion_age(age_out, age_labels) loss_gender = criterion_gender(gender_out, gender_labels) loss = loss_age + 0.5 * loss_gender loss.backward() optimizer.step() total_age_loss += loss_age.item() * images.size(0) total_gender_loss += loss_gender.item() * images.size(0) _, age_pred = torch.max(age_out, 1) _, gender_pred = torch.max(gender_out, 1) correct_age += (age_pred == age_labels).sum().item() correct_gender += (gender_pred == gender_labels).sum().item() total += images.size(0) avg_age_loss = total_age_loss / total avg_gender_loss = total_gender_loss / total age_acc = correct_age / total gender_acc = correct_gender / total return avg_age_loss, avg_gender_loss, age_acc, gender_acc模型保存时不要只存 state_dict,建议把年龄类别映射、训练参数一并记录下来,避免验证阶段要重新推导中间产物:
torch.save({ 'model_state_dict': model.state_dict(), 'age_bucket_list': age_bucket_list, 'input_size': 224, }, 'age_gender_model.pth')4. 训练过程中的常见问题与排查实录
4.1 第一个大坑:全量数据直接进内存导致 OOM
IMDB-WIKI 解压缩后图片占几十 GB,如果一口气把所有图片读入内存做原始数据矩阵,16G 内存机器直接崩。正确做法是自定义Dataset,每轮按路径读取,配合DataLoader的num_workers利用多进程预读取。网络上很多二手代码把数据一次性读成numpy数组再开始训练,这在人脸数据集上基本跑不通。
如果显存不足,除了调小batch_size,还可以选择冻结 backbone 的前几层。前期我只训练最后的分类层,等验证集精度不再上升时,再解冻 backbone 的后两个 Stage 做微调。这种方式能大幅降低显存占用,还能减少预训练特征被破坏的概率。
4.2 第二个大坑:验证集 Loss 与测试集表现严重不符
训练时模型在验证集上的年龄分类准确率能到 58% 左右,但一到真实照片上明显偏低。根源在于验证集和训练集都来自 IMDB-WIKI,人脸框裁剪方式和背景分布高度相似,而真实场景中的自拍、侧脸、遮挡等因素在训练集里占比有限。
解决思路是在验证集上额外加一条“真实照片评估线”,手动找 20-30 张网络照片或自拍,用同一套预处理流程做评估,实时观察模型在新域上的表现。做年龄预测项目,尤其在公开数据集上跑模型时,这一步不能省。否则模型只是记住了数据集的风格,而不是真正学会了人脸年龄特征。
4.3 第三个大坑:年龄极值段的预测塌陷
我早期训练出的模型对 20-40 岁区间的预测比较准,超过 60 岁和小于 10 岁的样本,输出几乎全部集中到中间年龄段。这种现象是回归目标和分类目标都会出现的“均值回归”效应:噪声标签越多,模型越倾向于保守预测。后来我把年龄分类从 8 类扩展到 16 类,并且在损失函数中按类别样本量的倒数加权,极端年龄段的预测能力得到改善,但整体准确率略有下降。这个取舍没有标准答案,取决于你更看重整体准确率还是结构化的年龄分布还原能力。如果是做商业项目,建议按年龄段分别报告准确率,而不是只看整体指标。
4.4 第四个坑:读图失败和路径不一致
不同机器的路径分隔符不一致,full_path中可能是/mnt/data/...或C:\data\...,必须统一转换成平台友好的路径。另外有少量图片本身就是损坏文件,cv2.imread返回None,需要在Dataset中做容错处理,否则训练过程会中断。为了保证训练稳定,我在读取失败时递归取下一张图,而不是报错退出:
image = cv2.imread(image_path) if image is None: return self.__getitem__((idx + random.randint(1, 10)) % len(self.records))5. 测试与效果评估:给模型做一次“体检”
5.1 测试流程与预测输出
训练完成后,加载保存的模型对一张新图片做预测,完整流程如下:
import cv2 import torch from torchvision import transforms def predict_image(model, image_path, device): model.eval() img = cv2.imread(image_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (224, 224)) transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) img_tensor = transform(img).unsqueeze(0).to(device) with torch.no_grad(): age_out, gender_out = model(img_tensor) gender_pred = torch.argmax(gender_out, dim=1).item() age_prob = torch.softmax(age_out, dim=1).cpu().numpy()[0] age_class = int(np.argmax(age_prob)) age_center = age_bucket_centers[age_class] gender_str = 'female' if gender_pred == 0 else 'male' print(f'Gender: {gender_str}, Age: {age_center:.1f}')5.2 不同输出的波动性分析
我在一批测试图上做过多次推理,发现同一张图在同一模型上性别输出非常稳定,几乎不会翻转;年龄输出则存在一定波动,相邻年龄段之间的概率差距有时不到 5%。这本质上是分类器置信度不高导致的,不能说明模型有问题。更好的工程化做法是取年龄各类别概率的加权期望作为最终输出,而不是直接取argmax,这样预测出的年龄会更平滑,也更适合作为推荐系统或营销场景的输入特征:
age_expectation = float(np.sum(age_prob * age_bucket_centers))5.3 在 CPU 上面向真实照片做推理
没有 GPU 时,单张 224×224 图片在 ResNet-50 上的 CPU 推理时间约 200~500ms。如果想提升并发性能,可以把输入降到 160×160,并转换成 FP16 或 ONNX 格式,CPU 推理速度能提升一倍左右。对需要部署到 Web 服务或移动端的项目,这个优化是必要的。转换 ONNX 的步骤很简单,先装onnx和onnxruntime,再用:
dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export(model, dummy_input, 'age_gender.onnx', input_names=['input'], output_names=['age', 'gender'], dynamic_axes={'input': {0: 'batch'}, 'age': {0: 'batch'}, 'gender': {0: 'batch'}})注意有些自定义层不支持 ONNX 导出,如果报错,替换成标准卷积层或全连接层即可。
6. 模型改进方向与后续扩展
6.1 从单张图片到视频流预测
项目跑通后,最自然的扩展方向是视频流预测。实际处理时建议每 5 到 10 帧抽取一帧做人脸检测,并对连续帧的预测结果做时序平滑,比如滑动窗口平均。单帧预测的抖动会比较大,不加平滑直接输出的话,画面里年龄数值会频繁跳动。时序平滑其实只是把预测结果做了简单滤波,并不需要引入额外的时序模型。
6.2 融入注意力机制与轻量化模型
如果追求更高的精度,可以考虑在 backbone 末端加入轻量级注意力模块,比如 SE 模块或 CBAM。它们对年龄这类细粒度特征提取是有帮助的——眼睛周围的皱纹、嘴角下垂程度这些局部特征需要通道注意力和空间注意力共同增强。但需要注意的是,IMDB-WIKI 的标签噪声太大,注意力机制带来的精度提升有时会被噪声淹没,需要配合标签清洗策略才能看到明显收益。
如果追求实时性,可以用 MobileNetV3 替换 ResNet-50,在 GPU 上推理速度提升接近三倍,精度下降约 4~5 个百分点。轻量化模型配合剪枝量化后,完全可以在边缘设备上运行。
6.3 多任务与跨数据集微调
年龄和性别预测不一定要局限在 IMDB-WIKI 上。实际应用中,尤其是面向真实客户时,用 UTKFace、AFAD 或 Apparel 数据集做一次微调,可以让模型适应更贴近业务场景的人脸分布。跨数据集微调时要特别注意标签体系的统一,比如年龄边界定义不同、性别字段含义不同,必须提前做映射。我的建议是先用 IMDB-WIKI 训练出具有泛化能力的基座模型,再用业务场景的数据做小规模微调,这样保留前者丰富的多样性,又融入新场景的分布特征。
6.4 在模型之上搭建完整服务
模型本身只是能力组件,工程化还差一层 API 封装。用 FastAPI 搭建一个推理服务并不复杂,核心逻辑是先做人脸检测,把检测结果输入年龄和性别模型,最后返回结构化 JSON:
from fastapi import FastAPI, UploadFile, File app = FastAPI() @app.post("/predict") async def predict(file: UploadFile = File(...)): image_bytes = await file.read() result = run_inference_on_bytes(image_bytes) return result如果并发量不高,单机多进程部署完全够用。每增加一个 worker 进程,显存占用也会成倍增加,需要根据实际显存评估最大并发数。
7. 总结一些值得记住的实操习惯
这个项目从零到跑通模型,我踩了不少坑,其中几个习惯现在看来非常值得坚持。
第一,数据集清洗阶段宁可多筛选也别贪多。网络上很多教程直接拿全部数据训练,结果噪声样本把模型的边缘特征搅乱,最终效果反而不如清洗后的子集。把face_score阈值调到 1.0 以上,过滤掉大半模糊样本,虽然数据量减少,但训练曲线更平滑。
第二,保存模型时把数据预处理的细节一起存下来。不同项目之间最容易出现的偏差就是归一化方式不一致。推理阶段用了不同的均值和方差,整体精度会下降几个点,这种问题排查起来非常隐蔽。
第三,评估模型不能只看分类准确率,对年龄预测还要画一张“预测年龄 vs 真实年龄”的散点图,观察是否存在系统性偏移。如果散点整体在某个区间上抬高或压低,说明标签里有系统误差,直接调模型没有用,得回去清洗数据。
第四,用多任务网络同时预测年龄和性别,并不只是省显存这么简单。两个任务共享骨干网络,实际上会互相约束特征表达。性别分支对局部纹理更敏感,年龄分支对整体脸型更敏感,共同训练让 backbone 两边都照顾到,泛化能力比单独训练更强。
这个项目做完之后,我再去看网上类似的开源代码时明显能分辨出哪些可以直接用、哪些只是搬运理论。数据清洗和训练细节才是决定模型效果的关键。如果不处理噪声,不检查真实场景表现,任何网络结构都无法发挥应有的潜力。后续如果读者对模型部署、ONNX 加速或视频流预测感兴趣,我再把这部分实践单独整理成文。