模块 1 和模块 2 里你已经把线性代数、微积分、概率与优化的数学推导走了一遍。从这一课起,我们把这些数学搬到机器上跑起来。但在写第一行张量(tensor)代码之前,有一个反复劝退新手的拦路虎要先解决:环境。本模块的目标是"能从零写出一个训练循环",而训练循环跑不起来时,十有八九不是你的数学错了,而是版本对不上、变量是上次残留的、或者你根本不知道哪一行炸了。这一课就是把这些"跑不出来"的元凶逐个拆掉,给后面四课(NumPy 广播、PyTorch 张量、Autograd、nn.Module)铺好一块稳的地基。
读完这一课,你将能够
- 说清为什么同一台机器要建多个隔离环境,并用
environment.yml或requirements.txt锁住版本。 - 一句话讲出 NumPy / SciPy / Pandas / Matplotlib / scikit-learn 各自管什么。
- 识别 Jupyter/Colab 的"隐藏状态"陷阱,判断一段乱序执行的代码到底输出什么。
- 判断一个项目何时该从 notebook 毕业到
.py+argparse。 - 用 assert / breakpoint() / print 三件套定位一个 shape 不符的 bug。
为什么要隔离环境与锁版本
机制直觉。你的电脑上同时有项目 A(去年的代码,依赖 PyTorch 1.13)和项目 B(这学期的课,要 PyTorch 2.x)。如果所有库都装在同一个全局 Python 里,给 B 升级 PyTorch 的那一刻,A 就坏了——它们共用同一份 site-packages,一个版本号只能有一份。隔离环境(isolated environment)的本质是:给每个项目一份独立的 site-packages 目录,互不污染。
- venv:Python 自带,只管 Python 包。轻量,适合纯 Python 项目。
- conda:除了 Python 包,还能管非 Python 的二进制依赖(CUDA、MKL、编译器),ML 里常用,因为 PyTorch/GPU 栈牵扯大量系统级库。
锁版本是隔离的另一半。"在我机器上能跑"几乎总是因为:你装的是 numpy 最新版,同伴装的是半年前的版本,某个函数的默认参数变了。把确切版本号写进文件,别人(和三个月后的你)才能复刻出一模一样的环境。
真实命令当指针。建环境、装包、锁版本的标准流程:
# 建一个独立环境,指定 Python 版本
conda create -n ml-course python=3.11
conda activate ml-course
# 装包
pip install numpy scipy pandas matplotlib scikit-learn
# 把当前环境的确切版本号导出(锁版本)
pip freeze > requirements.txt # pip 路线
conda env export > environment.yml # conda 路线
# 别人拿到你的项目后,一键复刻环境
pip install -r requirements.txt
# 或
conda env create -f environment.yml
requirements.txt 里会是 numpy==2.2.5 这样钉死的行——== 是锁版本的关键,没有它就等于没锁。
科学计算栈:五个库各管什么
整个 Python 科学计算栈以 NumPy 为地基,其他库几乎都建在它的数组(array)之上。一句话分工:
| 库 | 管什么 | 典型一行 |
|---|---|---|
| NumPy | 数值数组与向量化运算,整个栈的地基 | a @ b(矩阵乘) |
| SciPy | 科学算法:优化、积分、线性代数分解、统计分布 | scipy.optimize.minimize |
| Pandas | 带行列标签的表格数据(DataFrame),读 CSV、清洗、分组 | df.groupby("y").mean() |
| Matplotlib | 画图:曲线、散点、直方图 | plt.plot(x, y) |
| scikit-learn | 经典(非深度)机器学习:回归、聚类、SVM、预处理 | LinearRegression().fit(X, y) |
记住依赖方向:Pandas/Matplotlib/scikit-learn/SciPy 内部都把数据存成或转成 NumPy 数组。所以先把 NumPy 学透(第 2 课),其他库才不是黑盒。深度学习里 PyTorch 张量接替了数组的地基角色,但二者 API 高度相似,NumPy 仍然是最好的跳板——这正是我们先把 NumPy 学透的原因,而不是说 NumPy 被淘汰了。
Jupyter/Colab 的隐藏状态陷阱
机制直觉。notebook 的每个单元格(cell)共享同一个 Python 内核(kernel)的全局命名空间。你看到的代码是按"书写顺序"从上往下排的,但单元格可以按任意"执行顺序"反复跑。变量的真实值取决于最后一次执行了哪些单元格,而不是它们在屏幕上的排列。于是会出现"代码看起来对、跑出来却错"的鬼故事:上方单元格写着 x = 10,但下方某个单元格偷偷把 x 改成了别的值,你回头重跑上方的单元格时,它读到的是被改过的残留值。
最小可跑复现。下面用纯 Python 模拟 notebook:每个"单元格"是一段源码字符串,run() 在共享的 state 字典里执行它,正是内核共享命名空间的样子。注意书写顺序和执行顺序不一致时,同一个单元格 B 两次跑出不同结果。试着先不看下面的输出,自己推断两次 run(cellB) 各打印什么,再对答案。
import time
state = {} # 模拟内核共享的全局命名空间
def run(cell): # 模拟"执行一个单元格"
exec(cell, state)
# 屏幕上的书写顺序是 A、B、C:
cellA = "x = 10"
cellB = "print('B 看到的 x =', x)"
cellC = "x = 999" # C 偷偷改了 x
# 但实际执行顺序乱了:A -> B -> C -> 又回头重跑 B
run(cellA) # x = 10
run(cellB) # B 看到 10
run(cellC) # x 被改成 999
run(cellB) # 重跑 B:现在看到 999,可源码里 B 上方仍写着 x = 10
输出是:
B 看到的 x = 10
B 看到的 x = 999
同一段 print(x),源码一个字没改,却先后打印 10 和 999。这就是隐藏状态:屏幕上的代码骗了你。自救招数:怀疑结果时,Kernel → Restart & Run All,强制按书写顺序从头跑一遍;这是验证 notebook 真的可复现的唯一可靠办法。
何时从 notebook 毕业到 .py + argparse
notebook 适合探索:边写边看图、快速试一个想法。但当你需要可复现、可版本控制、可批量跑时,就该把代码搬进 .py 文件。出现下面任一信号,就是毕业时刻:
- 同一份实验要换不同超参数跑很多遍(学习率、batch size)——需要命令行传参,而不是手动改单元格。
- 代码要进 git,和别人协作——notebook 的
.ipynb是 JSON,diff 难读,还混进了输出和执行计数。 - 要在没有界面的服务器上后台批跑、或排进定时任务。
- 逻辑稳定下来、要被别处
import复用。
argparse 是标准库里把脚本变成命令行工具的方式:把超参数变成 --lr 0.01 这样的开关,跑不同配置不用改一行代码。下面是脚手架(这里不在浏览器跑,因为它要从命令行接参数):
import argparse
def main(args):
print(f"用 lr={args.lr}, epochs={args.epochs} 开跑")
# 这里以后会放训练循环(第 6 课)
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--lr", type=float, default=1e-3)
p.add_argument("--epochs", type=int, default=10)
p.add_argument("--seed", type=int, default=0)
args = p.parse_args()
main(args)
# 命令行调用:
# python train.py --lr 0.01 --epochs 50
if __name__ == "__main__": 这一行的意思是"只有当这个文件被直接运行、而不是被 import 时,才执行下面的代码"——它让同一个文件既能当脚本跑,又能被别处复用。
调试三件套:assert / breakpoint() / print
ML 代码里最常见的 bug 不是逻辑错,而是形状(shape)对不上:你以为是 (N, D) 的矩阵其实是 (D, N),矩阵乘法要么直接报错,要么更可怕地靠广播"凑"出一个错误结果还不报错。三件套各有定位场景:
- assert:在关键位置写下你对 shape/数值的假设,假设一旦被打破立刻在出错的源头炸出来,而不是几十行后才报一个莫名其妙的错。最适合卡 shape。
- breakpoint():调用它会进入 pdb(Python 调试器),程序在此暂停,你能交互式查看任何变量、单步执行。适合"我不知道这里到底发生了什么"。
- print:最朴素,打印
x.shape、x.dtype、几个数值。适合快速确认"流到这里的东西长什么样"。三者里它写起来最省事,也最容易留下垃圾,记得清理。
最小可跑:用 assert 抓住 shape 不符。下面写一个最小的全连接层 relu_layer,在矩阵乘之前 assert 检查维度,故意喂一个错 shape 看它被当场抓住。
import numpy as np
def relu_layer(X, W, b):
# 断言我的假设:X 的列数必须等于 W 的行数,否则 X @ W 没意义
assert X.shape[1] == W.shape[0], \
f"shape 不符: X 是 {X.shape}, W 是 {W.shape}"
Z = X @ W + b
return np.maximum(Z, 0.0) # ReLU
N, D, H = 4, 3, 5 # batch=4, 输入维=3, 隐藏=5
X = np.random.randn(N, D)
W = np.random.randn(D, H) # 正常的 W 是 (3, 5)
b = np.zeros(H)
print("正常输出 shape:", relu_layer(X, W, b).shape) # (4, 5)
# 故意把 W 的行数从 3 改成 4,X @ W 不再合法
try:
bad_W = np.random.randn(D + 1, H) # (4, 5),行数不对
relu_layer(X, bad_W, b)
except AssertionError as e:
print("被 assert 抓住:", e)
输出会是:
正常输出 shape: (4, 5)
被 assert 抓住: shape 不符: X 是 (4, 3), W 是 (4, 5)
真实调试里,把 relu_layer 第一行换成 breakpoint(),运行时会停在那里,你可以敲 X.shape、W.shape 当场查。
# PyTorch 里完全一样的思路;张量也有 .shape
import torch
x = torch.randn(4, 3)
assert x.shape == (4, 3), x.shape
# breakpoint() # 同样能停下来交互式查张量
调一调,观察现象
微任务 1:感受向量化 vs Python 循环的差距。把 N 从 100000 改成 1000000,预期:循环耗时大致线性涨 10 倍,而 NumPy 向量化几乎不变,加速比更夸张。为什么:Python 的 for 每次迭代都有解释器开销,而 .sum() 在底层 C 里一次性算完。这正是第 2 课"为什么要向量化"的引子。
import numpy as np, time
N = 100_000
a = np.random.rand(N)
t0 = time.perf_counter()
s = 0.0
for x in a: # 纯 Python 循环
s += x
loop_t = time.perf_counter() - t0
t0 = time.perf_counter()
s2 = a.sum() # NumPy 向量化
vec_t = time.perf_counter() - t0
print(f"循环: {loop_t*1e3:.2f} ms")
print(f"向量化: {vec_t*1e3:.3f} ms")
print(f"加速比: {loop_t/max(vec_t,1e-9):.0f}x")
微任务 2:让隐藏状态咬你一口。在下面把最后一次 run(cellB) 删掉再加回来,或调换 run(cellC) 与第二个 run(cellB) 的顺序,预期:B 打印的值随之改变。为什么:变量值只取决于执行顺序,与代码在屏幕上的位置无关——这就是 notebook 乱序执行的本质。
state = {}
def run(cell): exec(cell, state)
cellA = "x = 10"
cellB = "print('B 看到 x =', x)"
cellC = "x = 999"
run(cellA)
run(cellB) # 试试调换下面两行的顺序
run(cellC)
run(cellB)
微任务 3:让广播"静默吞掉"一个错误。下面这段刻意去掉了 assert 守门。两个一维数组 a(形状 (3,))和列向量 b(形状 (3, 1))相加,你直觉里会以为得到一个长度为 3 的向量。把它跑出来看实际形状,再试着把 b 的 reshape(3, 1) 去掉对比。预期:结果不是 (3,),而是意外的 (3, 3) 方阵,且全程不报错。为什么:NumPy 广播(broadcasting)会"好心"地把两个操作数在不同的轴上各自拉伸对齐——(3,) 在前面补成 (1, 3) 与 (3, 1) 撞出 (3, 3),错误被悄悄吞掉。这正说明为什么 assert 比"等它自己报错"更早、更可靠地定位问题:广播经常根本不报错。
import numpy as np
a = np.arange(3) # 形状 (3,),你以为是一个长度 3 的向量
b = np.arange(3).reshape(3, 1) # 形状 (3, 1),一个列向量
out = a + b # 没有任何 assert / shape 检查守门
print("a 的 shape:", a.shape)
print("b 的 shape:", b.shape)
print("意外的输出 shape:", out.shape) # 不是 (3,),而是 (3, 3)
动手练习
- 锁一个环境。在本机用
python -m venv .venv建一个虚拟环境,激活后pip install numpy,再pip freeze > requirements.txt。打开这个文件,确认里面的numpy==…带着确切版本号。然后用python -c "import numpy; print(numpy.__version__)"验证版本与文件一致。 - 抓一个 shape bug。写一个函数
batch_matmul(A, B)计算A @ B,在开头用 assert 同时检查A.ndim == 2、B.ndim == 2和A.shape[1] == B.shape[0],每条 assert 带上信息量足够的报错信息。喂三组输入:一组正常、一组维度不匹配、一组传了 1 维数组,确认每种错误都被对应的 assert 当场抓住。 - 给隐藏状态做一次复盘。用本课的
state/run模拟器,写出一个 4 个单元格的序列,使得"按书写顺序从头跑"和"按某个乱序跑"对同一个变量打印出不同的值。这复刻了Restart & Run All能救你于何处。
掌握自检
- 我能解释为什么不该把所有项目都装进同一个全局 Python,并能说出
requirements.txt里==的作用。 - 给我 NumPy/SciPy/Pandas/Matplotlib/scikit-learn 任意一个,我能一句话说出它管什么、以及谁依赖谁。
- 给我一段乱序执行的 notebook 单元格,我能推断出每个变量最后的真实值,而不是被屏幕顺序骗到。
- 我能列出至少两个"该从 notebook 毕业到 .py"的信号,并知道
argparse解决了什么。 - 遇到一个 shape 报错,我知道先用 assert/print 在哪里卡住假设,必要时用 breakpoint() 进 pdb 交互查变量。
可以先放过的点
- conda vs venv 的深度对比、conda-forge 频道、CUDA 版本匹配:现在只需会"建环境 + 锁版本"两个动作。等你真要上 GPU 跑 PyTorch(本模块学到 PyTorch 张量那一课、并开始上手训练之后)再回来研究 CUDA 对齐。
- pdb 的全部命令:记住
breakpoint()能停下来、能打印变量就够了。n/s/c/q等命令等你真的在调一个复杂 bug 时查一次就会了。 - SciPy/Pandas/sklearn 的具体 API:本课只要求知道它们各管什么这一层。深度学习训练循环主要用 NumPy 思维 + PyTorch,这些库到需要时再深入,不必现在啃。