简介以CelebA为训练数据、基于PyTorch实现的人脸识别神经网络项目包适合深度学习和计算机视觉学习者快速上手人脸识别模型训练。压缩包共30个文件以10个py脚本为核心覆盖网络模型定义、数据加载、训练、测试等完整流程另有6个xml配置文件、6个txt训练与测试列表、1个pkl预训练属性模型、1份README说明及少量缓存文件整体约24.72MB目录结构清晰便于按模块复用和二次开发。目前已有116人浏览学习使用者无需重新生成数据列表可直接加载预训练权重进行推理或继续训练有效减少环境配置和数据预处理的重复劳动。通过研读源码还能掌握PyTorch项目常用的分层写法、DataLoader设计、模型保存与测试流程这些经验可迁移到其他图像识别或属性分析任务中。项目包对于课程设计、论文复现或入门实践都有直接参考价值无论用于毕业设计还是个人项目都能从中获得从数据准备到模型部署的完整链路参考。1. 基于CelebA与PyTorch的人脸识别资源包从训练到识别的完整闭环拿到FaceDetectionByTorch.zip之后我做的第一件事不是翻代码而是核对数据集目录和入口脚本。基于CelebA人脸数据集和PyTorch搭建的这套神经网络项目标题里的Celebra是CelebA的笔误解压后按CelebA的习惯目录名找数据就行不影响使用。它解决的是人脸识别入门阶段最核心的问题原始对齐图片怎么组织成Dataset、骨干网络怎么输出人脸特征、训练和推理参数怎么设、以及训练好的模型怎么转成能被工业端调用的产物。适合刚把卷积神经网络和反向传播学完、想跑通第一个完整人脸识别训练闭环的人也适合正在做人脸识别门禁机离线方案评估、需要一份公开可复现基线代码的工程师。整套资源不需要高端硬件一张普通显卡就能把训练和验证跑完。下面我按数据准备、模型搭建、训练配置、踩坑记录、部署推理逐层拆开。2. 数据准备与预处理把CelebA原始图片变成能喂给神经网络的Tensor2.1 先摸清CelebA的文件结构代码怎么写由数据目录决定CelebA官方包通常分三块img_align_celeba存放约五万张已经按人脸边框裁剪并对齐的图片Anno目录下有list_attr_celeba.txt和identity_CelebA.txt前者记录40个属性标注后者记录每张图片对应的身份ID。做人脸识别训练只需要identity_CelebA.txt和图片本身属性和人脸框标注在属性识别或检测场景才用得上。这里有个容易翻车的地方list_attr_celeba.txt第一行是列名第二行开始才是真实内容identity_CelebA.txt没有表头直接是“文件名 身份ID”两列。写解析代码前先head两个文件对比能省掉后面大半解析问题。我见过不少人直接把两列数据塞进csv解析器结果表头混进数据导致第一行报错这种错误完全可以通过提前看文件格式避开。head -5 Anno/identity_CelebA.txt head -5 Anno/list_attr_celeba.txt ls img_align_celeba | head -5第一行输出应该是类似“000001.jpg 1”这样的格式第二行命令会看到40个属性值第三行确认图片文件都在同一个目录下。身份ID范围是1到10177也就是说数据集里有超过一万个不同的人类别数远大于常见图像分类任务的1000类这也决定了后面模型输出层和损失函数的设计思路。2.2 自定义Dataset读身份文件、返回图像与标签PyTorch官方torchvision.datasets.ImageFolder按子目录名做分类但CelebA的图片全平铺在一个目录里身份信息在独立txt文件里所以必须自己写Dataset。我不建议把txt内容一次性读进list后交给DataLoader硬扛内存压力不大但代码扩展性差常见做法是在__init__里把路径和标签整理成样本列表__getitem__按索引读取并做transform。import os from PIL import Image from torch.utils.data import Dataset class CelebADataset(Dataset): def __init__(self, root, split_file, transformNone): self.root root self.transform transform self.samples [] with open(split_file, r) as f: for line in f: tokens line.strip().split() if len(tokens) ! 2: continue # identity文件没有表头双字段行才是有效样本 img_name, identity tokens[0], int(tokens[1]) img_path os.path.join(root, img_align_celeba, img_name) self.samples.append((img_path, identity)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, identity self.samples[idx] img Image.open(path).convert(RGB) # 灰度图或RGBA统一转RGB if self.transform: img self.transform(img) return img, identity逻辑说明__getitem__返回的是PIL Image和int类型的identitytransform在此时才作用到图片上这样不同epoch可以复用同一份样本列表并应用不同的随机增强。关键注释里的双字段判断是为了跳过可能出现的空行CelebA文件本身没有表头但如果有人用split工具重新切分过数据集首行就可能混入表头或注释。参数说明root指向CelebA根目录split_file是训练或验证集对应的identity文件路径transform来自torchvision.transforms。身份ID是1到10177的原始编号直接当标签喂给CrossEntropyLoss会存在空洞编号训练前先做一次{原始ID: 连续编号}的映射否则输出层和标签对不上。批量读取时num_workers4到8、pin_memoryTrue是单卡训练最稳的组合。2.3 预处理与数据增强人脸位置经不起RandomResizedCrop的折腾很多人把人脸识别当图像分类处理直接套RandomResizedCrop(112)这在常规分类任务里没问题但人脸识别场景会随机裁剪掉眼睛或下巴等于人为制造困难样本。CelebA的图片已经按人脸框对齐过我一般只用固定缩放加水平翻转加轻微颜色抖动。from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((112, 112)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.1, contrast0.1), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])逻辑说明Resize把不同分辨率的对齐人脸统一到112x112这个尺寸是face recognition领域最常见的输入分辨率再大一点是160或224小尺寸能明显加快训练速度。RandomHorizontalFlip利用人脸左右对称的先验知识做增强比随机旋转安全得多因为人脸旋转超过一定角度后语义会变。ColorJitter只动亮度和对比度不动色相避免肤色信息被过度破坏。参数说明归一化的mean和std直接复用ImageNet的统计值这是个被验证过无数次的默认选择因为预训练模型和大多数公开权重都基于这套统计量。如果你打算从头训练不加载预训练权重理论上应该用CelebA自己的统计量重新算一遍但实际效果差距很小不值得为这件事单独写统计脚本。推理阶段不要加RandomHorizontalFlip只保留Resize、ToTensor、Normalize三件套否则每张图识别结果会随随机性抖动。提示数据集划分不要直接截取前N张做训练、后M张做验证必须按identity切分。同一张脸出现在训练和验证集评估指标会虚高十几个点这个问题我放在第5章详细说。3. 模型搭建从ResNet骨干到512维人脸特征向量3.1 为什么人脸识别网络输出的是embedding而不是类别概率分类网络最后接一个Softmax输出每个类别的概率人脸识别不能这么干。门禁机或考勤系统里识别目标永远在变化——今天录入三个人明天可能变成五个训练时的类别数根本cover不住实际场景。所以人脸识别网络的最后一层通常输出一个固定维度的特征向量比如128维或512维训练时用分类头辅助收敛推理时丢掉分类头只拿特征向量去算余弦相似度。这套设计有两个关键点第一embedding要具备判别性同一个人的特征向量距离近不同人的距离远第二embedding要对新身份有泛化能力没参与训练的人也能被编码成合理的向量靠相似度阈值判断是否识别成功。基于ResNet系列骨干做特征提取是当前最稳妥的路线VGG太深太重MobileNet精度在低算力下会有点吃力而ResNet18在CelebA这种规模的数据集上已经能跑出可用的基线结果。资源包默认的骨干选择也是这条路线我复现时会先确认一下torchvision.models.resnet18的pretrained参数写法不同版本PyTorch这里接口稍有差异。3.2 用PyTorch实现带Embedding分支的人脸识别网络人脸识别网络的结构可以拆成两部分骨干网络负责从像素学到抽象特征Embedding层负责把特征压缩成指定维度的向量。训练时在这个向量后面再挂一个分类头输出类别概率损失函数在整个链路里回传。import torch import torch.nn as nn from torchvision.models import resnet18 class FaceNet(nn.Module): def __init__(self, num_classes, embedding_dim512): super(FaceNet, self).__init__() backbone resnet18(pretrainedTrue) self.features nn.Sequential(*list(backbone.children())[:-1]) # 去掉最后的全连接层保留到全局平均池化之前的卷积部分 self.embedding nn.Linear(512, embedding_dim) self.classifier nn.Linear(embedding_dim, num_classes) def forward(self, x): feat self.features(x) feat feat.view(feat.size(0), -1) # 把 [B, 512, 1, 1] 压成 [B, 512] emb self.embedding(feat) if self.training: logits self.classifier(emb) return logits, emb return emb逻辑说明resnet18(pretrainedTrue)加载ImageNet预训练权重后面的卷积层参数复用已有特征可以大幅缩短收敛时间。nn.Sequential(*list(backbone.children())[:-1])把ResNet最后的全连接层去掉保留到全局平均池化的输出此时特征图尺寸是[B, 512, 1, 1]。view操作把空间维度压平再经过一个Linear层映射到512维embedding。参数说明embedding_dim取512是通用选择128维也可以但低维特征在相似度计算时对噪声更敏感。训练模式下forward同时返回分类logits和embedding损失函数同时使用推理模式下只返回embedding分类头被丢弃。这里有一个细节backbone.children()在PyTorch 1.x和2.x里返回的模块顺序基本一致但如果你换用resnet50最后线性层的输入维度要从512改成2048改成nn.Linear(2048, embedding_dim)才能避免维度不匹配报错。3.3 损失函数先用Softmax把训练稳住再谈Margin直接上ArcFace类损失函数很容易出现训练前期loss不下降、甚至NaN的问题因为margin会放大难样本的梯度模型还没学到基本特征时容易被带偏。我在这类项目上的习惯是先用普通的CrossEntropyLoss配合分类头做第一阶段训练等验证集准确率稳定在90%以上再换成带margin的损失函数做第二阶段微调。这个资源包原始训练脚本大概率就是Softmax打底我建议你也不要跳过这步。criterion_cls nn.CrossEntropyLoss() optimizer torch.optim.SGD( model.parameters(), lr0.01, momentum0.9, weight_decay5e-4 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max30, eta_min1e-5 )逻辑说明SGD带momentum是CNN训练里被验证过最稳的组合Adam在人脸识别任务上往往收敛更快但最终精度略低因为Adam的逐参数自适应学习率会削弱权重衰减的效果。weight_decay5e-4是ResNet系列的标准配置防止embedding向量在训练中变得过大。CosineAnnealingLR把学习率从0.01余弦式降到1e-5相比阶梯下降能更平滑地在后期微调特征空间。参数说明T_max30表示整个余弦周期是30个epoch如果你的训练计划是60个epoch可以把T_max设成60让曲线完整走完学习率初始值0.01对应batch_size128如果用256的batch一般要放大到0.02左右。第一阶段训练时embedding向量不需要做L2归一化Softmax分类头会自己适应尺度第二阶段如果用ArcFace输入给损失函数的embedding应该先归一化再乘一个固定缩放系数s常见取s32这个细节直接决定ArcFace能不能收敛。4. 训练闭环优化器、学习率调度与模型存档的完整配置4.1 训练循环每个step该打印什么、该跳过什么训练脚本本身不复杂但很多入门项目翻车在细节上比如没有固定随机种子导致两次训练结果对不上比如验证集参与数据增强导致指标漂移。我一般会在训练脚本开头固定PyTorch、Python和NumPy的随机种子。import random import numpy as np import torch def seed_everything(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False def train_one_epoch(model, loader, optimizer, criterion, device, epoch): model.train() total_loss, correct, total 0.0, 0, 0 for batch_idx, (images, labels) in enumerate(loader): images, labels images.to(device), labels.to(device) optimizer.zero_grad() logits, _ model(images) loss criterion(logits, labels) loss.backward() optimizer.step() _, preds logits.max(1) total labels.size(0) correct preds.eq(labels).sum().item() total_loss loss.item() if batch_idx % 50 0: print(fEpoch {epoch} step {batch_idx}/{len(loader)} floss {loss.item():.4f} acc {correct/total:.4f}) return total_loss / len(loader)逻辑说明model.train()必须显式调用否则BN层会使用推理时的统计均值导致训练不收敛。logits.max(1)拿到预测类别preds.eq(labels).sum()统计当前batch里预测正确的样本数。每50个step打印一次损失和累计准确率这样既能看到训练趋势又不会刷屏。参数说明seed_everything(42)里cudnn.deterministicTrue会牺牲一部分卷积性能换取完全可复现对CelebA这种规模的数据集影响不大cudnn.benchmarkFalse固定算法选择避免不同step之间卷积实现不一致。这里的criterion就是CrossEntropyLoss配合上一章的分类头使用。如果loss在几十个step内完全没下降优先检查学习率是否过大或数据归一化是否写错而不是急着改模型结构。4.2 模型存档checkpoint不是只存state_dict很多入门代码只保存model.state_dict()加载之后发现优化器状态、学习率调度器状态全丢了中断训练想恢复就得从零开始。我通常把模型参数、优化器状态、调度器状态、当前epoch和最佳准确率一起存进一个字典。def save_checkpoint(model, optimizer, scheduler, epoch, best_acc, path): torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), best_acc: best_acc, }, path) def load_checkpoint(model, optimizer, scheduler, path): ckpt torch.load(path, map_locationcpu) model.load_state_dict(ckpt[model_state_dict]) if optimizer is not None: optimizer.load_state_dict(ckpt[optimizer_state_dict]) if scheduler is not None: scheduler.load_state_dict(ckpt[scheduler_state_dict]) return ckpt[epoch], ckpt[best_acc]逻辑说明torch.load时加map_locationcpu是为了避免在GPU环境下加载CPU训练的权重时报设备不匹配错误加载完成后再通过.to(device)移动到当前设备这是跨环境恢复训练最稳的写法。model.load_state_dict默认是严格匹配如果模型结构有改动会直接抛错这时改用load_state_dict(ckpt, strictFalse)可以跳过缺失或多余的键但你要确认跳过的键不是分类头。参数说明best_acc用于配合早停或保存最佳模型我会在每次验证后比较当前准确率和best_acc更高才覆盖保存保证磁盘上留下的一定是最优权重。save_checkpoint里epoch从0开始计数恢复训练时传入epoch1给调度器避免CosineAnnealingLR从零重新计算周期。4.3 验证评估top1准确率只是入门指标验证时模型要切到eval()模式并关闭梯度计算。CelebA的验证集按身份划分每个身份多张图片top1准确率表示预测出的身份与真实身份一致的比例。这个指标对人脸识别入门够用但实际部署场景更看重的是TARFAR也就是在给定误识率下能正确接受的比例。torch.no_grad() def evaluate(model, loader, device): model.eval() correct, total 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) logits, _ model(images) _, preds logits.max(1) correct preds.eq(labels).sum().item() total labels.size(0) acc correct / total print(fValidation acc: {acc:.4f}) return acc逻辑说明torch.no_grad()直接让整个函数跳过梯度图构建推理速度比手动with torch.no_grad()略快一点写法也更干净。model.eval()会切换BN层和Dropout层行为这个动作在验证阶段必须做但验证结束后下次训练前要记得切回model.train()否则训练会突然变慢甚至不收敛。参数说明验证集loader里的transform不能包含随机增强我在第2章提过这件事。如果你发现验证准确率比训练准确率高出一大截先怀疑是不是验证集泄漏再怀疑是不是验证时忘了关数据增强。5. 复现之路的五个坑现象、原因与解决方法5.1 训练指标不升反降人脸没有对齐现象模型训练了20个epochloss下降到0.5之后不再变化验证准确率只有70%出头明显低于资料里声称的90%以上。原因CelebA原始包有img_align_celeba对齐版和img_celeba未对齐版未对齐图片里人脸位置、角度和尺度差异很大ResNet18从零学起很难在这种数据分布上收敛到高精度。解决确认你用的是img_align_celeba目录如果是img_celeba先跑一遍官方对齐工具或者换用对齐目录。复现时我习惯把图片尺寸固定成112x112因为对齐后的人脸五官位置相对固定Resize不会破坏关键结构。5.2 验证准确率虚高身份泄漏现象模型训练正常验证准确率高达98%但拿到新照片测试时效果很差top1直接掉到80%以下。原因训练集和验证集按文件顺序前N张后M张切分同一身份的图片同时出现在两边模型记住了具体人的特征而不是泛化出“人脸相似度”的概念。解决按identity做划分确保任何一个身份ID只能出现在训练集或验证集中不能两边同时出现。最简单的做法是遍历identity文件按身份ID聚合图片路径再用train_test_split对身份ID列表做分层切分最后生成各自的索引文件。5.3 显卡OOM与DataLoader卡死交替出现现象batch_size设为128时CUDA out of memory调成64后虽然不OOM了但每个epoch都要等很久GPU利用率上不去。原因OOM是因为112x112输入配128的batch在单卡上超出了显存卡顿是因为num_workers0导致数据加载在主进程里同步执行GPU在等CPU喂数据。解决batch_size回退到64num_workers设成4到8pin_memoryTrue。如果还想用大batch就先把输入分辨率降到96或者用混合精度训练。单卡8GB显存跑ResNet18加512维embedding64的batch是安全水位。5.4 加载预训练权重报尺寸不匹配现象model.load_state_dict(torch.load(weight_path))报出size mismatch for fc.weight一类错误。原因你下载的权重是ImageNet分类模型最后一层输出1000类而你的人脸识别模型最后是分类头输出10177类两边全连接层尺寸对不上。严格匹配模式下PyTorch会直接抛错。解决加载时用load_state_dict(weight, strictFalse)跳过分类头和embedding层的权重骨干部分参数照常加载。不要觉得跳过分类头很亏分类头本来就是要被替换掉重训的。5.5 训练loss一直不降学习率和优化器没配合好现象前100个step的loss稳定在5.0左右和随机猜测初始值差不多几十个epoch后降到2.0就再也不动。原因学习率0.01配SGD对部分网络初始层来说太大前期梯度爆炸后数值被NaN污染或者权重初始化方式不适合带BN的深层网络BN层的gamma和beta初始值导致梯度信号消失。解决先排除数据问题后把学习率降到0.003试一轮再不降就把SGD换成AdamW初始学习率3e-4一般能在头两个epoch看到明显下降趋势。从特征可视化上排查更直接——把embedding用PCA投影到2D看训练初期不同人的特征是否混成一团混成一团说明损失函数或优化器有问题。6. 部署与验证把模型转成ONNX并用相似度完成人脸识别6.1 导出ONNX固定输入尺寸与动态batch训练完成后的模型是PyTorch的state_dict工业推理设备通常不装PyTorch所以要把模型转成ONNX。导出时要点是输入给导出函数的tensor必须是真实形状的dummy输入opset_version不要追新12或13就够用。import torch from model import FaceNet model FaceNet(num_classes10177, embedding_dim512) model.load_state_dict(torch.load(checkpoints/best.pth)[model_state_dict]) model.eval() dummy_input torch.randn(1, 3, 112, 112) torch.onnx.export( model, dummy_input, face_net.onnx, input_names[input], output_names[embedding], dynamic_axes{input: {0: batch}, embedding: {0: batch}}, opset_version12 )逻辑说明dynamic_axes告诉ONNX导出器batch维度是动态的这样推理时既能处理单人图片也能一次处理一批底库图。model.eval()必须调用否则导出时BN层会带着训练模式运行ONNX里的常量折叠结果会错。output_names里的embedding对应我们模型forward返回的特征向量ONNX推理结果里它就是当前图片的512维特征。参数说明opset_version12在大多数onnxruntime版本上兼容性很好如果推理端是较老的TensorRT可能需要降到11。dummy_input的尺寸必须和训练时输入一致不能随意改否则导出的模型接受不到实际输入尺寸。6.2 用onnxruntime做推理提取embedding并计算余弦相似度部署时不需要PyTorch只需要onnxruntime。我写推理脚本时会把特征提取和相似度计算分开这样底库更新时不用重新加载整个模型只更新特征库就行。import numpy as np import onnxruntime as ort from PIL import Image from torchvision import transforms as T session ort.InferenceSession(face_net.onnx, providers[CPUExecutionProvider]) def preprocess(img_path): transform T.Compose([ T.Resize((112, 112)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) img Image.open(img_path).convert(RGB) return transform(img).unsqueeze(0).numpy() # ONNX输入是numpy def get_embedding(img_path): x preprocess(img_path) outputs session.run([embedding], {input: x}) emb outputs[0].reshape(1, -1) return emb / np.linalg.norm(emb) # L2归一化后相似度等价于余弦相似度 def cosine_similarity(emb1, emb2): return float(np.dot(emb1, emb2))逻辑说明session.run的第一个参数是输出名必须和导出时output_names完全一致第二个参数是输入名字和对应数组同理要和input_names匹配。L2归一化这步很关键它让embedding向量变成单位向量点积结果就是余弦相似度这样不同人之间的分数范围才能统一比较。参数说明providers在CPU设备上写CPUExecutionProvider在带CUDA的推理机上可以改成[CUDAExecutionProvider, CPUExecutionProvider]onnxruntime会优先尝试CUDA。预处理里的Resize尺寸112x112必须和导出时一致我用numpy输出是为了避开torch和onnxruntime之间的张量转换开销。6.3 阈值到底该取多少把相似度阈值当成业务参数而不是模型参数人脸识别的判定逻辑是识别出的人脸embedding与底库中最相似的一个人做对比相似度大于阈值判定为同一个人小于阈值则拒绝或进入人工确认环节。阈值取0.5只是一种常见习惯不代表安全水位。我拿到新数据集后的标准做法是留出几百对同一人的图片和不同人的图片计算所有相似度画两组直方图取两个分布交叉点附近的值作为初始阈值再根据业务容忍度调整。举个例子门禁机场景里误识率FAR要求低阈值就往高了调比如0.65宁可让员工多刷两次也不放陌生人进考勤打卡场景对通过率要求高阈值可以降到0.45偶尔误识可以通过后台记录纠错。阈值本质上是在误识率和拒识率之间做取舍不存在一个万能数值。把这个值暴露成配置文件里的参数比写死在代码里好得多。提示人脸识别上线前一定要拿现场条件重新采集照片测试CelebA上表现良好的阈值换到暗光摄像头下会明显偏移。从那以后我每次做门禁机或考勤项目都强制先走一遍“对齐检查、身份隔离、小batch热身、ONNX导出校验、阈值直方图确认”这五件事再谈优化策略。希望帮到你。本文还有配套的精品资源点击获取