ActionPeice-ICML2025-谷歌deepmind-生成式推荐中上下文感知分词技术
文章目录
- 1. 背景与意图
- 2. 方法
- 2.1 问题设定
- 2.2 Contextual Action Sequence Tokenizer
- 2.2.1 构建词表
- 2.2.1.1 初始化
- 2.2.1.2 上下文感知的token共现统计
- 加权共现统计
- 场景设定:两件连续商品
- 为什么要“打平 + 看相邻”?
- 同袋子对的权重
- 跨袋子对的权重
- 累计共现权重
- 在整个语料库里的累计
- 用权重找“下一对要合并的 token”
- 2.2.1.3 迭代合并 token
- 为什么要引入 Intermediate Node ?
- 三种更新场景
- 2.2.1.4 高效实现
- 结果复杂度
- 2.2.2 切分训练/测试序列
- 2.2.2.1 朴素切分
- 2.2.2.2 Set permutation regularization (SPR)
- 2.3 Generative Recommendation Models
- 2.3.1 Training on Augmented Token Sequences
- 2.3.2 Inference-Time Ensembling
- 同样的 SPR 打乱
- 融合
- 2.4 讨论
- 2.4.1 行为序列的顺序
- 为什么袋子里不排顺序?
- 为什么袋子之间一定要排顺序?
- ActionPiece 如何同时兼顾两层顺序?
- 直观例子
- 2.4.2 与 BPE 对比
- 核心区别:
- 为什么“一维 vs. 二维”会让实现全变?
- 例子
- 2.4.3 SPR 的效率
- 训练几乎不变慢:洗牌+分词在 CPU,异步于 GPU 更新
- 推理 FLOPs 的确变多,但延迟基本不涨
- 3. 实验
- 3.1 主实验
- 3.2 消融实验
- 3.3 其他
- 3.3.1 词表大小与效果
- 3.3.2 词表利用率
- 3.3.3 推理时 SPR 洗牌次数
- 4. 总结
这里学习一下 ICML 2025 的 ActionPiece,这是谷歌 DeepMind 的新工作,聚焦于生成式推荐。核心创新点大概是:把“动作序列”当做一串“无序特征集合”来切块(tokenize),并且让同一动作在不同上下文里被切成不同的块。
论文链接: https://arxiv.org/abs/2502.13581
代码链接:https://github.com/google-deepmind/action_piece
1. 背景与意图
现有的生成式推荐会把用户行为序列(比如用户交互过的商品序列)当成 token 串,像 LLM 一样自回归的生成下一个 token,然后解析成推荐结果。这样做可以省掉亿级别商品 Embedding 表,体积小、推理块。
但是现有方法是在“每个动作 --> 一套固定 token”这一步就把序列切完了:
- 先给单个商品贴一堆离散 ID(通过 RQ-VAE/RQ-Kmeans/PQ 等方式吧)。
- 训练时把整串动作替换成这些固定 token。
这样会有的问题就是: token 本身不带 “邻居是谁” 的信息,模型只能靠后续 Transformer 去猜上下文,效果受限。
ActionPiece 的核心思路:
传统做法 | ActionPiece 的变化 | 直观例子 |
---|---|---|
动作为有序ID 列表 | 动作为无序特征集合 | 一双鞋 = {Nike, 红, 男, ¥799},不用管顺序 |
固定拆分:同一商品无论出现在哪都映射到同一 token 串 | 上下文感知拆分:根据前后口袋谁跟谁常一起,动态决定如何合并特征 | 如果上一件是红袜子,耐克+红可能被合成“大 token”;若上一件是黑裤子,耐克+红可能不合并 |
普通 BPE 只看相邻字符 | 两层共现:口袋内 + 相邻口袋 | 红鞋里的{Nike,红},以及红鞋+红袜子之间的{红,红} |
生硬顺序 → BPE | Set permutation regularization(SPR):随机打乱口袋内部顺序,多次分段,既当数据增强又天然集成 | 同一口袋四种排列给模型多视角看同一动作 |
2. 方法
其实 ActionPiece 的核心是一个构词器。我自己整体梳理的逻辑大致是:① 离线构词;② 训练集分词+ SPR ;③ GR 模型训练;④推理-解码。
① 离线构词。这里的目的是统计共现频率,把最细特征合并成多粒度 token。需要先将训练集数据拿来,然后先初始 token,将每个 原子特征(品牌、类目、颜色…)分配唯一 token,统计共现频率,赋予权重,然后类似 BPE 方式迭代合并:把频次最高的一对 token 合并成新 token;更新倒排索引 + 堆;反复到设定词表大小 Q(Sport数据集:40002个 token)。得到扁平大词表 + 合并规则(merge-rules),保存。
② 训练集分词 + Set Permutation Regularization(SPR)。这里主要目的是把训练集里的动作序列转成 token 序列,并做数据增强。用刚才的合并规则(merge-rules)从左到右匹配出尽量长的 token。对每个口袋随机重排特征 q 次,每次重新分词得到 q 份等价但不同的 token 序列。把 q 条序列都当成训练样本。
③ 训练 GR 主模型。词表用第①步搞好的大小为 Q。
④ 推理-解码。将用户历史同训练方式,进行数据增强,得到 q 个序列,输入给 GR 模型,beam-search 生成候选集,对候选集序列中每个 token 查词典比如 14844–>747+923,直到全是最原始粒度(特征),然后进行冲突检查、完整性检查,查表找到对应 item。
以上是我理解的整体流程,下面按照文章方法章节再进行梳理。
2.1 问题设定
作者首先把序列推荐任务正式写成:给定按时间排序的历史动作串 S={i1,i2,…,it}S=\{i_1,i_2,\dots,i_t\}S={i1,i2,…,it},模型要预测下一件用户将交互的商品 it+1i_{t+1}it+1。
与传统把每个商品直接视作单一 ID 不同,作者将一次动作看成一个无序特征集合 Aj={fj,1,fj,2,…,fj,m}\mathcal A_j=\{f_{j,1},f_{j,2},\dots,f_{j,m}\}Aj={fj,1,fj,2,…,fj,m},其中第 kkk 个特征 fj,kf_{j,k}fj,k 来自特征域 Fk\mathcal F_kFk(如品牌域、类别域、颜色域、价格段域等)。这种集合式表示有两大优势:①集合内部本身没有先后顺序,符合推荐系统数据库里“字段-值”天然无序的现实;②它不仅能容纳离散语义 ID,还能把价格、尺码等数值离散化后一并纳入,因而比“有序语义向量”更通用。
将每件商品都写成集合后,整条历史就变成“集合的序列” S′={A1,A2,…,At}S'=\{\mathcal A_1,\mathcal A_2,\dots,\mathcal A_t\}S′={A1,A2,…,At}:集合间仍保留时间顺序,而集合内部元素顺序被刻意忽略。
接下来作者的目标是设计一个分词器,把 S′S'S′ 映射成 token 串 C={c1,c2,…,cl}C=\{c_1,c_2,\dots,c_l\}C={c1,c2,…,cl},这里 lll 往往大于 ttt,因为一件商品可以被拆成若干 token,也可能与邻居组合成更大 token。
得到 token 串后,用自回归模型学习条件概率 p(cl+1,…,cq∣c1,…,cl)p(c_{l+1},\dots,c_q\mid c_1,\dots,c_l)p(cl+1,…,cq∣c1,…,cl);推理时模型续写出未来若干 token {cl+1,…,cq}\{c_{l+1},\dots,c_q\}{cl+1,…,cq},再按 ActionPiece 的解码规则还原成下一个商品 i^t+1\hat i_{t+1}i^t+1。
为让分词器既捕捉集合间(有序)关系,又保持集合内(无序)一致性,作者在后续 3.2 提出两条核心机制:一是按口袋内和相邻口袋两种共现频率迭代合并 token,二是利用 Set Permutation Regularization 随机打乱集合内部顺序多次编码,从而让模型在训练阶段见到语义等价但排列不同的多视角数据。简而言之,这一节把任务界定为:在“时间序列 × 无序集合”双层结构上发明一种上下文感知的 tokenization,使生成式模型能更精细地理解购买动机。实际例子可帮助直观理解:若一双球鞋被写成 {Nike,Sneaker,Red,¥499}\{Nike,\ \text{Sneaker},\ Red,\ ¥499\}{Nike, Sneaker, Red, ¥499},与随后 {Nike,Socks,Red,¥29}\{Nike,\ Socks,\ Red,\ ¥29\}{Nike, Socks, Red, ¥29} 连续出现,分词器就有可能把跨集合的 {Nike,Red}\{\text{Nike},\ Red\}{Nike, Red} 合并成一个 token,暗示用户偏好“红色 Nike 套装”,而这种语义在传统 ID 序列中是缺失的。
2.2 Contextual Action Sequence Tokenizer
2.2.1 构建词表
这里的目标是:给定所有用户的历史序列(训练集),造出大小为 QQQ 的词表 V0\mathcal V_0V0。每个 token 就是一组 “老是一起出现” 的特征。
2.2.1.1 初始化
起始词表 V0\mathcal V_0V0 仅含“单一特征”:
V0={c={f}∣f∈F1∪⋯∪Fm}\mathcal V_0=\bigl\{\,c=\{f\}\,\bigm|\,f\in\mathcal F_1\cup\cdots\cup\mathcal F_m\bigr\} V0={c={f}f∈F1∪⋯∪Fm}
类比 BPE 把一个字节当最小元素——这里把“一张属性小卡片”当最小 token。
2.2.1.2 上下文感知的token共现统计
在构建 ActionPiece 词表的每一次迭代里,“统计计数 (Count)” 这一步决定了下一对要被合并的 token。作者把它命名为 context-aware token co-occurrence counting,意思是:计数时不仅看两个 token 同时出现的频率,还要看它们出现的位置——是在同一件商品的特征袋子内,还是跨两个相邻商品的袋子。位置不同,说明的语义强度不同,因此要赋予不同的权重。
加权共现统计
场景设定:两件连续商品
假设有一段购买序列,只截取中间相邻的两件:
- 商品 A 的特征袋子:
Ai={Nike,Sneaker,Red,¥499}\mathcal A_i = \{\text{Nike},\ \text{Sneaker},\ \text{Red},\ \text{¥499}\}Ai={Nike, Sneaker, Red, ¥499}
→ 袋子里共有 ∣Ai∣=4|\mathcal A_i| = 4∣Ai∣=4 个最小 token - 商品 B 的特征袋子:
Ai+1={Adidas,Socks,White}\mathcal A_{i+1} = \{\text{Adidas},\ \text{Socks},\ \text{White}\}Ai+1={Adidas, Socks, White}
→ 袋子大小 ∣Ai+1∣=3|\mathcal A_{i+1}| = 3∣Ai+1∣=3
把整条历史想像成「袋子 1 → 袋子 A → 袋子 B → 袋子 4 → …」的时间序列。我们现在只关心袋子 A 与袋子 B 内部及两袋之间的所有 token 对。
为什么要“打平 + 看相邻”?
若直接在二维结构里计“共现次数”,同袋子和跨袋子的对没有可比性。作者的做法是:
- 随机打乱每个袋子中的 token 顺序(因为袋子内部本是无序)。
- 串联两袋子,得到一条一维序列。
- 关心一对 token 是否在该 1-D 序列里恰好挨在一起。
- 在 BPE 里,相邻字符对最可能被合并——这里用同样思路。
于是计算的就是:随机打平后,两 token 出现在相邻位置的期望概率。这就是权重。
同袋子对的权重
选袋子 A 里任意两张“特征卡片” c1,c2c_1, c_2c1,c2。
- 把 4 张卡片随机排成一行,(42)=6\binom{4}{2}=6(24)=6 种无序对都会出现。
- 想让 c1c_1c1 与 c2c_2c2 紧挨着,有 4−1=34-1=34−1=3 种可行相对位置(“第1-2位”“第2-3位”“第3-4位”),但顺序 c1c2c_1c_2c1c2 和 c2c1c_2c_1c2c1 都算,所以有 2 个方向。综合可写成:
P(c1,c2)=2∣Ai∣(1)P(c_1,c_2)=\frac{2}{|\mathcal A_i|}\quad\text{(1)} P(c1,c2)=∣Ai∣2(1)
带入 ∣Ai∣=4|\mathcal A_i|=4∣Ai∣=4,得到权重 2/4=0.52/4 = 0.52/4=0.5。
直观:袋子越大,两张卡片“碰头”越不稀奇,因此权重自动变小。
跨袋子对的权重
再取 c1∈Aic_1 \in \mathcal A_ic1∈Ai 和 c3∈Ai+1c_3 \in \mathcal A_{i+1}c3∈Ai+1。
- 打乱各袋后,把 A 排在前、B 排在后;两袋共有 4×3=124\times3=124×3=12 种“首尾相接”组合。
- 若要 c1c_1c1 与 c3c_3c3 刚好在拼接点成为相邻,只存在 唯一 一种组合。于是
P(c1,c3)=1∣Ai∣⋅∣Ai+1∣(2)P(c_1,c_3)=\frac{1}{|\mathcal A_i|\cdot|\mathcal A_{i+1}|}\quad\text{(2)} P(c1,c3)=∣Ai∣⋅∣Ai+1∣1(2)
带入数字得 1/(4⋅3)=1/12≈0.0831/(4\cdot3)=1/12\approx0.0831/(4⋅3)=1/12≈0.083。
直观:跨袋子相遇概率更低 → 权重更小;只有当它们在大量历史里频繁跨袋紧挨出现,这个累计分数才会赶超同袋高频对,被挑中合并,从而把上下文搭配写进 token。
累计共现权重
在整个语料库里的累计
- 走遍所有用户、所有时间步,每当 ci,cjc_i, c_jci,cj 出现就根据它们的位置类别(同袋或跨袋)加上公式 (1) 或 (2) 的权重。
- 这样 一次出现就加一次概率值,多次出现就叠加,得到全局累计共现分数。
- 最后一对可能的情况:ci,cjc_i, c_jci,cj 在某些序列里同袋,在另一些序列里跨袋——就分别加两种权重,再求和。
用权重找“下一对要合并的 token”
完成本轮统计后,累计分数最高的 token 对被视为“最具价值的组合”——
- 如果来自同袋:合并后仍放回该袋;
- 如果跨袋:创建一个“中间节点”存放新 token,把它悬挂在两袋之间。
随后进入下一轮迭代:更新链表、倒排索引和全局堆,再重新统计局部更改带来的增量,直至词表达到目标大小。
2.2.1.3 迭代合并 token
为什么要引入 Intermediate Node ?
- Action Node:一开始,链表里的每个节点都对应用户历史中的一件商品(即一个特征袋子),里面装这件商品的 token。
- 跨袋子合并 会产出“混血 token”(含多件商品特征)。如果直接把它塞回某一 Action Node,就会破坏“谁的特征归谁”的边界,统计下一轮共现时也会混淆。
- 解决办法:在两件商品之间插一个 Intermediate Node(虚线方框),专门存放这颗跨袋 token,并规定 “同一相邻 Action Node 对之间最多只允许 1 个 Intermediate Node,且它内部最多只放 1 颗 token”。
- 这样既保存了商品顺序,又把“跨商品组合”显式挂在两者之间。
三种更新场景
场景 | 图示(颜色与形状仅示意) | 操作步骤 |
---|---|---|
① 同袋合并 (Merge tokens in one action node) | 左侧:红圆 + 红圆 → 棕圆 | 1. 在 原 Action Node 内删除那两颗 token ; 2. 插入新 token; 3. 链表结构 完全不变。 |
② 跨两 Action Node 合并 (Merge tokens in two adjacent action nodes) | 中间:紫圆 (左袋) + 紫方 (右袋) → 紫菱 | 1. 在两 Action Node 中间插入一个 全新的 Intermediate Node; 2. 把新 token 放进 Intermediate Node; 3. 各自从原 Action Node 删除被合并的旧 token。 |
③ Action Node 与已有 Intermediate Node 再次合并 (Merge tokens in action & intermediate nodes) | 右侧:蓝菱(Intermediate) + 蓝方(右袋) → 蓝新菱 | 1. 用新 token 替换 Intermediate Node 里原 token; 2. 从对应 Action Node 删除旧 token; 3. Intermediate Node 保持“只含 1 token”。 |
经过上述三条规则,无论合并多少轮,相邻两件商品中间永远只有 0 或 1 个 Intermediate Node,每个 Intermediate Node 永远只有 1 颗 token。这两条不变式让后续统计和修改都能锁定极小的局部范围。
2.2.1.4 高效实现
-
如果老老实实 — 在每一轮都把整座语料重新扫一遍、重新统计所有 token-pair 共现,再整串改写序列 — 算法会被拖进“指数级炼狱”。
-
记号:
- QQQ = 目标词表大小(几万到十几万);
- NNN = 训练集中用户序列条数(10^5 – 10^7);
- LLL = 每条序列平均动作数;
- mmm = 每个动作平均特征数。
-
朴素复杂度:O(QNLm2)\mathcal O(Q\,N\,L\,m^{2})O(QNLm2)。
例:Q=60k,N=1M,L=100,m=5Q=60\,\text{k},\ N=1\,\text{M},\ L=100,\ m=5Q=60k, N=1M, L=100, m=5 时,光主项就上百万亿,根本跑不动。
-
-
真正可行的办法是“只改被波及的局部”,并用几种数据结构把“找下一对”和“更新权重”都降到对数级。具体有三招:
招数 | 结构 & 技巧 | 解决的问题 |
---|---|---|
① 双端链表 保存整条动作序列 | 合并 token 只影响相邻 2 – 3 个节点 | 插删节点 O(1) |
② 倒排索引 $ \text{pair} \rightarrow \text{在哪些链表位置出现}$ | 精确定位“这对 token”出现在哪些节点,更新权重只触碰这些节点 | 避免整库重扫 |
③ 全局堆 + lazy tag | 堆顶始终给出“当前累计权重最高”的 token-pair;lazy tag 检查过期值 | 取最大对 O(logH\log HlogH);H 是堆大小 |
为什么能只改局部?
每合并一次,要么:
- 把两 token 换成一个(袋子内),
- 或插/替一个 Intermediate Node(跨袋)。
这最多影响 当前节点 ±1。倒排索引只更新这些节点的计数,堆也只替换相关 pair 的权重,就避开了“从 0 开始再数一遍”。
结果复杂度
-
定义 H=O(NLm)H=\mathcal O(N L m)H=O(NLm) 为堆里“可能出现过的 token-pair”上限。
-
维护倒排索引 + 堆,每轮只需:
O(logQlogH)\mathcal O\bigl(\log Q \,\log H\bigr) O(logQlogH)
的时间完成 统计增量 → 取最大对 → 局部更新,远低于朴素 QNLm2Q N L m^{2}QNLm2。
-
实践上,越到后期堆顶 pair 的权重差距越小、更新越少,后几万轮基本是秒级完成,因此总构词时间近似于“前几轮的开销 × 常数因子”。
2.2.2 切分训练/测试序列
这段讲的,是把“已经学好的词表”真正用到每条历史序列上时,如何切分(segment)得到 token 串,我理解主要就是做了个数据增强;
关键点是作者提出了 Set Permutation Regularization (SPR),用打乱顺序的方式同时解决两件麻烦事:
- 避免朴素贪心切分带来的 ID-偏置;
- 把“袋子内无序”这个事实转化为天然的数据增强与推理集成。
2.2.2.1 朴素切分
- 做法:沿用构词阶段的贪心策略——从序列左端开始,总是优先匹配“最长、ID 最小”的 token。
- 隐患:词表里的 token ID 按加入顺序递增;早期加入的往往覆盖高频对子。贪心一旦总挑它们,后期才加入的大量 token 几乎没有出手机会,导致:
- 训练统计出现 严重长尾(只有少数 token 被频繁使用);
- 模型过拟合这小撮 token,效果受限。
2.2.2.2 Set permutation regularization (SPR)
SPR:借“无序”打乱,生成多视角切分
核心思路:既然袋子内部本来就没有先后约束,那就在切分前给每个袋子来一次随机洗牌。
- 对历史里 每个特征袋 Ai\mathcal A_iAi 产生一次随机排列。
- 把所有袋子的排列串成一维序列(如:Nike → Red → Sneaker → ¥499 → …)。
- 用正常的 BPE 贪心切分得到一条 token 序列。
- 重复步骤 1–3 qqq 次,得到 qqq 条语义等价但分段方式不同的 token 序列。
近似含义:随机排列实际上在采样袋子内、袋子间所有可能相邻的 token 对,不必爆炸式枚举却能覆盖大部分搭配。
SPR 带来的两层收益:
训练阶段 | 推理阶段 |
---|---|
把 qqq 条序列都当作样本喂模型 ⇒ 数据增强,模型见过更多 token 组合,泛化更好。 | 同样先打乱 qqq 次并各自分词;把模型生成的 qqq 个候选结果做 投票 / 平均 ⇒ 天然集成,鲁棒性更高。 |
例子(q=2q=2q=2)
商品 A 袋子:{Nike, Sneaker, Red, ¥499}
第一次排列:Nike → Red → Sneaker → 499
贪心切分:TOK_Nike+Red, TOK_Sneaker+499
第二次排列:Sneaker → Nike → 499 → Red
贪心切分:TOK_Sneaker+Nike, TOK_499+Red
两条 token 串语义完全一致,却触发了两组不同的高阶 token,模型因而不会把注意力局限在某一种“默认顺序”的拆分上。
2.3 Generative Recommendation Models
这一小节讲的是怎样把前面得到的 ActionPiece-token 序列真正喂给任意 GR 骨干(P5、T-5、TIGER …)去训练和推理。核心思路是:
- 训练时,用 SPR 产生 多份等价但顺序不同的 token 序列 → 当作数据增强;
- 推理时,再做同样的 qqq 次打乱 → 把 qqq 份独立输出做 轻量级集成。这样既不用改网络结构,又能稳稳提升召回与排序。
2.3.1 Training on Augmented Token Sequences
- 输入/输出如何表示?
- 一条训练样本原本是:历史动作 SSS + label 下一件商品 it+1i_{t+1}it+1。
- 现在先用 ActionPiece + SPR 把二者分别分词:
- 历史 → CinC_{\text{in}}Cin
- label → CoutC_{\text{out}}Cout
这里 CoutC_{\text{out}}Cout 也可能是多个 token(如果下一件商品在词表里恰好拆成多段)。
- 如何做数据增强?
- 在 每个 epoch 都对同一条历史重新洗牌,再切一次——于是同一条训练样本,每个 epoch 看到的 CinC_{\text{in}}Cin 都不同,但语义相同。
- 对应的 label CoutC_{\text{out}}Cout 也跟着重新分词,保证输入输出总是配套。
- 网络 & 目标函数
- 作者选了 Transformer encoder–decoder(T5 风格);也可换成 TIGER、P5-CID 等「生成式推荐骨干」。
- 损失还是 next-token prediction cross-entropy:给定 CinC_{\text{in}}Cin,自回归地预测 CoutC_{\text{out}}Cout。
- 因为 ActionPiece 词表已把高频上下文写进 token,本质上是让 Transformer 某种程度“少背点锅”,更快收敛。
经验性结果:用 SPR 数据增强,平均 NDCG@10 比不用增强高 2 – 4 个百分点。
2.3.2 Inference-Time Ensembling
同样的 SPR 打乱
- 在线推理时,对用户当前历史再洗牌 qqq 次(默认 q=3q=3q=3 或 555),得到 qqq 份 Cin(1),…,Cin(q)C_{\text{in}}^{(1)},…,C_{\text{in}}^{(q)}Cin(1),…,Cin(q)。
- 每份都单独跑一次 beam search(TIGER 默认为 beam = 10),各自输出一个“候选 item 排序列表 + 分数”。
代码中的细节,论文中没提到:
步骤 | 解释 | 关键点 |
---|---|---|
① 生成序列 Beam-search 得到若干候选 token 串 CoutC_{\text{out}}Cout,例如 \[14844, 21063\] | 这些 token 全来自 固定词表,每个 token 本身可能囊括 1-n 个原子特征 | 保证“可递归拆分” |
② 递归拆 token | 调 decode_single_token() :把 14844 拆成 (品牌=Nike),(颜色=Red)(品牌=Nike),(颜色=Red)(品牌=Nike),(颜色=Red),把 21063 拆成 (品类=Sneaker),(性别=Men)(品类=Sneaker),(性别=Men)(品类=Sneaker),(性别=Men)……直到都是最底层原子特征 | 不会再出现未知子 token |
③ 填特征槽位 | 把所有原子特征放进固定槽位盒子(0=品牌,1=品类,2=颜色,3=性别,4=价位 …) | 若同一槽位重复 → 判冲突❌ |
④ 完整性检查 | 必须 ① 没冲突 ② 所有必填槽非空,否则整条候选作废 | 多数非法 C_out 在这步被过滤 |
⑤ 一对一哈希映射 | 对已填满的 (slot,value)(slot, value)(slot,value) 做 hash(tuple(sorted(...))) ,在 预先离线构好的字典 hashed_feat → item_id 里 O(1) 查找 | 训练阶段已确保「特征向量 ↔ item」唯一 |
⑥ 得到 item + 分数 | Beam-search 原分数附着在 item 上;若同 item 出现在多条 Beam,取平均 | 后续与其他 SPR 视角做加权融合 |
融合
-
把同一 item 在不同列表里的分数 求平均(或求和),再整体排序。
-
理解成一种 data-level ensemble:不同视角的分词让模型在不同 token 边界处给分,但相同语义的 item 会在多数视角下获高分,于是得分更稳。
-
不需要同时训练 qqq 份模型;只是多做 qqq 次 tokenizer + 一次模型前向。
-
论文显示:
单模型 + Inference Ensemble 能再把 NDCG@10 拉高约 1.5 pp。
举例:
步骤 | 视角 #1 | 视角 #2 | 视角 #3 |
---|---|---|---|
SPR 洗牌/ 分词 | Nike Red TOK1 TOK2 | TOK1 Nike TOK2 Red | Red TOK2 Nike TOK1 |
Beam 输出 (score) | 红袜(0.92) , 黑鞋(0.85) | 红袜(0.90) , 黑鞋(0.88) | 红袜(0.94) , 黑鞋(0.80) |
平均融合 | 红袜(0.92) , 黑鞋(0.84) | —— | —— |
最终推荐 红袜,且分数更平滑;极少数视角偶然打低分也被稀释掉。
2.4 讨论
2.4.1 行为序列的顺序
其实这是文章的主要创新点,也是与其他文章的主要不同。
在 ActionPiece 的世界里,“顺序”其实分两层:
- 动作内部(feature set):属性本身无先后,像一个装满卡片的袋子;
- 动作之间(历史序列):袋子按时间排成队,顺序必须保留,因为它体现了用户兴趣的演化。
为什么袋子里不排顺序?
电商日志里,一件商品通常伴随 标题、品牌、类目、颜色、价格 … 这些字段。系统检索时并不依赖它们谁在前谁在后;“Adidas + 白色 + Socks”与“白色 + Adidas + Socks”是同一件货。把它们建模成 无序集合,可以:
- 贴合数据库真实结构(字段集合);
- 方便加入更多离散化或数值特征,不必担心插在哪个位置会“打乱语义”;
- 避免让模型学习到“品牌字段总在第 1 位”这种假顺序。
为什么袋子之间一定要排顺序?
用户的点击 / 购买记录按时间戳逐条落在日志里:
周一 买了一双 红色 Nike 跑鞋 → 周三 加购 红色 Nike 袜子 → 周五 浏览 黑色 Nike 帽子
这一连串动作的先后本身就蕴含一个潜在意图:先确定品牌与配色,再逐步补齐全套装备。若随意打乱整条历史,模型就看不到这种“偏好递进”或“主题切换”——序列推荐的根本价值也就丢了。
ActionPiece 如何同时兼顾两层顺序?
- 集合内部:用 Set Permutation Regularization(SPR)随机打乱,再让词表自行合并高频组合 ⇒ 对无序保持鲁棒;
- 集合之间:链表永久保留时间顺序,任何跨袋 token 都放在“Intermediate Node”里而不改变节点位置 ⇒ 序列语义不被破坏。
这样,模型既能在 “袋子×时间” 的双层结构中捕捉 Nike → Nike → Nike 的品牌连续性、也能在跨袋 token 里直接读到 “红鞋+红袜” 的搭配需求。
直观例子
时间 | 动作 (= feature set) | 顺序意义 |
---|---|---|
t₁ | {Nike, 红, 跑鞋} | 开始选跑鞋,定下品牌+配色 |
t₂ | {Nike, 红, 袜子} | 保持品牌+配色,补配件 ⇒ 连贯偏好 |
t₃ | {Nike, 黑, 帽子} | 换颜色但不换品牌 ⇒ 主题过渡 |
集合内部 红是否先写、Nike 是否先写都无关紧要;集合之间 t₁→t₂→t₃ 的顺序却勾勒了兴趣轨迹。
总结:作者要强调的关键点是——ActionPiece 把“动作看成无序袋子 + 袋子排成时间序列”;内部乱序用 SPR 抹平,外部时间顺序严守,从而同时捕获了静态商品属性与动态购买意图的序列模式。
2.4.2 与 BPE 对比
这一段把 ActionPiece 和传统文本分词算法 BPE 放在一起对照:两者遵循同一套 “自底向上、频次优先合并” 的框架,但——因为要处理的数据结构截然不同——ActionPiece 在每一个实现细节上都做了“量体裁衣”的改造。
核心区别:
BPE | ActionPiece | |
---|---|---|
处理对象 | 一维字节/字符流 | 两层:时间序列 × 每步无序特征袋 |
要解决的痛点 | 把「频繁相邻字符」熔成词片,减短文本长度 | 既要捕捉同商品内高频组合,又要显式编码跨商品上下文 |
为什么“一维 vs. 二维”会让实现全变?
- 文本天然只有“左右邻居”;BPE 只需关心 adjacent byte pair。
- 购买历史有两重结构:
- 袋子内部 token 无序,但彼此可能高度相关(同色、同品牌);
- 袋子之间 有时间顺序,跨袋 token 共现频率更低但更有语义(红鞋 ↔ 红袜)。
如果直接照搬 BPE 的「相邻=邻居」概念,就只能合并同袋 token —— 上下文信息缺位;ActionPiece 因而引入 概率加权 + Intermediate Node + SPR 三板斧,将二维关系压成一维又不丢语义。
例子
BPE:
t h e _ f a n t a s t i c
→ 合并 't','h' → 'th',合并 'fan','tas','tic' …
ActionPiece (两件商品):
- A = {Nike, Sneaker, Red, ¥499}
- B = {Nike, Socks, Red, ¥39}
Step-1 同袋组合: (Nike, Red) → TOK_NR 存在 Action Node A
Step-2 跨袋组合: (TOK_NR, Socks) → TOK_NRS 存于 Intermediate Node (A,B)
Step-3 SPR 洗牌切分,得到多个 token 串
最终词表里既有单特征 token,又有 “Nike+Red”、“Nike+Red+Socks” 等上下文 token。
为什么要这么麻烦?
- 压缩序列:同袋 + 跨袋合并让平均 token 长度缩短 > 40 %。
- 显式语义:生成模型只要看到
TOK_NRS
就知道“用户在买红袜子来配红鞋”,不用靠注意力自己去推。 - 避免偏置:SPR 让词表里后期加入的 token 也能被用到;实验显示若不用 SPR,词表利用率 < 40 %,加入后提升到 70 % 以上。
ActionPiece 借用了 BPE 的“迭代合并”骨架,但把每一行“适用于一维文本”的默认设置替换成“适应二维行动序列”的做法:概率加权、Intermediate Node、SPR。 这套改造保证了推荐系统中的商品特征与上下文意图都能在分词阶段被“写进” token,从而真正发挥生成式模型的威力。
2.4.3 SPR 的效率
这段 “Efficiency impact of SPR” 主要回答两句质疑:
- 训练阶段 —— “每个 epoch 还要再洗牌、再分词,会不会拖慢?”
- 推理阶段 —— “同一条历史要跑 q 份视角,再做集成,延迟是不是爆炸?”
训练几乎不变慢:洗牌+分词在 CPU,异步于 GPU 更新
- SPR 动作 = 对每个 feature-set 洗牌 ➜ 贪心分词。
- 这一套逻辑 纯 CPU、内存友好;可以放到 DataLoader 线程里提前做,和 GPU/TPU 上的反向传播 并行。
- 实验里把 ActionPiece + TIGER(带 SPR)的吞吐与原生 TIGER 对比,samples/sec 几乎无差别;说明数据层处理没有成为瓶颈。
推理 FLOPs 的确变多,但延迟基本不涨
项目 | Baseline(q=1) | ActionPiece + SPR(q=3 或 5) |
---|---|---|
前向次数 | 1 | q |
单次序列长 | 传统 ID 序列 | ≈60 % 长度(因 ActionPiece 压缩) |
理论 FLOPs | 1×F | q×0.6×F ≈ 1.8–3 F |
实测延迟 | 100 ms | ~120 ms(多 GPU 并行) |
原因
- 序列本身更短,Transformer 每层开销是 O(L2)O(L^2)O(L2),所以长度减 40 % 让单次前向速度提升 ≈2×。
- 数据并行:q 份视角可以分到多张 GPU / TPU 上同时跑,主线程只等 max{t₁,…,t_q} 而非求和。
- 合并分数 只是在 CPU 上对 K≈100 个 item 做一次哈希 + 平均,耗时可以忽略。
3. 实验
3.1 主实验
3.2 消融实验
3.3 其他
3.3.1 词表大小与效果
3.3.2 词表利用率
3.3.3 推理时 SPR 洗牌次数
4. 总结
感觉看下来总体还是有意义的,切入点很细致,不过感觉有点太复杂。
item 特征增多的话,好像无法处理,因为刚开始的初始化 token 与这个相关。这里的 初始化 token 是从全训练集上找的,如果后续 infer 的时候需要处理新的特征,这个应该是无法处理的,在代码中看到设定了 UNK_brand,为每个槽位都保留了 UNK token。但是这样词表就需要经常动态更新了么?
SPR infer 总感觉效率会有问题?