☰
Python机器学习实战:基于多任务CNN的年龄与性别预测
2026/10/3 3:11:56 网站建设 项目流程

简介:这份资源是面向计算机相关专业学生与初学者的机器学习实战项目,围绕年龄与性别预测任务,提供完整的Python源码与配套数据集,适合课程设计、大作业、毕业设计或入门练手。项目基于MXNet深度学习框架搭建,涵盖数据构建、模型训练、精度测试与预测部署等环节,代码结构清晰,便于理解从数据处理到模型推理的完整流程。压缩包共36个文件,以20个py脚本为核心,辅以txt说明、cpickle序列化模型文件、json配置与日志、md文档,整体约34KB,体积轻量,解压后即可按说明运行。目前已有40人学习关注。对于希望掌握图像分类与多任务预测思路的读者,可借此熟悉数据预处理、标签编码、网络定义与评估指标等关键环节,并在此基础上进行二次开发或功能扩展,具备较高的学习借鉴价值。

1. 从一张人脸到两个标签:年龄和性别预测到底在做什么

你手里有一张用户上传的自拍,业务侧想要两个标签:大概多少岁、是男是女。听起来像两个独立任务,但真正落地时,它们往往被塞进同一个模型里一起出结果。这就是「基于 Python 机器学习的年龄和性别预测」最朴素的形态——输入一张人脸图,输出一个年龄数值和一个性别类别。它不神秘,也不依赖什么黑匣子,核心就是卷积神经网络加两个输出头。适合谁做?想入门计算机视觉、手里有数据集、需要快速跑通一个多任务学习 demo 的 Python 开发者。你不需要从零推导反向传播,但得知道数据怎么洗、模型怎么搭、两个 loss 怎么平衡。很多人卡住的地方不是网络结构,而是数据集里那些模糊的、侧脸的、戴墨镜的样本,它们会让模型在验证集上看起来还行,一上真实场景就翻车。这一篇就按「数据准备 → 模型搭建 → 训练调参 → 避坑排查 → 进阶技巧」的顺序,把这条链路拆开讲清楚,让你能照着复现,也能判断这个方向值不值得投入。

2. 数据集处理与输入管线:别急着喂给模型

2.1 年龄和性别标签的常见组织方式

你拿到的数据集压缩包里,通常是一个文件夹放图片,另一个 CSV 或 txt 记录文件名、年龄、性别。常见字段名有file、age、gender,性别一般用 0/1 或 male/female 表示。第一步不是写模型,而是把这份索引读进 pandas,检查三件事:年龄有没有负数或超过 120 的异常值、性别字段有没有第三种取值、文件名和实际图片是否一一对应。我一般会先跑一段统计脚本,把年龄分布画出来。如果发现 0 到 5 岁和 60 到 80 岁的样本极少,那后面训练时就要考虑加权采样,否则模型会偏向预测 20 到 40 岁这个区间。

import pandas as pd import os # 读取标签文件,假设是 CSV,分隔符为逗号 df = pd.read_csv('labels.csv') # 统一列名,避免大小写和空格干扰 df.columns = [c.strip().lower() for c in df.columns] # 检查年龄范围 print(df['age'].describe()) # 过滤掉明显异常:年龄小于 1 或大于 100 df = df[(df['age'] >= 1) & (df['age'] <= 100)] # 检查性别取值 print(df['gender'].value_counts()) # 确认图片路径存在 df['exists'] = df['file'].apply(lambda x: os.path.exists(os.path.join('images', x))) print(df['exists'].value_counts())

这段代码的逻辑是先做描述性统计,再用硬阈值剔除异常样本,最后验证文件路径。参数上,年龄上下限可以根据你的业务场景调整,比如只做成年人就设 18 到 80。性别字段如果出现unknown,要么归入某一类,要么直接丢弃,别让模型学一个模棱两可的第三类。

2.2 用 OpenCV 做人脸对齐和裁剪

原始图片往往包含背景、肩膀、头发,直接缩放到 224x224 会把大量无关像素喂给网络。常见做法是用 OpenCV 的 Haar 级联或 DNN 人脸检测器先框出人脸,再按框裁剪并缩放到固定尺寸。这一步能显著提升收敛速度。注意,如果检测不到人脸,不要直接丢弃,可以保留原图并做中心裁剪,否则数据量会掉得厉害。

import cv2 import numpy as np # 加载 OpenCV 自带的人脸检测器 face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') def crop_face(img_path, target_size=(224, 224)): img = cv2.imread(img_path) if img is None: return None gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30)) if len(faces) == 0: # 没检测到人脸时,取图像中心区域 h, w = img.shape[:2] side = min(h, w) y1 = (h - side) // 2 x1 = (w - side) // 2 crop = img[y1:y1+side, x1:x1+side] else: # 取面积最大的那个人脸框 x, y, w, h = max(faces, key=lambda f: f[2] * f[3]) crop = img[y:y+h, x:x+w] crop = cv2.resize(crop, target_size) return crop

scaleFactor控制每次图像缩小的比例,设 1.1 表示每次缩小 10%,值越小检测越慢但更细。minNeighbors越大,误检越少但漏检越多,一般 5 到 6 比较稳。minSize过滤掉太小的框,避免把噪点当人脸。裁剪后统一到 224x224,是因为后面用的骨干网络通常按这个尺寸预训练。

2.3 训练集、验证集划分与数据增强

划分不能随机打乱就完事。如果同一个人有多张照片,必须按人划分,否则同一个人既出现在训练集又出现在验证集,验证分数会虚高。我一般用GroupShuffleSplit,把人员 ID 作为分组依据。数据增强方面,年龄和性别预测对水平翻转、轻微旋转、亮度调整是安全的,但不要用大角度旋转或裁剪,因为那会改变面部结构,年龄特征会失真。

from sklearn.model_selection import GroupShuffleSplit from tensorflow.keras.preprocessing.image import ImageDataGenerator # 假设 df 里有一列 person_id 标识同一个人 gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(df, groups=df['person_id'])) train_df = df.iloc[train_idx] val_df = df.iloc[val_idx] # 训练集增强:翻转、小角度旋转、亮度微调 train_datagen = ImageDataGenerator( horizontal_flip=True, rotation_range=10, brightness_range=(0.8, 1.2), fill_mode='nearest' ) # 验证集只做归一化,不做增强 val_datagen = ImageDataGenerator()

rotation_range=10表示随机旋转正负 10 度,再大就可能把下巴或额头切掉。brightness_range模拟不同光照,但别设得太宽,否则肤色会变得不自然。验证集不做增强,是为了让评估结果稳定可复现。

3. 多任务模型搭建:一个骨干,两个头

3.1 为什么用多任务而不是两个独立模型

年龄和性别共享大量底层特征,比如脸型、五官比例、皮肤纹理。如果分别训练两个模型,等于把同样的卷积计算做了两遍,推理时也要跑两次。多任务学习让一个骨干网络同时输出两个结果,不仅省算力,还能让性别分类的梯度帮助年龄回归学到更鲁棒的特征。常见做法是拿一个预训练的图像分类网络当骨干,比如 MobileNetV2 或 ResNet50,去掉顶层,接一个全局池化,然后分两个分支:一个输出年龄的回归值,一个输出性别的二分类概率。

3.2 用 Keras 搭一个双输出网络

下面这段代码用 TensorFlow/Keras 实现一个基于 MobileNetV2 的双输出模型。骨干网络加载 ImageNet 预训练权重,前几层冻结,后面再解冻微调。

import tensorflow as tf from tensorflow.keras import layers, models, applications def build_model(input_shape=(224, 224, 3)): # 加载预训练骨干,不包含顶层分类器 base = applications.MobileNetV2(input_shape=input_shape, include_top=False, weights='imagenet') # 先冻结骨干,只训练后面的头 base.trainable = False inputs = layers.Input(shape=input_shape) x = base(inputs, training=False) x = layers.GlobalAveragePooling2D()(x) x = layers.Dropout(0.3)(x) # 年龄分支:回归,输出一个数值 age_branch = layers.Dense(128, activation='relu')(x) age_branch = layers.Dropout(0.2)(age_branch) age_output = layers.Dense(1, activation='linear', name='age')(age_branch) # 性别分支:二分类,输出一个概率 gender_branch = layers.Dense(128, activation='relu')(x) gender_branch = layers.Dropout(0.2)(gender_branch) gender_output = layers.Dense(1, activation='sigmoid', name='gender')(gender_branch) model = models.Model(inputs=inputs, outputs=[age_output, gender_output]) return model model = build_model() model.summary()

年龄输出用linear激活,因为年龄是连续值。性别输出用sigmoid,输出 0 到 1 之间的概率,大于 0.5 判为男性或女性,取决于你的标签编码。两个分支各自带一个 Dropout,防止过拟合。base.trainable = False表示第一阶段只训练新加的全连接层,等 loss 稳定后再解冻部分骨干层做微调。

3.3 损失函数加权与编译参数

多任务训练的关键是平衡两个 loss。年龄回归用均方误差 MSE,性别分类用二元交叉熵 BCE。如果直接相加,MSE 的数值通常在几十到几百,BCE 在 0.1 到 1 之间,年龄 loss 会主导梯度。常见做法是给年龄 loss 一个较小的权重,比如 0.01 到 0.05,或者对年龄做归一化,把它缩放到 0 到 1 再算 MSE。

# 年龄归一化:假设年龄范围 1 到 100 age_min, age_max = 1.0, 100.0 def age_loss(y_true, y_pred): # 把真实年龄缩放到 0-1 y_true_norm = (y_true - age_min) / (age_max - age_min) return tf.keras.losses.mse(y_true_norm, y_pred) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss={'age': age_loss, 'gender': 'binary_crossentropy'}, loss_weights={'age': 1.0, 'gender': 1.0}, metrics={'age': 'mae', 'gender': 'accuracy'} )

这里把年龄 loss 内部做了归一化,所以loss_weights可以都设 1.0。如果你不想改 loss 函数,也可以直接把loss_weights设成{'age': 0.02, 'gender': 1.0},效果类似。学习率先用 1e-3,等验证 loss 不降了再降到 1e-4。metrics里年龄看 MAE,性别看准确率,方便你判断两个任务是否都在正常收敛。

4. 训练、评估与调参:让两个头都收敛

4.1 用 fit 跑通第一轮训练

数据管线用ImageDataGenerator的flow_from_dataframe,把图片路径和标签对应起来。注意年龄和性别要作为两个输出传给模型,所以生成器需要返回一个字典或列表。Keras 的flow_from_dataframe默认只返回一个标签,这里我一般自己写一个tf.data管线,更灵活。

import tensorflow as tf def load_and_preprocess(path, age, gender): img = tf.io.read_file(path) img = tf.image.decode_jpeg(img, channels=3) img = tf.image.resize(img, (224, 224)) img = img / 255.0 # 年龄归一化到 0-1 age = (age - age_min) / (age_max - age_min) return img, {'age': age, 'gender': gender} def make_dataset(df, batch_size=32, shuffle=True): paths = df['file'].apply(lambda x: os.path.join('images', x)).values ages = df['age'].values.astype('float32') genders = df['gender'].values.astype('float32') ds = tf.data.Dataset.from_tensor_slices((paths, ages, genders)) if shuffle: ds = ds.shuffle(buffer_size=len(df)) ds = ds.map(load_and_preprocess, num_parallel_calls=tf.data.AUTOTUNE) ds = ds.batch(batch_size).prefetch(tf.data.AUTOTUNE) return ds train_ds = make_dataset(train_df, batch_size=32, shuffle=True) val_ds = make_dataset(val_df, batch_size=32, shuffle=False) history = model.fit( train_ds, validation_data=val_ds, epochs=20, callbacks=[ tf.keras.callbacks.EarlyStopping(patience=5, restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=3) ] )

num_parallel_calls和prefetch让数据加载和 GPU 计算重叠,避免 GPU 空等。EarlyStopping在验证 loss 连续 5 轮不降时停止,并恢复最佳权重。ReduceLROnPlateau在 3 轮不降后把学习率减半,帮助跳出局部震荡。

4.2 解冻骨干做微调

第一阶段训练完后,把骨干网络的后半部分解冻,用更小的学习率再跑几轮。MobileNetV2 有 150 多层,一般解冻最后 30 到 50 层就够了,前面的层保留通用特征。

# 解冻骨干的最后 40 层 base_model = model.layers[1] # 假设骨干是第二层 for layer in base_model.layers[-40:]: layer.trainable = True # 重新编译,学习率降到 1e-4 model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-4), loss={'age': age_loss, 'gender': 'binary_crossentropy'}, loss_weights={'age': 1.0, 'gender': 1.0}, metrics={'age': 'mae', 'gender': 'accuracy'} ) history_fine = model.fit( train_ds, validation_data=val_ds, epochs=15, callbacks=[ tf.keras.callbacks.EarlyStopping(patience=5, restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=3) ] )

微调时学习率一定要小,否则预训练权重会被破坏。如果验证 loss 反而上升,说明解冻层数太多或学习率太大,减少解冻层数或再降学习率。

4.3 评估指标:年龄看 MAE,性别看准确率

训练完后,在验证集上分别算年龄的平均绝对误差和性别的准确率。年龄 MAE 在 5 到 8 岁之间通常算可用,低于 5 岁说明数据集比较干净。性别准确率在 90% 以上算正常,低于 85% 要检查是不是侧脸或遮挡样本太多。

# 在验证集上预测 val_images = [] val_ages = [] val_genders = [] for img, labels in val_ds: val_images.append(img) val_ages.append(labels['age']) val_genders.append(labels['gender']) val_images = tf.concat(val_images, axis=0) val_ages = tf.concat(val_ages, axis=0) val_genders = tf.concat(val_genders, axis=0) pred_ages, pred_genders = model.predict(val_images) # 反归一化年龄 pred_ages = pred_ages.flatten() * (age_max - age_min) + age_min true_ages = val_ages.numpy() * (age_max - age_min) + age_min mae = np.mean(np.abs(pred_ages - true_ages)) # 性别准确率 pred_gender_labels = (pred_genders.flatten() > 0.5).astype(int) true_gender_labels = val_genders.numpy().astype(int) acc = np.mean(pred_gender_labels == true_gender_labels) print(f'Age MAE: {mae:.2f}, Gender Acc: {acc:.4f}')

如果年龄 MAE 偏大,先看是不是某些年龄段样本太少,可以分桶统计误差。性别准确率低,优先检查标签编码是否一致,比如训练时 0 代表男性,验证时有没有搞反。

5. 避坑与排查:那些让模型看起来还行但实际翻车的细节

5.1 现象:验证 loss 正常但推理时年龄全预测成同一个值

原因通常是年龄回归的 loss 被性别分类的梯度淹没,或者年龄标签没有归一化,导致模型直接输出均值就能让 MSE 很小。解决方法是把年龄归一化到 0 到 1,或者给年龄 loss 加权重。另外检查最后一层年龄输出有没有被sigmoid误激活,回归任务必须用linear。

5.2 现象:性别准确率很高但年龄 MAE 很大

这往往是因为两个任务共享了太多底层特征,而性别分类更容易学,梯度更新时把骨干带偏了。可以尝试给两个分支各自加一个独立的卷积层,或者用梯度裁剪把年龄分支的梯度范数限制住。我一般会在年龄分支前加一个BatchNormalization,让数值更稳定。

5.3 现象:训练集准确率 99%,验证集只有 70%

典型的过拟合。先检查是不是同一个人同时出现在训练集和验证集,用GroupShuffleSplit按人划分。然后增加 Dropout 比例,或者加 L2 正则。数据增强也可以再强一点,但别用大角度旋转。如果数据集本身只有几千张,考虑用预训练模型冻结更多层,只训练最后的分类头。

5.4 现象:OpenCV 检测不到人脸,裁剪后全是背景

Haar 级联对侧脸和暗光很敏感。换成 OpenCV 的 DNN 人脸检测器,或者用 MTCNN。如果还是检测不到,就退化为中心裁剪,但要在训练时告诉模型这不是标准人脸,可以加一个标志位。另外检查图片是不是灰度图或四通道 PNG,cv2.imread默认读成 BGR 三通道,遇到四通道会出错,需要加cv2.IMREAD_COLOR参数。

5.5 现象:模型推理速度太慢,达不到实时

MobileNetV2 在 CPU 上单张推理大约 50 到 100 毫秒,如果还嫌慢,可以换成 MobileNetV3 Small 或把输入尺寸降到 128x128。另外检查是不是每次推理都重新加载了模型,应该把模型加载放在服务启动时,而不是每次请求都加载。用 TensorFlow Lite 转换后还能再快一倍,但精度会掉一点,需要权衡。

6. 进阶技巧:用年龄分桶辅助回归和模型量化部署

年龄回归有一个天然优势:你可以把它变成分类问题来辅助回归。具体做法是把年龄分成若干个桶,比如 0-10、11-20、…、91-100,先训练一个分类头预测桶,再用桶的中心值作为年龄的粗估计,最后用一个小的回归头在桶内做微调。这样模型不会因为个别极端年龄样本而震荡,MAE 通常能降 0.5 到 1 岁。我一般会在骨干后面同时接三个头:年龄回归、年龄分桶分类、性别分类,三个 loss 加权求和。分桶分类的 loss 用交叉熵,权重设 0.3 左右。

# 年龄分桶:每 10 岁一个桶,共 10 个桶 def age_to_bucket(age): bucket = tf.floor(age / 10.0) bucket = tf.clip_by_value(bucket, 0, 9) return tf.cast(bucket, tf.int32) # 在模型里增加一个分桶分类头 bucket_branch = layers.Dense(128, activation='relu')(x) bucket_output = layers.Dense(10, activation='softmax', name='age_bucket')(bucket_branch) model = models.Model(inputs=inputs, outputs=[age_output, gender_output, bucket_output]) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss={'age': age_loss, 'gender': 'binary_crossentropy', 'age_bucket': 'sparse_categorical_crossentropy'}, loss_weights={'age': 1.0, 'gender': 1.0, 'age_bucket': 0.3}, metrics={'age': 'mae', 'gender': 'accuracy', 'age_bucket': 'accuracy'} )

分桶分类的标签需要是整数,所以用sparse_categorical_crossentropy。训练完后,最终年龄可以取回归输出和分桶中心值的加权平均,比如0.7 * 回归值 + 0.3 * 桶中心。这个技巧在数据量偏少或年龄分布不均时特别管用。

另一个进阶方向是模型量化。用 TensorFlow Lite 把模型转成 int8 格式,体积缩小到原来的四分之一,推理速度提升明显。转换时需要一个代表数据集做校准,通常从训练集里抽 100 到 200 张图就够了。量化后年龄 MAE 可能会涨 0.3 到 0.5,性别准确率掉 1% 左右,如果业务能接受,部署成本会低很多。

# 转换为 TFLite int8 量化模型 converter = tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations = [tf.lite.Optimize.DEFAULT] # 代表数据集生成器 def representative_dataset(): for img, _ in train_ds.take(100): yield [img] converter.representative_dataset = representative_dataset converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type = tf.uint8 converter.inference_output_type = tf.uint8 tflite_model = converter.convert() with open('age_gender_quant.tflite', 'wb') as f: f.write(tflite_model)

量化后的模型输入输出都变成 uint8,推理时要把图片归一化到 0 到 255 再传入。我自己的习惯是,每次做完一个模型,先不急着上线,而是拿 50 张真实场景的图跑一遍,看看年龄误差和性别误判的案例长什么样。很多时候,模型在验证集上的分数和真实体验之间的差距,就藏在那几张侧脸、逆光、戴帽子的图里。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询