
写这个系列以来被问得最多的一个问题不是“张量怎么算”而是“我怎么从一个空白文件建出一个能跑的神经网络”。这一篇就从 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×932 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_dim1)实现意思是把每个样本的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_size3, stride1, padding0) self.pool nn.MaxPool2d(2, 2) self.conv2 nn.Conv2d(4, 8, kernel_size3, stride1, padding0) 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_dim1) 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 conv1pool:, x.shape) x self.pool(F.relu(self.conv2(x))) print(after conv2pool:, x.shape) x torch.flatten(x, start_dim1) 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)/1126(1, 4, 26, 26)pool26/213(1, 4, 13, 13)conv2(13-3)/1111(1, 8, 11, 11)pool11/25整除(1, 8, 5, 5)flatten8×5×5200(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(), lr1e-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(fEpoch {epoch1}, 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 维。遇到这种报错别慌按顺序排查用上一节的方法在forward里逐行打印x.shape定位到底哪一层输出形状不对。核对卷积和池化的计算公式尤其是padding和stride有没有改变。检查flatten之后的总维度和nn.Linear第一个参数是否一致。记住维度计算影响的是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 曲线判断模型下一步该怎么调整。