A. Python、NumPy 与 PyTorch 最小入门
第一次运行机器学习代码,拦住我们的常常不是某个复杂算法,而是一行很普通的程序:为什么列表乘 2 没有把数字翻倍?为什么三份预测减去三份答案,突然冒出九个差值?
这份附录沿着第一章的打印任务,把这些小关节接起来。前半部分只需要会读变量、循环和函数;自动求导部分可以先照着运行,读过第二章和第十三章后再回来理解原理。
1. 先让“装包的 Python”和“运行的 Python”成为同一个
正文前几章的脚本只用 Python 标准库,不需要安装 NumPy 或 PyTorch。这里要比较三种写法,需要额外依赖。本书这一组例子使用 Python 3.12,依赖版本集中在下载清单中。
如果你已经有本书源码,在仓库根目录建立独立环境:
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python src/examples/array_workbench.py
这是 macOS/Linux 的命令。Windows 可以用已安装的 Python 3.12 运行 py -3.12 -m venv .venv,再直接使用 .venv\Scripts\python.exe -m pip install -r requirements.txt 和 .venv\Scripts\python.exe src\examples\array_workbench.py,不必先修改 PowerShell 的脚本执行策略。
如果只下载了本页的脚本和依赖清单,把它们放进同一个目录,用清单的实际路径安装,再运行脚本文件名即可。本页例子不下载数据或模型,只在 CPU 上处理几行数。
虚拟环境可以理解为这个项目自己的工具箱。python -m pip 让安装操作跟着当前这个解释器走,减少“明明装好了,运行却说没有”的困惑。想确认自己正在用谁,可以执行:
python -c "import sys; print(sys.executable); print(sys.version)"
python -m pip show numpy torch
退出环境用 deactivate。固定包版本有助于复核,但并不自动保证不同硬件上的浮点结果逐位相同。有关环境隔离的更多细节,见 Python 3.12 虚拟环境文档。
2. Python 列表先学会“存放”,数组才约定“逐项计算”
看这段短代码:
pages = [10, 4, 8]
print(pages * 2)
print([2 * value for value in pages])
第一行输出会是两份列表接在一起;第二种写法才逐个取数、乘 2,再装回新列表。这不是 Python 算错了,而是列表的乘法本来就表示重复。
方括号里的 for 叫列表推导式。它与下面的循环表达同一件事:
doubled = []
for value in pages:
doubled.append(2 * value)
脚本里还常见 enumerate(values):它同时给出从 0 开始的下标和对应的值。zip(left, right) 则把两组值配对;普通 zip 会在较短的一边结束,所以做向量计算前仍要检查长度。
函数用 def 定义,靠缩进区分函数体和外部代码。脚本末尾的 if __name__ == "__main__": 表示“直接运行这个文件时,才执行下面的入口”;别的文件导入它来借用函数时,不会顺手跑完全部实验。
这些语法的用途,是让同一个计算能够被反复调用,而不用在多处复制。可按需要查阅 Python 官方教程的控制流部分。
3. NumPy:先看形状,再看运算
把三份打印任务排成数组:
import numpy as np
x = np.array([[10, 2], [4, 6], [8, 0]], dtype=np.float64)
w = np.array([3, 5], dtype=np.float64)
prediction = x @ w + 20
x.shape 是 (3, 2):三份任务,每份两个特征。w.shape 是 (2,):两个权重。这里的一维数组既没有单独的行轴,也没有列轴;不要把 (2,) 与 (2, 1) 当成完全相同的对象。
@ 做矩阵乘法,* 做逐项乘法。因此 x @ w 把每行的两项贡献相加,得到三项结果;x * w 则保留一个 (3, 2) 的数组。转置用 .T,但一维数组的 .T 仍是一维,想增加轴要写 w[:, None]。
dtype 指数字在内存中的表示。这里用 64 位浮点数,便于核对小算例;实际网络训练常用 32 位浮点数。类别编号常用整数,取样掩码常用布尔值。类型相同不表示单位相同,秒与分钟仍需要你自己分清。
切片遵循“包含起点、不包含终点”:x[:2] 取前两行,x[:, 1] 取第二列,x[:, 1:2] 则保留列轴。后两者分别是 (3,) 与 (3, 1)。
广播让代码变短,也可能让错误藏起来
x @ w + 20 中,一个数加到了三个预测上,这就是广播。形状从最右边对齐,对应长度相等或其中一个为 1,就可以按规则扩展;缺失的轴也按长度 1 处理。详细规则见 NumPy 广播文档。
问题在于,规则只懂形状,不懂你的目的:
target = np.array([62, 60, 45], dtype=np.float64)
right = prediction - target
wrong = prediction[:, None] - target
第一种得到三份任务各自的残差;第二种得到每个预测与每个答案两两相减的九个结果。两种都能合法运行。
图 A-1:绿色框里的三个数才是本例需要的配对误差。广播后的矩阵仍含有它们,但还混入六个不属于同一任务的比较。
对监督学习的损失,可以在运算前直接写 assert prediction.shape == target.shape。这条检查并不复杂,却比训练半小时后猜测“为什么不收敛”便宜得多。
切片不一定是一份独立副本
NumPy 的基本切片通常共享原数组的存储。修改 a[:2] 中的一个元素,原来的 a 也可能改变。确实需要独立数据时,用 .copy()。布尔或整数数组索引又有不同的复制规则,不能笼统地说“所有索引都复制”或“都共享”。参见 NumPy 复制与视图。
本页完整脚本用三个小整数演示这一点,不会修改外部文件。
4. PyTorch:算出结果,还记住结果怎样来的
PyTorch 的张量也有形状和类型,还多了设备与自动求导。tensor.device 告诉你数据在 CPU 还是其他设备;本页统一用 CPU。需要一起运算的张量,应当在兼容的设备与类型上。
我们建立一个从两个输入到一个输出的线性层:
model = torch.nn.Linear(2, 1, dtype=torch.float64)
它的权重形状是 (1, 2),计算约定是输入乘以权重转置,再加偏置。本例把权重设为 [3, 5]、偏置设为 20,因此仍然是同一台教学打印机。
三份预测是 60、62、44,教学目标是 62、60、45。残差分别为 −2、2、−1,均方误差为:
调用 loss.backward() 后,参数的 .grad 保存当前损失对它的梯度。它没有自动修改权重;optimizer.step() 才按优化器规则更新。每轮常见的顺序是:
optimizer.zero_grad(set_to_none=True)
prediction = model(inputs)
loss = ((prediction - targets) ** 2).mean()
loss.backward()
optimizer.step()
zero_grad 是因为梯度默认累加。完整脚本故意重新前向、再反传一次而不清零,你会看到梯度翻倍;随后清理并重新计算,才做真正的更新。这里没有在已经释放的旧计算图上反复调用 backward()。
model.eval() 和 torch.no_grad() 各有用途:前者切换 Dropout、BatchNorm 等模块的工作方式,后者停止记录本段计算的求导图。线性层本身不因 eval() 改变结果,但以后评估更复杂的模型时,两者常一起使用。PyTorch 自动求导说明详细区分了它们。
5. 把整个例子跑一遍
下载完整脚本,在上述环境中运行。下面是固定算例的实际输出:
列表乘 2: [10, 4, 8, 10, 4, 8]
数组乘 2: [20, 8, 16]
三份预测: [60.0, 62.0, 44.0]
正确残差形状: (3,)
误广播残差形状: (3, 3)
修改切片后原数组: [9, 2, 3]
独立副本: [1, 2]
权重梯度: [-13.333333, 5.333333]
未清零再反传: [-26.666667, 10.666667]
偏置梯度:-0.666667
一步 SGD 的 MSE:3.000000 → 1.840519
这一步损失下降,只说明当前小例子按梯度更新后发生了什么,不能推出任何学习率、任何任务都会下降。学习率太大,完全可能越走越远。
展开完整代码
"""附录:从 Python 列表到 NumPy 数组和 PyTorch 梯度,CPU 小例子。"""
import numpy as np
import torch
def experiment():
pages = [10, 4, 8]
x = np.array([[10, 2], [4, 6], [8, 0]], dtype=np.float64)
w = np.array([3, 5], dtype=np.float64)
target = np.array([62, 60, 45], dtype=np.float64)
prediction = x @ w + 20
residual = prediction - target
wrong = prediction[:, None] - target
a = np.array([1, 2, 3])
view = a[:2]
independent = a[:2].copy()
view[0] = 9
xt, yt = torch.tensor(x), torch.tensor(target[:, None])
model = torch.nn.Linear(2, 1, dtype=torch.float64)
with torch.no_grad():
model.weight.copy_(torch.tensor([[3.0, 5.0]], dtype=torch.float64))
model.bias.fill_(20)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
loss = ((model(xt) - yt) ** 2).mean()
optimizer.zero_grad(set_to_none=True)
loss.backward()
first = model.weight.grad.detach().clone()
# 重新前向得到新计算图,但故意不清理参数上的旧梯度。
((model(xt) - yt) ** 2).mean().backward()
accumulated = model.weight.grad.detach().clone()
optimizer.zero_grad(set_to_none=True)
((model(xt) - yt) ** 2).mean().backward()
bias_gradient = model.bias.grad.item()
optimizer.step()
model.eval()
with torch.no_grad():
after = ((model(xt) - yt) ** 2).mean().item()
manual = 2 * x.T @ residual / len(x)
np.testing.assert_allclose(first.numpy().ravel(), manual)
torch.testing.assert_close(accumulated, 2 * first)
return dict(list_twice=pages * 2, array_twice=(np.array(pages) * 2).tolist(),
prediction=prediction, residual=residual, wrong=wrong,
original=a, independent=independent, gradient=first.numpy().ravel(),
accumulated=accumulated.numpy().ravel(), bias_gradient=bias_gradient,
before=loss.item(), after=after)
def main():
r = experiment()
print("列表乘 2:", r["list_twice"])
print("数组乘 2:", r["array_twice"])
print("三份预测:", r["prediction"].tolist())
print("正确残差形状:", r["residual"].shape)
print("误广播残差形状:", r["wrong"].shape)
print("修改切片后原数组:", r["original"].tolist())
print("独立副本:", r["independent"].tolist())
print("权重梯度:", [round(v, 6) for v in r["gradient"].tolist()])
print("未清零再反传:", [round(v, 6) for v in r["accumulated"].tolist()])
print(f'偏置梯度:{r["bias_gradient"]:.6f}')
print(f'一步 SGD 的 MSE:{r["before"]:.6f} → {r["after"]:.6f}')
if __name__ == "__main__":
main()
动手检查
x[0]、x[:1]、x[:, :1]分别是什么形状?- 为什么
x * w与x @ w都可以运行,却不是同一个计算? - 保留预测的
(3, 1)形状时,目标应该怎样整理? - 在副本上修改元素,会不会再影响原数组?亲手验证。
- 把学习率改成 1,记录更新后的损失,并用第二章的梯度下降解释它。
答案与提示
- 分别为
(2,)、(1, 2)、(3, 1)。 - 前者按列广播权重并逐项乘,结果是
(3, 2);后者沿特征轴求和,结果是(3,)。 - 可以把原来的一维目标写成
target[:, None],并断言两边形状相同。不要靠改变形状“试到不报错”为止,还要确认行代表同一批样本。 .copy()得到独立数组,修改它不会改变这里的原数组;给同一个数组再取一个变量名则不算复制。- 当前点的梯度只描述局部方向,大步可能越过较好的区域。先记录实际数值,再试中间大小的步长;这仍是教学调试,不是通过测试集选超参数。
接口参考 Python 3.12、NumPy 2.5 与 PyTorch 2.14 文档,核查日期 2026-10-20。想理解梯度的由来,可接着读导数、梯度与优化和反向传播与自动微分。