←返回大模型学习资料
学习资料第 3 章 · 数学直觉

CHAPTER 03 · MATH INTUITION

数学
直觉

数学不是大模型学习前的一道门槛,而是一套把“感觉”变成可检查关系的语言。本章从标量、向量和矩阵开始,逐步建立距离、概率、导数、梯度、损失函数与优化的直觉;每个公式都会配一个可以手算、可以画图、也可以用 Python 验证的小实验。你不需要先成为数学专业学生,但要学会读懂符号、追踪形状、说明假设,并把一个结果还原成它的输入与步骤。

难度:基础进阶建议:先手算,再运行代码:Python 3.11+小节:27 张独立学习卡片
TEXTBOOK MAP · MATHEMATICAL INTUITION

本章学习地图:从表示到更新

先描述对象,再描述关系;先验证方向,再讨论速度。

本章公式和示例均为本站重新编写,外部文档只作为概念、API 与边界的参考;所有示意图为本站绘制。

使用方式:每个主题都按“直觉 → 定义 → 小实验 → 易错点 → 练习”的顺序展开。看到公式时先说清楚每个符号代表什么、单位是什么、形状是什么,再把它翻译成代码;如果代码给出一个意外结果,先检查输入、维度、范围和假设,不要只改最后一行。

01 · LANGUAGE & VECTORS

先把数学对象说清楚:标量、向量与距离

大模型里的“表示”通常从一个数字列表开始。一个分数是标量,一行特征是向量,一批样本是矩阵;它们看起来都像数字,却拥有不同的形状、单位和运算规则。建立数学直觉的第一步不是背公式,而是给每个对象写一句完整的话:它表示什么、包含多少个位置、每个位置的单位是什么、两个对象能不能相加或比较。只要这句话说不清,后面的代码即使运行成功,也可能是在计算一个没有意义的量。

SCALAR1 个数例如温度、损失、学习率
VECTORn 个数例如特征、坐标、Embedding
MATRIXm × n例如一批样本或线性变换

3.1.1 标量和向量:数字的数量不等于对象的含义

先区分“值”与“位置”

标量只有一个数值,写成 7 时不需要说明第几个坐标;向量是一组按位置排列的数,写成 (7, 2) 时,两个位置可能分别代表身高和体重,也可能代表平面上的横坐标和纵坐标。数字本身并不能告诉你语义,顺序也不是装饰。如果把一个向量的两个分量交换,数学上得到的是另一个向量;在程序中却可能仍然通过类型检查,直到模型输出明显异常才被发现。

向量可以用行或列的形式书写。[x, y, z] 常用于展示一个长度为 3 的一维数组,[[x], [y], [z]] 则是 3 行 1 列的二维矩阵。它们包含相同的数字,却不一定能直接和同一个矩阵相乘。初学时建议在变量名或注释中写出形状,例如 feature_vector: (4,)、column_vector: (4, 1)。这样做看似啰嗦,却能提前阻止“维度刚好广播成功但语义错误”的问题。

单位同样重要。把“分钟”和“秒”直接相加,Python 不会报错;把表示颜色的 RGB 向量与表示词语的 Embedding 做平均,也可能得到一组合法浮点数。数学直觉不是只问“算式成立吗”,还要问“这个对象允许这样组合吗”。模型训练中常见的归一化、缩放和标准化,本质上都是在改变数值的单位或尺度;改变之后要把新含义记录下来。

表 3.1A · 写出一个向量的四个事实
事实要回答的问题例子忽略后的风险
语义每个位置表示什么?第 0 位是阅读分钟列顺序变了却没人发现
形状有几条轴、每轴多长?(4,) 或 (4,1)广播或矩阵乘法方向错
单位数值的量纲是什么?分钟、分数、无量纲比例不同尺度支配距离
范围允许的最小和最大值?概率在 0 到 1异常值污染聚合结果
PYTHON · scalar_vector_contract.py
from math import isclose

# 先用注释写清楚语义和形状
minutes = 42.0                    # 标量,单位:分钟
feature = [42.0, 0.75, 3.0, 1.0]  # 向量,形状:(4,)

assert len(feature) == 4
assert minutes >= 0
assert 0 <= feature[1] <= 1

# 这是按位置访问,不是按“最接近的含义”访问
minutes_again = feature[0]
assert isclose(minutes_again, minutes)
print("shape=(4,), minutes=", minutes_again)

这段代码没有调用任何机器学习库,却已经包含了一个重要的模型习惯:先写契约,再让程序验证契约。len(feature) 只告诉你有四个位置,并不能证明第一个位置真的代表分钟;语义仍然需要命名、注释、数据字典或测试来保证。后续把列表换成 NumPy 数组时,仍然应保留这些断言,而不是因为库会自动处理形状就放弃说明。

VECTOR LAB 3.1.1把一行数据翻译成向量契约

设计一个表示学习记录的向量,至少包含阅读分钟、完成比例和练习次数。写出形状、单位、允许范围,并故意把分钟数改成负数,观察你的断言能否在进入后续计算前阻止它。

不要只写“长度为 3”。应说明顺序,例如 [minutes, completion_ratio, exercise_count],并分别写非负、0 到 1、非负整数等约束。若未来加入主题类别,不要直接把中文字符串塞进数值向量,应先说明编码方法。

3.1.2 范数、距离与相似度:比较前先确认比较的尺度

长度是一个函数,不是“看起来有多长”

向量的长度通常用范数表示。二维向量 v=(3,4) 的 L2 范数是 sqrt(3²+4²)=5,它对应平面直角距离;L1 范数是各分量绝对值之和,表示沿坐标轴走过的总路程;无穷范数则只看绝对值最大的分量。不同范数不是谁对谁错,而是在表达不同的成本模型。图像稀疏、特征选择和鲁棒优化中,选择范数就等于选择“什么样的偏差更值得惩罚”。

距离通常是两个向量之间的量,常见的欧氏距离为 ||x-y||₂,余弦相似度则比较方向:cos(x,y)=x·y/(||x||₂||y||₂)。两个向量长度差很多但方向相同,余弦相似度仍可能接近 1;如果任务关心“总量”,欧氏距离更合适;如果任务关心“语义方向”,余弦通常更容易解释。Embedding 检索经常把向量归一化后使用点积,此时点积和余弦相似度在数值上相同,但前提是双方都真的归一化了。

尺度会改变距离的主导因素。一个特征范围是 0 到 1,另一个范围是 0 到 10000,直接计算欧氏距离时,后者几乎决定结果。标准化、最大最小缩放或单位换算可以缓解这种影响,但它们不是“让数据更正确”的魔法:缩放参数必须从训练集估计,验证集和测试集只能复用,不能用全体数据偷看未来。对于向量数据库,还要记录距离度量、归一化方式和版本,否则同一批 Embedding 换一个设置就会得到不同邻居。

表 3.1B · 三种常见长度与距离
名称公式直觉适合先问的问题
L1 范数Σ|xᵢ|每个方向移动的总量少数大偏差还是很多小偏差?
L2 范数√Σxᵢ²直线距离与能量整体偏离有多大?
无穷范数max|xᵢ|最严重的一项任何一个分量都不能超标吗?
余弦相似度x·y/(||x||||y||)方向是否一致忽略总长度后是否仍相似?
PYTHON · vector_metrics.py
from math import sqrt

def dot(left, right):
    assert len(left) == len(right)
    return sum(a * b for a, b in zip(left, right))

def l1(vector):
    return sum(abs(value) for value in vector)

def l2(vector):
    return sqrt(dot(vector, vector))

def cosine(left, right):
    left_norm = l2(left)
    right_norm = l2(right)
    if left_norm == 0 or right_norm == 0:
        raise ValueError("零向量没有可定义的方向")
    return dot(left, right) / (left_norm * right_norm)

a = [3.0, 4.0]
b = [6.0, 8.0]
print("L1(a)=", l1(a))
print("L2(a)=", l2(a))
print("cos(a,b)=", cosine(a, b))  # 方向相同,接近 1

零向量是一个值得单独记住的边界:它的 L1 和 L2 范数都是 0,但余弦相似度需要除以长度,因而没有定义。实际系统中不能把这种情况交给浮点异常或得到一个随意的 0;应该在数据清洗、Embedding 生成或检索函数中明确处理。类似地,浮点数比较要允许合理误差,不要直接写 l2(a) == 5 来判断计算是否成功。

图 3.1 · 方向与长度是两件事自制向量示意图
v:短,方向相同2v:长,方向仍相同u:方向不同原点横坐标纵坐标

余弦相似度主要看方向,欧氏距离同时看方向和长度;选择指标前,先说清任务更关心哪一部分。

VECTOR LAB 3.1.2解释一个“相似”结果

比较 [1, 0]、[10, 0] 和 [0, 1]。分别计算 L2 距离和余弦相似度,写一句话解释为什么某两个向量在一种指标下“更近”,在另一种指标下却不一定。

前两个方向相同,所以余弦相似度为 1;它们的长度差异很大,因此欧氏距离不为 0。与 [0,1] 的余弦相似度为 0。答案重点是区分“方向相似”和“位置相近”,并说明零向量等边界。

3.1.3 线性组合:用基向量表达混合的含义

“加权平均”不是随便加权

如果一组基向量能够通过系数相加得到一个目标向量,我们称目标向量是它们的线性组合。二维平面里,单位向量 e₁=(1,0) 和 e₂=(0,1) 可以表达任何 (x,y)=x e₁+y e₂。在特征工程中,基向量可以理解成一组方向;在 Embedding 中,向量的每个维度未必有清晰的人类标签,但线性组合仍然可以表示“多个因素共同出现”的状态。重要的不是把每个维度拟人化,而是观察组合、尺度和相似关系是否稳定。

系数是否非负、是否加起来等于 1,会改变解释。非负且和为 1 的系数形成凸组合,结果落在几个点之间,可解释为混合比例;系数允许为负时,结果可能离开原来的线段或区域,适合表达方向差异,却不能再直接说成“比例”。模型中的权重通常不满足和为 1,它们是优化过程中为了降低损失而学习到的系数。把所有权重都叫“概率”会造成误解,只有经过明确归一化并满足概率约束后才可以这样解释。

线性组合还帮助理解插值和外推。两个已知向量之间的插值通常比较平滑,因为系数在 0 到 1 之间;把系数推到区间外是外推,结果可能落到训练数据没有覆盖的区域。做向量检索或生成式模型分析时,不能因为一条路径在二维图上看起来直线,就断言语义空间一定线性;需要用实际任务、邻居稳定性和多个方向的实验验证这种近似是否有用。

表 3.1C · 线性组合的读法
形式系数约束几何位置常用解释
a u + b v无约束由两条方向张成的平面权重、方向叠加
(1-t)u + tv0≤t≤1u 与 v 的线段插值、混合
Σwᵢvᵢwᵢ≥0, Σwᵢ=1凸包内部比例、加权平均
u + αdα 可正可负沿方向 d 移动局部变化、对照实验
PYTHON · linear_combination.py
def combine(weights, vectors):
    if not vectors or len(weights) != len(vectors):
        raise ValueError("权重和向量数量必须匹配")
    dimension = len(vectors[0])
    if any(len(vector) != dimension for vector in vectors):
        raise ValueError("所有向量必须拥有相同维度")
    result = [0.0] * dimension
    for weight, vector in zip(weights, vectors):
        for index, value in enumerate(vector):
            result[index] += weight * value
    return result

python = [1.0, 0.0, 0.4]
math = [0.0, 1.0, 0.8]
weights = [0.25, 0.75]
mix = combine(weights, [python, math])
assert abs(sum(weights) - 1.0) < 1e-12
print("凸组合=", mix)

代码中的双层循环揭示了一个常被库函数遮住的事实:线性组合是“每个向量乘一个系数,再按对应位置相加”。如果向量维度不一致,不能靠补零或广播悄悄修复,除非任务明确需要那样做。向量化库会让运算更短,但不应让你忘记维度检查。调试时可以先用二维、三维小例子打印每一步,再换成几百或几千维的 Embedding。

易错:把权重当概率

线性层权重可以为负,也不要求加和为 1;Softmax 输出才具有非负且和为 1 的概率样式。解释模型时先确认权重来自哪一层、是否经过归一化,以及它优化的目标是什么。

易错:把坐标图当语义地图

降维图上的距离是投影后的距离,不能自动代表原空间中的全部关系。观察趋势可以启发假设,但结论要回到原始向量、检索任务和可复现实验。

VECTOR LAB 3.1.3做一次可解释的插值

选择两个三维向量,分别计算 t=0、0.25、0.5、0.75、1 时的插值结果。记录每个结果的 L2 范数,并判断范数是否一定在两个端点范数之间。

插值点一定在线段上,但 L2 范数不一定按直线变化;当两个向量方向相反时,中间点甚至可能接近零。练习重点是把“位置在中间”和“长度在中间”区分开。
表 3.1D · 本主题完成标准
能力最低证据迁移到大模型时的用法
读形状能区分 (n,) 与 (n,1)排查批次、特征和 Embedding 维度
选比较方式能解释 L1、L2 与余弦的差异选择检索指标并处理零向量
读权重能说明凸组合与一般线性组合理解加权汇总、线性层和插值实验
写契约为向量写语义、单位、范围保存模型输入与评测的字段说明

把坐标、尺度和语义放在同一张检查表里

向量进入模型前,至少要回答三层问题。第一层是表示层:每个位置对应什么特征,是否有顺序,是否允许重复或缺失。第二层是尺度层:各维的数值范围、单位和离群点如何处理,缩放参数从哪一批数据估计。第三层是比较层:后续使用内积、欧氏距离还是余弦相似度,零向量和非有限值怎样处理。只写“这是一个 768 维向量”远远不够,因为同样的形状可以承载完全不同的语义和尺度。

标准化也要区分训练阶段与推理阶段。假设用均值 μ 和标准差 σ 把特征变成 (x-μ)/σ,μ、σ 应只从训练数据估计,再原样应用到验证、测试和线上输入。如果把全部数据一起算均值,测试集的信息就泄漏进了预处理;如果每次线上请求重新计算,单个样本会被错误地当作一个总体。保存缩放参数时要带上字段顺序、缺失值策略和版本,恢复模型时先检查这些元数据。

距离度量背后其实是一组假设。欧氏距离把每个坐标的单位和重要性视为可比较,余弦相似度假设方向比长度更重要,L1 范数则让每个坐标的绝对偏差线性累加。没有“通用最好”的指标,只有与任务代价相符的指标。比如文档检索可能关心主题方向,异常检测可能更关心绝对偏离,资源分配则可能不能忽略总量。写实验报告时,把“为什么选这个度量”与结果一起保存。

可视化也可能改变直觉。把高维向量投影到二维后,投影方向、随机种子和降维方法会影响局部邻居;一个二维图上的簇不一定在原空间存在同样的边界。建议固定一组原始向量,分别报告原空间指标、二维图指标和投影参数。图形用来提出问题,回归测试和原始数值用来回答问题。

表 3.1E · 向量预处理的职责边界
步骤负责什么应保存什么不能偷偷做什么
清洗空白、缺失、非法值原值、规则、拒绝原因把缺失直接当 0
缩放统一尺度和单位均值、标准差、版本用测试集估计参数
归一化控制向量长度范数、零向量策略不说明是否改变长度
比较选择距离或相似度度量、阈值、top-k把分数当作事实概率
PYTHON · scale_and_audit.py
from statistics import mean, pstdev

train = [10.0, 12.0, 14.0, 16.0]
mean_value = mean(train)
std_value = pstdev(train)
scale = lambda value: (value - mean_value) / std_value if std_value else 0.0
scaled_train = [scale(value) for value in train]
new_value = scale(20.0)
print({"mean": mean_value, "std": std_value, "train": scaled_train, "new": new_value})

这段代码还没有处理异常值和缺失值,正好提醒我们:缩放函数不是清洗函数。正式实现可以返回一份审计记录,列出被跳过的值、训练样本数量和计算版本。对向量检索,应在缩放后再测范数和相似度,确认预处理没有让所有向量塌缩到同一个方向。

VECTOR LAB 3.1.4从业务问题选择距离

为“找相似学习资料”“发现异常延迟”“比较总资源消耗”三个任务分别选择一种主要度量,并为每种选择写出一个反例:如果使用另一种度量,什么现象可能被忽略?

相似资料可先考虑余弦,异常延迟可考虑标准化后的 L2 或稳健距离,总资源消耗不能忽略长度。答案不在于唯一选择,而在于明确任务代价、尺度假设和阈值如何验证。

标准化、归一化和坐标变换不是同一个词

标准化通常指减去均值再除以标准差,让一列特征接近零均值、单位方差;归一化可能指把数值压到某个区间,也可能指把向量除以 L1 或 L2 范数。不同资料对词语的使用不完全一致,所以实验中要写公式、统计量来源和拟合范围。只在训练集上估计均值与标准差,再把同一组统计量应用到验证和测试,才能避免把未来信息泄漏进预处理。

向量除以范数会改变长度信息,只保留方向。对检索来说,这可能让不同长度的表示更公平;对需要用范数表达置信度的任务,却可能损失信号。L1 归一化让分量绝对值之和为 1,L2 归一化让平方和开根为 1;稀疏特征、概率分布和几何方向分别可能更适合不同范数。选择范数前先写出下游相似度和决策含义。

坐标系改变后,数值大小的直觉也会改变。一个向量在标准基下看起来稀疏,经过旋转后可能每个分量都非零;一个矩阵的元素很大,不代表它会把所有方向都放大,因为奇异值和方向共同决定作用。比较模型表示时,要固定坐标变换、随机种子和降维方法,避免把可视化坐标的变化误认为语义变化。

距离度量隐含了几何假设。欧氏距离假设各轴可相加且尺度可比较,曼哈顿距离对单轴偏差更线性,余弦距离忽略长度,马氏距离还会考虑协方差结构。高维空间会出现距离集中、近邻差异变小等现象,不能直接把低维图上的“远近”套到高维检索。应在真实查询集上验证距离与任务指标的关系。

表 3.1F · 预处理与距离选择
操作公式/效果保留什么泄漏或误用风险
标准化(x-μ)/σ相对位置与尺度用全数据估计 μ、σ
Min-Max映射到固定区间相对顺序线上超出训练范围
L1 归一化除以绝对值和相对贡献全零向量无定义
L2 归一化除以 Euclidean 范数方向丢失长度信号
余弦距离一减方向相似度角度关系忽略范数可能不适合任务
马氏距离考虑协方差逆矩阵去相关后的距离协方差估计不稳定
PYTHON · preprocessing_contract.py
from math import sqrt

def fit_standardizer(train):
    if not train:
        raise ValueError("训练数据不能为空")
    mean = sum(train) / len(train)
    variance = sum((value - mean) ** 2 for value in train) / len(train)
    scale = sqrt(variance)
    if scale == 0:
        raise ValueError("常数特征不能按标准差缩放")
    return mean, scale

def transform(values, mean, scale):
    return [(value - mean) / scale for value in values]

mean, scale = fit_standardizer([10, 12, 14, 16])
print(transform([11, 15], mean, scale))
# 练习:故意把测试数据加入 fit,观察结果为何发生泄漏

预处理对象应像模型参数一样保存版本。上线时先加载训练阶段拟合出的统计量,再处理新数据;不要每个请求重新计算均值,否则输入分布会改变模型坐标。对于批量推理,记录原始值、转换后值、缺失标记和异常截断数量,才能在结果异常时回溯。

VECTOR LAB 3.1.5用同一组数据比较三种距离

选三组二维向量:方向相同但长度不同、长度相同但方向相反、一个轴有极端值。计算欧氏距离、曼哈顿距离和余弦相似度,写出每种度量最符合与最不符合的场景。

方向相同但长度不同的向量余弦相似度仍高,欧氏/曼哈顿距离会反映长度差;方向相反时余弦接近 -1;有极端轴时欧氏距离受平方放大,曼哈顿更稳但仍受尺度影响。答案要把度量的几何假设与数据语义连接起来。

把向量当成接口:长度、方向与单位必须同时可追踪

同一组数字在不同任务中可能代表金额、像素、词向量或梯度。学习向量时,除了会算加减乘,还要给它写上长度、单位和来源。长度决定内存和可相乘的维度,方向决定相似性,单位决定数值大小能否比较。一个没有这些说明的向量,最多只能被当作暂时的数组,不能安全地进入数据管道。

向量化代码的核心是保持不变式:元素顺序不变、样本之间不串位、缺失值的掩码与数据同步、转换前后能恢复或能解释。切片、拼接和排序都会改变位置关系,尤其要警惕先排序特征再排序标签、只对非空值排序或在广播时复制了错误样本。每次变换后打印一个带有 ID 的小样本,比打印一整屏数字更容易核对。

一个好的向量测试包含正例、边界和反例。正例验证常见输入能得到预期形状;边界覆盖空向量、单元素、零向量、最大长度和极端值;反例故意传入长度不一致、NaN、错误单位或重复 ID,确认函数会拒绝而不是悄悄修正。测试名直接写出性质,例如“零向量不能计算余弦”,比“test_case_3”更适合后续维护。

表 3.1G · 向量接口的不变式
不变式为什么重要最小测试违反后的症状
长度与轴名一致保证相乘与检索语义形状/轴断言广播成功但结果错
ID 与元素同步防止样本串位带 ID 的手算样本指标异常却难定位
单位可比较距离和阈值有意义单位转换回归某一轴支配距离
零向量有策略避免方向未定义显式异常或约定返回NaN 传播
掩码同步不把缺失当真实值删除样本后比对 ID统计分母错误
PYTHON · vector_invariants.py
def pairwise_difference(rows):
    if not rows:
        return []
    ids = [row["id"] for row in rows]
    values = [row["value"] for row in rows]
    if len(ids) != len(set(ids)):
        raise ValueError("id 必须唯一")
    if any(not isinstance(value, (int, float)) for value in values):
        raise TypeError("value 必须是数字")
    return [{"id": item_id, "delta": value - values[0]}
            for item_id, value in zip(ids, values)]

print(pairwise_difference([{"id": "a", "value": 2}, {"id": "b", "value": 5}]))
# 练习:加入缺失值并决定是拒绝、填补还是携带 mask

这里的函数没有把缺失值偷偷填成零,因为零可能是有意义的观测。工程上可以选择拒绝、用训练集统计量填补,或返回值与 mask 两个对象;无论哪种方案,都要在接口和实验记录中写明。向量的“基础”真正落地,就是让每一个数字都能被解释、被验证、被追溯。

02 · MATRICES & TRANSFORMS

把矩阵看成批量变换:形状决定能不能相乘

矩阵最容易被误解成“数字排成的表格”。在机器学习里,它更像一台有规则的变换机器:输入向量从右侧进入,矩阵按行读取输入的各个分量,再把结果送到新的坐标空间;一批输入同时进入时,矩阵还可以一次完成许多样本的计算。矩阵乘法的难点不是乘加本身,而是确认每一个轴的语义、乘法的方向和输出形状。只要把形状写在纸上,许多看似神秘的报错都能提前发现。

INPUTn 维向量每个位置是一个特征
WEIGHTSm × nm 个输出各读 n 个输入
OUTPUTm 维向量每个输出是一次点积

3.2.1 矩阵乘法:内侧维度相等,外侧维度留下

先用“形状句子”读乘法

若矩阵 A 的形状是 (m,n),矩阵 B 的形状是 (n,p),那么 A @ B 的结果形状是 (m,p)。中间的两个 n 必须相等,因为 A 的每一行要和 B 的每一列做点积;外侧的 m 和 p 留在结果里。记忆“行乘列”不如完整说出这句话:左边每个输出行使用左矩阵的一行,右边每个输出列提供一列,沿内侧维度逐项相乘再求和。

线性层通常写成 y = W x + b。若输入 x 是 n 维列向量,权重 W 就是 (m,n),偏置 b 是 (m,) 或 (m,1);输出 y 有 m 个分量。一次处理 batch 个样本时,可以把样本按行放成 X:(batch,n),写成 X @ W.T + b,也可以把样本按列放成 (n,batch) 并使用另一种写法。两种约定都能成立,危险的是在没有说明的情况下混用。

广播让偏置能够加到每一行,但它只是在形状兼容时自动扩展,不会替你确认语义。scores + bias 成功并不说明 scores 的最后一轴真的是类别;如果把批次轴和类别轴调换,代码可能仍然返回一个数组。调试矩阵代码时,除了打印数值,还要打印 shape、每一轴的名称和一个手算样本。

表 3.2A · 矩阵乘法的形状追踪
表达式左侧形状右侧形状结果形状
A @ x(m,n)(n,)(m,)
X @ W.T(batch,n)(n,m)(batch,m)
A @ B(m,n)(n,p)(m,p)
A * B相同或可广播相同或可广播逐元素乘法形状
PYTHON · matrix_multiply_by_hand.py
def matmul(left, right):
    if not left or not right:
        raise ValueError("矩阵不能为空")
    left_cols = len(left[0])
    right_cols = len(right[0])
    if any(len(row) != left_cols for row in left):
        raise ValueError("左矩阵不是规则矩阵")
    if any(len(row) != right_cols for row in right):
        raise ValueError("右矩阵不是规则矩阵")
    if left_cols != len(right):
        raise ValueError("内侧维度不相等,不能相乘")
    return [[sum(left[i][k] * right[k][j] for k in range(left_cols))
             for j in range(right_cols)]
            for i in range(len(left))]

weights = [[1, 2, 3], [0, 1, 1]]
features = [[2, 1], [1, 0], [3, 2]]
print(matmul(weights, features))  # 结果形状:(2, 2)

手写实现的价值不在于替代 NumPy,而在于把“每个结果位置怎么来的”暴露出来。结果左上角是第一行权重与第一列特征的点积,右下角是第二行与第二列的点积。换成 numpy.matmul 或深度学习框架以后,仍可以用这个两行三列的小例子做回归测试。对大矩阵,库会使用更高效的底层实现,但它仍然遵守相同的内侧维度规则。

MATRIX LAB 3.2.1先写形状,再写算式

给定批次输入 X:(8,4) 和线性层权重 W:(3,4),写出不转置和转置两种数据布局下的乘法表达式,并标出结果形状。再说明偏置应该有几个分量。

按行存样本时使用 X @ W.T,形状为 (8,4) @ (4,3) = (8,3),偏置有 3 个分量并按行广播。按列存样本时可使用 W @ X,但输入必须是 (4,8),结果为 (3,8)。答案要写清布局,而不是只写“矩阵相乘”。

3.2.2 线性变换:矩阵改变坐标,不自动改变问题

旋转、缩放、剪切和投影都可以写成矩阵

在二维平面中,矩阵可以把一个点旋转、拉伸、压缩或剪切。把这些操作想成“坐标系换了一副眼镜”,有助于理解神经网络的线性层:它不是凭空创造信息,而是重新组合已有分量,把输入送到对当前任务更方便的坐标系。随后接上的非线性激活,才让多层组合能够表达不再是单一平面的边界。

线性变换有几个重要性质。零向量会被送到零向量;加法和数乘关系保持不变,即 T(a+b)=T(a)+T(b)、T(ca)=cT(a)。如果有偏置 b,变换变成仿射变换,原点可以移动,严格来说不再是线性。教学和框架文档常把 Wx+b 统称为线性层,是工程上的简称;读公式时仍要知道偏置扮演的是平移,而不是旋转或缩放。

矩阵的列空间告诉你输出可能落在哪些方向,秩表示其中真正独立的方向数量。若权重矩阵的秩低于输出维度,某些输出方向无法独立变化;这可能是有意的低秩压缩,也可能是数据或初始化造成的信息瓶颈。做模型分析时,不要只看矩阵的元素大小,还要观察奇异值、秩和条件数。一个元素很小不等于对应方向不重要,许多小元素组合起来仍可能形成稳定的方向。

表 3.2B · 变换的几何与工程对应
变换几何效果可检查的事实常见用途
旋转方向改变,长度不变范数是否保持坐标变换、数据增强
缩放不同轴放大或缩小各轴比例、单位标准化、特征重标度
投影丢掉部分方向秩与残差降维、压缩、检索
平移原点移动偏置是否存在阈值、决策边界移动
PYTHON · affine_transform.py
from math import cos, sin, pi, hypot

angle = pi / 2
rotation = [[cos(angle), -sin(angle)], [sin(angle), cos(angle)]]
point = [1.0, 0.0]
bias = [2.0, -1.0]

def apply(matrix, vector):
    return [sum(row[i] * vector[i] for i in range(len(vector))) for row in matrix]

rotated = apply(rotation, point)
shifted = [value + delta for value, delta in zip(rotated, bias)]
print("rotated=", [round(value, 6) for value in rotated])
print("shifted=", [round(value, 6) for value in shifted])
assert abs(hypot(*point) - hypot(*rotated)) < 1e-9

由于浮点数不能精确表示所有角度,旋转 90 度后通常会出现接近零但不等于零的数。测试几何变换时使用误差范围,比直接比较字符串或完全相等更可靠。还要区分“矩阵乘法”和“逐元素乘法”:旋转矩阵必须使用行列点积,若误写成星号,代码可能返回形状正确却完全不同的结果。

图 3.2 · 一层变换如何移动一组点自制坐标示意图
输入坐标W x + b 后旋转 / 缩放 / 平移

矩阵重新组合方向,偏置负责平移;对照变换前后的形状和范数,可以验证公式是否按预期工作。

MATRIX LAB 3.2.2辨认“线性”还是“仿射”

判断 T(x)=2x、T(x)=2x+[1,0] 和“先取每个分量的平方”是否为线性变换。为每个判断给出一个能快速验证的性质或反例。

2x 是线性的;加常量后原点不再映射到原点,是仿射变换;逐分量平方不满足加法和数乘关系,是非线性。不要只凭“公式里有乘法”判断,要检查 T(0)、加法和数乘。

3.2.3 转置、逆与最小二乘:结果可靠还要看数值条件

转置改变方向,逆矩阵不是“每个数取倒数”

转置把行列交换,记作 Aᵀ。点积可以写成 xᵀy,协方差和注意力中的矩阵关系也经常依赖转置。转置不会改变一个矩阵的元素集合,但会改变形状和乘法方向。逆矩阵 A⁻¹ 只有在方阵满足可逆条件时才存在,且它满足 A⁻¹A=I;把每个元素取倒数通常没有意义。实际求解 Ax=b 时,优先使用线性方程求解器,不要先显式计算逆矩阵再相乘,因为求逆通常更慢,也可能放大数值误差。

当方程比未知数多时,往往没有一个点能完全满足所有方程。最小二乘寻找一个让残差平方和最小的解:min ||Ax-b||₂²。它不是“把错误平均掉”这么简单,而是明确选择了 L2 残差作为目标。异常值会被平方放大,所以需要检查残差分布、尺度和数据收集过程。加入正则化后,目标会再惩罚参数大小,得到的是偏差与稳定性的折中。

条件数描述输入的小扰动可能被解放大多少。条件数很大的矩阵接近不可逆,哪怕代码没有报错,结果也可能对舍入误差极其敏感。训练模型时出现 loss 抖动、梯度爆炸或参数异常,未必只是学习率问题,也可能与输入尺度、特征共线和矩阵条件有关。先标准化、检查奇异值和记录误差,再决定是否更换优化器。

表 3.2C · 线性方程的工程选择
任务推荐做法不建议的捷径要记录的证据
求 Ax=b调用 solve 类函数显式求逆再乘残差范数、条件数
方程过定最小二乘或带正则化假装存在精确解残差分布、样本量
矩阵近奇异检查尺度、SVD、特征冗余只增加小数位数奇异值、容差、版本
比较两次结果绝对和相对误差直接字符串比较误差阈值与单位
PYTHON · solve_and_check.py
import numpy as np

A = np.array([[3.0, 1.0], [1.0, 2.0]])
b = np.array([9.0, 8.0])
x = np.linalg.solve(A, b)
residual = A @ x - b
print("x=", x)
print("residual norm=", np.linalg.norm(residual))
assert np.allclose(A @ x, b)

# 条件数越大,解对输入扰动越敏感
print("condition number=", np.linalg.cond(A))

这段代码使用 NumPy 的线性代数模块,官方参考文档把求解、范数、奇异值分解等例程按主题组织,便于继续查阅。学习时不要只复制调用方式:先写出 A、b 的形状,说明为什么可以求解,再用残差验证输出。模型里的大矩阵通常不会手算,但同样需要记录 dtype、形状、误差容忍度和输入来源。

MATRIX LAB 3.2.3让一个矩阵结果可验收

写一个函数接收 A、x、b,返回预测值、残差向量和残差 L2 范数。对一个精确解和一个故意写错的解分别运行,说明只看预测值为什么不够。

验收至少包含 prediction = A @ x、residual = prediction - b 和范数;残差为零或小于明确阈值才算满足方程。还应检查 A、x、b 的内侧维度,记录单位和容差,不要把“程序没有异常”当成数学正确。
表 3.2D · 本主题完成标准
能力最低证据连接大模型的场景
追踪形状能写出每次 @ 前后的形状批次、线性层、Q/K/V 投影
区分变换能解释 W、b、转置和逆的职责理解参数如何改变表示
检查数值会看残差、误差和条件数排查训练不稳定与输入尺度
选择工具知道 solve 优于显式求逆把数学意图交给可靠实现

先给每条轴写名字,再谈矩阵乘法

同一个形状可以代表完全不同的东西。(32, 768) 可能是 32 个句子的句向量,也可能是一个批次中 32 个位置的隐藏状态;如果不写轴名,看到 transpose 或 reshape 时就无法判断它是在改变布局,还是误把语义换掉。建议在草稿、注释和日志里同时记录形状与轴名,例如 tokens:(batch, seq, hidden)、W_vocab:(hidden, vocab)。这一步看似慢,却能把大量“维度能对上、意义却错了”的 bug 变成可定位的检查项。

广播也应该用语义来解释。bias:(hidden,) 加到 states:(batch, seq, hidden) 是沿最后一轴给每个位置加同一组偏置;mask:(batch, seq) 扩展成 (batch, 1, seq) 后参与注意力分数,是为了对每个 query 共享同一套 key 位置屏蔽规则。若广播发生在错误的轴上,结果仍可能通过类型检查,甚至 loss 也会下降一点,因此测试要构造一个“每个轴使用不同数字”的小数组,让错误广播立刻显形。

批量矩阵乘法还涉及“哪一批和哪一批配对”。当 Q:(batch, heads, query, depth)、K:(batch, heads, key, depth) 时,注意力分数需要把 K 的最后两轴转为 (batch, heads, depth, key),得到 (batch, heads, query, key)。这里转置的目的不是让代码看起来整齐,而是让每个 query 与所有 key 做点积。写出轴名以后,einsum、matmul 和显式循环只是三种实现,数学关系是一致的。

处理真实数据时还要考虑空批次、单样本、长度为一和不同 dtype。numpy 中一维数组与列向量的行为不同;深度学习框架也可能因为 squeeze() 把批次轴悄悄删掉。稳妥的函数应在入口处断言最小维度、允许的 dtype 和每一轴的范围,在出口处检查形状不变式。错误信息应告诉使用者“期望什么、实际是什么、哪一个轴不对”,而不是只抛出一条底层广播异常。

表 3.2E · 轴语义与广播检查表
对象推荐写法要验证的关系典型陷阱
隐藏状态(batch, seq, hidden)最后一轴是特征维把 seq 当 hidden 做投影
偏置(hidden,)只沿特征轴广播偏置多出一个批次维
注意力分数(batch, heads, query, key)每个 query 对所有 keyQ、K 轴顺序互换
掩码(batch, 1, 1, key)能广播到分数形状把 padding 当 query 屏蔽
标量损失() 或单元素反向传播只有一个目标误把每个 token 损失直接当总损失
PYTHON · shape_audit.py
def assert_shape(name, value, expected):
    actual = tuple(value.shape)
    if actual != tuple(expected):
        raise ValueError(f"{name} 期望 {expected},实际 {actual}")

class FakeTensor:
    def __init__(self, shape):
        self.shape = shape

states = FakeTensor((2, 4, 8))
bias = FakeTensor((8,))
assert_shape("states", states, (2, 4, 8))
assert_shape("bias", bias, (8,))
print("axis contract passed")

# 练习:把 states 改成 (4, 2, 8),让检查器指出布局变化

这段检查器故意只依赖 shape,它可以迁移到 NumPy、PyTorch 或其他张量库。生产代码中还可以把轴名封装成数据类,或使用命名张量、类型标注和单元测试。重要的是把形状约定变成接口的一部分:调用者无需猜测,未来换实现时也有一条可以回归的契约。

MATRIX LAB 3.2.4制造一次广播错误

构造一个 scores:(2,3,4) 的数组,使三个轴的数值范围明显不同;分别尝试加入 bias:(4,)、bias:(3,) 和 bias:(2,1,1)。记录哪些表达式能够广播、它们实际沿哪个轴起作用,并写出一个不依赖肉眼猜测的断言。

最后一轴长度为 4 的偏置可以逐特征广播;长度为 3 的偏置不能直接匹配最后一轴,除非先明确扩展到目标轴;(2,1,1) 会给每个样本整体加一个标量。答案必须写出广播后的形状与语义,不能只说“能运行”。

秩、特征值与 SVD:识别矩阵保留了哪些方向

矩阵的秩可以理解为“输出空间里真正独立的方向数”。一个 3×3 矩阵即使写着九个参数,如果三行互相线性相关,它实际只能把输入压到一个更低维的子空间。低秩并不自动等于坏事:LoRA 等参数高效方法会有意用两个小矩阵的乘积近似一个大更新;但如果任务需要区分的方向被丢掉,低秩就会成为表达能力瓶颈。

特征向量满足 Av=λv,表示某些方向经过变换后方向不变,只被缩放。对称矩阵的特征向量可以提供一组相互正交的坐标,协方差矩阵的特征值则反映数据沿这些方向的方差。SVD 把任意矩阵写成 UΣVᵀ:V 描述输入方向,Σ 给出每个方向的缩放强度,U 描述输出方向。按奇异值从大到小截断,就得到一个可解释的低秩近似。

条件数与最小奇异值直接相关。若最小奇异值接近零,逆问题会放大噪声;在训练中,这表现为某些方向的梯度非常大或非常小。解决方案可能是标准化输入、移除冗余特征、增加正则化、使用稳定的求解器,或者接受一个近似解。不要把“换成 float64”当成唯一办法:更高精度只能减轻舍入,无法修复本身信息不足的模型。

矩阵分解还有一个沟通价值:它把“模型在学什么”从一堆元素提升到方向和尺度。可视化奇异值谱、记录有效秩、比较训练前后的谱变化,常常比只看权重均值更能解释压缩、过拟合或表示坍缩。解释结果时要同时说明数据范围、分解算法、截断阈值和随机种子,避免把一次数值现象夸大成普遍结论。

表 3.2F · 分解结果的阅读方式
指标回答的问题异常信号下一步
秩有多少独立方向?远低于预期维度检查冗余与信息瓶颈
最大奇异值最强方向放大多少?远大于其他值看输入尺度、梯度和正则
最小奇异值最弱方向是否可辨?接近零检查近奇异与噪声放大
谱衰减能否低秩压缩?衰减很慢谨慎选择截断秩并验证任务指标
PYTHON · low_rank_projection.py
import numpy as np

matrix = np.array([[3.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.1]])
u, singular_values, vt = np.linalg.svd(matrix, full_matrices=False)
for rank in (1, 2, 3):
    approx = (u[:, :rank] * singular_values[:rank]) @ vt[:rank, :]
    error = np.linalg.norm(matrix - approx)
    print(f"rank={rank}, error={error:.4f}")

# 练习:把第三个奇异值改为 0.8,观察低秩近似误差如何变化
MATRIX LAB 3.2.5为低秩近似设定验收条件

对一组矩阵计算奇异值,选择最小的秩 r,使重构相对误差不超过 5%。除了误差,还要说明压缩后会丢失什么、为什么不能只在训练集上验证。

可以用 ||A-A_r||/||A|| 作为相对误差,并在验证集或下游任务上复测。奇异值截断保留能量最大的方向,但不保证对每个任务都最优;需要记录选择的 r、误差阈值、数据范围和下游指标。

转置和乘法顺序:用小矩阵建立回归测试

转置满足 (AB)ᵀ=BᵀAᵀ,顺序会反过来;这不是记号游戏,而是因为先应用 B 再应用 A 的组合,在反向描述时要先撤回 A。类似地,矩阵乘法通常不满足交换律,AB 与 BA 可能形状不同甚至一个有定义、另一个没有。写代码前先在纸上标出每个矩阵的输入轴和输出轴,再决定 API 的参数顺序。

批处理让矩阵对象多出一个样本轴。把 X 按行存储时,线性层可写为 XWᵀ;若框架层默认输入形状为 (features,batch),则公式会写成 WX。不要因为教程中的字母方向不同就认为某个公式错误,重点是约定前后一致,并用一个手算样例验证每个输出位置。

回归测试应包含可预测的特殊矩阵:单位矩阵不改变向量,零矩阵输出为零,对角矩阵只缩放各轴,置换矩阵只交换分量。再加入一个非对称的小矩阵,检查转置、乘法和逆的顺序。特殊例子不能覆盖所有 bug,却能在重构底层实现后快速发现最基本的方向错误。

表 3.2G · 矩阵回归测试的特殊输入
输入预期性质发现的错误示例
单位矩阵 IIx=x维度、乘法顺序线性层初始化
零矩阵 00x=0偏置是否混入屏蔽/消融
对角矩阵逐轴缩放轴交换、广播特征重标度
置换矩阵只交换位置转置与 reshape头/序列重排
非对称矩阵转置后不同误用交换律一般线性层
PYTHON · matrix_regression.py
def matvec(matrix, vector):
    if any(len(row) != len(vector) for row in matrix):
        raise ValueError("内侧维度不一致")
    return [sum(value * item for value, item in zip(row, vector)) for row in matrix]

identity = [[1, 0], [0, 1]]
zero = [[0, 0], [0, 0]]
diagonal = [[2, 0], [0, 3]]
point = [4, -1]
assert matvec(identity, point) == point
assert matvec(zero, point) == [0, 0]
assert matvec(diagonal, point) == [8, -3]
print("special-matrix tests passed")
# 练习:加入交换两个坐标的置换矩阵并写断言

当数值来自浮点运算时,回归测试应使用绝对误差和相对误差;当结果是整数或纯置换时,精确比较更直观。测试名称要说明语义,例如 test_identity_preserves_vector,不要只写 test_matrix_1。这些小测试会成为后续注意力、卷积和低秩模块的安全网。

MATRIX LAB 3.2.6解释一个转置 bug

某层把 X:(batch, seq, hidden) 错误地变成 (batch, hidden, seq),但后续矩阵乘法仍能运行。设计一个最小输入,使输出差异明显,并写出应放在哪一层的断言。

让 batch、seq、hidden 取互不相同且数值模式明显的长度,逐轴填入递增数字;比较正确布局与错误布局的轴名、形状和一个位置的手算值。断言应紧贴 reshape/transpose 后,检查形状与轴语义,而不是等到最终 loss 才发现。

03 · FUNCTIONS & LIMITS

从函数图像读模型行为:输入、输出与局部变化

函数把一类输入和一类输出连接起来,是理解激活函数、损失函数和概率映射的共同语言。把函数写成 f(x) 并不意味着它只有一个输入或永远有定义;你还要说明定义域、值域、参数、单位和边界。画图能让趋势变得直观,代数和代码则负责验证图上的直觉。学习这一部分时,先练习读函数的行为,再讨论它在模型里的名字。

DOMAIN允许输入例如 log 只接受正数
RULE映射关系同一输入遵守同一规则
RANGE可能输出例如 sigmoid 落在 0 到 1

3.3.1 函数是带边界的输入—输出契约

“调用成功”不等于“使用合法”

函数的定义域是它允许接收的输入集合,值域是它可能返回的输出集合。平方函数对所有实数有定义,平方根在实数范围内要求输入不小于 0,倒数要求输入不等于 0。Python 的 math.sqrt(-1) 会抛出异常,说明库在保护定义域;某些数组库则可能返回 nan 并发出警告,程序仍然继续。无论哪种行为,都应该在数据契约中写明,而不是等结果扩散后才追查。

函数的参数可以改变图像。f(x)=a x+b 中,a 控制斜率,b 控制截距;在神经网络里,权重和偏置也承担类似职责。复合函数 g(f(x)) 先经过内层函数再经过外层函数,顺序不能随意交换。把标准化、激活和损失写成一条链,可以帮助你定位误差究竟是输入范围、非线性还是目标定义造成的。

离散代码和连续数学之间还有一个转换。数学函数可以在任意实数点计算,程序通常在有限数组上采样。采样间隔太大可能漏掉峰值,间隔太小则增加计算成本;绘图中的平滑曲线是若干采样点的连接,不是程序自动证明函数在中间没有剧烈变化。做实验时记录采样范围、步长和随机种子,别人才能复现图形。

表 3.3A · 读一个函数时要写出的信息
信息问题例子对模型的影响
定义域哪些输入合法?log(x) 要求 x>0决定清洗和异常处理
值域输出范围是什么?sigmoid(x)∈(0,1)能否当作概率解释
单调性输入增加时输出怎样变?平方在负半轴下降影响排序和阈值
斜率局部变化有多快?线性函数的 a影响梯度与学习率
PYTHON · function_contract.py
from math import log, sqrt

def safe_log(value):
    if value <= 0:
        raise ValueError("log 的输入必须大于 0")
    return log(value)

def describe(function, values):
    rows = []
    for value in values:
        try:
            rows.append((value, function(value), "ok"))
        except (ValueError, ZeroDivisionError) as error:
            rows.append((value, None, type(error).__name__))
    return rows

print(describe(safe_log, [0.1, 1.0, 10.0, 0.0, -1.0]))
print(describe(sqrt, [0.0, 4.0]))

把异常变成结果表的做法适合探索定义域,但正式接口仍应决定是抛错、返回缺失标记还是拒绝整批数据。最忌讳的是在没有记录的情况下把非法输入静默替换成 0,因为 0 可能拥有完全不同的业务含义。函数契约越明确,后续的测试和模型评估越容易解释。

FUNCTION LAB 3.3.1画出函数的关键点

选择 f(x)=x²-2x,找出零点、顶点和几个对称点。写一个函数返回给定列表的函数值,并说明如果输入是字符串,接口应该怎样处理。

可以配方为 (x-1)²-1,顶点是 (1,-1),零点是 0 和 2。接口应在入口检查数值类型或明确允许可转换的字符串,不能让隐式转换改变异常行为。

3.3.2 指数、对数与 Softmax:把分数变成相对权重

指数放大差异,对数把乘法变成加法

指数函数 eˣ 始终为正,输入相差一点,输出可能相差很多;对数是指数的逆函数,能把连乘的概率变成连加的对数似然。语言模型常处理一串概率的乘积,直接相乘很快会下溢到 0,因此工程实现通常在对数空间计算。看到 log 时,先问它是为了表示尺度、稳定乘法,还是定义某种损失。

Softmax 将一组任意实数分数映射成非负且总和为 1 的权重:softmax(zᵢ)=e^{zᵢ}/Σⱼe^{zⱼ}。它保留相对顺序,但不是把每个分数简单除以总和;指数会让高分更突出。温度参数 T 可以写成 softmax(z/T),温度高时分布更平,温度低时更尖。温度改变的是不确定性表达,不会自动让模型变得更准确。

直接计算大正数的指数会溢出。利用平移不变性,把所有分数减去最大值:softmax(z)=softmax(z-max(z)),结果不变但数值更安全。这是一个很好的例子:代数上等价的变形可以改善工程可靠性。实现稳定版本时仍要处理空列表、非有限值和维度;对批次数据要明确沿哪条轴归一化。

表 3.3B · Softmax 的参数与观察角度
对象作用检查误解
logit未归一化分数可以为负,不是概率分数大就代表绝对可信
exp把差异变成正权重是否溢出指数后的数可以直接解释为概率
温度 T控制分布尖锐度T 是否大于 0调温度等于改变事实
归一化轴决定谁和谁竞争每行还是每列和为 1默认轴总是正确
PYTHON · stable_softmax.py
from math import exp

def softmax(logits, temperature=1.0):
    if not logits or temperature <= 0:
        raise ValueError("分数不能为空,温度必须大于 0")
    shifted = [value / temperature for value in logits]
    pivot = max(shifted)
    weights = [exp(value - pivot) for value in shifted]
    total = sum(weights)
    if not all(map(lambda value: value == value, weights)) or total == 0:
        raise ValueError("分数包含不可计算的值")
    return [weight / total for weight in weights]

for temperature in (0.5, 1.0, 2.0):
    probabilities = softmax([2.0, 1.0, 0.0], temperature)
    print(temperature, probabilities, sum(probabilities))

代码中的 pivot 是数值稳定技巧,不是改变模型偏好的额外参数。三组温度的结果可以帮助你建立直觉:低温让最大分数占更大比例,高温让其他候选仍保留更多质量。报告模型输出时,不要只记录“最高概率”,还要记录温度、候选集合和是否经过截断或过滤。

FUNCTION LAB 3.3.2验证 Softmax 的两个性质

对任意三个分数,验证所有输出非负且总和接近 1;再给每个分数同时加上 100,比较结果是否变化。说明为什么减去最大值能够帮助稳定计算。

指数前统一加常数会产生相同倍数,分子分母约掉后结果不变;减最大值能让最大的指数为 1,其余指数不超过 1,避免大数溢出。浮点实现要用容差比较总和。

3.3.3 极限与局部近似:连续曲线是如何被代码观察的

极限关注“靠近时发生什么”

极限不是把变量直接代入某个值,而是观察输入越来越接近目标时,输出是否趋向稳定结果。例如 sin(x)/x 在 x 接近 0 时没有直接定义,但极限是 1。连续性要求函数在点上的值和附近的趋势相容;不连续点可能来自除零、分段规则或数据截断。模型中常见的激活、裁剪和阈值,都可以从连续性和局部变化角度检查它们是否会造成突变。

在一个很小的邻域里,函数可以用切线近似:f(x+Δx)≈f(x)+f'(x)Δx。这句话把复杂曲线暂时变成直线,也是梯度下降能工作的核心直觉。近似只在步长足够小、函数在该处变化平滑时可靠;步长太大,二阶曲率会显著影响结果。数值实验要同时改变步长,观察误差随步长如何变化,而不是只打印一次“看起来接近”的结果。

有限差分用 (f(x+h)-f(x))/h 估计导数。h 太大时截断误差大,太小时浮点相减会损失有效数字。中心差分 (f(x+h)-f(x-h))/(2h) 往往更准确,但需要多算一次函数。自动微分并不是魔法地“知道答案”,而是根据计算图应用链式法则;用有限差分做小规模对照,能发现实现中的符号或维度错误。

表 3.3C · 局部近似的验收
现象要问的问题实验方法风险
函数接近边界极限存在吗?从左右两侧取样把 NaN 当成普通数
步长改变近似是否稳定?比较多个 h只挑一个漂亮结果
局部线性曲率是否可忽略?观察误差和二阶项用大步长套小步长结论
数值差分误差来自哪里?中心差分与解析式对照浮点消去和尺度不当
PYTHON · finite_difference.py
from math import exp

def function(x):
    return exp(x) + x * x

def exact_derivative(x):
    return exp(x) + 2 * x

def central_difference(function, x, step):
    return (function(x + step) - function(x - step)) / (2 * step)

x = 0.7
for step in (1e-1, 1e-3, 1e-5, 1e-7):
    estimate = central_difference(function, x, step)
    error = abs(estimate - exact_derivative(x))
    print(step, estimate, error)

有限差分结果通常会先变准后变差:步长从大变小时,截断误差下降;再继续变小,浮点误差开始占主导。这个“U 形”现象提醒我们,数值计算总是在精度和稳定性之间取舍。以后看到一个自动微分梯度时,可以在二维玩具函数上用合适步长做抽查,但不要把有限差分当作高维训练的替代品。

图 3.3 · 曲线、切线与局部步长自制函数示意图
f(x), f′(x)在足够小的邻域内,用切线近似曲线xf(x)

导数描述的是某一点附近的变化率;步长太大时,切线不能代表整段曲线。

FUNCTION LAB 3.3.3找出近似失效的地方

对 f(x)=|x| 在 x=0 附近做左右有限差分。你会观察到什么?这对使用绝对值损失或分段激活有什么提示?

从左侧接近 0 的斜率约为 -1,从右侧约为 1,两个单侧导数不相等,因此经典导数不存在。工程实现可以使用次梯度或在零点选定一个约定,但分析时要记录这个非光滑边界。
表 3.3D · 本主题完成标准
能力最低证据迁移场景
读函数能写定义域、值域和参数理解激活、概率和损失
读 Softmax能说明 logit、温度和归一化轴解释分类与生成概率
做近似会用有限差分检查局部斜率验证自动微分与数值稳定性
说边界能指出除零、溢出和非光滑点设计清洗、测试和监控

函数首先是契约:输入、输出和边界都要说清楚

把函数只看成一个“代入公式的盒子”容易遗漏三个关键信息:允许的输入集合、输出的结构,以及函数是否有状态。sqrt(x) 在实数范围要求 x≥0;分类器的 predict(text) 接受字符串,却可能返回标签、概率和解释信息组成的对象。若调用者把概率列表当成标签使用,Python 不一定报错,错误却会一路传到评估阶段。因此函数文档、类型标注和运行时断言应共同描述契约。

还要区分纯函数和带副作用的函数。纯函数只由输入决定输出,适合单元测试、缓存和批量并行;读取文件、修改全局状态、写日志或更新模型参数的函数则有副作用,应该在名字或接口中体现。把“清洗文本”“计算特征”“保存结果”塞进一个函数,会让复现实验和定位错误都变困难。更好的拆分不是追求函数数量,而是让每个函数拥有一个可独立验收的职责。

函数的单调性、对称性和周期性是很实用的快速检查。若输入增加时输出应增加,可以用一组有序输入验证单调趋势;若函数应对称,检查 f(x) 与 f(-x) 的关系;若模型的归一化函数输出应落在区间内,就测试边界、极端值和空输入。性质测试不能代替完整测试,但能在改写实现后迅速发现方向性错误。

在机器学习中,一个函数往往要同时处理单个样本和批次。接口应明确是否支持广播、是否保留批次维、空批次如何返回,以及输入 dtype 是否会改变。推荐先写标量版本验证数学含义,再写向量化版本并用随机小样本对照;如果向量化结果与逐项循环不一致,优先检查轴、广播和隐式类型转换,而不是盲目调参。

表 3.3E · 函数契约的最小记录
项目应写出的内容检查例子失败时的处理
输入域类型、形状、取值范围文本非空、概率在 [0,1]尽早抛出带上下文的错误
输出域类型、形状、单位返回 (batch, classes)在出口断言不变式
边界空、极大、极小、重复值空列表、长序列、NaN给出约定行为
副作用文件、随机数、参数更新是否修改输入对象隔离并记录副作用
可复现性随机种子、版本、设备两次运行是否相同保存配置和环境摘要
PYTHON · function_contract.py
def normalize_scores(scores):
    if not scores:
        raise ValueError("scores 不能为空")
    if any(not isinstance(value, (int, float)) for value in scores):
        raise TypeError("scores 必须是数字序列")
    total = sum(scores)
    if total <= 0:
        raise ValueError("总和必须为正")
    result = [value / total for value in scores]
    if abs(sum(result) - 1.0) > 1e-12:
        raise ArithmeticError("归一化后总和不满足约定")
    return result

print(normalize_scores([2, 3, 5]))
# 练习:为包含负数、NaN 和空列表的输入补测试

示例把错误挡在函数边界,而不是让错误的概率继续向后传播。真实项目还可以使用 math.isfinite 排除无穷和 NaN,使用 typing.Sequence[float] 提升静态检查质量。注意“归一化”有很多含义:除以总和、标准化为零均值单位方差、Softmax 都不是一回事,函数名应该让读者不用翻实现就能知道选择。

FUNCTION LAB 3.3.4把模糊需求改写成函数契约

把“写一个函数处理模型输出”改写为一份契约:写出输入形状、类别轴、是否接受 logits、输出是否归一化、遇到全为负无穷和 NaN 时怎么办。再设计两个能暴露接口误用的测试。

合格答案应明确 logits 与概率的区别、类别轴位置、返回值类型和异常策略。例如输入为 (batch, classes) 的有限 logits,输出同形状且每行和为 1;全为负无穷应拒绝或返回预先约定的均匀分布,不能静默产生 NaN。测试要覆盖单样本与批次、极端值和错误轴。

数值稳定不是优化细节,而是函数定义的一部分

指数函数会迅速放大差异:当 logits 很大时直接计算 exp(x) 可能溢出,当 logits 很小时可能下溢为零。Softmax 的数学结果没有问题,问题在有限精度的表示方式。常用的稳定写法是先减去最大值:softmax(x)=exp(x-max(x))/Σexp(x-max(x))。因为分子分母同时乘了同一个常数,结果不变,却把指数的范围压到更安全的区间。

交叉熵通常也不应先把 logits 变成概率再取对数,而应直接调用稳定的 log_softmax 或框架的融合损失。融合实现能减少中间张量和舍入误差,还能正确处理忽略标签、类别权重与批次归约。学习阶段仍要会写一个小规模手算版本,用来核对目标类别、平均方式和掩码是否正确。

掩码会引入另一种边界:被屏蔽位置的 logit 常被设为负无穷。若整行都被屏蔽,稳定 Softmax 中的“最大值”也是负无穷,减法可能产生 NaN。因此实现应在归一化前检查是否至少有一个有效位置,或者规定全屏蔽行返回全零并在后续跳过。不要把负无穷当成普通的大负数后就忘了这个特殊情况。

函数的可导性也影响训练。ReLU 在零点不可导,框架会采用一个约定的次梯度;硬阈值、取整和离散索引往往在大部分区间梯度为零。若一个函数用于推理而不是训练,可以接受不可导;若要放进反向传播路径,就必须说明梯度如何定义,必要时使用平滑近似。

表 3.3F · 常见函数的数值与梯度边界
函数优点需要警惕实作建议
Sigmoid输出可解释为 0 到 1两端梯度饱和二分类用稳定 BCE with logits
Softmax得到类别分布指数溢出、全屏蔽行减最大值,检查有效位置
ReLU计算简单、正区间梯度稳定负区间梯度为零监控死亡单元比例
GELU平滑地保留小负值近似公式存在误差记录实现与 dtype
LogSumExp稳定计算对数和指数实现细节易写错使用减最大值的等价式
PYTHON · stable_logsumexp.py
import math

def logsumexp(values):
    if not values or not all(math.isfinite(v) for v in values):
        raise ValueError("需要非空有限数值")
    peak = max(values)
    return peak + math.log(sum(math.exp(v - peak) for v in values))

def stable_softmax(values):
    normalizer = logsumexp(values)
    return [math.exp(value - normalizer) for value in values]

scores = [1000.0, 1001.0, 999.0]
probabilities = stable_softmax(scores)
print(probabilities, sum(probabilities))
assert abs(sum(probabilities) - 1.0) < 1e-12

可以把同一组输入交给“直接指数版”和稳定版,对比输出是否一致以及是否出现溢出。测试不要只使用 0 到 1 的小数字,那会掩盖真正的数值风险。还应测试全相同 logits、差距极大的 logits、单元素输入和合法的负值。每个测试都要写出预期的数学性质,例如和为 1、最大 logit 对应最大概率、整体平移 logits 不改变结果。

FUNCTION LAB 3.3.5验证“平移不变性”

随机生成一组 logits,分别计算 softmax(x)、softmax(x+100) 和 softmax(x-100)。用绝对误差比较三者,并解释为什么结果理论上相同、实际可能只在误差范围内相同。

Softmax 的分子分母都会乘上 exp(c),所以整体加常数不改变概率。有限精度下应使用 allclose 和明确容差;若差异很大,先检查是否直接计算了溢出的指数、类别轴或归约方式是否错误。

分段函数和激活函数:表达能力与可训练性要一起看

分段函数用不同区间的规则描述一个整体,ReLU 是最简单的例子:负数区输出零,正数区保持线性。它引入了非线性,让多个线性层不再等价于一层;但大量输入落在负区时,梯度可能长期为零,形成“死亡单元”。观察激活值的分位数、零值比例和不同层之间的尺度,比只看最终 loss 更能发现这种现象。

Sigmoid 和 tanh 在两端都会饱和,输入绝对值很大时输出接近边界,局部导数很小。GELU、SiLU 等平滑激活通过按输入大小调节保留比例,常用于现代网络,但平滑不代表一定更好。激活选择应与归一化、初始化、残差连接和 dtype 一起评估;换激活时至少做一个控制变量实验。

函数的可逆性和单调性决定信息能否被恢复。严格单调函数在定义域上可逆,非单调函数可能把不同输入映射到同一输出。神经网络中的降维、截断和激活会有意丢弃部分信息,但如果任务需要精确重构,就要选择合适的维度和结构。讨论“模型理解了多少”时,也应问哪些信息在函数链路中已经不可逆地消失。

函数图像是很好的实验工具:在同一坐标系画原函数、平移缩放后的函数和导数曲线,能直观看出参数如何改变斜率、饱和区和零点。绘图采样要覆盖极端输入,坐标范围和采样密度要记录;只画中心小区间,可能把真正的爆炸或饱和隐藏掉。

表 3.3G · 激活函数的取舍
函数导数直觉优点需要监控
ReLU负区 0,正区 1简单、正区梯度稳定零值比例、死亡单元
Leaky ReLU负区保留小斜率减少完全断梯度负区系数、输出尺度
tanh中心区较大,两端变小零中心、输出有界饱和比例
Sigmoid概率式 S 曲线二分类输出直观极端 logits、饱和
GELU/SiLU平滑且依赖输入过渡自然近似公式与精度
PYTHON · activation_probe.py
import math

def relu(value):
    return max(0.0, value)

def sigmoid(value):
    if value >= 0:
        z = math.exp(-value)
        return 1 / (1 + z)
    z = math.exp(value)
    return z / (1 + z)

samples = [-20, -2, -0.1, 0, 0.1, 2, 20]
for value in samples:
    print(value, relu(value), sigmoid(value))
# 练习:统计一批随机输入经过 ReLU 后为零的比例

Sigmoid 示例使用按符号分支的稳定写法,避免负数或正数极端时指数溢出。无论使用哪种激活,都要测试 ±∞、极大值、零点和空批次;还要确认反向传播时的梯度约定与文档一致。一个可训练的函数不仅要“返回正确数值”,还要在目标尺度和硬件精度下提供可用的梯度。

FUNCTION LAB 3.3.6用激活探针定位饱和

生成一批均值为 0、标准差分别为 0.1、1、10 的输入,统计 tanh、Sigmoid 和 ReLU 的输出均值、标准差与近似饱和比例。解释输入尺度为什么会改变训练难度。

尺度变大时,tanh/Sigmoid 更多落在两端,导数变小;ReLU 的零比例取决于分布中心,但正区输出尺度会变大。应把统计结果与初始化、归一化和学习率一起看,不能只根据某个激活函数的名称下结论。

04 · CALCULUS & GRADIENTS

导数告诉你往哪儿改:从局部斜率到反向传播

训练模型并不是“把参数调到看起来合适”,而是反复回答一个局部问题:如果某个参数增加一点,目标函数会上升还是下降,变化有多快?导数、偏导数和梯度就是这类问题的精确语言。它们不负责告诉你全局最优解,也不保证每一步都有效;它们提供当前位置附近的方向和尺度。理解这个边界,就能同时理解梯度下降为什么有用、为什么会卡住,以及为什么自动微分需要被验证。

DERIVATIVE一个方向一维函数的局部斜率
GRADIENT多个偏导上升最快的方向
CHAIN RULE逐层传递把复合函数拆成局部乘积

3.4.1 导数:用一个小步长描述变化率

导数是“输出变化 ÷ 输入变化”的极限

对一元函数 f(x),导数 f′(x) 描述在 x 附近输入改变一点时输出的变化速度。正导数表示局部上升,负导数表示局部下降,绝对值大说明对输入敏感。导数为零只说明切线水平,不代表函数已经达到全局最低点;它可能是局部极值、鞍点的一部分,也可能只是曲线暂时平坦。

单位能帮助检查导数是否合理。如果输入是分钟、输出是损失,那么导数单位是“损失/分钟”;把不同特征直接放进同一模型,特征尺度不同会让对应梯度的数值不可直接比较。标准化让优化器面对更均衡的尺度,但不应抹掉业务含义。记录缩放参数、逆变换方法和梯度统计,是让训练结果可解释的一部分。

高阶导数描述斜率如何变化。二阶导数为正通常表示局部向上弯,曲率大意味着同样的步长会产生更明显的非线性误差。梯度下降只使用一阶信息,简单、通用,但在狭长谷底或不同方向曲率差异很大时会来回摆动。后续优化器中的动量、自适应步长和二阶近似,都是在不直接求解全局问题的前提下改善这类局部移动。

表 3.4A · 读导数时的四个问题
问题对应概念实验易错
往哪边变大?导数符号比较 x+h 与 x-h把负梯度当成下降方向
变得多快?绝对值大小记录单位和尺度跨特征直接比较数值
是否平坦?接近零的梯度改变 h 看稳定性把零梯度当成全局最优
弯曲有多强?二阶导数比较不同步长误差用大步长套局部结论
PYTHON · derivative_sanity.py
from math import isclose

square_loss = lambda x, target=3.0: (x - target) ** 2
analytic_derivative = lambda x, target=3.0: 2 * (x - target)
central_difference = lambda function, x, step: (function(x + step) - function(x - step)) / (2 * step)

for x in (0.0, 2.0, 3.0, 4.0):
    estimate = central_difference(square_loss, x, 1e-5)
    exact = analytic_derivative(x)
    print(x, estimate, exact, abs(estimate - exact))
    assert isclose(estimate, exact, rel_tol=1e-8, abs_tol=1e-8)

平方损失是训练直觉的第一个好例子:目标点两侧的导数符号相反,离目标越远,斜率绝对值越大;在目标点导数为零。代码同时计算解析导数和有限差分,让你看到公式与程序如何互相验收。真正的模型损失可能有数百万参数,但每个参数仍然遵循这个局部定义。

CALCULUS LAB 3.4.1用斜率判断移动方向

对 f(x)=(x+1)²+2,计算 x=-3、-1、1 处的导数。假设步长为 0.1,说明从每个位置沿负梯度移动会发生什么。

导数是 2(x+1),分别为 -4、0、4。负梯度方向在左侧向右、右侧向左;中间点斜率为零。答案应注明这是局部方向,步长过大可能越过最低点。

3.4.2 偏导数与梯度:多个参数共同决定一个目标

梯度是最陡上升方向,负梯度是局部下降方向

当函数有多个输入,例如 L(w,b),偏导数 ∂L/∂w 在固定其他变量时观察 w 的变化;梯度把所有偏导数排成向量。梯度的方向是函数上升最快的方向,因此最小化目标时通常沿负梯度移动。这个说法依赖局部可微和步长足够小的近似,不意味着模型会沿一条直线走到全局最低点。

梯度的形状必须和参数一致。标量参数的梯度是标量,向量参数的梯度是同形向量,矩阵参数的梯度是同形矩阵。框架在反向传播后把梯度累积到参数对象上,因此每个训练批次前通常要清零;忘记清零会把多个批次的梯度相加,导致实际更新与预期不同。梯度裁剪、混合精度和分布式同步也都围绕“梯度是什么、何时更新、如何缩放”展开。

方向和大小要分开看。把梯度乘一个正数不会改变方向,只会改变建议步长;把梯度归一化能限制大小,却可能丢掉不同参数之间的相对尺度。梯度爆炸和消失分别意味着数值过大或过小,但具体原因可能来自初始化、激活、输入尺度、损失设计或长链计算。监控梯度范数、分位数和非有限比例,比只看最终 loss 更早发现问题。

表 3.4B · 参数、梯度与更新的对应关系
对象形状更新写法检查点
标量 w()w ← w - η ∂L/∂w符号、步长
向量 w(n,)逐分量减梯度同形、范数
矩阵 W(m,n)W - η∇W行列语义
批次梯度与参数同形平均或求和batch size、归一化
PYTHON · two_parameter_gradient.py
loss = lambda w, b: (w - 2) ** 2 + 3 * (b + 1) ** 2
gradient = lambda w, b: (2 * (w - 2), 6 * (b + 1))

w, b = 5.0, 2.0
learning_rate = 0.1
for step in range(5):
    dw, db = gradient(w, b)
    w -= learning_rate * dw
    b -= learning_rate * db
    print(step, loss(w, b), (dw, db), (w, b))

这个二维例子故意让 b 方向的曲率更大,所以相同学习率下两个参数靠近目标的速度不同。观察每一步的损失、梯度和参数,比只看最后一行更能解释训练过程。真实模型通常使用批次估计梯度,梯度还会受到数据顺序和随机性的影响,因此应记录种子、批次大小、聚合方式和更新次数。

CALCULUS LAB 3.4.2找出梯度形状错误

设参数矩阵 W:(3,4),输入批次 X:(8,4),输出目标 Y:(8,3)。写出预测形状、平方损失对 W 的梯度形状,并说明为什么把梯度写成 (4,3) 会导致更新错误。

预测为 X @ W.T,形状 (8,3);损失对 W 的梯度必须与 W 同形 (3,4)。(4,3) 是转置后的权重形状,若直接相减会报错或在其他布局中产生语义混淆。

3.4.3 链式法则与反向传播:把复杂梯度拆成局部乘积

复合函数的梯度可以沿计算图反向传递

如果 y=f(g(x)),链式法则告诉我们 dy/dx=(dy/dg)(dg/dx)。直觉上,先问输出对中间量敏感多少,再问中间量对输入敏感多少,两个局部变化率相乘得到整体变化。多层网络只是把这条规则扩展到向量和矩阵:前向阶段保存中间结果,反向阶段从损失开始,逐层计算并传回参数梯度。

保存中间结果会占用内存,反向传播也需要按照正确的拓扑顺序执行。某个分支被多个后续节点使用时,来自不同路径的梯度要相加;把它当成只走一条路径会漏掉贡献。残差连接、注意力分支和共享参数都体现了这个规则。理解“梯度在图上汇合”,比背诵某个框架的 backward() 调用更有迁移价值。

非线性函数的导数会影响梯度流。Sigmoid 在饱和区导数接近 0,长链相乘后容易让早期层梯度变小;ReLU 在正区保持斜率,在负区可能完全不传递。现代架构会通过归一化、残差和合适初始化改善这些问题,但任何技巧都不能替代监控。训练实验应至少记录 loss、参数范数、梯度范数和非有限值计数。

表 3.4C · 计算图中的梯度流
节点前向保存反向需要常见问题
输入 x数值和形状局部导数尺度不一致
线性层 Wx+bx、W对 W、b、x 的梯度转置或批次轴错
激活 f输入或输出f′ 的局部值饱和、死区
汇合节点各分支输出梯度相加漏加共享路径
PYTHON · chain_rule_trace.py
def forward(x, w, b):
    z = w * x + b
    y = max(0.0, z)  # ReLU
    loss = (y - 1.0) ** 2
    return z, y, loss

def backward(x, w, b):
    z, y, loss = forward(x, w, b)
    d_loss_d_y = 2 * (y - 1.0)
    d_y_d_z = 1.0 if z > 0 else 0.0
    d_z_d_w = x
    d_z_d_b = 1.0
    return loss, d_loss_d_y * d_y_d_z * d_z_d_w, d_loss_d_y * d_y_d_z * d_z_d_b

print(backward(2.0, 0.4, 0.5))
print(backward(2.0, -0.4, -0.5))  # z<0,ReLU 让梯度为 0

手写反向传播能让每个局部因子显形:损失对激活的导数、激活对线性输入的导数、线性输入对参数的导数。实际框架会自动构建计算图并处理批次,但你仍要知道哪些节点应该有梯度、哪些操作被刻意停止梯度。用一个两参数例子做有限差分对照,是发现符号、缓存和分支错误的低成本方法。

图 3.4 · 梯度沿计算图反向汇合自制计算图
x、W、bz = Wx + by = f(z)loss前向保存中间值,反向按链式法则传递梯度

共享节点收到来自多条路径的梯度时要相加;这正是残差和注意力分支能够训练的基础。

CALCULUS LAB 3.4.3做一次梯度核对

选择一个只有两个参数的函数,写出解析梯度,再用多个步长的中心差分估计。把每个参数的误差和相对误差列成表,并解释误差变大可能来自什么。

要同时检查参数顺序、扰动方向和步长;步长过大带来截断误差,过小会受到浮点消去影响。若某一维误差明显更大,优先检查该维的公式、尺度和是否漏掉链式因子。
表 3.4D · 本主题完成标准
能力最低证据大模型迁移
解释导数能用局部斜率描述变化理解参数为何按某方向更新
追踪梯度梯度形状与参数一致排查批次、转置、累积与裁剪
读链式法则能列出每个局部因子理解反向传播和残差分支
做数值核对有限差分误差在容差内验证自动微分实现

导数的三个视角:斜率、敏感度与局部近似

对一元函数来说,导数是曲线在某一点附近的斜率;对模型来说,它更像“输出对输入的敏感度”。若 f'(x) 为正,输入在该点附近增加会让输出增加;绝对值越大,单位输入变化带来的输出变化越明显。这个解释必须带上单位:温度对损失的导数和像素对损失的导数不能直接比较,除非输入已经经过相同尺度的标准化。

导数来自极限定义 f'(x)=lim[h→0](f(x+h)-f(x))/h,但计算机不能真的让 h 等于零。h 太大,截断误差明显;h 太小,两个接近的浮点数相减会损失有效位。中心差分 [f(x+h)-f(x-h)]/(2h) 通常比前向差分更准确,但仍需根据函数尺度和 dtype 选择 h。梯度检查的目的不是证明实现绝对正确,而是用独立的近似发现符号、索引和链式法则错误。

导数还可以用来判断局部趋势。若一阶导数接近零,不代表这里一定是最小值,也可能是最大值、鞍点或平坦区;要结合二阶导数、邻域采样和任务语义。非光滑点则要说明采用左导数、右导数或次梯度的哪一种约定。把“梯度为零”简单等同于“模型学好了”,是初学者常见的误读。

在程序中,数值导数、符号导数和自动微分各有职责。数值导数适合小规模验算;符号推导适合理解公式;自动微分沿计算图精确应用链式法则,适合训练。工程上常把三者配合:先手算简单例子,再用有限差分对一个小张量做抽查,最后依赖框架在大规模数据上高效反向传播。

表 3.4E · 导数检查的选择
方法优点局限适合场景
手算暴露公式与符号维度大时容易漏项单元测试、小例子
前向差分实现简单一阶误差较大快速粗检
中心差分精度通常更好需要两次函数调用梯度检查
自动微分链式法则准确、可扩展依赖计算图与内存训练与批量计算
符号推导表达式可解释复杂图可能膨胀推导与教学
PYTHON · derivative_sanity.py
def loss(x):
    return (x - 3.0) ** 2 + 1.0

def central_difference(fn, x, step=1e-5):
    return (fn(x + step) - fn(x - step)) / (2 * step)

for point in (0.0, 2.0, 3.0, 5.0):
    analytic = 2 * (point - 3.0)
    numeric = central_difference(loss, point)
    print(point, analytic, numeric)
    assert abs(analytic - numeric) < 1e-5

# 练习:把 step 改成 1e-2、1e-8,观察误差为何先降后升

示例还告诉我们一个实验习惯:不要只在一个点比较梯度。选择目标点左侧、驻点和右侧,才能看到符号变化;改变步长并画出误差,才能发现浮点抵消。若函数包含随机数、缓存、离散分支或不可导操作,数值检查要先固定随机种子、关闭随机增强,并记录这些限制。

CALCULUS LAB 3.4.4从单位出发解释梯度

假设输入是“秒”,输出是“元”,某点的导数为 -2.5。用一句话解释它的含义,再说明如果把输入改用“分钟”表示,数值导数应该如何变化。

含义是该点附近每增加 1 秒,输出约减少 2.5 元,单位为元/秒。改成分钟后同一物理关系的数值应乘以 60,单位变为元/分钟;这说明比较梯度大小前必须确认坐标尺度。

Jacobian、Hessian 与向量—雅可比积

当输入和输出都是向量时,导数不再是一个数,而是 Jacobian 矩阵。若 y=f(x),Jacobian 的第 i,j 个元素是 ∂y_i/∂x_j,它回答“每个输入分量分别影响哪个输出分量”。梯度通常专指标量损失对参数的偏导集合,是 Jacobian 的一个特殊形态。写清楚行列方向很重要,因为不同库对 Jacobian 的布局和批次维约定可能不同。

训练神经网络时,通常不需要显式构造巨大的 Jacobian,而是计算向量—雅可比积或雅可比—向量积。反向传播从损失的上游梯度开始,把局部导数按链式法则乘回参数;这样一次反向就能得到所有参数对一个标量损失的梯度。若只想观察某个输出分量,可以传入一个选择向量,避免构造整张矩阵。

Hessian 是二阶导数矩阵,描述梯度如何变化。它能识别曲率、区分平坦方向和陡峭方向,也解释了为什么同一个学习率在不同方向上效果不同。完整 Hessian 对大模型通常太昂贵,因此工程上会使用对角近似、低秩近似、Hessian—vector product 或自适应优化器的二阶统计量。学习时先在二维函数上画等高线,再把概念映射回参数空间。

高阶导数会带来计算图和内存的额外开销。若在 PyTorch 中第一次求梯度后还要对梯度求导,需要保留图;如果只是记录数值,则应使用 detach 或在无梯度上下文中操作,避免把日志张量接回图。训练循环里忘记清零梯度,会让多个 batch 的梯度累积,这有时是有意的梯度累积,有时则是隐藏 bug,必须写在实验记录里。

表 3.4F · 导数对象的形状与用途
对象输入/输出形状直觉典型用途
标量导数标量 → 标量一个数曲线斜率、有限差分
梯度参数向量 → 标量损失与参数同形状更新权重
Jacobian向量 → 向量输出数 × 输入数敏感度、坐标变换
Hessian参数 → 标量损失参数数 × 参数数曲率与二阶近似
JVP/VJP方向与导数的乘积避免显式大矩阵自动微分与高效分析
PYTHON · gradient_check.py
def objective(theta):
    a, b = theta
    return (a - 2.0) ** 2 + 3.0 * (b + 1.0) ** 2 + a * b

def analytic(theta):
    a, b = theta
    return [2 * (a - 2.0) + b, 6 * (b + 1.0) + a]

def numeric(theta, step=1e-6):
    result = []
    for index in range(len(theta)):
        plus = list(theta); minus = list(theta)
        plus[index] += step; minus[index] -= step
        result.append((objective(plus) - objective(minus)) / (2 * step))
    return result

point = [0.4, -0.3]
for expected, measured in zip(analytic(point), numeric(point)):
    assert abs(expected - measured) < 1e-5
print("gradient check passed")

这个检查器逐个扰动参数,适合验证一个很小的目标函数。注意代码块中的换行实体会被浏览器还原;复制后可以直接运行。对于框架张量,要同时检查梯度的 dtype、device、是否为零和是否包含 NaN。梯度数值正确只是必要条件,最终还要确认更新方向确实降低了验证集目标。

CALCULUS LAB 3.4.5找出累积梯度的原因

设计一个两步训练循环:第一次更新后不清零梯度,第二次更新前清零梯度。比较两种写法的参数变化,并说明什么时候累积梯度是合理的,什么时候是 bug。

默认情况下每个 batch 都应在反向前清零;否则第二步会使用第一步与第二步梯度之和。若显存不足而希望用多个小 batch 模拟大 batch,可以有意累积若干步,再除以累积步数并更新,同时记录有效 batch size。答案要说明清零位置、归一化方式和更新时机。
CALCULUS LAB 3.4.5找出累积梯度的原因

设计一个两步训练循环:第一次更新后不清零梯度,第二次更新前清零梯度。比较两种写法的参数变化,并说明什么时候累积梯度是合理的,什么时候是 bug。

默认情况下每个 batch 都应在反向前清零;否则第二步会使用第一步与第二步梯度之和。若显存不足而希望用多个小 batch 模拟大 batch,可以有意累积若干步,再除以累积步数并更新,同时记录有效 batch size。答案要说明清零位置、归一化方式和更新时机。

链式法则的阅读顺序:先问“谁依赖谁”

复合函数 z=f(g(x)) 的导数是外层导数乘以内层导数。读神经网络计算图时,可以从输入到输出列出每个中间量,再从损失反向写出局部导数;不要一看到长公式就直接展开。每条边都要标注输入和输出形状,矩阵乘法的转置方向也应随图保留。这样做能把“梯度消失”具体定位到某个饱和激活、过小权重或错误归约,而不是笼统归咎于模型太深。

残差连接提供了一条近似恒等的路径,归一化改变了局部尺度,注意力和 MLP 则通过不同的 Jacobian 组合信息。分析梯度时,分别观察主分支与捷径分支的范数,才能知道哪条路径在主导更新。对一个小网络计算逐层梯度直方图、最大值和零值比例,通常比只看最终参数梯度更有诊断价值。

自动微分记录的是一次具体前向的计算图。改变控制流、调用原地操作、复用带历史的张量,都会改变图的结构或可求导性。把推理代码放进训练图会浪费内存,把训练图误用在评估阶段会产生错误的梯度和速度。写实验时要明确何时启用梯度、何时切断图、哪些中间值只是日志。

表 3.4G · 链式法则排错顺序
步骤要问的问题证据典型错误
定义中间量每一步依赖谁?计算图与轴名把 target 当 prediction
局部导数每条边的导数是什么?手算或有限差分漏掉转置、符号
形状检查梯度是否与参数同形?shape、dtype广播到错误轴
数值检查是否有 NaN/Inf/零梯度?范数、直方图饱和、溢出、图断开
更新检查小步更新是否降损失?前后 loss学习率符号或归约错误
PYTHON · chain_rule_audit.py
def forward(x, w, b):
    hidden = w * x + b
    output = hidden * hidden
    return hidden, output

x, w, b = 2.0, 1.5, -0.5
hidden, output = forward(x, w, b)
upstream = 1.0  # d loss / d output
d_output_d_hidden = 2 * hidden
d_hidden_d_w = x
gradient_w = upstream * d_output_d_hidden * d_hidden_d_w
print("hidden=", hidden, "output=", output, "dL/dw=", gradient_w)
# 练习:把 b 也求导,并用中心差分核对

最小链式法则实验应该同时给出中间值、局部导数和最终梯度。以后换成框架自动微分,只需把手算梯度与框架结果对照;如果不一致,就沿图逐项比,而不是直接重写整个模型。

CALCULUS LAB 3.4.6画出一条可追踪的计算图

选一个包含线性层、激活和损失的三步函数,画出前向箭头和反向箭头,为每条边写形状、局部导数和可能的数值风险。再指出一处可以用有限差分验证的位置。

图中至少应有输入、参数、中间激活、预测、损失和梯度回传方向;每条边标注形状与依赖。有限差分可选一个标量参数,固定其他量并比较解析梯度与中心差分。风险包括激活饱和、指数溢出、广播错误和损失归约不一致。

梯度数值正确之后,还要检查更新是否服务于目标

有限差分与自动微分一致,只能证明“当前函数在当前点附近的导数实现基本一致”,不能证明训练任务定义正确。标签可能错位,mask 可能把有效 token 排除,损失可能沿错误轴求平均,优化器也可能没有拿到真正需要更新的参数。排查时把问题分成四层:先验证目标函数和标签,再验证前向数值与形状,然后验证梯度,最后验证参数更新后损失是否按预期变化。

最小更新测试可以固定一个极小 batch,记录更新前的参数、标量 loss 和梯度;执行一次 step() 后确认至少一个应训练参数发生变化,再对同一 batch 重新前向。单步 loss 不保证每次都下降,尤其使用动量、随机层或较大学习率时,因此实验应关闭 Dropout、固定随机种子并使用足够小的学习率。若梯度非零而参数不变,检查参数是否交给优化器;若参数变化而 loss 完全不变,检查 forward 是否真的使用了这些参数。

表 3.4H · 从导数检查走向训练检查
检查层最小证据典型失败先做什么
任务目标手算一条样本的标签和损失优化了错误指标写出输入、标签和归约
前向计算每层 shape 与有限数值广播成功但轴错用带轴特征的小样本
梯度解析梯度与有限差分接近detach、饱和或 NaN逐参数检查范数
参数更新应训练参数发生可解释变化漏交优化器或学习率异常比较 step 前后参数

这四层检查会在神经网络章节再次出现。现在先记住职责边界:导数回答局部变化率,反向传播高效组合导数,优化器决定怎样使用梯度,而数据与损失共同决定“往哪个目标优化”。把四者混成一句“模型会自动学习”,就无法定位训练为什么失败。

05 · PROBABILITY & RANDOMNESS

概率不是“模型的感觉”:它是对不确定性的可计算约定

当输入、标签或模型输出存在不确定性时,概率提供了一种统一的记账方式。它可以描述事件发生的比例,也可以描述在当前信息下对多个结果的信念,但这两种解释不能混为一谈。学习概率时,要先定义样本空间和事件,再说明条件信息、观测过程与统计口径。这样,模型输出的“0.8”才有机会被追问:0.8 表示什么、在什么条件下、用什么数据校准出来、遗漏了哪些可能结果。

EVENT一个结果集合例如预测正确
CONDITION已知信息例如看到输入特征
EXPECTATION长期平均不是一次结果的保证
本节顺序:先学习样本空间和事件,再学习条件概率与 Bayes,接着理解期望和方差;完成这些基础后,下一节才进入分布、重复采样和置信区间。可同步下载 大模型基础衔接实验包,运行 labs/probability_order.py,用固定随机种子观察“理论概率—经验频率—不确定区间”的关系。

3.5.1 样本空间、事件与概率公理

先把“可能发生什么”列完整

样本空间是一次实验所有可能结果的集合,事件是样本空间中的一个子集。抛硬币的样本空间可以是正面和反面;分类模型的样本空间是所有类别,事件“预测正确”还依赖真实标签和模型输出。概率满足非负性、总和为 1 和可加性:互不相交事件的联合概率等于各自概率之和。公式很短,但最容易漏掉的是样本空间不完整——如果数据中没有“未知”或“拒答”这一类,系统就会被迫把它归入错误的类别。

频率和概率有关,却不是同一个词。重复进行相同条件的实验,事件出现的相对频率可能逐渐稳定;这是假设过程稳定、样本独立且抽样方式合理时的经验依据。线上流量变化、标注策略变化或输入分布漂移会破坏这些条件。报告一个准确率或成功率时,应同时写时间范围、样本来源、排除规则和分母,而不是只给一个百分比。

互斥、独立和不相关也要区分。互斥事件不能同时发生,独立事件则是一个事件发生不改变另一个事件的概率;两个变量相关性为零也不一定独立。把这些概念混用,会导致错误的特征选择、数据切分和风险解释。遇到“两个指标没有关系”的结论,先问它是由联合分布、相关系数还是一次样本的图表推出来的。

表 3.5A · 概率语言的边界
概念定义重点例子不要混淆
样本空间所有可能结果类别 A/B/拒答只列模型喜欢的结果
事件结果的集合“正确或拒答”把一个数当成事件
互斥不能同时发生一次抽签中两张不同牌与独立等同
独立条件信息不改变概率理想化的两次掷骰与相关为零等同
PYTHON · empirical_probability.py
from collections import Counter

outcomes = ["correct", "correct", "wrong", "abstain", "correct", "wrong"]
counts = Counter(outcomes)
total = len(outcomes)
probabilities = {outcome: count / total for outcome, count in counts.items()}
print(counts)
print(probabilities)
assert abs(sum(probabilities.values()) - 1.0) < 1e-12
not_wrong = probabilities["correct"] + probabilities["abstain"]
print("非错误比例=", not_wrong)

这个例子把一次评测结果当作有限样本,计算的是经验频率,不是对未来所有输入的保证。把 abstain 并入“正确”会改变指标含义;是否这样做要由评测协议决定。工程报告应同时保留原始计数和派生比例,避免四舍五入后无法回算分母。

PROBABILITY LAB 3.5.1补全样本空间

为一个三分类文本模型设计一次评测的样本空间,至少考虑正确、错误和拒答。列出两个可能的事件,并写出它们是否互斥、是否覆盖全部结果。

样本空间可写为 {正确、错误、拒答};“正确”和“错误”互斥但不覆盖拒答,“正确或拒答”与“错误”互斥且覆盖全部结果。答案应明确分母来自哪些样本。

3.5.2 条件概率与 Bayes:新证据如何改变判断

条件概率的分母已经改变

条件概率 P(A|B) 表示在 B 已发生的前提下 A 的概率,分母是所有满足 B 的结果。它通常不等于 P(B|A);Bayes 公式通过先验、似然和证据把二者联系起来:P(A|B)=P(B|A)P(A)/P(B)。医学筛查、垃圾邮件过滤和检索排序都需要先问“我们已知什么”,再计算“在这批条件下命中多少”。忽略基率会让一个看似很高的命中率产生过度自信。

先验不是拍脑袋的偏见,它是看到当前证据前的分布假设,可以来自历史数据、业务约束或明确的无信息选择。似然描述在某个假设成立时观察到证据的可能性。证据项把所有假设解释该证据的可能性加总。实际模型往往用训练数据学习这些关系,但分析者仍需知道输入分布变化会让先验或似然失效。

条件信息会产生选择偏差。只在“已经被模型标记为高风险”的样本上计算准确率,结果不能直接推广到全部用户;只分析留下来的日志,可能漏掉被拒答、超时或没有权限访问的请求。每个条件筛选都应记录筛选前后的数量和规则。数据管道中的分层抽样、时间切分和去重,本质上是在控制条件信息,避免评估泄漏。

表 3.5B · Bayes 公式的角色
项问句示例缺失时的风险
先验 P(A)证据前 A 多常见?某类请求占比忽略基率
似然 P(B|A)A 成立时看到 B 多可能?真阳性率只看命中不看条件
证据 P(B)不区分假设时 B 多常见?所有阳性比例分母写错
后验 P(A|B)看到 B 后 A 多可能?阳性后的真实概率把似然当后验
PYTHON · bayes_table.py
prior_spam = 0.02
true_positive = 0.90
false_positive = 0.05
prob_positive = true_positive * prior_spam + false_positive * (1 - prior_spam)
posterior_spam = true_positive * prior_spam / prob_positive
print("P(spam|positive)=", posterior_spam)
population = 10000
spam = int(population * prior_spam)
positive_spam = int(spam * true_positive)
positive_normal = int((population - spam) * false_positive)
print(positive_spam / (positive_spam + positive_normal))

当垃圾邮件只占 2% 时,即使检测器对垃圾邮件的命中率达到 90%,阳性中仍可能有不少普通邮件。这个反直觉例子说明“模型说概率很高”必须结合基率和决策阈值。写评测报告时,把混淆矩阵、类别占比和阈值一起展示,比单独列一个 precision 更不容易误导。

PROBABILITY LAB 3.5.2区分 P(A|B) 与 P(B|A)

写一个现实场景,其中“看到某特征后属于某类别”的概率与“属于某类别时出现该特征”的概率明显不同。用一张 2×2 计数表说明分母分别是哪一格之和。

可以使用“模型提示风险”和“真实发生风险”。前者以所有被提示的样本为分母,后者以所有真实风险样本为分母。只要把四格计数写出,就能避免交换条件概率。

3.5.3 期望、方差与协方差:把一批结果压缩成可解释的摘要

平均值描述中心,方差描述波动

离散随机变量的期望是各结果按概率加权的平均:E[X]=ΣxP(X=x)。它是长期重复实验的中心,不保证下一次就等于它。方差 Var(X)=E[(X-E[X])²] 衡量围绕中心的平方偏离,标准差是方差开平方并恢复原单位。平方会放大离群值,所以报告平均值时应同时查看中位数、分位数和样本量。

协方差描述两个变量是否一起增减,相关系数是经过尺度标准化的协方差。协方差受单位影响:把分钟改成秒会放大数值;相关系数通常落在 -1 到 1,但只捕捉线性关系。高相关不等于因果,低相关也不排除非线性关系。模型特征分析中,协方差矩阵可以发现重复信息和尺度差异,但需要配合业务语义与时间顺序。

期望还可以用于决策:如果不同结果有不同代价,应该计算期望损失,而不是只看事件概率。一次系统升级可能降低平均延迟,却增加极端超时的代价;只看均值会遗漏尾部风险。把指标按场景拆成正常、边界和失败三类,记录各类概率、成本和样本量,才能把“平均更好”解释成可执行的选择。

表 3.5C · 统计摘要的职责
摘要回答受什么影响建议补充
期望 / 均值中心大约在哪里?极端值、样本分布中位数、分位数
方差 / 标准差波动有多大?单位、离群值范围、箱线图
协方差两个量是否同向变化?两个单位、尺度相关系数、散点图
期望损失长期代价如何?概率与每类成本尾部案例、阈值
PYTHON · moments_and_covariance.py
from math import sqrt

x = [2.0, 3.0, 3.0, 8.0]
y = [1.0, 2.0, 4.0, 7.0]
mean_x = sum(x) / len(x)
mean_y = sum(y) / len(y)
variance_x = sum((value - mean_x) ** 2 for value in x) / len(x)
covariance = sum((a - mean_x) * (b - mean_y) for a, b in zip(x, y)) / len(x)
variance_y = sum((value - mean_y) ** 2 for value in y) / len(y)
correlation = covariance / (sqrt(variance_x) * sqrt(variance_y))
print(mean_x, sqrt(variance_x), covariance, correlation)

代码用总体分母计算方差和协方差;如果把这四个值看成从更大总体抽取的样本,统计推断可能使用 n-1 的无偏估计。选择哪种口径要写在报告里,不能只引用库函数默认值。特别是在比较不同样本量的实验时,分母和缺失处理会直接改变结论。

PROBABILITY LAB 3.5.3平均更好还是风险更低

设计两个系统方案:方案 A 大多数请求很快但偶尔很慢,方案 B 每次都中等速度。计算平均延迟和一个简单的期望代价,说明为什么只看平均值可能选错方案。

给极端慢请求设置更高代价后,方案 A 的期望代价可能高于 B,即使均值相近。答案重点是写出结果概率、代价和分母,并说明尾部风险没有被均值完全表达。
表 3.5D · 本主题完成标准
能力最低证据大模型迁移
定义事件能写样本空间、事件与分母设计准确率、拒答率和混淆矩阵
读条件能区分两种条件概率解释阈值、基率和数据选择
看波动能解释均值、方差、协方差监控延迟、质量和特征关系
保留计数比例能回算到原始数量审计评测分母和样本来源

概率建模的第一步:说明事件和观察单位

概率题最容易错在分母。说“抽到红球的概率”之前,要说明一次试验是什么、样本空间包含哪些等可能结果,以及是否放回。机器学习里同样如此:一个样本可以是一条消息、一个用户、一个 token 或一个时间窗口。若把同一用户的多条记录随机拆到训练集和验证集,样本表面上数量增加,事件却不再独立,评估会因为泄漏而虚高。

事件是样本空间的子集,随机变量则把事件映射成数值。把类别标签写成 0、1、2 只是编码,不代表类别之间存在大小关系;把 token id 当作连续变量直接求平均也没有概率意义。需要先判断变量是离散还是连续、是观测结果还是模型输出,再选择计数、密度或质量函数。概率为零也可能只是当前建模假设下不可能,不等于现实中绝对不会发生。

联合概率描述多个事件同时发生,边缘概率把其他变量求和或积分掉,条件概率则在已知信息下重新分配质量。三者的计算顺序和数据切分密切相关。生成模型估计的是 p(x) 或 p(x|context),分类器常近似 p(label|features);把条件方向写反,会导致数据、目标和解释完全不同。

独立性是强假设:A 与 B 独立时 p(A,B)=p(A)p(B),但不相关并不总是独立。文本 token 具有长程依赖,用户行为有时间相关,训练样本共享模板也会相关。使用独立同分布近似是为了让估计可行,不是事实宣言;报告结果时应指出数据来源、采样机制和可能的相关结构。

表 3.5E · 概率对象的方向与分母
对象含义分母/操作常见误读
p(A)事件 A 的整体概率全部样本空间把条件样本当全体
p(A,B)A 与 B 同时发生联合结果误当成相加概率
p(A|B)已知 B 后 A 的概率只在 B 中归一化把 A、B 顺序颠倒
p(x|θ)参数 θ 下观察 x对 x 归一化把似然当参数的概率
E[X]长期加权平均按概率加权当成必然会出现的值
PYTHON · probability_contract.py
from collections import Counter

def empirical_probability(samples, predicate):
    if not samples:
        raise ValueError("样本不能为空")
    hits = sum(predicate(item) for item in samples)
    return hits / len(samples)

records = [{"group": "A", "clicked": True}, {"group": "A", "clicked": False},
           {"group": "B", "clicked": True}, {"group": "B", "clicked": True}]
overall = empirical_probability(records, lambda row: row["clicked"])
group_a = [row for row in records if row["group"] == "A"]
conditional = empirical_probability(group_a, lambda row: row["clicked"])
print("p(click)=", overall, "p(click|A)=", conditional)

# 练习:增加一个 group C,比较小样本条件概率的波动

示例把条件筛选放在分母之前,避免“先数点击,再用另一套样本数相除”。真正的数据分析还要处理缺失值、重复记录、权重和时间窗口;这些不是概率公式的附属品,而是定义事件的一部分。建议在结果表中同时输出分子、分母、概率和置信区间,别人才能复核你算的到底是哪一个事件。

PROBABILITY LAB 3.5.4找出数据泄漏造成的概率偏差

同一用户有 10 条记录,其中 8 条进入训练集、2 条进入验证集。说明为什么直接计算验证点击率可能过于乐观,并提出按用户切分与按时间切分两种更合理的方案。

验证记录与训练记录共享用户行为,条件分布不再代表“未见过的用户”,模型可能记住用户特征。按用户切分检验跨用户泛化,按时间切分检验未来预测;两者回答的问题不同,不能用一个随机切分替代。

期望、方差与协方差:把“平均表现”拆成可解释的部分

期望是按概率加权的长期平均,不一定是某次实验会出现的值。投掷骰子的期望为 3.5,但不存在点数 3.5。方差衡量结果围绕期望的离散程度,量纲是原变量的平方;标准差开平方后才回到原单位。比较不同指标的波动时,要同时关注量纲和尺度,不能只看数字大小。

协方差描述两个变量是否同向变化,相关系数则进一步除以各自标准差,落在 -1 到 1 之间。协方差接近零只能说明线性关系弱,不代表不存在非线性关系。对特征做标准化、白化或降维时,协方差矩阵提供了结构线索;但它由样本估计,少量异常点就可能显著改变结果。

期望的线性性质很有用:E[aX+bY]=aE[X]+bE[Y],不需要 X、Y 独立。方差却只有在独立或协方差项被正确处理时才可简单相加。训练中的 mini-batch 平均损失是样本损失的估计,如果 batch 采样偏向某些类别,期望就被重新加权;应使用分层采样、样本权重或在报告中说明目标分布。

概率模型的输出还需要区分 aleatoric 与 epistemic 不确定性:前者来自数据本身的噪声,后者来自模型或知识不足。重复采样、集成和分层评估能提供线索,但不能把一次概率值直接解释成“模型知道自己错的概率”。不确定性表达必须和数据收集、校准指标以及决策成本一起讨论。

表 3.5F · 统计量与决策含义
统计量测量对异常值敏感度适合回答
均值加权中心高总体平均成本/收益
中位数排序中点较低典型个体体验
方差离散程度的平方量纲高风险与波动大小
标准差与原变量同单位的波动高阈值和区间宽度
协方差共同变化方向与尺度高特征冗余与组合风险
相关系数标准化线性关系中比较不同量纲的线性关联
PYTHON · moments_and_covariance.py
def mean(values):
    return sum(values) / len(values)

def variance(values):
    center = mean(values)
    return sum((value - center) ** 2 for value in values) / len(values)

def covariance(left, right):
    if len(left) != len(right):
        raise ValueError("两个变量必须一一配对")
    left_mean, right_mean = mean(left), mean(right)
    return sum((a - left_mean) * (b - right_mean) for a, b in zip(left, right)) / len(left)

hours = [1, 2, 3, 4, 5]
scores = [52, 55, 61, 67, 69]
print("mean=", mean(scores), "variance=", variance(scores))
print("covariance=", covariance(hours, scores))

# 练习:给 scores 末尾加入 200,观察方差与协方差的变化

手算统计量时默认使用总体分母还是样本分母,要在函数名或参数中说明。示例使用 n 作为描述性统计;估计总体方差时常见的无偏修正会使用 n-1。两者没有谁“永远正确”,关键是与问题、库函数和报告口径保持一致。

PROBABILITY LAB 3.5.5把统计口径写进实验记录

对一个二分类数据集报告均值、标准差和点击率。补充“样本单位、时间范围、缺失值处理、分母、是否加权、总体/样本方差”的说明,并指出若只写一个百分比会隐藏什么。

完整记录至少能让别人重建分子与分母,知道一个样本代表什么,以及指标是否受重复用户、时间漂移或类别不平衡影响。若方差口径不明,模型比较可能把统计定义的差异误认为性能差异。

概率结果要带范围:分子、分母、时间与分群

一个“35% 的概率”只有在分子、分母、时间窗口和样本单位都明确时才有意义。是 35% 的请求、用户、token,还是 35% 的会话?是当天、七天还是整个生命周期?同一事件按请求统计与按用户统计,结果可能完全不同。写概率时把这些限定词放进标题和代码变量名,比在正文末尾补一句说明更可靠。

小样本概率的波动很大,观察到 2/5 与 200/500 的点估计都为 40%,证据强度却不同。报告二项比例时可以给出 Wilson 区间或 bootstrap 区间,并显示有效样本量。对于零次命中或全数命中,也不要把区间宽度当成零;边界估计恰恰需要更谨慎的先验或区间方法。

概率还可能随时间、用户群和动作策略反馈而改变。模型提高某类内容的曝光,会改变之后被观察到的样本,形成策略反馈;历史概率不能直接当作未来基线。线上实验需要记录策略版本、流量分配和停止规则,避免中途看结果后随意结束而夸大显著性。

表 3.5H · 概率报告的限定词
限定词示例为什么影响结果应保存
样本单位请求/用户/会话重复行为权重不同唯一 ID 与聚合规则
时间窗口日/周/生命周期季节与滞后不同起止时间、时区
分群语言/渠道/版本混合比例会改变总体分群定义与样本量
策略阈值/流量分配行为会反过来改变数据策略版本、停止规则
不确定性区间/标准误点估计不表示证据强度方法、覆盖假设
PYTHON · proportion_interval.py
import math

def wilson(successes, total, z=1.96):
    if total <= 0 or not 0 <= successes <= total:
        raise ValueError("计数无效")
    p = successes / total
    denominator = 1 + z * z / total
    center = (p + z * z / (2 * total)) / denominator
    radius = z * math.sqrt(p * (1 - p) / total + z * z / (4 * total * total)) / denominator
    return center - radius, center + radius

print(wilson(2, 5), wilson(200, 500))
# 练习:比较 0/5 与 0/500 的区间,而不是只比较点估计

Bayes 不是“反过来相乘”:先写清先验、证据和后验

Bayes 公式 p(A|B)=p(B|A)p(A)/p(B) 把看到证据后的信念更新拆成三部分。先验 p(A) 反映在看证据前的基准频率,似然 p(B|A) 说明假设成立时出现证据的可能性,边缘概率 p(B) 负责归一化。忽略基准频率会产生“基础率谬误”:一个检测器在正类稀少时,即使灵敏度高,阳性结果也不一定意味着正类最可能。

用赔率形式写 Bayes 更容易看出证据如何累积:后验赔率 = 先验赔率 × 似然比。多个条件独立的证据可以相乘,但“条件独立”必须在给定假设下成立;同一来源的重复信号不能简单当成独立证据。机器学习中的朴素 Bayes 用强假设换取可计算性,效果好坏应通过数据验证,不应把独立假设当成现实事实。

概率模型还要区分观测相关与因果关系。看到“使用某功能”和“留存更高”并不能直接推出功能导致留存,可能存在用户类型、渠道或时间的混杂。条件概率可以帮助分层描述,但要回答干预问题,还需要实验、准实验或明确的因果假设。本章只提供概率语言,不把相关性包装成因果结论。

蒙特卡洛估计用随机样本近似期望:样本越多,随机误差通常下降,但相关样本、重尾分布和高方差函数会让收敛很慢。固定种子便于复现,却不等于一次种子代表真实不确定性;应报告多次运行的均值、标准差和区间。若可以使用分层抽样、控制变量或重要性采样,应说明它们改变了方差而没有改变目标的条件。

表 3.5G · Bayes 更新的阅读模板
部分问题数据来源容易错在哪里
先验 p(A)证据前 A 多常见?历史基线、抽样框用方便样本代替总体
似然 p(B|A)A 成立时 B 多常见?条件实验、标注数据误写成 p(A|B)
边缘 p(B)B 在总体多常见?全体证据分布遗漏其他假设类别
后验 p(A|B)看到 B 后 A 多可信?公式归一化结果忽略基准率与校准
似然比证据更支持哪个假设?两条件概率之比把相关性当因果
PYTHON · bayes_update.py
def posterior(prior, likelihood_if_true, likelihood_if_false):
    evidence = (likelihood_if_true * prior
                + likelihood_if_false * (1 - prior))
    if evidence == 0:
        raise ValueError("证据概率不能为零")
    return likelihood_if_true * prior / evidence

prior = 0.01
true_positive = 0.95
false_positive = 0.05
print(posterior(prior, true_positive, false_positive))
# 练习:改变先验为 0.2,观察同一检测结果的后验变化

把先验从 1% 改为 20% 后,后验会显著变化,这正是基准率的重要性。实验报告应说明先验来自哪里、是否随时间或分群改变、检测器的正负样本是否独立,以及概率是否经过校准。一个看似精确的后验数字,如果分母和数据来源不清楚,仍然不能支持高风险决策。

PROBABILITY LAB 3.5.6用赔率追踪两条证据

设事件先验概率为 1%,第一条证据的似然比为 10,第二条证据的似然比为 3。计算更新后的赔率与概率,并说明只有在什么条件下可以直接把两个似然比相乘。

先验赔率为 .01/.99,依次乘以 10 和 3,再转回概率。直接相乘要求证据在给定事件状态下条件独立;若两条证据来自同一传感器或同一模型输出,相关性会让简单相乘过度自信。

06 · DISTRIBUTIONS & UNCERTAINTY

从一个概率走向一整批样本:分布、采样与校准

概率告诉你某个事件有多可能,分布进一步描述一批随机变量在不同取值上的整体形状。均值、方差、偏度和尾部共同决定一个摘要是否足够;同样的平均值可以来自非常不同的风险结构。做模型实验时,不能只报告一条准确率或一个平均损失,还要知道样本是怎么抽的、结果在不同子群和时间段如何变化,以及模型给出的置信度是否与实际频率相符。

SHAPE中心与尾部分布比一个平均值更完整
SAMPLE有限观测样本只是总体的一次窗口
CALIBRATION概率与频率0.8 是否真的约八成正确

3.6.1 Bernoulli、二项与正态:先认出随机变量的形状

随机变量不是“随机的变量名”

随机变量把随机实验的结果映射成数字。Bernoulli 变量只有 0 和 1 两种结果,参数 p 表示取 1 的概率;重复 n 次独立 Bernoulli 实验,成功次数服从二项分布。许多二元评测指标都可以从这个模型开始思考:一次预测是否正确、一次请求是否超时、一个 token 是否命中目标。假设独立并不总成立,例如同一文档的多个片段可能高度相关,不能把它们当成完全独立的样本。

正态分布由均值和标准差控制,是许多测量误差和聚合量的近似模型,但现实数据经常偏斜、截断或有厚尾。标准化 z=(x-μ)/σ 只改变位置和尺度,不会把非正态数据神奇地变成正态。看到钟形曲线时,先问数据生成机制和样本量,而不是凭图形套公式。

分布的尾部对系统风险尤其重要。模型延迟的平均值可能很好,但第 99 百分位很差;错误率整体低,却可能在一个少量子群上集中。用分位数、箱线图和分层统计补充均值,可以把“罕见但昂贵”的情况显示出来。尾部估计需要更多样本,样本不足时要诚实表达不确定范围。

表 3.6A · 分布的基本读法
分布/摘要结果形态常见参数适合检查
Bernoulli0 或 1成功概率 p二元事件和独立性
二项0 到 n 的计数次数 n、概率 p批次正确数、点击数
正态连续钟形近似均值 μ、标准差 σ聚合误差和标准化
经验分布实际观测的频率样本本身偏斜、尾部、漂移
PYTHON · empirical_distribution.py
from statistics import mean, pstdev

results = [1, 1, 0, 1, 0, 1, 1, 0, 1, 0]
p = mean(results)
print("Bernoulli 经验 p=", p)
print("标准差=", pstdev(results))

latencies = [120, 128, 131, 133, 140, 145, 150, 166, 220, 480]
ordered = sorted(latencies)
percentile_90 = ordered[int(0.9 * (len(ordered) - 1))]
print("平均=", mean(latencies), "P90 近似=", percentile_90)
assert 0 <= p <= 1

代码故意把正确率和延迟放在同一页观察:二元结果的均值就是成功率,但延迟的均值无法代表尾部。百分位的计算有多种约定,示例只用于建立概念;正式报告应说明插值方式、样本量和是否按用户或请求分组。

DISTRIBUTION LAB 3.6.1两个平均值的不同故事

构造两组各 8 个数,使它们均值相同但一组的尾部明显更长。计算标准差和一个高分位数,写出哪一组更适合低延迟服务,并说明还缺什么证据。

让一组数集中在均值附近,另一组包含一个很大的离群值即可。除平均值外要报告标准差、P95/P99、样本量和离群值来源;不能只凭一次小样本就断言总体风险。

3.6.2 采样、重复实验与大数定律:更多数据不等于自动无偏

样本先要代表你想讨论的总体

总体是你真正想了解的对象,样本是实际收集的一部分。随机抽样、分层抽样、时间切片和方便抽样会产生不同的样本结构。大数定律说明在条件稳定、样本相互独立且抽样合理时,样本平均会趋近总体期望;它没有承诺“样本足够大就能修复系统性偏差”。如果只收集愿意反馈的用户,增加样本量只会更精确地估计这群用户的行为。

中心极限定理解释了为什么许多样本均值在一定条件下近似正态,即使单个观测不正态。它依赖独立性、有限方差和合适的聚合;厚尾、强相关或分布漂移时需要更谨慎。训练和评测中把同一文档的相邻片段随机打散,可能让有效样本量被高估;按来源分组或按时间切分,往往更接近真实部署条件。

重复实验可以估计随机性的影响。固定随机种子有助于复现一次运行,但不应把一次种子当成全部结论;更好的做法是使用多个种子,报告均值、标准差和最差/最好结果。若计算资源有限,可以先在小规模上做三到五次重复,确认趋势后再扩大规模。结果表要保留每次运行的配置和失败原因,不要只留下挑出来的最好曲线。

表 3.6B · 采样设计的检查点
问题要记录什么可能的偏差补救
总体是谁?用户、文档、时间范围讨论对象不清写出纳入/排除规则
怎么抽样?随机、分层、时间切片子群比例失真分层报告、加权
样本是否独立?来源、会话、文档关系有效样本量被高估按组切分、聚类抽样
是否重复?种子、配置、运行次数把偶然当趋势报告均值和波动
PYTHON · repeated_runs.py
from statistics import mean, pstdev

runs = [0.81, 0.79, 0.83, 0.80, 0.76]
summary = {"n": len(runs), "mean": mean(runs), "std": pstdev(runs), "min": min(runs), "max": max(runs)}
print(summary)

if summary["std"] > 0.02:
    print("结果波动较大,先检查随机种子、数据切分和训练稳定性")
else:
    print("当前重复运行较稳定,仍需确认样本是否代表部署分布")

这里的标准差只描述这五次运行的波动,不是总体参数的精确置信区间。报告时应把“运行随机性”和“抽样不确定性”分开:前者来自初始化、批次和算法,后者来自样本没有覆盖全部输入。两者同时存在时,单一的误差条可能遮住真正原因。

DISTRIBUTION LAB 3.6.2发现抽样偏差

假设你只从高频用户收集模型反馈。列出至少两个可能被低估的失败类型,并设计一个分层抽样方案,让报告同时显示总体和各层结果。

低频用户可能遇到的冷启动、长文本、设备或网络问题会被忽略。可以按用户活跃度、语言、长度或业务场景分层,记录每层样本量,再按总体比例汇总;不能只对高频层重复抽样后把结果叫作总体准确率。

3.6.3 不确定性、置信区间与校准:让“概率”接受现实检验

区间是在表达估计的不确定,不是给未来下保证

样本比例只是总体比例的估计。置信区间用一种规定好的重复抽样程序表达估计的波动:如果重复抽样并按同一方法构造区间,长期来看会有大约某个比例的区间覆盖真实参数。它不是说“真实参数有 95% 的概率在这一个已经算出的区间里”,也不是未来每一次预测都落在区间内。学习阶段可以用正态近似或自助法建立直觉,正式报告要说明方法和适用条件。

模型概率需要校准。一个输出 0.8 的样本集合,如果其中约 80% 最终正确,可以说这一档概率大致校准;如果只有 50% 正确,模型就过度自信。可靠性图、分桶准确率、Brier score 和校准曲线能帮助观察这种关系。校准不等于排序能力:一个模型可以很好地区分高低风险,却给出整体偏高的概率。阈值决策还要结合不同错误的代价。

不确定性有多个来源:数据噪声、样本不足、分布变化和模型本身不知道。增加同分布样本可能减少抽样波动,却不能消除测量错误或新场景风险。把拒答、人工复核和置信度阈值当作系统的一部分,比强迫每个输入都给一个确定答案更诚实。监控应同时看置信度分布、错误率和覆盖率。

表 3.6C · 区间与校准的读法
对象回答不能回答配套证据
比例区间估计在抽样下有多不稳定保证下一批结果样本量、抽样方法
可靠性图概率与实际频率是否匹配模型是否理解因果分桶计数、时间切片
Brier score概率预测的平方误差解释错误原因标签定义、基率
拒答覆盖率模型在什么比例上给答案拒答样本一定安全人工复核、风险分层
PYTHON · calibration_bins.py
predicted = [0.1, 0.2, 0.3, 0.7, 0.8, 0.9, 0.9, 0.8]; actual = [0, 0, 1, 1, 0, 1, 1, 1]; pairs = list(zip(predicted, actual)); brier = sum((score - label) ** 2 for score, label in pairs) / len(pairs); print("Brier=", brier); print("mean confidence=", sum(predicted) / len(predicted), "accuracy=", sum(actual) / len(actual))

分桶的示例故意使用很少的数据,只用于展示“平均预测概率”和“实际正确率”的比较。正式校准要选择足够的样本、说明分桶边界,并按时间或关键子群检查。一个整体看起来校准的模型,可能在少数高风险子群上严重失准。

图 3.6 · 概率需要回到频率自制校准示意图
平均预测概率实际正确率理想校准模型分桶

校准图比较“模型说有多大把握”和“现实中有多少次正确”,两者偏离时要调整解释或决策。

DISTRIBUTION LAB 3.6.3写一段不夸大的概率报告

给定某模型在一组样本上的平均置信度、准确率和拒答率,写三句话向非技术读者说明结果,并列出至少两个不能从这三个数字推出的结论。

报告应说明样本范围、分母和时间,并区分置信度、正确率和覆盖率。不能仅凭三项数字断言因果、未来保证或所有子群都一样;还需分层结果、错误案例和校准证据。
表 3.6D · 本主题完成标准
能力最低证据大模型迁移
读分布能区分中心、波动和尾部同时报告平均、分位数和样本量
设计抽样能写总体、抽样和独立性假设避免评测泄漏与选择偏差
看校准能解释分桶频率与置信度设计阈值、拒答和人工复核
表达区间不把区间写成未来保证把不确定性纳入产品决策

从一组观测到一条分布:不要把直方图当成事实本身

分布是对随机变量取值规律的描述,直方图只是有限样本和分箱规则共同产生的近似。箱宽太大,会把多峰结构抹平;箱宽太小,会把随机噪声看成模式。画图时应同时报告样本数、分箱边界、是否归一化以及异常值处理。对模型输出,密度曲线的平滑带宽也会改变视觉印象,不能只挑一张“看起来最合理”的图。

均值、方差和分位数是分布的摘要,而不是分布的全部。两个数据集可以拥有相同均值和标准差,却有完全不同的尾部、偏度或多峰结构。语言数据中的长度、置信度和延迟尤其常呈长尾,平均值可能被少数极端样本拉高。报告延迟或 token 数时,建议同时给出中位数、P90/P95/P99 和样本量。

中心极限定理解释了为什么许多样本均值在条件满足时趋近正态,但它不保证原始数据正态,也不替代检查独立性和有限方差。小样本、重尾、相关序列和分层数据都可能让正态近似失效。使用近似区间时,要标出近似依据;若不确定,可以使用 bootstrap、置换检验或按组重采样。

分布比较要先确定比较目标:位置变化、尺度变化、形状变化还是尾部风险。KS 距离、Wasserstein 距离、分位数差异各有侧重;统计显著不代表业务重要。大样本会让很小的差异也显著,应该同时报告效应大小、置信区间和实际阈值。

表 3.6E · 分布摘要的适用边界
摘要保留的信息容易忽略建议补充
均值总体中心长尾与多峰中位数、分位数
标准差整体离散非对称尾部分组或尾部指标
中位数排序中心尾部成本P90/P99、最大值
分位数阈值位置分位数间的形状箱线图或 ECDF
直方图局部频率形状分箱依赖多组箱宽与原始点数
PYTHON · bootstrap_interval.py
import random

def bootstrap_means(values, repeats=2000, seed=7):
    if not values:
        raise ValueError("values 不能为空")
    rng = random.Random(seed)
    n = len(values)
    return [sum(rng.choices(values, k=n)) / n for _ in range(repeats)]

def percentile(values, q):
    ordered = sorted(values)
    index = (len(ordered) - 1) * q
    low, high = int(index), min(int(index) + 1, len(ordered) - 1)
    weight = index - low
    return ordered[low] * (1 - weight) + ordered[high] * weight

sample = [12, 13, 15, 16, 19, 21, 25]
estimates = bootstrap_means(sample)
print(percentile(estimates, .025), percentile(estimates, .975))
# 练习:改变 seed 和 repeats,观察区间的稳定程度

bootstrap 把观测样本当作经验总体,反复有放回抽样来近似估计量的抽样分布。它能提供实用的区间,却不能修复样本代表性问题:如果原始样本漏掉了某类用户,重复抽样只会更稳定地重复这个偏差。时间序列、群组数据和强相关样本应按块或按组重采样,不能逐行打乱。

DISTRIBUTION LAB 3.6.4选择能回答问题的分布图

你要比较两个版本的 API 延迟。分别为“整体变慢”“只有尾部变慢”“出现两类用户”选择一种图或摘要,并说明为什么只比较平均延迟会漏掉风险。

整体变慢可看中位数与多分位数;尾部变慢应重点看 P95/P99、ECDF 或箱线图;两类用户应分组画分布或用散点/密度图。答案要把图形与要检测的分布变化对应起来。

校准、漂移与阈值:概率输出要经得起时间考验

校准关注的是“模型说 0.7 的样本,长期是否约有 70% 正例”,而不是只看排序能力。一个模型可以 AUC 很高却过度自信;另一个模型排序一般但概率更接近真实频率。可靠性图把预测概率分桶,再比较每桶的平均预测与实际命中率;分桶数、每桶样本量和置信区间都应记录,避免小桶的偶然波动被误读。

阈值是决策层的选择,不是模型概率自动带来的答案。医疗提醒、内容审核和资源分配的误报与漏报成本不同,同一组概率在不同场景应使用不同阈值。调阈值时只能使用验证集或独立校准集,不能在测试集上反复挑选,否则测试集也被间接训练。

数据漂移可以发生在输入、标签关系或类别比例。输入分布变了不一定影响性能,关系变了却可能让旧阈值失效;因此监控应同时记录特征分布、预测分布、延迟、缺失率和有标签后的真实指标。漂移检测的报警不是自动回滚命令,而是触发抽样复核、分群评估和再训练决策的证据。

线上分布还会受到版本、季节、渠道、语言和攻击行为影响。把所有样本混成一条总体曲线可能掩盖某个小群体的严重退化。应预先定义关键分群、最小样本量和报警窗口,并保留可追溯的模型版本、数据快照与随机种子。这样当指标变化时,能判断是模型、数据还是测量系统发生了变化。

表 3.6F · 概率输出的上线检查
检查问题最小证据误用后果
可靠性图概率是否校准?分桶预测/实际、样本量把过度自信当高质量
阈值曲线成本如何随阈值变?混淆矩阵、业务成本只追求单一准确率
分布监控输入/输出是否漂移?基线、窗口、分群群体退化被总体平均掩盖
数据延迟标签何时可得?时间戳、回填规则实时指标与真实指标错位
回滚条件何时停止使用模型?阈值、负责人、操作步骤报警出现却没人行动
PYTHON · reliability_bins.py
def reliability_bins(probabilities, labels, bins=5):
    if len(probabilities) != len(labels):
        raise ValueError("概率与标签长度不一致")
    result = []
    for bucket in range(bins):
        lower, upper = bucket / bins, (bucket + 1) / bins
        pairs = [(p, y) for p, y in zip(probabilities, labels)
                 if lower <= p < upper or (bucket == bins - 1 and p == upper)]
        if pairs:
            result.append({"range": (lower, upper),
                           "predicted": sum(p for p, _ in pairs) / len(pairs),
                           "observed": sum(y for _, y in pairs) / len(pairs),
                           "count": len(pairs)})
    return result

print(reliability_bins([.1, .2, .4, .7, .9], [0, 0, 1, 1, 1]))
# 练习:为每个桶加入 Wilson 区间或最小样本量规则

可靠性表里的每一行都应能追溯到原始预测与标签。若一个桶只有两条样本,观察频率可能从 0 跳到 1,不能据此宣布模型失准。实际报告可以合并小桶、显示置信区间,或采用等频分桶。无论选择哪种方法,都要在版本之间保持一致,才能比较校准变化。

DISTRIBUTION LAB 3.6.5写一份漂移处置规则

为“输入缺失率上升 3 个百分点”“P99 延迟超过基线 2 倍”“某语言分群准确率下降 8 个百分点”分别写出观察窗口、最小样本量、复核动作和回滚条件。

答案不应只有一个数字;要包含基线版本、时间窗口、分群、置信或稳定性要求、负责人和动作。输入漂移通常先抽样核查数据管道,性能退化则要在有标签后复算,并区分模型问题与标签延迟。

随机性要被量化:一次结果不能代表一条分布

生成模型或随机优化器的一次输出只是分布中的一个样本。若要比较两个配置,至少固定输入并重复采样,报告均值、离散程度和尾部,而不是挑一条最漂亮的结果。对文本生成,还应区分 token 级随机性、整段主题变化和评审者噪声;三者需要不同的重复设计。

置信区间描述估计过程的不确定性,不是“真实参数落在区间内的概率”这一句简单口号。区间的构造方法、重复次数和覆盖假设都重要。把同一测试集反复用于调参会让区间偏乐观;留出独立测试集或采用嵌套评估,才能保留更可信的泛化估计。

当分布有明显分群时,先分层再汇总通常更安全。整体均值可能出现辛普森悖论:每个分组都变好,混合后的总体却变差,原因只是分组比例改变。报告总体指标时同时给出关键分组和样本量,必要时采用加权汇总,才能避免把结构变化误判为模型变化。

表 3.6H · 随机实验的最小重复设计
随机来源重复方式汇总量注意事项
模型采样同 prompt 多次生成质量、重复率、安全固定 tokenizer 与解码参数
训练 seed多次完整运行均值、标准差、最佳值记录硬件与数据顺序
抽样误差bootstrap/分层重采样区间、分位数保持组或时间结构
评审噪声多评审者/重复评审一致性、置信区间先定义评分准则
PYTHON · repeated_sampling.py
import random

def summarize(generator, repeats=20, seed=7):
    rng = random.Random(seed)
    scores = [generator(rng) for _ in range(repeats)]
    scores.sort()
    mean = sum(scores) / len(scores)
    median = scores[len(scores) // 2]
    return {"mean": mean, "median": median, "min": scores[0], "max": scores[-1]}

def noisy_score(rng):
    return 0.7 + rng.uniform(-0.08, 0.08)

print(summarize(noisy_score))
# 练习:把 repeats 改为 3、100,观察摘要的稳定性

分布差异先问“来自哪里”:抽样、测量还是生成机制

看到两条分布曲线不同时,至少有三类解释:样本来自不同总体,测量流程发生变化,或生成机制本身改变。用户画像变化属于抽样/总体变化;埋点字段改名属于测量变化;模型版本更新导致预测分布改变则是生成机制变化。三者处理方式不同,不能统一叫“数据漂移”。先对齐时间、样本单位、过滤规则与数据版本,再比较形状。

抽样偏差常来自方便样本、幸存者偏差、缺失不随机和标签只在特定事件后产生。加权可以修正已知的选择概率,但如果选择机制未知,权重也会引入更大方差。报告分布时要保留纳入/排除流程、缺失比例和每个分群的样本量。一个总体直方图看起来稳定,不代表未观测人群没有发生变化。

分位数是稳健沟通尾部风险的工具,但分位数本身也是估计量。样本很少时,P99 可能由一个点决定;不同插值规则也会造成小差异。比较版本时使用同一分位数定义、相同过滤与时间窗口,并给出 bootstrap 区间或至少标注样本量。若尾部是安全阈值,宁可保守处理,也不要用平均值替代。

采样温度控制的是从分布中抽取时的随机性,不会凭空增加模型知识。降低温度通常让高概率 token 更集中,提高温度会增加探索;top-k、top-p 等截断则改变可采样的支持集。评估生成模型时,要固定 prompt、tokenizer、最大长度、采样参数和随机种子,分别比较质量、多样性、重复率和安全约束。

表 3.6G · 分布变化的诊断路径
现象优先假设验证动作不要直接做
输入缺失率突增管道/字段变化比对 schema、日志、样本立即重训模型
预测整体变尖温度/模型版本固定输入重放、比 logits只看准确率
P99 变差长尾群体或资源瓶颈分群、按长度和设备切片用均值掩盖尾部
某群体下降采样或标签关系变化检查分母、标签延迟、阈值用总体指标替代
多样性下降温度/截断过强固定 prompt 多次采样只追求单次最好答案
PYTHON · quantile_report.py
def quantile(values, q):
    if not values or not 0 <= q <= 1:
        raise ValueError("样本或 q 无效")
    ordered = sorted(values)
    position = (len(ordered) - 1) * q
    lower = int(position)
    upper = min(lower + 1, len(ordered) - 1)
    fraction = position - lower
    return ordered[lower] + fraction * (ordered[upper] - ordered[lower])

latency = [80, 82, 85, 87, 91, 94, 110, 145, 300, 610]
for q in (.5, .9, .95, .99):
    print(f"P{q * 100:g}={quantile(latency, q):.1f} ms")
# 练习:删除最大值后比较 P99,说明小样本尾部的脆弱性

分位数函数的插值规则只是一个选择,生产环境应与分析库保持一致。更重要的是把原始样本量、采样窗口、单位和是否去重一起输出。对于线上监控,可保存分位数摘要结构而非全部原始数据,但要验证摘要在合并分片后仍能近似目标分位数,并明确误差界限。

DISTRIBUTION LAB 3.6.6区分总体漂移与测量漂移

某天 API 输入长度分布突然变短,但模型质量没有变化。列出“用户真的变了”“客户端截断”“统计脚本过滤了长文本”三种假设,并为每种假设设计一个可验证的数据切片。

应分别检查来源/渠道与历史用户、原始与入模文本长度、schema/过滤日志和同一请求的前后字段。质量暂时不变不能证明没有问题,可能只是短期标签尚未回流或被总体平均掩盖。

07 · LOSS & INFORMATION

损失函数把“更好”写成数字:从误差到信息量

模型不会直接优化“回答有用”或“解释自然”这样的完整目标,而是通过一个可计算的损失函数获得方向。损失函数选择什么误差、怎样加权、在哪些样本上平均,都会改变模型学到的偏好。信息论提供了另一组重要语言:一个低概率事件带来的信息量更大,交叉熵衡量预测分布对真实结果的惊讶程度,KL 散度比较两个分布的差异。学会读损失,就能判断一条下降曲线究竟代表什么、没有代表什么。

ERROR预测离目标多远取决于误差的定义
LIKELIHOOD数据有多可能最大化似然等价于最小化负对数似然
INFORMATION意外程度低概率结果信息量更高

3.7.1 MSE、MAE 与加权误差:目标函数决定惩罚方式

平方误差和绝对误差对异常值的态度不同

均方误差 MSE 把每个残差平方后平均,较大的错误会被显著放大;平均绝对误差 MAE 只取绝对值,对离群点更鲁棒。二者都可以用于回归,但它们表达的业务代价不同:如果严重偏差会带来平方级损失,MSE 合理;如果希望每一分钟误差承担近似线性代价,MAE 更直接。选择损失前先画残差分布,观察异常值是否是真实风险、测量错误还是数据录入问题。

损失的缩放和聚合也会改变梯度。对一个批次求和,批次越大损失和梯度通常越大;求平均则让梯度尺度相对稳定。按样本加权可以处理类别不平衡或业务优先级,但权重必须写在契约里,不能在调参时悄悄修改。报告训练曲线时说明 reduction、样本权重和忽略的标签,才能比较不同实验。

训练损失下降不必然等于真实质量提升。模型可能利用数据泄漏、记住训练样本,或优化了容易计算却与最终目标不一致的代理指标。验证集损失、分层指标、人工检查和错误案例共同构成证据。把“可优化”与“值得优化”分开,是从数学走向工程判断的重要一步。

表 3.7A · 回归损失的选择
损失公式直觉对离群点适合追问
MSE残差平方的平均惩罚强大错是否特别昂贵?
MAE残差绝对值的平均较鲁棒每单位误差代价近似相同吗?
Huber小误差平方、大误差近似线性折中想兼顾平滑和鲁棒吗?
加权损失不同样本有不同权重取决于权重权重来源能审计吗?
PYTHON · regression_losses.py
errors = [0.2, -0.4, 0.1, 3.0]; mse = sum(error ** 2 for error in errors) / len(errors); mae = sum(abs(error) for error in errors) / len(errors); threshold = 1.0; huber = sum(0.5 * error ** 2 if abs(error) <= threshold else threshold * (abs(error) - 0.5 * threshold) for error in errors) / len(errors); print({"mse": mse, "mae": mae, "huber": huber})

同一组误差下,MSE 会被 3.0 这个离群点强烈拉高,而 MAE 和 Huber 反应较温和。这个结果不说明某个损失永远更好,只说明它们在优化时传递了不同的代价信号。用真实业务损失、验证集和错误分层共同决定,而不是为了让训练曲线更好看而选择。

LOSS LAB 3.7.1把离群点的影响画出来

固定三个小误差,只改变一个误差从 0.5 增加到 10,分别计算 MSE、MAE 和 Huber。记录三条曲线的增长速度,并写一句适用场景。

MSE 的增长近似平方,MAE 近似线性,Huber 在小误差区平方、大误差区线性。适用场景要和错误代价、数据质量及验证指标联系起来。

3.7.2 似然、交叉熵与最大似然:让正确答案得到足够的概率

似然是“在参数固定时,数据出现的可能性”

给定一组观测数据和模型参数,似然把每个观测在模型下的概率相乘。最大似然估计选择让观测数据最可能出现的参数;取负对数后,连乘变成求和,数值更稳定,也更容易按批次累积。分类任务中的交叉熵,通常就是对真实类别的预测概率取负对数并求平均。真实类别概率越低,损失越大;只要预测正确但概率不够高,仍会收到惩罚。

交叉熵要和标签编码对应。单标签多分类使用 one-hot 或类别索引,每个样本只有一个目标类别;多标签任务可以让多个标签同时为 1,损失形式不同。标签平滑会把绝对的 0/1 目标变成稍微柔和的分布,降低过度自信,但也改变了优化目标。不要把“概率最高的类别”与“分布完整”混为一谈,评估时还应查看校准、召回和置信度。

数值稳定性再次出现:如果预测概率为 0,-log(0) 在数学上趋向无穷,浮点实现会得到无穷或异常。框架通常直接接收 logits,在内部使用 log-sum-exp 技巧,而不是让你先手动 Softmax 再取对数。了解这一点能避免重复归一化、梯度变差和溢出。排查 loss 异常时,记录 logits 范围、目标索引和非有限数量。

表 3.7B · 交叉熵的组成
部分含义需要检查常见错误
logit未归一化分数形状、范围、轴先 Softmax 再传给 logits 接口
目标 y真实类别或分布编码、索引、忽略值标签错位或越界
负对数低概率得到大惩罚是否出现 0、-inf直接取 log 概率
聚合批次求和或平均分母、权重不同 batch 不可比较
PYTHON · cross_entropy_by_hand.py
from math import exp, log

logsumexp = lambda values: max(values) + log(sum(exp(value - max(values)) for value in values))
cross_entropy = lambda logits, target: logsumexp(logits) - logits[target]

for logits, target in [([2.0, 0.0, -1.0], 0), ([2.0, 0.0, -1.0], 2)]:
    print(cross_entropy(logits, target))

第二组把目标类别从最高分改成最低分,损失会明显增大。实现中用 logsumexp 避免先计算可能溢出的指数和;真正的框架还会处理批次、设备和梯度。手算版本的价值是确认“目标索引、分数顺序和聚合方式”都符合预期。

LOSS LAB 3.7.2解释两个同样准确的模型

模型 A 对正确类别给 0.51,模型 B 给 0.99,二者预测类别都正确。计算它们的单样本交叉熵,并说明为什么交叉熵能区分“勉强正确”和“高度自信”。

损失分别是 -log(0.51) 和 -log(0.99),后者更小。还应提醒:高置信度若在错误样本上出现,会受到更大惩罚,所以不能只追求概率尖锐。

3.7.3 KL 散度、困惑度与信息量:比较分布而不是只看一个类别

交叉熵可以拆成真实分布的熵加上 KL 差异

对两个离散分布 P 和 Q,KL 散度 DKL(P||Q)=ΣP(x)log(P(x)/Q(x)) 衡量用 Q 近似 P 时多付出的信息代价。它非负,但不对称,交换 P 和 Q 通常得到不同结果,因此不能简单称为“距离”。蒸馏、正则化和分布对齐会使用 KL,但必须写清谁是参考分布、谁是被优化分布。

熵衡量一个分布本身的不确定性。均匀分布的熵较高,集中在一个结果上的分布熵较低。语言模型在真实 token 上的平均负对数似然可以指数化为困惑度:perplexity=exp(cross-entropy)。困惑度直觉上像模型面对每个位置时的“有效候选数”,但它依赖词表、分词方式、数据清洗和计算口径,不能跨不同 tokenizer 直接比较。

信息量与概率成反比:I(x)=-log P(x)。罕见 token 信息量高,常见 token 信息量低;这不代表罕见内容一定重要,也不代表低困惑度等于事实正确。评测生成模型时,把交叉熵、任务准确率、人工质量、校准和安全错误放在一起,才能避免单一代理指标主导系统。

表 3.7C · 熵、交叉熵与 KL
量比较对象对称性模型场景
熵 H(P)P 与自身不适用目标数据的不确定性
交叉熵 H(P,Q)真实 P、预测 Q一般不对称分类、语言模型训练
KL(P||Q)Q 近似 P 的额外代价不对称蒸馏、分布约束
困惑度交叉熵的指数依赖 tokenizer语言建模比较
PYTHON · information_measures.py
from math import log, exp

p = [0.7, 0.2, 0.1]; q = [0.5, 0.3, 0.2]
entropy = -sum(value * log(value) for value in p)
cross_entropy = -sum(value * log(predicted) for value, predicted in zip(p, q))
kl = sum(value * log(value / predicted) for value, predicted in zip(p, q))
print({"entropy": entropy, "cross_entropy": cross_entropy, "kl": kl, "perplexity": exp(cross_entropy)})
assert abs(cross_entropy - entropy - kl) < 1e-12

分解关系 H(P,Q)=H(P)+DKL(P||Q) 是一个很好的自检:如果实现的交叉熵、熵和 KL 不满足近似等式,优先检查概率是否归一化、零概率如何处理和对数底数是否一致。不同文档可能使用自然对数或以 2 为底的对数,数值单位分别是 nat 和 bit,比较时要注明。

LOSS LAB 3.7.3不要跨 tokenizer 乱比困惑度

写一段说明,解释为什么两个模型在同一篇中文文章上的困惑度不能只看数值大小就判定优劣。至少提到分词粒度、词表和平均分母。

不同 tokenizer 把同一文本切成不同数量的 token,平均负对数似然的分母、可预测单位和词表都改变;应在相同 tokenizer、数据清洗、上下文长度和口径下比较,或补充任务级指标。
表 3.7D · 本主题完成标准
能力最低证据迁移到大模型
选损失能解释 MSE、MAE 和权重把业务代价写进优化目标
读交叉熵能从 logits、标签和分母复算排查训练曲线与标签编码
比较分布能区分熵、KL、困惑度理解蒸馏、校准和语言评测
说边界不把代理指标当最终质量组合自动指标、人工审查和安全检查

损失函数是训练目标,不等于最终评价

损失把一次预测与目标之间的差异转成可优化的标量。它选择了“什么错误更贵”的观点:MSE 放大大误差,MAE 对离群点更稳,Huber 在小误差区像 MSE、在大误差区像 MAE。没有一种损失天然正确,选择应由数据噪声、决策成本、梯度性质和最终任务共同决定。训练损失下降,只能说明模型更擅长当前目标,不保证用户体验、事实性或安全性同步改善。

损失的归约方式会改变梯度尺度。对 token 级交叉熵,可以求所有有效 token 的平均、求和,或按样本先平均再对样本平均;当序列长度不同、padding 很多时,三者的权重不同。若忽略掩码,把 padding 也计入平均,模型会学到填充符而不是内容。实现时要明确分母是有效 token 数、样本数还是固定 batch size,并在日志中同时记录分子和分母。

类别不平衡时,未加权损失可能被多数类主导。类别权重、重采样、focal loss 和阈值调整都能改变学习压力,但它们的作用位置不同:权重改变训练梯度,重采样改变看到的数据分布,阈值只改变决策。若同时使用多个方法,要做消融实验,避免把收益归因给错误的组件。权重还可能放大少数类噪声,需配合校准和分群评估。

损失函数的实现应满足基本性质:正确预测时是否达到最小、概率是否归一、对称性是否符合任务、输入为极端值时是否稳定、梯度是否有合理方向。最好先在一维或二维网格上画出曲线,再用自动微分检查梯度。对自定义损失,写出一个可读的参考实现与一个高效实现,随机输入对照数值和梯度。

表 3.7E · 训练损失与评估指标的分工
对象主要作用优点不能保证
训练损失提供可微优化信号能逐步更新参数与用户目标完全一致
准确率/F1描述离散决策易沟通概率质量与成本差异
对数损失评价概率分布惩罚过度自信不代表所有业务成本
人工偏好评价质量与风格覆盖复杂语义成本高、主观差异
安全/约束指标守住底线能表达不可接受错误不替代整体质量评估
PYTHON · reduction_audit.py
def masked_mean(values, mask):
    if len(values) != len(mask):
        raise ValueError("values 与 mask 长度不一致")
    selected = [value for value, keep in zip(values, mask) if keep]
    if not selected:
        raise ValueError("没有有效元素")
    return sum(selected) / len(selected)

token_losses = [0.2, 1.1, 0.7, 8.0, 0.4]
valid = [True, True, False, False, True]  # padding/忽略标签
print("masked mean=", masked_mean(token_losses, valid))
print("valid count=", sum(valid))

# 练习:比较全量平均、有效 token 平均和按样本平均的权重

这个例子特意把一个很大的损失放在被屏蔽位置,提醒我们掩码不是装饰。实际代码还要处理分布式训练:各设备的有效 token 数可能不同,不能先对每卡平均再简单平均,否则短序列卡片权重会被放大。应汇总有效损失和有效计数,再做全局除法,或者使用框架提供的正确归约。

LOSS LAB 3.7.4解释一个“loss 降了但指标没升”的实验

训练损失下降 20%,验证 F1 不变,人工偏好下降。列出至少四种可能原因,并为每种原因设计一个最小验证。

可能原因包括训练/验证分布不同、损失与 F1 不一致、类别权重改变阈值、数据泄漏、过拟合、标签噪声或人工评审口径变化。验证应分别检查分群曲线、混淆矩阵、阈值扫描、去重切分、训练/验证 gap 和评审一致性,而不是继续盲调学习率。

交叉熵、标签平滑与困惑度:概率质量的不同读法

交叉熵 H(p,q)=-Σp(x)log q(x) 衡量用模型分布 q 编码来自目标分布 p 的样本需要多少信息。分类的 one-hot 标签是特殊情况,只取正确类别的负对数概率。它对“自信但错误”惩罚很重,因此能推动模型把概率质量放到正确位置;但标签本身有噪声或类别边界模糊时,过分追求 one-hot 也会导致过度自信。

标签平滑把 one-hot 目标变成“正确类占大部分、其他类分到少量质量”的分布,能抑制极端 logits、改善校准,但也可能降低模型区分非常相近类别的能力。它不是无条件的正则化开关,平滑系数应在验证集上比较,并观察 NLL、准确率、校准和生成质量。对语言模型,padding、特殊 token 和不同序列长度还会改变有效 token 的分母。

困惑度常写为交叉熵的指数,是“模型平均分给真实 token 的有效分支数”的一种直观表达。它依赖 tokenizer、上下文窗口、归约方式和数据预处理,不能跨 tokenizer 或不同语料直接比较。困惑度下降说明预测分布更贴近评测文本,不等于事实性、指令遵循或安全性提高。

对数的底决定单位:自然对数对应 nats,底数为 2 对应 bits。换底只改变常数比例,不改变最优分布,但报告指标时必须注明。数值实现中,优先使用 logits 版融合交叉熵或稳定的 log-softmax,避免先取概率再取对数造成下溢。

表 3.7F · 交叉熵相关设置
设置改变了什么适用提醒需要一起报告
one-hot目标质量集中于一个类标签可信、边界明确标签来源与噪声
标签平滑给其他类少量质量抑制过度自信平滑系数、校准
类别权重改变不同错误的梯度类别不平衡权重、采样、阈值
忽略标签从分母排除 padding 等位置序列任务有效 token 数
温度缩放调整 logits 的尖锐度后处理校准校准集与温度
PYTHON · cross_entropy_reference.py
import math

def cross_entropy(logits, target, smoothing=0.0):
    if not logits or not 0 <= target < len(logits):
        raise ValueError("target 或 logits 无效")
    peak = max(logits)
    log_z = peak + math.log(sum(math.exp(x - peak) for x in logits))
    classes = len(logits)
    target_mass = 1.0 - smoothing
    other_mass = smoothing / (classes - 1) if classes > 1 else 0.0
    return -sum((target_mass if i == target else other_mass) * (x - log_z)
                for i, x in enumerate(logits))

print(cross_entropy([2.0, 0.1, -1.0], target=0))
print(cross_entropy([2.0, 0.1, -1.0], target=0, smoothing=0.1))
# 练习:检查平移所有 logits 不改变损失

参考实现把归一化常数写出来,便于逐项检查。优化版本可能使用向量化和融合 kernel,但应与这个版本在小输入上对齐。做对齐时要比较不同 reduction、dtype、忽略标签和权重设置;只比较最终一个 batch 的平均值,很容易把归约差异误认为实现 bug。

LOSS LAB 3.7.5从一个指标追到完整目标

为“下一 token 预测”写一份损失说明:目标分布、logits 形状、padding 处理、归约、温度、困惑度换算和至少两个不应混用的评估指标。

应说明 logits 通常为 (batch, seq, vocab),目标要错位一位,padding 不进入分母,交叉熵按有效 token 平均,再按需要指数化为困惑度。事实性、格式遵循和安全性不能由困惑度单独代表,需另设评估。

检查损失的三个方向:数值、梯度和决策

自定义损失至少要做三类检查。数值检查确认正确预测更小、输入平移或缩放符合预期、极端值不产生 NaN;梯度检查确认符号和量级与有限差分一致;决策检查则把损失变化映射到真正关心的错误,例如少数类召回、拒答率或人工偏好。只有三类证据相互支持,才适合把损失放进较大实验。

损失的目标分布也应与数据管道同步。分类标签是整数 id 还是概率分布,序列目标是否右移一位,padding 是否忽略,soft label 是否包含温度或平滑,都会改变公式。最有效的排错方法是打印一个样本的 logits、目标、有效 mask、逐项损失和最终归约,手算其中一项,再与框架输出对齐。

如果损失对某些样本极端敏感,可以记录逐样本分布而不是只记录平均值。高损失样本可能是难例、脏标签、分布外输入或模型真正的失败;把它们混成一个均值会错过改进线索。抽样查看高、中、低损失样本,并按来源、长度和类别分组,能帮助判断该调整数据、模型还是目标。

表 3.7H · 自定义损失的三层验收
层次最小问题测试样例通过条件
数值输出有限且方向正确吗?正确/错误、极端 logits有限、平移性质成立
梯度更新方向合理吗?有限差分、小步下降符号/量级在容差内
归约分母和权重清楚吗?不同长度、mask有效元素贡献一致
决策优化它会改善什么?分群与人工样例主/护栏指标不冲突
可追溯能复现一项损失吗?保存样本与配置逐项结果可重算
PYTHON · per_example_loss.py
import math

def nll_for_example(logits, target):
    peak = max(logits)
    log_z = peak + math.log(sum(math.exp(value - peak) for value in logits))
    return -(logits[target] - log_z)

batch = [([2.0, .1, -1.0], 0), ([.0, .1, .2], 2), ([8.0, -2.0, -3.0], 1)]
values = [nll_for_example(logits, target) for logits, target in batch]
print("per-example=", values, "mean=", sum(values) / len(values))
# 练习:按类别分组,找出平均损失最大的组

先定义错误的代价,再选择损失的形状

同一个预测任务可以有多种合理损失,因为“错多少”与“错在哪里”的代价不同。预测房价时,差 10 万元在不同价格区间的意义不同;审核任务里漏放一条高风险内容可能比误拦一条普通内容代价更高;生成任务里一个事实错误可能比措辞不自然更严重。损失应尽量把重要差异编码进梯度,同时把难以微分或需要人工判断的目标留给独立评估。

损失的尺度影响优化器的步长。把每个样本损失乘以 100,不改变理论最优点,却会把梯度放大 100 倍,混合精度和梯度裁剪的行为也会改变。比较实验时要记录 reduction、样本权重和损失单位;如果改变了权重,学习率往往也需要重新校准。不要只看 loss 的绝对值跨实验排序,先确认定义和尺度相同。

多任务训练把多个损失加权求和,权重决定不同目标之间的谈判。权重太大的一项会压制其他梯度,权重太小的一项几乎没有作用;动态权重、梯度归一化和分阶段训练都是解决办法,但每一种都引入新的假设。报告多任务结果时要分别给出各子损失、主指标、护栏指标和权重曲线,不能只报一个总 loss。

可微并不等于可优化。一个损失即使连续,仍可能有平坦区、尖锐区、局部极小或梯度很大。画一维切片、做小步下降测试、检查梯度方向和对极端样本的响应,是理解损失形状的低成本方法。自定义损失上线前还要检查有限性、设备/dtype、空 batch、忽略标签和分布式归约。

表 3.7G · 选择损失前的四个问题
问题要明确的内容可用证据遗漏的风险
错误代价哪类错误更贵?业务成本、专家排序指标提高但决策变差
尺度分母和权重是什么?损失单位、有效计数学习率与实验不可比
梯度哪些样本提供主要信号?逐样本损失、梯度范数噪声/多数类主导
稳定性极端值会怎样?边界测试、NaN 计数训练后期突然发散
对齐训练目标与最终指标是否相关?相关性、消融、人工评估loss 降而质量不升
PYTHON · multi_objective.py
def weighted_loss(task_losses, weights):
    if set(task_losses) != set(weights):
        raise ValueError("任务与权重键必须一致")
    if any(weight < 0 for weight in weights.values()):
        raise ValueError("权重不能为负")
    total = sum(task_losses[name] * weights[name] for name in task_losses)
    return total

losses = {"quality": 0.8, "safety": 0.2, "format": 0.4}
weights = {"quality": 1.0, "safety": 3.0, "format": .5}
print(weighted_loss(losses, weights))
# 练习:分别把 safety 权重改为 0.1 与 10,比较各项贡献

示例把权重和子损失显式打印出来,避免总值遮住某一项。若安全项是硬约束,单纯加权可能仍允许模型用质量收益换取安全退化,此时应增加拒绝条件、分层门槛或在训练流程中使用约束优化。权重不是“越大越重视”这么简单,还会改变优化轨迹和可达的折中区域。

LOSS LAB 3.7.6画出一个自定义损失的切片

定义一个包含 MAE 与平滑正则的损失,在误差从 -5 到 5 的区间采样,画出函数值和数值梯度。标注尖点、平坦区和梯度变化,并说明这些形状对优化有什么影响。

切片至少应覆盖零点附近和远端;观察正负误差是否对称、梯度是否在尖点处跳变、正则是否改变远端斜率。解释要把图形与步长、梯度裁剪、异常样本敏感度联系起来。

08 · OPTIMIZATION

优化不是“越快越好”:让更新稳定地服务于目标

优化把损失函数、梯度和参数更新连成训练循环。每一步都在一个局部近似上做选择:沿什么方向走、走多远、是否利用过去的方向、如何面对噪声和约束。学习率太大可能越过谷底,太小则训练缓慢;动量可以积累方向,也可能把错误方向带得更远。优化器只是执行更新规则,不能替你判断数据、损失和验证指标是否合理。

OBJECTIVE你真正要最小化什么训练损失只是一个代理
STEP SIZE每次改多少学习率决定稳定与速度
GENERALIZATION新数据是否有效验证曲线比训练曲线更关键

3.8.1 梯度下降与学习率:小步走也要知道目的地

基本更新式只有一句,但前提很多

梯度下降的更新写成 θ ← θ - η∇L(θ),其中 θ 是参数,η 是学习率,∇L 是损失对参数的梯度。负号表示朝局部下降方向走。学习率决定步长,不是“模型学习能力”的单一分数;它必须和损失尺度、批次大小、参数化方式及优化器配套。把学习率从 0.001 改到 0.01,实际改变的是每次参数移动的距离。

批量梯度使用所有训练样本,随机梯度使用一个样本,小批量梯度在噪声和效率之间折中。小批量更新的噪声有时能帮助离开狭窄的局部区域,却也会让曲线抖动。比较实验时记录有效更新次数,而不仅是 epoch;不同 batch size 下一个 epoch 的梯度估计次数不同。梯度累积可以模拟更大批次,但需要说明何时除以累积步数、何时更新学习率调度器。

学习率策略包括预热、衰减、周期变化和按指标降低。预热让初始阶段的参数和统计量先稳定,衰减在后期用更小步长细化。任何调度都应通过验证曲线和梯度统计验收,而不是因为名字流行就使用。遇到 loss 突然变为 NaN,先检查输入、logits、梯度和学习率范围,再决定是否降低学习率。

表 3.8A · 梯度下降的诊断顺序
现象优先检查可能原因不要直接做
loss 爆炸梯度范数、学习率、输入尺度步长过大、非有限值只把 loss 截断
loss 不动梯度是否为零、参数是否更新死区、冻结、学习率太小盲目训练更久
曲线抖动batch、种子、调度梯度噪声或步长过大只看最后一个点
训练降而验证升分割、正则化、泄漏过拟合或分布差异继续追训练最低点
PYTHON · gradient_descent_1d.py
loss = lambda x: (x - 3.0) ** 2 + 1.0; gradient = lambda x: 2 * (x - 3.0); results = {}; [results.setdefault(learning_rate, []) for learning_rate in (0.1, 0.8, 1.1)];
# 练习时把每个学习率的更新轨迹记录下来,再比较收敛与发散

学习率 0.1 会稳步接近最低点,0.8 可能在两侧快速摆动,1.1 则可能发散。这个一维例子不代表真实网络的全部行为,却能让你先理解“步长超过局部稳定范围”的概念。学习曲线要记录完整轨迹,不能只报告最后损失,因为不同设置可能在中途经历完全不同的风险。

OPTIMIZATION LAB 3.8.1画出三种学习率的轨迹

对一个二次函数试验三个学习率,记录每一步的位置和损失。判断哪一个是稳定收敛、哪一个是缓慢、哪一个是发散,并写出你使用的判断标准。

标准至少包括损失是否总体下降、参数是否有限、后期是否接近最低点。不要只用“最后一次更小”判断,应该观察整条轨迹和相同步数。

3.8.2 SGD、动量与 Adam:在噪声中寻找可复现的方向

优化器的差异是更新规则的差异

随机梯度下降用当前小批次估计梯度,估计会有噪声;动量维护一个历史方向,让连续朝同一方向的更新累积,减少狭长谷底中的来回摆动。自适应方法会为不同参数维护梯度平方等统计量,按历史尺度调整步长。它们能改善训练的数值行为,却不能修复错误标签、泄漏或不合适的损失。

Adam 常被描述为“结合动量和自适应学习率”,但真正决定结果的还有 β 参数、ε、权重衰减、学习率调度和梯度裁剪。更换优化器时应把其他变量尽量固定,并使用相同的训练/验证切分与随机种子集合。否则观察到的提升可能来自 batch、初始化或数据顺序,而不是优化器本身。

优化器状态也是实验状态。动量和二阶统计量会随着训练累积,恢复模型时若只保存参数、不保存优化器状态,继续训练的轨迹可能不同。检查点要记录模型权重、优化器状态、调度器步数、随机状态、数据版本和代码版本。比较两个 checkpoint 时,不要只看参数文件大小,要确认它们是否在同一训练阶段。

表 3.8B · 优化器对照
方法维护什么优点需要警惕
SGD当前梯度简单、状态少对学习率敏感、噪声大
Momentum梯度的一阶历史穿过狭长谷底更顺错误方向也会积累
Adam一阶和二阶历史不同参数自适应状态、权重衰减和泛化差异
AdamW解耦权重衰减正则化语义更清楚仍需调学习率和衰减
PYTHON · optimizer_state.py
gradient = [2.0, -1.0]; parameter = [4.0, -3.0]; velocity = [0.0, 0.0]; learning_rate = 0.1; momentum = 0.9;
for step in range(1, 4):
    velocity = [momentum * old + current for old, current in zip(velocity, gradient)]
    parameter = [value - learning_rate * move for value, move in zip(parameter, velocity)]
    print(step, parameter, velocity)
# 恢复训练时,parameter 和 velocity 都是检查点的一部分

这个简化例子没有实现 Adam 的偏置修正和平方梯度统计,只用于显示“优化器有状态”。如果只保存 parameter 而丢掉 velocity,从同一点继续训练会得到不同轨迹。实验报告应注明实现、版本和所有影响状态的超参数。

OPTIMIZATION LAB 3.8.2公平比较两个优化器

设计一次对照实验,比较 SGD 与 Adam。列出必须固定的因素、需要记录的指标,以及什么结果才足以支持“Adam 更好”的说法。

至少固定数据、切分、模型、批次、训练步数和多个随机种子;记录训练/验证损失、任务指标、梯度统计、耗时和显存。结论应限定在给定任务和预算内,不把一次运行推广成普遍规律。

3.8.3 正则化、早停与泛化:不要让训练集替你做所有决定

优化训练损失和获得新数据上的可靠性不是同一目标

模型参数很多时,可以把训练样本记得非常细,却在新样本上表现下降,这就是过拟合的典型表现。正则化通过在目标中加入参数大小、输出平滑或随机扰动的约束,减少模型依赖偶然细节。L2 权重衰减倾向于限制参数整体规模,L1 会鼓励部分参数变为零;Dropout 在训练时随机屏蔽部分激活,迫使网络不要依赖单一路径。每一种方法都改变了目标或训练过程,不应只把它们当成“让分数变高”的开关。

早停使用验证集监控来决定何时停止训练:当验证指标在若干轮内不再改善,就保存最佳检查点并停止。耐心轮数、最小改善幅度和监控指标需要提前写出,否则会在看到曲线后随意挑一个 checkpoint。验证集被反复用于调参,也会逐渐参与决策,最终应保留一个真正未参与选择的测试集或时间外样本。

泛化还取决于数据覆盖和任务定义。正则化不能补齐没有出现的语言、长度、领域或失败类型;早停不能修复标签泄漏和不一致的预处理。对大模型微调,训练集损失下降可能伴随通用能力、事实性或安全表现变化,因此需要多维评测和回归测试。最可靠的做法是把训练、验证、测试、人工审查和上线监控连成一条证据链。

表 3.8C · 泛化控制的工具
工具改变什么适合观察边界
L2 权重衰减惩罚参数平方参数规模、验证曲线不是数据质量修复
L1 正则惩罚绝对值稀疏性、特征选择可能丢掉小但重要的信号
Dropout训练时随机屏蔽依赖路径、鲁棒性推理模式需关闭随机性
早停选择验证最佳点训练/验证分离验证集也会被反复使用
PYTHON · early_stopping.py
validation_scores = [0.62, 0.68, 0.70, 0.69, 0.695, 0.694, 0.693]; best_score = float("-inf"); best_step = None; patience = 2; stale = 0;
for step, score in enumerate(validation_scores, start=1):
    if score > best_score: best_score, best_step, stale = score, step, 0
    else: stale += 1
    if stale >= patience: print("stop at", step, "restore", best_step, best_score); break

早停示例保存的是验证集最佳分数对应的步骤,而不是最后一次运行。真实训练还要处理指标越小越好、多个指标冲突、检查点写入失败和训练被中断等情况。把停止规则写成代码并记录,就能避免“凭曲线截图挑结果”。

OPTIMIZATION LAB 3.8.3写一份防过拟合协议

为一个小型文本分类实验写出训练、验证和测试的职责;指定何时早停、保存哪个 checkpoint、哪些指标触发回归检查,并列出一个可能泄漏的字段。

测试集不能用于调参;早停只看验证集,保存验证指标最优且能回读的 checkpoint。回归检查至少覆盖主指标、关键子群和已知失败案例。可能泄漏的字段包括人工标签、未来时间信息或由目标反推的文本。
表 3.8D · 本主题完成标准
能力最低证据大模型迁移
调步长能解释学习率、批次和调度设计可复现训练对照
读优化器能说明状态和动量的作用正确保存、恢复和比较 checkpoint
看泛化能区分训练、验证、测试处理过拟合、泄漏和分布漂移
定停止把早停规则写成可执行协议让实验结果可审计、可回滚

把梯度下降看成一套可观察的控制回路

梯度下降的更新式 θ_{t+1}=θ_t-η∇L(θ_t) 只写了方向和步长,真正的训练还需要观测回路:记录当前损失、梯度范数、参数范数、学习率、吞吐和验证集指标,再决定下一步是否继续。学习率太大时,损失可能震荡、发散或在狭窄谷底来回跳;太小时,损失下降很慢,甚至被数值噪声掩盖。先在一个小数据集上让模型过拟合,是检查更新方向和损失实现的有效方法。

非凸目标通常存在平坦区、鞍点、多个局部结构和不同尺度的方向。梯度下降并不需要找到全局最优才能得到有用模型,但实验必须说明“优化成功”的定义:训练损失达到阈值、验证指标不再提升、约束满足,还是预算耗尽。只看最后一个 loss 数字无法区分模型已经收敛、学习率过小、梯度被截断,还是数据管道没有真正更新。

学习率调度是时间维度上的建模选择。预热可以避免初始更新过猛,余弦衰减或分段衰减让后期步长变小,周期策略则允许在不同尺度探索。调度器的步进位置很关键:按 batch、按 epoch 或按实际更新次数会产生不同曲线;梯度累积、跳过 batch 和恢复 checkpoint 时,必须保存调度器状态,否则恢复后的学习率会错位。

梯度裁剪控制的是更新的最大幅度,不等同于修复爆炸原因。按全局范数裁剪会把所有参数的梯度同比缩放,按值裁剪则逐元素截断,二者对方向的影响不同。发现频繁裁剪时,应回查输入尺度、损失归约、混合精度、异常 batch 和模型初始化;否则裁剪可能只是把问题隐藏在一个看似稳定的训练曲线后面。

表 3.8E · 训练回路的观测指标
指标回答的问题异常表现优先排查
训练/验证损失目标是否改善、是否泛化训练降而验证升数据切分、容量、正则
梯度范数更新信号是否健康长期为零或爆炸图断开、尺度、裁剪
参数范数权重是否失控快速增长或坍缩学习率、权重衰减
学习率每次更新的尺度恢复后跳变调度器状态与步进
有效 batch一次更新看了多少样本不同实验不一致累积、丢弃和分布式归约
吞吐/显存预算是否可持续逐步变慢数据加载、图保留、缓存
PYTHON · training_loop_audit.py
def update(parameter, gradient, learning_rate, clip_norm=None):
    if clip_norm is not None:
        norm = sum(value * value for value in gradient) ** 0.5
        if norm > clip_norm:
            scale = clip_norm / norm
            gradient = [value * scale for value in gradient]
    new_parameter = [p - learning_rate * g for p, g in zip(parameter, gradient)]
    return new_parameter, sum(g * g for g in gradient) ** 0.5

parameter = [2.0, -1.0]
gradient = [4.0, -3.0]
parameter, grad_norm = update(parameter, gradient, .1, clip_norm=2.0)
print("parameter=", parameter, "effective grad norm=", grad_norm)
# 练习:记录裁剪前后的范数,解释方向是否发生改变

示例把更新与观测放在同一个小函数中,方便验证。完整训练器还应返回步数、是否发生裁剪、当前学习率和有限性检查结果。若出现 NaN,先保存触发问题的 batch 和 checkpoint,再缩小学习率或关闭混合精度做对照;直接跳过 NaN batch 会丢失诊断证据。

OPTIMIZATION LAB 3.8.4设计一个最小过拟合实验

选 8 条样本、一个很小的模型和固定随机种子,要求训练损失在有限步内接近零。列出必须固定的因素、每步要记录的指标,以及如果做不到应按什么顺序排查。

固定数据、标签、初始化、dtype、设备、优化器和学习率;记录 loss、梯度范数、参数是否更新和预测值。排查顺序可为:标签/损失对齐、梯度是否存在、学习率尺度、参数是否加入优化器、模型前向是否使用了更新后的参数。

SGD、动量与 Adam:状态变量改变了更新的记忆

随机梯度下降用一个 mini-batch 估计总体梯度,噪声有时能帮助离开狭窄结构,但也让曲线抖动。动量为梯度建立指数移动平均,相当于保留过去更新的方向;它能穿过小尺度的来回摆动,却可能在学习率过大时积累错误方向。Adam 同时维护一阶矩和二阶矩,按每个参数的历史尺度自适应调整,但它的默认超参数并非普适常数,权重衰减与 L2 惩罚也不总是同一件事。

Adam 的偏置修正是因为移动平均在训练初期从零开始,会系统性偏小。更新中的 m_hat 与 v_hat 用当前步数修正后,早期行为才与长期估计更一致。实现优化器时,参数、梯度、一阶状态、二阶状态和步数必须一一对应;恢复 checkpoint 时少保存一个状态,就会得到“参数相同、后续轨迹不同”的隐蔽差异。

权重衰减可以理解为每一步让参数向零收缩,但在自适应优化器中,直接把 L2 项加进梯度与解耦权重衰减的行为不同。选择哪一种取决于框架实现和实验目的。记录优化器类、版本、betas、epsilon、weight decay、是否解耦以及参数分组,比只写“用了 Adam”可复现得多。

不同参数组常需要不同学习率,例如预训练骨干与新加的分类头、Embedding 与输出投影、偏置与归一化参数。参数分组时要检查没有重复或遗漏,冻结参数要确认 requires_grad 与优化器列表同时反映冻结状态。否则“学习率调优”可能只是在给一个根本没有更新的参数组换数字。

表 3.8F · 优化器状态的复现清单
状态用途恢复时缺失的后果检查方式
参数 θ模型当前解直接改变预测权重哈希、范数
梯度 g当前 batch 的方向更新时机错位反向后打印范数
一阶矩 m动量/方向记忆恢复后轨迹突变状态字典键与形状
二阶矩 v尺度/方差记忆早期步长失真最小值、最大值、dtype
步数 t偏置修正与调度学习率或修正错位全局更新计数
随机状态采样与 dropout无法复现轨迹保存 Python/框架/设备 RNG
PYTHON · optimizer_state.py
def adam_step(parameter, gradient, state, lr=.01, beta1=.9, beta2=.999, eps=1e-8):
    state["step"] += 1
    state["m"] = [beta1 * m + (1 - beta1) * g for m, g in zip(state["m"], gradient)]
    state["v"] = [beta2 * v + (1 - beta2) * g * g for v, g in zip(state["v"], gradient)]
    t = state["step"]
    m_hat = [m / (1 - beta1 ** t) for m in state["m"]]
    v_hat = [v / (1 - beta2 ** t) for v in state["v"]]
    return [p - lr * m / ((v ** .5) + eps) for p, m, v in zip(parameter, m_hat, v_hat)]

parameter = [1.0, -2.0]
state = {"step": 0, "m": [0.0, 0.0], "v": [0.0, 0.0]}
for gradient in ([.5, -1.0], [.2, -.4]):
    parameter = adam_step(parameter, gradient, state)
    print(parameter, state["step"])
# 练习:删掉偏置修正,比较前两步的差异
OPTIMIZATION LAB 3.8.5检查 checkpoint 是否真的可恢复

保存参数、优化器状态、调度器步数和随机状态;从同一点恢复后继续训练两步。把恢复轨迹与不中断训练的轨迹逐项比较,并列出允许的浮点误差。

至少比较参数、loss、学习率和随机采样结果;若使用 dropout 或数据打乱,还要保存对应 RNG。允许误差应与 dtype、设备和算子确定性设置相关,不能用“看起来差不多”验收。若只保存模型权重而不保存优化器状态,后续轨迹通常不会完全一致。

停止、回滚与继续训练:把“最佳模型”定义清楚

训练停止并不只因为步数用完。常见条件包括验证指标在若干次评估内没有改善、梯度或损失出现非有限值、资源预算耗尽、护栏指标越界,以及达到预设目标。早停的 patience、最小改善量和比较方向必须写进配置;否则不同实验可能用不同的“最佳”定义,结果无法公平比较。

保存 checkpoint 时通常同时保留最近版本与验证集最佳版本。最近版本便于诊断和恢复,最佳版本用于交付;两者可能不同。回滚后要重新评估主指标和护栏,不能只看保存时的一个分数。对于长时间训练,还应定期验证 checkpoint 可读、权重与优化器状态形状一致,并在独立进程中做一次加载测试。

继续训练改变的不只是参数,还可能改变数据顺序、学习率、随机增强和标签版本。若数据或代码发生变化,应把它记录为新实验,而不是覆盖原运行。恢复后第一步可以使用较小学习率或只跑验证,确认状态对齐,再恢复正常更新。模型文件、配置和日志应有同一个运行 ID,方便从指标回到具体权重。

表 3.8H · 训练状态的处置规则
情况动作保留证据避免的误判
验证长期无改善早停并取最佳 checkpointpatience、最佳步数把最后一步当最好
出现 NaN暂停、保存现场、回滚batch、梯度、精度配置静默跳过问题样本
护栏越界停止交付或降级分群指标、阈值主指标上涨就放行
恢复训练先验证状态,再继续更新权重/状态哈希、步数只加载模型权重
预算耗尽记录未收敛状态资源、曲线、剩余目标把预算终点称为收敛
PYTHON · early_stop_policy.py
def should_stop(history, patience=3, min_delta=1e-3):
    if not history:
        return False, "没有评估记录"
    if not all(value == value and abs(value) != float("inf") for value in history):
        return True, "验证指标非有限"
    best = min(history)
    recent = history[-patience:]
    if len(recent) == patience and min(recent) >= best - min_delta:
        return True, "patience 用尽"
    return False, "继续"

for scores in ([1.0, .8, .7, .69, .691, .692], [.9, .7, float("nan")]):
    print(should_stop(scores))
# 练习:把“越高越好”的指标改写为同一策略

收敛不是一条漂亮曲线:用对照实验判断更新是否有效

损失曲线下降只是结果,不是原因。为了确认优化器真的在工作,可以固定一个小批次,分别执行一次正向、反向和小步更新,检查更新后的目标是否下降;再把梯度乘以负方向和正方向各走一步,比较哪一边更好。若两边都变差,可能处于噪声、非光滑点或实现有误;若只有大步变差,可能是学习率过大。

学习率搜索应有明确范围和停止规则。对数尺度试探通常比线性尺度覆盖更广;每个候选至少运行相同的更新次数和数据顺序,并记录达到目标所需的步数、最佳验证指标和峰值资源。不要只选择最终 loss 最小的曲线,早期收敛速度、稳定性和推理成本也可能是决策因素。

当训练不稳定时,逐项关闭复杂因素比同时换十个超参数更容易定位:先关闭混合精度,再固定数据顺序,再关闭 dropout,最后换回简单 SGD。每次只改变一个变量,并保留失败日志。若改变一个设置后曲线好看了,要确认收益不是因为有效 batch、梯度归约或随机种子同时被改变。

分布式训练还会改变梯度统计。数据并行需要在设备间聚合梯度,梯度平均还是求和会影响有效学习率;梯度累积与通信频率也会改变噪声。恢复训练时,数据采样器位置、参数分片、优化器状态和调度器步数都要一致。一个单卡可复现的实验,不能直接假设多卡结果在数值和吞吐上完全相同。

表 3.8G · 优化实验的逐项消融
阶段只保留的变量要回答的问题通过标准
单步方向固定小批次、固定参数负梯度是否降低目标?小步后 loss 下降
学习率其他设置不变步长范围在哪里?稳定且可解释的曲线
随机性固定 seed/关闭增强结果是否依赖随机样本?多次运行方差可接受
精度float32 vs 混合精度数值误差是否影响训练?loss/梯度无异常
并行单卡 vs 多卡归约与吞吐是否一致?有效 batch 和指标可比
PYTHON · one_step_probe.py
def quadratic(theta):
    return sum((value - target) ** 2 for value, target in zip(theta, [1.0, -2.0]))

def gradient(theta):
    return [2 * (value - target) for value, target in zip(theta, [1.0, -2.0])]

theta = [4.0, 3.0]
before = quadratic(theta)
g = gradient(theta)
step = 0.1
candidate = [value - step * direction for value, direction in zip(theta, g)]
opposite = [value + step * direction for value, direction in zip(theta, g)]
print("before=", before, "negative step=", quadratic(candidate), "positive step=", quadratic(opposite))
assert quadratic(candidate) < before < quadratic(opposite)

单步探针可以在没有数据加载器、分布式通信和复杂模型的情况下验证优化方向。把它嵌入真实模型时,保存更新前后的参数摘要和损失;如果梯度方向正确但训练仍不收敛,再查 batch 噪声、条件数、调度器和正则,而不是回到公式本身。

OPTIMIZATION LAB 3.8.6制作一个可比较的学习率表

选择 5 个对数间隔的学习率,在相同数据、步数、种子和硬件上运行。表格至少包括最终训练/验证损失、最佳验证步、梯度裁剪次数、NaN 次数、吞吐和峰值显存,并写出选择理由。

比较必须固定有效 batch、调度步进和评估频率。选择不应只看最终训练损失;若一个学习率训练更快但验证不稳,应把稳定性和资源作为护栏。记录裁剪与 NaN 次数能帮助识别“看似收敛、实际频繁被修补”的配置。

09 · LARGE-LANGUAGE-MODEL MAP

把数学接回大模型:表示、注意力与可验证实验

前面的向量、矩阵、概率、梯度和损失并不是彼此孤立的章节。大模型把它们组合成一条计算链:文本先被切成 token,再映射为向量;线性投影产生查询、键和值;点积和 Softmax 形成注意力权重;损失函数比较模型分布与目标 token;梯度通过链式法则更新参数。理解这条链不要求你立刻实现完整 Transformer,但要求你能对每个中间量提出同样的问题:形状是什么、范围是什么、谁决定它、如何验证。

REPRESENTToken → 向量把离散符号放进可计算空间
ATTENDQ · Kᵀ → 权重让位置之间按相似度交流
VERIFY数据 → 指标把结论还原到输入和假设

3.9.1 Embedding 与余弦检索:语义相似仍然要有距离定义

Embedding 是坐标,不是“意义本身”

Embedding 把离散对象映射到连续向量空间。相似文本往往被训练成方向或邻域更接近,但“相似”依赖训练目标、数据、分词、语言和距离度量。余弦相似度忽略向量长度,适合关注方向的检索;点积同时受方向和长度影响,若向量已归一化,两者才在数值上等价。欧氏距离则把向量看成空间中的位置。选择指标是系统设计的一部分,不是数据库的默认细节。

检索管道至少包含四个可检查阶段:文本预处理、Embedding 生成、索引构建和候选排序。任一阶段改变,邻居都可能变化。预处理中的空白、大小写、截断和语言识别会影响向量;模型版本或批处理方式变化会影响数值;索引的距离和 top-k 参数会影响排序。保存一条检索结果时,除了文档 ID 和分数,还应保留查询文本、Embedding 模型版本、归一化方式、索引版本和过滤条件。

高相似度不是事实正确性的证明。两个文档可能共享模板或常见词,却在关键数字上相反;一个问题可能没有真正相关的候选,但系统仍会返回最相近的结果。设置相似度阈值、允许“无合适结果”、展示来源片段并进行人工抽查,能避免把近邻当作答案。评测检索时同时看 Recall@k、MRR、人工相关性和错误类型。

表 3.9A · Embedding 检索的契约
环节必须说明常见漂移验收证据
文本分块、清洗、语言、长度同一文档切法不同chunk_id、字符数、来源
向量模型、维度、dtype、归一化模型升级或截断版本、范数、非有限比例
索引度量、过滤、top-k距离设置不一致索引配置和构建时间
结果分数、候选、阈值近邻并不相关Recall、人工抽查、拒答
PYTHON · embedding_retrieval.py
from math import sqrt

dot = lambda left, right: sum(a * b for a, b in zip(left, right))
norm = lambda vector: sqrt(dot(vector, vector))
cosine = lambda left, right: dot(left, right) / (norm(left) * norm(right)) if norm(left) * norm(right) else None

query = [0.8, 0.1, 0.2]; documents = {"A": [0.7, 0.2, 0.1], "B": [-0.1, 0.8, 0.3], "C": [0.6, 0.2, 0.2]}
ranked = sorted(((doc_id, cosine(query, vector)) for doc_id, vector in documents.items()), key=lambda item: item[1], reverse=True)
print(ranked)

示例只在内存中完成排序,真实系统还要处理向量维度不一致、空文本、零向量、索引分页和权限过滤。先用小数据打印全部候选,再接入近似最近邻索引;当结果异常时,优先对比一组固定查询的邻居和分数,而不是只看在线总体指标。

LLM LAB 3.9.1设计一组检索回归样本

准备 10 个查询和每个查询的相关文档 ID,固定一个 Embedding 版本。计算 Recall@3,故意改变归一化方式或文本分块,观察哪些查询发生变化,并记录原因。

回归集要覆盖短问、长问、同义表达、无答案和权限过滤。分数变化要和模型、分块、距离、过滤条件逐项对照;不要只记录“命中/未命中”,还要保留候选排序和来源。

3.9.2 点积注意力:矩阵、Softmax 与尺度为什么同时出现

注意力先计算相关性,再按权重混合信息

给定查询矩阵 Q、键矩阵 K 和值矩阵 V,缩放点积注意力的核心形式可以写成 softmax(QKᵀ/√dk)V。QKᵀ 产生每个查询与每个键的相关性矩阵,除以 √dk 降低维度变大时点积方差膨胀的影响,Softmax 把一行分数变成权重,最后用权重对 V 加权求和。每一步都对应前面学过的矩阵乘法、范数/尺度和概率分布。

形状是读懂注意力的钥匙。若序列长度为 n、每头维度为 d,Q、K、V 可写成 (n,d);K 转置后为 (d,n),点积得到 (n,n) 的位置关系矩阵;与 V 相乘又回到 (n,d)。批次和多头会增加轴,但内侧维度仍需匹配。注意力掩码把不允许看到的位置设成极小分数,Softmax 后它们的权重接近零。掩码的方向和广播形状错了,代码可能仍运行,却会产生信息泄漏。

注意力权重是当前层在当前输入上的相对分配,不是人类可直接阅读的因果解释。高权重位置可能只是格式、标点或重复模式;不同头、不同层和不同输入的权重会变化。做可解释性实验时要同时看输出变化、遮挡对照和任务指标,不能只截一张热力图下结论。

表 3.9B · 注意力计算的形状
对象形状语义失配后果
Q(n,d)每个位置发出的查询查询轴或头维度错
Kᵀ(d,n)每个位置提供的键无法做内积
QKᵀ(n,n)位置两两相关性掩码轴错、泄漏
V(n,d)被加权传递的内容输出维度错
结果(n,d)每个位置的混合表示残差相加不匹配
PYTHON · scaled_attention_toy.py
from math import exp, sqrt

def softmax(values):
    pivot = max(values)
    weights = [exp(value - pivot) for value in values]
    total = sum(weights)
    return [weight / total for weight in weights]

def attention(query, keys, values):
    dimension = len(query)
    scores = [sum(a * b for a, b in zip(query, key)) / sqrt(dimension) for key in keys]
    weights = softmax(scores)
    output = [sum(weight * value[index] for weight, value in zip(weights, values)) for index in range(len(values[0]))]
    return scores, weights, output

q = [1.0, 0.0]; k = [[1.0, 0.0], [0.0, 1.0], [0.7, 0.7]]; v = [[10.0, 0.0], [0.0, 10.0], [5.0, 5.0]]
print(attention(q, k, v))

玩具实现把一个查询与三个键比较,输出是三个值向量的加权混合。把 /sqrt(dimension) 删除,再把维度扩大,观察 Softmax 是否更尖,可以帮助理解缩放项的动机。真实实现还包括批次、多头、掩码、设备和高效内核;先用小矩阵核对数值,再依赖框架。

图 3.9 · 注意力的一条最小计算链自制结构示意图
Q、KQKᵀ / √dSoftmax权重 × V输出V 提供被混合的内容,掩码决定哪些位置可以参与

注意力把相关性分数转成一行权重,再对值向量做加权求和;每一步都能用形状和行和为 1 验证。

LLM LAB 3.9.2用掩码验证信息边界

在三位置玩具注意力中,禁止第一个位置看到后两个位置。把对应分数替换成一个极小值,检查第一行权重是否集中在允许位置,并说明掩码放错轴会怎样。

掩码应作用在相关性矩阵的“查询位置 × 被查看位置”轴上,Softmax 前设置为极小值。若把行列反了,可能让错误的位置可见;因此要打印矩阵形状、被屏蔽索引和每行权重和。

3.9.3 用数学写实验协议:指标、假设与可复现交付

最后的数学能力是把结论限制在证据里

一个大模型实验可以被写成输入分布、参数配置、目标函数、随机过程和输出指标的组合。输入分布决定你讨论的是哪类样本;参数配置决定模型和推理行为;目标函数决定训练偏好;随机过程决定重复运行的波动;指标决定什么算成功。任何一项没有记录,别人就难以复现或解释差异。把公式写成字段和表格,是从课堂练习走向工程的关键一步。

指标要和问题成对出现。生成质量不能只看困惑度,检索不能只看平均相似度,分类不能只看准确率,训练稳定性不能只看最后 loss。为每个主指标指定分母、方向、样本范围和失败处理,再补充一个能揭示风险的护栏指标。若指标冲突,先说明优先级和决策规则,不要在结果出来后挑最有利的一项。

最小可复现实验应包含:版本化输入、固定或记录随机种子、明确环境、可运行入口、结构化日志、模型/索引检查点、评测脚本和一份结果摘要。摘要里不只写最终数字,还要保留关键中间量的范围、形状和非有限计数。发生失败时保存错误信息和已完成步骤,避免把失败运行从历史中抹掉。数学直觉最终要落到这种可审计的行为上。

表 3.9C · 大模型实验的数学契约
层次写清楚什么示例验收方式
输入总体、切分、分词、长度时间外测试集数量、来源、泄漏检查
表示维度、dtype、归一化Embedding 768 维shape、范数、版本
目标损失、权重、聚合平均交叉熵logits、标签、分母
更新优化器、学习率、步数AdamW、warmup梯度、状态、checkpoint
输出主指标、护栏、区间Recall@k、拒答率回读、分层、人工抽查

综合项目:数学直觉实验册

用一个小型、人工编写的数据集完成四页实验记录:第一页比较不同范数和余弦相似度;第二页用二维函数做有限差分与梯度下降;第三页计算一个三分类分布的交叉熵与校准;第四页实现三位置缩放点积注意力。每一页都要保存输入、公式、代码、输出、解释和一个失败案例。

INPUT向量 · 分数 · 三位置序列
PROCESS计算 · 画图 · 对照 · 解释
OUTPUTMarkdown 报告 · JSON 清单 · PNG 图
表 3.9D · 项目验收清单
模块成功证据失败证据必须保留
向量距离指标和零向量边界正确尺度改变导致解释失效向量、度量、排序
梯度差分与解析式在容差内步长过大或过小步长、误差、函数
概率分布归一化、熵和交叉熵可回算标签或分母错位logits、目标、口径
注意力形状和每行权重和正确掩码方向错误Q/K/V、mask、输出

项目的评分重点不是代码长短,而是职责是否清楚:至少拆出“计算向量指标”“估计梯度”“计算分布损失”“执行注意力”四个函数;不要把所有逻辑写在一个循环里。每个函数都要有输入形状、返回结构和边界行为。完成后检查 JSON 是否能被再次读取,图表是否能从同一输入重新生成,报告中的数字是否能由代码复算。

LLM LAB 3.9.3写出一份能被别人复跑的报告

把综合项目整理成 README:列出环境、命令、输入文件、公式、随机性、输出路径、指标口径和已知限制。邀请同学只看 README 冷启动一次,记录他在哪一步需要额外询问。

README 应让新终端能够完成安装、运行、回读和复核;任何需要口头解释的默认值都应补进文件。结果报告要区分观察、解释和推测,说明哪些结论只适用于这组人工数据。
表 3.9E · 本主题完成标准
能力最低证据进入下一章前要做到
读表示能解释向量、矩阵和 Embedding 的形状与尺度排查检索和投影的维度问题
读注意力能说出 QKᵀ、缩放、Softmax、V 的职责看懂 Transformer 图中的计算链
读训练能把损失、梯度和优化器连起来理解框架训练循环,而非只会调用 API
做实验能保存输入、版本、指标、失败和复算证据为后续机器学习章节建立可靠习惯

从 token 到向量:Embedding 不是词典,而是可训练的坐标系

Tokenizer 先把文本切成 token,再把 token 映射成整数 id;Embedding 表把每个 id 查成一个向量。id 本身只是索引,不能拿来计算“词 100 比词 20 更接近”。真正可比较的是向量空间中的距离或夹角,而且相似性依赖训练目标、语料和层的位置。输入 Embedding、位置表示和上下文混合后的隐藏状态,语义会随着层数逐步变化,不能把某一层的坐标当成固定词义。

词表大小、向量维度、特殊 token 和分词规则会共同决定模型的输入接口。更换 tokenizer 会改变序列长度、未知词处理和困惑度分母,不能只替换词表文件。中文、代码、数字、表情和混合语言应分别做覆盖测试;长文本还要验证截断策略,避免把关键指令静默截掉。训练与推理必须使用同一套 tokenizer 版本和规范化规则。

检索中的余弦相似度只比较方向,不比较长度;点积同时受方向和范数影响。若向量范数携带了置信度或频率信息,归一化可能丢掉有用信号;若范数主要反映文档长度,点积会偏向长文档。选择相似度前要用标注查询集验证,并报告召回率、延迟、索引大小和长尾查询,而不是只展示几个“相似句子”。

Embedding 的可视化也有陷阱。降维算法会改变局部距离和全局结构,二维图只能辅助观察,不能证明高维空间真实呈现某种语义边界。应配合近邻一致性、跨域检索、分群指标和随机种子复现。对隐私敏感数据,向量仍可能保留可识别信息,不能因为它看起来像一串浮点数就忽略访问控制。

表 3.9F · 从文本到检索分数的链路
阶段对象关键选择验收证据
规范化原始文本空白、大小写、Unicode边界样例与版本
分词token 序列词表、特殊符号、截断长度分布、可逆性
查表Embedding维度、dtype、冻结策略形状、范数、梯度
归一化向量是否单位化、何时做相似度对照实验
检索候选排序索引、top-k、过滤Recall@k、延迟、分群
PYTHON · embedding_retrieval.py
import math

def cosine(left, right):
    if len(left) != len(right):
        raise ValueError("向量维度不一致")
    numerator = sum(a * b for a, b in zip(left, right))
    denominator = math.sqrt(sum(a * a for a in left)) * math.sqrt(sum(b * b for b in right))
    if denominator == 0:
        raise ValueError("零向量没有方向")
    return numerator / denominator

query = [1.0, .2, .0]
documents = {"A": [1.0, .1, .0], "B": [.0, .8, .4], "C": [-.5, .0, .2]}
ranking = sorted(((cosine(query, vector), name) for name, vector in documents.items()), reverse=True)
print(ranking)
# 练习:给 query 与每个文档加同一倍数,比较余弦与点积排序

在真正的向量数据库中,还要考虑索引近似带来的召回损失、更新延迟、元数据过滤和权限隔离。先用暴力搜索建立小数据集基线,再替换为近似索引;如果结果变化,才能区分是 Embedding、相似度还是索引参数造成的。查询日志应脱敏,评估集不能直接来自线上用户的答案回流,否则会形成隐性测试泄漏。

LLM LAB 3.9.4建立可解释的检索基线

准备 20 个查询、每个查询 3 个相关文档和若干干扰文档,分别评估点积与余弦的 Recall@1、Recall@5。记录是否归一化、向量维度、top-k、查询耗时,并为一个失败案例写出原因假设。

基线要固定 Embedding 模型、tokenizer、索引和查询集,按查询而不是按文档平均。失败案例需检查长度偏差、语义歧义、分词、向量范数和标注质量;不能只挑成功案例展示。若近似索引参与,先与暴力搜索比较召回损失。

注意力把相似度变成条件计算:每个位置都在重新选择上下文

缩放点积注意力可以写成 softmax(QKᵀ/√d_k)V。Q 表示当前查询想找什么,K 表示各位置提供什么线索,V 是真正被加权汇总的内容。QKᵀ 给出每个 query-key 配对分数,除以 √d_k 控制维度增大时点积方差,Softmax 把分数变成一行一和的权重。这个公式没有“记忆检索”的魔法,只有投影、点积、归一化和加权求和。

多头注意力把隐藏维拆成多个子空间,让不同头学习不同关系;头之间仍共享同一批 token,却使用不同的 Q/K/V 投影。实现时要检查 reshape 前后的元素总数、head_dim 是否整除 hidden_dim,以及合并头的顺序是否恢复。把 view 当成免费转置或在非连续张量上直接重排,可能得到形状正确但语义错位的结果。

因果掩码只允许位置看到自己和之前的位置,padding 掩码则屏蔽无效 token;二者的广播形状和加法位置不同。常见实现把无效位置的分数加上一个很大的负值,但必须避免整行全被屏蔽导致 Softmax NaN。训练与生成阶段的 mask、KV cache 和位置偏移要分别测试,不能因为短序列输出正常就假设长序列正确。

注意力的全连接分数矩阵让序列长度带来近似二次的时间和显存开销。长上下文方案可能使用滑动窗口、稀疏模式、低秩近似或线性注意力,但改变了可见范围和误差结构。比较方案时要同时报告长度、batch、硬件、dtype、吞吐、峰值显存和质量指标;只报一个速度数字会掩盖计算预算的转移。

表 3.9G · 注意力实现的形状契约
张量推荐形状每个轴含义检查点
Q(B,H,Q,D)批次、头、查询、头维Q 与 K 的 D 相同
K(B,H,K,D)批次、头、键、头维转置后参与点积
V(B,H,K,Dv)被汇总的值K 轴与分数最后一轴相配
scores(B,H,Q,K)每个查询对每个键mask 可广播且无全屏蔽行
output(B,H,Q,Dv)加权后的上下文合并头后回到 hidden
PYTHON · attention_toy.py
import math

def softmax(values):
    peak = max(values)
    weights = [math.exp(value - peak) for value in values]
    total = sum(weights)
    return [weight / total for weight in weights]

def attention(query, keys, values):
    if len(keys) != len(values):
        raise ValueError("keys 与 values 数量不一致")
    depth = len(query)
    scores = [sum(a * b for a, b in zip(query, key)) / math.sqrt(depth) for key in keys]
    weights = softmax(scores)
    return [sum(weight * value[i] for weight, value in zip(weights, values))
            for i in range(len(values[0]))], weights

context, weights = attention([1.0, 0.0], [[1.0, 0.0], [0.0, 1.0]], [[2.0, 0.0], [0.0, 3.0]])
print("context=", context, "weights=", weights, "sum=", sum(weights))
assert abs(sum(weights) - 1.0) < 1e-12
# 练习:加入一个 mask,禁止 query 看到第二个 key

玩具实现每次只处理一个 query,故意省略批次和多头,以便手算权重。扩展到张量时,先保留一个同样的单 query 回归测试,再逐步加入 batch、head、mask 和 cache。官方框架的融合注意力会在内部优化内存访问,但输出仍应满足权重归一、屏蔽位置不贡献和形状契约。

LLM LAB 3.9.5用可控输入验证 mask

构造两个 value 差异很大的 key,分别运行无 mask、因果 mask 和 padding mask。打印分数、权重和输出,验证被屏蔽位置的 value 不会影响结果;再测试整行被屏蔽时的行为。

被屏蔽位置的权重应接近零,替换其 value 不应改变输出。整行屏蔽必须有明确策略:跳过该 query、返回零向量或在上游修正 mask;不能接受 NaN 静默流入后续层。测试应注明 mask 形状与广播方向。

把数学实验升级为大模型实验协议

一个可信的小实验至少要有问题、假设、变量、基线、指标和停止条件。问题要具体到“改变温度是否改善校准”或“截断 rank=4 对召回损失多少”,而不是泛泛地问“哪个模型更好”。假设写出预期方向与可能反例;变量区分自变量、控制变量和结果变量;基线是能够独立复现的简单方案。

指标要和决策对应。检索实验需要 Recall@k、MRR、延迟和索引成本;生成实验除了 loss 或困惑度,还要检查事实性、格式遵循、拒答、安全和人工偏好;训练稳定性要看梯度、峰值显存、吞吐、checkpoint 恢复和多次种子的方差。指标越多,越要预先写清主指标、护栏指标和统计汇总方式,避免挑对自己有利的结果。

实验日志不是最后补的截图,而是数据管道的一部分。每次运行保存配置、代码版本、数据快照、tokenizer、环境、硬件、随机种子、耗时、异常和评估集哈希。随机实验至少运行多个种子并报告均值和离散程度;若预算只能跑一次,要在结论中明确不确定性,不把单次结果写成定律。

最终报告应能回答四个问题:改了什么、为什么改、证据是什么、还不知道什么。把失败实验和边界条件一起保留,后续学习者才能知道公式何时适用。对于第三方实现,应链接官方文档并注明版本;对于自己绘制的图表,保留生成数据与脚本,确保图不是不可追溯的装饰。

表 3.9H · 从数学直觉到模型实验的映射
数学概念模型组件最小实验关键证据
向量相似度Embedding 检索点积 vs 余弦Recall、范数、延迟
矩阵乘法线性层/QKV 投影形状与轴回归输出形状、手算样本
Softmax注意力/分类分布温度与 mask和为 1、无 NaN、校准
梯度反向传播/参数更新有限差分对照方向、范数、loss 变化
分布采样/不确定性多 seed 与分桶区间、校准、尾部风险
损失训练目标归约与标签消融有效分母、主指标、护栏
PYTHON · experiment_card.py
from dataclasses import dataclass, asdict
import json

@dataclass
class ExperimentCard:
    question: str
    hypothesis: str
    baseline: str
    primary_metric: str
    guardrails: tuple
    seed: int
    data_version: str

card = ExperimentCard(
    question="温度 0.8 是否改善校准?",
    hypothesis="降低温度会降低过度自信,但可能损失召回",
    baseline="原始 logits,温度 1.0",
    primary_metric="ECE",
    guardrails=("Recall@5", "P99 latency", "NaN count"),
    seed=7,
    data_version="eval-2026-08-01",
)
with open("experiment-card.json", "w", encoding="utf-8") as handle:
    json.dump(asdict(card), handle, ensure_ascii=False, indent=2)
print("card saved")
# 练习:加载 JSON,检查主指标和护栏是否都已填写

这张实验卡把数学对象、工程变量和决策证据放到同一个接口里。它不会自动保证结论正确,却能防止“跑完以后才想起基线、分母和随机种子”。完成本章后,应该能独立解释一个公式、写出一个最小实现、列出边界条件、设计一个对照实验,并用表格或图形把结果交代清楚。

LLM LAB 3.9.6提交一页可复现实验报告

选择本章任意一个主题完成实验卡:问题与假设、基线、控制变量、至少两个主/护栏指标、三次随机种子结果、一个失败案例、代码和数据版本。最后用 150 字回答“证据支持了什么,尚未支持什么”。

合格报告能够让别人重新运行并知道结论边界。不要只贴最终分数;应展示样本量、误差或区间、异常处理、版本与失败案例。若三次运行差异很大,结论应改为“结果不稳定,需要更多证据”,而不是挑一次最好成绩。