CNN人脸识别项目实战:从数据链路到训练推理落地
简介:面向希望将卷积神经网络落地到人脸识别实践的开发者与学习者,这份配套代码围绕Olivetti人脸数据集展开,提供从训练到调用预测的完整实现参考,适合具备Python与深度学习基础、想通过小型项目理解CNN分类流程的人群。压缩包共4个文件,约14.64MB,包含2个Python脚本分别承担模型训练与推理调用,1个pkl模型参数文件便于直接加载已训练权重,另有1个gif数据集预览图辅助确认样本形态,文件精简但环节完整。已有17754人学习或下载,说明其在入门级人脸识别项目中具有一定参考价值。读者可据此观察CNN处理人脸图像的输入组织、网络搭建、参数保存与复用方式,并对照代码梳理训练与测试阶段的衔接关系,为后续迁移到更复杂数据集或改进网络结构提供可复用的代码骨架与实验思路。
1. 从一张打卡照片说起:CNN人脸识别到底在解决什么
某公司行政拿了一千多张员工照过来,要求一周内做一个刷脸打卡的 demo。我起初以为难点在 CNN 的结构设计,真正动手才发现,耗掉大半时间的是把侧脸、逆光、戴口罩的样本一张张挑出来。这类 CNN 人脸识别项目通常由三段组成:人脸检测与对齐、卷积网络提特征、分类头或比对模块输出结果,交付物是一套能训练、能评估、能接摄像头实时推理的完整代码。它适合两类人:拿人脸识别当毕设或课程设计的学生,以及需要在内部系统里接一个离线识别模块的后端开发。不需要 GPU 集群,一张消费级显卡甚至纯 CPU 就能把几十到几百个身份的 demo 跑起来。
2. 工程结构与数据链路:从原始人脸图到可训练的批次张量
人脸识别项目的失败,十有八九不是模型写错,而是数据链路没搭稳。原始照片、标注文件、划分清单、增强策略这四样东西任何一环出问题,后面训练出来的模型都是黑匣子。所以先把目录和预处理流水线定下来,再谈网络结构。
2.1 目录分层与模块职责
我习惯把工程拆成配置、数据、源码、脚本四层,每一层只做一件事。目录大致长这样:
face_recog/ ├── configs/ │ └── base.yaml # 超参、路径、增强强度都放这里 ├── data/ │ ├── raw/ # 原始照片,按身份建子目录 │ ├── aligned/ # 对齐裁剪后的 112x112 人脸 │ └── splits/ # train/val/test 的 txt 清单 ├── src/ │ ├── preprocess.py # 检测、对齐、裁剪 │ ├── dataset.py # Dataset 与 transform │ ├── model.py # CNN 主干与分类头 │ ├── train.py # 训练循环 │ ├── evaluate.py # top-k、混淆矩阵 │ └── infer.py # 单图/批量推理封装 ├── scripts/ │ ├── build_dataset.py # 批量跑预处理 │ └── run_train.sh └── requirements.txtdata/raw按身份建目录,目录名就是类别名,这样写 Dataset 时可以直接用os.listdir生成标签映射,省掉一份额外的标注文件。data/aligned是预处理产物,训练只读这一层,原始照片永远不动,后面换对齐参数可以整份重跑,不用重新收集数据。configs/base.yaml里我一般放这几项:
data: aligned_dir: data/aligned split_dir: data/splits image_size: 112 num_classes: 0 # 由脚本扫描目录后回填 train: batch_size: 64 epochs: 40 lr: 0.01 weight_decay: 0.0005 label_smooth: 0.1把num_classes留成 0、由脚本回填,是为了避免手改配置时漏改类别数导致最后全连接层维度对不上——这种错报出来是 shape mismatch,但根因往往在配置文件,排查起来很绕。
src里各文件的职责边界要清晰:preprocess.py只负责把一张原始图变成对齐后的人脸图,不碰标签;dataset.py只负责读对齐图、做增强、返回张量;model.py只定义网络,不写训练逻辑;train.py负责调度。边界一乱,后面想换骨干网络或者换损失函数就得动好几个文件,二次开发成本立刻上来。
2.2 人脸检测、对齐与归一化的流水线
CNN 对输入的空间分布很敏感。同一个人的两张照片,一张正脸一张歪头,如果直接缩放送进网络,卷积核学到的特征分布会被角度差异污染。所以标准流程是:先检测人脸框和关键点,再用两眼坐标做仿射变换把脸摆正,最后按固定比例裁剪并缩放到统一尺寸。
import cv2 import numpy as np def align_and_crop(img_bgr, landmarks, out_size=112, scale=1.5): """landmarks: 5x2 数组,顺序为左眼、右眼、鼻尖、左嘴角、右嘴角""" left_eye, right_eye = landmarks[0], landmarks[1] dx, dy = right_eye[0] - left_eye[0], right_eye[1] - left_eye[1] angle = np.degrees(np.arctan2(dy, dx)) # 两眼连线的倾斜角 eyes_center = ((left_eye[0] + right_eye[0]) / 2.0, (left_eye[1] + right_eye[1]) / 2.0) # 以两眼中心为轴旋转,把脸摆正 M = cv2.getRotationMatrix2D(eyes_center, angle, 1.0) rotated = cv2.warpAffine(img_bgr, M, (img_bgr.shape[1], img_bgr.shape[0]), flags=cv2.INTER_LINEAR) # 以两眼距离为基准决定裁剪边长,scale 控制留白多少 eye_dist = np.sqrt(dx ** 2 + dy ** 2) crop_size = int(eye_dist * scale) cx, cy = int(eyes_center[0]), int(eyes_center[1]) half = crop_size // 2 x1, y1 = max(cx - half, 0), max(cy - half, 0) x2, y2 = min(cx + half, img_bgr.shape[1]), min(cy + half, img_bgr.shape[0]) face = rotated[y1:y2, x1:x2] if face.size == 0: return None # 关键点贴边时直接丢弃 face = cv2.resize(face, (out_size, out_size), interpolation=cv2.INTER_LINEAR) return face这段代码的核心是scale这个参数。取值 1.2 时裁剪很紧,只保留五官,适合身份分类;取值 1.8 到 2.0 会带进更多额头和下巴,适合后续要接口罩检测或活体检测的场景。我一般从 1.5 起调,跑一批样本目测裁剪框有没有切掉下巴。另一个容易忽略的点是插值方式:训练时用INTER_LINEAR,推理时保持一致,别一边线性一边三次,像素分布对不上,模型表现会掉几个点。
检测器本身可以选 Haar 级联或者 MTCNN 一类的关键点检测器。Haar 速度快但侧脸漏检率高,只适合正脸门禁;MTCNN 慢一些,但能给出五点坐标,正好喂给上面的对齐函数。批量预处理脚本里要做的事就是遍历data/raw,检测失败或关键点越界的样本记到bad_samples.txt,不要静默跳过——这批样本往往就是后面模型识别率上不去的元凶。
归一化放在 Dataset 的 transform 里做,不要在这里做。原因是训练和推理的均值方差必须来自同一处定义,写死在预处理脚本里,推理时很容易忘掉。
2.3 数据集划分与数据增强的落地参数
划分方式直接决定评估结果可不可信。人脸识别最常见的翻车是随机按图片划分,同一个人的不同照片同时出现在训练集和验证集里,验证准确率虚高到 99%,一上摄像头全错。正确做法是按身份分层:每个身份的照片先打乱,再按 8:1:1 切到 train/val/test,保证同一个身份的照片只出现在一个集合里。
import os, random from collections import defaultdict def split_by_identity(raw_dir, out_dir, ratios=(0.8, 0.1, 0.1), seed=42): random.seed(seed) id2files = defaultdict(list) for identity in sorted(os.listdir(raw_dir)): id_dir = os.path.join(raw_dir, identity) if not os.path.isdir(id_dir): continue id2files[identity] = [os.path.join(id_dir, f) for f in os.listdir(id_dir)] splits = {"train": [], "val": [], "test": []} for identity, files in id2files.items(): random.shuffle(files) n = len(files) n_train = max(1, int(n * ratios[0])) n_val = max(1, int(n * ratios[1])) if n >= 3 else 0 splits["train"] += files[:n_train] splits["val"] += files[n_train:n_train + n_val] splits["test"] += files[n_train + n_val:] os.makedirs(out_dir, exist_ok=True) for name, items in splits.items(): with open(os.path.join(out_dir, f"{name}.txt"), "w") as f: f.write("\n".join(items))max(1, ...)这个保护不能省。有些身份只有两三张照片,按比例算出来验证集是 0,后面算指标时除零直接崩。样本数少于 3 的身份,我一般直接并进训练集,不参与验证和测试,避免评估指标被极小类拉偏。
增强策略要克制。人脸识别的增强和通用图像分类不一样,水平翻转可以用,但垂直翻转、大角度旋转会破坏五官的相对位置,学出来的特征反而不稳。我常用的一组是:
from torchvision import transforms train_tf = transforms.Compose([ transforms.RandomResizedCrop(112, scale=(0.85, 1.0), ratio=(0.9, 1.1)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]), ]) val_tf = transforms.Compose([ transforms.Resize((112, 112)), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]), ])RandomResizedCrop的scale下限设到 0.85,是为了模拟检测框轻微偏移的情况,再低就会把半张脸裁掉。ColorJitter的饱和度只给 0.1,因为肤色是身份信息的一部分,调太狠等于让模型学不到肤色特征。归一化用 0.5/0.5 是最省事的做法,如果后面换成在 ImageNet 上预训练的骨干,记得改成 ImageNet 的均值和方差,这个不一致是推理掉点的经典原因。
类别不均衡在有几百个身份的数据集里几乎必然出现,有的身份几十张,有的只有三张。轻度的可以用WeightedRandomSampler按类别频率倒数采样;重度不均衡建议直接砍掉样本数少于 5 的身份,或者单独做一个 unknown 类,别硬塞进分类头。
3. 模型主干拆解:卷积、池化、全连接怎么搭出一个能收敛的人脸分类器
小规模人脸识别不需要 ResNet-152 那种量级。参数量在几十万到几百万之间的轻量 CNN,配合对齐后的 112×112 输入,在几百个身份上就能到 95% 以上的验证准确率。关键是把感受野、通道数和下采样节奏配平,而不是堆层数。
3.1 输入尺寸与通道设计
输入尺寸的选择要同时考虑检测器输出和算力。112×112 是业内比较通用的取值,它能让最后一次下采样后的特征图保留 7×7 的空间信息,足以区分五官位置;再小到 64×64,眼睛和眉毛的细节会被卷积核抹平;再大到 224×224,参数量和显存占用翻四倍,收益却很小。
通道数用 RGB 三通道还是灰度单通道,取决于数据。门禁场景光照稳定,灰度能省三分之一计算量;但如果数据里有大量不同肤色、不同妆容的样本,颜色是有判别力的信息,用 RGB 更稳。我一般先用 RGB 跑一版基线,确认准确率上限,再试灰度看掉多少点,掉得少就换灰度省算力。
还有一个细节是输入的人脸占比。对齐裁剪时scale=1.5意味着人脸大约占画面的三分之二,四周留一点背景。留白太多,卷积核会把背景纹理当特征学进去;留白太少,下巴和额头被切掉,角度稍微一变就识别不了。
3.2 卷积块与特征图尺寸推导
主干用四个卷积段加全局平均池化,结构简单但够用:
import torch import torch.nn as nn def conv_bn_relu(in_ch, out_ch, k=3, s=1, p=1): return nn.Sequential( nn.Conv2d(in_ch, out_ch, k, s, p, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) class FaceCNN(nn.Module): def __init__(self, num_classes, dropout=0.5): super().__init__() self.features = nn.Sequential( conv_bn_relu(3, 32), # 112 -> 112 conv_bn_relu(32, 64), nn.MaxPool2d(2), # 112 -> 56 conv_bn_relu(64, 128), conv_bn_relu(128, 128), nn.MaxPool2d(2), # 56 -> 28 conv_bn_relu(128, 256), conv_bn_relu(256, 256), nn.MaxPool2d(2), # 28 -> 14 conv_bn_relu(256, 512), nn.MaxPool2d(2), # 14 -> 7 ) self.gap = nn.AdaptiveAvgPool2d(1) # 7x7 -> 1x1 self.classifier = nn.Sequential( nn.Dropout(dropout), nn.Linear(512, num_classes), ) def forward(self, x): x = self.features(x) x = self.gap(x).flatten(1) return self.classifier(x)尺寸推导链条是 112 → 56 → 28 → 14 → 7,每次MaxPool2d(2)减半,卷积层用padding=1保持尺寸不变。这里有个容易踩的点:如果卷积核写成 5×5 而 padding 还是 1,尺寸会每层缩 2,四段下来特征图就没了,报错信息是池化层输入太小,但根因在卷积参数。
参数量粗算:3×32×9 约 864,32×64×9 约 1.8 万,64×128×9 约 7.4 万,128×128×9 约 14.7 万,128×256×9 约 29.5 万,256×256×9 约 59 万,256×512×9 约 118 万,卷积部分合计约 230 万。全连接层是 512×类别数,500 个身份约 25.6 万。整体不到 300 万参数,一张 8GB 显存的卡跑 batch size 64 毫无压力。
用全局平均池化替代展平后接大全连接,是我在这个项目里做的一个关键取舍。展平 512×7×7 得到 25088 维,接全连接层参数直接上千万,小数据集上必过拟合。GAP 把每个通道压成一个数,参数量归零,还自带一点正则效果。
3.3 损失函数、优化器与学习率策略
分类任务用交叉熵,但纯交叉熵在噪声标签上容易过拟合。加一点标签平滑,把硬标签 1.0 soften 成 0.9,模型输出的置信度分布会更平缓,泛化通常能涨一两个点。
import torch import torch.nn as nn from torch.optim.lr_scheduler import CosineAnnealingLR criterion = nn.CrossEntropyLoss(label_smoothing=0.1) optimizer = torch.optim.SGD( model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4, nesterov=True, ) scheduler = CosineAnnealingLR(optimizer, T_max=40, eta_min=1e-5)优化器选 SGD 而不是 Adam,是因为在分类任务上 SGD 收敛后的解更平坦,验证集表现通常更好,尤其是数据量不大的时候。weight_decay给 5e-4 是常见的正则强度,如果发现训练 loss 降得很快但验证不涨,可以往上加到 1e-3 试试。学习率 0.01 配合余弦退火,40 个 epoch 从 0.01 平滑降到 1e-5。
如果训练不稳定,前 5 个 epoch 加一个线性 warmup,让学习率从 0.001 爬上来,能明显减少早期 loss 震荡。warmup 的实现不用引第三方库,手写一个 lambda 就行:
def warmup_cosine(epoch, warmup_epochs=5, total_epochs=40): if epoch < warmup_epochs: return (epoch + 1) / warmup_epochs progress = (epoch - warmup_epochs) / max(1, total_epochs - warmup_epochs) return 0.5 * (1 + torch.cos(torch.tensor(3.14159 * progress))).item()这个函数返回的是缩放系数,乘在基础学习率上,每个 epoch 开始时调一次scheduler.step()或者手动设置param_group['lr']。注意别同时用CosineAnnealingLR和手动 warmup,两套逻辑会打架,学习率曲线变成锯齿。
4. 训练与推理双链路:把 loss 曲线变成能上线的人脸识别服务
训练脚本谁都能写,但训练脚本里埋的日志决定出问题时能不能定位。推理链路看起来简单,实际上预处理不一致、权重加载漏层、batch 维度处理错,这三个坑几乎每个项目都会踩一遍。
4.1 训练循环与日志埋点
训练循环要记录的不只是 loss,还有学习率、梯度范数、每个 epoch 的耗时和验证准确率。梯度范数能提前预警梯度爆炸,学习率能确认调度器有没有生效。
import time import torch def train_one_epoch(model, loader, criterion, optimizer, device, epoch): model.train() total_loss, correct, total = 0.0, 0, 0 t0 = time.time() for step, (imgs, labels) in enumerate(loader): imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad(set_to_none=True) logits = model(imgs) loss = criterion(logits, labels) loss.backward() # 记录梯度范数,超过阈值说明可能要爆炸 grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * imgs.size(0) correct += (logits.argmax(1) == labels).sum().item() total += imgs.size(0) if step % 20 == 0: print(f"epoch {epoch} step {step} loss {loss.item():.4f} " f"grad_norm {grad_norm:.2f} lr {optimizer.param_groups[0]['lr']:.6f}") return total_loss / total, correct / total, time.time() - t0clip_grad_norm_的返回值就是裁剪前的梯度范数,直接拿来打日志,不用额外算一遍。max_norm=5.0是个经验值,如果日志里这个数长期在 10 以上,说明学习率偏大或者数据里有异常样本;长期低于 0.1,说明梯度消失,得检查 BatchNorm 有没有正确启用。
optimizer.zero_grad(set_to_none=True)比默认的置零更省显存,因为梯度张量会被释放而不是填 0,在大 batch 下能省一点。这个参数在旧版本 PyTorch 里没有,用之前确认一下版本。
每个 epoch 结束保存一次权重,保存策略用「验证准确率最高的那一版权重」,而不是最后一个 epoch:
best_acc = 0.0 for epoch in range(40): train_loss, train_acc, cost = train_one_epoch(model, train_loader, criterion, optimizer, device, epoch) val_acc = evaluate(model, val_loader, device) scheduler.step() if val_acc > best_acc: best_acc = val_acc torch.save({"model": model.state_dict(), "epoch": epoch, "acc": val_acc}, "best.pt") print(f"epoch {epoch} train_loss {train_loss:.4f} train_acc {train_acc:.4f} " f"val_acc {val_acc:.4f} time {cost:.1f}s")保存字典而不是只保存state_dict,是为了把 epoch 和准确率一起落盘,后面推理加载时能确认用的是哪一版。这种小习惯在复现实验时能救命。
4.2 评估指标:准确率之外的 Top-K 与混淆矩阵
单一准确率在类别不均衡时会骗人。500 个身份里如果有一个类占了 30% 的样本,模型全猜这个类也能有 30% 准确率。所以要同时看 Top-5 准确率和混淆矩阵。
import torch @torch.no_grad() def evaluate(model, loader, device, topk=(1, 5)): model.eval() max_k = max(topk) correct = {k: 0 for k in topk} total = 0 confusion = None for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) logits = model(imgs) _, pred = logits.topk(max_k, dim=1) pred = pred.t() match = pred.eq(labels.view(1, -1).expand_as(pred)) for k in topk: correct[k] += match[:k].any(dim=0).sum().item() total += labels.size(0) # 累积混淆矩阵,用于定位具体哪类容易混 for t, p in zip(labels.view(-1).cpu(), logits.argmax(1).cpu()): if confusion is None: n = logits.size(1) confusion = torch.zeros(n, n, dtype=torch.long) confusion[t, p] += 1 return {f"top{k}": correct[k] / total for k in topk}, confusiontopk那几行是从官方 ImageNet 评估脚本里借鉴的写法,pred.t()转置后广播比较,避免写双重循环。混淆矩阵用torch.zeros在第一次循环时初始化,是为了拿到类别数,不用额外传参。
拿到混淆矩阵后,重点看对角线之外数值最大的那几个格子。如果 A 和 B 两个身份互相错认的比例很高,要么是这两人长得像,要么是数据里标签串了。我遇到过一次,两个身份的目录名只差一个数字,收集数据时人工放错了十几张,混淆矩阵直接把这个问题暴露出来。
4.3 推理封装与批量预测
推理代码最容易出错的地方是预处理和训练不一致。封装成一个类,把 transform 和权重加载都收进去,调用方只传 BGR 图和关键点,不碰内部细节。
import cv2 import numpy as np import torch class FaceRecognizer: def __init__(self, ckpt_path, class_names, device="cuda", image_size=112): self.device = torch.device(device if torch.cuda.is_available() else "cpu") ckpt = torch.load(ckpt_path, map_location=self.device) self.model = FaceCNN(num_classes=len(class_names)).to(self.device) self.model.load_state_dict(ckpt["model"]) self.model.eval() self.class_names = class_names self.image_size = image_size @torch.no_grad() def predict(self, img_bgr, landmarks): face = align_and_crop(img_bgr, landmarks, self.image_size) if face is None: return None, 0.0 face = cv2.cvtColor(face, cv2.COLOR_BGR2RGB) # 训练用 RGB,这里必须转 face = face.astype(np.float32) / 255.0 face = (face - 0.5) / 0.5 tensor = torch.from_numpy(face).permute(2, 0, 1).unsqueeze(0).to(self.device) logits = self.model(tensor) prob = torch.softmax(logits, dim=1) conf, idx = prob.max(dim=1) return self.class_names[idx.item()], conf.item()cv2.imread读进来是 BGR,训练时transforms.ToTensor之前如果用了cv2.cvtColor转 RGB,推理也必须转,漏掉这一步模型输出会乱得毫无规律,是典型的玄学问题。归一化的除法和减均值顺序也要和训练完全一致,训练用(x - 0.5) / 0.5,推理就不能写成先除后减。
批量推理时把unsqueeze(0)换成torch.stack,并且注意model.eval()和torch.no_grad()都要加,少一个都会让显存占用随 batch 线性增长。
5. 避坑与排查:人脸识别项目最容易翻车的五个地方
5.1 验证准确率 99%,实际摄像头全错
现象:训练日志里验证集准确率一路涨到 0.99,拿笔记本摄像头实时测试,换个人也能识别成同一个人,置信度还挺高。
原因:按图片随机划分,同一个人的照片同时进了训练集和验证集。CNN 参数量足够记住某个人的具体照片,验证时考的是「见过这张图没有」,不是「认不认识这个人」。
解决:改成按身份分层划分,同一个身份的所有照片只落在一个集合。改完验证准确率通常会掉 5 到 15 个点,这个数才是真实水平。另外测试集要单独留出来,调参阶段一眼都不要看,否则调参过程本身就在往测试集上过拟合。
5.2 训练几个 epoch 后 loss 变成 nan
现象:前两个 epoch loss 正常下降,第三个 epoch 开始跳变,随后整个输出变成 nan,权重全废。
原因:常见的有三种。学习率太大导致梯度爆炸;输入没归一化,像素值 0 到 255 直接进网络;标签越界,CrossEntropyLoss收到大于等于类别数的标签会直接产生非法梯度。
解决:先查标签,打印labels.max()和类别数做对比,这个最快。标签没问题就把学习率砍半再跑,同时打开梯度裁剪。输入归一化在 Dataset 里统一做,别在预处理脚本里做一半留一半。如果三者都排除了,检查 BatchNorm 层在 batch size 很小时的表现,batch 小于 8 的时候 BN 的统计量会很不稳定,可以换 GroupNorm 试试。
5.3 离线评估正常,线上推理结果飘忽
现象:用保存的测试集跑评估脚本准确率 96%,接上摄像头后同一张脸有时能认出有时认不出,置信度在 0.4 到 0.9 之间跳。
原因:预处理不一致。训练用了RandomResizedCrop加归一化,推理时直接cv2.resize就送进去了,插值方式、通道顺序、归一化三者至少有一处对不上。另一种可能是检测框抖动,每帧检出的关键点位置差几个像素,对齐后的裁剪区域跟着晃。
解决:把训练和推理的预处理代码收敛到同一个函数里,训练时通过参数控制要不要加随机增强,推理时固定走确定性分支。检测框抖动可以加一个简单的平滑,对连续帧的关键点做滑动平均,或者要求连续三帧识别为同一身份才输出。
5.4 显存越跑越少,最后 OOM
现象:训练到几十个 step 后显存占用持续上涨,最后报 out of memory,batch size 明明没变。
原因:验证阶段没加torch.no_grad(),计算图一直在累积;或者日志里把带梯度的 loss 张量存进了列表,整个计算图跟着被引用住。
解决:所有验证和推理函数都加@torch.no_grad()装饰器。日志里只存loss.item()这种标量,不要存张量本身。如果用了 AMP 混合精度,确认scaler.step()和scaler.update()都调了,漏掉 update 会导致梯度缩放系数异常。
5.5 小类全部识别失败
现象:整体准确率 90%,但样本数少于 10 的那几十个身份,召回率接近 0。
原因:交叉熵在类别不均衡时会被大类主导,小类的梯度贡献可以忽略不计。
解决:先用WeightedRandomSampler按类别频率倒数加权采样,让每个 batch 里小类出现的概率提高。如果效果还不够,给CrossEntropyLoss传weight参数,权重取类别频率的倒数归一化。这两种手段选一种就行,同时用容易矫枉过正,把小类过采样到过拟合。根本上还是补数据,每个身份至少凑到 15 张不同角度和光照的照片,比任何损失函数技巧都管用。
6. 二次开发:换数据集、换骨干、加人脸比对的具体改法
拿到这套代码后最常见的三个改造方向是:换自己的数据集、把主干换成更深的预训练网络、从「身份分类」扩展成「人脸比对」。前两个改动量很小,第三个要动的地方多一些。
换数据集只需要三步:按身份建目录放进data/raw,跑build_dataset.py重建对齐图和划分清单,把num_classes重新回填。这里有个后悔药式的设计——对齐图和原始图分开存,换裁剪参数时整份重跑即可,原始数据永远不用动。替换骨干时把FaceCNN换掉,注意保持输出维度是(batch, feat_dim),分类头单独抽出来,这样换 MobileNetV3 或者 ResNet18 只需要改一行导入。
import torchvision.models as models def build_backbone(name="resnet18", pretrained=True, feat_dim=512): if name == "resnet18": net = models.resnet18(weights="IMAGENET1K_V1" if pretrained else None) net.fc = nn.Identity() # 去掉原分类头,保留 512 维特征 elif name == "mobilenet_v3_small": net = models.mobilenet_v3_small(weights="IMAGENET1K_V1" if pretrained else None) net.classifier = nn.Identity() # MobileNet 输出 576 维 return net用预训练权重时,输入归一化必须同步改成 ImageNet 的mean=[0.485,0.456,0.406]、std=[0.229,0.224,0.225],否则预训练学到的特征分布对不上,微调效果反而不如从零训。学习率也要调小,从 0.01 降到 0.001,因为预训练权重已经比较接近最优解,大步长会把它冲掉。
从分类扩展成比对,思路是把分类头换成度量学习:训练时用 ArcFace 或 Triplet Loss 拉近同类特征、推远异类特征,推理时不再输出类别,而是输出一个归一化后的特征向量,两张脸算余弦相似度,超过阈值就判为同一人。好处是新增身份不用重新训练,把新人的照片过一遍网络存进特征库即可。
验证改造有没有效,我固定跑三件事:固定随机种子重跑一次基线,确认结果可复现;在留出的测试集上对比改造前后的 Top-1 和 Top-5;用同一段摄像头视频分别跑新旧模型,看逐帧输出的稳定性。第三件事最容易被跳过,但恰恰是线上表现的预演。从那以后我每次动网络结构或者损失函数,都会先录一段 30 秒的视频当回归用例,跑通了再谈准确率。希望帮到你。
本文还有配套的精品资源,点击获取