01 / 从已有知识建立连接
学完后,你应该能做什么?
过去你可能写过 y = Xw + b。现在我们要能解释:X 有几个轴,每个轴表示什么,为什么它能与 w 相乘,以及结果的形状是什么。
- 用自己的话解释张量、轴与形状。
- 读懂表格数据和图像批次的形状。
- 判断索引、切片、reshape、转置与广播的结果。
- 用 NumPy 组织数据,训练已学过的线性回归,并读懂损失曲线。
02 / 从一张表到一叠表
张量:用多个轴组织数值
在 NumPy 和 PyTorch 的入门计算语境中,可以先把张量理解为多维数值数组。向量和矩阵已经是你熟悉的例子:向量有一个轴,矩阵有两个轴;把多个同样大小的矩阵叠起来,就得到三个轴的数组。
严格数学中的张量还涉及坐标变换等定义,本节先学习编程和模型计算所需的数组概念。
| 对象 | 例子 | 轴数 / ndim | 形状 / shape |
|---|---|---|---|
| 标量 | 7 | 0 | () |
| 向量 | [1, 2, 3] | 1 | (3,) |
| 矩阵 | 2 行 3 列 | 2 | (2, 3) |
| 三维数组 | 2 张“2 行 3 列”的表 | 3 | (2, 2, 3) |
(32, 10) 的数组有两个轴,是二维张量。实验 A:改变轴数,观察数据的组织
看到三维数组时,不必想象一个三维空间中的几何物体。它也可以表示“班级 × 学生 × 科目”,每个轴是组织数据的一种方式。
03 / 先问每个轴代表什么
形状、轴、元素数量与索引
shape 按顺序列出每个轴的长度;ndim 是轴的数量;size 是元素总数。对于形状 (2, 3, 4),轴数是 3,元素数量是 2 × 3 × 4 = 24。
对于矩阵 X.shape == (3, 2),我们约定第 0 轴是样本、第 1 轴是特征。形状本身不携带业务含义,必须结合数据约定解释。
import numpy as np
X = np.array([[1, 2], [3, 4], [5, 6]])
print(X.shape) # (3, 2)
print(X.ndim) # 2
print(X.size) # 6
X[1, 0] # 3:第 1 个样本的第 0 个特征
X[1] # [3, 4],shape = (2,)
X[:, 0] # [1, 3, 5],shape = (3,)
X[:, 0:1] # [[1], [3], [5]],shape = (3, 1)
: 表示取这个轴上的全部元素;整数索引通常消去一个轴,切片通常保留这个轴。0:1 包含位置 0,不包含位置 1。实验 B:索引是否保留轴?
原矩阵 X
运算结果
04 / 一次处理多个样本
批次与图像:四个轴各有用途
一个有 10 个特征的样本可以写成形状 (10,) 的向量。把 32 个同样格式的样本叠起来,得到 (32, 10),其中 32 是批次大小(batch size)。批次让模型一次处理多个样本,也方便把矩阵计算交给硬件。
| 数据 | 一种常见形状 | 轴的含义 |
|---|---|---|
| 表格数据 | (B, F) | 样本数、特征数 |
| 单张灰度图 | (H, W) | 高度、宽度 |
| 单张 RGB 图 | (3, H, W) | 通道、高度、宽度 |
| RGB 图像批次 | (B, 3, H, W) | 样本数、通道、高度、宽度 |
这里使用 PyTorch 图像模型常见的 NCHW 约定。有些图像工具使用 HWC 或 NHWC,所以应当检查约定,而不是根据轴数猜含义。
实验 C:一批 RGB 图像有多少个数?
单张 RGB 图的三个通道,可以分别看成三张数值表。下方只示意每张表中的 4 × 4 个值。
images[0, 1, 2, 3]:第 0 张图、绿色通道(索引 1)、第 2 行、第 3 列的一个值。实际数值范围取决于预处理方式。
05 / 改分组,还是交换轴?
reshape 和转置做的是两件事
reshape 改变元素的分组方式,元素数量必须保持相等。这里按 NumPy 默认的 C 顺序,也就是最后一个轴变化最快,将 [[1,2,3],[4,5,6]] 重新组织成 3 行 2 列。
转置交换轴:对于二维矩阵,新位置 A.T[j, i] 保存原来的 A[i, j]。这与重新分组不是同一个操作。
实验 D:同样是 (3, 2),值的排列相同吗?
原矩阵 A = [[1, 2, 3], [4, 5, 6]],形状为 (2, 3)。
A.reshape(3, 2)
A.T
点击任意一个元素,观察它在两种结果中的位置。
A = np.arange(1, 7).reshape(2, 3)
A.reshape(3, 2) # [[1, 2], [3, 4], [5, 6]]
A.T # [[1, 4], [2, 5], [3, 6]]
A.reshape(-1) # [1, 2, 3, 4, 5, 6],-1 让 NumPy 推断长度
A.reshape(3, -1) # 推断第二个轴的长度为 2
(H, W, C) 改成 (C, H, W),需要交换轴,例如 image.transpose(2, 0, 1)。直接 reshape 成相同形状,通常会混淆像素与通道的对应关系。06 / 在计算前先推断形状
广播与矩阵乘法
先看最直接的情况:相同形状,逐位置相加
先不考虑广播。两个张量做逐元素加法时,最直接的方式是让它们形状相同:每个位置都有一个对应的加数。比如,两张都是 (2, 3) 的表相加,就是第一行第一列加第一行第一列,其他位置也一样。
X = np.array([[1, 2, 3],
[4, 5, 6]]) # (2, 3)
B = np.array([[10, 20, 30],
[40, 50, 60]]) # (2, 3)
X + B
# [[11, 22, 33],
# [44, 55, 66]] # (2, 3)
这里没有混合不同位置的数,也没有把每一行加起来。结果依然是 (2, 3),每个位置只计算它自己的那一对数。逐元素减法、乘法也可以这样理解。
广播:让兼容形状参与逐元素运算
现在换一个需求:给 X 的每个数都加 10。沿用上面的方法,我们可以先写出一张同样形状的加数表:
X + np.array([[10, 10, 10],
[10, 10, 10]]) # 每个位置都有一个 10
但重复写六个 10 很麻烦。NumPy 允许直接写 X + 10:计算时,把这个 10 用到 X 的每个位置。这种让较小的加数适配较大张量形状的机制,就叫广播。你可以把它想成“先补成同样大小,再逐位置相加”,但 NumPy 不要求真的复制出那张加数表。
同理,如果给每一列分别加 10、20、30,完整的加数表应该是 [[10, 20, 30], [10, 20, 30]]。因为两行重复,可以简写为 X + np.array([10, 20, 30]),让这三个加数在每一行重复使用。
(2, 3) 和 (3,) 兼容;(2, 3) 和 (2,) 不兼容。实验 E:给这张表加什么?
X = [[1, 2, 3], [4, 5, 6]]
图解:给三个通道分别加 10、20、30
先把“通道”理解成三张数值表。一张 RGB 图像的每个像素有三个数:红色分量 R、绿色分量 G、蓝色分量 B。把所有像素的 R 放进一张表,G 放进第二张表,B 放进第三张表,就得到三个通道。
我们把图像缩小到 2 行 × 2 列,并准备两张图。整个数组的形状是 (2, 3, 2, 2):2 张图、每张 3 个通道、每个通道 2 行 2 列。
点击任意数字,观察这个位置在三个通道中的值。三个通道中相同行、列位置的数,共同构成一个像素的 RGB 数值。
为什么加数的形状是 (1, 3, 1, 1)?
先写出三个加数 [10, 20, 30],再给它们标明对应的轴。我们只给通道轴准备不同的加数;图、行、列这三个轴都重复使用已有的加数,所以长度设为 1。
长度 1 表示只有一个可供重复使用的位置,数字 3 表示有三个对应通道的位置。这里的 reshape(1, 3, 1, 1) 只重新组织三个加数,元素数量仍为 3。
切换到“图 1”,你会看到图里的数变了,但三个加数不变。再只改 G 通道的加数,观察 R、B 通道的结果保持原值。
形式化表达:每个位置究竟加了哪个数?
以下固定示例使用默认加数 10、20、30。用 b 表示图像索引,c 表示通道索引,h 表示行,w 表示列。加数数组 offset 的形状为 (1, 3, 1, 1):
offset[0, 0, 0, 0] = 10 # R 的加数
offset[0, 1, 0, 0] = 20 # G 的加数
offset[0, 2, 0, 0] = 30 # B 的加数
result[b, c, h, w] = images[b, c, h, w] + offset[0, c, 0, 0]
右边的加数只随 c 改变。无论第几张图、第几行、第几列,只要是同一个通道,就使用同一个加数。
images = np.arange(1, 25).reshape(2, 3, 2, 2) # 本页示例数据
offset = np.array([10, 20, 30]).reshape(1, 3, 1, 1)
result = images + offset
# 可以用下面的循环理解它的计算结果:
for b in range(2):
for c in range(3):
for h in range(2):
for w in range(2):
result[b, c, h, w] = (
images[b, c, h, w] + offset[0, c, 0, 0]
)
上面的循环用来说明对应关系,实际 NumPy 代码直接写一次加法即可。
矩阵乘法:收缩相匹配的内侧轴
你熟悉的二维矩阵乘法规则是 (m, n) @ (n, p) → (m, p)。NumPy 中 @ 表示矩阵乘法;* 是逐元素乘法,遵循广播规则。二者的计算含义不同。
→ 预测: (32, 1)
加 b: (1,) → 预测仍为 (32, 1)
这里 32 个样本各有 10 个特征,W 把每个样本的 10 个特征组合成一个预测。后面学习神经网络时,多输出线性层也是同样的形状规则。
实验 F:预测输出的形状
(32, 1),标签形状为 (32,),相减会广播成 (32, 32),不是每个样本各算一次误差。可以把标签改为 (32, 1),或把预测改为 (32,),并在计算损失前检查形状。07 / 按需补齐
数学基础检查
你已经学习过回归的数学原理,这里只检查后续反向传播和强化学习需要的概念。先尝试回答标题中的问题,再展开答案。
导数:f(x) = x²,在 x = 3 时的导数是多少?
f′(x) = 2x,因此 f′(3) = 6。导数描述局部变化率:在 3 附近,x 增加很小的 Δx,f 的变化约为 6Δx。
偏导与梯度:L(w, b) = (wx + b − y)²,怎样求梯度?
令误差 e = wx + b − y,固定样本 x、y,则 ∂L/∂w = 2ex,∂L/∂b = 2e。梯度是把各个参数的偏导组织成一个向量:∇L = [2ex, 2e]。对足够小的步长,沿负梯度方向移动会在局部降低损失;步长过大可能发散。
链式法则:L = z²,z = wx + b,怎样求 ∂L/∂w?
∂L/∂w = (∂L/∂z)(∂z/∂w) = 2z · x。反向传播正是沿计算关系逐步应用链式法则;遇到多条路径时,还需要把各路径贡献相加。
条件概率:为什么 P(A|B) 通常不等于 P(B|A)?
因为限定的样本范围不同。对于 P(B) > 0,P(A|B) = P(A∩B)/P(B)。例如“在下雨的日子里带伞的比例”和“在带伞的日子里下雨的比例”并不相同。
期望与方差:公平骰子的平均点数是多少?方差描述什么?
离散变量的期望为 E[X] = Σ p(x)x,公平骰子的期望是 (1+2+3+4+5+6)/6 = 3.5。期望不必是某次实际出现的值。方差 Var(X) = E[(X−E[X])²] 描述围绕期望的波动程度;骰子的方差是 35/12。
Python 准备:你能定义函数、循环,并读懂列表吗?
def square(x):
return x * x
values = [1, 2, 3]
for value in values:
print(square(value))如果这段代码读起来有困难,先补变量、列表、循环、函数、模块导入。Python 列表与 NumPy 数组也有区别:[1,2] * 2 是重复列表,np.array([1,2]) * 2 才是逐元素乘 2。
08 / 将概念落实到代码
NumPy 练习与线性回归实验
先不引入新模型,继续使用你熟悉的线性回归。我们用一个特征,保持输入、预测和标签都是二维数组,观察一次批量计算如何得到多个样本的预测。
mean(axis=n):固定其他轴的索引,只遍历第 n 个轴
axis=n 这个维度上的所有位置,把取到的数求平均。然后换一组其他维度的索引,再算下一个平均值。轴编号从 0 开始。这里“固定”指的是索引不变,不是张量里的数值不变。比如 A[i, j] 中,固定 j、遍历 i,就是依次读取 A[0, j]、A[1, j]……。这样理解,不论张量有几个轴都适用。
A = np.array([[1, 2, 3],
[4, 5, 6]]) # (2, 3),用 A[i, j] 取数
| 操作 | 固定什么,遍历什么? | 每个结果怎样算? |
|---|---|---|
A.mean(axis=0) | 固定 j,遍历 i | j=0:(1+4)/2 = 2.5 j=1:(2+5)/2 = 3.5 j=2:(3+6)/2 = 4.5 结果 [2.5, 3.5, 4.5],形状 (3,) |
A.mean(axis=1) | 固定 i,遍历 j | i=0:(1+2+3)/3 = 2 i=1:(4+5+6)/3 = 5 结果 [2., 5.],形状 (2,) |
所以不用背“axis=0 是按列、axis=1 是按行”。直接看索引:指定哪个轴,就让那个轴的索引变化,其他索引保持不变。
三个轴也用同一个方法
T = np.array([[[1, 2, 3],
[4, 5, 6]],
[[7, 8, 9],
[10, 11, 12]]]) # (2, 2, 3),用 T[i, j, k] 取数
计算 T.mean(axis=1):固定 i 和 k,遍历 j。例如固定 i=0、k=2,取到 T[0, 0, 2]=3 和 T[0, 1, 2]=6,平均值就是 4.5。换成 i=1、k=0,则取 7 和 10,平均值是 8.5。对每组 i、k 都这样做:
T.mean(axis=1)
# [[2.5, 3.5, 4.5],
# [8.5, 9.5, 10.5]] # (2, 3),结果用 [i, k] 索引
为什么形状少了一个轴?因为 j 的多个位置已经被合成一个平均值,结果不再需要 j 这个索引。默认 keepdims=False 会移除被平均的轴;keepdims=True 则保留它,长度变成 1。例如 T.mean(axis=1, keepdims=True) 的形状是 (2, 1, 3)。
如果写 A.mean(),不指定 axis,默认就把所有元素一起求平均:(1+2+3+4+5+6)/6 = 3.5,得到一个标量。
把这个思路用在线性回归里
import numpy as np
X = np.array([[0.], [1.], [2.], [3.], [4.]]) # (5, 1)
y = 2 * X + 1 # (5, 1)
W = np.zeros((1, 1)) # (1, 1)
b = np.zeros((1,)) # (1,)
learning_rate = 0.05
for step in range(100):
prediction = X @ W + b # (5, 1)
error = prediction - y # (5, 1)
loss = np.mean(error ** 2) # 标量
grad_W = 2 / len(X) * X.T @ error # (1, 1)
grad_b = 2 * error.mean(axis=0) # (1,)
W -= learning_rate * grad_W
b -= learning_rate * grad_b
error 的形状为 (5, 1),用 error[i, j] 索引。error.mean(axis=0) 就是固定输出索引 j,遍历样本索引 i。这里只有 j=0,所以取五个样本的误差 error[0, 0] 到 error[4, 0] 求平均,结果形状为 (1,),正好对应 b 的形状。
这里 MSE 对所有元素取平均,模型只有一个输出,因此 2 / len(X) 的梯度系数与这份损失定义相匹配。
实验 G:亲手推进梯度下降
目标数据满足 y = 2x + 1。初始参数 w = 0, b = 0。改变学习率后,点击训练,观察直线和损失的变化。
预测直线与目标数据
当前训练过程的 MSE
配套 Python 文件
下载 NumPy 练习代码。代码包含张量与切片示例、reshape 与转置、广播检查、线性回归训练和两幅图的导出。
# 在终端进入项目根目录后运行(项目依赖已由 uv 管理):
uv run python lessons/lesson-01-practice.py
运行后在代码文件旁的 lesson-01-output 目录生成 loss.png 与 regression.png。本页实验采用相同的全批量梯度下降公式,浏览器本身不运行 Python。
动手任务
- 创建形状
(2, 3, 4)的数组,打印 shape、ndim、size,并解释三个轴。 - 预测
X[1]、X[:, 0]、X[:, 0:1]的形状,再运行验证。 - 比较
A.reshape(3,2)与A.T的元素排列。 - 把预测和标签刻意改成不一致的形状,观察错误广播,再修正。
- 用 0.01、0.05、0.20 三种学习率做实验,记录损失变化。不要只看一次训练的最后一个数字。
09 / 用输出检验理解