MiniOneRec SFT 深度分析
一、总体概述
sft.py 是 MiniOneRec 框架中 监督微调(Supervised Fine-Tuning) 阶段的核心训练脚本。其核心思想是:将推荐问题重新表述为一个序列到序列的语言生成任务——给定用户的历史交互序列(以语义 ID / SID 表示),让 LLM 通过 next-token prediction 生成下一个可能消费的商品的 SID。
与传统推荐模型不同,MiniOneRec 不是在固定的 item embedding 空间中做匹配或打分,而是让预训练语言模型直接生成目标商品的离散 token 序列,从而将 LLM 的世界知识与推荐任务深度融合。
二、SFT 整体流程
┌──────────────────────────────────────────────────────────────┐
│ sft.py 训练流程 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 1. 设置随机种子 & 环境变量 │
│ 2. 加载预训练 LLM (AutoModelForCausalLM) │
│ 3. 加载 Tokenizer & 扩展词表 (SID 新 token) │
│ 4. [可选] 冻结 LLM,仅训练新 token embedding │
│ 5. 构建多任务训练数据集 (ConcatDataset) │
│ ├── Task1: SidSFTDataset (SID序列→SID 序列推荐) │
│ ├── Task2: SidItemFeatDataset (SID↔Title 双向对齐) │
│ └── Task3: FusionSeqRecDataset (SID序列→Title 融合推荐) │
│ 6. 转换为 HuggingFace Dataset & Shuffle │
│ 7. 配置 Trainer (gradient accumulation, bf16, early stop) │
│ 8. 训练 & 保存最优模型 │
│ │
└──────────────────────────────────────────────────────────────┘
三、核心模块详解
3.1 TokenExtender — 词表扩展器
class TokenExtender:
def __init__(self, data_path, dataset, index_file=".index.json"):
作用:从 .index.json 文件中提取所有 SID token,用于扩展 LLM 的原始词表。
原理:RQ-VAE 将每个商品编码为三层语义 ID(如 <sid_12>, <sid_156>, <sid_78>),这些 token 不在原始 LLM 词表中。TokenExtender 收集所有唯一的 SID token,添加到 tokenizer 中,并相应地扩展模型的 embedding 矩阵。
数据变化:
.index.json 结构:
{
"item_id_1": ["<sid_12>", "<sid_156>", "<sid_78>"],
"item_id_2": ["<sid_12>", "<sid_203>", "<sid_45>"],
...
}
→ 提取所有唯一 token → ["<sid_12>", "<sid_45>", "<sid_78>", "<sid_156>", "<sid_203>", ...]
→ tokenizer.add_tokens(new_tokens)
→ model.resize_token_embeddings(len(tokenizer))
3.2 模型加载与词表扩展
model = AutoModelForCausalLM.from_pretrained(base_model, torch_dtype=torch.bfloat16)
tokenizer = AutoTokenizer.from_pretrained(base_model, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "left"
# 扩展词表
tokenizer.add_tokens(new_tokens)
model.resize_token_embeddings(len(tokenizer))
关键设计:
- 使用
bfloat16精度降低显存占用 padding_side = "left":生成式模型的标准做法,保证生成时右侧无 padding 干扰- 新 token 的 embedding 初始为随机值,通过 SFT 训练赋予语义
3.3 冻结策略(freeze_LLM)
if freeze_LLM:
for param in model.parameters():
param.requires_grad = False
# 仅解冻新 token 的 embedding
embedding_layer.weight.requires_grad = True
# 通过 gradient mask 只更新新 token 的梯度
def mask_grad(grad):
grad[:original_vocab_size].zero_()
return grad
embedding_layer.weight.register_hook(mask_grad)
原理:通过 register_hook 对 embedding 矩阵的梯度施加掩码,在反向传播时将原始词表对应的梯度行置零,仅更新新增 SID token 的 embedding 行。这使得 LLM 的语言能力完全保留,只学习 SID 的语义表示。
四、多任务训练数据集详解
MiniOneRec SFT 的核心创新之一是多任务联合训练,通过 ConcatDataset 将三种不同任务的数据合并:
4.1 Task1: SidSFTDataset — SID 序列推荐(主任务)
任务定义:给定用户历史交互的 SID 序列,预测下一个商品的 SID。
Prompt 模板:
Below is an instruction that describes a task, paired with an input that
provides further context. Write a response that appropriately completes the request.
### Instruction:
Can you predict the next possible item that the user may expect?
### User Input:
The user has interacted with items <sid_12><sid_156><sid_78>,
<sid_12><sid_203><sid_45>, <sid_33><sid_89><sid_12> in chronological order.
Can you predict the next possible item that the user may expect?
### Response:
<sid_7><sid_142><sid_56>
数据变化流程:
CSV 原始数据:
history_item_sid: "['<sid_12><sid_156><sid_78>', '<sid_12><sid_203><sid_45>']"
item_sid: "<sid_7><sid_142><sid_56>"
→ 解析为 Python list
→ 拼接为自然语言 prompt(instruction + history + question)
→ Tokenize:
instruction tokens: [BOS, t1, t2, ..., tn] (系统指令部分)
prompt tokens: [p1, p2, ..., pm] (用户输入部分)
target tokens: [g1, g2, ..., gk, EOS] (目标 SID)
→ 构建 labels:
[-100, -100, ..., -100, g1, g2, ..., gk, EOS]
|← prompt 部分不计算 loss →|← 仅对 target 计算 loss →|
→ 截断到 max_len(从左侧截断,保留最近的上下文和完整 target)
构造完成的样本实例:
假设 CSV 中一条数据为:
history_item_sid: "['<sid_12><sid_156><sid_78>', '<sid_12><sid_203><sid_45>', '<sid_33><sid_89><sid_12>']"
item_sid: "<sid_7><sid_142><sid_56>"
经 SidSFTDataset.pre() 处理后,生成的完整文本如下:
Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.
### Instruction:
Can you predict the next possible item that the user may expect?
### User Input:
The user has interacted with items <sid_12><sid_156><sid_78>, <sid_12><sid_203><sid_45>, <sid_33><sid_89><sid_12> in chronological order. Can you predict the next possible item that the user may expect?
### Response:
<sid_7><sid_142><sid_56>
Tokenize 后对应的三个字段(以 token id 示意,即是将上述的自然语言转化成了token id):
input_ids (长度 = prompt_len + target_len):
┌────────────────── prompt 部分 (len=62) ───────────────────┐ ┌── target 部分 (len=5) ─────┐
[BOS, 12851, ..., 2891, 30, 198, 198, 35035, ..., 198, 50260, 50305, 50419, 198, EOS]
│ │ │ │ │
└ "Below is an..." └ "...expect?" "Response:\n" └ "<sid_7>...<sid_56>\n" └ 结束
attention_mask:
[ 1, 1, ..., 1, 1, 1, 1, 1, ..., 1, 1, 1, 1, 1, 1]
(全部为 1,所有 token 都参与 attention 计算)
labels:
[-100, -100, ..., -100, -100, -100, -100, -100, ..., -100, 50260, 50305, 50419, 198, EOS]
│←─────── prompt 部分全部为 -100,不计算 loss ────────────→│ │←── 仅 target 部分计算 loss ──→│
其中 50260, 50305, 50419 是扩展词表后 <sid_7>, <sid_142>, <sid_56> 对应的 token id(实际值取决于词表大小和新增 token 顺序),198 是换行符 \n 的 token id。
4.2 Task2: SidItemFeatDataset — SID↔Title 双向对齐(辅助任务)
任务定义:建立 SID 与商品自然语言描述之间的双向映射。
子任务 A — Title → SID:
### Instruction:
Answer the question about item identification.
### User Input:
Which item has the title: Industrial Precision Screwdriver Set?
### Response:
<sid_12><sid_156><sid_78>
子任务 B — SID → Title:
### Instruction:
Answer the question about item identification.
### User Input:
What is the title of item "<sid_12><sid_156><sid_78>"?
### Response:
Industrial Precision Screwdriver Set
数据构建过程:
.index.json + .item.json
→ 遍历所有 item_id:
index.json[item_id] = ["<sid_12>", "<sid_156>", "<sid_78>"]
item.json[item_id] = {"title": "Industrial...", "description": "..."}
→ 合并 SID: "<sid_12><sid_156><sid_78>"
→ 构建双向映射:
sid2title: {"<sid_12><sid_156><sid_78>": "Industrial..."}
title2sid: {"Industrial...": "<sid_12><sid_156><sid_78>"}
→ 每对映射生成两条训练样本(双向)
→ 总样本数 = 2 × item_count
4.3 Task3: FusionSeqRecDataset — 融合序列推荐(辅助任务)
任务定义:给定 SID 历史序列,预测下一个商品的自然语言标题(而非 SID)。
Prompt 模板:
### Instruction:
Can you recommend the next item for the user based on their interaction history?
### User Input:
The user has sequentially interacted with items <sid_12><sid_156><sid_78>,
<sid_12><sid_203><sid_45>. Can you recommend the next item for him?
Tell me the title of the item
### Response:
Industrial Precision Screwdriver Set
数据变化流程:
CSV 序列数据 + .item.json + .index.json
→ 解析用户 SID 历史序列
→ 查找 target SID 对应的 title(通过 sid2title 映射)
→ 对 description 做特殊处理:
- 空描述 → 用 title 替代
- list 类型描述 → 选最长的非空项
- 空 list → 用 title 替代
→ Tokenize & 构建 labels(同 Task1 格式)
五、SFT 训练数据的完整变化链路
原始 Amazon 评论数据
│
├─ data/amazon18_data_process.py ─→ 过滤 & 序列化
│ (按时间排序, K-core 过滤, 划分 train/valid/test)
│
├─ rq/text2emb/ ─→ 商品文本 → 向量嵌入 (.npy)
│ (title + description → 冻结文本编码器 → dense embedding)
│
├─ rq/rqvae.py ─→ 向量 → 离散 SID
│ (三层 RQ-VAE 量化 → 每个 item 对应 3 个 codebook token)
│
├─ rq/generate_indices.py ─→ 生成 .index.json
│ (item_id → [sid_token_1, sid_token_2, sid_token_3])
│
├─ convert_dataset.py ─→ 转换为 SFT 格式 (.csv)
│ (将 item_id 替换为 SID, 保留序列结构)
│
└─ sft.py ─→ 多任务 SFT 训练
│
├─ SidSFTDataset: SID历史 → SID预测
│ input_ids: [BOS, instruction, history_sids, question]
│ labels: [-100, -100, ..., -100, target_sid, EOS]
│
├─ SidItemFeatDataset: SID↔Title 对齐
│ input_ids: [BOS, instruction, "Which item has title: xxx?"]
│ labels: [-100, -100, ..., -100, sid_tokens, EOS]
│ (反向亦然)
│
└─ FusionSeqRecDataset: SID历史 → Title预测
input_ids: [BOS, instruction, history_sids, question]
labels: [-100, -100, ..., -100, title_tokens, EOS]
六、Tokenization 与 Label 构建策略
所有三个数据集共享统一的 token 处理逻辑:
# 1. Instruction 编码(带 BOS,不带 EOS)
tokens = tokenizer.encode(instruction, bos=True, eos=False)
# 2. Prompt 编码(不带 BOS/EOS,拼接到 instruction 后)
tokens = tokens + tokenizer.encode(prompt, bos=False, eos=False)
# 3. Target 编码(不带 BOS,带 EOS 标记结束)
golden_tokens = tokenizer.encode(target, bos=False, eos=True)
# 4. 拼接 & 构建 labels
input_prompt_len = len(tokens) # prompt 部分长度
tokens = tokens + golden_tokens # 完整序列
labels = [-100] * input_prompt_len + tokens[input_prompt_len:]
# ↑ prompt 部分不参与 loss ↑ 仅 target 部分参与 loss
# 5. 长度截断(从左侧截断,保留最近上下文和完整 target)
tokens = tokens[-max_len:]
labels = labels[-max_len:]
Label 设计原理:
-100是 PyTorch CrossEntropyLoss 的ignore_index,设为-100的位置不参与 loss 计算- 只对 target(即模型需要生成的部分)计算 loss,instruction 和 history 部分仅作为条件上下文
- 从左侧截断确保 target 完整性,当序列过长时优先丢弃远期历史
七、训练配置详解
trainer = transformers.Trainer(
model=model,
train_dataset=hf_train_dataset,
eval_dataset=hf_val_dataset,
args=transformers.TrainingArguments(
per_device_train_batch_size=16, # 每卡 micro batch
gradient_accumulation_steps=64//8=8, # 梯度累积 → 等效 batch=1024
warmup_steps=20, # 线性 warmup
num_train_epochs=10, # 训练轮数
learning_rate=3e-4, # 学习率
bf16=True, # bfloat16 混合精度
optim="adamw_torch", # AdamW 优化器
eval_strategy="steps",
eval_steps=0.05, # 每 5% 训练进度评估一次
save_total_limit=1, # 仅保留最优 checkpoint
load_best_model_at_end=True, # 训练结束加载最优模型
),
data_collator=DataCollatorForSeq2Seq(
tokenizer, pad_to_multiple_of=8, # 对齐到 8 的倍数提升计算效率
return_tensors="pt", padding=True
),
callbacks=[EarlyStoppingCallback(early_stopping_patience=3)],
)
关键策略:
- 梯度累积:
batch_size=1024 / micro_batch_size=16 = 64步累积,大 batch 稳定训练 - DDP 支持:自动检测多卡环境,调整 gradient_accumulation_steps
- Early Stopping:patience=3,即连续 3 次评估 loss 未改善则停止训练
- pad_to_multiple_of=8:对齐到 8 的倍数,利用 Tensor Core 加速
八、自定义学习率调度器
def _get_cosine_schedule_with_warmup_lr_lambda(current_step, *, num_warmup_steps,
num_training_steps, num_cycles):
if current_step < num_warmup_steps:
return max(0.1, float(current_step) / float(max(1, num_warmup_steps)))
progress = float(current_step - num_warmup_steps) / float(
max(1, num_training_steps - num_warmup_steps))
return max(0.1, 0.5 * (1.0 + math.cos(math.pi * float(num_cycles) * 2.0 * progress)))
该调度器基于 cosine with warmup 策略,但有一个关键修改:设置了 max(0.1, ...) 的下界,即学习率永远不会低于初始学习率的 10%。这防止了训练后期学习率过小导致的收敛停滞。
学习率变化:
lr_max ┤ ╭─────╮
│╱ ╲
0.1×lr_0 ├─────────────────────────── (下界)
│
0 ├─────┬─────┬─────┬─────┬──→ steps
0 warmup total
注:当前代码中该调度器已定义但未实际使用(Trainer 使用默认调度器),属于备选方案。
九、算法创新点分析
创新点 1:SID 离散化 — 将推荐目标从连续空间映射到生成空间
| 维度 | 传统推荐 | MiniOneRec |
|---|---|---|
| Item 表示 | 连续向量 (embedding) | 离散 token 序列 (SID) |
| 预测方式 | 内积/MLP 打分 | 自回归 token 生成 |
| 候选空间 | 全量 item 枚举 | 受限在合法 SID 空间 |
原理:通过三层 RQ-VAE 将每个商品的语义 embedding 量化为三个离散 codebook token(如 <sid_12><sid_156><sid_78>),形成层次化的语义 ID。第一层 token 编码粗粒度类别信息,后续层逐步细化,实现从粗到细的语义表达。
作用:
- 将推荐问题转化为 LLM 擅长的序列生成问题
- 三层层次结构使得生成过程具有"先确定大类,再细化到具体商品"的决策逻辑
- SID token 数量远小于 item 总数,大幅压缩了输出空间
创新点 2:多任务联合训练 — 语言-SID 双向对齐
┌──────────────────────┐
│ 多任务联合训练 │
├──────────────────────┤
│ │
┌──────────────┤ 主任务: SID→SID │
│ │ (序列推荐) │
│ │ │
│ ┌───────────┤ 辅助任务1: SID↔Title│
│ │ │ (双向语义对齐) │
│ │ │ │
│ │ ┌────────┤ 辅助任务2: SID→Title│
│ │ │ │ (融合序列推荐) │
│ │ │ └──────────────────────┘
│ │ │
▼ ▼ ▼
ConcatDataset → 混合 Shuffle → 统一训练
原理:主任务训练模型进行 SID 序列推荐,但仅有 SID→SID 训练可能导致 SID 的语义空间与 LLM 的语言空间脱节。辅助任务通过以下机制建立桥梁:
- SID↔Title 双向对齐(SidItemFeatDataset)
- Title→SID:让模型学习"自然语言描述 → 离散 SID"的映射,将 LLM 的语言理解能力注入 SID 空间
- SID→Title:让模型学习"离散 SID → 自然语言描述"的反向映射,确保 SID 保有可解释的语义
- 融合序列推荐(FusionSeqRecDataset)
- SID历史→Title:让模型在 SID 序列上下文中生成自然语言 title,强化 SID 序列的语义可理解性
- 迫使模型真正"理解"SID 序列中蕴含的用户偏好,而非简单的模式匹配
作用:
- 防止 SID embedding 退化为无语义的随机编码
- 让 LLM 的预训练知识(对商品名称、描述的理解)自然迁移到 SID 空间
- 提升模型在面对新商品或稀疏交互时的泛化能力
创新点 3:词表扩展 + 梯度掩码选择性训练
传统方法:全量微调所有参数,或使用 LoRA 等 PEFT 方法。
MiniOneRec 方法:
# 冻结全部参数
for param in model.parameters():
param.requires_grad = False
# 仅解冻 embedding 层
embedding_layer.weight.requires_grad = True
# 梯度掩码:只更新新 token 对应的行
def mask_grad(grad):
grad[:original_vocab_size].zero_()
return grad
embedding_layer.weight.register_hook(mask_grad)
原理:
- 整个 embedding 矩阵的
requires_grad设为 True(PyTorch 不支持部分行的 grad 开关) - 通过
register_hook在反向传播时对梯度矩阵的前original_vocab_size行置零 - 效果等价于只更新新增 SID token 的 embedding,而保持原始词表 embedding 不变
作用:
- 极大减少可训练参数:仅需训练
num_new_tokens × hidden_dim个参数 - 完整保留 LLM 的语言能力:原始 token 的 embedding 不被破坏
- 训练效率高:冻结参数不参与优化器状态更新,节省显存和计算
创新点 4:左侧截断策略
return {
"input_ids": tokens[-self.max_len:], # 从左侧截断
"attention_mask": attention_mask[-self.max_len:],
"labels": labels[-self.max_len:],
}
原理:当序列长度超过 max_len 时,从左侧(即最早的历史交互)截断,保留:
- 最近的交互历史(recency bias:近期行为对预测更重要)
- 完整的 target 输出(确保 loss 计算正确)
对比:右侧截断会丢失 target,导致训练目标缺失;中间截断会破坏上下文连续性。
创新点 5:端到端的 SFT→RL 训练范式
MiniOneRec 的 SFT 阶段是整个 SFT→RL pipeline 的基础:
SFT 阶段 RL 阶段
├─ 学习 SID 序列模式 ├─ GRPO 策略优化
├─ 建立 SID-语言对齐 ├─ 约束解码 (合法 SID)
├─ 学习用户偏好建模 ├─ 排序感知奖励
└─ 形成初始推荐策略 └─ KL 散度正则化
SFT 为 RL 提供了一个高质量的初始策略(reference policy),使得 RL 阶段可以在合理的搜索空间内进一步优化推荐质量。
十、数据流示例
以一条具体样本为例,展示完整数据变化过程:
原始数据(CSV)
user_id: U_001
history_item_sid: "['<sid_3><sid_42><sid_17>', '<sid_7><sid_88><sid_5>', '<sid_3><sid_12><sid_99>']"
item_sid: "<sid_7><sid_142><sid_56>"
item_id: "B00X7ABCD"
Task1 SidSFTDataset 处理后
Prompt (不计算 loss):
"Below is an instruction that describes a task...
### Instruction: Can you predict the next possible item...
### User Input: The user has interacted with items <sid_3><sid_42><sid_17>,
<sid_7><sid_88><sid_5>, <sid_3><sid_12><sid_99> in chronological order...
### Response:\n"
Target (计算 loss):
"<sid_7><sid_142><sid_56>\n"
Token 序列:
input_ids: [BOS, ..prompt_tokens.., ..target_tokens.., EOS]
labels: [-100, -100, ..., -100, g1, g2, ..., gk, EOS]
↑ ignore ↑ compute loss
Task2 SidItemFeatDataset 处理后(title2sid 方向)
Prompt: "...Which item has the title: Industrial Precision Screwdriver Set?"
Target: "<sid_3><sid_42><sid_17>"
Task2 SidItemFeatDataset 处理后(sid2title 方向)
Prompt: '...What is the title of item "<sid_3><sid_42><sid_17>"?'
Target: "Industrial Precision Screwdriver Set"
Task3 FusionSeqRecDataset 处理后
Prompt: "...The user has sequentially interacted with items <sid_3><sid_42><sid_17>,
<sid_7><sid_88><sid_5>... Tell me the title of the item"
Target: "Industrial Precision Screwdriver Set"
十一、训练启动配置(sft.sh)
torchrun --nproc_per_node 8 \
sft.py \
--base_model your_model_path \
--batch_size 1024 \
--micro_batch_size 16 \
--train_file ./data/Amazon/train/Industrial_and_Scientific*11.csv \
--eval_file ./data/Amazon/valid/Industrial_and_Scientific*11.csv \
--output_dir output_dir/xxx \
--category Industrial_and_Scientific \
--train_from_scratch False \
--seed 42 \
--sid_index_path ./data/Amazon/index/Industrial_and_Scientific.index.json \
--item_meta_path ./data/Amazon/index/Industrial_and_Scientific.item.json \
--freeze_LLM False
关键参数解读:
nproc_per_node=8:8 卡 DDP 分布式训练batch_size=1024, micro_batch_size=16:等效 batch 1024,每卡每步 16,累积1024/(16×8)=8步freeze_LLM=False:全量微调(不冻结 LLM)train_from_scratch=False:基于预训练模型微调
十二、总结
MiniOneRec 的 SFT 阶段通过以下核心设计,将大语言模型成功适配为生成式推荐系统:
| 设计要素 | 具体实现 | 核心价值 |
|---|---|---|
| 商品离散化 | 三层 RQ-VAE → SID | 将推荐转化为生成问题 |
| 词表扩展 | TokenExtender + resize_embedding | 让 LLM 能"说出"商品 |
| 多任务训练 | SID推荐 + SID↔Title对齐 + 融合推荐 | 保持语义空间一致性 |
| 选择性冻结 | gradient mask 仅训练新 token | 保留语言能力,高效训练 |
| 标签掩码 | prompt 部分 labels=-100 | 仅对生成目标计算 loss |
| 左侧截断 | tokens[-max_len:] | 保留近期行为和完整目标 |
| 早停机制 | EarlyStoppingCallback(patience=3) | 防止过拟合 |
这一设计使得 MiniOneRec 成为首个完整开源的端到端生成式推荐框架,为后续的强化学习阶段提供了高质量的初始策略基础。
十二. EasDeepRecommand个人推荐系统开源项目介绍
链接如下:EasyDeepRecommand
一个通俗易懂的开源推荐系统(A user-friendly open-source project for recommendation systems).
本项目将结合:代码、数据流转图、博客、模型发展史 等多个方面通俗易懂地讲解经典推荐模型,让读者通过一个项目了解推荐系统概况!
持续更新中..., 欢迎star🌟, 第一时间获取更新,感谢!!!