向量、概率与评测指标:够用的数学直觉
理解相似度、阈值、概率与分类指标的工程意义,能够计算、解释并识别常见误用。
建议先读:给前端的 Python 入门:从数据脚本开始AI 评测与回归:让每次改动都有证据
本页内容
用途、程度与前置#
做 AI 应用不必先修完高等数学,但需要知道向量检索返回的分数代表什么、分类阈值为何影响业务,以及一份评测报告能支持什么结论。本章适用于 RAG 检索、相似内容去重、自动分类与模型方案对比。L1 要求是建立直觉并能手算小例子,不要求推导 Transformer 或反向传播。
前置是列表、循环、简单比例与上一章分类指标。示例使用 Python 3.12 标准库,所有数据都是人工构造,无需模型、GPU 或外部数据。二维向量便于看懂计算;真实 embedding 的维度更高,但计算契约相同。
向量可以理解成一组有顺序的特征值#
前端页面坐标 [x, y] 已经是向量。embedding 则把文本映射成有顺序的数值数组。每一维未必对应一个能用中文解释的标签,但整体位置用于表达模型学到的关系。两个向量只有处在相同表示空间时比较才有意义;不同模型即使维度一样,也不能默认直接混用。
点积是对应位置相乘再求和。它既受到方向影响,也受到长度影响。向量范数可以理解为长度;把点积除以两者长度得到余弦相似度,重点比较方向。非零实向量的余弦位于 -1 到 1,越大通常表示方向越近;但“语义更相关”是否真的成立,需要在业务数据上验证。余弦相似度 API
把向量乘以正数不会改变余弦相似度,所以它适合减少向量长度的影响。若向量已做单位长度归一化,点积和余弦数值一致。零向量没有可用方向,公式分母为零,应该明确拒绝或采用系统约定,不能默默产生 NaN 继续排序。
数据库返回的分数未必就是余弦相似度,有的返回距离,越小越近;有的会做额外变换。接入向量数据库时先读 metric 的定义,再决定排序方向。更换 embedding 模型、分块方法或索引参数后,原来的阈值不一定有效,需要重新评测。不要把 0.8 解释成“答案有八成正确”。
概率不是一句自信的话#
概率是对事件发生的不确定性进行数值描述;模型输出“我很确定”只是文本,不能当作经过校准的概率。分类模型的分数也不一定已校准:在被标为 0.8 的样本中,若实际只有一半正确,它的置信度就过高。
softmax 把一组实数变成非负且和为一的权重,但数学上归一化并不自动带来可靠性。生成模型每一步 token 的概率也不等于整段回答的事实正确率。工程上应把“置信分数”“人工可信度评价”“是否通过业务校验”分开命名。
先验分布会影响观察结果。罕见事件即使检测器看起来不错,也可能产生大量误报。例子:一千条请求只有十条真正需要人工干预,若普通请求里误报了二十条、真正找回八条,那么被转人工的二十八条中只有八条是目标事件。此时召回并不差,精确率却低,客服负担可能很大。
阈值决定何时采取行动。降低阈值通常找回更多正例,也可能引入更多误报;提高阈值通常减少误报,也可能漏掉正例。用验证集选择阈值,并结合误报与漏报的业务代价;不要直接在测试集挑一个最高分,再把这个最高分当独立结果。
指标的分母和聚合方式#
Accuracy 的分母是全部样本;Precision 的分母是预测正类;Recall 的分母是真实正类。F1 让精确率和召回率都影响结果,但它不反映真负例,也不自动包含每类错误的业务代价。退款、风险拦截和内容推荐可能需要不同目标。
多分类的宏平均先分别算每类指标再平均,让少数类获得同等权重;微平均先合并计数,受总体分布影响更大。报告必须写清平均方式与零分母约定。检索任务中的 recall@k 关注前 k 条是否找回相关内容,与二分类 recall 的使用场景不同,不要仅因名字相似就混用。
平均延迟、百分位和比例也需要样本量。五条数据中的一个慢请求会大幅改变 P95;一个类别只有一条数据时,从错到对会让指标从零变成百分之百。这些结果都是真实计算,却不能支撑很强的总体结论。
完整示例:算相似度,再比较分类阈值#
保存为 math_demo.py,只需 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()
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,仅标准库。分数与标签为教学数据,成本单位也是自定义单位,不代表经济或安全领域的通用定价。
# 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。
# 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 接口
置信区间表达的是基于样本与方法对估计不确定性的描述,不是“真实值有某个随意概率落在这里”的万能说法。相关样本、选择性采样和反复挑选最优方案都会影响解释。对于应用工程师,先保证独立采样、固定测试与透明样本量,再在需要时采用合适统计方法,比机械添加误差线更重要。
分数相同不代表两个系统的行为相同#
两套分类器都取得九成准确率,可能错在完全不同的十条案例上。一套漏掉高风险退款,另一套误判普通咨询,业务影响并不相同。总体指标压缩了大量信息,因此比较时还需要混淆矩阵、错误列表和按类别切片。数字适合概览,具体案例帮助解释原因。保存逐条预测及稳定案例编号,才能在之后更换指标时复算,并检查改善是否覆盖了真正重要的用户任务。
同样,向量相似度相同不代表两个文档可以互换。一个文档可能描述旧政策,另一个描述新政策;语义都很接近,却只有一个适用于当前订单。时间、权限、来源和实体约束应在检索系统中显式处理,而不能期待距离函数代替这些条件。
当两个方案分差很小时,合理结论可能是“当前数据不足以区分”,而不是必须宣布赢家。此时可以比较成本、延迟、可维护性,或收集更有区分力的新样本。把不确定性如实写入结论是一种工程判断,不代表评测失败;反而能避免团队围绕随机波动做昂贵迁移。
综合练习:选择阈值并保留验证边界#
练习是在 decision_cost.py 中扫描多个候选阈值,选择成本最小的一项,同时设置“漏报最多一个”的约束。选择只在验证集进行;最终测试集用来报告选定阈值,不再次调节。
完整参考实现
下面是对本章 evaluate 的使用片段,应追加到 decision_cost.py。它明确平局时优先较高阈值,这只是教学选择,需要按业务解释。由于本例只有四条数据,结果只证明算法,不证明真实阈值有效。
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 选择过程。可以将阈值、选择数据版本、成本参数与约束一起保存,作为发布配置的一部分。
练习、提示与参考解答#
练习:把 A 乘以十,验证余弦不变;输入零向量应失败;将阈值调整为 0.8,说明等于阈值的第二条为什么进入正类。
提示:不要只读输出,手算点积和长度;阈值比较符号是 API 语义的一部分。
参考答案
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。改变比较符号会改变边界结果,应写入验收案例。
可验证验收与自测#
验收数值输出、零向量、维度不符、非有限值和阈值边界。能解释相似度不是概率,能根据误报与漏报代价选择阈值方向,并说明小样本结果的局限。
- 余弦 0.8 等于正确概率百分之八十吗? 不等于,它衡量表示空间中的方向关系。
- 为什么提高阈值可能降低召回? 一部分真实正例分数低于新阈值,会变成漏报。
- F1 最高就是业务最优吗? 未必,业务错误代价、预算和人工负担可能需要其他目标或约束。