P5458. 编码器层前向计算
场次:2026年9月16日 · 来源:CodeFun2000 P5458
题目描述
实现一层标准的 Transformer Encoder Layer。该层依次包含两个子层:
- 多头自注意力(Multi-Head Self-Attention, MHSA)
- 逐位置前馈网络(Position-wise Feed-Forward Network, FFN)
每个子层的计算完成之后,立刻做残差连接,再做层归一化(Post-LN):
整层顺序为:先对输入做 MHSA 子层(残差 + LN),再对结果做 FFN 子层(残差 + LN)。
固定设定
为使评测结果唯一,按下述约定实现,不要自行改成 Pre-LN 或改权重。
- 输入张量 的形状为 。
- 头数 ,因此每头维度 。
- 注意力与输出投影、FFN 的权重均为 单位矩阵:。
- FFN 偏置 。
- 层归一化的 ,,。均值与方差都在最后一个维度上统计,方差用总体方差(除以 ,不要除以 )。
公式
缩放点积注意力(每个头内独立计算,在序列维上做 softmax):
多头拼接后再乘输出投影:
切分方式:把最后一维按长度 切成 段,第 个头取特征区间 。
层归一化:
FFN(ReLU):
softmax 在实现时先减去当前行最大值再 ,避免溢出。
输入格式
一行 个实数,按行优先给出 ,即
输出格式
一行 个实数,把形状为 的输出按同样顺序平铺。每个数四舍五入到小数点后 位。
数据范围
个分量均满足 。
示例
示例 1
输入:
2.00 0.00 0.00 0.00 0.00 2.00 0.00 0.00
输出:
1.73 -0.55 -0.59 -0.59 -0.55 1.73 -0.59 -0.59
说明:
两个位置分别是 与 。
第 个头上, 为 ,缩放后得分矩阵对角线为 ,另一侧为 。softmax 后约 ,该头输出约 。第 个头全 ,拼接后与 相加再 LN、ReLU、残差、LN,得到上述输出。
若把残差拿掉,或改成 Pre-LN,第一维不会落到 。
示例 2
输入:
1.00 0.00 1.00 0.00 0.00 1.00 0.00 1.00
输出:
1.00 -1.00 1.00 -1.00 -1.00 1.00 -1.00 1.00
说明:
两个位置为 与 ,两个头的 相同。MHSA 后与 相加,每个位置都是两个 与两个 交错;第一次 LN 已把它们变成 。ReLU 只保留正分量,第二次 LN 后仍是 的交错图案。
解题思路
第一步:理解问题本质
这道题不是“自己设计一个 Transformer”,而是按给定的固定参数,把一层 Encoder 的前向公式老老实实算一遍。题面把所有可能产生歧义的地方都钉死了:
| 项目 | 题目规定 |
|---|---|
| 输入形状 | |
| 头数 | ,每头 |
| 全部权重 | |
| FFN 偏置 | |
| LayerNorm | ,,,最后一维,总体方差 |
| 结构 | Post-LN:先残差相加,再层归一化 |
| 子层顺序 | 先 MHSA(残差 + LN),再 FFN(残差 + LN) |
因为所有权重都是单位矩阵,好几处矩阵乘法可以直接消掉:
- ,所以不需要真的做投影,直接按特征区间把 切成两半就是两个头;
- ,所以多头拼接之后不需要再乘矩阵;
- 、,所以 ,两次矩阵乘法退化成一次逐元素 ReLU。
于是整层的计算只剩下三件真正要做的事:缩放点积注意力、层归一化、残差相加。
需要特别注意的是“Post-LN”这个词。Post-LN 指层归一化放在残差相加之后:
而 Pre-LN 是 ,两者结果不同。题面在说明里专门写了“若把残差拿掉,或改成 Pre-LN,第一维不会落到 ”,这就是在提醒实现顺序。
第二步:暴力解法——逐条公式直译
最直接的做法是照着公式写四个函数:矩阵乘、切分多头、softmax、层归一化,然后按顺序调用。以注意力为例:
# 每个头单独构造 Q、K、V:这里 W_Q 等是单位矩阵,切片就等于投影
for head in range(h):
lo, hi = head * d_k, (head + 1) * d_k
q = [[x[i][t] for t in range(lo, hi)] for i in range(seq)]
k = [[x[i][t] for t in range(lo, hi)] for i in range(seq)]
v = [[x[i][t] for t in range(lo, hi)] for i in range(seq)]
# 打分:S = Q K^T / sqrt(d_k),形状 (seq, seq)
scores = [[dot(q[i], k[j]) / sqrt(d_k) for j in range(seq)] for i in range(seq)]
# 在序列维(每一行)上做 softmax
weights = [softmax(row) for row in scores]
# 加权求和:该头输出 (seq, d_k)
head_out = [[sum(weights[i][j] * v[j][t] for j in range(seq))
for t in range(d_k)] for i in range(seq)]
这样做在本题的数据规模下当然能过,因为 、 都是常数。但直译会带来两个问题:
- 反复构造小列表、反复调用通用矩阵乘法,代码里出现大量“为了通用而存在”的循环,读者难以看出哪里是本题的关键;
- 通用矩阵乘法要处理 这类三维形状,容易在 batch 维、头维上写错下标。
既然形状固定,不如把“通用”这层壳去掉,只保留真正的计算。
第三步:优化解法——消掉单位矩阵,按区间切头
第一层简化来自权重:
# W_Q = W_K = W_V = I,所以 Q、K、V 全都是 x 本身,
# 第 head 个头直接取特征区间 [head*d_k, (head+1)*d_k)
for head in range(NUM_HEADS):
lo = head * D_K
hi = lo + D_K
# 只用 x[i][lo:hi] 参与这个头的点积与加权
第二层简化来自 :每个头的分数矩阵只有 ,softmax 只需在 2 个数上做,注意力输出也只是两个 2 维向量的加权和。
第三层简化来自 :把两个头的结果直接写回 维向量的对应区间,就完成了“拼接 + 输出投影”:
output = [[0.0] * D_MODEL for _ in range(SEQ_LEN)]
for head in range(NUM_HEADS):
lo = head * D_K
for i in range(SEQ_LEN):
# ... 算出该头对位置 i 的输出 ...
for j in range(SEQ_LEN):
weight = exp_scores[j] / total
for t in range(lo, lo + D_K):
output[i][t] += weight * x[j][t] # 区间 lo..lo+d_k-1 就是第 head 个头
第四层简化来自 FFN 的权重:、偏置为零,于是
不需要写任何矩阵乘法。
第四步:最优解法——把两个子层抽象成同一个模式
两个子层其实是同一个模式:子层函数 + 残差 + LayerNorm。区别只在子层函数是 MHSA 还是 ReLU。把 LayerNorm 单独写成函数,整层就只剩十几行:
def layer_norm(vector):
n = len(vector)
mean = sum(vector) / n
# 题面要求总体方差:除以 n,不是 n - 1
variance = sum((v - mean) ** 2 for v in vector) / n
# eps 在根号里面:sqrt(Var + eps)
std = math.sqrt(variance + LAYER_NORM_EPS)
return [(v - mean) / std for v in vector]
def encoder_layer(x):
# 子层 1:MHSA(W_O = I,拼接后无需再投影)
attention = multi_head_attention(x)
first = [layer_norm([x[i][d] + attention[i][d] for d in range(D_MODEL)])
for i in range(SEQ_LEN)]
# 子层 2:FFN 退化为 ReLU
ffn = [[max(0.0, v) for v in row] for row in first]
second = [layer_norm([first[i][d] + ffn[i][d] for d in range(D_MODEL)])
for i in range(SEQ_LEN)]
return second
几个实现细节:
- 多头切分是“按区间切最后一维”,不是交错取。第 个头取 ,本题就是第 0 个头拿第 0、1 维,第 1 个头拿第 2、3 维。
- softmax 在序列维上做。分数矩阵 的形状是 ,第 行是“位置 对所有位置(包括自己)的分数”,所以每一行单独做 softmax,权重之和为 1。
- softmax 先减行最大值: 与 的比值完全相同,但前者不会溢出。本题分数最大可达约 , 必然溢出,所以这一步不能省。
- 层归一化在最后一维:对每个位置的 4 个数求均值和方差,两个位置互不影响。、 时就是标准化本身。
- 输出要按行优先平铺:位置 0 的 4 个数在前,位置 1 的 4 个数在后,与输入的读入顺序一致。
完整代码实现
import math
import sys
# 题目固定的模型设定。写成常量而不是魔法数字,便于对照题面。
SEQ_LEN = 2 # 序列长度
D_MODEL = 4 # 每个位置的特征维度
NUM_HEADS = 2 # 头数
D_K = D_MODEL // NUM_HEADS # 每头维度 = 2
LAYER_NORM_EPS = 1e-5
def layer_norm(vector):
"""在最后一维上做层归一化(gamma = 1,beta = 0,总体方差)。"""
n = len(vector)
mean = sum(vector) / n
# 题目明确要求总体方差:除以 n,而不是 n - 1。
variance = sum((v - mean) ** 2 for v in vector) / n
# eps 放在根号里面,和题面公式 sqrt(Var + eps) 完全一致。
std = math.sqrt(variance + LAYER_NORM_EPS)
return [(v - mean) / std for v in vector]
def multi_head_attention(x):
"""多头自注意力。权重矩阵都是单位矩阵,所以 Q = K = V = x。"""
scale = math.sqrt(D_K)
output = [[0.0] * D_MODEL for _ in range(SEQ_LEN)]
# 按特征区间 [t*d_k, (t+1)*d_k) 逐个处理每个头。
for head in range(NUM_HEADS):
lo = head * D_K
hi = lo + D_K
for i in range(SEQ_LEN):
# 第 i 个位置在该头内的 query 与所有位置的 key 做点积并缩放。
scores = []
for j in range(SEQ_LEN):
dot = 0.0
for t in range(lo, hi):
dot += x[i][t] * x[j][t]
scores.append(dot / scale)
# 减行最大值只改变数值稳定性,不改变 softmax 的结果。
max_score = max(scores)
exp_scores = [math.exp(s - max_score) for s in scores]
total = sum(exp_scores)
# 用注意力权重对本头的 V 加权求和,写回拼接结果的对应区间。
for j in range(SEQ_LEN):
weight = exp_scores[j] / total
for t in range(lo, hi):
output[i][t] += weight * x[j][t]
return output
def encoder_layer(x):
"""Post-LN 编码器层:MHSA 子层 + FFN 子层,各带残差与层归一化。"""
# 子层 1:多头自注意力。W_O 是单位矩阵,所以拼接结果直接就是输出。
attention = multi_head_attention(x)
first = [
layer_norm([x[i][d] + attention[i][d] for d in range(D_MODEL)])
for i in range(SEQ_LEN)
]
# 子层 2:W_1 = W_2 = I、b_1 = b_2 = 0,因此 FFN(y) = ReLU(y)。
ffn = [[max(0.0, v) for v in row] for row in first]
second = [
layer_norm([first[i][d] + ffn[i][d] for d in range(D_MODEL)])
for i in range(SEQ_LEN)
]
return second
def format_number(value: float) -> str:
"""保留两位小数,并把极其接近 0 的负数归零,避免输出 -0.00。"""
if abs(value) < 0.005:
value = 0.0
return f"{value:.2f}"
def solve():
"""读取 8 个实数,输出编码器层前向计算结果。"""
tokens = sys.stdin.buffer.read().split()
if len(tokens) < SEQ_LEN * D_MODEL:
return
values = [float(t) for t in tokens[:SEQ_LEN * D_MODEL]]
# 按行优先还原成 (seq, d_model) 的二维表:前 4 个是位置 0,后 4 个是位置 1。
x = [values[i * D_MODEL:(i + 1) * D_MODEL] for i in range(SEQ_LEN)]
result = encoder_layer(x)
# 输出同样按行优先平铺,同一个位置的分量挨在一起。
flat = [format_number(v) for row in result for v in row]
print(" ".join(flat))
if __name__ == "__main__":
solve()
NumPy 版本
以下版本使用 NumPy。只有在评测环境允许第三方库时才能提交;CodeFun2000 的普通 Python 环境不一定安装 NumPy。
import sys
import numpy as np
# 题目固定的模型设定
SEQ_LEN = 2 # 序列长度
D_MODEL = 4 # 每个位置的特征维度
NUM_HEADS = 2 # 头数
D_K = D_MODEL // NUM_HEADS # 每头维度 = 2
LAYER_NORM_EPS = 1e-5
def layer_norm(x):
"""在最后一维做层归一化(gamma = 1,beta = 0,总体方差)。
x 的形状是 (seq, d_model),两次归约都在 axis=-1 上完成,
两个位置互不影响,正好对应题面“均值与方差在最后一个维度上统计”。
"""
mean = x.mean(axis=-1, keepdims=True)
# 除以 d_model 而不是 d_model - 1:总体方差
var = ((x - mean) ** 2).mean(axis=-1, keepdims=True)
# eps 放在根号里面,和题面公式 sqrt(Var + eps) 一致
return (x - mean) / np.sqrt(var + LAYER_NORM_EPS)
def multi_head_attention(x):
"""多头自注意力;权重矩阵都是单位矩阵,所以 Q = K = V = x。
把 (seq, d_model) 看成 (seq, h, d_k) 再转置成 (h, seq, d_k):
头维提到最前面之后,每个头就是一张独立的 (seq, d_k) 矩阵,
后面两次矩阵乘可以一次算完所有头。
"""
qkv = x.reshape(SEQ_LEN, NUM_HEADS, D_K).transpose(1, 0, 2) # (h, seq, d_k)
# 每个头的分数矩阵 (seq, seq):Q K^T / sqrt(d_k)
scores = (qkv @ qkv.transpose(0, 2, 1)) / np.sqrt(D_K)
# 减行最大值只改变数值稳定性,不改变 softmax 的结果;
# 不这么做的话 exp 会溢出(本题分数最大约 3535)。
scores -= scores.max(axis=-1, keepdims=True)
weights = np.exp(scores)
# 在序列维(每一行)上归一化
weights /= weights.sum(axis=-1, keepdims=True)
heads = weights @ qkv # (h, seq, d_k)
# 拼回 d_model:先还原成 (seq, h, d_k),再按最后一维展平;
# W_O = I,所以拼接结果就是注意力的输出。
return heads.transpose(1, 0, 2).reshape(SEQ_LEN, D_MODEL)
def encoder_layer(x):
"""Post-LN 编码器层:MHSA 子层 + FFN 子层,各带残差与层归一化。"""
attention = multi_head_attention(x)
first = layer_norm(x + attention)
# FFN 的权重是单位矩阵、偏置为零,于是 FFN(y) = ReLU(y)
second = layer_norm(first + np.maximum(first, 0.0))
return second
def format_number(value):
"""保留两位小数,并把极其接近 0 的负数归零,避免输出 -0.00。"""
if abs(value) < 0.005:
value = 0.0
return f"{value:.2f}"
def solve():
"""读取 8 个实数,输出编码器层前向计算结果。"""
tokens = sys.stdin.buffer.read().split()
if len(tokens) < SEQ_LEN * D_MODEL:
return
# 按行优先还原成 (seq, d_model):前 4 个是位置 0,后 4 个是位置 1
x = np.fromiter(map(float, tokens[:SEQ_LEN * D_MODEL]),
dtype=np.float64, count=SEQ_LEN * D_MODEL)
x = x.reshape(SEQ_LEN, D_MODEL)
result = encoder_layer(x)
# 输出同样按行优先平铺(ravel 就是 C 序展开)
print(" ".join(format_number(v) for v in result.ravel().tolist()))
if __name__ == "__main__":
solve()
示例推演
样例 1
输入的两个位置是 、。
第 0 个头(特征区间 ):。
缩放后的分数矩阵:对角线为 ,另一侧为 :
每行做 softmax,,:
用 对 加权:
第 1 个头(特征区间 ):两个位置在该区间上都是 ,分数全为 0,softmax 得到均匀权重 ,但 也是 0,所以该头输出全 0。
拼接后就是 的注意力输出:
残差 + 第一次 LN:
以第一行为例:均值 ,偏差为 ,
标准差 ,把偏差逐个除以它:
第二行是第一行的镜像(前两维互换),结果同理。
FFN(ReLU) 只保留正分量:
残差 + 第二次 LN:第一行的残差结果为
均值 ,方差 ,标准差 :
四舍五入后为 。第二行镜像,于是输出:
1.73 -0.55 -0.59 -0.59 -0.55 1.73 -0.59 -0.59
注意第一次 LN 的第二维是 ,而最终输出是 。差异来自第二次 LN:ReLU 只留下第一维,残差里其余分量(包括这一维)保持原值,于是整体均值和方差都变了,这一维会被重新标准化一次。
样例 2
两个位置是 与 ,两个头的输入完全相同。
两个头都得到分数矩阵
softmax 权重为 交错,两个头的输出相同:
拼接后每个位置是 这样的交错图案。
残差 + 第一次 LN:第一行
均值恰为 ,四个偏差是 ,方差 ,标准差 ,于是
ReLU 只留下两个正分量:。
残差 + 第二次 LN:第一行残差为
均值 ,偏差 ,标准差 ,归一化后仍是 ,四舍五入输出:
1.00 -1.00 1.00 -1.00 -1.00 1.00 -1.00 1.00
复杂度分析
| 解法 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 直译通用矩阵乘法 | 需要处理 的三维形状与通用矩阵乘 | ||
| 消掉单位矩阵、按区间切头 | 本题采用;不做投影与输出投影 | ||
| 固定形状下 | 、、 都是常数 |
本题的规模完全固定,所以运行时间与输入数值无关,只有几十次浮点运算。
易错点总结
1. Post-LN 与 Pre-LN 的顺序不能混
本题是 Post-LN:先残差相加,再 LayerNorm。写成 就是 Pre-LN,数值完全不同。题面还特别提示“拿掉残差或改成 Pre-LN,第一维不会落到 ”。
2. 方差要除以 , 放在根号内
题目要求总体方差(除以 ),不是样本方差(除以 )。公式是 ,不是 。 很小,但恰好会在最后一位上体现出来。
3. 多头是“按区间切”,不是“隔一个取一个”
第 个头取特征区间 。本题第 0 个头是第 0、1 维,第 1 个头是第 2、3 维。如果按奇偶交错切,两个头的点积结果会完全不同。
4. softmax 在序列维上做
分数矩阵是 ,每一行(一个 query 对所有 key)单独归一化。若错误地按列归一化,权重之和不等于 1,输出会偏大或偏小。
5. softmax 要先减行最大值
对大的分数会溢出。减去该行最大值不改变结果,却能保证最大的指数项是 。
6. ReLU 作用在第一次 LN 的输出上,不是原始输入
FFN 的输入是第一个子层的输出 ,所以 ReLU 处理的是 ,残差加的也是 ,即 。
7. 输出顺序与 -0.00
输出必须按行优先平铺:位置 0 的 4 个数在前。浮点误差可能产生绝对值极小但符号为负的数,直接格式化会得到 -0.00,需要先把接近 0 的值归零。
8. 不要把“单位矩阵”理解成“可以跳过拼接”
只说明“拼接后不需要再做线性变换”,两个头仍然要按区间拼回 4 维。漏掉任何一个头,维度就对不上。
扩展思考
- 如果改成 Pre-LN:结构变为 、,训练更稳定,但推理时的数值与本题不同。把
encoder_layer里的残差与 LN 顺序对调即可验证。 - 如果权重不是单位矩阵:只需在切头之前先算 、、,拼接之后再乘 。代码结构不用变,只是多几次 矩阵乘。
- 如果 :每个样本独立走同一套计算,把外层循环加上 batch 维即可;注意力不会跨样本。
- 如果换成 GELU:只改 FFN 的激活函数,,其余流程不变。
- 数值稳定性:本题数据 ,分数最大约 , 必然溢出,所以“减最大值”这一步不是可选项。