ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

FasterNet与PConv实战:从原理到PyTorch图像分类部署

2026/9/28 15:11:59 拓冰建站 浏览量
FasterNet与PConv实战:从原理到PyTorch图像分类部署 简介这份资源是围绕FasterNet图像分类任务的完整实战项目包面向有深度学习基础、希望将前沿轻量级网络迁移到实际图像分类场景的开发者与学习者。FasterNet基于全新的Partial卷积PConv设计在GPU和CPU上均有更优的速度与精度平衡资源提供全套训练与推理代码可快速复现原模型并适配自己的数据集。压缩包共有2000余个文件以2433张png图像为主同时包含7个Python脚本、pth模型权重、class.json类别文件和说明文档整体大小约847.88MB目录分层展示数据、模型与代码便于检索与二次开发。目前已有1609人学习下载适合进行图像分类任务实战、轻量网络对比实验以及模型部署预研可直接加载权重完成预测也可借助PConv机制进行结构改进是一份兼具代码、数据与训练经验的完整参考。1. FasterNet 实战先理解 PConv再谈图像分类任务做图像分类选型时我习惯先看一个指标同样精度下CPU 推理能省多少时间。FasterNet 是一个主打“多平台都快”的神经网络家族靠 Partial ConvolutionPConv把计算量压下来同时精度没有明显回退。官方 FasterNet-L 在 ImageNet 上拿到 83.5% top-1与 Swin-B 基本持平CPU 上却可以省 42% 计算时间。这篇实战记录会从 PConv 原理讲到用 PyTorch 跑通图像分类训练与推理再把 class.json 和示例图如何接入讲清楚。适合正在对比 MobileViT、EfficientNet 的工程师也适合想拿完整案例练手的初学者。2. 从 PConv 到 FasterNet Block原理拆解与 PyTorch 搭建2.1 为什么 FasterNet 快在“内存访问”而不是“算得多快”轻量网络最容易犯的判断错误是只看 FLOPs。MobileNet 把标准卷积换成深度可分离卷积后 FLOPs 低了很多可实际部署到 CPU 上延迟并没有等比例下降。原因在于神经网络推理耗时包含两部分一次是真正做乘加运算另一次是数据在内存和计算单元之间的搬运后者就是常说的 memory access costMAC在轻量化网络里往往比 FLOPs 更能决定延迟。FasterNet 的核心观点正好落在这里传统轻量算子虽然在 FLOPs 上很省但读取所有输入输出通道时内存访问量没有降下来。于是它提出 Partial Convolution只对输入通道里的一部分做卷积其余通道直接跳过参与运算的通道变少内存访问量也随之下降。几种卷积算子相对计算量的直观对比如下算子FLOPs 相对值内存访问相对值标准卷积1.01.0深度可分离卷积约 1/8约 1.0PConv1/4 通道卷积约 1/4明显低于 1.0FLOPs 相对值只是示意不同分辨率下会变化。重点是想说明PConv 省的是内存访问和时间而不是单纯把模型参数变小。这一点在 CPU 上尤其明显因为 CPU 对内存带宽更敏感GPU 虽然带宽大也能吃到吞吐提升的好处。官方数据里 FasterNet-L 在 GPU 上的推断吞吐量高于 Swin-B在 CPU 上节省 42% 计算时间就是这个逻辑的落地结果。所以你在给项目做 backnone 选型时不要被 FLOPs 一叶障目真实跑一遍 CPU 延迟才算数。2.2 PConv 的 PyTorch 实现只对一部分通道做卷积PConv 的实现并不复杂核心是把输入在通道维度上拆成两份只对其中一份做卷积另一份原样保留。官方仓库里一般用 depthwise conv 处理被选中的那部分通道你也可以换成普通 Conv2d参数和计算量会有差异但整体结构不变。我通常先跟着官方用 depthwise 版本省参数后面做剪枝时再替换。import torch import torch.nn as nn class PartialConv(nn.Module): def __init__(self, dim, n_div4, kernel_size3, stride1, padding1): super().__init__() self.dim dim self.n_div n_div self.split_chs dim // n_div # 参与卷积的通道数 # 对拆分出来的一部分通道做 depthwise conv self.conv nn.Conv2d( self.split_chs, self.split_chs, kernel_sizekernel_size, stridestride, paddingpadding, groupsself.split_chs, biasFalse ) self.bn nn.BatchNorm2d(dim) def forward(self, x): x1, x2 torch.split(x, [self.split_chs, self.dim - self.split_chs], dim1) x1 self.conv(x1) out torch.cat([x1, x2], dim1) out self.bn(out) return outdim 是输入通道数n_div 决定把通道分成几份。默认 n_div4就是只卷积 1/4 的通道其余 3/4 原样绕过参数和计算量都降下来了。这里 groups 等于 split_chs是做 channel-wise 独立卷积如果想换回普通卷积把 groups1 就行但参数量会变成 split_chs 的平方倍。BatchNorm 我放在拼接之后对整个输出做归一化而不是只对卷积部分做训练会更稳定。实际使用中n_div 不是越大越好。数据量很少时把 n_div 调小一些比如 2让更多通道参与卷积模型表达能力会更强数据量够大、更追求速度时n_div4 是常见默认值。2.3 把 PConv 拼成 FasterNet Block拿到 PConv 之后还需要两个 1x1 卷积把它变成完整的 block。PConv 只处理了部分通道跨通道的信息交换必须靠 pointwise 卷积补回来。下面是我在分类项目里一直用的 FasterNetBlockclass FasterNetBlock(nn.Module): def __init__(self, dim, expand_ratio4, n_div4): super().__init__() self.pconv PartialConv(dim, n_divn_div) self.pw1 nn.Conv2d(dim, dim * expand_ratio, 1, biasFalse) self.pw2 nn.Conv2d(dim * expand_ratio, dim, 1, biasFalse) self.act nn.GELU() def forward(self, x): identity x x self.pconv(x) x self.pw1(x) x self.act(x) x self.pw2(x) return x identityexpand_ratio 控制中间通道扩展倍数默认 4中间先把通道扩到 4 倍再压回 dim类似轻量 bottleneck。激活函数用 GELUFasterNet 官方也是这么做的如果推理延迟特别敏感换成 ReLU 也能跑精度会有一点变化。残差连接 identity 必须保留去掉之后小数据集上很容易出现 loss 不降。真正组成一个 FasterNet需要把多个 block 串成 stage。我的做法是参考官方整体结构前面一个 stem 做 stride 2 下采样后面接四个 stage每个 stage 由若干个 FasterNetBlock 组成通道数从 96 逐步扩到 128、192、384block 数量直接用官方仓库里对应型号的配置不自己瞎调。要注意的是每个 stage 之间通常有一个下采样层负责把空间分辨率减半并翻倍通道数这一步别丢不然整网感受野和计算量都会失衡。这些代码写完模型已经可以 forward 了。下一步是把数据接进去也就是第三部分要重点讲的 class.json 和图片集处理。3. 图像分类实战class.json 解析、训练与推理脚本3.1 先看清 class.json类别映射的两种常见写法资源包里会看到 class.json 和几张示例图。class.json 的作用是把类别文本转成训练用的数字索引。常见格式有两种一种是{cat: 0, dog: 1}键是类别名、值是索引另一种是[cat, dog]数组下标就是索引。我写代码的第一步永远是先打印看结构而不是直接硬编码。import json with open(class.json, r, encodingutf-8) as f: raw json.load(f) print(type(raw)) print(list(raw.items())[:5] if isinstance(raw, dict) else raw[:5])看完格式后再做一层兼容转换统一得到 idx_to_name 和 name_to_idx 两个字典。训练用索引推理用类别名两边都得有。if isinstance(raw, dict): if raw and all(isinstance(k, str) and isinstance(v, int) for k, v in raw.items()): name_to_idx raw idx_to_name {v: k for k, v in raw.items()} else: idx_to_name {int(k): v for k, v in raw.items()} name_to_idx {v: int(k) for k, v in raw.items()} elif isinstance(raw, list): idx_to_name {i: c for i, c in enumerate(raw)} name_to_idx {c: i for i, c in enumerate(raw)} else: raise ValueError(class.json 格式不支持先打开看一眼)很多坑都出在“看起来是对的”上。比如 dict 里 key 是字符串数字0、value 是字符串如果不做类型判断直接用 int() 转一半就会报错。这段代码把两种常见格式都兼容了至少能保证后面阶段跑起来。3.2 数据加载让 PNG 图片与类别 ID 对齐训练数据最常见的目录组织方式是train/类名/xxx.png。如果保证了这种结构直接用 torchvision 的 ImageFolder 最省事但如果图片是散的或者类名和 class.json 的键不完全一致我会直接写一个轻量 Dataset可控性更好。import os from glob import glob from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class ImageClsDataset(Dataset): def __init__(self, root_dir, transformNone): self.samples [] self.classes sorted(os.listdir(root_dir)) self.class_to_idx {c: i for i, c in enumerate(self.classes)} for cls in self.classes: cls_dir os.path.join(root_dir, cls) for img_path in glob(os.path.join(cls_dir, *.png)) glob(os.path.join(cls_dir, *.jpg)): self.samples.append((img_path, self.class_to_idx[cls])) self.transform transform or T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] image Image.open(path).convert(RGB) return self.transform(image), label这里有两处容易踩一是sorted(os.listdir(root_dir))会按字符串排序如果类名是中文顺序可能和 class.json 对不上所以调用 Dataset 后要立刻打印dataset.class_to_idx与 name_to_idx 对比二是 transform 里的 Resize 到 224x224 只适合大部分公开分类模型如果你用的 FasterNet 输入尺寸不是 224记得改。Normalize 的均值和标准差是 ImageNet 统计量小数据集上不一定最优但先跑通流程再调这里的收益更大。3.3 训练闭环一个可跑的 PyTorch 训练函数模型和数据集就位后训练部分其实很通用。下面这个train_one_epoch函数用在好几个轻量分类项目里核心是加了梯度累积方便小显存环境下调 batch size。import torch import torch.nn as nn import torch.optim as optim def train_one_epoch(model, loader, optimizer, criterion, device, grad_accum1): model.train() running_loss 0.0 correct, total 0, 0 optimizer.zero_grad() for i, (images, labels) in enumerate(loader): images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) loss.backward() if (i 1) % grad_accum 0: optimizer.step() optimizer.zero_grad() running_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) # 处理最后不足 grad_accum 的残余 batch if len(loader) % grad_accum ! 0: optimizer.step() optimizer.zero_grad() return running_loss / total, correct / totalcriterion 我用nn.CrossEntropyLoss()优化器分两种情况从头训练用 SGD(lr0.1 * batch_size / 256, momentum0.9, weight_decay1e-4)微调用 AdamW(lr1e-4) 会更稳。grad_accum 的作用是把多个 batch 的梯度累加起来再更新一次等效于把 batch size 放大但需要注意 BN 层还是按实际 batch 统计的所以效果不等于真正放大 batch。这段函数只回传 loss 和 acc。训练脚本里还要包一层 epoch 循环并在每个 epoch 后做验证只保留验证集最高 acc 的权重否则最后很可能拿着过拟合模型去推理。3.4 验证集评估别只盯着训练 acc训练 acc 高不代表泛化好尤其小数据集上更容易发生严重过拟合。我一般每个 epoch 结束都会跑一遍验证集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) preds model(images).argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return correct / total这里有两个关键点model.eval()必须写否则 BN 仍按训练状态统计验证结果会虚高torch.no_grad()省显存的同时也防止反向图累积。验证时用的 transform 要和推理一致只保留 Resize、ToTensor、Normalize不要带随机增强。对比训练 acc 和验证 acc 的差如果差值超过 5 个百分点先考虑加数据增强或减小模型容量再谈调学习率。3.5 推理脚本输入一张图输出 top-k训练完成后真正要交付的是单张图像的推理流程。下面函数读入任意一张 png/jpg做和训练完全一致的预处理输出 top-3 预测。def infer_on_image(model, image_path, transform, idx_to_name, device): image Image.open(image_path).convert(RGB) x transform(image).unsqueeze(0).to(device) model.eval() with torch.no_grad(): logits model(x) probs torch.softmax(logits, dim1) topk torch.topk(probs, 3, dim1) print(top-3 预测结果) for rank in range(3): class_idx topk.indices[0, rank].item() score topk.values[0, rank].item() print(f {rank 1}. {idx_to_name[class_idx]} : {score:.4f})model.eval()不能省否则 BN 和 dropout 的行为会随训练状态变化。top-3 的选择没有固定标准三分类到十类的小数据集上我会看 top-3因为样本量少时模型置信度往往偏低。推理时最容易犯的错误是训练用了随机裁剪、翻转推理时也照做了一遍。测试阶段只保留 Resize、ToTensor、Normalize任何随机增强都要去掉否则结果会和训练指标根本对不上。4. 实战避坑数据、显存与收敛的五个常见问题4.1 图片通道不一致灰度图和 RGB 强制转换现象训练时 loss 正常下降训练集准确率很高但验证集或单张推理的准确率突然差一大截。原因训练数据里混入了灰度图。灰度图只有一个通道如果某个 Dataset 实现里只做了Image.open(path)没有convert(RGB)ToTensor 会给它加一个假通道和真正的三通道模型产生错位。推理时如果又换了一张 PNG通道数可能不同结果自然不稳定。解决所有读图统一走Image.open(path).convert(RGB)在__getitem__和推理函数里都做相同转换。做完后检查输入 tensor 的 shape一定是[N, 3, H, W]。如果发现某些图片本身是 RGBA 四通道convert(RGB) 会安全忽略 alpha 通道。4.2 显存不足batch size、分辨率与梯度累积现象训练一开始就报CUDA out of memory或者跑到一半被系统 kill。原因最常见是 batch size 设得太大其次是输入分辨率太高。FasterNet 本身不算特别吃显存但如果分辨率和 batch 同时调大8GB 显卡照样爆。解决先用小 batch size 比如 16 跑通再逐步加到 64想用更大的等效 batch就用第三章代码里的 grad_accum。另外可以开torch.cuda.amp混合精度显存能再省一半左右。注意梯度累积不是银弹BN 层的统计仍基于实际 batch显存省下来但 BN 收敛会变慢必要时配合 sync_bn 或干脆换成 GroupNorm。4.3 类别索引错位训练 ID 与 class.json 对不上现象训练准确率很高但推理打印出来的类别名完全错误甚至越界。原因训练时使用了 ImageFolder它是按文件夹名字典序自动生成 label而 class.json 可能是发布者在另一台机器上维护的。两边顺序一旦不一致训练数据里的 0 实际对应 class.json 里的 5所有预测都会错位。解决不用隐式排序而是在初始化 Dataset 时用 class.json 的 name_to_idx 去映射标签。如果坚持用 ImageFolder必须在训练前打印dataset.class_to_idx和name_to_idx人工确认每个 key 对应一致。调一次只要两分钟能避免训练半天后白干。4.4 loss 不降或震荡学习率、预热与类别不均衡现象loss 一直在 0.7 左右不动或者每几个 step 就在 0.5 和 1.2 之间剧烈跳动。原因模型刚初始化时参数很不稳定大步长容易把特征推到坏区域另一个常见原因是类别数量不平衡少数类样本太少模型干脆把多数类当成唯一答案。解决从头训练时先做 3 到 5 个 epoch 的 warmup把学习率从 1e-6 线性升到目标值类别分布差距超过 10 倍时给 CrossEntropyLoss 传weight按样本数倒数归一化。对 FasterNet 这类轻量网络中小规模数据集上我还会把输入分辨率从 224 降到 192收敛更快指标稳定后再回到 224 微调。不要一上来就怀疑 PConv 算子有问题它在 ImageNet 上验证过精度问题大多出在使用方式。5. 进阶验证用 FLOPs 和吞吐量给 FasterNet 把把脉5.1 用 torchinfo 快速打印参数量和 FLOPs无论训练还是部署拿到模型后我都会先做静态检查。不装额外库时直接统计参数量就够了def count_parameters(model): return sum(p.numel() for p in model.parameters() if p.requires_grad) print(fFasterNet 参数量: {count_parameters(model) / 1e6:.2f} M)装 torchinfo 后更建议用 summary可以看到每层输出形状和乘加量from torchinfo import summary summary(model, input_size(1, 3, 224, 224), devicecpu)这一步的价值在于换一个 backbone 之前就能看到计算量差异不用等训练完才发现模型太大。我通常把参数量、FLOPs 和最终 acc 一起记在实验表里选型时比单看 acc 靠谱很多。5.2 顺手验证一下吞吐FPS 才是硬指标FLOPs 低的网络不一定端上跑得快。所以我习惯在项目结束前强制跑一遍单张推理吞吐测试import time def benchmark_fps(model, device, input_size(1, 3, 224, 224), repeat50): model.eval() x torch.randn(*input_size).to(device) with torch.no_grad(): for _ in range(5): # warmup model(x) torch.cuda.synchronize() t0 time.time() for _ in range(repeat): model(x) torch.cuda.synchronize() return repeat / (time.time() - t0) print(fFPS: {benchmark_fps(model, device):.1f})测试时固定 batch size 为 1先 warmup 再用torch.cuda.synchronize()做时间对齐。CPU 上同步没那么严格但最好跑三遍取中位数。我在一个二分类项目上对比过 FasterNet 和 MobileViTMobileViT 的 FLOPs 更低但 CPU 延迟反而更高原因就在内存访问。从那以后我每次训完 FasterNet都会先跑一遍 FPS 自检再谈要不要换 Backbone。这个习惯虽然简单但能帮你躲掉不少“理论快、实际慢”的部署坑希望帮到你。本文还有配套的精品资源点击获取