开始前,会这些就够了
- 会把连续步骤的条件概率相乘
- 知道对数会把乘法变成加法
- 能运行Python标准库脚本
你已经找到一个完整答案,它的分数比所有还没写完的候选都高。现在停下来安全吗?如果比较的是原始序列概率,继续乘不超过一的条件概率不会让它变大;如果分数还要除以长度,结论就变了。
我们用只有三条完整结果的小树,把“当前分数较低”和“以后不可能更高”拆开。实验不运行模型,也不删减候选;先穷举确认正确结果,再对照一种会过早停止的规则和一种使用上界的规则,最后导出每一步的决策账本。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- length_stop.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B length_stop.py解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。精确分数的有限概率树,计入 EOS、固定最大长度且不剪枝;不调用模型、不比较答案质量,也不保证任意 beam search 全局最优。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先确定分数怎样算,长度数到哪里
EOS 是表示生成结束的符号,BOS 是表示序列开始的符号。本例的完整结果必须以 EOS 结束;长度 L 包括 EOS,不包括提示词或 BOS,最多生成三个符号,即最大长度 M=3。所有边的条件概率均为正数,每个节点的概率和为一。提前停止意味着选中已有完整结果,而不是把未完成前缀直接当作答案。
比较的分数是S=log(P)/L,其中P为整条路径的概率,log是自然对数。它等于每个生成符号的对数概率的平均,分数越大越好。为便于阅读,也展示exp(S)=P的L次方根,即几何平均;指数函数保持大小顺序,因此两种显示选出同一结果。
这里长度惩罚的指数固定为一。其他指数、额外奖励、最短长度或不同EOS计长约定,会改变推导条件。看到库里同样叫length_penalty的参数,仍要核对它具体除以什么,不能把本文的分母直接套到所有实现。
把三条完整结果摆在同一张表里
第一步选A的概率为0.6,选B为0.4。A之后以0.8的概率结束,以0.2的概率再写x;B之后一定写x。无论从A还是B走到x,下一步都一定结束。于是完整结果只有A EOS、A x EOS、B x EOS,概率依次为0.48、0.12、0.4,合计一。
若按原始序列概率排序,A EOS以0.48胜出。按平均对数分排序,却是B x EOS胜出:前者几何平均约0.69282,后者约0.73681。目标函数改变了,最优结果也会改变;不能把选择不同答案一概认作搜索出错。
脚本比较分数时不依赖这些四舍五入的小数。比较Pa的La次方根与Pb的Lb次方根,只需比较Pa的Lb次方与Pb的La次方,全部用精确分数。显示用的对数和开方不参与停止决定,也不会因两个近似值碰巧相等而改变路径。
平均对数分也不是所有完整结果组成的一张新概率表。各条路径的几何平均通常不会相加等于一,它只是用于排序的分数。不要把0.73681解释为B x EOS被随机选中的机会;在这棵原始概率树里,那条完整路径的机会仍是0.4。
当前得分较低,并不等于以后追不上
搜索到第二步时,A EOS已经完成。仍在继续的A x和B x,当前概率分别为0.12和0.4,当前长度都是二。它们的几何平均约0.34641和0.63246,都低于完整结果的0.69282。若据此停止,就会错过B x EOS。
原因是B x再接一个概率为一的EOS,原概率仍是0.4,长度却从二变成三。log(0.4)是负数,除以更大的正数后更接近零,分数因而上升。原概率不增加与归一分数可增加能够同时成立,不存在数学矛盾。
这个反例只需要一个后续概率为一的边,不需要随机波动或复杂模型。若后续概率小于一,分子也会变得更负,最终分数可能上升也可能下降;我们需要的是能覆盖全部合法后续的上界,不能只凭某一次延长的直觉。
用最长允许长度,给前缀一个乐观上限
设未完成前缀概率为p,任何完整续写的概率P都不超过p;最终长度L不超过M。因为这些概率不大于一,log(p)不大于零,所以log(P)/L≤log(p)/L≤log(p)/M。因此log(p)/M是一个可能很宽松、但不会低估的最高分。
在第二步,B x的上界对应0.4的三次方根,约0.73681,高于已有完整结果的0.69282,便不能停止。继续一步后,穷举基线和上界规则都选出B x EOS。代码用p的已完成长度次方与已完成概率的M次方比较,避免用浮点数作这道门槛。
停止条件是:已经有完整结果,而且每个仍保留前缀的上界都不高于它。若只要求取得任意一个最高分结果,相等时可以保留已完成者;若还要收集所有并列结果,就不能把相等视为足够。没有完整结果时,本实验不会因候选表为空而报告成功。
上界不需要真的能达到。某个前缀可能只能提前结束,或者后续每一步都必须乘小于一的概率;按最长长度和后续不减概率估计会过分乐观,却仍是安全的。代价是有时多展开几步。把上界压得更紧可以提高效率,但必须重新证明没有压到真实可达分数之下。
可靠上界也能真的省下后续展开
第二组输入把第一步A的概率改为0.9,A之后结束的概率也改为0.9,其余结构不变。第二步已有A EOS,概率0.81、几何平均0.9。A x和B x的概率为0.09和0.1;它们按最大长度三计算的最高几何平均约0.44814和0.46416,都追不上0.9。
因此上界规则可以在第二步结束,无需把两个前缀都展开到第三步。程序另行穷举完整树,核对这次早停与完整结果一致。这个对照说明安全规则并非必须永远走到最大长度,只是需要足够的证据才停止。
实验故意不按beam宽度删候选,以单独检查停止条件。真实beam search若早先已经丢掉某个好前缀,正确的停止上界也不能把它找回来。最多只能对当前保留范围给出保证,不能因此宣称获得所有可能文本中的全局最优答案。
运行两种规则,逐行核对停止理由
脚本生成length-inputs.json、length-report.json,以及完整结果表length-leaves.csv和停止账本length-stopping.csv。两组输入共六条完整结果;两种规则共九行搜索记录。每行保留当前深度、活跃前缀、最好完整结果及最大的前缀概率。
因为所有活跃前缀共用最大长度M,最大前缀概率也给出最高上界;这只压缩了记录,没有漏查另一种长度限制。本例所有树枝都在三步内以EOS结束,没有处理到达预算仍不结束的前缀。扩展真实生成器时,必须另外约定这种前缀是否可作为输出。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Finite search without beam pruning. Length counts EOS, excludes the prompt."""
import csv
import json
import math
from fractions import Fraction as F
from pathlib import Path
ROOT = Path(__file__).resolve().parent
MAX_LENGTH = 3
CASES = [
{"id": "unsafe_current_score", "root_A": "3/5", "A_EOS": "4/5"},
{"id": "safe_early_stop", "root_A": "9/10", "A_EOS": "9/10"},
]
def tree_for(case):
a, eos = F(case["root_A"]), F(case["A_EOS"])
assert 0 < a < 1 and 0 < eos < 1
return {
(): {"A": a, "B": 1 - a},
("A",): {"EOS": eos, "x": 1 - eos},
("B",): {"x": F(1)},
("A", "x"): {"EOS": F(1)},
("B", "x"): {"EOS": F(1)},
}
def check_tree(tree):
for prefix, edges in tree.items():
assert len(prefix) < MAX_LENGTH and "EOS" not in prefix
assert sum(edges.values()) == 1
assert all(0 < p <= 1 for p in edges.values())
for token in edges:
assert token == "EOS" or prefix + (token,) in tree
def better(left, right):
"""Compare mean log probabilities exactly, without computing a log."""
if right is None:
return True
lp, rp = left[1], right[1]
return lp ** len(right[0]) > rp ** len(left[0])
def best_of(items):
best = None
for item in items:
if better(item, best):
best = item
return best
def enumerate_leaves(tree, prefix=(), probability=F(1)):
leaves = []
for token, conditional in tree[prefix].items():
path, mass = prefix + (token,), probability * conditional
if token == "EOS":
leaves.append((path, mass))
else:
leaves.extend(enumerate_leaves(tree, path, mass))
return leaves
def may_stop(best, active, policy):
if best is None:
return False
if not active:
return True
if policy == "unsafe_current":
return all(not better(prefix, best) for prefix in active)
assert policy == "bounded"
# log(prefix_mass) / MAX_LENGTH <= log(best_mass) / best_length
return all(mass ** len(best[0]) <= best[1] ** MAX_LENGTH
for _, mass in active)
def describe(item):
path, mass = item
return {"path": " ".join(path), "probability": str(mass), "length": len(path),
"mean_log": math.log(float(mass)) / len(path),
"geometric_mean": float(mass) ** (1 / len(path))}
def search(case_id, tree, policy):
active, finished, ledger = [((), F(1))], [], []
for depth in range(1, MAX_LENGTH + 1):
expanded = []
for prefix, mass in active:
for token, conditional in tree[prefix].items():
item = (prefix + (token,), mass * conditional)
(finished if token == "EOS" else expanded).append(item)
active = expanded
best = best_of(finished)
stop = may_stop(best, active, policy)
ledger.append({"case": case_id, "policy": policy, "depth": depth,
"active_paths": " | ".join(" ".join(p) for p, _ in active),
"best_finished": " ".join(best[0]) if best else "",
"best_probability": str(best[1]) if best else "",
"best_length": len(best[0]) if best else "",
"largest_active_mass": str(max((p for _, p in active), default=F(0))),
"upper_bound_length": MAX_LENGTH, "stop": stop})
if stop:
assert best is not None
return best, ledger
raise AssertionError("The finite tree must finish by MAX_LENGTH")
def write_csv(name, rows):
with (ROOT / name).open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
def main():
leaf_rows, ledger_rows, summaries = [], [], []
for case in CASES:
tree = tree_for(case)
check_tree(tree)
leaves = enumerate_leaves(tree)
assert sum(mass for _, mass in leaves) == 1
exhaustive = best_of(leaves)
raw_best = max(leaves, key=lambda item: item[1])
runs = {}
for policy in ("unsafe_current", "bounded"):
chosen, ledger = search(case["id"], tree, policy)
ledger_rows.extend(ledger)
runs[policy] = {**describe(chosen), "stop_depth": ledger[-1]["depth"],
"matches_exhaustive": chosen == exhaustive}
assert runs["bounded"]["matches_exhaustive"]
leaf_rows.extend({"case": case["id"], **describe(item)} for item in leaves)
summaries.append({"case": case["id"], "raw_probability_best": describe(raw_best),
"exhaustive_mean_log_best": describe(exhaustive), "searches": runs})
assert summaries[0]["searches"]["unsafe_current"]["matches_exhaustive"] is False
assert summaries[0]["searches"]["bounded"]["path"] == "B x EOS"
assert summaries[1]["searches"]["bounded"]["stop_depth"] == 2
assert may_stop(None, [], "bounded") is False
assert may_stop((("A", "EOS"), F(1)), [], "bounded") is True
assert better((("a",), F(1, 2)), (("b", "EOS"), F(1, 4))) is False
inputs = {"max_length": MAX_LENGTH, "length_counts_eos": True,
"length_excludes_prompt": True, "cases": CASES}
report = {"scope": "Exact finite toy tree; no beam pruning, model, training, or quality evaluation",
"objective": "mean natural log probability; exponent 1; ties retain first candidate",
"comparisons": "Exact rational powers; floats only for displayed log/geometric mean",
"upper_bound": "log(prefix_probability) / max_length",
"cases": summaries, "leaf_rows": len(leaf_rows), "ledger_rows": len(ledger_rows)}
(ROOT / "length-inputs.json").write_text(json.dumps(inputs, indent=2) + "\n", encoding="utf-8")
(ROOT / "length-report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
write_csv("length-leaves.csv", leaf_rows)
write_csv("length-stopping.csv", ledger_rows)
print(f"PASS length_stop: {len(CASES)} cases, {len(leaf_rows)} leaves, {len(ledger_rows)} stopping rows")
if __name__ == "__main__":
main()
怎样运行:Python 3.10+标准库;保存为length_stop.py,在空练习目录运行 python length_stop.py。不要加-O,验收使用assert。 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。精确分数的有限概率树,计入 EOS、固定最大长度且不剪枝;不调用模型、不比较答案质量,也不保证任意 beam search 全局最优。
运行后对照结果
PASS length_stop: 2 cases, 6 leaves, 9 stopping rows读论文和框架时,分清相似思想与同一算法
Huang等人在2017年的论文讨论何时结束beam search,并明确把最优保证限定在beam大小带来的搜索范围内。论文先解释原概率的停止逻辑,再讨论长度带来的问题,提出有界长度奖励。本文使用平均对数分与有限最大长度上界,没有复现论文那套奖励算法或翻译评测。
Transformers v4.57.1的文档区分不同early_stopping设置;同版本源码在特定长度惩罚条件下,用最大长度计算可能达到的分数。这些固定版本资料用于核对思路和计长细节,不能保证较新版本的类、默认值或接口不变,也不代表本脚本调用了该库。
学习产物是一份能解释每次停止的记录
先写目标函数,再写前缀可以达到的上界,最后才写停止判断。若分数额外加上正奖励、覆盖率奖励或其他项,原来只依靠概率不增加的证明可能失效;必须把新项的未来最高贡献也算进去。没有有限最大长度时,本文这条具体上界也不能照搬。
我们的六条完整结果全部来自人工概率树,执行结果只检验比较逻辑和有限搜索的决策。没有模型质量、吞吐或延迟结论,也没有证明越长的回答越好。长度归一只是在改写偏好;选择什么偏好,仍需由任务目标和独立评价决定。
记住这些要点
- 原序列概率不会因继续相乘而变大,长度归一分数却可能上升。
- 停止前要比较未来最高分与已有完整结果,当前分数不一定是上界。
- 有限最大长度给出
log(前缀概率)/M这一上界,计长约定必须一致。 - 正确的停止条件无法补救已经被beam剪枝删除的路径。
别把例子用过头
- 把EOS在分子中计入,却在长度里临时漏掉。
- 用活跃前缀当前归一分数证明它以后追不上。
- 改变奖励或长度惩罚后仍沿用原证明。
- 把保留候选范围内的正确性写成所有文本的全局最优。
自己试一小步
你能独立造一棵树,证明自己的停止规则何时可靠吗?
- 设计最大长度为四的概率树,至少有四条完整结果,并声明EOS和提示词怎样计长。
- 同时实现完整穷举与按前缀展开,写出自己的评分目标和可证明上界。
- 导出每步完整候选、活跃前缀、上界及停止原因,再用独立读取程序核对。
- 加入有限beam宽度,寻找因剪枝丢掉优解的输入,说明它与停止条件出错的区别。
做到这些,就算完成
- 所有节点概率和为一,零概率路径有明确处理约定。
- 上界规则与穷举的比较范围一致,平局政策写明。
- 报告自己的反例与边界,不借用正文两组输入充当独立实验。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- When to Finish? Optimal Beam Search (modulo beam size) (新标签页)↗
原概率停止条件、beam范围限制及长度因素对停止判断的影响。
- Transformers v4.57.1:Generation参数 (新标签页)↗
early_stopping三种行为、length_penalty及最大长度与提示词长度的定义。 - Transformers v4.57.1:beam_search.py (新标签页)↗
BeamHypotheses.add与is_done中评分长度和可达最高分的计算分支。