RecSim 可配置的推荐系统仿真平台 使用指南

1. RecSim介绍

RecSim是一个可配置的推荐系统仿真平台,它通过真实推荐系统的用户数据来构建模拟可控的仿真环境,为推荐系统模型算法的开发、测评以及对比提供了便利的环境。同时,它作为开源系统为研究人员提供了强化学习与推荐系统的交叉研究环境、并支持模型与算法的重用与分享,也为学术界和工业界提供了协作的平台,在无需暴露用户数据和敏感的行业策略的情况下就能进行有效的研究。

2. RecSim安装

2.1. 环境配置

RecSim开发者给出的示例中使用的TensorFlow版本是Tensorflow 1.15.0,然而默认安装的TensorFlow是Tensorflow 2.x,由于这两个版本的TensorFlow差异巨大,因此必须先解决TensorFlow的版本问题才能正常运行示例代码。

这里建议选择先修改TensorFlow版本到Tensorflow 1.15.0,再体验完RecSim的示例代码后再自行升级或换用其他工具。

根据 TensorFlow Windows设置 中的提示,无论是CPU版本还是GPU版本的Tensorflow 1.15.0,都仅适用于Python 3.5-3.7。如果是按照GPU版本,还需要额外注意一下 cuDNNCUDA 的版本:

版本 Python 版本 编译器 构建工具 cuDNN CUDA
tensorflow-1.15.0 3.5-3.7 MSVC 2017 Bazel 0.26.1 × ×
tensorflow_gpu-1.15.0 3.5-3.7 MSVC 2017 Bazel 0.26.1 7.4 10

如果自己设备上的Python版本不在Python 3.5-3.7这个范围内,建议使用模拟环境(如Pycharm的venv)或者 重新安装

在确定自己的Python版本之后,并且安装好RecSim后,使用如下代码更改TensorFlow版本(选其中一个安装即可):

pip install tensorflow==1.15.0 # CPU版本
pip install tensorflow_gpu==1.15.0 # GPU版本

博主最终选择的配置如下:

工具 版本
Python 3.7 (Pycharm模拟环境)
cuDNN cuDNN v7.4.2, for CUDA 10.0
CUDA CUDA Toolkit 10.0
tensorflow tensorflow_gpu 1.15.0

2.2. 安装步骤

打开终端,使用如下命令下载安装RecSim:

python -m pip install --upgrade pip
pip install recsim

如果上述命令一直连接不成功,那么可以到 recsim· PyPI 下载recsim-0.2.4.tar.gz,在命令提示符下转到解压后的目录,再输入:

python setup.py install

等待一段时间之后如果显示了如下信息,则说明安装成功:

Successfully built recsim gym
Installing collected packages: gym, google-pasta, gin-config, gast, flatbuffers, astunparse, tensorflow, dopamine-rl, recsim
Successfully installed astunparse-1.6.3 dopamine-rl-3.0.1 flatbuffers-1.12 gast-0.3.3 gin-config-0.4.0 google-pasta-0.2.0 gym-0.18.0 recsim-0.2.4 tensorflow-2.4.0

接着在GitHub上把 google-research/recsim 中的代码下载下来,解压后是一个名为recsim-master 的目录,用常用的编辑器或IDE打开该目录下的 ./recsim-master/recsim-master/recsim目录,这里面是整个RecSim的Python代码。

例如用Pycharm打开./recsim-master/recsim-master/recsim目录后显示如下:
在这里插入图片描述

3. 运行RecSim示例

在github下载的RecSim架构自带有以下论文的复现代码:

SlateQ: A Tractable Decomposition for Reinforcement Learning with Recommendation Sets. IJCAI 2019: 2592-2599

找到 main.py ,其内容如下:

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

from absl import app
from absl import flags
import numpy as np
from recsim.agents import full_slate_q_agent
from recsim.environments import interest_evolution
from recsim.simulator import runner_lib

FLAGS = flags.FLAGS

def create_agent(sess, environment, eval_mode, summary_writer=None):
  kwargs = {
    
    
      'observation_space': environment.observation_space,
      'action_space': environment.action_space,
      'summary_writer': summary_writer,
      'eval_mode': eval_mode,
  }
  return full_slate_q_agent.FullSlateQAgent(sess, **kwargs)

def main(argv):
  if len(argv) > 1:
    raise app.UsageError('Too many command-line arguments.')

  runner_lib.load_gin_configs(FLAGS.gin_files, FLAGS.gin_bindings)
  seed = 0
  slate_size = 2
  np.random.seed(seed)
  env_config = {
    
    
      'num_candidates': 5,
      'slate_size': slate_size,
      'resample_documents': True,
      'seed': seed,
  }

  runner = runner_lib.TrainRunner(
      base_dir=FLAGS.base_dir,
      create_agent_fn=create_agent,
      env=interest_evolution.create_environment(env_config),
      episode_log_file=FLAGS.episode_log_file,
      max_training_steps=50,
      num_iterations=10)
  runner.run_experiment()

  runner = runner_lib.EvalRunner(
      base_dir=FLAGS.base_dir,
      create_agent_fn=create_agent,
      env=interest_evolution.create_environment(env_config),
      max_eval_episodes=5,
      test_mode=True)
  runner.run_experiment()

if __name__ == '__main__':
  flags.mark_flag_as_required('base_dir')
  app.run(main)

在终端输入:

# Linux (Windows要把"/"改成"\\")
# base_dir 指定了输出目录
# gin_bindings 指定了数据绑定参数
python main.py --base_dir=./tmp/interest_evolution --gin_bindings=simulator.runner_lib.Runner.max_steps_per_episode=50

等待其训练结束,在终端下找到最后输出的eval_file地址:

I0105 14:22:51.851023  7280 runner_lib.py:483] eval_file: .\\tmp\\interest_evolution\eval_5\returns_500

然后复制eval_file文件的目录,带入到以下终端命令中,例如:

# 注意logdir填写 ./tmp/interest_evolution/eval_5/
# 而不是 ./tmp/interest_evolution/eval_5/returns_500
tensorboard --logdir=./tmp/interest_evolution/eval_5/ --host=127.0.0.1

执行之后会得到一个本地链接,用浏览器打开它

2021-01-05 14:52:00.686903: I tensorflow/stream_executor/platform/default/dso_loader.cc:44] Successfully opened dynamic library cudart64_100.dll
TensorBoard 1.15.0 at http://127.0.0.1:6006/ (Press CTRL+C to quit)

在浏览器地址栏输入http://127.0.0.1:6006/后可以得到如下界面,这就是TensorFlow训练过程的图形化的显示了:
在这里插入图片描述

4. 自定义RecSim环境

首先我们来看一下RecSim的架构图,下图中的绿色和蓝色块表示了RecSim中实现的类。本节将解释这些类,以及描述它们通过何种方式聚合在一起。并且通过一些小例子来说明它们的具体组织实现方式。
在这里插入图片描述

4.1. 概览

一个RecSim模拟步骤大致可以概括如下:

  1. 记录(document)数据库为推荐器提供 D D D 个记录的语料库。每个步骤都可以有不同的记录数据集(由采样或某个“候选生成”过程产生),或在整个模拟过程中采用固定的记录数据集。每个记录都由一个特征列表表示。在完全可观察到的情况下,推荐器会观察每个记录拥有的可以影响用户状态和记录选择(以及用户响应的其他方面)的特征,除此之外,大多数场景都包含了一些无法直接观察到的特征。
  2. 推荐器会观察 D D D 个记录(及其特征)以及用户对最后一个推荐的响应。然后选择(可能是有序的) 其中 k k k 个记录,并将它们呈现给用户。排序可能会影响用户选择或用户状态,因此是否排序以及如何排序取决于我们的模拟目的。
  3. 用户查看记录候选列表并选择其中一个记录,或不选择任何一个记录。之后用户状态会变化,并且输出一个观察结果,推荐器在下一次迭代时检查该观察结果。观察通常包括关于用户对所选择记录内容的反馈,以及关于用户隐藏状态的潜在线索。通常,用户的状态是不完全可见的。

如果我们仔细看上面的图表,我们会注意到沿着弧线的信息流是无循环的,这意味着RecSim环境是一个动态贝叶斯网络(DBN),其中的各种盒子代表条件概率分布。现在我们将简单模拟一个问题场景并实现它。

4.2. 模拟场景:专业 vs 喜闻乐见

考虑以下场景:我们的语料库元素的特征有两种,分别是专业的记录和喜闻乐见的记录(document)。

喜闻乐见的记录在用户中有很高的参与度,但长期过度参与这些记录会导致用户满意度下降。另一方面,专业的记录在用户中的参与度相对较低,但参与专业的记录会带来长期的满足感。我们将这种记录的属性建模为一个连续特征,称为专业度(Prof),其值设定在 [ 0 , 1 ] [0,1] [0,1]区间内。得分为1的记录是非常专业的,而得分为0的记录是非常让用户喜闻乐见的。

用户的隐藏状态由一个一维的满意度变量组成。每次参与更多的专业的记录时,满意度会增加,反之,参与喜闻乐见的记录时会降低满意度。在参与一个记录时,用户的参与度可以通过某些特征来度量(例如观看时长等)。预期参与度的大小与用户的满意度成正比,与记录内容的专业度成反比。

我们的目标是找到专业和喜闻乐见的最佳比例组合,以便长期保持用户粘性。在接下来的讨论中,我们将讨论不同组件的在本模拟场景中的实现方法。

RecSim中的user(用户)和document(记录)提供了实例化上述场景的所有组件所需的抽象类。

首先导入需要的RecSim包

from gym import spaces
import numpy as np

from recsim import document
from recsim import user
from recsim.simulator import environment
from recsim.simulator import recsim_gym

4.2.1. 记录模型 document

RecSim 记录(document)是继承自RecSim.document.AbstractDocument的类。它是document模型、Agent和用户(user)之间的主要交换单元。document类实现本质上是底层document特征(所有可见的和隐藏的特征)的容器。基类需要实现一个observation_space()静态方法,将document可见特征的格式声明为OpenAI gym空间,以及一个create_observe()函数,该函数返回所述空间的实现。另外,每个document必须有一个唯一的整数ID。

在我们的模拟场景中,document只有一个特征,即它们的专业度(Prof),用一维空间Box来表示。

# 记录(document)模型
class SIMDocument(document.AbstractDocument):
    def __init__(self, doc_id, prof):
        self.prof = prof
        super(SIMDocument, self).__init__(doc_id)

    # 构建环境,返回OpenAI gym动作空间的实现
    def create_observation(self):
        return np.array([self.prof])

    # 将document的可见特征的格式声明为OpenAI gym动作空间
    @staticmethod
    def observation_space():
        return spaces.Box(shape=(1,), dtype=np.float32, low=0.0, high=1.0)

    # 返回document对象的描述信息,自动调用
    def __str__(self):
        return "Document {} with Prof {}.".format(self._doc_id, self.prof)

实现了document模型之后,我们现在需要设计一个document采样器。document采样器用于按一定规则生成或抽取document,可以在每个步骤或每个会话结束之后调用采样器来重新生成语料库(这取决于runner_lib的设置)。document在sample_document()函数中生成,它按我们设定的分布规则采样document。

在我们的模拟场景中,采样器按均匀分布生成document(包含document的id、专业度Prof)。

# document采样器模型
class SIMDocumentSampler(document.AbstractDocumentSampler):
    # 采样器初始化,自动调用
    def __init__(self, doc_ctor=SIMDocument, **kwargs):
        super(SIMDocumentSampler, self).__init__(doc_ctor, **kwargs)
        self._doc_count = 0

    # 对document采样
    def sample_document(self):
        doc_features = {
    
    }
        doc_features['doc_id'] = self._doc_count # 赋予ID
        doc_features['prof'] = self._rng.random_sample() # 均匀分布采样
        self._doc_count += 1 # ID自增1
        return self._doc_ctor(**doc_features)

有了document模型以及document采样器之后,就可以模拟document了。如下代码,我们用采样器生成了5个document模型,它们分别都有各自的专业度(Prof)

# 临时调试用
if __name__ == '__main__':
    sampler = SIMDocumentSampler() # 实例化一个采样器
    # 采样5个document
    for i in range(5):
        print(sampler.sample_document())
    d = sampler.sample_document()
    print("Documents have observation space:", d.observation_space(), "\nAn example realization is: ", d.create_observation())

输出:

Document 0 with Prof 0.5488135039273248.
Document 1 with Prof 0.7151893663724195.
Document 2 with Prof 0.6027633760716439.
Document 3 with Prof 0.5448831829968969.
Document 4 with Prof 0.4236547993389047.
Documents have observation space: Box(0.0, 1.0, (1,), float32) 
An example realization is:  [0.64589411]

Process finished with exit code 0

4.2.2. 用户模型 user

上一节中我们构造了一个可用的document模型以及它的采样器,现在我们来构造用户模型。

用户模型由以下组件组成:

  • 用户状态
  • 用户采样器(用户启动状态的分布)
  • 用户状态转换模型
  • 用户响应。

本模拟场景的用户模型如下:

  • 每个用户都有一个被称为专业记录曝光度( npe t \text{npe}_t npet)和满意度( sat t \text{sat}_t satt)的特性。它们通过线性关系联系起来,反映了满意度不可能是无限的这一事实。表达为: sat t = σ ( τ ⋅ enga t ) \text{sat}_t=\sigma(\tau\cdot\text{enga}_t) satt=σ(τengat)其中 τ \tau τ是用户敏感性参数。由公式可知满意度 sat t \text{sat}_t satt与专业记录曝光度( npe t \text{npe}_t npet)是双向相关的,所以只需要知道其中一个就可以跟踪用户状态了。
  • 给定一个记录候选列表(slate) S S S,用户根据一个以记录的喜闻乐见程度作为特征的多项逻辑选择模型(multinomial logit choice model)选择一个记录 d i d_i di p ( user choose  d i  from slate  S ) ∼ e 1 − p r o f ( d i ) p(\text{user choose }d_i \text{ from slate }S)\sim e^{1-prof(d_i)} p(user choose di from slate S)e1prof(di)之所以使用 1 − p r o f ( d i ) 1-prof(d_i) 1prof(di),是因为喜闻乐见的记录更容易被用户选择。
  • 一旦用户从记录候选列表(slate) S S S选择了一个记录,专业记录曝光度( npe t \text{npe}_t npet)就会变化为: enga t + 1 = β ⋅ enga t + 2 ( p d − 1 2 ) + N ( 0 , η ) \text{enga}_{t+1}=\beta\cdot\text{enga}_t+2(p_d-\frac{1}{2})+\mathscr{N}(0,\eta) engat+1=βengat+2(pd21)+N(0,η)其中 β \beta β是某个用户的折扣因子, p d p_d pd是所选记录的专业度, N ( 0 , η ) \mathscr{N}(0,\eta) N(0,η)是呈正态分布的噪声数据。
  • 最后,用户查看所选记录的时间为 s d s_d sd秒,其中 s d s_d sd是根据以下公式生成的: s d ∼ log ⁡ ( N ( p d μ p + ( 1 − p d ) μ l , p d σ p + ( 1 − p d ) σ l ) ) s_d\sim\log\biggl(\mathscr{N}(p_d\mu_p+(1-p_d)\mu_l,p_d\sigma_p+(1-p_d)\sigma_l)\biggr) sdlog(N(pdμp+(1pd)μl,pdσp+(1pd)σl))即一个对数正态分布,其值是在纯专业分布 ( μ p , σ p ) (\mu_p, \sigma_p) (μp,σp)和纯喜闻乐见分布 ( μ l , σ l ) (\mu_l,\sigma_l) (μl,σl)之间的线性插值。

因此,用户状态是由元组 ( sat , τ , β , η , μ p , σ p , μ l , σ l ) (\text{sat},\tau,\beta,\eta,\mu_p, \sigma_p,\mu_l,\sigma_l) (sat,τ,β,η,μp,σp,μl,σl)定义的。满意度变量 sat \text{sat} sat是状态中唯一的动态部分,而其他参数是由用户定义的,并且是静态不变的。从技术上讲,我们不需要将它们作为状态的一部分,而是应该硬编码它们,但是,将它们作为状态的一部分可以使得我们能够采样出具有不同属性的用户。

4.2.2.1. 用户状态和用户采样器

与document类似,我们首先实现一个用户状态类,作为所有用户状态参数的容器。与AbstractDocument类似,AbstractUserState基类要求我们实现observation_space()create_observations(),它们用于在每次迭代时向Agent提供关于用户状态的部分(或全部)信息。

我们也有对Session时间的限制,在本模拟场景中,会话长度将固定为某个常量,所以不在时间预算模型中明确说明,但我们也可以将其视为用户状态的一部分,并利用它做点其他的事情。

最后,我们将实现一个score_document()方法,该方法将document映射到一个非负实数。

class SIMUserState(user.AbstractUserState):
    def __init__(self, memory_discount, sensitivity, innovation_stddev,
                 like_mean, like_stddev, prof_mean, prof_stddev,
                 net_professional_exposure, time_budget, observation_noise_stddev=0.1):
        # 用户状态转化模型参数
        self.memory_discount = memory_discount
        self.sensitivity = sensitivity
        self.innovation_stddev = innovation_stddev

        # 对数正态分布参数
        self.like_mean = like_mean
        self.like_stddev = like_stddev
        self.prof_mean = prof_mean
        self.prof_stddev = prof_stddev

        # 状态变量
        self.net_professional_exposure = net_professional_exposure
        self.satisfaction = 1 / (1 + np.exp(-sensitivity * net_professional_exposure))
        self.time_budget = time_budget

        # 噪声数据
        self._observation_noise = observation_noise_stddev

    def create_observation(self):
        # 用户的状态是隐藏的
        clip_low, clip_high = (-1.0 / (1.0 * self._observation_noise),
                               1.0 / (1.0 * self._observation_noise))
        noise = stats.truncnorm(clip_low, clip_high, loc=0.0, scale=self._observation_noise).rvs()
        noisy_sat = self.satisfaction + noise
        return np.array([noisy_sat, ])

    @staticmethod
    def observation_space():
        return spaces.Box(shape=(1,), dtype=np.float32, low=-2.0, high=2.0)

    # 对document评分——用户更有可能选择更喜闻乐见的内容。
    def score_document(self, doc_obs):
        return 1 - doc_obs

同样与我们的document模型类似,我们需要一个启动状态采样器,它为每个会话设置初始化的用户状态。

对于本模拟场景,我们将只对开头的专业记录曝光度( npe t \text{npe}_t npet)进行采样,并保持所有静态参数相同,这意味着我们是以不同的满意度处理同一个用户。当然,我们也可以随机生成具有不同参数的用户。

注意,如果 η = 0 \eta = 0 η=0, 则 npe t \text{npe}_t npet的值始终在 [ − 1 1 − β , … , 1 1 − β ] \left[-\frac{1}{1-\beta},\ldots,\frac{1}{1-\beta} \right] [1β1,,1β1]区间内,所以作为初始分布,我们只在这个范围内均匀抽样。根据基类的要求,采样代码必须在sample_user()中实现。

class SIMStaticUserSampler(user.AbstractUserSampler):
    _state_parameters = None
    def __init__(self,
               user_ctor=SIMUserState,
               memory_discount=0.9,
               sensitivity=0.01,
               innovation_stddev=0.05,
               like_mean=5.0,
               like_stddev=1.0,
               prof_mean=4.0,
               prof_stddev=1.0,
               time_budget=60,
               **kwargs):
        self._state_parameters = {
    
    'memory_discount': memory_discount,
                                  'sensitivity': sensitivity,
                                  'innovation_stddev': innovation_stddev,
                                  'like_mean': like_mean,
                                  'like_stddev': like_stddev,
                                  'prof_mean': prof_mean,
                                  'prof_stddev': prof_stddev,
                                  'time_budget': time_budget}
        super(SIMStaticUserSampler, self).__init__(user_ctor, **kwargs)

    def sample_user(self):
        starting_npe = ((self._rng.random_sample() - .5)*(1 / (1.0 - self._state_parameters['memory_discount'])))
        self._state_parameters['net_professional_exposure'] = starting_npe
        return self._user_ctor(**self._state_parameters)

至此,我们可以开始采样一些用户,例如下面的代码就采样了1000个用户,并且用直方图表示了不同专业记录曝光度区间范围内的用户数量各为多少

if __name__ == '__main__':
    sampler = SIMStaticUserSampler()
    starting_npe = []
    for i in range(1000):
        sampled_user = sampler.sample_user()
        starting_npe.append(sampled_user.net_professional_exposure)
    plt.hist(starting_npe,edgecolor='white',linewidth=1) # import matplotlib.pyplot as plt
    plt.show()

在这里插入图片描述

4.2.2.2. 响应模型

接下来要构造user response类。RecSim将为候选列表中的每个推荐项目生成一个响应。Agent看到的响应内容是用户对推荐的特定document的反馈(在SIMUserState.create_observation中生成非特定document的反馈)。

class SIMResponse(user.AbstractResponse):
    # 参与度最大值
    MAX_ENGAGEMENT_MAGNITUDE = 100.0
    def __init__(self, clicked=False, engagement=0.0):
        self.clicked = clicked
        self.engagement = engagement

    def create_observation(self):
        return {
    
    'click': int(self.clicked), 'engagement': np.array(self.engagement)}

    @classmethod
    def response_space(cls):
        # engagement的范围是[0,MAX_ENGAGEMENT_MAGNITUDE]
        return spaces.Dict({
    
    
            'click': spaces.Discrete(2),
            'engagement': spaces.Box(
                    low=0.0,
                    high=cls.MAX_ENGAGEMENT_MAGNITUDE,
                    shape=tuple(),
                    dtype=np.float32)})
4.2.2.3. 用户模型

现在我们已经有了为会话生成用户的方法,接下来需要指定实际的用户行为。RecSim用户模型(源自recsim.user.AbstractUserModel)负责以下三个动作:

  • 维护用户的状态;
  • 根据推荐的结果,更新用户状态;
  • 生成对一系列建议的响应。

为此,用户模型需要实现基类的update_state()simulate_response()方法,以及控制会话于何时结束的is_terminal,这是通过self.time_budget的自减来实现的。

我们的初始化很简单,只将需要response_model构造函数、用户采样器和候选列表大小传递给AbstractUserModel基类就可以了。

def __init__(self, slate_size, seed=0):
    super(SIMUserModel, self).__init__(SIMResponse, SIMStaticUserSampler(SIMUserState, seed=seed), slate_size)
    self.choice_model = MultinomialLogitChoiceModel({
    
    }) # from choice_model import MultinomialLogitChoiceModel

simulate_response()方法用于接受由Agent生成的SIMDocuments的候选列表,并输出用户响应列表。响应列表中的第 k k k个响应对应推荐列表中的第 k k k个document。在这种情况下,我们根据我们的选择模型选择一个document进行点击,并产生一个参与度。我们让未点击的document的响应为空,或者用其他更好的方式来操作(例如记录用户是否检查了该文档等等)。

def simulate_response(self, slate_documents):
    # 空响应列表
    responses = [self._response_model_ctor() for _ in slate_documents]
    # 从选择模型获取点击
    self.choice_model.score_documents(self._user_state, [doc.create_observation() for doc in slate_documents])
    scores = self.choice_model.scores
    selected_index = self.choice_model.choose_item()
    # 填充点击项
    self._generate_response(slate_documents[selected_index], responses[selected_index])
    return responses

def _generate_response(self, doc, response):
    response.clicked = True
    # 专业记录和喜闻乐见记录之间的线性插值
    engagement_loc = (doc.prof * self._user_state.like_mean + (1 - doc.prof) * self._user_state.prof_mean)
    engagement_loc *= self._user_state.satisfaction
    engagement_scale = (doc.prof * self._user_state.like_stddev + ((1 - doc.prof) * self._user_state.prof_stddev))
    log_engagement = np.random.normal(loc=engagement_loc, scale=engagement_scale)
    response.engagement = np.exp(log_engagement)

update_state()方法是用户状态转换的关键。它使用推荐的候选列表以及用户实际的选择(响应)来诱导状态转换。状态被直接修改,所以函数没有任何返回值。

def update_state(self, slate_documents, responses):
    for doc, response in zip(slate_documents, responses):
        if response.clicked:
            innovation = np.random.normal(scale=self._user_state.innovation_stddev)
            net_professional_exposure = (self._user_state.memory_discount * self._user_state.net_professional_exposure - 2.0 * (doc.prof - 0.5) + innovation)
            self._user_state.net_professional_exposure = net_professional_exposure
            satisfaction = 1 / (1.0 + np.exp(-self._user_state.sensitivity * net_professional_exposure))
            self._user_state.satisfaction = satisfaction
            self._user_state.time_budget -= 1
            return

最后,当时间预算time_budge小于等于0时,会话过期。

def is_terminal(self):
    # 返回一个布尔值,指示会话是否结束
    return self._user_state.time_budget <= 0

我们把上面所讲的所有函数组件封装在一个类中,命名为SIMUserModel,继承user.AbstractUserModel基类。

class SIMUserModel(user.AbstractUserModel):
	def __init__(self, slate_size, seed=0):
		...
	def simulate_response(self, slate_documents):
		...
	def _generate_response(self, doc, response):
		...
	def update_state(self, slate_documents, responses):
		...
	def is_terminal(self):
		...

最后,我们将所有组件(包括document组件和user组件)配置到一个环境中。

if __name__ == '__main__':
    slate_size = 3
    num_candidates = 10
    simenv = environment.Environment(
        SIMUserModel(slate_size),
        SIMDocumentSampler(),
        num_candidates,
        slate_size,
        resample_documents=True)
4.2.2.4. 与Agent的交互

我们现在已经实现了一个模拟环境。为了在这种环境中训练和评估Agent,我们还需要设定一个奖励函数,用于将一组反应映射到实数域。假设我们想要最大化点击文档的参与度,那么就可以这样设定奖励函数:

def clicked_engagement_reward(responses):
    reward = 0.0
    for response in responses:
        if response.clicked:
            reward += response.engagement
    return reward

现在,我们只需使用OpenAI gym包装器载入我们的模拟环境即可,从4.2.3. 模拟场景的试运行的随机执行结果可以看到,observation_1的用户满意度比observation_0的用户满意度高了不少。

if __name__ == '__main__':
	'''
    其他语句
    '''
    sim_gym_env = recsim_gym.RecSimGymEnv(simenv, clicked_engagement_reward)
    observation_0 = sim_gym_env.reset()
    print('Observation 0') # 用户观测到的环境 0
    print('Available documents') # 输出候选列表中的所有document
    doc_strings = ['doc_id ' + key + " prof " + str(value) for key, value
                   in observation_0['doc'].items()]
    print('\n'.join(doc_strings))
    print('Noisy user state observation')
    print(observation_0['user']) # 用户满意度
    recommendation_slate_0 = [0, 1, 2] # Agent 推荐出候选列表(slate)的前三个document
    observation_1, reward, done, _ = sim_gym_env.step(recommendation_slate_0) # 环境状态转移
    print('Observation 1') # 用户观测到的环境 1
    print('Available documents') # 输出候选列表中的所有document
    doc_strings = ['doc_id ' + key + " prof " + str(value) for key, value
                   in observation_1['doc'].items()]
    print('\n'.join(doc_strings))
    rsp_strings = [str(response) for response in observation_1['response']]
    print('User responses to documents in the slate')
    print('\n'.join(rsp_strings)) # 输出用户参与(响应)情况
    print('Noisy user state observation')
    print(observation_1['user']) # 用户满意度

4.2.3. 模拟场景的试运行

完整代码请关注公众号:推荐系统新视野(RecView)。回复“recsim模拟场景”获取
在这里插入图片描述

输出:

Observation 0
Available documents
doc_id 10 prof [0.79172504]
doc_id 11 prof [0.52889492]
doc_id 12 prof [0.56804456]
doc_id 13 prof [0.92559664]
doc_id 14 prof [0.07103606]
doc_id 15 prof [0.0871293]
doc_id 16 prof [0.0202184]
doc_id 17 prof [0.83261985]
doc_id 18 prof [0.77815675]
doc_id 19 prof [0.87001215]
Noisy user state observation
[0.51296355]
Observation 1
Available documents
doc_id 20 prof [0.97861834]
doc_id 21 prof [0.79915856]
doc_id 22 prof [0.46147936]
doc_id 23 prof [0.78052918]
doc_id 24 prof [0.11827443]
doc_id 25 prof [0.63992102]
doc_id 26 prof [0.14335329]
doc_id 27 prof [0.94466892]
doc_id 28 prof [0.52184832]
doc_id 29 prof [0.41466194]
User responses to documents in the slate
{
    
    'click': 0, 'engagement': array(0.)}
{
    
    'click': 1, 'engagement': array(55.75022179)}
{
    
    'click': 0, 'engagement': array(0.)}
Noisy user state observation
[0.5730411]

Process finished with exit code 0

5. 自定义Agent

在熟悉了RecSim的整体架构以及各个组件之间是如何组合在一起成为一个完整的模拟环境之后,我们现在只剩Agent需要开发了。要弄清楚Agent与RecSim之间的交互方式,可以从下面两个角度入手:

  • RecSim向Agent提供什么数据?以及如何提供数据?数据处理的结果是什么?
  • RecSim为开发Agent提供哪些功能?

在了解RecSim给Agent提供的功能之前,我们再看一看RecSim的架构图。
在这里插入图片描述

把关注点放在图中Agent所在的区域,我们可以得出以下Agent的四种作用:

  • 接收用户状态(用户可见特征);
  • 接收用户对推荐的响应(用户响应);
  • 接收一组记录 D D D(记录可见特征)。
  • 输出大小为 K K K的记录候选列表,以提供给用户选择模型和用户转换模型使用。

为了更好地理解RecSim为开发Agent提供的API,我们将在RecSim的兴趣探索(interest exploration)环境中实现一个简单的bandit Agent。

假设整个环境由巨量的记录组成,这些记录聚集成不同主题。在RecSim中,我们也假设用户聚集成不同类型,于是有了:

记录对用户的吸引力 = 记录的质量 + 主题对用户(用户类型)的吸引力 \text{记录对用户的吸引力}=\text{记录的质量}+\text{主题对用户(用户类型)的吸引力} 记录对用户的吸引力=记录的质量+主题对用户(用户类型)的吸引力

这自然会造成这样一种情况:一个目光短浅的Agent会根据预测的点击率对记录进行排名,它会青睐高质量的记录,因为这样的记录在所有用户类型中都有很高的先验概率被点击。这导致Agent忽视探索可能带来的利益,从而选择了没这么好的策略。为了克服这种情况,就需要人为控制Agent采取积极的探索策略。

我们先定义Agent的各种方法,然后将其封装到一个类中。在这之前,我们先实例化一个模拟环境,以便进行后续处理。

首先导入需要的包

import functools
from gym import spaces
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats

from recsim import agent
from recsim import document
from recsim import user
from recsim.choice_model import MultinomialLogitChoiceModel
from recsim.simulator import environment
from recsim.simulator import recsim_gym
from recsim.simulator import runner_lib
from recsim.environments import interest_exploration

这里我们只使用RecSim提供的create_environment()函数对环境进行初始化,也就是仅构造一个基础环境,而不添加任何其他的属性。在构建了环境之后,调用reset()方法对环境进行初始化,从而使用户可以得到最初的观测结果:

env_config = {
    
    'slate_size': 2,
              'seed': 0,
              'num_candidates': 15,
              'resample_documents': True}
ie_environment = interest_exploration.create_environment(env_config)
initial_observation = ie_environment.reset()

5.1. 观测 Observation

用户的观测即用户看到的部分环境,RecSim中,用户的观测可以表示成有3个键的字典:

  • “user” : 表示架构图中的"用户可见特征";
  • “doc” : 包含当前值得推荐的记录及其可见特征,
  • “response” : 表示用户对最后一组推荐的响应(“用户响应”)。在这个阶段,“响应”键是空白的,将被设置为None,因为还没有提出任何建议。
print('User Observable Features')
print(initial_observation['user'])
print('User Response')
print(initial_observation['response'])
print('Document Observable Features')
for doc_id, doc_features in initial_observation['doc'].items():
  print('ID:', doc_id, 'features:', doc_features)

输出:

User Observable Features
[]
User Response
None
Document Observable Features
ID: 15 features: {
    
    'quality': array(1.22720163), 'cluster_id': 1}
ID: 16 features: {
    
    'quality': array(1.29258489), 'cluster_id': 1}
ID: 17 features: {
    
    'quality': array(1.23977078), 'cluster_id': 1}
ID: 18 features: {
    
    'quality': array(1.46045555), 'cluster_id': 1}
ID: 19 features: {
    
    'quality': array(2.10233425), 'cluster_id': 0}
ID: 20 features: {
    
    'quality': array(1.09572905), 'cluster_id': 1}
ID: 21 features: {
    
    'quality': array(2.37256963), 'cluster_id': 0}
ID: 22 features: {
    
    'quality': array(1.34928002), 'cluster_id': 1}
ID: 23 features: {
    
    'quality': array(1.00670188), 'cluster_id': 1}
ID: 24 features: {
    
    'quality': array(1.20448562), 'cluster_id': 1}
ID: 25 features: {
    
    'quality': array(2.18351159), 'cluster_id': 0}
ID: 26 features: {
    
    'quality': array(1.19411585), 'cluster_id': 1}
ID: 27 features: {
    
    'quality': array(1.03514646), 'cluster_id': 1}
ID: 28 features: {
    
    'quality': array(2.29592623), 'cluster_id': 0}
ID: 29 features: {
    
    'quality': array(2.05936556), 'cluster_id': 0}

Process finished with exit code 0

此时,我们得到了一个由15个记录(num_candidates)组成的语料库,每个记录都由它们的主题(cluster_id)和质量分数(quality)表示。

用户观测的格式规范可以作为OpenAI gym空间的环境特征,它也在初始化时提供给Agent

print('Document observation space')
for key, space in ie_environment.observation_space['doc'].spaces.items():
  print(key, ':', space)
print('Response observation space')
print(ie_environment.observation_space['response'])
print('User observation space')
try:
    print(ie_environment.observation_space['user'])
except ValueError:
    # ValueError: zero-size array to reduction operation minimum which has no identity
    # 从上一段代码的输出结果可以看出,此时没有用户可见特征,则用户可见特征空间为空
    print("Box(0,)")

输出:

Document observation space
15 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
16 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
17 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
18 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
19 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
20 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
21 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
22 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
23 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
24 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
25 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
26 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
27 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
28 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
29 : Dict(cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32))
Response observation space
Tuple(Dict(click:Discrete(2), cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32)), Dict(click:Discrete(2), cluster_id:Discrete(2), quality:Box(0.0, inf, (), float32)))
User observation space
Box(0,)

5.2. 候选列表 Slate

RecSim的候选列表是前 K K K个索引['doc']的列表。例如, K K K为2的候选列表可以将上述15个记录表示为:

slate = [0, 1]
for slate_doc in slate:
  print(list(initial_observation['doc'].items())[slate_doc])

输出:

('15', {
    
    'quality': array(1.22720163), 'cluster_id': 1})
('16', {
    
    'quality': array(1.29258489), 'cluster_id': 1})

环境的动作空间是一个 N ∗ N N*N NN记录大小的多维离散空间

print(ie_environment.action_space)

输出:

MultiDiscrete([15 15])

当候选列表被用户选用时,环境就会生成一个新的观察结果以及对Agent的奖励,这个过程称为状态转移,Agent的主要工作就是为模拟的每个状态转移步骤,并生成有效的候选推荐列表。

observation, reward, done, _ = ie_environment.step(slate)

5.3. 搭建Agent

5.3.1. 简单Agent

现在我们来实现一个功能简单的Agent。首先导入包。

from recsim.agent import AbstractEpisodicRecommenderAgent

RecSim的Agent类继承自AbstractEpisodicRecommenderAgent。Agent初始化所需的参数是observation_spaceaction_space,我们可以用这两个参数来验证环境是否满足Agent进行操作的先决条件。

class StaticAgent(AbstractEpisodicRecommenderAgent):
    def __init__(self, observation_space, action_space):
        # 检查document语料库是否足够大
        if len(observation_space['doc'].spaces) < len(action_space.nvec):
            raise RuntimeError('Slate size larger than size of the corpus.')
        super(StaticAgent, self).__init__(action_space)
    
    def step(self, reward, observation):
        print(observation)
        return list(range(self._slate_size))

这个Agent命名为StaticAgent,含义是它会静态地推荐语料库的前 K K K个记录。我们现在可以使用runner_lib在RecSim中运行它。

def create_agent(sess, environment, eval_mode, summary_writer=None):
    return StaticAgent(environment.observation_space, environment.action_space)

# Windows改成'.\\tmp\\recsim\\'
tmp_base_dir = './tmp/recsim/'

runner = runner_lib.EvalRunner(
    base_dir=tmp_base_dir,
    create_agent_fn=create_agent,
    env=ie_environment,
    max_eval_episodes=1,
    max_steps_per_episode=5,
    test_mode=True)

# 运行Agent
# runner.run_experiment()

构建好runner后,就可以打开tensorboard来观察runner的运行过程

tensorboard --logdir=./tmp/recsim/eval_1/ --host=127.0.0.1

实际上,由于我们没有进行任何操作,所以此时tensorboard中是没有任何数据的。

5.3.2. 层次化Agent结构

5.3.1. 简单Agent中我们构造了一个基本的Agent,接下来,我们试着把多个Agent组合起来组成一个Agent层,其中每个单独的Agent称为“基Agent”。

我们使用bandit算法来揭示用户对每个主题的平均参与度。也就是说,每个主题都视作一条arm。一旦算法选择了一个主题,我们就可以从这个主题中获取质量最高的记录。

当推荐多个产品时,通常会发生这样的情况:在一个会话期内,用户将与某些产品产生一些交互,这些交互中会蕴含一些用户的意图信息。有时,用户会进行显式的查询(例如输入搜索词),此时的用户意图十分明显。然而大多数情况下,用户的意图是隐藏的,即用户通过从一组物品中选择来间接地展示自己的意图。假设我们已经通过某种方法得知了用户意图,那么就可以用一个特定于产品的策略来实现Agent。

Agent层的构造方法是模块化的,RecSim提供了一组易于扩展的Agent构建块,可以将单个Agent组合到层次化结构中以构建复杂的Agent结构,如下图所示:

在这里插入图片描述

从图中可以清晰看到,Agent层依赖于一个或多个基Agent来实现:

  • Agent层接收用户观测和来自环境的反馈;
  • 原始用户观测需进行预处理后再传递给Agent层中的基Agent。
  • 每个基Agent都输出候选列表(或抽象操作),然后进行后处理,以创建/输出最终候选列表(或具体操作)。

通过修改Agent层的前处理和后处理功能定义,可以发挥很多有意思的作用。例如,一个层可以被用作纯特征提取器,即它可以从用户观测中提取一些特征,并将其传递给基Agent,同时不需要再进行后处理。

前处理层-基Agent层-后处理层的分层结构设计有利于特征工程和Agent工程的解耦。通过修改奖励函数,可以实现各种不同的规范化器。Agent层也可以是动态的,以便于预处理或后处理函数可以实现参数更新或加入学习机制。

5.3.2.1. ClusterClickStats

ClusterClickStats负责为Agent探索提供必要的、足够的统计信息。在本节的最开始,我们介绍了兴趣探索(Interest Exploration)环境,它提供了用户点击的反馈,但没有跟踪累积点击次数。由于在实际项目中维护这样的统计数据通常是有意义的,所以RecSim实现了这样一个功能性Agent层来完成这一工作,下面我们来介绍一下ClusterClickStats。

ClusterClickStats监控了响应流,响应空间中有键"click"和"cluster_id",分别代表点击量和聚集簇的编号。

首先导入包

from recsim.agents.layers.cluster_click_statistics import ClusterClickStatsLayer

ClusterClickStats的构造函数与其基Agent(这里就用上面的StaticAgent)一致,且不会对候选列表进行任何后处理,也就是说基Agent输出的候选列表会直接发送给用户。

一旦基Agent实例化,ClusterClickStats将向其基Agent的观测空间中注入足够的统计信息。

static_agent = StaticAgent(ie_environment.observation_space, ie_environment.action_space)
static_agent.step(reward, observation)

输出:

{
    
    'user': array([], dtype=float64), 'doc': OrderedDict([
('30', {
    
    'quality': array(2.48922445), 'cluster_id': 0}), 
('31', {
    
    'quality': array(2.12592661), 'cluster_id': 0}), 
('32', {
    
    'quality': array(1.27448139), 'cluster_id': 1}), 
('33', {
    
    'quality': array(1.21799112), 'cluster_id': 1}), 
('34', {
    
    'quality': array(1.17770375), 'cluster_id': 1}), 
('35', {
    
    'quality': array(2.07948915), 'cluster_id': 0}), 
('36', {
    
    'quality': array(1.14167652), 'cluster_id': 1}), 
('37', {
    
    'quality': array(1.20529165), 'cluster_id': 1}), 
('38', {
    
    'quality': array(1.2424684), 'cluster_id': 1}), 
('39', {
    
    'quality': array(1.87279668), 'cluster_id': 0}), 
('40', {
    
    'quality': array(1.19644888), 'cluster_id': 1}), 
('41', {
    
    'quality': array(1.28254021), 'cluster_id': 1}), 
('42', {
    
    'quality': array(2.01558539), 'cluster_id': 0}), 
('43', {
    
    'quality': array(2.46400483), 'cluster_id': 0}), 
('44', {
    
    'quality': array(1.33980633), 'cluster_id': 1})]), 
'response': (
{
    
    'click': 0, 'quality': array(1.22720163), 'cluster_id': 1}, 
{
    
    'click': 0, 'quality': array(1.29258489), 'cluster_id': 1})}
cluster_static_agent = ClusterClickStatsLayer(StaticAgent, ie_environment.observation_space, ie_environment.action_space)
cluster_static_agent.step(reward, observation)

输出:

{
    
    'user': {
    
    'raw_observation': array([], dtype=float64), 'sufficient_statistics': {
    
    'impression_count': array([0, 2]), 
'click_count': array([0, 0])}}, 
'doc': OrderedDict([
('30', {
    
    'quality': array(2.48922445), 'cluster_id': 0}), 
('31', {
    
    'quality': array(2.12592661), 'cluster_id': 0}), 
('32', {
    
    'quality': array(1.27448139), 'cluster_id': 1}), 
('33', {
    
    'quality': array(1.21799112), 'cluster_id': 1}), 
('34', {
    
    'quality': array(1.17770375), 'cluster_id': 1}), 
('35', {
    
    'quality': array(2.07948915), 'cluster_id': 0}), 
('36', {
    
    'quality': array(1.14167652), 'cluster_id': 1}), 
('37', {
    
    'quality': array(1.20529165), 'cluster_id': 1}), 
('38', {
    
    'quality': array(1.2424684), 'cluster_id': 1}), 
('39', {
    
    'quality': array(1.87279668), 'cluster_id': 0}), 
('40', {
    
    'quality': array(1.19644888), 'cluster_id': 1}), 
('41', {
    
    'quality': array(1.28254021), 'cluster_id': 1}), 
('42', {
    
    'quality': array(2.01558539), 'cluster_id': 0}), 
('43', {
    
    'quality': array(2.46400483), 'cluster_id': 0}), 
('44', {
    
    'quality': array(1.33980633), 'cluster_id': 1})]), 'response': (
{
    
    'click': 0, 'quality': array(1.22720163), 'cluster_id': 1}, 
{
    
    'click': 0, 'quality': array(1.29258489), 'cluster_id': 1})}

如上输出结果中,“user"字段有了一个新键"sufficient_statistics”,而之前的用户观测(空的)在"raw_observe"键下,之所以这样做是为了避免命名冲突。

5.3.2.2. AbstractClickBandit

AbstractClickBandit负责实现实际的bandit策略,RecSim提供了一个功能性抽象bandit——AbstractClickBandit,它将基Agent的候选列表作为输入,并其视为arm进行强化学习。

它利用几个已经实现的bandit策略(UCB1、KL-UCB、ThompsonSampling)中的一个来构建策略,以实现相对于最佳策略(这是先验未知的)的次线性遗憾(sub-linear regret),具体选择哪个bandit策略取决于对环境的要求。

首先导入包:

from recsim.agents.layers.abstract_click_bandit import AbstractClickBanditLayer

要实例化一个抽象bandit,必须提供一个基Agent的候选列表。在我们的示例中,每个主题都有一个基Agent。该Agent只从语料库中检索属于该主题的document,并根据质量对它们进行排序。

class GreedyClusterAgent(agent.AbstractEpisodicRecommenderAgent):
    """Agent根据质量对主题的所有document进行排序"""
    def __init__(self, observation_space, action_space, cluster_id, **kwargs):
        del observation_space
        super(GreedyClusterAgent, self).__init__(action_space)
        self._cluster_id = cluster_id

    def step(self, reward, observation):
        del reward
        my_docs = []
        my_doc_quality = []
        for i, doc in enumerate(observation['doc'].values()):
            if doc['cluster_id'] == self._cluster_id:
                my_docs.append(i)
                my_doc_quality.append(doc['quality'])
        if not bool(my_docs):
            return []
        sorted_indices = np.argsort(my_doc_quality)[::-1]
        return list(np.array(my_docs)[sorted_indices])

然后为每个集群实例化一个GreedyClusterAgent。

num_topics = list(ie_environment.observation_space.spaces['doc'].spaces.values())[0].spaces['cluster_id'].n
base_agent_ctors = [
  functools.partial(GreedyClusterAgent, cluster_id=i)
  for i in range(num_topics)
]

接下来实例化ClusterClickStatsLayer,并命名为Cluster_Bandit

bandit_ctor = functools.partial(AbstractClickBanditLayer, arm_base_agent_ctors=base_agent_ctors)
cluster_bandit = ClusterClickStatsLayer(bandit_ctor, ie_environment.observation_space, ie_environment.action_space)

现在ClusterClickStatsLayer可以正常工作了,运行之后会输出一个最终候选列表,也就是推荐给用户的记录

observation0 = ie_environment.reset()
slate = cluster_bandit.begin_episode(observation0)
print("Cluster bandit slate 0:")
doc_list = list(observation0['doc'].values())
for doc_position in slate:
    print(doc_list[doc_position])

输出:

Cluster bandit slate 0:
{
    
    'quality': array(1.46868751), 'cluster_id': 1}
{
    
    'quality': array(1.42269182), 'cluster_id': 1}

6. RecSim参考文档

仅仅了解上面几节所讲的内容还不能让我们随心所欲的开发RecSim,更多的内容请参考./recsim-master/recsim-master/docs/api_docs/python中的参考文档

猜你喜欢

转载自blog.csdn.net/baishuiniyaonulia/article/details/112176376