先给结论:这篇文章不是教你抄一段代码跑完就完事,而是带着你把"为什么要这么写""每一行到底在干什么""报错了怎么查"这些问题全部过一遍。如果你已经在网上看过不少PyTorch教程,但始终觉得代码能跑、心里没底,那么这篇就是写给你看的。
我用的是一个最经典、又足够说明问题的例子:MNIST手写数字识别。它只有28x28的灰度图、10个类别,用一张显卡训练几分钟就能看到效果,特别适合作为第一个亲手搭起来的神经网络。你不需要有深度学习基础,但要懂一点Python,了解for循环、函数和类就够用了。
1. 起步前的三个决定:框架、环境和你最终想做的事
1.1 为什么选择PyTorch而不是TensorFlow
不少新手问过我:第一个神经网络用PyTorch还是TensorFlow?我的建议很直接:如果你不是被公司或学校项目强制要求用TensorFlow,那就选PyTorch。
原因有三个:
- 调试体验好。PyTorch是动态计算图,代码跑到哪一步,变量就是什么值,可以随时
print出来看,就像写普通Python一样。TensorFlow 2.x虽然默认开启了Eager模式,但很多历史资料和教程还带着1.x的思维,容易把人绕晕。 - 生态和论文同步。近几年的深度学习论文,开源代码绝大多数都是PyTorch的。你以后想在已有模型上做修改,直接读PyTorch代码会轻松很多。从2024年的实际趋势看,PyTorch在工业界和学术界的占有率都在持续上升,现在学会它,收益期很长。
- 和
numpy的亲和度。PyTorch的Tensor数据类型和numpy的ndarray几乎一一对应,会操作数组就会操作大部分张量。
1.2 环境搭建:CPU版还是GPU版,Anaconda怎么配
很多人一上来就卡在装环境这一步。其实先想清楚一件事:你只是跑通第一个神经网络,CPU完全够用。MNIST单次训练,用CPU跑5个epoch大概几分钟,没必要为了"提高效率"折腾显卡,然后把时间都花在驱动和CUDA的报错上。
当然,如果你的电脑有NVIDIA显卡,并且以后打算认真搞深度学习,那一步到位装GPU版会更省心。我建议的完整环境方案如下:
| 场景 | 推荐方案 |
|---|---|
| 没有NVIDIA显卡 | CPU版PyTorch + Anaconda |
| 有NVIDIA显卡,仅跑测试 | CPU版PyTorch 先行,后续再换GPU版 |
| 有NVIDIA显卡,长期学习 | GPU版 + CUDA 11.8 或 12.1 驱动版本 |
用Anaconda创建环境是最稳的。为什么要用Anaconda而不是直接pip?因为它可以给你隔离出一个独立的Python环境,你把PyTorch相关的包装进去,装坏了直接删掉这个环境重来,不会影响你机器上其他项目的Python版本。
具体安装步骤:
- 安装Anaconda,安装完打开Anaconda Prompt(Windows)或终端(macOS/Linux)。
- 创建并激活环境:
conda create -n pytorch_env python=3.10 conda activate pytorch_env- 安装PyTorch。去PyTorch官网(pytorch.org)首页,选择你的系统、安装方式、CUDA版本,网站会生成对应的命令。比如Linux + pip + CUDA 12.1:
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121如果是CPU版(没有NVIDIA显卡),直接:
pip install torch torchvision torchaudio- 验证安装是否成功:
import torch print(torch.__version__) print(torch.cuda.is_available())如果输出True,说明GPU版本正常工作;如果是CPU版,torch.cuda.is_available()会输出False,这属于正常现象。
1.3 下载慢的解决办法,实测有效
这里我必须专门提一下,因为真的太多人卡住了:PyTorch安装包非常大,GPU版动辄2GB以上,直接从官网下载慢到怀疑人生,尤其是手机热点环境,蜗牛速度不是假的。
我的办法如下:
- 换国内镜像源。清华源、阿里源都行:
pip install torch torchvision torchaudio -i https://pypi.tuna.tsinghua.edu.cn/simple- 下载到本地再安装。如果镜像源依然不稳定,可以在浏览器里用下载工具(比如IDM、迅雷)先把
.whl文件下载到本地,然后再:
pip install C:/path/to/torch-xxx.whl这个方式在我自己机器上测试过,下载速度能快非常多,不推荐用手机热点硬扛。
- 用conda安装替代pip。conda在某些情况下会比pip快,但不能保证版本最新:
conda install pytorch torchvision torchaudio cpuonly -c pytorch另外,有显卡且是Windows系统的读者,要注意NVIDIA驱动版本和CUDA版本的匹配。装完驱动后,在终端运行nvidia-smi,看到右上角的CUDA Version,比如12.4,那么选择12.1或11.8的PyTorch都可以,因为PyTorch自带CUDA运行时,不需要你先单独装全套CUDA。
2. 神经网络的底层游戏规则:张量、自动求导和训练循环
2.1 张量(Tensor)和NumPy到底有什么区别
很多教程第一课讲张量,上来就一句"张量是多维数组",然后就没下文了。但你需要了解它对神经网络真正的意义。
你可以把张量理解为"带加速引擎的数组"。它在CPU上做运算,和numpy数组差别不大;但一旦放到GPU上(通过.cuda()或.to("cuda")),运算速度会比CPU快几十倍甚至上百倍。更关键的是,PyTorch的张量自带梯度记录功能。
你训练神经网络的本质,就是不断调整一大堆参数(也就是权重和偏置,weights和biases),让模型的预测结果越来越准确。而"调整参数"依赖每个参数对最终损失的影响程度,这个影响程度就是梯度。如果你用纯NumPy手写,你需要自己推导每个参数的梯度公式,再手动更新;用PyTorch,你只需要在计算过程中设置requires_grad=True,框架会自动记录整个计算图,然后一行loss.backward()就可以自动求出所有参数的梯度。
这就是PyTorch最核心的价值:自动求导。
2.2 神经网络里bias是什么,为什么必不可少
新手刚接触nn.Linear(784, 128)时,会看到这个线性层内部有weight和bias两个参数。权重好理解,就是每个输入特征的重要性;那bias(偏置)呢?
我打个比方。一个线性模型就像一条直线:y = wx + b。w是斜率,决定这条直线陡不陡;b是截距,决定这条直线在坐标系里上下平移的位置。如果没有b,所有直线都必须经过原点(0,0),模型的表达能力就被限制了很多。对于神经网络里的每一层,bias同理,它让每一个神经元在输入为0时也可以有一个非零的激活值,从而更灵活地拟合数据。
也就是说,bias是每个神经元自带的"基础反应倾向"。一个神经元收到一堆加权求和的结果后,先加上自己的偏置,再经过激活函数,才决定要不要"兴奋"(输出一个较大的值)。动手实验时,你甚至可以故意把bias设为False试试,看看模型表现变差多少——这是理解bias作用最直观的方法。
2.3 前馈神经网络的结构
我们这篇要搭建的网络是一个前馈神经网络(Feedforward Neural Network),也叫多层感知机(MLP,但注意这个术语严格来说有隐含层必须超过一层的约定,我们这里通常指包含至少一个隐藏层的全连接网络)。
它的结构很简单:
- 输入层:接收原始数据。对于一张28x28的MNIST图片,就是784个像素值。
- 隐藏层:中间的计算层。每个神经元接收上一层所有神经元的输出,乘以权重、加上偏置、再经过激活函数,然后把结果传给下一层。我们这里用一层或两层隐藏层就够。
- 输出层:最后的预测结果。我们的任务是10个数字(0-9),所以输出层有10个神经元,每个神经元的值表示"输入图片是数字k的概率"(经过softmax归一化之后)。
层与层之间是全连接的,也就是说上一层的任意一个神经元,都连接着下一层的任意一个神经元,所以叫"全连接网络"。这也是最经典、最基础的神经网络结构,你把它的训练过程弄明白了,后面学CNN、RNN都会轻松很多。
2.4 训练循环里的四个动作
任何一个有监督的神经网络训练,都离不开下面这四个动作的循环:
- 前向传播(forward):把一批数据喂给网络,得到预测输出。
- 计算损失(loss):用一个损失函数,比如交叉熵,计算出"预测值和真实值差了多少"。
- 反向传播(backward):调用
loss.backward(),PyTorch自动根据链式法则,从输出层向输入层逐层计算出每个参数的梯度。 - 更新参数(update):优化器拿着这些梯度,按照一定规则更新网络里的所有参数,比如
optimizer.step()。
用生活化的方式理解:你是一个学生,做了一套题(前向传播),对答案发现错了很多(计算损失),于是逐个题目分析自己是哪个知识点不会(反向传播),最后拿着分析结果去重新学习对应知识点(更新参数)。下一轮做题,做得好的地方保留,做错的再修正,反复迭代,成绩就越来越好了。
3. 第一个实战:MNIST手写数字识别,从数据到训练完成
3.1 数据集的加载与预处理
MNIST是深度学习界的"hello world":包含6万张训练图片和1万张测试图片,每张都是28x28的灰度图,标注着对应的数字(0到9)。PyTorch的torchvision库已经帮我们封装好了下载和读取,不用自己去找数据集。
先写数据加载部分:
import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader # 定义数据预处理 transform = transforms.Compose([ transforms.ToTensor(), # 将PIL图片转为Tensor,并自动归一化到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # 用MNIST数据集的均值和标准差做标准化 ]) # 下载并加载训练集和测试集 train_dataset = torchvision.datasets.MNIST( root="./data", train=True, transform=transform, download=True ) test_dataset = torchvision.datasets.MNIST( root="./data", train=False, transform=transform, download=True ) # 创建数据加载器 train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)这里有两个关键点需要拆开讲。
为什么要ToTensor()?
原始图片是一个0-255的像素值矩阵,而神经网络的训练需要浮点数,并且数值不宜过大。ToTensor()会把像素值从整数范围0-255压缩到浮点数范围0.0-1.0,同时把数据维度从(H, W)变成(C, H, W),其中C表示通道数(灰度图就是1)。
为什么要Normalize((0.1307,), (0.3081,))?
这是标准化操作:把每个像素值减去均值,再除以标准差,得到的数据分布就近似"均值为0、方差为1"。0.1307和0.3081是MNIST数据集的像素均值和标准差,是官方统计出来的固定值。标准化之后,梯度下降收敛得更快、更稳定。这个操作不能省,很多新手漏掉它之后,会遇到loss下降缓慢、最终准确率死活上不去的问题。
DataLoader的作用是把数据集分成一批一批地喂给模型。batch_size=64意味着每批64张图片;shuffle=True表示每个epoch开始时打乱顺序,避免模型学到样本顺序的"套路"。
3.2 定义神经网络结构
这部分是核心。我们用两层隐藏层加一层输出层:
class SimpleNN(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(28 * 28, 128) # 第一层:输入784,输出128 self.fc2 = nn.Linear(128, 64) # 第二层:输入128,输出64 self.fc3 = nn.Linear(64, 10) # 输出层:输入64,输出10 self.relu = nn.ReLU() def forward(self, x): x = x.view(x.size(0), -1) # 把(64,1,28,28)展平成(64,784) x = self.relu(self.fc1(x)) x = self.relu(self.fc2(x)) x = self.fc3(x) # 输出层不接ReLU,直接输出10个数值 return x这里每个设计都值得说道说道。
nn.Linear(28*28, 128):输入维度是784。因为图片是28x28,展平后就是784个像素值。输出维度128是我们自己定的,隐藏层神经元的数量没有唯一正确答案,128是性能和复杂度之间比较平衡的选择。你以后做别的任务,这个数字可以随便调。- 隐藏层激活函数用
ReLU(Rectified Linear Unit),公式是f(x)=max(0,x)。它会把负数全部压成0,正数保持不变。这样做的目的是给网络引入非线性。如果每一层都是线性变换,那不管叠多少层,本质上还是线性模型,根本拟合不了复杂的数据。ReLU是目前最常用的激活函数,计算简单、在正区间梯度不消失,适合绝大多数场景。 - 输出层不接ReLU,因为我们要的是10个类别的"得分"(logits),得分越高代表模型越倾向于这个类别。真正的概率转换将由后面的
CrossEntropyLoss内部完成。
为什么用nn.Module从基类继承?
nn.Module给我们的类带来了自动参数管理、.to(device)、.eval()等能力。你只需要在__init__里声明层,在forward里定义数据怎么流动,PyTorch会自动把self.fc1、self.fc2里的参数注册到model.parameters()里,后面优化器就能直接操作这些参数。
3.3 训练代码:完整的训练循环
网络定义好了,接下来是训练过程。
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = SimpleNN().to(device) criterion = nn.CrossEntropyLoss() # 交叉熵损失,用于分类任务 optimizer = optim.Adam(model.parameters(), lr=1e-3) # Adam优化器,学习率0.001 epochs = 5 for epoch in range(epochs): model.train() # 切换到训练模式 running_loss = 0.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() avg_loss = running_loss / len(train_loader) print(f"Epoch [{epoch+1}/{epochs}], Loss: {avg_loss:.4f}")逐行解释几个重要操作:
optimizer.zero_grad():PyTorch的梯度是累积的,如果不清空,下一次backward()时梯度会和上一次相加,结果完全错误。这是一行最容易被新手遗漏的代码。每处理一个batch都要清空一次。loss.item():loss本身是一个Tensor,直接print会看到很多乱七八糟的设备信息和梯度信息。.item()能取到它内部的Python浮点数。model.train():把模型切换到训练模式。这个对我们的网络暂时没有影响,但如果你想加入Dropout或BatchNorm层,训练模式和评估模式的行为会不同,所以养成习惯总没坏处。
跑完5个epoch,你会看到loss从最初的约0.35左右持续下降到0.1左右或更低,这说明模型在持续学习。
3.4 评估模型:在测试集上算准确率
训练完成后,我们要看在模型从没见过的测试集上表现如何:
def evaluate(model, test_loader): model.eval() # 切换到评估模式 correct = 0 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, 1) # 取每个样本得分最高的类别 total += labels.size(0) correct += (predicted == labels).sum().item() accuracy = 100.0 * correct / total return accuracy acc = evaluate(model, test_loader) print(f"测试集准确率: {acc:.2f}%")这里有几个细节:
- **
model.eval()**很重要。它会让Dropout层停止随机丢弃神经元,让BatchNorm使用训练阶段统计好的均值方差,而不是依赖当前batch。忘了加eval(),模型评估出来的准确率会偏低且不稳定。 torch.no_grad():显式告诉PyTorch"这段代码不需要记录梯度",可以大大减少内存占用和计算量。torch.max(outputs, 1):outputs的shape是(batch_size, 10),torch.max在维度1(类别维度)上找最大值,返回两个结果:最大值和对应的索引。索引就是预测的类别。_表示丢弃最大值本身,我们只要索引。
在我们这个结构下,5个epoch后测试集准确率通常能达到97%以上。对于一个只有128+64个隐藏神经元的简单网络,这个数字已经很不错了。
4. 新手最容易翻车的五个现场与排查思路
训练第一个网络,报错和迷惑几乎是必然的。我不打算只扔一个"问题-答案表",而是把最常见的几个场景的排查思路完整过一遍,帮你建立"遇到问题自己排查"的感觉。
4.1 维度不匹配:mat1 and mat2 shapes cannot be multiplied
这是出现频率最高的报错。原因基本就是:输入数据的形状和你定义的nn.Linear首层维度对不上。
比如你定义了nn.Linear(28*28, 128),但喂进来的数据是(64, 28, 28)而不是(64, 784)。解决方法很简单,在forward里加上展平操作:
x = x.view(x.size(0), -1)-1是个灵活的写法,意思是"自动推断这个维度"。对于28x28的图片,x.view(x.size(0), -1)会把它变成(64, 784)。
4.2 设备不匹配:Expected all tensors to be on the same device
如果你有显卡,模型放到了GPU,但数据还在CPU上,就会报这个错。解决方法就是让模型和数据都保持在同一个设备上:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = SimpleNN().to(device)然后在每个batch加载时也:
images, labels = images.to(device), labels.to(device)我的习惯是,定义完模型后立刻写一句print(next(model.parameters()).device),确认模型当前在CPU还是GPU,免得后面排查半天。
4.3 loss突然变成nan
nan(Not a Number)意味着计算过程出现了无效数值。最常见的原因有两个:学习率太大,导致参数更新幅度过大,梯度爆炸;或者数据里有nan或无穷大,这通常和预处理不当有关。
排查思路:
- 把学习率
lr从1e-3降到1e-4试试。 - 检查输入数据,用
torch.isnan(images).any()检查一下。 - 如果只是在训练中途跑到某个epoch突然变成
nan,更有可能是某个batch的数据异常,建议调小batch_size或检查数据加载部分。
4.4 训练集准确率很高,测试集准确率很低(过拟合)
这是最典型的过拟合现象:模型把训练数据背了下来,却没有学到泛化规律。解决思路有几种:
- 增大数据量,或者做数据增强(比如对图片加噪声、随机裁剪)。
- 降低模型复杂度:减少隐藏层神经元数量或层数。
- 加入Dropout层:在训练时随机让一部分神经元失活,模型没办法过度依赖单一路径,泛化能力会变强。
- 使用正则化:在损失函数中加L2正则项,抑制过大参数。
对MNIST这种简单数据集,我们的网络并不大,过拟合不明显。但以后换到复杂任务,这一条会经常遇到。
4.5 准确率一直卡在10%左右(相当于瞎猜)
MNIST有10个类别,纯随机猜的准确率是10%。如果你的模型训练半天准确率还在10%附近,说明模型根本没有学到任何东西。我见过的新手案例,原因不外乎:
- 忘记调用
optimizer.zero_grad(),梯度累加导致参数乱跳。 - 数据没归一化,输入数值太大,模型无法收敛。
- 标签没对齐,或者数据集加载错了。
- 在损失函数上重复做了
softmax,而CrossEntropyLoss内部已经包含了LogSoftmax,把nn.Softmax(dim=1)放在输出层后再喂给损失函数,就相当于做了两次概率转换,会导致训练极不稳定。
这个"10%"的特征非常明显,看到它,你应该第一时间怀疑:训练循环的基本逻辑是不是写错了。
5. 跑通第一个网络之后,下一步往哪些方向走
5.1 进阶方向一:从全连接网络到卷积神经网络(CNN)
全连接网络对图片的处理方式是很"笨"的:它把每个像素都当成独立的特征,完全没有利用"相邻像素之间存在局部关联"这个重要的空间信息。**卷积神经网络(CNN)**的核心改进就是引入卷积核,用滑动窗口的方式提取局部特征,参数数量大幅减少,同时效果显著提升。
你现在这个网络的所有经验,卷积神经网络里都会复用:前向传播、损失函数、反向传播、优化器、训练循环、评估流程,完全一样的套路。唯一变的是nn.Linear换成了nn.Conv2d和nn.MaxPool2d,在进入全连接层之前多了展平操作。用LeNet-5或简单的两层卷积网络跑MNIST,准确率可以轻松超过99%。
5.2 进阶方向二:循环神经网络(RNN)和序列问题
如果数据不是图片,而是文本、语音、股票价格这类有时间顺序的序列数据,全连接网络和CNN都不太合适,因为它们无法建模"上下文依赖"。**循环神经网络(RNN)**及其实战变体LSTM、GRU,会在处理当前输入时保留一个"隐藏状态",把之前看到的信息传递到后面,形成时间维度上的记忆。
你在这一步积累的交叉熵损失、优化器、训练循环,在RNN任务中依然适用。只是要把数据切分为时间步,并且注意每个时间步的输入维度。更前沿一点的方案,还有时序卷积网络(TCN)+ Transformer这类组合结构,常用于股票预测等时序任务,原理都是在这个基础框架上叠加和改造。
5.3 进阶方向三:模型保存、加载和部署
训练好的模型不只是留在内存里的变量,你需要保存下来,下次直接加载使用,或者部署上线。
# 保存整个模型 torch.save(model.state_dict(), "mnist_model.pth") # 加载模型 model = SimpleNN() model.load_state_dict(torch.load("mnist_model.pth")) model.eval()对于PyTorch模型而言,业界更推荐保存state_dict(只包含参数),而不是整个模型对象。因为整个模型对象会绑定到当时的代码结构,代码类定义改了之后可能加载失败。你之后可能还会遇到.pt、.pth、.bin这些不同后缀的模型文件,它们的核心内容大同小异,都是参数集合的序列化结果,只是命名习惯不同。
5.4 进阶方向四:可视化和调试技巧
训练模型不能只看最终的准确率,你还需要了解训练过程中的变化趋势。推荐给新手两个工具:
- TensorBoard:PyTorch有
torch.utils.tensorboard可以直接对接。它能实时画出loss和准确率的曲线,比盯着终端里的数字更直观。 matplotlib手绘曲线:不想引入额外依赖的话,每个epoch记录一下avg_loss和验证准确率,训练结束后用matplotlib画出来,效果一样。
我的一个经验是:先在一个很小的子集上过拟合。比如只取训练集里的256张图片,把batch_size设为64,训练10个epoch。如果模型连这么小的数据都训练不到接近100%的准确率,说明代码有bug;如果能在小数据上成功过拟合,再放到全量数据上训练,这时候遇到问题多半是超参数设置或者数据量不足的问题,而不是代码逻辑错了。这个"小数据过拟合测试"是排查模型问题的利器,能省下大量debug时间。
5.5 进阶方向五:超参数调优的心法
学习率lr、批量大小batch_size、隐藏层神经元数量、epoch数量,这些统称超参数。它们没有标准答案,但有一些经验规律:
- 学习率是最敏感的超参数。
1e-3(0.001)是一个很常见的起点。偏大,loss会震荡甚至变成nan;偏小,收敛很慢,需要更多epoch才能看到效果。 - batch_size影响训练稳定性和显存占用。太小(比如1)梯度噪声大,太大(比如512)收敛速度可能变慢、且占用显存。一般64到256之间起步比较稳妥。
- epoch数量不是越大越好。训练到一定程度后,测试集准确率可能不升反降,那就是过拟合开始了。一个实用的做法是保存验证集上效果最好的那个模型,而不是只看最后一个epoch的结果。
写在最后的实操建议
坦白说,我自己在一开始跑神经网络时也踩过不少坑,最想跟你说的一句话是:不要只盯着代码能不能跑,一定要亲手改动一些参数然后观察效果变化。把隐藏层神经元从128改成32,测试准确率会下降多少?学习率调到0.1会发生什么?不加Normalize训练曲线会有什么不同?这些实验做一遍,你对神经网络的理解会突飞猛进。
还有一个小建议:训练前先打印一下每个batch的数据维度,images.shape和labels.shape,确认数据和模型是匹配的。这看起来是多余的步骤,但它能帮你把"数据问题"和"模型问题"分开排查,避免把所有问题都混在一起纠结半天。
当你把MNIST准确率稳定在97%以上、并且能手写画出网络结构和训练流程图的时候,就可以自信地进入下一阶段了。深度学习这条路很长,但第一个神经网络跑通的那一瞬间,你会发现自己已经站在了一个新台阶上。