news 2026/10/19 5:09:19

PyTorch从零搭建卷积神经网络:nn.Module、前向传播与训练全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch从零搭建卷积神经网络:nn.Module、前向传播与训练全流程

写这个系列以来,被问得最多的一个问题不是“张量怎么算”,而是“我怎么从一个空白文件,建出一个能跑的神经网络”。这一篇就从 PyTorch 里最基础的nn.Module说起,完整拆解建立神经网络的全过程,从设计网络结构、把每一层正确连接起来,再到训练循环怎么写。这篇文章适合第一次用 PyTorch 搭模型的人,也适合那些已经看过不少教程、却总觉得代码像是“背默”出来的同学。我会把每一层为什么这样设计、每个参数的来历、以及我自己实际调试时踩过的坑一并写出来。

1. 动手之前,先把设计思路理清楚

1.1 为什么 PyTorch 要把网络写成“类”

很多人第一次接触 PyTorch 时都会有一个疑惑:为什么要继承nn.Module,而不是像写张量运算那样直接写一个函数?其实答案很朴素——神经网络不是一个“一次性计算”,它里面有大量需要被反复更新和保存的参数。如果你用普通函数写,每一次前向传播后,权重去哪里了?梯度更新的时候又去哪里找这些权重?

nn.Module这个类相当于给你充当了一个“项目公司”。你在这个类里定义的每一个nn.Linear、nn.Conv2d,都会被自动注册成公司账上的“资产”。model.parameters()能一口气把所有资产拿出来交给优化器去更新。你不需要手动维护一个dict去保存权重,更不需要在 GPU 上换来换去时手动搬运每一份参数。自动求导、设备迁移、训练/测试模式切换这些琐事,nn.Module都替你打理好了。

所以,用类定义网络不是 PyTorch 在装模作样,而是为了让你把注意力放在“网络结构长什么样”上,而不是放在手工管理参数这种容易出错的活上。

1.2 从任务倒推网络结构

建立神经网络之前,先想清楚你的输入是什么、输出是什么。以图像分类为例,输入是一张图,形状通常是(通道数, 高, 宽);输出是这张图属于每一个类别的打分,形状是(类别数,)。

中间需要什么?需要两条流水线:

  • 特征提取:把图像里的边缘、纹理、局部模式逐步提炼成更高层的语义信息。
  • 分类决策:把提好的特征“拍扁”,映射到具体的类别打分上。

为什么图像任务优先用卷积而不是全连接?因为一张 28×28 的灰度图直接拉成向量是 784 维,如果第一层就用全连接,输入 784 维、输出 512 维,光这一层就是 40 万个参数。更麻烦的是,图像里的一个物体不管出现在左上角还是右下角,它都应该是同一个东西,全连接层没有这种“平移不变性”。卷积层靠参数共享解决了这个问题:一个 3×3 的卷积核在整张图上滑动,同样的权重到处复用,既大幅减少了参数量,又天然具备了局部感知能力。

1.3 环境准备与数据基础

在写网络之前,先确保 PyTorch 装好了。如果你只是学习,CPU 也完全能跑通今天这篇的所有示例,只是训练会慢一些;如果有一张支持 CUDA 的显卡,装上对应版本的 PyTorch 后训练会舒服很多。

数据方面,不需要一开始就上大规模数据集,可以先准备一个小规模的图像分类数据,比如手写数字图片,或者自己随便找几十张小图分两个类。训练神经网络的第一个目标永远是“能跑通”,而不是“刷精度”。拿一个小数据集把一个完整流程跑通,后面再换大数据集,你会省下大量排错时间。

2. 核心层解析与参数计算:读懂每一层在干什么

2.1 卷积层:感受野与参数量怎么算

nn.Conv2d是卷积神经网络里最重要的层。它的参数包括in_channels、out_channels、kernel_size、stride、padding。新手常问:这些数字到底应该取多少?有没有一个标准?

先说输出尺寸计算公式。对于输入高 H 和宽 W,卷积后的尺寸是:

H_out = (H - kernel_size + 2 * padding) / stride + 1

输出维度同理。比如输入是 28×28,用 3×3 卷积核,步长 1,不填充,得到的就是 26×26。你会发现尺寸变小了一点,因为卷积核没法完整覆盖边缘外面的区域。如果希望输出尺寸不变,把 padding 设为 1 就行,因为 3×3 核左右上下各补一圈刚好抵消掉尺寸缩减。

再看参数量公式:

参数量 = in_channels * out_channels * kernel_h * kernel_w + out_channels

最后一个+ out_channels是 bias。举个例子:输入 3 通道,输出 32 通道,用 3×3 卷积核,那么参数量是 3×32×9+32 = 896。这个数字很小,远远小于全连接层动辄几十万的参数量。这正是卷积在图像任务里的最大优势。

至于为什么大家那么喜欢用 3×3 而不是 5×5、7×7,是因为两个连续的 3×3 卷积叠加,感受野等效于一个 5×5 卷积,但参数更少,而且中间多了一次非线性激活,表达能力反而更强。这已经是卷积网络设计里的一个近乎默认的经验法则。

2.2 激活函数:为什么非 ReLU 不可

卷积和全连接本质上都是线性变换,也就是一堆乘法和加法。如果网络里只有这些线性变换,那么多层叠加之后仍然等价于一层线性变换,网络再深也没什么用。激活函数的作用就是给网络注入非线性,让它能拟合复杂的决策边界。

ReLU 是现在的绝对主流,公式也简单:f(x) = max(0, x)。正数原样输出,负数直接截断成 0。它为什么好用?第一,计算量小,一次比较就完了;第二,梯度在正区间恒定为 1,不像 sigmoid 那样在两端梯度几乎为 0,能有效缓解深层网络里的梯度消失问题;第三,把一部分神经元输出置零后,网络会变得稀疏,一定程度上还能起到正则化的作用。

你一定见过 sigmoid 函数,它的输出范围是 0 到 1,看起来很优雅,但放在隐藏层里很容易让梯度变得非常小,训练收敛极慢。现在的通用建议是:隐藏层激活函数默认选 ReLU 或它的变体,只在最后一层输出概率时才用 sigmoid 或者 softmax。

2.3 池化层、Flatten 与全连接层

池化层在早期的卷积网络里几乎是标配。它的作用简单说就是“浓缩”:把一个小区域里的信息用一个值来代表。最大池化取区域里最大的值,能保留最显著的特征,而且它没有任何可学习参数。比如 2×2、步长 2 的最大池化,会把 26×26 的特征图缩小到 13×13,信息量减半但关键特征还在。

有人会问,池化到底好不好?近年来的研究发现,如果卷积步长设得够大,不用池化也能做到降采样,很多新网络甚至不再用池化。但作为理解神经网络的起点,池化依然是一个很好用的下采样工具,写起来简单,跑起来稳定。

从卷积特征图到分类器之间,必须经过一个形状转换。卷积输出的形状是(batch, channels, height, width),而全连接层期望的输入是(batch, features),也就是二维矩阵。这个转换在 PyTorch 里用torch.flatten(x, start_dim=1)实现,意思是把每个样本的channels * height * width全部拉直成一个一维向量,batch 维度保持不变。

全连接层的输入维度必须和前一层的输出维度严格匹配。这是新手最常踩的坑:前面某一个卷积或者池化改动了一下,忘记重新计算全连接层的输入维度,一跑就报错说维度对不上。我的建议是动手写的阶段,每一步都打印一次x.shape来核对。

2.4 前向传播 forward 的定义要点

在nn.Module的子类里,forward方法决定了数据在网络里怎么流动。它不需要你手动调用——你只要写model(x),PyTorch 会自动调用forward。但要注意,不要手贱去写model.forward(x)。虽然运行结果一样,但会跳过nn.Module内部的一些预置逻辑,比如某些钩子函数、缓存机制,将来排查问题时会平白多出很多麻烦。

forward内部可以有两种写法。一种是直接用self.conv1(x)一层层往后传;另一种是把几个层封装进nn.Sequential,用类似“流水线”的方式让数据自动依次通过。Sequential的优点是代码简洁清晰,适合那种直上直下的线性结构;缺点是遇到分支、跳跃连接、或者中间有自定义操作时就写不了了。实际项目里通常是混着用:主干用Sequential封装,特殊路径单独写。

3. 实操:完整搭建一个可运行的神经网络

3.1 定义网络类的标准模板

下面给出一个非常典型的小型卷积网络,它不需要刻意追求精度,但结构是完整且能直接跑起来的:

import torch import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 4, kernel_size=3, stride=1, padding=0) self.pool = nn.MaxPool2d(2, 2) self.conv2 = nn.Conv2d(4, 8, kernel_size=3, stride=1, padding=0) self.fc1 = nn.Linear(8 * 5 * 5, 32) self.fc2 = nn.Linear(32, 10) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) x = self.pool(F.relu(self.conv2(x))) x = torch.flatten(x, start_dim=1) x = F.relu(self.fc1(x)) x = self.fc2(x) return x

这个例子假设输入是单通道、28×28 的图像,输出是 10 个类别打分。__init__里只负责“创建”层,不负责“连接”;连接逻辑全部写在forward里。这是一个非常重要的设计习惯:层和连接分离,代码才容易维护。

3.2 维度变化的追踪方法

写网络时最怕出现“不知道自己改完一层后数据变成什么形状了”的盲区。我建议在第一次调试时,在forward里临时加几行打印:

def forward(self, x): print('input:', x.shape) x = self.pool(F.relu(self.conv1(x))) print('after conv1+pool:', x.shape) x = self.pool(F.relu(self.conv2(x))) print('after conv2+pool:', x.shape) x = torch.flatten(x, start_dim=1) print('after flatten:', x.shape) x = F.relu(self.fc1(x)) x = self.fc2(x) return x

拿一张 28×28 的图走一遍,你会看到这样的变化:

位置形状计算结果
输入(1, 1, 28, 28)单样本,1通道
conv1(28-3)/1+1=26(1, 4, 26, 26)
pool26/2=13(1, 4, 13, 13)
conv2(13-3)/1+1=11(1, 8, 11, 11)
pool11/2=5(整除)(1, 8, 5, 5)
flatten8×5×5=200(1, 200)
fc1200→32(1, 32)
fc232→10(1, 10)

所以上面代码里fc1的输入维度是8 * 5 * 5 = 200,就是这么来的。以后不管你怎么改前面的卷积或者池化,只要能算清楚这一步的形状,全连接层的输入维度就不会错。

3.3 损失函数与优化器的搭配选择

网络建好之后,要回答两个问题:怎么判断它错得厉害不厉害?怎么让错误变小?

分类任务的标准损失函数是nn.CrossEntropyLoss()。这里有一个很多人容易误解的点:这个损失函数内部已经包含了 softmax 操作,所以你的网络最后一层不需要再手动加 softmax,直接输出 10 个原始得分就行。如果你在最后一层画蛇添足加了个 softmax,再喂给交叉熵损失,其实也能跑,但数值上会出现不必要的精度损失,而且当你以后做推理时还要小心避免重复计算。所以记住:训练时网络输出 raw logits,损失函数内部做 softmax;只有推理阶段想拿到“概率”时才需要自己调一次softmax。

优化器方面,新手最稳妥的选择是 Adam,学习率设1e-3左右。SGD 虽然泛化能力常常更好,但它对学习率的设置和动量参数更敏感,调起来需要经验。Adam 自带自适应学习率,大部分情况下开箱即用,训练曲线也比较平滑。先用 Adam 把网络跑通,再回头试 SGD,是很多人推荐的路线。

3.4 训练一个最小可运行示例

有了网络、损失函数、优化器,就可以写训练循环了。核心逻辑其实非常固定,只有四步:

def train_one_step(model, optimizer, loss_fn, images, labels): optimizer.zero_grad() outputs = model(images) loss = loss_fn(outputs, labels) loss.backward() optimizer.step() return loss.item()

这里我想重点解释一下optimizer.zero_grad()为什么必须在前面。PyTorch 的梯度是“累加”的:默认情况下,每次backward()之后,梯度会积累在张量的.grad属性里。如果你不清零,下一轮的梯度会加到上一轮上,导致梯度越来越大,训练彻底乱掉。所以一步一清,几乎是铁律。

完整的训练循环就是循环里套批次:

model = SimpleCNN() loss_fn = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) num_epochs = 3 for epoch in range(num_epochs): total_loss = 0.0 for images, labels in train_loader: loss = train_one_step(model, optimizer, loss_fn, images, labels) total_loss += loss avg_loss = total_loss / len(train_loader) print(f'Epoch {epoch+1}, average loss: {avg_loss:.4f}')

model.parameters()会把网络里所有可学习参数一次性交给 Adam。这也是nn.Module最方便的地方:不需要自己到处收集参数列表。

4. 常见问题与调试经验

4.1 维度不匹配与报错速查

维度不匹配几乎是每个人建立神经网络时遇到的第一个大型“社会性死亡”现场。报错信息通常长这样:size mismatch, m1: [32 x 196], m2: [200 x 32]。意思就是上一层的输出是 196 维,但你的全连接层期待输入是 200 维。

遇到这种报错别慌,按顺序排查:

  1. 用上一节的方法,在forward里逐行打印x.shape,定位到底哪一层输出形状不对。
  2. 核对卷积和池化的计算公式,尤其是padding和stride有没有改变。
  3. 检查flatten之后的总维度和nn.Linear第一个参数是否一致。
  4. 记住维度计算影响的是batch之外的那些维度。nn.Linear会自动处理最后一维,它把(batch, in_features)变换到(batch, out_features),前面有几个维度不重要,最后一维匹配就行。

一个小技巧:写网络时不要“裸奔”。先构造一张假数据dummy = torch.randn(1, 1, 28, 28),直接把model(dummy)跑一遍,维度正确与否在这一步就能暴露,比等训练到一半再炸出来强得多。

4.2 设备管理中容易忽略的坑

模型建好了,数据也准备好了,训练一开始就报错:Expected all tensors to be on the same device。这几乎都是因为模型在 GPU 上,而数据还在 CPU 上,或者反过来。解决方案只有一句话:模型和数据都要.to(device)。

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SimpleCNN().to(device) for images, labels in train_loader: images, labels = images.to(device), labels.to(device) ...

这里要注意,优化器要等模型转移到设备之后再创建,也就是先model.to(device),再Adam(model.parameters())。虽然先创建优化器再搬模型通常也能跑,但有些情况下参数的id会变化,还是按顺序来最稳。

另外,对于 Python 原生基础类型(整数、浮点数)不需要转设备,它们没有设备这个概念。只有张量才需要。

4.3 过拟合和训练不收敛的排查思路

loss 一直不降,或者降得很慢的时候,不要急着改网络结构。先按几个方向排查:

  • 学习率是否合适。学习率太大,loss 会乱跳甚至变成 nan;学习率太小,loss 降得像乌龟爬。简单试法:每次乘 10 或除以 10 调整一次,看 loss 曲线的变化趋势。
  • 数据是否真的喂对。检查images和labels的形状和取值。比如图像像素有没有归一化到合理范围,标签从 0 开始还是从 1 开始,这些都会影响收敛。
  • 网络表达能力是否足够强。如果你用的网络太小,也可能学不动。有一个很实用的验证方法:拿一个 batch 的数据反复训练,如果网络能把这一小批数据都背下来,说明模型容量和代码逻辑没大问题;如果连一个小 batch 都过不了拟合,那八成是代码写错了。

loss 变成 nan 也是一个经典问题。常见原因是学习率太大,导致梯度爆炸;或者数据里出现了极端值。可以先调低学习率试试,如果还不行,检查输入数据中是否存在缺失值或无穷大。

4.4 调试技巧总结

调试神经网络这件事,很多老手也不会用什么高级工具,靠的是一些朴素但管用的方法:

  • 固定随机种子。在代码开头加torch.manual_seed(0),保证每次运行结果可复现,这样排查问题才能确定变量到底是哪一个。
  • 先过拟合一个小 batch。这是我最推荐的做法。连续训练几十步,观察 loss 有没有明显下降。这一步能快速把“代码逻辑错误”和“调参问题”分开。
  • 打印中间特征图。把某一层的输出可视化,如果卷积层的输出一片空白或者全是噪声,通常意味着网络没有正确学习。
  • 模型先小后大。搭建阶段先用小通道数、少层数把流程跑通,准确率低没关系,流程正确以后再加宽加深。这样每次改动引入的变量只有一个,出了错也容易定位。

5. 写在后面:我的一点实操体会

建立神经网络的本质,其实不是“写模型类”,而是建立一条清晰的“数据形状流水线”。我见过太多人今天加一层卷积,明天删一个池化,改完就报错,又不知道错在哪里。我自己的习惯是,动手写代码之前,先在纸上把每一层的输入、输出形状、参数量推演一遍,确认无误后再上键盘。这样做表面上看是慢了一点,实际上能帮你省下好几轮的调试时间。

如果你现在正准备用 PyTorch 搭自己的第一个网络,我的建议是:别急着堆很多花哨的模块,哪怕只用一个卷积加一个全连接,只要把前向传播和训练循环完整跑通,你对神经网络的体感就会从“背代码”变成“真正理解”。之后再去尝试调整结构、引入新的层、改进训练策略,会顺利很多。本期内容到这里,下期我会接着聊聊训练循环里那些容易被忽视的细节,以及怎么从 loss 曲线判断模型下一步该怎么调整。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 5:09:06

Git-aware CLI代码审查工具:LLM驱动的函数级精准审查

1. 项目概述:一个真正能落地的开源代码审查 CLI 工具“open-code-review”这个名字乍一听像某个 GitHub 上刚建的空仓库,但如果你最近在团队里被 PR 堆得喘不过气,或者自己写完一段逻辑复杂的函数却不敢直接合入主干,又或者你正用…

作者头像 李华
网站建设 2026/10/19 5:08:54

Docker data-root存储迁移实战:从/var/lib/docker到新数据盘

1. 迁移之前,先搞懂Docker的存储布局1.1 data-root到底存了什么很多人以为Docker的/var/lib/docker目录里只有镜像,其实这个想法太天真了。我自己第一次拆开这个目录的时候,也被里面的结构吓了一跳。默认情况下,Docker的所有核心数…

作者头像 李华
网站建设 2026/10/19 5:08:48

Unity数据持久化全解析:从PlayerPrefs到SQLite的工程实践

做Unity开发的人迟早都要面对同一个问题:游戏关了,玩家数据怎么留下来?不管是音量设置、金币数量、背包物品还是剧情进度,只要进程一结束,内存里的东西就全没了。想让这些数据在下次启动时原样恢复,就得做数…

作者头像 李华
网站建设 2026/10/19 5:08:48

Open-Code-Review:一种可版本化、多语言、LLM增强的代码评审协议

1. “Open-Code-Review”不是新工具,而是一套可落地的协作范式重构 “Open-Code-Review”这个词最近在GitHub Trending、Hacker News和几个主流技术社区的讨论帖里频繁出现,但它既不是某个新开源项目的名字,也不是某家大厂刚发布的SaaS产品。…

作者头像 李华
网站建设 2026/10/19 5:08:46

本地CLI+Git Hook+LLM:构建可审计的代码审查新工作流

1. 项目概述:这不是一个“工具”,而是一套可落地的代码审查新工作流 open-code-review 这个名字乍看像某个开源项目仓库名,但结合当前搜索热词里反复出现的 codex cli、zcode cli、trae cli、llm、git、agent、embedding 等关键词&#xff0…

作者头像 李华
网站建设 2026/10/19 5:08:37

1Panel可视化部署Open WebUI:接入Ollama与OpenAI兼容API实战

最近大模型本地化部署非常热,但很多朋友卡在第一步:Open WebUI 这个漂亮的聊天天花板界面,到底怎么才能跑起来?网上教程大多让你敲一堆 docker 命令,小白看着就头皮发麻。我自己折腾过几台服务器,踩过不少坑…

作者头像 李华