开始前,会这些就够了
- 知道概率之和为一
- 会把先后两步的条件概率相乘
- 能运行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 后:第一步已经排除,不会到达这里。
| 路径 | 原树概率 | 每步筛选再归一化 | 整段只保留这些路径后归一化 |
|---|---|---|---|
| Ax | 54.00% | 66.67% | 65.45% |
| Ay(排除) | 3.60% | — | — |
| Az(排除) | 2.40% | — | — |
| Bx | 18.00% | 21.05% | 21.82% |
| By | 10.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;非法阈值会明确拒绝。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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,就声称原概率没有被改变。
- 把局部归一化的多个分母当作统一常数。
- 没有约定平局与阈值等号边界,就比较不同实现。
自己试一小步
换成三步树后,你能说明两种归一化何时相同、何时不同吗?
- 自己设计每步两个候选的三步概率树,使用精确分数并固定平局规则。
- 穷举八条路径,分别导出原概率、逐步采样概率与整段条件化概率。
- 另外设计一组两种分布恰好一致的输入,并说明使它成立的条件。
- 提交输入树、CSV、独立核对程序和推理;不要用一次随机抽样代替概率证明。
做到这些,就算完成
- 每个节点的原分布与筛选后分布都各自归一。
- 保留事件从原路径表重建,不能直接抄主程序总数。
- 结论限定在固定长度树和已声明的筛选规则。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- The Curious Case of Neural Text Degeneration v2 (新标签页)↗
第3节序列概率乘积及3.1节top-p候选与局部归一化。
- Transformers v4.57.1:TopPLogitsWarper源码 (新标签页)↗
TopPLogitsWarper的排序、累计概率筛选和min_tokens_to_keep。 - Transformers v4.57.1:Generation strategies (新标签页)↗
Sampling段落区分按分布采样与贪心,并说明
do_sample配置。