# 向量、概率与评测指标：够用的数学直觉

## 用途、程度与前置

做 AI 应用不必先修完高等数学，但需要知道向量检索返回的分数代表什么、分类阈值为何影响业务，以及一份评测报告能支持什么结论。本章适用于 RAG 检索、相似内容去重、自动分类与模型方案对比。L1 要求是建立直觉并能手算小例子，不要求推导 Transformer 或反向传播。

前置是列表、循环、简单比例与上一章分类指标。示例使用 Python 3.12 标准库，所有数据都是人工构造，无需模型、GPU 或外部数据。二维向量便于看懂计算；真实 embedding 的维度更高，但计算契约相同。

## 向量可以理解成一组有顺序的特征值

前端页面坐标 [x, y] 已经是向量。embedding 则把文本映射成有顺序的数值数组。每一维未必对应一个能用中文解释的标签，但整体位置用于表达模型学到的关系。两个向量只有处在相同表示空间时比较才有意义；不同模型即使维度一样，也不能默认直接混用。

点积是对应位置相乘再求和。它既受到方向影响，也受到长度影响。向量范数可以理解为长度；把点积除以两者长度得到余弦相似度，重点比较方向。非零实向量的余弦位于 -1 到 1，越大通常表示方向越近；但“语义更相关”是否真的成立，需要在业务数据上验证。[余弦相似度 API](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.cosine_similarity.html)

把向量乘以正数不会改变余弦相似度，所以它适合减少向量长度的影响。若向量已做单位长度归一化，点积和余弦数值一致。零向量没有可用方向，公式分母为零，应该明确拒绝或采用系统约定，不能默默产生 NaN 继续排序。

数据库返回的分数未必就是余弦相似度，有的返回距离，越小越近；有的会做额外变换。接入向量数据库时先读 metric 的定义，再决定排序方向。更换 embedding 模型、分块方法或索引参数后，原来的阈值不一定有效，需要重新评测。不要把 0.8 解释成“答案有八成正确”。

## 概率不是一句自信的话

概率是对事件发生的不确定性进行数值描述；模型输出“我很确定”只是文本，不能当作经过校准的概率。分类模型的分数也不一定已校准：在被标为 0.8 的样本中，若实际只有一半正确，它的置信度就过高。

softmax 把一组实数变成非负且和为一的权重，但数学上归一化并不自动带来可靠性。生成模型每一步 token 的概率也不等于整段回答的事实正确率。工程上应把“置信分数”“人工可信度评价”“是否通过业务校验”分开命名。

先验分布会影响观察结果。罕见事件即使检测器看起来不错，也可能产生大量误报。例子：一千条请求只有十条真正需要人工干预，若普通请求里误报了二十条、真正找回八条，那么被转人工的二十八条中只有八条是目标事件。此时召回并不差，精确率却低，客服负担可能很大。

阈值决定何时采取行动。降低阈值通常找回更多正例，也可能引入更多误报；提高阈值通常减少误报，也可能漏掉正例。用验证集选择阈值，并结合误报与漏报的业务代价；不要直接在测试集挑一个最高分，再把这个最高分当独立结果。

## 指标的分母和聚合方式

Accuracy 的分母是全部样本；Precision 的分母是预测正类；Recall 的分母是真实正类。F1 让精确率和召回率都影响结果，但它不反映真负例，也不自动包含每类错误的业务代价。退款、风险拦截和内容推荐可能需要不同目标。

多分类的宏平均先分别算每类指标再平均，让少数类获得同等权重；微平均先合并计数，受总体分布影响更大。报告必须写清平均方式与零分母约定。检索任务中的 recall@k 关注前 k 条是否找回相关内容，与二分类 recall 的使用场景不同，不要仅因名字相似就混用。

平均延迟、百分位和比例也需要样本量。五条数据中的一个慢请求会大幅改变 P95；一个类别只有一条数据时，从错到对会让指标从零变成百分之百。这些结果都是真实计算，却不能支撑很强的总体结论。

## 完整示例：算相似度，再比较分类阈值

保存为 math_demo.py，只需 Python，无安装步骤。

```python
# math_demo.py
import math

def cosine(a: list[float], b: list[float]) -> float:
    if not a or len(a) != len(b):
        raise ValueError("向量必须非空且维度相同")
    if any(not math.isfinite(x) for x in a + b):
        raise ValueError("向量不能含 NaN 或无穷值")
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(x * x for x in b))
    if na == 0 or nb == 0:
        raise ValueError("零向量没有可比较方向")
    return sum(x * y for x, y in zip(a, b)) / (na * nb)

def metrics(scores: list[float], gold: list[int], threshold: float) -> dict:
    if len(scores) != len(gold) or not scores:
        raise ValueError("分数与标签数量不一致或为空")
    if not 0 <= threshold <= 1:
        raise ValueError("阈值必须在 0 到 1 之间")
    if any(not 0 <= x <= 1 for x in scores) or any(x not in (0, 1) for x in gold):
        raise ValueError("分数或标签无效")
    pred = [int(score >= threshold) for score in scores]
    tp = sum(p == 1 and y == 1 for p, y in zip(pred, gold))
    fp = sum(p == 1 and y == 0 for p, y in zip(pred, gold))
    fn = sum(p == 0 and y == 1 for p, y in zip(pred, gold))
    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
    return {"precision": round(precision, 3), "recall": round(recall, 3), "f1": round(f1, 3)}

def main() -> None:
    query = [1.0, 0.0]
    documents = {"A": [0.8, 0.6], "B": [0.0, 1.0]}
    ranking = sorted(
        ((name, cosine(query, vector)) for name, vector in documents.items()),
        key=lambda item: item[1], reverse=True
    )
    print("相似度排序：", [(name, round(score, 3)) for name, score in ranking])
    scores, gold = [0.9, 0.8, 0.6, 0.2], [1, 0, 1, 0]
    for threshold in (0.5, 0.85):
        print("阈值", threshold, metrics(scores, gold, threshold))

if __name__ == "__main__":
    main()
```

```bash
python math_demo.py
```

预期相似度 A 为 0.8、B 为 0.0。阈值 0.5 时 precision 为 0.667、recall 为 1.0、F1 为 0.8；阈值 0.85 时 precision 为 1.0、recall 为 0.5、F1 为 0.667。两种阈值谁更好取决于业务损失，而不只取决于哪个 F1 更大。

逐段解析：cosine 先检查维度和有限值，再算长度和点积，体现数学公式也有输入合同；sorting 的 reverse=True 对应相似度越大越近。metrics 用同一个 >= 规则离散化分数，等于阈值时算正类；取整比较把布尔条件转成计数；最后只在展示时 round，避免中间舍入影响指标。

注意本例分数是人工输入，并非真实校准概率。math.isfinite 是运行时防线，防止 NaN 在排序中引入难以理解的结果。大规模向量计算通常使用成熟库，以获得稳定数值实现和速度；这里的公式服务于理解，不作为高性能向量引擎。

## 常见错误与排障

检索结果方向完全反了，先确认拿到的是 distance 还是 similarity。维度相同但效果突然下降，核对模型、归一化与索引版本。分数都很接近时，可能是任务本身难、分块缺少上下文或模型不适合；不要直接把阈值改低后当质量改善。

分类指标异常，先手工列出 TP、FP、FN，检查正类定义和分母。线上精确率下降而离线没变，检查真实类别比例和输入分布是否漂移。模型更自信不表示更可靠，需要按预测分数分组查看真实正确率。

## 用几何直觉理解检索，而不是把向量当神秘分数

二维坐标可以画在纸上，三维坐标可以想象为空间位置，更高维向量仍然遵循相同的乘加与长度运算。你不需要想象每一维的视觉方向，仍可以理解“距离近”“方向接近”和“投影”这些关系。工程上最重要的是保证数组的顺序、维度与生成方式一致。

embedding 是由模型把输入映射到某个空间。相近文本可能在这个空间中接近，但“接近”取决于训练目标：适合语义检索的表示未必适合判定法律条款是否互相矛盾，也未必适合比较代码运行行为。模型选型应围绕实际相关性标签，而不是只看一个通用榜单的分数。

点积把每个维度的共同方向累加，较长向量可能取得更大点积。余弦通过长度归一化消除这种尺度影响；欧氏距离衡量位置差异，单位向量上它与余弦有明确关系，但未归一化时不能随意互换。数据库配置采用哪种度量、SDK 返回的是距离还是分数，都必须在索引与查询两侧一致。

向量归一化改变的是表示尺度，不会让不相关内容变成相关，也不会自动清除模型偏差。零向量、极小范数和非有限数值都需要处理。原示例直接求平方和适合小型教学数值，面对极端大数可能溢出；生产数值库通常有更稳定实现。理解公式不等于应该手写一个通用向量引擎。

高维空间还有一个容易误解的现象：很多候选之间分数差异可能很小。第一名比第二名高一点，不一定代表明显更可靠。可以评估候选间的分数间隔、相关性标注和后续重排，而不是把 top1 当成确定答案。检索选出的文档仍可能过期或无权限，相似度只参与排序，不承担事实和权限判断。

## 从检索指标走到答案指标

recall@k 表示前 k 个结果找回相关内容的能力，但相关内容的定义需要标注合同。一个问题可能有多个相关段落，只找回其中一个关键段落就足以回答；另一个问题需要同时找到两条相互补充的政策。任务不同，召回指标的解释也不同。

precision@k 关注返回候选里有多少相关内容。增大 k 通常能找回更多材料，却也带来噪声、上下文长度和成本。检索 recall 提高不保证最终答案更好，模型可能被无关材料干扰。应分别测检索层与答案层，才能判断是材料没找到、材料太多还是生成阶段没有正确使用。

排序指标还关注相关结果是否排在前面。用户只看前三条时，相关内容排在第二十条的价值有限；模型上下文预算只容纳少量段落时也是如此。选择指标应对应真实消费方式，不是把所有常见缩写都放进报告。先明确使用前几条、是否重排和如何截断，再解释数值。

在小型项目中，可以先人工标出每个查询需要的文档 id，计算是否在前 k 条中出现。随后记录最终回答是否引用这些文档及是否正确。这个简单分层能显著改善排障：没有召回时改检索；已经召回却答错时改提示、证据组织或模型；材料本身错误时修数据源。

## 条件概率和基础发生率如何改变决策

“模型预测危险”与“真实危险”是不同事件。Precision 可以理解为在预测为正的条件下，真实为正的比例；Recall 则是在真实为正的条件下，被预测为正的比例。它们的条件方向不同，因此不能互相替换。检测器召回高，不代表每个警报都可信。

基础发生率说明目标事件在真实人群或流量中有多常见。离线数据为了覆盖风险可能放了一半正例，线上正例却只有很小比例。同样的误报行为在大量负例中会产生许多警报，使线上精确率下降。模型未必突然变差，也可能是评测分布没有代表真实应用。

可以手算一个例子：一万条里一百条需要转人工，系统找回九十条，同时把九千九百条普通请求中的九十九条误报。召回是九成，但一百八十九条警报中只有九十条是真的，精确率不到一半。若人工处理每条需要时间，误报成本会成为实际瓶颈。

这不意味着应该一味提高阈值。漏掉一个高风险事件可能比处理十条误报更严重，合理决策取决于成本和承受能力。把阈值选择写成业务规则，说明误报与漏报的代价，再在验证集检查，是比“选择 F1 最高的数字”更完整的工程做法。

## 第二个完整例子：同一分数，不同业务代价

保存为 decision_cost.py，Python 3.12，仅标准库。分数与标签为教学数据，成本单位也是自定义单位，不代表经济或安全领域的通用定价。

```python
# decision_cost.py
SCORES = [0.9, 0.8, 0.6, 0.2]
GOLD = [1, 0, 1, 0]

def evaluate(threshold, false_positive_cost=1, false_negative_cost=10):
    if not 0 <= threshold <= 1:
        raise ValueError("阈值范围错误")
    if false_positive_cost < 0 or false_negative_cost < 0:
        raise ValueError("成本不能为负")
    predicted = [int(value >= threshold) for value in SCORES]
    fp = sum(p == 1 and y == 0 for p, y in zip(predicted, GOLD))
    fn = sum(p == 0 and y == 1 for p, y in zip(predicted, GOLD))
    return {
        "threshold": threshold, "false_positives": fp, "false_negatives": fn,
        "cost": fp * false_positive_cost + fn * false_negative_cost
    }

for threshold in (0.5, 0.85):
    print(evaluate(threshold))
```

执行 python decision_cost.py，阈值零点五只有一个误报，成本为一；阈值零点八五漏掉一个正例，成本为十。若把误报成本改成二十，排序可能反转。阈值没有脱离业务代价的“绝对最佳值”，而且这些代价应由业务约束确定，不应为了证明某个模型优越而临时调整。

程序故意把成本参数显式暴露，展示决策假设。真实任务还可能有人工队列容量、不同类型正例的严重性和延迟影响，可以在此基础上增加约束。先保证基础计数正确，再引入复杂权重，避免把错误公式包在更复杂的指标里。

## 校准：分数像概率，还需要证据

一个模型把十条请求都打成八成把握，若其中确实约八条正确，才有初步校准意义。单条样本无法验证“八成”这个概率，需要观察一组具有相似预测分数的案例。模型可以排序很好但概率偏高，也可以概率相对合理但排序能力不足，这两种能力要分开看。

温度缩放、校准模型等方法可以调整分数，但必须在独立验证数据上拟合，不能用最终测试标签调完再报告效果。校准也可能随数据分布变化而失效。线上用户类型、任务或模型版本变化后，需要重新检查，而不是把曾经校准过当作永久保证。

Brier score 是预测概率与真实零一标签的平方差平均值，越低通常表示此项概率预测误差更小。它能惩罚自信但错误的预测，但单一数值仍不能替代分组查看、排序指标和业务成本。概率是否有意义首先取决于你评估的事件定义，例如“分类为退款”与“整段回答无事实错误”是不同事件。

## 第三个完整例子：检查分数段的实际命中率

保存为 calibration_demo.py，Python 3.12，仅标准库。它把教学分数分为三个区间，分别比较平均预测值与实际正例比例，并计算 Brier score。

```python
# calibration_demo.py
import math

pairs = [(0.9, 1), (0.8, 0), (0.7, 1), (0.6, 0), (0.2, 0), (0.1, 0)]
for probability, label in pairs:
    if not math.isfinite(probability) or not 0 <= probability <= 1:
        raise ValueError("概率无效")
    if label not in (0, 1):
        raise ValueError("标签无效")
brier = sum((p - y) ** 2 for p, y in pairs) / len(pairs)
print("Brier", round(brier, 4))
for lower, upper in ((0.0, 0.5), (0.5, 0.8), (0.8, 1.01)):
    selected = [(p, y) for p, y in pairs if lower <= p < upper]
    if not selected:
        print("空区间", lower, upper)
        continue
    mean_prediction = sum(p for p, _ in selected) / len(selected)
    actual_rate = sum(y for _, y in selected) / len(selected)
    print(lower, upper, "样本", len(selected),
          "平均预测", round(mean_prediction, 2), "实际比例", actual_rate)
```

执行 python calibration_demo.py，Brier 约为零点一九一七。高分区间平均预测零点八五，实际正例比例只有零点五，显示这六条样本中的高分判断过于乐观。但每组只有两条，不能据此推断模型总体校准状况；需要更大且代表性的独立集合。

区间边界采用左闭右开，最后上限略大于一只为包含概率恰好为一的样本。真实实现可以单独处理最后闭区间，关键是保证每条记录恰好进入一组。空区间不应伪造为零命中率，因为没有样本与观察到全错是不同情况。

## 平均值、百分位与不确定性

平均值适合描述总体水平，但会被极端值影响。中位数告诉你一半样本在它两侧；高百分位关注尾部。两种方案平均延迟相同，一种每次都稳定，另一种多数很快却少量极慢，用户体验可能不同。报告首字延迟与总耗时的分布，比只给一个平均数更完整。

百分位的计算存在不同插值约定，小样本时差异尤其明显。使用 Python statistics.quantiles 等 API 时应记录方法和版本，不要混用来自不同系统的 P95 结果后追究微小差距。重要的是用一致口径观察真实变化，而不是认为某个小数必然代表唯一数学事实。[statistics 接口](https://docs.python.org/3/library/statistics.html)

置信区间表达的是基于样本与方法对估计不确定性的描述，不是“真实值有某个随意概率落在这里”的万能说法。相关样本、选择性采样和反复挑选最优方案都会影响解释。对于应用工程师，先保证独立采样、固定测试与透明样本量，再在需要时采用合适统计方法，比机械添加误差线更重要。

## 分数相同不代表两个系统的行为相同

两套分类器都取得九成准确率，可能错在完全不同的十条案例上。一套漏掉高风险退款，另一套误判普通咨询，业务影响并不相同。总体指标压缩了大量信息，因此比较时还需要混淆矩阵、错误列表和按类别切片。数字适合概览，具体案例帮助解释原因。保存逐条预测及稳定案例编号，才能在之后更换指标时复算，并检查改善是否覆盖了真正重要的用户任务。

同样，向量相似度相同不代表两个文档可以互换。一个文档可能描述旧政策，另一个描述新政策；语义都很接近，却只有一个适用于当前订单。时间、权限、来源和实体约束应在检索系统中显式处理，而不能期待距离函数代替这些条件。

当两个方案分差很小时，合理结论可能是“当前数据不足以区分”，而不是必须宣布赢家。此时可以比较成本、延迟、可维护性，或收集更有区分力的新样本。把不确定性如实写入结论是一种工程判断，不代表评测失败；反而能避免团队围绕随机波动做昂贵迁移。

## 综合练习：选择阈值并保留验证边界

练习是在 decision_cost.py 中扫描多个候选阈值，选择成本最小的一项，同时设置“漏报最多一个”的约束。选择只在验证集进行；最终测试集用来报告选定阈值，不再次调节。

<details><summary>完整参考实现</summary>

下面是对本章 evaluate 的使用片段，应追加到 decision_cost.py。它明确平局时优先较高阈值，这只是教学选择，需要按业务解释。由于本例只有四条数据，结果只证明算法，不证明真实阈值有效。

```python
candidates = [evaluate(value / 100) for value in range(10, 96, 5)]
eligible = [item for item in candidates if item["false_negatives"] <= 1]
if not eligible:
    raise RuntimeError("没有满足漏报约束的阈值")
best = min(eligible, key=lambda item: (item["cost"], -item["threshold"]))
print("验证集选择", best)
```

先手算阈值零点六附近的预测，再对照程序。若增加测试数据，不要把测试标签放进 candidates 选择过程。可以将阈值、选择数据版本、成本参数与约束一起保存，作为发布配置的一部分。

</details>


## 练习、提示与参考解答

练习：把 A 乘以十，验证余弦不变；输入零向量应失败；将阈值调整为 0.8，说明等于阈值的第二条为什么进入正类。

提示：不要只读输出，手算点积和长度；阈值比较符号是 API 语义的一部分。

<details><summary>参考答案</summary>

A 变为 [8, 6] 后长度变为 10，点积变为 8，余弦仍为 0.8。零向量触发 ValueError。阈值 0.8 时，0.9 和 0.8 预测正类：TP 为 1、FP 为 1、FN 为 1，precision、recall、F1 都为 0.5。改变比较符号会改变边界结果，应写入验收案例。

</details>

## 可验证验收与自测

验收数值输出、零向量、维度不符、非有限值和阈值边界。能解释相似度不是概率，能根据误报与漏报代价选择阈值方向，并说明小样本结果的局限。

1. **余弦 0.8 等于正确概率百分之八十吗？** 不等于，它衡量表示空间中的方向关系。
2. **为什么提高阈值可能降低召回？** 一部分真实正例分数低于新阈值，会变成漏报。
3. **F1 最高就是业务最优吗？** 未必，业务错误代价、预算和人工负担可能需要其他目标或约束。