MXNet多任务卷积网络实现年龄与性别预测
简介一份基于Python机器学习的年龄与性别预测项目资源整合了完整源码与配套数据集面向人工智能、数据科学等计算机相关专业的学生与开发者适用于课程设计、毕业设计或入门进阶实践。项目围绕年龄和性别分类任务展开覆盖数据构建、模型训练、测试评估与部署预测等环节并附带有可用的预训练模型文件、配置参数以及Windows环境安装MXNet GPU版本的常见问题说明能有效降低环境配置门槛。全包共36个文件以Python脚本为主20个另有模型缓存、配置文件、训练日志与说明文档整体仅34KB轻量易用便于快速启动与二次开发。已有40人学习下载。目录结构清晰包含数据预处理、标签映射、模型定义、测试与可视化等辅助脚本有助于完整理解深度学习实现链路也可作为毕设或课程设计的实用参考。1. 年龄与性别预测为什么一个模型要做两个任务年龄预测的准确率会比性别准确率低一大截。同样是卷积网络性别二分类在 Adience 上通常能到 93% 左右而年龄八分类常见只到 55%~60%。这不是模型容量不够而是年龄标签本身是回归问题强行离散化成年龄段后相邻类别的特征边界非常模糊。这个基于 Python 机器学习的年龄和性别预测项目选择把两个任务塞进同一个多输出网络里用 MXNet 的 gluon 接口实现源码完整含训练日志和标签映射文件解压后改成英文路径就能跑。一个deploy.py调进去人脸图进去性别和年龄段同时出来。适合课程作业、毕设起步也适合作为深度学习入门的第一个可复现项目。2. 源码清单与 build_dataset.py 数据预处理从 Adience 标注到 cpickle 标签映射这套源码解压后是一堆.py和.cpickle文件看起来杂乱但功能边界很清晰。先搞懂每个文件在管道里的位置后面改代码才不会迷路。2.1 项目文件与职责文件实际职责build_dataset.py解析 Adience 标注 CSV生成标签编码和训练样本列表mxagegendernet.py定义多输出卷积网络年龄头 性别头age_gender_config.py集中管理路径、批大小、学习率、训练轮数train.py训练入口加载数据、构建网络、执行前向和反向mxmetrics.py自定义验证指标比如每个年龄段各自的准确率test_accuracy.py在验证集上计算整体准确率和单类准确率test_prediction.py单张图片推理输出性别和年龄段文本age_gender_deploy.py部署用的封装模块被外部调用agegenderhelper.py公共工具函数比如路径处理、模型加载、图像预处理age_le.cpickle年龄段字符串到索引的映射gender_le.cpickle性别字符串到索引的映射age_adience_mean.json训练集年龄分布或均值数据用于归一化或加权可以看到数据流是build_dataset.py产出.cpickletrain.py消费它们test_prediction.py再反查它们把索引还原成文本。这个设计很朴素但利于学也利于二次开发。2.2 标签解析年龄区间不是连续值Adience 数据集的 age 字段不是整数而是字符串区间比如25-32、38-43还有(0, 2)或(8, 12)这种括号格式。性别字段只有m和f。直接把字符串喂给网络是不行的常见做法是拆成闭区间后映射成类别索引。import pandas as pd import pickle # 8 个年龄段与 Adience 官方口径一致 AGE_BINS [(0, 2), (4, 6), (8, 12), (15, 20), (25, 32), (38, 43), (48, 53), (60, 100)] AGE_INDEX {f{lo}-{hi}: i for i, (lo, hi) in enumerate(AGE_BINS)} GENDER_INDEX {m: 0, f: 1} def parse_adience_label(row): age_str str(row[age]).strip() # 部分标注写成 (0, 2) 或 (8, 12)统一成 0-2 if age_str.startswith((): lo, hi age_str.strip(()).split(,) age_str f{int(lo)}-{int(hi)} if age_str not in AGE_INDEX or row[gender] not in GENDER_INDEX: return None age_label AGE_INDEX[age_str] gender_label GENDER_INDEX[row[gender]] return age_label, gender_label def build_label_csv(csv_path): df pd.read_csv(csv_path) # 只保留检测到人脸的样本 df df[df[hasFace] True] records [] for _, row in df.iterrows(): label parse_adience_label(row) if label is None: continue records.append({ image: f{row[user_id]}/landmark_aligned_face.{row[face_id]}.jpg, age_label: label[0], gender_label: label[1], }) return recordsAGE_BINS的区间是 Adience 论文里的标准 8 分组很多复现项目都沿用这组边界。AGE_INDEX生成的是25-32: 4这种映射训练时网络输出的 8 维 softmax 实际上对应年龄段索引而不是回归一个具体的年龄值。这一步里最容易出错的是去重逻辑Adience 里同一张脸可能有多条标注同一个face_id只保留一条即可否则训练集和验证集会交叉污染。2.3 运行数据构建命令这里不生成原始图片只生成索引和标签映射文件。项目里的age_le.cpickle和gender_le.cpickle就是上一步的AGE_INDEX和GENDER_INDEX序列化结果。python build_dataset.py \ --csv age_gender.csv \ --data_dir /data/Adience \ --output ./train.lst--data_dir是 Adience 原图所在的父目录里面有很多用户 ID 文件夹--output是生成的样本列表每行是图片路径 \t age_label \t gender_label。跑完后用head -5 train.lst检查路径是否真实存在这一条能拦掉大部分路径拼接问题。我拿到的这套源码里build_dataset.py还顺带把年龄均值和性别均值写成了 JSON作用是部署时做减均值操作。MXNet 的 gluon 数据变换里没有自动做 Adience 均值的内置实现所以项目在配置里直接用 JSON 文件里的值做transforms.Normalize这个细节容易被忽略但直接影响验证集准确率。3. mxagegendernet.py 多输出卷积网络与 train.py 训练参数调优网络部分是这个源码的核心也是和二分类项目差异最大的地方。它不是两个独立网络而是共享卷积特征层最后分叉成两个全连接输出头。3.1 共享骨干 双分类头的结构这种设计的动机很直接性别和年龄都依赖人脸纹理、轮廓、皮肤状态等底层特征共享 frontend 可以减少一半参数量也让梯度同时更新特征层对性别相对有利对年龄有正则化作用。代码里用的是 ResNet18 或类似的小型骨干。from mxnet.gluon import nn from mxnet.gluon.model_zoo.vision import resnet18 class AgeGenderNet(nn.HybridBlock): def __init__(self, num_age8, num_gender2): super().__init__() # 共享特征提取器去掉 ResNet 自带的全连接分类层 self.features resnet18(pretrainedTrue) self.features.classifier None # 性别头 self.gender_head nn.HybridSequential() self.gender_head.add(nn.Dense(32, activationrelu)) self.gender_head.add(nn.Dense(num_gender)) # 年龄头 self.age_head nn.HybridSequential() self.age_head.add(nn.Dense(128, activationrelu)) self.age_head.add(nn.Dense(num_age)) def hybrid_forward(self, F, x): feat self.features(x) gender_out self.gender_head(feat) age_out self.age_head(feat) return gender_out, age_outhybrid_forward返回两个输出顺序是性别在前、年龄在后。这个顺序由训练时的 loss 计算决定部署时也要保持一致。age_head中间层维度比gender_head大因为年龄类别区分度低需要更高维的中间表示。ResNet18 的最后卷积输出是 512 维接两个头都是够用的。3.2 损失函数组合与训练循环两个任务都是分类问题所以都用 softmax cross entropy。年龄性能低于性别可以在 loss 加权时给年龄更大的权重。不过这个项目默认是 1:1想要调优可以按2 * age_loss gender_loss试。import mxnet as mx from mxnet import gluon, autograd net AgeGenderNet() softmax_loss gluon.loss.SoftmaxCrossEntropyLoss() trainer gluon.Trainer(net.collect_params(), adam, {learning_rate: 0.001}) for epoch in range(60): for data, gender_label, age_label in train_loader: with autograd.record(): gender_out, age_out net(data) loss_gender softmax_loss(gender_out, gender_label) loss_age softmax_loss(age_out, age_label) total_loss loss_age loss_gender total_loss.backward() trainer.step(data.shape[0])trainer.step传入的是批量大小而不是 batch 内样本数手动除MXNet 会自动按 batch size 归一化梯度。这里要注意autograd.record()必须包住两个输出的 loss只 record 其中一个会导致另一个头的梯度为空参数不更新。3.3 训练超参数与运行命令训练入口是train.py超参集中在age_gender_config.py里。下表是这套源码里比较稳妥的一组超参数推荐值选型理由输入尺寸224x224ResNet 标准输入过小损失细节过大显存占用高批大小648GB 显存够用显存小就降到 32优化器Adam收敛快避免 adam 调 lr 的坑初始学习率1e-3适用迁移学习学习率衰减60 轮时降到 1e-4后期微调分类头训练轮数60Adience 约 1.5w 样本60 轮能收敛数据增强随机裁剪 水平翻转人脸存在对称性翻转是安全的启动训练python train.py --config age_gender_config.py --gpu 0如果没有 GPU就把配置里的ctx改成mx.cpu()。CPU 上训练 60 轮会很慢预期 4~6 小时GPU 只要 20 分钟左右。训练过程中日志会打印每个 epoch 的 loss 和验证集准确率这个日志被保存为training_0.log后面分析过拟合要看它。有个容易犯错的地方预训练模型的features层如果同时练会发现前期 loss 降得很快但验证集准确率反而偏低。我一般会先把前面几层 learning rate 设为全局的十分之一或者先冻结features只训练两个 head等 head 收敛后再解冻全层。项目源码没有这个选项但你可以在train.py里用net.features.collect_params().setattr(lr_mult, 0.1)做到。4. test_accuracy.py 验证与 test_prediction.py 部署推理的完整链路训练完的模型文件包括符号图、参数、标签映射三个部分。验证和部署的差异只在于是否遍历全部验证数据以及最终输出是文本还是逗号分隔的准确率。4.1 验证集准确率计算test_accuracy.py的逻辑是加载训练好的mxagegendernet网络把 checkpoint 参数灌进去然后在验证集上跑一轮预测计算整体准确率和每个年龄段、每个性别的准确率。注意这里不能直接复用训练时的 DataLoader因为验证集不需要数据增强且 batch 内的排序不需要 shuffle。from mxnet import gluon, nd from mxagegendernet import AgeGenderNet def evaluate(net, val_loader, ctx): top1_gender 0.0 top1_age 0.0 total 0 for data, gender_label, age_label in val_loader: data data.as_in_context(ctx) gender_out, age_out net(data) gender_pred nd.argmax(gender_out, axis1) age_pred nd.argmax(age_out, axis1) top1_gender nd.sum(gender_pred gender_label).asscalar() top1_age nd.sum(age_pred age_label).asscalar() total len(gender_label) return top1_gender / total, top1_age / totalnd.argmax默认返回扁平索引axis1表示按每个样本的输出通道取最大。这里的gender_label和age_label是整型张量在 Adience 验证集上这个代码能直接把两个预测结果对齐到样本维度不会出现 batch 内错位。4.2 部署推理从图片到文本标签部署时真正需要的文件只有四个age_gender_deploy.py、age_gender_deploy.py生成的模型实际上是通过 checkpoint 保存的符号和参数、age_le.cpickle、gender_le.cpickle。在源码里test_prediction.py是部署流程的骨架。import pickle import numpy as np import mxnet as mx from mxnet import nd import cv2 def load_label_encoder(path): with open(path, rb) as f: return pickle.load(f) def preprocess_image(image_path, input_size224): img cv2.imread(image_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (input_size, input_size)) img img.astype(float32) / 255.0 # 用 Adience 均值归一化 mean np.array([0.4385, 0.4049, 0.3763]) img img - mean img img.transpose(2, 0, 1) img np.expand_dims(img, axis0) return nd.array(img) def predict_face(image_path, sym, arg_params, aux_params, age_le, gender_le): data preprocess_image(image_path) executor sym.simple_bind(ctxmx.cpu(), datadata.shape) executor.copy_params_from(arg_params, aux_params) executor.forward(is_trainFalse, datadata) gender_out executor.outputs[0].asnumpy() age_out executor.outputs[1].asnumpy() gender_idx int(np.argmax(gender_out[0])) age_idx int(np.argmax(age_out[0])) gender_text [k for k, v in gender_le.items() if v gender_idx][0] age_text [k for k, v in age_le.items() if v age_idx][0] return gender_text, age_textpreprocess_image里三个关键点颜色通道要先 BGR 转 RGB因为模型是在 RGB 的图像上训练的归一化用的是 Adience 数据集的像素均值不是 ImageNet 均值最后要加一个 batch 维度simple_bind会严格校验数据 shape漏掉np.expand_dims一定会报维度错误。如果项目里模型是用 gluon 的save_parameters保存而不是save_checkpoint还要改加载方式为net.load_parameters。从源码自带文件名推测它用的是 MXNet 经典的save_checkpoint三件套-symbol.json、-0000.params、-0000.nd。4.3 单张图片预测的运行命令python test_prediction.py --model_prefix age_gender_model --epoch 60 --image test_face.jpg输出常见是Predicted: male, 25-32这种格式。这一步最容易出问题的是测试图片里人脸不是正脸或者检测框不准。源码没有内置人脸检测所以我一般建议先用 OpenCV 的 DNN 人脸检测器跑一个裁剪框再交给test_prediction.py。否则模型在非人脸图上输出没有意义而且 softmax 概率往往接近均匀分布从分布形状就能看出来输入不对。一个简单验证方式打印age_out[0]的原始 logits如果最大值和次大值差不大于 0.2基本可以判定输入不是人脸。5. 训练日志解读与年龄分类不平衡的排错技巧training_0.log是训练过程中保存下来的标准输出格式通常是Epoch[0] Train-accuracy0.83 Valid-accuracy0.58。很多人只看整体准确率但这个项目必须分开看年龄和性别各自的表现否则会掩盖问题。5.1 从日志判断过拟合发生在哪个头看前 10 个 epoch性别准确率可能迅速爬到 0.9年龄还在 0.4 附近这是正常的两个任务难度不同。如果第 30 轮后性别验证准确率不再变化但年龄验证准确率小幅波动就说明性别头已经收敛年龄头还在学习困难样本。如果此时训练准确率已经接近 1.0 而验证只有 0.6说明整体过拟合。常见的处理是给gender_head加 dropout或者把features层冻结起来只训两个分类头。年龄类别不均衡在日志里的表现是整体年龄准确率被 25-32 这类大样本主导而 0-2 和 60-100 的准确率可能只有 20%。Adience 数据集中 25-32 年龄段样本量接近 0-2 的五六倍网络会把所有不确定样本都推向高频类别。针对这一点我会在 loss 里对年龄类别按样本量倒数加权from collections import Counter import numpy as np age_count Counter(all_age_labels) total sum(age_count.values()) age_weight np.array([total / age_count[i] for i in range(8)], dtypefloat32) age_weight age_weight / age_weight.sum() # 训练时传给 MXNet Loss 的 sample_weight 参数 loss_age softmax_loss(age_out, age_label, sample_weightage_weight)权重只作用在年龄分支性别分支不引入额外的类别权重。sample_weight的 shape 要跟 label 一致或者能被广播成[batch_size, 1]否则 mxnet 会报错说维度不匹配。这个技巧能直接提升 0-2 和 60-100 的召回率但会把 25-32 的准确率拉低 2~3 个百分点业务上更关心整体准确率时谨慎使用。5.2 验证集分布漂移的坑项目里的age_adience_mean.json是训练集的年龄分布均值。如果下载的数据集和原版 Adience 不是同一个版本或者训练前手动过滤了大量图片这个均值就不再准确。最简单的自查方法是统计验证集 age 索引和 json 里每个年龄段比例对比相差超过 5% 就要重新生成训练列表。另外原始数据中同一张人脸出现多次比如同一个人在不同照片里的脸如果都进训练集会导致模型记忆人物身份而非年龄特征。我在做数据拆分时一般按user_id分组保证同一人的所有照片只出现在训练集或验证集中否则测试准确率虚高 8% 以上。5.3 Windows 下 MXNet GPU 安装的边界条件项目附带了一个mxnet的gpu版本windows安装问题.txt说明作者也踩过坑。Windows 下pip install mxnet-cu101这类版本包常见的失败原因是 CUDA 和 cuDNN 版本不匹配或者 Visual Studio 编译环境缺失。建议先只用 CPU 版把流程跑通再换 GPUpip install mxnet然后在age_gender_config.py里设置ctx mx.cpu()。CPU 推理一张 224x224 图片大约 200 毫秒测试功能足够。等人脸数据规模大了再考虑 Docker 或 WSL 里的 Linux GPU 环境这是性价比最高的路径。要看年龄预测是否真的提升了可以把几个难分的年龄区间合并比如把 15-20 和 25-32 合并成“青年”如果合并后准确率明显提高说明网络学到的年龄排序特征是对的只是区间边界定义太细了。这个结论可以直接指导你改写AGE_BINS而不是盲目加深网络。本文还有配套的精品资源点击获取