第一次接触神经网络的时候,我的第一反应其实是抵触的:数学公式还没完全吃透,又要面对一堆抽象概念,什么反向传播、梯度下降、激活函数——光听名字就头大。真正让我把这件事当成日常技能去掌握的,正是从用 PyTorch 搭建第一个神经网络开始的。
不是故作谦虚。PyTorch 这套框架最友好的地方,在于它把“定义网络”“算损失”“更新参数”这几件事拆得非常清楚,你不需要先成为数学家,也能一点一点把模型跑起来。而且它的调试方式非常直接,能够像看普通 Python 代码一样一行行观察输出。无论你之前是写脚本、做数据分析,还是刚接触机器学习,这篇文章要做的就是带你亲手做一次完整的神经网络训练流程。从安装环境、准备数据,到搭建层结构、训练模型,再到排查训练过程中最常见的坑,全部覆盖。
1. 这个项目到底要做什么,我为什么推荐从 PyTorch 入手
1.1 核心目标与整体拆解
先说清楚技术目标:我们要构建一个能够完成图片分类的神经网络,训练数据是手写数字图片。当你把一张 28×28 像素的灰度图片喂给网络,它能告诉你这张图片属于 0 到 9 中的哪一个数字。这是计算机视觉里很经典的“Hello World”,网上大多数入门项目也都在做同一件事。
但我的目标不是让你复制粘贴代码跑个 90% 的准确率就完事。我更想带着你把这个项目拆开,理解它内部的四个环节:
- 数据准备:把原始图片转换成网络能处理的数据张量,做好标准化、分批
- 网络结构设计:决定有多少层、每层多少神经元、用什么激活函数
- 训练循环:把数据喂进去,算损失,反向传播,更新权重,反复迭代
- 评估与推理:用没见过的数据验证模型效果,导出可复用的模型文件
这四个环节不是相互割裂的,它们构成了一个完整的闭环。很多人第一次写神经网络,代码能跑通但完全不知道改哪里,就是因为只盯着训练循环里那几行代码,没有把整个流程串起来。所以我建议初学者跟着我这个思路走一遍,别急着调参。
1.2 为什么是 PyTorch,而不是 TensorFlow 或者纯 NumPy
经常有人问我:现在框架这么多,为什么先推荐 PyTorch?
第一个原因是它的“动态计算图”机制。PyTorch 是在你实际运行代码的时候,一步步动态构建计算图的。这意味着你可以用最普通的 Python 调试方式去观察中间结果,也可以在训练过程中根据条件修改网络结构。相比之下,某些静态图框架会让你先定义完整计算图再执行,上手成本高不少。
第二个原因是生态和资料。无论是学术界最新论文的官方实现,还是工业界的部署方案,PyTorch 的覆盖面都非常广。你遇到的大多数问题,基本都能在社区里找到答案,最新的模型和技术也往往优先支持 PyTorch。
第三个原因,也是我在文章里反复强调的——它能帮你把概念落地。神经网络最基本的东西无非是线性变换、激活函数、损失函数,而这些在 PyTorch 里都有直观对应。比如一个全连接层就是 nn.Linear(in_features, out_features),一个 ReLU 就是 nn.ReLU(),你把它们拼在一起,网络结构可能就实现了。这种可视化程度,对建立心智模型太重要了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建与项目准备
2.1 PyTorch 安装:从虚拟环境到验证可用
我建议所有初学者一开始就使用 Anaconda 这类虚拟环境管理工具,不要直接往系统 Python 里装任何机器学习库。具体原因后面再说,先看安装命令。
如果你使用的是带 NVIDIA 显卡的设备,并且已经安装了合适的驱动,可以按照 支持 CUDA 的版本安装:
bash复制conda create -n pytorch-beginner python=3.10
conda activate pytorch-beginner
conda install pytorch torchvision torchaudio pytorch-cuda=11.8 -c pytorch -c nvidia
如果你没有独立显卡,或者暂时先不折腾 GPU 加速,装 CPU 版本就够了:
bash复制conda install pytorch torchvision torchaudio cpuonly -c pytorch
安装完成之后,先不要急着写代码,花半分钟验证一下环境:
bash复制python -c "import torch; print(torch.__version__); print(torch.cuda.is_available())"
如果输出 True,说明 CUDA 可用,后续训练可以用 GPU 加速;如果输出 False,也没关系,CPU 版足够应付我们这个小项目。这里强调一点:不要纠结于某个固定的 CUDA 版本号,请根据你本地的驱动版本和安装源支持情况选择。
2.2 数据集的获取与数据预处理
数据集我们直接用 torchvision 自带的 MNIST 手写数字集。第一次运行时会自动下载,所以确保网络通畅。
python复制from torchvision import datasets, transforms
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)
print(f"训练集大小: {len(train_dataset)}")
print(f"测试集大小: {len(test_dataset)}")
这里有两个容易被忽略的细节。第一个是 ToTensor(),它会把原始图片从 0 到 255 的整型像素值转换成 0 到 1 的浮点数,同时把通道维度调整到最前面。在 PyTorch 里,图像张量的形状约定是 (通道数, 高, 宽),和很多图像处理库不一样,这点务必记牢。第二个是 Normalize,本质上就是做标准化,让每个像素值减去均值再除以标准差,让数值范围分布在一个合理区间,网络训练会更稳定。
为什么一定要标准化?我用一个直观的例子说明:如果输入数值都在 0 到 255 这种大范围里,某些权重更新幅度会特别大,导致梯度不稳定。标准化之后,数据分布集中在 0 附近,训练收敛会快很多。
2.3 项目目录约定与文件组织
虽然我们只写了一个 Python 文件,但从一开始养成良好的目录习惯,后面做复杂项目时会省很多事。
code复制mnist-pytorch/
├── data/ # 数据集存放目录
├── models/ # 保存训练后的模型文件
├── train.py # 训练脚本
└── README.md # 项目说明
我在实际项目中一般会多建一个 config.py 专门放超参数,比如学习率、批次大小、训练轮数。如果你只在一个文件里改,后面想对比不同参数的效果可能要来回改代码,容易出错。初学者暂时可以不用拆那么细,但至少要有“数据”“代码”“模型结果”分开的意识。
3. 网络设计:从理论到 PyTorch 代码
3.1 从最朴素的前馈神经网络说起
很多人把神经网络想得很玄乎,剥开来看,它的核心结构就是“一堆线性变换 + 激活函数”的组合。对于 MNIST 这种 28×28 的灰度图片,我们完全可以把它拉成一个长度为 784 的一维向量,然后送入全连接层。
PyTorch 里定义一个网络模块非常灵活,既可以用 nn.Sequential 快速堆叠,也可以自定义一个类。我强烈推荐用自定义类的方式来写,因为训练到后面,你一定会需要在 forward 里加入一些自定义逻辑,比如打印中间特征、加入跳连接等。
python复制import torch
import torch.nn as nn
import torch.nn.functional as F
class FirstNet(nn.Module):
def __init__(self, input_size=784, num_classes=10):
super(FirstNet, self).__init__()
self.fc1 = nn.Linear(input_size, 256)
self.fc2 = nn.Linear(256, 128)
self.fc3 = nn.Linear(128, num_classes)
def forward(self, x):
x = x.view(x.size(0), -1) # 将 (batch, 1, 28, 28) 展平成 (batch, 784)
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = self.fc3(x)
return x
这段代码有三个地方值得展开讲。
第一是 nn.Module 这个基类。PyTorch 里所有网络结构都继承它,它接管了参数管理、设备迁移等一堆功能。你以为你只写了三行 nn.Linear,实际上 model.parameters() 已经能返回所有需要训练的权重了,这是框架帮你省下的重要工作。
第二是 forward 方法。你平时不会显式调用 model.forward(x),而是直接写 model(x),PyTorch 会在背后完成前向传播,这也就是标题里常常出现的“forward”概念。
第三是 x.view(x.size(0), -1) 这行展平操作。因为卷积层或者原始输入通常是个高维张量,而全连接层希望接收到二维的 (批量大小, 特征数),所以必须先做一次“拉伸”。这个操作只改变形状,不改变数据的物理意义。
3.2 激活函数到底在干什么
很多人对 ReLU 不理解:为什么要加一个 max(0, x) 这么简单的函数?
核心原因有三个:引入非线性、缓解梯度消失、计算简单。线性变换做一百次还是线性变换,如果不加激活函数,加深网络没有任何意义。ReLU 在正区间的梯度恒为 1,可以有效缓解深层网络训练时梯度消失的问题,而且它只需要做一个比较和取最大值,速度非常快。
当然,ReLU 也不是完美的,如果神经元被“撞死”在负半轴上,它的梯度始终为 0,参数就再也不更新了。所以后来又有 LeakyReLU、ELU、GELU 等改进方案。但对初学者来说,ReLU 是一个稳定且容易调试的起点。
3.3 再说说 CNN、RNN 这些热词
我做这个项目的时候,经常被人问:你用的是卷积神经网络吗?你那个循环神经网络是不是更适合做序列任务?这里我先给个整体的定位,免得新手被热词吓住。
- 前馈神经网络(FNN):本项目的默认选择,信息单向流动,适合普通结构化数据和简单图像
- 卷积神经网络(CNN):通过卷积核提取局部特征,适合图像、视频等具有空间结构的数据
- 循环神经网络(RNN)及其变体(LSTM、GRU):带有时间维度上的循环连接,适合文本、语音等序列数据
- 图神经网络(GNN):处理图结构数据,比如社交网络、分子结构
这个项目选择前馈神经网络,不是因为 CNN 不好,而是因为 MNIST 图片很小,用全连接网络也能取得不错的结果,而且它更容易帮助初学者理解“参数怎么流动”这件事。如果一上来就上卷积、池化、空洞卷积这些概念,信息过载反而会影响学习效率。
4. 训练流程:让损失下降的核心逻辑
4.1 损失函数和优化器的选择
网络输出的是一个长度为 10 的向量,每个位置对应一个数字类别的“得分”。我们需要一个函数来计算预测和真实标签差多少,这就是损失函数。多分类最常用的损失函数是交叉熵,PyTorch 里可以直接使用 nn.CrossEntropyLoss()。
注意一个很容易踩的坑:nn.CrossEntropyLoss 内部已经包含了 Softmax 操作,所以网络最后一层不要额外加 Softmax。很多人一开始觉得,先 Softmax 再算损失更符合理论推导,于是情不自禁在 forward 里加了 F.softmax,结果训练效果反而异常,因为实际上相当于对梯度进行了错误放大。
优化器选择上,我推荐直接用 Adam。虽然理论书上讲得最多的是随机梯度下降 SGD,Adam 在大多数场景下收敛更快、对初始学习率不敏感,更适合入门。这里给出一个完整定义:
python复制model = FirstNet()
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
学习率设成 0.001 是因为这是 Adam 的默认偏好。过大会震荡,过小会收敛很慢。
4.2 训练循环代码拆解
训练循环是整个项目最核心的部分。很多人一开始分不清一个 epoch 和一个 batch。我这么说:如果训练集有 60000 张图片,一个 epoch 就是完整地扫一遍全部 60000 张;bitch size 设成 64,那就意味着每 64 张图片为一个批次,一个 epoch 里大约有 938 个批次。
完整训练代码如下:
python复制def train_one_epoch(model, dataloader, criterion, optimizer, device):
model.train()
total_loss = 0
correct = 0
total = 0
for images, labels in dataloader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item()
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
avg_loss = total_loss / len(dataloader)
accuracy = correct / total
return avg_loss, accuracy
我需要强调两个高频错误。
第一个是 optimizer.zero_grad() 必不可少。PyTorch 的梯度是默认累积的,如果不手动清零,下一轮的反向传播会把上一轮的梯度加在一起,导致参数更新偏离方向,损失看起来像癫痫一样上下跳动。
第二个是注意 model.train() 这个模式切换。虽然对于纯全连接网络,train 和 eval 模式没有本质区别,但养成写 model.train() 和 model.eval() 的习惯,后面用到 Dropout、BatchNorm 时就不会因为模式没切换导致测试结果诡异。
4.3 批次大小 batch size 的讲究
批次大小是影响训练效果的重要超参数。我见过不少新手喜欢设 batch size=1,以为梯度更新最频繁,收敛应该最快。实际上这种做法极其不稳定,会让损失曲线剧烈波动,而且无法利用 GPU 的并行计算优势。反过来,batch size 设得过大,比如 1024,可能会让模型陷入尖锐极小值,泛化能力变差。
对于 MNIST 这种小数据集,64 或 128 是很好的起点。选择的标准是:既要保证每个批次里面的样本分布大致接近整体数据分布,又不能超过显存容纳上限。单纯提高 batch size 并不会线性提高训练速度,到后面可能更多是在浪费显存。
5. 完整代码与运行效果
5.1 训练主脚本
结合上面所有环节,我们拼装出完整可运行的 train.py。这里面包含了数据加载、网络定义、训练循环、测试评估、模型保存。
python复制import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)
class FirstNet(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(784, 256)
self.fc2 = nn.Linear(256, 128)
self.fc3 = nn.Linear(128, 10)
def forward(self, x):
x = x.view(x.size(0), -1)
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
return self.fc3(x)
model = FirstNet().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
num_epochs = 5
for epoch in range(1, num_epochs + 1):
model.train()
running_loss = 0.0
correct = 0
total = 0
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
train_loss = running_loss / len(train_loader)
train_acc = correct / total
model.eval()
test_correct = 0
test_total = 0
with torch.no_grad():
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
_, predicted = torch.max(outputs.data, 1)
test_total += labels.size(0)
test_correct += (predicted == labels).sum().item()
test_acc = test_correct / test_total
print(f"Epoch {epoch:2d} | 训练损失: {train_loss:.4f} | 训练准确率: {train_acc:.4f} | 测试准确率: {test_acc:.4f}")
torch.save(model.state_dict(), "models/mnist_first_net.pth")
print("模型已保存到 models/mnist_first_net.pth")
你可能会注意到在测试阶段多了 with torch.no_grad(): 这个上下文管理器。它的作用非常明确:告诉 PyTorch 这个代码块里面所有操作都不要构建计算图、不要记录梯度。因为推理阶段不需要反向传播,关闭梯度记录能显著减少内存占用,也能加速计算。这也是新手最容易忽视的地方,很多人写测试代码时把测试和训练混在一起,导致显存莫名其妙升高。
5.2 训练输出分析
在我本机上,CPU 环境大概训练 5 个 epoch,输出类似:
code复制使用设备: cpu
Epoch 1 | 训练损失: 0.3102 | 训练准确率: 0.9068 | 测试准确率: 0.9288
Epoch 2 | 训练损失: 0.1483 | 训练准确率: 0.9563 | 测试准确率: 0.9613
Epoch 3 | 训练损失: 0.1040 | 训练准确率: 0.9693 | 测试准确率: 0.9702
Epoch 4 | 训练损失: 0.0791 | 训练准确率: 0.9764 | 测试准确率: 0.9749
Epoch 5 | 训练损失: 0.0627 | 训练准确率: 0.9813 | 测试准确率: 0.9773
从这里的趋势,你可以观察到两件事:
第一是训练损失稳步下降。每一步反向传播都在有效更新参数,说明梯度流动正常,没有出现梯度爆炸或者梯度消失。
第二是训练准确率和测试准确率接近。说明模型没有严重过拟合,泛化能力正常。如果测试准确率远低于训练准确率,就要考虑是不是模型太复杂、训练数据太少、或者需要加入 Dropout 等正则化手段。
97% 左右的手写数字准确率听起来不错,但离当前最优水平还有很大差距。如果后面想进一步提高,可以把全连接网络换成卷积神经网络,加入更多数据增强,或者用更专业的结构。这个项目本身的意义不在于刷分,而在于让你亲手走通一次完整的模型训练生命周期。
5.3 模型保存与加载
模型训练完成后,我建议同时保存 state_dict 和完整模型结构。严格来说,state_dict 保存的只是参数字典,不包含网络结构定义。所以加载时你需要先实例化一个同样的网络,然后再加载参数。
python复制# 保存
torch.save(model.state_dict(), "models/mnist_first_net.pth")
# 加载
model = FirstNet()
model.load_state_dict(torch.load("models/mnist_first_net.pth"))
model.eval()
加载后先调用 model.eval() 是许多反复训练模型的人容易忘记的一步。不切回 eval 模式的话,如果网络包含 Dropout 或 BatchNorm,推理结果会不一致。在模型服务上线或做批量预测之前,这行代码往往决定了你的结果稳不稳定。
6. 常见问题排查与调优笔记
6.1 环境安装类的坑
网上关于 PyTorch 安装的教程多到爆炸,但很多人照着装依然出现 GPU 版本不可用。归纳起来,高频问题基本就这几种。
问题一:装了 GPU 版,torch.cuda.is_available() 还是 False
处理思路是:先去命令行运行 nvidia-smi 查看本机驱动支持的 CUDA 版本,然后确认 PyTorch 版本对应安装源是否匹配。现在的 PyTorch 发布页一般都附带对应的 CUDA 支持矩阵,不要凭猜和旧经验去装,官方文档为准。
问题二:明明装好了,import torch 却报 OSError
常见于 Linux 系统库文件缺失,或者同一个环境里有多个 Python 版本混用。优先查看报错是不是 libcudnn 或 libnccl 相关,按提示安装对应依赖。如果只是在 Windows 上遇到动态链接库错误,往往需要安装 Visual C++ 运行库。
问题三:conda 安装速度太慢或者下载中断
建议切换到国内镜像源,但注意要选择同步频率高的镜像。安装大文件时用 conda 会比 pip 更可靠,因为会做依赖解析,避免一些底层库版本冲突。
6.2 训练过程中的诡异现象
现象一:损失一开始不降反升,然后突然下降
先不要急着调大学习率。可能是数据没有做标准化,输入数值范围太大导致梯度不稳定。检查数据预处理部分,确认 Normalize 是否生效。
现象二:损失卡在常数,几乎不变化
如果使用 CrossEntropyLoss,损失稳定在 2.3026 附近(10 分类约等于 log10),说明网络输出接近均匀分布,完全没有学到东西。这时考虑是不是学习率设得太低、网络初始化有问题,或者梯度根本没有回传。
现象三:训练准确率 100%,测试准确率很低
这是明显的过拟合。解决方案有:增加训练数据多样性、使用数据增强、加入 Dropout 层、减小模型容量。对 MNIST 这个任务来说,全连接网络过拟合的情况还不算严重,但在真实项目中十分常见。
6.3 性能调优实践经验
如果你发现训练速度很慢,先不要盲目上大显卡。可以按顺序排查这几个方面:
- 确认
DataLoader是否设置了num_workers,我一般设置为 2 到 4,但 Windows 下设置过高有时会遇到进程启动问题 - 确认训练循环里是不是反复把张量从 GPU 搬回 CPU。一次一次调用
.item()没有问题,但大量这种操作会拖慢速度 - 确认数据加载有没有成为瓶颈。如果 GPU 利用率很低而 CPU 跑满,问题多半在数据读取这里
- 训练代码里尽量避免在循环内部打印太多日志,IO 开销会让你误以为代码很慢
上面的优化思路,看起来都是琐碎的小细节,但实际运行起来很可能成为瓶颈。我自己的经验是:先保证代码逻辑正确,再去做性能优化。不要在训练还收敛不了的时候去折腾 pin_memory=True 这些参数,方向就错了。
7. 写在最后:一点个人体会
这个项目我做了很多次,每次带朋友或组里新人入门,我都会让他们先把整个流程亲手跑通,再考虑看更多花哨的模型。自己动手复现一遍训练过程,收获了那个梯度不断下降的趋势,比读十篇理论文章都管用。
在把玩过 PyTorch 生态之后,我还想分享的两个后续方向。
第一个方向是把全连接网络升级成简单的卷积神经网络。你可以试试在 FirstNet 前面加两个卷积层和池化层,把特征提取交给卷积操作,再接入全连接分类器。这种改动很直观,能让你体会到不同网络结构之间差异的真实意义。第二方向是把 MNIST 换成更有挑战性的数据集,比如 CIFAR-10 或你在工作中的真实数据。很多时候,你会在换数据的瞬间遇到前一个新项目从未遇到过的问题——数据不平衡、类别混淆、标注错误、样本分布漂移。这些问题,才是真实世界里做深度学习真正需要长期应对的东西。
这个项目不是终点,只是一个足够结实的地基。先把地基走稳,再走出去。
