📑 本页目录(点开跳转)
挑战项目 C · 迷你生成式推荐:在笔记本上复现 2026 前沿
⏱ 7–10 天 | 难度 ★★★★★ | 前置:第 5、9、14 节吃透 + 项目二 + 会 PyTorch
🎯 为什么选这个项目(它的「特点」)
第 14 节讲的生成式推荐(Semantic ID + 生成式召回)听起来遥不可及——万亿参数、千卡 GPU。 但它的核心机制完全可以在笔记本上复现。这个项目就是造一个「TIGER-mini」:
你要亲手实现的三个前沿组件:
① RQ-VAE:把电影的内容向量量化成 3 层语义码字
《星际穿越》 → <c₁=12, c₂=47, c₃=201>
《盗梦空间》 → <c₁=12, c₂=47, c₃=88> ← 前两层相同=模型知道它们相似!
② 生成式召回:一个小型 GPT,输入用户看过的语义 ID 序列,
逐 token"写出"下一部电影的语义 ID
③ Trie 约束解码:保证生成的 ID 是真实存在的电影
(否则模型会"幻觉"出不存在的电影)
最终考试:
🥊 和你自己训的 SASRec 打一架(Recall@10)
❄️ 冷启动挑战赛:故意藏起 5% 的电影不参与训练,
看谁能把它们推出来 —— 生成式的招牌优势在这里兑现
做完它你会得到什么:对 2026 年最热方向的肌肉记忆级理解。读 HSTU/OneRec/TIGER 论文时,别人看到的是名词,你看到的是自己调过的超参。这个项目放在简历上,是「跟进前沿」最硬的证据。
🗺️ 全景架构
🧠 ADHD 任务切分
第一阶段:内容向量 + 基线(Day 1–2)
- [ ] T1 (60min) MovieLens-1M:解析
movies.dat(标题+年份+类型),构建每部电影的文本描述 - [ ] T2 (60min) 内容向量:TF-IDF → TruncatedSVD 到 128 维(想上难度就用
sentence-transformers,384 维) - [ ] T3 (90min) 先训一个 SASRec 基线(第 9 节代码直接搬),Leave-One-Out 评估 Recall@10 / NDCG@10。这是你的靶子,后面一切和它比
- [ ] T4 (30min) 准备冷启动赛道:随机抽 5% 电影,从训练序列里整体删除(它们只出现在测试目标里)
第二阶段:RQ-VAE —— 项目最难的部分(Day 3–5)
- [ ] T5 (120min) 实现 RQ-VAE(下面有完整骨架):编码器 → 3 层残差量化 → 解码器重建
- [ ] T6 (90min) 训练并debug。必查三件事:重建损失下降;码字使用率(每层多少码字被用到);同一码字下的电影是否语义相近(抽查打印!)
- [ ] T7 (60min) 治「码本坍塌」:如果某层 80% 电影挤在 20 个码字里 → 上 EMA 更新 + 死码字重启(骨架里已给)
- [ ] T8 (45min) 生成语义 ID 表:
movie_id → (c₁,c₂,c₃,dedup)。统计冲突率(多少电影共享前 3 层码字),加第 4 位去重
第三阶段:生成模型(Day 6–7)
- [ ] T9 (60min) 把用户序列翻译成 token 流。⭐ 词表设计:每层码本独立编号(层1: 0-255, 层2: 256-511, 层3: 512-767, 去重位: 768+),外加 PAD/BOS
- [ ] T10 (90min) 训小型 GPT(就是第 9 节 SASRec 的骨架,只是序列换成语义 token,预测目标是 next-token)
- [ ] T11 (90min) 实现 Trie + 受约束 Beam Search(下面有骨架)
- [ ] T12 (60min) 端到端评估:生成 top-10 → Recall@10 / NDCG@10,和 T3 的 SASRec 对比
第四阶段:决战冷启动 + 报告(Day 8–10)
- [ ] T13 (60min) 冷启动赛道评估:那 5% 被藏起来的电影,SASRec 的 Recall 是多少?(剧透:≈0,它们的 ID Embedding 没训过)你的生成式模型呢?(新电影一编码就有语义 ID,理论上能被生成出来)
- [ ] T14 (45min) 消融:把语义 ID 换成随机 ID(打乱电影→码字的映射)重训,看指标掉多少 → 证明「语义」本身在起作用,这是整个项目最有说服力的实验
- [ ] T15 (90min) README:架构图、三组对比(SASRec vs 生成式 vs 随机ID消融)、冷启动对决、码本可视化(每个 c₁ 码字下的电影类型分布)
🔨 核心代码骨架
RQ-VAE(带 EMA 更新和死码字重启)
import torch, torch.nn as nn, torch.nn.functional as F
class VQLayer(nn.Module):
"""一层向量量化:找最近的码字,EMA 更新码本"""
def __init__(self, n_codes=256, dim=64, decay=0.99, eps=1e-5):
super().__init__()
self.decay, self.eps = decay, eps
self.register_buffer("codebook", torch.randn(n_codes, dim) * 0.1)
self.register_buffer("cluster_size", torch.zeros(n_codes))
self.register_buffer("embed_avg", self.codebook.clone())
def forward(self, x): # x: (B, D)
d = (x.pow(2).sum(1, keepdim=True)
- 2 * x @ self.codebook.T
+ self.codebook.pow(2).sum(1)) # (B, n_codes) 欧氏距离
idx = d.argmin(1) # 每个样本最近的码字
quant = self.codebook[idx]
if self.training:
onehot = F.one_hot(idx, self.codebook.size(0)).float()
# EMA 统计
self.cluster_size.mul_(self.decay).add_(onehot.sum(0), alpha=1-self.decay)
self.embed_avg.mul_(self.decay).add_(onehot.T @ x, alpha=1-self.decay)
n = self.cluster_size.sum()
smoothed = (self.cluster_size + self.eps) / (n + self.codebook.size(0)*self.eps) * n
self.codebook.copy_(self.embed_avg / smoothed.unsqueeze(1))
# ⭐ 死码字重启:长期没人用的码字,随机跳到一个真实样本上
dead = self.cluster_size < 0.01
if dead.any():
rand = x[torch.randint(0, x.size(0), (int(dead.sum()),))]
self.codebook[dead] = rand
# straight-through:前向用量化值,反向梯度直通
return x + (quant - x).detach(), idx
class RQVAE(nn.Module):
def __init__(self, in_dim=128, latent=64, n_layers=3, n_codes=256):
super().__init__()
self.enc = nn.Sequential(nn.Linear(in_dim, 256), nn.ReLU(), nn.Linear(256, latent))
self.dec = nn.Sequential(nn.Linear(latent, 256), nn.ReLU(), nn.Linear(256, in_dim))
self.vqs = nn.ModuleList([VQLayer(n_codes, latent) for _ in range(n_layers)])
def forward(self, x):
z = self.enc(x)
residual, quant_sum, codes, commit = z, 0, [], 0.0
for vq in self.vqs: # ⭐ 残差量化:逐层逼近
q, idx = vq(residual)
quant_sum = quant_sum + q
commit = commit + F.mse_loss(residual, q.detach())
residual = residual - q.detach()
codes.append(idx)
recon = self.dec(quant_sum)
loss = F.mse_loss(recon, x) + 0.25 * commit
return loss, torch.stack(codes, dim=1) # (B, n_layers)
# 训练循环:普通 Adam,几千步就收敛(数据只有几千部电影)
# 训完:_, codes = model(content_vecs) → 每部电影 3 个码字
Trie 约束 Beam Search
class Trie:
"""所有合法电影的语义 ID 前缀树"""
def __init__(self):
self.root = {}
def insert(self, tokens, movie_id):
node = self.root
for t in tokens:
node = node.setdefault(t, {})
node["_end"] = movie_id
def valid_next(self, prefix):
node = self.root
for t in prefix:
node = node.get(t)
if node is None: return []
return [k for k in node if k != "_end"]
@torch.no_grad()
def constrained_beam_search(model, user_tokens, trie, n_steps=4, beam=20, topk=10):
"""从用户历史 token 流出发,生成 top-k 合法语义 ID"""
beams = [(user_tokens, 0.0)] # (序列, 累计 log 概率)
for step in range(n_steps):
cand = []
for seq, lp in beams:
logits = model(torch.tensor([seq]))[0, -1] # 最后位置的预测
prefix = seq[len(user_tokens):] # 已生成的部分
allowed = trie.valid_next(prefix) # ⭐ 只许走合法分支
if not allowed: continue
logp = torch.log_softmax(logits, -1)
for t in allowed:
cand.append((seq + [t], lp + logp[t].item()))
beams = sorted(cand, key=lambda x: -x[1])[:beam]
results = []
for seq, lp in beams:
node = trie.root
for t in seq[len(user_tokens):]: node = node[t]
results.append((node["_end"], lp)) # 反查电影 ID
return [m for m, _ in results[:topk]]
🕳️ 这个项目专属的坑
| 坑 | 症状 | 解法 |
|---|---|---|
| 码本坍塌 ⭐ 必遇 | 某层 90% 样本挤进几个码字,语义 ID 全长一样 | EMA 更新 + 死码字重启(骨架已带);降低该层学习率;先用 K-Means 初始化码本 |
| 冲突率过高 | 30%+ 电影共享同一个 (c₁,c₂,c₃) | 加码字数(256→512)或加一层;剩余冲突用第 4 位流水号去重 |
| 生成模型学会了"作弊" | 只会生成最热门电影的 ID | 训练时对热门 target 降采样;检查 beam search 是不是 beam 太小 |
| 词表设计错误 | 三层码字混用同一编号区间,模型分不清层 | 每层独立偏移(层2 = 码字号+256),这是最容易犯的低级错误 |
| 冷启动评估作弊 | 藏起来的电影其实还在 RQ-VAE 训练集里? | 没关系——这正是设计:内容向量任何时候可得(新电影一上架就有简介),要藏的是行为序列 |
| 和 SASRec 比输了 | 很正常! | 小数据上生成式往往打不过 SASRec(第 14 节说过它需要规模)。赢的应该是冷启动赛道——这才是这个项目要验证的命题 |
🔑 预期管理:这个项目的成功标准不是总体指标超过 SASRec(大概率超不过,论文里也要大数据才行)。 成功标准是:① 三个组件都真的工作 ② 冷启动赛道显著胜出 ③ 随机 ID 消融证明语义在起作用。 能把「为什么小数据上打不过」讲清楚,恰恰证明你懂了第 14 节。
✅ 通关标准(Definition of Done)
- RQ-VAE 码字使用率各层 > 50%,抽查同码字电影语义确实相近(放 3 个例子进 README)
- 受约束解码生成的 ID 100% 合法(0 幻觉)
- 总体赛道:生成式 Recall@10 达到 SASRec 的 70%+(能到 90% 就很优秀)
- 冷启动赛道:生成式显著 > SASRec(SASRec 对没见过的 item 接近 0)
- 随机 ID 消融:语义 ID 比随机 ID 的指标高(证明语义在起作用)
🏆 拉开差距的加分项
- 把用户行为 token 和语义 token 混合建模(加入「评分高/低」action token)——这就摸到 HSTU 的门了
- 对比两种内容向量(TF-IDF vs sentence-transformers)对码本质量和最终指标的影响
- 实现层级分析:只用 c₁ 生成(粗粒度)vs 用满 3 层,观察召回率-多样性的变化
- 读 TIGER 论文 和 HSTU 论文,在 README 里写一段「我的迷你版和原版的差距在哪」
⚡ 走神救援
我在造 TIGER-mini:① RQ-VAE 把电影内容向量量化成 3 层码字(EMA+死码字重启防坍塌)② 小 GPT 在语义 token 流上做 next-token 训练(每层码字独立编号区间!)③ Trie 约束 beam search 保证零幻觉。评估两个赛道:总体(打平 SASRec 七成就算赢)+ 冷启动(必须显著赢,这是生成式的招牌)。随机 ID 消融证明语义在起作用。
🎓 三个挑战项目做完之后
你已经覆盖了推荐系统的三大核心矛盾: - A(新闻):内容 vs 行为 —— 极端冷启动下内容理解的价值 - B(模拟器):短期 vs 长期 —— 反馈循环、茧房、探索的动态本质 - C(生成式):检索 vs 生成 —— 2026 范式转变的第一手体感
这三个项目 + 第 16 节的三个基础项目,就是一份完整的作品集。 回到 17-面试与求职 把它们包装成故事,或者回 README 看看还有什么没打勾。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 全景导论 02 | 这个项目要手搓的 Decoder,那一章有注意力和位置编码的完整拆解 |
| 全景导论 03 | Semantic ID 就是推荐场景的 tokenizer;码本坍塌和词表设计是同一类问题 |
| 强化学习 13 | 想给生成结果加偏好对齐时,DPO 比 RLHF 更适合单机复现 |
✅ 检查点
- 「TIGER-mini」要亲手实现的三个前沿组件是什么?
- RQ-VAE 做的事用一句话怎么说?为什么「前两层码字相同」是有意义的?
- 为什么必须有 Trie 约束解码?没有它会怎样?
- 这个项目的两场「考试」分别是什么?哪一场是生成式的招牌优势?
- 冷启动赛道具体怎么设置?
- 为什么说生成式召回在冷启动上有结构性优势?
- 做完这个项目,你读 HSTU/OneRec/TIGER 论文时会有什么不同?
👀 答案
- ①RQ-VAE(把内容向量量化成多层语义码字)②生成式召回(小型 GPT 逐 token 写出下一部电影的语义 ID)③Trie 约束解码(保证生成的 ID 真实存在)。
- 把连续的内容向量压缩成一串离散的、有层次的码字(如《星际穿越》→ ⟨12, 47, 201⟩)。前两层相同意味着模型认为这两部电影在粗粒度语义上属于同一类——语义 ID 天然带有层次结构,相似的物品共享前缀,这正是它比随机 ID 强的地方。
- 因为模型会「幻觉」出不存在的电影——它是逐 token 生成的,完全可能拼出一个合法但没有对应物品的码字组合。Trie 约束保证每一步只能走真实存在的分支。
- ①和自己训的 SASRec 打 Recall@10 ②冷启动挑战赛。第二场是招牌优势——生成式方法在冷启动上的结构性优势正是它最被看好的理由。
- 随机抽 5% 的电影,从训练序列里整体删除,它们只出现在测试目标里。这样这些电影对模型来说是完全没见过的。
- 因为新物品的语义 ID 是由内容算出来的,不需要行为数据。一部新电影一旦有了元数据就有了 ⟨c₁,c₂,c₃⟩,而且它和相似老电影共享前缀——模型学到的「看完 A 常看 B」的规律会自动泛化到它身上。传统 ID Embedding 则必须等行为数据积累。
- 别人看到的是名词,你看到的是自己调过的超参。论文里的每个组件你都实现过、踩过它的坑、知道它在什么规模下才开始有用。
🛑 可以停在这里
⚡ 走神救援
⭐在笔记本上复现 2026 前沿:造一个 TIGER-mini,三个组件——①RQ-VAE:把内容向量量化成多层语义码字(《星际穿越》→⟨12,47,201⟩),前两层相同 = 模型认为它们粗粒度语义同类,相似物品天然共享前缀②生成式召回:小型 GPT 逐 token 写出下一部电影的语义 ID③⚠️Trie 约束解码——必须有,否则模型会「幻觉」出不存在的电影(逐 token 生成完全可能拼出合法但无对应物品的组合)。两场考试:和自己训的 SASRec 打 Recall@10;⭐冷启动挑战赛(随机藏起 5% 电影不参与训练)——这是生成式的招牌优势,因为新物品的语义 ID 由内容算出、不需要行为数据,且与相似老物品共享前缀,模型学到的规律会自动泛化过去。⭐ 做完之后读 HSTU/OneRec/TIGER 论文,别人看到的是名词,你看到的是自己调过的超参。