qinghuan
← 回到知识探索

AI 直觉 / 动手实验

每一步保留九成概率,整句话还保留九成吗?

穷举一棵两步概率树,把每步删词后的重新分配、原模型仍保留的整段机会,以及整段条件概率分别算清。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先说清九成筛选怎样做
  4. 九个完整结果,来自四张不同的概率表
  5. 筛选两次后,原来的机会只留下82.5%
  6. 实际逐步采样,每个节点分别重新分配
  7. 把完整结果统一筛选,会得到另一组数
  8. 运行完整账本,给每条路径保留三列
  9. 原论文定义的是每步规则
  10. 保存概率时,把它属于哪一步写进字段
  11. 记住这些要点
  12. 别把例子用过头
  13. 自己试一小步
  14. 查原始资料
开始前,会这些就够了
  • 知道概率之和为一
  • 会把先后两步的条件概率相乘
  • 能运行Python标准库脚本

把top_p设为0.9,常被理解成“保留九成可能性”。这个九成作用于当前一步的候选表;生成下一步时,模型会根据已经选出的前缀换一张表。经过两次筛选,原来全部完整结果中留下的概率,未必还有九成。

更细的一点是:每一步筛选后重新分配概率,通常也不等于先列出所有完整结果、删掉不合条件的结果,再统一分配。本篇不靠大量随机抽签估计,而是用精确分数穷举九条路径,分别给这三个问题留出一列。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • nucleus_paths.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B nucleus_paths.py

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。手工指定的两步概率树与精确分数枚举;不调用语言模型,也不评估文本正确性。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

每一步留下的机会,怎样变成九条完整路径?

第一步 A、B、C 的原概率为 60%、30%、10%。第二步在 A 后为 [90%, 6%, 4%],在 B 后为 [60%, 35%, 5%],在 C 后为 [50%, 30%, 20%];顺序都是 x、y、z。

仍可到达 3 / 9 条路径。

这些路径在原树中共占 82.50%;本题两步的通用下界为 81.00%。

第一步保留 A、B,原概率合计 90.00%。

  • A 后:保留 x,这一层原概率合计 90.00%。
  • B 后:保留 x、y,这一层原概率合计 95.00%。
  • C 后:第一步已经排除,不会到达这里。
先看保留哪些路径,再比较两种重新分配的方法
路径原树概率每步筛选再归一化整段只保留这些路径后归一化
Ax54.00%66.67%65.45%
Ay(排除)3.60%——
Az(排除)2.40%——
Bx18.00%21.05%21.82%
By10.50%12.28%12.73%
Bz(排除)1.50%——
Cx(排除)5.00%——
Cy(排除)3.00%——
Cz(排除)2.00%——

试 90%:第一步刚好保留 A 和 B。在 A 后只留 x,在 B 后留 x 和 y。留下的是同三条路径,两种重新分配的结果却不同;逐步采样会分别调整每个分支的份额。

固定两步概率树,没有提前终止或真实模型。每层按概率从高到低取到累计值大于或等于阈值;边界选择用整数百分数,概率显示使用 Number 并四舍五入。整段原概率的下界是本题固定两步的计算,不代表答案正确率;正文下载包用精确分数复核。

先说清九成筛选怎样做

本实验把候选按概率从大到小排序,逐个加入,直到累计概率大于或等于阈值p。留下的概率总和记为Z,保留项各自除以Z,删除项变成0。这通常叫核采样或top-p采样。因为候选不能拆成半个,Z可能超过p;阈值是最低保留份额,不是每次恰好留下多少。

我们约定概率平局时按词名的字母顺序选,累计恰好等于p就停止,只接受0<p≤1。这是本实验明确写出的边界规则。框架的排序、最少保留词数与浮点比较可能有自己的细节,不能拿本例平局顺序保证所有库都选同一个词。

例如概率为0.6、0.3、0.1时,阈值0.6只需保留第一项,阈值0.9需要前两项;只把阈值稍微提高到0.91,就得把第三项也留下。候选集合随阈值变化可以突然扩大,不能把阈值增加一点理解成只新增同样一点的实际保留质量。

九个完整结果,来自四张不同的概率表

第一步A、B、C的原概率分别为0.6、0.3、0.1。走到A后,第二步x、y、z的条件概率是0.9、0.06、0.04;走到B后变成0.6、0.35、0.05;走到C后是0.5、0.3、0.2。这些都是人为固定的数,不来自训练或接口。

整段结果由两个符号组成。例如Ax的原概率是0.6×0.9=0.54,Bx为0.3×0.6=0.18,By为0.3×0.35=0.105。其余六条同理。九条概率加起来为1,表示我们已经列完固定两步下的所有结果;本例没有提前结束标记,也没有最大长度之外的尾部。

筛选两次后,原来的机会只留下82.5%

阈值设为0.9。第一步保留A与B,累计质量为0.9;A之后只保留x,质量仍是0.9;B之后需要保留x和y,质量达到0.95。C已经在第一步被删除,虽能计算它的下一张表,却不再有通过C的输出路径。

因此保留的整段结果只有Ax、Bx、By。回到原始分布,把它们的原概率相加:0.54+0.18+0.105=0.825,也就是33/40。这个数回答的是“原模型全部两步输出中,有多少概率落在这些仍允许的路径上”,不能用某一节点的0.9直接替代。

固定两步且每个可达节点都保留至少p时,原分布留在允许路径上的质量至少为p²;本例下界是0.81,实际为0.825。B处多留了0.05,所以结果没有恰好等于0.81。更一般的固定T步可得p的T次方下界,但这不是每段文本必然丢掉某个固定比例的承诺。

下界可以只用加权求和理解:第一步留下的原质量至少p,而每条留下的第一步分支,下一步又至少保住其中p的份量。所有这些份量加起来至少是p乘p。这里没有要求两步相互独立;第二步已经明确按第一步选择了不同的条件概率表。

实际逐步采样,每个节点分别重新分配

第一步剩下A和B,除以0.9后变成2/3和1/3。A之后x变成1;B之后x、y分别为0.6/0.95=12/19和0.35/0.95=7/19。把这两步相乘,实际逐步采样输出Ax、Bx、By的概率依次为2/3、4/19、7/57,合计恰好为1。

注意1与0.825都没有错,它们属于两张分布。0.825是允许路径在原模型中的质量;1是采样器删去其他路径并重新归一化后的总质量。把删除后仍能输出的结果总和为1,当作原分布没有损失,就混淆了比较对象。

把完整结果统一筛选,会得到另一组数

假如做另一件事:先按原始概率生成整段,只有结果属于Ax、Bx、By才接纳,其他结果整段作废重来。那么这三条的概率应统一除以0.825,得到36/55、12/55、7/55。这就是原分布在“整段通过筛选”这个事件下的条件概率。

它与逐步采样不相等。原因能直接从分母看出:逐步采样的Ax除以0.9×0.9,B开头的两条除以0.9×0.95;整段条件化却对三条都除以同一个0.825。局部规则只用眼前的表,没有提前根据未来的可保留质量改写第一步。

也可以反过来看开头的比例。逐步方法第一步选择A的机会固定为2/3;整段通过筛选后,A开头的比例却是36/55。后者已经把“从B出发更容易通过第二关”这条信息算进来了,因此B的相对份量略有增加。两种方法的区别不只出现在最后一个词,连已保留的前缀比例也会变化。

这不说明top-p实现出了错,它原本就定义了一个逐步采样过程。若不同前缀的后续保留质量相同,两种过程在某些输入上可以一致;p=1且不再附加其他筛选时,本例也恢复原分布。因此文章展示的是一般不能等同,不是断言它们永远不同。

运行完整账本,给每条路径保留三列

脚本为0.6、0.9、0.95、1四个阈值各枚举九条路径,输出36行CSV。original列是原概率,local是逐步筛选采样概率,conditioned_whole_sequence是整段条件化概率;kept表示路径是否通过两步规则。所有数都用Fraction保存,因此可以逐项精确比较。

JSON保存原始树、各节点保留集合和归一化前的质量。核对时先从输入表重算路径乘积,再按kept汇总原质量,最后独立重算两种归一化结果。程序还检查累计恰好等于阈值、平局、零概率和阈值为1;非法阈值会明确拒绝。

展开收起代码 · 小实验python · 104 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Enumerate a two-step probability tree exactly; no random draws or model calls."""
import csv
import json
from fractions import Fraction as F
from pathlib import Path

TREE = {"root": {"A": "3/5", "B": "3/10", "C": "1/10"},
        "A": {"x": "9/10", "y": "3/50", "z": "1/25"},
        "B": {"x": "3/5", "y": "7/20", "z": "1/20"},
        "C": {"x": "1/2", "y": "3/10", "z": "1/5"}}


def parse(values):
    result = {k: F(v) for k, v in values.items()}
    if not result or any(v < 0 for v in result.values()) or sum(result.values()) != 1:
        raise ValueError("nonnegative probabilities summing to one required")
    return result


def nucleus(probabilities, threshold):
    if not 0 < threshold <= 1:
        raise ValueError("threshold must be in (0,1]")
    if not probabilities or any(v < 0 for v in probabilities.values()) or sum(probabilities.values()) != 1:
        raise ValueError("invalid distribution")
    ordered = sorted(probabilities, key=lambda key: (-probabilities[key], key))
    kept, mass = [], F(0)
    for token in ordered:
        kept.append(token)
        mass += probabilities[token]
        if mass >= threshold:  # Equality stops; tied probabilities use lexical token order.
            break
    normalized = {token: probabilities[token] / mass if token in kept else F(0)
                  for token in probabilities}
    assert sum(normalized.values()) == 1
    return kept, mass, normalized


def enumerate_tree(threshold):
    original = {name: parse(values) for name, values in TREE.items()}
    filtered = {name: nucleus(values, threshold) for name, values in original.items()}
    paths = []
    for first, p_first in original["root"].items():
        for second, p_second in original[first].items():
            kept = first in filtered["root"][0] and second in filtered[first][0]
            paths.append({"threshold": str(threshold), "path": first + second,
                          "first": first, "second": second, "kept": kept,
                          "original": p_first * p_second,
                          "local": filtered["root"][2][first] * filtered[first][2][second],
                          "root_mass": filtered["root"][1], "child_mass": filtered[first][1]})
    event_mass = sum((row["original"] for row in paths if row["kept"]), F(0))
    for row in paths:
        row["conditioned_whole_sequence"] = row["original"] / event_mass if row["kept"] else F(0)
    assert sum(row["original"] for row in paths) == 1
    assert sum(row["local"] for row in paths) == 1
    assert sum(row["conditioned_whole_sequence"] for row in paths) == 1
    assert event_mass >= threshold ** 2
    summary = {"threshold": str(threshold), "original_mass_of_kept_paths": str(event_mass),
               "two_step_lower_bound": str(threshold ** 2),
               "nodes": {node: {"kept": kept, "mass_before_rescaling": str(mass),
                                  "after_rescaling": {k: str(v) for k, v in probabilities.items()}}
                         for node, (kept, mass, probabilities) in filtered.items()},
               "local_equals_whole_conditioning": all(row["local"] == row["conditioned_whole_sequence"] for row in paths)}
    return summary, paths


def main():
    summaries, all_paths = [], []
    for threshold in (F(3, 5), F(9, 10), F(19, 20), F(1)):
        summary, paths = enumerate_tree(threshold)
        summaries.append(summary)
        all_paths.extend(paths)
    main_paths = {r["path"]: r for r in all_paths if r["threshold"] == "9/10"}
    assert summaries[1]["original_mass_of_kept_paths"] == "33/40"
    assert {k for k, r in main_paths.items() if r["kept"]} == {"Ax", "Bx", "By"}
    assert [main_paths[k]["original"] for k in ("Ax", "Bx", "By")] == [F(27, 50), F(9, 50), F(21, 200)]
    assert [main_paths[k]["local"] for k in ("Ax", "Bx", "By")] == [F(2, 3), F(4, 19), F(7, 57)]
    assert [main_paths[k]["conditioned_whole_sequence"] for k in ("Ax", "Bx", "By")] == [F(36, 55), F(12, 55), F(7, 55)]
    assert not summaries[1]["local_equals_whole_conditioning"]
    assert summaries[-1]["original_mass_of_kept_paths"] == "1"
    assert summaries[-1]["local_equals_whole_conditioning"]
    assert nucleus(parse({"A": "1/2", "B": "1/2"}), F(1, 2))[0] == ["A"]
    assert nucleus(parse({"A": "1", "B": "0"}), F(1))[0] == ["A"]
    for invalid in (F(0), F(-1), F(2)):
        try:
            nucleus(parse(TREE["root"]), invalid)
        except ValueError:
            pass
        else:
            raise AssertionError("invalid threshold accepted")
    Path("nucleus-inputs.json").write_text(json.dumps(TREE, indent=2) + "\n", encoding="utf-8")
    Path("nucleus-report.json").write_text(json.dumps({
        "scope": "Exact fixed-length two-step finite tree; no EOS, temperature, random draws or model calls.",
        "rule": "descending probability; lexical token tie-break; stop at cumulative mass >= threshold",
        "cases": summaries}, indent=2) + "\n", encoding="utf-8")
    with Path("nucleus-paths.csv").open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=list(all_paths[0]))
        writer.writeheader()
        writer.writerows(all_paths)
    print(json.dumps({"status": "PASS", "thresholds": 4, "path_rows": len(all_paths),
                      "main_retained_original_mass": "33/40", "main_local_equals_conditioned": False}))


if __name__ == "__main__":
    main()

怎样运行:Python 3.10+标准库;保存为nucleus_paths.py,在空练习目录运行 python nucleus_paths.py。不要加-O,验收使用assert。 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。手工指定的两步概率树与精确分数枚举;不调用语言模型,也不评估文本正确性。

运行后对照结果
{"status": "PASS", "thresholds": 4, "path_rows": 36, "main_retained_original_mass": "33/40", "main_local_equals_conditioned": false}

原论文定义的是每步规则

Holtzman等人的原论文第3.1节用当前前缀下的概率定义候选集合,再以保留质量重新归一化;本文两步账本是从这个定义推导的原创有限例子。它不复现论文的语言模型、人工评价或文本质量结果,也不从82.5%推导答题正确率。

Transformers v4.57.1的TopPLogitsWarper按排序后的累计概率遮掉尾部,并保留最低词数;生成文档区分采样与贪心。我们核读固定版本的规则,但没有调用Transformers。本例从已给出的概率开始,不涉及温度、重复惩罚、logits数值稳定性或其他处理器的先后顺序。

保存概率时,把它属于哪一步写进字段

如果报告同时保存模型概率与采样器概率,字段应明确是否经过筛选、在哪个前缀下、使用什么规则。单独一列probability容易让人把原分布概率与处理后的抽样概率相乘,最后得到一份看似精确却没有统一含义的结果。

这个实验的交付是路径表和可复核的解释。真实语言生成还涉及可变长度、结束标记和庞大词表;无法枚举时可以测频率,但要记录随机过程与误差。本例没有随机模拟,更没有声称某个top-p值能普遍提高质量。

检查CSV时,被删除的六条路径仍然保留着原概率,只是另外两列变成零。保留这些行很有用:你能说明概率质量被移除了多少、从哪些路径移除,而不是只看到一张已经加到一的新表。概率解释要保留比较前的对象,才能追查筛选带来的变化。

想接着往下学,可以阅读解码器与语言模型路线。把训练中的条件概率与推理时选择候选的规则分开核对。

记住这些要点

  • top-p约束当前一步保留的概率质量,保留量可以超过阈值。
  • 两步后允许路径在原分布中的总质量未必仍是p。
  • 逐节点归一化通常不同于整段统一条件化。
  • 模型原概率、采样概率和任务正确率需要分别记录。

别把例子用过头

  • 把每一步至少九成说成整段至少九成。
  • 删除后概率重新加到1,就声称原概率没有被改变。
  • 把局部归一化的多个分母当作统一常数。
  • 没有约定平局与阈值等号边界,就比较不同实现。

自己试一小步

换成三步树后,你能说明两种归一化何时相同、何时不同吗?

  1. 自己设计每步两个候选的三步概率树,使用精确分数并固定平局规则。
  2. 穷举八条路径,分别导出原概率、逐步采样概率与整段条件化概率。
  3. 另外设计一组两种分布恰好一致的输入,并说明使它成立的条件。
  4. 提交输入树、CSV、独立核对程序和推理;不要用一次随机抽样代替概率证明。

做到这些,就算完成

  • 每个节点的原分布与筛选后分布都各自归一。
  • 保留事件从原路径表重建,不能直接抄主程序总数。
  • 结论限定在固定长度树和已声明的筛选规则。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。