第一章 Python、数学与神经网络:建立可计算的直觉¶
本章的目标不是“快速看完 Python”,而是建立后续所有章节都会用到的三种能力:用数组表达数据,用函数表达模型,用梯度表达学习。学完后,你应当能读懂一段 PyTorch 训练代码,能手写一个小型神经网络,并能定位形状、数值和数据三类常见错误。

图 1-1 训练不是一次函数调用,而是数据、预测、损失、梯度和参数更新组成的闭环。
1.1 Python:先掌握会反复出现的部分¶
先建立直觉。 Python 在本书中不是考语法,而是用来表达数据怎样流动。初学者先把变量、容器、函数、类和异常看成组织状态与行为的工具。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。
把过程拆开看:
- 用 list、dict 与 dataclass 表示一条样本。
- 用函数把读取、变换、评估拆开。
- 用类型标注和断言固定输入输出契约。
最小例子。 把一条问答样本写成包含 prompt、answer、metadata 的字典,再写函数只返回模型需要的字段;打印每个字段的类型与长度。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。
容易踩坑。 能运行不等于可维护。最常见的问题是可变默认参数、维度含义藏在变量名里、异常被无条件捕获,以及循环中反复做昂贵初始化。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。
动手任务:实现 batch_iter(samples, batch_size),覆盖空列表、最后一个不足批次和非法 batch_size 三种边界。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。
大模型代码里最常见的 Python 能力包括容器、函数、类、迭代器、上下文管理器和类型标注。列表适合有序样本,字典适合配置与结构化记录,生成器适合不一次性加载全部数据。下面的批处理生成器不会复制完整数据集:
from collections.abc import Iterator, Sequence
def batches(data: Sequence, batch_size: int) -> Iterator[Sequence]:
if batch_size <= 0:
raise ValueError("batch_size 必须大于 0")
for start in range(0, len(data), batch_size):
yield data[start:start + batch_size]
for batch in batches(list(range(10)), 4):
print(batch)
yield 让函数返回一个迭代器。真实训练中的 DataLoader 还会处理并行读取、随机打乱、采样和拼接,但“按需产生批次”的思想相同。调试 Python 时,不要只打印最终结果;优先打印类型、长度、形状和少量样本。
面向对象在模型代码中的主要价值是组织状态。torch.nn.Module 把参数、子模块和前向计算组合在同一对象中。函数式编程则适合无状态的数据变换、损失计算和指标计算。两者并不冲突:模型用类封装,纯计算尽量写成小函数,便于测试。
1.2 数学基础:向量、矩阵、概率和导数¶

图 01-2 先标注批次、特征和隐藏轴,再进行矩阵乘法与反向传播。 从问题出发。 线性代数负责描述批量变换,概率负责描述不确定性,微积分负责说明参数怎样改变损失。三者在神经网络里不是三门分开的课,而是一条计算链。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。
沿数据流逐步检查:
- 先写出每个张量的形状与轴含义。
- 再判断运算是逐元素、点积还是矩阵乘。
- 最后沿计算图用链式法则追踪梯度。
用小数据走一遍。 若 X 为 (B,D),W 为 (D,H),则 XW 为 (B,H)。其中每个输出元素是 D 个乘积的求和;反向传播时 dW=X^T dY。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。
这里最容易出现的误解。 只背公式而不写形状会掩盖大多数错误;把概率当作频数、把梯度当作参数更新量、忽略广播轴也会造成似是而非的理解。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。
小实验:手算一个 2×3 矩阵与 3×2 矩阵的乘积,再用 NumPy 验证;对其中一个元素做有限差分梯度检查。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。
一个词向量可写成 \(x\in\mathbb{R}^d\),一批长度为 \(T\) 的序列可写成 \(X\in\mathbb{R}^{B\times T\times d}\)。这里 \(B\) 是批量大小,\(T\) 是序列长度,\(d\) 是隐藏维度。后续阅读任何模型,先写出每个张量的形状,通常比背模块名称更有效。
线性层为
$$
Y=XW+b,
$$
若 \(X\) 的末维是 \(d_{in}\),\(W\in\mathbb{R}^{d_{in}\times d_{out}}\),则输出末维是 \(d_{out}\)。偏置 \(b\) 会按广播规则加到前面的所有位置。广播方便,但也可能掩盖错误;(B,T,1) 与 (B,T) 相加会得到意外形状,调试时应显式检查。
概率分布满足非负且总和为 1。语言模型把词表上的 logits \(z\) 通过 Softmax 转为概率:
$$ p_i=\frac{e^{z_i}}{\sum_j e^{z_j}}. $$ 实际实现先减去最大值,避免指数溢出。分类常用交叉熵 \(-\log p_y\);自回归语言模型则对每个位置的下一个 token 计算交叉熵,再对有效位置求平均。困惑度 \(\mathrm{PPL}=e^{\text{loss}}\) 便于解释,但跨 tokenizer 的数值不可直接比较。
导数描述输出对输入的局部敏感度。复合函数通过链式法则传播梯度:若 \(L=f(g(x))\),则
$$ \frac{\partial L}{\partial x}=\frac{\partial L}{\partial f}\frac{\partial f}{\partial g}\frac{\partial g}{\partial x}. $$ 反向传播不是另一套学习算法,而是高效应用链式法则。计算图记录前向运算和依赖关系,反向阶段从标量损失出发,把梯度沿图反向累积到参数。
1.3 神经网络的基本组件¶
先看它解决什么。 神经网络可以理解为可微函数的层叠:线性层改变坐标,非线性层提供表达能力,损失函数把预测质量压缩成一个可优化标量。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。
可以把实现分成以下环节:
- 前向计算保存中间量。
- 损失函数比较预测与目标。
- 反向传播计算每个参数的偏导。
- 优化器依据梯度更新参数。
一个可以手算的例子。 二分类网络把 x 送入 Linear-ReLU-Linear,输出一个 logit;BCEWithLogitsLoss 同时完成数值稳定的 Sigmoid 与交叉熵。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。
排错时先看这些地方。 激活函数并非越多越好;最后一层是否加 Softmax 取决于损失函数是否已包含它。训练和评估模式还会影响 Dropout 与 BatchNorm。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。
现在动手:画出两层网络的计算图,标注每个节点的形状,并说明哪个节点必须是标量才能直接调用 backward。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。
多层感知机可写成
$$ h=\phi(XW_1+b_1),\qquad \hat{y}=hW_2+b_2. $$ 激活函数让网络具有非线性。ReLU 简单高效,但负半轴梯度为零;GELU 平滑地按输入大小“门控”,Transformer 中很常见;SwiGLU 将两条线性投影相乘,后续 Llama 章节会展开。初始化要让各层信号不过度放大或衰减,线性层常结合 Xavier 或 Kaiming 方法。
优化器根据梯度更新参数。最基础的随机梯度下降为 \(\theta\leftarrow\theta-\eta\nabla_\theta L\)。AdamW 维护一阶、二阶矩估计,并把权重衰减与梯度更新解耦。学习率过大可能发散,过小则学习缓慢;梯度裁剪只能限制异常大梯度,不能修复错误数据或错误损失。
训练集用于更新参数,验证集用于选超参数和早停,测试集只用于最终报告。若同一用户、同一文档或高度相似样本跨集合出现,就会发生数据泄漏,指标会虚高。
1.4 NumPy 手写两层网络¶
抓住这一节的主线。 手写 NumPy 网络的目的不是替代框架,而是亲眼看到缓存、中间梯度和更新规则。只要能解释每一行为什么存在,之后使用自动微分才不会变成黑箱。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。
真正动手时按这个顺序走:
- 固定随机种子并初始化小权重。
- 完成前向与稳定损失。
- 按链式法则反传到每个参数。
- 更新后重新计算损失。
先做最小实验。 对一个权重 w,用 (L(w+ε)-L(w-ε))/(2ε) 近似真实梯度;它应与解析梯度接近,但 ε 过大有截断误差、过小有浮点误差。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。
别被表面现象带偏。 常见错误包括忘记除以 batch 大小、Sigmoid 溢出、原地修改缓存、矩阵转置方向写反以及梯度检查时仍在更新参数。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。
验证任务:给现有两层网络加入 ReLU,并对 W1、b1、W2、b2 各抽一个元素做中心差分检查。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。
下面用 NumPy 完成二分类网络的前向与反向传播。代码刻意展开中间量,便于检查每一步。
import numpy as np
rng = np.random.default_rng(7)
X = rng.normal(size=(128, 2))
y = ((X[:, 0] * X[:, 1]) > 0).astype(np.float32)[:, None]
W1 = rng.normal(scale=0.2, size=(2, 16))
b1 = np.zeros((1, 16))
W2 = rng.normal(scale=0.2, size=(16, 1))
b2 = np.zeros((1, 1))
def sigmoid(x):
x = np.clip(x, -30, 30)
return 1.0 / (1.0 + np.exp(-x))
lr = 0.1
for step in range(1000):
z1 = X @ W1 + b1
h = np.tanh(z1)
logits = h @ W2 + b2
p = sigmoid(logits)
eps = 1e-7
loss = -(y * np.log(p + eps) + (1 - y) * np.log(1 - p + eps)).mean()
# 对 sigmoid + 二元交叉熵,dL/dlogits 可化简为 (p-y)/N
dlogits = (p - y) / len(X)
dW2 = h.T @ dlogits
db2 = dlogits.sum(axis=0, keepdims=True)
dh = dlogits @ W2.T
dz1 = dh * (1 - h ** 2)
dW1 = X.T @ dz1
db1 = dz1.sum(axis=0, keepdims=True)
W1 -= lr * dW1
b1 -= lr * db1
W2 -= lr * dW2
b2 -= lr * db2
if step % 200 == 0:
print(step, float(loss))
代码解读:h.T @ dlogits 把 128 个样本对第二层权重的贡献求和;sum(..., keepdims=True) 保持偏置的二维形状;1-h**2 是 tanh 的导数。若损失不降,先检查数据是否可学,再做数值梯度检查:对单个参数加减很小的 \(\epsilon\),比较有限差分与解析梯度。
1.5 用 PyTorch 重写训练闭环¶
先把概念落到可观察对象上。 PyTorch 把手写缓存换成动态计算图,把参数集合交给 Module 管理,但训练闭环仍是同一件事:取批次、前向、计算损失、清梯度、反传、更新。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。
把抽象概念还原成操作:
- Dataset 定义单个样本。
- DataLoader 负责批处理与打乱。
- Module 定义可学习计算。
- optimizer.step 执行更新。
把它缩小到能逐项检查。 调用 loss.backward 后查看 model.linear.weight.grad;再比较 zero_grad 前后的梯度,理解 PyTorch 默认累积梯度的语义。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。
需要特别守住的边界。 遗漏 model.train/model.eval、把张量错误搬到不同设备、在评估时保留计算图、混用 float32 与 float64,都会引入隐蔽问题。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。
本节练习:在训练循环中记录 train loss、validation loss 和梯度范数;故意把学习率放大 100 倍,观察并解释曲线。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
torch.manual_seed(7)
X = torch.randn(1024, 2)
y = ((X[:, 0] * X[:, 1]) > 0).long()
loader = DataLoader(TensorDataset(X, y), batch_size=64, shuffle=True)
model = nn.Sequential(
nn.Linear(2, 32),
nn.GELU(),
nn.Linear(32, 2),
)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=1e-2)
criterion = nn.CrossEntropyLoss()
for epoch in range(20):
model.train()
total_loss = 0.0
for xb, yb in loader:
optimizer.zero_grad(set_to_none=True)
logits = model(xb)
loss = criterion(logits, yb)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
total_loss += loss.item() * len(xb)
print(f"epoch={epoch:02d} loss={total_loss / len(X):.4f}")
关键顺序是:清梯度、前向、算损失、反向、更新。PyTorch 默认把多次反向的梯度累加到 .grad,因此忘记清梯度会改变优化过程。model.train() 与 model.eval() 会切换 Dropout、BatchNorm 等模块行为;推理时还应使用 torch.inference_mode() 关闭梯度记录。
1.6 环境、模型仓库与可复现性¶
先建立直觉。 可复现不是一句 random seed,而是能够说明数据、代码、依赖、硬件和随机状态。模型仓库中的权重还伴随配置、tokenizer、许可证与自定义代码风险。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。
把过程拆开看:
- 锁定 Python 与关键库版本。
- 记录数据哈希和切分清单。
- 保存配置、种子和代码提交。
- 核验模型卡与许可证。
最小例子。 同一模型若 tokenizer 版本不同,特殊 token id 可能改变;即使权重相同,输入序列与输出也可能不同。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。
容易踩坑。 盲目开启 trust_remote_code、只保存 model.bin、不记录 CUDA/驱动版本,以及用测试集调参,都会破坏复现或安全边界。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。
动手任务:写一个环境报告脚本,输出 Python、PyTorch、CUDA、GPU、随机种子和 git commit,并保存为 JSON。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。
建议为项目创建独立虚拟环境,锁定依赖,并记录 GPU 驱动、CUDA 与 PyTorch 版本。Hugging Face Hub 和 ModelScope 都可托管模型与数据集;下载模型前要检查许可证、模型卡、所需显存和自定义代码。不要对来源不明的仓库轻易启用 trust_remote_code=True。
随机种子只能改善复现,不保证所有 GPU 算子逐比特一致。完整复现还需要固定数据顺序、预处理、代码提交、依赖版本、混合精度和分布式配置。
1.7 常见故障与练习¶
从问题出发。 排错要从最便宜、最确定的证据开始:语法与导入、形状与 dtype、数值范围、梯度、数据,再到性能。不要一出现问题就换模型或调参。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。
沿数据流逐步检查:
- 构造能复现问题的最小输入。
- 在边界打印形状与统计量。
- 加断言把错误定位到最早位置。
- 修复后补回归测试。
用小数据走一遍。 损失变成 NaN 时先检查输入是否有限、logits 范围、学习率和梯度范数,而不是立即换优化器。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。
这里最容易出现的误解。 只看最后一行异常、一次修改多个变量、用 try/except 吞掉堆栈、没有保存失败样本,会让同一问题反复出现。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。
小实验:为本章代码设计五个自动测试:形状、有限值、梯度、损失下降和固定种子复现。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。
- 损失为
nan:检查学习率、除零、对数输入、混合精度溢出和异常数据。 - 训练准确率高、验证准确率低:检查过拟合、分布漂移和数据泄漏。
- GPU 利用率低:检查数据加载、批量过小、频繁 CPU/GPU 拷贝和同步打印。
- 显存不断增长:检查是否保存了带计算图的张量,应改用
tensor.detach()或loss.item()。
练习:为 NumPy 网络实现数值梯度检查;为 PyTorch 训练加入验证集、准确率、早停和模型保存;故意交换标签或放大学习率,记录曲线并解释失败原因。
1.8 延伸资源¶
先看它解决什么。 延伸材料应围绕当前缺口使用:数学直觉不清就做小矩阵实验,PyTorch 不熟就逐行运行官方教程,注意力不懂再进入下一章,而不是同时收藏几十门课。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。
可以把实现分成以下环节:
- 带着一个明确问题阅读。
- 复现最小代码并改变一个变量。
- 用自己的话记录结论。
- 把结论写成可失败的测试。
一个可以手算的例子。 阅读自动微分教程时,不只复制代码;把中间张量改为 requires_grad=False,预测结果,再运行验证。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。
排错时先看这些地方。 把观看时长当学习进度、依赖过期安装命令、跳过练习和不记录失败,都会形成虚假的熟悉感。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。
现在动手:建立学习日志,记录问题、假设、实验、证据和结论;下一章继续沿用同一模板。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。
本章配套代码¶
下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。
examples/01_numpy_mlp.py:NumPy 两层网络。examples/01_gradient_check.py:有限差分梯度检查。
本章端到端实验:把知识变成可复现证据¶
本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。
实验步骤¶
- 实现 batch_iter(samples, batch_size),覆盖空列表、最后一个不足批次和非法 batch_size 三种边界。
- 手算一个 2×3 矩阵与 3×2 矩阵的乘积,再用 NumPy 验证;对其中一个元素做有限差分梯度检查。
- 画出两层网络的计算图,标注每个节点的形状,并说明哪个节点必须是标量才能直接调用 backward。
- 给现有两层网络加入 ReLU,并对 W1、b1、W2、b2 各抽一个元素做中心差分检查。
- 在训练循环中记录 train loss、validation loss 和梯度范数;故意把学习率放大 100 倍,观察并解释曲线。
- 写一个环境报告脚本,输出 Python、PyTorch、CUDA、GPU、随机种子和 git commit,并保存为 JSON。
每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。
验收标准¶
- 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
- 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
- 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
- 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
- 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。
本章自测¶
- 不看正文,用自己的话解释“Python 在本书中不是考语法,而是用来表达数据怎样流动”,并给出一个可以证伪的测试。
- 不看正文,用自己的话解释“线性代数负责描述批量变换,概率负责描述不确定性,微积分负责说明参数怎样改变损失”,并给出一个可以证伪的测试。
- 不看正文,用自己的话解释“神经网络可以理解为可微函数的层叠:线性层改变坐标,非线性层提供表达能力,损失函数把预测质量压缩成一个可优化标量”,并给出一个可以证伪的测试。
- 不看正文,用自己的话解释“手写 NumPy 网络的目的不是替代框架,而是亲眼看到缓存、中间梯度和更新规则”,并给出一个可以证伪的测试。
- 不看正文,用自己的话解释“PyTorch 把手写缓存换成动态计算图,把参数集合交给 Module 管理,但训练闭环仍是同一件事:取批次、前向、计算损失、清梯度、反传、更新”,并给出一个可以证伪的测试。
- 不看正文,用自己的话解释“可复现不是一句 random seed,而是能够说明数据、代码、依赖、硬件和随机状态”,并给出一个可以证伪的测试。
回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。