PyTorch强化学习实战(22)——将强化学习应用于TextWorld互动小说游戏

PyTorch强化学习实战(22)------将强化学习应用于TextWorld互动小说游戏

    • [0. 前言](#0. 前言)
    • [1. 互动小说](#1. 互动小说)
    • [2. TextWorld 环境](#2. TextWorld 环境)
      • [2.1 安装](#2.1 安装)
      • [2.2 游戏生成](#2.2 游戏生成)
      • [2.3 观测和动作空间](#2.3 观测和动作空间)
      • [2.4 额外的游戏信息](#2.4 额外的游戏信息)
    • [3. 深度 NLP 基础](#3. 深度 NLP 基础)
      • [3.1 循环神经网络](#3.1 循环神经网络)
      • [3.2 词嵌入](#3.2 词嵌入)
      • [3.3 编码器-解码器架构](#3.3 编码器-解码器架构)
      • [3.4 Transformer](#3.4 Transformer)
    • [4. 使用 DQN 解决 TextWorld 互动小说游戏](#4. 使用 DQN 解决 TextWorld 互动小说游戏)
      • [4.1 观测数据预处理](#4.1 观测数据预处理)
      • [4.2 嵌入和编码器](#4.2 嵌入和编码器)
      • [4.3 构建 DQN 模型](#4.3 构建 DQN 模型)
      • [4.4 训练代码](#4.4 训练代码)
      • [4.5 训练结果](#4.5 训练结果)
    • [5. 观测信息优化](#5. 观测信息优化)
      • [5.1 房间访问记录追踪](#5.1 房间访问记录追踪)
      • [5.2 相对方位动作](#5.2 相对方位动作)
      • [5.3 观测信息中的任务目标](#5.3 观测信息中的任务目标)
    • 小结
    • 系列链接

0. 前言

在本节中,我们将使用强化学习 (Reinforcement Learning, RL)解决基于文本的互动小说 (interactive fiction) 游戏,采用微软研究院发布的 TextWorld 环境,展示 RL 如何应用于具有丰富观测空间的复杂环境。此外,我们还会探讨深度自然语言处理方法,并介绍大语言模型 (Large Language Model, LLM)。

1. 互动小说

计算机游戏不仅为人类提供娱乐,由于其复杂的观测与动作空间、游戏过程中需要做出的长序列决策以及天然奖励系统,也为强化学习研究者提供了具有挑战性的问题。

Atari 2600 等街机游戏只是游戏产业众多类型中的一种。Atari 2600 平台在 70 年代末至 80 年代初达到其流行巅峰,随后进入 Z80 时代,进而演变为现今的 PC 兼容平台与游戏主机时代。随着时间的推移,计算机游戏在图形表现上持续变得更加复杂、精细,这不可避免地增加了硬件需求。这种趋势使得 RL 研究者与实践者难以将 RL 方法应用于较新游戏时变得更加困难;例如,几乎每个人都可以训练一个强化学习智能体来解决 Atari 游戏,但对于《星际争霸II》,DeepMind 利用图形处理单元 (Graphics Processing Unit, GPU) 集群来进行计算仍需花费数周时间,因此《星际争霸II》和《Dota》等游戏的复杂度对大多数人而言成本过高。

解决该问题存在几种途径:

  1. 第一种方法是选择复杂度介于 Atari 和《星际争霸》之间的游戏。例如,Z80NES、世嘉和 C64 平台上有数千款游戏可选
  2. 第二种方法是选择具有挑战性但对环境进行简化的游戏。例如 Gymnasium 库中提供的多种《毁灭战士》环境,虽然使用原版游戏引擎,但目标远比原游戏简单(如穿越走廊、收集武器或射击敌人),《星际争霸II》也提供此类简化游戏
  3. 第三种方法则采用完全不同的思路:选择那些观测空间不一定复杂,但需要长期规划、复杂状态空间探索,并且物体之间有挑战性交互的游戏。此类游戏的典型代表是《蒙特祖马的复仇》(Montezuma's Revenge),即使对现代强化学习方法而言仍具有挑战性
  4. 最后一种方法因资源易得性且复杂度恰好达到 RL 方法极限而颇具吸引力。文本游戏,也就是互动小说,正是此类游戏的代表。该类型现已几乎消亡(被现代游戏和硬件进步淘汰),但在 AtariZ80 时代,互动小说与传统游戏曾并存发展。这些游戏不依赖绚丽图形呈现游戏状态,而是依靠玩家的思维和想象力

游戏进程通过文本交互实现:系统向玩家描述当前游戏状态,游戏中的动作用自由文本命令形式输入,通常采用"动词+名词"的简单结构和有限词汇集。

尽管描述形式简约,在 20 世纪 80 年代至 90 年代初,个人开发者与商业工作室仍开发了数百款规模各异的文本游戏。这些游戏往往需要数十小时的游戏时长,包含数千个场景地点以及大量可交互对象。

这类游戏的挑战性几乎可以无限提升------因为可以引入物体间的复杂交互、游戏状态探索、角色对话等现实生活场景。Interactive Fiction Archive网站收录了大量此类游戏。

20186 月,微软研究院发布了一个开源项目,旨在为研究者提供使用熟悉工具进行文本游戏实验的便捷途径。这个名为 TextWorld 的项目托管于 GitHub,提供以下功能:

  • 支持文本游戏的 Gymnasium 环境,兼容两种格式:Z-machine 字节码(支持 1-8 版本)和 Glulx 游戏
  • 支持生成随机任务的可配置游戏生成器,可预设对象数量、描述文本和任务长度等复杂度参数
  • 通过窥探游戏状态调节(生成游戏的)环境复杂度,例如启用中间奖励机制------当智能体朝正确方向推进时给予正向奖励。
    在本节中,我们将通过多款游戏实验来探索环境功能,并实现多个版本的训练代码以解决生成游戏。使用脚本 make_games.sh 能够生成游戏文件,该脚本通过不同随机种子生成 21 个长度为 5 `的游戏,确保游戏多样性。虽然游戏复杂度不会很高,但可作为自主实验和验证想法的基础。

2. TextWorld 环境

TextWorld 环境内部依赖 Inform 7系统。该项目有两个主要页面:微软研究院官网页面提供环境概览,GitHub 页面则详细说明安装与使用方法。首先从安装开始。

2.1 安装

安装可以简单的通过pip install textworld完成。安装完成后,既可在 Python 代码中导入包使用,同时会提供两个命令行工具:tw-make (游戏生成)和 tw-play (游戏交互)。如果计划挑战完整互动小说游戏,这些工具并非必需;但为简化入门,我们将从人工生成的任务开始。

2.2 游戏生成

tw-make 工具支持生成具有以下特性的游戏:

  • 游戏场景:可选择经典任务模式(要求使用物品并遵循特定行动序列)或"金币收集"模式(玩家需探索场景寻找金币)
  • 游戏主题:可设置游戏场景风格,支持 housebasic 两种主题
  • 物体属性:可为物体添加形容词修饰,例如需要"绿色钥匙"而非普通"钥匙"来开启宝箱
  • 并行任务数量:默认只有单一行动序列,但可设置子目标和替代路径来增加游戏分支
  • 任务长度:可定义玩家达成游戏终局所需的步骤数量
  • 随机种子:用于生成可复现的游戏版本

生成的游戏可采用 GlulxZ-machine 格式------这两种标准便携式虚拟机指令广泛适用于常规游戏,并且受多个互动小说解释器的支持,因此可以像玩普通互动小说游戏一样玩这些生成的游戏。

(1) 生成一些游戏并查看其内容:

shell 复制代码
$ tw-make tw-coin_collector --output t1 --seed 10 --level 5 --format ulx
Global seed: 10
Game generated: t1.ulx

该命令会生成三个文件:t1.ulxt1.nit1.json。第一个文件包含需加载到解释器的字节码,其余文件是扩展数据------可供环境在游戏过程中提供额外信息。

(2) 若要以交互模式运行游戏,可使用任何支持 Glulx 格式的互动小说解释器,或者使用内置工具 tw-play,尽管这可能不是玩互动小说游戏最方便的方式,但可用来验证生成结果:

shell 复制代码
$ tw-play t1.ulx

2.3 观测和动作空间

TextWorld 的核心价值在于能为生成或现有游戏提供RL接口。接下来,我们探索如何利用前一节生成的游戏:

shell 复制代码
>>> from textworld import gym
>>> from textworld.gym import register_game
>>> env_id = register_game("t1.ulx")
>>> env_id
'tw-v0'
>>> env = gym.make(env_id)
>>> env
<textworld.gym.envs.textworld.TextworldGymEnv object at 0x738e6a3915b0>
>>> r = env.reset()
>>> print(r[1])
{}
>>> print(r[0][1205:])

此处我们注册了生成的游戏并创建环境。可以注意到:这里没有使用Gymnasium的make(https://blog.csdn.net/LOVEmy134611/article/details/157394417)函数,而是调用了 textworld 模块中同名的函数。实际上,TextWorld(1.6.2) 已移除对 Gym API 包的依赖,提供了自研的环境类(虽与 Env 类高度相似但并非完全一致)。使用 TextWorld 时需注意:

  • 需要使用 textworld.gym.make() 函数来创建游戏,而非 gym.make()
  • 创建的环境不包含观测空间和行动空间的规范定义,默认两者均为字符串类型。
  • 环境的 step() 函数不返回 is_truncated 标志,仅返回观测值、奖励值、is_done 标志及包含额外信息的字典。因此无法直接应用 Gymnasium 包装器,需要创建小型"适配器"包装
  • 需要自行处理文本预处理

接下来,我们查看游戏引擎提供的额外信息。

2.4 额外的游戏信息

在开始规划首个训练代码前,我们需要探讨 TextWorld 的一项附加功能。我们需要意识到,即便是简单问题也充满挑战:

  • 观测空间是由 1250 个词汇组成的文本序列(最长 200 个词元),动作空间则是最长 8 个词元的文本。生成游戏需要按正确顺序执行 5 个动作,随机找到 8×5=40 个词元的正确序列概率约为 1 10 123 \frac 1{10^{123}} 101231,即使使用最快 GPU,这种概率也几乎不可行。尽管我们有起始和结束序列词元,可以用于略微提升概率,但随机探索找到正确动作序列的可能性微乎其微
  • 另一项挑战来自环境的部分可观测马尔可夫决策过程 (Partially Observable Markov Decision Process, POMDP) 特性:游戏中的物品栏通常不会直接显示。互动小说游戏中,通常只有在发出某个明确命令(如 inventory)后,才会显示角色拥有的物品。但智能体无法感知前一个状态,所以从它的视角看,执行 "take apple" 命令前后的场景完全一致(唯一区别是场景描述中不再提及苹果)。我们可以通过像在处理Atari 游戏中那样堆叠状态来处理这个问题,但需要显式实现此机制,这将显著增加智能体需处理的信息量。

综上所述,我们需要对环境进行适当简化。TextWorld 为我们提供了方便的方式来解决这些问题。在游戏注册时,可通过附加标志位来增强观测空间,注入更多结构化信息。以下是我们可以查看的内部信息列表:

  • 通过 look 命令获取的当前房间独立描述
  • 当前物品栏状态
  • 当前位置名称
  • 当前世界状态的事实描述
  • 最近执行的动作与命令
  • 当前状态下可执行的命令列表
  • 获胜所需执行的动作序列

除每步提供的额外结构化观测外,我们还可要求 TextWorld 在任务推进过程中提供中间奖励------这对加速收敛极具帮助。

我们可以添加的额外信息中最有用的功能是可执行的命令,这能够极大减少我们的动作空间,将动作空间从 1250 40 1250^{40} 125040 压缩至十余个(为训练指引正确方向)。要启用这些功能,需向 register_game() 方法传递可选参数:

shell 复制代码
>>> from textworld import gym, EnvInfos 
>>> from textworld.gym import register_game
>>> env = gym.make(env_id)
>>> r = env.reset()
>>> r[1]

可以看到,环境通过字典提供了之前为空值的额外信息。在此状态下,仅有三条命令有效 (go eastinventorylook)。我们尝试第一条命令:

shell 复制代码
>>> r = env.step('go east')
>>> r[1:]

命令被接受后,我们获得的中间奖励为 1。现在我们已经具备了实现第一个基准深度Q网络 (Deep Q-Network, DQN)智能体来解决 TextWorld 问题所需的全部条件,但在此之前,我们需要先深入自然语言处理 (Natural Language Processing, NLP) 领域。

3. 深度 NLP 基础

本节将简要介绍深度自然语言处理 (Natural Language Processing, NLP) 的核心组件与标准方法。NLP 领域正在快速发展,大语言模型 (Large Language Model, LLM) 已经在聊天机器人和文本处理方面取得了惊人的突破。

本节内容仅涉及最基础的标准构建模块。其中一些,比如循环神经网络 (Recurrent Neural Network, RNN) 和长短期记忆 (Long Short-Term Memory, LSTM),可能已显过时,但了解历史演进至关重要。对于简单任务,选择最适合当前需求的工具(即使不再流行)仍是明智之举。

3.1 循环神经网络

自然语言处理具有区别于计算机视觉等其他领域的独特性,其中之一便是处理可变长度对象。在 NLP 的不同层级中,处理对象的长度都可能变化:例如语言中的单词可能包含多个字符,句子由可变长度的单词序列构成,段落或文档则包含数量不等的句子。这种可变性并非 NLP 独有,也出现在信号处理、视频处理等领域------甚至标准计算机视觉问题也可视为某种对象序列(如图像描述任务中,神经网络可聚焦于图像的不同区域来生成描述)。

RNN 是处理这种可变性的标准构建模块之一。它是一种具有固定输入输出尺寸的网络,可应用于对象序列并沿序列传递信息。这种传递的信息称为隐藏状态,通常是特定尺寸的数值向量。

在下图中的 RNN 中,输入是一个固定尺寸的数值向量;输出是另一个向量。与标准的前馈神经网络或卷积神经网络不同的是,它有两个额外的门控:一个输入和一个输出。额外的输入将前一个项目的隐藏状态输入到 RNN 单元,额外输出则向后续序列提供转换后的隐藏状态:

由于 RNN 有两个输入,只需将前一个单元产生的隐藏状态传递给下一个单元,即可处理任意长度的输入序列。如下图所示,RNN 应用于 "this is a cat" 这句话,为序列中的每个单词生成输出。在整个处理过程中,相同的 RNN 被应用于每个输入项,但借助隐藏状态机制,它能够沿序列传递信息:

这与卷积神经网络 (Convolutional Neural Network, CNN)类似,两者都使用相同的权重组处理输入的不同位置,区别在于卷积神经网络无法传递隐藏状态。

尽管该模型结构简单,却为标准前馈神经网络增加了额外的自由度。前馈网络由其输入决定,对于固定输入总是产生相同输出(推理阶段而非训练阶段)。而 RNN 的输出不仅取决于输入,还受隐藏状态影响(该状态可由网络自身修改)。因此,网络能够将信息从序列开端传递至末端,使相同输入在不同语境下产生不同输出。这种语境依赖性在 NLP 中至关重要:自然语言中,单个单词在不同语境下可能具有完全不同的含义,整个句子的意义也可能因一个单词而改变。

当然,这种灵活性也有相应代价。RNN 通常需要更长的训练时间,并可能产生异常行为,如损失振荡或突然的记忆丧失。但研究人员一直致力于使 RNN 更加实用和稳定,因此 RNN 及其现代替代模型(如 Transformer )已成为处理可变长度输入系统的标准构建模块。

在本节中,我们将使用 RNN 的改进版本,长短期记忆 (Long Short-Term Memory, LSTM)模型,LSTMRNN 非常相似,但通过更复杂的内部结构解决了 RNN 的某些固有缺陷。

3.2 词嵌入

现代深度学习驱动的 NLP 中的另一个标准构建块是词嵌入,因其最流行的训练方法也常称作 word2vec。该技术源于在神经网络中表示语言序列的问题:神经网络通常处理固定维度的数值向量,而 NLP 的输入往往是单词或字符。

虽然word2vec等传统方法仍广泛用于简单任务,但BERTTransformer等其他方法已广泛应用于更复杂任务。

一种解决方案是对词典进行独热编码 (one-hot encoding):每个单词在输入向量中占据唯一位置,当该词出现时对应位置置 1。这是神经网络处理一些相对较小的离散项集合时常用的标准方法。但独热编码存在明显缺陷:

  1. 输入集合通常较大。仅编码常用英语词典就需数万词条(牛津英语词典有 17 万个常用单词和 5 万个稀有词,这还未计入俚语、新词、科技术语、缩写、拼写错误、网络梗等------且仅限英语
  2. 词频分布不均。少量高频词(如 a )与大量罕见词(如 bibliopole )共存,后者在大型语料库中可能仅出现一两次,导致独热编码空间效率低下
  3. 无法捕捉单词之间的关系。同义词含义相同但向量表示不同;固定搭配的共现关系也无法体现

为解决这些问题,我们可以采用词嵌入技术------将词汇表中的每个单词映射为密集的固定长度数值向量。这些数值并非随机生成,而是通过大量文本语料训练获得,能有效捕捉词汇的上下文语义。这种强大且广泛应用的 NLP 技术确实能高效表示序列中的单词、字符等对象。我们可以简单的将其理解为:将单词映射为数值向量,这种映射方式便于神经网络区分不同词汇。

获取词嵌入主要有两种方式:其一可下载所需语言的预训练向量;另一种获取词嵌入的方式是自己在数据集上训练嵌入向量,为此可以使用一些特殊的工具,如 fastText,或者直接随机初始化嵌入并在常规训练中让模型自行调整。

此外,LLM 及其它一般的序列到序列架构都能生成高质量的文本嵌入向量。OpenAI ChatGPT API 还专门提供了将文本转换为嵌入向量的接口。

3.3 编码器-解码器架构

NLP 中另一个广泛使用的模型是编码器-解码器 (Encoder-Decoder) 或序列到序列 (seq2seq) 模型。该架构最初源于机器翻译领域,接收源语言单词序列并生成目标语言序列。seq2seq 的核心思想是使用 RNN 处理输入序列,将其编码为固定长度的表示(该 RNN 称为编码器),然后将编码向量馈入另一个 RNN (称为解码器)来生成目标语言的结果序列。Seq2Seq 架构如下所示,其中门控循环单元 (Gated Recurrent Unit, GRU) 是 RNN 的一种变体:

该模型(经过大量现代改进与扩展)至今仍是机器翻译的主力架构,但其通用性足以应用于更广泛领域,例如音频处理、图像标注和视频字幕生成。在本节的 TextWorld 示例中,将使用该架构生成来自环境的可变长度观测值的嵌入表示。

虽然 RNN 在特定场景中依然有效,但近年来随着更复杂的Transformer模型出现,NLP 领域取得了重大突破。接下来,我们将介绍 Transformer 架构。

3.4 Transformer

Transformer2017 年由 Vaswani 等人在论文《Attention is all you need》中提出的架构。高层架构上,它采用编码器-解码器结构,但对底层构建模块进行了多项改进(这些改进对解决 RNN 现存问题至关重要):

  • 位置编码:向嵌入表示注入输入输出序列的位置信息
  • 注意力机制:可以视为系统聚焦输入序列特定部分的可训练方式。在 Transformer 中,注意力机制处于核心地位

如今 Transformer 已成为几乎所有 NLP 和深度学习系统的核心(包括大语言模型)。关于架构细节,可以查看Transformer 详解。现在我们已经具备实现 DQN 智能体来解决 TextWorld 问题的全部条件。

4. 使用 DQN 解决 TextWorld 互动小说游戏

回到我们的 TextWorld 环境,主要面临以下挑战:

  • 文本序列本身可能会成为问题,序列长度的可变性可能导致循环神经网络出现梯度消失或爆炸问题,造成训练速度缓慢和收敛困难
  • 此外,TextWorld 环境会同时提供多个需要分别处理的文本序列。例如场景描述字符串对智能体的意义,与描述所持物品的库存字符串可能完全不同

另一个难点在于动作空间。如前一小节所示,TextWorld 可能在每个状态提供可执行命令列表。这虽然显著缩小了待选动作空间,但带来了新的复杂性:一是可用命令列表会随状态变化(不同地点允许执行不同命令);二是列表中的每个命令都是单词序列。

理论上可以通过构建一个包含所有可能命令的字典,将其作为离散的固定维度动作空间来解决变长问题。在简单游戏中,由于场景和物体数量有限,这种方法可能奏效。在本节中,我们将采用另一种方案。

迄今为止我们接触的都是离散动作空间,其预定义动作数量有限,这也影响了 DQN 的架构设计:网络的输出预测了所有动作的Q值,这在训练和模型应用中都非常方便(因为寻找 argmax 时需要所有动作的Q值)。但这种 DQN 架构的选择并非由方法决定,必要时我们可以进行调整------变长动作数量问题正是通过这种方式解决。为深入理解实现原理,查看本节的 TextWorld DQN 的架构,如下图所示:

该图的主要部分由预处理模块占据。网络左侧的输入模块接收观测数据各组成部分的可变长度序列(包括"原始文本"、"描述"和"库存信息"),以及待评估的单个动作指令序列。该指令将从允许执行的指令列表中选取,而网络的目标正是预测当前游戏状态下该特定指令对应的单一Q值。这种方法不同于我们之前使用的 DQN 网络,但由于无法预知每个状态下需要评估哪些指令,因此需要对每个指令进行独立评估。

这四组输入序列(即词汇表中词元 ID 的列表)将经过嵌入层处理,随后输入到独立的 LSTM 网络中。

图中标注为"编码器"的 LSTM 网络(因其作为编码器的具体实现)旨在将可变长度序列转换为固定维度的向量。每个输入片段由具有独立权重的 LSTM 进行处理,这使得网络能够从不同输入序列中提取差异化特征。后续将用Hugging Face Hub 的预训练 Transformer替代 LSTM,以验证更智能的大模型对同一问题的影响。

编码器的输出被拼接为一个单一的向量后传递到主 DQN 网络。由于我们的可变长度序列已经转化为固定大小的向量,DQN 网络相对简单:仅有几层前馈层,输出一个Q值。代码包括以下模块:

  • train_basic.py:基准训练程序
  • common.py:用于设置 Ignite 引擎和超参数的通用工具
  • preproc.py:预处理管道,包括嵌入和编码器类
  • model.pyDQN 模型以及辅助函数

4.1 观测数据预处理

(1) 我们从上图流程最左侧的部分开始。在输入端,我们将获得多组词元序列------既包含环境状态观测值,也包含待评估的指令序列。但 TextWorld 环境输出的原始数据包含字符串和扩展信息字典,因此需要对字符串进行分词处理并过滤非相关信息。这由 preproc.py 模块中定义的 TextWorldPreproc 类实现:

python 复制代码
class TextWorldPreproc(gym.Wrapper):
    log = logging.getLogger("TextWorldPreproc")

    # field with observation
    OBS_FIELD = "obs"

    def __init__(
            self, env: gym.Env, vocab_rev: tt.Optional[tt.Dict[str, int]],
            encode_raw_text: bool = False,
            encode_extra_fields: tt.Iterable[str] = ('description', 'inventory'),
            copy_extra_fields: tt.Iterable[str] = (),
            use_admissible_commands: bool = True, keep_admissible_commands: bool = False,
            use_intermediate_reward: bool = True, tokens_limit: tt.Optional[int] = None,
            reward_wrong_last_command: tt.Optional[float] = None
    ):
        super(TextWorldPreproc, self).__init__(env)
        self._vocab_rev = vocab_rev
        self._encode_raw_text = encode_raw_text
        self._encode_extra_field = tuple(encode_extra_fields)
        self._copy_extra_fields = tuple(copy_extra_fields)
        self._use_admissible_commands = use_admissible_commands
        self._keep_admissible_commands = keep_admissible_commands
        self._use_intermedate_reward = use_intermediate_reward
        self._num_fields = len(self._encode_extra_field) + int(self._encode_raw_text)
        self._last_admissible_commands = None
        self._last_extra_info = None
        self._tokens_limit = tokens_limit
        self._reward_wrong_last_command = reward_wrong_last_command
        self._cmd_hist = []

该类遵循 gym.Wrapper 接口规范,能够按照需求对 TextWorld 环境的观测值和动作进行转换。构造函数接受多个配置参数,便于后续实验调整。例如,可以禁用可用指令列表或中间奖励的使用,设置标记数量上限,或调整需要处理的观测字段集合。

(2) 接下来,num_fields 属性返回观测序列的数量,该数值用于确定编码后观测结果的维度形状:

python 复制代码
    @property
    def num_fields(self):
        return self._num_fields

    def _maybe_tokenize(self, s: str) -> str | tt.List[int]:
        if self._vocab_rev is None:
            return s
        tokens = common.tokenize(s, self._vocab_rev)
        if self._tokens_limit is not None:
            tokens = tokens[:self._tokens_limit]
        return tokens

(3) _maybe_tokenize() 方法执行输入字符串的分词。若未提供词汇表,则直接返回原始字符串。我们将在Transformer中使用此函数,因为 Hugging Face 库会执行内置分词:

python 复制代码
    def _encode(self, obs: str, extra_info: dict) -> dict:
        obs_result = []
        if self._encode_raw_text:
            obs_result.append(self._maybe_tokenize(obs))
        for field in self._encode_extra_field:
            extra = extra_info[field]
            obs_result.append(self._maybe_tokenize(extra))
        result = {self.OBS_FIELD: obs_result}
        if self._use_admissible_commands:
            result[KEY_ADM_COMMANDS] = [
                self._maybe_tokenize(cmd) for cmd in extra_info[KEY_ADM_COMMANDS]
            ]
            self._last_admissible_commands = extra_info[KEY_ADM_COMMANDS]
        if self._keep_admissible_commands:
            result[KEY_ADM_COMMANDS] = extra_info[KEY_ADM_COMMANDS]
            if 'policy_commands' in extra_info:
                result['policy_commands'] = extra_info['policy_commands']
        self._last_extra_info = extra_info
        for field in self._copy_extra_fields:
            if field in extra_info:
                result[field] = extra_info[field]
        return result

_encode() 方法是观测转换的核心,该方法接受观测字符串和扩展信息字典,并返回一个包含以下键的字典:

  • obs:包含输入序列的词元 ID 二维列表
  • admissible_commands:当前状态可用指令列表,每条指令均经过分词处理并转换为词元 ID 序列

此外,_encode() 方法还会记录扩展信息字典和原始可用指令列表。这些信息虽非训练所必需,但在模型应用阶段有助于通过指令索引反查指令文本内容。

(4) 基于已定义的 _encode() 方法,reset()step() 方法的实现变得十分简单,主要完成观测值编码处理及中间奖励的处理(若启用该功能):

python 复制代码
    def reset(self, seed: tt.Optional[int] = None):
        res, extra = self.env.reset()
        self._cmd_hist = []
        return self._encode(res, extra), extra

    def step(self, action):
        if self._use_admissible_commands:
            action = self._last_admissible_commands[action]
            self._cmd_hist.append(action)
        obs, r, is_done, extra = self.env.step(action)
        if self._use_intermedate_reward:
            r += extra.get('intermediate_reward', 0)
        if self._reward_wrong_last_command is not None:
            if action not in self._last_extra_info[KEY_ADM_COMMANDS]:
                r += self._reward_wrong_last_command
        return self._encode(obs, extra), r, is_done, False, extra

(5) 最后有两个属性用于访问已存储的状态:

python 复制代码
    @property
    def last_admissible_commands(self):
        if self._last_admissible_commands:
            return tuple(self._last_admissible_commands)
        return None

    @property
    def last_extra_info(self):
        return self._last_extra_info

为说明上述类的应用方式及其对观测值的处理逻辑,查看以下交互式示例。注册游戏时请求获取库存信息、中间奖励、可用指令列表和场景描述:

shell 复制代码
>>> from textworld import gym, EnvInfos 
>>> from lib import preproc, common 
>>> env_id = gym.register_game("games/simple1.ulx", request_infos=EnvInfos(inventory=True, intermediate_reward=True, admissible_commands=True, description=True))
>>> env = gym.make(env_id)

以上是从 TextWorld 环境获取的原始观测数据。接下来,我们提取游戏词汇表并应用预处理器:

shell 复制代码
>>> vocab, action_space, obs_space = common.get_games_spaces(["games/simple1.ulx"])>>> vocab 
{0: 'a', 1: 'about', 2: 'accomplished', 3: 'an', 4: 'and', 5: 'appears', 6: 'are', 7: 'arrive',.... 

 >>> len(vocab) 
192 
>>> vocab_rev = common.build_rev_vocab(vocab) 
>>> vocab_rev 
{'a': 0, 'about': 1, 'accomplished': 2, 'an': 3, 'and': 4, 'appears': 5, 'are': 6, 'arrive':... 
 >>> pr_env = preproc.TextWorldPreproc(env, vocab_rev) 
>>> r = pr_env.reset() 
>>> r[0] 

尝试执行一个动作。第 0 号动作对应可用指令列表中的第一条指令,本节中为"drop sponge"(丢弃海绵):

shell 复制代码
>>> r[1]['inventory'] 
'You are carrying: a type D latchkey, a teacup and a sponge.' 
>>> obs, reward, is_done, _, info = pr_env.step(0) 
>>> info['inventory'] 
'You are carrying: a type D latchkey and a teacup.' 
>>> reward 
0

可以看到,虽然我们已不再拥有海绵物品,但这不是正确的动作,因此系统未给予中间奖励。

虽然这种数据表示形式仍无法直接输入神经网络,但已更接近我们的需求。

4.2 嵌入和编码器

预处理管道的下一步由两个类实现:

  • EncoderLSTM 单元的封装器,负责将单个序列(经过嵌入层处理后)转换为固定大小的向量
  • Preprocessor:负责应用嵌入层,并通过相应的编码器类对各个序列进行转换

(1) 由于 Encoder 类较为简单,让我们先从它开始:

python 复制代码
class Encoder(nn.Module): 
    def __init__(self, emb_size: int, out_size: int): 
        super(Encoder, self).__init__() 
        self.net = nn.LSTM(input_size=emb_size, hidden_size=out_size, batch_first=True) 
    def forward(self, x): 
        self.net.flatten_parameters() 
        _, hid_cell = self.net(x) 
        return hid_cell[0].squeeze(0)

其运行逻辑是:应用 LSTM 层处理序列后返回其隐藏状态。

(2) Preprocessor类稍微复杂一些,它整合了多个 Encoder 实例并同时负责嵌入处理:

python 复制代码
class Preprocessor(nn.Module): 
    def __init__(self, dict_size: int, emb_size: int, num_sequences: int, 
                 enc_output_size: int, extra_flags: tt.Sequence[str] = ()): 
        super(Preprocessor, self).__init__() 
        self._extra_flags = extra_flags 
        self._enc_output_size = enc_output_size 
        self.emb = nn.Embedding(num_embeddings=dict_size, embedding_dim=emb_size) 
        self.encoders = [] 
        for idx in range(num_sequences): 
            enc = Encoder(emb_size, enc_output_size) 
            self.encoders.append(enc) 
            self.add_module(f"enc_{idx}", enc) 
        self.enc_commands = Encoder(emb_size, enc_output_size)

在构造函数中,我们创建了一个嵌入层,用于将字典中的每个词元映射为固定维度的稠密向量。随后为每个输入序列创建 num_sequencesEncoder 实例,并额外创建一个实例用于编码指令词元。

(3) 内部方法 _apply_encoder() 接受序列批次(每个序列是词元 ID 的列表)并通过编码器进行转换:

python 复制代码
    def _apply_encoder(self, batch: tt.List[tt.List[int]], encoder: Encoder): 
        dev = self.emb.weight.device 
        batch_t = [self.emb(torch.tensor(sample).to(dev)) for sample in batch]batch_seq = rnn_utils.pack_sequence(batch_t, enforce_sorted=False) 
        return encoder(batch_seq)

PyTorch 早期版本中,我们需要在 RNN 处理前对可变长度序列批次进行排序。自 PyTorch 1.0 起,这个步骤已不再必要,因为 PackedSequence 类会在内部自动处理排序和转换。要实现这个功能,我们需要传递 enforce_sorted=False 参数。

(4) encode_observations() 方法接收一个观测数据批次(来自 TextWorldPreproc )并将其编码为张量:

python 复制代码
def encode_observations(self, observations: tt.List[dict]) -> torch.Tensor: 
    sequences = [obs[TextWorldPreproc.OBS_FIELD] for obs in observations ] 
    res_t = self.encode_sequences(sequences) 
    if not self._extra_flags: 
        return res_t 
    extra = [[obs[field] for field in self._extra_flags] for obs in observations] 
    extra_t = torch.Tensor(extra).to(res_t.device) 
    res_t = torch.cat([res_t, extra_t], dim=1) 
    return res_t

除了可变长度序列外,我们还可以将额外的标志字段直接传入编码后的张量。这个功能将在后续实验和基础方法的扩展中使用。

(5) 最后,encode_sequences()encode_commands() 这两个方法,用于将不同的编码器应用于一批可变长度的序列:

python 复制代码
    def encode_sequences(self, batches): 
        data = [] 
        for enc, enc_batch in zip(self.encoders, zip(*batches)): 
            data.append(self._apply_encoder(enc_batch, enc)) 
        res_t = torch.cat(data, dim=1) 
        return res_t 
    def encode_commands(self, batch): 
        return self._apply_encoder(batch, self.enc_commands)

4.3 构建 DQN 模型

完成所有准备工作后,我们实现智能体的核心部分:DQN 模型。该模型需要接收维度为 num_sequences × encoder_size 的向量,并输出单个标量值。但与之前介绍的 DQN 模型不同之处在于其应用方式:

python 复制代码
class DQNModel(nn.Module):
    def __init__(self, obs_size: int, cmd_size: int, hid_size: int = 256):
        super(DQNModel, self).__init__()

        self.net = nn.Sequential(
            nn.Linear(obs_size + cmd_size, hid_size),
            nn.ReLU(),
            nn.Linear(hid_size, 1)
        )

    def forward(self, obs, cmd):
        x = torch.cat((obs, cmd), dim=1)
        return self.net(x)

    @torch.no_grad()
    def q_values(self, obs_t, commands_t):
        result = []
        for cmd_t in commands_t:
            qval = self(obs_t, cmd_t.unsqueeze(0))[0].cpu().item()
            result.append(qval)
        return result

上述代码中,forward() 方法同时接收两个批次数据------观测值和指令,为每对组合生成对应的Q值。另一个方法 q_values() 则接收 Preprocessor 类生成的单个观测值和已编码指令张量,应用模型后返回每个指令对应的Q值列表。

model.py 模块中,DQNAgent 类接收预处理器并实现 Agent 接口,从而在决策过程中隐藏观测值预处理的细节。

4.4 训练代码

模型训练使用 train_basic.py 程序。该程序支持多个命令行参数来调整训练行为:

  • -g--game:指定 games 目录中游戏文件的前缀。提供的脚本会生成多个名为 simpleNN.ulx 的游戏文件,其中 NN 为游戏种子编号
  • -s--suffices:这是训练中使用的游戏数量。如果指定 1 (默认值),仅使用 simple1.ulx 文件进行训练;若指定 -s 10,则使用编号 11010 个游戏进行训练。该选项旨在增加训练游戏的多样性,因为我们的目标不仅是学会玩特定游戏,还希望(尽可能)掌握在类似游戏中的行为策略
  • -v--validation:指定验证所用游戏的后缀。默认值为 -val,用于定义检验训练智能体泛化能力的游戏文件
  • --params:选择使用的超参数集。common.py 中定义了两组参数:smallmedium。前者使用较少的嵌入和编码向量,能快速解决少量游戏;但在多游戏训练场景下难以收敛
  • --dev:指定计算设备。
  • -r--run:设置运行名称,用于保存目录和 TensorBoard 的命名

训练过程中每 100 次迭代会执行一次验证,使用当前网络运行验证游戏。奖励值和步数将记录在 TensorBoard 中,帮助我们了解智能体的泛化能力。众所周知,强化学习中的泛化是个重大挑战------在有限轨迹集的情况下,训练过程容易对某些状态过拟合,无法保证在未见过的游戏中表现良好。与玩法相对固定的 Atari 游戏相比,互动小说游戏因任务目标、物品属性及交互方式的差异可能具有高度可变性。因此,检验智能体在游戏间的泛化能力是一项有趣的实验。

4.5 训练结果

默认情况下,脚本 games/make_games.sh 会生成 20 个游戏,命名从 simple1.ulxsimple20.ulx,外加一个验证游戏 simple-val.ulx

首先,让我们使用小型超参数集在单个游戏上训练智能体:

shell 复制代码
$ python3 train_basic.py -s 1 --dev cuda -r t1 

选项 -s 指定用于训练的游戏索引数量。本节仅使用一个游戏,当游戏平均步数降至 15 以下时训练停止,这表明智能体已找到正确的步骤序列并能高效完成游戏。

在单游戏训练场景下,仅需约 120 个回合即可解决游戏。

但若观察验证奖励(即在 simple-val.ulx 游戏中获得的奖励),我们会发现其随时间推移并未改善。在本节中,验证奖励始终为零,且验证回合的步数保持 50 步(默认时间限制)。这直接表明经过训练的智能体未能实现泛化。

若增加训练所用游戏数量,收敛所需时间将会延长,因为网络需要学习不同状态下更多动作序列。训练结束时智能体能获得 2 分(满分 6 分),且在训练中期曾达到 4 分。但验证游戏的步数仍为 50 步,表明智能体只是半随机地执行某些动作四处游荡,这个结果并不令人满意。我们也可以对该智能体尝试不同的超参数组合。

5. 观测信息优化

接下来,我们尝试将侧重于为智能体提供更多信息,我们将简要介绍所做的改动及其对训练结果的影响,完整代码可参阅 train_preproc.py

5.1 房间访问记录追踪

首先需要注意到,当前智能体无法感知房间是否曾被访问。当智能体已掌握通往目标的最优路径时,这可能并非必要(因为生成游戏总是包含不同房间)。但当策略未臻完善时,明确标识重复访问的房间将大有裨益。

为将这一信息融入观测值,在 preproc.LocationWrappe r类中实现了简单的房间访问追踪功能,该功能会记录整个回合中访问过的房间。随后将该标志以二进制形式( 1 表示曾访问,0 表示新房间)拼接至智能体观测值中。

要使用此扩展功能训练智能体,可运行 train_preproc.py 并添加命令行选项 --seen-rooms

对比基线版本与增加该观测信息的版本在 20 个游戏上的表现,训练游戏奖励值基本持平,但验证奖励得到改善------几乎在整个训练期间都能获得非零验证奖励。不过验证游戏的步数仍然是 50 步。

但在 200 个游戏上尝试此扩展功能后(需要修改脚本以生成相应游戏),经过约 8000 个回合的训练,智能体不仅能在验证游戏中获得最高分数,还能高效完成目标(步数少于 10 步)。

5.2 相对方位动作

第二次改进智能体学习的尝试聚焦于动作空间。本质上,智能体的任务是在房间中导航并对周围物体执行特定操作(例如打开储物柜并取出物品)。因此,导航能力在学习过程中至关重要。

目前我们通过执行"绝对坐标"指令进行移动,例如"向北走"或"向东走",这些指令与房间特性相关,因为不同房间可能提供不同的出口。此外,执行某些动作后,返回原房间的逆向动作取决于初始动作。例如:若所在房间有向北出口,使用该出口后需要执行"向南走"才能返回。但智能体没有动作历史记忆,因此在向北走后无法知晓如何返回。

前一节我们添加了房间访问记录信息,现在我们将把绝对动作转换为相对动作。通过预处理器包装类 preproc.RelativeDirectionsWrapper 追踪"朝向方位",并根据朝向将"向北走"或"向东走"等指令替换为"向左转"、"向右转"、"前进"或"后退"。例如:当处于有北出口的房间且面朝北方时,需执行"前进"指令使用出口;之后可通过"后退"指令返回原房间。这种转换有望让模型更轻松地在 TextWorld 游戏中导航。

要启用此功能,需使用 --relative-actions 命令行选项运行 train_preproc.py。该扩展功能需同时启用"房间访问记录",因此我们在此测试两种修改的组合效果。在 20 个游戏上,训练动态和验证结果与基线版本非常相似,但在 200 个游戏的测试中,智能体仅用 2.5 小时就能在验证游戏中获得最高分(相比"房间访问记录"扩展所需的 13 小时大幅缩短)。验证步数也降至 10 步以下。但随着训练持续进行,验证指标反而回落至较低水平,表明智能体对训练游戏产生了过拟合,反而丧失了已掌握的技能。

5.3 观测信息中的任务目标

另一个思路是将游戏目标融入智能体观测信息。游戏目标以文本形式在开局时呈现,例如:"首先需要尝试向东探索。然后向南行进。接着继续向南前进。成功之后,尝试向西移动。若能完成这些,请从房间地板上拾取硬币,全部完成后即可停止!"。

这些信息可能有助于智能体规划行动,因此让我们将其添加到编码向量中。无需实现新的包装器,现有包装器已具备足够灵活性,只需传递几个额外参数即可。要启用目标信息功能,需使用 --objective 命令行参数运行 train_preproc.py

20 个游戏上的测试结果与基线版本几乎完全相同,在 200 个游戏上的训练效果不如之前的改进方案:验证期间得分维持在 2-4 分区间,始终未能达到 6 分满分。

小结

在本节中,我们介绍了如何将深度Q网络 (Deep Q-Network, DQN)应用于交互式小说游戏,这是一个属于强化学习 (Reinforcement Learning, RL) 和自然语言处理 (Natural Language Processing, NLP) 的交叉领域。我们学习了如何使用 NLP 工具处理复杂的文本数据,并在交互式小说游戏环境中进行实验。

系列链接

PyTorch强化学习实战(1)------强化学习(Reinforcement Learning,RL)详解

PyTorch强化学习实战(2)------强化学习环境库Gymnasium

PyTorch强化学习实战(3)------Gymnasium API扩展功能

PyTorch强化学习实战(4)------PyTorch基础

PyTorch强化学习实战(5)------PyTorch Ignite 事件驱动机制与实践

PyTorch强化学习实战(6)------交叉熵方法详解与实现

PyTorch强化学习实战(7)------表格学习与贝尔曼方程

PyTorch强化学习实战(8)------Q学习详解与实现

PyTorch强化学习实战(9)------深度Q学习

PyTorch强化学习实战(10)------强化学习高级组件

PyTorch强化学习实战(11)------N步DQN(N-step DQN)

PyTorch强化学习实战(12)------Double DQN(DDQN)

PyTorch强化学习实战(13)------噪声网络(NoisyNet-DQN)

PyTorch强化学习实战(14)------优先经验回放机制

PyTorch强化学习实战(15)------Dueling DQN

PyTorch强化学习实战(16)------Categorical DQN

PyTorch强化学习实战(17)------强化学习训练加速

PyTorch强化学习实战(18)------基于DQN处理股票交易问题

PyTorch强化学习实战(19)------策略梯度法

PyTorch强化学习实战(20)------优势演员-评论家(Advantage Actor-Critic, A2C)

PyTorch强化学习实战(21)------异步优势演员-评论家(Asynchronous Advantage Actor-Critic, A3C)

相关推荐
jay神3 小时前
深度学习的优化器应该怎么选?
人工智能·python·深度学习·毕业设计·课程设计
YOLO数据集集合15 小时前
一站式AI数据自动化标注与训练平台:零门槛玩转YOLO全系列模型
人工智能·深度学习·yolo·ai·自动化·数据集·标注软件
史上最甜的躯18 小时前
医疗影像分析与深度学习-导论
深度学习·医疗检测
铅笔侠_小龙虾19 小时前
深度学习阶段总结(2)--矩阵
深度学习·矩阵
Uncommon.20 小时前
使用Pytorch自动计算梯度
人工智能·pytorch·python
手写码匠1 天前
华为云Flexus+DeepSeek征文|Dify 多 Agent 灰度发布实战:让每一次变更都“小步快跑、随时可回滚“
人工智能·深度学习·算法·aigc
TonyLee0171 天前
神经网络门控机制探讨
人工智能·深度学习·神经网络
JAI科研1 天前
Deepseek Agent Harness教程(二) | DeepSeek Harness 设计思路
人工智能·深度学习·算法·机器学习·自然语言处理·transformer·vllm
还不秃顶的计科生1 天前
具身智能论文学习10:π0: A Vision-Language-Action Flow Model for General Robot Control
人工智能·深度学习·算法·机器学习·语言模型·vla·vlm