☰
FasterNet与PConv实战:从原理到PyTorch图像分类部署
2026/9/28 15:11:43 网站建设 项目流程

简介:这份资源是围绕FasterNet图像分类任务的完整实战项目包,面向有深度学习基础、希望将前沿轻量级网络迁移到实际图像分类场景的开发者与学习者。FasterNet基于全新的Partial卷积(PConv)设计,在GPU和CPU上均有更优的速度与精度平衡,资源提供全套训练与推理代码,可快速复现原模型并适配自己的数据集。压缩包共有2000余个文件,以2433张png图像为主,同时包含7个Python脚本、pth模型权重、class.json类别文件和说明文档,整体大小约847.88MB,目录分层展示数据、模型与代码,便于检索与二次开发。目前已有1609人学习下载,适合进行图像分类任务实战、轻量网络对比实验以及模型部署预研,可直接加载权重完成预测,也可借助PConv机制进行结构改进,是一份兼具代码、数据与训练经验的完整参考。

1. FasterNet 实战:先理解 PConv,再谈图像分类任务

做图像分类选型时,我习惯先看一个指标:同样精度下,CPU 推理能省多少时间。FasterNet 是一个主打“多平台都快”的神经网络家族,靠 Partial Convolution(PConv)把计算量压下来,同时精度没有明显回退。官方 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 cost(MAC),在轻量化网络里往往比 FLOPs 更能决定延迟。

FasterNet 的核心观点正好落在这里:传统轻量算子虽然在 FLOPs 上很省,但读取所有输入输出通道时内存访问量没有降下来。于是它提出 Partial Convolution,只对输入通道里的一部分做卷积,其余通道直接跳过,参与运算的通道变少,内存访问量也随之下降。几种卷积算子相对计算量的直观对比如下:

算子FLOPs 相对值内存访问相对值
标准卷积1.01.0
深度可分离卷积约 1/8约 1.0
PConv(1/4 通道卷积)约 1/4明显低于 1.0

FLOPs 相对值只是示意,不同分辨率下会变化。重点是想说明: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_div=4, kernel_size=3, stride=1, padding=1): 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_size=kernel_size, stride=stride, padding=padding, groups=self.split_chs, bias=False ) self.bn = nn.BatchNorm2d(dim) def forward(self, x): x1, x2 = torch.split(x, [self.split_chs, self.dim - self.split_chs], dim=1) x1 = self.conv(x1) out = torch.cat([x1, x2], dim=1) out = self.bn(out) return out

dim 是输入通道数,n_div 决定把通道分成几份。默认 n_div=4,就是只卷积 1/4 的通道,其余 3/4 原样绕过,参数和计算量都降下来了。这里 groups 等于 split_chs,是做 channel-wise 独立卷积;如果想换回普通卷积,把 groups=1 就行,但参数量会变成 split_chs 的平方倍。BatchNorm 我放在拼接之后,对整个输出做归一化,而不是只对卷积部分做,训练会更稳定。

实际使用中,n_div 不是越大越好。数据量很少时,把 n_div 调小一些,比如 2,让更多通道参与卷积,模型表达能力会更强;数据量够大、更追求速度时,n_div=4 是常见默认值。

2.3 把 PConv 拼成 FasterNet Block

拿到 PConv 之后,还需要两个 1x1 卷积把它变成完整的 block。PConv 只处理了部分通道,跨通道的信息交换必须靠 pointwise 卷积补回来。下面是我在分类项目里一直用的 FasterNetBlock:

class FasterNetBlock(nn.Module): def __init__(self, dim, expand_ratio=4, n_div=4): super().__init__() self.pconv = PartialConv(dim, n_div=n_div) self.pw1 = nn.Conv2d(dim, dim * expand_ratio, 1, bias=False) self.pw2 = nn.Conv2d(dim * expand_ratio, dim, 1, bias=False) 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 + identity

expand_ratio 控制中间通道扩展倍数,默认 4,中间先把通道扩到 4 倍再压回 dim,类似轻量 bottleneck。激活函数用 GELU,FasterNet 官方也是这么做的;如果推理延迟特别敏感,换成 ReLU 也能跑,精度会有一点变化。残差连接 identity 必须保留,去掉之后小数据集上很容易出现 loss 不降。

真正组成一个 FasterNet,需要把多个 block 串成 stage。我的做法是参考官方整体结构:前面一个 stem 做 stride 2 下采样,后面接四个 stage,每个 stage 由若干个 FasterNetBlock 组成,通道数从 96 逐步扩到 128、192、384,block 数量直接用官方仓库里对应型号的配置,不自己瞎调。要注意的是:每个 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", encoding="utf-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, transform=None): 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_accum=1): 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 / total

criterion 我用nn.CrossEntropyLoss();优化器分两种情况:从头训练用 SGD(lr=0.1 * batch_size / 256, momentum=0.9, weight_decay=1e-4),微调用 AdamW(lr=1e-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(dim=1) 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, dim=1) topk = torch.topk(probs, 3, dim=1) 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(f"FasterNet 参数量: {count_parameters(model) / 1e6:.2f} M")

装 torchinfo 后更建议用 summary,可以看到每层输出形状和乘加量:

from torchinfo import summary summary(model, input_size=(1, 3, 224, 224), device="cpu")

这一步的价值在于,换一个 backbone 之前就能看到计算量差异,不用等训练完才发现模型太大。我通常把参数量、FLOPs 和最终 acc 一起记在实验表里,选型时比单看 acc 靠谱很多。

5.2 顺手验证一下吞吐:FPS 才是硬指标

FLOPs 低的网络不一定端上跑得快。所以我习惯在项目结束前强制跑一遍单张推理吞吐测试:

import time def benchmark_fps(model, device, input_size=(1, 3, 224, 224), repeat=50): 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(f"FPS: {benchmark_fps(model, device):.1f}")

测试时固定 batch size 为 1,先 warmup 再用torch.cuda.synchronize()做时间对齐。CPU 上同步没那么严格,但最好跑三遍取中位数。我在一个二分类项目上对比过 FasterNet 和 MobileViT,MobileViT 的 FLOPs 更低,但 CPU 延迟反而更高,原因就在内存访问。从那以后我每次训完 FasterNet,都会先跑一遍 FPS 自检,再谈要不要换 Backbone。这个习惯虽然简单,但能帮你躲掉不少“理论快、实际慢”的部署坑,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询