PyTorch入门-张量操作与自动求导
PyTorch是深度学习领域的标准工具。理解它的设计哲学,比会用它的API更重要。
PyTorch入门:张量操作与自动求导
PyTorch是深度学习领域的标准工具。理解它的设计哲学,比会用它的API更重要。
前置知识提示:本文理论内容较多,建议先完成以下内容再阅读:
- 第2章 Python基础(特别是类和面向对象章节)
- 第3章 微积分与梯度下降(理解为什么需要自动求导)
如果你是Java工程师:
nn.Module类似Java的抽象类,forward()是必须实现的抽象方法;DataLoader类似Java的迭代器模式;PyTorch的动态计算图类似Java中的链式方法调用,每次调用都会"记录"操作历史。本文目标:重点理解「为什么需要PyTorch」、「张量与神经网络的关系」和「计算图是什么」。代码是辅助理解的工具,不要求跑通,先读懂逻辑。
1.1 一、为什么需要 PyTorch?
在回答"PyTorch 是什么"之前,先回答一个更根本的问题:PyTorch 之前,深度学习是怎么做的?
1.1.1 手工推导梯度:一场噩梦
第3章讲过,训练神经网络的核心是梯度下降——反复计算损失函数对每个参数的梯度,然后沿着梯度的反方向调整参数。
问题在于:梯度怎么算?
对于一个只有两层、每层 3 个神经元的小网络,参数已经有几十个。损失函数是这些参数的复合函数(复合又复合又复合),要手动对每个参数求偏导数,需要反复应用链式法则,计算过程复杂且极易出错。
早期研究者真的是这样做的。2000 年代以前,实现一个神经网络意味着:
- 在纸上推导出该网络的反向传播公式
- 把公式逐行翻译成 C 或 FORTRAN 代码
- 调试——因为数学推导和代码实现都可能有错
换一个网络结构?重头推导一遍。增加一层?重新算一遍链式法则。
这不是工程问题,而是认知负担问题:大量精力花在"正确地算梯度"上,而不是"设计更好的网络结构"上。这严重限制了研究者的实验速度。
1.1.2 早期框架的困境:静态计算图
2010 年代初,Theano 和早期 TensorFlow(1.x)出现了。它们提出了"计算图"的概念(后面会详细解释),能自动计算梯度——这是巨大的进步。
但它们有一个共同的设计缺陷:静态计算图。
使用这些框架时,你需要先"描述"整个计算过程,构建一张完整的图,然后把数据"喂进"这张图执行。图一旦建好就不能动了。
这带来了两个痛点:
- 调试困难:你无法在计算过程中打印中间值或设置断点,因为执行是在图构建完之后统一发生的,就像在编译后才能运行
- 灵活性差:网络结构必须在运行前完全确定。但很多自然语言处理任务的输入序列长度是动态的,RNN 的循环次数随输入变化——静态图很难优雅地处理这类情况
1.1.3 PyTorch 的回答:动态计算图
2016 年,Facebook AI Research 发布了 PyTorch。它的核心创新是动态计算图(Dynamic Computation Graph,也叫 Define-by-Run):
计算图不是预先构建的,而是在你执行代码时实时生成的。
每一行 Python 代码运行时,PyTorch 都在背后悄悄记录"刚才做了什么操作"。等你需要反向传播时,PyTorch 就沿着这些记录,自动算出所有梯度。
这意味着:
- 你可以像写普通 Python 代码一样写深度学习代码
- 可以随时打印中间结果调试
- 网络结构可以根据输入动态变化(比如 RNN 的循环次数随句子长度变化)
- if-else、for 循环、函数调用——任何 Python 控制流都可以自然地用在网络里
PyTorch 的本质:
你写 Python → PyTorch 悄悄记录操作 → 你调用 .backward() → PyTorch 自动求所有梯度
加上对 GPU 的无缝支持(把数据和模型一起搬到 GPU 上,用几乎一样的代码获得数十倍加速),PyTorch 迅速成为学术研究的首选工具,并在 2020 年代初超越 TensorFlow 成为业界主流。
截至 2026 年 3 月:PyTorch 2.x 已经是稳定版本,新增了
torch.compile()等性能优化特性。本文聚焦核心概念,版本细节以官方文档为准。
1.2 二、张量:多维Excel表格
1.2.1 张量就是多维的Excel表格
很多教程把张量(Tensor)介绍为"多维数组",这在技术上没错,但缺少直觉感。更好的类比是:张量就是多维的Excel表格。
- 一个数字就像一个单元格:标量(0维张量),比如损失值
0.35 - 一行数字就像一行数据:向量(1维张量),比如一个神经元的所有输入
[0.2, 0.8, 0.5, 0.1] - 一张Excel表格(行×列):矩阵(2维张量),比如一层神经网络的权重
- 多张Excel表格叠在一起(批次×行×列):3维张量,比如一批文本的词向量
- 再加一个维度(批次×通道×高×宽):4维张量,比如一批彩色图片
张量是所有这些结构的统一抽象。深度学习之所以用张量,是因为神经网络的每一个核心操作,都天然地是张量运算。
1.2.2 矩阵乘法如何对应前向传播
回忆神经网络的前向传播:输入经过一层神经元,每个神经元计算"加权求和 + 激活函数"。
以最简单的全连接层为例:输入是 4 个特征,这一层有 3 个神经元。
每个神经元的计算是:
神经元1的输出 = w11*x1 + w12*x2 + w13*x3 + w14*x4 + b1
神经元2的输出 = w21*x1 + w22*x2 + w23*x3 + w24*x4 + b2
神经元3的输出 = w31*x1 + w32*x2 + w33*x3 + w34*x4 + b3
如果你把所有权重排成一个 3×4 的矩阵 W,把输入排成一个长度为 4 的向量 x,把偏置排成长度为 3 的向量 b,那么上面三个公式可以统一写成:
输出向量 = W × x + b
这就是矩阵乘法。一层神经网络的前向传播,本质上就是一次矩阵乘法。
这个认识非常重要:
- 整个神经网络的前向传播,就是一系列矩阵乘法串联在一起(加上激活函数)
- GPU 最擅长的事恰好是大规模矩阵运算(这就是为什么深度学习需要 GPU)
- 张量运算库(比如 PyTorch)的核心,就是高效执行这些矩阵运算
1.2.3 批处理:为什么要一次处理多条数据
实际训练时,我们不会一次只处理一条数据,而是一批(batch)一起处理。原因有两个:
- 效率:GPU 的并行性在处理大量数据时才能充分发挥。一次处理 64 条数据,比循环 64 次处理单条数据快得多
- 梯度稳定:单条数据的梯度噪声很大,多条数据的平均梯度更稳定
批处理让张量维度增加了一维。输入从"4 个特征的向量"变成"64 × 4 的矩阵"(64 条数据,每条 4 个特征)。矩阵乘法天然支持批处理,维度自动扩展,这是张量运算的另一个优势。
1.2.4 代码验证:创建张量和矩阵乘法
import torch
# 创建一个全连接层的输入(模拟64条数据,每条4个特征)
x = torch.randn(64, 4) # 形状: (64, 4)
# 权重矩阵:4个输入特征 → 3个神经元
W = torch.randn(3, 4) # 形状: (3, 4)
b = torch.randn(3) # 偏置: 3个神经元各一个
# 前向传播就是矩阵乘法:(64,4) × (4,3) + (3,) = (64,3)
output = x @ W.T + b # @是矩阵乘法运算符,.T是转置
print(output.shape) # torch.Size([64, 3])
# 64条数据,每条得到3个神经元的输出值
# 张量的关键属性
print(x.dtype) # torch.float32(默认浮点精度)
print(x.device) # cpu(还未移动到GPU)
print(x.shape) # torch.Size([64, 4])
这段代码做的事情,和一个有 3 个神经元的全连接层的前向传播,在数学上完全等价。
1.2.5 GPU:为什么深度学习需要它
CPU 的设计目标是通用计算,擅长处理复杂的控制逻辑,有少量但强大的计算核心(通常 8-32 个)。
GPU 的设计目标是图形渲染,需要同时处理屏幕上数百万个像素点的颜色计算——这是典型的"大量简单运算并行执行"。GPU 有数千个计算核心,非常适合矩阵乘法这类操作。
深度学习的核心运算(矩阵乘法)和图形渲染(像素计算)在计算模式上高度相似:都是对大量数据做相同的简单运算。这就是为什么 GPU 能让深度学习训练加速 10-100 倍。
在 PyTorch 里,把计算从 CPU 移到 GPU 只需要一行:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 数据和模型都要移到同一个设备,运算才能发生
x = x.to(device)
代码几乎不变,但背后的执行从 CPU 切换到了 GPU。这是 PyTorch 抽象做得好的体现——硬件细节被隐藏起来,你专注于网络结构的设计。
1.3 三、自动求导:自动追踪计算过程
PyTorch 自动求导完整流程——前向传播构建计算图,反向传播自动计算梯度
这一节是PyTorch最核心的概念。理解了计算图,就理解了PyTorch的本质。
1.3.1 自动求导是什么:自动追踪你的每一步计算
想象一个"记账本"。你做的每一步计算,PyTorch都悄悄地在记账本上记下来:"刚才做了平方运算"、"刚才做了乘法"、"刚才做了加法"……
当你需要反向传播时,PyTorch就翻出这本账,从最后一页往前翻,用链式法则一步一步算出每个参数的梯度。
这就是**自动求导(Automatic Differentiation)**的核心——你只需要写前向计算代码,PyTorch会自动帮你记录过程、计算梯度。
1.3.2 什么是计算图?
计算图(Computation Graph)是这个"记账本"的数学表达——一种记录"计算过程"的数据结构。
用一个简单例子来说明。假设我们要计算 y = x² + 2x + 1,当 x = 3 时:
第一步:a = x² (x=3,a=9)
第二步:b = 2x (x=3,b=6)
第三步:c = a + b (c=15)
第四步:y = c + 1 (y=16)
这四步计算可以用一张图来表示:节点是中间变量,边是操作(平方、乘法、加法)。这张图就叫计算图。
x(3) ──[平方]──→ a(9) ──→
[加法]──→ c(15) ──[+1]──→ y(16)
x(3) ──[×2]───→ b(6) ──→
计算图有两个关键性质:
- 它记录的不只是结果,还记录了"如何得到这个结果"
- 有了计算图,反向传播就是在这张图上倒着走
1.3.3 为什么要记录计算图?
答案是:为了反向传播。
反向传播的核心是链式法则:要算损失函数对某个参数的梯度,需要把从该参数到损失函数路径上所有操作的导数乘起来。
手动算链式法则很繁琐,但如果有计算图,这件事可以自动化:
- 沿着计算图正向走一遍,记录所有中间值(前向传播)
- 从输出端反向沿着图走回去,用链式法则逐步计算每个节点的梯度(反向传播)
PyTorch 在你做前向计算时,悄悄构建这张计算图。等你调用 .backward(),它就沿着图倒着走,自动算出所有梯度。
1.3.4 计算图如何处理复杂网络?
一个真实的神经网络可能有数百层、数十亿参数。它的计算图也会非常庞大——但原理完全一样:
- 数据从输入层经过每一层,每次运算都在图上增加节点
- 最终得到损失值(标量)
- 对损失值调用
.backward() - PyTorch 从损失值出发,沿着图反向走,计算每个参数的梯度
- 梯度被存储在每个参数的
.grad属性里,等待优化器使用
这个过程中,程序员完全不需要手工推导任何公式。PyTorch 把"正确地计算梯度"这件苦差事彻底自动化了。
1.3.5 计算图的生命周期
计算图在调用 .backward() 后默认会被销毁。这是为了节省内存——图本身占用大量内存,计算完梯度就不再需要了。
这也解释了一个常见现象:如果你对同一个计算调用两次 .backward(),第二次会报错,因为图已经被销毁。若需要多次反向传播(某些特殊场景),可以使用 retain_graph=True 保留计算图。
在推理(Inference,用训练好的模型做预测,不需要训练)时,我们不需要计算梯度,也就不需要构建计算图。使用 torch.no_grad() 上下文管理器可以关闭图的构建,节省内存和计算:
# requires_grad=True 告诉 PyTorch:追踪这个张量的计算历史
x = torch.tensor(3.0, requires_grad=True)
# 前向计算:PyTorch 悄悄记录每一步操作
y = x ** 2 + 2 * x + 1 # y = x^2 + 2x + 1
# 反向传播:沿着计算图反向走,自动算梯度
y.backward()
# dy/dx = 2x + 2,在 x=3 处 = 8
print(x.grad) # tensor(8.) ← 和手算结果完全一致
# 推理时关闭计算图构建,节省内存
with torch.no_grad():
z = x ** 2 + 2 * x + 1 # 这次不构建图,节省内存
1.3.6 梯度累加陷阱
PyTorch 有一个重要的设计决定:梯度是累加的,不是覆盖的。
每次调用 .backward(),梯度会加到 .grad 上,而不是替换它。这个设计在某些场景(如梯度累积模拟大 batch)很有用,但在普通训练循环中是一个陷阱:
如果你忘记在每步训练前清空梯度,第 2 步的梯度 = 第 1 步梯度 + 第 2 步梯度,参数更新就完全乱了,损失不会正常下降。
这就是为什么训练循环里的第一行永远是 optimizer.zero_grad()——清空上一步留下的梯度。
1.4 四、训练循环的核心节奏
1.4.1 三步节奏的逻辑
整个神经网络的训练,无论多复杂,在结构上都是以下三步的循环:
optimizer.zero_grad() # ① 清空上一步的梯度(必须,原因见上一节)
loss.backward() # ② 反向传播:沿计算图算出所有参数的梯度
optimizer.step() # ③ 优化器按梯度更新参数(走一步)
顺序不能乱,逻辑是:
- 必须先清空,否则梯度污染
- 必须先算梯度(
backward),才能用梯度更新参数(step) step完成后,参数已经更新,这一步训练结束
Java 类比:这三步类似于 Java 事务处理的模式——先重置状态(
zero_grad),再计算操作(backward),最后提交(step)。如果忘记"重置",下一次操作会在污染的状态上进行。
1.4.2 训练模式和评估模式
model.train() 和 model.eval() 是两个经常被忽视但很重要的切换。
网络里有些组件在训练时和推理时行为不同:
- Dropout(随机丢弃层):训练时随机关闭一些神经元(防止过拟合,强迫网络不依赖单一路径);推理时必须全部打开,否则输出不稳定
- BatchNorm(批归一化):训练时用当前 batch 的统计数据做归一化;推理时用训练期间积累的全局统计数据
model.train() 开启这些组件的训练行为;model.eval() 切换到推理行为。每次切换场景都必须显式调用。
1.4.3 损失函数:衡量"差了多少"
损失函数(Loss Function)是训练的"罗盘",衡量模型预测与真实标签之间的差距。损失越小,模型越好。
不同任务用不同的损失函数:
| 任务类型 | 常用损失函数 | 直觉理解 |
|---|---|---|
| 分类(判断类别) | 交叉熵损失(CrossEntropyLoss) | 预测概率分布与真实分布的"距离" |
| 回归(预测数值) | 均方误差(MSELoss) | 预测值与真实值差的平方的平均 |
| 二分类 | 二元交叉熵(BCELoss) | 交叉熵的二分类特例 |
损失函数的输出是一个标量(单个数字)。反向传播必须从标量出发,这就是为什么最终的 loss 一定是一个数字。
1.4.4 优化器:怎么"走一步"
梯度告诉我们"应该朝哪个方向调整参数",但没说"应该调多大"。优化器决定这个"步长"的策略。
最基础的是 SGD(随机梯度下降):每个参数直接减去 学习率 × 梯度。简单粗暴,但在学习率选择不当时容易在最优解附近震荡。
Adam(Adaptive Moment Estimation)是目前最常用的优化器。它的核心改进是:为每个参数自适应地调整学习率——历史上梯度大的参数,步长自动变小;历史上梯度小的参数,步长自动变大。实际效果是对学习率不那么敏感,收敛更稳定。
Adam 不是炼丹魔法,它的本质仍然是梯度下降,只是用了更聪明的步长策略。第3章的梯度下降原理仍然完全适用。
1.5 五、MNIST:把所有概念串起来
MNIST(Modified National Institute of Standards and Technology database)是深度学习的"Hello World"——6 万张手写数字图片(0-9),每张 28×28 像素的灰度图。
用它来验证前面所有概念,重点不是代码本身,而是每一步背后的"为什么"。
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# ===== 第一步:数据准备 =====
# ToTensor():把像素值从 [0,255] 转换为 [0,1] 的浮点张量
# Normalize():再做标准化,让数据分布以0为中心(加速收敛)
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)) # MNIST 数据集的均值和标准差
])
train_data = datasets.MNIST('./data', train=True, download=True, transform=transform)
train_loader = DataLoader(train_data, batch_size=64, shuffle=True)
# DataLoader 的作用:把 6 万张图片分成批次,每批 64 张
# shuffle=True:每个 epoch 打乱顺序,防止模型记住数据排列规律
# ===== 第二步:定义网络结构 =====
class MNISTNet(nn.Module): # 继承 nn.Module,就像 Java 继承抽象类
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Flatten(), # 把 28×28 的图片展平成 784 维向量
nn.Linear(784, 128), # 全连接层:784 维 → 128 维(矩阵乘法)
nn.ReLU(), # 激活函数:让网络能学习非线性关系
nn.Linear(128, 10), # 128 维 → 10 维(对应 0-9 十个类别)
)
def forward(self, x): # forward() 就是前向传播,必须实现
return self.net(x)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = MNISTNet().to(device) # 模型移到 GPU(如果有的话)
# ===== 第三步:定义损失函数和优化器 =====
criterion = nn.CrossEntropyLoss() # 分类任务用交叉熵损失
optimizer = optim.Adam(model.parameters(), lr=1e-3) # Adam 优化器
# ===== 第四步:训练循环 =====
model.train() # 开启训练模式(Dropout 等组件生效)
for epoch in range(3):
for X, y in train_loader: # X: (64,1,28,28) 的批次,y: (64,) 的标签
X, y = X.to(device), y.to(device)
optimizer.zero_grad() # ① 清空梯度(避免累加污染)
output = model(X) # ② 前向传播:调用 forward(),PyTorch 同时构建计算图
loss = criterion(output, y) # ③ 计算损失:预测 vs 真实标签
loss.backward() # ④ 反向传播:沿计算图自动求梯度
optimizer.step() # ⑤ 更新参数:按梯度走一步
print(f"Epoch {epoch+1}: loss = {loss.item():.4f}")
这段代码约 30 行,但包含了深度学习训练的全部核心要素:张量运算、前向传播、计算图、反向传播、梯度下降。更复杂的网络(包括大语言模型的微调)在结构上与此完全一致,只是网络更深、数据更多、优化策略更复杂。
小结
| 概念 | 本质 | 关键理解 |
|---|---|---|
| 张量 | 统一的多维数组结构 | 神经网络的所有运算(前向传播)都是张量运算,本质是矩阵乘法 |
| 计算图 | 记录计算过程的有向图 | 前向传播时自动构建,.backward() 时沿图反向传播梯度 |
| 自动求导 | 基于计算图的梯度自动计算 | 彻底解放了手工推导梯度的工作量 |
| GPU 加速 | 矩阵运算的硬件并行化 | .to(device) 一行切换,代码几乎不变 |
| 训练循环 | zero_grad → forward → loss → backward → step | 顺序固定,逻辑严格,是所有深度学习代码的基础框架 |
| 损失函数 | 衡量预测与真实值的差距 | 分类用交叉熵,回归用均方误差 |
| 优化器 | 决定参数更新策略 | Adam 是目前最常用的,对学习率不敏感 |
掌握这七个概念,你就能读懂绝大多数深度学习代码,以及理解第16章模型微调的技术原理。
后续应用:本文知识将在以下章节直接用到:
- 第5章第5篇 Transformer架构详解:注意力机制的计算就是一系列张量运算,理解了矩阵乘法才能看懂 Attention
- 第16章 模型微调:微调本质上是对预训练模型继续执行训练循环,用的是完全相同的
zero_grad → backward → step框架;LoRA 等参数高效微调方法,更需要理解计算图和梯度的概念