白话生成式推荐二:MiniOneRec之SFT

0 阅读14分钟

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 的语言空间脱节。辅助任务通过以下机制建立桥梁:

  1. SID↔Title 双向对齐(SidItemFeatDataset)
  • Title→SID:让模型学习"自然语言描述 → 离散 SID"的映射,将 LLM 的语言理解能力注入 SID 空间
  • SID→Title:让模型学习"离散 SID → 自然语言描述"的反向映射,确保 SID 保有可解释的语义
  1. 融合序列推荐(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🌟, 第一时间获取更新,感谢!!!