
简介这是一份面向计算机视觉入门与进阶研究者的性别检测与分类数据集包含300张真实手机采集的高质量人脸图片已按woman和man完成分类划分与标注可直接用于深度学习模型训练与算法验证。压缩包整体约339MB共505个文件以Python训练/推理脚本、模型配置文件、预训练模型检查点checkpoint、pb、pbtxt、样本图片与说明文档为主同时包含少量视频样例与数据记录文件结构清晰便于配合TensorFlow Object Detection API等框架使用。数据集文件按用途分层组织涵盖图片、标注信息与数据记录可快速构建数据流水线并加载预训练权重进行微调或推理。目前已有58人浏览/学习。除了支撑人脸检测、特征提取和性别分类实验外还能结合MTCNN、SSD、YOLO等检测思路以及Faster R-CNN、Mean-shift聚类和卡尔曼滤波实现人流统计分析适合用于算法调研、课程设计或实际项目验证。1. 三百张人脸照片训性别分类模型别急着直接上 CNN看到“人脸性别检测分类数据集-含 womanman 两种分类300 张高质量真实手机采集人脸图片已做分类划分标注”这个标题第一反应通常是终于有现成数据可以直接扔进 ResNet 训练了。但 300 张图片对于深度学习二分类而言属于典型的小样本。直接从头训一个深层网络大概率验证集还有九成准确率换一张现场拍的照片就崩。这个数据集真正的价值不在“够用”而在于把数据清洗、迁移学习、评估口径这整条链路压缩到了可以一个下午走完的程度——前提是你知道怎么用对方法。本文就从这份数据集的“可用边界”出发讲清楚如何组织数据、加载数据、微调模型以及在小样本下怎么判断模型是真的学到了性别特征还是仅仅背下了这 300 张图的灯光和角度。适合刚接触分类任务的读者按步骤跑通也适合有经验的人对照检查自己的数据划分和验证手法有没有漏洞。2. 人脸性别检测的数据组织与标注校验从目录结构到划分清单2.1 先定分类语义二分类不是“男人/女人”这么简单性别检测在这个数据集里被定义为二分类任务标签只有 woman 和 man。但实际准备数据时首先要检查标注的边界图片里是只有一张正脸还是有多张脸如果一张图出现多人模型该学哪个区域手机采集的人脸通常带有旋转角度、遮挡、光照不均这些都会让一个简单的二分类变得不稳定。我拿到一份自称“已划分标注”的数据集第一件事不是训练而是先写脚本遍历所有图片统计每张图的尺寸、通道数、文件后缀同时检查标注文件是否与文件名一一对应。常见的问题是某些图片是灰度图某些是 RGBA 四通道某些标注文件名多了空格或中文这些都会在加载时报错。这里先给出一段最基础的目录核对脚本用 Python 实现import os from PIL import Image base_dir face_gender_dataset for split in [train, val, test]: split_path os.path.join(base_dir, split) if not os.path.exists(split_path): print(f[warning] miss split: {split}) continue for label in [woman, man]: label_dir os.path.join(split_path, label) if not os.path.isdir(label_dir): print(f[warning] miss class dir: {label_dir}) continue imgs os.listdir(label_dir) print(f{split}/{label}: {len(imgs)} files) for name in imgs[:3]: img_path os.path.join(label_dir, name) try: with Image.open(img_path) as img: print(f {name}: {img.size}, {img.mode}) except Exception as e: print(f [error] {name}: {e})这段代码会输出每个划分下每个类别的文件数以及前几张图的尺寸和通道模式。逻辑说明它先检查目录是否存在再检查两个标签目录最后用 PIL 读取图片头部信息不加载完整像素速度很快。参数方面img.size返回(width, height)img.mode返回RGB、L灰度、RGBA含透明通道等。如果发现某张图是 RGBA后续读入转 RGB 时就必须做通道融合否则会报错或者颜色失真。2.2 标注划分清单要另存一份 JSON别迷信文件夹名很多人习惯用文件夹名直接当标签比如train/woman/xxx.jpg。这种方式简单但一旦需要重新划分、增加样本或者做交叉验证就得移动文件既慢又容易出错。我倾向于维护一份split.json或split.csv显式记录每个样本属于哪个划分、什么标签、原始路径。这么做的好处是数据增强时不需要物理复制图片只需要在读取时按清单路径动态操作。下面给出一个从文件夹生成 JSON 清单的示例import json import os from sklearn.model_selection import train_test_split base_dir face_gender_dataset samples [] for split in [train, val, test]: for label in [woman, man]: label_dir os.path.join(base_dir, split, label) for fname in os.listdir(label_dir): if fname.lower().endswith((.jpg, .jpeg, .png)): samples.append({ path: os.path.join(label_dir, fname), label: label, split: split }) with open(face_gender_split.json, w, encodingutf-8) as f: json.dump(samples, f, ensure_asciiFalse, indent2) print(ftotal samples: {len(samples)})这里使用了sklearn.model_selection中的train_test_split虽然这个例子里没有真正调用但如果你后续需要重新划分比如把 train 再拆出 20% 给 val可以用它。注意ensure_asciiFalse是为了在 JSON 里保留中文标签名方便人工检查。生成清单后训练代码只需要读这个 JSON而不再关心图片在哪个文件夹后续做五折交叉验证、按敏感场景重采样都会灵活很多。2.3 检查类别平衡300 张图可能根本不是 150 比 150标题里只写了“含 womanman 两种分类 300 张”没写两类各多少。这个数字必须自己数。如果 woman 有 220 张man 只有 80 张那么模型会倾向于把所有模糊人脸都判成 woman因为在训练中减少 loss 的最快捷方式就是多预测大类别。这时候有两种处理方式一是收集更多 man 的数据二是给 loss 加权重让少数类的错误得到更高惩罚。对于 300 张的小数据集我更建议先用类别权重而不是先删数据或暴力复制少数类——复制样本容易让模型记住重复像素反而过拟合。在 PyTorch 里给CrossEntropyLoss传一个weight向量即可。计算方式可以简单用n_total / (n_classes * n_class_samples)。下面这段代码放在训练脚本的数据准备部分import torch class_names [woman, man] counts [220, 80] # 假设值以你实际统计为准 total sum(counts) weights [total / (len(counts) * c) for c in counts] weights_tensor torch.tensor(weights, dtypetorch.float32) criterion torch.nn.CrossEntropyLoss(weightweights_tensor)这段代码的作用是把 one-hot 的 loss 按类别占比做缩放woman 的损失权重小于 man这样模型在预测 man 时出错会被放大迫使它更仔细学习 man 的特征。要注意weights_tensor的维度必须与模型输出的类别顺序一致如果你的 className 顺序是[man, woman]就对应调整。类别不平衡还可能体现在验证集划分上如果 300 张里 man 只有 80 张而 val/test 又随机抽可能验证集里 man 只有十几张指标抖动会非常大。此时建议按类别分层抽样保证每个 split 中类别比例大致一致。3. 用 PyTorch 训练二分类模型数据加载、预训练权重与核心参数3.1 数据加载别把全部 300 张一次塞进内存300 张图确实不大每张 1080p 的 JPEG 展开成 RGB 数组也只有约 6MB300 张约 1.8GB看起来能放进内存。但训练时不仅需要原图还要做随机裁剪、翻转、颜色抖动这些操作会在每个 epoch 重新产生新的张量累积的内存压力不小。更关键的是直接把所有数据读进内存会让你失去用DataLoader调试数据集 pipeline 的机会。正规做法是保留ImageFolder或自定义Dataset让磁盘读取和预处理发生在迭代过程中。使用torchvision.datasets.ImageFolder是最省事的方案它的目录结构要求是root/label/*.jpg正好与你现有目录匹配。如果前面生成了 JSON 清单也可以写入自定义 Dataset但第一版先跑通 ImageFolder 更稳。加载代码from torchvision import datasets, transforms train_transforms transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees10), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_dataset datasets.ImageFolder(face_gender_dataset/train, transformtrain_transforms) train_loader torch.utils.data.DataLoader( train_dataset, batch_size16, shuffleTrue, num_workers4 )这里提到几个必须关注的地方Resize((224, 224))把不同分辨率的手机照片强行缩放到标准输入尺寸会损失一部分细节但这是预训练模型输入的硬性要求。RandomHorizontalFlip对性别分类有意义吗横向翻转对男女外貌基本是合法的增强因为人脸左右对称性较强。RandomRotation(degrees10)则模拟手机拍照的轻微倾斜但角度不能太大超过 20 度容易把头肩区域旋出画面。Normalize的均值和标准差必须使用 ImageNet 的统计值因为后面加载的预训练权重是在 ImageNet 上预训练的输入数据需要保持相同分布。如果你的模型从头训练这里可以不改但既然是小数据预训练几乎是必需的。3.2 迁移学习把 ResNet18 的最后两层换掉就够用300 张图训一个 ResNet50 纯粹是自找麻烦参数量太大必然过拟合。可用的预训练模型很多但对于二分类、输入是 224×224 人脸的场景ResNet18 或 MobileNetV3-Small 已经绰绰有余。我常用 ResNet18 作为基线因为它结构成熟、在 CPU 上也能快速推理而且 PyTorch 直接提供预训练权重。加载预训练模型并替换分类头的代码如下import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_features model.fc.in_features model.fc torch.nn.Linear(num_features, 2)这里model.fc.in_features是 ResNet18 最后一个池化层输出的维度对于 resnet18 是 512。替换成输出为 2 的全连接层后模型就能输出两个类别的 logits。注意weightsmodels.ResNet18_Weights.IMAGENET1K_V1这个写法是 torchvision 新版本推荐的旧版本用pretrainedTrue会提示弃用。如果你用的是 MobileNetV3分类头不叫fc而是classifier[3]。为此我一般写一个通用函数def get_model(nameresnet18, num_classes2): if name resnet18: model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc torch.nn.Linear(model.fc.in_features, num_classes) elif name mobilenet_v3_small: model models.mobilenet_v3_small(weightsmodels.MobileNet_V3_Small_Weights.IMAGENET1K_V1) model.classifier[3] torch.nn.Linear(model.classifier[3].in_features, num_classes) else: raise ValueError(funsupported model: {name}) return model这段函数避免你每次换网络时去翻源码查分类头结构。加载预训练权重后分类头是随机初始化的而你前几轮梯度更新会把整个网络都推着走导致预训练特征被快速破坏。所以下一步是冻结骨干网络只训练分类头。3.3 冻结骨干微调的代码与学习率策略冻结骨干本质是让预训练层不参与反向传播只更新最后几层。对于 300 张图的数据量从零微调全网络几乎必然过拟合。先冻结训练几个 epoch等分类头收敛后再解冻最后一两个 block用小学习率微调这是成熟的小样本分类做法。实现方式for param in model.parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True optimizer torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr1e-3)filter(lambda p: p.requires_grad, model.parameters())确保优化器只会更新分类头的参数。lr1e-3对于新初始化的全连接层来说是合适的学习率如果一开始就用1e-4收敛速度会非常慢。跑 5 ~ 10 个 epoch 后观察训练 loss 是否下降、验证准确率是否上升再把requires_grad打开网络后半部分比如model.layer4并把学习率降到1e-5继续微调。为什么是layer4因为 ResNet 中越靠后的层学习到的是越具体、越任务相关的特征先微调这里比微调layer1更安全。训练循环本身很常规但有一点容易被忽略验证集也要做与训练集不同的 transform。验证时不能做随机翻转、旋转、颜色抖动否则指标不稳定。只需要Resize、ToTensor、Normalize。这里直接给出一个验证函数的关键部分def evaluate(model, val_loader, criterion): model.eval() total_loss 0 correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: outputs model(images) loss criterion(outputs, labels) total_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() return total_loss / len(val_loader), correct / total注意model.eval()会把 BatchNorm 层切到推理模式使用运行均值而不是当前 batch 的均值。如果忘了调用验证集结果会偏大。另外torch.max(outputs, 1)返回每行最大值的索引predicted就是 0 或 1对应 woman 或 man。准确率是所有正确预测数除以总样本数但对于类别不均衡的数据集这个指标不能单独使用下一章会讲怎么看混淆矩阵。4. 小数据集防过拟合的三个关键操作数据增强、冻结微调与评估指标4.1 数据增强不是越多越好要贴合手机采集的退化过程300 张真实手机图片意味着样本空间里天然包含轻微模糊、暗光、自拍镜像、低头抬头等变体。数据增强要模拟这些真实扰动而不是制造训练集里根本不会出现的极端形状。我见过有人给所有人脸加了 30 度的旋转结果训练样本里出现大量歪着头的人脸而真实验证集里几乎都是正脸反而把模型学偏。针对手机人脸图片推荐的增强组合有一个优先级水平翻转 随机亮度对比度 小角度旋转 随机遮挡擦除。下面这段是经过调整的增强管线适用于这种小数据集train_transforms transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.8, 1.0), ratio(0.9, 1.1)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.1, hue0.02), transforms.RandomRotation(degrees8), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])这里和上一章的最大区别是加入了RandomResizedCrop先缩放图片到 256×256再随机裁剪一个区域并缩放到 224×224相当于模拟了人脸在画面中位置不固定。scale(0.8, 1.0)意味着裁剪区域占原图的 80% 到 100%太小的裁剪会把手部或者背景裁进人脸区域干扰特征学习。ratio(0.9, 1.1)限制了裁剪宽高比接近 1:1避免把脸拉伸变形。还要注意ColorJitter的hue参数我只设了 0.02因为肤色色调如果变化过大会把性别特征中的肤色信息破坏掉。你要明白性别分类在深度学习里往往不是靠肤色区分而是靠轮廓、发际线、脸部骨骼结构等颜色抖动过度反而会让模型忽略这些结构特征。4.2 验证指标只看准确率会骗人要看混淆矩阵和 AUC二分类里如果测试集是 150 比 150准确率 90% 看起来不错。但如果测试集 woman 多 man 少模型把 man 全判成 woman 也可能有 85% 的准确率。所以必须看混淆矩阵、精确率Precision、召回率Recall以及 AUC。下面用sklearn.metrics打印这些结果from sklearn.metrics import confusion_matrix, classification_report, roc_auc_score y_true [] y_pred [] y_proba [] model.eval() with torch.no_grad(): for images, labels in val_loader: outputs model(images) probs torch.softmax(outputs, dim1) _, predicted torch.max(outputs, 1) y_true.extend(labels.cpu().tolist()) y_pred.extend(predicted.cpu().tolist()) y_proba.extend(probs[:, 1].cpu().tolist()) # man 类别的概率 print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, target_names[woman, man])) print(AUC:, roc_auc_score(y_true, y_proba))confusion_matrix会输出一个 2×2 的矩阵第i行第j列表示真实标签为i被预测为j的数量。如果man的召回率只有 0.6说明有 40% 的男性人脸被错判成 woman这个模型在性别检测里是不可用的。y_proba取的是probs[:, 1]也就是第二个类别的概率这里第二个类别是 man因为ImageFolder会按字母序分配类别索引man排在woman前面不一定要看你的目录名。实际上ImageFolder的类别顺序是按照文件名排序后的顺序所以man一定在woman之前索引 0 是 man索引 1 是 woman。这里我代码里写probs[:, 1]真实对应的是 woman。为了避免混淆建议用train_dataset.classes动态获取索引man_idx train_dataset.classes.index(man)然后取probs[:, man_idx]。这种细节最容易在报告里写反。4.3 训练轮数和 EarlyStopping300 张图一般在 15 轮附近收敛小数据集训练速度很快ResNet18 在 CPU 上一轮 300 张图大约几十秒GPU 上几秒。但也因为快很多人顺手跑 50 个 epoch结果训练 loss 降到 0.01验证 loss 在第 12 轮就开始反弹。这就是典型的过拟合。所以我习惯写一个简单的 EarlyStopping记录验证集上最佳 AUC 或准确率连续 N 轮没有提升就停止训练。class EarlyStopping: def __init__(self, patience5, min_delta0.001): self.patience patience self.min_delta min_delta self.best_score None self.counter 0 def __call__(self, val_score): if self.best_score is None: self.best_score val_score return False if val_score self.best_score self.min_delta: self.best_score val_score self.counter 0 return False else: self.counter 1 if self.counter self.patience: return True return False在每次验证结束后调用如果val_score比历史最佳高出min_delta才认为真正变好否则counter加一。当连续patience次没有提升就停止训练。注意patience设 5 对 300 张图的小数据集比较合理因为验证集本身就小每轮之间的随机波动比较大patience2很容易过早停止。min_delta设为 0.001 是为了避免那种 0.1% 的提升也当作“变好”的情况。5. 验证模型的鲁棒性错例分析、阈值调整与 ONNX 导出5.1 错例图片逐张检查比调参更重要训练结束拿到 90% 准确率先别高兴把验证集里所有预测错误的图片单独存到一个文件夹然后逐张看。为什么因为错例往往不是“长得像异性”而是数据本身有问题模糊到人眼都难分辨、人脸占比过小、带有夸张表情、被头发遮挡半边脸。如果错例集中在某一种特定情况说明后续做数据增强时应该专门模拟这种退化。下面把错例保存下来import shutil misclassified_dir misclassified os.makedirs(misclassified_dir, exist_okTrue) model.eval() for images, labels, paths in some_valid_loader_with_paths: outputs model(images) preds torch.max(outputs, 1)[1] for img, label, pred, path in zip(images, labels, preds, paths): if label.item() ! pred.item(): fname os.path.basename(path) shutil.copy(path, os.path.join( misclassified_dir, ftrue_{label.item()}_pred_{pred.item()}_{fname} ))注意这里的some_valid_loader_with_paths是一个返回图片、标签、文件路径三元组的 DataLoader。常规的ImageFolder不会返回路径如果你需要这个功能可以自定义 Dataset或者简单地在验证循环里从你的split.json中按索引查找路径。文件名里带上true_xxx和pred_xxx前缀便于一眼看出错在哪。这一步能直接暴露数据标注的问题如果某张图被标成 man但人眼看着明显是 woman那就是标注错了需要修正而不是让模型去拟合错误标签。5.2 调整决策阈值0.5 不是唯一选择模型输出的 softmax 概率默认按 0.5 作为分类边界。当类别数量不均衡或者你更重视某一类时阈值需要移动。比如场景是门禁系统漏放女性把 woman 判成 man和误拦男性把 man 判成 woman的代价不同你可以根据业务调整。做法是计算验证集上不同阈值对应的混淆矩阵然后选择一个合适的工作点。这里用一段不依赖框架的代码演示import numpy as np # y_proba 和 y_true 是验证集收集的 # 假设 man 为正类proba 是 man 的概率 thresholds np.linspace(0.3, 0.7, 41) best_t 0.5 best_f1 0 for t in thresholds: y_pred_t (y_proba t).astype(int) tp np.sum((y_true 1) (y_pred_t 1)) fp np.sum((y_true 0) (y_pred_t 1)) fn np.sum((y_true 1) (y_pred_t 0)) precision tp / (tp fp) if (tp fp) 0 else 0 recall tp / (tp fn) if (tp fn) 0 else 0 f1 2 * precision * recall / (precision recall) if (precision recall) 0 else 0 if f1 best_f1: best_f1 f1 best_t t print(fbest threshold: {best_t:.2f}, F1: {best_f1:.4f})y_proba必须是对应正类比如 man的概率。这个循环遍历 0.30 到 0.70 的阈值计算每个阈值下的混淆矩阵和 F1选出最优阈值。为什么阈值一般不会低于 0.3 或高于 0.7因为如果阈值过低几乎所有样本都被判成 man模型失去区分能力阈值过高则几乎所有样本都是 woman。你可以把阈值调整后的结果应用到测试集看看独立测试集上的指标是否依然稳定。5.3 把模型导出成 ONNX部署时避免引入 torchvision训练验证完成后实际部署往往不是用 Python 的 PyTorch而是通过 ONNX、TensorRT 或者转成 Core ML。这里给出最简单的 ONNX 导出同时提醒几个关键点import torch import torch.onnx model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, gender_classifier.onnx, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}, logits: {0: batch}}, opset_version12 )dummy_input的形状必须是(1, 3, 224, 224)分别对应 batch、通道、高宽。dynamic_axes指定 batch 维度可变这样部署时可以一次输入多张图片。opset_version12是兼容性比较广的版本如果目标推理引擎较老可能需要降级到 11 或 10。导出后用onnxruntime验证一次输出防止某些算子在转换时丢失import onnxruntime as ort import numpy as np ort_session ort.InferenceSession(gender_classifier.onnx) inputs np.random.randn(1, 3, 224, 224).astype(np.float32) results ort_session.run(None, {input: inputs}) print(results[0].shape)这一步虽然在部署到手机或服务器环境中不直接用 torch但能确认导出的模型可以独立运行。如果onnxruntime的输出和 PyTorch 推理结果不一致先检查是否在导出前调用了model.eval()以及输入是否做了同样的归一化。很多时候部署端忘记做Normalize导致输出概率分布完全不同。第五章节到这里整个流程已经覆盖了从数据检查、训练、验证到导出的完整闭环。对这个 300 张的小数据集我的最终建议是把精力花在错例分析和数据清洗上而不是换更深的网络。ResNet18 加迁移学习足矣剩下的提升空间几乎都藏在数据质量里。本文还有配套的精品资源点击获取