有指导的迁移学习:如何利用“侦察的力量”提升机器学习表现
对训练神经网络的革命性新方法的独家预览
·发表于 Towards Data Science ·阅读时间 10 分钟·2023 年 3 月 27 日
–
在这一新提议的技术中,小型“侦察模型”被派遣去探索问题领域,并“反馈”给主要模型。图片由 @ansgarscheffold 在 Unsplash 上提供。
我的好朋友和谦逊的天才 Dr Danko Nikolić 最近与我分享了一篇未发表的论文,认为我可能会感兴趣。我确实感兴趣了。阅读它让我感觉像是在目睹一个历史时刻,并且我迫不及待地想要分享。幸运的是,Danko 同意了。所以这是我将一种可能革新深度神经网络训练的方法翻译成日常语言的版本。它甚至还没有发布到 arXiv 上(更新:现在已经发布了!),但 NASA 已经在使用它。所以一旦它爆火,记住:你首先在这里听到的。😉
从问题开始
我相信你知道:机器学习,尤其是深度神经网络,需要大量的数据、计算能力和模型参数。这使得这些技术只有最富有的公司和研究机构才能使用,因此将开发塑造我们技术未来的 AI 技术的权力集中在少数人手中。并不酷。
为什么会有这个问题
当我们为某个任务训练神经网络时,实际上是在通过成千上万个例子教它如何调整自身的权重和偏差,以便输入网络的信息产生另一种所需的信息输出。这些权重和偏差统称为“参数”,一个网络可以拥有数百万甚至数万亿个这样的参数。由于“参数空间”如此庞大,学习到正确的参数非常困难,因为数量实在太多。
由于无法尝试每种可能的参数值组合,我们尝试做出好的猜测。一个典型的机器学习算法会寻找有关每个参数如何变化的“提示”,然后根据这些提示进行调整,再根据调整的成功程度寻找新的提示。这些算法中最著名的是“梯度下降”。
如果你已经对梯度下降有所了解,可以跳过这一段。否则,你可以将其想象为这样工作:问题空间是一个有丘陵和山谷的地形,而我们是一个在这个地形上滚动的球,试图到达最低的山谷底部。我们称之为“全局最小值”。所以我们的算法会根据周围地面的陡峭程度、倾斜方向等信息来决定滚动的方向。希望它朝着最低的点前进。但也有危险:我们可能会滚入一个“并不是特别低”的沟壑中,并陷入困境。我们称之为“局部最小值”,这是一个不好的位置。
神经网络使用梯度下降来学习解决各种问题。不幸的是,一些研究表明,这些网络的性能提升有限,之后需要再次增加规模和数据,而这种趋势似乎遵循一种“幂律”。简单来说,这意味着“小幅度的‘智能’提升需要大量的资源增加”。反过来说,我们投入这些模型的资源回报正在递减。
理论上的希望……和更多的痛苦
理论上,更小的网络也可以完成任务,但必须通过人工构建或由专门为特定任务设计的算法学习。另一方面,梯度下降可以应用于大型模型,学习解决更广泛的问题,但在小模型中找到合适的参数会面临困难。
它之所以感到困难,是因为它无法看到在任何给定的训练周期中对参数的更改将如何影响后续的性能。我们,这个在问题空间中滚动的球,可能会被诱使向右滚动,因为那里地面坡度最陡。但这可能会直接带我们到一个令人害怕的局部最小值!也许继续直行会更好——即使从我们站的位置看起来不那么陡——因为最终会带我们到所有山谷中最深的那个。
梯度下降试图通过增加模型参数的数量来解决这个问题,因为更多的维度只是减少我们陷入困境的统计可能性。然而,更多的参数可能使模型“过拟合”(即记住当前问题的答案,但之后对其他问题的概括能力变得无用)。因此,我们最终不得不增加相应的训练数据,以帮助训练出的模型具有更好的泛化能力。但这种不断增加资源的需求是不可持续的。必须有更好的方法!
指导性迁移学习…来拯救!
Nikolić博士和他的同事 Davor Andrić及 Vjekoslav Nikolić(是的,他们是兄弟!)提出了一个我认为相当巧妙的解决方案。他们将其比作将侦察员送入问题领域,只不过在这里,“侦察员是解决这个领域中更小但相关问题的模型。他们深入探究并带回关于他们成功旅行方向的信息。” 侦察员不太可能陷入局部极小值,因为他们的问题更容易解决。而且由于他们解决的是较小的问题,我们可以提供更多数据给他们工作,这使得他们不容易过拟合。最终,他们将知识转移回主模型。可爱,对吧?
论文中对此的描述最为恰当,所以我将其转述:局部极小值就像山中的湖泊;降雨水仅凭局部视角向下流动,这可能导致其陷入困境。指导性迁移学习(GTL)就像沿着山坡侧行走,而不是直接向最近的山谷下滑。有时山谷是一个陷阱,而侦察员知道这一点。
那么它是如何工作的呢?
侦察员被“派遣”去解决比主模型更简单的问题。例如,如果整体任务是将输入分类为十个不同的类别中的一个,那么每个侦察员模型可能会被分配一个只包含这三个类别的数据子集进行分类。简单的问题减少了侦察员模型过拟合的机会,因此,它们代表主模型获得的“知识”更可靠。
降低过拟合风险的另一种策略是给侦察员更多的数据。这也具有类似于预训练的知识迁移好处,我将在下一部分中介绍。
这次侦察任务的结果是所谓的“指导矩阵”,它告诉主模型对于每个参数,这个参数的重要性。一个低值意味着侦察员在该维度(参数)上没有发现太多变化,因此改变它可能对整体解决方案贡献不大。例如,侦察员走了这条路但坡度保持相当平缓,所以他们放弃了。一个大值意味着更戏剧性和潜在有用的变化。例如,一个侦察员走了一条看起来很平坦的小径,然后几乎绊倒在悬崖边缘!很可能向那个方向移动受影响的参数是个好主意,对吧?
这种方法的好处在于,它在数学和实现代码方面都非常简单。基本上,梯度下降始终忙于计算每个参数变化的值,使用它对周围倾斜地形的所有信息。假设参数是一个网络权重 w。我们称这种变化的值为Δ_w(其中 _ 表示后续字母是下标),或称为‘delta w’。引导矩阵有一个相应的引导值,称为 g_w。因此,引导变化Δw_g,就是:
就这些,我将涵盖的数学就是这些(在论文第 5 页上还有一点点更多,第 7 页上作者以非常简单的方式讨论了计算引导矩阵的不同方法。但我给你的已经是理解这个思想所需的所有数学)。代码也同样简单。如果你不是程序员,放心,下面的代码非常简单易懂:
loss.backward()
for name, param in model.named_parameters():
param.grad *= guidance_mask[name]
optimizer.step()
但传统的预训练呢?
GPT-4 中的‘PT’代表‘预训练’,所以如果你还不知道,这可是大事。其思想是在某个庞大而通用的数据集和任务上首先训练神经网络,然后在更具体的数据集和任务上进行微调。例如,GPT 和其他大型语言模型通常会预训练以预测句子中缺失的单词(即那些被人类操控者随机移除的单词),使用从几乎整个互联网抓取的文本。这使得模型对不同上下文中最常出现的单词有一个相当好的了解(从统计学角度讲,当然不是认知上的)。之后,模型会在实际任务上进行微调,例如问答任务,使用更小且更具体的数据集。从原始任务中获得的一般‘知识’往往能提升下游任务的表现;我们称之为‘迁移学习’。
迁移学习通过预训练有助于应对许多,但不一定全部,可能存在的局部最小值。因此,引导迁移学习通过帮助探索新的问题领域以避免来补充预训练。这两种方法不一定需要结合,但当它们结合时,效果可能会非常显著。
听起来不错,但有效吗?
论文首先展示了指导转移学习在‘一次性学习’中的好处。这是将一个模型训练成执行一项任务,例如将图像分类为不同类别,然后向其展示一个新类别的单一示例,并期望它能够正确分类更多该类别的示例。作者发现,使用 GTL 作为预训练的补充可以一致地提高性能,但指导矩阵很快达到了‘帮助’的最大程度,此后添加更多的侦察者或提供更多的数据并没有帮助。好的一面是,这也意味着减少侦察者的数量或训练数据的规模对性能影响不大。
这意味着 GTL 是一种低成本、稍微有用的技术,特别是在数据有限的情况下(通常会使用一次性学习或少量学习)。而且目前还处于初期阶段:作者目前只尝试了使用单一指导矩阵,但建议其他实现可能会有用,比如根据侦察者从起点移动的距离来创建不同的矩阵。这在直观上是有意义的:主要模型已经有关于其周围环境的信息,我们希望侦察者帮助看到更远的地方,因此如果我们制作多个矩阵,并对那些距离起点更远的矩阵赋予更大的重要性,这可能会帮助算法‘选择’最佳的下一步行动。
经作者许可转载:A) 示例一次性学习任务:给出一个示例进行训练,然后必须找到其他相同字符的示例。B) 使用预训练和添加 GTL 的分类性能示例。
第二个实验涉及‘XOR 问题’,也叫‘排他性或’。目标是学习一个将两个输入(x1 和 x2)映射到一个输出(y)的函数,使得当 x1 和 x2 不同时,y = 1(或‘真’),否则 y = 0(或‘假’)。使用梯度下降的大型模型通常会在这一挑战中停滞不前,因此 Nikolić、Andrić和 Nikolić应用了未经过预训练的 GTL 来帮助避免这些局部最小值。这一方法非常有效,以至于没有观察到停滞现象。
最后的实验解决了“灾难性遗忘”的问题:即神经网络在新任务上训练时,新数据的影响使得所有已学习的权重大幅更新,从而“忘记”了之前学到的内容。Nikolić等人进行了一系列模型重训练:每一步额外的训练持续固定数量的纪元,并基于一个额外的数据点;每个数据点都是已经学习过的类别的新示例。也就是说,这些数据点是顺序学习的,而不是通常的批量学习。在一次私人谈话中,Danko 将其比作现实生活:存在一种称为“汽车”的物体类别,而今天你正在驾驶一种特定类型的汽车,比如 SUV。即使你现在接触到 SUV 的特性,也不意味着你会忘记以前接触过的所有其他汽车类型。你的新接触应该增加你对汽车的理解,而不是减少它。
尽管仅仅依靠经典的预训练转移学习无法从这些顺序添加的数据点中受益,但添加 GTL 却带来了逐步的性能提升,这表明知识得到了积累。换句话说,模型对遗忘之前的示例更具鲁棒性,尽管最终还是会发生。作者用常识解释了这一点:在特定的问题空间中,会有一些解决方案既适用于现在学习的示例,也适用于之前学习的示例;这些解决方案可能接近于模型开始在新示例上训练时的起始点;没有 GTL,模型可能会“偏离”;有了GTL,指导矩阵鼓励模型保持在那个有用的邻域。这就像一个侦察员提醒你不要离营地太远。
那这在实践中意味着什么?
指导转移学习是一种“学习如何学习”的方法,并不是唯一的方法。其他方法也存在,并且可能会产生更好的结果,但它们往往不可扩展,这可能会使它们失去意义。GTL 计算便宜且灵活,这可能使它特别适用于那些已经将所有可用资源榨取出来的模型。大型语言模型和计算机视觉模型(这些模型经常在严格的硬件限制下存在,如嵌入在自动驾驶汽车中)是几个好的例子。
另一方面,如果数据和计算能力非常充足,那么作者承认 GTL 可能就不再需要了。正如他们所说,它不是万能的,但没有任何单一的机器学习解决方案是万能的。正如实验所示,它对那些即使是资源充足的模型也难以解决的难题仍然充满了希望。
作者计划尝试所有可能的指导矩阵范式,我对此感到乐观。所以,请关注这个领域。
指导大型语言模型进行任务特定推理 — 提示设计与软提示
了解提示设计和软提示如何用于开发和部署 SOTA 模型。
·
关注 发表于 Towards Data Science ·8 分钟阅读·2023 年 2 月 27 日
–
图片来源:camilo jimenez 在 Unsplash
提示(Prompting)是为训练好的模型提供额外信息的过程,以在预测任务的输出标签时进行条件设置。这是通过提示来完成的,提示是输入到模型中的几行指令,用于执行某项任务,无论是否提供了几个示例。
最近,随着大型 Transformer 模型如GPT-2和GPT-3的成功,提示得到了大量关注。通过 GPT-2,OpenAI 证明了扩大模型规模可以相对提高其任务特定性能,而不需要更新模型在下游任务中的权重。通过 GPT-3,他们展示了描述所需任务的提示加上零到少量的结果样本,足以使这些大型模型成功执行任务。这催生了提示工程(Prompt Engineering),其目标是精心策划这些输入提示,以从模型中提取最佳结果。在此基础上,谷歌的研究人员最近推出了提示调优(Prompt Tuning),其思想是学习软提示(Soft Prompts),这些软提示可以指导大型语言模型(LLMs)执行各种任务。这些提示是经过训练的权重,一旦调优后,可以与输入一起提供给冻结的语言模型,以获得所需的结果。
背景
越来越多的趋势倾向于预训练语言模型,以创建任务无关的语言表示,这些表示可以通过特定任务的数据集和架构灵活地适应以执行特定任务。一个相关的例子是谷歌的BERT,它具有多层双向 Transformer 编码器,并在未标注的数据集上使用掩码语言模型(Masked LM)和下一句预测(NSP)进行预训练。然后,模型必须在下游任务中使用标注数据进行微调,以更新其参数。BERT 的一个显著特点是通过在预训练和微调过程中使用统一架构,能够避免需要任务特定架构的问题:
预训练的 BERT 模型可以通过仅添加一个额外的输出层来微调,以创建用于广泛任务(如问答和语言推理)的最先进模型,而无需对任务特定架构进行重大修改。— BERT: 语言理解的深度双向 Transformer 预训练
这显而易见的缺点是需要任务特定的数据集和精细调优方法,这限制了模型的适用性。在面临需要利用训练好的语言模型进行特定任务时,研究人员/开发人员通常会陷入获取大量特定任务标注数据的繁重任务,这需要为每个新任务重复进行。此外,研究表明,大型精细调优模型可能对训练分布过于特定,难以很好地泛化到分布外,导致领域转移的问题。这主要归因于在预训练和精细调优过程中训练的数据量的差异。
1. 使用纯文本进行编程 — 提示设计
多任务学习一直是克服这些限制和提高模型总体性能的一个有前途的框架。实现这一目标的一个最突出的方式是元学习,其中模型在训练过程中被教会执行多个任务,以便它能够发展广泛的技能和模式识别能力,这些能力在推断时可以被利用来生成期望的输出。随着变换器的引入,GPT2 展示了通过学习* p(output | input, task)* 的条件概率,可以实现这一点,使其成为多任务学习者*。* 尽管沿着这些方向已有一些类似的研究,如 MAML(模型无关元学习)和 MQAN(多任务问答网络),变换器的可扩展性和大量数据的可用性使研究人员能够开发出任务无关的模型,这些模型可以与现有的精细调优模型相媲美。此外,MQAN 展示了常见 NLP 任务的特性提供了将它们转化为带有执行每个任务说明的问答任务的灵活性。这是其中之一。
虽然 GPT2 显示出大型模型可以进行多任务学习,但 GPT3,主要在模型大小、数据规模和多样性以及训练时间上与前者有所不同,表明在少量样本设置中(通过文本交互指定)可以改善大型语言模型的下游任务,而无需任何梯度更新或微调。这节省了训练、存储和部署不同任务特定模型的需求。这里的少量样本(K)表示模型只需几个示例,K 从 0 到模型令牌限制能容纳的最大值。为了对比基于 K 的性能差异,图 1 显示了模型在 LAMBADA(语言建模拓展到话语方面)基准数据集上的准确率。该基准的目标是通过要求模型预测段落末尾最可能的词来评估模型理解文本段落的能力。这也可以间接测量模型捕捉长程依赖的能力。有关 GPT-3 在各种语言建模基准上的表现,请参阅其原始论文 这里。
图 1. GPT-3 在不同少量样本(K)设置下的 LAMBADA 性能。来源: 语言模型是少样本学习者
不幸的是,提示设计也存在一些关键缺陷。模型性能的质量通常取决于任务描述,并受限于模型输入中可以容纳的条件文本量。构建高质量提示需要人工参与,并涉及对每个任务运行多个实验,测试几种提示设计。尽管已有尝试(如 AutoPrompt)自动生成提示,并有工具(如 promptsource)可用于简化过程,但性能往往无法超越 SOTA。
2. 可学习提示 — 提示调优
另一种技术由 Google 提出,建立在其文本到文本 T5 语言模型之上,是训练一组特定任务的令牌,这些令牌可以附加到冻结的语言模型输入令牌上。提示调优是学习每个下游任务的可调令牌的过程,这些令牌可以预先附加到输入文本中。这是一种高效的冻结模型条件方法,因为即使模型规模较小,也能实现与 SOTA 相当的性能,但与提示设计不同,它们仍需任务特定的数据集来训练这些提示。
虽然用于提示设计的输入令牌的嵌入来自模型自身的嵌入空间,但用于提示调优的令牌嵌入则是从特定任务的数据集中单独学习得到的。此外,与模型调优中更新模型权重的方法不同,此方法仅更新提示权重,同时保持模型权重不变。
图 2. 对比模型调优和提示调优的服务效果。来源:参数高效提示调优的规模力量
如图 2所示,这进一步使得通过批处理和向量化来节省资源成为可能。学习到的任务提示可以附加到各种任务输入中,以创建一个多任务批处理,并将其传递给相同的冻结模型。
提示调优的另一个优势是它能够调节模型的输入表示,从而防止模型修改其学习到的语言通用理解。他们认为,这将帮助模型在使用这些数据集时克服分布外错误。为了证明这一点,研究人员调查了提示调优在问答和同义句检测上的零样本领域转移性能。提示在 SQuAD 上进行了训练,结果显示提示调优在大多数常见的领域外数据集上优于模型调优(图 3. 有关详细结果,请参见论文)。这表明,在领域外任务中,用轻量级提示代替重型模型可以以更低的计算和内存成本获得更好的性能。
图 3. 在不同的领域外数据集上对比提示调优和模型调优的 F1 分数。来源:参数高效提示调优的规模力量
由于软提示可以用来影响模型的嵌入空间以适应下游任务,它们提供了一种高效的方式来集成巨大的语言模型而没有相关的开销。提高任务性能的一种方法是使用多个具有不同初始化的微调模型,但在相同数据上训练。这可以通过软提示高效地完成,因为现在可以创建N个具有不同初始化的单独提示,而不是N个微调模型。之前讨论的批处理和向量化的想法也可以在这里使用,以通过对冻结的语言模型进行一次传递来获得结果。
论文进一步报告了消融研究的结果,调查了提示长度、初始化方法以及预训练目标的选择如何影响提示调优的性能,因此我强烈推荐阅读论文以了解更多信息。
关键要点:
图 4. 比较模型调优、提示设计和提示调优。来源:缩放的力量:高效参数提示调优
-
模型调优 涉及在下游任务中更新任务无关的预训练语言模型的权重,是否更新底层架构都可以。因此,每个应用只能由其自身的模型服务,并且在分布外示例上的表现相当差。
-
提示设计 适用于大规模语言模型,这些模型将每个 NLP 任务视为一种问答问题,输出标签通常是一系列标记。通过冻结模型权重,这些模型能够通过少量样本提示快速适应任务。然而,由于提示文本大多是手动创建的,输出的质量取决于输入提示和任务描述的质量。
-
与更新模型权重不同,提示调优 涉及训练一个独立于模型嵌入空间的标记向量,可以根据当前任务调节模型的嵌入。这些提示可以与特定任务的输入一起批处理,并输入到一个冻结的模型中。这种方法对领域偏移表现出强大的弹性,并且能够有效替代神经网络集成。
参考文献:
-
GPT 2: Radford, Alec, 等。“语言模型是无监督的多任务学习者。” OpenAI 博客 1.8 (2019): 9。
-
GPT 3: Brown, Tom, 等。“语言模型是少样本学习者。” 神经信息处理系统进展 33 (2020): 1877–1901。
-
MAML: Finn, Chelsea, Pieter Abbeel, 和 Sergey Levine。“模型无关的元学习用于深度网络的快速适应。” 国际机器学习会议。PMLR, 2017。
-
MQAN: McCann, Bryan, 等。“自然语言十项全能:将多任务学习作为问题回答。” arXiv 预印本 arXiv:1806.08730 (2018)。
-
提示调优:Lester, Brian, Rami Al-Rfou, 和 Noah Constant。“缩放的力量:高效参数提示调优。” arXiv 预印本 arXiv:2104.08691 (2021)。
破解因果推断:使用 ML 方法的合成控制
原文:
towardsdatascience.com/hacking-causal-inference-synthetic-control-with-ml-approaches-7f3c19c7abfa
使用 PCA 测试任何治疗的效果随时间变化
·发表于 Towards Data Science ·阅读时间 5 分钟·2023 年 3 月 14 日
–
图片由 Raul Petri 提供,来源于 Unsplash
在文献中提出并被公司大规模采用的标准,用于研究商业行动(如设计更改、折扣优惠和临床试验)的因果影响肯定是 AB 测试。进行 AB 测试时,我们在进行随机化实验。换句话说,我们将受我们控制的人群(病人、用户、客户)随机分成两组:治疗组和对照组。治疗组接受治疗措施,而对照组保持不变。经过一段时间,我们重新记录感兴趣的指标,并分析治疗对人群行为的影响。
闪烁的东西并非金子!已证明 AB 测试存在不同的缺陷。随机试验的主要假设包括:
-
没有治疗组和对照组之间的互动(即网络效应)。
-
大规模实验的成本不断增加。
在现实世界中,网络效应很常见,因为我们可能会预期到人们之间的“污染”。例如在营销试验中,社交媒体上的意见分享会影响彼此的选择。为了克服这一不便,解决方案是通过选择不同地区具有不同口味的人来扩大实验规模。尽管增加样本大小可能是一个有效的解决方案,但由于成本呈指数增长,它却是不可行的。在这种情况下,引入了一种叫做合成控制的技术。
在这篇文章中,我们介绍了用于随机实验试验的合成控制方法。这项技术在本文中被介绍[1]。我们的目标不是仅仅展示在实际数据集上的方法实现。我们利用数据中存在的时间动态,提出了一种使用数据科学家工具箱中的工具的合成控制变体。
合成控制是什么
合成控制旨在评估案例研究中干预的效果。它类似于任何随机实验。在初步阶段,选择处理组和控制组。与标准因果分析不同,处理人群可以有任意大小!
想象一下你有兴趣验证一个由单一单元组成的群体上的处理效果。采用经典的合成控制方法,我们最终会构建一个多个控制单元的加权平均,以模拟处理案例的行为(人工控制案例)。
合成控制数学公式 [作者提供的图像]
从数学角度来看,这个问题在于找到使上述方程最小化的W(单位权重)的最佳值。这些值代表了每个控制单元在构建人工控制案例中的贡献。
合成控制模拟 [作者提供的图像]
我们希望验证单位之间的关系在处理引入日期(干预)之前和之后如何变化。如果我们在测试期间观察到人工控制案例和实际处理案例之间存在显著差异,我们可以断言处理是成功的。
主成分分析下的合成控制
合成控制是一种革命性的技术,它为研究人员提供了生成控制案例的规则。通过使合成案例看起来像处理案例,能够研究任何处理行动随时间的影响。
合成控制在幕后做的就是研究控制单元和处理单元的时间动态。首先,在构建人工控制案例时,研究控制单元之间的互动。其次,在处理引入之后,外推并验证相同的互动。换句话说,我们在检查干预前后单位之间关系的变化。
在机器学习中,我们习惯于检查分布和关系随时间的变化。如果我们在因果背景中应用一种通常用于检测关系变化的技术,以验证处理实验的有效性会怎么样呢?
一个适合此任务的候选方法可能是主成分分析(PCA)。这是一个在机器学习中广泛采用的技术,用于解决各种任务。我们决定使用它来学习处理引入之前的单元关系。
pca = make_pipeline(StandardScaler(), PCA(n_components=1))
pca.fit(df_train)
然后,我们将其应用于重建处理后的单元路径。
df_test_reconstruct = pca.steps[0][1].inverse_transform(
pca.steps[-1][1].inverse_transform(pca.transform(df_test))
)
df_test_reconstruct = pd.DataFrame(
df_test_reconstruct,
columns=df_test.columns, index=df_test.index
)
最终,我们通过测量重建误差来量化每个单独单元动态可能发生的变化,这可能是由于治疗措施的采用。
reconstruct_errors = (df_test - df_test_reconstruct).mean()
我们应用提出的方法研究 1988 年加州实施的限制措施 (提案 99),以研究对烟草消费的影响。该修正案旨在提高烟草产品的税收以防止香烟消费。
各州多年香烟消费 [作者提供的图片]
这项研究与 Abadie 在这项工作中提出的相同 [2]。实验数据可以通过 这里 下载,并在 公共许可证 下发布。
PCA 拟合的组件多年 [作者提供的图片]
独立分析每个州的重建误差,加州记录了最大的负向减少。对于加州,我们注意到预期消费(由 PCA 重建)与观察到的消费之间有显著偏差。这种行为可能是由于烟草税的增加导致香烟消费的减少。
干预后各州的重建误差 [作者提供的图片]
通过简单的建模策略,我们可以了解研究中的各个单元的路径。我们可以观察它们的预期行为,并分析与正常条件下或引入治疗活动后预期的偏差。
总结
在这篇文章中,我们介绍了合成控制作为从任何治疗措施中提取因果洞察的方法。经典的合成控制方法通过研究对照组单元的关系来构建一个人工对照案例。我们发现可以以一种直接的方式做到这一点,简单地研究单元之间的关系并观察它们随时间的变化。我们通过分析治疗引入后的 PCA 重建误差来实现这一点。
保持联系: Linkedin
参考文献
[1] A. Abadie, J. Gardeazabal, 冲突的经济成本:以巴斯克地区为例 (2003), 美国经济评论。
[2] A. Abadie, A. Diamond, J. Hainmueller, 比较案例研究的合成控制方法:估计加州烟草控制计划的效果 (2010),美国统计协会期刊。
破解 MySQL 的 JSON_ARRAYAGG 函数以创建动态、多值维度
补偿 MySQL 的一个不太为人知的不足
·
关注 发布于 Towards Data Science ·9 分钟阅读·2023 年 7 月 25 日
–
图片由 Azamat E 拍摄,刊登于 Unsplash。感谢 Azamat!
介绍
让我们假设我们是一个订阅盒子公司的数据团队成员。在 MySQL 数据库中,购买的事务记录被写入名为 subscriptions
的表中。除了元数据之外,该表包含一个 customer_id
和 subscription
字段,类似于这样:
订阅表。(注意:所有图像,除非另有说明,均由作者提供)
请注意,在这个示例场景中,一个客户可以有多个订阅。每条记录的唯一性由客户 ID 和订阅共同定义,即没有客户可以拥有相同的订阅两次。如果你想将这些测试数据加载到自己的数据库中,你可以在 这里找到相应的代码。
作为一个订阅盒子公司,我们的目标是销售更多的订阅盒子。为此,产品团队最近指出我们当前的所有客户都拥有不止一个订阅。他们对这表明的客户行为感到好奇。他们要求我们的团队提供一个数据模型,展示用户购买的订阅组合,以及哪些组合最为常见。
市场营销团队对这个模型也表现出了兴趣。他们认为这些结果可能对市场营销捆绑产品促销、客户画像以及定向电子邮件活动有用。出于这些相同的原因,他们还希望查看每个客户购买的最常见订阅数量。
简而言之,请求的数据模型希望回答一些重要的问题,理想情况下这些问题将最终导致更高的订阅盒子销售。问题是,我们应该如何准确执行?
在这篇文章中,我们将解决一个独特的数据建模挑战,以弥补 MySQL 的一个不太为人所知的缺陷。我们将讨论定性聚合、JSON 数据类型,以及如何强制 MySQL 以一种产生独特、多值维度的方式对值进行排序。
目录
-
聚合作为维度
-
MySQL 中 JSON 数据类型的简要概述
-
JSON_ARRAYAGG
-
使用 ROW_NUMBER 强制值的排序
-
回顾
聚合作为维度
从概念上讲,我们需要做的事情相对简单:我们需要按客户对订阅进行捆绑(分组)。然后,我们需要查看这些捆绑,看看哪些最为常见,以及它们包含了多少订阅。
在数据建模术语中,我们关注的是某种形式的聚合:具体来说,是按客户对订阅进行聚合。
通常会想到定量意义上的聚合函数(SUM
、COUNT
等),这主要是因为 SQL 中大多数聚合函数的功能。但我们也可以将拼接的字符串值聚合成更长的、类似列表的字符串。
然而,这个挑战在于访问、操控或评估这些连接字符串中的值。MySQL 会将foo, bar, hello, world
的值视为文本,而不是列表。
这有什么相关性?主要是因为在我们的假设场景中,我们想要计算每个组合中的订阅数量。我们不希望得到一个长的以逗号分隔的字符串,我们希望得到一个更真正的列表形式。
在 Python 中解决这个问题会很简单——使用 pandas,也许是 polars,甚至只是 Python 本身的数据结构。但有许多情况下这不是一个选项。也许数据团队只使用 dbt;或者更常见的是,你在一个 IT 部门严密锁定本地环境的公司工作。
无论如何,如果你只有 SQL 可以使用,你需要一个能够提供最可读代码和最灵活结果的解决方案。实现这一点并不直观。例如,我遇到这个问题时的第一反应是使用GROUP_CONCAT
,这是一个根据你定义的分组连接字符串的函数:
WITH
subscriptions_grouped AS (
SELECT
customer_id,
GROUP_CONCAT(subscription) AS subscriptions
FROM
subscriptions
GROUP BY customer_id
)
SELECT
subscriptions,
COUNT(*) AS num_accounts
FROM subscriptions_grouped
GROUP BY subscriptions
;
查询结果
正如你所看到的,聚合有效,从技术上讲,但它并没有按照我们的业务逻辑工作。请查看第一行和最后一行。组合“international_snacks, self_care”的订阅与“self_care, international_snacks”是相同的组合。(第二行和第四行也是如此。)
我们可以在GROUP_CONCAT
中使用ORDER BY
子句来解决这个特定问题:
WITH
subscriptions_grouped AS (
SELECT
customer_id,
GROUP_CONCAT(subscription ORDER BY subscription) AS subscriptions
FROM
subscriptions
GROUP BY 1
)
SELECT
subscriptions,
COUNT(*) AS num_accounts
FROM subscriptions_grouped
GROUP BY subscriptions
;
查询结果
但这仍然留下了一个问题:如何计算每个组合中的订阅数量。
这样做是可行的。但我认为不仅复杂且不太易读,而且还伴随着一些不太明显的陷阱。
关于如何计算 MySQL 中以逗号分隔的字符串中的值数量的快速搜索找到了一个解决方案,对我们来说,相当于这个(subscriptions_grouped
CTE 除外):
SELECT
subscriptions,
LENGTH(subscriptions) - LENGTH(REPLACE(subscriptions, ',', '')) + 1 AS num_subscriptions,
COUNT(*) AS num_accounts
FROM subscriptions_grouped
GROUP BY subscriptions
;
这本质上是计算逗号的数量,然后将结果加 1。这是可行的。但这个答案不仅难以一眼理解,还引入了一个潜在的错误:LENGTH
和CHAR_LENGTH
函数计算的内容不同。
正如你可能猜到的,这篇文章详细描述了我在工作中遇到的障碍,当时我发现自己处于类似的情况。
最终,解决方案是使用本地 MySQL JSON 数据类型的某种黑客式但非常易懂的变通方法。
MySQL 中 JSON 数据类型的简要概述
MySQL 中的 JSON 数据类型是在 5.7.8 版本中新增的,提供了许多对存储和建模非常有用的功能。
在 JSON 数据类型的伞下(官方称为“JSON 文档”)有两种不同的数据结构:JSON 数组和 JSON 对象。
JSON 数组可以简单地被看作是一个数组(如果你是 Python 爱好者的话,就是一个列表):值用方括号 [ ]
括起来,并用逗号分隔。
- 一个 MySQL JSON 数组值的示例:
[“foo”, “bar”, 1, 2]
JSON 对象可以被看作是一个哈希表(或者用 Python 的术语说,就是一个字典):键值对,用逗号分隔,并用花括号 { }
括起来。
- 一个 MySQL JSON 对象值的示例:
{“foo”: “bar”, 1: 2}
MySQL 提供了许多函数来处理这两种格式—几乎没有函数执行任何形式的聚合。
不过幸运的是,有两个函数是可以的。它们都返回 JSON 文档,这意味着我们可以使用 MySQL 内置的函数来访问其中的值。
JSON_ARRAYAGG
MySQL 函数 JSON_ARRAYAGG
很像 GROUP_CONCAT
。最大的区别是它返回一个 JSON 数组,而这个 JSON 数组带有多个有用的内置函数,如上所述。
JSON 数组数据类型以惊人的简单性解决了我们面临的两个问题中的一个:可靠计算组合中订阅数量的问题。这是通过使用 [JSON_LENGTH](https://dev.mysql.com/doc/refman/5.7/en/json-attribute-functions.html#function_json-length)
函数完成的。语法非常简单:
SELECT JSON_LENGTH(JSON_ARRAY("foo", "bar", "hello", "world"));
-- JSON_ARRAY function used here just to quickly create an example array
这个语句的结果是 4,因为生成的 JSON 数组中有 4 个值。
但让我们回到订阅的组合。不幸的是,JSON_ARRAYAGG
不具备 GROUP_CONCAT
的排序功能。在基查询之前即使在 CTE 中排序 subscription
值,也无法返回期望的结果:
WITH
subscriptions_ordered AS (
SELECT
customer_id,
subscription
FROM subscriptions
ORDER BY subscription
)
, subscriptions_grouped AS (
SELECT
customer_id,
JSON_ARRAYAGG(subscription) AS subscriptions,
JSON_LENGTH(JSON_ARRAYAGG(subscription)) AS num_subscriptions
FROM
subscriptions_ordered
GROUP BY customer_id
)
SELECT
subscriptions,
COUNT(*) AS num_accounts
num_subscriptions
FROM subscriptions_grouped
GROUP BY subscriptions
;
查询结果
每个组合中的订阅数量是存在的,这要归功于 JSON_LENGTH
函数——但由于它们的顺序,相同的组合再次被错误地标记为不同的。
使用 ROW_NUMBER 强制值的排序
ROW_NUMBER
是一个窗口函数,用于创建索引。索引必须被定义;也就是说,你需要告诉它从哪里开始,如何递增(方向),以及在哪里结束。
我们可以通过应用 ROW_NUMBER
函数并告诉它按 subscription
字段排序来快速查看一个例子:
SELECT
customer_id,
subscription,
ROW_NUMBER() OVER(ORDER BY subscription) AS alphabetical_row_num
FROM subscriptions
;
查询结果
仔细查看结果。尽管我们在查询的最后没有使用 ORDER BY
语句,但数据还是根据 ORDER BY
在 OVER
子句中的顺序进行了排序。
当然,这还不是我们想要的。接下来我们需要在窗口函数中添加一个 PARTITION BY
子句,以便结果的排序与每个客户 ID 相关(实际上是受每个客户 ID 的限制)。如下所示:
SELECT
customer_id,
subscription,
ROW_NUMBER() OVER(PARTITION BY customer_id ORDER BY subscription) AS alphabetical_order
FROM subscriptions
;
查询结果
你可能已经看出接下来会发生什么了。
如果我们在 CTE 中对这些结果执行 JSON_ARRAYAGG
函数,我们会看到重复的组合现在看起来完全一样,这要归功于 ROW_NUMBER
函数强制按字母顺序排序的订阅:
WITH
subscriptions_ordered AS (
SELECT
customer_id,
subscription,
ROW_NUMBER() OVER(PARTITION BY customer_id ORDER BY subscription) AS alphabetical_order
FROM subscriptions
)
SELECT
customer_id,
JSON_ARRAYAGG(subscription) AS subscriptions
FROM subscriptions_ordered
GROUP BY 1
ORDER BY 2
;
查询结果
现在我们只需在执行 ROW_NUMBER
的 CTE 后添加分组 CTE,并修改基本查询即可:
WITH
subscriptions_ordered AS (
SELECT
customer_id,
subscription,
ROW_NUMBER() OVER(PARTITION BY customer_id ORDER BY subscription) AS alphabetical_order
FROM subscriptions
)
, subscriptions_grouped AS (
SELECT
customer_id,
JSON_ARRAYAGG(subscription) AS subscriptions,
JSON_LENGTH(JSON_ARRAYAGG(subscription)) AS num_subscriptions
FROM subscriptions_ordered
GROUP BY customer_id
)
SELECT
subscriptions,
COUNT(*) AS num_customers,
num_subscriptions
FROM subscriptions_grouped
GROUP BY subscriptions
ORDER BY num_customers DESC
;
这不仅提供了准确的独特订阅组合,还显示了购买这些组合的客户数量,以及每个组合包含的订阅数量:
查询结果
看这里!
总结
-
我们想知道有多少客户购买了不同组合的订阅,以及每种组合中包含了多少个订阅。这提出了两个问题:如何最好地获取后者,以及如何生成准确独特的订阅组合。
-
为了获取每个组合中的订阅数量,我们选择了 MySQL 的 JSON 函数之一
JSON_ARRAYAGG
。结果的聚合以 JSON 数据类型返回给我们,允许我们使用JSON_LENGTH
函数。 -
我们接着需要强制 JSON 数组内部值的排序,以免重复的组合被错误地认为是不同的。为此,我们在基本查询之前使用了
ROW_NUMBER
窗口函数,在 CTE 中按客户 ID 分区,并按字母顺序(升序)排序订阅。 -
这最终使我们能够准确地聚合独特的订阅组合;通过这个方法,我们能够使用简单的
COUNT
函数查看每种组合有多少客户购买。
感谢阅读!🤓
我希望这对你有所帮助!如果你知道 SQL 中的其他聪明技巧/变通方法(无论方言如何),我很想听听。SQL 长期以来一直是转换结构化数据的事实上的通用语言,但它并不完美。我总是喜欢了解针对现实世界挑战的创新和/或聪明的解决方案。🔥
我定期撰写有关数据工程和分析主题的文章——目标始终是尽可能清晰简洁。如果本文中的任何内容让你感到困惑,请在评论中告诉我。如果你有兴趣阅读更多类似的文章,欢迎 关注我 和/或 在 LinkedIn 上联系我。
黑客统计显著性:使用机器学习方法进行假设检验
在任何上下文中无假设地测试统计显著性
·发表于Towards Data Science ·阅读时间 7 分钟·2023 年 1 月 10 日
–
照片由Christian Stahl提供,发布在Unsplash
数据分析的重要性在各个领域都很清楚。从商业到学术,进行适当的分析是达到前沿结果的关键。在这方面,正确地操作和提取数据中的有意义见解是至关重要的。数据分析师/科学家负责填补理论假设与实际证据之间的差距。
为所有可能提出的问题提供分析答案是一个昂贵且艰难的过程。将问题/需求转化为分析语言是开始进行的第一步。这类操作的好坏至关重要,因为它会影响最终结果的正确性。在初步阶段,理解分析目标并指出最佳的数据源、框架和参与人员,以达到最佳结果,是非常重要的。
大多数情况下,分析性地回答问题是通过进行统计检验来完成的。许多统计检验如下所示:
-
陈述一个原假设,这是描述世界的默认选项。
-
陈述一个替代且补充的假设。
-
计算检验统计量(数据的函数)并概述最终结果。
已知检验统计量的分布后,可以轻松计算观察到任何值的概率(p 值)。如果 p 值小于预设的(通常为 0.01 或 0.05)显著性水平,则拒绝原假设,接受替代假设。
统计测试本身没有问题,但我们需要注意一些隐藏的陷阱:
-
对数据的严格假设。大多数时候,基础数据必须遵循正态或已知的分布。正如我们所知,现实世界的现象并不遵循正态分布。
-
对我们不感兴趣的数量/统计的限制。如果我们想测试一些定制的或更复杂的内容,可能会遇到困难。
在这篇文章中,我们介绍了一些时髦且简单的方法来测试假设并从我们手头的数据中提取有意义的见解。我们不使用标准统计测试得出结论,而是通过模拟和排列来实现。
数据准备
为了说明这些方法,我们使用一个包含金县(美国)房屋销售记录的数据集。该数据集可以从Kaggle访问,并且在CC0 公共领域许可证下提供。它包含了 2014 年 5 月到 2015 年 5 月之间的房屋销售价格数据,涉及金县(包括西雅图)。
数据集包含大约 20,000 条销售房屋的记录,具有不同的数值属性:销售价格、卧室数量、浴室数量、生活空间的平方英尺、楼层数、纬度/经度、建筑年份,等等。
在一个标准的预测应用中,预测房屋的销售价格,考虑到它们的特征,会是一个有趣的问题。这里我们并不关注这种应用。我们希望通过一种不同于传统统计测试的方法来分析数据,从而回答一些问题,这种方法同样高效(或者可能更灵活)。
模拟
让我们假设我们对房屋的建筑年份和销售价格之间是否存在关联感兴趣。
销售价格的分布与正态分布相差较大。正如我们所预期的,价格和建筑年份之间并不存在明确的线性关系。
价格分布(左)。价格与建筑年份(右)[作者提供的图片]
中位销售价格为 45,000 美元。2015 年建造的房屋(根据我们的数据集,较新的房屋)有更高的中位价格。这似乎是合理的,但了解这种效果是否“由于偶然”会很有趣。
每年的最低和最高中位销售价格 [作者提供的图片]
所谓“由于偶然”,指的是我们仅观察到整个总体的一个样本。我们手头的数据仅限于 2014 年至 2015 年间在金县发生的所有房屋交易的一部分。在此期间可能还有更多 2015 年建造的房屋被售出,但未记录在我们的数据集中。
在这种情况下,我们能做的最好的是注意到局限性,并尝试估计真实的中位数。我们可以通过模拟来做到这一点。
作为第一步,我们计算并存储 2015 年建造的房屋的中位数销售价格与我们掌握的所有数据的中位数销售价格之间的观察到的差异。这个值(观察到的差异)代表了我们可以观察到的价格差异,并且我们希望验证这一点。
year = 2015
y = df[df['yr_built'] == year]['price'].agg(['count','median'])
observed_diff = abs(y['median'] - df['price'].median())
此时,我们希望检查我们的观察到的差异是否可能被任何随机销售子组所记录。我们随机抽取与 2015 年房屋相同大小的组,并计算它们的中位数价格与数据集中中位数价格之间的差异。
n_simulation = 1_000
sampling = lambda x,y: x['price'].sample(n=int(y['count']))
sim_diffs = np.asarray([
abs(sampling(df,y).median() - df['price'].median())
for i in tqdm(range(n_simulation))
])
最后,我们验证模拟价格差异高于我们的观察到的差异的次数。这个值可以解释为成功的概率,并代表我们的估计的p 值。
p_value = np.mean(sim_diffs >= observed_diff)
具有较低的 p 值,我们更有信心拒绝原假设并接受替代假设。在我们的情况下,我们更有信心拒绝 2015 年房屋与其他房屋之间没有价格差异的假设。
2015 年和 2012 年的模拟结果 [作者提供的图片]
根据我们的需求,我们可以对所有感兴趣的建筑年份进行测试。下图显示了所有年份的测试结果。
所有建筑年份及其中位数销售价格的模拟结果 [作者提供的图片]
多变量模拟
这是一个令人难以置信的结果!只需几行代码,我们就可以测试和验证任何实证问题。我们的研究验证了不同年份建造的房屋销售价格差异的存在。这是否意味着 2015 年建造的房屋与 80 年代建造的房屋不同?并不完全,因为我们仅验证了价格差异的可能性。可能有很多因素会区分不同年份建造的房屋。希望我们的数据集中还有许多其他特征,我们可以用来进一步验证可能的差异。
如前所述,我们希望检查 2015 年建造的房屋与其他房屋之间是否存在差异。现在我们不仅仅关注销售价格,还考虑所有可用的特征。为了有效地进行这种多维度测试,我们拟合一个二分类模型来区分 2015 年的房屋和其他房屋。我们记录 ROC-AUC 作为一个度量指标(观察到的分数)。
year = 2015
cv_scoring = lambda x,y: np.mean(cross_val_score(
RandomForestClassifier(10),
x, y, cv=5, scoring='roc_auc', n_jobs=-1,
error_score='raise'
))
observed_score = cv_scoring(
df.drop(['yr_built','date','id'], axis=1),
(df['yr_built'] == year).astype(int)
)
然后我们检查我们的观察到的分数是否可能被任何随机的房屋销售子组所记录。我们随机抽取与 2015 年房屋相同大小的组,拟合一个二分类器来区分它们,并记录获得的 ROC-AUC。
n_simulation = 1_000
sim_scores = np.asarray([
cv_scoring(
df.drop(['yr_built','date','id'], axis=1),
(df['yr_built'] == year).sample(frac=1).astype(int)
)
for i in tqdm(range(n_simulation))
])
最后,我们可以像之前一样验证观察到的分数是否高于模拟值,并计算相对的 p 值。
p_value = np.mean(sim_scores >= observed_score)
在我们的案例中,我们更有信心拒绝认为 2015 年房屋与其他房屋之间没有整体差异的假设。
2015 年和 2012 年的模拟结果 [作者提供的图片]
总结
在这篇文章中,我们展示了一种基于模拟的方法来回答观察数据时可能出现的任何问题。所提出的方法的灵活性使其适用于任何背景,并且没有特别的前提假设。我们还提出了一种多变量泛化方法,以测试数据子组之间的差异,进一步证明了该方法可以在任何领域扩展,以验证任何假设。
保持联系: Linkedin
为了庆祝这些非常有用和实用的文章,本周的《Variable》将聚焦于我们“提示与技巧”栏目中的近期亮点:它们提供了可操作的、经过验证的建议,可以帮助你节省时间和精力,并在项目中取得更好的结果。无论你本周是否已经享受了你的“甜点”(祝庆祝者万圣节快乐!),我们希望这些技巧能激发你寻找新的方法或工具进行尝试。
我们在 TDS 对长篇、详尽的指南情有独钟——但我们也欣赏那些针对数据科学家在日常工作中面临的具体挑战和痛点的集中帖子。
·
关注 发布于 Towards Data Science · 以 Newsletter 形式发送 · 阅读需 3 分钟 · 2023 年 11 月 2 日
享受过甜点了吗?是时候了解数据科学技巧了
-
在探索性数据分析中简化重复任务EDA 有时因为需要经历繁琐的阶段而被人诟病,这一阶段你必须经历,才能进入更有趣的建模和预测工作阶段。Christabelle Pabalan 最近分享了一种聪明的方法,为该过程添加了自动化层,但没有牺牲过程中的细致和精确。
-
探索 Pydantic V2 的增强数据验证能力Pydantic,“最广泛使用的 Python 数据验证库”,是许多数据从业者的首选工具。Lynn Kwong 对 Pydantic V2 的概述提供了利用其最新改进的具体技巧,包括支持严格模式和在没有模型的情况下验证数据的可能性。
照片由 Ahmad Ossayli 拍摄,来源于 Unsplash
-
6 个你应该了解的 Pandas 索引相关操作鉴于 Pandas 在数据科学工作流中的普及,深入理解其功能并扩展处理数据框的有效方法从来不是坏主意。Yong Cui 的新文章重点介绍了与索引相关的操作,并通过简单的实际案例进行了详细说明。
-
如何在数据可视化中使用颜色如果你一直将图表和图形中的颜色选择视为事后考虑,Michal Szudejko 的关于颜色正确使用的提示合集肯定会让你重新考虑你的方法。从可访问性到调色板选项,你将学习到小的调整如何使你的可视化更清晰,并帮助其成为更强大的讲故事工具。
-
释放 Julia 超类型的力量对于越来越多的 Julia 爱好者,Emma Boudreau 关于抽象及其如何有效地融入代码的实践资源是必读的——它提供了关于如何以最小的努力开始创建我们自己的超类型的详细概述。
我们希望你还有空间容纳一些额外的美味,因为我们不希望你错过这些其他主题的精彩阅读:
-
AI 生成内容的普及将如何影响 LLM 训练的质量?Aicha Bokbot 探讨了一个关于 AI 工具可持续发展的新兴关注点。
-
音乐与机器学习相遇 在 Emmanouil Karystinaios 的迷人项目中,该项目试图自动化和声分析。
-
想要构建和发布一个 R 数据包吗?Deepsha Menghani 提供了一个 利用 devtools 的逐步指南 来实现这一目标。
-
通过使用混合搜索、层级排名和讲师嵌入,Agustinus Nalwan 尝试解决 RAG 在领域特定搜索中的重大挑战。
-
要对 AI 初创生态系统的现状 有一个清晰的反思,不要错过 Clemens Mewald 最近的深度剖析,解释了为何 LLMs 成功进入主流而 MLOps 工具却没有。
-
可疑的数据黑客行为不幸地无处不在;Hennie de Harder 提供了一个 关于这些行为背后统计学概念的有益看法。
感谢你支持我们作者的工作!如果你喜欢在 TDS 上阅读的文章,可以考虑 成为 Medium 会员 —— 这将解锁我们整个档案(以及 Medium 上的所有其他帖子)。
直到下一个变量,
TDS 编辑
使用 Delta 表处理缓慢变化的维度(SCD)
原文:
towardsdatascience.com/handling-slowly-changing-dimensions-scd-using-delta-tables-511122022e45
使用 Delta 框架处理缓慢变化维度的挑战
·发表于 Towards Data Science ·10 分钟阅读·2023 年 1 月 23 日
–
长期以来,Kimball 方法一直是维度数据建模技术的标准。根据 Kimball 的说法“时间的概念渗透到数据仓库的每一个角落”。在数据分析的背景下,这意味着什么?从高层次来看,现代分析可以被视为随着时间的推移对不断变化的数据的聚合。问题在于,不断变化的数据不仅包括新的添加,还包括对以前数据集的更改。
整体维度数据建模将数据分为两大类:
事实 — 这些数据表示无限的数据集,存储实体的测量信息。它包含对定量分析和决策制定至关重要的数据。事实表通常具有与其他表(维度)连接的列,以供参考。
维度 — 这些数据表示相对有限的数据集,提供关于在事实表中进行的测量的描述性信息。与事实表相比,维度的变化速度要慢得多。这就是为什么它们通常被称为“缓慢变化的维度”。
Kimball 的方法涉及基于事实和维度创建星型模式。由于其非规范化的结构,星型模式非常适合分析用例……不需要复杂的连接条件。因此,多年来,星型模式一直是传统数据仓库建模的事实标准。
图片由作者提供
多年来,数据处理人员面临着处理缓慢变化维度的挑战,同时保持其之前的历史记录并保留与事实表的关系参考。Kimball 方法提出了几种有效处理缓慢变化维度的方法。现实情况是,一旦选择了特定的 SCD 方法,在数据仓库中实施它相对容易。对 SQL 和 ACID 事务的支持使其处理起来变得简单。
不幸的是,在数据湖中实现相同的操作是另一回事。有几个原因:
-
第一个问题是 不变性。根据最佳实践,数据湖中的数据不应更改。
-
其次,多年来在数据湖中无法进行原子写入。这意味着即使你只做了小的编辑,也需要重写整个表。
Delta Lake 框架解决了上述问题。对 ACID(原子性、一致性、隔离性和持久性)事务的支持现在使得在数据湖中实现 SCD 与在数据仓库中一样轻松。在本文中,我们将学习如何使用 Delta Lake 框架实施处理缓慢变化维度的最常见方法。
下面是一个示例案例:
“一家公司希望跟踪客户维度随时间发生的变化。他们要求数据工程团队提供几种替代方案。经过仔细考虑,数据工程提出了三种管理缓慢变化维度的选项:SCD 类型 1、SCD 类型 2 和 SCD 类型 3。”
在我们深入探讨每个选项之前,让我们尝试理解客户维度的数据结构。在本文中,我们将使用下面的示例数据集。下面的数据集显示了一些示例客户记录。为了说明处理缓慢变化维度的不同选项,我们将重点关注用红色框标出的客户记录(客户名称 = Magee Cash)。
作者提供的图片
Magee Cash 最近更改了她的地址。更改记录以 CDC 记录的形式传送到 OLAP 系统。在数据工程的背景下,CDC 过程旨在从源头捕获增量数据集,并将其合并到企业数据湖中。以下是 Magee Cash 的更改记录,注意地址与上述原始记录不同。
作者提供的图片
Delta Lake 的核心能力使其成为构建现代数据湖仓架构的极其合适的平台。在湖仓架构中,Delta Lake 可用于将变更记录合并到公共数据层(银层)。一旦创建,银层就会作为分析工作负载的基础数据层,包括 BI、数据科学、机器学习和人工智能。因此,银层通常被称为“单一事实来源”。
让我们回到本文的核心目标。现在我们对数据集有了清晰的理解,我们可以开始探索第一个 SCD 方法。
SCD 类型 1
这种类型通常被称为“覆盖”方法。在这种方法中,任何对维度数据的更改都会用相同键的先前数据状态进行覆盖。虽然这种方法实现起来非常简单,但它有一个主要缺点。由于覆盖机制,你不仅会丢失维度的先前历史记录,还会丢失它所附加的事实表的状态。下图展示了使用 SCD 类型 1 方法的客户维度的前后图像。
图片由作者提供
请注意,新的住址是覆盖了之前的地址,之前地址的历史记录丢失了。在维度发生变化时,失去历史记录的后果可能非常严重。如果事实表的聚合受到维度变化的影响,那么没有历史记录的情况下,很难追溯聚合值变化的原因。
我们现在将学习如何使用 Delta 框架实现 SCD 类型 1。从使用湖仓的铜层原始客户数据集创建银层客户维度表(customer_silver_scd1)开始。
图片由作者提供
使用Magee Cash的变更记录创建一个新的数据框架。
图片由作者提供
最后,将地址变更记录合并到customer_silver_scd1银层维度表中。
图片由作者提供
在对银层维度表执行查询后,你会注意到地址变更已经覆盖了其先前的状态。问题在于,这条记录的先前状态无法找到。
考虑一种情况,Magee Cash可能使用旧版本的地址下了电子商务订单。产品尚未发货,但地址在此期间已发生变化。那么,产品应该发往哪里?旧地址还是新地址?
图片由作者提供
让我向你介绍 Delta Lake 框架中一个非常有用的功能。Delta Lake 维护了更改的时间顺序历史,包括插入、更新和删除。在上面的示例中,版本 0的表是在创建customer_silver_scd1银层表时生成的。同样,版本 1的表是在我们为地址变更记录执行数据合并时创建的。此外,Delta Lake 表可以轻松恢复到任何所需的先前版本。
图片由作者提供
由于上述缺陷,现代数据平台中很少使用 SCD 类型 1。因此,我们需要一种更好的方法,能够在进行维度更改时保留先前的引用以供活动使用。总的来说,如果你的计算不关心数据的先前状态或其可能引发的后果,可以简单地使用 SCD 类型 1。
SCD 类型 2
也称为“添加新记录”方法。在这种方法中,更改记录作为新记录添加到维度表中,并标记为“当前”或“活动”。此外,记录的先前版本被标记为“过期”或“非活动”。记录的不同版本(当前和历史)通过替代键关联起来。在表级别,SCD 类型 2 通过为维度表中的每一行添加开始日期和结束日期时间戳列来实现。此外,添加了一个状态列,以标记记录的当前或过期状态。下面展示了使用 SCD 类型 2 方法的客户维度的前后图像。
图片由作者提供
我们现在将学习如何使用 delta 框架实现 SCD 类型 2。从使用 Lakehouse 原始客户数据集创建银层客户维度表(customer_silver_scd2)开始。
图片由作者提供
现在将地址更改记录合并到customer_silver_scd2银层维度表中。
图片由作者提供
请注意,之前的记录被标记为过期,并且更新了结束日期。同时,插入了一条新记录,包含最新地址,其开始日期与之前记录的结束日期相同。使用这种方法,Magee Cash将确保她的电子商务订单被送到正确的地址。
图片由作者提供
使用 SCD 类型 2 方法,你可以按时间顺序跟踪更改历史,并以时间顺序的方式维护对事实表的引用。我必须承认,与 SCD 类型 1 相比,这种实现有些复杂。
作为一个警告,维护维度表的应用程序需要以这样的方式编码,即将带有当前版本的新记录和先前版本的过期合并为一个事务。此外,所有针对维度表的查询都需要过滤status=Current。
还有一种更简单的替代方案,我们进一步探讨另一种方法,这在某些方面仅仅是 SCD 类型 1 方法的扩展。
SCD 类型 3
也称为“添加新字段”方法。对于每次更改,先前版本和当前版本作为两个不同的列存储在同一行的维度表中。与 SCD 类型 2 相比,SCD 类型 3 相对更易于实现,历史记录仅包括当前和先前的版本。
作者提供的图片
我们现在将学习如何使用 Delta 框架实现 SCD 类型 3。首先使用湖库中铜层的原始客户数据集创建银层客户维度表 (customer_silver_scd3)。
作者提供的图片
请注意,维度表中的每一列都维护当前和先前状态。在创建维度表时,列的当前状态填充了最新的数据,而列的先前状态则留空。
现在将地址变更记录合并到customer_silver_scd3银层维度表中。
作者提供的图片
继续查看在 Delta Lake 合并后记录的状态。
作者提供的图片
请注意,address 字段现在已填充了更改后的记录,地址的先前版本已移动到 previous_address 字段。同样,modifieddate 字段已更新以维护更改的时间顺序。
由于仅有有限的历史记录可用,SCD 类型 3 的使用案例略显有限。但实现的简便性使其具有一定的吸引力。如果您厌恶 SCD 类型 1 的局限性,并且发现 SCD 类型 2 难以实现和管理,那么这是一个不错的折衷方案。
本文中使用的所有代码可以在下面的链接中找到:
[## blogs/scd at master · mkukreja1/blogs
目前无法执行该操作。您在另一个标签页或窗口中已登录。您在另一个标签页或…
在许多方面,SCD 类型 2 通常被认为是实施缓慢变化维度的主要技术。需要明确的是,SCD 的主要目标不是存储记录的历史,而是保持与事实表的准确关联。此外,在许多方面,缓慢变化维度要求你更新记录,这在一般情况下与数据湖/仓库的不可变性原则相悖。然而,像 Delta Lake 这样的框架的新进展使得以简单和轻松的方式实现 SCD 场景成为可能。
希望这篇文章对你有帮助。SCD 作为 Datafence Cloud Academy 提供的 AWS 大数据分析课程的一部分进行了讲解。课程由我在周末在线授课。
使用 Python 处理时区
·
关注 发表在 Towards Data Science ·10 分钟阅读·2023 年 4 月 21 日
–
本文通过对 2020 年四个城市的小时太阳辐射数据在不同时间区的比较评估,展示了在 Python 中处理时区的功能。
时区
当我在德国波恩的工作开始的第一天,十月的早上 9 点时,我在尼泊尔奇旺的家乡已经是下午 12:45。我在澳大利亚悉尼的朋友当天已经在晚上 6 点结束了他的工作安排。另一个在美国纽约的朋友还在睡觉,因为那里的时间是凌晨 3 点。这表明这四个地方有不同的时区。
世界各地的不同时间区插图。地图 © OpenStreetMap contributors,依据 Open Data Commons Open Database License (ODbl) 由 OpenStreetMap foundation 授权(OpenStreetMap,2023)。标签由作者添加。
时区 是一个区域,该区域为了法律、社会或商业目的而观察统一的标准时间。世界并不是根据经度均匀划分成不同的时区。时区往往根据国家之间和国家内部的边界进行划分。
所有时区都定义为相对于协调世界时 (UTC) 的偏移量。这些值范围从 UTC-12:00 到 UTC+14:00。虽然偏移量通常是整数小时,但有些时区的偏移量还额外增加了 30 分钟或 45 分钟。例如,尼泊尔的时区偏移量为 UTC+05:45。全球共有 38 个时区。
如果我有尼泊尔、德国、澳大利亚和美国四个城市的太阳辐射数据,并且这些数据是基于 UTC 时区的,这并不反映每个国家同一时刻的数据。在这篇文章中,我将讨论如何处理数据的时区,以便在 Python 中处理日期时间对象,包括 pandas 数据框。
为此,我将下载这四个城市/国家的 2020 年太阳辐射数据,并在以下情况下对数据进行比较和分析:
-
每个国家的数据都在 UTC 时区中
-
数据指的是相应国家的时区。
让我们开始吧。
图片由 Luis Cortes 提供,来自 Unsplash。
地理编码以检索四个城市的坐标
在第一步中,我检索了四个国家四个城市的坐标,因为我需要这些坐标来提取太阳辐射数据。通过提供地点名称来提取地理坐标的过程称为地理编码。
如下所示,我编写了一个使用 geopy 包进行地理编码的函数。该函数利用了 Nominatim,这是一个开源的地理编码服务,使用 OpenStreetMap 数据通过名称和地址查找地球上的位置。
from geopy.geocoders import Nominatim
geolocator = Nominatim(user_agent="app")
def get_coordinates(place):
"""Return the latitude and longitude of the place."""
place_details = geolocator.geocode(place)
coordinates = (place_details[1][0], place_details[1][1])
return coordinates
我使用该函数提取了各个城市的坐标,并创建了一个 pandas 数据框,如下图所示。
创建了一个包含四个城市的纬度和经度值的数据框。插图由作者提供。
使用 NASA Power API 访问数据
美国宇航局 Power 的应用程序编程接口(API)服务允许检索分析就绪数据(NASA Power, 2023a)。对于这篇文章,我从 NASA Power 数据(NASA Power, 2023b)下载了四个城市的每小时分辨率的太阳辐射数据。我使用的参数是2020年的全天空表面短波下行辐射量(ALLSKY_SFC_SW_DWN
),其详细描述见下节。
数据以UTC 时区格式调用,尽管每小时 API默认也允许以本地太阳时间(LST)格式调用数据。
base_url
配置如下所示:
base_url = r”https://power.larc.nasa.gov/api/temporal/hourly/point?parameters=ALLSKY_SFC_SW_DWN&community=RE&time-standard=UTC&longitude={longitude}&latitude={latitude}&format=JSON&start=2020&end=2020"
接下来,我遍历由地理编码定义的每个地点的经纬度,这些地点在一个名为places
的列表中,并请求 2020 年的每小时太阳辐射数据。该步骤的完整代码如下所示:
参数描述
太阳辐射数据指的是在所有天空条件下,每小时每单位面积(Wh/m²)从太阳获得的总功率(直接+漫射),位于地球表面上的水平面上(NASA Power, 2023c)。
该参数,也称为全球水平辐射(GHI),用于计算满足给定电力需求所需的太阳光伏模块尺寸,如下方公式所示:
方程 1 和 2 指的是计算太阳光伏模块和电池尺寸的公式,以满足给定的电力需求,基于太阳辐射量和其他技术参数。由作者说明。
给定数据的基本统计
下载的全球水平辐射数据,涵盖四个城市。由作者说明。
下载的数据如上图所示。数据显示悉尼在年初和年末的太阳辐射量较高,而在年中较低。这一模式在其他三个城市中正好相反,这可以通过悉尼位于南半球而其他城市位于北半球来解释。
从下载的数据中得到的统计信息。由作者说明。
观察到 2020 年,尼泊尔的奇特旺接收到的年太阳辐射量最高(1669 kWh/m²),其次是澳大利亚悉尼(1631 kWh/m²)、美国纽约(1462 kWh/m²),而德国波恩接收到的最少(1193 kWh/m²)。
然而,特定小时接收到的最大太阳辐射量最高的是悉尼(1061.3 W/m²),其次是奇特旺(997 W/m²)。
每个城市的最小太阳辐射量和第 25 百分位值为零,因为夜间没有太阳辐射。
时区处理
1. 默认的 pandas 数据框,没有“datetime”格式索引
由于 2020 年是闰年,有 366 天,因此数据获取了 8784 小时。
当数据首次下载时,其索引是整数(int64)类型,如下所示:
数据最初从 NASA Power 网站下载。作者插图。
2. 将整数类型索引转换为“原始”日期时间索引
可以使用pd.to_datetime()
并指定格式%Y%m%d%H
将数据框架索引转换为日期时间类型,分别对应年、月、日和小时。
将整数索引转换为日期时间索引。作者插图。
当数据框绘制时也反映了这一变化,因为 2020 年 1 月至 12 月的月份在 xticks 中可见,如下所示:
用“原始”日期时间索引绘制 pandas 数据框显示了 2020 年 1 月至 12 月的月份在 x 轴上。作者插图。
尽管此数据框具有日期时间索引,但没有任何关于时区和夏令时的信息。因此,数据框索引是一个原始的日期时间对象。通过检查 pandas 数据框架的一个索引的时区信息可见。
检查数据框架第一个日期时间索引的时区信息。作者插图。
3. 将“原始”日期时间对象本地化为“时区感知”日期时间对象
Python 的 datetime 模块可用于访问、检索和操作日期和时间信息。
默认情况下,datetime.now()
函数返回当前的“本地”日期和时间信息。但是,由于下面代码段中的time_now.tzinfo
返回 None,这意味着它是一个原始的日期时间对象。
截至目前(2023 年 4 月 21 日),我在尼泊尔。因此,我使用pytz包的timezone.localize()
模块将当前时间本地化为“Asia/Kathmandu”时区。现在,time_in_nepal
是一个时区感知的日期时间对象。
要获取德国当前本地时间,可以使用time_in_nepal.astimezone(timezone("Europe/Berlin"))
,这也是一个时区感知的日期时间对象。
第一个单元格显示当前本地时间time_now
作为一个没有时区信息的原始日期时间对象。第二个单元格显示将time_now
本地化为尼泊尔时区。第三个单元格显示将尼泊尔本地时间转换为德国本地时间。作者插图。
4. 本地化 pandas 数据框的时区
接下来,我使用df.tz_localize(tz = "UTC")
将 pandas 数据框的原始索引本地化为 UTC 时区,如下图所示。
将原始数据框索引本地化为 UTC 时区。作者插图。
观察到df
的索引从天真的索引转换为 UTC 时区的时区感知索引,如上所示。
5. 所有可能的时区地址列表
所有可以参考的可能时区地址列表可通过 pytz 包的all_timezones
模块获得。共有 594 个这样的地址。一些地址可以指向相同的时区。例如,Europe/Berlin、Europe/Amsterdam、Europe/Copenhagen 都指向相同的时区。
使用 pytz 模块参考的可能地址列表。插图由作者提供。
6. 为每个城市创建新的数据框,并将 UTC 时区转换为相应的本地时区
df
包含四个城市的 UTC 时区的太阳辐射数据。在这一步中,我从df
的每一列创建了四个数据框。然后,我将新数据框的时区从 UTC 转换为它所属城市或国家的本地时区。例如,df_chitwan
的时区转换使用
df_chitwan.tz_convert(tz = "Asia/Kathmandu")
。
从df
的每一列创建不同的数据框。新数据框的时区从 UTC 转换为相应国家/城市的时区。
需要注意的是,对于有夏令时的国家,这在时区转换中会自动考虑。例如,尼泊尔时间全年与 UTC + 05:45 保持一致。然而,对于悉尼,Python 会自动处理夏令时,因为与 UTC 时区的偏移可以根据年份的不同为 10 小时或 11 小时。
7. 比较不同时间区的太阳辐射数据图
在最后一步中,我想比较四个城市的数据在下列情况下的太阳辐射情况:
a. UTC 时区和
b. 每个城市的本地时区。
在下面的代码片段中,我创建了两个子图来绘制四个城市的太阳辐射。在左侧子图中,绘制了基于 UTC 时区的 2020 年 10 月 1 日的太阳辐射数据。在右侧子图中,绘制了基于每个城市本地时间的 2020 年 10 月 1 日的太阳辐射数据。
fig, (ax1, ax2) = plt.subplots(1, 2, figsize = (20, 6))
fig.suptitle("Solar irradiance on October 1, 2020")
ax1.plot(df.loc["2020–10–01"])
ax1.set_title("Based on UTC time zone")
ax1.xaxis.set_ticks(ticks = df.loc["2020–10–01"].index[::4], labels = np.arange(0, 24, 4))
cities = df.columns.tolist()
handles = ax1.get_legend_handles_labels()[0]
ax1.legend(handles, labels = cities, loc = "upper right")
ax1.set_xlabel("Hour of day")
ax1.set_ylabel("W/m$²$")
ax2.plot(df_chitwan.loc["2020–10–01"].values.tolist())
ax2.plot(df_newyork.loc["2020–10–01"].values.tolist())
ax2.plot(df_bonn.loc["2020–10–01"].values.tolist())
ax2.plot(df_sydney.loc["2020–10–01"].values.tolist())
ax2.xaxis.set_ticks(ticks = np.arange(0, 24, 4), labels = np.arange(0, 24, 4))
handles = ax2.get_legend_handles_labels()[0]
ax2.legend(handles, labels = cities)
ax2.set_title("Based on local time zone of each city/country")
ax2.set_xlabel("Hour of day")
ax2.set_ylabel("W/m$²$")
plt.savefig("output/solar irradiance on october 1.jpeg",
dpi = 300)
plt.show()
图形如下所示:
2020 年 10 月 1 日的太阳辐射。左图:基于 UTC 时区。右图:基于每个城市/国家的本地时区。插图由作者提供。
截至 2020 年 10 月 1 日,与 UTC 时区相比,四个城市的时区如下:奇特旺(UTC+05:45)、纽约(UTC-04:00)、波恩(UTC+02:00)和悉尼(UTC+10:00)。因此,我们可以看到在左图中,奇特旺、纽约、波恩和悉尼的太阳辐射峰值分别出现在 UTC 时区的凌晨 4 点、下午 3 点、上午 10 点和凌晨 3 点。
右侧的图表显示了各城市的太阳辐射在每天的本地时间内有相似的形状。太阳辐射在每天的 5 或 6 点左右开始从零增加,在中午达到峰值,然后在 5 或 6 点前继续下降,最后再次达到零。在这一年的这一天,悉尼接收到的太阳辐射量最高,其次是奇特旺、纽约和波恩。
结论
在本文中,我演示了在处理日期时间对象(包括数据框)时如何处理时区的方法。我使用了四个城市的太阳辐射数据作为示例。这些方法在处理时间序列数据时非常有用,特别是当时区很重要时,例如气象数据。我总结了从本文中学习到的处理 Python 中时区的关键技术,列在以下编号的要点中:
- 可以使用tzinfo模块检查日期时间对象的时区。
2. 当日期时间对象不包含任何关于时区和夏令时的信息时,它被称为naive日期时间对象。
3. 使用pytz包的timezone模块,可以将naive time转换为local time。例如,
time_in_nepal = timezone("Asia/Kathmandu”).localize(datetime.now())
4. 新对象现在是时区感知的。可以使用日期时间对象的astimezone
模块获取不同时区的时间。例如,
german_timezone = timezone(“Europe/Berlin”)
time_in_germany = time_in_nepal.astimezone(german_timezone)
5. 在处理时间序列数据时,将 pandas 数据框的索引转换为日期时间索引是有意义的。
6. 使用df
中的tz_localize
模块和指定的时区可以对 naive 数据框索引进行本地化。例如,
df_utc = df.tz_localize(tz = “UTC”)
7. 数据框对象也可以使用df
的tz_convert
模块转换为不同的时区。
df_nepal = df_utc.tz_convert(tz = “Asia/Kathmandu”)
本文的数据、代码和输出图表可以在此 GitHub 代码库中的notebooks/Timezone_handling
文件夹中找到。感谢您的阅读!
参考文献
OpenStreetMap, 2023. 版权和许可。
NASA Power, 2023a. NASA Power APIs。
NASA Power, 2023b. POWER|数据访问查看器。
NASA Power, 2023c. 参数定义。
实战深度 Q 学习
原文:
towardsdatascience.com/hands-on-deep-q-learning-9073040ce841
强化学习
提升你的代理,以赢得更困难的游戏!
·发表于 Towards Data Science ·14 分钟阅读·2023 年 11 月 25 日
–
照片由 Sean Stratton 提供,来源于 Unsplash
强化学习是机器学习中最迷人的领域之一。与监督学习不同,强化学习模型可以独立学习复杂的过程,即使没有精美整理的数据。
对我来说,看到 AI 代理赢得视频游戏是最有趣的,但你也可以使用强化学习来解决商业问题。只需将其表达为游戏,就可以开始了!你只需要定义……
-
你的代理所处的环境,
-
你的代理可以做出什么决策,以及
-
成功和失败的表现。
AI 代理掌握游戏的示例。接客并将其送到酒店。图片来源:作者。
在继续之前,请阅读我关于强化学习的介绍文章。它会给你更多背景信息,并展示如何自己进行简单但有效的强化学习。它也为本文提供了基础。
开始编写获胜的游戏 AI 代理的第一步
实践者的强化学习指南
在这篇文章中,你将了解深度 Q 学习,为什么我们需要它,以及如何自己实现它以掌握看起来比我另一篇文章中的游戏更复杂的游戏。
你可以在 我的 Github找到代码。
大型观察空间
在上述链接的文章中,我们进行了 Q 学习,使得一个智能体能够在具有小的离散观察空间的一些简单游戏中进行游戏。例如,在 Frozen Lake 游戏中,你可以在 4x4 地图上的 16 个领域(=状态或观察)中站立。在Blackjack 卡牌游戏的 gymnasium 版本中,有 32 · 11 · 2 = 704 个状态。
Q 学习的低效性
在我提到的简单游戏中,Q 学习效果非常好,因为 Q 表保持得相当小。然而,更大的表意味着算法需要更多工作,因此 Q 学习变得更加低效。
这通常发生在你有过多的观察而不是过多的动作时,因为动作空间通常比观察空间小得多。例如,在 Blackjack 环境中,你只有 2 个动作但有 704 个状态。
卡车杆游戏
或者更糟的是:想象一下你的观察空间是连续的,例如在 gymnasium 提供的卡车杆游戏中。在这个游戏中,智能体必须通过左右转动卡车来学习如何平衡杆子。
平衡那个杆子。图片由作者提供。
虽然这个游戏的动作空间仅由两个动作组成——向左或向右——观察空间由
-
车的位置,
-
其速度,
-
杆的角度和
-
杆的角速度,
每一个都是实数。这意味着你的观察空间是无限的,创建一个 Q 表对于它来说……是具有挑战性的。
离散化 Q 学习
作为解决方法,你可以离散化空间,即将其划分为有限数量的桶,并将每个连续状态映射到这些桶之一。然后你可以进行正常的 Q 学习。
四个桶用于卡车位置,并非政治光谱。图片由作者提供。
这就提出了一个问题,你必须决定要使用多少个桶。如果你使用的桶太多,Q 表将会过大。如果桶太少,非常不同的状态将被同等对待,这可能导致智能体性能较差。这可能发生在上面的图像中,例如,其中 0 到 3 之间的所有卡车位置都被视为相同。在模型看来,位置 0.1 与位置 2.9 是一样的。
我们可能在另一篇文章中尝试这种方法,但请注意,它基本上是我们所知道的 Q 学习,只是对观察进行了额外的离散化步骤。然而,在这篇文章中,我们想应用 深度 Q 学习,它能够 直接处理连续观察空间!
深度 Q 学习
之前,我们为 Q 表中的每个状态 s 和动作 a 保留了一个单元。如果这些表格变得过大,我们可以尝试另一种策略:通过函数建模 Q 值!
对于给定的状态 s 和动作 a,我们希望输出 (接近) Q 值。由于我们不能自己构建这个函数——至少我不能——让我们使用一个具有可学习权重的神经网络作为近似。
Q 学习与深度 Q 学习的比较。作者提供的图片。
这不是我编造的,它早在 1993 年就以 QCON(连接主义 Q 学习)的名称在 Long-Ji Lin 的论文 “使用神经网络的机器人强化学习” 中描述过。祝 30 周年快乐!
作者还在其目前的形式中引入了 经验回放 的概念,这种形式在著名的 DeepMind 论文 使用深度强化学习玩 Atari 游戏 中被使用。
注意: 你可能会想知道为什么网络不使用状态 s 和动作 a 作为输入,因为这将是用模型替代表的直接方法。输入状态和动作,接收 Q 值。DeepMind 论文的作者说如下:
有几种可能的方法可以使用神经网络对 Q 进行参数化。由于 Q 将历史-动作对映射到它们的 Q 值的标量估计,一些先前的方法使用历史和动作作为神经网络的输入。[……] 这种架构的主要缺点是需要单独的前向传播来计算每个动作的 Q 值,这导致成本随动作数量线性增长。[……] 我们类型架构的主要优势是能够通过对网络进行单次前向传播来计算给定状态下所有可能动作的 Q 值。
直观理解
在我的另一篇文章中,我告诉你 Q 表中的更新是通过以下公式完成的
作者提供的图片。
用简单的话说,就是我们将旧的价值 Q(s, a) 稍微向目标值 R(s, a) + γ·maxₐ Q(s’, a) 的方向移动,其中 s 是状态,s’ 是在采取行动 a 后的下一个状态,R 是奖励函数,γ < 1 是折扣因子。α 是学习率,告诉我们将旧的 Q 值 Q(s, a) 移动到方向 R(s, a) + γ·maxₐ Q(s’, a) 的程度。
小示例。 假设 Q(s, a) = 3, R(s, a) + γ·maxₐ Q(s’, a) = 4,并且 α = 0.1*。* 那么我们原来的值 3 会更新为 3 + 0.1·(4–3) = 3.1,稍微偏离 3 向 4 的方向。
这意味着这个更新规则逐步改变条目,使得某个时刻我们有 Q(s, a) = R(s, a) + γ·maxₐ Q(s’, a),这也称为贝尔曼方程。
令人惊叹的观察是,我们可以将这个目标转换为一个简单的机器学习任务给我们的神经网络。所以,假设我们在环境中执行一步。我们从状态 s 开始,执行某个动作 a,然后进入新状态 s’。我们还从环境中获得了奖励 r = R(s, a)。接下来,我们按照以下步骤进行训练:
-
计算 N(s) 和 N(s’),它们都是实数向量,每个动作一个向量。
-
查看 N(s) 的 a 号动作,称其为 N(s)[a]。
-
确保 N(s)[a] 更接近 R(s, a) + γ·maxₐ N(s’) 通过执行一个梯度更新步骤来最小化均方误差,这意味着 (R(s, a) + γ·maxₐ N(s’) - Q(s, a))²。
然后我们可以在环境中再进行一步,获取新数据 (s, a, s’, r) (阅读: 代理在状态 s 中,执行 a,进入 s’,并获得奖励 r),然后再次执行步骤 1–3。我们这样做直到有一个足够好的代理,或者我们耗尽耐心或资金进行训练。
经验回放
上述直觉有一个缺陷:它并不像那样有效。当我刚开始时,我实现了这个逻辑,但从未得到过好的模型。
原因是我每次只用单个训练样本更新模型。这就像进行纯随机梯度下降。它可能有效,但在优化过程中,模型参数会剧烈波动,可能很难收敛。另一个问题是后续动作高度相关,因为状态通常不会因为单个动作而变化太多。现在,我们知道一种简单的方法来解决这两个问题:使用小批量!
花哨的术语 经验回放 仅仅是这样。
你不是在环境中执行单个步骤,而是多个步骤,并将记忆 (s, a, s’, r) 存储到某种数据结构中,即回放记忆。它可以是一个数组,但通常,人们使用 双端队列 来实现有限的回放记忆。
图片由作者提供。
新的记忆会将旧的记忆淘汰,一旦回放记忆的大小限制达到。这是有道理的,因为非常旧的记忆不应该对现在发生的事情产生太大影响。这也使得模型能够适应游戏中的新规则。
7 淘汰 1。图片由作者提供。
伪代码
好了,了解了这些知识后,让我们看看《Playing Atari with Deep Reinforcement Learning》论文中的伪代码。唯一的区别是,他们可能会使用某些函数 φ 预处理状态 s(我们这里没有这样做),并且在代理达到终止状态时,即游戏结束时,他们会移除最大项。
来自他们的论文。
现在我们可以在 Python 中实现这个想法了!
实现
首先,让我们导入一些库并定义一些参数。
import random
from collections import deque, namedtuple
import gymnasium as gym
import numpy as np
import tensorflow as tf
from tqdm.auto import tqdm
n_episodes = 1000 # play 1000 games
eps = 0.4 # exploration rate, probability of choosing random action
eps_decay = 0.95 # eps gets multiplied by this number each epoch...
min_eps = 0.1 # ...until this minimum eps is reached
gamma = 0.95 # discount
max_memory_size = 10000 # size of the replay memory
batch_size = 16 # batch size of the neural network training
min_length = 160 # minimum length of the replay memory for training, before it reached this length, no gradient updates happen
memory_parts = ["state", "action", "next_state", "reward", "done"] # nice names for the part of replay memory, otherweise the names are 0-5
这里没有什么特别的,但如果有不清楚的地方请阅读注释。
回放记忆
现在,我们定义了回放,这只是一个 Python deque 的薄包装器。
Memory = namedtuple("Memory", memory_parts) # a single entry of the memory replay
class ReplayMemory:
def __init__(self, max_length=None):
self.max_length = max_length
self.memory = deque(maxlen=max_length)
def store(self, data):
self.memory.append(data)
def _sample(self, k):
return random.sample(self.memory, k)
def structured_sample(self, k):
batch = self._sample(k)
result = {}
for i, part in enumerate(memory_parts):
result[part] = np.array([row[i] for row in batch])
return result
def __len__(self):
return len(self.memory)
在这里,我们定义了一个易于使用的记忆回放。让我们先玩一下,然后再实际使用它。我们可以进行
r = ReplayMemory(max_length=3)
r.store(("a", "b", "c", "e", "f"))
r.store((1, 2, 3, 4, 5))
r.store((6, 7, 8, 9, 0))
print(r.structured_sample(2)) # get 2 random sampples from the replay memory
# Output (for me):
# {
# 'state': array([1, 6]),
# 'action': array([2, 7]),
# 'next_state': array([3, 8]),
# 'reward': array([4, 9]),
# 'done': array([5, 0])
# }
如果我们添加另一个记忆,我们将丢失第一个记忆:
r.store((0, 0, 0, 0, 0))
print(r.memory)
# Output:
# deque([(1, 2, 3, 4, 5), (6, 7, 8, 9, 0), (0, 0, 0, 0, 0)], maxlen=3)
# no more letters in here!
模型
让我们定义一个简单的模型!我们将在这里使用 TensorFlow,但也可以使用 PyTorch,JAX 或其他工具。
model = tf.keras.Sequential(
[
tf.keras.layers.Dense(16, input_shape=(4,), activation="relu"), # state consists of 4 floats
tf.keras.layers.Dense(16, activation="relu"),
tf.keras.layers.Dense(16, activation="relu"),
tf.keras.layers.Dense(16, activation="relu"),
tf.keras.layers.Dense(2, activation="linear"), # 2 actions: go left or go right
]
)
model.compile(
loss=tf.keras.losses.MeanSquaredError(),
optimizer=tf.keras.optimizers.Adam(learning_rate=0.01),
)
训练
准备好了,就让我们开始训练吧。
env = gym.make("CartPole-v1")
replay_memory = ReplayMemory(max_length=max_memory_size)
for episode in tqdm(range(n_episodes)): # tqdm makes a nice proress bar
state, _ = env.reset()
done = False
while not done:
if random.random() < eps:
action = env.action_space.sample() # random action
else:
action = model.predict(state[np.newaxis, :], verbose=False).argmax() # best action according to the model
next_state, reward, done, _, _ = env.step(action)
memory = Memory(state, action, next_state, reward, done)
replay_memory.store(memory)
if len(replay_memory) >= min_length:
batch = replay_memory.structured_sample(batch_size) # get samples from the replay memory
target_batch = batch["reward"] + gamma * model.predict(batch["next_state"], verbose=False).max(axis=1) * (1 - batch["done"]) # R(s, a) + γ·maxₐ N(s') if not a terminal state, otherwise R(s, a)
targets = model.predict(batch["state"], verbose=False)
targets[range(batch_size), batch["action"]] = target_batch # set the target for the action that was done and leave the outputs of other 3 actions as they are
model.fit(batch["state"], targets, verbose=False, batch_size=batch_size) # train for one epoch
state = next_state
eps = max(min_eps, eps * eps_decay)
就这样!它看起来有点像 Q-learning 的代码,但表格中单元格的简单更新被替换为一个 fit
步骤的梯度下降。
结果
代码可能会运行几个小时。你也可以在训练期间记录一些数据,例如每个回合的奖励。另一个常见做法是每隔几个回合记录模型, 这样当训练崩溃时,你不会丢失太多工作。不过,保存模型还有另一个好处:通常情况下,最好的模型不是最新的那个。你可以从我的奖励图中看到这一点:
作者提供的图片。
你可以看到最开始,代理表现很差。大约第 100 集时,代理已经获得了大约 200 的奖励,然后又下降了。大约第 230 集时,模型变得异常优秀,但直到第 1000 集时才又变得较差,那时我结束了训练。这很奇怪,但这是一个我在其他人做强化学习时也常见的模式。
来玩吧!
有什么比看到我们辛苦训练的模型实际运作更好的呢?对,没有,所以让我们开始吧!首先,让我们看看随机代理如何竞争。
随机代理的三次不同运行。白色闪光表示游戏结束。图片由作者提供。
哇,随机代理真的很糟糕。中间的运行开始时看起来还不错,但随机性使其无法向左移动。你可以通过以下方式重现这样的悲惨运行:
env = gym.make("CartPole-v1", render_mode="human")
env.reset()
done = False
while not done:
env.render()
action = env.action_space.sample()
_, _, done, _, _ = env.step(action)
env.close()
但你来这里是为了别的,对吧?
新的挑战者出现
让我们加载第 230 集的模型,看看它在实时测试中的表现如何!
model = tf.keras.models.load_model("PATH/TO/MODEL/230") # the model is in my Github, https://github.com/Garve/towards_data_science/blob/main/A%20Tutorial%20on%20Deep%20Q-Learning/230.zip
env = gym.make("CartPole-v1", render_mode="human")
state, _ = env.reset()
done = False
total_reward = 0
while not done and total_reward < 500: # force end the game after 500 time steps because the model is too good!
env.render()
action = model.predict(state[np.newaxis, :], verbose=False).argmax(axis=1)[0]
state, reward, done, _, _ = env.step(action)
total_reward += reward
env.close()
我们的第 230 集的智能体动作流畅。经过 500 个时间步后,我切断了动画,但智能体并没有失败。图片由作者提供。
看这儿!智能体可以轻松地用最小的动作保持杆子的平衡。然而,你可以看到它略微向右漂移,这在长时间内可能会成为一个问题。让我们将 500 个时间步的人工限制增加到几千个,并查看加速版本:
智能体 230 再次以 60fps 的游戏风格行动。图片由作者提供。
看起来这个智能体表现不错,即使经过了 500 步。太棒了!
结论
在这篇文章中,我们解释了为什么普通的 Q-learning 在处理大规模甚至潜在无限的观察空间时会失败。离散化 Q-learning 在这种情况下可以有所帮助,但你必须考虑你离散化的程度,以找到智能体性能和训练时间之间的平衡。
离散化的 Q-learning 方法完全没问题,但我们选择了另一种成功应用于更复杂游戏的技术——深度 Q-learning。我们不是更新一个可能很大的 Q 表条目,而是训练一个具有固定参数量的模型。
不过,我们还是要诚实。深度 Q-learning 也是在训练时间和智能体性能之间的权衡。像线性回归(没有隐藏层)这样的小型网络可能表现得很糟。如果你有数百万个参数用于摆杆游戏,那就是过度配置了,而且训练需要很长时间。
你也必须找到一个平衡,这不是免费的。
然而,从论文来看,似乎深度 Q-learning 对于像 Atari 游戏 这样更复杂的游戏仍然更有前景。
从感官输入中学习
到目前为止,我们使用了环境的内部状态。在摆杆游戏中,我们获得了汽车的位置、速度、角度等信息。当我们作为人类玩游戏时,通常我们没有这种奢侈。我的意思是,我们无法足够快地处理这些信息以在游戏中表现出色。我们必须依赖视觉反馈——屏幕——来做出快速决策。
如果智能体也只能使用屏幕输出呢?正如《用深度强化学习玩 Atari》的作者所展示的那样,它效果相当好!正如他们所写:
我们将我们的方法应用于来自 Arcade Learning Environment 的七款 Atari 2600 游戏,未对架构或学习算法进行任何调整。我们发现它在六款游戏中优于所有以前的方法,并在三款游戏中超越了人类专家。
作者使用了一个深度卷积神经网络,它接收屏幕,处理它所看到的图像,并输出 Q 值。准确地说,他们不使用单帧图像,而是使用游戏的最后 4 帧。这使模型能够学习屏幕上物体的移动速度和方向。
但我现在先这样,我们可以在另一篇文章中以清新的思维深入探讨这个话题!
希望你今天学到了一些新、趣味且有价值的东西。感谢阅读!
如果你有任何问题,可以在 LinkedIn上联系我!
如果你想更深入了解算法的世界,可以尝试我的新出版物《算法全景》!我仍在寻找作者!
从直观解释到深入分析,算法通过示例、代码和令人惊叹的内容变得生动。
针对产品和工程领导者的动手 GenAI
通过深入了解基于 LLM 的产品,做出更好的产品决策
·
关注 发表在 Towards Data Science · 35 分钟阅读 · 2023 年 11 月 28 日
–
图片由 Bing Image Creator 根据提示“为机器学习驱动的应用程序工作中的产品所有者”生成
介绍
如果你是一个普通司机,你可能不在乎你车的引擎盖下是什么。然而,如果你是设计和执行链条中的一部分,负责打造更好的汽车,了解不同部件是什么以及它们如何协同工作,将帮助你打造更好的汽车。
同样,作为产品负责人、业务领导或负责创建新大型语言模型(LLM)驱动产品的工程师,或将 LLM/生成性 AI 引入现有产品的工程师,了解 LLM 驱动产品的构建模块将帮助你解决与技术相关的战略和战术问题,例如,
-
我们的使用案例是否适合 LLM 驱动的解决方案?也许传统的分析、监督式机器学习或其他方法更合适?
-
如果 LLM 是可行的,我们的使用案例现在或在不久的将来是否可以通过现成的产品(比如 ChatGPT Enterprise)来解决?这是经典的构建与购买决策。
-
我们的 LLM 驱动产品的不同构建模块有哪些?其中哪些已经商品化,哪些可能需要更多时间来构建和测试?
-
我们如何衡量解决方案的性能?有哪些杠杆可以提高我们产品输出的质量?
-
我们的数据质量是否符合使用案例的要求?我们是否正确地组织了数据,并将相关数据传递给了 LLM?
-
我们能否确信 LLM 的回答始终是事实准确的?也就是说,我们的解决方案是否会在生成回答时偶尔出现“幻觉”?
虽然这些问题在文章后面会得到回答,但通过动手实践的目标是建立对 LLM 驱动解决方案的直观理解,这应该有助于你自己回答这些问题,或者至少让你更好地进行进一步研究。
在一篇上一篇文章中,我深入探讨了与构建 LLM 驱动产品相关的一些基础概念。但你不能仅仅通过阅读博客或观看视频来学会驾驶——这需要你亲自上路。好在我们生活的时代提供了免费的工具(这些工具的创建花费了数百万美元),我们可以在不到一小时的时间里构建自己的 LLM 解决方案!因此,在这篇文章中,我建议我们就这样做。这比学习驾驶要容易得多😝。
构建一个允许你与网站“聊天”的聊天机器人
目标:构建一个基于提供的网站信息回答问题的聊天机器人,以更好地理解当前流行的 GenAI 解决方案的构建模块
我们将创建一个基于知识库信息回答问题的问答聊天机器人。这种解决方案模式,称为检索增强生成(RAG),已成为公司中的首选解决方案模式。RAG 之所以受欢迎的一个原因是,它不仅依赖于 LLM 自身的知识,还可以以自动化的方式将外部信息带入 LLM。在实际应用中,外部信息可以来自组织自己的知识库,包含专有信息,以使产品能够回答有关业务、产品、业务流程等问题。RAG 还减少了 LLM 的“幻觉”,即生成的响应是基于提供给 LLM 的信息的。根据 最近的一次演讲,
“RAG 将是企业使用 LLM 的默认方式”
-Dr. Waleed Kadous, Chief Scientist, AnyScale
在我们的实操练习中,我们将允许用户输入一个网站,我们的解决方案将“读取”该网站到其知识库中。然后,解决方案将能够根据网站上的信息回答问题。这个网站是一个占位符——实际上,可以调整为从任何数据源如 PDFs、Excel、其他产品或内部系统等获取文本。这种方法也适用于其他媒体——如图像——但它们需要一些不同的 LLM。目前,我们将重点关注来自网站的文本。
作为示例,我们将使用为本博客创建的示例书单网页:Books I’d Pick Up — If There Were More Hours in the Day! 您也可以使用您选择的其他网站。
这就是我们的结果的样子:
LLM 驱动的聊天机器人可以根据网站上的信息智能回答问题。(图像由作者提供)
以下是我们将遵循的步骤来构建我们的解决方案:
0. 设置 — Google Colaboratory 和 OpenAI API 密钥
1. 创建知识库
2. 搜索与问题相关的上下文
3. 使用 LLM 生成答案
4. 添加“聊天”功能(可选)
5. 添加一个简单的预编码 UI(可选)
0.1. 设置 — Google Colaboratory 和 OpenAI API 密钥
要构建一个 LLM 解决方案,我们需要一个编写和运行代码的地方,以及一个生成问题回答的 LLM。我们将使用 Google Colab 作为代码环境,并使用 ChatGPT 背后的模型作为我们的 LLM。
首先设置 Google Colab,这是一个由 Google 提供的免费服务,可以以易于阅读的格式运行 Python 代码 — 无需在计算机上安装任何东西。我发现将 Colab 添加到 Google Drive 中很方便,这样我就可以轻松找到 Colab 笔记本。
为此,导航至 Google Drive(使用浏览器) > 新建 > 更多 > 连接更多应用 > 在 Google Marketplace 中 搜索 “Colaboratory” > 安装。
要开始使用 Colab,你可以选择 新建 > 更多 > Google Colaboratory。这将会在你的 Google 云端硬盘中创建一个新的笔记本,方便你返回继续使用。
Google Colaboratory 可以在 Google 云端硬盘中访问。(图片由作者提供)
接下来,让我们获取对 LLM 的访问权限。有几个开源和专有选项可供选择。虽然开源 LLM 是免费的,但强大的 LLM 通常需要强大的 GPU 来处理输入和生成响应,且 GPU 的运行成本较低。在我们的示例中,我们将使用 OpenAI 的服务来使用 ChatGPT 所用的 LLM。为此,你需要一个 API 密钥,它类似于用户名/密码的组合,用以让 OpenAI 知道是谁在尝试访问 LLM。根据此时的信息,OpenAI 为新用户提供了 $5 的信用额度,足以用于本实践教程。以下是获取 API 密钥的步骤:
访问OpenAI 平台网站> 开始使用 > 注册,使用电子邮件和密码进行注册,或使用 Google 或 Microsoft 帐户注册。你还可能需要一个电话号码进行验证。
登录后,点击右上角的个人资料图标 > 查看 API 密钥 > 创建新密钥。密钥将类似于以下内容(仅供参考的假密钥)。请保存以备后用。
sk-4f3a9b8e7c4f4c8f8f3a9b8e7c4f4c8f-UsH4C3vE64
现在,我们已经准备好构建解决方案了。
0.2. 准备构建解决方案的笔记本
我们需要在 Colab 环境中安装一些软件包以方便我们的解决方案。只需在 Colab 中的文本框(称为“单元格”)中输入以下代码,然后按“Shift + Return(Enter)”。或者,直接点击单元格左侧的“播放”按钮或使用笔记本顶部的“运行”菜单。你可能需要使用菜单插入新的代码单元格以运行后续代码:
# Install OpenAI & tiktoken packages to use the embeddings model as well as the chat completion model
!pip install openai tiktoken
# Install the langchain package to facilitate a most of the functionality in our solution, from processing documents to enabling "chat" using LLM
!pip install langchain
# Install ChromaDB - an in-memory vector database package - to save the "knowledge" relied on by our solution to answer questions
!pip install chromadb
# Install HTML to text package to transform webpage content to a more human readable format
!pip install html2text
# Install gradio to create a basic UI for our solution
!pip install gradio
接下来,我们应该从已安装的软件包中提取代码,以便在编写的代码中使用这些软件包。你可以使用新的代码单元格并再次按“Shift + Return” — 以这种方式继续进行每个后续的代码块。
# Import packages needed to enable different functionality for the solution
from langchain.document_loaders import AsyncHtmlLoader # To load website content into a document
from langchain.text_splitter import MarkdownHeaderTextSplitter # To document into smaller chunks by document headings
from langchain.document_transformers import Html2TextTransformer # To converrt HTML to Markdown text
from langchain.chat_models import ChatOpenAI # To use OpenAI's LLM
from langchain.prompts import PromptTemplate # To formulate instructions / prompts
from langchain.chains import RetrievalQA, ConversationalRetrievalChain # For RAG
from langchain.memory import ConversationTokenBufferMemory # To maintain chat history
from langchain.embeddings.openai import OpenAIEmbeddings # To convert text to numerical representation
from langchain.vectorstores import Chroma # To interact with vector database
import pandas as pd, gradio as gr # To show data as tables, and to build UI respectively
import chromadb, json, textwrap # Vector database, converting json to text, and prettify printing respectively
from chromadb.utils import embedding_functions # Setting up embedding function, following protocol required by Chroma
最后,将 OpenAI API 密钥添加到一个变量中。请注意,这个密钥类似于你的密码 — 请勿分享。此外,在分享你的 Colab 笔记本前,请务必先删除 API 密钥。
# Add your OpenAI API Key to a variable
# Saving the key in a variable like so is bad practice. It should be loaded into environment variables and loaded from there, but this is okay for a quick demo
OPENAI_API_KEY='sk-4f3a9b8e7c4f4c8f8f3a9b8e7c4f4c8f-UsH4C3vE64' # Fake Key - use your own real key here
现在我们准备开始构建解决方案。以下是接下来步骤的高级视图:
构建 RAG 解决方案的核心步骤(图片由作者提供)
在编码时,我们将使用 LangChain,它已经成为构建此类解决方案的流行框架。它有助于从连接数据源到发送和接收 LLM 信息的每个步骤。LlamaIndex 是另一个简化构建 LLM 驱动应用的选项。虽然并不严格要求使用 LangChain(或 LlamaIndex),并且在某些情况下,高级抽象可能使团队对内部发生的事情一无所知,但我们将使用 LangChain,但仍会经常查看内部情况。
请注意,由于创新的速度如此之快,可能会有代码中使用的包更新,有些更新可能会导致代码停止工作,除非相应地进行更新。我不打算保持代码的最新状态。然而,本文旨在作为演示,代码可以作为参考或起点,您可以根据需要进行调整。
1. 创建知识库
1.1. 确定并读取文档 让我们访问书单并将内容读取到我们的 Colab 环境中。内容最初以 HTML 格式加载,这对网页浏览器很有用。然而,我们将使用 HTML 转文本工具将其转换为更易读的格式。
url = "https://ninadsohoni.github.io/booklist/" # Feel free to use any other website here, but note that some code might need to be edited to display contents properly
# Load HTML from URL and transform to a more readable text format
docs = Html2TextTransformer().transform_documents(AsyncHtmlLoader(url).load())
# Let's take a quick peek again to see what do we have now
print("\n\nIncluded metadata:\n", textwrap.fill(json.dumps(docs[0].metadata), width=100), "\n\n")
print("Page content loaded:")
print('...', textwrap.fill(docs[0].page_content[2500:3000], width=100, replace_whitespace=False), '...')
以下是运行代码在 Google Colab 上生成的内容:
执行上述代码的结果。网站内容被加载到 Colab 环境中。(图片由作者提供)
1.2. 将文档分解为较小的摘录 在我们将博客的信息加载到我们的知识库(即我们选择的数据库)之前,还有一步。文本不应按原样加载到数据库中。它应首先分割成较小的块。这有几个原因:
-
如果我们的文本太长,由于超出文本长度阈值(即*“上下文大小”*),则不能发送给 LLM。
-
较长的文本可能包含广泛且松散相关的信息。我们将依赖 LLM 挑选出相关部分——这可能不会总是如预期那样有效。使用较小的块,我们可以利用检索机制来识别仅相关的信息并发送给 LLM,如我们后面将看到的。
-
LLM 对文本的开始和结束部分关注较强,因此较长的块可能导致 LLM 对后面更多的内容关注较少(称为*“在中间迷失”*)。
每个用例的适当块大小将根据用例的具体情况而有所不同,包括内容类型、使用的 LLM 及其他因素。明智的做法是尝试不同的块大小并在确定解决方案之前评估响应质量。在本演示中,我们将使用上下文感知的分块,其中书单中的每个书籍推荐都有自己的块。
# Now we split the entire content of the website into smaller chunks
# Each book review will get its own chunk since we are splitting by headings
# The LangChain splitter used here will also create a set of metadata from headings and associate it with the text in each chunk
headers_to_split_on = [ ("#", "Header 1"), ("##", "Header 2"),
("###", "Header 3"), ("####", "Header 4"), ("#####", "Header 5") ]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on = headers_to_split_on)
chunks = splitter.split_text(docs[0].page_content)
print(f"{len(chunks)} smaller chunks generated from original document")
# Let's look at one of the chunks
print("\nLooking at a sample chunk:")
print("Included metadata:\n", textwrap.fill(json.dumps(chunks[5].metadata), width=100), "\n\n")
print("Page content loaded:")
print(textwrap.fill(chunks[5].page_content[:500], width=100, drop_whitespace=False), '...')
原始内容拆分后的众多文档块之一。(图片由作者提供)
请注意,如果迄今为止创建的文本块仍然比所需的长度长,可以使用其他文本拆分算法进一步拆分,这些算法可以通过 LangChain 或 LlamaIndex 轻松获得。例如,每本书的评论可以根据需要拆分为段落。
1.3. 将摘录加载到知识库 文本块现在准备好加载到知识库中。这些文本块首先通过嵌入模型转换为一系列捕捉文本意义的数字。然后,实际的文本及其数值表示(即嵌入)将加载到向量数据库——我们的知识库中。请注意,嵌入也由大语言模型(LLMs)生成,只是与聊天 LLM 的类型不同。如果你想了解更多关于嵌入的内容,上一篇文章通过示例展示了这一概念。
我们将使用向量数据库来存储所有信息。这将实现我们的知识库。向量数据库是专门设计用于通过嵌入相似性进行搜索的。如果我们想从数据库中搜索某些内容,搜索词首先通过嵌入模型转换为数值表示,然后将问题的嵌入与数据库中的所有嵌入进行比较。与问题嵌入最接近的记录(在我们的例子中,就是关于每本书的文本块)作为搜索结果返回,只要它们超过了一个阈值。
# We will get embeddings for each chunk (and subsequently questions) using an embeddings model from OpenAI
openai_embedding_func = embedding_functions.OpenAIEmbeddingFunction(api_key=OPENAI_API_KEY)
# Initialize vector DB and create a collection
persistent_chroma_client = chromadb.PersistentClient()
collection = persistent_chroma_client.get_or_create_collection("my_rag_demo_collection", embedding_function=openai_embedding_func)
cur_max_id = collection.count() # To not overwrite existing data
# Let's add data to our collection in the vector DB
collection.add(
ids=[str(t) for t in range(cur_max_id+1, cur_max_id+len(chunks)+1)],
documents=[t.page_content for t in chunks],
metadatas=[None if len(t.metadata) == 0 else t.metadata for t in chunks]
)
print(f"{collection.count()} documents in vector DB")
# 25 documents in vector DB
# Optional: We will write a scrappy helper function to print data slightly better -
# it limits the length embeddings shown on the screen (since these are over a 1,000 long numbers).
# Also shows a subset of text from documents as well as metadatas fields
def render_vectorDB_content(chromadb_collection):
vectordb_data = pd.DataFrame(chromadb_collection.get(include=["embeddings", "metadatas", "documents"]))
return pd.DataFrame({'IDs': [str(t) if len(str(t)) <= 10 else str(t)[:10] + '...'for t in vectordb_data.ids],
'Embeddings': [str(t)[:27] + '...' for t in vectordb_data.embeddings],
'Documents': [str(t) if len(str(t)) <= 300 else str(t)[:300] + '...' for t in vectordb_data.documents],
'Metadatas': ['' if not t else json.dumps(t) if len(json.dumps(t)) <= 90 else '...' + json.dumps(t)[-90:] for t in vectordb_data.metadatas]
})
# Let's take a look at what is in the vector DB using our helper function. We will look at the first 4 chunks
render_vectorDB_content(collection)[:4]
加载到向量数据库中的前几个文本块及其数值表示(即嵌入)的视图。(图片由作者提供)
2. 搜索问题相关的上下文
我们的终极目标是让我们的解决方案从向量数据库知识库中挑选相关信息,并将其与我们希望 LLM 回答的问题一起传递给 LLM。让我们尝试一下向量数据库搜索,询问问题“你能推荐几本侦探小说吗?”
# Here we are linking to the previously created an instance of the ChromaDB database using a LangChain ChromaDB client
vectordb = Chroma(client=persistent_chroma_client, collection_name="my_rag_demo_collection",
embedding_function=OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY)
)
# Optional - We will define another scrappy helper function to print data slightly better
def render_source_documents(source_documents):
return pd.DataFrame({'#': range(1, len(source_documents) + 1),
'Documents': [t.page_content if len(t.page_content) <= 300 else t.page_content[:300] + '...' for t in source_documents],
'Metadatas': ['' if not t else '...' + json.dumps(t.metadata, indent=2)[-88:] for t in source_documents]
})
# Here's where we are compiling the question
question = "Can you recommend a few detective novels?"
# Running the search against the vector DB based on our question
relevant_chunks = vectordb.similarity_search(question)
# Printing results
print(f"Top {len(relevant_chunks)} search results")
render_source_documents(relevant_chunks)
问题“你能推荐几本侦探小说吗?”的搜索结果前 4 名(图片由作者提供)
默认情况下,我们得到前 4 个结果,除非我们明确设置不同的值。在这个例子中,排名第一的结果是一本福尔摩斯小说,直接提到了“侦探”一词。第二个结果(《杰克尔的日子》)虽然没有“侦探”一词,但提到了“警察机构”和“揭露阴谋”,这些与“侦探小说”在语义上相关。第三个结果(《卧底经济学家》)提到了“卧底”一词,尽管它是关于经济学的。我认为最后一个结果被获取是因为它与小说/书籍有关,而不是特定的“侦探小说”,因为请求了四个结果。
同样,使用向量数据库并不是绝对必要的。您可以加载嵌入并在其他存储形式中进行搜索。“普通”关系数据库甚至 Excel 都可以使用。但您需要在应用程序逻辑中处理“相似性”计算,当使用 OpenAI 嵌入时,这可以是点积。另一方面,向量数据库为您处理了这些。
请注意,如果我们想通过元数据预筛选一些搜索结果,我们可以这样做。为了演示,我们将根据元数据中从书单加载的“Header 2”过滤。
# Let's try filtering and accessing records that match a specific metadata filter. This can probably be transformed into a pre-filter if needed
pd.DataFrame(vectordb.get(where = {'Header 2': 'Finance'}))
基于应用元数据预筛选的搜索结果,仅显示关键列。(图像由作者提供)
LLM 提供了一个有趣的机会,即利用 LLM 本身来检查用户问题,审查可用的元数据,评估是否需要和可能进行基于元数据的预筛选,并制定预筛选查询代码,这些代码可以在向量数据库上实际预筛选数据。有关更多信息,请参见 LangChain 的 自查询检索器。
3. 使用 LLM 生成答案
接下来,我们将向 LLM 添加指令,基本上是说“我将给你一些信息片段和一个问题。请使用提供的信息片段回答问题”。然后,我们将这些指令、向量数据库中的搜索结果和我们的问题打包,并发送给 LLM 进行回应。所有这些步骤都由以下代码完成。
请注意,LangChain 提供了抽象一些代码的机会,因此您的代码不必像以下代码那样冗长。然而,以下代码的目的是展示发送到语言模型的指令。这里也是自定义这些指令的地方——例如,在这种情况下,默认指令被更改为请求 LLM 尽可能简洁地回应。如果默认设置适用于您的用例,您的代码可以完全跳过问题模板部分,LangChain 会在向 LLM 发送请求时使用其包中的默认提示。
# Let's select the language model behind free version of ChatGPT: GPT-3.5-turbo
llm = ChatOpenAI(model_name = 'gpt-3.5-turbo', temperature = 0, openai_api_key = OPENAI_API_KEY)
# Let's build a prompt. This is what actually gets sent to the ChatGPT LLM, with the context from our vector database and question injected into the prompt
template = """Use the following pieces of context to answer the question at the end.
If you don't know the answer, just say that the available information is not sufficient to answer the question.
Don't try to make up an answer. Keep the answer as concise as possible, limited to five sentences.
{context}
Question: {question}
Helpful Answer:"""
QA_CHAIN_PROMPT = PromptTemplate.from_template(template)
# Define a Retrieval QA chain, which will take the question, get the relevant context from the vectorDB, and pass both to the language model for a response
qa_chain = RetrievalQA.from_chain_type(llm,
retriever=vectordb.as_retriever(),
return_source_documents=True,
chain_type_kwargs={"prompt": QA_CHAIN_PROMPT}
)
现在,让我们再次请求侦探小说的推荐,看看我们会得到什么回应。
# Let's ask a question and run our question-answering chain
question = "Can you recommend a few detective novels?"
result = qa_chain({"query": question})
# Let's look at the result
result["result"]
推荐侦探小说的解决方案回应(图像由作者提供)
让我们确认模型是否回顾了我们从向量数据库中获得的所有四个搜索结果,还是仅仅获取了响应中提到的两个结果?
# Let's look at the source documents used as context by the LLM
# We will use our helper function from before to limit the size of the information shown
render_source_documents(result["source_documents"])
与问题一起传递给 LLM 的上下文,以便于响应。(图像由作者提供)
我们可以看到,LLM 仍然访问了所有四个搜索结果,并推断出只有前两本书是侦探小说。
请注意,LLM 的响应每次提问时可能会有所不同,尽管发送的是相同的指令和来自向量数据库的相同信息。例如,在询问关于奇幻书籍推荐时,LLM 有时给出三本书籍推荐,有时则更多——尽管都是来自书单。在所有情况下,最推荐的书籍保持不变。请注意,这些变化发生在将一致性——创造力谱——即“温度”参数配置为 0 以最小化差异的情况下。
4. 添加“聊天”功能(可选)
现在,解决方案具备了必要的核心功能——它能够从网站中读取信息并根据这些信息回答问题。但目前它并未提供“对话式”的用户体验。感谢 ChatGPT,“聊天界面”已成为主流设计:我们现在期望这成为与生成式 AI 尤其是 LLM 互动的“自然”方式 😅。实现聊天界面的第一步涉及向解决方案中添加“内存”。
这里的“内存”是一种假象,LLM 实际上并不记住到目前为止的对话——它需要在每次回合中展示完整的对话记录。因此,如果用户向 LLM 提问后续问题,解决方案将打包原始问题、LLM 的原始答案以及后续问题,并将其发送给 LLM。LLM 阅读整个对话并生成有意义的响应以继续对话。
在问答聊天机器人中,如我们正在构建的这个,通常需要进一步扩展此方法,因为存在中间步骤需要从向量数据库中提取相关信息以制定对用户后续问题的响应。在问答聊天机器人中,“内存”是这样模拟的:
-
将所有问题和响应保留(在一个变量中)作为“聊天记录”
-
当用户提问时,将聊天记录和新问题发送给 LLM,并要求生成一个独立的问题
-
此时,聊天记录不再需要。使用独立的问题在向量数据库上进行新的搜索
-
将独立问题和搜索结果传递给 LLM,并附上指令以获得最终答案。这个步骤类似于我们在之前阶段“使用 LLM 生成答案”中实施的步骤
虽然我们可以使用简单的变量来跟踪聊天记录,但我们将使用 LangChain 的一种内存类型。我们将使用的特定内存对象提供了一个很好的功能,即在达到你指定的大小限制时自动截断较旧的聊天记录,通常是选定 LLM 可以接受的文本大小。在我们的案例中,LLM 应该能够接受略超过 4,000 个“tokens”(即词汇部分),这大约是 3,000 个单词或 ~5 页的 Word 文档。OpenAI 提供了相同 ChatGPT LLM 的 16k 变体,可以接受 4 倍的输入。因此,需要配置内存大小。
这是实现这些步骤的代码。同样,LangChain 提供了更高层次的抽象,代码不必如此明确。这个版本只是为了展示发送到 LLM 的底层指令——首先将聊天记录浓缩成一个独立的单一问题,然后用于向量数据库搜索,其次根据向量数据库搜索结果生成对生成的独立问题的响应。
# Let's create a memory object to track chat history. This will start accumulating human messages and AI responses.
# Here a "token" based memory is used to restrict the length of chat history to what can be passed into the selected LLM.
# Generally, the maximum token length configured will depend on the LLM. Assuming we are using the 4K version of the LLM,
# we will set the token maximum to 3K, to allow some room for the question prompt.
# The LLM parameter is to make LangChain aware of the tokenization scheme of the selected LLM.
memory = ConversationTokenBufferMemory(memory_key="chat_history", return_messages=True, input_key="question", output_key="answer", max_token_limit=3000, llm=llm)
# While LangChain includes a default prompt to generate a standalone question based on the users' latest question and
# any context from the conversation up to that point, we will extend the default prompt with additional instructions.
standalone_question_generator_template = """Given the following conversation and a follow up question,
rephrase the follow up question to be a standalone question, in its original language.
Be as explicit as possible in formulating the standalone question, and
include any context necessary to clarify the standalone question.
Conversation:
{chat_history}
Follow Up Question: {question}
Standalone question:"""
updated_condense_question_prompt = PromptTemplate.from_template(standalone_question_generator_template)
# Let's rebuild the final prompt (again, optional since LangChain uses a default prompt, though it might be a little different)
final_response_synthesizer_template = """Use the following pieces of context to answer the question at the end.
If you don't know the answer, just say that the available information is not sufficient to answer the question.
Don't try to make up an answer. Keep the answer as concise as possible, limited to five sentences.
{context}
Question: {question}
Helpful Answer:"""
custom_final_prompt = PromptTemplate.from_template(final_response_synthesizer_template)
qa = ConversationalRetrievalChain.from_llm(
llm=llm,
retriever=vectordb.as_retriever(),
memory=memory,
return_source_documents=True,
return_generated_question=True,
condense_question_prompt= updated_condense_question_prompt,
combine_docs_chain_kwargs={"prompt": custom_final_prompt})
# Let's again ask the question we previously asked the retrieval QA chain
query = "Can you recommend a few detective novels?"
result = qa({"question": query})
print(textwrap.fill(result['answer'], width=100))
解决方案的侦探小说推荐。与之前仅使用“问答”能力而没有“记忆”时收到的响应相同(图片由作者提供)
让我们提出一个后续问题,并查看响应以验证解决方案现在具有“记忆”并且可以对后续问题进行对话式回答:
query = "Tell me more about the second book"
result = qa({"question": query})
print(textwrap.fill(result['answer'], width=100))
对“第二本书”的更多信息的后续问题的响应。解决方案返回更多关于同一本书的信息(图片由作者提供)
让我们看看在引擎盖下发生了什么,以验证解决方案确实经过了本节开头概述的四个步骤。让我们从聊天记录开始,以验证解决方案确实记录了到目前为止的对话:
# Let's look at chat history upto this point
result['chat_history']
提问第二个问题后的聊天记录。注意此时响应也包括在对话中。(图片由作者提供)
让我们看看解决方案除了聊天记录外还跟踪了什么:
# Let's print the other parts of the results
print("Here is the standalone question generated by the LLM based on chat history:")
print(textwrap.fill(result['generated_question'], width=100 ))
print("\nHere are the source documents the model referenced:")
display(render_source_documents(result['source_documents']))
print(textwrap.fill(f"\nGenerated Answer: {result['answer']}", width=100, replace_whitespace=False) )
提问第二个问题后,除聊天记录之外的输出。(图片由作者提供)
解决方案内部使用 LLM 将问题*“告诉我更多关于第二本书的事”转换为“你能提供更多关于弗雷德里克·福赛斯的《贼日》‘The Day of the Jackal’的信息吗?”*。有了这个问题,解决方案能够在向量数据库中搜索任何相关信息,并这次首先检索到《贼日》的信息。虽然注意到也包括了一些关于其他书籍的无关搜索结果。
快速的可选侧边栏讨论潜在问题
潜在问题 #1 — 独立问题生成不佳: 在我的测试中,聊天解决方案在生成一个好的独立问题时并不总是成功,直到调整了问题生成器提示。例如,对于一个后续问题,“告诉我关于第二本书的事”,生成的后续问题往往是“你能告诉我关于第二本书的什么?”这本身并没有特别的意义,并导致随机的搜索结果,因此 LLM 的生成响应看起来也是随机的。
潜在问题 #2 — 原始问题与跟进问题之间的搜索结果变化: 值得注意的是,即使第二个生成的问题明确提到感兴趣的书籍,向量数据库搜索返回的结果中也包括其他书籍的结果,更重要的是,这些搜索结果与原始问题的结果不同!在这个例子中,这种搜索结果的变化是期望的,因为问题从“侦探小说推荐”变成了特定的小说。然而,当用户提出跟进问题以深入探讨某个主题时,问题表述的变化或 LLM 生成的独立问题可能会导致不同的搜索结果或搜索结果的不同排序,这可能不是期望的。
这个问题可能会在一定程度上自动得到缓解,至少是通过对向量数据库进行更广泛的初步搜索——返回更多的结果,而不仅仅是我们示例中的 4-5 个——并对结果进行重新排序,以确保最相关的结果上升到顶部并始终发送到 LLM 以生成最终响应(参见Cohere 的“重新排序”)。此外,应用程序应相对容易识别搜索结果是否发生了变化。可能可以应用一些启发式方法来判断搜索结果的变化程度(通过排序和重叠度量)以及问题变化的程度(通过余弦相似度等距离度量)是否匹配。至少在搜索结果在聊天轮次中出现意外波动的情况下,可以根据用例的关键性以及最终用户的培训或素养,提醒最终用户并让他们参与更详细的检查。
控制这种行为的另一个想法是利用 LLM 来决定跟进问题是否需要再次访问向量数据库,或者这个问题是否可以用之前获取的结果有意义地回答。一些用例可能希望生成两组搜索结果和回答,让 LLM 在答案之间裁定,另一些用例可能通过赋予用户冻结上下文的能力来将控制上下文的责任转交给用户(这取决于用例、用户培训或素养以及其他考虑因素),还有一些用例可能对跟进问题中搜索结果的变化持宽容态度。
正如你可能已经看出,获得一个基本解决方案是相对简单的,但做到完美——这才是难点。这里提到的问题只是冰山一角。好了,回到主要任务 …
5. 添加预编码 UI
最后,聊天机器人的功能已经准备好。现在,我们可以添加一个漂亮的用户界面以改善用户体验。这是(某种程度上)由于 Python 库如 Gradio 和 Streamlit,使得基于 Python 编写的指令构建前端小部件成为可能。在这里,我们将使用 Gradio 快速创建一个用户界面。
以使任何尚未执行代码的人能够跟上进度,并演示一些达到相同结果的变体,以下两个代码块是自包含的,可以在全新的 Colab 笔记本中运行,以生成完整的聊天机器人。
# Initial setup - Install necessary software in code environment
!pip install openai tiktoken langchain chromadb html2text gradio # Uncomment by removing '#' at the beginning if you're starting here and haven't yet installed anything
# Import packages needed to enable different functionality for the solution
from langchain.document_loaders import AsyncHtmlLoader # To load website content into a document
from langchain.text_splitter import MarkdownHeaderTextSplitter # To document into smaller chunks by document headings
from langchain.document_transformers import Html2TextTransformer # To converrt HTML to Markdown text
from langchain.chat_models import ChatOpenAI # To use OpenAI's LLM
from langchain.prompts import PromptTemplate # To formulate instructions / prompts
from langchain.chains import RetrievalQA, ConversationalRetrievalChain # For RAG
from langchain.memory import ConversationTokenBufferMemory # To maintain chat history
from langchain.embeddings.openai import OpenAIEmbeddings # To convert text to numerical representation
from langchain.vectorstores import Chroma # To interact with vector database
import pandas as pd, gradio as gr # To show data as tables, and to build UI respectively
import chromadb, json, textwrap # Vector database, converting json to text, and prettify printing respectively
from chromadb.utils import embedding_functions # Setting up embedding function, following protocol required by Chroma
# Add the OpenAI API Key to a variable
# Saving the key in a variable like so is bad practice. It should be loaded into environment variables and loaded from there, but this is okay for a quick demo
OPENAI_API_KEY='sk-4f3a9b8e7c4f4c8f8f3a9b8e7c4f4c8f-UsH4C3vE64' # Fake Key - use your own real key here
在运行下一组代码以呈现聊天机器人 UI 之前,请注意,当通过 Colab 渲染时,应用程序对于任何拥有链接的人公开可访问 3 天(链接在 Colab 笔记本单元输出中提供)。理论上,可以通过将代码中的最后一行更改为*demo.launch(share=False)*来保持应用的私密性,但那时我无法使应用正常工作。相反,我更倾向于在 Colab 中以“调试”模式运行,这样 Colab 单元会“运行”直到停止,然后终止聊天机器人。或者,在不同的 Colab 单元中运行下面的代码,以终止聊天机器人并删除在 Colab 中加载到 Chroma 向量数据库中的内容。
# To be run at the end to terminate the demo chatbot
demo.close() # To end the chat session and terminate the shared demo
# Retrieve and delete the vector DB collection created for the chatbot
vectordb = Chroma(client=persistent_chroma_client, collection_name="my_rag_demo_collection", embedding_function=openai_embedding_func_for_langchain)
vectordb.delete_collection()
以下是将聊天机器人作为应用运行的代码。大部分代码重复了到目前为止的文章中的代码,所以应该很熟悉。请注意,与之前的代码相比,下面的代码存在一些差异,包括但不限于没有使用之前用过的 LangChain 的‘token’内存对象进行内存管理。这意味着随着对话的继续,历史记录将变得过长,无法传递给语言模型的上下文,应用需要重启。
# Initiate OpenAI embedding functions. There are two because the function protocol is different when passing the function to Chroma DB directly vs using it with Chroma DB via LangChain
openai_embedding_func_for_langchain = OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY)
openai_embedding_func_for_chroma = embedding_functions.OpenAIEmbeddingFunction(api_key=OPENAI_API_KEY)
# Initiate the LangChain chat model object using the GPT 3.5 turbo model
llm = ChatOpenAI(model_name='gpt-3.5-turbo', temperature=0, openai_api_key=OPENAI_API_KEY)
# Initialize vector DB and create a collection
persistent_chroma_client = chromadb.PersistentClient()
collection = persistent_chroma_client.get_or_create_collection("my_rag_demo_collection", embedding_function=openai_embedding_func_for_chroma)
# Function to load website content into vector DB
def load_content_from_url(url):
# Load HTML from URL and transform to a more readable text format
docs = Html2TextTransformer().transform_documents(AsyncHtmlLoader(url).load())
# Split docs by section
headers_to_split_on = [ ("#", "Header 1"), ("##", "Header 2"), ("###", "Header 3"), ("####", "Header 4"), ("#####", "Header 5") ]
chunks = MarkdownHeaderTextSplitter(headers_to_split_on = headers_to_split_on).split_text(docs[0].page_content)
# Here we are linking to the previously created an instance of the ChromaDB database
vectordb_collection = persistent_chroma_client.get_or_create_collection("my_rag_demo_collection", embedding_function=openai_embedding_func_for_chroma)
# Let's add data to the vector DB; specifically to our collection in the vector DB
cur_max_id = vectordb_collection.count()
vectordb_collection.add(ids=[str(t) for t in range(cur_max_id+1, cur_max_id+len(chunks)+1)],
documents=[t.page_content for t in chunks],
metadatas=[None if len(t.metadata) == 0 else t.metadata for t in chunks]
)
# Alert user that content is loaded and ready to be queried
gr.Info(f"Website content loaded. Vector DB now has {vectordb_collection.count()} chunks")
return
# Define the UI and the chat function
with gr.Blocks() as demo:
# Function to chat with language model using documents for context
def predict(message, history):
# Here we are linking to the previously created an instance of the ChromaDB database using a LangChain ChromaDB client
langchain_chroma = Chroma(client=persistent_chroma_client, collection_name="my_rag_demo_collection", embedding_function=openai_embedding_func_for_langchain)
# Convert to langchain chat history format - list of tuples rather than list of lists
langchain_history_format = []
for human, ai in history:
langchain_history_format.append((human, ai))
# We are now defining the ConversationalRetrieval chain, starting with the prompts used
standalone_question_generator_template = """Given the following conversation and a follow up question,
rephrase the follow up question to be a standalone question, in its original language. Be as explicit as possible
in formulating the standalone question, and include any context necessary to clarify the standalone question.
Conversation: {chat_history}
Follow Up Question: {question}
Standalone question:"""
updated_condense_question_prompt = PromptTemplate.from_template(standalone_question_generator_template)
# Let's rebuild the final prompt (again, optional since LangChain uses a default prompt, though it might be a little different)
final_response_synthesizer_template = """Use the following pieces of context to answer the question at the end.
If you don't know the answer, just say that the available information is not sufficient to answer the question.
Don't try to make up an answer. Keep the answer as concise as possible, limited to five sentences.
{context}
Question: {question}
Helpful Answer:"""
custom_final_prompt = PromptTemplate.from_template(final_response_synthesizer_template)
# Define the chain
qa_chain = ConversationalRetrievalChain.from_llm(llm, retriever=langchain_chroma.as_retriever(),
return_source_documents=True, return_generated_question=True,
condense_question_prompt= updated_condense_question_prompt,
combine_docs_chain_kwargs={"prompt": custom_final_prompt})
# Execute the chain
gpt_response = qa_chain({"question": message, "chat_history": langchain_history_format})
# Add human message and LLM response to chat history
langchain_history_format.append((message, gpt_response['answer']))
return gpt_response['answer']
gr.Markdown(
"""
# Chat with Websites
### Enter URL to extract content from website and start question-answering using this Chatbot
"""
)
with gr.Row():
url_text = gr.Textbox(show_label=False, placeholder='Website URL to load content', scale = 5)
url_submit = gr.Button(value="Load", scale = 1)
url_submit.click(fn=load_content_from_url, inputs=url_text)
with gr.Row():
gr.ChatInterface(fn=predict)
demo.launch(debug=True)
你可以通过给应用提供不同的 URL 来加载内容进行尝试。无需多言:这不是一个生产级应用,只是用来演示基于 RAG 的 GenAI 解决方案的构建块。这充其量是一个早期原型,如果要将其转换为常规产品,大部分的软件工程工作还在前面。
重新审视介绍中的常见问题
根据我们创建的聊天机器人背景和知识,让我们重新审视在介绍中提出的一些问题,并深入探讨一下。
-
我们的用例是否适合 LLM 驱动的解决方案?也许传统的分析方法、监督学习或其他方法更合适?
LLM 在“理解”语言相关任务以及遵循指令方面表现出色。因此,LLM 的早期使用案例包括问答、总结、生成(在这里是文本)、提供更好的基于意义的搜索、情感分析、编码等。LLM 还获得了解决问题和推理的能力。例如,LLM 可以充当学生作业的自动评分员,只要你提供答案键,甚至有时不提供。
另一方面,基于大量数据点的预测或分类、用于营销优化的多臂赌博机实验、推荐系统、强化学习系统(如 Roomba、Nest 温控器、优化能源消耗或库存水平等)是其他类型分析或机器学习的强项……至少目前是这样。传统 ML 模型向 LLM 提供信息和反向传递信息的混合方法也应考虑作为解决核心业务问题的整体方案。
-
如果 LLM 是可行的解决方案,我们的使用案例是否可以通过现成的产品(例如,ChatGPT Enterprise)现在或在不久的将来得到解决?经典的构建与购买决策。
OpenAI、AWS 和其他公司提供的服务和产品将变得更加广泛、更好,可能还更便宜。例如,ChatGPT 允许用户上传文件进行分析,Bing Chat 和 Google 的 Bard 让你指向外部网站进行问答,AWS Kendra 将语义搜索引入企业的信息中,Microsoft Copilot 让你在 Word、Powerpoint、Excel 等应用中使用 LLM。正如公司不会自己构建操作系统或数据库一样,公司也应该考虑是否需要构建可能被当前和未来的现成产品所取代的 AI 解决方案。另一方面,如果公司的使用案例非常具体或在某种程度上受限,例如由于敏感性或法规指导不能将敏感数据发送给任何供应商,那么可能需要在公司内部构建生成性 AI 产品以解决这些使用案例。使用 LLM 推理能力但承担的任务或生成的输出与现成解决方案差异太大的产品可能需要内部开发。例如,监控工厂车间、制造过程或库存水平的系统可能需要定制开发,特别是当没有好的领域特定产品时。此外,如果应用需要专业领域知识,那么在领域特定数据上微调的 LLM 可能会优于 OpenAI 的通用 LLM,内部开发也可以考虑。
-
我们的 LLM 驱动产品的不同构建模块有哪些?这些模块中哪些已经商品化,哪些可能需要更多时间来构建和测试?
像我们构建的 RAG 解决方案的高级构建块包括数据管道、向量数据库、检索、生成,当然还有 LLM。LLM 和向量数据库有很多优秀的选择。数据管道、检索、生成的提示工程将需要一些传统的数据科学实验来针对具体用例进行优化。一旦初步解决方案到位,生产化将需要大量工作,这在任何数据科学/机器学习管道中都是真实的。本讲座提供了有关生产化的宝贵经验:LLMs in Production: Learning from Experience, Dr. Waleed Kadous, Chief Scientist, AnyScale
-
我们如何衡量解决方案的性能?有哪些杠杆可以改善我们产品的输出质量? 与任何技术(或非技术)解决方案一样,商业影响应使用领先的关键绩效指标来衡量。一些直接度量难以测量,通常会被替代指标如每日活跃用户数(DAU)和其他产品指标所取代。
商业指标应补充技术指标,以评估 RAG 解决方案的性能。可以使用一系列测试信息量、事实准确性、相关性、毒性等的指标来评估回应的整体质量——系统的回应与专家或如 GPT-4(当前)的最先进模型的回应相比如何。这有助于深入了解各个组件的性能,以便迭代和改进每个组件:解决方案将用作上下文的信息质量、检索和生成。
i. 数据质量如何?如果组织可用的数据存储在向量数据库中没有所需的信息,则没有人或 LLM 能够基于这些信息构造回应。
ii. 检索效果如何?假设信息可用,系统在找到和提取相关信息方面有多成功?
iii. **生成(即合成)**效果如何?假设信息可用、检索正确,并传递给 LLM 生成最终回应,LLM 是否按预期使用这些信息?
每个领域都可以单独评估并同时改进,以提升整体输出。
改善数据质量: 公司需要在数据管道上进行工作,以向系统提供良好的信息。如果向量数据库中的信息质量差,拥有优秀的 LLM 也不会显著改善输出。除了采用传统的数据质量和治理框架外,公司还应考虑改善分块的质量(下一问题的回应中将更多讨论此事)。
改善检索: 通过尝试不同的检索算法、语义重排序、结合语义搜索和关键词搜索的混合搜索,以及微调嵌入,可以改善检索。改善指令/提示也应有助于提升检索质量。
改善生成: 随着 LLM 的进步,合成步骤将得到改善,检索可能也会因为嵌入模型的改进而得到提升。另一种选择是进行微调,前提是资源和时间充足,这可以提高特定领域和任务的响应质量。例如,针对特定医疗条件进行微调的小模型可能在任务上优于像 GPT-4 这样的通用模型,同时也更快、更便宜。
-
我们的数据质量是否适合用例?我们是否正确组织了数据,并将相关数据传递给 LLM?
数据质量可以通过传统的数据质量与治理框架进行评估。此外,对于 LLM 驱动的解决方案,LLM 回答用户问题或执行任务所需的信息应在解决方案可用的数据中存在。
假设数据是可用的,数据应根据用例和所使用的 LLM 进行适当的拆分。块不应过于宽泛,以免稀释与特定主题相关的连贯性,也不应过于狭窄,以免遗漏所有必要的上下文。数据不应以将必要的上下文分割在块之间并且在这种分隔下毫无意义的方式进行拆分。例如,如果下面的两个句子被拆分成两个块,
“OpenAI 的 GPT-3.5 是一个强大的 LLM。它可以支持高达 16K 令牌的上下文大小。”
像“告诉我关于 GPT 3.5 LLM 的情况”这样的问题可能不会获取第二句,因为它没有提到 GPT 3.5,而这条信息可能不会提供给用户,仅仅是由于子优化块的原因。更危险的是,由于上下文大小和令牌与 LLM 的语义关联,当询问完全不同的 LLM 时,可能仍会获取该句子,且回答可能是其他重点模型的上下文大小高达 16K,这将是不准确的。这是一个在生产环境中不太可能遇到的简化示例,但这个想法是成立的。
改善内容质量的一种可能方法是使用上下文感知的文本拆分,例如按逻辑部分拆分(如我们书单的示例)。如果任何逻辑块过大——例如,维基百科上关于某些主题的页面可能非常长——它们可以进一步按逻辑部分或按语义单元(如段落)拆分,同时在块之间保持有意义的重叠,并确保将整体元数据和块特定的元数据传递给 LLM。
-
我们能否确信 LLM 的回答总是事实准确的?也就是说,我们的解决方案会不会在生成回答时偶尔‘幻觉’?
RAG 的一个关键卖点是推动事实准确性。GPT 3.5 和 GPT-4 在遵循指令方面表现良好:“仅从提供的上下文中回应,或说‘基于提供的信息无法回答该问题’”。这被推测是由于 OpenAI 进行了大量的基于人类反馈的强化学习(RLHF)。作为推论,其他 LLM 可能当前在遵循指令方面表现不佳。对于生产应用,特别是面向外部的应用,进行大量测试以验证生成的输出是否忠实于从向量数据库检索的可用上下文,即使 LLM 相信这是正确的,也是明智的。方法包括对样本进行手动测试,使用强大的模型如 GPT-4 测试检索到的上下文样本和其他模型生成的响应,或使用如Galileo这样的服务和产品,专注于实时检测 LLM 幻觉。
结论
如果你在 11 个月前就知道这些内容,这将值得与你公司首席执行官进行一次演示,甚至可能有一个 TED 演讲向更广泛的观众介绍。今天,这已成为 AI 素养的基本要求,特别是如果你参与生成式 AI 产品的交付。希望通过这次练习,你能比较跟得上!👍
一些结束语,
-
这项技术具有巨大的潜力——还有多少其他技术能“思考”到这种程度,并且能作为“推理引擎”(用安德鲁·吴博士的话说,参见这里)?
-
虽然前沿模型(目前为 GPT-4)将继续进步,但开源模型及其领域特定和任务特定的微调变体在许多任务中将具有竞争力,并找到许多应用。
-
无论好坏,这项耗资数百万(甚至数亿?)美元开发的前沿技术现在是免费的——你可以填写一个表格,下载 Meta 功能强大的 Llama2 模型,拥有非常宽松的许可。HuggingFace 的模型中心几乎有 30 万个基础 LLM 或其微调变体。硬件也已经商品化。
-
OpenAI 模型现在能够识别并使用“工具”(功能、API 等),使得解决方案不仅能与人类和数据库接口,还能与其他程序接口。LangChain 和其他软件包已经展示了如何将 LLM 作为“智能体”的“大脑”,这些智能体能够接受输入、决定采取的行动并执行,重复这些步骤直到智能体实现目标。我们的简单聊天机器人在确定性序列中使用了两个 LLM 调用——生成独立问题,并将搜索结果合成成连贯的自然语言响应。想象一下,数百次对快速发展的 LLM 进行的调用会取得什么成果!
-
这些快速进展是由于 GenAI 周围的巨大动力,它将通过我们的设备渗透到企业和日常生活中。最初是以更简单的方式,但随后将在利用技术的推理和决策能力的越来越复杂的应用中体现,与传统 AI 融合。
-
最终,现在是一个绝佳的时机来参与,因为应用这项技术的竞争环境相对公平——自 2022 年 12 月 ChatGPT 的爆发以来,每个人基本上都在同一时间学习这项技术。当然,研发方面情况有所不同,大型科技公司已经投入了多年和数十亿美元来开发这项技术。尽管如此,为了将来构建更复杂的解决方案,现在正是开始的最佳时机!
额外资源
-
LangChain: Deeplearning.ai课程:LangChain:与您的数据对话 | LangChain 文档
-
Gradio: Deeplearning.ai课程 - 使用 Gradio 构建生成性 AI 应用 | Gradio 文档和指南
-
我发现Shawhin Talebi的文章非常具有启发性。请参阅破解 OpenAI (Python) API,破解 Hugging Face Transformers 库以及其他近期文章。
-
由 LlamaIndex 的联合创始人 Jerry Liu 所做的演讲概述了输出评估的各种方法:构建生产就绪的 LLM 应用的实用数据考虑
使用 Python 实现生成式 AI:自编码器
原文:
towardsdatascience.com/hands-on-generative-ai-with-gans-using-python-autoencoders-c77232b402fc
从自编码器开始,更好地理解 GANs
·发表于 Towards Data Science ·阅读时间 6 分钟·2023 年 3 月 21 日
–
介绍
最近几年,由于人工智能能够生成几乎与真实数据难以区分的合成实例,生成模型变得越来越受欢迎。你可能对能够生成文本的神经网络 Chat GPT 和能够生成完全原创图像的 DALLE 比较熟悉。
网站 thispersondoesnotexist.com 是一个著名的生成网络例子,每次你访问这个链接时,都会显示一个不存在的人的 AI 生成图像。这只是生成式人工智能惊人可能性中的一个例子。
随着时间的推移,生成式人工智能已经发展,随着研究的推进,出现了许多架构来解决各种应用场景。但要开始学习生成式人工智能,你需要熟悉一种架构:生成对抗网络(GANs)。
GANs 概述
生成网络的最终目标是生成与其训练集具有相同分布的新数据。生成网络通常被视为机器学习中的无监督学习的一部分,因为它们不需要标记数据。生成对抗网络(GAN)概念由 Ian Goodfellow 于 2014 年提出,相关论文是“Generative Adversarial Nets”。
最初,GAN 的架构基于全连接层,旨在生成低分辨率图像,如手写数字。从那时起,GAN 经历了众多改进和应用。它们已被用于图像到图像的转换、图像超分辨率和图像修补等任务,其中网络学习重建图像的缺失部分。
GANs 也可以用于监督学习和半监督学习任务。例如,条件 GANs 可以根据某些条件生成数据,如根据用户输入生成不同动物的图像。半监督 GANs 使用标记数据来提高生成数据的质量。
GANs 的应用远不止于图像生成。这些模型已被应用于 NLP(自然语言处理)、音乐生成甚至药物发现!生成模型的潜力巨大,随着技术的不断进步,我们可以期待更多创新应用的出现。
GANs 很有吸引力,因为它们可以生成与训练数据分布相同的数据。
自编码器与 GANs
要完全理解这些生成对抗网络的工作原理,首先了解自编码器是很有帮助的。自编码器是一种可以压缩和解压训练数据的神经网络类型,使其在数据压缩和特征提取方面非常有用。
标准自编码器无法生成新数据,但它们作为理解 GANs 的有用起点。自编码器由两个串联的网络组成——编码器网络和解码器网络。编码器网络接收d 维输入特征 x,并将其编码为 p 维向量 z。换句话说,编码器的角色是学习如何建模函数 z = f(X)。向量 z 也称为潜在向量。通常,潜在向量的维度低于原始输入向量,因此p < d
解码器网络接收编码后的向量z 并重建原始输入特征 x。自编码器的目标是最小化原始输入特征与重建特征之间的差异。通过这样做,自编码器学习在压缩和解压输入数据的同时保留其本质特征。
让我们看看一个表示自编码器架构的图片。
自编码器架构(图像由作者提供)
虽然自编码器可以用于数据压缩和特征提取,但它们无法像 GANs 那样生成新数据。
在这个简单的例子中,编码器和解码器都是简单的线性层,用于压缩和解压空间。更复杂的架构可以包含多个层,并且可能包含不同类型的层,例如在应用于图像模型时使用卷积层。
让我们看看在 PyTorch 中自编码器的一个简单实现。
class AutoEncoder(nn.Module):
def __init__(self, **kwargs):
super().__init__()
self.encoder = nn.Linear(
in_features=kwargs["input_shape"], out_features=128
)
self.decoder = nn.Linear(
in_features=128, out_features=kwargs["input_shape"]
)
def forward(self, x):
latent_vector = torch.relu(self.encoder(x))
reconstructed = torch.relu(self.decoder(latent_vector))
return reconstructed
AutoEncoder 类像往常一样继承 nn.Module,包括一个编码器和一个解码器,它们都是线性层,接受一个大小为 input_shape(例如 784)的输入向量 x,将其减少到大小为 128 的潜在空间,最终重建原始大小的向量。
其他类型的 AutoEncoders
我们已经看到,通常潜在向量的大小小于输入向量的大小,因此发生压缩,即 p<d。这类 autoencoders 被称为 undercomplete。
但我们可以创建一个比输入向量更大的潜在向量,p>d。当然,overcomplete autoencoders!但它们的用途是什么?它们可以用于 降噪。
在这些网络的训练过程中,输入数据中会添加噪声,例如模糊的图像,网络必须能够重建无噪声的图像。这种特定的架构称为去噪 autoencoder。
基本去噪架构(图片作者)
Autoencoder 的实际示例
现在让我们看一个如何使用 PyTorch 实现更复杂的 Autoencoder 的例子,该 Autoencoder 用于生成类似于 MNIST 数据集的合成数据。
首先,像往常一样,我们安装并导入所需的库。
!pip install torchvision
!pip install torch
from torchvision import datasets
from torchvision import transforms
import torch
import matplotlib.pyplot as plt
现在我们只需导入数据集。在 Pytorch 中这非常简单,因为库提供了快速下载数据集的方法。所以我们实例化数据集,然后是我们需要训练网络的 dataloader。我们还定义了一个转换,将图像在被网络处理时转换为张量。
dataset = datasets.MNIST(root = "./data",
train = True,
download = True,
transform = tensor_transform)
loader = torch.utils.data.DataLoader(dataset = dataset,
batch_size = 64,
shuffle = True)
tensor_transform = transforms.ToTensor()
现在是时候创建 AutoEncoder 类了,就像之前一样。但在这种情况下,编码器和解码器都会更深,因为它们将由更多的层组成,以更好地捕捉图像特征。
class AutoEncoder(torch.nn.Module):
def __init__(self):
super().__init__()
self.encoder = torch.nn.Sequential(
torch.nn.Linear(28 * 28, 128),
torch.nn.ReLU(),
torch.nn.Linear(128, 64),
torch.nn.ReLU(),
torch.nn.Linear(64, 36),
torch.nn.ReLU(),
torch.nn.Linear(36, 18),
torch.nn.ReLU(),
torch.nn.Linear(18, 9)
)
self.decoder = torch.nn.Sequential(
torch.nn.Linear(9, 18),
torch.nn.ReLU(),
torch.nn.Linear(18, 36),
torch.nn.ReLU(),
torch.nn.Linear(36, 64),
torch.nn.ReLU(),
torch.nn.Linear(64, 128),
torch.nn.ReLU(),
torch.nn.Linear(128, 28 * 28),
torch.nn.Sigmoid()
)
def forward(self, x):
encoded = self.encoder(x)
decoded = self.decoder(encoded)
return decoded
如你所见,它并没有比最初看到的简单例子复杂多少。
正如我们训练模型时总是这样,我们实例化类,并定义一个损失函数和一个优化器。在这里是 MSELoss 和 Adam。
model = AutoEncoder()
loss_function = torch.nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(),
lr = 1e-1,
weight_decay = 1e-6)
训练网络的时刻来了。我们必须遍历我们的 dataloader,并调整输入以匹配模型架构。然后计算输出和获得的损失,并将所有内容保存到一个列表中,以便在训练结束时绘制。
epochs = 25
losses = []
for epoch in range(epochs):
for (image, _) in loader:
image = image.reshape(-1, 28*28)
reconstructed = model(image)
loss = loss_function(reconstructed, image)
optimizer.zero_grad()
loss.backward()
optimizer.step()
losses.append(loss)
plt.style.use('fivethirtyeight')
plt.xlabel('Iteration')
plt.ylabel('MSE-Loss')
plt.plot(losses[-100:])
好了,我们的网络已经训练完成!现在我们可以将原始图像与网络重建的图像进行对比。
plt.imshow(dataset[0])
plt.imshow(model(dataset[0].reshape(-1, 28, 28))
原始 vs 重建(来源: arxiv.org/pdf/2003.05991.pdf
)
最终思考
学习自编码器对于理解生成对抗网络(GANs)的工作原理非常有帮助。在这篇文章中,我们了解了一些关于这些架构的理论,然后看到它们如何用于重构 MNIST 图像的输出。使用它们非常有趣,而且它们也有各种用途,其中一些包括压缩和解压输入或去噪图像,正如我们所见。在下一篇文章中,我将解释自编码器如何与 GANs 相关,并且我们将看到如何实现它们。 关注我以获取未来的文章
结束
Marcello Politi