简介:面向深度学习与人脸识别方向的学习者,压缩包围绕PyTorch框架实现ResNet50,聚焦跨年龄人脸识别这一挑战性任务。压缩包共10个文件,包含5个Python脚本、2个Markdown说明文档、2个npy数据文件与1个License,整包仅715KB,轻量却覆盖完整。Python脚本中,ResNet.py定义残差网络结构,VGG.py提供对比模型,main.py与train.py分别负责数据流程与训练逻辑,data.py完成预处理,label.npy和name.npy则保存标签与身份对应信息,便于快速运行项目。针对年龄增长导致的面部特征变化,工程从数据加载、模型搭建到训练评估给出完整代码框架,并附有README帮助理解使用方式,适合希望掌握残差网络实战、特征提取与迁移学习的中高级开发者。已有2791人学习下载,可从中参考跨年龄人脸识别的数据增强策略和损失函数设计,是入门该方向的小巧实用范例。
1. 跨年龄人脸识别的核心矛盾:同一个人,不同年龄,比两个陌生人还难认
拿现在的证件照,去十年前的照片库里找同一个人,这类需求在门禁老访客比对、老照片整理、寻亲系统中反复出现。难点不在人脸检测,而在年龄引起的特征漂移:儿童期到青春期再到老年,面部软组织和纹理变化剧烈,同一个人的类内距离经常大于不同人同年龄段的类间距离。人工智能和深度学习推动的人脸识别方案里,ResNet50 是出现频率最高的骨干之一,Pytorch 则是把它落地成训练代码的主流框架。下文按「理论—数据—训练—调参—验证」的顺序,把跨年龄人脸识别的 Pytorch 实现链路讲透,中间每一段都有可直接照搬的命令和代码。适合已经跑通基础 CNN 分类任务、想进入人脸识别方向的工程师,也适合把跨年龄识别当作第一个完整项目的深度学习入门者。
2. ResNet50残差机制与年龄不变特征的提取路径
2.1 残差块跳过连接:为什么深层网络没把身份特征揉碎
先交代一个反直觉的事实:跨年龄识别里,对身份区分贡献最大的特征往往来自网络的中间层,而不是最后的分类层。ResNet 的核心设计是残差块,把期望映射 H(x) 拆成 F(x) + x,网络只需要学残差 F(x)。这种结构给反向传播留了一条从 loss 直达浅层的短路,梯度不再随层数指数衰减,50 层甚至更深的堆叠可以获得更大感受野和更高阶的特征组合,却不会因为优化困难而退化成浅层效果。
放在人脸场景里,身份信息集中在五官比例和骨骼结构上,这些信息由边缘、肤色块等低级视觉特征组合而来,主要出现在网络较浅的阶段。年龄信息则体现在纹理、轮廓松弛度上,网络越深,对这类「高频表层」信息越敏感。残差连接的价值在于:深层的类别判别逻辑不会覆盖掉浅层保留下来的结构响应,年龄变化改变了纹理层,但骨骼和比例信息依然能通过跳过连接影响最终特征。这是跨年龄任务与普通分类任务在特征提取路径上的根本差异。
这一点直接决定了怎么改网络:跨年龄识别不能只取最后一层全连接输出,而应该把倒数第二层的 2048 维全局池化特征当作基础嵌入,或把 layer3、layer4 的中层特征拼接起来。常见工程做法是保留训练好的 ResNet50 骨干,把最后一层换成身份嵌入层,特征维度选 128 或 256,再用余弦距离做最终比对。何恺明等人最初提出残差结构时验证的是图像分类,但它在度量学习场景下的价值,恰恰是这种跨年龄的身份保持能力。
2.2 用 Pytorch hook 观察 ResNet50 各层输出
动手前先验证特征走向。用 torchvision 加载 ImageNet 预训练权重,在 layer2 和 layer3 出口挂 hook,观察一张随机输入经过各层后张量形状的变化:
import torch import torchvision.models as models model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) model.eval() shape_book = {} def make_hook(name): def hook(module, inputs, output): shape_book[name] = output.shape return hook model.layer2[-1].register_forward_hook(make_hook("layer2")) model.layer3[-1].register_forward_hook(make_hook("layer3")) dummy = torch.randn(1, 3, 224, 224) with torch.no_grad(): _ = model(dummy) print(shape_book)这段代码的关键点是 register_forward_hook,它在前向结束后触发回调,不需要改写模型 forward,也不影响原有推理流程。回调函数的第一个参数是模块自身,第二个是输入元组,第三个是输出张量。hook 的用途有两个:一是调试特征可视化,二是把 hook 写进输出特征抽取器,用于训练时同时产出辅助损失,后续替换嵌入头时不必动整个网络。注意 dummy 输入用随机张量即可,torchvision 的 BatchNorm 统计已经预训练好,eval 模式下不会更新。
| 模块 | 输出通道 | 224 输入下的空间尺寸 | 直观语义 |
|---|---|---|---|
| conv1+bn | 64 | 112x112 | 边缘与颜色块 |
| layer1 | 256 | 56x56 | 简单纹理组合 |
| layer2 | 512 | 28x28 | 五官轮廓边界 |
| layer3 | 1024 | 14x14 | 面部结构组合 |
| layer4 | 2048 | 7x7 | 全局头部姿态语义 |
表里的语义判断是经验性结论,来自可视化工作上常见的解读。真正到训练阶段,layer3 和 layer4 的 14x14 与 7x7 特征图对身份判别的贡献最大,因为二者的感受野已经覆盖了整个面部关键区域,又不至于像 layer4 末尾那样被全局姿态信号稀释。网上流传的 ResNet50 网络结构示意图大多画到 fc 层为止,那对分类任务够用,对特征提取任务参考价值有限。
2.3 ResNet50、ResNet18 与 ResNet101 的选择边界
对跨年龄任务,ResNet50 出现的频率比另外两个高,有明确理由。ResNet18 参数量不到 12M,推理快,适合移动端,但瓶颈层的通道数有限,特征嵌入的表达力受容量约束,在年龄跨度大、类内差异大的数据上容易出现误识别。ResNet101 的 44M 参数表达力更强,但训练开销接近 ResNet50 的两倍,如果只有几百个人、几千张图的小数据集,很容易把骨骼特征学成噪声,过拟合比 ResNet50 更严重。ResNet50 的 25M 参数落在中间,官方预训练权重覆盖好,Pytorch 生态里配套脚本最齐全,单张 24G 显存的卡上能以 batch 64 微调 224x224 输入,这是一个现实分界线。
提示:后续部署到推理卡或端侧设备时,ResNet50 的嵌入特征可以降到 128 维,相比保留 2048 维原始特征能省掉约 94% 的存储,相似度计算降到常数级,这个压缩在跨年龄任务里精度损失很小。
3. 用 Pytorch 准备跨年龄训练数据:对齐、归一化与增强策略
3.1 跨年龄数据集的组织方式
公开基准里,FG-NET 这类以年龄跨度著称的小型数据集,适合验证算法思路;MORPH 一类的真实场景数据量大但标注噪声也大。工程落地时更常见的是用自有数据:按身份证照片、证件照、日常照片分组,再按年龄段分桶。数据量级在万张以下时,首先要保证每个身份有至少 3 张不同年龄段的照片,年龄差最好超过 5 年,否则模型学到的只是「同一个人长得像」而不是「不同年龄的同一个人的共性特征」。
自己攒数据最忌讳按时间顺序切分训练集和测试集。比如证件照在 2015 年以前、生活照在 2020 年以后,时间相关偏差会和年龄相关偏差纠缠,模型学到的是拍摄设备差异而不是年龄无关特征。正确做法是按人物 ID 划分:同一个人所有年龄段的照片必须进同一侧,保证验证集里出现的都是「没见过的脸」。这个边界条件对最终泛化能力的影响比任何单一增强项都大。
起步阶段推荐分类训练:每个身份一个类别,用 Softmax 或 ArcFace 学嵌入。分类训练稳定后,如果数据量允许,再切到三元组微调。三元组需要在线采样 anchor、positive、negative,其中 positive 是同一个人另一个年龄段的照片,negative 是其他人的任意年龄照片,代码复杂度比分类训练高一档,但能让模型直接学习「年龄变了身份不变」的相对关系。
3.2 人脸对齐与输入归一化
两步式对齐是标准做法。第一步检测人脸和五个关键点(左右眼、鼻尖、左右嘴角),工具常用 MTCNN 或 RetinaFace;第二步用仿射变换把关键点映射到统一模板。代码片段如下:
import cv2 import numpy as np def align_face(image, landmarks, output_size=(224, 224)): # landmarks 顺序: 左眼, 右眼, 鼻尖, 左嘴角, 右嘴角 standard = np.array([[0.32, 0.34], [0.68, 0.34], [0.50, 0.46], [0.32, 0.64], [0.68, 0.64]], dtype=np.float32) standard[:, 0] *= output_size[0] standard[:, 1] *= output_size[1] matrix, _ = cv2.estimateAffinePartial2D( np.array(landmarks, dtype=np.float32), standard, method=cv2.LMEDS) aligned = cv2.warpAffine(image, matrix, output_size, flags=cv2.INTER_CUBIC) return alignedestimateAffinePartial2D 使用 LMEDS 做稳健估计,能剔除一两个关键点定位误差较大的干扰。标准模板把左右眼放在宽度约 1/3 和 2/3 处,面部基本保持水平,这是人脸识别里常用的归一化约定。输出尺寸选 224x224,与 ResNet50 的输入对齐;算力紧张时也可以选 112x112,但跨年龄任务对眼部周边细纹理敏感,输入缩到 112 后特征明显变钝。
归一化沿用 ImageNet 的三通道均值 [0.485, 0.456, 0.406] 和标准差 [0.229, 0.224, 0.225]。torchvision 的预训练权重默认适配这个数据分布,换数据后不更新归一化参数,训练初期 loss 会异常跳动,这一条经常被忽略。
3.3 数据增强参数与 DataLoader 实现
跨年龄任务的增强策略和普通分类不同,重点在模拟年龄变化带来的影像质量差异:老照片分辨率低、对比度低、偏色严重,新照片清晰锐利。只做随机翻转远远不够。
from torch.utils.data import Dataset, DataLoader from torchvision import transforms import cv2 class AlignedFaceDataset(Dataset): def __init__(self, img_paths, labels, phase='train'): self.paths = img_paths self.labels = labels self.phase = phase if phase == 'train': self.transform = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(0.5), transforms.ColorJitter(brightness=0.12, contrast=0.15, saturation=0.08), transforms.GaussianBlur(kernel_size=3, sigma=(0.1, 1.0)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) else: self.transform = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) def __len__(self): return len(self.paths) def __getitem__(self, idx): img = cv2.imread(self.paths[idx]) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) try: img = self.transform(img) except Exception: img = torch.zeros(3, 224, 224) return img, torch.tensor(self.labels[idx], dtype=torch.long)RandomCrop 在 256 输入上裁出 224 区域,让模型看到同一个人脸的不同裁切角度,属于低开销的几何增强。ColorJitter 的 brightness 和 contrast 模拟老照片褪色与光线差异,但 saturation 不要给太高,否则会把肤色偏移学成身份特征。GaussianBlur 用 3x3 核、sigma 范围 (0.1, 1.0),模拟老照片失焦质感,这个增强在跨年龄任务里比随机擦除更有用。
| 增强项 | 推荐参数 | 理由 | 不推荐 |
|---|---|---|---|
| RandomCrop | 256 -> 224 | 平移不变性开销小 | 大位移裁切掉五官 |
| ColorJitter | brightness=0.12, contrast=0.15 | 补偿照片年代差异 | 大饱和值偏色 |
| GaussianBlur | 3x3, sigma 0.1~1.0 | 模拟旧照片弱纹理 | 范围过大丢失五官 |
| RandomErasing | 概率 0.2 | 应对遮挡场景 | 裁掉关键五官区域 |
训练时 DataLoader 设 shuffle=True,num_workers 按机器配置取 4 或 8,pin_memory=True。num_workers 过大会在 Windows 上报 DataLoader worker 相关段错误,稳妥做法是先用 0 跑通流程再逐步加大。
4. 基于 Pytorch 实现 ResNet50 的模型改造与训练循环
4.1 从分类头到身份嵌入层
标准 torchvision ResNet50 的最后一层是 fc,输出 1000 类 ImageNet 标签。跨年龄识别要的是身份嵌入而不是类别输出。常见做法是把 fc 换成一个两层线性映射,输出维度设为 128 或 256。有人直接取全局池化后的 2048 维原始特征,不再加额外映射,这种方案也能用,差别在存储和度量计算量上。
import torch.nn as nn import torchvision.models as models def build_resnet50_embedding(out_dim=256, freeze_backbone=False): model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) # 替换分类头:全局池化后是 2048 维,降到 out_dim 嵌入维度 model.fc = nn.Sequential( nn.Linear(2048, out_dim), nn.BatchNorm1d(out_dim), nn.ReLU(inplace=True), nn.Linear(out_dim, out_dim), # 第二层不加激活,保留线性映射能力 ) if freeze_backbone: for name, param in model.named_parameters(): if 'layer4' not in name and 'fc' not in name: param.requires_grad = False return modelBatchNorm1d 把嵌入维度拉回同一尺度,避免某个维度的数值过大主导距离计算。第二层线性层不加激活,输出值域保持自然分布,后续做余弦相似度时不会被 ReLU 截断到非负区间。freeze_backbone 参数控制是否冻结除 layer4 和 fc 以外的网络部分,数据量小于两万张时建议开启。
4.2 损失函数:Softmax 收敛稳定,ArcFace 边界更清晰
分类式训练里最直接的损失是 CrossEntropyLoss,配合嵌入后的分类头一起训练。训练完丢到分类头,只用嵌入做比对。Softmax 实现简单、收敛稳定,但对类间距离没有显式约束,同一个人跨年龄的嵌入可能比不同人的嵌入更远,正好踩中跨年龄任务的雷。
| 损失函数 | 类内约束 | 收敛速度 | 跨年龄适用性 |
|---|---|---|---|
| Softmax | 无显式约束 | 快 | 中,随类别划分变化 |
| ArcFace | 显式角度间隔 | 较慢 | 高,跨年龄推荐 |
| 三元组损失 | 需在线挖掘 | 不稳定 | 适合微调阶段 |
工程常用 ArcFace 这类加性角度间隔损失。ArcFace 在 Softmax 的 logits 上对目标类加上角度间隔 m,要求嵌入向量与对应类中心的夹角更小,同一个人不同年龄的照片会被压到更紧凑的区域。
import torch import torch.nn as nn class ArcFaceLoss(nn.Module): def __init__(self, in_features, out_features, s=30.0, m=0.50): super().__init__() self.weight = nn.Parameter(torch.FloatTensor(out_features, in_features)) nn.init.xavier_uniform_(self.weight) self.s = s # 特征缩放系数 self.m = m # 角度间隔 def forward(self, inputs, labels): cosine = torch.nn.functional.linear( torch.nn.functional.normalize(inputs), torch.nn.functional.normalize(self.weight) ) theta = torch.acos(torch.clamp(cosine, -1.0 + 1e-7, 1.0 - 1e-7)) target_logits = torch.cos(theta + self.m) one_hot = torch.zeros_like(cosine) one_hot.scatter_(1, labels.view(-1, 1).long(), 1) output = cosine * (1 - one_hot) + target_logits * one_hot return torch.nn.functional.cross_entropy(output * self.s, labels)s 是特征缩放因子,常见取 30 到 64;m 是角度间隔,常见取 0.45 到 0.5。s 太小会让样本在 logits 空间重叠,s 过大会让训练对噪声标签敏感。m 增大能压缩类内距离,但过大会让网络不收敛,训练曲线表现为 loss 波动剧烈、验证集交叉上不去。小数据量下 ArcFace 收敛比 Softmax 慢,实际项目里可以先跑 20 轮 Softmax 预热,再切到 ArcFace 微调。
4.3 最小可跑的训练循环与学习率退火
下面这段代码是跨年龄分类训练的最小闭环,配合 3.3 节的 DataLoader 使用:
from torch.optim import SGD from torch.optim.lr_scheduler import CosineAnnealingLR model = build_resnet50_embedding(out_dim=256, freeze_backbone=True) model.train() optimizer = SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingLR(optimizer, T_max=80, eta_min=1e-5) criterion = ArcFaceLoss(in_features=256, out_features=num_identities, s=32.0, m=0.45) for epoch in range(80): total_loss = 0.0 for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() embeddings = model(images) loss = criterion(embeddings, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() if (epoch + 1) % 10 == 0: print(f"epoch {epoch+1}, loss {total_loss/len(train_loader):.4f}, lr {scheduler.get_last_lr()[0]:.6f}")SGD 带 momentum=0.9、weight_decay=5e-4 是 ResNet 系列最稳的组合,Adam 在跨年龄小数据集上不如 SGD 稳定,测试集波动大时优先回头检查优化器选择。CosineAnnealingLR 的 T_max 设为总轮数,学习率先快后慢地退火,这是在特征嵌入任务里最常见的调度方式。torchvision 权重自带 BatchNorm,model.train() 开启 bn 统计更新,评估时切回 eval(),否则验证集准确率会差好几个百分点。冻结骨干时,冻结层里的 BatchNorm 统计量也会在 train 模式下继续更新,如果发现预训练特征被破坏,把冻结层切到 eval 模式再训练。
5. 跨年龄训练的常见坑与参数修正
5.1 冻结骨干的分界线:什么时候解冻 layer4
小数据集微调时,冻结前面所有层、只训练 layer4 和 fc,能让训练稳定且速度接近推理。如果冻结后验证集准确率停滞在 70% 上下超过 15 轮,就该考虑放开 layer4 的卷积权重。解冻时学习率要成比例缩小,常见做法是从 0.01 降到 0.002,同时用早停机制监控验证集。成像设备差异大于年龄差异的数据里,冻结前几层反而能让模型不被采集噪声干扰,这个判断要基于数据来源做,不是越多层解冻越好。
另外一个容易被忽略的点:解冻后不是所有层都需要同样的学习率。把 layer4 的 lr 设为骨干层的 3 倍、嵌入层设为骨干层的 10 倍,是参数敏感的调法。Pytorch 里给不同参数组配不同 lr 用param_groups就能实现:
embed_params = list(model.fc.parameters()) layer4_params = list(model.layer4.parameters()) backbone_params = [p for p in model.parameters() if p.requires_grad and not any(p is q for q in layer4_params + embed_params)] optimizer = SGD([ {"params": backbone_params, "lr": 0.0005}, {"params": layer4_params, "lr": 0.0015}, {"params": embed_params, "lr": 0.006}, ], momentum=0.9, weight_decay=5e-4)any(p is q ...)用对象身份判断避免把同一个参数分进两个组。优化器会在此基础上叠加 momentum 和 weight_decay,但不会覆盖每个组的 lr。lr 分三层之后,解冻后的震荡通常能在 10 轮内缓解。
5.2 类别数与采样策略
跨年龄数据集的类别数通常远小于通用人脸数据集。通用集合动辄几万身份,这里的场景往往只有几百到几千身份,每个身份还必须覆盖多个年龄段。类别少时分类边界不够精细,训练末期嵌入虽然收敛,但误判依然多。一种常见配合方案是分类损失加三元组微调,但首先要解决类别不平衡。
人脸数据天然有头部聚类效应,少数人的照片占了好几批,不处理会让模型只见过这几张脸。加权采样是标准处理方式:
from collections import Counter from torch.utils.data import WeightedRandomSampler class_counts = Counter(labels) weights = [1.0 / class_counts[label] for label in labels] sampler = WeightedRandomSampler(weights, num_samples=len(labels), replacement=True) train_loader = DataLoader(dataset, batch_size=64, sampler=sampler, num_workers=4, pin_memory=True)WeightedRandomSampler 对低频身份上采样、高频身份下采样。weights 数组按每个样本的真实类别取倒数,频率越高的类别权重越小。replacement=True 允许同一张图在同一轮里出现多次,num_samples 指定每轮采样总量,这样不会因数据集长度有限而循环耗尽。注意 sampler 与 DataLoader 的 shuffle 参数互斥,上面的代码里没写 shuffle 是刻意的,写了会抛运行时错误。
5.3 学习率、batch size 和输入尺寸的关系
三个参数互相耦合:batch size 翻倍,梯度估计更稳定,学习率通常也要跟着放大;输入尺寸从 224 降到 160,单卡能装下 batch 128,但细节特征下降,年龄不变表达会被削弱。推荐从下表的稳定组合开始,再按显卡内存上下浮动。
| 配置 | 推荐值 | 适用条件 |
|---|---|---|
| 输入尺寸 | 224x224 | 标准训练;显存不足降到 160 |
| batch size | 32~64 | 单卡 12G~24G;小于 16 先跑通流程 |
| 初始学习率 | 0.01(SGD) | 冻结骨干用 0.01,解冻 layer4 后 0.002 |
| weight_decay | 5e-4 | ResNet 系列稳定范围 |
| ArcFace s | 30~64 | 类别数多取大值 |
| ArcFace m | 0.4~0.5 | 跨年龄任务取 0.45 附近 |
调试时看训练 loss 和验证集 top-1 精度同图。如果 loss 在下降但 top-1 不涨,大概率是嵌入没有区分度,检查最后一层权重初始化和类别数。混合精度训练时,loss 缩放保持默认即可,跨年龄数据不涉及梯度异常,不需要额外处理。
6. 验证指标、阈值选定与单张推理的落地技巧
6.1 阈值校准:找 EER 而不是拍脑袋设 0.5
训练结束后需要给一对人脸定判定阈值。直接设 0.5 通常不对。更可靠的路线是:全量推理拿到所有身份嵌入,按同人对和异人对分别收集余弦相似度,再画 ROC 曲线取 EER——即误识率 FAR 与拒识率 FRR 相等的位置。阈值与数据集构成强相关,测试集年龄跨度越大,EER 阈值通常越低。
from sklearn.metrics import roc_curve # embeddings 形状 (N, 256),pairs_same 与 pairs_diff 是索引对列表 same_scores = [ torch.cosine_similarity(embeddings[i], embeddings[j], dim=0).item() for i, j in pairs_same ] diff_scores = [ torch.cosine_similarity(embeddings[i], embeddings[j], dim=0).item() for i, j in pairs_diff ] fpr, tpr, thresholds = roc_curve( [1]*len(same_scores) + [0]*len(diff_scores), same_scores + diff_scores ) fnr = 1 - tpr eer_idx = np.argmin(np.abs(fpr - fnr)) best_thr = thresholds[eer_idx] print(f"EER={fpr[eer_idx]*100:.2f}%, threshold={best_thr:.4f}")roc_curve 返回的 thresholds 按降序排列,eer_idx 处的阈值直接可用。部署后数据分布会随采集设备变化,建议每季度用新积累的比对日志重新校准一次。
6.2 单张推理脚本与写死预处理
线上推理脚本要把预处理和模型路径完整串起来,越简单越好:
from PIL import Image import numpy as np import torch def embed_one(model, image_path): model.eval() img = Image.open(image_path).convert("RGB") img = img.resize((224, 224), Image.BILINEAR) arr = np.asarray(img, dtype=np.float32) / 255.0 arr = (arr - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) tensor = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).cuda() with torch.no_grad(): emb = model(tensor).squeeze() emb = torch.nn.functional.normalize(emb, p=2, dim=0) return emb.cpu().numpy()预处理写死在脚本里,不依赖训练代码里的 transforms 对象,线上环境少一个 import 都会直接崩。normalize 这一步把嵌入向量标准化到单位长度,后续余弦相似度等于内积,省一次除法。Pytorch 环境搭建完成后,这套推理链路不需要额外安装任何推理框架。
注意:onnx export 时 BatchNorm 会折叠进卷积权重,输出张量语义不变,但输入尺寸必须固定为 224。动态尺寸会让池化层输出维度产生歧义,固定 batch=1 导出最稳。
在线服务通常只做单张比对,batch=1 时 GPU 利用率很低,常见做法是攒一批请求拼到同一个 batch 再推理,吞吐量大约能提升 3 到 5 倍。
本文还有配套的精品资源,点击获取