大学4年没讲明白的线性代数,被一段 PyTorch 代码讲透了
行列式、特征值、矩阵的秩、向量空间……当年背得滚瓜烂熟,考完试就全忘了。 因为老师只给了你算法,没给你画面。
这篇文章反过来:不背一个定义,不证一个定理,全部用能跑起来的 PyTorch 代码, 把线性代数最核心的那几个概念,一段一段"跑"给你看。
并且顺手回答四个让无数人卡住的灵魂拷问:
- 人类识别的"特征",和矩阵里的"特征值/特征向量",到底是不是一回事?
- 难道大模型、神经网络,说穿了就是在做向量点积?
- 一个个离散的字、像素、整数,是怎么变成连续可导、能被梯度下降优化的东西的?
- 都说训练靠 GPU,GPU 是不是就是一台大型向量机?
0. 一句话主线
如果只能留一句话,那就是这句:
线性代数不是"算数表",是"搬空间"。一个矩阵就是一台把整个空间整体搬动的机器。
整个深度学习里,你看到的所有 @、matmul、nn.Linear、Q @ K.T,
本质都是在反复做同一件事:把数据从一个空间,搬到另一个更好处理的空间。
import torch
x = torch.tensor([1.0, 2.0]) # 一个点 / 一个向量
M = torch.tensor([[2.0, 0.0],
[0.0, 3.0]]) # 一台机器:横向拉 2 倍,纵向拉 3 倍
print(M @ x) # tensor([2., 6.]) —— 这个点被"搬"走了
记住这个画面,下面所有概念都挂在它上面。
1. 向量:不是"一列数字",是"空间里的一个箭头"
课本第一页就给你一个列向量 [1, 2, 3]^T,然后让你背"加法、数乘"。
但向量真正的意思是:从原点指出去的一根箭头,它有方向、有长度。
import torch
a = torch.tensor([3.0, 4.0])
length = torch.linalg.norm(a) # 长度 = 勾股定理 = sqrt(3^2 + 4^2)
print("这根箭头的长度:", length.item()) # 5.0
两个向量之间最重要的关系是点积。点积衡量"两根箭头有多同向":
import torch
a = torch.tensor([1.0, 0.0]) # 指向正右
b = torch.tensor([0.0, 1.0]) # 指向正上
c = torch.tensor([1.0, 1.0]) # 指向右上 45°
print(torch.dot(a, b)) # 0.0 —— 垂直,毫不相关
print(torch.dot(a, c)) # 1.0 —— 有点同向
print(torch.dot(a, a)) # 1.0 —— 和自己最同向
点积 = 0 就是"垂直/正交/无关",点积大就是"方向相近"。 记住这个直觉——它会在第 6 节"神经网络只是点积吗"里变成主角。
2. 矩阵就是映射:把空间整体搬动
别把矩阵看成"一堆数字排成方块",把它看成"对空间里所有点施加的同一个动作"。 喂几个点进去,看它们被搬到哪:
import torch
M = torch.tensor([[2.0, 0.0],
[0.0, 3.0]]) # 横向拉 2 倍,纵向拉 3 倍
points = torch.tensor([[1.0, 0.0], # 右
[0.0, 1.0], # 上
[1.0, 1.0]]) # 右上角
print(points @ M.t())
# [[2, 0], [0, 3], [2, 3]] —— 每个点都被同一条规则搬动
换一个矩阵,就是换一个动作。下面四台机器:旋转、剪切、投影、放大:
import torch, math
theta = math.radians(30)
rotate = torch.tensor([[math.cos(theta), -math.sin(theta)], # 转 30°
[math.sin(theta), math.cos(theta)]])
shear = torch.tensor([[1.0, 1.0], # 剪切:越往上越往右滑(像推一摞书)
[0.0, 1.0]])
project = torch.tensor([[1.0, 0.0], # 投影:所有点拍扁到 x 轴
[0.0, 0.0]])
v = torch.tensor([1.0, 2.0])
print(rotate @ v) # 转了角度,长度不变
print(shear @ v) # x 被 y 拖着走
print(project @ v) # y 变 0,二维塌成一维
把一整张网格喂进去,你就能亲眼看见空间被扭曲:
import torch
import matplotlib.pyplot as plt
xs, ys = torch.meshgrid(torch.linspace(-2, 2, 11),
torch.linspace(-2, 2, 11), indexing="xy")
grid = torch.stack([xs.reshape(-1), ys.reshape(-1)], dim=1)
M = torch.tensor([[1.0, 0.8],
[0.3, 1.2]])
moved = grid @ M.t()
fig, ax = plt.subplots(1, 2, figsize=(10, 5))
ax[0].scatter(grid[:, 0], grid[:, 1]); ax[0].set_title("原始空间")
ax[1].scatter(moved[:, 0], moved[:, 1]); ax[1].set_title("被矩阵 M 映射后")
plt.show()
# 横平竖直的网格,被整体"斜拉"成平行四边形网格
这就是线性代数最该记住的一张图:直线还是直线、原点还是原点,但整个空间被均匀地拉斜了。
3. 行列式 = 面积的缩放倍数
老师让你背"按行展开""余子式""代数余子式",背完你还是不知道行列式是什么。 其实它简单到一句话:行列式 = 这台机器把面积放大/缩小了几倍。
取单位正方形(面积 1)的两条边,用矩阵搬动它们,搬完后围出的面积,正好等于行列式:
import torch
M = torch.tensor([[2.0, 1.0],
[0.0, 3.0]])
e1 = torch.tensor([1.0, 0.0])
e2 = torch.tensor([0.0, 1.0])
a, b = M @ e1, M @ e2 # 两条边被搬动后
area = abs(a[0] * b[1] - a[1] * b[0]) # 平行四边形面积
print("搬动后面积:", area.item()) # 6.0
print("行列式: ", torch.det(M).item()) # 6.0 —— 完全一样
det = 2:面积变 2 倍。det = 1:面积不变(旋转就是,它只转不拉)。det = 0:面积被压成 0,整个平面被拍扁成一条线,信息丢了,再也回不来。
det = 0 就是课本里那个抽象的"矩阵不可逆/奇异"。它一点不抽象——
就是你把立体拍成了影子,没法从影子反推回立体。
import torch
project = torch.tensor([[1.0, 0.0],
[0.0, 0.0]]) # 拍扁到 x 轴
print(torch.det(project).item()) # 0.0 —— 拍扁了,不可逆
4. 特征向量:映射里"不改方向"的那几根轴
这是线性代数最被神化、也最被讲烂的概念。其实就一句话:
大多数点被矩阵搬动后方向会变;但总有那么几根特殊方向,搬动后方向不变,只是被拉长或缩短。 这几根方向就是特征向量,拉伸的倍数就是特征值。
代码找出来并验证:
import torch
M = torch.tensor([[2.0, 1.0],
[1.0, 2.0]])
vals, vecs = torch.linalg.eig(M)
print("特征值:", vals.real) # [1., 3.]
v = vecs[:, 1].real # 取特征值 3 对应的特征向量
print("M @ v :", M @ v) # 等于 3*v
print("3 * v :", 3 * v) # 方向完全一样,只是长度 ×3
M @ v 和 3 * v 一模一样:方向没动,只是被拉长了 3 倍。
特征向量是这台机器的"骨架方向"。 任何复杂的线性变换,
都可以理解成"沿着这几根骨架,各自拉伸不同倍数"。PCA 降维、协方差分析、PageRank,全靠它。
🤔 疑惑点一:人类识别的"特征",和矩阵的"特征值/特征向量"是一回事吗?
不是。它们连英文都不是同一个词,是中文翻译把两个毫不相干的概念都叫成了"特征"。
一句话先把它们分开:
- 人脸的"特征"(feature)= 描述这个东西长什么样。 高鼻梁、大眼睛、爱笑—— 是名词的属性,回答的是"它是什么样的"。
- 矩阵的"特征"向量(eigenvector)= 描述一个变换动作里,哪个方向不会被带偏。 回答的是"这台机器在怎么动",和东西长什么样毫无关系。
打个比方就懂了——eigenvector 就像旋转的地轴:
地球在自转,地表上几乎每一个点都在被甩着转圈。但有一根方向是例外—— 穿过南北极的那根地轴,方向始终不变。这根"转不动的轴",就是这个旋转变换的特征向量。 它描述的是"地球在怎么转",而不是"地球上有什么"(海洋、山脉那才是 feature)。
换成第 2 节"矩阵就是揉空间"的画面:大多数箭头被矩阵一搬,方向都会偏;
只有那么几根特殊方向的箭头,搬完只是被拉长/缩短,方向纹丝不动。 那几根就是特征向量。
还是上面那个 M,亲手验证一下"拉伸方向不变":
import torch
M = torch.tensor([[2.0, 1.0],
[1.0, 2.0]])
a = torch.tensor([1.0, 0.0]) # 一根"歪着"的普通箭头
print("普通方向被搬后:", M @ a, " <- (2,1),方向偏了")
v = torch.tensor([1.0, 1.0]) # 正好顺着拉伸轴的箭头
print("特征方向被搬后:", M @ v, " <- (3,3),还在同一条线上,只是长 ×3")
普通箭头 (1,0) 被搬到 (2,1)——方向歪了;而顺着对角线的 (1,1) 被搬到 (3,3),
还在原来那条线上,只是被拉长了 3 倍。这个"3"就是特征值。
光看数字不过瘾——跑一个 3Blue1Brown 风格的动画,亲眼看它发生:
python eigenvector_visualization.py
画面里,整个空间(灰色网格)被这个矩阵慢慢揉斜。注意看那一圈灰色"探针"箭头: 它们几乎都被转离了自己出发的方向。唯独红、蓝两根粗箭头—— 它们死死贴在自己的虚线上,自始至终不转向,只是变长: 蓝色那根长度不变(特征值 1),红色那根被拉长到 3 倍(特征值 3)。 同时那个单位圆被压成一个椭圆,椭圆的长短轴方向,正好就是这两根特征向量。
这就是"特征向量 = 变换里不被带偏的那几根轴"最直观的一幕。
所以:
| 人脸"特征" | 矩阵"特征"向量 | |
|---|---|---|
| 英文 | feature | eigenvector(eigen=德语"自身固有的") |
| 描述的是 | 一个东西长什么样(属性) | 一个变换怎么动(哪根轴不被带偏) |
| 例子 | 高鼻梁、爱笑、身高 178 | 旋转的地轴、拉伸的方向 |
| 谁在用 | 你看脸、神经网络提特征 | PCA、振动分析、PageRank |
记住这一条就够了:feature 是"长什么样",eigenvector 是"往哪个方向不被带偏"。 两个八竿子打不着,只是倒霉地共用了"特征"两个字。
彩蛋:它们唯一会握手的地方——PCA。
有意思的是,有一个场景能让这两个"特征"恰好对上号,就是 PCA(主成分分析)。 道理很简单:人类想找的"语义特征方向",正好等于"数据这朵点云散得最开的方向", 而那个方向,就是数据协方差矩阵的最大特征向量。 一段代码看它对上号:
import torch
# 造一批"又高又重"的人:身高和体重强相关,点云挤成一条斜带子
torch.manual_seed(0)
h = torch.randn(500) * 10 + 170 # 身高
w = h * 0.6 + torch.randn(500) * 2 # 体重 ≈ 0.6×身高 + 噪声
data = torch.stack([h, w], dim=1)
data = data - data.mean(0) # 去均值
cov = (data.T @ data) / len(data) # 协方差矩阵
vals, vecs = torch.linalg.eigh(cov)
print("最大特征向量(点云最长的方向):", vecs[:, -1]) # 指向"又高又重"那条斜线
输出那根最大特征向量,指向身高体重点云拉得最长的那条斜线。人类一看就给它起个名: "体型大小"——一个能听懂的 feature。
一句话收尾:eigenvector 是数学算出来的"轴",feature 是人类给这根轴起的"名"。 算法找轴,人类命名,这才是两个"特征"唯一的交集。
顺带提一句:神经网络里
nn.Linear提取的"特征",是 feature 那个意思—— 它在学一组新的坐标方向,把数据投影上去量分量。见下一节。
5. nn.Linear 就是一次矩阵映射(线性代数和深度学习的接头)
现在把前四节的画面,对接到 PyTorch 最基础的 nn.Linear:
import torch
import torch.nn as nn
x = torch.tensor([[2.0, 3.0]]) # 1 个样本,2 个特征
layer = nn.Linear(2, 1) # 2 维输入 -> 1 维输出
W, b = layer.weight, layer.bias # W 形状 (1,2),b 形状 (1,)
manual = x @ W.t() + b # PyTorch 内部做的事
print(torch.allclose(layer(x), manual)) # True
nn.Linear(2, 1) 就是一次矩阵乘法加偏置,一点不神秘。
看高维一点的——nn.Linear(3, 128):
import torch.nn as nn
layer = nn.Linear(3, 128)
print(layer.weight.shape) # torch.Size([128, 3])
它是一个 128 × 3 的矩阵,把 3 维输入映射到 128 维空间。
矩阵的每一行是一个 3 维方向;输入和这一行做点积,就是把输入投影到这个方向上,
量出"输入在这个方向有多少分量"。128 行 = 从 128 个角度同时打量同一个输入。
为什么要升维?因为在低维挤成一团、分不开的数据,升到高维往往一刀就能切开:
import torch
# 同心内外圈,在 2D 平面无法用一条直线分开
theta = torch.linspace(0, 2 * 3.14159, 100)
inner = torch.stack([torch.cos(theta), torch.sin(theta)], 1) * 1.0
outer = torch.stack([torch.cos(theta), torch.sin(theta)], 1) * 2.0
def lift(p): # 升维:加一维 z = x^2 + y^2(半径平方)
z = (p ** 2).sum(1, keepdim=True)
return torch.cat([p, z], 1)
print("内圈 z 均值约:", lift(inner)[:, 2].mean().item()) # ≈ 1
print("外圈 z 均值约:", lift(outer)[:, 2].mean().item()) # ≈ 4
# 升到 3D 后,内外圈在 z 轴上彻底分开,一个平面 z=2.5 就切开了
nn.Linear 升维 + 激活函数折弯,合起来就干一件事:把搅在一起的数据,
搬到一个能被简单切开的新空间。 训练,就是在学"往哪个空间搬"。
🤔 疑惑点二:大模型、神经网络,说穿了就是在做"向量点积"吗?
大体上,是的——但"点积"这两个字撑起了整座大厦。 我们一层层拆。
第一层:单个神经元 = 一次点积。
import torch
x = torch.tensor([0.5, 0.2, 0.9]) # 输入向量
w = torch.tensor([1.0, -2.0, 0.5]) # 一个神经元的权重
b = 0.1
print("神经元输出:", torch.dot(x, w) + b) # 一次点积 + 偏置,仅此而已
第二层:一个 Linear 层 = 一堆点积打包成矩阵乘法。
import torch
x = torch.randn(4, 3) # 4 个样本,每个 3 维
W = torch.randn(8, 3) # 8 个神经元,每个 3 维权重
out = x @ W.t() # 一次性算完 4×8=32 个点积
print(out.shape) # (4, 8)
矩阵乘法的本质,就是左边每一行和右边每一列做点积。GPU 拼命优化的,就是这个。
第三层:连 Transformer 的注意力(Attention),核心也是点积。 大模型最性感的那个机制, 其实就是"每个词去和别的词做点积,看谁和谁最相关"——回想第 1 节:点积衡量两根箭头多同向:
import torch
import torch.nn.functional as F
# 3 个词,每个用 4 维向量表示
Q = torch.randn(3, 4) # Query
K = torch.randn(3, 4) # Key
V = torch.randn(3, 4) # Value
scores = Q @ K.t() # 每个词和每个词做点积 = "相关度矩阵"
attn = F.softmax(scores, dim=-1) # 归一化成权重
out = attn @ V # 按相关度加权求和
print("注意力输出形状:", out.shape) # (3, 4)
Q @ K.t() 就是在算"谁和谁同向/相关"。整个 GPT,堆的就是这套
点积 → 加权 → 再点积。
那为什么不只是点积? 因为纯点积/纯矩阵乘法叠多少层都还是线性的(多个映射可以合并成一个), 表达不了曲线。所以每层后面要插一个非线性(ReLU 等)"折一下",才打破线性封闭性:
import torch
import torch.nn as nn
# 纯线性叠两层,等价于一层 —— 用代码证明
torch.manual_seed(0)
x = torch.randn(5, 3)
a, b = nn.Linear(3, 4), nn.Linear(4, 2)
two = b(a(x))
W = b.weight @ a.weight # 合并成一台机器
one = x @ W.t() + (b.weight @ a.bias + b.bias)
print(torch.allclose(two, one, atol=1e-6)) # True —— 叠了也白叠
所以最准确的回答是:
神经网络 = 大量点积(线性代数)+ 少量折弯(非线性)。 点积负责"在新空间里量分量、算相关性",折弯负责"让模型能表达曲线"。 大模型的"智能",是几千亿个点积在超高维空间里协作的涌现结果—— 单看每一步,确实平平无奇,就是点积。
🤔 疑惑点三:离散的字/像素/整数,怎么变成"连续可导"的?
这是初学者最大的困惑:梯度下降要求对参数可导,可现实世界全是离散的——
一个字、一个像素灰度、一个运算符 +,怎么"求导"?
答案分两步:①把离散对象塞进连续空间(embedding);②对离散的"选择"动作做软化(softmax/温度)。
第一步:embedding——给每个离散符号发一个可学习的连续向量。 字本身不可导,但"代表这个字的那串浮点数"可导。
import torch
import torch.nn as nn
# 词表里 10 个词,每个词用一个 4 维连续向量表示
emb = nn.Embedding(num_embeddings=10, embedding_dim=4)
ids = torch.tensor([1, 5, 9]) # 三个离散的词 id(不可导)
vecs = emb(ids) # 查表 -> 连续向量(可导!)
print(vecs.shape) # (3, 4)
print(vecs.requires_grad) # True —— 这些浮点数能被梯度更新
nn.Embedding 本质就是一张可学习的查找表:离散 id 进去,连续向量出来。
训练时,梯度回传去微调这张表里的浮点数。"猫"和"狗"这两个离散符号的语义远近,
就编码在它们对应向量的点积里(又是点积!)。
第二步:argmax 不可导,就用 softmax 这个"软化版"替身。
"在 10 个词里挑概率最大的那个"是 argmax——一个阶梯式的硬选择,梯度处处为 0,没法学。
softmax 把它软化成连续的概率分布,于是可导:
import torch
logits = torch.tensor([2.0, 1.0, 0.1], requires_grad=True)
hard = logits.argmax() # 硬选择:返回索引 0,不可导
soft = torch.softmax(logits, dim=0) # 软选择:连续概率 [0.66, 0.24, 0.10]
print("硬:", hard, " 软:", soft)
soft.sum().backward() # 软版本能求导
print("softmax 可以回传梯度:", logits.grad is not None) # True
温度参数还能在"软"和"硬"之间连续滑动——温度→0 时 softmax 逼近 argmax:
import torch
logits = torch.tensor([2.0, 1.0, 0.1])
for T in [3.0, 1.0, 0.1]:
p = torch.softmax(logits / T, dim=0)
print(f"温度 {T}: {p.numpy().round(3)}") # T 越小越接近 one-hot 硬选择
光看数字还是抽象——跑一个动画,亲眼看"硬台阶"怎么变成"可导的斜坡":
python discrete_to_differentiable_visualization.py
这是整段最该"看见"的一幕。"可导"到底意味着什么?意味着曲线上每一点都有一条切线(斜率), 梯度下降就靠这个斜率知道往哪走。
- 左图:灰色虚线是
argmax——一个硬台阶。台阶到处是平的、在跳变处又直接竖起来, 画不出切线 = 不可导,梯度下降在它上面彻底瞎了。蓝色是softmax——一条光滑斜坡, 随便哪一点都能画出切线(图里那条红色切线),切线的斜率就是梯度。- 右图:把两者的梯度画出来对比。
argmax的梯度恒为 0(一条贴着零的虚线,无坡可下、学不动);softmax的梯度是一个非零的鼓包——这就是反向传播能跟着走的"坡"。- 拖动温度 T 从大到小,蓝色斜坡越变越陡、越来越逼近那个硬台阶, 但只要
T>0,它始终是光滑可导的。这正是"用一个连续旋钮,在可导的软选择和不可导的硬选择之间滑动"。一句话:softmax 干的事,就是把"不可导的台阶"换成"处处有坡度的斜坡",于是梯度有了方向,离散选择就能被训练了。
这就是"离散变连续可导"的全部魔法:
用 embedding 把离散符号搬进连续向量空间(可以求导的浮点数), 用 softmax/温度把离散的"选择"动作软化成连续概率(可以求导的分布)。 训练完,再用 argmax 把连续概率"硬化"回离散的字—— 训练时连续可导,推理时离散输出。 两头都要,中间靠这两招缝合。
像素其实天生就是连续的(0–255 归一化成 0–1 的浮点),所以图像神经网络 一开始就不太有这个烦恼;真正难的是文字、类别、动作这类纯符号的东西。
6. 训练,就是把"搬空间的机器"一点点调好(线性代数遇上微积分)
前面所有矩阵都是手写死的。训练,就是让模型自己学出这些矩阵该长什么样。 把第 2~5 节缝进一个最小训练循环:
import torch
import torch.nn as nn
# 目标:学一个 2x2 矩阵,把输入"旋转 90°"
torch.manual_seed(0)
target = torch.tensor([[0.0, -1.0],
[1.0, 0.0]]) # 真正的旋转矩阵(模型不知道)
x = torch.randn(200, 2)
y = x @ target.t() # 标准答案
model = nn.Linear(2, 2, bias=False) # 模型:一个待学习的 2x2 矩阵
opt = torch.optim.Adam(model.parameters(), lr=0.05)
for step in range(300):
pred = model(x)
loss = ((pred - y) ** 2).mean() # 搬得对不对
opt.zero_grad(); loss.backward(); opt.step()
print("学到的矩阵:\n", model.weight.detach().round(decimals=2))
print("目标矩阵:\n", target) # 两者几乎一样 —— 模型"猜出"了旋转
跑完你会看到 model.weight 收敛到那个旋转矩阵。
这就是深度学习的全部:用梯度下降,把一台台"搬空间的机器"调到正好把数据搬对位置。
线性代数提供"机器",微积分提供"怎么调"。
🤔 疑惑点四:GPU 是不是就是一台"大型向量机"?
基本上,是的——而且这个直觉惊人地准确。 但要补一个关键修正。
CPU 像一个博士:单核极聪明,能处理复杂分支逻辑,但一次只干一件事(核心少)。 GPU 像几千个小学生:每个都只会做简单算术,但几千个同时做同一道题。 而矩阵乘法——第 6 节我们反复在做的——恰好就是"对成千上万个数,同时做同样的乘加"。 这正是 GPU 的主场:
import torch, time
# 一个 4096x4096 的矩阵乘法 = 约 1370 亿次乘加
A = torch.randn(4096, 4096)
B = torch.randn(4096, 4096)
t = time.time(); _ = A @ B; cpu_t = time.time() - t
print(f"CPU 矩阵乘法耗时: {cpu_t*1000:.1f} ms")
if torch.cuda.is_available():
Ag, Bg = A.cuda(), B.cuda()
torch.cuda.synchronize()
t = time.time(); _ = Ag @ Bg; torch.cuda.synchronize()
gpu_t = time.time() - t
print(f"GPU 矩阵乘法耗时: {gpu_t*1000:.1f} ms") # 通常快几十到上百倍
print(f"加速比: {cpu_t/gpu_t:.0f}x")
为什么差这么多?因为矩阵乘法的每个输出元素,都是互不依赖的一次点积, 天然可以并行。GPU 有几千个核心,一口气把几千个点积同时算了:
import torch
# 矩阵乘法 = 一大批彼此独立的点积,谁也不等谁 -> 完美并行
A = torch.randn(3, 4)
B = torch.randn(4, 5)
manual = torch.stack([torch.stack([torch.dot(A[i], B[:, j]) # 每个格子一个点积
for j in range(5)])
for i in range(3)])
print(torch.allclose(manual, A @ B)) # True —— matmul 就是一堆并行点积
需要补的那个修正: 现代 GPU 不只是"向量机",更准确说是"矩阵机/张量机"。 NVIDIA 的 Tensor Core 是直接为"小矩阵块乘加"定制的硬件电路, 比起一个数一个数地算向量,它一个时钟周期就吞下一整块矩阵。所以更精确的说法是:
GPU 是一台大规模并行的"张量计算机"。 它的存在意义,就是把第 6 节那种"成千上万个互相独立的点积/矩阵乘"在同一瞬间算完。 深度学习之所以在 2012 年后爆发,硬件上就一句话: 有人发现,神经网络的计算 = 海量矩阵乘法 = 正好是 GPU 最擅长的事。
线性代数(矩阵乘法)、神经网络(堆叠点积)、GPU(并行算点积)—— 三者是同一件事在数学、模型、硬件三个层面的三张面孔。
🎬 动手 A:一张纸变形的故事 —— 深度学习在学"空间变换"
前面所有矩阵都是手写死的。这一节,我们让网络自己学出一个变换,并且把它动起来看。整个故事分两幕。
把一张脸画在一张橡皮纸上。捏一捏纸的不同部位,脸就有了不同表情—— 嘴角往上一推是微笑,往下一拉是沮丧,把下巴拽下来、眉眼上挑就是惊讶。 每一种表情,本质都是对整张纸的一次变形,也就是一次空间变换—— 第 2 节说的"矩阵就是把整个空间搬动的机器",只不过这里的揉法是非线性的(真实人脸之间的变化也不是简单旋转拉伸)。
第一幕:先看清"表情 = 纸的变形"。 每个表情就是一个作用在平面上的位移场,捏哪、捏多大,决定了哪种表情:
import numpy as np
def expr_field(p, kind, a): # 把平面上的点按某种表情、强度 a 揉一下
x, y = p[:, 0].copy(), p[:, 1].copy()
mouth = np.exp(-((y + 0.7) ** 2) / 0.18) # 嘴巴附近的区域权重
eye = np.exp(-((y - 0.45) ** 2) / 0.10) # 眼睛附近的区域权重
dy = np.zeros_like(y)
if kind == "smile": dy += a * ( 0.9 * x**2 - 0.18) * mouth # 嘴角上扬
elif kind == "sad": dy += a * (-(0.9 * x**2 - 0.18)) * mouth # 嘴角下垂
elif kind == "surprise": # 张嘴 + 挑眉
dy += a * (-0.55) * mouth * np.exp(-(x**2) / 0.30)
dy += a * 0.22 * eye
return np.stack([x, y + dy], 1)
# 同一张脸,三个不同的 kind,就是三种表情 —— 都只是"纸怎么揉"的区别
动画的第一幕,就是依次把这张脸捏成 微笑 → 沮丧 → 惊讶,让你先建立"表情就是空间变换"的直觉。
第二幕:让深度学习自己学会其中一种变换。 现在反过来——只给网络看"纸上每个点揉之前 / 揉之后的位置", 让它把这套揉纸规则自己反推出来。网络学的是"平面上任意一点该搬到哪":
import numpy as np, torch, torch.nn as nn
# 让网络去学的"复合表情"(又惊又喜)—— 网络看不到这个函数,只能从数据里猜
def target_warp(p):
return expr_field(expr_field(p, "smile", 1.0), "surprise", 0.7)
# 数据:纸上随机撒点,记录"揉之前 -> 揉之后"
X = np.random.uniform(-1.7, 1.7, (3000, 2)).astype("float32")
Y = target_warp(X).astype("float32")
Xt, Yt = torch.tensor(X), torch.tensor(Y)
# 一个小网络去学这套规则:输入 2 维坐标 -> 输出 2 维新坐标
net = nn.Sequential(nn.Linear(2, 64), nn.Tanh(),
nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, 2))
opt = torch.optim.Adam(net.parameters(), lr=0.01)
for step in range(3000):
loss = ((net(Xt) - Yt) ** 2).mean()
opt.zero_grad(); loss.backward(); opt.step()
# 训练完,net 就成了那套揉纸规则:喂任意点进去,它知道该搬到哪
学会之后,把这套规则同时作用在 ① 脸的点 和 ② 整张纸的网格线 上: 你会看到脸被正确地揉成目标脸,而整张纸(网格)也跟着一起均匀地扭曲—— 这正是"矩阵就是映射、把整个空间搬动"那句话的动态实证。
完整的动画脚本在仓库里,直接跑就能看到训练过程中纸一帧帧地变形:
python face_morph_paper_transform.py
画面里你会看到两幕: 第一幕,同一张脸被依次捏成微笑、沮丧、惊讶,网格(纸)跟着局部鼓起/凹陷—— 让你先认定"表情 = 纸的变形 = 空间变换"。 第二幕,一开始网络乱揉一通(随机初始化),脸是一团乱点; 随着 loss 下降,网格线慢慢"长"成目标形状,红色的脸点逐渐贴合灰色的目标脸。 深度学习的"学习",肉眼可见地就是在学这张纸该怎么揉。
🎬 动手 B:亲眼看着"权重"被改 —— 线性回归的动态可视化
疑惑了半天"权重到底在变什么",不如把它放慢镜头看一遍。
最简单的网络只有两个参数:斜率 w 和截距 b(y = w·x + b)。训练就是不停微调这两个数。
import torch
# 数据服从 y = 2x + 1(真实权重 w=2, b=1),故意从离谱的 w=0,b=0 起步
x = torch.linspace(-3, 3, 60).unsqueeze(1)
y = 2 * x + 1 + torch.randn(60, 1) * 0.7
w = torch.tensor([[0.0]], requires_grad=True)
b = torch.tensor([0.0], requires_grad=True)
lr = 0.03
for step in range(120):
loss = ((x @ w + b - y) ** 2).mean()
loss.backward()
with torch.no_grad(): # 这两行就是 optimizer.step() 在做的事
w -= lr * w.grad
b -= lr * b.grad
w.grad.zero_(); b.grad.zero_()
print(f"step {step}: w={w.item():.3f} b={b.item():.3f} loss={loss.item():.3f}")
# w 从 0 一路爬到 ≈2,b 从 0 爬到 ≈1 —— 这串数字就是"权重被修改"的全过程
仓库里的动画脚本把这串数字画成了双面板动图:
python linear_regression_weight_animation.py
- 左面板:数据点 + 当前拟合直线。你会看到红线从一条平躺的水平线,一帧帧转动、上移,直到压在数据中间。
- 右面板:一张
loss随(w, b)变化的地形等高线图,红点(当前权重)拖着一条白色轨迹, 从山坡上一步步滚向谷底那颗黄星(真正的w=2, b=1)。
这就是"训练"最朴素的真相:右面板那个红点的移动,就是 optimizer.step() 在真实地改写权重;
它走的每一步方向,都是第 5 节讲的"负梯度方向"。
把动手 A 和动手 B 连起来看——A 让你看到网络学到的变换有多复杂,B 让你看清这个变换是怎样一个数一个数被调出来的。
7. 把所有画面缝合起来
最后回到开头那句话,现在每个概念都有了画面和代码出处:
| 线性代数概念 | 课本怎么讲(抽象) | 这篇文章怎么看(画面) | 在深度学习里是什么 |
|---|---|---|---|
| 向量 | 一列数 | 空间里一根有方向的箭头(第 1 节) | 一个样本 / 一个词的 embedding |
| 点积 | Σ 对应相乘 | 两根箭头有多同向(第 1 节) | 神经元、注意力相关度(疑惑点二) |
| 矩阵 | 数表 + 运算法则 | 把整个空间搬动的机器(第 2 节) | nn.Linear / 权重矩阵(第 5 节) |
| 行列式 | 按行展开 | 面积被放大几倍,0 = 拍扁不可逆(第 3 节) | 衡量映射是否丢信息 |
| 特征向量 | 解 det(A-λI)=0 |
映射里方向不变的骨架轴(第 4 节) | PCA / 数据主方向(疑惑点一) |
| 矩阵乘法 | 行乘列 | 一堆独立点积,可并行(疑惑点四) | GPU 上的核心运算 |
三句话总结这篇文章:
线性代数的灵魂是"矩阵就是映射"——一台把空间整体搬动的机器(第 2、3 节); 神经网络的灵魂是"海量点积"——在高维空间里量分量、算相关,再用非线性折弯(疑惑点二); 而离散变可导(embedding + softmax)让符号也能进这套机器,GPU 让这套机器一瞬间跑完(疑惑点三、四)。
当年线性代数没讲明白,不是因为它难,是因为没人告诉你: 这些符号,最后全都活在一段能跑起来的 PyTorch 代码里。 现在你把上面每段代码都跑一遍,比期末复习四个晚上都管用。