AI 学习课程 / LESSON 01

第一节:张量与必要基础

你已经知道向量、矩阵和矩阵乘法。这一节从这些知识出发,理解 AI 中的数据怎样组织,以及怎样读懂一段计算的输入与输出。

从矩阵出发可操作的例子配套 NumPy 练习

01 / 从已有知识建立连接

学完后,你应该能做什么?

过去你可能写过 y = Xw + b。现在我们要能解释:X 有几个轴,每个轴表示什么,为什么它能与 w 相乘,以及结果的形状是什么。

  • 用自己的话解释张量、轴与形状。
  • 读懂表格数据和图像批次的形状。
  • 判断索引、切片、reshape、转置与广播的结果。
  • 用 NumPy 组织数据,训练已学过的线性回归,并读懂损失曲线。
建议顺序:先读张量与形状,操作每个实验,再运行配套代码。数学基础检查可以按掌握情况展开阅读。

02 / 从一张表到一叠表

张量:用多个轴组织数值

在 NumPy 和 PyTorch 的入门计算语境中,可以先把张量理解为多维数值数组。向量和矩阵已经是你熟悉的例子:向量有一个轴,矩阵有两个轴;把多个同样大小的矩阵叠起来,就得到三个轴的数组。

严格数学中的张量还涉及坐标变换等定义,本节先学习编程和模型计算所需的数组概念。

对象例子轴数 / ndim形状 / shape
标量70()
向量[1, 2, 3]1(3,)
矩阵2 行 3 列2(2, 3)
三维数组2 张“2 行 3 列”的表3(2, 2, 3)
“维”有两个常见意思。线性代数中,“3 维向量”指向量有 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 开始。: 表示取这个轴上的全部元素;整数索引通常消去一个轴,切片通常保留这个轴。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)
[[1, 2, 3], + [[10, 20, 30], = [[1+10, 2+20, 3+30], [4, 5, 6]] [40, 50, 60]] [4+40, 5+50, 6+60]]

这里没有混合不同位置的数,也没有把每一行加起来。结果依然是 (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]),让这三个加数在每一行重复使用。

把两步连起来:先理解“相同形状,逐位置运算”,再理解“用广播,让不同但兼容的形状也能逐位置运算”。所以并不是张量只能和相同形状的张量运算;形状不同能否广播,要看下面的规则。这里讨论的是逐元素运算,矩阵乘法另有规则。
广播规则:从最右侧的轴开始对齐;对应长度相等,或其中一个为 1,才兼容。较短形状缺少的左侧轴视为长度 1。比如 (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 数值。

形式化表达:每个位置究竟加了哪个数?

以下固定示例使用默认加数 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 中 @ 表示矩阵乘法;* 是逐元素乘法,遵循广播规则。二者的计算含义不同。

X: (32, 10)   @   W: (10, 1)
→ 预测: (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,遍历 ij=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,遍历 ji=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。改变学习率后,点击训练,观察直线和损失的变化。

预测直线与目标数据

预测直线与目标数据横轴 x,纵轴 y;圆点是目标值,蓝色直线是当前模型预测。纵轴随预测值调整。

当前训练过程的 MSE

均方误差训练曲线横轴为训练步数,纵轴为均方误差,使用线性刻度。

配套 Python 文件

下载 NumPy 练习代码。代码包含张量与切片示例、reshape 与转置、广播检查、线性回归训练和两幅图的导出。

# 在终端进入项目根目录后运行(项目依赖已由 uv 管理):
uv run python lessons/lesson-01-practice.py

运行后在代码文件旁的 lesson-01-output 目录生成 loss.png 与 regression.png。本页实验采用相同的全批量梯度下降公式,浏览器本身不运行 Python。

动手任务

  1. 创建形状 (2, 3, 4) 的数组,打印 shape、ndim、size,并解释三个轴。
  2. 预测 X[1]、X[:, 0]、X[:, 0:1] 的形状,再运行验证。
  3. 比较 A.reshape(3,2) 与 A.T 的元素排列。
  4. 把预测和标签刻意改成不一致的形状,观察错误广播,再修正。
  5. 用 0.01、0.05、0.20 三种学习率做实验,记录损失变化。不要只看一次训练的最后一个数字。

09 / 用输出检验理解

先作答,再查看反馈

1. 形状为 (8, 3, 32, 32) 的数组有几个轴、多少个元素?

2. X.shape = (5, 2),X[:, 0:1] 的形状是什么?

3. X: (16, 4),W: (4, 3),X @ W 的形状是什么?

4. (5, 1) 的预测减去 (5,) 的标签,结果是什么?

5. 对 A = [[1,2,3],[4,5,6]],哪句话正确?

完成标准

下一节:第二节:模型怎样学习。我们会在这节的数组与梯度基础上,进一步讲解交叉熵、小批量训练和反向传播。