具有按臂特征的多臂老虎机教程#

开始#

在 TensorFlow.org 上查看 在 Google Colab 运行 在 Github 上查看源代码 下载笔记本

本教程将逐步指导您使用 TF-Agents 库来解决每个动作(臂)都具有自己的特征的上下文老虎机问题,例如通过特征(类型、发行年份等)表示的电影列表。

前提条件#

我们假定读者对于 TF-Agents 的 Bandit 库有一定了解,特别是在阅读本教程之前已完成 TF-Agents 中的多臂老虎机教程

具有臂特征的多臂老虎机#

在“经典”上下文多臂老虎机环境中,代理在每个时间步都会接收一个上下文向量(也称观测值),并且需要从一组有限的带编号动作(臂)中进行选择以最大化其累积奖励。

现在请考虑一种场景:代理向用户推荐下一部要观看的电影。每次需要做出决定时,代理都会接收一些用户相关信息(观影历史记录、类型偏好等)作为上下文,以及可供选择的电影列表。

我们可以尝试通过将用户信息作为上下文来分析这个问题,老虎机臂将为 movie_1, movie_2, ..., movie_K,但这种方式有多个缺点:

  • 动作的数量必须是系统中的电影总数,而添加新电影将非常麻烦。

  • 代理必须针对每一部电影学习一个模型。

  • 不会考虑到电影之间的相似度。

与其对电影进行编号,我们不如使用更加直观的方式:我们可以用一组特征来表示电影,包括类型、时长、演员、评分、年份等。这种方式具有多个优点:

  • 在不同电影之间进行泛化。

  • 代理仅学习一种使用用户和电影特征对奖励进行建模的奖励函数。

  • 易于从系统中移除电影或在系统中引入新电影。

在这种新的环境中,每个时间步的动作数量甚至不必相同。

TF-Agents 中的按臂老虎机#

开发的 TF-Agents Bandit 套件同样可用于按臂案例。它提供了按臂环境,并且大多数策略和代理都可以在按臂模式下运行。

在我们深入编写示例之前,我们需要导入必要内容。

安装#

!pip install tf-agents

导入#

import functools
import matplotlib.pyplot as plt
import numpy as np
import tensorflow as tf

from tf_agents.bandits.agents import lin_ucb_agent
from tf_agents.bandits.environments import stationary_stochastic_per_arm_py_environment as p_a_env
from tf_agents.bandits.metrics import tf_metrics as tf_bandit_metrics
from tf_agents.drivers import dynamic_step_driver
from tf_agents.environments import tf_py_environment
from tf_agents.replay_buffers import tf_uniform_replay_buffer
from tf_agents.specs import tensor_spec
from tf_agents.trajectories import time_step as ts

nest = tf.nest

参数 – 请随意调整#

# The dimension of the global features.
GLOBAL_DIM = 40  #@param {type:"integer"}
# The elements of the global feature will be integers in [-GLOBAL_BOUND, GLOBAL_BOUND).
GLOBAL_BOUND = 10  #@param {type:"integer"}
# The dimension of the per-arm features.
PER_ARM_DIM = 50  #@param {type:"integer"}
# The elements of the PER-ARM feature will be integers in [-PER_ARM_BOUND, PER_ARM_BOUND).
PER_ARM_BOUND = 6  #@param {type:"integer"}
# The variance of the Gaussian distribution that generates the rewards.
VARIANCE = 100.0  #@param {type: "number"}
# The elements of the linear reward parameter will be integers in [-PARAM_BOUND, PARAM_BOUND).
PARAM_BOUND = 10  #@param {type: "integer"}

NUM_ACTIONS = 70  #@param {type:"integer"}
BATCH_SIZE = 20  #@param {type:"integer"}

# Parameter for linear reward function acting on the
# concatenation of global and per-arm features.
reward_param = list(np.random.randint(
      -PARAM_BOUND, PARAM_BOUND, [GLOBAL_DIM + PER_ARM_DIM]))

简单的按臂环境#

我们在另一个教程中讲解过平稳随机环境,而它也具有相对应的按臂平稳随机环境。

要初始化按臂环境,必须定义函数来生成以下内容:

  • 全局和按臂特征:这些函数没有输入参数,并会在调用时生成单个(全局或按臂)特征向量。

  • 奖励:此函数会将全局和按臂特征向量的串联作为参数,并生成奖励。基本上,这是代理需要“猜测”的函数。这里值得注意的是,在按臂案例中,奖励函数对于每个老虎机臂都是相同的。这是与经典老虎机案例的根本区别,在经典老虎机案例中,代理必须针对每个老虎机臂独立估计奖励函数。

def global_context_sampling_fn():
  """This function generates a single global observation vector."""
  return np.random.randint(
      -GLOBAL_BOUND, GLOBAL_BOUND, [GLOBAL_DIM]).astype(np.float32)

def per_arm_context_sampling_fn():
  """"This function generates a single per-arm observation vector."""
  return np.random.randint(
      -PER_ARM_BOUND, PER_ARM_BOUND, [PER_ARM_DIM]).astype(np.float32)

def linear_normal_reward_fn(x):
  """This function generates a reward from the concatenated global and per-arm observations."""
  mu = np.dot(x, reward_param)
  return np.random.normal(mu, VARIANCE)

现在,我们可以初始化我们的环境了。

per_arm_py_env = p_a_env.StationaryStochasticPerArmPyEnvironment(
    global_context_sampling_fn,
    per_arm_context_sampling_fn,
    NUM_ACTIONS,
    linear_normal_reward_fn,
    batch_size=BATCH_SIZE
)
per_arm_tf_env = tf_py_environment.TFPyEnvironment(per_arm_py_env)

下面我们可以检查此环境能够生成哪些内容。

print('observation spec: ', per_arm_tf_env.observation_spec())
print('\nAn observation: ', per_arm_tf_env.reset().observation)

action = tf.zeros(BATCH_SIZE, dtype=tf.int32)
time_step = per_arm_tf_env.step(action)
print('\nRewards after taking an action: ', time_step.reward)

我们看到观测值规范是一个包含两个元素的字典:

  • 一个包含键 'global':这是全局上下文部分,其形状与参数 GLOBAL_DIM 匹配。

  • 一个包含键 'per_arm':这是按臂上下文,其形状为 [NUM_ACTIONS, PER_ARM_DIM]。此部分为一个时间步中每个老虎机臂的臂特征占位符。

LinUCB 代理#

LinUCB 代理可实现同名的 Bandit 算法,能够估计线性奖励函数的参数,同时会在估计周围保持一个置信椭圆。代理会选择具有最高估计期望奖励的臂,假定参数位于置信椭圆内。

创建代理需要了解观测值和动作规范。定义代理时,我们要将布尔参数 accepts_per_arm_features 设置为 True

observation_spec = per_arm_tf_env.observation_spec()
time_step_spec = ts.time_step_spec(observation_spec)
action_spec = tensor_spec.BoundedTensorSpec(
    dtype=tf.int32, shape=(), minimum=0, maximum=NUM_ACTIONS - 1)

agent = lin_ucb_agent.LinearUCBAgent(time_step_spec=time_step_spec,
                                     action_spec=action_spec,
                                     accepts_per_arm_features=True)

训练数据流#

本部分将简要介绍按臂特征从策略到训练的机制。您可随意跳到下一部分(定义后悔值指标),如有兴趣可稍后回来阅读。

首先,让我们看一下代理中的数据规范。代理的 training_data_spec 特性用于指定训练数据应具有哪些元素和什么结构。

print('training data spec: ', agent.training_data_spec)

如果我们仔细查看规范的 observation 部分,我们会发现它并不包含按臂特征!

print('observation spec in training: ', agent.training_data_spec.observation)

按臂特征发生了什么?要回答这个问题,我们首先要注意到,LinUCB 代理进行训练时,它并不需要所有老虎机臂的按臂特征,而是只需要所选老虎机臂的按臂特征。因此,有道理丢弃形状为 [BATCH_SIZE, NUM_ACTIONS, PER_ARM_DIM] 的张量,因为它非常浪费资源,尤其是在动作数量较大的情况下。

但是,所选老虎机臂的按臂特征必须位于某个位置!为此,我们要确保 LinUCB 策略将所选老虎机臂的特征存储在训练数据的 policy_info 字段中:

print('chosen arm features: ', agent.training_data_spec.policy_info.chosen_arm_features)

我们从形状看出,chosen_arm_features 字段只有一个老虎机臂的特征向量,它将是所选老虎机臂。请注意,正如我们在查看训练数据规范时所见,policy_info 以及随后的 chosen_arm_features 是训练数据的一部分,因此在训练时可用。

定义后悔值指标#

在开始训练循环之前,我们定义了一些效用函数来帮助计算代理的后悔值。这些函数有助于在给定一组动作(由其臂特征给出)和对代理隐藏的线性参数的情况下确定最佳预期奖励。

def _all_rewards(observation, hidden_param):
  """Outputs rewards for all actions, given an observation."""
  hidden_param = tf.cast(hidden_param, dtype=tf.float32)
  global_obs = observation['global']
  per_arm_obs = observation['per_arm']
  num_actions = tf.shape(per_arm_obs)[1]
  tiled_global = tf.tile(
      tf.expand_dims(global_obs, axis=1), [1, num_actions, 1])
  concatenated = tf.concat([tiled_global, per_arm_obs], axis=-1)
  rewards = tf.linalg.matvec(concatenated, hidden_param)
  return rewards

def optimal_reward(observation):
  """Outputs the maximum expected reward for every element in the batch."""
  return tf.reduce_max(_all_rewards(observation, reward_param), axis=1)

regret_metric = tf_bandit_metrics.RegretMetric(optimal_reward)

现在我们已准备就绪,可以开始我们的老虎机训练循环了。下面的驱动器负责使用策略选择动作,将所选动作的奖励存储在重播缓冲区中,计算预定义的后悔值指标,以及执行代理的训练步。

num_iterations = 20 # @param
steps_per_loop = 1 # @param

replay_buffer = tf_uniform_replay_buffer.TFUniformReplayBuffer(
    data_spec=agent.policy.trajectory_spec,
    batch_size=BATCH_SIZE,
    max_length=steps_per_loop)

observers = [replay_buffer.add_batch, regret_metric]

driver = dynamic_step_driver.DynamicStepDriver(
    env=per_arm_tf_env,
    policy=agent.collect_policy,
    num_steps=steps_per_loop * BATCH_SIZE,
    observers=observers)

regret_values = []

for _ in range(num_iterations):
  driver.run()
  loss_info = agent.train(replay_buffer.gather_all())
  replay_buffer.clear()
  regret_values.append(regret_metric.result())

现在让我们看看结果。如果所做工作全部正确,代理将能够有效估计线性奖励函数,因此策略可以选择预期奖励接近最优值的动作。我们上面定义的后悔值指标可以表明这点,该指标逐渐下降并趋近于零。

plt.plot(regret_values)
plt.title('Regret of LinUCB on the Linear per-arm environment')
plt.xlabel('Number of Iterations')
_ = plt.ylabel('Average Regret')

后续步骤#

我们的代码库中实现了上面的示例,您也可以选择其他代理,包括神经 epsilon 贪心算法代理