第20章 强化学习与Bandit推荐¶
⚠️ 时效性说明:本章涉及前沿模型/价格/榜单等信息,可能随版本快速变化;请以论文原文、官方发布页和 API 文档为准。
前置知识: 需要了解推荐系统基础(第1-10章)和强化学习基础(见强化学习教程)。本章聚焦RL/Bandit方法在推荐系统中的具体应用,是字节跳动等公司面试的高频考点。
20.1 推荐系统中的探索与利用¶
20.1.1 为什么推荐系统需要探索¶
推荐系统的核心目标是为用户推荐最相关的内容,但"最相关"依赖于对用户偏好的准确估计。如果系统只推荐当前认为最好的内容(利用 Exploitation),就永远无法发现用户潜在的新兴趣;如果系统经常推荐未知内容(探索 Exploration),短期用户体验会受损。
这就是经典的 Exploration vs Exploitation (EE) 问题。
推荐系统中的EE困境:
用户画像 → [已知偏好: 科技、编程] → Exploitation: 推荐科技文章
→ Exploration: 尝试推荐旅行、美食
问题:如何在不损害用户体验的前提下,持续发现用户的新兴趣?
20.1.2 纯利用的问题¶
只做 Exploitation 会导致严重的系统性问题:
| 问题 | 描述 | 后果 |
|---|---|---|
| Filter Bubble | 用户只能看到与历史行为相似的内容 | 信息茧房,用户视野变窄 |
| 马太效应 | 热门内容越推越热,冷门内容永无出头之日 | 内容生态不健康,创作者流失 |
| 估计偏差 | 未被展示的内容缺乏反馈数据,其价值永远被低估 | 系统无法自我修正 |
| 用户疲劳 | 反复推荐同类内容导致用户厌倦 | 留存率下降 |
20.1.3 EE在推荐各阶段的应用¶
EE策略可以在推荐系统的不同阶段实施:
召回阶段: - 在候选集中混入一定比例的随机/新颖物品 - 使用多路召回,其中一路专门负责探索(如随机召回、新品召回)
排序阶段: - 在排序模型的预估分上加噪声(ε-Greedy 思想) - 使用 Bandit 模型替代或辅助排序模型(如 LinUCB) - Thompson Sampling 对排序分进行采样
重排阶段: - 在最终推荐列表中插入探索性内容 - 使用 MMR(Maximal Marginal Relevance)增加多样性 - Determinantal Point Process (DPP) 保证列表多样性
20.1.4 面试考点¶
Q: 如何在推荐系统中平衡 Exploration 和 Exploitation?
A: 核心思路是在保证用户体验的前提下进行有策略的探索: 1. 流量分配:用一小部分流量(如5%)做探索,大部分流量做利用 2. 算法层面:使用 Bandit 算法(UCB/Thompson Sampling)自动平衡EE 3. 分阶段策略:召回阶段大胆探索(成本低),排序/重排阶段保守探索 4. 用户分群:对活跃用户多探索,对低活用户少探索 5. 置信度驱动:对模型不确定性高的物品进行探索(UCB思想) 6. 时间衰减:新用户多探索,随着数据积累逐渐减少探索比例
20.2 Multi-Armed Bandit基础¶
20.2.1 MAB问题形式化¶
Multi-Armed Bandit(多臂老虎机)是探索与利用问题的经典抽象:
- 有 \(K\) 个臂(arm),对应 \(K\) 个候选物品
- 每轮选择一个臂拉动,获得一个随机奖励 \(r \sim P_a\)
- 目标:在 \(T\) 轮内最大化累积奖励,等价于最小化累积遗憾(Regret)
其中 \(\mu^* = \max_a \mu_a\) 是最优臂的期望奖励,\(\mu_{a_t}\) 是第 \(t\) 轮选择的臂的期望奖励。
20.2.2 ε-Greedy策略¶
最简单的EE策略:以 \(\epsilon\) 的概率随机选择一个臂(探索),以 \(1-\epsilon\) 的概率选择当前估计最优的臂(利用)。
import numpy as np
class EpsilonGreedy:
"""ε-Greedy策略"""
def __init__(self, n_arms, epsilon=0.1):
self.n_arms = n_arms
self.epsilon = epsilon
self.counts = np.zeros(n_arms) # 每个臂被选次数
self.values = np.zeros(n_arms) # 每个臂的估计价值
def select_arm(self):
if np.random.random() < self.epsilon:
return np.random.randint(self.n_arms) # 探索
else:
return np.argmax(self.values) # 利用
def update(self, arm, reward):
self.counts[arm] += 1
n = self.counts[arm]
# 增量更新均值: Q_new = Q_old + (r - Q_old) / n
self.values[arm] += (reward - self.values[arm]) / n
缺点:探索是完全随机的,不考虑已有信息;\(\epsilon\) 是固定的,不会随时间衰减。
20.2.3 Upper Confidence Bound (UCB)¶
UCB 的核心思想:乐观面对不确定性。选择的不是估计值最高的臂,而是估计值+置信上界最高的臂。
- \(Q(a)\):臂 \(a\) 的估计期望奖励
- \(c\):探索系数,控制探索程度
- \(\sqrt{\frac{\ln t}{N(a)}}\):置信半径,被选次数少的臂置信区间更宽
class UCB:
"""Upper Confidence Bound策略"""
def __init__(self, n_arms, c=2.0):
self.n_arms = n_arms
self.c = c
self.counts = np.zeros(n_arms)
self.values = np.zeros(n_arms)
self.t = 0
def select_arm(self):
self.t += 1
# 先让每个臂至少被选一次
for arm in range(self.n_arms):
if self.counts[arm] == 0:
return arm
ucb_values = self.values + self.c * np.sqrt(
np.log(self.t) / self.counts
)
return np.argmax(ucb_values)
def update(self, arm, reward):
self.counts[arm] += 1
n = self.counts[arm]
self.values[arm] += (reward - self.values[arm]) / n
优点:探索有方向性,优先探索不确定性高的臂;有理论保证(Regret上界为 \(O(\sqrt{KT\ln T})\))。
20.2.4 Thompson Sampling¶
Thompson Sampling 使用贝叶斯方法,为每个臂维护一个奖励分布的后验,每轮从后验中采样来做决策。
Beta-Bernoulli模型(适用于0/1奖励,如点击/不点击): - 先验:\(\text{Beta}(\alpha=1, \beta=1)\)(均匀分布) - 观测到一次成功(点击):\(\alpha \leftarrow \alpha + 1\) - 观测到一次失败(不点击):\(\beta \leftarrow \beta + 1\) - 后验:\(\text{Beta}(\alpha, \beta)\),均值为 \(\frac{\alpha}{\alpha + \beta}\)
class ThompsonSampling:
"""Thompson Sampling (Beta-Bernoulli)"""
def __init__(self, n_arms):
self.n_arms = n_arms
self.alpha = np.ones(n_arms) # 成功次数 + 1
self.beta = np.ones(n_arms) # 失败次数 + 1
def select_arm(self):
# 从每个臂的后验Beta分布中采样
samples = np.random.beta(self.alpha, self.beta)
return np.argmax(samples)
def update(self, arm, reward):
# reward为0或1
if reward == 1:
self.alpha[arm] += 1
else:
self.beta[arm] += 1
优点:自动平衡EE(不确定性大的臂采样方差大,被选概率高);实现简洁;经验效果通常最好。
20.2.5 三种策略对比实验¶
import numpy as np
import matplotlib.pyplot as plt
# EpsilonGreedy, UCB, ThompsonSampling 类定义同上
class BanditEnv:
"""模拟K臂老虎机环境"""
def __init__(self, true_probs):
self.true_probs = np.array(true_probs) # np.array创建NumPy数组
self.n_arms = len(true_probs)
self.best_arm = np.argmax(true_probs)
self.best_prob = true_probs[self.best_arm]
def pull(self, arm):
return 1 if np.random.random() < self.true_probs[arm] else 0
# ----- 运行实验 -----
def run_experiment(env, policy, n_rounds=5000):
"""运行一次实验,返回累积遗憾"""
regrets = []
cumulative_regret = 0
for t in range(n_rounds):
arm = policy.select_arm()
reward = env.pull(arm)
policy.update(arm, reward)
instant_regret = env.best_prob - env.true_probs[arm]
cumulative_regret += instant_regret
regrets.append(cumulative_regret)
return regrets
# 设置环境:10个臂的Bandit
np.random.seed(42)
true_probs = [0.05, 0.08, 0.12, 0.15, 0.20, 0.25, 0.30, 0.35, 0.10, 0.45]
env = BanditEnv(true_probs)
n_rounds = 5000
n_experiments = 20 # 多次实验取平均
policies = {
'ε-Greedy (ε=0.1)': lambda: EpsilonGreedy(env.n_arms, epsilon=0.1),
'UCB (c=2)': lambda: UCB(env.n_arms, c=2.0),
'Thompson Sampling': lambda: ThompsonSampling(env.n_arms),
}
plt.figure(figsize=(10, 6))
for name, policy_fn in policies.items():
all_regrets = []
for _ in range(n_experiments):
policy = policy_fn()
regrets = run_experiment(env, policy, n_rounds)
all_regrets.append(regrets)
mean_regrets = np.mean(all_regrets, axis=0)
plt.plot(mean_regrets, label=name)
plt.xlabel('Round')
plt.ylabel('Cumulative Regret')
plt.title('Bandit策略对比 - 累积遗憾曲线')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('bandit_comparison.png', dpi=150)
plt.show()
print(f"最优臂: arm {env.best_arm}, 真实概率: {env.best_prob}")
实验观察: - Thompson Sampling 通常 Regret 增长最慢,收敛最快 - UCB 表现稳定,有理论保证 - ε-Greedy 因为持续随机探索,Regret 线性增长
20.2.6 面试考点¶
Q: UCB 与 Thompson Sampling 的优劣对比?
维度 UCB Thompson Sampling 理论保证 有严格Regret上界证明 有渐近最优性证明 实现复杂度 简单,确定性选择 需要采样,略复杂 经验效果 好 通常更好 超参数 探索系数 \(c\) 需要调 仅需选择先验(不敏感) 批量更新 不太自然 天然支持批量/延迟更新 扩展性 易扩展到连续动作 后验复杂时需近似采样 工业应用 较多用于广告、推荐 广泛用于A/B测试、推荐 结论:工业界更偏好 Thompson Sampling,因为效果好、对超参数不敏感、天然支持批量延迟反馈。
20.3 Contextual Bandit¶
20.3.1 从MAB到Contextual Bandit¶
经典MAB的局限:不考虑上下文。给所有用户推荐同一个最优臂,没有个性化。
Contextual Bandit 引入上下文向量 \(\mathbf{x}_{t,a}\),每轮: 1. 观察上下文 \(\mathbf{x}_t\)(用户特征、物品特征、交叉特征等) 2. 根据上下文选择动作 \(a_t\) 3. 观察奖励 \(r_t\) 4. 更新策略
在推荐场景中: - 上下文:用户特征(年龄、性别、历史行为)+ 环境特征(时间、地点、设备) - 臂/动作:候选物品或推荐策略 - 奖励:点击、购买、观看时长等
20.3.2 LinUCB算法详解¶
LinUCB(Li et al., 2010)是最经典的 Contextual Bandit 算法,Yahoo! 用它做新闻推荐。
核心假设:每个臂 \(a\) 的期望奖励是上下文特征的线性函数:
其中 \(\boldsymbol{\theta}_a^*\) 是臂 \(a\) 的未知参数向量。
岭回归参数估计:
通过收集的数据 \(\{(\mathbf{x}_s, r_s)\}\) 进行岭回归估计:
其中: - \(\mathbf{A}_a = \mathbf{I}_d + \sum_{s: a_s=a} \mathbf{x}_s \mathbf{x}_s^\top\)(正则化协方差矩阵) - \(\mathbf{b}_a = \sum_{s: a_s=a} r_s \mathbf{x}_s\)(特征加权奖励累积)
UCB置信区间:
选择策略加入置信上界:
\(\sqrt{\mathbf{x}_{t,a}^\top \mathbf{A}_a^{-1} \mathbf{x}_{t,a}}\) 衡量的是在当前上下文下对臂 \(a\) 奖励估计的不确定性。
完整 LinUCB 实现:
import numpy as np
class LinUCB:
"""
Disjoint LinUCB算法
每个臂有独立的参数向量θ_a
"""
def __init__(self, n_arms, d, alpha=1.0):
"""
Args:
n_arms: 候选臂数量
d: 上下文特征维度
alpha: 探索系数,控制置信区间宽度
"""
self.n_arms = n_arms
self.d = d
self.alpha = alpha
# 每个臂的参数
self.A = {} # A_a = I_d + Σ x_s x_s^T
self.b = {} # b_a = Σ r_s * x_s
for a in range(n_arms):
self.A[a] = np.eye(d) # d×d 单位矩阵
self.b[a] = np.zeros(d) # d维零向量
def select_arm(self, context_vectors):
"""
选择一个臂
Args:
context_vectors: dict or list, {arm_id: context_vector}
或 shape=(n_arms, d) 的数组
Returns:
chosen_arm: 选择的臂编号
"""
ucb_values = np.zeros(self.n_arms)
for a in range(self.n_arms):
if isinstance(context_vectors, dict): # isinstance检查类型
x = context_vectors[a]
else:
x = context_vectors[a]
A_inv = np.linalg.inv(self.A[a]) # np.linalg线性代数运算
theta_hat = A_inv @ self.b[a] # 参数估计
# UCB = x^T θ_hat + α * sqrt(x^T A^{-1} x)
pred = x @ theta_hat # 预测奖励
confidence = self.alpha * np.sqrt(x @ A_inv @ x) # 置信宽度
ucb_values[a] = pred + confidence
return np.argmax(ucb_values)
def update(self, arm, context, reward):
"""
更新臂的参数
Args:
arm: 选择的臂
context: 上下文向量 shape=(d,)
reward: 获得的奖励
"""
self.A[arm] += np.outer(context, context) # A_a += x x^T
self.b[arm] += reward * context # b_a += r * x
# ----- LinUCB 模拟实验 -----
def simulate_linucb():
"""模拟新闻推荐场景"""
np.random.seed(42)
n_arms = 5 # 5篇候选新闻
d = 6 # 6维用户特征
n_rounds = 3000
alpha = 0.5
# 每个臂的真实参数(未知,模拟用)
true_theta = np.random.randn(n_arms, d) * 0.5
agent = LinUCB(n_arms, d, alpha=alpha)
cumulative_reward = 0
rewards_history = []
optimal_rewards = []
for t in range(n_rounds):
# 生成随机用户上下文
user_context = np.random.randn(d)
user_context = user_context / np.linalg.norm(user_context) # 归一化
# 构建每个臂的上下文(此处简化为共享用户特征)
contexts = {a: user_context for a in range(n_arms)}
# 计算真实期望奖励
true_rewards = [user_context @ true_theta[a] for a in range(n_arms)]
optimal_arm = np.argmax(true_rewards)
# LinUCB选择
chosen_arm = agent.select_arm(contexts)
# 生成奖励(真实期望 + 噪声)
reward = user_context @ true_theta[chosen_arm] + np.random.randn() * 0.1
reward = max(0, min(1, reward)) # 裁剪到[0,1]
# 更新
agent.update(chosen_arm, user_context, reward)
cumulative_reward += reward
rewards_history.append(cumulative_reward / (t + 1))
optimal_rewards.append(
max(0, min(1, true_rewards[optimal_arm]))
)
# 绘制平均奖励曲线
plt.figure(figsize=(10, 5))
plt.plot(rewards_history, label='LinUCB Avg Reward', alpha=0.8)
window = 100
rolling_optimal = np.convolve(
optimal_rewards, np.ones(window)/window, mode='valid'
)
plt.plot(range(window-1, n_rounds), rolling_optimal,
label='Optimal (rolling avg)', alpha=0.8, linestyle='--')
plt.xlabel('Round')
plt.ylabel('Average Reward')
plt.title('LinUCB 新闻推荐模拟')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('linucb_simulation.png', dpi=150)
plt.show()
print(f"最终平均奖励: {rewards_history[-1]:.4f}") # [-1]负索引取最后元素
import matplotlib.pyplot as plt
simulate_linucb()
20.3.3 Disjoint vs Hybrid LinUCB¶
| 类型 | 参数结构 | 适用场景 |
|---|---|---|
| Disjoint | 每个臂独立的 \(\boldsymbol{\theta}_a\) | 臂之间无共享特征时 |
| Hybrid | 共享参数 \(\boldsymbol{\beta}\) + 独立参数 \(\boldsymbol{\theta}_a\) | 臂之间有共享特征(如物品共有属性) |
Hybrid LinUCB 的奖励模型:
- \(\mathbf{z}_{t,a}\):用户-物品共享特征
- \(\mathbf{x}_{t,a}\):臂特有的特征
共享参数 \(\boldsymbol{\beta}\) 允许跨臂的知识迁移,对冷启动物品特别有帮助。
20.3.4 面试考点¶
Q: LinUCB如何处理冷启动?
A: LinUCB 天然具有冷启动能力: 1. 新物品冷启动:新物品的 \(\mathbf{A}_a = \mathbf{I}_d\),\(\mathbf{b}_a = \mathbf{0}\),因此 \(\hat{\boldsymbol{\theta}}_a = \mathbf{0}\),预测值为0,但置信宽度 \(\sqrt{\mathbf{x}^\top \mathbf{A}_a^{-1} \mathbf{x}}\) 很大,UCB值高,系统自动倾向于探索新物品 2. Hybrid模式:共享参数 \(\boldsymbol{\beta}\) 可以利用已有物品的知识帮助估计新物品 3. 特征泛化:即使物品是新的,只要有上下文特征(如内容特征),模型也能给出合理的初始估计 4. 与协同过滤对比:协同过滤需要用户-物品交互数据,新物品无数据时完全无法预测;LinUCB 只需特征即可
20.4 深度强化学习推荐¶
20.4.1 DQN在推荐中的应用¶
当状态空间和动作空间很大时,传统 Bandit 方法难以扩展。深度强化学习(Deep RL)使用神经网络逼近价值函数或策略函数,可以处理高维连续状态空间。
推荐系统的MDP建模:
| 要素 | 定义 | 示例 |
|---|---|---|
| 状态 \(s_t\) | 用户当前状态 | 用户历史行为序列、画像特征 |
| 动作 \(a_t\) | 推荐决策 | 推荐一个物品 / 一个列表 |
| 奖励 \(r_t\) | 即时反馈 | 点击=1,购买=5,跳过=0 |
| 转移 \(P\) | 状态转换 | 用户行为后状态更新 |
| 折扣 \(\gamma\) | 长期收益权重 | 0.9~0.99 |
核心优势:DQN 可以优化长期用户价值(如用户留存),而不仅是即时点击率。
20.4.2 DQN推荐Agent¶
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from collections import deque
import random
import matplotlib.pyplot as plt
class QNetwork(nn.Module): # 继承nn.Module定义网络层
"""Q网络:输入状态,输出每个动作的Q值"""
def __init__(self, state_dim, n_actions, hidden_dim=128):
super().__init__() # super()调用父类方法
self.net = nn.Sequential(
nn.Linear(state_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, n_actions)
)
def forward(self, x):
return self.net(x)
class DQNRecommender:
"""基于DQN的推荐Agent"""
def __init__(self, state_dim, n_items, hidden_dim=128,
lr=1e-3, gamma=0.99, epsilon_start=1.0,
epsilon_end=0.01, epsilon_decay=0.995,
buffer_size=10000, batch_size=64,
target_update_freq=100):
self.state_dim = state_dim
self.n_items = n_items
self.gamma = gamma
self.epsilon = epsilon_start
self.epsilon_end = epsilon_end
self.epsilon_decay = epsilon_decay
self.batch_size = batch_size
self.target_update_freq = target_update_freq
self.step_count = 0
# 主网络和目标网络
self.q_net = QNetwork(state_dim, n_items, hidden_dim)
self.target_net = QNetwork(state_dim, n_items, hidden_dim)
self.target_net.load_state_dict(self.q_net.state_dict())
self.target_net.eval()
self.optimizer = optim.Adam(self.q_net.parameters(), lr=lr)
self.loss_fn = nn.SmoothL1Loss() # Huber Loss
# 经验回放
self.replay_buffer = deque(maxlen=buffer_size)
def select_item(self, state):
"""ε-Greedy选择推荐物品"""
if random.random() < self.epsilon:
return random.randint(0, self.n_items - 1)
with torch.no_grad(): # 禁用梯度计算,节省内存
state_t = torch.FloatTensor(state).unsqueeze(0) # unsqueeze增加一个维度
q_values = self.q_net(state_t)
return q_values.argmax(dim=1).item() # 将单元素张量转为Python数值
def store_transition(self, state, action, reward, next_state, done):
"""存入经验回放"""
self.replay_buffer.append((state, action, reward, next_state, done))
def train_step(self):
"""从经验回放中采样并训练"""
if len(self.replay_buffer) < self.batch_size:
return None
batch = random.sample(self.replay_buffer, self.batch_size)
states, actions, rewards, next_states, dones = zip(*batch) # zip按位置配对
states = torch.FloatTensor(np.array(states))
actions = torch.LongTensor(actions).unsqueeze(1)
rewards = torch.FloatTensor(rewards).unsqueeze(1)
next_states = torch.FloatTensor(np.array(next_states))
dones = torch.FloatTensor(dones).unsqueeze(1)
# 当前Q值
q_values = self.q_net(states).gather(1, actions)
# 目标Q值 (Double DQN)
with torch.no_grad():
next_actions = self.q_net(next_states).argmax(dim=1, keepdim=True)
next_q = self.target_net(next_states).gather(1, next_actions)
target_q = rewards + self.gamma * next_q * (1 - dones)
loss = self.loss_fn(q_values, target_q)
self.optimizer.zero_grad() # 清零梯度
loss.backward() # 反向传播计算梯度
torch.nn.utils.clip_grad_norm_(self.q_net.parameters(), 1.0)
self.optimizer.step() # 更新参数
# 更新目标网络
self.step_count += 1
if self.step_count % self.target_update_freq == 0:
self.target_net.load_state_dict(self.q_net.state_dict())
# 衰减ε
self.epsilon = max(self.epsilon_end,
self.epsilon * self.epsilon_decay)
return loss.item()
# ----- 简化模拟 -----
class SimpleRecEnv:
"""简化的推荐环境模拟"""
def __init__(self, n_items=20, state_dim=16):
self.n_items = n_items
self.state_dim = state_dim
# 模拟用户偏好
self.user_pref = np.random.randn(state_dim)
self.item_embeddings = np.random.randn(n_items, state_dim) * 0.5
self.step = 0
self.max_steps = 50
def reset(self):
self.user_pref = np.random.randn(self.state_dim)
self.step = 0
return self._get_state()
def _get_state(self):
return self.user_pref.copy()
def act(self, item_id):
"""模拟用户对推荐物品的反应"""
# 相似度越高,奖励越高
sim = np.dot(self.user_pref, self.item_embeddings[item_id]) # np.dot矩阵/向量点乘
prob_click = 1 / (1 + np.exp(-sim)) # sigmoid
clicked = np.random.random() < prob_click
reward = 1.0 if clicked else 0.0
# 更新用户状态(模拟兴趣漂移)
if clicked:
self.user_pref = 0.9 * self.user_pref + \
0.1 * self.item_embeddings[item_id]
self.step += 1
done = self.step >= self.max_steps
return self._get_state(), reward, done
def train_dqn_recommender():
"""训练DQN推荐Agent"""
env = SimpleRecEnv(n_items=20, state_dim=16)
agent = DQNRecommender(
state_dim=16, n_items=20,
lr=1e-3, gamma=0.95, buffer_size=5000, batch_size=32
)
n_episodes = 300
episode_rewards = []
for ep in range(n_episodes):
state = env.reset()
total_reward = 0
while True:
action = agent.select_item(state)
next_state, reward, done = env.act(action)
agent.store_transition(state, action, reward, next_state, done)
loss = agent.train_step()
state = next_state
total_reward += reward
if done:
break
episode_rewards.append(total_reward)
if (ep + 1) % 50 == 0:
avg = np.mean(episode_rewards[-50:])
print(f"Episode {ep+1}, Avg Reward: {avg:.2f}, "
f"Epsilon: {agent.epsilon:.3f}")
# 绘制学习曲线
plt.figure(figsize=(10, 5))
window = 20
smoothed = np.convolve(episode_rewards,
np.ones(window)/window, mode='valid')
plt.plot(smoothed)
plt.xlabel('Episode')
plt.ylabel('Total Reward (smoothed)')
plt.title('DQN推荐Agent学习曲线')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('dqn_recommender.png', dpi=150)
plt.show()
train_dqn_recommender()
20.4.3 Actor-Critic在推荐中的应用¶
Actor-Critic 框架中,Actor(策略网络)输出推荐策略,Critic(价值网络)评估长期收益。优势函数 \(A = r + \gamma V(s') - V(s)\) 指导Actor更新。
优势:比DQN更适合大动作空间;可输出概率分布天然支持探索;训练更稳定。
20.5 策略梯度与列表推荐¶
20.5.1 Policy Gradient用于列表排序¶
推荐系统通常需要输出一个有序列表(slate),而不是单个物品。策略梯度方法可以直接优化整个列表的质量。
REINFORCE算法在推荐中的应用:
将推荐列表的生成建模为序列决策过程——依次选择列表中的每个位置放什么物品。
策略梯度定理:
其中 \(G_t = \sum_{k=t}^{T} \gamma^{k-t} r_k\) 是折扣回报。
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
class SlatePolicy(nn.Module):
"""
推荐列表策略网络
输入用户状态,输出每个候选物品的选择概率
"""
def __init__(self, state_dim, n_candidates, hidden_dim=64):
super().__init__()
self.encoder = nn.Sequential(
nn.Linear(state_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
)
self.scorer = nn.Linear(hidden_dim, n_candidates)
def forward(self, state):
h = self.encoder(state)
logits = self.scorer(h)
return logits
def select_slate(self, state, slate_size, greedy=False):
"""
选择一个推荐列表(无放回采样)
Returns: (slate, log_probs)
"""
logits = self.forward(state)
slate = []
log_probs = []
mask = torch.zeros_like(logits) # 已选物品的mask
for _ in range(slate_size):
masked_logits = logits + mask
probs = F.softmax(masked_logits, dim=-1) # F.xxx PyTorch函数式API
if greedy:
idx = probs.argmax(dim=-1)
else:
dist = torch.distributions.Categorical(probs)
idx = dist.sample()
log_probs.append(dist.log_prob(idx))
slate.append(idx.item())
mask[0, idx] = float('-inf') # 屏蔽已选物品
return slate, log_probs
def train_slate_reinforce():
"""REINFORCE训练推荐列表策略"""
state_dim = 16
n_candidates = 50
slate_size = 5
n_episodes = 500
lr = 1e-3
gamma = 0.99
policy = SlatePolicy(state_dim, n_candidates)
optimizer = torch.optim.Adam(policy.parameters(), lr=lr)
# 模拟物品质量(未知)
item_quality = np.random.rand(n_candidates)
episode_rewards = []
for ep in range(n_episodes):
# 随机用户状态
state = torch.randn(1, state_dim)
slate, log_probs = policy.select_slate(state, slate_size)
# 模拟用户反馈:位置衰减 + 物品质量
rewards = []
for pos, item_id in enumerate(slate): # enumerate同时获取索引和元素
position_bias = 1.0 / np.log2(pos + 2) # DCG位置权重
click_prob = item_quality[item_id] * position_bias
reward = 1.0 if np.random.random() < click_prob else 0.0
rewards.append(reward)
# 计算折扣回报
returns = []
G = 0
for r in reversed(rewards):
G = r + gamma * G
returns.insert(0, G)
returns = torch.FloatTensor(returns)
returns = (returns - returns.mean()) / (returns.std() + 1e-8)
# REINFORCE更新
policy_loss = 0
for lp, G in zip(log_probs, returns):
policy_loss -= lp * G
optimizer.zero_grad()
policy_loss.backward()
optimizer.step()
episode_rewards.append(sum(rewards))
if (ep + 1) % 100 == 0:
avg_r = np.mean(episode_rewards[-100:])
print(f"Episode {ep+1}, Avg Reward: {avg_r:.3f}")
print(f"\n学到的Top物品: {slate}")
print(f"真实最优Top-{slate_size}: "
f"{np.argsort(item_quality)[-slate_size:][::-1].tolist()}")
train_slate_reinforce()
20.5.2 离线策略评估(Off-Policy Evaluation, OPE)¶
在推荐系统中,离线评估至关重要——我们不可能每次都上线测试新策略。
为什么OPE在RL推荐中特别重要? - 在线A/B测试成本高、周期长 - 错误的策略可能严重损害用户体验 - 需要在部署前评估新策略的效果
Inverse Propensity Scoring (IPS)¶
- \(\pi_e\):待评估策略(evaluation policy)
- \(\pi_b\):行为策略(behavior policy,收集数据时使用的策略)
- 重要性采样权重 \(w_i = \frac{\pi_e(a_i|x_i)}{\pi_b(a_i|x_i)}\) 修正分布偏差
问题:当 \(\pi_e\) 和 \(\pi_b\) 差异大时,方差极大。
Doubly Robust Estimator¶
- \(\hat{r}(x, a)\):奖励预测模型(Direct Method 部分)
- 同时使用 IPS 和 DM,当其中一个准确时,估计就是准确的
def ips_estimator(rewards, pi_e_probs, pi_b_probs, clip=None):
"""
IPS离线策略评估
Args:
rewards: 观测到的奖励
pi_e_probs: 评估策略在观测动作上的概率 π_e(a|x)
pi_b_probs: 行为策略在观测动作上的概率 π_b(a|x)
clip: 重要性权重裁剪阈值
"""
weights = pi_e_probs / (pi_b_probs + 1e-10)
if clip is not None:
weights = np.clip(weights, 0, clip)
return np.mean(weights * rewards)
def doubly_robust_estimator(rewards, pi_e_probs, pi_b_probs,
reward_model_preds, pi_e_expected_reward):
"""
Doubly Robust离线策略评估
Args:
reward_model_preds: 奖励模型对(x,a)的预测 r_hat(x,a)
pi_e_expected_reward: 奖励模型在评估策略下的期望 E_π_e[r_hat(x,a)]
"""
weights = pi_e_probs / (pi_b_probs + 1e-10)
residuals = rewards - reward_model_preds
return np.mean(pi_e_expected_reward + weights * residuals)
# 示例
np.random.seed(42)
n = 1000
rewards = np.random.binomial(1, 0.3, n).astype(float)
pi_b_probs = np.ones(n) * 0.2 # 行为策略:均匀分布(5选1)
pi_e_probs = np.random.uniform(0.1, 0.5, n) # 评估策略
reward_preds = np.ones(n) * 0.3 # 简单奖励模型
pi_e_expected = np.ones(n) * 0.3
v_ips = ips_estimator(rewards, pi_e_probs, pi_b_probs, clip=10)
v_dr = doubly_robust_estimator(
rewards, pi_e_probs, pi_b_probs, reward_preds, pi_e_expected
)
print(f"IPS估计: {v_ips:.4f}")
print(f"DR估计: {v_dr:.4f}")
print(f"真实均值: {rewards.mean():.4f}")
20.5.3 面试考点¶
Q: 为什么推荐系统的RL方法特别需要离线评估?
A: 1. 在线实验代价高:RL策略需要大量交互才能评估,线上实验影响真实用户 2. 安全约束:未经验证的策略可能推荐不当内容 3. 反馈延迟:推荐的长期效果(留存、LTV)需要很长时间才能观测 4. 部署周期:工业系统上线流程复杂,不可能频繁部署-评估-回滚 5. Counterfactual需求:"如果当时用了新策略,效果会怎样"只能离线估计
20.6 工业界RL推荐实践¶
20.6.1 字节跳动推荐系统中的Bandit应用¶
字节跳动(抖音/今日头条)大量使用 Contextual Bandit:
应用场景: - 冷启动探索:新内容/新用户使用 Bandit 做快速探索 - 流量调控:通过 Bandit 动态分配不同策略的流量比例 - 创作者激励:用 Bandit 在内容质量和创作者多样性之间平衡 - 多目标权衡:在点击、时长、互动等多个目标间使用 Bandit 动态调权
技术细节: - 使用 Thompson Sampling 做后验采样,天然支持延迟反馈 - 在粗排层使用 Bandit 做策略选择,精排层用深度模型
20.6.2 阿里妈妈的RL出价/排序¶
阿里将出价建模为MDP,用DQN/DDPG学习实时出价策略(RLBID),并用RL在一天内动态分配广告预算最大化ROI。
20.6.3 YouTube的强化学习推荐¶
YouTube的 SlateQ 方法将推荐看作选择slate的问题,使用分解Q值处理组合动作空间,优化长期用户满意度。
20.6.4 线上RL推荐的挑战¶
| 挑战 | 描述 | 应对方案 |
|---|---|---|
| 延迟奖励 | 用户购买/留存信号可能延迟数天 | Reward Shaping、短期代理指标 |
| 安全约束 | 不能推荐违规/低质内容 | 约束优化(CMDP)、安全层过滤 |
| 样本效率 | 在线学习需要快速收敛 | 离线预训练 + 在线微调 |
| 非平稳性 | 用户兴趣和内容分布持续变化 | 滑动窗口、遗忘因子 |
| 大动作空间 | 候选物品数量可达百万级 | 层级动作空间、候选生成+排序 |
| 部分可观测 | 用户真实意图不可完全观测 | 信念状态、RNN/Transformer编码历史 |
20.6.5 Reward Shaping技巧¶
直接使用原始奖励(如购买)训练RL推荐系统效果很差,需要精心设计奖励:
def shaped_reward(action_type, duration=0, **kwargs): # *args接收任意位置参数,**kwargs接收任意关键字参数
"""
推荐系统中的Reward Shaping示例
将稀疏的长期奖励转化为密集的短期信号
"""
reward_map = {
'impression': 0.0, # 曝光:无奖励
'click': 1.0, # 点击
'like': 2.0, # 点赞
'comment': 3.0, # 评论
'share': 5.0, # 分享(高价值行为)
'follow': 4.0, # 关注创作者
'purchase': 10.0, # 购买
'skip': -0.5, # 快速跳过(负反馈)
'dislike': -2.0, # 不喜欢
'report': -5.0, # 举报
}
base_reward = reward_map.get(action_type, 0.0)
# 观看时长加成(视频推荐场景)
if action_type == 'click' and duration > 0:
# 完播给额外奖励
completion_ratio = min(duration / kwargs.get('video_len', 60), 1.0)
base_reward += completion_ratio * 2.0
return base_reward
设计原则:奖励反映真实业务目标;短期代理指标要与长期目标对齐;负反馈同样重要;多目标权重动态调整。注意避免Goodhart问题(优化代理指标而非真实目标)。
20.6.6 面试考点¶
Q: RL推荐系统部署的主要挑战有哪些?
A: 从工程和算法两个维度回答:
算法挑战: 1. 离线-在线差距(Sim-to-Real Gap):离线训练的模拟环境与真实用户行为有偏差 2. 延迟奖励:购买、留存等关键指标反馈延迟,导致信用分配困难 3. 样本效率:在线学习不能用太多轮次探索,否则损害用户体验 4. 非平稳环境:用户兴趣漂移、内容池持续更新
工程挑战: 1. 延迟要求:推荐系统要求毫秒级响应,RL推理不能太慢 2. 大规模动作空间:候选集可达百万,不能对每个物品计算Q值 3. AB测试框架:需要可靠的在线实验平台评估RL策略 4. 回滚机制:RL策略可能突然变差,需要自动降级和回滚
20.7 前沿方向¶
20.7.1 LLM作为推荐策略Agent¶
大语言模型为推荐系统带来新范式:
- LLM作为排序器:用prompt让LLM对候选物品排序
- 对话式推荐:LLM理解用户自然语言需求,主动提问并推荐
- Agent-based推荐:LLM作为Agent,调用检索/排序工具完成推荐,可生成推荐理由
挑战:延迟高、成本大、可控性差、幻觉问题。
20.7.2 Offline RL在推荐中的应用¶
Offline RL 从历史日志数据中学习策略,不需要在线交互:
- Conservative Q-Learning (CQL):惩罚超出数据分布的Q值,防止过高估计
- Batch-Constrained Q-Learning (BCQ):限制策略只选择数据中出现过的动作
- Decision Transformer:将RL建模为序列预测问题,用Transformer处理
优势:充分利用海量日志数据;不需在线探索,安全性好;适合推荐等高风险场景。
20.7.3 多目标RL推荐¶
推荐需同时优化点击、时长、互动等多个可能冲突的目标。方法路线:
- Pareto最优:搜索Pareto前沿上的策略
- 约束优化:将部分目标作为约束(如"时长≥30s前提下最大化点击")
- 动态加权:用Meta-Learning学习目标权重的自适应调整
- Conditioned Policy:策略以目标权重为条件输入,一个模型支持多种偏好
20.7.4 公平性约束下的RL推荐¶
公平性包括用户侧(不同群体获得相似质量推荐)、物品侧(创作者公平曝光)和双侧公平。技术方案:Constrained MDP (CMDP) 添加公平约束、Lagrangian方法转化为惩罚项、后处理方法在RL输出后调整。
20.8 面试高频题¶
题目1:Exploration vs Exploitation¶
Q: 推荐系统中为什么需要 Exploration?纯 Exploitation 策略有什么问题?举例说明在工业推荐系统中如何实施探索。
A:
纯 Exploitation 会导致三个核心问题:(1) Filter Bubble——用户只能看到历史偏好的内容;(2) 马太效应——热门越推越热,新内容没有机会;(3) 估计偏差——未曝光物品缺乏数据,价值永远被低估。
工业实践:字节跳动召回层设置“探索通道”混入新内容,排序层用TS采样;淘宝5%流量做探索桶;新用户多探索(70%),老用户少探索(5%)。
题目2:UCB公式推导与直觉¶
Q: 写出UCB1的选择公式,解释每一项的含义,以及为什么它能平衡探索与利用。
A:
- \(Q_t(a)\):臂 \(a\) 截至第 \(t\) 轮的平均奖励估计。越高说明这个臂越好 → 利用
- \(c\):探索系数,手动设置,典型值为 \(\sqrt{2}\)
- \(\ln t\):总轮次的对数。随时间增长,鼓励探索
- \(N_t(a)\):臂 \(a\) 被选次数。被选次数少 → 该项大 → 鼓励探索
EE平衡机制: - 如果某个臂很少被选(\(N_t(a)\) 小),探索项很大,UCB值高,被选概率大 → 探索 - 如果某个臂估计奖励很高(\(Q_t(a)\) 大),即使探索项不大,UCB也高 → 利用 - 随着 \(N_t(a)\) 增加,探索项缩小,系统逐渐从探索转向利用
题目3:Thompson Sampling原理¶
Q: 解释Thompson Sampling在推荐场景中如何工作?为什么工业界偏好它?
A:
工作流程(以CTR预估为例): 1. 为每个候选物品维护点击率的后验分布 \(\text{Beta}(\alpha_i, \beta_i)\) 2. 每次推荐时,从每个物品的后验中采样一个CTR值 \(\tilde{p}_i \sim \text{Beta}(\alpha_i, \beta_i)\) 3. 推荐采样CTR最高的物品 4. 观察用户是否点击,更新后验:点击 → \(\alpha_i + 1\),未点击 → \(\beta_i + 1\)
工业偏好的原因:天然支持批量/延迟反馈(不需每次立即更新);易于分布式实现(独立采样,天然并行);无需调参(不像UCB调\(c\)、ε-Greedy调\(\epsilon\));效果好且收敛快;可与深度模型的不确定性估计结合。
题目4:LinUCB算法¶
Q: LinUCB和传统UCB有什么区别?它在推荐系统中如何处理个性化?
A:
核心区别:UCB对每个臂维护一个标量估计,不考虑上下文;LinUCB 为每个臂维护一个参数向量 \(\boldsymbol{\theta}_a\),奖励是上下文特征的线性函数。
个性化机制:上下文向量 \(\mathbf{x}\) 包含用户特征,不同用户的 \(\mathbf{x}\) 不同 → 每个臂的UCB值对不同用户不同 → 个性化排序。活跃用户数据多 → 不确定性小 → 更倾向利用;新用户不确定性大 → 更倾向探索。矩阵更新复杂度:\(\mathbf{A}_a\) 更新 \(O(d^2)\),选择时求逆 \(O(d^3)\)(或用 Sherman-Morrison 增量更新 \(O(d^2)\))。
题目5:DQN推荐与传统推荐的区别¶
Q: 用DQN做推荐和传统的CTR预估模型有什么本质区别?
A:
| 维度 | CTR预估模型 | DQN推荐 |
|---|---|---|
| 优化目标 | 即时指标(点击/转化) | 长期累积奖励(留存/LTV) |
| 决策范围 | 贪心:每次推最可能被点击的 | 全局最优:可能推"当前点击率不高但能培养长期兴趣"的物品 |
| 状态建模 | 独立预测每次请求 | 考虑用户状态序列和转移 |
| 探索机制 | 通常没有(或外挂EE模块) | 内置(ε-Greedy / Boltzmann) |
| 训练方式 | 监督学习 | 强化学习(需要环境交互或离线数据) |
| 部署难度 | 简单,预测分→排序 | 复杂,需状态追踪、经验回放 |
关键洞察:DQN可以学会"牺牲短期收益换长期价值"。例如:给新用户推几次高质量但非最热门的内容 → 建立信任 → 长期留存。
题目6:Off-Policy Evaluation¶
Q: 什么是离线策略评估(OPE)?IPS估计的主要问题是什么?
A:
OPE定义:用行为策略 \(\pi_b\) 收集的历史数据,评估另一个策略 \(\pi_e\) 的期望表现,无需将 \(\pi_e\) 部署上线。
IPS:
主要问题:
- 高方差:当 \(\pi_e\) 和 \(\pi_b\) 差异大时,重要性权重 \(w = \pi_e / \pi_b\) 可能极大,导致少数样本主导估计
- 需要知道 \(\pi_b\):需要记录行为策略的概率,但工业系统中 \(\pi_b\) 可能很复杂且不精确不可知
- 支撑集问题:如果 \(\pi_b(a|x) = 0\) 但 \(\pi_e(a|x) > 0\),权重无穷大
解决方案:权重裁剪 \(w = \min(w, M)\) 降低方差但引入偏差;自归一化IPS \(\hat{V}_{SNIPS} = \frac{\sum w_i r_i}{\sum w_i}\);Doubly Robust 结合DM和IPS更鲁棒。
题目7:Reward Shaping¶
Q: 为什么RL推荐需要Reward Shaping?怎么设计?
A:
需要的原因: - 真实长期奖励(如30日留存)稀疏且延迟,RL无法有效学习 - 直接用原始奖励信号(如购买=1),大量的非购买样本奖励为0,梯度信号太弱
设计原则: 1. 层次化奖励:曝光(0) < 点击(1) < 深度阅读(2) < 互动(3-5) < 转化(10) 2. 时间折扣:越快发生的正反馈奖励越高(快速点击 > 延迟点击) 3. 负反馈惩罚:明确的负信号(跳过、不感兴趣、举报)给负奖励 4. 多样性奖励:推荐非重复类别给额外奖励
潜在陷阱: - Reward Shaping 设计不当可能导致策略优化代理指标而非真实目标(Goodhart问题) - 需要定期验证 shaped reward 与长期业务指标的相关性 - 各信号的权重需要反复实验调优
题目8:Contextual Bandit vs Full RL¶
Q: Contextual Bandit和完整的MDP/RL在推荐中各自适合什么场景?
A:
| 维度 | Contextual Bandit | Full RL (MDP) |
|---|---|---|
| 状态转移 | 不考虑 | 考虑 |
| 奖励 | 即时奖励 | 长期累积奖励 |
| 适用场景 | 单次推荐决策(如一次请求推什么) | 序列推荐(优化session/长期) |
| 计算复杂度 | 低 | 高 |
| 样本效率 | 高 | 低 |
| 可解释性 | 较好(线性模型) | 较差(黑箱) |
| 工业落地难度 | 低 | 高 |
选择建议: - 首选Contextual Bandit:大多数场景下,Bandit已经足够好,且更容易落地 - 使用Full RL:当需要优化长期指标(30天留存、生命周期价值)且有足够工程支持时 - 渐进路线:MAB → Contextual Bandit → Offline RL → Online RL
题目9:字节跳动推荐系统中的Bandit¶
Q: 字节跳动的推荐系统如何使用Bandit算法?在系统架构的哪个环节?
A:
核心应用环节:
- 粗排策略选择:
- 有多个排序策略(模型A、模型B、规则C),用Bandit动态选择对当前用户使用哪个策略
-
Thompson Sampling 做策略采样,实时更新后验
-
冷启动探索:
- 新内容(视频/文章)上传后缺乏数据,用Bandit快速测试其在不同用户群的表现
-
给新内容额外的探索流量配额
-
多目标权重调节:
- 推荐打分 = w₁·CTR + w₂·时长 + w₃·互动
-
权重向量(w₁, w₂, w₃)用Bandit在不同时段/人群上寻优
-
请求级别的召回通道选择:
- 有协同过滤召回、内容召回、热门召回等多路召回
- 用Bandit决定每路召回返回多少条候选
为什么选Bandit而非Full RL: - 工程复杂度远低于Full RL(无需状态追踪、经验回放) - 推理延迟几乎为零 - 效果在大多数场景已经足够好 - 容易做AB测试和回滚
题目10:Offline RL推荐¶
Q: 什么是Offline RL?它相比Online RL在推荐场景中有什么优势和问题?
A:
定义:Offline RL(也叫 Batch RL)完全从已有的历史交互日志中学习策略,不需要与环境进行新的交互。
优势: 1. 安全,所有学习离线完成;2. 充分利用海量历史日志;3. 成本低,无需在线RL框架;4. 可反复迭代尝试不同算法
问题:(1) 分布偏移:\(\pi_e\) 可能选择数据中未出现的动作 → Q值过高估计,CQL通过惩罚OOD动作的Q值解决;(2) 评估困难:OPE本身也有误差;(3) 数据质量依赖:行为策略探索不足则学不到好策略。
工业实践:通常采用 Offline预训练 + Online微调,先从日志学到初始策略,再在线上小心做少量探索和适应。
总结¶
本章覆盖了推荐系统中RL/Bandit方法的完整知识体系:
知识图谱:
探索与利用 (EE)
├── Multi-Armed Bandit
│ ├── ε-Greedy
│ ├── UCB
│ └── Thompson Sampling ★工业首选
├── Contextual Bandit
│ ├── LinUCB ★面试必考
│ └── Hybrid LinUCB
├── Deep RL推荐
│ ├── DQN → 离散动作
│ ├── Actor-Critic → 大动作空间
│ └── Policy Gradient → 列表推荐
├── 离线评估 (OPE)
│ ├── IPS
│ └── Doubly Robust ★
├── 工业实践
│ ├── 字节跳动 Bandit ★
│ ├── 阿里 RL 出价
│ └── YouTube SlateQ
└── 前沿
├── LLM Agent推荐
├── Offline RL (CQL)
├── 多目标RL
└── 公平性RL
面试核心要点: 1. 理解 EE 问题的本质和工业解决方案 2. 能手写 UCB、Thompson Sampling、LinUCB 3. 理解 Contextual Bandit 如何做个性化推荐 4. 知道 DQN 推荐与传统 CTR 模型的区别 5. 了解 OPE(特别是 IPS 和 DR)的原理和局限 6. 熟悉字节跳动等公司的 Bandit 工程实践