原文:
zh.annas-archive.org/md5/11b175e592527142ad4d19f0711517be
译者:飞龙
第五章:启发式搜索技术和逻辑推理
在本章中,我们将介绍一系列问题解决工具。我们将从本体论和基于知识的推理开始,然后转向布尔可满足性 (SAT)和组合优化的优化,其中我们将模拟个体行为和社会协调的结果。最后,我们将实现蒙特卡洛树搜索以找到国际象棋中的最佳着法。
在本章中,我们将涉及各种技术,包括逻辑求解器、图嵌入、遗传算法 (GA)、粒子群优化 (PSO)、SAT 求解器、模拟退火 (SA)、蚁群优化、多主体系统和蒙特卡洛树搜索。
在本章中,我们将涵盖以下配方:
-
基于知识做出决策
-
解决 n 皇后问题
-
查找最短的公交路线
-
模拟疾病的传播
-
编写带有蒙特卡洛树搜索的国际象棋引擎
让我们开始吧!
基于知识做出决策
当关于一个主题有大量背景知识可用时,为什么不在做出决策时使用它?这被称为基于知识的系统。专家系统中的推理引擎和逻辑求解器中的统一化就是其中的例子。
在做出决策时,另一种检索知识的方式是基于在图中表示知识。图中的每个节点表示一个概念,而每条边表示一种关系。两者都可以嵌入并表示为表达它们与图中其他元素位置关系的数值特征。
在本配方中,我们将为每种可能性举例两次。
从亚里士多德到林奈再到今天的数学家和物理学家,人们试图通过将对象分类到系统顺序中来对世界进行排序,这被称为分类学。在数学上,分类法被表示为图,它表示信息作为元组*(s, o),其中主题 s 与对象 o 相连;或者三元组(s, p, o)*,其中主题 a 与谓词 p 相关联到 o。经常使用的一种类型是 ISA 分类法,其中关系为 is-a 类型。例如,汽车是车辆,飞机也是车辆。
准备就绪
在这个配方中,我们将使用从 Python 的nltk
(自然语言工具包)库接口的逻辑求解器,然后使用被称为networkx
和karateclub
的图库。
您需要使用的pip
命令如下:
pip install nltk karateclub networkx
对于这个配方的第二部分,我们还需要从 Kaggle 下载动物园数据集,可在www.kaggle.com/uciml/zoo-animal-classification
获取。
如何做到…
正如我们在这个配方的介绍中解释的那样,我们将从两种不同的方法来看待两个不同的问题。
我们将从使用逻辑求解器开始进行逻辑推理。
逻辑推理
在这个配方的这一部分,我们将使用nltk
库捆绑的一些库来简单展示逻辑推理的一个示例。还有许多其他方法可以处理逻辑推理,我们将在配方末尾的参考资料部分中看到一些。
我们将使用一个非常简单的玩具问题,你可以在任何101 – 逻辑入门书籍中找到,尽管解决这类问题的方法可以更复杂。
我们的问题是众所周知的:如果所有人类都是可死的,苏格拉底是一个人类,那么苏格拉底是可死的吗?
我们可以在nltk
中非常自然地表达这个过程,如下所示:
from nltk import *
from nltk.sem import Expression
p1 = Expression.fromstring('man(socrates)')
p2 = Expression.fromstring('all x.(man(x) -> mortal(x))')
c = Expression.fromstring('mortal(socrates)')
ResolutionProver().prove(c, [p1, p2], verbose=True)
前面的代码给出了以下输出:
[1] {-mortal(socrates)} A
[2] {man(socrates)} A
[3] {-man(z2), mortal(z2)} A
[4] {-man(socrates)} (1, 3)
[5] {mortal(socrates)} (2, 3)
[6] {} (1, 5)
True
求解器提供的推理也可以很自然地阅读,所以我们不会在这里解释这个过程。我们将在*如何工作…*部分中学习其内部工作原理。
接下来,我们将看看知识嵌入。
知识嵌入
在这个配方的这一部分,我们将尝试利用信息如何相互关联,将其嵌入到一个可以作为特征化一部分的多维空间中。
这里,我们将加载数据,预处理数据,嵌入数据,然后通过对其新特征进行分类来测试我们的嵌入效果。让我们开始吧:
- 数据集加载和预处理:首先,我们将像之前那样将动物园数据集加载到 pandas 中。然后,我们将确保将二进制列表示为
bool
而不是int
:
import pandas as pd
zoo = pd.read_csv('zoo.csv')
binary_cols = zoo.columns[zoo.nunique() == 2]
for col in binary_cols:
zoo[col] = zoo[col].astype(bool)
labels = [
'Mammal', 'Bird', 'Reptile',
'Fish', 'Amphibian', 'Bug',
'Invertebrate'
]
training_size = int(len(zoo) * 0.8)
动物园数据集包含 101 种动物,每种动物都有描述其是否有毛发或产奶等特征。这里,目标类别是动物的生物学类别。
- 图嵌入:
get_triplet()
函数以(s, p, o)的格式返回二进制和整数元素的三元组。注意,我们是从完整数据集中创建三元组,而不仅仅是训练集。但是,为了避免目标泄漏,我们不会从训练集外的数据点创建三元组:
all_labels = { i+1: c for i, c in enumerate(labels) }
cols = list(zoo.columns)
triplets = []
def get_triplet(row, col):
if col == 'class_type':
return (
all_labels[row[col]],
'is_a',
row['animal_name'],
)
# int properties:
if col in ['legs']:
#if row[col] > 0:
return (
row['animal_name'],
'has' + col,
str(row[col]) + '_legs'
)
#else:
# return ()
# binary properties:
if row[col]:
return (
row['animal_name'],
'has',
str(col)
)
else:
return ()
for i, row in zoo.iterrows():
for col in cols:
if col == 'animal_name':
continue
if col == 'class_type' and i > training_size:
continue
triplet = get_triplet(row, col)
if triplet:
triplets.append(triplet)
前面的代码将创建我们的三元组。让我们看一些示例,了解它们的样子。以下是我们得到的前 20 个条目;我们使用triplets[:20]
来获取它们:
[('aardvark', 'has', 'hair'),
('aardvark', 'has', 'milk'),
('aardvark', 'has', 'predator'),
('aardvark', 'has', 'toothed'),
('aardvark', 'has', 'backbone'),
('aardvark', 'has', 'breathes'),
('aardvark', 'haslegs', '4_legs'),
('aardvark', 'has', 'catsize'),
('Mammal', 'is_a', 'aardvark'),
('antelope', 'has', 'hair'),
('antelope', 'has', 'milk'),
('antelope', 'has', 'toothed'),
('antelope', 'has', 'backbone'),
('antelope', 'has', 'breathes'),
('antelope', 'haslegs', '4_legs'),
('antelope', 'has', 'tail'),
('antelope', 'has', 'catsize'),
('Mammal', 'is_a', 'antelope'),
('bass', 'has', 'eggs'),
('bass', 'has', 'aquatic')]
前面的代码块展示了一些结果三元组的示例。总共,我们从 101 行中得到了 842 个三元组。
现在,我们可以使用networkx
API 将这个数据集加载到图中:
import networkx as nx
class Vocabulary:
label2id = {}
id2label = {}
def lookup(self, word):
"""get word id; if not present, insert"""
if word in self.label2id:
return self.label2id[word]
ind = len(self.label2id)
self.label2id[word] = ind
return ind
def inverse_lookup(self, index):
if len(self.id2label) == 0:
self.id2label = {
ind: label
for label, ind in self.label2id.items()
}
return self.id2label.get(index, None)
vocab = Vocabulary()
nx_graph = nx.Graph()
for (a, p, b) in triplets:
id1, id2 = vocab.lookup(a), vocab.lookup(b)
nx_graph.add_edge(id1, id2)
Vocabulary
类是label2id
和id2label
字典的包装器。我们需要这个类是因为一些图嵌入算法不接受节点或关系的字符串名称。在这里,我们在将概念标签存储到图中之前将其转换为 ID。
现在,我们可以用不同的算法对图进行数值嵌入。这里我们将使用Walklets
:
from karateclub.node_embedding.neighbourhood import Walklets
model_w = Walklets(dimensions=5)
model_w.fit(nx_graph)
embedding = model_w.get_embedding()
前面的代码显示了图中每个概念将由一个 5 维向量表示。
现在,我们可以测试这些特征是否对预测目标(动物
)有用:
trainamals = [
vocab.label2id[animal]
for animal in zoo.animal_name.values[:training_size]
]
testimals = [
vocab.label2id[animal]
for animal in zoo.animal_name.values[training_size:]
]
clf = SVC(random_state=42)
clf.fit(embedding[trainamals, :], zoo.class_type[:training_size])
test_labels = zoo.class_type[training_size:]
test_embeddings = embedding[testimals, :]
print(end='Support Vector Machine: Accuracy: ')
print('{:.3f}'.format(
accuracy_score(test_labels, clf.predict(test_embeddings)
))
print(confusion_matrix(test_labels, clf.predict(test_embeddings)))
输出如下所示:
Support Vector Machine: Accuracy = 0.809
[[5 0 0 0 0 0 0]
[0 4 0 0 0 0 0]
[2 0 0 1 0 0 0]
[0 0 0 3 0 0 0]
[1 0 0 0 0 0 0]
[0 0 0 0 0 2 0]
[0 0 0 0 0 0 3]]
看起来很不错,尽管这项技术只有在我们拥有超越训练集的知识库时才会变得真正有趣。在不加载数百万个三元组或庞大图表的情况下,很难展示图嵌入。我们将在接下来的小节中提到一些大型知识库。
工作原理…
在本节中,我们将首先涵盖逻辑推理和逻辑证明器,然后再看知识嵌入和 Walklets 图嵌入的基本概念。
逻辑推理
逻辑推理是一个涵盖逻辑推断技术如演绎、归纳和引导的术语。引导推理,经常在专家系统中使用,是从现有观察中检查并推导可能结论(最佳解释)的过程。
专家系统是一种模拟人类专家决策能力的推理系统。专家系统通过推理处理知识体系,主要以 if-then-else 规则表示(这称为知识库)。
归纳推理 是在遵循初始前提和规则的情况下确定结论。在演绎推理 中,我们从观察中推断出一个规则。
要应用逻辑推理,语言陈述必须编码为良好形式的逻辑公式,以便我们可以应用逻辑演算。良好形式的公式可以包含以下实体:
-
断言符号如 P
-
等号符号,
-
否定,
-
二元连接词如
-
量词如
(对于所有)和
(存在)。
例如,推理Socrates 是一个人。人是有限的。因此,苏格拉底是有限的,可以用命题逻辑的逻辑陈述来表达,如下:
接下来,我们将看一下逻辑证明器。
逻辑证明器
自动定理证明是一个广泛的领域,包括基于逻辑定理和数学公式的工作。我们已经看过证明由逻辑等式组成的一阶逻辑方程的问题。搜索算法与逻辑方程结合,以便确定命题公式的可满足性(参见本章中的Solving the n-queens problem配方),以及在给定一组公理的情况下句子的有效性。nltk
中的解析定理证明器提供了其他功能,如统一化、包含、以及问答(QA):www.nltk.org/howto/resolution.html
。
在下一小节中,我们将看一下知识嵌入。
知识嵌入
知识嵌入(KE)指的是从概念关系中导出的分布式表示。这些通常在知识图谱(KG)中表示。
知识图谱的一个著名示例是WordNet(G.A. Miller 等人,《WordNet:一个在线词汇数据库》;1990 年),它提供了单词的同义词、上义词和其他扩展,类似于一本词典,并且在所有主要操作系统中都有不同的 GUI 和适当的命令行。WordNet 提供了 200 多种语言的版本,每个词(synset)都通过定向语义关系与其他词相关联,如上义词或下义词、部分或整体关系等。
知识图谱可以在自然语言处理(NLP)应用中使用,以支持决策,它们可以有效地作为查找引擎或推理的工具。
知识嵌入是概念关系的低维表示,可以使用嵌入或更通用的降维方法提取。在下一小节中,我们将看看 Walklet 嵌入方法。
用 Walklets 进行图嵌入
Walklet 算法基本上将 Word2Vec skipgram 算法应用于图中的顶点,因此我们将根据它们的连接而不是单词(Word2Vec 的原始应用)获得概念的嵌入。Walklet 算法在图的顶点上对短随机行走进行子采样,作为路径传递给浅层神经网络(见下图),用于 skipgram 训练。
skipgram 算法(Mikolov 等人,2013 年;arxiv.org/abs/1301.3781
)根据单词本身预测单词(即顶点)的上下文。每个单词被表示为连续的词袋向量(实际上,每个单词都在我们使用的字典中得到索引),我们预测基于隐藏层投影的周围单词(概念)的索引。该隐藏层投影的维度和上下文的窗口大小是算法的主要参数。训练后,我们使用隐藏层作为嵌入。
下图说明了 skipgram 网络架构,包括输入层、隐藏层和单词预测的输出层:
w(t)指的是当前单词(或概念),而w(t-2)、w(t-1)、*w(t+1)和w(t+2)*指的是当前单词之前和之后的两个单词。我们根据当前单词预测单词上下文。正如我们已经提到的,上下文的大小(窗口大小)是 skipgram 算法的超参数。
一个相关的算法是连续词袋算法(CBOW),其中架构被倒置 - 我们根据上下文预测单个词。两者都基于这样一个假设:共同出现的词具有相关的含义或分布相似性,这意味着它们在意义上是相似的。这被称为分布假设(Harris, 1954, Distributional structure)。
Walklet 算法在大型图上表现良好,并且由于它是神经网络,可以在线训练。关于 Walklets 的更多信息可以在 Brian Perozzi 等人的 2017 年论文Don’t Walk, Skip! Online Learning of Multi-scale** Network Embeddings中找到(arxiv.org/abs/1605.02115
)。
另见
下面是用于 Python 中逻辑推理的库:
-
Kanren 逻辑编程:
github.com/logpy/logpy
-
PyDatalog:
sites.google.com/site/pydatalog/
我们一直在nltk
的推理指南中遵循推理的指导。你可以在官方nltk
网站找到更多工具:www.nltk.org/howto/inference.html
。
一些用于图嵌入的其他库如下:
-
pykg2vec:
github.com/Sujit-O/pykg2vec
-
PyTorch BigGraph(由 Facebook Research 提供):
github.com/facebookresearch/PyTorch-BigGraph
-
GraphVite:
graphvite.io/
-
AmpliGraph(由 Accenture 提供):
docs.ampligraph.org/
-
pyRDF2Vec:
github.com/IBCNServices/pyRDF2Vec
KarateClub 由爱丁堡大学的博士生 Benedek Rozemberczki 维护,包含许多无监督图嵌入算法的实现。
一些图库还提供链路预测。这意味着对于给定的节点集合,您可以推断是否存在与其他节点的关系。关于链路预测的评论可以在 Andrea Rossi 等人的论文Knowledge Graph Embedding for Link Prediction: A Comparative Analysis中找到(2020; arxiv.org/abs/2002.00819
)。
一些关于推理真实世界和/或常识的资源如下:
-
ActionCores:
www.actioncores.org/apidoc.html#pracinference
-
KagNet:
github.com/INK-USC/KagNet
-
Allen AI Commonsense Knowledge Graphs:
mosaic.allenai.org/projects/commonsense-knowledge-graphs
-
Commonsense Reasoning Problem Page at NYU CS:
commonsensereasoning.org/problem_page.html
Learning on graphs: Open Graph Benchmark: Datasets for Machine Learning on Graphs, Hu and others, 2020 (arxiv.org/pdf/2005.00687.pdf
) 是关于使用机器学习进行图形嵌入的另一个参考文献。
还有几个大型的现实世界知识数据库可供使用,例如以下内容:
-
Wikidata:
www.wikidata.org/
-
Conceptnet5:
github.com/commonsense/conceptnet5
-
The Open Multilingual Wordnet:
compling.hss.ntu.edu.sg/omw/
解决 n-皇后问题
在数理逻辑中,可满足性是关于一个公式在某些解释(参数)下是否有效的问题。如果一个公式在任何解释下都不能成立,我们称其为不可满足。布尔可满足性问题(SAT)是关于一个布尔公式在其参数的任何值下是否有效(可满足)的问题。由于许多问题可以归约为 SAT 问题,并且存在针对它的求解器和优化方法,SAT 问题是一个重要的问题类别。
SAT 问题已被证明是 NP 完全的。NP 完全性(缩写为非确定性多项式时间)意味着问题的解决方案可以在多项式时间内验证。请注意,这并不意味着可以快速找到解决方案,只是可以快速验证解决方案。NP 完全问题通常使用搜索启发式和算法来解决。
在这个配方中,我们将以多种方式解决 SAT 问题。我们将以一个相对简单且深入研究的案例来解释,即 n-皇后问题,其中我们尝试在一个n乘n的棋盘上放置皇后,以使得任何列、行和对角线最多只能放置一个皇后。
首先,我们将应用遗传算法(GA),然后是粒子群优化(PSO),最后使用专门的 SAT 求解器。
准备工作
我们在这个配方中的一个方法中将使用dd
求解器。要安装它,我们还需要omega
库。我们可以使用pip
命令获取这两个库,如下所示:
pip install dd omega
我们稍后将使用dd
SAT 求解器库,但首先我们将研究一些其他的算法方法。
如何做…
我们将从遗传算法(GA)开始。
遗传算法
首先,我们将定义染色体的表示方式和如何进行变异。然后,我们将定义一个反馈循环来测试和改变这些染色体。我们将在最后的工作原理部分详细解释算法本身。让我们开始吧:
- 表示解决方案(一个染色体):面向对象的风格适合定义染色体。让我们看看我们的实现。首先,我们需要知道染色体是什么以及它的作用:
import random
from typing import Optional, List, Tuple
class Chromosome:
def __init__(self, configuration: Optional[List]=None, nq: Optional[int]=None):
if configuration is None:
self.nq = nq
self.max_fitness = np.sum(np.arange(nq))
self.configuration = [
random.randint(1, nq) for _ in range(nq)
]
else:
self.configuration = configuration
self.nq = len(configuration)
self.max_fitness = np.sum(np.arange(self.nq))
def fitness(self):
return cost_function(self.configuration) / self.max_fitness
def mutate(self):
ind = random.randint(0, self.nq-1)
val = random.randint(1, self.nq)
self.configuration[ind] = val
上述代码创建了我们的基本数据结构,其中包含一个候选解决方案,可以复制和突变。此代码涉及成本函数。
我们需要一个成本函数,以便知道如何适应我们的基因:
def cost_function(props):
res = 0
for i1, q1 in enumerate(props[:-1]):
for i2, q2 in enumerate(props[i1+1:], i1+1):
if (q1 != q2) and (abs(i1 - i2) != abs(q1 - q2)):
res += 1
return res
我们可以根据这个成本函数(见fitness()
方法)选择基因。
- 编写主要算法:N 皇后问题的 GA 如下(我们在此省略了可视化):
class GeneticQueen:
def __init__(self, nq, population_size=20, mutation_prob=0.5):
self.nq = nq
self.population_size = population_size
self.mutation_prob = mutation_prob
self.population = [Chromosome(nq=nq) for _ in range(population_size)]
self.solution = None
self.best_fitness = None
def iterate(self):
new_population = []
best_fitness = -1
for i in range(len(self.population)):
p1, p2 = self.get_parents()
child = Chromosome(self.cross_over(p1, p2))
if random.random() < self.mutation_prob:
child.mutate()
new_population.append(child)
fit = child.fitness()
if fit > best_fitness:
best_fitness = fit
if fit == 1:
self.solution = child
break
self.best_fitness = best_fitness
self.population = new_population
def cross_over(self, p1, p2):
return [
yi
if random.random() > 0
else xi
for xi, yi in zip(
p1.configuration,
p2.configuration
)
]
def get_parents(self) -> Tuple[Chromosome, Chromosome]:
weights = [chrom.fitness() for chrom in self.population]
return tuple(
random.choices(
self.population,
weights=weights,
k=2
)
)
该类包含染色体的种群,并可以对其应用方法(如果您喜欢的话,如get_parents()
和cross_over()
)。请注意iterate()
方法,在这里实现了主要逻辑。我们将在*它的工作原理…*部分对我们在这里做出的主要决策进行评论。
- 运行算法:我们通过简单地实例化一个
GeneticQueen
并调用iterate()
来执行我们的算法。我们还可以添加几行额外的代码来定期更新并随时间收集适应性数据。然后,我们像这样运行算法:
def ga_solver(nq):
fitness_trace = []
gq = GeneticQueen(nq=nq)
generation = 0
while not gq.solution:
gq.iterate()
if (generation % 100) == 0:
print('Generation {}'.format(generation))
print('Maximum Fitness: {:.3f}'.format(gq.best_fitness))
fitness_trace.append(gq.best_fitness)
generation += 1
gq.visualize_solution()
return fitness_trace
最后,我们可以可视化解决方案。
如果我们运行上述代码,将得到一个看起来像这样的单次运行结果(您的结果可能会有所不同):
Generation 0
Maximum Fitness: 0.857
Generation 100
Maximum Fitness: 0.821
Generation 200
Maximum Fitness: 0.892
Generation 300
Maximum Fitness: 0.892
Generation 400
Maximum Fitness: 0.892
上述代码给出了以下输出:
这个操作接近 8 秒才完成。
下图显示了算法每次迭代中最佳染色体的适应性:
在这里,我们可以看到算法的适应性并不总是改善;它也可能下降。我们本可以选择在此处保留最佳染色体。在这种情况下,我们不会看到任何下降(但潜在的缺点是我们可能会陷入局部最小值)。
现在,让我们继续 PSO!
粒子群优化
在这个配方的这一部分,我们将从头开始实现 N 皇后问题的 PSO 算法。让我们开始吧:
- 表示解决方案:与 GA 类似,我们需要定义解决方案的外观。在 PSO 中,这意味着我们定义一个粒子:
class Particle:
best_fitness: int = 0
def __init__(
self, N=None, props=None,
velocities=None
):
if props is None:
self.current_particle = np.random.randint(0, N-1, N)
self.best_state = np.random.randint(0, N-1, N)
self.velocities = np.random.uniform(-(N-1), N-1, N)
else:
self.current_particle = props
self.best_state = props
self.velocities = velocities
self.best_fitness = cost_function(self.best_state)
def set_new_best(self, props: List[int], new_fitness: int):
self.best_state = props
self.best_fitness = new_fitness
def __repr__(self):
return f'{self.__class__.__name__}(\n' +\
f'\tcurrent_particle={self.current_particle}\n' +\
f'\best_state={self.best_state}\n' +\
f'\tvelocities={self.velocities}\n' +\
f'\best_fitness={self.best_fitness}\n' +\
')'
这是我们将要处理的主数据结构。它包含一个候选解决方案。应用 PSO 将涉及更改一堆这些粒子。我们将在*它的工作原理…*部分详细解释Particle
的工作原理。
我们将使用与我们为 GA 定义的相同成本函数。该成本函数告诉我们我们的粒子如何适应给定问题 - 换句话说,一个性质向量有多好。
我们将初始化和主算法封装到一个类中:
class ParticleSwarm:
def __init__(self, N: int, n_particles: int,
omega: float, phip: float, phig: float
):
self.particles = [Particle(N=N) for i in range(n_particles)]
self.omega = omega
self.phip = phip
self.phig = phig
def get_best_particle(self):
best_particle = 0
best_score = -1
score = -1
for i, particle in enumerate(self.particles):
score = cost_function(particle.current_particle)
if score > best_score:
best_score = score
best_ind = i
return self.particles[best_ind].current_particle, best_score
def iterate(self):
for particle in self.particles:
rg = np.random.rand((N))
rp = np.random.rand((N))
delta_p = particle.best_state - particle.current_particle
delta_g = best_particle - particle.current_particle
update = (rp * self.phip * delta_p +
\ rg * self.phig * delta_g) # local vs global
particle.velocities = self.omega * particle.velocities + update
particle.current_particle = (np.abs(
particle.current_particle + particle.velocities
) % N ).astype(int) # update the particle best
current_fitness = cost_function(particle.current_particle)
if current_fitness > particle.best_fitness:
particle.set_new_best(
particle.current_particle, current_fitness
)
particle_candidate, score_candidate = get_best_particle(particles)
if best_score_cand > best_score:
best_particle = particle_candidate
best_score = score_candidate
return best_particle, best_score
get_best_particle()
方法返回最佳配置和最佳分数。请注意iterate()
方法,它更新我们的粒子并返回最佳粒子及其分数。关于此更新的详细信息在*工作原理…*部分提供。优化过程本身使用几个相对简单的公式完成。
我们还想展示我们的解决方案。显示棋盘位置的代码如下:
import chess
import chess.svg
from IPython.display import display
def show_board(queens):
fen = '/'.join([queen_to_str(q) for q in queens])
display(chess.svg.board(board=chess.Board(fen), size=300))
下面是 PSO 的主算法:
def particle_swarm_optimization(
N: int, omega: float, phip: float, phig: float,
n_particles: int, visualize=False, max_iteration=999999
) -> List[int]:
def print_best():
print(f'iteration {iteration} - best particle: {best_particle}, score: {best_score}')
solved_cost = np.sum(np.arange(N))
pso = ParticleSwarm(N, n_particles, omega, phip, phig)
iteration = 0
best_particle, best_score = get_best_particle(particles)
scores = [best_score]
if visualize:
print('iteration:', iteration)
show_board(best_particle)
while best_score < solved_cost and iteration < max_iteration:
if (iteration % 500) == 0 or iteration == 0:
print_best()
best_particle, best_score = pso.iterate()
if iteration > 0 and visualize:
print('iteration:', iteration)
show_board(best_particle)
scores.append(best_score)
iteration += 1
print_best()
return best_particle, scores
类似于我们在 GA 案例中所做的,我们追踪解决方案在迭代中的表现(通过我们的成本函数)。主函数返回以下内容:
-
best_particle
:最佳解决方案 -
scores
:我们迭代中的最佳分数
正如我们之前提到的,我们将在*工作原理…*部分解释所有这些内容的工作方式。
您可以查看使用n = 8运行的算法输出,网址为github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/blob/master/chapter05/solving-n-queens.md
。
我们在这里使用棋盘库进行可视化。
在下面的图表中,您可以看到解决方案在迭代中的质量:
由于所有粒子都保持其最佳解的记录,分数永远不会下降。在第 1,323 次迭代时,我们找到了一个解决方案,算法停止了。
SAT 求解器
这主要基于可以在dd
库中找到的示例,版权属于加州理工学院,网址为github.com/tulip-control/dd/blob/0f6d16483cc13078edebac9e89d1d4b99d22991e/examples/queens.py
。
在 Python 中的现代 SAT 求解器中,我们可以将约束定义为简单的函数。
基本上,有一个公式包含所有约束条件。一旦所有约束条件满足(或所有约束条件的合取),就找到了解决方案:
def queens_formula(n):
present = at_least_one_queen_per_row(n)
rows = at_most_one_queen_per_line(True, n)
cols = at_most_one_queen_per_line(False, n)
slash = at_most_one_queen_per_diagonal(True, n)
backslash = at_most_one_queen_per_diagonal(False, n)
s = conj([present, rows, cols, slash, backslash])
return s
这是at_least_one_queen_per_row
的约束条件:
def at_least_one_queen_per_row(n):
c = list()
for i in range(n):
xijs = [_var_str(i, j) for j in range(n)]
s = disj(xijs)
c.append(s)
return conj(c)
在这里,我们对每行上的皇后进行析取。
主运行如下所示:
def benchmark(n):
t0 = time.time()
u, bdd = solve_queens(n)
t1 = time.time()
dt = t1 - t0
for i, d in enumerate(bdd.pick_iter(u)):
if len(d) > 0:
visualize_solution(d)
break
n_solutions = bdd.count(u)
s = (
'------\n'
'queens: {n}\n'
'time: {dt} (sec)\n'
'node: {u}\n'
'total nodes: {k}\n'
'number solutions: {n_solutions}\n'
'------\n'
).format(
n=n, dt=dt, u=u, k=len(bdd),
n_solutions=n_solutions,
)
print(s)
return dt
当我们运行此代码时,应该看到一个示例解决方案。我们还应该得到一些关于找到多少解决方案以及花费多长时间找到它们的统计信息。
下面是八皇后问题的示例解决方案:
文本输出如下所示:
queens: 8
time: 4.775595426559448 (sec)
node: -250797
total nodes: 250797
number solutions: 92
此求解器不仅获得了所有的解决方案(我们只显示了其中一个),而且比遗传算法快大约两倍!
工作原理…
在本节中,我们将解释在此配方中使用的不同方法,从遗传算法开始。
遗传算法
在本质上,遗传算法很简单:我们维护一组候选解决方案(称为染色体),并且我们有两种操作可以用来改变它们:
-
cross-over
:两个染色体产生子代(这意味着它们混合) -
mutation:
染色体随机变化
一个染色体存储在configuration
中的候选解决方案。在初始化染色体时,我们必须给它皇后的数量或者初始配置。在本章的前文中,我们已经讨论了染色体的实际含义。如果没有给定配置,则需要使用列表推导创建一个,比如[random.randint(1, nq) for _ in range(nq)]
。
一个染色体可以计算自己的适应度;在这里,我们使用了先前使用的相同成本函数,但这次我们将其缩放到 0 到 1 之间,其中 1 表示我们找到了一个解决方案,介于其中的任何值显示我们距离解决方案有多接近。染色体也可以对自己进行突变;也就是说,它可以随机改变其值之一。
算法的每一次迭代,我们都通过这两个操作创建新的染色体代。
-
首先,我们使用代表解决方案不同参数的不同值初始化我们的第一代染色体。
-
然后,我们计算我们的染色体的适应度。这可以通过与环境的交互来完成,或者它可能是解决方案本身固有的,就像我们的九皇后问题的组合问题一样。
-
接下来,我们按以下方式创建新的染色体代:
-
在考虑其适应度的情况下选择父母
-
根据一定的概率突变几个染色体
-
-
最后,我们从第 2 步开始重复,直到适应度足够高或者我们已经迭代了很多次。
我们在这里非常宽泛地表达了最后一步。基本上,我们可以决定何时适应度足够高以及我们想要迭代多少次。这些是我们的停止标准。
这在我们的GeneticQueen.iterate()
的实现中非常清晰,因此为了可视化目的,让我们再看一眼(仅稍微简化):
def iterate(self):
new_population = []
for i in range(len(self.population)):
p1, p2 = self.get_parents()
child = Chromosome(self.cross_over(p1, p2))
if random.random() < self.mutation_prob:
child.mutate()
new_population.append(child)
关于遗传算法(GA),我们必须做出的一个重要决策是是否保留最佳解决方案,或者是否所有染色体(包括最佳的)都必须死亡(可能在产生后代后)。在这里,每次迭代都会创建一个全新的代。
我们通过按其适应度加权随机选择父母,其中适应度最高的被选择的可能性更大。在我们的实现中,cross-over
函数会随机在每个参数中的两个父母之间做出决策。
为 GA 必须做出的主要超参数和主要决策如下:
-
种群大小(我们有多少染色体?)
-
变异率(染色体变异时变化的程度是多少?)
-
多少(以及哪些)染色体产生后代?通常这些是具有最高适应度的染色体。
-
我们的停止标准是什么?通常,算法的适应度有一个阈值,并且有一个设定的迭代次数。
正如我们所见,遗传算法非常灵活且直观。在接下来的部分,我们将看看 PSO。
粒子群优化(Particle Swarm Optimization,PSO)
我们以Particle
数据结构开始我们的实现。要初始化一个粒子,我们传入皇后数量(N
)或者我们的速度和参数向量。基本上,一个粒子有一个配置,或者说一组参数 - 在这种情况下是一个向量,它与问题的某个程度匹配(current_particle
),以及一个速度(类似于学习率)。每个粒子的属性向量表示皇后的位置。
PSO 然后以特定的方式对粒子应用变化。PSO 结合了局部搜索和全局搜索;也就是说,在每个粒子处,我们试图将搜索引导向全局最佳粒子和过去最佳粒子。一个粒子保持其最佳实例的记录;也就是说,其最佳参数的向量和相应的得分。我们还保持参数的相应速度。这些速度可以根据正在使用的公式而减慢、增加或改变方向。
PSO 需要一些参数,如下所示(大多数这些在我们的实现中已命名;这里省略了那些特定于我们的九皇后问题的参数):
-
omega
:衰减参数 -
phip
:控制局部搜索的贡献 -
phig
:控制全局搜索的贡献 -
n_particles
:粒子的数量 -
max_iterations
:用于没有解决方案的提前停止
在我们的 PSO 问题中,有两个增量,delta_p
和 delta_g
,其中 p 和 g 分别代表粒子(particle)和全局(global)。这是因为其中一个是根据粒子的历史最佳计算的,另一个是根据粒子的全局最佳计算的。
更新根据以下代码计算:
delta_p = particle.best_state - particle.current_particle
delta_g = best_particle - particle.current_particle
update = (rp * phip * delta_p +\
rg * phig * delta_g) # local vs global
这里,rp
和 rg
是随机数,phip
和 phig
分别是局部和全局因子。它们分别指一个唯一的粒子或所有粒子,如delta_p
和 delta_g
变量所示。
还有另一个参数 omega
,它调节当前速度的衰减。在每次迭代中,根据以下公式计算新的速度:
particle.velocities = omega * particle.velocities + update
接着,根据它们的速度递增粒子参数。
请注意,算法对于phip
、phig
和omega
的选择非常敏感。
我们的成本函数(或好度函数)根据给定的皇后配置为每个粒子计算分数。这个配置被表示为在范围]0, N-1*.*中的索引列表对于每对皇后,函数检查它们是否在对角线、垂直或水平方向上重叠。每个不冲突的检查都给予一个点,因此最大的得分是![。这对于 8 皇后问题是 28。
SAT 求解器
有许多不同的专用可满足性(SAT)求解器工作方式。Weiwei Gong 和 Xu Zhou(2017)的调查提供了对不同方法的广泛概述:aip.scitation.org/doi/abs/10.1063/1.4981999
。
我们在配方中使用的 dd
求解器,使用二进制决策图(BDD)工作,这些图是由 Randal Bryant(基于图的布尔函数操作算法,1986 年)引入的。二进制决策图(有时称为分支程序)将约束表示为布尔函数,而不是其他编码方式,如否定范式。
在 BDD 中,一个算法或一组约束被表示为在维度为 n 的布尔域上的布尔函数,其评估为真或假:
这意味着我们可以将问题表示为二叉树或等效地表示为真值表。
为了说明这一点,让我们看一个例子。我们可以枚举所有关于我们的二进制变量(x1,x2 和 x3)的状态,然后得出一个最终状态,即 f 的结果。以下真值表总结了我们变量的状态,以及我们的函数评估:
x1 | x2 | x3 | f |
---|---|---|---|
False | False | False | False |
False | False | True | False |
False | True | False | False |
False | True | True | False |
True | False | False | True |
True | False | True | False |
True | True | False | True |
True | True | True | True |
这对应于以下二叉树:
二叉树和真值表具有高度优化的库实现,因此它们可以运行非常快。这解释了我们如何如此快速地得到结果。
另见
Python 中还有许多其他 SAT 求解器,其中一些如下所示:
-
Microsoft 的 PDP 求解器:
github.com/microsoft/PDP-Solver
-
Z3,由微软研究院提供:
github.com/Z3Prover/z3
-
Python 绑定到 picosat,由 Continuum 开发:
github.com/ContinuumIO/pycosat
关于 SAT 求解器在解数独中的应用讨论可在此找到:codingnest.com/modern-sat-solvers-fast-neat-underused-part-1-of-n/
。
这里可以找到解决骑士和卫士问题的 Z3 示例: jamiecollinson.com/blog/solving-knights-and-knaves-with-z3/
寻找最短公交路线
寻找最短公交路线意味着寻找一条连接地图上点(公交车站)的路径。这是旅行推销员问题的一个实例。在本篇文章中,我们将通过不同的算法来解决寻找最短公交路线的问题,包括模拟退火和蚁群优化。
准备工作
除了像scipy
和numpy
这样的标准依赖项外,我们还将使用scikit-opt
库,该库实现了许多不同的群体智能算法。
群体智能是分散式、自组织系统的集体行为,这种行为在观察者眼中表现出明显的智能性。这个概念在基于人工智能的工作中被使用。自然系统,如蚂蚁群、鸟群、鹰的捕猎、动物群集和细菌生长,在全局层面展示出一定水平的智能,尽管蚂蚁、鸟类和鹰通常表现出相对简单的行为。受生物学启发的群体算法包括遗传算法、粒子群优化、模拟退火和蚁群优化。
我们可以使用pip
安装scikit-opt
,如下所示:
pip install scikit-opt
现在,我们准备解决旅行推销员问题。
如何做…
正如我们之前提到的,我们将以两种不同的方式解决最短公交路线问题。
首先,我们需要为公交车站创建一组坐标(经度,纬度)。问题的难度取决于站点的数量(N
)。在这里,我们将N
设置为15
:
import numpy as np
N = 15
stops = np.random.randint(0, 100, (N, 2))
我们还可以预先计算站点之间的距离矩阵,如下所示:
from scipy import spatial
distance_matrix = spatial.distance.cdist(stops, stops, metric='euclidean')
我们可以将这个距离矩阵输入到两个算法中以节省时间。
我们将从模拟退火开始。
模拟退火
在这个子节中,我们将编写我们的算法来寻找最短公交路线。这基于 Luke Mile 的 Python 实现的模拟退火,应用于旅行推销员问题:gist.github.com/qpwo/a46274751cc5db2ab1d936980072a134
。让我们开始吧:
- 实现本身非常简短而简洁:
def find_tour(stops, distance_matrix, iterations=10**5):
def calc_distance(i, j):
"""sum of distance to and from i and j in tour
"""
return sum(
distance_matrix[tour[k], tour[k+1]]
for k in [j - 1, j, i - 1, i]
)
n = len(stops)
tour = np.random.permutation(n)
lengths = []
for temperature in np.logspace(4, 0, num=iterations):
i = np.random.randint(n - 1) # city 1
j = np.random.randint(i + 1, n) # city 2
old_length = calc_distance(i, j)
# swap i and j:
tour[[i, j]] = tour[[j, i]]
new_length = calc_distance(i, j)
if np.exp((old_length - new_length) / temperature) < np.random.random(): # bad swap
tour[[i, j]] = tour[[j, i]] # undo swap
lengths.append(old_length)
else:
lengths.append(new_length)
return tour, lengths
- 接下来,我们需要调用算法,如下所示:
from scipy.spatial.distance import euclidean
tour, lengths = find_tour(
stops, distance_matrix, iterations=1000000
)
这是最终的解决方案 – 路径如下所示:
我们还可以绘制算法的内部距离度量。请注意,这个内部成本函数在约 800,000 次迭代之前一直下降:
现在,让我们尝试蚁群优化算法。
蚁群优化
在这里,我们正在从库中加载实现。我们将在*它的工作原理…*部分解释细节:
from sko.ACA import ACA_TSP
def cal_total_distance(tour):
return sum([
distance_matrix[tour[i % N], tour[(i + 1) % N]]
for i in range(N)
])
aca = ACA_TSP(
func=cal_total_distance,
n_dim=N,
size_pop=N,
max_iter=200,
distance_matrix=distance_matrix
)
best_x, best_y = aca.run()
我们使用基于我们之前获取的点距离的距离计算(distance_matrix
)。
再次,我们可以绘制最佳路径和路径距离随迭代次数的变化情况,如下所示:
再次,我们可以看到最终路径,这是我们优化的结果(左侧子图),以及随着算法迭代距离逐渐减少的路径(右侧子图)。
工作原理…
最短巴士路线问题是旅行商问题(TSP)的一个示例,而 TSP 又是组合优化的一个众所周知的示例。
组合优化是指使用组合技术来解决离散优化问题。换句话说,它是在一组对象中找到解决方案的行为。在这种情况下,“离散”意味着有限数量的选项。组合优化的智能部分在于减少搜索空间或加速搜索。旅行商问题、最小生成树问题、婚姻问题和背包问题都是组合优化的应用。
TSP 可以表述如下:给定要访问的城镇列表,找出遍历所有城镇并回到起点的最短路径是什么?TSP 在规划、物流和微芯片设计等领域有应用。
现在,让我们更详细地看一下模拟退火和蚁群优化。
模拟退火
模拟退火是一种概率优化技术。其名称来源于冶金学,其中加热和冷却用于减少材料中的缺陷。简单来说,在每次迭代中,可以发生状态转换(即变化)。如果变化成功,则系统会降低其温度。这可以重复进行,直到状态足够好或达到一定迭代次数为止。
在这个示例中,我们随机初始化了我们的城市旅游路线,然后进行了模拟退火的迭代。SA 的主要思想是,变化的速率取决于一定的温度。在我们的实现中,我们从 4 逻辑地降低了温度到 0。在每次迭代中,我们尝试交换(也可以尝试其他操作)路径(旅游路线)中两个随机巴士站点的索引 i 和 j,其中 i < j,然后计算从 i-1 到 i、从 i 到 i+1、从 j-1 到 j 和从 j 到 j+1 的距离总和(见 calc_distance
)。我们还需要一个距离度量来进行 calc_distance
。我们选择了欧几里得距离,在这里,但我们也可以选择其他距离度量。
温度在我们需要决定是否接受交换时发挥作用。我们计算路径长度变化前后的指数差:
然后,我们生成一个随机数。如果这个随机数小于我们的表达式,我们就接受这个变化;否则,我们撤销它。
蚁群优化
正如其名称所示,蚁群优化 受到蚂蚁群体的启发。让我们使用蚂蚁分泌的信息素作为类比:这里,代理人具有候选解决方案,越接近解决方案,越有吸引力。
总体而言,蚂蚁编号 k 从状态 x 转移到状态 y 的概率如下:
Tau 是在 x 和 y 之间沉积的信息素路径。eta 参数控制信息素的影响,其中 eta 的 beta 次幂是状态转换(例如转换成本的倒数)。信息素路径根据包括状态转换在内的整体解决方案的好坏而更新。
在这里,scikit-opt
函数起到了重要作用。我们只需传递几个参数,如距离函数、点数、种群中的蚂蚁数量、迭代次数和距离矩阵,然后调用 run()
。
另请参阅
您也可以将此问题作为混合整数问题来解决。Python-MIP 库解决混合整数问题,您可以在 python-mip.readthedocs.io/en/latest/examples.html
找到 TSP 的示例。
TSP 也可以用 Hopfield 网络解决,如本教程所述:www.tutorialspoint.com/artificial_neural_network/artificial_neural_network_optimization_using_hopfield.htm
。在这里讨论了一种布谷鸟搜索方法:github.com/Ashwin-Surana/cuckoo-search
。
scikit-opt
是一个强大的启发式算法库。它包括以下算法:
-
差分进化
-
遗传算法
-
粒子群优化
-
模拟退火
-
蚁群算法
-
免疫算法
-
人工鱼群算法
scikit-opt
文档包含更多解决 TSP(旅行推销员问题)的例子:scikit-opt.github.io/scikit-opt/#/en/README?id=_22-genetic-algorithm-for-tsptravelling-salesman-problem
。另一个类似于 scikit-opt
的库是 pyswarms
,可以在 pyswarms.readthedocs.io/en/latest/index.html
找到。
正如我们在本文开头提到的,运输物流在 TSP 中有其独特的应用,甚至在其纯粹形式中。墨西哥拥有 30,000 辆公共汽车、小巴和面包车的数据集可以在 thelivinglib.org/mapaton-cdmx/
找到。
模拟疾病传播
诸如天花、结核病和黑死病等大流行病,长期以来显著影响了人类群体。截至 2020 年,新冠肺炎正在全球范围内传播,关于如何在尽可能少的伤亡情况下控制病毒的政治和经济问题已广泛讨论。
关于新冠肺炎,对于自由主义者来说,瑞典曾一度成为无需封锁的典范,尽管未考虑到诸如高比例的单人户和社会距离的文化倾向等次要因素。最近,瑞典的死亡人数有所上升,其人均发病率是已记录的最高之一(www.worldometers.info/coronavirus/
)。
在英国,最初的反应是依赖群体免疫,只有在其他国家已经实施封锁数周后才宣布封锁。由于无法应对,国民健康服务系统(NHS)使用临时床位并租用商业医院的床位。
多代理系统(MAS)是由参与者(称为代理)组成的计算机模拟。这些个体代理可以根据启发式或基于强化学习作出响应。此外,这些代理相互响应以及对环境的响应的系统行为可以应用于研究以下主题:
-
合作与协调
-
分布式约束优化
-
交流与协商
-
分布式问题解决,尤其是分布式约束优化
在这个食谱中,一个相对简单的多代理模拟将展示不同的响应如何导致疫情的致命人数和传播方式上的差异。
准备就绪
我们将使用mesa
多代理建模库来实现我们的多代理模拟。
用于此操作的pip
命令如下:
pip install mesa
现在,我们已经准备好了!
如何做…
这个模拟基于 Maple Rain Research Co., Ltd.的工作。对于这个食谱,我们已经做了一些更改,引入了因素如医院床位和封锁政策,并且我们也改变了感染和活跃病例的计算方式。你可以在github.com/benman1/covid19-sim-mesa
找到完整的代码。
声明:本食谱的目的不是提供医疗建议,我们也不是合格的医疗从业者或专家。
首先,我们将通过Person
类来定义我们的代理:
class Person(Agent):
def __init__(self, unique_id, model):
super().__init__(unique_id, model)
self.alive = True
self.infected = False
self.hospitalized = False
self.immune = False
self.in_quarantine = False # self-quarantine
self.time_infected = 0
此定义将代理定义为拥有健康和隔离状态的人。
我们仍然需要一些方法来改变其他属性的变化方式。我们不会详细介绍所有这些方法,只是介绍那些足以让你理解所有内容如何结合在一起的方法。我们需要理解的核心是代理在感染时做什么。基本上,在感染期间,我们需要了解代理是否会传染给其他人,是否会因感染而死亡,或者是否会康复:
def while_infected(self):
self.time_infected += 1
if self.hospitalized:
# stay in bed, do nothing; maybe die
if self.random.random() < (
self.model.critical_rate *
self.model.hospital_factor
):
# die
self.alive = False
self.hospitalized = False
self.infected = False
return
self.hospitalized -= 1
return
if self.random.random() < (
self.model.quarantine_rate /
self.model.recovery_period
):
self.set_quarantine()
if not self.in_quarantine:
self.infect_others() # infect others in same cell
if self.time_infected < self.model.recovery_period:
if self.random.random() < self.model.critical_rate:
if self.model.hospital_takeup:
self.hospitalized = self.model.hospital_period
self.set_quarantine()
else:
self.alive = False # person died from infection
self.infected = False
else: # person has passed the recovery period so no longer infected
self.infected = False
self.quarantine = False
if self.random.random() < self.model.immunity_chance:
self.immune = True
在这里,我们可以看到几个在模型层面上定义的变量,例如self.model.critical_rate
,self.model.hospital_factor
和self.model.recovery_period
。我们稍后会更详细地查看这些模型变量。
现在,我们需要一种方法让我们的代理记录它们的位置,这在mesa
中被称为MultiGrid
:
def move_to_next(self):
possible_steps = self.model.grid.get_neighborhood(
self.pos,
moore=True,
include_center=False
)
new_position = self.random.choice(possible_steps)
self.model.grid.move_agent(self, new_position)
这是相对直接的。如果代理移动,它们只在它们的邻域内移动;也就是说,下一个相邻的单元。
被称为step()
方法的入口方法在每个周期(迭代)都会被调用:
def step(self):
if self.alive:
self.move()
如果代理活着,它们在每一步都会移动。这是它们移动时会发生的事情:
def move(self):
if self.in_quarantine or self.model.lockdown:
pass
else:
self.move_to_next()
if self.infected:
self.while_infected()
这结束了我们的代理,也就是Person
的主要逻辑。现在,让我们看看在模型层面上如何将所有内容整合在一起。这可以在model.py
中的Simulation
类中找到。
让我们看看代理是如何创建的:
def create_agents(self):
for i in range(self.num_agents):
a = Person(i, self)
if self.random.random() < self.start_infected:
a.set_infected()
self.schedule.add(a)
x = self.random.randrange(self.grid.width)
y = self.random.randrange(self.grid.height)
self.grid.place_agent(a, (x, y))
上述代码创建了我们需要的代理数量。其中一些会根据start_infected
参数被感染。我们还将这些代理添加到一个以网格形式组织的单元地图中。
我们还需要定义一些数据收集器,如下所示:
def set_reporters(self):
self.datacollector = DataCollector(
model_reporters={
'Active Cases': active_cases,
'Deaths': total_deaths,
'Immune': total_immune,
'Hospitalized': total_hospitalized,
'Lockdown': get_lockdown,
})
此字典列表中的变量在每个周期中都会追加,以便我们可以绘图或进行统计评估。例如,让我们看看active_cases
函数是如何定义的:
def active_cases(model):
return sum([
1
for agent in model.schedule.agents
if agent.infected
])
当被调用时,该函数会迭代模型中的代理,并计算状态为infected
的代理数量。
同样地,就像对Person
一样,Simulation
的主要逻辑在step()
方法中,该方法推进模型一个周期:
def step(self):
self.datacollector.collect(self)
self.hospital_takeup = self.datacollector.model_vars[
'Hospitalized'
][-1] < self.free_beds
self.schedule.step()
if self.lockdown:
self.lockdown -= 1
else:
if self.lockdown_policy(
self.datacollector.model_vars['Active Cases'],
self.datacollector.model_vars['Deaths'],
self.num_agents
):
self.lockdown = self.lockdown_period
self.current_cycle += 1
让我们看看不同的封锁政策如何影响死亡和疾病的传播。
我们将在这些模拟中使用与之前相同的一组变量。我们设置它们以便它们大致对应于英国,按照 1/1,000 的因子:
scale_factor = 0.001
area = 242495 # km2 uk
side = int(math.sqrt(area)) # 492
sim_params = {
'grid_x': side,
'grid_y': side,
'density': 259 * scale_factor, # population density uk,
'initial_infected': 0.05,
'infect_rate': 0.1,
'recovery_period': 14 * 12,
'critical_rate': 0.05,
'hospital_capacity_rate': .02,
'active_ratio': 8 / 24.0,
'immunity_chance': 1.0,
'quarantine_rate': 0.6,
'lockdown_policy': lockdown_policy,
'cycles': 200 * 12,
'hospital_period': 21 * 12,
}
我们将在*它是如何工作的……*部分解释网格的动机。
封锁由lockdown_policy
方法声明,该方法被传递给Simulation
的构造函数。
首先,让我们看看在没有引入封锁的情况下的数据。如果我们的policy
函数始终返回False
,我们可以创建这个策略:
def lockdown_policy(infected, deaths, population_size):
return 0
结果图显示了我们随时间收集的五个变量:
总体而言,我们有 8,774 例死亡。
在这里,我们可以看到随着这一政策早期解除封锁,多次感染的波动:
def lockdown_policy(infected, deaths, population_size):
if (
(max(infected[-5 * 10:]) / population_size) > 0.6
and
(len(deaths) > 2 and deaths[-1] > deaths[-2])
):
return 7 * 12
return 0
当我们运行这个模拟时,我们得到完全不同的结果,如下所示:
在 250 个迭代周围的矩形形状显示了封锁的宣布时间(忽略比例或形状)。总体而言,我们可以看到这导致了 20663 人的死亡。这种极高的死亡率——远高于critical_rate
参数的设定——由于免疫前再感染,已经设定为 5%。
让我们将这与一个非常谨慎的政策进行比较,即每次死亡率上升或者感染率在(大致)3 周内超过 20%时宣布封锁:
def lockdown_policy(infected, deaths, population_size):
if infected[-1] / population_size > 0.2:
return 21 * 12
return 0
仅有一次封锁,我们得到了以下的图表,显示了大约 600 人的总死亡人数:
您可以更改这些参数或者调整逻辑,以创建更复杂和/或更真实的模拟。
更多关于原始工作的细节可以在线上找到(teck78.blogspot.com/2020/04/using-mesa-framework-to-simulate-spread.html
)。
工作原理…
模拟非常简单:它由代理组成,并且在迭代(称为周期)中进行。每个代理代表人群的一部分。
在这里,某个群体被这种疾病感染。在每个周期(对应 1 小时)内,被感染者可以去医院(如果有空位)、死亡、或者朝着康复迈进。他们还可以进入隔离状态。在尚未康复、未隔离且尚未死亡的情况下,他们可以在与他们空间接近的人群中传播疾病。恢复时,代理可以获得免疫力。
在每个周期内,代理可以移动。如果他们不在隔离中或者国家实施了封锁,他们将会移动到一个新的位置;否则,他们将保持原地。如果一个人被感染,他们可以死亡、去医院、康复、传染给其他人,或者进入隔离状态。
根据死亡和感染率,可以宣布国家封锁,这是我们模拟的主要焦点:国家封锁的引入如何影响死亡人数?
我们需要考虑不同的变量。其中一个是人口密度。我们可以通过将我们的代理放在地图或网格上来引入人口密度,网格大小由grid_x
和grid_y
定义。infect_rate
参数必须根据网格大小和人口密度进行调整。
我们在这里需要考虑更多的参数,比如以下的参数:
-
initial_infected
是初始感染率。 -
recovery_period
声明了被感染后恢复所需的周期数(大致以小时计),0
表示永不恢复。 -
critical_rate
是在整个恢复期内患病者可能重症的比例,这意味着他们可能会去医院(如果可能的话)或者死亡。 -
hospital_capacity_rate
是全人口每人的医院床位数。我们通过在线搜索找到了这些信息(www.hsj.co.uk/acute-care/nhs-hospitals-have-four-times-more-empty-beds-than-normal/7027392.article
,www.kingsfund.org.uk/publications/nhs-hospital-bed-numbers
)。 -
还有
active_ratio
定义一个人的活跃程度;quarantine_rate
决定一个人不去医院而进行自我隔离的可能性;以及immunity_chance
,在康复后相关。 -
模拟将运行一定数量的
cycles
,我们的封锁政策在lockdown_policy
函数中声明。
在 Simulation
的 step()
方法中,我们进行了数据收集。然后,根据 free_beds
变量检查医院是否可以接收更多患者。接着,我们运行了代理器 self.schedule.step()
。如果我们处于封锁状态,我们开始倒计时。封锁状态由 False
变量到 lockdown_period
变量设置(在 Python 的鸭子类型中有所改动)。
lockdown_policy()
函数确定国家封锁的持续时间,根据感染和死亡的人数随时间变化(列表)。在这里,0 意味着我们不宣布封锁。
还有更多…
由于模拟可能需要很长时间才能运行,尝试参数可能非常缓慢。而不是必须进行完整运行,然后才能看到是否产生预期效果,我们可以使用 matplotlib
的实时绘图功能。
为了获得更快的反馈,让我们实时绘制模拟循环,如下所示:
%matplotlib inline
from collections import defaultdict
from matplotlib import pyplot as plt
from IPython.display import clear_output
def live_plot(data_dict, figsize=(7,5), title=''):
clear_output(wait=True)
plt.figure(figsize=figsize)
for label,data in data_dict.items():
plt.plot(data, label=label)
plt.title(title)
plt.grid(True)
plt.xlabel('iteration')
plt.legend(loc='best')
plt.show()
model = Simulation(sim_params)
cycles_to_run = sim_params.get('cycles')
print(sim_params)
for current_cycle in range(cycles_to_run):
model.step()
if (current_cycle % 10) == 0:
live_plot(model.datacollector.model_vars)
print('Total deaths: {}'.format(
model.datacollector.model_vars['Deaths'][-1]
))
这将持续(每 10 个周期)更新我们的模拟参数绘图。如果没有达到预期效果,我们可以中止它,而不必等待完整模拟。
另请参见
您可以在 mesa.readthedocs.io/en/master/
找到有关 mesa 的基于 Python 的多智能体建模的更多信息。以下是一些其他的多智能体库:
-
MAgent 专注于具有非常多代理的 2D 环境,通过强化学习进行学习:
github.com/PettingZoo-Team/MAgent
。 -
osBrain 和 PADE 是通用的多智能体系统库。它们分别可以在
osbrain.readthedocs.io/en/stable/
和pade.readthedocs.io/en/latest/
找到。 -
SimPy 是一个离散事件模拟器,可用于更广泛的模拟:
simpy.readthedocs.io/en/latest/
。
其他模拟器也已发布,其中最突出的是 CovidSim 微模型(github.com/mrc-ide/covid-sim
),由伦敦帝国学院全球传染病分析中心 MRC 开发。
使用蒙特卡洛树搜索编写国际象棋引擎
国际象棋是一种两人对弈的棋盘游戏,自 15 世纪以来作为智力游戏而广受欢迎。在 20 世纪 50 年代,计算机击败了第一个人类玩家(一个完全的新手),然后在 1997 年击败了人类世界冠军。此后,它们已经发展到拥有超人类的智能。编写国际象棋引擎的主要困难之一是搜索许多变化和组合并选择最佳策略。
在这个示例中,我们将使用蒙特卡洛树搜索来创建一个基本的国际象棋引擎。
准备好了
我们将使用python-chess
库进行可视化,获取有效移动,并知道状态是否终止。我们可以使用pip
命令安装它,如下所示:
pip install python-chess
我们将使用这个库进行可视化,生成每个位置的有效移动,并检查是否达到了最终位置。
如何实现…
本示例基于 Luke Miles 在gist.github.com/qpwo/c538c6f73727e254fdc7fab81024f6e1
上对蒙特卡洛树搜索的最小实现。
首先,我们将查看我们将用来定义我们的树搜索类的代码,然后看看搜索是如何工作的。之后,我们将学习如何将其适应于国际象棋。
树搜索
树搜索是一种利用搜索树作为数据结构的搜索方法。通常情况下,在搜索树中,节点(或叶子)表示一个概念或情况,这些节点通过边(分支)连接。树搜索遍历树以得出最佳解决方案。
让我们首先实现树搜索类:
import random
class MCTS:
def __init__(self, exploration_weight=1):
self.Q = defaultdict(int)
self.N = defaultdict(int)
self.children = dict()
self.exploration_weight = exploration_weight
我们将在*它的工作原理…*部分更详细地讨论这些变量。我们很快将向这个类添加更多方法。
我们的树搜索中的不同步骤在我们的do_rollout
方法中执行:
def do_rollout(self, node):
path = self._select(node)
leaf = path[-1]
self._expand(leaf)
reward = self._simulate(leaf)
self._backpropagate(path, reward)
每个rollout()
调用都会向我们的树中添加一层。
让我们依次完成四个主要步骤:
select
步骤找到一个叶节点,从该节点尚未启动模拟:
def _select(self, node):
path = []
while True:
path.append(node)
if node not in self.children or not self.children[node]:
return path
unexplored = self.children[node] - self.children.keys()
if unexplored:
n = unexplored.pop()
path.append(n)
return path
node = self._select(random.choice(self.children[node]))
这是递归定义的,因此如果我们找不到未探索的节点,我们就会探索当前节点的一个子节点。
- 扩展步骤添加子节点——即通过有效移动到达的节点,给定一个棋盘位置:
def _expand(self, node):
if node in self.children:
return
self.children[node] = node.find_children()
此函数使用后代(或子节点)更新children
字典。这些节点是从当前节点通过单个移动可以到达的任何有效棋盘位置。
- 模拟步骤运行一系列移动,直到游戏结束:
def _simulate(self, node):
invert_reward = True
while True:
if node.is_terminal():
reward = node.reward()
return 1 - reward if invert_reward else reward
node = node.find_random_child()
invert_reward = not invert_reward
此函数执行模拟直到游戏结束。
- 反向传播步骤将奖励与路径上的每一步关联起来:
def _backpropagate(self, path, reward):
for node in reversed(path):
self.N[node] += 1
self.Q[node] += reward
reward = 1 - reward
最后,我们需要一种选择最佳移动的方法,可以简单地通过查看Q
和N
字典并选择具有最大效用(奖励)的后代来实现:
def choose(self, node):
if node not in self.children:
return node.find_random_child()
def score(n):
if self.N[n] == 0:
return float('-inf')
return self.Q[n] / self.N[n]
return max(self.children[node], key=score)
我们将看不见的节点的分数设置为-infinity
,以避免选择未见过的移动。
实现一个节点
现在,让我们学习如何为我们的国际象棋实现使用一个节点。
因为这基于python-chess
库,所以实现起来相对容易:
import hashlib
import copy
class ChessGame:
def find_children(self):
if self.is_terminal():
return set()
return {
self.make_move(m) for m in self.board.legal_moves
}
def find_random_child(self):
if self.is_terminal():
return None
moves = list(self.board.legal_moves)
m = choice(moves)
return self.make_move(m)
def player_win(self, turn):
if self.board.result() == '1-0' and turn:
return True
if self.board.result() == '0-1' and not turn:
return True
return False
def reward(self):
if self.board.result() == '1/2-1/2':
return 0.5
if self.player_win(not self.board.turn):
return 0.0
def make_move(self, move):
child = self.board.copy()
child.push(move)
return ChessGame(child)
def is_terminal(self):
return self.board.is_game_over()
我们在这里省略了一些方法,但不要担心 - 我们将在*工作原理…*部分进行覆盖。
现在一切准备就绪,我们终于可以下棋了。
下国际象棋
让我们下一盘国际象棋!
以下只是一个简单的循环,带有一个图形提示显示棋盘位置:
from IPython.display import display
import chess
import chess.svg
def play_chess():
tree = MCTS()
game = ChessGame(chess.Board())
display(chess.svg.board(board=game.board, size=300))
while True:
move_str = input('enter move: ')
move = chess.Move.from_uci(move_str)
if move not in list(game.board.legal_moves):
raise RuntimeError('Invalid move')
game = game.make_move(move)
display(chess.svg.board(board=game.board, size=300))
if game.is_terminal():
break
for _ in range(50):
tree.do_rollout(game)
game = tree.choose(game)
print(game)
if game.is_terminal():
break
然后,您应该被要求输入一个移动到棋盘上某个位置的移动。每次移动后,将显示一个棋盘,显示当前棋子的位置。这可以在以下截图中看到:
注意移动必须以 UCI 符号输入。如果以“square to square”格式输入移动,例如 a2a4,它应该总是有效。
这里使用的游戏强度并不是非常高,但是在玩弄它时应该仍然很容易看到一些改进。请注意,此实现没有并行化。
工作原理…
在蒙特卡洛树搜索(MCTS)中,我们应用蒙特卡洛方法 - 基本上是随机抽样 - 以获取关于玩家所做移动强度的概念。对于每个移动,我们随机进行移动直到游戏结束。如果我们做得足够频繁,我们将得到一个很好的估计。
树搜索维护不同的变量:
-
Q
是每个节点的总奖励。 -
N
是每个节点的总访问次数。 -
children
保存每个节点的子节点 - 可以从一个棋盘位置到达的节点。 -
节点在我们的情况下是一个棋盘状态。
这些字典很重要,因为我们通过奖励对节点(棋盘状态)的效用进行平均,并根据它们被访问的频率(或者更确切地说,它们被访问的次数越少)对节点进行抽样。
搜索的每次迭代包括四个步骤:
-
选择
-
扩展
-
模拟
-
回传播
选择步骤,在其最基本的形式下,寻找一个尚未探索过的节点(例如一个棋盘位置)。
扩展步骤将children
字典更新为所选节点的子节点。
模拟步骤很简单:我们执行一系列随机移动,直到到达终止位置,并返回奖励。由于这是一个两人零和棋盘游戏,当轮到对手时,我们必须反转奖励。
反向传播步骤按照反向方向的路径将奖励与探索路径中的所有节点关联起来。_backpropagate()
方法沿着一系列移动(路径)回溯所有节点,赋予它们奖励,并更新访问次数。
至于节点的实现,由于我们将它们存储在之前提到的字典中,所以节点必须是可散列且可比较的。因此,在这里,我们需要实现__hash__
和__eq__
方法。我们以前没有提到它们,因为我们不需要它们来理解算法本身,所以我们在这里补充了它们以保持完整性:
def __hash__(self):
return int(
hashlib.md5(
self.board.fen().encode('utf-8')
).hexdigest()[:8],
16
)
def __eq__(self, other):
return self.__hash__() == other.__hash__()
def __repr__(self):
return '\n' + str(self.board)
当你在调试时,__repr__()
方法可能非常有用。
对于ChessGame
类的主要功能,我们还需要以下方法:
-
find_children()
: 从当前节点查找所有可能的后继节点 -
find_random_child()
: 从当前节点找到一个随机的后继节点 -
is_terminal()
: 确定节点是否为终端节点 -
reward()
: 为当前节点提供奖励
请再次查看ChessGame
的实现,以了解它的运作方式。
这还不算完…
MCTS 的一个重要扩展是上置信树(UCTs),用于平衡探索和利用。在 9x9 棋盘上达到段位的第一个围棋程序使用了带有 UCT 的 MCTS。
要实现UCT
扩展,我们需要回到我们的MCTS
类,并进行一些更改:
def _uct_select(self, node):
log_N_vertex = math.log(self.N[node])
def uct(n):
return self.Q[n] / self.N[n] + self.exploration_weight * math.sqrt(
log_N_vertex / self.N[n]
)
return max(self.children[node], key=uct)
uct()
函数应用上置信界限(UCB)公式,为一个移动提供一个得分。节点n的得分是从节点n开始的所有模拟中赢得的模拟数量的总和,加上一个置信项:
在这里,c 是一个常数。
接下来,我们需要替换代码的最后一行,以便使用_uct_select()
代替_select()
进行递归。在这里,我们将替换_select()
的最后一行,使其陈述如下:
node = self._uct_select(node)
进行此更改应该会进一步增强代理程序的游戏强度。
另请参见
要了解更多关于 UCT 的信息,请查看 MoGO 关于在 9x9 棋盘上达到段位的第一个计算机围棋程序的文章:hal.inria.fr/file/index/docid/369786/filename/TCIAIG-2008-0010_Accepted_.pdf
。它还提供了 MCTS 的伪代码描述。
easyAI 库包含许多不同的搜索算法:zulko.github.io/easyAI/index.html
。
第六章:深度强化学习
强化学习是指通过优化它们在环境中的行动来自动化问题解决的目标驱动代理的发展。这涉及预测和分类可用数据,并训练代理成功执行任务。通常,代理是一个能够与环境进行交互的实体,学习是通过将来自环境的累积奖励的反馈来指导未来的行动。
可以区分三种不同类型的强化学习:
-
基于价值——价值函数提供当前环境状态的好坏估计。
-
基于策略——其中函数根据状态确定行动。
-
基于模型——包括状态转换、奖励和行动规划在内的环境模型。
在本章中,我们将从多臂赌博机的角度开始介绍强化学习在网站优化中的相对基础的用例,我们将看到一个代理和一个环境以及它们的交互。然后,我们将进入控制的简单演示,这时情况会稍微复杂一些,我们将看到一个代理环境和基于策略的方法 REINFORCE。最后,我们将学习如何玩二十一点,我们将使用深度 Q 网络(DQN),这是一个基于价值的算法,2015 年由 DeepMind 创建用于玩 Atari 游戏的 AI。
在本章中,我们将涵盖以下步骤:
-
优化网站
-
控制车杆
-
玩二十一点
技术要求
完整的笔记本可以在线上 GitHub 找到:github.com/PacktPublishing/Artificial-Intelligence-with-Python-Cookbook/tree/master/chapter06
。
优化网站
在这个步骤中,我们将处理网站优化。通常,需要对网站进行变更(或者更好的是,单一变更)来观察其效果。在所谓的A/B 测试的典型情况下,将系统地比较两个版本的网页。A/B 测试通过向预定数量的用户展示网页版本 A 和版本 B 来进行。之后,计算统计显著性或置信区间,以便量化点击率的差异,目的是决定保留哪种网页变体。
这里,我们将从强化学习的角度来看待网站优化,即对每个访问者(或加载页面的用户),根据加载网站时可用数据选择最佳版本。在每次反馈(点击或未点击)后,我们会更新统计数据。与 A/B 测试相比,这种过程可能会产生更可靠的结果,并且随着时间推移,我们还会更频繁地展示最佳的网页变体。请注意,我们不限于两种变体,而是可以比较许多变体。
这个网站优化的使用案例将帮助我们介绍代理和环境的概念,并展示探索与利用之间的权衡。我们将在*工作原理…*部分解释这些概念。
怎么做…
为了实施我们的方案,我们需要两个组件:
-
我们的代理决定向用户展示哪个网页。
-
环境是一个测试平台,将给我们的代理提供反馈(点击或不点击)。
由于我们仅使用标准的 Python,无需安装任何东西,我们可以直接开始实施我们的方案:
- 首先我们将实现我们的环境。我们将考虑这作为一个多臂老虎机问题,在*工作原理…*部分中会有详细解释。因此,我们将称我们的环境为
Bandit
:
import random
import numpy as np
class Bandit:
def __init__(self, K=2, probs=None):
self.K = K
if probs is None:
self.probs = [
random.random() for _ in range(self.K)
]
else:
assert len(probs) == K
self.probs = probs
self.probs = list(np.array(probs) / np.sum(probs))
self.best_probs = max(self.probs)
def play(self, i):
if random.random() < self.probs[i]:
return 1
else:
return 0
这个老虎机初始化时有可用选择的数量K
。这将为每个选择设置一个点击的概率。在实践中,环境将是真实用户的反馈;在这里,我们模拟用户行为。play()
方法会玩第i
台机器,并返回1
或0
的奖励。
- 现在我们需要与这个环境进行交互。这就是我们的代理要发挥作用的地方。代理需要做出决策,我们将为它提供一个决策策略。我们也会包括指标的收集。一个抽象的代理看起来是这样的:
class Agent:
def __init__(self, env):
self.env = env
self.listeners = {}
self.metrics = {}
self.reset()
def reset(self):
for k in self.metrics:
self.metrics[k] = []
def add_listener(self, name, fun):
self.listeners[name] = fun
self.metrics[name] = []
def run_metrics(self, i):
for key, fun in self.listeners.items():
fun(self, i, key)
def run_one_step(self):
raise NotImplementedError
def run(self, n_steps):
raise NotImplementedError
任何代理都需要一个环境来进行交互。它需要做出单一的决策(run_one_step(self)
),为了看到其决策的好坏,我们需要运行一个模拟(run(self, n_steps)
)。
代理将包含一个指标函数的查找列表,并且还会继承一个指标收集功能。我们可以通过run_metrics(self, i)
函数来运行指标收集。
我们在这里使用的策略称为UCB1
。我们将在*如何做…*部分解释这个策略:
class UCB1(Agent):
def __init__(self, env, alpha=2.):
self.alpha = alpha
super(UCB1, self).__init__(env)
def run_exploration(self):
for i in range(self.env.K):
self.estimates[i] = self.env.play(i)
self.counts[i] += 1
self.history.append(i)
self.run_metrics(i)
self.t += 1
def update_estimate(self, i, r):
self.estimates[i] += (r - self.estimates[i]) / (self.counts[i] + 1)
def reset(self):
self.history = []
self.t = 0
self.counts = [0] * self.env.K
self.estimates = [None] * self.env.K
super(UCB1, self).reset()
def run(self, n_steps):
assert self.env is not None
self.reset()
if self.estimates[0] is None:
self.run_exploration()
for _ in range(n_steps):
i = self.run_one_step()
self.counts[i] += 1
self.history.append(i)
self.run_metrics(i)
def upper_bound(self, i):
return np.sqrt(
self.alpha * np.log(self.t) / (1 + self.counts[i])
)
def run_one_step(self):
i = max(
range(self.env.K),
key=lambda i: self.estimates[i] + self.upper_bound(i)
)
r = self.env.play(i)
self.update_estimate(i, r)
self.t += 1
return i
我们的UCB1
代理需要一个环境(即老虎机)进行交互,并且还需要一个单一的参数 alpha,用于权衡探索动作的重要性(与利用已知最佳动作的程度)。代理会随着时间维护其选择的历史记录,以及每个可能选择的估计记录。
我们应该看一下run_one_step(self)
方法,它通过选择最佳的乐观选择来做出单一选择。run(self, n_step)
方法运行一系列选择,并从环境中获取反馈。
让我们跟踪两个指标:遗憾值,即由于次优选择而导致的预期损失之和,以及作为代理估计值与环境实际配置之间收敛性的衡量标准——斯皮尔曼等级相关系数(stats.spearmanr()
)。
斯皮尔曼等级相关系数等于排名变量的皮尔逊相关系数(通常简称为相关系数或乘积矩法相关系数)。
两个变量和
之间的皮尔逊相关性可以表示如下:
其中是
和
的协方差,而
是
的标准差。
斯皮尔曼相关性不是基于原始分数,而是基于排名分数计算。排名转换意味着变量按值排序,并为每个条目分配其顺序。给定在X中第i个点的排名,斯皮尔曼秩相关性计算如下:
这评估了两个变量之间关系能否被描述为单调函数的好坏,但不一定是线性的(如皮尔逊相关性的情况)。与皮尔逊相关性类似,斯皮尔曼相关性在完全负相关时为,在完全相关时为
。
表示没有相关性。
我们的跟踪函数是update_regret()
和update_rank_corr()
:
from scipy import stats
def update_regret(agent, i, key):
regret = agent.env.best_probs - agent.env.probs[i]
if agent.metrics[key]:
agent.metrics[key].append(
agent.metrics[key][-1] + regret
)
else:
agent.metrics[key] = [regret]
def update_rank_corr(agent, i, key):
if agent.t < agent.env.K:
agent.metrics[key].append(0.0)
else:
agent.metrics[key].append(
stats.spearmanr(agent.env.probs, agent.estimates)[0]
)
现在,我们可以跟踪这些指标,以便比较alpha
参数(更多或更少的探索)的影响。随后,我们可以观察随时间的收敛和累积遗憾:
random.seed(42.0)
bandit = Bandit(20)
agent = UCB1(bandit, alpha=2.0)
agent.add_listener('regret', update_regret)
agent.add_listener('corr', update_rank_corr)
agent.run(5000)
因此,我们有 20 个不同的网页选择,并收集定义的regret
和corr
,并进行5000
次迭代。如果我们绘制这个,我们可以了解这个代理的表现如何:
对于第二次运行,我们将 alpha 更改为0.5
,因此我们将进行较少的探索:
我们可以看到,alpha=0.5时的累积遗憾远低于alpha=2.0时;然而,估计值与环境参数的总体相关性较低。
因此,较少的探索使得我们的代理模型对环境的真实参数了解程度较差。这是因为较少的探索使得较低排名特征的排序没有收敛。尽管它们被排名为次优,但它们还没有被选择足够多次来确定它们是最差还是次差,例如。这就是我们在较少探索时看到的情况,这也可能是可以接受的,因为我们可能只关心知道哪种选择是最佳的。
工作原理如下…
在这个示例中,我们处理了网站优化问题。我们模拟用户对不同版本网页的选择,同时实时更新每个变体的统计数据,以及应该显示的频率。此外,我们比较了探索性场景和更加利用性场景的优缺点。
我们将用户对网页的响应框架化为多臂赌博问题。多臂赌博(MABP)是一种投币并拉动多个杠杆之一的老虎机,每个杠杆与不同的奖励分布相关联,而这对投资者来说是未知的。更普遍地说,多臂赌博问题(也称为K 臂赌博问题)是在资源在竞争选择之间分配的情况下,每个选择的结果仅部分已知,但随着时间的推移可能会更好地了解。当在做出决策时考虑世界的观察结果时,这被称为上下文赌博。
我们使用了置信上界版本 1(UCB1)算法(Auer 等人,有限时间分析多臂赌博问题,2002 年),这个算法易于实现。
运行方式如下:
-
为了获取平均奖励的初始估计值(探索阶段),每个动作都要执行一次。
-
对于每一轮 t 更新 Q(a) 和 N(a),并根据以下公式执行动作 a’:
其中 是平均奖励的查找表,
是动作
被执行的次数,
是参数。
UCB 算法遵循在不确定性面前保持乐观的原则,通过选择在其置信区间上 UCB 最高的臂而不是估计奖励最高的臂来执行动作。它使用简单的均值估计器来估算动作奖励。
前述方程式中的第二项量化了不确定性。不确定性越低,我们越依赖 Q(a)。不确定性随着动作播放次数的增加而线性减少,并随着轮数的对数增加而对数增加。
多臂赌博在许多领域中非常有用,包括在线广告、临床试验、网络路由或在生产中两个或多个版本的机器学习模型之间的切换。
有许多变体的赌博算法来处理更复杂的场景,例如,选择之间切换的成本,或者具有有限生命周期的选择,例如秘书问题。秘书问题的基本设置是你想从一个有限的申请者池中雇佣一名秘书。每位申请者按随机顺序进行面试,面试后立即做出明确的决定(是否雇佣)。秘书问题也被称为婚姻问题。
另见
Ax 库在 Python 中实现了许多赌徒算法:ax.dev/
。
Facebook 的 PlanOut 是一个用于大规模在线实验的库:facebook.github.io/planout/index.html
。
作为阅读材料,我们建议这些书籍:
-
Russo 等人,2007 年,《关于汤普森采样的教程》(
arxiv.org/pdf/1707.02038.pdf
) -
Szepesvari 和 Lattimore,《赌徒算法》,2020 年(在线版本可用:(
tor-lattimore.com/downloads/book/book.pdf
)
控制一个倒立摆
倒立摆是 OpenAI Gym 中的一个控制任务,已经研究了多年。虽然与其他任务相比相对简单,但它包含了我们实施强化学习算法所需的一切,我们在这里开发的一切也可以应用于其他更复杂的学习任务。它还可以作为在模拟环境中进行机器人操作的示例。选择一个不那么苛刻的任务的好处在于训练和反馈更快。
OpenAI Gym是一个开源库,可以通过为代理与之交互的广泛环境标准化,帮助开发强化学习算法。OpenAI Gym 提供了数百个环境和集成,从机器人控制和三维行走到电脑游戏和自动驾驶汽车:gym.openai.com/
。
在 OpenAI Gym 环境的以下截图中展示了倒立摆任务,通过将购物车向左或向右移动来平衡一个立杆:
在这个示例中,我们将使用 PyTorch 实现 REINFORCE 策略梯度方法来解决倒立摆任务。让我们开始吧。
准备工作
有许多库提供了测试问题和环境的集合。其中一个集成最多的库是 OpenAI Gym,我们将在这个示例中使用它:
pip install gym
现在我们可以在我们的示例中使用 OpenAI Gym 了。
实施方法…
OpenAI Gym 为我们节省了工作——我们不必自己定义环境,确定奖励信号,编码环境或说明允许哪些动作。
首先,我们将加载环境,定义一个深度学习策略用于动作选择,定义一个使用此策略来选择执行动作的代理,最后我们将测试代理在我们的任务中的表现:
-
首先,我们将加载环境。每次杆子不倒下时,我们都会得到一个奖励。我们有两个可用的移动方式,向左或向右,并且观察空间包括购物车位置和速度的表示以及杆角度和速度,如下表所示:
编号 观察值 最小值 最大值 0 购物车位置 -2.4 2.4 1 购物车速度 -Inf -Inf 2 杆角度 ~ -41.8° ~ 41.8° 3 Pole Velocity At Tip -Inf -Inf
您可以在这里了解更多关于此环境的信息:gym.openai.com/envs/CartPole-v1/
。
我们可以加载环境并打印这些参数如下:
import gym
env = gym.make('CartPole-v1')
print('observation space: {}'.format(
env.observation_space
))
print('actions: {}'.format(
env.action_space.n
))
#observation space: Box(4,)
#actions: 2
因此,我们确认我们有四个输入和两个动作,我们的代理将类似于前面的示例优化网站定义,只是这次我们会在代理外部定义我们的神经网络。
代理将创建一个策略网络,并使用它来做出决策,直到达到结束状态;然后将累积奖励馈送到网络中进行学习。让我们从策略网络开始。
- 让我们创建一个策略网络。我们将采用一个全连接的前馈神经网络,根据观察空间预测动作。这部分基于 PyTorch 实现,可以在
github.com/pytorch/examples/blob/master/reinforcement_learning/reinforce.py
找到:
import torch as T
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
class PolicyNetwork(nn.Module):
def __init__(
self, lr, n_inputs,
n_hidden, n_actions
):
super(PolicyNetwork, self).__init__()
self.lr = lr
self.fc1 = nn.Linear(n_inputs, n_hidden)
self.fc2 = nn.Linear(n_hidden, n_actions)
self.optimizer = optim.Adam(self.parameters(), lr=self.lr)
self.device = T.device(
'cuda:0'
if T.cuda.is_available()
else 'cpu:0'
)
self.to(self.device)
def forward(self, observation):
x = T.Tensor(observation.reshape(-1).astype('float32'),
).to(self.device)
x = F.relu(self.fc1(x))
x = F.softmax(self.fc2(x), dim=0)
return x
这是一个神经网络模块,用于学习策略,换句话说,从观察到动作的映射。它建立了一个具有一层隐藏层和一层输出层的两层神经网络,其中输出层中的每个神经元对应于一个可能的动作。我们设置了以下参数:
-
lr
: 学习率 -
n_inputs
: 输入数量 -
n_hidden
: 隐藏神经元数量 -
n_actions
: 输入维度
- 现在我们可以定义我们的代理人了:
class Agent:
eps = np.finfo(
np.float32
).eps.item()
def __init__(self, env, lr, params, gamma=0.99):
self.env = env
self.gamma = gamma
self.actions = []
self.rewards = []
self.policy = PolicyNetwork(
lr=lr,
**params
)
def choose_action(self, observation):
output = self.policy.forward(observation)
action_probs = T.distributions.Categorical(
output
)
action = action_probs.sample()
log_probs = action_probs.log_prob(action)
action = action.item()
self.actions.append(log_probs)
return action, log_probs
代理人评估策略以执行动作并获得奖励。 gamma
是折扣因子。
使用 choose_action(self, observation)
方法,我们的代理根据观察选择动作。动作是根据我们网络的分类分布进行抽样。
我们省略了run()
方法,其内容如下:
def run(self):
state = self.env.reset()
probs = []
rewards = []
done = False
observation = self.env.reset()
t = 0
while not done:
action, prob = self.choose_action(observation.reshape(-1))
probs.append(prob)
observation, reward, done, _ = self.env.step(action)
rewards.append(reward)
t += 1
policy_loss = []
returns = []
R = 0
for r in rewards[::-1]:
R = r + self.gamma * R
returns.insert(0, R)
returns = T.tensor(returns)
returns = (returns - returns.mean()) / (returns.std() + self.eps)
for log_prob, R in zip(probs, returns):
policy_loss.append(-log_prob * R)
if(len(policy_loss)) > 0:
self.policy.optimizer.zero_grad()
policy_loss = T.stack(policy_loss, 0).sum()
policy_loss.backward()
self.policy.optimizer.step()
return t
run(self)
方法类似于之前的示例,优化网站,在环境中运行完整的模拟直到结束。这是直到杆几乎倒下或达到 500 步(即 env._max_episode_steps
的默认值)为止。
- 接下来,我们将测试我们的代理人。我们将在环境中运行我们的代理人,通过模拟与环境的交互来开始。为了获得我们学习率的更干净的曲线,我们将
env._max_episode_steps
设置为10000
。这意味着模拟在 10000 步后停止。如果我们保持默认值500
,算法将在达到约 500 步后停滞或性能达到某个水平。相反,我们试图做更多的优化:
env._max_episode_steps = 10000
input_dims = env.observation_space.low.reshape(-1).shape[0]
n_actions = env.action_space.n
agent = Agent(
env=env,
lr=0.01,
params=dict(
n_inputs=input_dims,
n_hidden=10,
n_actions=n_actions
),
gamma=0.99,
)
update_interval = 100
scores = []
score = 0
n_episodes = 25000
stop_criterion = 1000
for i in range(n_episodes):
mean_score = np.mean(scores[-update_interval:])
if (i>0) and (i % update_interval) == 0:
print('Iteration {}, average score: {:.3f}'.format(
i, mean_score
))
T.save(agent.policy.state_dict(), filename)
score = agent.run()
scores.append(score)
if score >= stop_criterion:
print('Stopping. Iteration {}, average score: {:.3f}'.format(
i, mean_score
))
break
我们应该看到以下输出:
Iteration 100, average score: 31.060
Iteration 200, average score: 132.340
Iteration 300, average score: 236.550
Stopping. Iteration 301, average score: 238.350
在进行模拟时,我们每 100 次迭代看到一次更新的平均分数。一旦达到 1000 分,我们就会停止。这是我们的分数随时间的变化情况:
我们可以看到我们的策略正在持续改进——网络正在成功学习如何操作杆车。请注意,您的结果可能会有所不同。网络可能学习得更快或更慢。
在下一节中,我们将深入了解这个算法的实际工作原理。
它是如何工作的…
在这个案例中,我们看了一个杆车控制场景中的基于策略的算法。让我们更详细地看看其中的一些内容。
策略梯度方法通过给定的梯度上升找到一个策略,以最大化相对于策略参数的累积奖励。我们实现了一种无模型的基于策略的方法,即 REINFORCE 算法(R. Williams,《简单的统计梯度跟随算法用于连接主义强化学习》,1992 年)。
在基于策略的方法中,我们有一个策略函数。策略函数定义在一个环境 和一个动作
上,并返回给定环境下执行动作的概率。在离散选择的情况下,我们可以使用 softmax 函数:
这就是我们在策略网络中所做的,这有助于我们做出我们的动作选择。
值函数 (有时用
表示)返回给定环境中任何动作的奖励。策略的更新定义如下:
其中 是学习率。
在参数初始化之后,REINFORCE 算法通过每次执行动作时应用此更新函数来进行。
您应该能够在任何 Gym 环境上运行我们的实现,几乎不需要更改。我们故意做了一些事情(例如,将观测数据重塑为向量),以便更容易重用它;但是,请确保您的网络架构与观测数据的性质相对应。例如,如果您的观测数据是时间序列(例如股票交易或声音),您可能希望使用 1D 卷积网络或递归神经网络,或者如果您的观测数据是图像,则可以使用 2D 卷积。
还有更多内容…
还有一些其他的事情可以让我们玩得更开心。首先,我们想看到代理与杆互动,其次,我们可以使用库来避免从零开始实施代理。
观察我们在环境中的代理
我们可以玩数百场游戏或尝试不同的控制任务。如果我们真的想在 Jupyter 笔记本中观看我们的代理与环境的互动,我们可以做到:
from IPython import display
import matplotlib.pyplot as plt
%matplotlib inline
observation = env.reset()
img = plt.imshow(env.render(mode='rgb_array'))
for _ in range(100):
img.set_data(env.render(mode='rgb_array'))
display.display(plt.gcf())
display.clear_output(wait=True)
action, prob = agent.choose_action(observation)
observation, _, done, _ = agent.env.step(action)
if done:
break
现在我们应该看到我们的代理与环境互动了。
如果您正在远程连接(例如在 Google Colab 上运行),您可能需要做一些额外的工作:
!sudo apt-get install -y xvfb ffmpeg
!pip install 'gym==0.10.11'
!pip install 'imageio==2.4.0'
!pip install PILLOW
!pip install 'pyglet==1.3.2'
!pip install pyvirtualdisplay
display = pyvirtualdisplay.Display(
visible=0, size=(1400, 900)
).start()
在接下来的部分,我们将使用一个实现在库中的强化学习算法,RLlib
。
使用 RLlib 库
我们可以使用 Python 库和包中的实现,而不是从头开始实现算法。例如,我们可以训练 PPO 算法(Schulman 等人,《Proximal Policy Optimization Algorithms》,2017),该算法包含在RLlib
包中。 RLlib 是我们在《Python 人工智能入门》第一章中遇到的Ray
库的一部分。 PPO 是一种政策梯度方法,引入了一个替代目标函数,可以通过梯度下降进行优化:
import ray
from ray import tune
from ray.rllib.agents.ppo import PPOTrainer
ray.init()
trainer = PPOTrainer
analysis = tune.run(
trainer,
stop={'episode_reward_mean': 100},
config={'env': 'CartPole-v0'},
checkpoint_freq=1,
)
这将开始训练。 您的代理将存储在本地目录中,以便稍后加载它们。 RLlib 允许您使用'torch': True
选项来使用 PyTorch 和 TensorFlow。
另见
一些强化学习库提供了许多深度强化学习算法的实现:
-
OpenAI 的 Baselines(需要 TensorFlow 版本 < 2):
github.com/openai/baselines
-
RLlib(
Ray
的一部分)是一个可扩展(和分布式)强化学习库:docs.ray.io/en/master/rllib-algorithms.html
。 -
Machin 是基于 PyTorch 的强化学习库:
github.com/iffiX/machin
。 -
TF-Agents 提供了许多工具和算法(适用于 TensorFlow 版本 2.0 及更高版本):
github.com/tensorflow/agents
。
请注意,安装这些库可能需要一段时间,并可能占用几个 GB 的硬盘空间。
最后,OpenAI 提供了一个与强化学习相关的教育资源库:spinningup.openai.com/
。
玩 21 点游戏
强化学习中的一个基准是游戏。 研究人员或爱好者设计了许多与游戏相关的不同环境。 有些游戏的里程碑已经在《Python 人工智能入门》第一章中提到。 对许多人来说,游戏的亮点肯定是在国际象棋和围棋两方面击败人类冠军——1997 年国际象棋冠军加里·卡斯帕罗夫和 2016 年围棋冠军李世石——并在 2015 年达到超人类水平的 Atari 游戏中表现出色。
在这个示例中,我们开始使用最简单的游戏环境之一:21 点游戏。 21 点游戏与现实世界有一个有趣的共同点:不确定性。
Blackjack 是一种纸牌游戏,在其最简单的形式中,您将与一名纸牌荷官对战。您面前有一副牌,您可以"hit",意味着您会得到一张额外的牌,或者"stick",这时荷官会继续抽牌。为了赢得比赛,您希望尽可能接近 21 分,但不能超过 21 分。
在这个教程中,我们将使用 Keras 实现一个模型来评估给定环境配置下不同动作的价值函数。我们将实现的变体称为 DQN,它在 2015 年的 Atari 里程碑成就中被使用。让我们开始吧。
准备工作
如果您尚未安装依赖项,则需要进行安装。
我们将使用 OpenAI Gym,并且我们需要安装它:
pip install gym
我们将使用 Gym 环境来进行 21 点游戏。
如何做…
我们需要一个代理人来维护其行为影响的模型。这些行动是从其内存中回放以进行学习的。我们将从一个记录过去经验以供学习的内存开始:
- 让我们实现这个记忆。这个记忆本质上是一个 FIFO 队列。在 Python 中,您可以使用一个 deque;然而,我们发现 PyTorch 示例中的回放内存实现非常优雅,因此这是基于 Adam Paszke 的 PyTorch 设计:
#this is based on https://pytorch.org/tutorials/intermediate/reinforcement_q_learning.html
from collections import namedtuple
Transition = namedtuple(
'Transition',
('state', 'action', 'next_state', 'reward')
)
class ReplayMemory:
def __init__(self, capacity=2000):
self.capacity = capacity
self.memory = []
self.position = 0
def push(self, *args):
if len(self.memory) < self.capacity:
self.memory.append(None)
self.memory[self.position] = Transition(*args)
self.position = (self.position + 1) % self.capacity
def sample(self, batch_size):
batch = random.sample(self.memory, batch_size)
batch = Transition(
*(np.array(el).reshape(batch_size, -1) for el in zip(*batch))
)
return batch
def __len__(self):
return len(self.memory)
我们实际上只需要两个方法:
-
如果我们的容量已满,我们需要推入新的记忆,并覆盖旧的记忆。
-
我们需要为学习抽样记忆。
最后一点值得强调:我们不是使用所有的记忆来进行学习,而是只取其中的一部分。
在sample()
方法中,我们做了一些修改以使我们的数据符合正确的形状。
- 让我们看看我们的代理:
import random
import numpy as np
import numpy.matlib
import tensorflow as tf
from tensorflow.keras import layers
from tensorflow.keras import optimizers
from tensorflow.keras import initializers
class DQNAgent():
def __init__(self, env, epsilon=1.0, lr=0.5, batch_size=128):
self.env = env
self.action_size = self.env.action_space.n
self.state_size = env.observation_space
self.memory = ReplayMemory()
self.epsilon = epsilon
self.lr = lr
self.batch_size = batch_size
self.model = self._build_model()
def encode(self, state, action=None):
if action is None:
action = np.reshape(
list(range(self.action_size)),
(self.action_size, 1)
)
return np.hstack([
np.matlib.repmat(state, self.action_size, 1),
action
])
return np.hstack([state, action])
def play(self, state):
state = np.reshape(state, (1, 3)).astype(float)
if np.random.rand() <= self.epsilon:
action = np.random.randint(0, self.action_size)
else:
action_value = self.model.predict(self.encode(state)).squeeze()
action = np.argmax(action_value)
next_state1, reward, done, _ = self.env.step(action)
next_state = np.reshape(next_state1, (1, 3)).astype(float)
if done:
self.memory.push(state, action, next_state, reward)
return next_state1, reward, done
def learn(self):
if len(self.memory) < self.batch_size:
return
batch = self.memory.sample(
self.batch_size
)
result = self.model.fit(
self.encode(batch.state, batch.action),
batch.reward,
epochs=1,
verbose=0
)
请注意在play()
方法开始时的动作选择。我们掷一个骰子来确定我们是要随机选择一个动作,还是要遵循我们模型的判断。这被称为ε-贪心动作选择,它可以促进更多的探索并更好地适应环境。
该代理人带有一些用于配置的超参数:
-
lr
: 网络的学习率。 -
batch_size
: 从内存和网络训练中进行抽样的批大小。 -
epsilon
: 此因子位于0
到1
之间,控制我们希望响应中的随机性程度。1
表示完全随机的探索,0
表示没有探索(完全利用)。
我们发现这三个参数可以显著改变我们学习的轨迹。
我们从列表中省略了一个方法,该方法定义了神经网络模型:
def _build_model(self):
model = tf.keras.Sequential([
layers.Dense(
100,
input_shape=(4,),
kernel_initializer=initializers.RandomNormal(stddev=5.0),
bias_initializer=initializers.Ones(),
activation='relu',
name='state'
),
layers.Dense(
2,
activation='relu'
),
layers.Dense(1, name='action', activation='tanh'),
])
model.summary()
model.compile(
loss='hinge',
optimizer=optimizers.RMSprop(lr=self.lr)
)
return model
这是一个三层神经网络,有两个隐藏层,一个有 100 个神经元,另一个有 2 个神经元,采用 ReLU 激活函数,并带有一个输出层,有 1 个神经元。
- 让我们加载环境并初始化我们的代理。我们初始化我们的代理和环境如下:
import gym
env = gym.make('Blackjack-v0')
agent = DQNAgent(
env=env, epsilon=0.01, lr=0.1, batch_size=100
)
这加载了Blackjack OpenAI Gym 环境和我们在本教程的第 2 步中实现的DQNAgent。
epsilon
参数定义了代理的随机行为。我们不希望将其设置得太低。学习率是我们在实验后选择的值。由于我们在进行随机卡牌游戏,如果设置得太高,算法将会非常快速地遗忘。批处理大小和记忆大小参数分别决定了每一步的训练量以及关于奖励历史的记忆。
我们可以看到这个网络的结构(由 Keras 的summary()
方法显示)。
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
state (Dense) (None, 100) 500
_________________________________________________________________
dense_4 (Dense) (None, 2) 202
_________________________________________________________________
action (Dense) (None, 1) 3
=================================================================
Total params: 705
Trainable params: 705
Non-trainable params: 0
对于模拟来说,我们的关键问题之一是epsilon
参数的值。如果设置得太低,我们的代理将无法学到任何东西;如果设置得太高,我们将会因为代理做出随机动作而亏钱。
- 现在让我们来玩 21 点吧。我们选择以线性方式稳定减少
epsilon
,然后在一定数量的回合内进行利用。当epsilon
达到0
时,我们停止学习:
num_rounds = 5000
exploit_runs = num_rounds // 5
best_100 = -1.0
payouts = []
epsilons = np.hstack([
np.linspace(0.5, 0.01, num=num_rounds - exploit_runs),
np.zeros(exploit_runs)
])
这是实际开始玩 21 点的代码:
from tqdm.notebook import trange
for sample in trange(num_rounds):
epsilon = epsilons[sample]
agent.epsilon = epsilon
total_payout = 0
state = agent.env.reset()
for _ in range(10):
state, payout, done = agent.play(state)
total_payout += payout
if done:
break
if epsilon > 0:
agent.learn()
mean_100 = np.mean(payouts[-100:])
if mean_100 > best_100:
best_100 = mean_100
payouts.append(total_payout)
if (sample % 100) == 0 and sample >= 100:
print('average payout: {:.3f}'.format(
mean_100
))
print(agent.losses[-1])
print('best 100 average: {:.3f}'.format(best_100))
在模拟过程中,我们收集了网络训练损失的统计数据,并且收集了连续 100 次游戏中的最大津贴。
在 OpenAI Gym 中,奖励或者如果我们想保持 21 点的术语,就是津贴,可以是-1(我们输了),0(什么也没有),或者 1(我们赢了)。我们使用学习的策略随时间的津贴如下所示:
由于巨大的可变性,我们并未展示原始数据,而是绘制了移动平均线,分别为 100 和 1,000,结果呈现两条线:一条高度变化,另一条平滑,如图所示。
随着时间推移,我们确实看到了津贴的增加;然而,平均来看我们仍然亏损。即使在停止学习的开发阶段也会发生这种情况。
我们的 21 点环境没有奖励阈值,认为达到此阈值即解决问题;但是,一篇报道列出了 100 个最佳剧集,平均为 1.0,这也是我们达到的:gym.openai.com/evaluations/eval_21dT2zxJTbKa1TJg9NB8eg/
。
它是如何工作的…
在这个案例中,我们看到了强化学习中更高级的算法,更具体地说是一种基于价值的算法。在基于价值的强化学习中,算法构建了价值函数的估计器,进而让我们选择策略。
代理需要一些额外的评论。如果你读过之前的配方,控制倒立摆,你可能会觉得没有太多的事情发生——有一个网络,一个play()
方法来决定动作,和一个learn()
方法。代码相对较少。一个基本的阈值策略(我的牌加起来是 17 吗?)已经相当成功,但希望本配方展示的内容对于更复杂的用例仍然有教育意义和帮助。与我们之前见过的策略网络不同,这次,网络不是直接建议最佳动作,而是将环境和动作的组合作为输入,并输出预期奖励。我们的模型是一个两层的前馈模型,隐藏层有两个神经元,在由一个单个神经元组成的最终层中求和。代理以ε贪婪的方式进行游戏——它以概率epsilon
随机移动;否则,根据其知识选择最佳移动。play
函数通过比较所有可用动作的预期结果来建议具有最高效用的动作。
Q 值函数 定义如下:
其中 是时间
时的奖励,状态和动作。
是折扣因子;策略
选择动作。
在最简单的情况下, 可以是一个查找表,每个状态-动作对都有一个条目。
最优 Q 值函数定义如下:
因此,可以根据以下公式确定最佳策略:
在神经拟合 Q 学习(NFQ)(Riedmiller,《神经拟合 Q 迭代——数据高效的神经强化学习方法的第一次经验》,2005 年),神经网络对给定状态进行前向传播,输出对应的可用动作。神经 Q 值函数可以通过梯度下降根据平方误差进行更新:
其中 指的是迭代
的参数,而
指的是下一个时间步骤的动作和状态。
DQN(Mnih 等人,《使用深度强化学习玩 Atari 游戏》,2015 年)基于 NFQ 进行了一些改进。这些改进包括仅在几次迭代中的小批量更新参数,基于来自重播记忆的随机样本。由于在原始论文中,该算法从屏幕像素值学习,网络的第一层是卷积层(我们将在第七章,高级图像应用中介绍)。
参见
这是 Sutton 和 Barto 的开创性著作《强化学习导论》的网站:incompleteideas.net/book/the-book-2nd.html
。
他们在那里描述了一个简单的 21 点游戏代理。如果您正在寻找其他卡牌游戏,可以查看 neuron-poker,这是一个 OpenAI 扑克环境;他们实现了 DQN 和其他算法:github.com/dickreuter/neuron_poker
。
关于 DQN 及其使用的更多细节,我们建议阅读 Mnih 等人的文章,《使用深度强化学习玩 Atari 游戏》:arxiv.org/abs/1312.5602
。
最后,DQN 及其后继者,双 DQN 和对决 DQN,构成了 AlphaGo 的基础,该成果发表于《自然》杂志(Silver 等人,2017 年),题为《无需人类知识掌握围棋》:www.nature.com/articles/nature24270
。