查看数据文件并直接输出结果的行为将被判为作弊。
这是一道工程题,你不需要自己设计算法,只需要实现我们描述的算法。
作为一个中 V 厨你当然认识天依和阿绫的所有歌曲啦,那就写个程序来识别它们吧!
具体的,你需要读取一段歌词(含错字,各种格式的分割标点,歌词截断,错位等),然后输出歌名。
保证歌曲都是中 V 歌曲,歌词足够有辨识度保证人类能够顺利识别。
太难了?没关系,我们预训练了一个 Transformer,它真的认识天依和阿绫的所有歌。
你需要实现一个程序,从标准输入读取模型权重和测试歌词,对每条歌词输出对应的歌曲名称(与模型内置的歌曲名列表完全一致)。
不会 Transformer?Nevermind! 我们下发了 《Attention Is All You Need》 的原文 PDF,还给了一份笨蛋都看得懂的解释,加油吧,少女!
附件提供了样板代码用于输入,建议直接复制样板代码。
输入由二进制段和文本段两部分从标准输入依次读入。
二进制段(从输入开头开始,长度由数据自描述)包含模型权重、Tokenizer 词表和歌曲名列表。文本段(紧跟在二进制段之后)包含待识别的歌词片段:
T
lyrics_1
lyrics_2
...
lyrics_T
第一行为测试数量 ,接下来 行每行一条歌词片段(歌词中的换行符转义为字面量 \n)。
二进制段的详细格式见下方"输入格式与接口文档"一节。
行,每行一个歌曲名称(UTF-8 编码),与输入二进制段中 Song Names 部分的条目完全匹配。
[二进制段: Header + Tokenizer + Song Names + Weights, 详见格式文档]
5
我仍然在,无人问津的阴雨霉湿之地,和着雨音,唱着没有听众的歌曲。
Xngsagh我仍然在无人问津的英语没454事之地 或者语音,唱着没有听众nxzh的歌曲。
夏夜空,寻觅着,色彩褪去
星点的回音汇成声浪强烈的力量,将心房不断叩响
我愿把记忆燃成灯火寻遍这星空,乘夜风飘飞挣脱万有引力的枷锁。冲破空间囚笼,跨越时间洪流。逃出虚妄和无奈编制的黑洞。
世末歌者
世末歌者
世末歌者
回音
寻遍星空
example.cpp / example.py 从标准输入一次性读取全部数据(二进制段 + 文本段)。可以用 freopen("input.bin", "rb", stdin); 重定向标准输入来测试样例是否能正确读取。example_mix.cpp 从文件读模型权重,从标准输入只读歌词文本。适合本地调试。(cat model.bin; printf '1\n你的歌词\n') | ./solution 可以进行交互式测试。下发的 down.zip 中包含:
sample/
├── model.bin 纯模型数据 (文件读取方式使用)
├── input.bin 完整样例输入 (二进制段 + 文本段,标准输入方式使用)
└── output.txt 样例标准输出 (10行歌名)
example.cpp C++ 样板程序 — 全部从 stdin 读取 (自包含)
example.py Python 样板程序 — 全部从 stdin 读取 (自包含)
example_mix.cpp C++ 样板程序 — 文件读权重 + stdin 读歌词 (自包含)
Attention is All You Need.pdf 原始论文参考
方式一:全部从标准输入读取(对应 example.cpp)
评测时输入文件即为 input.bin 格式(二进制段 + 文本段拼接)。本地测试:
./solution < sample/input.bin
# 或在代码开头加:
# freopen("sample/input.bin", "rb", stdin);
方式二:文件读权重 + 标准输入读歌词(对应 example_mix.cpp)
将 model.bin 放在同目录,标准输入只传歌词文本。本地测试:
example_mix.cpp 加载模型后会输出验证信息到 stderr:
Model loaded: 387 songs, hidden=192, 4 layers, vocab=11592
song[0]="我的悲伤是水做的", song[386]="天魔策"
tok_emb[5][0]=0.481048, song_queries[0][0]=0.149305
2 test(s), first tokenized to 15 ids
看到以上输出即表示模型文件读取成功。
从标准输入正常输入歌曲数量后,每行输入对应歌词即可。
本题使用的模型是一个基于 Transformer Encoder 的歌曲识别网络。输入为歌词片段的 token 序列,输出为歌曲编号。
本模型基于 Attention Is All You Need (Vaswani et al., 2017) 中的 Encoder 部分,有以下修改:
| 原文 | 本模型 | |
|---|---|---|
| 结构 | Encoder-Decoder | 仅 Encoder + 分类头 |
| 层数 | 6 | 4 |
| 隐层维度 d | 512 | 192 |
| 注意力头数 h | 8 (d_k=64) | 4 (d_k=48) |
| FFN 中间维度 | 2048 | 768 |
| 位置编码 | 固定正弦/余弦 | 可学习嵌入 |
| LayerNorm | Post-Norm | Pre-Norm |
| FFN 激活 | ReLU | GELU |
token_ids [seq_len]
│
▼
tok_emb[token_id] + pos_emb[position] → x: [seq_len, 192]
│
▼ ×4 层
┌─────────────────────────────────────┐
│ residual = x │
│ x = LayerNorm(x) │
│ x = MultiHeadSelfAttention(x) │
│ x = residual + x │
│ │
│ residual = x │
│ x = LayerNorm(x) │
│ x = FFN(x) │
│ x = residual + x │
└─────────────────────────────────────┘
│
▼
x = LayerNorm(x) → H: [seq_len, 192] (Encoder 输出)
│
▼
Cross-Attention 打分 → scores: [num_songs]
│
▼
argmax(scores) → 预测歌曲编号
x[t] = tok_emb[token_id[t]] + pos_emb[t]
tok_emb: [vocab_size, 192] 查表pos_emb: [max_seq_len, 192] 查表将任意实数向量归一化为概率分布(所有元素非负且和为 1):
softmax(x)[i] = exp(x[i]) / Σ_j exp(x[j])
实现时为避免浮点溢出,先减去最大值:
m = max(x[0], x[1], ..., x[n-1])
e[i] = exp(x[i] - m)
softmax(x)[i] = e[i] / Σ_j e[j]
对一个长度为 d 的向量做归一化,使其均值为 0、方差为 1,再用可学习参数缩放和偏移:
mean = (1/d) * Σ x[i] // i = 0..d-1
var = (1/d) * Σ (x[i] - mean)² // i = 0..d-1
out[i] = (x[i] - mean) / sqrt(var + 1e-5) * w[i] + b[i]
其中 w 和 b 是可学习的一维向量(从权重文件读入)。
注意:本模型使用 Pre-Norm,即先 LayerNorm 再做 Attention/FFN,然后加残差。与原文的 Post-Norm(先做 Attention/FFN,加残差后再 LayerNorm)不同。
4 个头,每头维度 d_k = 48。
对序列中的每个 token(行向量 x[t],长度 192),做线性投影得到 Q、K、V:
对 t = 0..seq_len-1:
Q[t] = Wq @ x[t] // Q[t] 是长度 192 的行向量
K[t] = Wk @ x[t]
V[t] = Wv @ x[t]
即 Q[t][i] = Σ_j Wq[i][j] * x[t][j]。
对每个 head h (h = 0..3),取 Q/K/V 中对应的 48 维切片:
Q_h[t] = Q[t][h*48 .. h*48+47]
K_h[t] = K[t][h*48 .. h*48+47]
V_h[t] = V[t][h*48 .. h*48+47]
每个注意力头的缩放点积注意力:
attn[i][j] = (Q_h[i] · K_h[j]) / sqrt(48)
attn[i] = softmax(attn[i]) // 对 j 维 softmax
out_h[i] = Σ_j attn[i][j] * V_h[j]
拼接所有 head 后做输出投影:
concat[t] = [out_0[t], out_1[t], out_2[t], out_3[t]] // 192 维
output[t] = Wo @ concat[t]
权重矩阵存储:Wq, Wk, Wv, Wo 均为 [192][192] 的二维数组。乘法统一为 y = W @ x,即 y[i] = Σ_j W[i][j] * x[j]。
对每个 token 独立执行:
对 t = 0..seq_len-1:
h[t] = ffn_w1 @ x[t] + ffn_b1 // h[t] 长度 768
h[t] = GELU(h[t])
y[t] = ffn_w2 @ h[t] + ffn_b2 // y[t] 长度 192
即 h[t][i] = Σ_j ffn_w1[i][j] * x[t][j] + ffn_b1[i]。
其中 GELU 近似实现为:
GELU(x) = 0.5 * x * (1 + tanh(0.7978845608 * (x + 0.044715 * x³)))
Encoder 4 层结束后,对输出再做一次 LayerNorm:
H[t] = LayerNorm(x[t], final_norm_w, final_norm_b)
模型有 387 个可学习的 song_query 向量,每个 192 维,存储为 song_queries[num_songs][hidden]。通过单头 cross-attention 汇聚 Encoder 输出来计算每首歌的得分。
// K、V 对所有歌曲共享,只需计算一次
对 t = 0..seq_len-1:
K[t] = cross_Wk @ H[t] // K[t] 长度 192
V[t] = cross_Wv @ H[t] // V[t] 长度 192
对每首歌 s = 0..386:
Q = cross_Wq @ song_queries[s] // Q 长度 192
对 j = 0..seq_len-1:
attn[j] = (Q · K[j]) / sqrt(192) // 点积再除以 sqrt(192)
attn = softmax(attn)
z = Σ_j attn[j] * V[j] // z 长度 192
score[s] = song_queries[s] · z // 标量,注意这里用的是原始 song_queries[s],非投影后的 Q
最终预测 = argmax(score),对应 song_names[预测编号]。
1/sqrt(192)song_queries[s] 在 Q 投影前和点积打分时使用的是同一个向量(原始的,非投影后的)y = W @ x 约定:y[i] = Σ_j W[i][j] * x[j]| 部分 | 参数量 |
|---|---|
| Token Embedding | 2,225,664 |
| Position Embedding | 98,304 |
| Encoder (4层) | 1,776,384 |
| Final LayerNorm | 384 |
| Song Queries | 74,304 |
| Cross-Attention (Wq, Wk, Wv) | 110,592 |
| 总计 | 4,285,632 |
输入由二进制段和文本段两部分拼接而成:
┌─────────────────────────────────────┐
│ Binary: Header + Tokenizer + │
│ Song Names + Weights │ ← 自描述长度, 解析后自动结束
├─────────────────────────────────────┤
│ Text: T │ ← 测试数量
│ lyrics_1 │ ← 每行一条歌词 (换行转义为 \n)
│ lyrics_2 │
│ ... │
└─────────────────────────────────────┘
T 行, 每行一个歌曲名称 (UTF-8), 与 song_names 中的条目完全匹配。
将 model.h 的内容复制粘贴到你代码的开头,然后在其后编写推理逻辑:
// ← 这里是 model.h 的全部内容(已粘贴)
int main() {
Model m;
m.load(); // 从 stdin 读取二进制段
int T = m.read_num_tests(); // 读取测试数量
for (int i = 0; i < T; i++) {
string lyrics = m.read_test(); // 读取一条歌词 (自动 unescape)
vector<int> ids = m.tokenize(lyrics); // 歌词 → token id 序列
int idx = predict(m, ids); // 你的推理函数
printf("%s\n", m.song_names[idx].c_str());
}
}
编译: g++ -O2 -o solution solution.cpp
将 model.py 的内容复制粘贴到你代码的开头,然后在其后编写推理逻辑:
# ← 这里是 model.py 的全部内容(已粘贴)
def predict(m, ids):
# 你的推理函数
return 0
m = Model()
m.load()
tests = m.read_tests()
for lyrics in tests:
ids = m.tokenize(lyrics)
idx = predict(m, ids)
print(m.song_names[idx])
运行: python solution.py < input.bin
所有数据按 row-major 顺序连续存储为 float32。
一维向量 v[n]:n 个连续 float,v[i] = 第 i 个元素。
二维矩阵 M[rows][cols]:rows × cols 个连续 float,M[i][j] = flat[i * cols + j]。
序列中的每个 token 是一个行向量,维度为 hidden (192)。
序列 x[seq_len][hidden] 中,x[t][i] 表示第 t 个 token 的第 i 维分量。
所有权重矩阵 W 的形状为 [out_dim][in_dim],乘法定义为:
y = W @ x
即 y[i] = Σ_j W[i][j] * x[j]
其中 x 是长度为 in_dim 的行向量,y 是长度为 out_dim 的行向量。
对序列中的每个 token 独立执行相同的变换:
对 t = 0..seq_len-1:
y[t][i] = Σ_j W[i][j] * x[t][j]
| 字段 | 含义 | 示例值 |
|---|---|---|
| num_songs | 歌曲总数 | 387 |
| vocab_size | 词表大小 (含特殊 token) | 11592 |
| max_seq_len | 最大序列长度 | 512 |
| hidden | 隐层维度 | 192 |
| num_heads | 注意力头数 | 4 |
| num_layers | Encoder 层数 | |
| ffn_size | FFN 中间维度 | 768 |
| num_tokens | Tokenizer 词条数 | 11494 |
每条: int32 token_id + int32 byte_length + byte_length 字节 UTF-8
每条: int32 byte_length + byte_length 字节 UTF-8
按以下固定顺序排列,每个矩阵按 row-major 展开为连续 float32:
| # | 字段名 | 形状 [rows][cols] | 语义 |
|---|---|---|---|
| 1 | tok_emb | [vocab_size][hidden] | tok_emb[token_id] = 该 token 的行向量 |
| 2 | pos_emb | [max_seq_len][hidden] | pos_emb[pos] = 该位置的行向量 |
| — | 每层 (× num_layers): | ||
| 3 | layers[l].norm1_w | [hidden] | LayerNorm 缩放 |
| 4 | layers[l].norm1_b | LayerNorm 偏置 | |
| 5 | layers[l].Wq | [hidden][hidden] | y = Wq @ x |
| 6 | layers[l].Wk | y = Wk @ x | |
| 7 | layers[l].Wv | y = Wv @ x | |
| 8 | layers[l].Wo | y = Wo @ x | |
| 9 | layers[l].norm2_w | [hidden] | LayerNorm 缩放 |
| 10 | layers[l].norm2_b | LayerNorm 偏置 | |
| 11 | layers[l].ffn_w1 | [ffn_size][hidden] | y = ffn_w1 @ x |
| 12 | layers[l].ffn_b1 | [ffn_size] | FFN 第一层偏置 |
| 13 | layers[l].ffn_w2 | [hidden][ffn_size] | y = ffn_w2 @ x |
| 14 | layers[l].ffn_b2 | [hidden] | FFN 第二层偏置 |
| — | 全局: | ||
| 15 | final_norm_w | [hidden] | 最终 LayerNorm 缩放 |
| 16 | final_norm_b | 最终 LayerNorm 偏置 | |
| 17 | song_queries | [num_songs][hidden] | song_queries[s] = 第 s 首歌的查询行向量 |
| 18 | cross_Wq | [hidden][hidden] | y = cross_Wq @ x |
| 19 | cross_Wk | y = cross_Wk @ x | |
| 20 | cross_Wv | y = cross_Wv @ x | |
| ID | 含义 |
|---|---|
| 0 | PAD |
| 1 | UNK |
| 2 | BOS (tokenize 自动添加) |
| 3 | EOS (tokenize 自动添加) |
| 4 | NL (换行, trie 中以 <NL> 编码) |
输入 lyrics
↓ tokenize
token ids (len ≤ 512)
↓ Embedding (查表)
tok_emb[id] + pos_emb[pos] → x[seq_len][192]
↓ 4× Pre-Norm Transformer Encoder Layer
│ LayerNorm → 4-head Self-Attention (head_dim=48) → Residual
│ LayerNorm → FFN (192→768, GELU, 768→192) → Residual
↓ Final LayerNorm
encoder output H[seq_len][192]
↓ Cross-Attention (单头)
│ K[t] = cross_Wk @ H[t]
│ V[t] = cross_Wv @ H[t]
│ 对每首歌 s:
│ Q = cross_Wq @ song_queries[s]
│ attn = softmax(Q·K^T / √192)
│ z = Σ attn[j] * V[j]
│ score[s] = song_queries[s] · z
↓ argmax(scores)
predicted song index → song_names[index]