开始前,会这些就够了
两条输入单独计算都正常,放在同一个批次里,短的那条却换了输出。你已经传入掩码,让注意力不读取补上的 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有十二行数据,表头另外计数:两条独立基准,加五种方案各两条结果。除了所选候选,还记录选中的索引、它是不是内容、两个分数、与独立基准的最大差值。两个正确方案都要核对完整分数;三个故意错误的方案则必须保留可解释的短输入反例。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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也返回默认候选,掩盖没有有效输入的事实。
自己试一小步
换成至少三种不同长度的输入,哪些检查仍能区分取列错误与位置错误?
- 保留正文两条输入作为对照,再自行加入至少三种长度,其中一条只有一个真实符号。
- 先手算其中一条的独立末位置分数,再运行左右补齐与不同选择器。
- 提交你新增输入的完整分数差异表,并挑出一个最终候选相同、但中间取列或数值仍有问题的情形;若未找到,说明搜索范围。
- 另写一份真实推理接口的检查计划,列出哪些行为必须查对应版本或实际执行后才能确认。
做到这些,就算完成
- 每条输入都保留独立基准、有效掩码、位置编号、被选索引与完整输出分数。
- 左右补齐的正确对照均按同一内容位置约定核验,不只比较最大分数对应的候选。
- 全PAD和掩码不匹配有明确拒绝结果,无效查询不会混入合法输出。
- 区分自己已运行的两维计算和计划中的真实模型验证,没有用前者替代后者。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Transformers v4.57.1:Text generation 的 Padding side (新标签页)↗
阅读默认生成输入构造与 Padding side:该教程对解码器模型批量生成使用左侧补齐的说明。只采用这一范围,未运行页面里的真实模型。
- Transformers v4.57.1:生成时取分数与准备位置的源码 (新标签页)↗
核对
_sample取最后序列位置、generate的右补齐检查,以及prepare_inputs_for_generation按条件累计position_ids;同时看到logits_to_keep的限制。正文只解释这些路径,不声称复刻generate。 - PyTorch 2.9:MultiheadAttention 的键掩码和输出形状 (新标签页)↗
阅读forward的query、
key_padding_mask与Outputs:键掩码限制哪些key参与,输出仍有查询位置维度。该API布尔True表示忽略,与本文一表示有效的自定义掩码不同。