qinghuan
← 回到知识探索

AI 直觉 / 动手实验

短答案已经分更高,为什么还不能提前结束搜索?

列出三条完整路径,用精确分数比较原概率与平均对数分,再给未完成的前缀计算一个可证明的最高分。

展开本文目录
  1. 下载完整练习包
  2. 先确定分数怎样算,长度数到哪里
  3. 把三条完整结果摆在同一张表里
  4. 当前得分较低,并不等于以后追不上
  5. 用最长允许长度,给前缀一个乐观上限
  6. 可靠上界也能真的省下后续展开
  7. 运行两种规则,逐行核对停止理由
  8. 读论文和框架时,分清相似思想与同一算法
  9. 学习产物是一份能解释每次停止的记录
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 会把连续步骤的条件概率相乘
  • 知道对数会把乘法变成加法
  • 能运行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结束,没有处理到达预算仍不结束的前缀。扩展真实生成器时,必须另外约定这种前缀是否可作为输出。

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

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

小实验 · python
"""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在分子中计入,却在长度里临时漏掉。
  • 用活跃前缀当前归一分数证明它以后追不上。
  • 改变奖励或长度惩罚后仍沿用原证明。
  • 把保留候选范围内的正确性写成所有文本的全局最优。

自己试一小步

你能独立造一棵树,证明自己的停止规则何时可靠吗?

  1. 设计最大长度为四的概率树,至少有四条完整结果,并声明EOS和提示词怎样计长。
  2. 同时实现完整穷举与按前缀展开,写出自己的评分目标和可证明上界。
  3. 导出每步完整候选、活跃前缀、上界及停止原因,再用独立读取程序核对。
  4. 加入有限beam宽度,寻找因剪枝丢掉优解的输入,说明它与停止条件出错的区别。

做到这些,就算完成

  • 所有节点概率和为一,零概率路径有明确处理约定。
  • 上界规则与穷举的比较范围一致,平局政策写明。
  • 报告自己的反例与边界,不借用正文两组输入充当独立实验。

查原始资料

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