qinghuan
← 回到知识探索

AI 直觉 / 动手实验

已经遮住补位,为什么短句批量生成还是取错分数?

用两维向量手算首次输出,把“可以读取谁”“从哪一列取分数”和“内容的位置编号”分开核对。

展开本文目录
  1. 下载完整练习包
  2. 一张掩码,不能包办三个不同选择
  3. 先把短输入的结果完整手算一遍
  4. 右侧补齐后,最后一列已经不是内容末尾
  5. 按真实索引取末尾,别把数量当坐标
  6. 左侧补齐能对齐末列,位置编号仍要核对
  7. 对照固定版本源码,只确认它实际做了什么
  8. 运行完整脚本,从轨迹而不是标签验收
  9. 把验证范围写清,才知道下一步还缺什么
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 知道短输入会用 PAD 补到同一长度
  • 能对两个数字组成的列表相加、求平均
  • 了解因果注意力只允许读取当前位置及以前的有效输入
  • 先读补齐与有效位置,再读注意力读取的范围,最后区分本篇的取列与位置编号。

两条输入单独计算都正常,放在同一个批次里,短的那条却换了输出。你已经传入掩码,让注意力不读取补上的 PAD,问题还可能在哪里?输出通常也沿输入位置排成一列列数字;如果最后选的是补位那一列,前面的掩码即使完全正确,也没有替你改正这个取列动作。

本篇只研究第一次取下一项输出的过程。我们使用固定的两维计算规则,比较单独输入、右侧补齐和左侧补齐,保存每个位置的计算轨迹。没有训练或下载语言模型,没有执行真实 Transformer 框架;所谓正确,是满足“有效内容采用同样编号和计算时,批量结果应与单独计算一致”的实验约定。

把例子带到自己的电脑上

下载完整练习包

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

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

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

python --version
python -B batch_last_position.py

解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 程序会在当前目录写入教学报告,建议使用独立练习文件夹。 教学数据与标准库公式,不代表 PyTorch 执行、训练效果或真实模型准确率。

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

一张掩码,不能包办三个不同选择

先把输入想成一排带编号的格子。本例短输入是[A,B],长输入是[A,B,A,B]。为了排成同宽的两行,可以把短输入写成[A,B,PAD,PAD],并另记[1,1,0,0]:一表示真实内容,零表示补位。这里的 PAD 是专用符号,不与任何真实内容共用身份。

第一个选择是“计算这一格时,可以读取哪些格子”。我们只准读取自己及以前的真实内容,这由键掩码和先后关系一起限制。第二个选择是“计算结束后,从哪一格取下一项输出的分数”。第三个选择是“这一格里的真实内容,在原输入中排第几”。它们分别控制读取范围、结果取列和位置编号。

键掩码只限制哪些位置能作为被读取的资料,不自动删除补位对应的输出,也不替调用者寻找内容末尾。各接口的掩码含义还可能不同:本篇一表示保留;不能把这串数字不经核对就当成其他库的布尔屏蔽参数。

先把短输入的结果完整手算一遍

给A分配[4,0],给B分配[0,4],给PAD分配[0,0]。内容位置从零开始;位置p额外加上[p,−p]。因此[A,B]进入后面的计算时,两个向量分别是[4,0]和[1,3]。这个位置加法是我们明确选定的教学规则。

接着,每个位置对允许读取的向量求平均,再加回自己的向量。它对应查询与键打分都为零、允许位置均分权重的一个特殊算例,并保留一条把自身输入加回来的路线。我们把结果的两个数字分别作为候选A、B的分数,取较大的那个;没有必要先把分数转成概率。

短输入最后的B允许读A和B,平均是[2.5,1.5];再加上B自身的[1,3],得到[3.5,4.5],所以这套规则选择B。这里没有自然语言正确答案,B也不代表模型理解了任何句意。后续要保持的基准是这两个完整数字,而不只是恰好选中了同一符号。

这个算例没有多头、前馈层、归一化、训练权重或缓存。故意把数学缩小,是为了让读者能从输入直接算到每一列输出;它不承担真实架构实现或语言质量评测的任务。

右侧补齐后,最后一列已经不是内容末尾

把短输入补成[A,B,PAD,PAD],仍然使用[1,1,0,0]作为有效标记。真实A、B的位置编号保持零、一;补位编号在本实验中固定写零。计算B所在列时,读取范围和自身向量都没变,所以这一列仍然得到[3.5,4.5]。

问题出在盲目取列表最后一列。最后那格是PAD,它自己是[0,0],但作为一次查询仍能读取前面的A、B。键掩码没有禁止这一点:它禁止别人把PAD当资料,不是把所有PAD查询的结果强制清零。这一格的平均仍是[2.5,1.5],加回自身零向量后也还是[2.5,1.5]。

于是,同一个调用者若直接取末列,就会从选B变成选A。这里没有发生跨样本读取,也没有修改真实内容;变化来自取了另一格的输出。轨迹文件同时保留B列与PAD列,能证明“有效位置算错了”和“有效位置算对但取错了列”是两类不同故障。

按真实索引取末尾,别把数量当坐标

本实验对右侧补齐的修正,是找到掩码中最后一个一的实际索引,再取那个位置。短输入的有效索引是零、一,因此取索引一;长输入四格都有效,取索引三。两者的完整分数都与各自单独计算一致,这才是一组可以核对的正确对照。

在真实内容连续位于开头、只往右补齐的前提下,“有效数量减一”也恰好得到末尾索引。但换成左侧补齐[PAD,PAD,A,B]时,有效数量仍然是二,二减一却指向第二个PAD;真正末尾在索引三。数量回答有几个,索引回答在哪里,不能省略布局前提。

脚本把这个错误选择器也留下用于观察:它会选到补位并得到[0,0]。平局规则固定选择A,但验收首先检查 selected_is_content 为假;不能因为某个例子碰巧选到同一候选,就认可取了错误位置的流程。所有输入都为PAD时,程序直接拒绝,不能让默认索引偷偷选走一格。

左侧补齐能对齐末列,位置编号仍要核对

左侧补齐后,两条输入的最后物理列都是真实内容,所以统一取末列可以解决本例的取列问题。但短输入的A、B现在位于物理索引二、三;如果直接把这些物理索引当内容位置,A变成[6,−2],B变成[3,1]。最后B读到的平均是[4.5,−0.5],最终分数为[7.5,0.5],又与单独计算不同。

正确对照只对真实内容累计位置:左补齐掩码[0,0,1,1]对应的有效位置仍是零、一。补位的编号只是占位约定,不参与被读取的内容。这样B自身和允许读取的向量都恢复原样,末列重新得到[3.5,4.5]。

这个反例依赖本篇显式加入的绝对位置向量[p,−p]。使用RoPE或其他位置机制的模型,需要按自己的规则分析;不能从这里推断所有模型共同平移位置都会产生同样变化。我们核验的是“同一约定有没有被两种布局一致应用”,不是宣布一种编号公式适用于所有推理系统。

对照固定版本源码,只确认它实际做了什么

Transformers v4.57.1的生成教程提醒解码器模型批量生成时使用左侧补齐。同版本生成代码的常见采样或贪心路径,从输出的最后一个序列位置取分数,再进行后续选词处理;它还检查右侧补齐并给出提示。这个具体调用约定解释了为何仅传入注意力掩码,并不等于生成循环会替每条输入寻找内容末尾。

同文件在满足相应条件、调用者没有提供位置编号时,会根据有效掩码累计生成位置。要注意这是带条件的代码路径,不是所有模型共有的保证。本篇补位位置固定填零,而该版本这段源码填一;我们没有复刻它的全部数值,只用原创规则演示有效位置与物理列号的区别。

也不要把本文的最后有效位置函数直接当作通用库补丁。有些模型只返回最后一格的分数,生成后还要更新输入、掩码和缓存位置;选对首次输出并不足以完成后续循环。这里引用v4.57.1是为了固定阅读对象,不表示它是当前最新版,也没有运行该库或教程里的预训练模型。

运行完整脚本,从轨迹而不是标签验收

脚本只需要 Python 3 标准库。它先计算两条原始输入,再比较五种批量布局与取列方案;每条仍由同一个小函数计算,没有测量并行吞吐。输出包括三个 JSON 和一个 CSV,原输入及固定参数保留在 batch-inputs.json,全部位置轨迹在 position-traces.json,汇总在 batch-generation-report.json。

selection-results.csv有十二行数据,表头另外计数:两条独立基准,加五种方案各两条结果。除了所选候选,还记录选中的索引、它是不是内容、两个分数、与独立基准的最大差值。两个正确方案都要核对完整分数;三个故意错误的方案则必须保留可解释的短输入反例。

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

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

小实验 · python
"""Inspect first-output selection in a tiny, fixed-parameter causal computation.

Standard library only. No trained model, Transformers execution, KV cache,
generation loop or real-language quality measurement is performed.
"""
import csv
import json
from copy import deepcopy
from pathlib import Path

EMBEDDINGS = {"PAD": [0.0, 0.0], "A": [4.0, 0.0], "B": [0.0, 4.0]}
CANDIDATES = ("A", "B")
PROMPTS = {"short": ["A", "B"], "long": ["A", "B", "A", "B"]}


def validate_mask(mask):
    if not mask or any(type(bit) is not int or bit not in (0, 1) for bit in mask):
        raise ValueError("nonempty integer 0/1 mask required")
    if not any(mask):
        raise ValueError("all-PAD prompts have no final content position")


def logical_positions(mask):
    validate_mask(mask)
    count = 0
    result = []
    for bit in mask:
        result.append(count if bit else 0)  # PAD position 0 is this toy's convention.
        count += bit
    return result


def last_valid_index(mask):
    validate_mask(mask)
    return max(i for i, bit in enumerate(mask) if bit == 1)


def forward(tokens, mask, position_policy="logical"):
    validate_mask(mask)
    if len(tokens) != len(mask) or any(token not in EMBEDDINGS for token in tokens):
        raise ValueError("known tokens with matching mask length required")
    # In this fixture PAD has its own identity; real PAD/EOS sharing needs position metadata.
    if any((token != "PAD") != bool(bit) for token, bit in zip(tokens, mask)):
        raise ValueError("toy PAD identity and mask disagree")
    if position_policy not in ("logical", "physical"):
        raise ValueError("unknown position policy")
    positions = logical_positions(mask) if position_policy == "logical" else list(range(len(tokens)))
    x = [[EMBEDDINGS[token][0] + p, EMBEDDINGS[token][1] - p]
         for token, p in zip(tokens, positions)]
    rows = []
    for i, token in enumerate(tokens):
        allowed = [j for j in range(i + 1) if mask[j] == 1]
        # Q=K=0, so every allowed attention score is zero and weights are uniform.
        weights = [1.0 / len(allowed) if j in allowed else 0.0 for j in range(len(tokens))]
        # Empty-key PAD queries are inspection-only rows; do not normalize an empty set.
        context = [sum(x[j][d] for j in allowed) / len(allowed) if allowed else 0.0
                   for d in range(2)]
        logits = [x[i][d] + context[d] for d in range(2)]  # Residual plus identity readout.
        rows.append({"index": i, "token": token, "query_is_content": bool(mask[i]),
                     "position_id": positions[i], "input_vector": x[i],
                     "allowed_key_indices": allowed, "attention_weights": weights,
                     "empty_key_query": not allowed, "context": context, "logits": logits})
    return {"tokens": list(tokens), "mask": list(mask), "position_policy": position_policy,
            "position_ids": positions, "rows": rows}


def pack(prompts, side):
    if side not in ("left", "right") or not prompts:
        raise ValueError("nonempty prompts and left/right padding required")
    if any(not tokens or any(token not in CANDIDATES for token in tokens) for tokens in prompts.values()):
        raise ValueError("original prompts must contain one or more A/B tokens")
    width = max(map(len, prompts.values()))
    result = {}
    for name, tokens in prompts.items():
        pads = ["PAD"] * (width - len(tokens))
        content, empty = [1] * len(tokens), [0] * len(pads)
        result[name] = {"tokens": pads + tokens if side == "left" else tokens + pads,
                        "mask": empty + content if side == "left" else content + empty}
    return result


def inspect_selection(trace, selector):
    # Incorrect selectors are retained only to make their failures visible in the report.
    if selector == "last_valid":
        index = last_valid_index(trace["mask"])
    elif selector == "last_physical":
        index = len(trace["mask"]) - 1
    elif selector == "count_minus_one":
        index = sum(trace["mask"]) - 1
    else:
        raise ValueError("unknown selector")
    row = trace["rows"][index]
    winner = max(range(len(CANDIDATES)), key=lambda i: row["logits"][i])
    return {"selected_index": index, "selected_is_content": row["query_is_content"],
            "logits": row["logits"], "next_candidate": CANDIDATES[winner]}


def rejected(action):
    try:
        action()
    except ValueError:
        return
    raise AssertionError("invalid teaching input was accepted")


def main():
    frozen = deepcopy(PROMPTS)
    traces, results, baselines, csv_rows = {}, {}, {}, []
    for name, tokens in PROMPTS.items():
        trace = forward(tokens, [1] * len(tokens))
        result = inspect_selection(trace, "last_valid")
        traces[f"single/{name}"] = trace
        baselines[name] = result
        csv_rows.append(["single", name, "none", "logical", "last_valid", result["selected_index"],
                         True, *result["logits"], result["next_candidate"], True, 0.0])

    specs = [
        ("right_wrong_last", "right", "logical", "last_physical"),
        ("right_correct_last_valid", "right", "logical", "last_valid"),
        ("left_correct_last", "left", "logical", "last_physical"),
        ("left_wrong_count", "left", "logical", "count_minus_one"),
        ("left_wrong_positions", "left", "physical", "last_physical"),
    ]
    for case, side, position_policy, selector in specs:
        results[case] = {}
        for name, item in pack(PROMPTS, side).items():
            trace = forward(item["tokens"], item["mask"], position_policy)
            result = inspect_selection(trace, selector)
            difference = max(abs(a - b) for a, b in zip(result["logits"], baselines[name]["logits"]))
            result["matches_single_logits"] = difference <= 1e-12
            result["max_logit_difference"] = difference
            traces[f"{case}/{name}"] = trace
            results[case][name] = result
            csv_rows.append([case, name, side, position_policy, selector, result["selected_index"],
                             result["selected_is_content"], *result["logits"], result["next_candidate"],
                             result["matches_single_logits"], difference])

    assert baselines["short"]["logits"] == [3.5, 4.5]
    assert baselines["short"]["next_candidate"] == "B"
    assert results["right_wrong_last"]["short"]["logits"] == [2.5, 1.5]
    assert results["left_wrong_positions"]["short"]["logits"] == [7.5, 0.5]
    assert not results["left_wrong_count"]["short"]["selected_is_content"]
    for case in ("right_wrong_last", "left_wrong_count", "left_wrong_positions"):
        assert not results[case]["short"]["matches_single_logits"]
    for case in ("right_correct_last_valid", "left_correct_last"):
        assert all(r["matches_single_logits"] and r["selected_is_content"] for r in results[case].values())
    assert all(results[case]["long"]["matches_single_logits"] for case, *_ in specs)
    assert last_valid_index([0, 0, 1, 1]) == 3 and sum([0, 0, 1, 1]) - 1 == 1
    assert last_valid_index([1, 1, 0, 0]) == sum([1, 1, 0, 0]) - 1 == 1
    rejected(lambda: forward(["PAD", "PAD"], [0, 0]))
    rejected(lambda: forward(["A"], [True]))
    rejected(lambda: forward(["A"], [1, 0]))
    rejected(lambda: forward(["A", "PAD"], [1, 1]))
    assert PROMPTS == frozen and len(csv_rows) == 12

    parameters = {"prompts": PROMPTS, "embeddings": EMBEDDINGS, "output_candidates": CANDIDATES,
                  "position_vector": "[p, -p]", "Q_and_K": "zero", "V": "input vector",
                  "readout": "input vector plus uniform causal context; identity two-score output",
                  "pad_position_id_in_logical_mode": 0, "argmax_tie_rule": "first candidate A"}
    report = {"scope": "fixed-parameter synthetic first-output calculation, not a full Transformer, framework run or generate()",
              "baselines": baselines, "cases": results, "csv_data_rows": len(csv_rows),
              "limits": ["Only the first next-output selection is inspected; no generation loop or cache.",
                         "Absolute position vector [p,-p] is an explicit toy design, not RoPE.",
                         "All-PAD prompts are rejected; empty-key PAD query rows use zero context for inspection only.",
                         "Real frameworks may have other positions, output shapes and padding contracts."]}
    for filename, value in (("batch-inputs.json", parameters), ("position-traces.json", traces),
                            ("batch-generation-report.json", report)):
        Path(filename).write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
    with Path("selection-results.csv").open("w", encoding="utf-8", newline="") as stream:
        writer = csv.writer(stream)
        writer.writerow(["case", "prompt", "padding_side", "position_policy", "selector", "selected_index",
                         "selected_is_content", "logit_A", "logit_B", "next_candidate",
                         "matches_single_logits", "max_logit_difference"])
        writer.writerows(csv_rows)
    print("single short:", baselines["short"]["logits"], baselines["short"]["next_candidate"])
    for case, *_ in specs:
        row = results[case]["short"]
        print(case, row["logits"], row["next_candidate"], "matches_single=" + str(row["matches_single_logits"]))
    print("PASS: two valid batch controls, three short-prompt counterexamples, all-PAD rejected; four files saved")


if __name__ == "__main__":
    main()

怎样运行:保存为 batch_last_position.py,在空练习目录运行 python batch_last_position.py。只用 Python 3 标准库;在当前目录写出三个 JSON 和一个 CSV,不下载数据或模型。 2026-10-13 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。教学数据与标准库公式,不代表 PyTorch 执行、训练效果或真实模型准确率。

运行后对照结果
single short: [3.5, 4.5] B
right_wrong_last [2.5, 1.5] A matches_single=False
right_correct_last_valid [3.5, 4.5] B matches_single=True
left_correct_last [3.5, 4.5] B matches_single=True
left_wrong_count [0.0, 0.0] A matches_single=False
left_wrong_positions [7.5, 0.5] A matches_single=False
PASS: two valid batch controls, three short-prompt counterexamples, all-PAD rejected; four files saved

把验证范围写清,才知道下一步还缺什么

验收时按顺序看三样东西:选中的输出位置是真实内容吗,它读到的是同一组有效内容吗,这些内容的位置编号符合原约定吗?随后才比较分数。这样的检查能指出问题属于取列、可见范围还是位置构造,而不仅给出“批量结果不一样”的现象。

左侧补位在内容出现前可能没有任何允许读取的键。本脚本只为观察这些无效查询记录零上下文,不对空集合归一化,也不把它们作为合法输出。整条输入全是补位会更早被拒绝。这是明确写出的教学处理,不能假定其他注意力实现会返回相同数值。

本次只验证了两条合成输入的首次输出、两组正确对照和三组反例。没有检验完整多步生成、缓存重排、停止符、真实分词、设备数值差异或实际语言质量。迁移到真实系统时,应先固定模型与推理设置,再保留每条输入的掩码、位置和被选输出,逐项验证接口实际支持的布局。

记住这些要点

  • 限制哪些键可被读取,不等于选定哪一格输出。
  • 有效数量与最后有效索引不同;公式必须附带布局前提。
  • 左侧补齐对齐了末列,还需按具体模型核对位置编号。
  • 比较完整分数和所选位置,不能只看最终符号恰好相同。

别把例子用过头

  • 以为传入键掩码后,所有补位输出都会自动清零或消失。
  • 对左侧补齐仍用有效数量减一当末尾索引。
  • 把物理列号直接当作内容位置,却要求结果与单独计算一致。
  • 把首次输出的手写修正扩大成完整generate或所有模型的通用方案。
  • 全PAD也返回默认候选,掩盖没有有效输入的事实。

自己试一小步

换成至少三种不同长度的输入,哪些检查仍能区分取列错误与位置错误?

  1. 保留正文两条输入作为对照,再自行加入至少三种长度,其中一条只有一个真实符号。
  2. 先手算其中一条的独立末位置分数,再运行左右补齐与不同选择器。
  3. 提交你新增输入的完整分数差异表,并挑出一个最终候选相同、但中间取列或数值仍有问题的情形;若未找到,说明搜索范围。
  4. 另写一份真实推理接口的检查计划,列出哪些行为必须查对应版本或实际执行后才能确认。

做到这些,就算完成

  • 每条输入都保留独立基准、有效掩码、位置编号、被选索引与完整输出分数。
  • 左右补齐的正确对照均按同一内容位置约定核验,不只比较最大分数对应的候选。
  • 全PAD和掩码不匹配有明确拒绝结果,无效查询不会混入合法输出。
  • 区分自己已运行的两维计算和计划中的真实模型验证,没有用前者替代后者。

查原始资料

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

  • Transformers v4.57.1:Text generation 的 Padding side (新标签页)↗Hugging Face 官方文档 · 英文

    阅读默认生成输入构造与 Padding side:该教程对解码器模型批量生成使用左侧补齐的说明。只采用这一范围,未运行页面里的真实模型。

    2026-10-13 核对固定v4.57.1正文;这是固定阅读版本,不称为最新版。

  • Transformers v4.57.1:生成时取分数与准备位置的源码 (新标签页)↗Hugging Face 官方仓库 · 英文

    核对_sample取最后序列位置、generate的右补齐检查,以及prepare_inputs_for_generation按条件累计position_ids;同时看到logits_to_keep的限制。正文只解释这些路径,不声称复刻generate。

    2026-10-13 阅读固定tag源码,网页过长时以同tag raw文件核对;未安装或执行Transformers。

  • PyTorch 2.9:MultiheadAttention 的键掩码和输出形状 (新标签页)↗PyTorch 官方文档 · 英文

    阅读forward的query、key_padding_mask与Outputs:键掩码限制哪些key参与,输出仍有查询位置维度。该API布尔True表示忽略,与本文一表示有效的自定义掩码不同。

    2026-10-13 核对固定2.9文档;本文没有调用PyTorch或检验其具体内核行为。