读《动手学深度学习》之前,我对线性代数的印象停留在大学期末试卷上——行列式、逆矩阵、特征值,考完就忘。直到在 PyTorch 里写第一个线性层,看着数据的形状从[2, 4]变成[2, 1],我才真正理解书里反复强调的一句话:深度学习模型的每一次前向计算,本质上都是一连串的矩阵运算在搬运数据。
这篇内容是我把书中"线性代数"一章重刷了三遍之后的实操笔记,重点解决三个问题:PyTorch 环境怎么一次性配好不再折腾、张量和矩阵运算在代码里该怎么写、以及最关键的——这些数学概念跟神经网络到底是什么关系。如果你刚装好 PyTorch,准备认真啃这本书,这篇应该能帮你少走不少弯路。
1. 先跑通 PyTorch 环境:conda、CUDA 版本和第一个 import
1.1 用 conda 创建独立环境,别再往基础环境里堆包
我见过太多人装 PyTorch 踩坑,根源只有一个:直接把包装进了 Anaconda 的 base 环境,装到一半发现跟某个旧包冲突,又不敢删。正确做法是给《动手学深度学习》单独开一个环境,互不干扰。
conda create -n d2l python=3.10 conda activate d2lPython 版本我推荐 3.10,不是越新越好。3.11、3.12 虽然出来了,但一些第三方库的预编译 wheel 可能还没跟上,3.10 是目前兼容性最稳的区间。
激活环境之后,用conda install还是pip install?我的习惯是 PyTorch 相关一律用 pip,因为它能直接到 PyTorch 官方源拉取对应 CUDA 版本的包,conda 源有时候会滞后。
pip install torch torchvision这里多说一句:如果你用的是 NVIDIA 显卡,纯 CPU 版和 GPU 版的差别非常大。一个 epoch 的训练,GPU 可能几秒钟就跑完,CPU 得上分钟。去 PyTorch 官网的 Get Started 页面,选择你的操作系统、包管理方式和 CUDA 版本,页面会直接生成安装命令。
1.2 验证安装正确性,别急着写模型代码
装完第一件事不是写神经网络,而是先验证三件事:版本号、CUDA 是否可用、基础矩阵运算是否正常。
import torch print(torch.__version__) # 2.x.x print(torch.cuda.is_available())# True 才有意义 print(torch.zeros(2, 3)) # 矩阵创建是否正常torch.cuda.is_available()返回True才代表 GPU 能被 PyTorch 调用。如果返回False,最常见的原因是 CUDA 驱动和 PyTorch 编译时的 CUDA 版本不匹配。新版本 PyTorch 对 CUDA 12.8 的支持已经不错了,装之前看一眼自己显卡驱动支持的 CUDA 版本,再选对应的安装命令。
还有一个高频报错值得提:在 Windows 的 PowerShell 或 cmd 里输入conda提示"无法将 conda 项识别"。这不是你装错了,是 conda 的可执行文件路径没有加到系统 PATH 里。两个解法:一是永远从 Anaconda Prompt 进入终端,这个入口自带环境变量;二是手动把C:\ProgramData\Anaconda3\Scripts加进 PATH。
提示:环境配置阶段遇到任何报错,先看最后一行红色文字,大多数情况下错误原因就藏在最后一句里,不用急着搜全文。
2. 从标量到高维张量:shape 是深度学习的头等大事
2.1 张量创建:每种写法都有对应场景
线性代数里你学的是标量、向量、矩阵,PyTorch 里统一叫张量(Tensor)。表面是换了个名字,实际是维度变了:标量是 0 维张量,向量是 1 维,矩阵是 2 维,再往上就是深度学习里真正的主角——高维张量。
# 标量:一个数,0 维 scalar = torch.tensor(3.0) # 向量:一维,比如一个样本的 4 个特征 vector = torch.arange(4) # tensor([0, 1, 2, 3]) # 矩阵:二维,比如 3 个样本,每个样本 4 个特征 matrix = torch.randn(3, 4) # shape [3, 4] # 高维张量:批量图片就是典型 images = torch.randn(2, 3, 32, 32) # 2 张图,3 通道(RGB),32x32 像素最后那个[2, 3, 32, 32]是深度学习最常见的格式:批量大小、通道数、高、宽。初学最容易在这里懵——为什么一张图要用四维表示?因为一张彩色图片有三个颜色通道,每个通道是一个二维像素矩阵,再加上一次处理多张图片,维度自然就叠上去了。
2.2 形状操作是基本功:reshape、view、squeeze、unsqueeze
书里反复强调 shape 的重要性,真不是小题大做。深度学习中 80% 的报错都是 shape 不匹配,剩下的 20% 是不匹配但没报错。
x = torch.arange(12) print(x.shape) # torch.Size([12]) print(x.reshape(3, 4)) # 变成 3 行 4 列 # 增加一个维度:常用于给单样本加 batch 维 y = torch.tensor([1.0, 2.0, 3.0]) print(y.unsqueeze(0).shape) # torch.Size([1, 3]) print(y.unsqueeze(1).shape) # torch.Size([3, 1]) # 去掉长度为 1 的维度 z = torch.randn(1, 3, 1, 4) print(z.squeeze().shape) # torch.Size([3, 4])reshape和view看着像,底层逻辑完全不同。view要求张量在内存中是连续存储的,不连续就报错;reshape更宽容,返回的可能是一个拷贝。我的建议是:不清楚是否连续时用reshape,安全第一。
2.3 广播机制:不同 shape 的张量也能做运算
广播可能是新手遇到的第一个"反直觉但合理"的机制。按线性代数的规则,两个矩阵相加要求维度完全一致,但 PyTorch 允许不同 shape 的张量做逐元素运算:
a = torch.ones(3, 1) # shape [3, 1] b = torch.ones(1, 4) # shape [1, 4] print(a + b) # 结果是 [3, 4]这个过程分两步:先把[3, 1]横向扩展成[3, 4],再把[1, 4]纵向扩展成[3, 4],然后逐元素相加。你不需要真的把数据复制一遍,PyTorch 在底层做了优化。
这个机制在归一化操作里特别常见。比如你有[batch, features]的特征矩阵,要按样本维度减均值,均值向量的 shape 是[batch],两者直接相减就会触发广播。
注意:广播不是万能的。
[3, 4]和[2, 4]相加会直接报错,因为维度 3 和 2 不是"其中一个为 1"的关系。看到 shape 不匹配的报错时,先检查两个张量从右往左的每个维度是否满足"相等或其中一个为 1"。
3. 矩阵运算的直觉:点积是相似度,矩阵乘法是数据变换
3.1 三个容易搞混的函数:dot、mv、mm
PyTorch 里的矩阵乘法函数有好几个,初学最容易混淆。我踩过的坑是这样的:
torch.dot(x, y):两个一维向量的点积,结果是一个标量。要求长度一样。torch.mv(A, x):矩阵和向量相乘,A是二维、x是一维,结果是向量。torch.mm(A, B):两个矩阵相乘,标准的线性代数矩阵乘法。
x = torch.tensor([1.0, 2.0]) y = torch.tensor([3.0, 4.0]) A = torch.randn(2, 3) B = torch.randn(3, 2) print(torch.dot(x, y)) # tensor(11.) print(torch.mv(A, x)) # A 是 [2, 3],x 是 [3],结果是 [2] print(torch.mm(A, B)) # A 是 [2, 3],B 是 [3, 2],结果是 [2, 2]3.2 矩阵乘法的形状变化,是最实用的技能
矩阵乘法形状匹配规则只有一条:A[m, k]乘B[k, n],结果C[m, n],即左边的列数必须等于右边的行数。
深度学习里怎么理解这件事?把一个矩阵看成"一组样本",另一个矩阵看成"线性变换",矩阵乘法就是把这组样本整体映射到新的空间。以X = [batch_size, features]为例,乘一个W = [features, hidden]的权重矩阵,结果[batch_size, hidden]表示每个样本经过变换后获得了新的 hidden 维特征。
我用一个具体例子帮你建立直觉。假设 4 个学生考了 3 门试,成绩矩阵X是[4, 3],现在想按"平时分占 30%、期中占 30%、期末占 40%"算出总评,权重向量w是[3],那么X @ w就是[4],每个元素对应一个学生的加权总评成绩。这个例子几乎就是神经网络线性层的本质——给特征加权求和。
3.3 点积的深层含义:向量相似度
点积不只是算术运算,它还衡量两个向量的相似程度。两个向量方向越接近,点积越大;垂直时点积为 0;方向相反时点积为负。
这个性质在深度学习里直接喂出了注意力机制。Transformer 里计算 Query 和 Key 的匹配分数,用的就是Q @ K.T:每个 Query 和所有 Key 做点积,得到一个相似度矩阵,再经过 softmax 变成权重。理解了点积的几何意义,再回头看注意力机制就会豁然开朗。
query = torch.randn(2, 4) # 2 个查询 key = torch.randn(5, 4) # 5 个键 scores = query @ key.T # [2, 5],每个查询与所有键的相似度3.4 Hadamard 积和范数:看似简单却在模型里无处不在
逐元素乘法(Hadamard 积)用*操作符,要求两个张量形状完全一致,对应元素相乘。它在深度学习中用来做掩码操作:生成一个 0/1 掩码矩阵并与目标矩阵逐元素相乘,就能实现"只保留某些位置的值",Dropout 就是这么干的——随机生成 0/1 掩码,把一部分神经元"关掉"。
范数衡量的是向量长度。L2 范数是最常用的,公式是平方和的平方根。PyTorch 里用torch.norm(x)就能直接算。
为什么 L2 范数和深度学习关系紧密?因为这正是 L2 正则化的底层逻辑。模型过拟合时,权重往往会长得很大,正则化就是在损失函数里加一项权重的 L2 范数平方,强迫模型保持权重较小:
# 手动实现 L2 正则化项 reg_loss = torch.norm(model.weight) ** 2 # 用 PyTorch 内置方法:在优化器里直接设置 weight_decay optimizer = torch.optim.SGD(model.parameters(), lr=0.01, weight_decay=0.001)weight_decay参数的意义就是:每次更新权重时,额外往零方向拉一点。热词里很多人搜"深度学习 L2 正则化 pytorch 代码",其实就是这一行配置的事。
4. 从 y = XW + b 到神经网络:线性变换为什么撑起整个深度学习
4.1 线性层做的事情,就是线性代数书里的线性变换
我当初学线性代数完全没概念:线性变换到底有什么用?直到把它对应到神经网络的一层才反应过来。一个线性层做的事就是y = XW + b:
X是输入,shape 是[batch, in_features]W是权重,shape 是[in_features, out_features]b是偏置,shape 是[out_features]y是输出,shape 是[batch, out_features]
如果从几何视角看,W这个矩阵做的事就是:对输入空间进行旋转、缩放、翻转。偏置b则是平移——把整个空间平移一段距离。没有偏置,变换就永远通过原点,模型表达能力打折。
import torch.nn as nn layer = nn.Linear(in_features=4, out_features=3) x = torch.randn(2, 4) # 2 个样本,每个 4 维 y = layer(x) # shape [2, 3] print(layer.weight.shape) # [3, 4],注意是 [out, in] print(layer.bias.shape) # [3]注意nn.Linear的权重 shape 是[out_features, in_features],跟数学公式里习惯写的[in, out]相反。这就是为什么有时候你手动实现线性层时会发现维度对不上——转置一下就好。
4.2 多个线性层叠加,为什么还需要激活函数
线性代数的基本结论之一:有限个线性变换复合,结果仍然是线性变换。这意味着如果你把几个线性层直接串起来,无论叠多少层,整体上还是等价于一个线性层,那"深度"就没有意义了。
这就是激活函数出现的原因:在每个线性层之间插入非线性变换(ReLU、sigmoid、tanh),打破线性叠加的限制。现代深度学习中,一个多层感知机块长这样:
def mlp_block(in_dim, out_dim): return nn.Sequential( nn.Linear(in_dim, out_dim), nn.ReLU() )ReLU做的事情极简:负数变 0,正数保留。但正是这个简单的非线性操作,让神经网络从"只能拟合直线"变成"能拟合任何函数"。
4.3 从线性代数的角度看 softmax 回归
《动手学深度学习》第一个完整模型就是 softmax 回归,它本质上是:一个线性层 + softmax 操作。线性层把输入映射到类别得分,softmax 把得分转换成概率分布。
假设 10 个类别,通过线性层得到 10 个原始分数,softmax 做两件事:先对分数取指数(保证非负),再归一化(保证加起来等于 1)。矩阵运算来看就是:
logits = x @ W + b # [batch, num_classes] probs = torch.softmax(logits, dim=1) # [batch, num_classes]dim=1表示在第 1 维(类别维)上做归一化。这也是新手容易踩的一个点:softmax 的维度选错,结果就会变成"每行不是 1"或者对称位置的数字串了。
4.4 手写一个最简单的训练循环,观察线性层的学习过程
理论说得再多,不如跑一个最小例子。我建议你在学完线性代数这一章后,立刻做这个实验:
import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset # 生成一份线性关系的合成数据:y = 2*x1 - 1.5*x2 + 0.5 torch.manual_seed(42) X = torch.randn(1000, 2) y = 2 * X[:, 0] - 1.5 * X[:, 1] + 0.5 + torch.randn(1000) * 0.1 model = nn.Linear(2, 1) # 单个线性层 loss_fn = nn.MSELoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.01) dataset = TensorDataset(X, y) loader = DataLoader(dataset, batch_size=64) for epoch in range(10): for xb, yb in loader: optimizer.zero_grad() pred = model(xb).squeeze() loss = loss_fn(pred, yb) loss.backward() optimizer.step() print(f"epoch {epoch + 1}, loss = {loss.item():.4f}") print("训练后的权重:", model.weight.detach().numpy()) print("训练后的偏置:", model.bias.detach().numpy())训练结束后,你会发现学到的权重接近[2.0, -1.5]、偏置接近[0.5]。这个实验最有价值的地方在于让你看到:所谓"机器学习",就是用一个线性层去拟合数据的线性规律,权重通过梯度下降不断调整,最终逼近真实参数。整个过程全部建立在线性代数之上。
5. 向量化思维与自动求导:体会 PyTorch 的 backward 机制
5.1 为什么 PyTorch 不需要你手动写梯度
学过微积分的人都知道,求解多层复合函数的梯度是一件极其繁琐的事情。PyTorch 的自动求导(autograd)机制把这个过程自动化了:它维护一张计算图,记录每个张量是怎么算出来的。当你调用loss.backward()时,梯度沿着计算图反向传播,链式法则被逐节点执行。
听起来像魔法,但底层的数学仍然是线性代数。考虑一个线性层y = XW + b,损失函数如 MSE,反向传播时你需要计算d_loss / d_W,即损失对权重矩阵的偏导。这个过程如果用数学语言推导,涉及雅可比矩阵的连乘。但 PyTorch 做了一个聪明的折中:它不显式计算雅可比矩阵,而是为每个参数直接更新梯度张量,形状和参数完全一致。
model = nn.Linear(2, 1) loss_fn = nn.MSELoss() x = torch.randn(1, 2) target = torch.randn(1) loss = loss_fn(model(x), target) loss.backward() # 查看梯度 print(model.weight.grad) # shape [1, 2] print(model.bias.grad) # shape [1]这个机制的核心要求是:backward()只能从标量开始反向传播。为什么必须是标量?因为损失函数定义的是"一个数值",衡量预测与真实值的差距。如果你对一个向量调用backward(),必须传入grad_tensors参数来指定每个分量的权重,否则会直接报错。这也是每次训练循环里loss都是标量的原因。
5.2 梯度清零、梯度裁剪、梯度爆炸:三个绕不开的实操细节
第一个细节:梯度累加。调用backward()后,梯度不是被覆盖,而是累加到.grad上。所以每个训练循环开始前都要执行optimizer.zero_grad(),清空上一步的梯度。如果不做这一步,梯度会在多轮之间累加,训练结果完全错乱。
第二个细节:梯度裁剪。深层网络或 RNN 训练时,梯度可能变得非常大,导致参数更新幅度过大,损失值跳到 NaN。解决办法是设置梯度上限:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)这句代码把所有参数的梯度范数裁剪到不超过 1.0。很多人在训练 Transformer 或 LSTM 时遇到过 NaN 损失,第一反应是调学习率,但更可能是梯度爆炸,试试裁剪往往立竿见影。
第三个细节:no_grad上下文。验证集或测试集前向推理时,不需要计算梯度。用with torch.no_grad():包起来,可以省内存、提速。这个操作不影响结果,因为推理本来就不需要反向传播。
# 验证阶段 model.eval() with torch.no_grad(): val_pred = model(val_X)5.3 向量化是 GPU 发挥威力的前提
学线性代数这一章时,你可能会觉得torch.mm(A, B)和 for 循环逐行算没啥区别。区别太大了。
import time A = torch.randn(1000, 1000) B = torch.randn(1000, 1000) # 循环方式 start = time.time() result_loop = torch.empty(1000, 1000) for i in range(1000): result_loop[i] = torch.mv(A, B[i]) print("循环耗时:", time.time() - start) # 向量化方式 start = time.time() result_matrix = torch.mm(A, B) print("矩阵乘法耗时:", time.time() - start)在 CPU 上这个差距通常是几十倍,GPU 上更夸张。原因很好理解:矩阵乘法是高度规则化的运算,底层可以调用高度优化的 BLAS 库,并且天然适合 GPU 的并行架构。GPU 有几千个核心同时计算,而 for 循环只能一个接一个跑。
这也是《动手学深度学习》整本书反复强调向量化思维的原因。写代码时看到 for 循环,先想想能不能换成矩阵运算——不要只是处理二维矩阵,也要习惯三维、四维张量整体的运算。一开始觉得抽象,但一旦从"逐元素"上升到"逐张量",你才能真正发挥 PyTorch 的效率。
5.4 一个组合实验:在线性拟合中加入 L2 正则化
把正文的线性拟合实验升级一下:给模型手动加上 L2 正则化项,观察权重的变化,这是检验你对线性代数和自动求导理解是否到位的好方式。
model = nn.Linear(2, 1) lambda_reg = 0.1 for epoch in range(20): optimizer.zero_grad() pred = model(X).squeeze() mse_loss = loss_fn(pred, y) l2_penalty = torch.norm(model.weight) ** 2 # 权重矩阵的 L2 范数平方 total_loss = mse_loss + lambda_reg * l2_penalty total_loss.backward() optimizer.step() print(f"epoch {epoch + 1}, loss = {total_loss.item():.4f}")你会发现,加入正则化后,权重不再精确等于[2.0, -1.5],而是略微偏小,偏置也受到轻微压制。这就是正则化的效果:用一点点拟合精度,换取权重的稳定性和泛化能力。这一坨代码用到了本篇文章讲过的所有核心内容:张量形状、矩阵乘法、范数、自动求导、优化器更新。跑通之后,你对"线性代数在深度学习里到底有什么用"这个问题,应该已经有了自己的答案。
我在实际学习中有个体会:这章内容与其说在教数学,不如说在教你一种思维方式——把数据看作张量,把层看作变换,把训练看作优化张量参数。每个抽象的数学概念都能在代码里找到一个对应的函数,遇到不理解的公式,先写两行 PyTorch 跑一遍,把输入输出 shape 打出来,往往比死磕推导过程更有效。
最后分享一个小技巧:遇到维度对不上的报错时,别急着搜博客,随手拿张纸把两个张量的 shape 写出来,从右往左逐个维度比对,哪一对既不等值其一也不是 1,问题就出在哪。五分钟内基本能定位,比翻十篇教程都快。