docs-merge-03

TowardsDataScience 2024 中文翻译(四)

原文:TowardsDataScience

协议:CC BY-NC-SA 4.0

结合文本与符号:通向强大 LLM 推理能力的道路

原文:towardsdatascience.com/blending-text-and-symbols-a-path-to-robust-llm-reasoning-607ceebbf958?source=collection_archive---------13-----------------------#2024-01-17

Anthony AlcarazTowards Data Science Anthony Alcaraz

·发表于 Towards Data Science ·8 分钟阅读·2024 年 1 月 17 日

--

人工智能软件被用来增强本文文本的语法、流畅度和可读性。

大型语言模型(LLMs)在自然语言处理方面展示了巨大的能力。它们能够生成极其逼真的文本,进行对话,总结长篇内容,甚至尝试进行初步的推理。

然而,尽管 LLM 在语义理解方面取得了卓越的进展,但当需要复杂的逻辑推理时,它们仍面临深刻的局限性。它们的理解仍然停留在表面,常常缺乏更深层次的联系,或者在需要数学逻辑的推理中失败。

两个暴露这些大型语言模型(LLM)推理不足之处的领域是表格数据和知识图谱。表格包含结构化的统计数据、关系和属性,这些在商业分析、科学研究和公共政策等领域中随处可见。知识图谱将概念、现实世界实体及其相互关系汇集成复杂的事实网络,以图节点和边的形式呈现。

对这些结构化数据进行推理需要在上下文与符号逻辑之间微妙地平衡。例如,在表格中识别统计信息时,理解语义有助于将数字的含义放入上下文中。又如,解决分析图查询则依赖于操作逻辑图模式,同时追踪现实世界的实体。

BM25S — BM25 算法在文档检索中的效能提升

原文:towardsdatascience.com/bm25s-efficacy-improvement-of-bm25-algorithm-in-document-retrieval-7c27ba665b7e?source=collection_archive---------11-----------------------#2024-08-12

bm25s 是 BM25 算法在 Python 中的实现,利用 Scipy 提高文档检索速度。

Chien VuTowards Data Science Chien Vu

·发表于 Towards Data Science ·阅读时长 10 分钟·2024 年 8 月 12 日

--

图片来源:作者

BM25 算法背景

BM25,即最佳匹配 25,是一种流行的基于向量的文档检索算法。BM25 旨在通过根据文档中的词频和长度对文档进行评分,从而提供准确且相关的搜索结果。

BM25 使用词频和逆文档频率作为其公式的一部分。词频和逆文档频率是 TF-IDF 的核心。

首先,让我们快速浏览一下 TF-IDF 公式。

TF-IDF 公式(作者提供的图片)

在 TF-IDF 中,单词的重要性与该单词在文档中出现的频率成正比,但会受到该单词在语料库中的频率的抵消。第一部分,词频(TF),表示一个术语在特定文档中出现的频率。如果术语在文档中出现得更频繁,它更可能是重要的。然而,它会通过文档总数来进行归一化……

在科技裁员期间提升你的数据科学求职之路,第一部分

原文:towardsdatascience.com/boost-your-data-science-job-hunt-during-tech-layoffs-part-i-a6746eab05d5?source=collection_archive---------10-----------------------#2024-07-23

通过这 5 个可操作的步骤

Maggie MaTowards Data Science Maggie Ma

·发布于 Towards Data Science ·6 分钟阅读·2024 年 7 月 23 日

--

目前,科技行业的就业市场非常具有挑战性。

图片来源:Iewek GnosUnsplash

根据 layoffs.fyi,2024 年仅在科技行业就有 106,630 人被裁员。如果你是一名数据科学家并正在寻求新职位,务必在求职过程中保持主动战略性,以便脱颖而出。

在这篇文章中,我将分享 5 个可操作的步骤,以提高你在这些科技裁员期间获得并通过下一个数据科学面试的机会。

1. 优先考虑建立人脉并利用你的职业连接

研究表明,通过人脉网络获得工作的机会是通过单纯提交在线申请的四倍。建立人脉不仅有助于你建立宝贵的关系,还能为你打开那些可能没有公开招聘的机会的大门。

你知道吗,最多 70%的工作并不会在公共招聘网站上发布吗?

即使是公开发布的职位,通常也会通过推荐或内部候选人来填补。与前同事、同学以及行业联系人保持联系,告诉他们你正在寻找工作。你将会…

使用 CUDA 加速你的 Python 代码

原文:towardsdatascience.com/boost-your-python-code-with-cuda-8bbdd08fc51e?source=collection_archive---------0-----------------------#2024-11-20

图像来源:AI (Dalle-3)

轻松使用 Numba 的 CUDA JIT 来定位 GPU

Thomas ReidTowards Data Science Thomas Reid

·发表于 Towards Data Science ·阅读时间 11 分钟·2024 年 11 月 20 日

--

我之前写过关于 Python 库 Numba 的文章。你可以通过以下链接查看我的文章,

[## Python 增强版:Numba 加速

轻松加速你的代码

ai.gopubby.com](https://ai.gopubby.com/python-on-steroids-the-numba-boost-d3c35fbde47f?source=post_page-----8bbdd08fc51e--------------------------------)

上面的 TL;DR(简短总结)是我展示了如何使用 Numba 实现显著的 Python 代码加速。Numba 是一个高性能的 Python 库,旨在优化代码速度。它的核心是一个即时编译器(Just-In-Time, JIT),可以将 Python 和 NumPy 代码的子集转换为快速的机器代码。这个过程是自动且动态的,让 Python 开发者能够在最小修改原始代码的情况下,获得真实的性能提升。

普通的 Numba JIT 编译器主要用于优化 CPU 上代码的运行时间,但如果你有幸能够使用 GPU,本文将向你展示如何再次使用 Numba,这次是利用它的 CUDA JIT,通过将代码运行到 GPU 上来进一步加速你的 Python 代码。

前提条件

机器学习中的提升算法,第一部分:AdaBoost

原文:towardsdatascience.com/boosting-algorithms-in-machine-learning-part-i-adaboost-b9d86041a521?source=collection_archive---------12-----------------------#2024-01-05

了解 AdaBoost 背后的逻辑,并使用 Python 实现它

Gurjinder KaurTowards Data Science Gurjinder Kaur

·发表于 Towards Data Science ·13 分钟阅读·2024 年 1 月 5 日

--

Jeffrey Brandjes 拍摄,图片来自 Unsplash

介绍

在机器学习中,提升(Boosting)是一种集成学习方法,它将多个弱学习器结合成一个强学习器。其思想是顺序地训练这些弱学习器,每个学习器尽力避免重复前一个学习器所犯的错误,最终构建出一个强大的集成模型。

在本文中,我们将学习一种流行的提升技术,称为 AdaBoost,并展示它如何优雅地使每个弱学习器将其错误传递给下一个弱学习器,从而最终提高预测质量。

本文将涵盖以下主题:

  • 了解什么是集成学习及其不同类型。这就是我们将定义 提升算法的地方。

  • 了解什么是弱学习器,通过一个例子来说明,因为提升算法涉及到多个弱学习器的训练。

  • 了解为什么需要提升算法。

  • AdaBoost 算法简介。

  • 在 Python 中实现 AdaBoost 算法进行二分类,并查看各个弱学习器的表现……

机器学习中的提升算法,第二部分:梯度提升

原文:towardsdatascience.com/boosting-algorithms-in-machine-learning-part-ii-gradient-boosting-c155ae505fe9?source=collection_archive---------7-----------------------#2024-11-12

揭示一个简单却强大的、获奖的机器学习算法

Gurjinder KaurTowards Data Science Gurjinder Kaur

·发表于 Towards Data Science ·9 分钟阅读·2024 年 11 月 12 日

--

图片来源:Kevin BowlerUnsplash

在本文中,我们将学习梯度提升,一种机器学习算法,它为像 XGBoost 和 LightGBM 这样的流行框架奠定了基础,这些框架是多个机器学习竞赛的获奖解决方案。

这是一篇非常适合任何考虑在机器学习中使用集成方法的人阅读的文章,或者对于那些已经是专家但只是想暂时摆脱点拟合和点预测,想看看背后原理的人来说,也是一个很好的复习材料!

我们将介绍集成学习的基础知识,并通过一个逐步示例解释梯度提升算法如何进行预测。我们还将探索梯度下降和梯度提升之间的关系,看看它们是否存在联系。让我们开始吧!

集成学习

集成学习是训练并组合多个模型的过程,这些模型通常是弱学习器,目的是创造一个具有更高预测能力的强学习器。实现这一目标的两种方式是baggingboosting

1. Bagging

使用推测性解码提升大语言模型推理速度

原文:towardsdatascience.com/boosting-llm-inference-speed-using-speculative-decoding-0cb0bf36d001?source=collection_archive---------8-----------------------#2024-08-27

使用前沿优化技术加速推理的实用指南

Het TrivediTowards Data Science Het Trivedi

·发表于 Towards Data Science ·6 分钟阅读·2024 年 8 月 27 日

--

图片由 Flux Schnell 生成

介绍

大型语言模型非常耗电,需要大量的 GPU 资源才能有效运行。然而,变换器架构并未充分利用 GPU 的优势。

GPU 从设计上来说能够并行处理任务,但变换器架构是自回归的。为了生成下一个标记,必须查看所有之前的标记。变换器不允许你并行预测下一个 n 个标记。最终,这使得大型语言模型(LLM)的生成过程非常缓慢,因为每个新标记必须按顺序生成。推测性解码是一种新颖的优化技术,旨在解决这一问题。

每次前向传递会生成一个新的标记,由大型语言模型生成

推测性解码有几种不同的方法。本文所描述的技术使用的是两模型方法。

推测性解码

推测性解码的工作原理是使用两个模型,一个是大型的模型,另一个是...

有界核密度估计

原文:towardsdatascience.com/bounded-kernel-density-estimation-2082dff3f47f?source=collection_archive---------7-----------------------#2024-02-28

了解核密度估计是如何工作的,以及如何调整它以更好地处理有界数据,如年龄、身高或价格

Thomas RouchTowards Data Science Thomas Rouch

·发表于《数据科学前沿》 ·9 分钟阅读·2024 年 2 月 28 日

--

图片来自Maxim BergUnsplash

直方图被广泛使用且易于理解,但在估计连续密度时,人们常常将其视为一个神秘的黑箱。然而,理解这个概念其实同样简单,而且尤其重要,特别是在处理像年龄、身高或价格这样的有界数据时,现有的库可能无法自动处理它。

1. 核密度估计

直方图

直方图涉及将数据范围划分为箱子或子区间,并计算落在每个箱子中的样本数量。因此,它通过分段常数函数近似连续密度函数。

  • 大的箱子大小:有助于捕捉密度函数的低频轮廓,通过汇总邻近的样本来避免空箱子。然而,它失去了连续性,因为相邻箱子之间的计数可能会有显著差距。

  • 小的箱子大小:有助于捕捉较高频率的细节。然而,如果样本数量太小,我们可能会在真实密度不存在的地方出现大量空箱子。

从高斯分布中抽取的 100 个样本的直方图,箱子数量递增(5/10/50)— 作者提供的图片

核密度估计 (KDE)

一个直观的想法是,假设样本的密度函数是平滑的,并利用它来填补我们高频直方图中的空白。

这正是核密度估计(KDE)所做的。它通过将每个样本周围的局部密度核 K 的平均值作为全局密度进行估计。核是一个非负函数,其积分为 1,例如均匀、三角形、正态分布等……就像在直方图中调整箱子的大小一样,我们引入了一个带宽参数 h,它调节每个样本点周围核的偏差。因此,它控制着最终密度估计的平滑度。

带宽选择

寻找合适的平衡点,在欠平滑和过度平滑之间并非易事。一个流行且易于计算的启发式方法是 Silverman 的经验法则,当估计的基础密度为高斯分布时,它是最优的。

Silverman 的经验法则,其中 n 是样本数量,sigma 是样本的标准差

请记住,它可能并不总能在所有数据分布中得到最优结果。本文不讨论这些内容,但还有其他可用的替代方法和改进。

下图展示了在不同带宽值下,通过高斯核密度估计(KDE)估计的高斯分布。正如我们所见,Silverman 的经验法则非常适用,但更高的带宽会导致过度平滑,而较低的带宽则会在真实密度周围引入高频振荡。

从 100 个样本中抽取的真实高斯分布进行的高斯核密度估计,使用不同的带宽参数(0.01、0.04、0.10)——图片由作者提供

示例

以下视频展示了随着提供的样本数量增加,使用高斯核的核密度估计在 4 个标准密度分布中的收敛过程。

尽管这不是最优的,但我选择在视频中保持较小的常数带宽 h,以更好地展示核平均的过程,并在样本量非常小的时候避免过度平滑。

随着样本大小增加,高斯核密度估计在 4 个标准密度分布(均匀、三角形、高斯、高斯混合)上的收敛性——视频由作者提供

高斯核密度估计的实现

scipyscikit-learn这样的优秀 Python 库提供了核密度估计(Kernel Density Estimation)的公开实现:

  • scipy.stats.gaussian_kde

  • sklearn.neighbors.KernelDensity

然而,值得注意的是,可以通过仅仅三行代码就构建一个基本的等效方法,使用 numpy 来实现。我们需要从分布中抽取样本 x_data 进行估计,并且需要评估密度估计的点 x_prediction。然后,使用数组广播,我们可以在每个输入样本周围评估一个局部高斯核,并将其平均为最终的密度估计。

注意:此版本之所以快速,是因为它是向量化的。然而,它涉及创建一个形状为 (len(x_data), len(x_prediction)) 的大型二维临时数组来存储所有核评估。为了降低内存占用,我们可以使用 numbacython 重新编写代码(以避免 Python 循环带来的计算负担),在每次输出预测时动态地将核评估聚合到一个累计和中。

图片由 Parker Coffman 提供,来自 Unsplash

2. 处理边界

有界分布

现实生活中的数据通常受限于给定的域。例如,属性如年龄、体重或持续时间通常是非负值。在这种情况下,标准的平滑 KDE 可能无法准确地捕捉分布的真实形状,特别是当边界处存在密度不连续性时。

在 1D 中,除了某些特殊情况外,有界分布通常具有单边(例如,正值)或双边(例如,均匀区间)有界域。

如下图所示,核在估计均匀分布的边缘时效果较差,会泄漏到有界域之外。

高斯 KDE 对 100 个从均匀分布中抽取的样本进行估计 — 图片来自作者

Python 中没有现成的公共解决方案

不幸的是,像 scipyscikit-learn 这样的流行公共 Python 库目前并没有解决这个问题。虽然在 GitHub 上已有讨论此话题的问题和拉取请求,但遗憾的是,它们已经长时间没有得到解决。

在 R 中,[kde.boundary](https://search.r-project.org/CRAN/refmans/ks/html/kde.boundary.html) 允许对有界数据进行核密度估计。

有多种方法可以考虑分布的有界特性。我们将描述其中最流行的几种:反射、加权和变换。

警告:

为了提高可读性,我们将专注于单位有界域,即 *[0,1]*。请记得在一般情况下对数据进行标准化,并适当缩放密度,特别是对于 *[a,b]*

解决方案:反射

该技巧是通过在左右边界处反射样本集来扩增样本集。这相当于将局部核的尾部反射,以将其保持在有界域内。该方法在密度导数在边界处为零时效果最好。

反射技术还意味着需要处理三倍于样本数的数据。

下面的图表展示了反射技巧在三种标准分布中的应用:均匀分布、右三角分布和反平方根分布。即使是在反平方根分布的奇点处,它也能有效地减少边界处的偏差。

对均匀分布的 KDE,使用反射处理边界——作者提供的图片

对三角形分布的 KDE,使用反射处理边界——作者提供的图片

对反平方根分布的 KDE,使用反射处理边界——作者提供的图片

注意:basic_kde的签名已稍作更新,现在可以选择提供自定义的带宽参数,而不是使用 Silverman 的经验法则。

解决方案:加权

上述反射技巧将局部核的泄漏尾部重新加回到边界域中,从而避免信息丢失。然而,我们也可以计算出局部核在边界域外损失了多少,并利用这一信息来修正偏差。

对于大量样本,KDE 会收敛到核函数与真实密度的卷积,并在边界域上进行截断。

如果x处于边界,那么只有一半的核区域会被实际使用。直观上,我们希望对卷积核进行归一化,使其在有界域内积分为 1。该积分在有界区间的中心接近 1,在边界附近会降至 0.5。这是因为边界处缺少邻近的核函数。

类似于反射技术,下面的图表展示了三种标准分布的加权技巧:均匀分布、右三角分布和反平方根分布。其效果与反射法非常相似。

从计算角度来看,这不需要处理三倍于样本数的数据,但需要在预测点计算正态累积分布函数。

对均匀分布的 KDE,应用边缘加权处理边界——作者提供的图片

对三角形分布的 KDE,应用边缘加权处理边界——作者提供的图片

对反平方根分布应用加权来处理边界的 KDE — 图像由作者提供

变换

变换技巧将有界数据映射到无界空间,在该空间中可以安全地应用 KDE。这导致对每个输入样本使用不同的核函数。

Logit 函数利用对数将单位区间 [0,1] 映射到整个实数轴。

Logit 函数 — 图像由作者提供

当对随机变量 X 应用变换 f 时,得到的密度可以通过除以 f 的导数的绝对值来获得。

现在我们可以将其应用于 logit 变换的特例,从 logit 空间中估算的密度分布中恢复出原始密度分布。

类似于反射和加权技术,下图展示了三种标准分布(均匀分布、右三角形分布和反平方根分布)的加权技巧。通过在边界处创建大幅波动,这种方法表现较差。然而,它很好地处理了反平方根分布的奇异性。

在将样本映射到 logit 空间后,计算出的均匀分布的 KDE——图像由作者提供

在将样本映射到 logit 空间后,计算出的三角形分布的 KDE — 图像由作者提供

在将样本映射到 logit 空间后,计算出的反平方根分布的 KDE——图像由作者提供

图片来自 Martin MartzUnsplash

结论

直方图和 KDE

在核密度估计中,每个样本都会被分配一个围绕其中心的局部核密度,然后我们将所有这些密度进行平均,得到全局密度。带宽参数定义了每个核函数的影响范围。直观上,随着样本数量的增加,我们应该减小带宽,以防止过度平滑。

直方图可以看作是 KDE 的简化版本。实际上,箱体隐式地定义了一组有限的矩形核函数,每个样本都会被分配到最近的那个核。最后,所有这些密度的平均值会得到一个分段常数的全局密度估计。

哪种方法最好?

反射、加权和变换是处理 KDE 中有界数据的有效基本方法。然而,请记住,没有一种放之四海而皆准的解决方案;这在很大程度上取决于数据的形状。

如我们在逆平方根分布中看到的那样,变换方法能够很好地处理奇异性。至于反射和加权,它们通常更适用于更广泛的场景。

反射会在训练过程中引入复杂性,而加权则在推理过程中增加复杂性。

从[0,1]到[a,b],[a, +∞[ 和 ]-∞,b]

上面展示的代码是针对单位区间内的数据编写的。在应用仿射变换以规范化数据时,别忘了缩放密度。

通过仅在一侧进行反射、将核函数在一侧积分到无穷大,或使用对数代替逻辑值,它也可以很容易地调整为单侧有界域。

希望你喜欢阅读这篇文章,并且它能为你提供更多关于核密度估计如何工作以及如何处理有界域的见解!

新经理常犯的 5 个错误(这些错误我自己也曾犯过)

原文:towardsdatascience.com/break-free-from-the-ic-mindset-you-are-a-manager-now-b3890f0bfce2?source=collection_archive---------6-----------------------#2024-12-05

摆脱个人贡献者思维定式。你现在是经理了。

Jose ParreñoTowards Data Science Jose Parreño

·发表于Towards Data Science ·14 分钟阅读·2024 年 12 月 5 日

--

图片由Jose Aragones提供,来源于Unsplash

你已经在数据科学领域工作多年,从初级到高级。在这些年里,你有着出色的交付记录,倾向于解决最大的问题,并且在技术解决方案方面是数据科学领域可信赖的成员。现在有一个开放的职位需要领导其中一个数据科学团队,而业务方已经将识别为领导这个团队的候选人

你觉得这是一个令人兴奋的机会!最近你确实读了很多关于管理的书籍,并相信这将是一个你能够成长的领域。不仅如此,你还有幸为 1 或 2 位出色的经理工作过,他们已成为你希望成为的领导者的灵感来源。你甚至觉得你已经理解了一些这个过渡所需要的权衡。

然而,尽管做了充分的准备,没有多少阅读或灵感能完全传达从个人贡献者转变为经理的感觉。许多新经理——甚至是经验丰富的经理——都低估了这个角色的不同之处。

成为一名新经理就像学开车一样:你可能看过别人如何…

将逻辑回归拆解到最基本的部分

原文:towardsdatascience.com/breaking-down-logistic-regression-basics-ml-machine-learning-algorithm-classification-a81f54ed6163?source=collection_archive---------2-----------------------#2024-01-21

MLBasics #2:用逻辑回归的简洁性揭开机器学习算法的神秘面纱

Josep FerrerTowards Data Science Josep Ferrer

·发表于Towards Data Science ·阅读时长 7 分钟·2024 年 1 月 21 日

--

图像由作者提供。ML 基础。逻辑回归。

在数据和计算机程序的世界里,机器学习的概念可能听起来像是一个难解的难题,充满了复杂的数学和抽象的概念。

这就是为什么今天我想放慢速度,看看使这一切工作的基本内容,并通过我的MLBasics 系列来讨论。

我们将重新审视这些简单但极为重要的模型,它们是机器学习的基础。可以把它当作从一个大拼图的简单部分开始。我们回到简单的东西,从中轻松理解正在发生的事情。

所以跟随我一起,拆解并清晰地理解它。

让我们一起一步步深入了解逻辑回归!👇🏻🤓

#1. 从数据到决策的路径

在众多机器学习算法中,逻辑回归是解决二分类问题的最佳模型之一。

当我们面临分类性数据且目标是做出决策时,逻辑回归是我们信赖的路径。

逻辑回归不仅仅是一个统计工具,它还是一个讲故事的工具…

JAX 中近端策略优化(PPO)的实用指南

原文:towardsdatascience.com/breaking-down-state-of-the-art-ppo-implementations-in-jax-6f102c06c149?source=collection_archive---------7-----------------------#2024-05-01

所有你希望了解的关于 PPO 的技巧和细节

Ryan PégoudTowards Data Science Ryan Pégoud

·发表于 Towards Data Science ·9 分钟阅读·2024 年 5 月 1 日

--

图片来自 Lorenzo HerreraUnsplash

自从 OpenAI 在 2017 年的论文 中发布以来,近端策略优化(PPO)被广泛认为是强化学习领域最先进的算法之一。实际上,PPO 在各种任务中都表现出了显著的性能,从 在 Dota 2 中取得超人类表现 的团队到使用单个机器人手臂 解 Rubik’s Cube,同时保持三个主要优势:简洁性、稳定性和样本效率。

然而,从零开始实现强化学习(RL)算法非常困难且容易出错,因为有许多错误源和实现细节需要注意。

在本文中,我们将重点分析在 JAX 中流行的 PPO 实现中使用的巧妙技巧和编程概念。具体来说,我们将聚焦于 PureJaxRL 库中的实现,该库由 Chris Lu 开发。

免责声明:本文并未深入探讨理论,而是聚焦于在 PPO 的流行实现版本中所使用的实际实现细节和(众多)技巧。如果你需要回顾 PPO 的理论,请参考本文末尾的“参考文献”部分。此外,所有代码(不包括新增的注释)均直接复制自 PureJaxRL,旨在教学目的。

[## UGitHub - luchris429/purejaxrl: 超快的端到端 Jax 强化学习实现

超快的端到端 Jax 强化学习实现。通过创建一个账户,贡献代码至 luchris429/purejaxrl 开发项目...

github.com](https://github.com/luchris429/purejaxrl/tree/main?source=post_page-----6f102c06c149--------------------------------)

演员-评论家架构

近端策略优化(Proximal Policy Optimization,PPO)被归类为策略梯度算法家族,其中包括演员-评论家方法。‘演员-评论家’这一名称反映了模型的双重组件:

  • 演员网络根据环境当前状态生成一个行动分布,并从该分布中采样一个行动。这里,演员网络包括三层全连接层,这些层之间由两层激活层(可以是 ReLU 或双曲正切)隔开,并且最终有一个应用softmax函数的分类层来计算分布。

  • 评论家网络 估算当前状态的价值函数,换句话说,它评估在某一时刻特定行动的好坏。其架构几乎与演员网络相同,唯一的区别是最终的 softmax 层。实际上,评论家网络在处理回归任务时,并不会对最后一层全连接层的输出应用任何激活函数。

演员-评论家架构,如 PureJaxRL 中定义的(插图由作者制作)

此外,该实现特别注意权重初始化在全连接层中的应用。实际上,所有全连接层都通过正交矩阵与特定系数进行初始化。该初始化策略已被证明在前向传播和反向传播过程中能够保持梯度范数(即尺度),从而实现更平滑的收敛,并限制梯度消失或爆炸的风险[1]。

正交初始化与特定的缩放系数一起使用:

  • 2 的平方根:该因子用于两个网络的前两层全连接层,旨在补偿 ReLU 激活导致的方差减少(因为负值输入会被设为 0)。对于 tanh 激活函数,Xavier 初始化是一个流行的替代方案[2]。

  • 0.01: 用于演员网络的最后一层密集层,该因子有助于最小化 logit 值的初始差异,在应用 softmax 函数之前。这将减少动作概率的差异,从而鼓励早期探索

  • 1: 由于评论员网络执行的是回归任务,我们不会缩放初始权重。

演员-评论员网络(来源:PureJaxRL, Chris Lu)

训练循环

训练循环分为 3 个主要部分,这些部分共享类似的编码模式,充分利用 JAX 的功能:

  1. 轨迹收集: 首先,我们将与环境交互若干步骤,并收集观测值和奖励。

  2. 广义优势估计(GAE): 然后,我们通过计算广义优势估计来近似每个轨迹的期望回报。

  3. 更新步骤: 最后,我们将计算损失的梯度,并通过梯度下降更新网络参数。

在详细介绍每个模块之前,这里简要提醒一下jax.lax.scan函数,它将在代码中多次出现:

Jax.lax.scan

JAX 中常见的编程模式是定义一个作用于单个样本的函数,并使用jax.lax.scan迭代地将其应用于序列或数组的元素,同时携带某些状态。

例如,我们将其应用于step函数,以在连续 N 次步骤中推进环境,同时在每次迭代中传递环境的新状态。

在纯 Python 中,我们可以按以下方式进行:

trajectories = []

for step in range(n_steps):
  action = actor_network(obs)
  obs, state, reward, done, info = env.step(action, state)
  trajectories.append(tuple(obs, state, reward, done, info))

然而,为了性能考虑(因为纯 Python 循环与 JIT 编译不兼容),我们避免在 JAX 中编写此类循环。替代方法是jax.lax.scan,其等效于:

def scan(f, init, xs, length=None):
  """Example provided in the JAX documentation."""
  if xs is None:
    xs = [None] * length

  carry = init
  ys = []
  for x in xs:
    # apply function f to current state
    # and element x
    carry, y = f(carry, x) 
    ys.append(y)
  return carry, np.stack(ys)

使用jax.lax.scan比使用 Python 循环更高效,因为它允许对转换进行优化,并作为单一的编译操作执行,而不是在运行时解释每个循环迭代。

我们可以看到,scan函数接受多个参数:

  • f: 在每个步骤应用的函数。它接受当前状态和xs的一个元素(如果xsNone,则使用占位符),并返回更新后的状态和输出。

  • init: f在第一次调用时使用的初始状态。

  • xs: 一个输入序列,它将被f逐步处理。如果xsNone,则该函数模拟一个具有length次迭代的循环,并将每次迭代的输入设置为None

  • length: 如果xsNone,指定迭代的次数,确保函数在没有明确输入的情况下仍能操作。

此外,scan返回:

  • carry: 所有迭代后的最终状态。

  • ys: 对应于每个步骤应用f的输出数组,堆叠以便于分析或进一步处理。

最后,scan可以与vmap结合使用,在多个维度上并行扫描一个函数。正如我们在下一节中将看到的,这使得我们能够并行与多个环境交互,从而快速收集轨迹。

在步长函数上下文中,展示了 vmap、scan 和 scan + vmap 的示意图(作者制作)

1. 轨迹收集

如前一节所述,轨迹收集块由step函数跨 N 次迭代扫描组成。这个step函数依次执行:

  • 使用演员网络选择一个动作

  • 执行环境步长

  • 将转移数据存储在transition元组中

  • 将模型参数、环境状态、当前观察值和随机数生成器键存储在runner_state元组中

  • 返回runner_statetransition

扫描这个函数将返回最新的runner_statetraj_batch,后者是一个包含transition元组的数组。实际上,为了提高效率,转移数据是从多个环境中并行收集的,如使用jax.vmap(env.step, …)所示(有关向量化环境和vmap的更多详细信息,请参阅我的上一篇文章)。

env 步长函数(来源:PureJaxRL,Chris Lu)

2. 广义优势估计

收集完轨迹后,我们需要计算优势函数,这是 PPO 损失函数的一个关键组成部分。优势函数衡量一个特定动作在给定状态下,相较于平均动作的表现如何:

其中Gt是时间t时刻的回报,V(St)是时间t时刻状态s的值。

由于回报通常是未知的,我们必须对优势函数进行近似。一个流行的解决方案是广义优势估计[3],其定义如下:

其中γ是折扣因子,λ是控制偏差与方差权衡的参数,δt是时间t时刻的时序差异误差:

如我们所见,GAE 在时间t的值依赖于未来时间步的 GAE。因此,我们从轨迹的末端开始反向计算。例如,对于一个包含 3 个转移的轨迹,我们将得到:

这等价于以下递归形式:

再次,我们对轨迹批次使用jax.lax.scan(这次是逆序)来迭代计算 GAE。

广义优势估计(来源:PureJaxRL,Chris Lu)

注意,该函数返回advantages + traj_batch.value作为第二个输出,这相当于本节第一个方程中的回报。

3. 更新步骤

训练循环的最后一部分定义了损失函数,计算其梯度,并在 minibatches 上执行梯度下降。与前面几节类似,更新步骤是按层次顺序排列的多个函数的组合:

def _update_epoch(update_state, unused):
  """
  Scans update_minibatch over shuffled and permuted 
  mini batches created from the trajectory batch.
  """

  def _update_minbatch(train_state, batch_info):
    """
    Wraps loss_fn and computes its gradient over the 
    trajectory batch before updating the network parameters.
    """
    ...

    def _loss_fn(params, traj_batch, gae, targets):
      """
      Defines the PPO loss and computes its value.
      """
      ...

让我们逐一拆解它们,从更新步骤中最内层的函数开始。

3.1 损失函数

这个函数旨在定义和计算 PPO 损失,最初定义为:

其中:

然而,PureJaxRL 的实现与原始 PPO 论文[4]相比,具有一些技巧和差异:

  • 论文中定义的 PPO 损失是基于梯度上升的,而实际实现采用了梯度下降。因此,每个损失组件的符号被反转。

  • 值函数项被修改,包含了一个额外的截断项。这可以看作是使值函数更新更为保守的一种方式(就像截断的替代目标一样):

  • GAE 被标准化。

这是完整的损失函数:

PPO 损失函数(来源:PureJaxRL, Chris Lu)

3.2 更新 Minibatch

update_minibatch 函数本质上是一个包装器,用于计算在轨迹批次上对 loss_fn 的梯度,并更新存储在 train_state 中的模型参数。

更新 minibatch(来源:PureJaxRL, Chris Lu)

3.3 更新 Epoch

最后,update_epoch 封装了 update_minibatch 并在 minibatch 上应用它。同样,jax.lax.scan 被用来迭代地对所有 minibatch 应用更新函数。

更新 epoch(来源:PureJaxRL, Chris Lu)

结论

从这里开始,我们可以将所有先前的函数封装在一个 update_step 函数中,并使用 scan 最后一次迭代 N 步以完成训练循环。

训练循环的全局视图大致如下:

训练脚本总结(来源:PureJaxRL, Chris Lu)

我们现在可以使用 jax.jit(train(rng)) 运行一个完全编译的训练循环,或者使用 jax.vmap(train(rng)) 并行训练多个代理。

到此为止!我们涵盖了 PPO 训练循环的基本构建块以及 JAX 中常见的编程模式。

若想深入了解,强烈建议详细阅读 完整训练脚本,并在 PureJaxRL 仓库上运行示例笔记本。

[## GitHub - luchris429/purejaxrl: 真正快速的端到端 Jax 强化学习实现

真正快速的端到端 Jax 强化学习实现。通过创建一个账户来贡献于 luchris429/purejaxrl 的开发…

github.com](https://github.com/luchris429/purejaxrl?source=post_page-----6f102c06c149--------------------------------)

非常感谢您的支持,期待下次再见 👋

参考文献:

完整训练脚本,PureJaxRL,Chris Lu,2023

[1] 解释和说明递归神经网络的正交初始化,Smerity,2016

[2] 初始化神经网络,DeepLearning.ai

[3] 强化学习中的广义优势估计,Siwei Causevic,Towards Data Science,2023

[4] 近端策略优化算法,Schulman 等,OpenAI,2017

解析:为更好的 RAG 进行分块

原文:towardsdatascience.com/breaking-it-down-chunking-techniques-for-better-rag-3fd288bf25a0?source=collection_archive---------3-----------------------#2024-09-23

掌握分块技术以提高 RAG 系统中的信息检索效率

Abhinav KimothiTowards Data Science Abhinav Kimothi

·发表于 Towards Data Science ·阅读时长:16 分钟·2024 年 9 月 23 日

--

分块是 RAG 系统中的关键组成部分(来源:作者使用 FLUX 生成的 AI 图像)

前言:RAG 和知识库的需求

在短短的时间里,大语言模型(LLMs)已经在现代语言处理任务中找到了广泛的应用,甚至为自主 AI 代理铺平了道路。你很可能听说过(如果没有亲自使用过)ChatGPT。ChatGPT 由一种叫做大语言模型的生成式 AI 技术驱动。

检索增强生成(RAG)已成为应用生成 AI 领域中最流行的技术之一。尽管大语言模型展示了前所未有的文本生成能力,但它们的回答并不总是准确的。经过更仔细的观察,你可能会发现,LLM 的回答常常存在次优信息和固有的记忆限制。RAG 通过为这些模型提供外部信息来解决 LLM 的这些局限性。 这样,LLM 的回答变得更加可靠和值得信赖。RAG 的基本概念 如下示例所示。在这里,我们向 ChatGPT 提供外部信息(手动)以使其准确回答。

弥合数据素养鸿沟

原文:towardsdatascience.com/bridging-the-data-literacy-gap-2d9284d33f96?source=collection_archive---------4-----------------------#2024-12-06

“数据翻译者”的出现、演变及现状

Nithhyaa RamamoorthyTowards Data Science Nithhyaa Ramamoorthy

·发表于 Towards Data Science ·13 分钟阅读·2024 年 12 月 6 日

--

简介

随着数据被不断推崇为组织可以拥有的最宝贵资产,领导者和决策者始终在寻找有效的方法将数据洞察付诸实践。每当客户与数字产品互动时,都会生成数百万个数据点,如果不利用这些数据点来打造更好的产品、优化收入生成、改善客户足迹,其带来的机会损失是显而易见的,无法忽视。“数据翻译者”这一角色开始在分析和数据科学招聘网站上出现,旨在帮助弥合商业团队与数据团队之间的知识鸿沟,使组织能够变得更加数据驱动。过去十年中,这一角色逐渐发展,涵盖了更多与数据驱动决策相关的方面,并为企业领导层提供了急需的背景信息和翻译支持。这个角色还在与市场营销、产品、战略等利益相关方的对接中发挥着重要作用,帮助将所有决策转向数据驱动。随着这一角色的重要性被广泛接受,以及其所承担的职责灵活性,所有数据从业者都必须培养“数据翻译”能力,才能在工作和职业生涯中脱颖而出,取得成功并不断进步。

解决数据知识鸿沟

决策一直是各行业成功商业故事的基石。著名的管理理论与实践专家彼得·德鲁克曾说过:“每一个决策都是有风险的:它是将当前资源承诺给一个不确定和未知的未来。”在大多数现代组织中,数据中心化和数据驱动的决策已经被普遍认同为减少风险和模糊性、提高商业决策成功率的有效途径。数据和市场营销高管每天都需要做出一系列决策,这些决策对组织的日常运作和长期优先事项有着深远的影响。尽管当前数据资源丰富,利用这些资源的过程依然充满挑战。根据甲骨文公司(Oracle)最近发布的《决策困境》研究报告(2023 年 4 月),72%的商业领导者表示,海量的数据和数据源的可信度及不一致性让他们无法做出决策,89%的领导者认为,数据源数量的增加限制了他们组织的成功,尽管他们明白,缺乏数据支持的决策往往不够准确、不够成功且更容易出错。

数据驱动决策无疑不是一个新概念,实际上,基于数据和统计原则的第一个决策模型早在 1953 年由厄尔温·D·J·布罗斯(Irwin D.J Bross)提出,他区分了真实和象征性有效性,并阐明了测量和验证的重要性。多年来,组织不断发展,进行数据投资,制定战略,将数据作为其风险缓解和决策努力的核心。尽管拥有这些资源,组织当前面临的独特问题是如何平衡对高质量可操作性洞察的需求和资源的可用性。可以用一个简单的“跷跷板”类比来描述这种商业状况。过度追求知识和可操作性洞察,加上数据资源不足,可能会导致数据领导者依赖过去的决策、轶事证据和直觉来做决策。另一方面,数据资源充足,但对知识的需求较低,最终可能会导致构建不必要的数据解决方案和过多的自助式仪表盘,而没有明确的战略来使这些资源变得有用。

来源:图像由作者使用 AI 提示生成。

尽管有丰富的数据资源,数据知识差距却越来越明显。我们正日益观察到一个独特的“断裂跷跷板”现象,数据资源和知识需求并存,但由于缺乏将数据团队所提供价值转化为业务领导者可理解的努力,跷跷板的两端都变得过载,最终导致长期的决策过程破裂和低效。宝贵的数据创造影响力,其他的一切都沉睡在仪表盘中。

数据素养是答案吗?

是的,和不完全是。

数据素养迅速成为关于数据洞察和交付的讨论重点,因为组织意识到为员工提供理解和有效利用数据的技能是多么重要。随着研究凸显了商业专业人士在解读数据时存在的显著技能差距,这一运动获得了动力。强调对用户进行数据解读培训的同时,往往忽略了培养批判性思维、以帮助风险规避的方式解读数据证据所需要的陡峭学习曲线。

技术障碍是数据团队与业务利益相关者之间的另一个瓶颈。我们可以将这一瓶颈分解为两个部分。首先,数据分析工具堆栈不足可能会妨碍非高级数据用户有效利用数据和进行沟通。其次,缺乏相关培训往往会导致误解,并与其他数据源不一致,从而阻碍建立单一数据源的可能性。这最终影响了数据团队的可信度。

当前对数据素养的强调存在一个显著缺点,那就是往往过分将数据产品的失败归咎于用户的技能或理解不足。当数据产品未能提供价值或遭遇抵制时,反应性回应往往是假定用户缺乏技能或理解。这种看法忽视了商业素养和业务背景在有效传达数据洞察方面所扮演的重要角色,无论是在验证还是反驳商业假设时。数据素养是一条双向街道。许多时候,数据团队成员有责任从业务的角度来看待任务,并理解他们为什么需要关注数据团队所传递的信息。承认并解决这些不足,将数据计划与业务目标对齐,可以促成更有效、更和谐的数据驱动文化。

数据翻译员的出现——他们当时是谁,现在又是谁?

数据行业为解决数据与知识之间的差距以及数据素养努力的不足,已采取了引入“数据翻译员”角色的解决方案。数据翻译员的角色多年来经历了显著的演变,反映了组织在利用数据分析方面的变化。最初作为数据科学家与业务部门之间的桥梁出现,该角色的设计旨在确保复杂的数据洞察能够转化为可执行的业务战略。

在早期阶段,数据翻译员主要被视为能够将技术性发现传达给非技术性利益相关者的中介,帮助优先解决业务问题,并确保分析解决方案与业务目标保持一致。随着数据驱动决策需求的增长,这一角色的重要性也逐步提升。到 2019 年,这一角色变得更加普遍,大约三分之一的公司设有符合数据翻译员描述的职位。其职责不仅扩展到了沟通,还包括确保分析工具在企业中得到广泛应用,并实现数据的民主化。近年来,角色逐渐向更广泛的职能整合,例如数据产品负责人,反映出这一角色向更加全面的职能转变,涵盖了技术和战略责任。这一变化突显了有效将数据洞察与业务成果链接的角色持续需求。

图 1:数据翻译员技能集宇宙中的主导主题。来源:作者插图。

图 2:数据翻译员技能所支持的各类角色。来源:作者插图。

数据翻译员的角色根据其服务的组织性质可能承担多种责任。例如,咨询公司通常会指定一名专职数据翻译员,负责将提供的数据解决方案转化为业务领域的语言。被聘用到公司的专业人士通常是以专职数据翻译员、数据产品经理或分析交付经理的形式存在,负责确保数据团队的工作能够合理地用于关键的业务决策。尽管有不同的职位名称,数据翻译员的核心任务是证明数据团队所驱动的价值和影响力。他们通过关注以下几个关键领域来完成这一任务:

1. 成本控制:

数据翻译员作为业务领导与数据团队之间的联络人,通过持续量化数据团队交付项目的影响,并权衡数据资源的合理分配。例如,他们可以通过记录数据团队支持的决策和财务影响来做到这一点。这些记录在评估新战略项目所需资源时常常非常有用,并且作为可以在新背景下复制的数据解决方案的参考。

2. 策略和优先级排序:

数据翻译员对业务目标和优先级有深刻理解,并致力于将团队的努力与更广泛的业务目标对齐。这个过程通常包括识别那些不仅能利用团队技能,而且有可能影响战略结果的项目。优先排序的一种流行方法是使用评估项目潜在影响和可行性的框架。通过简化数据团队的任务接受系统,并聚焦于那些承诺带来显著回报或解决关键业务问题的举措,数据团队可以最大化其效用和生产力。在一篇解释数据产品经理和数据翻译员特质的文章中,《哈佛商业评论》指出,商业背景、广泛的技术流利度、项目管理技能、创业精神,以及将数据需求和战略向其他组织成员解释的能力,是成功的关键。

3. 弥合数据素养差距

数据翻译员与组织内的治理团队合作,建立共同的数据语言、定义和标准,以确保所有团队在理解和解释数据时保持一致。这确保了所有数据工作能够协同合作,建立一个单一的事实来源。

4. 利益相关者参与

确定并优先考虑关键利益相关者对于数据团队至关重要,以确保其工作与组织的战略目标保持一致。数据翻译员通常通过使用一种名为“利益 — 影响矩阵”的项目管理技术来实现这一点。该过程首先通过将利益相关者映射到两个维度来进行:他们对数据项目的兴趣程度以及他们对决策的影响力。高兴趣和高影响力的利益相关者被视为关键角色,应优先与其保持定期沟通和合作。与这些人建立稳固的关系至关重要,因为他们可以推动数据项目,帮助确保资源,并消除障碍。对于影响力较小的利益相关者,保持定期联系可以确保他们了解最新情况,同时不至于过度消耗团队资源。这种深思熟虑的参与方式使数据团队能够将精力集中在能够产生最大影响的地方,从而为整个组织创造价值。

5. 内部推广与外联

在一个日益以数据为中心的环境中,数据团队的角色变得至关重要,但它们常常被误解。数据翻译者通常创建路演、演示文稿和教育材料,分享数据团队的成就和提供的价值,以便在整个组织内建立和维护可信度与信任。

构建数据翻译能力

观察数据翻译者角色的历史和发展表明,除了数据流利度外,领域知识、商业背景以及对组织微妙差异的深刻理解(如目标、预期结果和有效的利益相关者合作)是成功担任此角色的关键。这个角色的灵活性不容忽视。在过去几十年里,来自数据生态系统的各类专业人士以不同方式被纳入“数据翻译者”的职责范围。为了为数据职业生涯做好未来准备,并始终如一地为组织带来成功与价值,数据专业人员必须培养“数据翻译者”能力。

分析师可以遵循的实用技巧,以增强他们成为数据翻译者的能力

下面详细列出了一些实用技巧,帮助分析师成为数据翻译的专家,列表非详尽无遗。

知识的诅咒

知识的诅咒是一种认知偏误,发生在一个具有专门知识的人假设其他人也拥有相同知识时。这种偏误使得知识渊博的人难以想象缺乏他们专业知识的情境。假设每个人都共享相同的理解和背景知识,会导致误解、错误假设和低效沟通。特别是在与市场营销和产品等团队合作时,这种现象尤为突出,因为这些利益相关者不一定精通数据,但数据在他们的项目和广告活动的高效性及成果中起着重要作用。数据翻译者必须具备独特的能力,将问题陈述解构并映射到可用的数据点上,建立联系,找到答案,并以通俗易懂的语言向利益相关者解释。以下是一个市场分析的示例:

声明 1(分析师): 查看渠道归因图表,似乎大多数广告活动的 ROAS 是负数,但看起来流失率较低,参与度更高,这并非完全是无用功。

声明 2(数据翻译者): 在评估了营销投入与回报后,似乎您的广告活动在短期内亏损。但是从大局来看,通过您的营销活动获得的用户参与度更高,并且回归频率更高,因此创造了长期价值。

数据翻译版本的陈述清晰地解释了发现的结果,并说明了广告活动的长期影响,而没有使用数据分析术语。

从商业问题转向商业目标。

许多分析师将自己局限于工作职责范围内,单纯专注于回答业务问题。有时,这种现象也是组织范围内数据素养努力的一个意外副作用。回答业务问题将见解局限于一个特定问题,而聚焦于整体业务结果则为数据团队和业务团队提供了一个机会,可以从更全面的角度审视数据见解。数据素养与业务素养是相辅相成的。数据翻译员始终被期望具备一定的业务结果知识,以便他们能够将见解与总体目标联系起来。

例如,

业务问题: 我新推出的品牌活动效果如何?

回答(分析师): 我们在 3 天内获得了 6000 次展示,比去年同期进行类似活动时高出 50%。

回答(数据翻译员): 这个活动的预期结果是提升品牌知名度。我们通过此次活动吸引了 3000 名新用户访问我们的网站。我们还通过对这些特定用户进行调查,测量了品牌认知度的变化,结果显示他们对品牌产品的认识和看法有所提升。

学会放眼全局

学会放眼全局,看到大局,并能够将个别任务与整体优先事项联系起来,帮助数据翻译员将精力集中在有影响力的举措上。这项技能还使他们能够学会构建可扩展的分析解决方案,这些解决方案可以重新利用,最终节省时间并提高洞察的速度。

成为一个优秀的数据故事讲述者

“我没有时间写一封短信,所以我写了一封长信。”

― 马克·吐温

数据故事讲述既是科学也是艺术。它是数据翻译员工具包中的一个基本工具。它要求对问题和解决方案有透彻的理解,构建一个清晰、简洁、易于理解的叙事,并以可以付诸实践的建议和见解结束。每一个数据故事都需要一个主导思想,且大致遵循一个情节。安排分析故事的一个有效方法是按问题、问题的影响、发现、建议和下一步行动的顺序进行排列。这可以确保你的数据故事容易理解,并且即使你不在场讲解,整个故事也能自我说明。

在重复性任务中,如常规的绩效更新或回顾总结,顺序可能会有所不同。但对于需要数据见解以支持决策的典型请求,这个顺序是一个很好的起点。在这一阶段,最重要的是确保数据准确且相关,能够清晰支持你的故事。除此之外,我还有一些小技巧可以帮助你将分析方案整理得更为简洁。这些细节对展示效果大有帮助,并且能够帮助观众记住关键见解。

· 清楚地指明分享的关键数据点是一个好兆头还是坏兆头,可以通过使用箭头和颜色来表示。(示例:较低的跳出率是一个好兆头,但较低的转化率是一个坏兆头。)

· 在幻灯片中分享任何数字(数据点)时,总是需要通过提供基准数据或趋势分析来添加背景信息。(示例:本月的转化率为 12%,与同一产品线中的其他 SKU 一致,并且高于过去三年相同月份的平均转化率。)

· 在每一张幻灯片中,将洞察与原始业务问题、目标和结果关联起来。

· 包括样本大小、分析时间框架和脚注中的重要注释等细节,有助于建立信任和可信度。

从本质上讲,数据故事被认为是有效的,当它能够让观众了解信息并激励他们采取行动时。

结论

数据翻译员在数据和业务团队之间发挥着至关重要的桥梁作用。他们的技能在证明数据投资的价值和影响、促进数据素养、优先处理高影响力的举措,以及保护分析师免于从事低价值任务方面起着重要作用。通过鼓励、融入和培养具有数据翻译技能的团队成员,组织和数据团队可以获得互利共赢的好处。

关于作者:

Nithhyaa Ramamoorthy 是一位数据领域的专家,拥有超过 12 年的分析和大数据经验,特别是在医疗保健和消费者行为的交集领域。她拥有信息科学硕士学位,并且最近获得了 CSPO 认证以及其他多个专业认证。她热衷于利用自己的分析技能推动业务决策,创造以同理心为基础的包容性和公平的数字产品。

为你的数据带来结构

原文:towardsdatascience.com/bringing-structure-to-your-data-9acbd1051a1f?source=collection_archive---------4-----------------------#2024-10-14

使用路径模型检验假设

Dorian DrostTowards Data Science Dorian Drost

·发表于Towards Data Science ·11 分钟阅读·2024 年 10 月 14 日

--

在复杂的路径模型中,可能很难找到方向。图片由Deva Darshan提供,来自Unsplash

数据科学家常常收集大量变量,并寻找它们之间的关系。在这个过程中,假设和假说有助于理解变量之间的具体关系。一个学生准备下一次考试的动力是否会影响他们的成绩?还是良好的成绩会激励他们去学习?那么,究竟是什么样的行为模式会促使那些有动力的人最终取得好成绩?

为了给像上述问题提供一些结构,并提供一个工具来进行实证检验,我想在本文中解释路径模型,也叫做结构方程模型SEM)。虽然在社会科学领域,如心理学中,路径模型被广泛使用,但我觉得它们在数据科学和计算机科学等其他领域并不那么突出。因此,我想概述路径分析的主要概念,并介绍semopy,这是一个用于在 Python 中应用路径分析的包。在整篇文章中,我们将分析人工数据,以展示可以通过路径模型解决的典型问题,并介绍调节变量中介变量的概念。请注意,这些数据是为演示目的生成的,可能在每个细节上并不完全符合现实。

研究问题

在分析数据之前,我们需要对我们要寻找的内容有所了解。图片来源:Ansia LasaUnsplash

如果我们想要分析数据,我们需要有一个研究问题,想要调查的内容。对于本文,我们假设调查的是学校儿童及其所获得的成绩。我们可能对促进学习和获得好成绩的因素感兴趣。可能的因素包括他们在学校的乐趣、他们对班级的归属感、对学科的兴趣、他们在班级里的朋友数量、与老师的关系、他们的智力等。于是我们进入不同的学校,通过发放问卷收集数据,问卷内容涉及归属感、与老师的关系、对学科的兴趣以及学生在学校的乐趣,我们还进行智商测试,并询问他们有多少朋友。当然,我们还会收集他们的考试成绩。

一切从数据开始

我们现在拥有了以下所有变量的数据:

我们的下一步是调查,到底这些变量如何影响成绩。我们可以对这些影响做出不同的假设,并用数据来验证这些假设。让我们从最简单的情况开始,假设每个变量对成绩都有直接影响,而且这种影响与其他变量无关。例如,我们会假设智力越高,成绩越好,不论对学科的兴趣或在学校的乐趣如何。我们对其他变量也会假设类似的关系。用图形显示,这种关系是这样的:

假设所有变量都直接影响变量成绩。图片来源:作者。

每个箭头表示变量之间的影响。我们也可以将这种关系表示为加权和,像这样:

grades = afeeling_of_belonging* + bnumber_of_friends* + crelationship_with_teacher* + dfun_in_school* + eintelligence* + finterest_in_topic*

这里的 a、b、c、d、e 和 f 是权重,告诉我们不同变量对我们结果成绩的影响有多强。好的,这是我们的假设。现在我们想要根据数据来测试这个假设。假设我们有一个名为data的数据框架,其中每个列代表上述的一个变量。然后我们可以像这样在 Python 中使用 semopy:

import semopy 

path = """
grades ~ intelligence + interest_in_topic 
+ feeling_of_belonging + relationship_with_teacher 
+ fun_in_school + number_of_friends
"""

m = semopy.Model(path)
m.fit(data)

在最后几行中,我们创建了一个semopy.Model对象并用数据进行拟合。最有趣的部分是前面提到的变量path。在这里,我们指定了我们刚刚提出的假设,即变量grades是所有其他变量的组合。在波浪号(~)左边的部分是我们期望依赖于波浪号右边变量的那个变量。请注意,我们没有明确指定权重 a、b、c、d、e 和 f。这些权重实际上是我们想要了解的内容,所以让我们运行以下代码行以获得结果:

m.inspect()

假设所有变量直接影响成绩变量的结果。图片来自作者。

权重 a、b、c、d、e 和 f 是我们在Estimate列中看到的内容。我们可以从这个表格中提取什么信息呢?首先,我们看到一些权重大,一些权重小。例如,feeling_of_belonging的权重最大(0.40),这表明它有最强的影响力。例如,interest_in_topic的影响要小得多(0.08),而像intelligencenumber_of_friends这样的其他变量的权重几乎为零。

此外,请查看p-value列。如果你熟悉统计测试,可能已经知道如何解读这个值。如果不熟悉,别担心。关于如何理解显著性(这正是此列所表示的内容)的文献非常丰富,我鼓励你深入了解它。然而,暂时我们可以说,这一列给我们提供了一些关于我们发现的效应是否只是随机噪声的可能性的线索。例如,number_of_friends对成绩的影响非常小(-0.01),而且很可能(0.42)只是巧合。因此我们会说没有效应,尽管权重不完全为零。反之,如果 p 值接近零,我们可以假设我们确实找到了一个不只是巧合的效应。

好的,根据我们的分析,有三个变量对成绩产生了影响,它们是interest_in_topic(0.08)、feeling_of_belonging(0.40)和relationship_with_teacher(0.19)。其他变量没有影响。这是我们的最终答案吗?

结果并非如此!请记住,semopy 执行的计算受我们给定假设的影响。我们假设所有变量都直接影响成绩,且彼此独立。但如果实际关系有所不同呢?变量之间可能有许多不同的相互影响方式,因此让我们提出一些不同的假设,从而探索中介变量调节变量的概念。

中介变量

调解变量可以像台球一样,一个推动另一个。照片来源:Steve MusheroUnsplash

与其说朋友数量归属感直接影响成绩,不如换个方向思考。如果你在班里没有任何朋友,你就不会感到归属感,对吧?这种(不)归属的感觉可能会影响成绩。所以关系应该更像这样:

模型假设朋友数量影响归属感,归属感反过来影响成绩。图像来源:作者。

请注意,朋友数量成绩的直接影响已经消失,但我们现在看到朋友数量归属感的影响,归属感反过来影响成绩。我们可以拿这个假设让 semopy 来测试:

path = """
feeling_of_belonging ~ number_of_friends
grades ~ feeling_of_belonging
"""
m = semopy.Model(path)
m.fit(data)

在这里我们说到,归属感取决于朋友数量,而成绩则取决于归属感。你可以看到下面的输出。现在,归属感成绩之间仍然有 0.40 的权重,但我们也有 0.29 的权重连接朋友数量归属感。看起来我们的假设是有效的。朋友的数量影响归属感,而归属感又反过来影响成绩。

朋友数量影响归属感的假设结果。图像来源:作者。

我们在这里建模的这种影响叫做中介,因为一个变量在另一个变量的影响过程中充当了中介。换句话说,朋友数量成绩没有直接影响,而是通过归属感间接影响成绩。

中介分析可以帮助我们理解一些变量是如何及通过哪些过程相互影响的。有明确目标和想法的学生不太可能辍学,但到底是什么行为模式导致了在学校中表现良好呢?是学习更多吗?是如果不理解某个主题就寻求帮助吗?这些都可能是中介变量,部分解释了明确目标对学术成就的影响。

调节变量

调节变量可以像一个阀门,只允许一定量的流量通过。照片来源:Igal NessUnsplash

我们刚才看到,假设变量之间存在不同的关系帮助更有效地描述数据。也许我们可以做类似的事情来解释为什么数据中智力对成绩没有影响。这是令人惊讶的,因为我们通常会认为更聪明的学生平均应该能获得更高的成绩,不是吗?然而,如果一个学生对某个主题不感兴趣,他可能就不会投入多少精力,不是吗?也许智力对成绩没有直接的影响,但智力和兴趣之间存在某种联合作用。如果学生对某个主题感兴趣,那么智力更高的学生将获得更高的成绩;但如果他们不感兴趣,那就无所谓了,因为他们没有投入任何努力。我们可以这样来可视化这种关系:

假设对主题的兴趣调节智力成绩的影响。图片由作者提供。

也就是说,我们假设智力成绩有影响,但这个影响受对主题的兴趣的影响。如果兴趣很高,学生会充分利用自己的认知能力,从而获得更高的成绩;但如果兴趣很低,他们就不会这样做。

如果我们想在 semopy 中检验这个假设,我们需要创建一个新变量,它是智力对主题的兴趣的乘积。你能看出将变量相乘是如何反映我们刚才的想法的吗?如果对主题的兴趣接近零,那么整个乘积也接近零,不管智力如何。但如果对主题的兴趣很高,那么乘积主要由智力的高低决定。所以,我们在数据框中计算一个新列,叫做智力 x 兴趣,然后将我们假设的这种关系输入到 semopy 中,与成绩之间的关系如下:

path = """
grades ~ intellgence_x_interest
"""
m = semopy.Model(path)
m.fit(data)

然后我们发现一个影响:

假设智力和兴趣的乘积影响成绩的结果。图片由作者提供。

之前,智力成绩没有影响,而对主题的兴趣几乎没有影响(0.08)。但是,如果我们将这两个因素结合起来,我们会发现一个很大的影响值 0.81。看起来这两个变量的结合能够更好地描述我们的数据。

这种变量间的交互作用叫做调节作用。我们可以说,对主题的兴趣调节了智力成绩的影响,因为智力成绩之间的关系强度取决于兴趣。调节作用对于理解变量之间关系如何在不同情境或不同参与者群体之间有所不同非常重要。例如,工作经验更长通常会正面影响薪水,但对于男性而言,这种影响比女性要强。在这种情况下,性别是工作经验对薪水影响的调节因素。

总结

如果我们将之前的所有步骤结合起来,我们的新模型看起来是这样的:

包含所有先前假设的完整模型。图片由作者提供。

完整模型的结果。图片由作者提供。

现在我们为数据构建了一个更加复杂且更具 plausibility(合理性)的结构。注意,fun_in_school仍然没有对grades产生影响(因此在上面的可视化中,我给它加上了虚线)。要么数据中不存在这种影响,要么我们尚未找到与其他变量的正确互动关系。我们甚至可能缺少一些有趣的变量。就像intelligence只有与interest_in_topic结合时才有意义一样,或许还有另一个变量是理解fun_in_school对成绩影响所必需的。这向你展示了,对于路径分析来说,理解你的数据并明确你想要调查的内容是非常重要的。一切都始于你从理论中(有时是凭直觉)得出的假设,然后通过数据来验证这些假设,从而更好地理解数据。

这就是路径模型的含义。让我们总结一下我们刚才学到的内容。

  • 路径模型使我们能够测试变量之间如何相互影响的假设。

  • 中介效应出现的情况是,当变量a对变量c没有直接影响,但通过影响另一个变量b,再由b影响c

  • 我们称之为调节效应,如果变量a对变量c的影响强度根据另一个变量b的不同而有所变化。这个现象可以通过计算变量的乘积来建模。

  • Semopy可以用来在 python 中测试给定数据的路径模型。

我希望我已经能够让你相信路径模型的有用性。尽管如此,我所展示的仅仅是它的冰山一角。通过路径模型或从中衍生的其他模型,还可以测试更多复杂的假设,这些假设远远超出了本文的范围。

参考文献

你可以在这里找到 semopy:

如果你想了解更多关于路径分析的内容,维基百科可以是一个很好的入门点:

我使用这本书作为统计学背景参考(不幸的是,它仅提供德语版本):

  • Eid, M., Gollwitzer, M., & Schmitt, M. (2015). Statistik und Forschungsmethoden.

这是本文数据生成的方式:

import numpy as np
import pandas as pd

np.random.seed(42)

N = 7500

def norm(x):
    return (x - np.mean(x)) / np.std(x)

number_of_friends = [int(x) for x in np.random.exponential(2, N)]

# let's assume the questionairs here had a range from 0 to 5
relationship_with_teacher = np.random.normal(3.5,1,N)
relationship_with_teacher = np.clip(relationship_with_teacher, 0,5)
fun_in_school = np.random.normal(2.5, 2, N)
fun_in_school = np.clip(fun_in_school, 0,5)

# let's assume the interest_in_topic questionaire goes from 0 to 10
interest_in_topic = 10-np.random.exponential(1, N)
interest_in_topic = np.clip(interest_in_topic, 0, 10)

intelligence = np.random.normal(100, 15, N)
# normalize variables
interest_in_topic = norm(interest_in_topic)
fun_in_school = norm(fun_in_school)
intelligence = norm(intelligence)
relationship_with_teacher = norm(relationship_with_teacher)
number_of_friends = norm(number_of_friends)

# create dependend variables
feeling_of_belonging = np.multiply(0.3, number_of_friends) + np.random.normal(0, 1, N)
grades = 0.8 * intelligence * interest_in_topic + 0.2 * relationship_with_teacher + 0.4*feeling_of_belonging + np.random.normal(0,0.5,N)

data = pd.DataFrame({
    "grades":grades,
    "intelligence":intelligence,
    "number_of_friends":number_of_friends,
    "fun_in_school":fun_in_school,
    "feeling_of_belonging": feeling_of_belonging,
    "interest_in_topic":interest_in_topic,
    "intellgence_x_interest" : intelligence * interest_in_topic,
    "relationship_with_teacher":relationship_with_teacher
})

喜欢这篇文章吗? 关注我 以便接收我的未来更新。

使用 Gemini 为任何类型的 PDF 构建文档 AI 流水线

原文:towardsdatascience.com/build-a-document-ai-pipeline-for-any-type-of-pdf-with-gemini-9221c8e143db?source=collection_archive---------0-----------------------#2024-12-15

表格、图片、图表或公式已经不再是问题!提供完整代码。

Youness MansarTowards Data Science Youness Mansar

·发表于 Towards Data Science ·阅读时间 10 分钟·2024 年 12 月 15 日

--

图片来源:Matt Noble via Unsplash

自动化文档处理是 ChatGPT 革命中的最大赢家之一,因为大型语言模型(LLM)能够在零样本环境下处理各种主题和任务,这意味着它们不需要领域内的标注训练数据。这使得构建基于 AI 的应用程序来处理、解析和自动理解任意文档变得更加容易。尽管使用 LLM 的简单方法仍然受到非文本上下文的制约,如图表、图片和表格,但这正是我们将在本博客中尝试解决的问题,特别是关注 PDF 文件。

从基础层面来说,PDF 文件仅仅是由字符、图片和线条以及它们的精确坐标组成。它们没有内在的“文本”结构,并且并非为了作为文本进行处理而构建,而只是为了查看而存在。这就是为什么与它们打交道会很困难,因为仅限文本的方法无法捕捉到这些文档中的所有布局和视觉元素,导致大量的上下文和信息丢失。

绕过这一“仅限文本”的限制的一种方法是,通过在将文档输入到 LLM 之前,进行大量的预处理工作,检测表格、图片和布局。表格可以解析为 Markdown 或 JSON,图片和图表可以通过它们的标题来表示,文本则可以按原样输入。然而,这种方法需要自定义模型,并且仍然会导致一些信息丢失,那么我们能做得更好吗?

多模态 LLMs

最近的大型模型大多是多模态的,意味着它们能够处理文本、代码和图像等多种模态。这为我们的问题提供了更简单的解决方案,可以让一个模型一次性处理所有内容。因此,代替为图像加上标题和解析表格,我们可以直接将页面作为图像输入,并按原样处理。我们的管道将能够加载 PDF,提取每一页作为图像,使用 LLM 将其拆分为多个分块,并为每个分块创建索引。如果某个分块被检索到,完整的页面将被包含在 LLM 的上下文中进行任务处理。接下来,我们将详细说明如何在实践中实现这一点。

管道

我们实现的管道是一个两步过程。首先,我们将每一页分割成重要的分块并对每个分块进行摘要。其次,我们在每次获取请求时检索这些分块并将完整上下文与每个检索到的分块一同包含在 LLM 上下文中。

步骤 1:页面分割与摘要

我们将页面提取为图像,并将每个图像传递给多模态 LLM 进行分割。像 Gemini 这样的模型可以轻松理解并处理页面布局:

  • 表格被视为一个分块。

  • 图形构成另一个分块。

  • 文本块被分割成独立的分块。

对于每个元素,LLM 会生成一个摘要,可以嵌入并索引到向量数据库中。

步骤 2:嵌入与上下文检索

在本教程中,我们将仅使用文本嵌入以简化操作,但一种改进方法是直接使用视觉嵌入。

数据库中的每一条记录包括:

  • 分块的摘要。

  • 找到该项的页面编号。

  • 提供完整页面图像的链接以增加上下文信息。

该架构允许局部级别搜索(按分块级别),同时保持上下文跟踪(通过链接回完整页面)。例如,如果搜索查询检索到某个项,代理可以将整个页面图像包含进来,以便向 LLM 提供完整的布局和额外的上下文信息,从而最大化响应质量。

通过提供完整图像,所有的视觉提示和重要的布局信息(如图像、标题、项目符号…)以及邻近的项(表格、段落等)都可以在生成响应时提供给 LLM。

代理

我们将每个步骤实现为一个独立的、可重用的代理:

第一个代理用于解析、分块和摘要。这涉及到将文档分割成重要的分块,并为每个分块生成摘要。这个代理每个 PDF 只需要运行一次,用于预处理文档。

第二个代理负责管理索引、搜索和检索。这包括将分块的嵌入插入到向量数据库中,以便高效搜索。索引在每个文档中只执行一次,而搜索可以根据不同的查询重复进行。

对于两个代理,我们使用Gemini,一个具有强大视觉理解能力的多模态 LLM。

解析与分块代理

第一个代理负责将每一页分割成有意义的片段,并对每个片段进行总结,按照以下步骤进行:

步骤 1:将 PDF 页面提取为图像

我们使用pdf2image库。然后将图像编码为 Base64 格式,以简化将其添加到 LLM 请求中。

下面是实现过程:

from document_ai_agents.document_utils import extract_images_from_pdf
from document_ai_agents.image_utils import pil_image_to_base64_jpeg
from pathlib import Path

class DocumentParsingAgent:
    @classmethod
    def get_images(cls, state):
        """
        Extract pages of a PDF as Base64-encoded JPEG images.
        """
        assert Path(state.document_path).is_file(), "File does not exist"
        # Extract images from PDF
        images = extract_images_from_pdf(state.document_path)
        assert images, "No images extracted"
        # Convert images to Base64-encoded JPEG
        pages_as_base64_jpeg_images = [pil_image_to_base64_jpeg(x) for x in images]
        return {"pages_as_base64_jpeg_images": pages_as_base64_jpeg_images}

extract_images_from_pdf:将 PDF 的每一页提取为 PIL 图像。

pil_image_to_base64_jpeg:将图像转换为 Base64 编码的 JPEG 格式。

步骤 2:分段和总结

每个图像将被发送到 LLM 进行分割和总结。我们使用结构化输出以确保获得我们期望的格式的预测:

from pydantic import BaseModel, Field
from typing import Literal
import json
import google.generativeai as genai
from langchain_core.documents import Document

class DetectedLayoutItem(BaseModel):
    """
    Schema for each detected layout element on a page.
    """
    element_type: Literal["Table", "Figure", "Image", "Text-block"] = Field(
        ..., 
        description="Type of detected item. Examples: Table, Figure, Image, Text-block."
    )
    summary: str = Field(..., description="A detailed description of the layout item.")

class LayoutElements(BaseModel):
    """
    Schema for the list of layout elements on a page.
    """
    layout_items: list[DetectedLayoutItem] = []

class FindLayoutItemsInput(BaseModel):
    """
    Input schema for processing a single page.
    """
    document_path: str
    base64_jpeg: str
    page_number: int

class DocumentParsingAgent:
    def __init__(self, model_name="gemini-1.5-flash-002"):
        """
        Initialize the LLM with the appropriate schema.
        """
        layout_elements_schema = prepare_schema_for_gemini(LayoutElements)
        self.model_name = model_name
        self.model = genai.GenerativeModel(
            self.model_name,
            generation_config={
                "response_mime_type": "application/json",
                "response_schema": layout_elements_schema,
            },
        )
    def find_layout_items(self, state: FindLayoutItemsInput):
        """
        Send a page image to the LLM for segmentation and summarization.
        """
        messages = [
            f"Find and summarize all the relevant layout elements in this PDF page in the following format: "
            f"{LayoutElements.schema_json()}. "
            f"Tables should have at least two columns and at least two rows. "
            f"The coordinates should overlap with each layout item.",
            {"mime_type": "image/jpeg", "data": state.base64_jpeg},
        ]
        # Send the prompt to the LLM
        result = self.model.generate_content(messages)
        data = json.loads(result.text)

        # Convert the JSON output into documents
        documents = [
            Document(
                page_content=item["summary"],
                metadata={
                    "page_number": state.page_number,
                    "element_type": item["element_type"],
                    "document_path": state.document_path,
                },
            )
            for item in data["layout_items"]
        ]
        return {"documents": documents}

LayoutElements模式定义了输出的结构,其中包括每种布局项类型(表格、图形等)及其总结。

步骤 3:页面的并行处理

页面并行处理以提高速度。以下方法创建一个任务列表,以便同时处理所有页面图像,因为处理是 IO 绑定的:

from langgraph.types import Send

class DocumentParsingAgent:
    @classmethod
    def continue_to_find_layout_items(cls, state):
        """
        Generate tasks to process each page in parallel.
        """
        return [
            Send(
                "find_layout_items",
                FindLayoutItemsInput(
                    base64_jpeg=base64_jpeg,
                    page_number=i,
                    document_path=state.document_path,
                ),
            )
            for i, base64_jpeg in enumerate(state.pages_as_base64_jpeg_images)
        ]

每一页将作为独立任务发送到find_layout_items函数。

完整工作流程

该代理的工作流程使用StateGraph构建,将图像提取和布局检测步骤链接成一个统一的管道 ->

from langgraph.graph import StateGraph, START, END

class DocumentParsingAgent:
    def build_agent(self):
        """
        Build the agent workflow using a state graph.
        """
        builder = StateGraph(DocumentLayoutParsingState)

        # Add nodes for image extraction and layout item detection
        builder.add_node("get_images", self.get_images)
        builder.add_node("find_layout_items", self.find_layout_items)
        # Define the flow of the graph
        builder.add_edge(START, "get_images")
        builder.add_conditional_edges("get_images", self.continue_to_find_layout_items)
        builder.add_edge("find_layout_items", END)

        self.graph = builder.compile()

要在一个样本 PDF 上运行代理,我们需要执行以下操作:

if __name__ == "__main__":
    _state = DocumentLayoutParsingState(
        document_path="path/to/document.pdf"
    )
    agent = DocumentParsingAgent()

    # Step 1: Extract images from PDF
    result_images = agent.get_images(_state)
    _state.pages_as_base64_jpeg_images = result_images["pages_as_base64_jpeg_images"]

    # Step 2: Process the first page (as an example)
    result_layout = agent.find_layout_items(
        FindLayoutItemsInput(
            base64_jpeg=_state.pages_as_base64_jpeg_images[0],
            page_number=0,
            document_path=_state.document_path,
        )
    )
    # Display the results
    for item in result_layout["documents"]:
        print(item.page_content)
        print(item.metadata["element_type"]) 

这将生成一个解析、分段和总结后的 PDF 表示,它是我们接下来要构建的第二个代理的输入。

RAG 代理

这个第二个代理负责索引和检索部分。它将前一个代理的文档保存到向量数据库中,并使用结果进行检索。这可以分为两个独立的步骤,索引和检索。

步骤 1:索引拆分文档

使用生成的总结,我们将它们向量化并保存到 ChromaDB 数据库中:

class DocumentRAGAgent:
    def index_documents(self, state: DocumentRAGState):
        """
        Index the parsed documents into the vector store.
        """
        assert state.documents, "Documents should have at least one element"
        # Check if the document is already indexed
        if self.vector_store.get(where={"document_path": state.document_path})["ids"]:
            logger.info(
                "Documents for this file are already indexed, exiting this node"
            )
            return  # Skip indexing if already done
        # Add parsed documents to the vector store
        self.vector_store.add_documents(state.documents)
        logger.info(f"Indexed {len(state.documents)} documents for {state.document_path}")

index_documents方法将片段总结嵌入到向量存储中。我们保留文档路径和页面编号等元数据,以备后续使用。

步骤 2:处理问题

当用户提出问题时,代理会在向量存储中搜索最相关的片段。它检索总结和相应的页面图像以便理解上下文。

class DocumentRAGAgent:
    def answer_question(self, state: DocumentRAGState):
        """
        Retrieve relevant chunks and generate a response to the user's question.
        """
        # Retrieve the top-k relevant documents based on the query
        relevant_documents: list[Document] = self.retriever.invoke(state.question)

        # Retrieve corresponding page images (avoid duplicates)
        images = list(
            set(
                [
                    state.pages_as_base64_jpeg_images[doc.metadata["page_number"]]
                    for doc in relevant_documents
                ]
            )
        )
        logger.info(f"Responding to question: {state.question}")
        # Construct the prompt: Combine images, relevant summaries, and the question
        messages = (
            [{"mime_type": "image/jpeg", "data": base64_jpeg} for base64_jpeg in images]
            + [doc.page_content for doc in relevant_documents]
            + [
                f"Answer this question using the context images and text elements only: {state.question}",
            ]
        )
        # Generate the response using the LLM
        response = self.model.generate_content(messages)
        return {"response": response.text, "relevant_documents": relevant_documents}

检索器查询向量存储以查找与用户问题最相关的片段。然后我们为 LLM(Gemini)构建上下文,结合文本片段和图像以生成回应。

完整代理工作流程

代理的工作流程包括两个阶段,一个是索引阶段,另一个是问答阶段:

class DocumentRAGAgent:
    def build_agent(self):
        """
        Build the RAG agent workflow.
        """
        builder = StateGraph(DocumentRAGState)
        # Add nodes for indexing and answering questions
        builder.add_node("index_documents", self.index_documents)
        builder.add_node("answer_question", self.answer_question)
        # Define the workflow
        builder.add_edge(START, "index_documents")
        builder.add_edge("index_documents", "answer_question")
        builder.add_edge("answer_question", END)
        self.graph = builder.compile()

示例运行

if __name__ == "__main__":
    from pathlib import Path

  # Import the first agent to parse the document
    from document_ai_agents.document_parsing_agent import (
        DocumentLayoutParsingState,
        DocumentParsingAgent,
    )
    # Step 1: Parse the document using the first agent
    state1 = DocumentLayoutParsingState(
        document_path=str(Path(__file__).parents[1] / "data" / "docs.pdf")
    )
    agent1 = DocumentParsingAgent()
    result1 = agent1.graph.invoke(state1)
    # Step 2: Set up the second agent for retrieval and answering
    state2 = DocumentRAGState(
        question="Who was acknowledged in this paper?",
        document_path=str(Path(__file__).parents[1] / "data" / "docs.pdf"),
        pages_as_base64_jpeg_images=result1["pages_as_base64_jpeg_images"],
        documents=result1["documents"],
    )
    agent2 = DocumentRAGAgent()
    # Index the documents
    agent2.graph.invoke(state2)
    # Answer the first question
    result2 = agent2.graph.invoke(state2)
    print(result2["response"])
    # Answer a second question
    state3 = DocumentRAGState(
        question="What is the macro average when fine-tuning on PubLayNet using M-RCNN?",
        document_path=str(Path(__file__).parents[1] / "data" / "docs.pdf"),
        pages_as_base64_jpeg_images=result1["pages_as_base64_jpeg_images"],
        documents=result1["documents"],
    )
    result3 = agent2.graph.invoke(state3)
    print(result3["response"])

通过此实现,管道完成了文档处理、检索和问答功能。

示例:使用文档 AI 管道

让我们通过一个实际示例来操作,使用文档LLM & Adaptation.pdf,它包含 39 页幻灯片,包含文本、公式和图形(CC BY 4.0)。

第 1 步:解析和总结文档(代理 1)

  • 执行时间:解析 39 页文档花费了29 秒

  • 结果:代理 1 生成了一个索引文档,其中包含每页的摘要和 Base64 编码的 JPEG 图片。

第 2 步:质疑文档(代理 2)

我们提出了以下问题:

解释 LoRA,给出相关的公式

结果:

检索的页面:

来源:LLM & Adaptation.pdf 许可证 CC-BY

来自 LLM 的回应

图片来源:作者。

LLM 能够通过利用生成连贯且正确回应的视觉上下文,将公式和图形纳入其回应中。

结论

在这个简短的教程中,我们看到了如何通过利用近期 LLMs 的多模态性,进一步推进你的文档 AI 处理流程,并利用每个文档中的完整视觉上下文,来提升你从信息提取或 RAG 流程中获得的输出质量。

我们构建了一个更强大的文档分割步骤,能够检测到重要的项目,如段落、表格和图形,并对其进行总结,随后使用这一第一步的结果来查询项目和页面集合,利用 Gemini 给出相关且精准的答案。作为下一步,你可以在你的使用案例和文档上尝试,试着使用一个可扩展的向量数据库,并将这些代理部署为你 AI 应用的一部分。

完整代码和示例请参见:github.com/CVxTz/document_ai_agents

感谢阅读!😃

如何构建一个通用的大型语言模型(LLM)智能体

原文:towardsdatascience.com/build-a-general-purpose-ai-agent-c40be49e7400?source=collection_archive---------0-----------------------#2024-12-05

一步步指南

Maya MuradTowards Data Science Maya Murad

·发表于 Towards Data Science ·阅读时间 11 分钟·2024 年 12 月 5 日

--

LLM 智能体的高级概览。 (图片由作者提供)

为什么要构建一个通用智能体? 因为它是一个出色的工具,可以快速原型化你的使用案例,并为设计你自己的定制智能架构奠定基础。

在我们深入之前,先简要介绍一下 LLM 智能体。可以随时跳过。

什么是 LLM 智能体?

LLM 智能体是一种程序,其执行逻辑由其底层模型控制。

从独立的 LLM 到智能系统。图片由作者提供

LLM 智能体与类似少量提示(few-shot prompting)或固定工作流的方法的区别在于它能够定义并适应执行用户查询所需的步骤。通过访问一组工具(如代码执行或网页搜索),智能体可以决定使用哪种工具,如何使用它,并根据输出对结果进行迭代。这种适应性使系统能够在最少配置的情况下处理多样化的使用场景。

智能架构的谱系。 (图片由作者提供)

代理架构存在一个谱系,从固定工作流的可靠性到自主代理的灵活性。例如,像检索增强生成(RAG)这样的固定流程可以通过自我反思循环进行增强,使得程序能够在初始响应不足时进行迭代。或者,像ReAct这样的代理可以装备固定流程作为工具,提供一种灵活而结构化的方法。架构的选择最终取决于使用场景和可靠性与灵活性之间的权衡。

若要深入了解,请查看这个视频

让我们从零开始构建一个通用的 LLM 代理吧!

第 1 步:选择合适的 LLM

选择正确的模型对于实现预期的性能至关重要。需要考虑的因素包括许可、成本和语言支持。构建 LLM 代理时最重要的考虑因素是模型在关键任务上的表现,例如编码、工具调用和推理。可以评估的基准包括:

另一个关键因素是模型的上下文窗口。代理工作流可能会消耗大量的 token——有时达到 10 万或更多——因此较大的上下文窗口非常有帮助。

值得考虑的模型(写作时)

通常来说,较大的模型往往能够提供更好的性能,但能够在本地运行的小型模型仍然是一个可靠的选择。使用小型模型时,你将受限于更简单的应用场景,并且可能只能将代理连接到一两个基础工具。

第 2 步:定义代理的控制逻辑(即通信结构)

单一代理架构。(图像来源:作者)

简单的 LLM 与代理之间的主要区别在于系统提示

在 LLM 的背景下,系统提示是一组在模型与用户查询互动之前提供给模型的指令和上下文信息。

LLM 期望的代理行为可以在系统提示中进行编码。

这里是一些常见的代理模式,可以根据需要进行自定义:

  • 工具使用:代理决定何时将查询路由到合适的工具或依赖其自身的知识。

  • 反思:代理在回应用户之前回顾并修正其回答。大多数 LLM 系统中也可以加入一个反思步骤。

  • 先推理后行动(ReAct:代理反复推理如何解决查询,执行一个行动,观察结果,并决定是否采取另一个行动或提供响应。

  • 计划后执行:代理首先通过将任务分解为子步骤(如果需要)进行规划,然后执行每个步骤。

最后两种模式——ReAct计划后执行——通常是构建通用单一代理的最佳起点。

常见代理模式概述。(图片由作者提供)

为了有效实施这些行为,你需要进行一些提示工程。你也许还想使用结构化生成技术。这基本上意味着将 LLM 的输出格式化为特定的格式或模式,以便代理的回应保持一致,符合你期望的沟通风格。

示例:以下是来自Bee Agent Framework的 ReAct 风格代理系统提示摘录。

# Communication structure
You communicate only in instruction lines. The format is: "Instruction: expected output". You must only use these instruction lines and must not enter empty lines or anything else between instruction lines.
You must skip the instruction lines Function Name, Function Input and Function Output if no function calling is required.

Message: User's message. You never use this instruction line.
Thought: A single-line plan of how to answer the user's message. It must be immediately followed by Final Answer.
Thought: A single-line step-by-step plan of how to answer the user's message. You can use the available functions defined above. This instruction line must be immediately followed by Function Name if one of the available functions defined above needs to be called, or by Final Answer. Do not provide the answer here.
Function Name: Name of the function. This instruction line must be immediately followed by Function Input.
Function Input: Function parameters. Empty object is a valid parameter.
Function Output: Output of the function in JSON format.
Thought: Continue your thinking process.
Final Answer: Answer the user or ask for more information or clarification. It must always be preceded by Thought.

## Examples
Message: Can you translate "How are you" into French?
Thought: The user wants to translate a text into French. I can do that.
Final Answer: Comment vas-tu?

第 3 步。定义代理的核心指令

我们常常理所当然地认为,大型语言模型(LLMs)自带许多功能。虽然其中一些功能很棒,但也有些可能并不是你真正需要的。为了获得理想的表现,重要的是在系统提示中明确列出你想要的——以及不想要的——功能。

这可能包括如下指令:

  • 代理名称与角色:代理被称为何,它的职责是什么。

  • 语气与简洁性:应该听起来多正式或随意,以及应该多简洁。

  • 何时使用工具:决定何时依赖外部工具与模型自身的知识。

  • 错误处理:当工具或过程出现问题时,代理应该怎么做。

示例:以下是来自Bee Agent Framework的指令部分摘录。

# Instructions
User can only see the Final Answer, all answers must be provided there.
You must always use the communication structure and instructions defined above. Do not forget that Thought must be a single-line immediately followed by Final Answer.
You must always use the communication structure and instructions defined above. Do not forget that Thought must be a single-line immediately followed by either Function Name or Final Answer.
Functions must be used to retrieve factual or historical information to answer the message.
If the user suggests using a function that is not available, answer that the function is not available. You can suggest alternatives if appropriate.
When the message is unclear or you need more information from the user, ask in Final Answer.

# Your capabilities
Prefer to use these capabilities over functions.
- You understand these languages: English, Spanish, French.
- You can translate and summarize, even long documents.

# Notes
- If you don't know the answer, say that you don't know.
- The current time and date in ISO format can be found in the last message.
- When answering the user, use friendly formats for time and date.
- Use markdown syntax for formatting code snippets, links, JSON, tables, images, files.
- Sometimes, things don't go as planned. Functions may not provide useful information on the first few tries. You should always try a few different approaches before declaring the problem unsolvable.
- When the function doesn't give you what you were asking for, you must either use another function or a different function input.
  - When using search engines, you try different formulations of the query, possibly even in a different language.
- You cannot do complex calculations, computations, or data manipulations without using functions.m

第 4 步。定义并优化核心工具

工具赋予了代理超能力。通过一组定义明确的窄工具,你可以实现广泛的功能。需要包括的关键工具有代码执行、网页搜索、文件读取和数据分析。

对于每个工具,你需要定义以下内容,并将其作为系统提示的一部分:

  • 工具名称:能力的独特、描述性的名称。

  • 工具描述: 清晰地解释工具的功能以及何时使用它。这有助于代理决定何时选择合适的工具。

  • 工具输入模式: 描述所需和可选参数、其类型以及任何约束条件的模式。代理使用此模式根据用户的查询填写所需的输入。

  • 指示在哪里/如何运行工具。

示例: 以下是 Langchain Community 中 Arxiv 工具实现的一个摘录。该实现需要一个 ArxivAPIWrapper 实现。

class ArxivInput(BaseModel):
    """Input for the Arxiv tool."""

    query: str = Field(description="search query to look up")

class ArxivQueryRun(BaseTool):  # type: ignore[override, override]
    """Tool that searches the Arxiv API."""

    name: str = "arxiv"
    description: str = (
        "A wrapper around Arxiv.org "
        "Useful for when you need to answer questions about Physics, Mathematics, "
        "Computer Science, Quantitative Biology, Quantitative Finance, Statistics, "
        "Electrical Engineering, and Economics "
        "from scientific articles on arxiv.org. "
        "Input should be a search query."
    )
    api_wrapper: ArxivAPIWrapper = Field(default_factory=ArxivAPIWrapper)  # type: ignore[arg-type]
    args_schema: Type[BaseModel] = ArxivInput

    def _run(
        self,
        query: str,
        run_manager: Optional[CallbackManagerForToolRun] = None,
    ) -> str:
        """Use the Arxiv tool."""
        return self.api_wrapper.run(query)p

在某些情况下,你需要优化工具,以获得所期望的性能。这可能涉及通过一些提示工程调整工具名称或描述、设置高级配置以处理常见错误,或过滤工具的输出。

步骤 5. 决定内存处理策略

LLM 的上下文窗口有限——它们每次能“记住”的标记数量。随着多轮对话中的过去交互、冗长的工具输出或代理所依赖的额外上下文的增加,这些内存会很快被填满。这就是为什么拥有一个稳固的内存处理策略至关重要的原因。

在代理的上下文中,内存 指的是系统存储、回忆和利用过去交互信息的能力。这使得代理能够随着时间的推移保持上下文,根据之前的交流改进回应,并提供更加个性化的体验。

常见的内存处理策略:

  • 滑动内存: 保留最近的 k 轮对话,并删除较旧的对话。

  • 令牌内存: 保留最近的 n 个令牌并忘记其余部分。

  • 总结内存: 使用 LLM 在每轮对话中总结会话,并丢弃单个消息。

此外,你还可以让 LLM 检测关键时刻并将其存储到长期记忆中。这使得代理能够“记住”关于用户的重要事实,从而让体验更加个性化。

到目前为止,我们所涵盖的五个步骤为设置代理奠定了基础。那么,如果我们在这个阶段通过我们的 LLM 运行用户查询,会发生什么呢?

答案:你得到的是原始文本输出。(图片来源:作者)

以下是可能的示例:

User Message: Extract key insighs from this dataset
Files: bill-of-materials.csv
Thought: First, I need to inspect the columns of the dataset and provide basic data statistics.
Function Name: Python
Function Input: {"language":"python","code":"import pandas as pd\n\ndataset = pd.read_csv('bill-of-materials.csv')\n\nprint(dataset.columns)\nprint(dataset.describe())","inputFiles":["bill-of-materials.csv"]}
Function Output:

在此阶段,代理产生原始文本输出。那么,我们如何让它实际执行下一步呢?这时解析和协调就显得尤为重要。

步骤 6. 解析代理的原始输出

解析器 是一种将原始数据转换为应用程序可以理解和使用的格式(例如具有属性的对象)的功能。

对于我们正在构建的代理,解析器需要识别我们在步骤 2中定义的通信结构,并返回结构化输出,例如 JSON。这使得应用程序能够更容易地处理并执行代理的下一步操作。

注意:一些模型提供商,如 OpenAI默认情况下可以返回可解析的输出。对于其他模型,尤其是开源模型,需要进行配置。

第 7 步:协调代理的下一步

最后一步是设置协调逻辑。这决定了 LLM 输出结果后的处理方式。根据输出,你将:

  1. 执行工具调用,或者

  2. 返回答案——这是用户查询的最终响应,或是请求更多信息的后续请求。

扩展的单代理架构。(图片由作者提供)

如果触发了工具调用,工具的输出将被发送回 LLM(作为其工作记忆的一部分)。然后,LLM 将决定如何处理这些新信息:要么执行另一个工具调用,要么返回答案给用户。

下面是这段协调逻辑在代码中的一个示例:

def orchestrator(llm_agent, llm_output, tools, user_query):
    """
    Orchestrates the response based on LLM output and iterates if necessary.

    Parameters:
    - llm_agent (callable): The LLM agent function for processing tool outputs.
    - llm_output (dict): Initial output from the LLM, specifying the next action.
    - tools (dict): Dictionary of available tools with their execution methods.
    - user_query (str): The original user query.

    Returns:
    - str: The final response to the user.
    """
    while True:
        action = llm_output.get("action")

        if action == "tool_call":
            # Extract tool name and parameters
            tool_name = llm_output.get("tool_name")
            tool_params = llm_output.get("tool_params", {})

            if tool_name in tools:
                try:
                    # Execute the tool
                    tool_result = toolstool_name
                    # Send tool output back to the LLM agent for further processing
                    llm_output = llm_agent({"tool_output": tool_result})
                except Exception as e:
                    return f"Error executing tool '{tool_name}': {str(e)}"
            else:
                return f"Error: Tool '{tool_name}' not found."

        elif action == "return_answer":
            # Return the final answer to the user
            return llm_output.get("answer", "No answer provided.")

        else:
            return "Error: Unrecognized action type from LLM output."

瞧! 现在你有了一个能够处理各种不同用例的系统——从竞争分析和高级研究到自动化复杂工作流。

多代理系统何时发挥作用?

虽然这一代 LLM 非常强大,但它们有一个关键的限制:它们在信息过载方面存在困难。过多的上下文或过多的工具可能会压倒模型,从而导致性能问题。通用单代理最终会达到这个瓶颈,尤其是代理往往非常消耗 token。

对于某些用例,使用多代理设置可能更有意义。通过将职责分配给多个代理,你可以避免单一 LLM 代理的上下文过载,从而提高整体效率。

话虽如此,通用单一代理设置是原型设计的绝佳起点。它可以帮助你快速测试用例并找出问题所在。通过这个过程,你可以:

  1. 理解任务的哪些部分真正从代理化方法中受益。

  2. 确定可以作为独立过程脱离出来的组件,并在更大的工作流中使用。

从单一代理开始,能为你提供宝贵的见解,帮助你在向更复杂的系统扩展时进行优化。

启动的最佳方式是什么?

准备好深入研究并开始构建了吗?使用框架可以成为快速测试和迭代代理配置的好方法。

你在构建通用代理方面有什么经验?

在评论中分享你的经验吧!*

使用 RAG 和混合搜索构建一个(食谱)推荐聊天机器人(第一部分)

原文:towardsdatascience.com/build-a-recipe-recommender-chatbot-using-rag-and-hybrid-search-part-i-c4aa07d14dcf?source=collection_archive---------2-----------------------#2024-03-20

本教程将教你如何创建稀疏和密集嵌入,并使用混合搜索构建推荐系统。

Sebastian BahrTowards Data Science Sebastian Bahr

·发表于Towards Data Science ·14 分钟阅读·2024 年 3 月 20 日

--

图片来自Katie SmithUnsplash

本教程提供了一个逐步指南,并附有代码,教你如何创建一个聊天机器人风格的推荐系统。完成后,你将构建一个推荐系统,利用用户的开放文本输入通过混合搜索在稀疏和密集向量中找到匹配项。本教程使用的数据集包含食谱,但你可以轻松地将数据集替换为适合你需求的数据集,只需做少量调整。本任务的第一部分将专注于构建推荐系统,包括数据清洗、创建稀疏和密集嵌入、将它们上传到向量数据库,以及执行密集向量搜索和混合搜索。在第二部分,你将创建一个聊天机器人,基于用户输入和推荐生成回应,并使用 Plotly 仪表板构建用户界面。

为了跟随本教程,你需要为付费服务(如 Vertex AI、OpenAI API 和 Pinecone)设置账户。幸运的是,大多数服务提供免费额度,跟随本教程的费用不应超过 $5。除此之外,你可以通过使用我在 GitHub 上提供的代码库中的文件和数据集进一步降低成本。

数据准备

对于这个项目,我们将使用来自Public Domain Recipes的食谱。所有食谱都以 Markdown 文件格式存储在这个 GitHub 仓库中。对于本教程,我已经进行了数据清理,并从原始文本输入中创建了特征。如果你有兴趣自己做数据清理部分,代码可以在我的 GitHub 仓库中找到。

数据集包含以下列:

  • title: 食谱的标题

  • date: 食谱添加的日期

  • tags: 描述菜肴的标签列表

  • introduction: 食谱的介绍,内容在不同记录之间变化很大

  • ingredients: 所有所需的食材。请注意,我已移除数量,因为在创建嵌入时不需要它,而且反而可能导致不理想的推荐。

  • direction: 烹饪所需执行的所有步骤

  • recipe_type: 指示食谱是纯素食、素食还是常规食谱

  • output: 包含食谱的titleingredientsdirection,并将在后续提供给聊天模型作为输入。

让我们来看看recipe_type特征的分布。我们可以看到,大多数(60%)的食谱包含鱼或肉类,不适合素食者。大约 35%的食谱适合素食者,只有 5%的食谱适合纯素食者。这个特征将作为从向量数据库中检索匹配食谱的硬性筛选条件。

import re
import json
import spacy
import torch
import openai
import vertexai
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from tqdm.auto import tqdm
from transformers import AutoModelForMaskedLM, AutoTokenizer
from pinecone import Pinecone, ServerlessSpec
from vertexai.language_models import TextEmbeddingModel
from utils_google import authenticate
credentials, PROJECT_ID, service_account, pinecone_API_KEY = authenticate() 
from utils_openai import authenticate
OPENAI_API_KEY = authenticate() 

openai_client = openai.OpenAI(api_key=OPENAI_API_KEY)

REGION = "us-central1"
vertexai.init(project = PROJECT_ID,
              location = REGION,
              credentials = credentials)

pc = Pinecone(api_key=pinecone_API_KEY)

# download spacy model
#!python -m spacy download en_core_web_sm
recipes = pd.read_json("recipes_v2.json")
recipes.head()

plt.bar(recipes.recipe_type.unique(), recipes.recipe_type.value_counts(normalize=True).values)
plt.show()

食谱类型的分布

混合搜索使用稀疏向量和密集向量的组合,以及加权因子alpha,这使得在检索过程中可以调整密集向量的重要性。接下来,我们将基于titletagsintroduction创建密集向量,并基于ingredients创建稀疏向量。通过调整alpha,我们可以在后续确定在查询中,用户提到的食材应受到多少“关注”。

在创建嵌入之前,需要创建一个新的特征,包含titletagsintroduction的组合信息。

recipes["dense_feature"] = recipes.title + "; " + recipes.tags.apply(lambda x: str(x).strip("[]").replace("'", "")) + "; " + recipes.introduction
recipes["dense_feature"].head()

最后,在深入生成嵌入之前,我们先来看看output列。本教程的第二部分将全程讲解如何使用 OpenAI 创建一个能够回答用户问题的聊天机器人,并利用我们的食谱数据库中的知识。因此,在找到最匹配用户查询的食谱后,聊天模型需要一些信息来构建其答案。这就是output的作用,它包含了构建一个合适答案所需的所有信息。

# example output
{'title': 'Creamy Mashed Potatoes',
 'ingredients': 'The quantities here are for about four adult portions. If you are planning on eating this as a side dish, it might be more like 6-8 portions. * 1kg potatoes * 200ml milk* * 200ml mayonnaise* * ~100g cheese * Garlic powder * 12-16 strips of bacon * Butter * 3-4 green onions * Black pepper * Salt  *You can play with the proportions depending on how creamy or dry you want the mashed potatoes to be.',
 'direction': '1\. Peel and cut the potatoes into medium sized pieces. 2\. Put the potatoes in a pot with some water so that it covers the potatoes and   boil them for about 20-30 minutes, or until the potatoes are soft. 3\. About ten minutes before removing the potatoes from the boiling water, cut   the bacon into little pieces and fry it. 4\. Warm up the milk and mayonnaise. 5\. Shred the cheese. 6\. When the potatoes are done, remove all water from the pot, add the warm milk   and mayonnaise mix, add some butter, and mash with a potato masher or a   blender. 7\. Add some salt, black pepper and garlic powder to taste and continue mashing   the mix. 8\. Once the mix is somewhat homogeneous and the potatoes are properly mashed,   add the shredded cheese and fried bacon and mix a little. 9\. Serve and top with chopped green onions.'}

此外,还需要为每个食谱添加一个唯一标识符,以便检索推荐候选食谱及其output

recipes["ID"] = range(len(recipes))

生成稀疏嵌入

下一步是为所有 360 条观察生成稀疏嵌入。为了计算这些嵌入,使用了一种比常用的 TF-IDF 或 BM25 方法更为复杂的方法。相反,应用了 SPLADE Sparse Lexical and Expansion 模型。关于 SPLADE 的详细解释可以在 这里找到。密集嵌入对于每个文本输入具有相同的形状,无论输入中的 token 数量如何。相反,稀疏嵌入包含输入中每个唯一 token 的权重。下面的字典表示一个稀疏向量,其中 token ID 是键,分配的权重是值。

model_id = "naver/splade-cocondenser-ensembledistil"

tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForMaskedLM.from_pretrained(model_id)

def to_sparse_vector(text, tokenizer, model):
    tokens = tokenizer(text, return_tensors='pt')
    output = model(**tokens)
    vec = torch.max(
        torch.log(1 + torch.relu(output.logits)) * tokens.attention_mask.unsqueeze(-1), dim=1
    )[0].squeeze()

    cols = vec.nonzero().squeeze().cpu().tolist()
    weights = vec[cols].cpu().tolist()
    sparse_dict = dict(zip(cols, weights))
    return sparse_dict

sparse_vectors = []

for i in tqdm(range(len(recipes))):
    sparse_vectors.append(to_sparse_vector(recipes.iloc[i]["ingredients"], tokenizer, model))

recipes["sparse_vectors"] = sparse_vectors

第一份食谱的稀疏嵌入

生成密集嵌入

在本教程的这一阶段,如果您使用 VertexAI(谷歌)或 OpenAI 的文本嵌入模型,将会产生一些费用。然而,如果您使用相同的数据集,费用最多为 $5。费用可能会根据数据集记录数或文本长度的不同而有所变化,因为按 token 收费。如果您不想产生任何费用,但仍希望继续进行教程,特别是第二部分,您可以从我的 GitHub 仓库 下载预生成嵌入数据的 pandas DataFrame recipes_with_vectors.pkl

您可以选择使用 VertexAI 或 OpenAI 来创建嵌入。OpenAI 的优点是设置简单,只需一个 API 密钥,而 VertexAI 需要登录 Google 控制台、创建一个项目并将 VertexAI API 添加到您的项目中。此外,OpenAI 模型允许您指定密集向量的维度。然而,这两者都能创建最先进的密集嵌入。

使用 VertexAI API

# running this code will create costs !!!
model = TextEmbeddingModel.from_pretrained("textembedding-gecko@003")

def to_dense_vector(text, model):
    dense_vectors = model.get_embeddings([text])
    return [dense_vector.values for dense_vector in dense_vectors][0]

dense_vectors = []

for i in tqdm(range(len(recipes))):
    dense_vectors.append(to_dense_vector(recipes.iloc[i]["dense_feature"], model))

recipes["dense_vectors"] = dense_vectors

使用 OpenAI API

# running this code will create costs !!!

# Create dense embeddings using OpenAIs text embedding model with 768 dimensions
model = "text-embedding-3-small"

def to_dense_vector_openAI(text, client, model, dimensions):
    dense_vectors = client.embeddings.create(model=model, dimensions=dimensions, input=[text])
    return [dense_vector.values for dense_vector in dense_vectors][0]

dense_vectors = []

for i in tqdm(range(len(recipes))):
    dense_vectors.append(to_dense_vector_openAI(recipes.iloc[i]["dense_feature"], openai_client, model, 768))

recipes["dense_vectors"] = dense_vectors

上传数据到向量数据库

在生成稀疏和密集嵌入后,我们拥有了上传到向量数据库所需的所有数据。在本教程中,将使用 Pinecone,因为它们支持使用稀疏和密集向量进行混合搜索,并提供 $100 免费积分的无服务器定价模式。为了以后执行混合搜索,必须将相似度度量设置为点积。如果我们只进行密集搜索而不是混合搜索,我们将能够选择以下相似度度量之一:点积、余弦相似度和欧几里得距离。关于相似度度量及其如何计算两个向量之间相似度的更多信息,请查看 这里

# load pandas DataFrame with pre-generated embeddings if you
# didn't generate them in the last step
recipes = pd.read_pickle("recipes_with_vectors.pkl")

# if you need to delte an existing index
pc.delete_index("index-name")

# create a new index 
pc.create_index(
    name="recipe-project",
    dimension=768, # adjust if needed
    metric="dotproduct",
    spec=ServerlessSpec(
        cloud="aws",
        region="us-west-2"
    )
)

pc.describe_index("recipe-project")

恭喜您创建了第一个 Pinecone 索引!现在是时候将嵌入数据上传到向量数据库了。如果您使用的嵌入模型创建的向量维度不同,请确保调整 dimension 参数。

现在是时候将数据上传到新创建的 Pinecone 索引了。

# upsert to pinecone in batches
def sparse_to_dict(data):
    dict_ = {"indices": list(data.keys()),
             "values": list(data.values())}
    return dict_

batch_size = 100
index = pc.Index("recipe-project")

for i in tqdm(range(0, len(recipes), batch_size)):
    i_end = min(i + batch_size, len(recipes))
    meta_batch = recipes.iloc[i: i_end][["ID", "recipe_type"]]
    meta_dict = meta_batch.to_dict(orient="records")

    sparse_batch = recipes.iloc[i: i_end]["sparse_vectors"].apply(lambda x: sparse_to_dict(x))
    dense_batch = recipes.iloc[i: i_end]["dense_vectors"]

    upserts = []

    ids = [str(x) for x in range(i, i_end)]
    for id_, meta, sparse_, dense_ in zip(ids, meta_dict, sparse_batch, dense_batch):
        upserts.append({
            "id": id_,
            "sparse_values": sparse_,
            "values": dense_,
            "metadata": meta
        })

    index.upsert(upserts)

index.describe_index_stats()

如果你对上传的数据内容感到好奇,可以登录 Pinecone,选择新创建的索引,查看其中的项目。目前,我们无需关注分数,因为它是默认生成的,表示与 Pinecone 随机生成的向量的匹配度。不过,稍后我们将计算嵌入的用户查询与向量数据库中所有条目的相似度,并检索最相似的 k 个条目。此外,每个项目都包含一个由 Pinecone 生成的项目 ID 和元数据,其中包括食谱ID及其recipe_type。密集嵌入存储在Values中,稀疏嵌入存储在Sparse Values中。

索引的前三项(作者提供的图片

我们可以使用 Pinecone Python SDK 获取上述信息。让我们查看索引项 ID 为 50 的第一个项目存储的信息。

index.fetch(ids=["50"])

与 Pinecone 仪表板中一样,我们获取元素的项目 ID、元数据、稀疏值和密集值,这些信息存储在截断输出底部的列表中。

搜索

在本节中,我们将仅使用密集向量来查找数据库中最匹配的条目(密集搜索)。在第二步中,我们将利用稀疏和密集向量中存储的信息来执行混合搜索。

使用密集向量的常规搜索

为了测试推荐系统的功能,我们将尝试获取素食意大利菜肴的推荐。需要注意的是,必须使用与嵌入食谱时相同的模型来生成密集嵌入。

user_query = "I want to cook some Italian dish with rice"
recipe_type = "vegetarian"
# running this code will create costs !!!

# If you used VertexAI and gecko003 to create dense embeddings
model = TextEmbeddingModel.from_pretrained("textembedding-gecko@003")

def to_dense_vector(text, model):
    dense_vectors = model.get_embeddings([text])
    return [dense_vector.values for dense_vector in dense_vectors][0]

text_dense_vector = to_dense_vector(user_query, model)

使用 OpenAI API

# running this code will create costs !!!

# If you used OpenAI to create dense embeddings
model = "text-embedding-3-small"

def to_dense_vector_openAI(text, client, model, dimensions):
    dense_vectors = client.embeddings.create(model=model, dimensions=dimensions, input=[text])
    return [dense_vector.values for dense_vector in dense_vectors][0]

text_dense_vector = to_dense_vector_openAI(user_query, openai_client, model, 768)

在将用户文本嵌入后,我们可以查询向量数据库,获取与用户查询最相似的食谱。如前所述,Pinecone 使用点积来计算相似度分数。此外,我们指定 Pinecone 返回推荐项的元数据,因为我们需要食谱的ID来筛选食谱数据库并获取相应条目的输出。参数top_k允许我们指定应该返回的匹配项数量,最后,我们使用硬筛选仅推荐价格等于或低于指定价格(10.0)的咖啡混合物。有关 Pinecone 中如何筛选元数据的更多信息,请查看此处

index = pc.Index("recipe-project")

retrieved_items = index.query(vector=text_dense_vector,
                              include_values=False,
                              include_metadata=True,
                              top_k=3,
                              filter={"recipe_type": {"$eq": recipe_type}})

retrieved_ids = [item.get("metadata").get("ID") for item in retrieved_items.get("matches")]

retrieved_items

在获取推荐食谱的 ID 后,我们可以轻松查询食谱数据集,并查看它们的输出输出包含所有所需信息,如标题食材做法。查看前几条推荐结果,发现它们都是素食,这并不奇怪,因为我们应用了“硬”筛选,但它们都是用户要求的意大利菜肴。

recipes[recipes.ID.isin(retrieved_ids)].output.values

相似度得分最高的食谱

recipes[recipes.ID.isin(retrieved_ids)].output.values[0]
{'title': 'Pasta Arrabbiata',
 'ingredients': '- Pasta - Olive oil - Chilli flakes or diced chilli peppers - Crushed garlic cloves - Crushed tomatoes (about 800 gramms for 500 gramms of pasta) - Chopped parsley - Grated Pecorino Romano or Parmigiano Reggiano (optional, but highly recommended)',
 'direction': '1\. Start heating up water for the pasta. 2\. Heat up a few tablespoons of olive oil over low heat. 3\. Crush several cloves of garlic into the olive oil, add the chilli flakes or chilli peppers and fry them for a short time, while being careful not to burn the garlic. 4\. Add your crushed tomatoes, together with some salt and pepper, increase the heat to medium and let simmer for 10-15 minutes or until it looks nicely thickened. 5\. When the water starts boiling, put a handful of salt into it and then your pasta of choice. Ideally leave the pasta slightly undercooked, because it will go in the hot sauce and finish cooking there. 6\. When the sauce is almost ready, add most of your chopped parsley and stir it around. Save some to top the dish later. 8\. When the pasta is ready (ideally at the same time as the sauce or slightly later), strain it and add it to the sauce, which should be off the heat. If the sauce looks a bit too thick, add some of the pasta water. Mix well. 9\. Add some of the grated cheese of your choice and stir it in. 10\. Serve with some more grated cheese and chopped parsley on top.'}

混合搜索

现在是时候实现混合搜索了。这个概念听起来比实际要复杂,你会发现我们只需用两行代码就能实现它。混合搜索通过一个因子alpha对密集向量的值进行加权,对稀疏向量的值加权系数为1-alpha。换句话说,alpha决定了输入文本的密集向量与稀疏向量分别应该获得多少“关注”。如果alpha=1,我们进行纯密集向量搜索;alpha=0.5是纯混合搜索;而alpha=0是纯稀疏向量搜索。

正如你所记得的,稀疏和密集向量是使用不同的信息创建的。稀疏向量包含有关食材的信息,而密集向量包含标题、标签和介绍。因此,通过改变alpha,我们可以告诉查询引擎优先考虑食谱的某些特征而非其他特征。让我们首先使用 alpha 值为 1,并在用户查询上进行纯密集搜索:

我可以用土豆、蘑菇和牛肉做些什么?

不幸的是,除了牛肉,推荐的食谱不包含其他提到的食材。

生成稀疏嵌入

model_id = "naver/splade-cocondenser-ensembledistil"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForMaskedLM.from_pretrained(model_id)

def to_sparse_vector(text, tokenizer, model):
    tokens = tokenizer(text, return_tensors='pt')
    output = model(**tokens)
    vec = torch.max(
        torch.log(1 + torch.relu(output.logits)) * tokens.attention_mask.unsqueeze(-1), dim=1
    )[0].squeeze()

    cols = vec.nonzero().squeeze().cpu().tolist()
    weights = vec[cols].cpu().tolist()
    sparse_dict = dict(zip(cols, weights))
    return sparse_dict

text_sparse_vector = to_sparse_vector(user_query, tokenizer, model)

生成密集嵌入

# running this code will create costs !!!

# If you used VertexAI and gecko003 to create dense embeddings
model = TextEmbeddingModel.from_pretrained("textembedding-gecko@003")

text_dense_vector = to_dense_vector(user_query, model)
def hybride_search(sparse_dict, dense_vectors, alpha):

    # check alpha value is in range
    if alpha < 0 or alpha > 1:
        raise ValueError("Alpha must be between 0 and 1")
    # scale sparse and dense vectors to create hybrid search vecs
    hsparse = {
        "indices": list(sparse_dict.keys()),
        "values": [v * (1 - alpha) for v in list(sparse_dict.values())]
    }
    hdense = [v * alpha for v in dense_vectors]
    return hdense, hsparse

user_query = "What can I cook with potatos, mushrooms, and beef?"
recipe_type = ["regular", "vegetarian", "vegan"] # allows for all recipe types

dense_vector, sparse_dict = hybride_search(text_sparse_vector, text_dense_vector, 1.0)

retrieved_items = index.query(vector=dense_vector,
                              sparse_vector=sparse_dict,
                              include_values=False,
                              include_metadata=True,
                              top_k=1,
                              filter={"recipe_type": {"$in": recipe_type}})

retrieved_ids = [item.get("metadata").get("ID") for item in retrieved_items.get("matches")]

[x.get("ingredients") for x in recipes[recipes.ID.isin(retrieved_ids)].output.values]
# retrived output with alpha=1.0
['- 1 beef kidney - 60g butter - 2 onions - 2 shallots - 1 sprig of fresh parsley - 3 bay leaves - 400g croutons or toasted bread in pieces']

让我们将 alpha 设置为 0.5,看看推荐食谱的食材。这个 alpha 值得出了一个更好的结果,推荐的食谱包含了要求的所有三种食材:

  • 500 克牛肉

  • 300–400 克土豆

  • 2–3 颗香菇

dense_vector, sparse_dict = hybride_search(text_sparse_vector, text_dense_vector, 0.5)

retrieved_items = index.query(vector=dense_vector,
                              sparse_vector=sparse_dict,
                              include_values=False,
                              include_metadata=True,
                              top_k=1,
                              filter={"recipe_type": {"$in": recipe_type}})

retrieved_ids = [item.get("metadata").get("ID") for item in retrieved_items.get("matches")]

[x.get("ingredients") for x in recipes[recipes.ID.isin(retrieved_ids)].output.values]
# retrived output with alpha=0.5
['* 500g beef * 300-400g potatoes * 1 carrot * 1 medium onion * 12 tablespoons tomato paste * 500ml water * 3-4 garlic cloves * 3-4 bay leaves * Curcuma * Paprika * Oregano * Parsley * Caraway * Basil (optional) * Cilantro (optional) * 2-3 champignon mushrooms (optional)']Using a serverless index has the advantage that you do not need to pay for a server instance that runs 24/7\. Instead, you are billed by queries or read and write units, as they are called by Pinecone. Sparse and dense vector searches work well with a serverless index. However, please keep in mind the following limitation.

恭喜你,已经完成了本教程的学习!

最后的备注

混合搜索的实现,在基于 pod 和无服务器索引之间有所不同。如果你从一种切换到另一种,可能会经历精度或性能上的回退。

当你查询无服务器索引时,查询的密集值用于检索初步的候选记录,然后在返回最终结果时考虑稀疏值。

结论

在本教程中,你学习了如何使用稀疏和密集嵌入来嵌入数据集,并使用密集和混合搜索来查找向量数据库中最匹配的条目。

在第二部分,你将使用 GPT 3.5-turbo 模型构建一个带有函数调用的聊天机器人,并使用 Plotly Dash 生成 UI。如果你感兴趣并且喜欢第一部分,可以看看第二部分。

请支持我的工作!

如果你喜欢这篇博客文章,请留下掌声或评论。要保持关注,请在MediumLinkedIn上关注我。

从零开始构建泰语分词器

原文:towardsdatascience.com/build-a-tokenizer-for-the-thai-language-from-scratch-0e4ea5f2a8b3?source=collection_archive---------6-----------------------#2024-09-14

基于 BPE 算法,使用 Python 训练泰语和英语数据集,构建一个泰语多语言子词分词器的逐步指南

Milan TamangTowards Data Science Milan Tamang

·发表于 Towards Data Science ·14 分钟阅读·2024 年 9 月 14 日

--

图片来源:作者:泰语分词器将泰语文本编码和解码为 Token ID,并反向操作。

分词器的主要任务是将原始输入文本(在我们的例子中是泰语,但也可以是任何外语)转换为数字,并将其传递给模型的 Transformer。模型的 Transformer 然后生成输出数字。再次,分词器将这些数字转回为用户可以理解的文本。下方的高层次图示描述了上述过程。

一般来说,我们中的许多人只对学习模型的 Transformer 架构如何在后台工作感兴趣。我们往往忽视了详细学习一些重要的组件,如分词器。理解分词器如何在后台工作,并能够很好地控制其功能,可以为我们提高模型的准确性和性能提供很好的杠杆作用。

类似于分词器,一些在大型语言模型(LLM)实现管道中的重要组件包括数据预处理、评估、保护措施/安全性以及测试/监控。我强烈建议你深入学习这些主题。我是在实际实现我的基础多语言模型 ThaiLLM 并投入生产后,才意识到这些组件的重要性。

为什么你需要一个泰语分词器或任何其他外语分词器?

  • 假设你正在使用通用的基于英语的分词器来预训练一个多语言的大型语言模型,如泰语、印地语、印尼语、阿拉伯语、中文等。在这种情况下,你的模型可能不会给出适合你特定领域或用例的合理输出。因此,构建你自己的分词器,选择自己喜欢的语言,肯定会帮助使模型输出更加连贯和易于理解。

  • 构建自己的分词器还可以让你完全控制词汇的全面性和包容性。在注意力机制中,由于词汇的全面性,token 可以在序列的有限上下文长度内关注并从更多的 tokens 中学习。因此,它使学习更加连贯,最终有助于更好的模型推理。

好消息是,在构建泰语分词器完成后,你可以轻松地构建任何其他语言的分词器。所有构建步骤都是相同的,唯一不同的是你需要在你选择的语言的数据集上进行训练。

现在我们已经有了所有构建我们自己分词器的充分理由。以下是构建我们泰语分词器的步骤。

  1. 构建我们自己的 BPE 算法

  2. 训练分词器

  3. 分词器的编码和解码功能

  4. 加载并测试分词器

步骤 1:构建我们自己的 BPE(字节对编码)算法:

BPE 算法在许多流行的 LLM(大型语言模型)中都有应用,如 Llama、GPT 等,用于构建它们的分词器。如果我们的模型是基于英语的,我们可以选择这些 LLM 分词器之一。由于我们正在构建泰语分词器,最佳选择是从零开始创建我们自己的 BPE 算法,并用它来构建我们的分词器。让我们首先通过下面简单的流程图理解 BPE 算法是如何工作的,然后我们就可以根据它开始构建。

[图像来自作者]:BPE 流程图。示例引用自维基百科页面(en.wikipedia.org/wiki/Byte_pair_encoding

流程图中的示例以英语展示,目的是为了让理解更加简便。

让我们写代码来实现我们泰语分词器的 BPE 算法。

# A simple practice example to get familiarization with utf-8 encoding to convert strings to bytes. 
text = "How are you คุณเป็นอย่างไร"      # Text string in both English and Thai
text_bytes = text.encode("utf-8")
print(f"Text in byte: {text_bytes}")

text_list = list(text_bytes)          # Converts text bytes to a list of integer
print(f"Text list in integer: {text_list}")
# As I don't want to reinvent the wheel, I will be referencing most of the code block from Andrej Karpathy's GitHub (https://github.com/karpathy/minbpe?tab=readme-ov-file).
# However, I'll be modifying code blocks specific to building our Thai language tokenizer and also explaining the codes so that you can understand how each code block works and make it easy when you implement code for your use case later.

# This module provides access to the Unicode Character Database (UCD) which defines character properties for all Unicode characters.
import unicodedata

# This function returns a dictionary with consecutive pairs of integers and their counts in the given list of integers.
def get_stats(ids, stats=None):

    stats = {} if stats is None else stats
    # zip function allows to iterate consecutive items from given two list
    for pair in zip(ids, ids[1:]): 
        # If a pair already exists in the stats dictionary, add 1 to its value else assign the value as 0.
        stats[pair] = stats.get(pair, 0) + 1
    return stats

# Once we find out the list of consecutive pairs of integers, we'll then replace those pairs with new integer tokens.
def merge(ids, pair, idx):
    newids = []
    i = 0
    # As we'll be merging a pair of ids, hence the minimum id in the list should be 2 or more.
    while i < len(ids):
        # If the current id and next id(id+1) exist in the given pair, and the position of id is not the last, then replace the 2 consecutive id with the given index value.
        if ids[i] == pair[0] and i < len(ids) - 1 and ids[i+1] == pair[1]:
            newids.append(idx)
            i += 2  # If the pair is matched, the next iteration starts after 2 positions in the list.
        else:
            newids.append(ids[i])
            i += 1  # Since the current id pair didn't match, so start iteration from the 1 position next in the list.
    # Returns the Merged Ids list
    return newids

# This function checks that using 'unicodedata.category' which returns "C" as the first letter if it is a control character and we'll have to replace it readable character.
def replace_control_characters(s: str) -> str:
    chars = []
    for ch in s:
        # If the character is not distorted (meaning the first letter doesn't start with "C"), then append the character to chars list.
        if unicodedata.category(ch)[0] != "C":
            chars.append(ch) 
        # If the character is distorted (meaning the first letter has the letter "C"), then replace it with readable bytes and append to chars list.
        else:
            chars.append(f"\\u{ord(ch):04x}") 
    return "".join(chars)

# Some of the tokens such as control characters like Escape Characters can't be decoded into valid strings. 
# Hence those need to be replace with readable character such as �
def render_token(t: bytes) -> str:    
    s = t.decode('utf-8', errors='replace')
    s = replace_control_characters(s)
    return s

上面代码块中定义的两个函数get_statsmerge是我们泰语分词器的 BPE 算法实现。现在算法已经准备好。让我们写代码来训练我们的分词器。

步骤 2:训练分词器:

训练分词器涉及生成一个词汇表,这是一个包含唯一标记(单词和子单词)及其唯一索引编号的数据库。我们将使用泰语维基数据集从 Hugging Face 来训练我们的泰语分词器。就像训练 LLM 需要大量数据一样,你也需要大量数据来训练分词器。你也可以使用相同的数据集来同时训练 LLM 和分词器,虽然这不是必须的。对于多语言 LLM,建议以 2:1 的比例使用英语和泰语数据集,这是许多从业者遵循的标准做法。

让我们开始编写训练代码。

# Import Regular Expression
import regex as re    

# Create a Thai Tokenizer class.
class ThaiTokenizer():

  def __init__(self):

        # The byte pair should be done within the related words or sentences that give a proper context. Pairing between unrelated words or sentences may give undesirable output.
        # To prevent this behavior, we'll implement the LLama 3 regular expression pattern to make meaningful chunks of our text before implementing the byte pair algorithm.
        self.pattern = r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+"                
        self.compiled_pattern = re.compile(self.pattern)

        # Special tokens are used to provide coherence in the sequence while training.
        # Special tokens are assigned a unique index number and stored in vocabulary. 
        self.special_tokens = {
            '<|begin_of_text|>': 1101,
            '<|end_of_text|>': 1102,
            '<|start_header_id|>': 1103,
            '<|end_header_id|>': 1104,
            '<|eot_id|>': 1105
        }

        # Initialize merges with empty dictionary
        self.merges = {}

        # Initialize the vocab dictionary by calling the function _build_vocab which is defined later in this class.
        self.vocab = self._build_vocab()

  # Tokenizer training function
  def train(self, text, vocab_size):

        # Make sure the vocab size must be at least 256 as the utf-8 encoding for the range 0-255 are same as the Ascii character.
        assert vocab_size >= 256
        # Total number of merges into the vocabulary.
        num_merges = vocab_size - 256

        # The first step is to make sure to split the text up into text chunks using the pattern defined above.
        text_chunks = re.findall(self.compiled_pattern, text)

        # Each text_chunks will be utf-8 encoded to bytes and then converted into an integer list.
        ids = [list(ch.encode("utf-8")) for ch in text_chunks]

        # Iteratively merge the most common pairs to create new tokens
        merges = {} # (int, int) -> int
        vocab = {idx: bytes([idx]) for idx in range(256)} # idx -> bytes

        # Until the total num_merges is reached, find the common pair of consecutive id in the ids list and start merging them to create a new token
        for i in range(num_merges):
            # Count the number of times every consecutive pair appears
            stats = {}
            for chunk_ids in ids:
                # Passing in stats will update it in place, adding up counts
                get_stats(chunk_ids, stats)
            # Find the pair with the highest count
            pair = max(stats, key=stats.get)
            # Mint a new token: assign it the next available id
            idx = 256 + i
            # Replace all occurrences of pair in ids with idx
            ids = [merge(chunk_ids, pair, idx) for chunk_ids in ids]
            # Save the merge
            merges[pair] = idx
            vocab[idx] = vocab[pair[0]] + vocab[pair[1]]

        # Save class variables to be used later during tokenizer encode and decode
        self.merges = merges 
        self.vocab = vocab   

  # Function to return a vocab dictionary combines with merges and special tokens
  def _build_vocab(self):
        # The utf-8 encoding for the range 0-255 are same as the Ascii character. 
        vocab = {idx: bytes([idx]) for idx in range(256)}

        # Iterate through merge dictionary and add into vocab dictionary
        for (p0, p1), idx in self.merges.items():
            vocab[idx] = vocab[p0] + vocab[p1]

        # Iterate through special token dictionary and add into vocab dictionary
        for special, idx in self.special_tokens.items():
            vocab[idx] = special.encode("utf-8")

        return vocab

  # After training is complete, use the save function to save the model file and vocab file.
  # Model file will be used to load the tokenizer model for further use in llm
  # Vocab file is just for the purpose of human verification
  def save(self, file_prefix):        
        # Writing to model file 
        model_file = file_prefix + ".model"           # model file name

        # Model write begins
        with open(model_file, 'w') as f:            
            f.write("thai tokenizer v1.0\n")          # write the tokenizer version
            f.write(f"{self.pattern}\n")              # write the pattern used in tokenizer            
            f.write(f"{len(self.special_tokens)}\n")  # write the length of special tokens

            # Write each special token in the specific format like below
            for tokens, idx in self.special_tokens.items():
                f.write(f"{tokens} {idx}\n")

            # Write only the keys part from the merges dict
            for idx1, idx2 in self.merges:
                f.write(f"{idx1} {idx2}\n")

        # Writing to the vocab file
        vocab_file = file_prefix + ".vocab"       # vocab file name

        # Change the position of keys and values of merge dict and store into inverted_merges 
        inverted_merges = {idx: pair for pair, idx in self.merges.items()}        
        # Vocab write begins
        with open(vocab_file, "w", encoding="utf-8") as f:
            for idx, token in self.vocab.items():
                # render_token function processes tokens and prevents distorted bytes by replacing them with readable character
                s = render_token(token)
                # If the index of vocab is present in merge dict, then find its child index, convert their corresponding bytes in vocab dict and write the characters
                if idx in inverted_merges:                    
                    idx0, idx1 = inverted_merges[idx]
                    s0 = render_token(self.vocab[idx0])
                    s1 = render_token(self.vocab[idx1])
                    f.write(f"[{s0}][{s1}] -> [{s}] {idx}\n")
                # If index of vocab is not present in merge dict, just write it's index and the corresponding string
                else:                    
                    f.write(f"[{s}] {idx}\n")

  # Function to load tokenizer model. 
  # This function is invoked only after the training is complete and the tokenizer model file is saved.
  def load(self, model_file):

        merges = {}             # Initialize merge and special_tokens with empty dict
        special_tokens = {}     # Initialize special_tokens with empty dict
        idx = 256               # As the range (0, 255) is already reserved in vocab. So the next index only starts from 256 and onwards.

        # Read model file
        with open(model_file, 'r', encoding="utf-8") as f:

            version = f.readline().strip()          # Read the tokenizer version as defined during model file writing            
            self.pattern = f.readline().strip()     # Read the pattern used in tokenizer            
            num_special = int(f.readline().strip()) # Read the length of special tokens

            # Read all the special tokens and store in special_tokens dict defined earlier
            for _ in range(num_special):
                special, special_idx = f.readline().strip().split()
                special_tokens[special] = int(special_idx)

            # Read all the merge indexes from the file. Make it a key pair and store it in merge dictionary defined earlier. 
            # The value of this key pair would be idx(256) as defined above and keep on increase by 1\.            
            for line in f:
                idx1, idx2 = map(int, line.split())
                merges[(idx1, idx2)] = idx
                idx += 1

        self.merges = merges                  
        self.special_tokens = special_tokens  

        # Create a final vocabulary dictionary by combining merge, special_token and vocab (0-255). _build_vocab function helps to do just that.
        self.vocab = self._build_vocab() 

第 3 步:分词器的编码和解码功能:

  • 分词器编码: 分词器的编码功能查找词汇表,将给定的输入文本或提示转换为整数 ID 列表。这些 ID 随后被输入到转换器块中。

  • 分词器解码: 分词器的解码功能查找词汇表,将来自转换器分类块生成的 ID 列表转换为输出文本。

让我们看一下下面的图表,以便更清楚地理解。

图片来源:作者:泰语分词器的编码和解码功能

让我们编写代码来实现分词器的编码和解码功能。

# Tokenizer encode function takes text as a string and returns integer ids list
  def encode(self, text):      

        # Define a pattern to identify special token present in the text
        special_pattern = "(" + "|".join(re.escape(k) for k in self.special_tokens) + ")"        
        # Split special token (if present) from the rest of the text
        special_chunks = re.split(special_pattern, text)        
        # Initialize empty ids list 
        ids = []                                                

        # Loop through each of parts in the special chunks list.
        for part in special_chunks:
            # If the part of the text is the special token, get the idx of the part from the special token dictionary and append it to the ids list.
            if part in self.special_tokens:                
                ids.append(self.special_tokens[part])            
            # If the part of text is not a special token 
            else:                
                # Split the text into multiple chunks using the pattern we've defined earlier.
                text_chunks = re.findall(self.compiled_pattern, text)

                # All text chunks are encoded separately, then the results are joined                
                for chunk in text_chunks:
                    chunk_bytes = chunk.encode("utf-8")   # Encode text to bytes                    
                    chunk_ids = list(chunk_bytes)         # Convert bytes to list of integer  

                    while len(chunk_ids) >= 2:    # chunks ids list must be at least 2 id to form a byte-pair
                        # Count the number of times every consecutive pair appears
                        stats = get_stats(chunk_ids)
                        # Some idx pair might be created with another idx in the merge dictionary. Hence we'll find the pair with the lowest merge index to ensure we cover all byte pairs in the merge dict.
                        pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))

                        # Break the loop and return if the pair is not present in the merges dictionary                        
                        if pair not in self.merges:
                            break 
                        # Find the idx of the pair present in the merges dictionary
                        idx = self.merges[pair]
                        # Replace the occurrences of pair in ids list with this idx and continue
                        chunk_ids = merge(chunk_ids, pair, idx)                    

                    ids.extend(chunk_ids)                
        return ids

  # Tokenizer decode function takes a list of integer ids and return strings
  def decode(self, ids):

        # Initialize empty byte list
        part_bytes = []
        # Change the position of keys and values of special_tokens dict and store into inverse_special_tokens 
        inverse_special_tokens = {v: k for k, v in self.special_tokens.items()}

        # Loop through idx in the ids list
        for idx in ids:
            # If the idx is found in vocab dict, get the bytes of idx and append them into part_bytes list
            if idx in self.vocab:
                part_bytes.append(self.vocab[idx])
            # If the idx is found in inverse_special_tokens dict, get the token string of the corresponding idx, convert it to bytes using utf-8 encode and then append it into part_bytes list
            elif idx in inverse_special_tokens:
                part_bytes.append(inverse_special_tokens[idx].encode("utf-8"))
            # If the idx is not found in both vocab and special token dict, throw an invalid error
            else:
                raise ValueError(f"invalid token id: {idx}")

        # Join all the individual bytes from the part_byte list
        text_bytes = b"".join(part_bytes)

        # Convert the bytes to text string using utf-8 decode function. Make sure to use "errors=replace" to replace distorted characters with readable characters such as �.
        text = text_bytes.decode("utf-8", errors="replace")
        return text

第 4 步:加载并测试分词器:

最后,来到了本文的精彩部分。在这一部分,我们将进行两个有趣的任务。

  • 首先,用 Hugging Face 的泰语维基数据集训练我们的分词器。我们选择了一个较小的数据集(2.2 MB),以便加快训练速度。然而,实际应用中,你应该选择一个更大的数据集以获得更好的结果。训练完成后,我们将保存模型。

  • 其次,我们将加载保存的分词器模型,并测试分词器的编码和解码功能。

让我们深入了解。

# Train the tokenizer

import time   # To caculate the duration of training completion
# Load training raw text data (thai_wiki dataset) from huggingface. thai_wiki_small.text: https://github.com/tamangmilan/thai_tokenizer
texts = open("/content/thai_wiki_small.txt", "r", encoding="utf-8").read()
texts = texts.strip()
# Define vocab size
vocab_size = 512
# Initialize a tokenizer model class
tokenizer = ThaiTokenizer()
# Start train a tokenizer
start_time = time.time()
tokenizer.train(texts, vocab_size)
end_time = time.time()
# Save tokenizer: you can change path and filename.
tokenizer.save("./models/thaitokenizer")
print(f"Total time to complete tokenizer training: {end_time-start_time:.2f} seconds")

# Output: Total time to complete tokenizer training: 186.11 seconds (3m 6s) [Note: Training duration will be longer if vocab_size is bigger and lesser for smaller vocab_size]
# Test the tokenizer

# Initialize a tokenizer model class
tokenizer = ThaiTokenizer()
# Load tokenizer model. This model was saved during training.
tokenizer.load("./models/thaitokenizer.model")
# Invoke and verify the tokenizer encode and decode function for English Language
eng_texts = "When society evolved in different lands"
print(f"English Text: {eng_texts}")
encoded_ids = tokenizer.encode(eng_texts)
print(f"Encoded Ids: {encoded_ids}")
decoded_texts = tokenizer.decode(encoded_ids)
print(f"Decoded Texts: {decoded_texts}\n")

# Invoke and verify the tokenizer encode and decode function for Thai Language
thai_texts = "เมื่อสังคมมีวิวัฒนาการขึ้นในดินแดนต่าง"
print(f"Thai Text: {thai_texts}")
thai_encoded_ids = tokenizer.encode(thai_texts)
print(f"Encoded Ids: {thai_encoded_ids}")
thai_decoded_texts = tokenizer.decode(thai_encoded_ids)
print(f"Decoded Texts: {thai_decoded_texts}")

[泰语分词器]:泰语和英语文本的编码与解码输出。

完美。我们的泰语分词器现在可以成功且准确地对泰语和英语文本进行编码和解码。

你有没有注意到,英文文本的编码 ID 比泰语编码 ID 要长?这是因为我们仅使用泰语数据集来训练我们的分词器。因此,分词器只能为泰语构建一个全面的词汇表。由于我们没有使用英文数据集进行训练,分词器必须从字符级别开始编码,这就导致了更长的编码 ID。正如我之前提到的,对于多语言 LLM,你应该以 2:1 的比例训练英语和泰语数据集。这样可以获得平衡且高质量的结果。

就这样! 我们现在已经成功地从零开始仅使用 Python 创建了我们自己的泰语分词器。而且,我觉得这真的很酷。有了这个,你可以轻松地为任何外语构建分词器。这将在实现你的多语言大语言模型时为你提供很大的帮助。

非常感谢阅读!

链接到 Google Colab 笔记本

参考文献

[1] Andrej Karpathy, Git Hub: Karpthy/minbpe

构建 WhatsApp LLM 机器人:懒人单人程序员指南

原文:towardsdatascience.com/build-a-whatsapp-llm-bot-a-guide-for-lazy-solo-programmers-24934d8f5488?source=collection_archive---------3-----------------------#2024-09-20

我如何在 12 小时内使用 Python、AWS 和 OpenAI 构建它,并总结经验教训

Ian XiaoTowards Data Science Ian Xiao

·发表于 Towards Data Science ·阅读时长:8 分钟·2024 年 9 月 20 日

--

图片来源:Milad FakurianUnsplash

简而言之: 我在 12 小时内构建并部署了一个 WhatsApp LLM 机器人,以便更快、更好地学习英语。我正在探索如何将 LLM 应用于我们的日常生活。我分享了我的设计选择、构建的内容、使用的工具、经验教训以及产品路线图。

我正在分阶段构建这个应用。敬请关注后续更新。

👉 看起来对你有用吗?请花 3 分钟完成这个 调查 我需要来自社区的一些设计指导,希望你能参与测试版。

这不是一篇代码讲解。我将在最后列出我所使用的所有资源,如果你感兴趣,可以查看。

问题

我喜欢阅读和写作。

但是,作为一名非英语母语者,我经常会遇到一些我不懂的新词,或者是认为自己懂的词,但实际上需要帮助理解。新词总是匆匆而过,在我忙碌的日常或享受阅读的过程中。我希望它们能记住;我希望自己变得更有口才。

怎么样,查找并记录它们呢?数字解决方案(如词典或词汇应用)和纸笔并不起作用。

构建并部署一个多文件、多格式的 RAG 应用到 Web

原文:towardsdatascience.com/build-and-deploy-a-multi-file-multi-format-rag-app-to-the-web-910b7ac5eb6d?source=collection_archive---------2-----------------------#2024-10-24

图像由 AI(Dalle-3)生成

开发应用

第一部分 — 使用 Python、Gradio、GROQ 和 LlamaIndex 开发代码

Thomas ReidTowards Data Science Thomas Reid

·发布于 Towards Data Science ·阅读时间 11 分钟·2024 年 10 月 24 日

--

这是一个两部分系列文章的第一部分。在这一部分(第一部分),我将向你展示如何开发一个有用的 Web 应用,能够上传和读取多种不同类型的文件,例如 PDF、TXT、DOCX 等等……然后我们将利用 AI 和 RAG 来分析这些文件并回答相关问题。

在第二部分,我将向你展示如何使用 Hugging Face Spaces 将你的应用部署到 Web 上,让全世界的人都能惊叹于你的伟大。

附言:如果你想提前预览在 Hugging Face Spaces 上部署的应用,请点击这个 链接

毫无疑问,AI 和大型语言模型的一个重要增长领域是检索增强生成(RAG)领域。RAG 是一种微调方法,你通过为 LLM 提供它在训练数据中无法访问的特定信息。

如果你之前从未听说过 RAG,不用担心,它并不复杂。一个典型的 RAG 流程包括读取一个或多个(通常是 PDF 格式)文档,但它们也可以是 CSV、TXT 或其他格式。将这些文档分割成较小的文本块,对每个 token 进行编码(有点像…)

构建并将多文件 RAG 应用部署到 Web

原文:towardsdatascience.com/build-and-deploy-a-multi-file-rag-app-to-the-web-70ee4eceb0e3?source=collection_archive---------5-----------------------#2024-11-01

图片来自 AI (Dalle-3)

第二部分 — 使用 Hugging Face Spaces 将应用部署到网络

Thomas ReidTowards Data Science Thomas Reid

·发表于 Towards Data Science ·阅读时间:9 分钟·2024 年 11 月 1 日

--

这是关于构建和部署基于 Gradio 的 AI 网络应用的两部分系列文章中的第二部分。

本部分内容将讲解如何使用 Hugging Face Spaces 将已完成的应用部署到全球互联网。

PS. 如果你想提前预览已部署的应用,可以点击这个 链接

我之前在许多文章中提到过 Gradio。依我看,它是构建 Python 代码 GUI 应用最简单的方法之一。

如果 Gradio 对你来说完全陌生,或者你只是略有了解,我建议你查看我下面的文章,在其中我介绍了他们是谁以及他们在做什么。我还展示了一些小的代码示例,演示了 Gradio 的实际应用。

[## Gradio:快速 GUI 原型设计

使用 Python 在几分钟内创建直观的 Web 界面

ai.gopubby.com](https://ai.gopubby.com/gradio-rapid-gui-prototyping-a0091c28116b?source=post_page-----70ee4eceb0e3--------------------------------)

在上一篇文章中,我带你了解了如何构建一个多文件 RAG 聊天应用,该应用能够上传、读取和分析各种文档格式,包括 PDF、文本、Microsoft Word 和 Excel 文件……

使用函数调用构建自主 AI 代理

原文:towardsdatascience.com/build-autonomous-ai-agents-with-function-calling-0bb483753975?source=collection_archive---------0-----------------------#2024-04-02

将您的聊天机器人转变为能够与外部 API 交互的代理

Julian YipTowards Data Science Julian Yip

·发表于Towards Data Science ·阅读时间 11 分钟·2024 年 4 月 2 日

--

函数调用并不是什么新鲜事。2023 年 7 月,OpenAI 为其 GPT 模型引入了函数调用功能,现在这一功能正在被竞争对手采用。谷歌的 Gemini API 最近也支持了这一功能,而 Anthropic 正在将其集成到 Claude 中。函数调用正在成为大型语言模型(LLMs)中不可或缺的一部分,增强了它们的能力。学习这项技术会更加有用!

牢记这一点,我计划编写一份全面的教程,深入探讨函数调用,超越基本介绍(市面上已有很多相关教程)。重点将放在实际应用上,构建一个完全自主的 AI 代理,并将其与 Streamlit 集成,提供类似 ChatGPT 的界面。虽然使用 OpenAI 进行演示,但本教程也可以轻松适应支持函数调用的其他 LLM,例如 Gemini。

函数调用有什么用途?

函数调用使开发者能够描述函数(即工具,您可以将其视为模型执行的操作,例如进行计算或下订单),并让模型智能地选择输出一个包含调用这些函数所需参数的 JSON 对象。更简单来说,它使得:

  • 自主决策:模型能够智能地选择工具来回答问题。

  • 可靠的解析:响应以 JSON 格式返回,而不是更典型的对话式响应。乍一看,这似乎没什么,但正是这种格式让大型语言模型(LLM)能够与外部系统连接,比如通过结构化输入的 API。

它开启了许多可能性:

  • 自主 AI 助手:机器人可以与内部系统互动,执行诸如客户订单和退货等任务,而不仅仅是回答查询

  • 个人研究助手:假设你正在计划旅行,助手可以搜索网页、抓取内容、比较选项,并将结果总结在 Excel 中。

  • 物联网语音命令:模型可以控制设备或根据检测到的意图建议操作,比如调整空调温度。

(稍微偏题:为了实现这些潜力,我们必须有一个系统化的方法来设计我们的提示并进行测试。我也写了一篇关于此的文章!)

## 像数据科学家一样构建提示:使用 DSPy 进行自动提示优化和测试

将机器学习方法应用于提示构建

towardsdatascience.com

不再浪费时间,让我们深入探讨一下功能调用是什么!

功能调用的结构

借鉴Gemini 的功能调用文档,功能调用具有以下结构,在 OpenAI 中也适用

来自Gemini 的功能调用文档的图片

  1. 用户向应用程序发出提示

  2. 应用程序传递用户提供的提示以及功能声明,这些功能声明是对模型可能使用的工具的描述

  3. 基于功能声明,模型建议使用的工具和相关请求参数。注意,模型仅输出建议的工具和参数,并不会实际调用这些功能

  4. & 5. 根据响应,应用程序调用相关的 API

6. & 7. 来自 API 的响应再次传入模型,输出一个人类可读的响应

8. 应用程序将最终响应返回给用户,然后从第 1 步开始重复。

这看起来可能有些复杂,但这个概念将通过示例详细说明

架构

在深入代码之前,先简要说明一下演示应用程序的架构

解决方案

在这里,我们为游客访问酒店构建一个助手。该助手可以访问以下工具,使其能够访问外部应用程序。

  • get_itemspurchase_item:通过 API 连接到存储在数据库中的产品目录,分别用于检索商品列表和进行购买

  • rag_pipeline_func:通过检索增强生成(RAG)连接到文档存储,从非结构化文本(例如酒店的宣传册)中获取信息

技术栈

现在,让我们开始吧!

示例应用

准备工作

访问Github来克隆我的代码。以下内容可以在function_calling_demo Notebook 中找到

请先创建并激活一个虚拟环境,然后通过pip install -r requirements.txt安装所需的包

初始化

我们首先连接到 OpenRouter。或者,使用原始的OpenAIChatGenerator,如果没有覆盖api_base_url,也可以工作,前提是你有 OpenAI 的 API 密钥

import os
from dotenv import load_dotenv
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.utils import Secret
from haystack.dataclasses import ChatMessage
from haystack.components.generators.utils import print_streaming_chunk

# Set your API key as environment variable before executing this
load_dotenv()
OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY')

chat_generator = OpenAIChatGenerator(api_key=Secret.from_env_var("OPENROUTER_API_KEY"),
  api_base_url="https://openrouter.ai/api/v1",
  model="openai/gpt-4-turbo-preview",
        streaming_callback=print_streaming_chunk)

然后我们测试是否可以成功调用chat_generator

chat_generator.run(messages=[ChatMessage.from_user("Return this text: 'test'")])
---------- The response should look like this ----------
{'replies': [ChatMessage(content="'test'", role=<ChatRole.ASSISTANT: 'assistant'>, name=None, meta={'model': 'openai/gpt-4-turbo-preview', 'index': 0, 'finish_reason': 'stop', 'usage': {}})]}

步骤 1:建立数据存储

在这里,我们建立了应用程序与两个数据源之间的连接:文档存储用于非结构化文本,应用数据库通过 API 连接

使用管道索引文档

我们在documents中提供样本文本,供模型执行检索增强生成(RAG)。这些文本被转换为嵌入并存储在内存文档存储中

from haystack import Pipeline, Document
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.components.writers import DocumentWriter
from haystack.components.embedders import SentenceTransformersDocumentEmbedder

# Sample documents
documents = [
    Document(content="Coffee shop opens at 9am and closes at 5pm."),
    Document(content="Gym room opens at 6am and closes at 10pm.")
]

# Create the document store
document_store = InMemoryDocumentStore()

# Create a pipeline to turn the texts into embeddings and store them in the document store
indexing_pipeline = Pipeline()
indexing_pipeline.add_component(
    "doc_embedder", SentenceTransformersDocumentEmbedder(model="sentence-transformers/all-MiniLM-L6-v2")
)
indexing_pipeline.add_component("doc_writer", DocumentWriter(document_store=document_store))

indexing_pipeline.connect("doc_embedder.documents", "doc_writer.documents")

indexing_pipeline.run({"doc_embedder": {"documents": documents}})

它应该输出与我们之前创建的documents样本相对应的内容

{'doc_writer': {'documents_written': 2}}

启动 API 服务器

使用 Flask 创建的 API 服务器位于db_api.py,用于连接 SQLite。请通过在终端运行python db_api.py来启动它

如果成功执行,这将在终端显示

还要注意,一些初始数据已添加到db_api.py

数据库中的示例数据

步骤 2:定义函数

在这里,我们为模型准备实际的函数,以便在调用函数后(如函数调用结构中描述的第 4-5 步)进行调用

RAG 功能

rag_pipeline_func。这是让模型通过搜索存储在文档存储中的文本来提供答案。我们首先将 RAG 检索定义为 Haystack 管道

from haystack.components.embedders import SentenceTransformersTextEmbedder
from haystack.components.retrievers.in_memory import InMemoryEmbeddingRetriever
from haystack.components.builders import PromptBuilder
from haystack.components.generators import OpenAIGenerator

template = """
Answer the questions based on the given context.

Context:
{% for document in documents %}
    {{ document.content }}
{% endfor %}
Question: {{ question }}
Answer:
"""
rag_pipe = Pipeline()
rag_pipe.add_component("embedder", SentenceTransformersTextEmbedder(model="sentence-transformers/all-MiniLM-L6-v2"))
rag_pipe.add_component("retriever", InMemoryEmbeddingRetriever(document_store=document_store))
rag_pipe.add_component("prompt_builder", PromptBuilder(template=template))
# Note to llm: We are using OpenAIGenerator, not the OpenAIChatGenerator, because the latter only accepts List[str] as input and cannot accept prompt_builder's str output
rag_pipe.add_component("llm", OpenAIGenerator(api_key=Secret.from_env_var("OPENROUTER_API_KEY"),
  api_base_url="https://openrouter.ai/api/v1",
  model="openai/gpt-4-turbo-preview"))

rag_pipe.connect("embedder.embedding", "retriever.query_embedding")
rag_pipe.connect("retriever", "prompt_builder.documents")
rag_pipe.connect("prompt_builder", "llm")

测试函数是否正常工作

query = “When does the coffee shop open?”
rag_pipe.run({"embedder": {"text": query}, "prompt_builder": {"question": query}})

这应该会产生以下输出。注意,模型给出的replies来自我们之前提供的样本文档

{'llm': {'replies': ['The coffee shop opens at 9am.'],
  'meta': [{'model': 'openai/gpt-4-turbo-preview',
    'index': 0,
    'finish_reason': 'stop',
    'usage': {'completion_tokens': 9,
     'prompt_tokens': 60,
     'total_tokens': 69,
     'total_cost': 0.00087}}]}}

然后我们可以将rag_pipe转化为一个函数,该函数仅提供replies,而不加入其他细节

def rag_pipeline_func(query: str):
    result = rag_pipe.run({"embedder": {"text": query}, "prompt_builder": {"question": query}})

    return {"reply": result["llm"]["replies"][0]}

API 调用

我们定义了get_itemspurchase_item函数,用于与数据库交互

# Flask's default local URL, change it if necessary
db_base_url = 'http://127.0.0.1:5000'

# Use requests to get the data from the database
import requests
import json

# get_categories is supplied as part of the prompt, it is not used as a tool
def get_categories():
    response = requests.get(f'{db_base_url}/category')
    data = response.json()
    return data

def get_items(ids=None,categories=None):
    params = {
        'id': ids,
        'category': categories,
    }
    response = requests.get(f'{db_base_url}/item', params=params)
    data = response.json()
    return data

def purchase_item(id,quantity):

    headers = {
    'Content-type':'application/json', 
    'Accept':'application/json'
    }

    data = {
        'id': id,
        'quantity': quantity,
    }
    response = requests.post(f'{db_base_url}/item/purchase', json=data, headers=headers)
    return response.json()

定义工具列表

现在我们已经定义了函数,我们需要让模型识别这些函数,并通过提供描述来指示它们如何使用。

由于我们在这里使用的是 OpenAI,tools格式如下,遵循OpenAI 要求的格式

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_items",
            "description": "Get a list of items from the database",
            "parameters": {
                "type": "object",
                "properties": {
                    "ids": {
                        "type": "string",
                        "description": "Comma separated list of item ids to fetch",
                    },
                    "categories": {
                        "type": "string",
                        "description": "Comma separated list of item categories to fetch",
                    },
                },
                "required": [],
            },
        }
    },
    {
        "type": "function",
        "function": {
            "name": "purchase_item",
            "description": "Purchase a particular item",
            "parameters": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "string",
                        "description": "The given product ID, product name is not accepted here. Please obtain the product ID from the database first.",
                    },
                    "quantity": {
                        "type": "integer",
                        "description": "Number of items to purchase",
                    },
                },
                "required": [],
            },
        }
    },
    {
        "type": "function",
        "function": {
            "name": "rag_pipeline_func",
            "description": "Get information from hotel brochure",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "The query to use in the search. Infer this from the user's message. It should be a question or a statement",
                    }
                },
                "required": ["query"],
            },
        },
    }
]

步骤 3:将所有内容整合

现在我们有了必要的输入来测试函数调用!在这里我们做了几件事:

  1. 提供初始提示给模型,以便给它一些上下文

  2. 提供一个示例用户生成的消息

  3. 最重要的是,我们将工具列表传递给tools中的聊天生成器

# 1\. Initial prompt
context = f"""You are an assistant to tourists visiting a hotel.
You have access to a database of items (which includes {get_categories()}) that tourists can buy, you also have access to the hotel's brochure.
If the tourist's question cannot be answered from the database, you can refer to the brochure.
If the tourist's question cannot be answered from the brochure, you can ask the tourist to ask the hotel staff.
"""
messages = [
    ChatMessage.from_system(context),
    # 2\. Sample message from user
    ChatMessage.from_user("Can I buy a coffee?"),
    ]

# 3\. Passing the tools list and invoke the chat generator
response = chat_generator.run(messages=messages, generation_kwargs= {"tools": tools})
response
---------- Response ----------
{'replies': [ChatMessage(content='[{"index": 0, "id": "call_AkTWoiJzx5uJSgKW0WAI1yBB", "function": {"arguments": "{\\"categories\\":\\"Food and beverages\\"}", "name": "get_items"}, "type": "function"}]', role=<ChatRole.ASSISTANT: 'assistant'>, name=None, meta={'model': 'openai/gpt-4-turbo-preview', 'index': 0, 'finish_reason': 'tool_calls', 'usage': {}})]}

现在让我们检查响应。注意函数调用是如何返回模型选择的函数以及调用该函数的参数的。

function_call = json.loads(response["replies"][0].content)[0]
function_name = function_call["function"]["name"]
function_args = json.loads(function_call["function"]["arguments"])
print("Function Name:", function_name)
print("Function Arguments:", function_args)
---------- Response ----------
Function Name: get_items
Function Arguments: {‘categories’: ‘Food and beverages’}

当出现另一个问题时,模型将使用另一个更相关的工具

# Another question
messages.append(ChatMessage.from_user("Where's the coffee shop?"))

# Invoke the chat generator, and passing the tools list
response = chat_generator.run(messages=messages, generation_kwargs= {"tools": tools})
function_call = json.loads(response["replies"][0].content)[0]
function_name = function_call["function"]["name"]
function_args = json.loads(function_call["function"]["arguments"])
print("Function Name:", function_name)
print("Function Arguments:", function_args)
---------- Response ----------
Function Name: rag_pipeline_func
Function Arguments: {'query': "Where's the coffee shop?"}

再次注意,这里并没有实际调用函数,这是我们接下来要做的!

调用函数

然后我们可以将参数传递到选定的函数中

## Find the correspoding function and call it with the given arguments
available_functions = {"get_items": get_items, "purchase_item": purchase_item,"rag_pipeline_func": rag_pipeline_func}
function_to_call = available_functions[function_name]
function_response = function_to_call(**function_args)
print("Function Response:", function_response)
---------- Response ----------
Function Response: {'reply': 'The provided context does not specify a physical location for the coffee shop, only its operating hours. Therefore, I cannot determine where the coffee shop is located based on the given information.'}

然后rag_pipeline_func的响应可以作为上下文传递给聊天,通过将其附加到messages下,供模型提供最终答案

messages.append(ChatMessage.from_function(content=json.dumps(function_response), name=function_name))
response = chat_generator.run(messages=messages)
response_msg = response["replies"][0]

print(response_msg.content)
---------- Response ----------
For the location of the coffee shop within the hotel, I recommend asking the hotel staff directly. They will be able to guide you to it accurately.

我们现在已经完成了聊天循环!

步骤 4:转变为互动聊天

上面的代码展示了如何进行函数调用,但我们想更进一步,将其转化为互动聊天

这里我展示了两种方法:从更原始的input(),它将对话打印到笔记本本身,到通过Streamlit渲染它,提供类似 ChatGPT 的 UI

**input()** 循环

该代码来自Haystack 的教程,使我们能够快速测试模型。注意:此应用程序是为了展示函数调用的概念,而非旨在做到完美稳健,例如不支持同时处理多个项目的顺序、没有幻想等。

import json
from haystack.dataclasses import ChatMessage, ChatRole

response = None
messages = [
    ChatMessage.from_system(context)
]

while True:
    # if OpenAI response is a tool call
    if response and response["replies"][0].meta["finish_reason"] == "tool_calls":
        function_calls = json.loads(response["replies"][0].content)

        for function_call in function_calls:
            ## Parse function calling information
            function_name = function_call["function"]["name"]
            function_args = json.loads(function_call["function"]["arguments"])

            ## Find the correspoding function and call it with the given arguments
            function_to_call = available_functions[function_name]
            function_response = function_to_call(**function_args)

            ## Append function response to the messages list using `ChatMessage.from_function`
            messages.append(ChatMessage.from_function(content=json.dumps(function_response), name=function_name))

    # Regular Conversation
    else:
        # Append assistant messages to the messages list
        if not messages[-1].is_from(ChatRole.SYSTEM):
            messages.append(response["replies"][0])

        user_input = input("ENTER YOUR MESSAGE 👇 INFO: Type 'exit' or 'quit' to stop\n")
        if user_input.lower() == "exit" or user_input.lower() == "quit":
            break
        else:
            messages.append(ChatMessage.from_user(user_input))

    response = chat_generator.run(messages=messages, generation_kwargs={"tools": tools})

在 IDE 中运行互动聊天

虽然它可以工作,但我们可能希望有一些看起来更漂亮的东西。

Streamlit 界面

Streamlit 将数据脚本转化为可分享的 Web 应用,这为我们的应用提供了整洁的 UI。上面展示的代码被改编成一个 Streamlit 应用,位于我仓库的streamlit文件夹下。

你可以通过以下方式运行:

  1. 如果你还没有做,使用python db_api.py启动 API 服务器

  2. 将 OPENROUTER_API_KEY 设置为环境变量,例如export OPENROUTER_API_KEY = ‘@REPLACE WITH YOUR API KEY’,假设你在 Linux 上或使用 git bash 执行

  3. 在终端中使用cd streamlit命令导航到streamlit文件夹

  4. 通过streamlit run app.py运行 Streamlit。你的浏览器中应该会自动创建一个新标签页来运行该应用程序。

就是这样!希望你喜欢这篇文章。

Streamlit 用户界面

*除非另有说明,所有图片均由作者提供

使用 Airflow 和 Mlflow 构建机器学习管道:预订取消预测

原文:towardsdatascience.com/build-machine-learning-pipelines-with-airflow-and-mlflow-reservation-cancellation-forecasting-da675d409842?source=collection_archive---------1-----------------------#2024-01-12

通过高级机器学习任务学习如何创建可复现的、适用于生产环境的机器学习管道

Jeremy ArancioTowards Data Science Jeremy Arancio

·发表于Towards Data Science ·21 分钟阅读·2024 年 1 月 12 日

--

现如今,公司需要工程技能来开发和部署机器学习模型到生产环境中。

机器学习工程师现在应具备处理整个模型生命周期的能力,涵盖如实验跟踪调度器CI/CD版本控制模型注册表等方面——从数据提取到生产化。

在本文中,我们将深入探讨我为短期租赁行业领导者进行的机器学习任务,并使用该行业中广泛应用的两种工具:AirflowMlflow

本文不仅指导读者解决一个真实的机器学习项目,还提供了该项目的完整仓库,供未来在机器学习项目中使用。

[## GitHub - jeremyarancio/reservation_cancellation_prediction: 预测预订是否会被取消…

通过 Airflow 和 Mlflow 的强大机器学习管道预测预订是否会被取消 - GitHub …

github.com](https://github.com/jeremyarancio/reservation_cancellation_prediction?source=post_page-----da675d409842--------------------------------)

照片来源:EJ StratUnsplash

从零开始构建你的智能体

原文:towardsdatascience.com/build-your-agents-from-scratch-forget-autogen-or-crewai-part-a-a114cd1e785f?source=collection_archive---------2-----------------------#2024-09-23

设计你自己的智能体,完全不依赖任何框架

Hamza FarooqTowards Data Science Hamza Farooq

·发表于Towards Data Science ·7 分钟阅读·2024 年 9 月 23 日

--

图片由Arseny Togulev提供,来源于Unsplash

最近几个月,我们都听说过智能体和多智能体框架。这些 AI 智能体已经成为自动化和决策制定中默默无闻的英雄。

虽然像AutoGenCrewAI这样的预构建框架提供了诱人的快捷方式,(这是有道理的!)但是,从零开始构建你自己的智能体,所带来的无与伦比的刺激感和深度理解,是任何快捷方式都无法比拟的。

这就像是在选择方便面的速食和制作一顿精美的餐点——当然,前者很快,但后者?那才是魔力的源泉。

今天,我们将撸起袖子,深入探讨如何创建AgentPro,我们自己的 AI 助手。在本文结束时,你将对 AI 智能体的运作原理有一个基础的了解,并且你将能够顺利创建一个可以按需生成和执行代码的数字助手。

这就像是在教一个机器人钓鱼,只不过钓到的不是鱼,而是从空中提取 Python 脚本!

警告:此代码可能在所有情况下无法运行,但它应该能帮助你入门 + 代码中可能会出现缩进错误

这是Colab Notebook

构建模块:通往 AgentPro 的路线图

在深入代码之前,让我们先概述一下我们将构建的关键组件:

开发一个代理人从零开始的五个阶段(图片由作者提供)

  1. 初始化:设置我们代理人的“大脑”

  2. 代码生成:教我们的代理人编写 Python 脚本

  3. 库管理:让我们的代理人能够安装必要的工具

  4. 代码执行:赋能我们的代理人运行它生成的代码

  5. 指挥中心:创建一个集中管理所有这些功能的中心枢纽

现在,让我们分解每个步骤,看看它们如何结合在一起,形成我们的 AI 助手。

步骤 1:初始化 — 给予我们的代理人第一次生命的火花

每一段伟大的旅程都是从第一步开始的,在人工智能代理人的世界里,这一步就是初始化。这是我们设置代理人基本结构并将其与主要智能源(在本例中是 OpenAI API)连接的地方。

from openai import OpenAI
import os
from google.colab import userdata
import base64
import requests
from PIL import Image
from io import BytesIO
import subprocess
import tempfile
import re
import importlib
import sys

os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')
class AgentPro:
    def __init__(self):
        # Future initialization code can go here
        pass

这段代码是给我们的 AI 助手赋予生命的数字等价物。我们正在导入必要的库,设置我们的 OpenAI API 密钥,并创建 AgentPro 类的框架。这就像是为我们的 AI 提供了一个身体——单独来看,它不太有用,但它对接下来的所有内容都是至关重要的。

步骤 2:代码生成 — 教我们的代理人写 Python 代码

现在我们的代理人有了一个“身体”,让我们赋予它思考的能力——或者在这个例子中,赋予它生成代码的能力。这就是事情开始变得激动人心的地方!

def generate_code(self, prompt):
    client = OpenAI()
    response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
      {"role": "system", "content": "You are a Python code generator. Respond only with executable Python code, no explanations or comments except for required pip installations at the top."},
      {"role": "user", "content": f"Generate Python code to {prompt}. If you need to use any external libraries, include a comment at the top of the code listing the required pip installations."}
    ],
    max_tokens=4000,
    temperature=0.7,
    top_p=1,
    frequency_penalty=0,
    presence_penalty=0
    )
    code = re.sub(r'^```python\n|^```py\n|```$', '', response.choices[0].message.content, flags=re.MULTILINE)

    code_lines = code.split('\n')

    当 code_lines 并且不是以 'import' 或 'from' 或 '#' 开头时:

        code_lines.pop(0)

    return '\n'.join(code_lines)

```py

This method is the crown jewel of our agent’s capabilities. It’s using the OpenAI API to generate Python code based on a given prompt.

Think of it as giving our agent the ability to brainstorm and write code on the fly. We’re also doing some cleanup to ensure we get clean, executable Python code without any markdown formatting or unnecessary comments.

The parameters we’re using (like temperature and top_p) allow us to control the creativity and randomness of the generated code. It’s like adjusting the “inspiration” knob on our AI’s imagination!

**Step 3: Library Management — Equipping Our Agent with the Right Tools**

Every good coder knows the importance of having the right libraries at their disposal. Our AI assistant is no different. This next method allows AgentPro to identify and install any necessary Python libraries

def install_libraries(self, code):

libraries = re.findall(r'#\s*pip install\s+([\w-]+)', code)

如果 libraries:

    print("正在安装所需的库...")

    对于 lib 在 libraries 中:

        try:

            importlib.import_module(lib.replace('-', '_'))

            print(f"{lib} 已经安装。")

        except ImportError:

            print(f"正在安装 {lib}...")

            subprocess.check_call([sys.executable, "-m", "pip", "install", lib])

    print("库安装成功。")

This method is like sending our agent on a shopping spree in the Python Package Index. It scans the generated code for any pip install comments, checks if the libraries are already installed, and if not, installs them. It’s ensuring our agent always has the right tools for the job, no matter what task we throw at it.

**Step 4: Code Execution — Bringing the Code to Life**

Generating code is great, but executing it is where the rubber meets the road. This next method allows our agent to run the code it has generated:

def execute_code(self, code):

with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as temp_file:

    temp_file.write(code)

    temp_file_path = temp_file.name

try:

    result = subprocess.run(['python', temp_file_path], capture_output=True, text=True, timeout=30)

    output = result.stdout

    error = result.stderr

except subprocess.TimeoutExpired:

    output = ""

    error = "执行超时,超出 30 秒。"

finally:

    os.unlink(temp_file_path)

return output, error

This method is where the magic really happens. It takes the generated code, writes it to a temporary file, executes it, captures the output (or any errors), and then cleans up after itself. It’s like giving our agent hands to type out the code and run it, all in the blink of an eye.

**Step 5: Command Center — Putting It All Together**

Finally, we need a way to orchestrate all these amazing capabilities. Enter the run method:

def run(self, prompt):

print(f"正在为 {prompt} 生成代码")

code = self.generate_code(prompt)

print("生成的代码:")

print(code)

print("\n 正在执行代码...")

output, error = self.execute_code(code)

如果 output:

    print("输出:")

    print(output)

如果 error:

    print("错误:")

    print(error)

This is the command center of our AI assistant. It takes a prompt, generates the code, executes it, and reports back with the results or any errors. It’s like having a personal assistant who not only understands your requests but carries them out and gives you a full report.

**Putting It All Together:**

Now that we have all our components, let’s see how we can use our newly minted AI assistant:

如果 name == "main":

agent = AgentPro()

agent.run("""制作一个关于最佳领导形式的详细幻灯片,至少包含 10 张,并保存为名为 leadership.pptx 的 pptx 文件""")

至少包含 10 张幻灯片,并保存为名为 leadership.pptx 的 pptx 文件""")


使用这个简单的命令,我们要求我们的代理创建一个关于领导风格的完整演示文稿,包含至少 10 张幻灯片,并将其保存为 PowerPoint 文件。

我们的代理将生成必要的 Python 代码(可能使用如 python-pptx 之类的库),安装任何需要的库,执行代码以创建演示文稿,然后报告结果或遇到的任何错误。

我们刚刚建立了一个强大的 AI 代理的基础,能够按需生成并执行 Python 代码。从通过 OpenAI API 设置它的“大脑”,到赋予它编写和运行代码的能力,再到为它装备安装必要工具的功能,我们创造了一个多功能的数字助手。

这只是自定义 AI 代理可能实现的一个开端。在未来的系列中,我们将探索如何通过网络搜索能力、图像生成,甚至更复杂的决策过程来增强 AgentPro。

记住,**能力越大,责任越大**。你新的 AI 助手是一个强大的工具,但如何使用它完全取决于你。用它来自动化繁琐的任务,探索新想法,推动 AI 的边界。

只是,也许不要让它为你写结婚誓言或决定你的下一份职业——有些事情还是交给人类直觉来处理吧!

敬请期待 B 部分,在那里我们将教我们的代理一些新技巧,并开始解锁它的真正潜力。直到那时,祝你编程愉快,愿你的 AI 冒险没有 bug 且永无止境!

关注 B 部分!

如果你对了解更多内容感兴趣,请订阅。你也可以通过[LinkedIn](https://www.linkedin.com/in/hamzafarooq/)与我联系。

**关于我**

你好!我是 Hamza,我很高兴成为你进入 AI 代理世界的指南。作为 Google 的资深研究科学家,并在斯坦福和 UCLA 等著名院校有教学经验,我多年来一直处于 AI 开发和教育的前沿。我的热情在于揭开复杂 AI 概念的神秘面纱,并赋能下一代 AI 从业者。

说到这个,如果你喜欢这次深入了解如何从零构建 AI 代理,或许你会对将你的 LLM 知识提升到一个新层次感兴趣。我最近在 MAVEN 平台上开发了一门名为[企业 RAG 与多代理应用](https://maven.com/boring-bot/advanced-llm)的综合课程。这门课程专为那些希望推动大型语言模型(LLM)边界的实践者而设计,尤其是在企业环境中。

在[企业 RAG 和多智能体应用](https://maven.com/boring-bot/advanced-llm)中,我们探索了超越基础的前沿技术。从先进的检索增强生成(RAG)解决方案,到最新的模型优化方法和负责任的 AI 实践,本课程旨在让你掌握应对现实世界 AI 挑战所需的技能。

无论你是想实施最先进的 LLM 应用,还是深入研究模型微调和伦理 AI 部署的复杂性,本课程都能满足你的需求。


# 使用 Java 和 Python 构建你自己的类似 ChatGPT 的聊天机器人

> 原文:[`towardsdatascience.com/build-your-own-chatgpt-like-chatbot-with-java-and-python-5def2c4852c3?source=collection_archive---------1-----------------------#2024-05-30`](https://towardsdatascience.com/build-your-own-chatgpt-like-chatbot-with-java-and-python-5def2c4852c3?source=collection_archive---------1-----------------------#2024-05-30)

## 从零开始创建自定义的 LLM 推理基础设施

[](https://cardstdani.medium.com/?source=post_page---byline--5def2c4852c3--------------------------------)![Daniel García Solla](https://cardstdani.medium.com/?source=post_page---byline--5def2c4852c3--------------------------------)[](https://towardsdatascience.com/?source=post_page---byline--5def2c4852c3--------------------------------)![Towards Data Science](https://towardsdatascience.com/?source=post_page---byline--5def2c4852c3--------------------------------) [Daniel García Solla](https://cardstdani.medium.com/?source=post_page---byline--5def2c4852c3--------------------------------)

·发布于 [Towards Data Science](https://towardsdatascience.com/?source=post_page---byline--5def2c4852c3--------------------------------) ·阅读时间 25 分钟·2024 年 5 月 30 日

--

![](https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/04f5c0784845e51a157b80a2649323bb.png)

作者图片

## 引言

近年来,**[大型语言模型(LLMs)](https://aws.amazon.com/what-is/large-language-model/)** 已成为一项颠覆性技术,彻底改变了我们与机器互动的方式。这些模型以 OpenAI 的 GPT 系列为代表,举例如 GPT-3.5 或 GPT-4,它们可以接受一段输入文本并生成连贯的、符合上下文的、听起来像人类的回复文本。因此,它们的应用领域广泛,涵盖了客户服务、内容创作、语言翻译或代码生成等多个领域。然而,这些能力的核心是先进的机器学习/统计技术,包括用于提升自然语言理解过程的注意力机制、用于大规模提供基础模型的迁移学习、数据增强,甚至是 [**基于人类反馈的强化学习**](https://huggingface.co/blog/rlhf),这些技术使得系统能够在推理过程中不断扩展训练并提升性能。

作为人工智能的一个子集,机器学习负责处理数据集,识别模式并开发准确表示数据特性的模型。这种方法生成了有价值的知识,并解锁了各种任务,例如内容生成,这为[**生成式人工智能**](https://en.wikipedia.org/wiki/Generative_artificial_intelligence)领域提供了基础,推动了大语言模型的发展。值得强调的是,这个领域不仅仅专注于自然语言,还包括任何可能被生成的内容。从音频,拥有生成声音、语音或音乐的模型;到视频,最新的模型如 OpenAI 的[**SORA**](https://openai.com/index/sora/);再到图像,以及从文本序列生成图像、编辑和风格迁移。后者的数据格式尤其有价值,因为通过使用多模态集成和图像/文本嵌入技术,可以有效地展示通过自然语言进行知识表示的潜力。

然而,创建和维护执行此类操作的模型,尤其是在大规模的情况下,并不是一项容易的工作。主要原因之一是数据,因为它是构建良好运行模型的关键要素。也就是说,使用结构优化的架构和高质量的数据训练模型将产生有价值的结果。相反,如果提供的数据质量差,模型将生成误导性的输出。因此,在创建数据集时,它应该包含适量的数据,以适应特定模型架构的需求。这个要求使得数据处理和质量验证变得更加复杂,此外,如果数据是通过自动化或抓取方式收集的,还必须考虑潜在的法律和隐私问题。

另一个原因在于硬件。现代部署的模型需要同时处理大量来自多个用户的数据,不仅体积庞大,还需要大量的计算资源来执行推理任务并为客户提供高质量的服务。这在经济成本上同样表现为巨大的开销。一方面,搭建配备正确硬件的服务器和数据中心非常昂贵,考虑到为了提供可靠的服务,需要使用**GPU**、[**TPU**](https://cloud.google.com/tpu/docs/intro-to-tpu)、[**DPU**](https://blogs.nvidia.com/blog/whats-a-dpu-data-processing-unit/)以及精心挑选的组件以最大化效率。另一方面,其维护需要技术熟练的人员——合格的工作人员来解决潜在问题并根据需要进行系统升级。

## 目标

在构建这种模型及其大规模部署的过程中,存在许多其他问题。总体而言,构建一个支持基础设施足够强大的系统,以匹敌市场上领先的服务,如[**ChatGPT**](https://openai.com/chatgpt/),是非常困难的。然而,由于公开领域中有大量开源内容和技术,我们仍然能够实现与参考服务相当可接受和合理的近似。此外,考虑到其中一些技术的高进展度,它们被证明非常易于使用,使我们能够受益于它们的抽象性、模块化、易于集成等优点,这些特性有助于提升开发过程。

因此,本文的目的是展示我们如何设计、实现和部署一个支持 ChatGPT 类似服务的计算系统。尽管最终的结果可能无法具备预期的服务能力,但通过使用高质量的依赖项和开发工具,并采用良好的架构设计,可以确保系统根据用户需求轻松扩展到所需的计算能力。也就是说,系统将准备在非常少的机器上运行,可能仅需一台,且资源非常有限,提供与这些资源一致的吞吐量,或者在更大的计算机网络上运行,配备适当的硬件,提供扩展服务。

# 架构

最初,系统的主要功能是允许客户端提交文本查询,查询由 LLM 模型处理并返回给源客户端,所有过程都在合理的时间范围内完成,并提供公平的服务质量。这是我们系统的最高级别描述,具体来说,是该系统提供的应用功能,因为所有实现细节,如组件之间的通信协议、涉及的数据结构等,都故意被省略了。但现在,我们已经有了明确的目标,可以开始进行逐步的分解,逐渐增加解决问题时涉及的细节,这通常被称为[**功能分解**](https://stackoverflow.com/questions/947874/what-is-functional-decomposition)。因此,从一个黑箱系统[*(抽象)*](https://en.wikipedia.org/wiki/Black_box)开始,它接收并返回查询,我们可以开始全面定义客户端如何与系统互动,以及将实现这种互动的技术。

![](https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/c4a11ecb1f9470fd5e8601f5d500cba8.png)

作者提供的图片

最初,我们必须确定什么构成客户端,特别是用户与系统交互所需的工具或接口。如上所示,我们假设系统目前是一个完全实现并投入运行的功能单元;这使我们能够专注于客户端和客户端-系统的连接。在客户端实例中,接口将通过一个网站提供,该网站设计上具有通用性,但主要面向桌面设备。同时,也可以开发并集成一个移动应用程序,使用相同的系统服务并具有特定的接口,但从抽象角度来看,理想的做法是将所有类型的客户端统一为一个,即 Web 客户端。

随后,需要找到一种方法将客户端与系统连接,以便在它们之间进行信息交换,在这种情况下是查询。此时,值得注意的是,Web 客户端将依赖于特定的技术,如 JavaScript,这带来了所有相应的通信影响。对于其他类型的平台,技术可能会发生变化,例如移动客户端可能会使用 Java,物联网设备可能会使用 C/C++,而兼容性要求可能要求系统相应地进行适配。

![](https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/1ae51d8c94d610afad19d5df59daf004.png)

图片由作者提供

一种建立通信的方式是使用[**套接字**](https://www.geeksforgeeks.org/socket-in-computer-network/)及类似工具,这样可以在更低的层级上提供对整个协议的完全控制。然而,这种选择需要满足上述所述的所有客户端技术的兼容性要求,因为系统需要能够从所有可用的客户端类型收集查询。此外,拥有完全控制意味着开发过程会更长且可能更加复杂,因为必须考虑许多额外的细节,这会显著增加代码行数,并使其可维护性和可扩展性变得更加复杂。

如上所示,最优的替代方案是构建一个[**应用程序编程接口(API)**](https://www.ibm.com/topics/api),它作为客户端和负责计算的系统部分之间的中介,即解决查询的部分。使用 API 的主要优点是,所有内部连接处理,例如打开和关闭套接字、线程池管理以及其他重要细节(*数据序列化*),都由构建 API 的框架来执行。通过这种方式,我们确保客户端只需将查询发送到执行 API 的服务器,并等待响应,所有这些都依赖于简化 API 请求管理的依赖项。另一个来自前一点的好处是,通过修改 API 的[**端点**](https://www.baeldung.com/cs/api-endpoints),可以轻松扩展服务。例如,如果我们想为系统添加一个新模型或其他功能,只需添加并实现一个新端点,而无需更改通信协议本身或客户端与系统的交互方式。

## 计算服务

一旦我们设置了一个优雅的机制让客户端与系统进行通信,我们必须解决如何处理传入查询并在合理的时间内将其返回给相应客户端的问题。但首先,需要指出的是,当查询到达系统时,它必须被重定向到一台机器,该机器内存中加载了 LLM(大语言模型)及其相应的推理管道,并通过该管道处理查询,获取结果文本(*LLM 回答*),然后将其返回。因此,推理过程无法分布在多台机器上进行查询解决。考虑到这一点,我们可以开始设计支撑推理过程的基础设施。

在前面的图像中,计算服务被表示为一个单一的单元。如果我们将其视为通过一个单一通道连接的机器,并使用套接字与 API 服务器进行通信,我们将能够将所有 API 查询重定向到该机器,将所有系统负载集中在一个地方。如你所想,这将是一个适合仅供少数人使用的家庭系统的不错选择。然而,在这种情况下,我们需要一种方法来使这种方法具备可扩展性,以便在增加计算资源的情况下,我们可以为更多的用户提供服务。但首先,我们必须将前述的计算资源细分为多个单元。通过这种方式,我们将能够全面了解它们的相互连接,并能够通过改变它们的结构或组成方式来优化我们的项目吞吐量。

一个计算单元,为了方便实现,我们将其称为节点,将由一台物理机器集成,该机器接收需要解决的请求(*并非所有请求*)。此外,我们可以将节点视为一组(*可能较少*)机器的虚拟化,目的是通过在本地引入并行性来增加每个节点的总吞吐量。关于使用的硬件,这将很大程度上取决于服务的方向以及我们希望达到的程度。然而,对于本案例中呈现的版本,我们将假设使用标准的 CPU、大量的 RAM 以避免加载模型或转发查询时出现问题,以及专用的处理器,如 GPU,并在某些特定情况下可能包括 TPU。

![](https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/438dae7ba400296bda5f07129e1e5d9b.png)

图片由作者提供

现在,我们可以建立一个网络,将多个节点连接起来,通过其中一个节点(连接到 API 服务器)可以将查询分发到整个网络中,充分利用系统的所有资源。上面,我们可以看到所有节点在结构上以树状形态连接,其根节点负责收集 API 查询并按需转发。如何将节点互联的决策在很大程度上取决于系统的具体目的。在本例中,选择了树结构,因其分配原语简单。例如,如果我们希望最大化 API 与节点之间传输的查询数量,则必须从 API 到多个树的根节点建立多个连接,或者如果需要,选择其他不同的数据结构。

最后,我们需要定义当查询到达根节点时,查询是如何被转发和处理的。如前所述,有许多可用的且同样有效的替代方案。然而,我们将遵循的算法也将有助于理解为什么选择树状结构来连接系统节点。

![](https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/56ace988d96c7a69a2176147578c375b.png)

图片由作者提供

由于查询必须在单个节点上解决,因此分配算法的目标是找到一个空闲节点,并将输入查询分配给该节点以进行解决。如上所示,如果我们考虑按自然顺序(*从 1 开始索引*)编号的有序查询序列,每个编号对应于与被分配去解决该查询的节点相连的边。为了理解这个具体例子中的编号,我们可以假设到达节点的查询需要无限长的时间来解决,因此确保每个节点逐步变得忙碌有助于理解该算法的启发式方法。

简而言之,我们将让根节点不执行任何解析处理,保留其所有能力用于转发与 API 的请求。对于任何其他节点,当它接收到来自层级更高节点的查询时,第一步是检查它是否正在处理之前查询的计算;如果它处于空闲状态,它将解析该查询,否则,它将通过[**轮询调度**](https://en.wikipedia.org/wiki/Round-robin_scheduling)将查询转发给其一个子节点。通过轮询调度,每次查询都会被重定向到不同的子节点,遍历整个子节点列表,仿佛它是一个循环缓冲区。这意味着一个节点的本地负载可以均匀分配到下游,同时高效利用每个节点的资源,并且通过添加更多子节点来实现系统的扩展。

最后,如果系统当前服务于许多用户,并且一个查询到达一个也忙碌的叶节点时,它将没有任何子节点可以将其重定向。因此,所有节点都将有一个查询排队机制,在这种情况下它们会等待,并能够在排队的查询之间应用批处理操作以加速 LLM 推理。此外,当一个查询完成后,为了避免通过向上传递查询直到到达树顶而导致系统过载,它会直接发送到根节点,随后到达 API 和客户端。我们可以将所有节点连接到 API,或实现其他替代方案,但为了保持代码尽可能简单,且系统高效,它们都会发送到根节点。

# Web 客户端

在定义了完整的系统架构和如何执行任务之后,我们可以开始构建用户在与我们的解决方案交互时需要的 Web 客户端。

正如预期的那样,Web 客户端是用基本的 HTML、CSS 和 JavaScript 实现的,所有内容都嵌入在一个单独的 .html 文件中,以便于使用。每次客户端发起与应用启动相关的请求时,API 将提供此文件,也就是说,当客户端进入浏览器并输入 API 托管入口点的地址时,它将返回 .html 文件以便在浏览器中渲染。

随后,当用户希望向系统发送文本查询时,JavaScript 会在内部向 API 提交一个 HTTP 请求,包含相应的细节,例如数据类型、端点或[**CSRF**](https://en.wikipedia.org/wiki/Cross-site_request_forgery)安全令牌。通过在此过程中使用[**AJAX**](https://www.w3schools.com/js/js_ajax_intro.asp),可以非常简单地定义一个原语,该原语在 API 返回请求的值时执行,负责将结果显示在屏幕上。此外,值得一提的是,发送的消息并非直接是书面或返回的文本,而是被封装在一个[**JSON**](https://en.wikipedia.org/wiki/JSON)中,包含其他重要参数,如时间戳,提供了在运行时添加额外字段的可能性,以管理某些系统组件的同步。

# Django API

当网页客户端准备就绪时,我们可以开始实现提供必要服务的 API。

有许多可用的技术来构建 API,但在本项目中,我们将专门使用[**Django**](https://developer.mozilla.org/en-US/docs/Learn/Server-side/Django/Introduction)通过 Python 在专用服务器上实现。做出这个决定的原因是该框架提供了较高的可扩展性和与其他 Python 依赖项的集成便利性,除此之外,还具有安全性和默认管理面板等有用的属性。

![](https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/622d9f5b9d5667ffb824103b9612709a.png)

作者提供的图片

需要配置的端点之一是网页客户端的入口点,表示为默认的 URL 斜杠/。因此,当用户通过上述示例中的默认 HTTP 请求访问服务器时,API 将返回显示界面所需的 HTML 代码,并开始向 LLM 服务发出请求。

![](https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/58ad783edf8b7866cec09b9f4a188446.png)

作者提供的图片

同时,一旦访问了接口,它还必须支持客户端的请求。这些请求由于需要特殊管理,将有一个名为“/arranca”的端点,查询数据将以相应的 JSON 格式发送到该端点,API 在处理完查询并通过节点树解决后,返回已解决的查询。在此端点,服务器通过一个预先建立的 Socket 通道与层级结构中的根节点转发查询,并通过同步机制等待其响应。

关于代码,在**urls.py**文件中,我们将存储 URL 和端点之间的关联,以便将默认的空 URL 分配给其对应的函数,该函数读取模板文件夹中的.html 文件并将其返回,或者将 URL /arranca 分配给执行查询解决函数的端点。此外,还将执行一个视图函数来启动主服务器线程。同时,在**settings.py**中,唯一需要修改的就是将 DEBUG 参数设置为 False,并输入允许连接到服务器的主机所需的权限。

最后是**views.py**脚本,在其中实现了所有的 API 功能。首先,我们有一个主线程负责接收和处理来自根节点的传入连接。最初,这个连接将在整个系统生命周期内保持常驻。然而,它被放置在一个无限循环中,以防连接被中断并需要重新建立。其次,默认的端点通过**index()**函数实现,如果客户端发起 GET 请求,它将返回.html 内容。此外,用户在应用程序中提交的查询会通过***/arranca***端点传递给 API,该端点由同名函数实现。在这里,输入的查询被转发到根节点,直到从根节点收到响应并返回给客户端时,才会解除阻塞。

这种阻塞是通过[**锁**](https://docs.python.org/3/library/threading.html#condition-objects)和同步机制实现的,每个查询都有一个唯一标识符,该标识符由***arranca()***函数作为字段插入到 JSON 消息中,命名为**request_id**。本质上,这是一个自然数,对应于查询到达的顺序。因此,当根节点将已解决的查询发送到 API 时,可以知道是哪个被阻塞的执行生成了该查询,从而解除阻塞,返回并重新阻塞其余查询。

# Java 计算节点

在 API 运行后,我们将继续在 Java 中实现节点系统。选择这种语言的主要原因是它提供的技术使我们能够在节点之间进行通信。为了在这一层面上获得尽可能简单的通信语义,我们将放弃使用套接字和手动序列化消息,并用[**RMI**](https://www.javatpoint.com/RMI)替代,尽管在其他平台上这可能会稍显复杂,尽管它们也提供了像[**Pyro4**](https://pyro4.readthedocs.io/en/stable/)这样的 Python 解决方案。

[**远程方法调用 (RMI)**](https://en.wikipedia.org/wiki/Java_remote_method_invocation)是一种通信范式,使得由托管在不同机器上的远程对象组成的分布式系统的创建成为可能,能够相互获取远程引用并在它们的服务接口中调用远程方法。因此,由于 Java 中的高度抽象,节点之间的查询传输将通过对发送节点引用的远程对象的调用来实现,复杂的 API 连接过程将由手动处理,如同之前在 Python 中做的一样。

起初,我们应定义远程接口,它决定了每个节点可调用的远程方法。一方面,我们有返回调试信息相关方法***(log() 或 getIP())***。另一方面,有一些方法负责获取对其他节点的远程引用,并将其注册到本地层次结构中,作为一个升序或降序节点,使用一个我们假设对每个节点都是唯一的名称。此外,它还有另外两个原语,用于接收来自其他节点的传入查询***(receiveMessage())***和向 API 发送已解决的查询***(sendMessagePython())***,这些方法仅在根节点中执行。

在接口中,我们可以在节点类中实现其操作,每当我们启动系统并决定向节点树中添加新机器时,就会实例化该类。节点类中包含的主要功能之一是**getRemoteNode()**方法,它通过节点名称获取对另一个节点的远程引用。为此,它访问名称注册表并执行`lookup()`原语,如果该节点已注册,则返回以接口形式呈现的远程引用,否则返回 null。

获取远程引用在树的构建中至关重要,特别是对于其他方法,这些方法将父节点连接到后代节点或获取根节点的引用以发送已解决的查询。其一是`connectParent()`,当一个后代节点需要与父节点连接时会调用它。如你所见,它首先使用`getRemoteNode()`来获取父节点,一旦获得引用,就将其分配给每个节点实例的本地变量。然后它会调用**connectChild()**,该方法将从中调用的远程节点附加到后代节点列表中。如果父节点不存在,它会尝试在一个空对象上调用函数,从而抛出异常。接下来需要注意的是,用于接收来自 API 的查询**receiveMessagePython()**和来自其他节点的查询**receiveMessage()**的方法都通过`synchronized`关键字进行保护,以避免可能干扰系统正确运行的竞争条件。这些方法还负责实现查询分发启发式算法,使用本地变量来确定应该将传入的查询发送到哪个对应的节点。

最后,节点类有一个线程池,用于管理**consultLLM()**方法中的查询解析。通过这种方式,它的调用将在 Java 代码中立即结束,因为线程池将为所需的计算分配一个线程,并将控制权返回给程序,从而可以接受更多查询。这对于检测节点是否正在进行任何计算也有优势,因为只需要检查活动线程的数量是否大于 0。另一方面,节点类中线程的另一个使用,位于线程池之外,是在**connectServer()**方法中,用于将根节点与用于查询交换的 API 连接。

在**Utilities**类中,我们只提供了创建 LDAP 使用上下文的方法,利用该方法可以根据节点的名称注册和查找远程引用。这个方法本可以直接放在节点类中,但如果我们需要更多类似的方法,出于设计模式的考虑,我们将其保留在 Utilities 类中。

节点实例的创建以及每个节点的手动管理是在 Launcher 类中实现的。它使用命令行接口来指示相应的节点,节点在启动时创建,并在指定的 LDAP 服务器上注册特定名称。一些命令包括:

+   **日志:** 打印有用的信息以了解节点的状态。

+   **父节点:** 将节点连接到指定的父节点,依据其名称进行连接。

+   **注册表:** 列出了当前在 LDAP 目录下的所有节点,这些节点位于组织单位**ou=Nodes**下。这对于监控注册表服务器或创建新节点可能很有用。

+   **服务器:** 将节点连接到通过其地址和端口号指定的服务器。主要情况下,服务器将是 Python API,但它也可以提供其他功能。

# LDAP 服务器

由于节点是远程对象,它们必须能够访问注册表,以便从其名称获取其他节点的远程引用。Java 提供的解决方案是使用**rmiregistry**在一台机器上初始化注册表服务。然而,当从其他主机执行如 rebind() 等受保护操作时,会抛出安全异常,阻止新节点在除注册表所在机器以外的其他机器上注册。因此,除了其简便性外,本项目将使用 Apache 服务器作为注册表,采用[**轻量级目录访问协议(LDAP)**](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/lightweight-directory-access-protocol-ldap-api)。该协议允许在目录系统中管理*名称->远程节点*对,并具有其他附加功能,显著提升了与 Java 注册表提供的服务相比的注册表服务。

使用 LDAP 的优势始于其操作的复杂性,乍一看这可能显得相反,但实际上,这正是使系统能够在更高的细节级别上适应各种安全性和配置需求的关键。一方面,它所提供的认证和安全功能允许任何主机执行受保护的操作,如注册新节点,只要该主机已通过 LDAP 服务器验证。例如,当创建一个上下文对象以访问服务器并能够执行操作时,可以选择向其构造函数的 HashMap 中添加包含认证数据的参数。如果上下文已创建,则意味着数据与服务器期望的匹配,否则可以认为连接是由未经认证的*(“恶意”)*主机发起的,从而确保只有系统节点才能操作服务器信息。另一方面,LDAP 允许更高效的节点注册集中化,提供更高级的互操作性,并且可以轻松集成像[**Kerberos**](https://www.ibm.com/docs/en/aix/7.3?topic=network-kerberos)这样的额外服务。

为确保服务器能够作为节点注册中心运行,我们必须对其应用特定配置。首先,由于该项目不会部署在有真实(且可能是恶意)用户的环境中,因此所有认证选项都被省略,以保持简单和干净。接下来,必须定义一个[**区分名称**](https://www.ibm.com/docs/en/i/7.5?topic=eim-distinguished-name),以便将节点名称与其对应的远程对象关联。在这种情况下,假设我们防止注册多个具有相同名称的节点,我们只需将节点名称存储在某个属性中,如 cn=(常用名称),并放置在指定的组织单元 ou=Nodes 中。因此,区分名称将是:**cn=Node_Name,ou=Nodes**

![](https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/bb0053eddbdfba4f5fd216e06c3bdb0f.png)

图片来源:作者

每当创建一个新节点时,它会使用其区分名和节点实例,在 LDAP 服务器中作为目录形式的新条目进行注册。同样,删除节点或从注册表中获取其远程引用也需要使用区分名。对注册表执行这些操作意味着必须保持与 LDAP 服务器的连接。但是,由于节点是用 Java 编写的,我们可以使用一些服务来抽象整个连接过程,专注于调用操作。节点将使用的服务是一个目录上下文,通常由[**DirContext**](https://docs.oracle.com/javase/8/docs/api/javax/naming/directory/DirContext.html)接口定义。因此,访问服务器并执行一些管理操作的过程就像创建一个实现了 DirContext 接口的对象一样简单,在这个例子中是 InitialDirContext,并为其分配适当的参数以识别服务器,其中包括一个**ldap://IP:port/**形式的 URL、要使用的协议标识符,甚至是认证参数,在本项目中这些认证参数不会被使用。

## 查找、绑定和解绑

为了简单起见,Launcher 将拥有自己的上下文对象,而每个节点也将拥有自己的上下文对象。这使得 Launcher 可以创建条目并执行删除操作,而每个节点则能够执行查找操作,从节点名称中获取远程引用。删除操作是最简单的,因为它只需要对应于要删除的节点的服务器条目的区分名。如果存在,它将被删除,并且对**unbind()**的调用将成功结束,否则它会抛出一个异常。另一方面,查找和注册操作需要遵循[**RFC-2713**](https://www.rfc-editor.org/rfc/rfc2713.html)。在将节点添加到服务器的情况下,将使用**bind()**原语,其参数是该节点将托管的条目的区分名,以及其远程对象。然而,bind 函数不会直接传递节点对象或其接口,因为对象不可序列化,而 bind()不能直接获取接口*“实例”*。作为解决方法,上述 RFC 强制将节点实例通过[**MarshalledObject**](https://docs.oracle.com/javase/8/docs/api/java/rmi/MarshalledObject.html)进行封装。因此,bind 将接收一个由正在注册到服务器的节点组成的 MarshalledObject,而不是原始节点实例。

最后,查找操作通过**lookup()**原语在上下文中执行。如果名称和节点没有被预先注册,或者在过程中发生了意外错误,将抛出异常。相反,如果操作成功,它将返回与查询的独特名称关联的 MarshalledObject。但是,lookup()返回的远程引用包含在存储在注册表中的 MarshalledObject 包装器内。因此,必须使用 MarshalledObject 的**get()**操作来获取可用的远程引用。此外,借助此功能,可以防止注册与已注册节点同名的节点,因为在执行 bind()之前会通过 lookup()检查是否存在与该独特名称相关的异常。

# LLM 推理

关于每个节点的推理过程,节点树中有一个 LLMProcess 类,负责实例化一个用 Python 实现的进程,查询将在被解决之前传递到该进程,因为在 Python 中我们可以轻松管理 LLM 及其推理管道。

当实例化一个新的 LLMProcess 时,需要在机器上找到一个可用端口,以便 Java 和 Python 进程之间进行通信。为了简化,该数据交换将通过套接字(Sockets)完成,因此在通过打开和关闭 ServerSocket 找到可用端口后,**llm.py**进程将使用端口号作为参数启动。其主要功能包括**destroyProcess()**,在系统停止时终止进程,以及**sendQuery()**,它向 llm.py 发送查询并等待响应,每个查询使用一个新的连接。

在 llm.py 内部,有一个循环不断等待接受来自 Java 进程的传入连接。当建立此类连接时,它将通过[**ThreadPoolExecutor()**](https://docs.python.org/3/library/concurrent.futures.html#threadpoolexecutor)线程并通过**handle_connection()**函数进行处理,该函数从通道读取输入数据,按 JSON 格式解释并将“text”字段转发到推理管道。一旦数据返回,它会被发送回 Java 进程*(连接的另一端)*,并且函数返回,同时释放相应的线程。

## 模型性能

如脚本中所示,管道实例允许我们选择将在托管节点上执行的 LLM 模型。这使我们可以访问上传到[**Huggingface**](https://huggingface.co/models?pipeline_tag=text-generation&sort=trending)网站上的所有模型,包括代码生成模型、聊天模型、通用响应生成模型等各种选择。

默认情况下,我们使用**gpt2**模型,它大约有[**117M 参数**](https://huggingface.co/transformers/v2.2.0/pretrained_models.html),并且约有 500MB 的权重,是最轻便且最易于集成的选择。由于它是一个小型模型,它的回答相对基础,注意到一个查询的解答与以下文本的预测非常接近输入文本,例如:

> User: 你好。
> 
> GPT: 你好,首先我想提到的是……

还有其他版本的 gpt2,例如**gpt2-large**或**gpt2-xl**,它们都可以从 Huggingface 获取,其中最强大的是 XL,具有 15 亿个参数和 6GB 的权重,需要显著更强的硬件来运行它,能够生成像下面这样的连贯回答:

> User: 你好。
> 
> GPT: 大家好——感谢大家这几个月以来的耐心等待!在过去的一年里,我已经整理出了……

除了 OpenAI 的 GPT 系列外,你还可以选择许多其他可用的模型,尽管其中大多数需要在脚本中插入[**认证令牌**](https://huggingface.co/docs/hub/en/security-tokens)。例如,最近发布了经过优化的现代模型,优化了占用空间和查询通过整个推理流程所需的时间。Llama3 就是其中之一,有[**8B 参数**](https://huggingface.co/nvidia/Llama3-ChatQA-1.5-8B)的小型版本,以及[**70B**](https://huggingface.co/nvidia/Llama3-ChatQA-1.5-70B)的大型版本。

然而,选择一个系统模型不应仅仅基于它的参数数量,因为它的架构决定了它可以建模的知识量。由于这个原因,一些小型模型的表现与大规模模型非常相似,即它们在语言理解层面上生成的回答非常相似,同时优化了生成这些回答所需的计算资源。作为参考,你可以使用[**基准测试**](https://huggingface.co/collections/open-llm-leaderboard/the-big-benchmarks-collection-64faca6335a7fc7d4ffe974a),这个基准测试也由 Huggingface 提供,或者使用[**专业测试**](https://github.com/leobeeson/llm_benchmarks)来衡量任何 LLM 的上述参数。

上述测试中的结果,以及在特定硬件上响应所需的平均时间,是选择模型的一个相当完整的指标。尽管如此,始终记住,LLM 必须适应它运行的芯片内存。因此,如果我们使用 GPU 推理,如在 llm.py 脚本中使用 CUDA,则图形内存必须大于模型大小。如果不够,你必须将计算分配到[**多个 GPU**](https://huggingface.co/docs/diffusers/training/distributed_inference),无论是在同一台机器上,还是在多台机器上,这取决于你想要达到的复杂度。

## Kotlin 移动客户端

在我们完成之前,可以看看如何将一种新的客户端类型包含到系统中,从而展示我们迄今为止构建的一切所提供的可扩展性。当然,这个项目是一个[**分布式系统**](https://www.geeksforgeeks.org/what-is-a-distributed-system/)的尝试,因此你可以期待它与移动设备兼容,就像常规的 ChatGPT 应用程序兼容 Android 和 iOS 一样。在我们的案例中,我们可以为原生 Android 开发一个应用程序,尽管一个更好的选择是将系统适配为多平台 Jetpack Compose 项目。这个选项仍然是未来更新的一种可能性。

最初的想法是将移动客户端连接到 API,并使用与 Web 版本相同的请求,依赖项包括[**HttpURLConnection**](https://developer.android.com/reference/kotlin/java/net/HttpURLConnection)。代码实现并不困难,Android 官方页面提供的文档对于此目的也很有帮助。然而,我们也可以使用自定义的 Kotlin 中间组件来模拟 API 的功能,使用普通的 TCP Android 套接字进行通信。套接字相对容易使用,需要一点管理工作,确保一切正常运行,并提供对代码的良好控制。为了弥补缺乏规范 API 的问题,我们可以在移动客户端和 Java 节点树之间放置一个 Kotlin 节点,它将管理根节点和只有移动客户端之间的连接,前提是 Web 客户端和 API 是分开的。

关于界面,我们所模仿的应用程序 ChatGPT,具有非常简洁和现代的外观。由于 HTTP 版本已经完成,我们可以尽量在 Android Studio 编辑器中尽可能接近地复制它。

![](https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/330bb76614059e14df78a0e575d37464.png)

作者提供的图片

在使用套接字时,我们必须确保用户连接到正确的 IP 地址和端口,该服务器将解决他的查询。我们可以通过每次打开应用程序时出现的新初始界面来实现这一点。它是一个简单的视图,包含一个按钮,一个用于输入 IP 地址的文本视图,以及一个小的文本标签,实时向用户提供发生的情况,正如你在上面所看到的那样。

然后,我们需要使界面像一个真实的聊天,新的消息出现在底部,旧的消息则向上移动。为此,我们可以插入一个 RecyclerView,它将占据屏幕的约 80%。计划是拥有一个预定义的消息视图,可以动态添加到视图中,并且会根据消息是来自用户还是系统来改变。

最后,Android 连接的问题在于,你不能在主线程中执行任何与网络相关的操作,否则会抛出[**NetworkOnMainThreadException**](https://developer.android.com/reference/android/os/NetworkOnMainThreadException)。但同时,如果不在主线程中,你也无法管理组件,因为这会抛出[**CalledFromWrongThreadException**](https://stackoverflow.com/questions/44483224/how-can-i-fix-this-calledfromwrongthreadexception)。我们可以通过将连接视图移动到主线程中来解决这个问题,最重要的是充分利用协程,使你能够从中执行网络相关任务。

![](https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/7ce7122e49603c9bd56764e9a97a5fc8.png)

作者提供的图片

现在,如果你运行系统并输入一个文本查询,答案应该会在几秒钟内显示出来,就像在 ChatGPT 等大型应用程序中一样。

# 结论

尽管系统已经具备功能,但你可以根据所用技术(无论是软件还是硬件)做出显著改进。然而,它仍然能够为有限数量的用户提供体面的服务,具体范围大致取决于可用资源。最后,需要指出的是,像 ChatGPT 这样的真实系统的性能是非常复杂的,因为支持它所需的模型大小和硬件特别昂贵。本文所展示的系统对于小型甚至中型解决方案具有高度可扩展性,但要实现大规模解决方案则需要更复杂的技术,可能还需要利用该系统的一些架构。

# 致谢

感谢[**deivih84**](https://medium.com/@forexsencillo)在**Kotlin** 移动客户端部分的合作,感谢[**carolinaherasc**](https://medium.com/@carolinaherasc)在 RMI 和分布式系统实现方面的帮助,以及[**hugodiezrubio**](https://medium.com/@hugodiezrubio)在开发部分系统管理组件中的贡献。


# 如何创建合成数据

> 原文:[`towardsdatascience.com/build-your-own-synthetic-data-15d91389a37b?source=collection_archive---------4-----------------------#2024-02-07`](https://towardsdatascience.com/build-your-own-synthetic-data-15d91389a37b?source=collection_archive---------4-----------------------#2024-02-07)

## 从零开始使用 Python 创建完整的数据框

[](https://medium.com/@kurt.klingensmith?source=post_page---byline--15d91389a37b--------------------------------)![Kurt Klingensmith](https://medium.com/@kurt.klingensmith?source=post_page---byline--15d91389a37b--------------------------------)[](https://towardsdatascience.com/?source=post_page---byline--15d91389a37b--------------------------------)![Towards Data Science](https://towardsdatascience.com/?source=post_page---byline--15d91389a37b--------------------------------) [Kurt Klingensmith](https://medium.com/@kurt.klingensmith?source=post_page---byline--15d91389a37b--------------------------------)

·发表于[Towards Data Science](https://towardsdatascience.com/?source=post_page---byline--15d91389a37b--------------------------------) ·阅读时间:9 分钟·2024 年 2 月 7 日

--

![](https://github.com/OpenDocCN/towardsdatascience-blog-zh-2024/raw/master/docs/img/0cdc29d0203d7225c4541733960f9b92.png)

图片来自[Joshua Sortino](https://unsplash.com/@sortino?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash)在[Unsplash](https://unsplash.com/photos/worms-eye-view-photography-of-ceiling-LqKhnDzSF-8?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash)上的作品。

在向《Towards Data Science》编辑团队提交最近的一篇文章后,我收到了一条简单的询问信息:这些数据集是否可以用于商业用途?这是一个很好的问题——我草稿中的数据集来自[Seaborn](https://seaborn.pydata.org),这是一个常见的 Python 库,包含了 17 个示例数据集[1]。这些数据集看起来确实是开源的,而且,果然,许多数据集都可以轻松找到授权商业用途的许可证。遗憾的是,我恰好选择了其中一个没有找到许可证的数据集。但我决定不更换为另一个 Seaborn 数据集,而是自己制作合成数据。

## **什么是合成数据?**

IBM 的 Kim Martineau 将合成数据定义为“在计算机上生成的信息,用于增强或替代真实数据,以改进 AI 模型、保护敏感数据并减少偏见”[2]。

合成数据可能*看起来*像是来自现实事件的信息,**但它并不是**。这样可以避免许可问题,隐藏专有数据,并保护个人信息。

合成数据不同于匿名化或掩码数据,后者通过改变某些字段,将来自实际事件的真实数据转变为无法追溯的数据。如果你在寻找……


# 使用代理和工具构建你的个人助手

> 原文:[`towardsdatascience.com/build-your-personal-assistant-with-agents-and-tools-048637ac308e?source=collection_archive---------0-----------------------#2024-11-24`](https://towardsdatascience.com/build-your-personal-assistant-with-agents-and-tools-048637ac308e?source=collection_archive---------0-----------------------#2024-11-24)

## 单独的 LLM 无法访问外部或实时数据。了解如何通过将 LLM 与外部资源结合,使用 LangChain 代理和 Gemini 构建个人助手。

[](https://medium.com/@benjamin_47408?source=post_page---byline--048637ac308e--------------------------------)![Benjamin Etienne](https://medium.com/@benjamin_47408?source=post_page---byline--048637ac308e--------------------------------)[](https://towardsdatascience.com/?source=post_page---byline--048637ac308e--------------------------------)![Towards Data Science](https://towardsdatascience.com/?source=post_page---byline--048637ac308e--------------------------------) [Benjamin Etienne](https://medium.com/@benjamin_47408?source=post_page---byline--048637ac308e--------------------------------)

·发布于[Towards Data Science](https://towardsdatascience.com/?source=post_page---byline--048637ac308e--------------------------------)·14 分钟阅读·2024 年 11 月 24 日

--

## 总结:

1.  LLM 的问题

1.  代理、工具和链是什么?

1.  创建一个没有工具的简单聊天

1.  向我们的聊天添加工具:谷歌式的函数调用方式

1.  向我们的聊天添加工具:Langchain 方式与代理

1.  向我们的代理添加记忆

1.  创建一个包含人工验证步骤的链条

1.  使用搜索工具

## 1. LLM 的问题

所以你有了你最喜欢的聊天机器人,你用它来提升工作效率。它可以翻译文本、写漂亮的邮件、讲笑话等。然后有一天,你的同事走到你面前,问道:

*“你知道美元和欧元的当前汇率吗?我在想是否应该卖掉我的欧元……”*

你向你最喜欢的聊天机器人提问,答案马上就出现:

```py
I am sorry, I cannot fulfill this request. 
I do not have access to real-time information, including financial data 
like exchange rates.

这里的问题是什么?

问题在于,你已经碰到了 LLM 的一个短板。大型语言模型(LLM)在解决许多类型的问题时非常强大,例如问题解决、文本摘要、生成等。

然而,它们受到以下限制的约束:

  • 它们在训练后被“冻结”,导致知识过时。

  • 它们无法查询或修改外部数据。

就像我们每天使用搜索引擎、阅读书籍和文档或查询数据库一样,我们理想中希望将这些知识提供给我们的 LLM,以提高其效率。

幸运的是,有一种方法可以做到:工具和代理。

尽管基础模型在文本和图像生成方面令人印象深刻,但它们仍然受到无法与外部世界交互的限制。工具弥补了这一鸿沟,使智能体能够与外部数据和服务交互,同时解锁比单独的基础模型更多的操作。

(来源:谷歌智能体白皮书)

使用智能体和工具,我们可以通过聊天界面:

  • 从我们自己的文档中检索数据

  • 阅读/发送电子邮件

  • 与内部数据库进行交互

  • 执行实时谷歌搜索

  • 等等。

2. 什么是智能体、工具和链条?

智能体是一个应用程序,它试图通过拥有一组工具并根据对环境的观察做出决策来实现一个目标(或任务)。

一个好的智能体例子可以是你自己:如果你需要计算一个复杂的数学操作(目标),你可以使用计算器(工具#1)或编程语言(工具#2)。也许你会选择计算器来进行简单的加法,但对于更复杂的算法,你可能会选择工具#2。

因此,智能体由以下部分构成:

  • 模型:我们智能体的大脑是大语言模型(LLM)。它将理解查询(目标),并浏览其可用工具以选择最佳工具。

  • 一个或多个工具:这些是函数或 API,负责执行特定的操作(例如:检索美元与欧元的当前汇率、加法等)。

  • 一个协调过程:这就是模型在被要求解决任务时的行为方式。它是一个认知过程,定义了模型如何分析问题、完善输入、选择工具等。此类过程的示例有 ReAct、CoT(思维链)、ToT(思维树)

下面是一个工作流说明

图片由作者提供

链条有些不同。尽管智能体可以“自行决定”做什么以及采取哪些步骤,但链条仅是预定义步骤的序列。它们仍然可以依赖工具,这意味着它们可以包括一个需要从可用工具中选择的步骤。稍后我们将详细讨论。

3. 创建一个没有工具的简单聊天

为了说明我们的观点,我们将首先看到没有任何帮助的情况下,我们的 LLM 如何表现。

让我们安装所需的库:

vertexai==1.65.0
langchain==0.2.16
langchain-community==0.2.16
langchain-core==0.2.38
langchain-google-community==1.0.8
langchain-google-vertexai==1.0.6

并使用谷歌的 Gemini 大语言模型创建我们非常简单的聊天:

from vertexai.generative_models import (
    GenerativeModel,
    GenerationConfig,
    Part
)

gemini_model = GenerativeModel(
    "gemini-1.5-flash",
    generation_config=GenerationConfig(temperature=0),
)
chat = gemini_model.start_chat()

如果你运行这个简单的聊天并询问当前的汇率问题,你可能会得到类似的答案:

response = chat.send_message("What is the current exchange rate for USD vs EUR ?")
answer = response.candidates[0].content.parts[0].text

--- OUTPUT ---
"I am sorry, I cannot fulfill this request. I do not have access to real-time information, including financial data like exchange rates." 

并不奇怪,因为我们知道大语言模型(LLMs)无法访问实时数据。

让我们为此添加一个工具。我们的工具将是一个调用 API 以实时检索汇率数据的小功能。

def get_exchange_rate_from_api(params):
    url = f"https://api.frankfurter.app/latest?from={params['currency_from']}&to={params['currency_to']}"
    print(url)
    api_response = requests.get(url)
    return api_response.text

# Try it out !
get_exchange_rate_from_api({'currency_from': 'USD', 'currency_to': 'EUR'})
---
'{"amount":1.0,"base":"USD","date":"2024-11-20","rates":{"EUR":0.94679}}'

现在我们知道了工具的工作原理,我们希望告诉我们的聊天 LLM 使用这个功能来回答问题。因此,我们将创建一个单工具智能体。为此,我们有几个选项,我将在此列出:

  • 使用谷歌的 Gemini 聊天 API 与功能调用

  • 使用 LangChain 的 API 与智能体和工具

两者各有优缺点。本文的目的是展示这些可能性,让您决定更喜欢哪一种方式。

4. 向我们的聊天添加工具:使用 Google 的函数调用方式

创建工具的基本方法有两种。

第一个是“字典”方法,在这种方法中,您指定函数的输入和描述。重要的参数是:

  • 函数的名称(请明确)

  • 描述:在这里要详细说明,因为一个扎实且详尽的描述将帮助 LLM 选择正确的工具

  • 参数:这是您指定参数的位置(类型和描述)。同样,请在描述参数时进行详细说明,帮助 LLM 了解如何将值传递给您的函数

import requests

from vertexai.generative_models import FunctionDeclaration

get_exchange_rate_func = FunctionDeclaration(
    name="get_exchange_rate",
    description="Get the exchange rate for currencies between countries",
    parameters={
    "type": "object",
    "properties": {
        "currency_from": {
            "type": "string",
            "description": "The currency to convert from in ISO 4217 format"
        },
        "currency_to": {
            "type": "string",
            "description": "The currency to convert to in ISO 4217 format"
        }
    },
        "required": [
            "currency_from",
            "currency_to",
      ]
  },
)

使用 Google SDK 添加工具的第二种方式是通过from_func实例化。这需要我们修改原始函数,使其更加明确,并添加文档字符串等内容。与工具创建时冗长不同,我们是在函数创建时进行详细描述。

# Edit our function
def get_exchange_rate_from_api(currency_from: str, currency_to: str):
    """
    Get the exchange rate for currencies   

    Args:
        currency_from (str): The currency to convert from in ISO 4217 format
        currency_to (str): The currency to convert to in ISO 4217 format
    """
    url = f"https://api.frankfurter.app/latest?from={currency_from}&to={currency_to}"
    api_response = requests.get(url)
    return api_response.text

# Create the tool
get_exchange_rate_func = FunctionDeclaration.from_func(
  get_exchange_rate_from_api
)

下一步实际上是创建工具。为此,我们将把我们的 FunctionDeclaration 添加到列表中,以创建我们的 Tool 对象:

from vertexai.generative_models import Tool as VertexTool

tool = VertexTool(
    function_declarations=[
        get_exchange_rate_func,
        # add more functions here !
    ]
)

现在让我们将其传递给我们的聊天,看看它是否能够回答我们关于汇率的查询!记住,如果没有工具,我们的聊天回答是:

让我们尝试 Google 的函数调用工具,看看这是否有帮助!首先,让我们将查询发送给聊天:

from vertexai.generative_models import GenerativeModel

gemini_model = GenerativeModel(
    "gemini-1.5-flash",
    generation_config=GenerationConfig(temperature=0),
    tools=[tool] #We add the tool here !
)
chat = gemini_model.start_chat()

response = chat.send_message(prompt)

# Extract the function call response
response.candidates[0].content.parts[0].function_call

--- OUTPUT ---
"""
name: "get_exchange_rate"
args {
  fields {
    key: "currency_to"
    value {
      string_value: "EUR"
    }
  }
  fields {
    key: "currency_from"
    value {
      string_value: "USD"
    }
  }
  fields {
    key: "currency_date"
    value {
      string_value: "latest"
    }
  }
}""" 

LLM 正确猜测它需要使用get_exchange_rate函数,并且也正确猜测两个参数是USDEUR

但这还不够。我们现在想要的是实际运行这个函数以获得我们的结果!

# mapping dictionnary to map function names and function
function_handler = {
    "get_exchange_rate": get_exchange_rate_from_api,
}

# Extract the function call name
function_name = function_call.name
print("#### Predicted function name")
print(function_name, "\n")

# Extract the function call parameters
params = {key: value for key, value in function_call.args.items()}
print("#### Predicted function parameters")
print(params, "\n")

function_api_response = function_handlerfunction_name
print("#### API response")
print(function_api_response)
response = chat.send_message(
    Part.from_function_response(
        name=function_name,
        response={"content": function_api_response},
    ),
)   
print("\n#### Final Answer")
print(response.candidates[0].content.parts[0].text)

--- OUTPUT ---
"""
#### Predicted function name
get_exchange_rate 

#### Predicted function parameters
{'currency_from': 'USD', 'currency_date': 'latest', 'currency_to': 'EUR'} 

#### API response
{"amount":1.0,"base":"USD","date":"2024-11-20","rates":{"EUR":0.94679}}

#### Final Answer
The current exchange rate for USD vs EUR is 0.94679\. This means that 1 USD is equal to 0.94679 EUR. 
"""

我们现在可以看到我们的聊天能够回答问题了!它:

  • 正确猜测调用的功能是get_exchange_rate

  • 正确分配了调用函数的参数{'currency_from': 'USD', 'currency_to': 'EUR'}

  • 从 API 获取结果

  • 并且格式化答案,使其易于人类阅读!

现在让我们看看另一种使用 LangChain 的方法。

5. 向我们的聊天添加工具:使用 Langchain 的代理方式

LangChain 是一个可组合的框架,用于与 LLM 一起构建。它是用于可控代理工作流的编排框架。

与我们之前采用的“Google”方式类似,我们将使用 Langchain 的方式构建工具。让我们从定义函数开始。与 Google 一样,我们需要在文档字符串中详尽且冗长地描述:

from langchain_core.tools import tool

@tool
def get_exchange_rate_from_api(currency_from: str, currency_to: str) -> str:
    """
    Return the exchange rate between currencies
    Args:
        currency_from: str
        currency_to: str
    """
    url = f"https://api.frankfurter.app/latest?from={currency_from}&to={currency_to}"
    api_response = requests.get(url)
    return api_response.text

为了让事情更加有趣,我将添加另一个可以列出 BigQuery 数据集中的表的工具。以下是代码:

@tool
def list_tables(project: str, dataset_id: str) -> list:
    """
    Return a list of Bigquery tables
    Args:
        project: GCP project id
        dataset_id: ID of the dataset
    """
    client = bigquery.Client(project=project)
    try:
        response = client.list_tables(dataset_id)
        return [table.table_id for table in response]
    except Exception as e:
        return f"The dataset {params['dataset_id']} is not found in the {params['project']} project, please specify the dataset and project"

一旦完成,我们将我们的函数添加到 LangChain 工具箱中!

langchain_tool = [
    list_tables,
    get_exchange_rate_from_api
]

为了构建我们的代理,我们将使用 LangChain 的AgentExecutor对象。这个对象基本上将使用之前定义的三个组件:

  • LLM

  • 一个提示

  • 以及工具。

让我们首先选择我们的 LLM:

gemini_llm = ChatVertexAI(model="gemini-1.5-flash")

然后我们创建一个提示来管理对话:

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "You are a helpful assistant"),
        ("human", "{input}"),
        # Placeholders fill up a **list** of messages
        ("placeholder", "{agent_scratchpad}"),
    ]
)

最后,我们创建AgentExecutor并运行查询:

agent = create_tool_calling_agent(gemini_llm, langchain_tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=langchain_tools)
agent_executor.invoke({
    "input": "Which tables are available in the thelook_ecommerce dataset ?"
})

--- OUTPUT ---
"""
{'input': 'Which tables are available in the thelook_ecommerce dataset ?',
 'output': 'The dataset `thelook_ecommerce` is not found in the `gcp-project-id` project. 
            Please specify the correct dataset and project. \n'}
""" 

嗯,看起来代理缺少一个参数,或者至少需要更多的信息……我们来回应并提供这个信息:

agent_executor.invoke({"input": f"Project id is bigquery-public-data"})

--- OUPTUT ---
"""
{'input': 'Project id is bigquery-public-data',
 'output': 'OK. What else can I do for you? \n'}
""" 

好像我们又回到了原点。虽然已经告诉了 LLM 项目 ID,但它忘记了问题。我们的代理似乎缺乏记忆,无法记住之前的问题和答案。也许我们应该考虑……

6. 为我们的代理添加记忆

记忆是代理中的另一个概念,它基本上帮助系统记住对话历史,避免像上述情况那样的无休止循环。可以把记忆看作是一个记事本,LLM 用它来跟踪之前的问题和答案,从而构建对话的上下文。

我们将修改我们的提示(指令),让模型包含记忆:

from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory

# Different types of memory can be found in Langchain
memory = InMemoryChatMessageHistory(session_id="foo")

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "You are a helpful assistant."),
        # First put the history
        ("placeholder", "{chat_history}"),
        # Then the new input
        ("human", "{input}"),
        # Finally the scratchpad
        ("placeholder", "{agent_scratchpad}"),
    ]
)

# Remains unchanged
agent = create_tool_calling_agent(gemini_llm, langchain_tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=langchain_tools)

# We add the memory part and the chat history
agent_with_chat_history = RunnableWithMessageHistory(
    agent_executor,
    lambda session_id: memory, #<-- NEW
    input_messages_key="input", 
    history_messages_key="chat_history", #<-- NEW
)

config = {"configurable": {"session_id": "foo"}}

我们现在将从头重新运行我们的查询:

agent_with_chat_history.invoke({
    "input": "Which tables are available in the thelook_ecommerce dataset ?"
    }, 
    config
)

--- OUTPUT ---
"""
{'input': 'Which tables are available in the thelook_ecommerce dataset ?',
 'chat_history': [],
 'output': 'The dataset `thelook_ecommerce` is not found in the `gcp-project-id` project. Please specify the correct dataset and project. \n'}
"""

在没有聊天历史的情况下,模型仍然要求提供项目 ID。这与之前没有记忆的代理行为一致。我们来回应代理并补充缺失的信息:

reply = "Project id is bigquery-public-data"
agent_with_chat_history.invoke({"input": reply}, config)

--- OUTPUT ---
"""
{'input': 'Project id is bigquery-public-data',
 'chat_history': [HumanMessage(content='Which tables are available in the thelook_ecommerce dataset ?'),
  AIMessage(content='The dataset `thelook_ecommerce` is not found in the `gcp-project-id` project. Please specify the correct dataset and project. \n')],
 'output': 'The following tables are available in the `thelook_ecommerce` dataset:\n- distribution_centers\n- events\n- inventory_items\n- order_items\n- orders\n- products\n- users \n'}
"""

请注意,在输出中:

  • 聊天历史跟踪之前的问答

  • 输出现在返回了表格列表!

'output': 'The following tables are available in the `thelook_ecommerce` dataset:\n- distribution_centers\n- events\n- inventory_items\n- order_items\n- orders\n- products\n- users \n'}

然而,在某些使用场景中,某些操作可能因其性质需要特别关注(例如删除数据库中的条目、编辑信息、发送电子邮件等)。没有控制的完全自动化可能导致代理做出错误决定并造成损害。

确保我们的工作流的一种方法是添加一个人工干预步骤。

7. 创建一个包含人工验证步骤的链

链与代理有所不同。代理可以决定是否使用工具,而链则更为静态。它是一个步骤序列,我们仍然可以在其中加入一个步骤,让 LLM 从一组工具中选择。

在 LangChain 中构建链时,我们使用 LCEL。

LangChain 表达式语言(LCEL)是一种声明式的方法,可以轻松地将链式操作组合在一起。在 LangChain 中,链使用管道符号|表示步骤的执行顺序,如step 1 | step 2 | step 3 等。与代理的不同之处在于,链始终会按照这些步骤执行,而代理可以“自行决定”并在决策过程中具有自主性。

在我们的例子中,我们将按照以下方式构建一个简单的prompt | llm链。

# define the prompt with memory
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "You are a helpful assistant."),
        # First put the history
        ("placeholder", "{chat_history}"),
        # Then the new input
        ("human", "{input}"),
        # Finally the scratchpad
        ("placeholder", "{agent_scratchpad}"),
    ]
)

# bind the tools to the LLM
gemini_with_tools = gemini_llm.bind_tools(langchain_tool)

# build the chain
chain = prompt | gemini_with_tools

还记得在前一个步骤中,我们将一个代理传递给了我们的RunnableWithMessageHistory吗?好吧,我们将在这里做同样的事情,但……

# With AgentExecutor

# agent = create_tool_calling_agent(gemini_llm, langchain_tool, prompt)
# agent_executor = AgentExecutor(agent=agent, tools=langchain_tool)

# agent_with_chat_history = RunnableWithMessageHistory(
#     agent_executor,
#     lambda session_id: memory,
#     input_messages_key="input",
#     history_messages_key="chat_history",
# )

config = {"configurable": {"session_id": "foo"}}

# With Chains
memory = InMemoryChatMessageHistory(session_id="foo")
chain_with_history = RunnableWithMessageHistory(
    chain,
    lambda session_id: memory,
    input_messages_key="input",
    history_messages_key="chat_history",
)

response = chain_with_history.invoke(
    {"input": "What is the current CHF EUR exchange rate ?"}, config)

--- OUTPUT
"""
content='', 
additional_kwargs={
    'function_call': {
        'name': 'get_exchange_rate_from_api', 
        'arguments': '{"currency_from": "CHF", "currency_to": "EUR"}'
    }
}
""" 

与代理不同,链在没有明确告诉它的情况下不会提供答案。在我们的例子中,它停留在 LLM 返回需要调用的功能的步骤上。

我们需要添加一个额外的步骤来实际调用工具。让我们添加另一个函数来调用这些工具:

from langchain_core.messages import AIMessage

def call_tools(msg: AIMessage) -> list[dict]:
    """Simple sequential tool calling helper."""
    tool_map = {tool.name: tool for tool in langchain_tool}
    tool_calls = msg.tool_calls.copy()
    for tool_call in tool_calls:
        tool_call["output"] = tool_map[tool_call["name"]].invoke(tool_call["args"])
    return tool_calls

chain = prompt | gemini_with_tools | call_tools #<-- Extra step

chain_with_history = RunnableWithMessageHistory(
    chain,
    lambda session_id: memory,
    input_messages_key="input",
    history_messages_key="chat_history",
)

# Rerun the chain 
chain_with_history.invoke({"input": "What is the current CHF EUR exchange rate ?"}, config)

现在我们得到以下输出,显示 API 已成功调用:

[{'name': 'get_exchange_rate_from_api',
  'args': {'currency_from': 'CHF', 'currency_to': 'EUR'},
  'id': '81bc85ea-dfd4-4c01-85e8-f3ca592fff5b',
  'type': 'tool_call',
  'output': '{"amount":1.0,"base":"USD","date":"2024-11-20","rates":{"EUR":0.94679}}'
}]

现在我们已经理解了如何链式连接步骤,接下来让我们加入“人类在环”步骤!我们希望这个步骤检查 LLM 是否已经理解我们的请求,并将正确地调用 API。如果 LLM 理解错了请求或将错误地使用该功能,我们可以决定中断这个过程。

def human_approval(msg: AIMessage) -> AIMessage:
    """Responsible for passing through its input or raising an exception.

    Args:
        msg: output from the chat model

    Returns:
        msg: original output from the msg
    """
    for tool_call in msg.tool_calls:
        print(f"I want to use function [{tool_call.get('name')}] with the following parameters :")
        for k,v in tool_call.get('args').items():
            print(" {} = {}".format(k, v))

    print("")
    input_msg = (
        f"Do you approve (Y|y)?\n\n"
        ">>>"
    )
    resp = input(input_msg)
    if resp.lower() not in ("yes", "y"):
        raise NotApproved(f"Tool invocations not approved:\n\n{tool_strs}")
    return msg

接下来,在函数调用之前将此步骤添加到链中:

chain = prompt | gemini_with_tools | human_approval | call_tools

memory = InMemoryChatMessageHistory(session_id="foo")

chain_with_history = RunnableWithMessageHistory(
    chain,
    lambda session_id: memory,
    input_messages_key="input",
    history_messages_key="chat_history",
)

chain_with_history.invoke({"input": "What is the current CHF EUR exchange rate ?"}, config)

然后你将被要求确认 LLM 是否正确理解:

这个“人类在环”步骤对于关键工作流非常有帮助,因为 LLM 的误解可能会造成严重后果。

8. 使用搜索工具

获取实时信息的最便捷工具之一是搜索引擎。实现这一点的一种方法是使用 GoogleSerperAPIWrapper(你需要注册并获取 API 密钥才能使用它),它提供了一个良好的界面,可以快速查询 Google 搜索并获取结果。

幸运的是,LangChain 已经为你提供了一个工具,所以我们不需要自己编写这个函数。

因此,让我们尝试问一个关于昨天事件(11 月 20 日)的问题,看看我们的代理是否能回答。我们的问题是关于拉斐尔·纳达尔的最后一场正式比赛(他输给了范·德·赞斯丘普)。

agent_with_chat_history.invoke(
    {"input": "What was the result of Rafael Nadal's latest game ?"}, config)

--- OUTPUT ---
"""
{'input': "What was the result of Rafael Nadal's latest game ?",
 'chat_history': [],
 'output': "I do not have access to real-time information, including sports results. To get the latest information on Rafael Nadal's game, I recommend checking a reliable sports website or news source. \n"}
"""

如果无法访问 Google 搜索,我们的模型将无法回答,因为这些信息在训练时并不可用。

现在让我们将 Serper 工具加入我们的工具箱,看看我们的模型能否使用 Google 搜索来找到信息:

from langchain_community.utilities import GoogleSerperAPIWrapper

# Create our new search tool here
search = GoogleSerperAPIWrapper(serper_api_key="...")

@tool
def google_search(query: str):
    """
    Perform a search on Google
    Args:
        query: the information to be retrieved with google search
    """
    return search.run(query)

# Add it to our existing tools
langchain_tool = [
    list_datasets,
    list_tables,
    get_exchange_rate_from_api,
    google_search
]

# Create agent
agent = create_tool_calling_agent(gemini_llm, langchain_tool, prompt)
agent_executor = AgentExecutor(agent=agent, tools=langchain_tool)

# Add memory
memory = InMemoryChatMessageHistory()
agent_with_chat_history = RunnableWithMessageHistory(
    agent_executor,
    lambda session_id: memory,
    input_messages_key="input",
    history_messages_key="chat_history",
)

然后重新运行我们的查询:

agent_with_chat_history.invoke({"input": "What was the result of Rafael Nadal's latest game ?"}, config)

--- OUTPUT ---
"""
{'input': "What was the result of Rafael Nadal's latest game ?",
 'chat_history': [],
 'output': "Rafael Nadal's last match was a loss to Botic van de Zandschulp in the Davis Cup. Spain was eliminated by the Netherlands. \n"}
""" 

结论

仅依靠 LLM,当涉及到使用个人、公司、私密或真实数据时,通常会遇到阻碍。事实上,这类信息在训练时通常不可用。代理和工具是增强这些模型的强大手段,它们允许模型与系统和 API 交互,并协调工作流以提升生产力。

使用 LLM 构建生物医学实体链接器

原文:towardsdatascience.com/building-a-biomedical-entity-linker-with-llms-d385cb85c15a?source=collection_archive---------1-----------------------#2024-03-19

如何有效地将 LLM 应用于生物医学实体链接?

Anand SubramanianTowards Data Science Anand Subramanian

·发布于 Towards Data Science ·阅读时长 26 分钟·2024 年 3 月 19 日

--

图片来源:Alina GrubnyakUnsplash

生物医学文本是一个广泛的术语,通常包括如研究文章、临床试验报告和病人记录等文档,作为关于各种生物学、医学和科学概念的丰富信息库。生物医学领域的研究论文展示了药物发现、药物副作用和新疾病治疗等领域的突破性进展。临床试验报告提供了有关新药物或治疗方法的安全性、有效性和副作用的详细信息。同时,病历记录包含医生和医疗专业人员记录的全面的病史、诊断、治疗方案和治疗结果。

对这些文本进行挖掘可以帮助实践者提取有价值的见解,这些见解可以应用于各种下游任务。你可以挖掘文本来识别不良药物反应、构建自动化医学编码算法,或者实现信息检索或问答系统,从庞大的研究文献库中提取信息。然而,影响生物医学文档处理的一个问题是文本通常是非结构化的。例如,研究人员可能使用不同的术语来指代相同的概念。例如,一个研究人员可能称之为“心脏病发作”,而另一个研究人员则称之为“心肌梗死”。类似地,在与药物相关的文献中,技术名称和常用名称可能会互换使用。例如,“对乙酰氨基酚”是某种药物的技术名称,而“扑热息痛”则是其更常用的名称。缩写的普遍使用也增加了复杂性;例如,“一氧化氮”可能在另一种情况下被称为“NO”。尽管这些不同的术语指代相同的概念,但这些变化使得普通人或文本处理算法很难判断它们是否指的是同一个概念。因此,实体链接在这种情况下变得尤为重要。

目录:

  1. 什么是实体链接?

  2. LLM 在这里有什么作用?

  3. 实验设置

  4. 处理数据集

  5. 使用 LLM 进行零样本实体链接

  6. 使用带有检索增强生成的 LLM 进行实体链接

  7. 使用 LLM 和外部知识库链接器进行零样本实体提取

  8. 使用 LLM 和外部知识库链接器进行微调实体提取

  9. Scispacy 基准测试

  10. 关键要点

  11. 局限性

  12. 参考文献

什么是实体链接?

当文本是非结构化时,准确识别和标准化医学概念变得至关重要。为了实现这一点,医学术语系统,如统一医学语言系统(UMLS)[1]、医学临床术语系统化命名(SNOMED-CT)[2]和医学主题词表(MeSH)[3],在其中扮演着重要角色。这些系统提供了一套全面且标准化的医学概念,每个概念都由一个字母数字代码唯一标识。

实体链接涉及在文本中识别和提取实体,并将它们映射到大型术语库中的标准化概念。在此上下文中,知识库(KB)指的是一个详细的数据库,包含与术语库相关的标准化信息和概念,如医学术语、疾病和药物。通常,知识库由专家策划和设计,包含关于概念的详细信息,包括可能用来指代该概念的术语变体,或该概念与其他概念的关系。

实体识别与链接管道概述。实体首先从文本中解析出来,然后将每个实体链接到知识库,以获取它们对应的标识符。本例中考虑的知识库是 MeSH 术语。示例文本来自 BioCreative V CDR 语料库[4,5,6,7,8](图像来自作者)。

实体识别涉及提取在我们任务上下文中重要的单词或短语。在这个上下文中,通常指的是提取生物医学术语,如药物、疾病等。通常,基于查找的方法或基于机器学习/深度学习的系统常用于实体识别。将实体链接到知识库通常需要一个检索系统,该系统对知识库进行索引。该系统从前一步中提取每个实体,并从知识库中检索可能的标识符。这里的检索器也是一种抽象,可能是稀疏的(BM-25)、密集的(基于嵌入的)或甚至是生成性的系统(如大型语言模型(LLM)),它将知识库编码在其参数中。

LLM 在这里的作用是什么?

我一直很好奇如何将 LLM 集成到生物医学和临床文本处理管道中。鉴于实体链接是这类管道的重要组成部分,我决定探索 LLM 如何最好地应用于这个任务。具体来说,我研究了以下几种设置:

  1. 零-shot 实体链接与 LLM: 利用 LLM 直接从输入的生物医学文本中识别所有实体和概念 ID,而无需任何微调。

  2. LLM 与检索增强生成(RAG): 在 RAG 框架中使用 LLM,通过在提示中注入有关相关概念 ID 的信息来进行实体链接。

  3. 使用外部知识库链接器的零-shot 实体提取: 利用 LLM 从生物医学文本中进行零-shot 实体提取,并使用外部链接器/检索器将实体映射到概念 ID。

  4. 与外部知识库链接器的微调实体提取: 首先对 LLM 进行实体提取任务的微调,并将其用作实体提取器,与外部链接器/检索器结合,将实体映射到概念 ID。

  5. 与现有管道的比较: 与用于生物医学文本处理的常用库 Scispacy 相比,这些方法表现如何?

实验设置

与本文相关的所有代码和资源都可以在此 Github 仓库的 entity_linking 文件夹中找到。欢迎拉取仓库并直接运行笔记本,以进行这些实验。如果您有任何反馈或观察,或者发现任何错误,请告诉我!

为了进行这些实验,我们利用 Mistral-7B Instruct [9] 作为我们的 LLM。为了将医学术语与实体进行链接,我们使用 MeSH 术语。引用 美国国家医学图书馆网站 的话:

“医学主题词表(MeSH)是由美国国家医学图书馆(National Library of Medicine)制作的一个受控且层级组织的词汇表,用于对生物医学和健康相关信息进行索引、目录编制和检索。”

我们使用 BioCreative-V-CDR-Corpus [4,5,6,7,8] 进行评估。该数据集包含了疾病和化学实体的标注,以及它们对应的 MeSH ID。为了评估的目的,我们从测试集中随机抽取了 100 个数据点。我们使用了 Scispacy [10,11] 提供的 MeSH 知识库版本,其中包含了 MeSH 标识符的信息,例如定义和与每个 ID 对应的实体。

为了评估性能,我们计算两个指标。第一个指标与实体提取性能相关。原始数据集包含文本中所有实体的提及,并在子字符串级别进行标注。严格的评估会检查算法是否输出了所有实体的所有出现。然而,我们简化了这个过程以便于评估;我们将实体在真实标签中的大小写转换为小写并去重。然后,我们为每个实例计算精确度、召回率和 F1 得分,并计算每个指标的宏平均值。

假设你有一组实际实体,ground_truth,以及一个模型为每个输入文本预测的实体集,pred真正例 TP 可以通过识别 predground_truth 之间的共同元素来确定,实际上就是计算这两个集合的交集。

对于每个输入,我们可以计算:

precision = len(TP)/ len(pred)

recall = len(TP) / len(ground_truth)

f1 = 2 * precision * recall / (precision + recall)

最后,通过将所有指标加总并除以测试集中的数据点数,我们可以计算每个指标的宏平均值。

为了评估整体实体链接性能,我们再次计算相同的指标。在这种情况下,对于每个输入数据点,我们有一组元组,其中每个元组是一个 (entity, mesh_id) 对。其他指标的计算方式相同。

处理数据集

好的,让我们先从定义一些用于处理数据集的辅助函数开始。

def parse_dataset(file_path):
    """
    Parse the BioCreative Dataset.

    Args:
    - file_path (str): Path to the file containing the documents.

    Returns:
    - list of dict: A list where each element is a dictionary representing a document.
    """
    documents = []
    current_doc = None

    with open(file_path, 'r', encoding='utf-8') as file:
        for line in file:
            line = line.strip()
            if not line:
                continue
            if "|t|" in line:
                if current_doc:
                    documents.append(current_doc)
                id_, title = line.split("|t|", 1)
                current_doc = {'id': id_, 'title': title, 'abstract': '', 'annotations': []}
            elif "|a|" in line:
                _, abstract = line.split("|a|", 1)
                current_doc['abstract'] = abstract
            else:
                parts = line.split("\t")
                if parts[1] == "CID":
                    continue
                annotation = {
                    'text': parts[3],
                    'type': parts[4],
                    'identifier': parts[5]
                }
                current_doc['annotations'].append(annotation)

        if current_doc:
            documents.append(current_doc)

    return documents

def deduplicate_annotations(documents):
    """
    Filter documents to ensure annotation consistency.

    Args:
    - documents (list of dict): The list of documents to be checked.
    """
    for doc in documents:
        doc["annotations"] = remove_duplicates(doc["annotations"])

def remove_duplicates(dict_list):
    """
    Remove duplicate dictionaries from a list of dictionaries.

    Args:
    - dict_list (list of dict): A list of dictionaries from which duplicates are to be removed.

    Returns:
    - list of dict: A list of dictionaries after removing duplicates.
    """
    unique_dicts = []  
    seen = set()

    for d in dict_list:
        dict_tuple = tuple(sorted(d.items()))
        if dict_tuple not in seen:
            seen.add(dict_tuple)
            unique_dicts.append(d)

    return unique_dicts

我们首先解析原始数据集中提供的文本文件中的数据集。原始数据集包括标题、摘要,以及所有标注的实体和它们的实体类型(疾病或化学物质)、它们在文本中的准确位置的子字符串索引,以及它们的 MeSH ID。在处理我们的数据集时,我们进行了一些简化。我们忽略了子字符串索引和实体类型。此外,我们去重了具有相同实体名称和 MeSH ID 的标注。在这一阶段,我们仅进行区分大小写的去重处理,这意味着如果同一实体在文档中同时以大小写不同的形式出现,我们目前的处理方式会保留这两个实例。

使用 LLM 进行零-shot 实体链接

首先,我们旨在确定 LLM 是否由于其预训练而已经具备了 MeSH 术语的理解,以及它是否能够作为零-shot 实体链接器。所谓零-shot,指的是 LLM 基于其内在知识,在没有依赖外部知识库链接器的情况下,直接将实体链接到其 MeSH ID。这个假设并不完全不现实,因为 MeSH 相关的信息在网上是可以找到的,这意味着模型在预训练阶段可能接触过 MeSH 相关的信息。然而,即使 LLM 在训练时包含了这些信息,仅凭这些信息也不太可能使模型有效地执行零-shot 实体链接,因为生物医学术语的复杂性和进行准确实体链接所需的精确性都非常高。

为了评估这一点,我们将输入文本提供给 LLM,并直接提示它预测实体及其对应的 MeSH ID。此外,我们通过从训练数据集中抽取三个数据点来创建一个 few-shot 提示。需要澄清的是,这里“zero-shot”和“few-shot”的使用有所区别:“zero-shot”指的是 LLM 在没有针对这一任务的特定训练的情况下进行实体链接,而“few-shot”指的是在该上下文中采用的提示策略。

LLM 作为零-shot 实体链接器(图像由作者提供)

为了计算我们的指标,我们定义了评估性能的函数:

def calculate_entity_metrics(gt, pred):
    """
    Calculate precision, recall, and F1-score for entity recognition.

    Args:
    - gt (list of dict): A list of dictionaries representing the ground truth entities. 
                         Each dictionary should have a key "text" with the entity text.
    - pred (list of dict): A list of dictionaries representing the predicted entities.
                           Similar to `gt`, each dictionary should have a key "text".

    Returns:
    tuple: A tuple containing precision, recall, and F1-score (in that order).
    """
    ground_truth_set = set([x["text"].lower() for x in gt])
    predicted_set = set([x["text"].lower() for x in pred])

    # True positives are predicted items that are in the ground truth
    true_positives = len(predicted_set.intersection(ground_truth_set))

    # Precision calculation
    if len(predicted_set) == 0:
        precision = 0
    else:
        precision = true_positives / len(predicted_set)

    # Recall calculation
    if len(ground_truth_set) == 0:
        recall = 0
    else:
        recall = true_positives / len(ground_truth_set)

    # F1-score calculation
    if precision + recall == 0:
        f1_score = 0
    else:
        f1_score = 2 * (precision * recall) / (precision + recall)

    return precision, recall, f1_score

def calculate_mesh_metrics(gt, pred):
    """
    Calculate precision, recall, and F1-score for matching MeSH (Medical Subject Headings) codes.

    Args:
    - gt (list of dict): Ground truth data
    - pred (list of dict): Predicted data

    Returns:
    tuple: A tuple containing precision, recall, and F1-score (in that order).
    """
    ground_truth = []

    for item in gt:
        mesh_codes = item["identifier"]
        if mesh_codes == "-1":
            mesh_codes = "None"
        mesh_codes_split = mesh_codes.split("|")
        for elem in mesh_codes_split:
            combined_elem = {"entity": item["text"].lower(), "identifier": elem}
            if combined_elem not in ground_truth:
                ground_truth.append(combined_elem)

    predicted = []
    for item in pred:
        mesh_codes = item["identifier"]
        mesh_codes_split = mesh_codes.strip().split("|")
        for elem in mesh_codes_split:
            combined_elem = {"entity": item["text"].lower(), "identifier": elem}
            if combined_elem not in predicted:
                predicted.append(combined_elem)
    # True positives are predicted items that are in the ground truth
    true_positives = len([x for x in predicted if x in ground_truth])

    # Precision calculation
    if len(predicted) == 0:
        precision = 0
    else:
        precision = true_positives / len(predicted)

    # Recall calculation
    if len(ground_truth) == 0:
        recall = 0
    else:
        recall = true_positives / len(ground_truth)

    # F1-score calculation
    if precision + recall == 0:
        f1_score = 0
    else:
        f1_score = 2 * (precision * recall) / (precision + recall)

    return precision, recall, f1_score

现在,让我们运行模型并获取预测结果:

model = AutoModelForCausalLM.from_pretrained("mistralai/Mistral-7B-Instruct-v0.2",  torch_dtype=torch.bfloat16).cuda()
tokenizer = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-Instruct-v0.2")
model.eval()

mistral_few_shot_answers = []
for item in tqdm(test_set_subsample):
    few_shot_prompt_messages = build_few_shot_prompt(SYSTEM_PROMPT, item, few_shot_example)
    input_ids = tokenizer.apply_chat_template(few_shot_prompt_messages, tokenize=True, return_tensors = "pt").cuda()
    outputs = model.generate(input_ids = input_ids, max_new_tokens=200, do_sample=False)    
    # https://github.com/huggingface/transformers/issues/17117#issuecomment-1124497554
    gen_text = tokenizer.batch_decode(outputs.detach().cpu().numpy()[:, input_ids.shape[1]:], skip_special_tokens=True)[0]
    mistral_few_shot_answers.append(parse_answer(gen_text.strip()))

在实体提取层面,考虑到 LLM 并未专门为此任务进行显式微调,其表现相当不错。然而,作为零-shot 链接器,它的表现非常差,整体表现低于 1%。这个结果是直观的,因为 MeSH 标签的输出空间非常庞大,将实体精确映射到特定的 MeSH ID 是一项艰巨的任务。

零-shot 实体提取与实体链接得分

使用检索增强生成的 LLM 进行实体链接

检索增强生成(RAG)[12] 指的是一种将 LLM 与外部知识库(KB)结合的框架,该知识库配备了查询功能,如检索器/链接器。对于每个输入查询,系统首先使用查询功能从 KB 中检索与查询相关的知识。然后,系统将检索到的知识与查询结合,将这个合并后的提示提供给 LLM 来执行任务。这种方法基于这样一个理解:LLM 可能没有所有必要的知识或信息来有效地回答输入查询。因此,知识通过查询外部知识源注入到模型中。

使用 RAG 框架可以提供几个优势:

  1. 现有的 LLM 可以用于新的领域或任务而无需领域特定的微调,因为相关信息可以通过提示查询并提供给模型。

  2. LLM 有时在回答查询时可能会提供不正确的答案(幻想)。使用 RAG 与 LLM 结合可以显著减少这种幻想,因为 LLM 提供的答案更有可能基于事实,这是由于有外部知识的支持。

考虑到 LLM 对 MeSH 术语缺乏特定的知识,我们研究 RAG 设置是否能提高性能。在这种方法中,对于每个输入段落,我们使用 BM-25 检索器查询知识库(KB)。对于每个 MeSH ID,我们可以访问该 ID 的一般描述和与之相关的实体名称。检索后,我们通过提示将这些信息注入模型,以进行实体链接。

为了研究提供给模型的检索到的 ID 数量对实体链接过程的影响,我们运行此设置,提供前 10、30 和 50 个文档给模型,并量化其在实体提取和 MeSH 概念识别上的表现。

具有 RAG 的 LLM 作为实体链接器(图示由作者提供)

首先,我们定义我们的 BM-25 检索器:

from rank_bm25 import BM25Okapi
from typing import List, Tuple, Dict
from nltk.tokenize import word_tokenize
from tqdm import tqdm

class BM25Retriever:
    """
    A class for retrieving documents using the BM25 algorithm.

    Attributes:
        index (List[int, str]): A dictionary with document IDs as keys and document texts as values.
        tokenized_docs (List[List[str]]): Tokenized version of the documents in `processed_index`.
        bm25 (BM25Okapi): An instance of the BM25Okapi model from the rank_bm25 package.
    """

    def __init__(self, docs_with_ids: Dict[int, str]):
        """
        Initializes the BM25Retriever with a dictionary of documents.

        Args:
            docs_with_ids (List[List[str, str]]): A dictionary with document IDs as keys and document texts as values.
        """
        self.index = docs_with_ids
        self.tokenized_docs = self._tokenize_docs([x[1] for x in self.index])
        self.bm25 = BM25Okapi(self.tokenized_docs)

    def _tokenize_docs(self, docs: List[str]) -> List[List[str]]:
        """
        Tokenizes the documents using NLTK's word_tokenize.

        Args:
            docs (List[str]): A list of documents to be tokenized.

        Returns:
            List[List[str]]: A list of tokenized documents.
        """
        return [word_tokenize(doc.lower()) for doc in docs]

    def query(self, query: str, top_n: int = 10) -> List[Tuple[int, float]]:
        """
        Queries the BM25 model and retrieves the top N documents with their scores.

        Args:
            query (str): The query string.
            top_n (int): The number of top documents to retrieve.

        Returns:
            List[Tuple[int, float]]: A list of tuples, each containing a document ID and its BM25 score.
        """
        tokenized_query = word_tokenize(query.lower())
        scores = self.bm25.get_scores(tokenized_query)
        doc_scores_with_ids = [(doc_id, scores[i]) for i, (doc_id, _) in enumerate(self.index)]
        top_doc_ids_and_scores = sorted(doc_scores_with_ids, key=lambda x: x[1], reverse=True)[:top_n]
        return [x[0] for x in top_doc_ids_and_scores]

我们现在处理我们的 KB 文件,并创建一个索引它的 BM-25 检索器实例。在索引 KB 时,我们通过将描述、别名和规范名称连接在一起,对每个 ID 进行索引。

def process_index(index):
    """
    Processes the initial document index to combine aliases, canonical names, and definitions into a single text index.

    Args:
    - index (Dict): The MeSH knowledge base
    Returns:
        List[List[int, str]]: A dictionary with document IDs as keys and combined text indices as values.
    """
    processed_index = []
    for key, value in tqdm(index.items()):
        assert(type(value["aliases"]) != list)
        aliases_text = " ".join(value["aliases"].split(","))
        text_index = (aliases_text + " " +  value.get("canonical_name", "")).strip()
        if "definition" in value:
            text_index += " " + value["definition"]
        processed_index.append([value["concept_id"], text_index])
    return processed_index

mesh_data = read_jsonl_file("mesh_2020.jsonl")
process_mesh_kb(mesh_data)
mesh_data_kb = {x["concept_id"]:x for x in mesh_data}
mesh_data_dict = process_index({x["concept_id"]:x for x in mesh_data})
retriever = BM25Retriever(mesh_data_dict)
mistral_rag_answers = {10:[], 30:[], 50:[]}

for k in [10,30,50]:
    for item in tqdm(test_set_subsample):
        relevant_mesh_ids = retriever.query(item["title"] + " " + item["abstract"], top_n = k)
        relevant_contexts = [mesh_data_kb[x] for x in relevant_mesh_ids]
        rag_prompt = build_rag_prompt(SYSTEM_RAG_PROMPT, item, relevant_contexts)
        input_ids = tokenizer.apply_chat_template(rag_prompt, tokenize=True, return_tensors = "pt").cuda()
        outputs = model.generate(input_ids = input_ids, max_new_tokens=200, do_sample=False)    
        gen_text = tokenizer.batch_decode(outputs.detach().cpu().numpy()[:, input_ids.shape[1]:], skip_special_tokens=True)[0]
        mistral_rag_answers[k].append(parse_answer(gen_text.strip()))
entity_scores_at_k = {}
mesh_scores_at_k = {}

for key, value in mistral_rag_answers.items():
    entity_scores = [calculate_entity_metrics(gt["annotations"],pred) for gt, pred in zip(test_set_subsample, value)]
    macro_precision_entity = sum([x[0] for x in entity_scores]) / len(entity_scores)
    macro_recall_entity = sum([x[1] for x in entity_scores]) / len(entity_scores)
    macro_f1_entity = sum([x[2] for x in entity_scores]) / len(entity_scores)
    entity_scores_at_k[key] = {"macro-precision": macro_precision_entity, "macro-recall": macro_recall_entity, "macro-f1": macro_f1_entity}

    mesh_scores = [calculate_mesh_metrics(gt["annotations"],pred) for gt, pred in zip(test_set_subsample, value)]
    macro_precision_mesh = sum([x[0] for x in mesh_scores]) / len(mesh_scores)
    macro_recall_mesh = sum([x[1] for x in mesh_scores]) / len(mesh_scores)
    macro_f1_mesh = sum([x[2] for x in mesh_scores]) / len(mesh_scores)
    mesh_scores_at_k[key] = {"macro-precision": macro_precision_mesh, "macro-recall": macro_recall_mesh, "macro-f1": macro_f1_mesh}

通常,与原始的零-shot 设置相比,RAG 设置改进了整体的 MeSH 标识过程。但提供给模型的信息文档数量有何影响呢?我们绘制了得分与提供给模型作为上下文的检索到的 ID 数量的关系。

在 RAG 设置中,实体提取和实体链接性能指标随着检索文档数量变化的图表(图示由作者提供)

在研究图表时,我们观察到了一些有趣的趋势。对于实体提取,检索到的文档数量增加与宏观精度的急剧提高相关,宏观精度得分略高于 50%。这比模型的零-shot 实体提取性能高出近 10%。然而,宏观召回率的影响是任务相关的;对于实体提取任务,它保持不变,但对于实体链接任务则有所提高。总体而言,增加提供给模型的文档数量作为上下文,在 MeSH 标识设置中显著改善了所有指标,但在实体提取设置中效果不一。

在这个实验中需要考虑的一个重要限制是上游检索器的性能。如果检索器无法检索到相关文档,那么 LLM 的性能将受到影响,因为模型所提供的知识中并没有实际答案。

作为输入文本每个 MeSH ID 被检索器检索到的 MeSH ID 中的地面真值百分比,作为检索到的 ID 总数的函数(图片由作者提供)

为了调查这一点,我们计算了每个输入文本中,检索器所提取的 MeSH ID 中包含的地面真值 MeSH ID 的平均百分比。我们的发现表明,BM-25 检索器平均只能检索到约 12.6%到 17.7%的相关 MeSH ID。检索器的选择和我们检索的方式因此成为 RAG 设置中的一个重要性能瓶颈,可能需要优化以提升性能。

LLM 与外部知识库连接器的零-shot 实体提取

到目前为止,我们已经考察了 LLM 作为零-shot 实体链接器的表现,以及 RAG 如何提升其性能。尽管与零-shot 设置相比,RAG 提高了性能,但这种方法也有其局限性。

在 RAG 设置中使用 LLM 时,我们一直将知识组件(KB + 检索器)置于模型的上游。RAG 设置中的知识检索是粗糙的,即通过查询检索器使用整个生物医学文本来检索可能的 MeSH ID。这在一定程度上保证了检索结果的多样性,因为获取的结果很可能对应文本中的不同实体,但这些结果的精确度较低。起初这似乎并不是问题,因为你可以通过在 RAG 设置中为模型提供更多相关结果作为上下文来在一定程度上缓解这个问题。然而,这有两个缺点:

  1. LLM 通常在处理文本时有一个上下文长度的上限。LLM 的上下文长度大致指的是 LLM 在生成新文本之前,可以考虑的最大标记数量(提示中的标记数)。这可能限制我们能提供给 LLM 的知识量。

  2. 假设我们有一个能够处理长上下文长度的 LLM。我们现在可以检索并附加更多的上下文到模型中。太棒了!然而,更长的上下文长度不一定与 LLM 的增强 RAG 能力相关联[13]。即使你通过检索更多结果将大量相关知识传递给 LLM,也不能保证 LLM 会准确地提取出正确的答案。

这将我们带回最初描述的传统管道的实体链接。在这种设置中,知识组件被保持在模型的下游,在实体提取后,实体被提供给外部检索器以获取相关的 MeSH ID。只要你有一个好的实体提取器,你就可以检索到更精确的 MeSH ID。

早些时候,我们在完全零-shot 的设置下观察到,虽然 LLM 在预测 MeSH ID 时表现较差,但它的实体提取表现相当不错。我们现在使用 Mistral 模型提取实体,并将其提供给外部检索器以获取 MeSH ID。

使用 LLM 作为实体提取器和外部检索器的实体链接(作者提供的图像)

在此检索中,我们再次使用 BM-25 检索器作为我们的知识库链接器。然而,我们在这里做的一个小调整是将我们的 ID 索引基于连接它们的标准名称和别名。我们重新使用在第一次零-shot 设置中提取的实体进行本次实验。现在让我们评估一下这个设置的表现如何:

entity_mesh_data_dict = [[x["concept_id"] , " ".join(x["aliases"].split(",")) + " " + x["canonical_name"]] for x in mesh_data]
entity_retriever = BM25Retriever(entity_mesh_data_dict)

parsed_entities_few_shot = [[y["text"] for y in x] for x in mistral_few_shot_answers]
retrieved_answers = []

for item in tqdm(parsed_entities_few_shot):
    answer_element = []
    for entity in item:
        retrieved_mesh_ids = entity_retriever.query(entity, top_n = 1)
        answer_element.append({"text": entity, "identifier":retrieved_mesh_ids[0]})
    retrieved_answers.append(answer_element)

mesh_scores = [calculate_mesh_metrics(gt["annotations"],pred) for gt, pred in zip(test_set_subsample, retrieved_answers)]
macro_precision_mesh = sum([x[0] for x in mesh_scores]) / len(metric_scores)
macro_recall_mesh = sum([x[1] for x in mesh_scores]) / len(metric_scores)
macro_f1_mesh = sum([x[2] for x in mesh_scores]) / len(metric_scores)

在这种设置下,性能在所有指标上都明显优于 RAG 设置。与最佳 RAG 设置(在 50 个文档进行检索)相比,我们在宏精度上提高了超过 12%,在宏召回率上提高了 20%,在宏 F1 得分上提高了 16%。再次强调,这更类似于传统的实体提取管道,其中实体提取和链接是分开的组件。

Zero-Shot LLM 实体提取与外部检索器得分

使用 LLM 和外部知识库链接器的微调实体提取

到目前为止,我们通过将 LLM 作为实体提取器放入更大的管道中获得了最佳性能。然而,我们是在零-shot 的方式下进行实体提取的。我们是否可以通过特别为实体提取微调 LLM 来进一步提升性能?

对于微调,我们使用来自 BioCreative V 数据集的训练集,该数据集包含 500 个数据点。我们采用 Q-Lora [14]来微调我们的 LLM,这一过程包括将 LLM 量化为 4 位并冻结,同时微调低秩适配器。这种方法通常在参数和内存方面效率较高,因为适配器的权重仅占原始 LLM 的极小一部分,意味着我们微调的权重要远少于微调整个 LLM 的情况。它还使我们能够在单个 GPU 上微调模型。

让我们实现微调组件。对于这部分,我参考并修改了Niels Rogge 关于使用 Q-Lora 微调 Mistral 模型的笔记本,修改内容主要集中在正确准备和处理数据集。

from datasets import load_dataset
import json
from tqdm import tqdm
from itertools import chain
from datasets import DatasetDict
from transformers import AutoTokenizer, BitsAndBytesConfig
import torch
from trl import SFTTrainer
from peft import LoraConfig
from transformers import TrainingArguments
from helpers import *

def read_jsonl_file(file_path):
    """
    Parses a JSONL (JSON Lines) file and returns a list of dictionaries.

    Args:
        file_path (str): The path to the JSONL file to be read.

    Returns:
        list of dict: A list where each element is a dictionary representing
            a JSON object from the file.
    """
    jsonl_lines = []
    with open(file_path, 'r', encoding="utf-8") as file:
        for line in file:
            json_object = json.loads(line)
            jsonl_lines.append(json_object)

    return jsonl_lines

def convert_to_template(data):
    messages = []
    messages.append({"role": "user", "content": data["question"]})
    messages.append({"role": "assistant", "content": data["answer"]})

    return tokenizer.apply_chat_template(messages, tokenize = False)

mesh_dataset = parse_dataset("CDR_TrainingSet.PubTator.txt")

现在,我们加载分词器并设置适当的参数:

model_id = "mistralai/Mistral-7B-Instruct-v0.2"

tokenizer = AutoTokenizer.from_pretrained(model_id)

# set pad_token_id equal to the eos_token_id if not set
tokenizer.pad_token_id = tokenizer.eos_token_id
tokenizer.padding_side = "right"

# Set reasonable default for models without max length
if tokenizer.model_max_length > 100_000:
  tokenizer.model_max_length = 512

现在让我们准备并正确格式化我们的数据集。我们为模型定义提示,并将数据集格式化为预期的聊天模板。

prepared_dataset = []
system_prompt = "Answer the question factually and precisely."
entity_prompt = "What are the chemical and disease related entities present in this biomedical text?"
prepared_dataset = []

def prepare_instructions(elem):
    entities = []
    for x in elem["annotations"]:
        if x["text"] not in entities:
            entities.append(x["text"])

    return {"question": system_prompt + "\n" + entity_prompt + "\n" + elem["title"] + " " + elem["abstract"] , "answer": "The entities are:" + ",".join(entities)}

questions = [prepare_instructions(x) for x in tqdm(mesh_dataset)]
chat_format_questions = [{"text": convert_to_template(x)} for x in tqdm(questions)]

df = pd.DataFrame(chat_format_questions)
train_dataset = Dataset.from_pandas(df)

现在让我们为微调模型定义适当的配置。我们为量化 LLM 定义配置:

quantization_config = BitsAndBytesConfig(
            load_in_4bit=True,
            bnb_4bit_quant_type="nf4",
            bnb_4bit_compute_dtype=torch.bfloat16,
)

device_map = {"": torch.cuda.current_device()} if torch.cuda.is_available() else None

model_kwargs = dict(
    torch_dtype=torch.bfloat16,
    use_cache=False, # set to False as we're going to use gradient checkpointing
    device_map=device_map,
    quantization_config=quantization_config,
)

现在,我们已经准备好微调我们的模型:

output_dir = 'entity_finetune'

# based on config
training_args = TrainingArguments(
    bf16=True, # specify bf16=True instead when training on GPUs that support bf16
    do_eval=False,
    # evaluation_strategy="no",
    gradient_accumulation_steps=1,
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
    learning_rate=1.0e-04,
    log_level="info",
    logging_steps=5,
    logging_strategy="steps",
    lr_scheduler_type="cosine",
    max_steps=-1,
    num_train_epochs=5,
    output_dir=output_dir,
    overwrite_output_dir=True,
    per_device_eval_batch_size=1, 
    per_device_train_batch_size=8,
    save_strategy="no",
    save_total_limit=None,
    seed=42,
)

# based on config
peft_config = LoraConfig(
        r=16,
        lora_alpha=16,
        lora_dropout=0.1,
        bias="none",
        task_type="CAUSAL_LM",
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)

trainer = SFTTrainer(
        model=model_id,
        model_init_kwargs=model_kwargs,
        args=training_args,
        train_dataset=train_dataset,
        eval_dataset=train_dataset,
        dataset_text_field="text",
        tokenizer=tokenizer,
        packing = True,
        peft_config=peft_config,
        max_seq_length=tokenizer.model_max_length,
    )

train_result = trainer.train()
trainer.save_model(output_dir)

微调过程完成后,我们现在利用模型进行推理并获得性能指标:

def parse_entities_from_trained_model(content):
    """
    Extracts a list of entities from the output of a trained model.

    Args:
    - content (str): The raw string output from a trained model.

    Returns:
    - list of str: A list of entities extracted from the model's output.
    """
    return content.split("The entities are:")[-1].split(",")

mistral_few_shot_answers = []
for item in tqdm(test_set_subsample):
    few_shot_prompt_messages = build_entity_prompt(item)
    # input_ids = tokenizer.apply_chat_template(few_shot_prompt_messages, tokenize=True, return_tensors = "pt").cuda()
    prompt = tokenizer.apply_chat_template(few_shot_prompt_messages, tokenize=False)
    tensors = tokenizer(prompt, return_tensors="pt")
    input_ids = tensors.input_ids.cuda()
    attention_mask = tensors.attention_mask.cuda()
    outputs = model.generate(input_ids = input_ids, attention_mask = attention_mask, max_new_tokens=200, do_sample=False)    
    # https://github.com/huggingface/transformers/issues/17117#issuecomment-1124497554
    gen_text = tokenizer.batch_decode(outputs.detach().cpu().numpy()[:, input_ids.shape[1]:], skip_special_tokens=True)[0]
    mistral_few_shot_answers.append(parse_entities_from_trained_model(gen_text.strip()))
parsed_entities_few_shot = [[y["text"] for y in x] for x in mistral_few_shot_answers]
retrieved_answers = []

for item in tqdm(parsed_entities_few_shot):
    answer_element = []
    for entity in item:
        retrieved_mesh_ids = entity_ranker.query(entity, top_n = 1)
        answer_element.append({"identifier":retrieved_mesh_ids[0], "text":entity})
    retrieved_answers.append(answer_element)

entity_scores = [calculate_entity_metrics(gt["annotations"],pred) for gt, pred in zip(test_set_subsample, retrieved_answers)]
macro_precision_entity = sum([x[0] for x in entity_scores]) / len(entity_scores)
macro_recall_entity = sum([x[1] for x in entity_scores]) / len(entity_scores)
macro_f1_entity = sum([x[2] for x in entity_scores]) / len(entity_scores)

mesh_scores = [calculate_mesh_metrics(gt["annotations"],pred) for gt, pred in zip(test_set_subsample, retrieved_answers)]
macro_precision_mesh = sum([x[0] for x in mesh_scores]) / len(mesh_scores)
macro_recall_mesh = sum([x[1] for x in mesh_scores]) / len(mesh_scores)
macro_f1_mesh = sum([x[2] for x in mesh_scores]) / len(mesh_scores)

这个设置与之前的设置完全相同,仍然使用 LLM 作为实体提取器,外部检索器用于将每个实体链接到 MeSH ID。微调模型带来了实体提取和链接方面的显著改进。

与零-shot 实体提取相比,微调在所有指标上提高了最多或超过 20%。类似地,与之前的设置相比,实体链接也在所有指标上提高了约 12%至 14%。这些结果并不令人惊讶,因为任务特定的模型预计会比零-shot 设置表现更好。尽管如此,将这些改进量化出来仍然令人高兴!

微调的 LLM 作为实体提取器和外部检索器得分

Scispacy 基准测试

这个实现与现有的可以执行实体链接的工具相比如何?Scispacy 是生物医学和临床文本处理中常用的工具,提供了实体提取和实体链接功能。特别地,Scispacy 还提供了一种将实体链接到 MeSH 知识库的功能,而这个文件也是我们原始 LLM 实验中使用的知识库。让我们在我们的测试集上基准测试 Scispacy 的性能,并与我们的 LLM 实验进行比较。

我们使用 Scispacy 中的“en_ner_bc5cdr_md” [15]作为实体提取模块,因为该模型是专门在 BioCreative V 数据集上训练的。让我们评估其性能:

from scispacy.linking import EntityLinker
import spacy, scispacy
import pandas as pd
from helpers import *
from tqdm import tqdm

#code for setting up MeSH linker referred from https://github.com/allenai/scispacy/issues/355
config = {
    "resolve_abbreviations": True,  
    "linker_name": "mesh", 
    "max_entities_per_mention":1
}

nlp = spacy.load("en_ner_bc5cdr_md")
nlp.add_pipe("scispacy_linker", config=config) 

linker = nlp.get_pipe("scispacy_linker")

def extract_mesh_ids(text):
    mesh_entity_pairs = []
    doc = nlp(text)
    for e in doc.ents:
        if e._.kb_ents:
            cui = e._.kb_ents[0][0]
            mesh_entity_pairs.append({"text": e.text, "identifier": cui})
        else:
            mesh_entity_pairs.append({"text": e.text, "identifier": "None"})

    return mesh_entity_pairs
all_mesh_ids = []
for item in tqdm(test_set_subsample):
    text = item["title"] + " " + item["abstract"]
    mesh_ids = extract_mesh_ids(text)
    all_mesh_ids.append(mesh_ids)

entity_scores = [calculate_entity_metrics(gt["annotations"],pred) for gt, pred in zip(test_set_subsample, all_mesh_ids)]
macro_precision_entity = sum([x[0] for x in entity_scores]) / len(entity_scores)
macro_recall_entity = sum([x[1] for x in entity_scores]) / len(entity_scores)
macro_f1_entity = sum([x[2] for x in entity_scores]) / len(entity_scores)

mesh_scores = [calculate_mesh_metrics(gt["annotations"],pred) for gt, pred in zip(test_set_subsample, all_mesh_ids)]
macro_precision_mesh = sum([x[0] for x in mesh_scores]) / len(entity_scores)
macro_recall_mesh = sum([x[1] for x in mesh_scores]) / len(entity_scores)
macro_f1_mesh = sum([x[2] for x in mesh_scores]) / len(entity_scores)

Scispacy 评估得分

Scispacy 在实体提取方面比微调后的 LLM 提高了 10%的得分,实体链接方面提高了 14%到 20%!对于生物医学实体提取和链接任务,Scispacy 仍然是一个强大的工具。

重点总结

在所有设置下,实体提取和实体链接的宏 F1 得分

实验结束时,我们可以从中得到哪些具体的收获?

  1. 零-shot 实体提取的优势: Mistral-Instruct 是一个不错的生物医学文本零-shot 实体提取器。尽管它的参数化知识不足以进行零-shot 的 MeSH 实体链接,但我们在实验中将其与外部 KB 检索器结合使用,作为实体提取器,从而获得了更好的性能。

  2. RAG 在零-shot 预测上的改进: 在 RAG 设置中,LLM 在实体链接方面相比纯粹的零-shot 方法表现出了一定的改进。然而,RAG 设置中的检索器组件可能是一个显著的瓶颈,正如我们在案例中所看到的,BM-25 检索器每个数据点只能检索到大约 12-17%的相关 ID。这表明需要更有效的检索方法。

  3. 流水线提取提供最佳性能: 鉴于 LLM 作为实体提取器的能力,当将这些能力与包含外部检索器以将实体链接到 MeSH 知识库(KB)的更大管道结合时,可以实现最佳性能。这与传统设置相同,其中实体提取和 KB 链接保持为独立模块。

  4. 微调的好处: 使用 QLora 对 LLM 进行微调以进行实体提取任务,可以显著提高实体提取和实体链接的性能,尤其是与外部检索器一起使用时。

  5. Scispacy 表现最佳: 在我们的实验中,Scispacy 在实体链接任务上优于所有基于 LLM 的方法。在生物医学文本处理方面,Scispacy 依然是一款强大的工具。与需要良好 GPU 进行快速推理的 LLM 相比,它还需要较少的计算资源。相比之下,Scispacy 只需要一台好的 CPU。

  6. 优化机会: 我们目前基于 LLM 的实体链接管道实现相当基础,存在较大的改进空间。一些可以优化的领域包括检索的选择以及检索逻辑本身。使用更多数据对 LLM 进行微调也能进一步提升其实体提取性能。

局限性

到目前为止,我们的实验存在一些局限性。

  1. 一个实体对应多个 MeSH ID: 在我们的数据集中,每个文档中的一些实体可能会与多个 MeSH ID 关联。在我们测试集中的 100 个文档中,共有 968 个实体,其中有 15 个案例(1.54%)存在这种情况。在 Scispacy 评估中,以及在所有我们使用外部知识库链接器(BM-25 检索器)进行实体提取的 LLM 实验中,我们每个实体只链接一个 MeSH 概念。尽管 Scispacy 提供了为每个实体链接多个 MeSH ID 的可能性,但我们选择不使用这个功能,以确保与 LLM 实验的公平比较。扩展功能以支持链接多个概念也是一个有趣的补充。

  2. 不在知识库中的 MeSH ID: 在测试数据集中,有一些实体的 MeSH ID 不在我们的知识库中。具体而言,64 个实体(占 6.6%)拥有一个不在我们知识库中的 MeSH ID。这个限制出在检索器端,可以通过更新知识库来解决。

  3. 缺乏 MeSH ID 的实体: 同样,另有 1.65%的实体(968 个中的 16 个)无法映射到 MeSH ID。在所有使用外部 KB 链接器进行实体提取的 LLM 实验中,我们目前无法确定一个实体是否没有 MeSH ID。

参考文献

我已经将本文中提到的所有论文和资源汇总在此。如果我遗漏了什么,请告诉我,我将添加它们!

[1] Bodenreider O. (2004). 统一医学语言系统(UMLS):整合生物医学术语。核酸研究, 32(数据库专刊),D267–D270. doi.org/10.1093/nar/gkh061

[2] www.nlm.nih.gov/healthit/snomedct/index.html

[3] www.nlm.nih.gov/mesh/meshhome.html

[4] Wei CH, Peng Y, Leaman R, Davis AP, Mattingly CJ, Li J, Wiegers TC, Lu Z. BioCreative V 化学-疾病关系(CDR)任务概述,发表于《第五届 BioCreative 挑战评估工作坊论文集》,第 154–166 页,2015 年

[5] Li J, Sun Y, Johnson RJ, Sciaky D, Wei CH, Leaman R, Davis AP, Mattingly CJ, Wiegers TC, Lu Z. 在生物医学文献中注释化学品、疾病及其相互作用,发表于《第五届 BioCreative 挑战评估工作坊论文集》,第 173–182 页,2015 年

[6] Leaman R, Dogan RI, Lu Z. DNorm:通过成对学习排序进行疾病名称规范化,生物信息学 29(22):2909–17,2013 年

[7] Leaman R, Wei CH, Lu Z. tmChem:一种高效的化学命名实体识别与规范化方法。J Cheminform, 7:S3, 2015 年

[8] Li, J., Sun, Y., Johnson, R. J., Sciaky, D., Wei, C. H., Leaman, R., Davis, A. P., Mattingly, C. J., Wiegers, T. C., & Lu, Z. (2016). BioCreative V CDR 任务语料库:化学-疾病关系抽取资源。数据库:生物数据库与注释期刊, 2016, baw068. doi.org/10.1093/database/baw068

[9] Jiang, A. Q., Sablayrolles, A., Mensch, A., Bamford, C., Chaplot, D. S., Casas, D. D. L., … & Sayed, W. E. (2023). Mistral 7B. arXiv 预印本 arXiv:2310.06825

[10] Neumann, M., King, D., Beltagy, I., & Ammar, W. (2019 年 8 月). ScispaCy:生物医学自然语言处理的快速且稳健的模型。发表于《第 18 届 BioNLP 研讨会及共享任务论文集》 (第 319–327 页)。

[11] ai2-s2-scispacy.s3-us-west-2.amazonaws.com/data/kbs/2020-10-09/mesh_2020.jsonl

[12] Lewis, P., Perez, E., Piktus, A., Petroni, F., Karpukhin, V., Goyal, N., … & Kiela, D. (2020). 用于知识密集型自然语言处理任务的检索增强生成。神经信息处理系统进展33,9459–9474。

[13] Liu, N. F., Lin, K., Hewitt, J., Paranjape, A., Bevilacqua, M., Petroni, F., & Liang, P. (2024). 迷失在其中:语言模型如何使用长上下文。计算语言学协会会刊12

[14] Dettmers, T., Pagnoni, A., Holtzman, A., & Zettlemoyer, L. (2024). Qlora: 高效的量化大语言模型微调。神经信息处理系统进展36

[15] s3-us-west-2.amazonaws.com/ai2-s2-scispacy/releases/v0.4.0/en_ner_bc5cdr_md-0.4.0.tar.gz

使用 LangChain、LLM 和 Streamlit 构建复杂 SQL 数据库交互的聊天应用

原文:towardsdatascience.com/building-a-chat-app-with-langchain-llms-and-streamlit-for-complex-sql-database-interaction-7433245079f3?source=collection_archive---------0-----------------------#2024-02-09

构建并部署一个用于复杂数据库交互的聊天应用,使用 LangChain 代理。

Hamza GharbiTowards Data Science Hamza Gharbi

·发表于Towards Data Science ·16 分钟阅读·2024 年 2 月 9 日

--

由 DALL-E 生成的图像。

在本文中,我们将展示如何使用大型语言模型(LLM)通过Langchain代理和工具与复杂数据库进行交互,并随后使用Streamlit部署聊天应用。

本文是一个两阶段项目的第二部分,也是最终部分,该项目利用了RappelConso API 数据,这是一个法国公共服务,提供关于法国产品召回的信息。

第一篇文章中,我们建立了一个管道,利用各种数据工程工具从 API 中查询数据,并将其存储到 PostgreSQL 数据库中。在本文中,我们将开发一个基于语言模型的聊天应用,允许我们与数据库进行交互。

目录

· 概览

· 设置

· SQL 代理

· SQL 数据库工具包

· 附加工具

· 实现记忆功能

· 使用 Streamlit 创建应用

· 观察与增强

· 结论

· 参考文献

概览

在这个项目中,我们将创建一个聊天机器人,它可以通过Langchain框架与 RappelConso 数据库进行交互。这个聊天机器人能够理解自然语言,并利用自然语言创建和执行 SQL 查询。我们将通过提供额外的工具来增强聊天机器人进行 SQL 查询的能力。它还将具备记忆功能,以便记住与用户的过去互动。为了使其更易于使用,我们将使用Streamlit将其转化为一个基于聊天的网页应用程序。

用户查询和代理响应的示例。图片由作者提供。

你可以在这里看到最终应用程序的演示:

rappel-conso 数据库上的最终 Streamlit 聊天应用程序演示。视频由作者提供。

该聊天机器人可以回答不同复杂度的查询,从召回产品的类别计数到关于产品或品牌的具体问题。它可以通过使用可用的工具来识别查询所需的正确列。该聊天机器人还可以用“ASCII”兼容的语言(如英语、法语、德语等)回答查询。

德语中的查询和响应示例。图片由作者提供。

术语表:

这里是一些关键术语的快速介绍,帮助你理解本文中提到的概念。

  • Langchain: LangChain 是一个开源框架,用于构建利用大语言模型(LLM)的应用程序。

  • 代理: 它们是 Langchain 的组件,利用语言模型决定采取哪些操作以及操作的顺序。代理通常可以访问一组称为工具的函数,它可以根据用户输入决定使用哪一个工具。

  • 工具: 这些是代理可以调用的函数,使其能够与外界互动。工具必须以最有助于代理的方式进行描述。

  • 工具包: 一组相关工具。在这个项目中,我们将使用SQLDatabaseToolkit。关于这一点将在后续部分进行详细说明。

  • SQL 数据库: 存储你将查询数据的数据库。在我们的项目中,我们将使用 Postgres 数据库。

  • Streamlit: 一个 Python 框架,能够非常简单地创建交互式网页应用程序。

现在让我们深入了解这个项目的技术细节!

设置

首先,你可以使用以下命令克隆 GitHub 仓库:

git clone https://github.com/HamzaG737/rappel-conso-chat-app.git

接下来,你可以导航到项目根目录并安装所需的包:

pip install -r requirements.txt

在这个项目中,我们使用了 OpenAI 的两个大型语言模型,gpt-3.5-turbo-1106gpt-4–1106-preview。由于后者在理解和执行复杂查询方面表现更好,我们将其作为默认的 LLM。

设置数据库

在我的上一篇文章中,我介绍了如何为从源 API 直接流式传输数据到 Postgres 数据库设置数据管道。然而,如果你想要更简单的解决方案,我创建了一个脚本,可以将所有数据从 API 直接传输到 Postgres,省去了设置完整管道的需求。

首先,你需要安装Docker。然后,你必须将 POSTGRES_PASSWORD 设置为环境变量。默认情况下,它将设置为字符串"postgres"

接下来,使用项目根目录中的 docker-compose yaml 文件启动 Postgres 服务器:

docker-compose -f docker-compose-postgres.yaml up -d

之后,脚本database/stream_data.py帮助你创建rappel_conso_table表,将数据从 API 流式传输到数据库,并通过计数行数对数据进行快速检查。截至 2024 年 2 月,你应该能看到大约 10400 行数据,因此预期会接近这个数字。

要运行该脚本,请使用以下命令:

python database/stream_data.py

请注意,数据传输可能需要约一分钟,视你的网络连接速度而定,可能稍微长一些。

rappel_conso_table 总共有25列,其中大部分是TEXT类型,可以接受无限值。以下是一些重要的列:

  • reference_fiche (参考表格): 被召回产品的唯一标识符。它作为我们 Postgres 数据库的主键。

  • categorie_de_produit (产品类别): 例如食品、电器、工具、交通工具等……

  • sous_categorie_de_produit (产品子类别): 例如我们可以将肉类、乳制品、谷物作为食品类别的子类别。

  • motif_de_rappel (召回原因): 一目了然,且是最重要的字段之一。

  • date_de_publication 代表发布日期。

  • risques_pour_le_consommateur 包含消费者在使用产品时可能遇到的风险。

  • 还有几个字段对应不同的链接,如产品图片链接、分销商列表链接等。

所有列的完整列表可以在constants.py文件中的常量RAPPEL_CONSO_COLUMNS下找到。

鉴于存在大量的列,代理必须有效地区分它们,尤其是在用户查询不明确的情况下。SQLDatabaseToolkit,以及我们计划实现的其他工具,将在提供必要的上下文方面发挥重要作用。这个上下文对于代理准确生成适当的 SQL 查询至关重要。

SQL 代理

LangChain 提供了一个 SQL 代理,它提供了一种灵活的方式与 SQL 数据库进行交互。

使用 SQL 代理的好处包括:

  • 它能够响应关于数据库结构(如特定表的详细信息)以及内容的查询。

  • 它有效处理错误的能力。当执行查询时发生错误,SQL 代理可以识别问题、修正它,然后成功执行修正后的查询。

在 Langchain 中,我们可以通过 create_sql_agent 函数初始化 SQL 代理。

from langchain.agents import create_sql_agent

agent = create_sql_agent(
        llm=llm_agent,
        agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
        toolkit=toolkit,
        verbose=True,
    )
  • 在这个函数中,llm 是代理的主要大型语言模型骨干。我们为此任务选择了 OpenAI GPT 模型,但其他模型也可能适用。以下是如何为代理定义 LLM 的方法:
from langchain.chat_models import ChatOpenAI

from constants import chat_openai_model_kwargs, langchain_chat_kwargs

# Optional: set the API key for OpenAI if it's not set in the environment.
# os.environ["OPENAI_API_KEY"] = "xxxxxx"

def get_chat_openai(model_name):
    llm = ChatOpenAI(
        model_name=model_name,
        model_kwargs=chat_openai_model_kwargs,
        **langchain_chat_kwargs
    )
    return llm
  • 目前,create_sql_agent 函数支持两种类型的代理:OpenAI 函数代理和 ReAct 代理。我们选择了 ReAct 代理,因为它们更容易与记忆功能集成。ReAct 代理模型使用大型语言模型同时生成推理和特定任务的动作。这种方法帮助代理在处理异常时规划、跟踪和调整其动作。它还使代理能够连接外部资源,如知识库,以获取更多信息,从而提高任务的效率。关于该框架的更多详细信息可以在这里找到。

ReAct 框架的示意图。图片基于 ReAct 论文(请查看参考文献部分)。

  • 最后,create_sql_agent 函数中的 toolkit 表示与数据库交互的 SQL 工具集。更多内容将在下一节介绍!

SQL 数据库工具包

SQLDatabaseToolkit 包含以下工具:

  • 创建并执行查询:在以下示例中,ReAct 代理将调用 sql_db_query 工具,并以某个 SQL 查询作为输入。随后,它分析数据库结果以为用户制定适当的响应。
Action: sql_db_query
Action Input: SELECT reference_fiche, nom_de_la_marque_du_produit, noms_des_modeles_ou_references, date_de_publication, liens_vers_les_images FROM rappel_conso_table WHERE categorie_de_produit = 'Alimentation' ORDER BY date_de_publication DESC LIMIT 1
Observation: [('2024-01-0125', 'MAITRE COQ', 'Petite Dinde', '2024-01-13', 'https://rappel.conso.gouv.fr/image/ea3257df-7a68-4b49-916b-d6f019672ed2.jpg https://rappel.conso.gouv.fr/image/2a73be1e-b2ae-4a31-ad38-266028c6b219.jpg https://rappel.conso.gouv.fr/image/95bc9aa0-cc75-4246-bf6f-b8e8e35e2a88.jpg')]
Thought:I now know the final answer to the question about the last recalled food item.

Final Answer: The last recalled food item is "Petite Dinde" by the brand "MAITRE COQ", which was published on January 13, 2024\. You can find the images of the recalled food item here: [lien vers l'image](https://rappel.conso.gouv.fr/image/ea3257df-7a68-4b49-916b-d6f019672ed2.jpg), [lien vers l'image](https://rappel.conso.gouv.fr/image/2a73be1e-b2ae-4a31-ad38-266028c6b219.jpg), [lien vers l'image](https://rappel.conso.gouv.fr/image/95bc9aa0-cc75-4246-bf6f-b8e8e35e2a88.jpg).
  • 使用 sql_db_query_checker 工具检查查询语法。
Action: sql_db_query_checker
Action Input: SELECT reference_fiche, nom_de_la_marque_du_produit, noms_des_modeles_ou_references, date_de_publication, liens_vers_les_images FROM rappel_conso_table WHERE categorie_de_produit = 'Alimentation' ORDER BY date_de_publication DESC LIMIT 1
Observation: ```sql

SELECT reference_fiche, nom_de_la_marque_du_produit, noms_des_modeles_ou_references, date_de_publication, liens_vers_les_images FROM rappel_conso_table WHERE categorie_de_produit = 'Alimentation' ORDER BY date_de_publication DESC LIMIT 1

```py
Thought:The query has been checked and is correct. I will now execute the query to find the last recalled food item.
  • 使用 sql_db_schema 工具获取表描述。
Action: sql_db_schema
Action Input: rappel_conso_table
Observation: 
CREATE TABLE rappel_conso_table (
        reference_fiche TEXT NOT NULL, 
        liens_vers_les_images TEXT, 
        lien_vers_la_liste_des_produits TEXT, 
        lien_vers_la_liste_des_distributeurs TEXT, 
        lien_vers_affichette_pdf TEXT, 
        lien_vers_la_fiche_rappel TEXT, 
        date_de_publication TEXT, 
        date_de_fin_de_la_procedure_de_rappel TEXT, 
        categorie_de_produit TEXT, 
        sous_categorie_de_produit TEXT, 
        nom_de_la_marque_du_produit TEXT, 
        noms_des_modeles_ou_references TEXT, 
        identification_des_produits TEXT, 
        conditionnements TEXT, 
        temperature_de_conservation TEXT, 
        zone_geographique_de_vente TEXT, 
        distributeurs TEXT, 
        motif_du_rappel TEXT, 
        numero_de_contact TEXT, 
        modalites_de_compensation TEXT, 
        risques_pour_le_consommateur TEXT, 
        recommandations_sante TEXT, 
        date_debut_commercialisation TEXT, 
        date_fin_commercialisation TEXT, 
        informations_complementaires TEXT, 
        CONSTRAINT rappel_conso_table_pkey PRIMARY KEY (reference_fiche)
)

/*
1 rows from rappel_conso_table table:
reference_fiche liens_vers_les_images   lien_vers_la_liste_des_produits lien_vers_la_liste_des_distributeurs    lien_vers_affichette_pdf        lien_vers_la_fiche_rappel      date_de_publication     date_de_fin_de_la_procedure_de_rappel   categorie_de_produit    sous_categorie_de_produit       nom_de_la_marque_du_produit     noms_des_modeles_ou_references identification_des_produits     conditionnements        temperature_de_conservation     zone_geographique_de_vente      distributeurs   motif_du_rappel        numero_de_contact       modalites_de_compensation       risques_pour_le_consommateur    recommandations_sante   date_debut_commercialisation    date_fin_commercialisation     informations_complementaires
2021-04-0165    https://rappel.conso.gouv.fr/image/bd8027eb-ba27-499f-ba07-9a5610ad8856.jpg     None    None    https://rappel.conso.gouv.fr/affichettePDF/225/Internehttps://rappel.conso.gouv.fr/fiche-rappel/225/Interne    2021-04-22      mercredi 5 mai 2021     Alimentation    Cereales et produits de boulangerie     GERBLE BIO    BISCUITS 3 GRAINES BIO   3175681257535 11908141 Date de durabilite minimale 31/03/2022   ETUI CARTON 132 g       Produit a conserver a temperature ambiante      France entiere CASINO  Presence possible d'oxyde d'ethylene superieure a la limite autorisee sur un lot de matiere premiere    0805293032      Remboursement   Produits phytosanitaires non autorises Ne plus consommer Rapporter le produit au point de vente        19/03/2021      02/04/2021      None

在定义 SQLDatabaseToolkit 类之前,我们必须初始化围绕 Postgres 数据库的 SQLDatabase 包装器:

import os

from langchain.sql_database import SQLDatabase
from .constants_db import port, password, user, host, dbname

url = f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{dbname}"
TABLE_NAME = "rappel_conso_table"

db = SQLDatabase.from_uri(
    url,
    include_tables=[TABLE_NAME],
    sample_rows_in_table_info=1,
)

sample_rows_in_table_info 设置决定了每个表的描述中添加多少示例行。添加这些示例行可以提升代理的性能,正如这篇论文中所示。因此,当代理访问表描述以获得更清晰的理解时,它将同时获取表的模式和该表的一个示例行。

最后让我们定义 SQL 工具包:

from langchain.agents.agent_toolkits import SQLDatabaseToolkit

def get_sql_toolkit(tool_llm_name):
    llm_tool = get_chat_openai(model_name=tool_llm_name)
    toolkit = SQLDatabaseToolkit(db=db, llm=llm_tool)
    return toolkit

额外工具

鉴于我们的表格复杂性,代理可能仅通过检查模式和示例行来无法完全理解数据库中的信息。例如,代理应该识别出一个关于汽车的查询意味着在 category 列中搜索值为 ‘Automobiles et moyens de déplacement’(即‘汽车与交通工具’)。因此,额外的工具是必要的,以为代理提供更多关于数据库的上下文。

以下是我们计划使用的额外工具的详细说明:

  • get_categories_and_sub_categories:此工具旨在帮助代理从 categorysub_category 列中获取不同项的列表。由于这些列中独特值的数量相对较少,这种方法非常有效。如果这些列包含数百或数千个独特值,可能更适合使用检索工具。在这种情况下,当用户询问类别时,代理可以在向量数据库中查找最相似的类别,向量数据库存储了各种值的嵌入。代理随后会使用这些类别来执行 SQL 查询。然而,鉴于我们的 categorysub_category 列没有太多独特值,我们将直接返回列表。
from langchain.tools import tool, Tool

import ast
import json

from sql_agent.sql_db import db

def run_query_save_results(db, query):
    res = db.run(query)
    res = [el for sub in ast.literal_eval(res) for el in sub]
    return res

def get_categories(query: str) -> str:
    """
    Useful to get categories and sub_categories. A json is returned where the key can be category or sub_category,
    and the value is a list of unique itmes for either both.
    """
    sub_cat = run_query_save_results(
        db, "SELECT DISTINCT sous_categorie_de_produit FROM rappel_conso_table"
    )
    cat = run_query_save_results(
        db, "SELECT DISTINCT categorie_de_produit FROM rappel_conso_table"
    )
    category_str = (
        "List of unique values of the categorie_de_produit column : \n"
        + json.dumps(cat, ensure_ascii=False)
    )
    sub_category_str = (
        "\n List of unique values of the sous_categorie_de_produit column : \n"
        + json.dumps(sub_cat, ensure_ascii=False)
    )

    return category_str + sub_category_str
  • get_columns_descriptions:由于我们不能直接提供列描述,我们创建了一个额外的工具,用于返回每个模糊列的简短描述。一些例子包括:
"reference_fiche": "primary key of the database and unique identifier in the database. ",
"nom_de_la_marque_du_produit": "A string representing the Name of the product brand. Example: Apple, Carrefour, etc ... When you filter by this column,you must use LOWER() function to make the comparison case insensitive and you must use LIKE operator to make the comparison fuzzy.",
"noms_des_modeles_ou_references": "Names of the models or references. Can be used to get specific infos about the product. Example: iPhone 12, etc, candy X, product Y, bread, butter ...",
"identification_des_produits": "Identification of the products, for example the sales lot.",
 def get_columns_descriptions(query: str) -> str:
    """
    Useful to get the description of the columns in the rappel_conso_table table.
    """
    return json.dumps(COLUMNS_DESCRIPTIONS)
  • get_today_date:这个工具使用 Python 的 datetime 库来获取今天的日期。代理在被问及时间性问题时会使用此工具。例如:“上周以来召回的产品有哪些?”
from datetime import datetime

def get_today_date(query: str) -> str:
    """
    Useful to get the date of today.
    """
    # Getting today's date in string format
    today_date_string = datetime.now().strftime("%Y-%m-%d")
    return today_date_string

最后,我们创建一个包含所有这些工具的列表,并将其传递给 create_sql_agent 函数。对于每个工具,我们必须在提供给代理的工具集合中定义一个唯一的名称。描述是可选的,但强烈推荐提供,因为它可以用于提供更多信息。

def sql_agent_tools():
    tools = [
        Tool.from_function(
            func=get_categories,
            name="get_categories_and_sub_categories",
            description="""
            Useful to get categories and sub_categories. A json is returned where the key can be category or sub_category, 
            and the value is a list of unique items for either both.
            """,
        ),
        Tool.from_function(
            func=get_columns_descriptions,
            name="get_columns_descriptions",
            description="""
            Useful to get the description of the columns in the rappel_conso_table table.
            """,
        ),
        Tool.from_function(
            func=get_today_date,
            name="get_today_date",
            description="""
            Useful to get the date of today.
            """,
        ),
    ]
    return tools
extra_tools = sql_agent_tools()

agent = create_sql_agent(
    llm=llm_agent,
    toolkit=toolkit,
    agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
    extra_tools=extra_tools,
    verbose=True,
)

有时候,仅靠工具描述,代理可能无法理解何时使用它们。为了解决这个问题,我们可以修改代理 LLM 提示的结尾部分,即后缀。在我们的设置中,提示有三个部分:

  1. 前缀: 这是放在工具列表之前的字符串。我们遵循默认前缀,指示代理如何根据用户的问题创建和执行 SQL 查询,设置结果数量限制为 10,仔细检查查询,并避免对数据库进行更改。

  2. 工具列表: 这一部分列出了代理可用的所有工具。

  3. 后缀: 这是我们给代理指示如何处理和思考用户问题的部分。

这是 Langchain 中 SQL ReAct 代理的默认后缀:

SQL_SUFFIX = """Begin!

Question: {input}
Thought: I should look at the tables in the database to see what I can query.  Then I should query the schema of the most relevant tables.
{agent_scratchpad}"""

inputagent_scratchpad 是两个占位符。input 代表用户的查询,agent_scratchpad 则代表工具调用的历史记录和相应的工具输出。

我们可以让“思维”部分更长一些,以提供更多关于使用哪些工具以及何时使用的指令:

CUSTOM_SUFFIX = """Begin!

Question: {input}
Thought Process: It is imperative that I do not fabricate information not present in the database or engage in hallucination; 
maintaining trustworthiness is crucial. If the user specifies a category, I should attempt to align it with the categories in the `categories_produits` 
or `sous_categorie_de_produit` columns of the `rappel_conso_table` table, utilizing the `get_categories` tool with an empty string as the argument. 
Next, I will acquire the schema of the `rappel_conso_table` table using the `sql_db_schema` tool. 
Utilizing the `get_columns_descriptions` tool is highly advisable for a deeper understanding of the `rappel_conso_table` columns, except for straightforward tasks. 
When provided with a product brand, I will search in the `nom_de_la_marque_du_produit` column; for a product type, in the `noms_des_modeles_ou_references` column. 
The `get_today_date` tool, requiring an empty string as an argument, will provide today's date. 
In SQL queries involving string or TEXT comparisons, I must use the `LOWER()` function for case-insensitive comparisons and the `LIKE` operator for fuzzy matching. 
Queries for currently recalled products should return rows where `date_de_fin_de_la_procedure_de_rappel` (the recall's ending date) is null or later than today's date. 
When presenting products, I will include image links from the `liens_vers_les_images` column, formatted strictly as:  [lien vers l'image] url1, [lien vers l'image] url2 ... Preceded by the mention in the query's language "here is(are) the image(s) :"
Additionally, the specific recalled product lot will be included from the `identification_des_produits` column. 
My final response must be delivered in the language of the user's query.

{agent_scratchpad}
"""

这样,代理不仅知道它拥有的工具,还能获得更好的指导,了解何时使用它们。

现在,让我们修改create_sql_agent的参数,以适应新的后缀:

agent = create_sql_agent(
    llm=llm_agent,
    toolkit=toolkit,
    agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
    suffix=CUSTOM_SUFFIX,
    extra_tools=agent_tools,
    verbose=True,
)

我们考虑的另一个选项是将指令包含在前缀中。然而,我们的实证观察表明,这对最终响应几乎没有影响。因此,我们选择将指令保留在后缀中。对模型输出进行更广泛的评估可能有助于对这两种方法进行详细比较。

实现记忆功能

我们为代理添加的一个有用功能是记住过去的互动。这样,代理在每次对话时就不必重新开始,特别是当查询之间有联系时。

要添加这个记忆功能,我们需要进行以下几个步骤:

  • 首先,我们导入ConversationBufferMemory类。它是一个缓冲区,用于跟踪对话历史。
from langchain.memory import ConversationBufferMemory

memory = ConversationBufferMemory(memory_key="history", input_key="input")
  • 接下来,我们更新后缀,以包含对话历史。
custom_suffix = """Begin!

Relevant pieces of previous conversation:
{history}
(Note: Only reference this information if it is relevant to the current query.)

Question: {input}
Thought Process: It is imperative that I do not fabricate information ... (same as previous suffix)

{agent_scratchpad}
"""
  • 最后,我们调整create_sql_agent函数,将历史记录添加到提示占位符中,并将记忆包含在代理执行器的参数中。
agent = create_sql_agent(
    llm=llm_agent,
    toolkit=toolkit,
    agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
    input_variables=["input", "agent_scratchpad", "history"],
    suffix=custom_suffix,
    agent_executor_kwargs={"memory": memory},
    extra_tools=agent_tools,
    verbose=True,
)

这样,代理可以利用其记忆,更好地处理对话中的相关查询。

使用 Streamlit 创建应用程序

我们将使用 Python 框架 Streamlit 来构建一个基本的 LLM 聊天应用程序。Streamlit 提供了聊天元素,可用于构建对话式应用程序。我们将使用的元素包括:

  • st.chat_input:一个聊天输入小部件,用户可以用来输入消息。

chat_input小部件的示例。图片由作者提供。

  • st.chat_message:此函数将聊天消息添加到应用程序中,显示用户或应用程序的输入。第一个参数指定消息的作者,可以选择“user”或“assistant”,以应用适当的样式和头像。

“用户”聊天信息示例。图片由作者提供。

此外,我们将利用 Streamlit 的会话状态来保持对话历史。此功能对于提供良好的用户体验至关重要,因为它能保留聊天上下文。

有关创建对话应用程序的更多细节,可以在这里找到。

由于我们指示代理始终返回图像网址,因此我们创建了一个后处理函数,该函数从这些网址获取图像,格式化输出,并使用 Streamlit 的 Markdown 和图像组件显示内容。此功能的实现细节可以在streamlit_app/gen_final_output.py模块中找到。

现在一切准备就绪,可以启动聊天应用程序。您可以执行以下命令:

streamlit run streamlit_app/app.py

未来的改进可能包括用户选择所需模型并配置 OpenAI API 密钥的选项,进一步自定义聊天体验。

观察和改进

以下是我们在与代理进行多次对话后获得的一些见解:

  • 这并不令人惊讶,但 GPT-4 确实比 GPT-3.5 强大得多。后者能够很好地处理简单查询,但往往在调用数据库相关的必要工具以提供更多上下文时遇到困难,导致频繁的幻觉。

  • 用户问题的复杂性可能使得使用 GPT-4 既昂贵又缓慢。生成像数据库架构、行数和列描述等详细信息需要消耗大量的 tokens。此外,如果需要深入的结果,例如有关最后 10 个回溯产品的信息,代理需要处理查询输出以及工具的操作和观察,这可能会非常昂贵。因此,监控使用情况以避免意外费用是非常重要的。

为了提高代理的表现,我们可以:

  • 改进我们设计提示的方式,调整后缀和/或前缀,更好地预测和高效地调用必要的工具。

  • 在提示中包括一些示例,或使用检索工具来查找与常见用户查询最相关的示例,从而减少每次新问题时反复调用相同工具的需要。

  • 添加一个评估框架,例如,根据最终答案评估大型语言模型(LLMs)的表现,或用于比较提示。

结论

总结:本文探讨了如何创建一个聊天应用程序,该应用程序利用大型语言模型(LLMs)通过 Langchain 框架与 SQL 数据库进行交互。我们使用了 ReACT 代理框架,并结合了各种 SQL 工具和其他资源,能够回应广泛的用户查询。

通过整合记忆功能并通过 Streamlit 进行部署,我们创建了一个简化复杂数据库查询的用户友好界面,使其能够以对话方式进行交互。

鉴于数据库的复杂性及其包含的大量列,我们的解决方案需要一套全面的工具和强大的 LLM。

我们已经讨论了增强聊天机器人功能的方法。此外,使用在 SQL 查询上进行微调的 LLM 可以作为使用像 GPT 这样的通用模型的替代方法。这可以使系统在处理数据库时表现得更好,帮助它更有效地解决复杂查询。

联系方式

参考文献

从零开始构建卷积神经网络(CNNs)

原文:towardsdatascience.com/building-a-convolutional-neural-network-cnns-from-scratch-3cfa453f9594?source=collection_archive---------2-----------------------#2024-11-05

一步一步,构建一个基于 MNIST-Fashion 数据集的 ResNet 分类器

Matthew GuntonTowards Data Science Matthew Gunton

·发布于 Towards Data Science ·12 分钟阅读·2024 年 11 月 5 日

--

图片来源:作者 — Flux.1

机器学习之所以是一个如此有趣的领域,其中一个原因是它使我们能够将计算逻辑应用到以前无法触及的领域。虽然计算机在处理数组和整数方面非常高效,但它们在处理突现属性时传统上不太擅长。例如,你无法仅凭屏幕上的一个像素就知道图像是一只狗。你必须综合大量数据点才能得出结论。

在过去十年中,计算机科学家通过创建计算机视觉模型——特别是卷积神经网络(CNNs)——成功地弥合了这一鸿沟。今天,我将展示如何将它们应用于图像分类。

现实世界数据的分类对于将机器学习技术集成到更典型的软件系统中非常有用。如果你从事电子商务,你可以利用这些信息自动对新产品进行分类。如果你从事医学领域,你可以用它来判断一张 X 光或 MRI 图像是否与之前需要手术的图像相似。最后,如果你在车辆中并希望安全驾驶,图像分类是目标检测和碰撞避免的关键部分。

2024 年构建数据平台

原文:towardsdatascience.com/building-a-data-platform-in-2024-d63c736cccef?source=collection_archive---------0-----------------------#2024-02-05

如何构建现代化、可扩展的数据平台,以支持您的分析和数据科学项目(更新版)

Dave MelilloTowards Data Science Dave Melillo

·发表于Towards Data Science ·阅读时间 9 分钟·2024 年 2 月 5 日

--

目录:

发生了什么变化?

自 2021 年以来,也许更好的问题是,什么没有发生变化?

在摆脱 COVID 的阴影后,我们的社会面临了无数挑战——政治和社会动荡、金融市场波动、人工智能的迅速发展,泰勒·斯威夫特(Taylor Swift)成为了…… 查阅笔记 …… *国家橄榄球联盟!?!

在过去三年中,我的生活也发生了变化。我在不同行业的数据信息挑战中摸索,凭借我的专业知识,在大公司和灵活的初创公司之间提供工作和咨询支持。

与此同时,我也花了大量精力塑造自己作为数据教育者的身份,与全球一些最著名的公司和顶级大学合作。

因此,以下是激励我撰写对原始2021 年文章进行修订的简短清单:

  • 规模

大大小小的公司正在开始达到以前仅限于 Netflix、Uber、Spotify 等巨头的规模,这些公司利用数据创造独特的服务。单纯地将数据管道和定时任务拼凑在一起,跨越不同的应用程序,已经不再有效,因此在讨论大规模数据平台时,出现了新的考量。

  • 流媒体

尽管我在 2021 年的文章中简要提到了流处理,但在 2024 年的版本中,你将看到更多的关注。我坚信数据必须跟上商业的速度,而要在现代实现这一目标,唯一的途径就是通过数据流处理。

  • 编排

我在 2021 年的文章中提到了模块化是构建现代数据平台的核心概念,但我未能强调数据编排的重要性。这一次,我专门有一整节讨论编排,以及它为何成为现代数据堆栈的自然补充。

平台

令我惊讶的是,目前仍没有单一的供应商能够主宰整个数据领域,尽管 Snowflake 通过收购和开发工作(如 Snowpipe、Snowpark、Snowplow)在尽力争取。Databricks 也在其平台上取得了显著的进展,特别是在 ML/AI 领域。

2021 年文章中的所有组件都进入了 2024 年,但即便是熟悉的条目,3 年后看起来也有些不同:

  • 集成

  • 数据存储

  • 转换

  • 编排

  • 展示

  • 运输

  • 可观察性

集成

集成类别在 2024 年获得了最大升级,分为三个逻辑子类别:

  • 批处理

  • 流处理

  • 事件处理

批处理

能够以日常或每小时的间隔处理来自不同来源的输入数据流是任何数据平台的基础。

Fivetran仍然是托管 ETL 领域无可争议的领导者,但它面临着Airbyte等新兴竞争者,以及通过加强平台功能的大型云服务商的激烈竞争。

在过去的 3 年中,Fivetran 显著改进了其核心产品,扩展了连接器库,甚至开始在轻量级编排方面有所突破,推出了像他们的dbt 集成这样的功能。

值得一提的是,许多供应商,如 Fivetran,已经将开源软件(OSS)和风险投资的最佳元素融合成一种名为“产品驱动增长”的模式,通过在产品中提供免费层,降低了进入企业级平台的门槛。

即使你解决的问题需要许多自定义的源集成,使用托管 ETL 提供商来处理大部分工作,其余部分使用自定义 Python 代码,并通过编排将所有内容整合在一起,仍然是有意义的。

流处理

Kafka/Confluent在数据流处理方面占据主导地位,但处理流数据引入了许多新的考虑因素,除了主题、生产者、消费者和代理之外,还涉及序列化、模式注册表、流处理/转化以及流式分析。

Confluent 在将成功的数据流处理所需的所有组件聚集在一个平台上做得很好,但我将指出数据平台其他层次中的流处理注意事项。

数据流处理的引入本身并不要求彻底改造数据平台的结构。实际上,批处理和流处理管道之间的协同作用对于应对数据平台在大规模应用中面临的各种挑战至关重要。解决这些挑战的关键,毫无疑问,在于数据编排。

事件处理

在许多情况下,数据平台本身需要负责,或至少需要通知,生成第一方数据的过程。许多人可能会认为这是软件工程师和应用开发者的工作,但我认为让构建数据平台的人也负责你的事件策略是一个协同的机会。

我将事件分为两类:

  • 变更数据捕获 — CDC

CDC 的基本要点是将数据库的 CRUD 命令本身作为数据流来使用。我第一次接触到的 CDC 平台是一个名为Debezium的开源项目,目前有许多大大小小的公司在这一新兴领域中争夺市场份额。

  • 点击流 — Segment/Snowplow

构建遥测以捕捉网站或应用程序上客户活动的方式就是我所指的点击流。Segment 借助点击流的浪潮实现了十亿美元收购Amplitude将点击流构建成了一个完整的分析平台,而Snowplow最近通过其开源方法大幅增长,展示了这一领域适合持续创新并最终标准化。

AWS 在数据流处理方面一直处于领先地位,提供了建立外部箱模式的模板,并构建了如MSKSQSSNSLambdasDynamoDB等数据流处理产品。

数据存储

从 2021 年到 2024 年的另一个重大变化是从“数据仓库”到“数据存储”的转变,承认数据库视野的扩展,包括数据湖的兴起。

将数据湖视为战略而非产品,强调其作为结构化和非结构化数据的暂存区的角色,可能与数据仓库交互。为数据湖的每个方面选择合适的数据存储解决方案至关重要,但更大的技术决策是将这些存储结合起来并探索它们,以将原始数据转化为下游洞察。

分布式 SQL 引擎,如 PrestoTrino 以及它们的众多托管版本(PandioStarburst),已经出现,能够跨越数据湖,允许用户使用 SQL 连接不同物理位置的多样化数据。

在追赶生成性 AI 和大语言模型趋势的过程中,像向量数据库这样的专用数据存储变得至关重要。这些包括像 Weaviate 这样的开源选项,像 Pinecone 这样的托管解决方案,以及更多的其他选择。

转换

很少有工具像 dbt 那样彻底改变了数据工程。它的影响深远,甚至催生了一种新的数据角色——分析工程师

dbt 已成为各类组织在其数据平台上自动化转换的首选工具。dbt 产品的免费版本 dbt core 的推出,在帮助数据工程师和分析师熟悉 dbt、加速其普及以及推动新特性迅速发展的过程中,起到了至关重要的作用。

在这些特性中, dbt mesh 尤为引人注目。这一创新使得多个 dbt 项目的关联和引用成为可能,帮助组织模块化其数据转换管道,特别是应对大规模数据转换的挑战。

与“静态”数据处理工具(如 dbt)相比,流式转换仍然是一个较为不成熟的领域。尽管像 Flink 这样的开源项目已经存在多年(自 2011 年以来),它们的影响力却没有像处理“静态”数据的工具那样广泛。然而,随着流数据的日益普及以及计算资源的不断进化,推进流式转换领域的需求愈加迫切。

在我看来,这一领域的广泛采用未来依赖于像 Flink SQL 这样的技术,或者来自 ConfluentDecodableVervericaAiven 等提供商的托管服务。这些解决方案使分析师能够利用熟悉的语言,如 SQL,将这些概念应用于实时流数据。

编排

回顾 2024 年构建数据平台时的数据摄取、数据存储和转换组件,突显了在众多工具、技术和解决方案中做出选择的艰巨挑战。

根据我的经验,找到适合你场景的正确迭代的关键是通过实验,这样你可以交换不同的组件,直到获得理想的结果。

数据编排在构建数据平台的初期阶段,促进实验过程变得至关重要。它不仅简化了流程,还提供了可扩展的选项,以适应任何业务的发展轨迹。

编排通常通过有向无环图(DAG)或代码来执行,这些代码构建了跨多个系统的任务层次、依赖关系和管道。同时,它还管理和扩展用于运行这些任务的资源。

Airflow 仍然是数据编排的首选解决方案,提供多种托管版本,如 MWAAAstronomer,以及一些令人鼓舞的衍生分支,如 PrefectDagster

没有编排引擎,你将无法充分模块化你的数据平台,进而解锁其全部潜力。此外,它还是启动数据可观察性和治理策略的前提条件,在整个数据平台的成功中发挥着至关重要的作用。

演示

令人惊讶的是,像 TableauPowerBILookerQlik 等传统的数据可视化平台仍然主导着这个领域。虽然数据可视化在最初经历了快速增长,但过去十年中该领域相对停滞。唯一的例外是微软,通过 PowerBI 服务 等产品,在保持相关性和创新方面作出了值得称赞的努力。

新兴的数据可视化平台,如 SigmaSuperset,感觉是通往未来的自然桥梁。它们支持即时、资源高效的转换,同时具备世界一流的数据可视化能力。然而,一位强有力的新晋者,Streamlit,有潜力重新定义一切。

Streamlit,一个强大的 Python 库,用于构建前端界面与 Python 代码的交互,已在展示层中开辟了一个有价值的市场。虽然与 PowerBI 和 Tableau 等拖放工具相比,技术学习曲线更陡峭,但 Streamlit 提供了无限的可能性,包括交互式设计元素、动态切片、内容显示以及自定义导航和品牌塑造。

Streamlit 给人的印象极为深刻,以至于 Snowflake 在 2022 年以近 10 亿美元收购了这家公司。收购后,Streamlit 如何与 Snowflake 的产品套件融合,可能会塑造 Snowflake 以及数据可视化的未来发展。

运输

运输、反向 ETL 或数据激活——数据平台的最终环节——代表了一个关键阶段,在这个阶段,平台的转换和洞察结果回流到源系统和应用中,真正影响业务操作。

目前, Hightouch 作为该领域的领军者脱颖而出。它们强大的核心产品能够无缝地将数据仓库与数据密集型应用集成在一起。值得注意的是,他们与 Snowflake 和 dbt 的战略合作伙伴关系,进一步强调了其致力于成为多功能数据工具的目标,区别于单纯的营销和销售小工具。

运输层的未来似乎注定与 API 相交,创造出通过 SQL 查询生成的 API 端点将与导出 .csv 文件共享查询结果一样普遍的场景。虽然这种转变已经被预期,但目前仍然有很少的供应商在探索这一领域的商品化。

可观察性

与数据编排类似,数据可观察性已成为捕捉和追踪数据平台不同组件生成的所有元数据的必要条件。这些元数据随后被用于管理、监控和促进平台的增长。

许多组织通过构建内部仪表盘或依赖单点故障(如数据编排管道)进行观察来解决数据可观察性问题。虽然这种方法对于基础监控可能足够,但在解决更复杂的逻辑可观察性挑战(如数据血缘追踪)时,仍显得力不从心。

这时,DataHub 作为一个受欢迎的开源项目,正在获得越来越多的关注。它的托管服务对应产品,Acryl,更是放大了其影响力。DataHub 擅长整合来自各个应用的元数据,帮助追踪组织内部数据流动的各个环节。它无缝地将这些信息结合起来,使得用户可以追溯仪表盘上的关键绩效指标(KPI),追溯到原始数据管道以及其中的每一个步骤。

Monte CarloGreat Expectations 在数据平台中承担类似的可观察性角色,但它们采用了更为主观的方式。诸如“端到端数据血缘”和“数据合同”之类术语的日益流行,预示着这一领域将迎来一波增长。我们可以预见,无论是已经确立的领导者,还是富有创新精神的新兴企业,都将推动数据可观察性领域的革命性变化。

结语

本文 2021 版本字数为 1,278 字。

本文的 2024 年版本在结尾之前已经超过了 2000 字。

我猜这意味着我应该简短一些。

构建一个既足够快速满足当今需求,又足够灵活以应对未来挑战的平台,从模块化开始,并由编排实现。为了采用最具创新性的解决方案来解决你的具体问题,你的平台必须为各种形式和大小的数据解决方案腾出空间,无论它是一个开源项目、新的托管服务,还是 AWS 提供的一整套产品。

这篇文章有很多观点,但最终的选择还是取决于你。我很期待听到这能如何激励人们探索新的可能性,并创造新的数据问题解决方式。

注意:我目前与此文中提到的任何公司没有任何关联,也没有为这些工具提供赞助。

使用 Kubernetes 构建数据科学平台

原文:towardsdatascience.com/building-a-data-science-tool-stack-with-kubernetes-00c74b491b9d?source=collection_archive---------7-----------------------#2024-07-11

Kubernetes 如何作为后端工具,支持数据科学团队实现从模型开发到部署的端到端机器学习生命周期

Avinash KanumuruTowards Data Science Avinash Kanumuru

·发布于Towards Data Science ·阅读时长 6 分钟·2024 年 7 月 11 日

--

图片来源:GrowtikaUnsplash

当我开始担任数据科学经理的新职位时,我对为团队搭建数据科学平台几乎一无所知。在我之前的所有职位中,我主要工作是构建模型,并在一定程度上进行模型部署(或者至少是支持进行模型部署的团队),但我从未需要从零开始搭建什么(我指的是基础设施)。那时,数据科学团队还不存在。

所以,我的第一个目标是搭建一个平台,不仅仅是为数据科学团队单独使用,而是能够与数据工程和软件团队集成。这就是我第一次直接接触 Kubernetes(k8s)的时候。我之前听说过它,但没有超出创建 docker 镜像的范围,其他人会在某些基础设施上进行部署。

那么,为什么数据科学团队需要 Kubernetes 呢?数据科学团队面临哪些挑战?

  • 根据需求可扩展的计算——作为数据科学家,我们每天处理不同的问题,每个问题的资源需求各不相同。没有一种通用的计算机。即使有,也不能提供给数据科学团队的每个成员。

  • 版本问题——在团队中工作时,或者在我们部署到生产环境时,Python 和包的版本问题

  • 不同的技术和平台 — 有些预处理和模型构建需要使用 Spark,而有些可以通过 Pandas 完成。所以再次强调,本地计算机上并没有适用于所有情况的“一刀切”方案。

  • 团队内部的工作共享 — 在 Excel 表格中共享和跟踪模型结果,并在每次迭代后进行分发

  • 最重要的是,生产部署 —— 我如何将完成的模型部署到生产环境中?模型往往无法用于实时场景,因为我们作为数据科学家不懂得围绕模型构建 API/系统。最终,我们往往只能在批处理模式中运行模型评分。

我探讨过包括云平台解决方案(AWS SageMaker、GCP AI Platform、Azure Machine Learning)在内的方案,但我们的主要考虑因素是成本,其次是云无关性。如果成本不是问题,那么可以使用上述提到的云平台服务。

我们发现 Kubernetes 是一个理想的平台,能够满足大多数这些需求 —— 它能扩展并服务容器化的镜像。通过这种方式,我们也保持了云无关性。如果必须迁移到其他供应商,只需最小的修改,便能轻松迁移一切。

许多工具提供了完整/类似的解决方案,比如 KubeFlow、Weights & Biases、Kedro 等,但我最终部署了以下三项服务作为数据科学平台的第一版。尽管这些工具没有提供完整的 MLOps 框架,但它们为我们提供了构建数据科学平台和团队的起点。

  1. JupyterHub — 为开发模型提供容器化的交互式 Jupyter Notebook 用户环境

  2. MLflow — 实验跟踪和存储模型工件

  3. Seldon Core — 简化的 Kubernetes 模型部署方式

通过这三个服务,我可以让我的团队在 JupyterHub 中构建模型,包括大数据处理,跟踪不同的调优参数和指标,并使用 MLflow 存储工件,通过 Seldon-Core 提供生产环境中的模型。

JupyterHub

部署是最棘手的部分。相比于 Kubernetes 安装,单独的 JupyterHub 安装要简单一些。但这里有许多必需的配置 —

[## 使用 Kubernetes 从零开始搭建 JupyterHub

JupyterHub 允许用户通过网页与计算环境进行交互。由于大多数设备都能访问...

z2jh.jupyter.org](https://z2jh.jupyter.org/?source=post_page-----00c74b491b9d--------------------------------)

由于我们希望使用 Spark 进行一些数据处理,我们创建了 2 个 Docker 镜像 —

  1. 基本笔记本 — 扩展自jupyter/minimal-notebook:python-3.9

  2. Spark 笔记本 — 在上述基础上扩展,增加了 Spark 配置。

这些笔记本 Docker 镜像的代码和使用这些 Docker 镜像安装 JupyterHub 的 Helm 配置可以在这里找到。

[## GitHub - avinashknmr/data-science-tools

通过在 GitHub 上创建账户,参与开发 avinashknmr/data-science-tools 项目。

github.com

为了启用 Google Oauth,我做了很多调整,包括将 Notebook 作为 root 用户启动,但以个别用户身份运行它们,检索用户名、用户级别的权限、持久卷声明和服务账户等,这些都花费了我几天时间才能让它工作,尤其是身份验证部分。但代码库中的这段代码,可以为你提供一个框架,帮助你开始使用。

MLflow

设置 MLFlow 很简单。

[## 什么是 MLflow?

进入机器学习(ML)领域是一次令人兴奋的旅程,但它常常伴随着一些复杂性,这可能…

mlflow.org

MLflow 提供模型追踪、模型注册和模型服务能力。但对于模型服务,我们使用下一个工具(Seldon-Core)。

构建一个包含所需 Python 包的 Docker 镜像。

FROM python:3.11-slim

RUN pip install mlflow==2.0.1 boto3==1.26.12 awscli==1.27.22 psycopg2-binary==2.9.5

EXPOSE 5000

一旦创建并推送 Docker 镜像到你选择的容器注册中心,我们会为 Kubernetes 创建一个部署和服务文件(类似于任何其他 Docker 镜像部署)。下面给出了部署 yaml 的一个片段。

containers:
- image: avinashknmr/mlflow:2.0.1
  imagePullPolicy: IfNotPresent
  name: mlflow-server
  command: ["mlflow", "server"]
  args:
  - --host=0.0.0.0
  - --port=5000
  - --artifacts-destination=$(MLFLOW_ARTIFACTS_LOCATION)
  - --backend-store-uri=postgresql+psycopg2://$(MLFLOW_DB_USER):$(MLFLOW_DB_PWD)@$(MLFLOW_DB_HOST):$(MLFLOW_DB_PORT)/$(MLFLOW_DB_NAME)
  - --workers=2

这里有两个主要的配置,花了我一些时间才理解和配置完成——

  1. artifact 的位置

  2. 后端存储

artifact 的位置将是一个 Blob 存储,你的模型文件将存储在此处,并可用于模型服务。然而,在我们的案例中,这个位置是 AWS S3,所有的模型都存储在这里,并且它对我们来说是一个模型注册中心。还有其他几种选择可以在服务器本地存储模型,但每当 Pod 重启时,数据会丢失,而且 PersistentVolume 只能通过服务器访问。通过使用云存储,我们可以与其他服务进行集成——例如,Seldon-Core 可以从这个位置加载并服务模型。后端存储则保存运行应用所需的所有元数据,包括模型追踪——每次实验/运行的参数和指标。

Seldon-Core

三者中第二个最棘手的是 Seldon-Core。

Seldon-Core 就像是你模型的封装器,它可以打包、部署和监控 ML 模型。这消除了对 ML 工程师的依赖,避免了需要他们来创建部署流水线。

[## GitHub - SeldonIO/seldon-core:一个用于打包、部署、监控和管理成千上万的生产机器学习模型的 MLOps 框架…

一个 MLOps 框架,用于打包、部署、监控和管理成千上万的生产机器学习模型…

github.com

我们使用了 Helm chart 和 Istio 进行安装,以实现 ingress。Ingress 有两种选择 —— Istio 和 Ambassador。我不会深入讲解 Istio 的设置,因为这是由 DevOps 团队完成的。Seldon 是通过以下 Helm 和 Kubectl 命令安装的。

kubectl create namespace seldon-system
kubectl label namespace seldon-system istio-injection=enabled

helm repo add seldonio https://storage.googleapis.com/seldon-charts
helm repo update

helm install seldon-core seldon-core-operator \
    --repo https://storage.googleapis.com/seldon-charts \
    --set usageMetrics.enabled=true \
    --set istio.enabled=true \
    --set istio.gateway=seldon-system/seldon-gateway \
    --namespace seldon-system

假设你已经设置好了 Istio,下面是设置我们 Seldon 的 Gateway 和 VirtualService 的 Yaml 配置。

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: seldon-gateway
  namespace: seldon-system
spec:
  selector:
    istio: ingressgateway
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - "*"
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: seldon-vs
  namespace: seldon-system
spec:
  hosts:
  - "*"
  gateways:
  - seldon-gateway
  http:
  - match:
    - uri:
        prefix: /seldon
    route:
    - destination:
        host: seldon-webhook-service.seldon-system.svc.cluster.local
        port:
          number: 8000

下面是一个示例的 k8s 部署文件,用于从 GCS 提供 iris 模型服务。如果使用 scikit-learn 包进行模型开发,模型应通过 joblib 导出,并命名为 model.joblib

apiVersion: machinelearning.seldon.io/v1
kind: SeldonDeployment
metadata:
  name: iris-model
  namespace: prod-data-science
spec:
  name: iris
  predictors:
  - graph:
      implementation: SKLEARN_SERVER
      modelUri: gs://seldon-models/v1.16.0-dev/sklearn/iris
      name: classifier
    name: default
    replicas: 1

在这个示例中,我们使用 SKLEARN_SERVER,但它也支持 MLFLOW_SERVER 和 TF_SERVER,分别用于 MLflow 和 TensorFlow。

Seldon-Core 不仅支持 REST API,还支持 gRPC,能够实现无缝的服务器间调用。

结论

这些工具是开源的,可以部署在 Kubernetes 上,因此对于小团队来说具有成本效益,并且是云中立的。它们解决了数据科学团队的大部分挑战,比如集中式的 Jupyter Notebook 用于协作,避免版本问题,同时无需专门的机器学习工程师就能提供模型服务。

JupyterHub 和 Seldon-Core 都利用了 Kubernetes 的能力。JupyterHub 会在用户登录时启动一个 Pod,并在空闲时将其销毁。Seldon-Core 会将模型封装并在几分钟内作为 API 提供服务。MLflow 是唯一一个独立安装的工具,它连接了模型开发和模型部署。MLflow 作为一个模型注册库,用于追踪模型并存储后续使用的工件。

构建数据仓库

原文:towardsdatascience.com/building-a-data-warehouse-9696b238b2da?source=collection_archive---------5-----------------------#2024-02-24

面向初学者的最佳实践和高级技术

💡Mike ShakhomirovTowards Data Science 💡Mike Shakhomirov

·发表于Towards Data Science ·12 分钟阅读·2024 年 2 月 24 日

--

AI 生成的图像,使用Kandinsky

在这个故事中,我想谈谈数据仓库设计以及我们如何组织这个过程。数据建模是数据工程中的一个重要部分。它定义了数据库结构、我们使用的模式以及用于分析的数据物化策略。设计得当时,它有助于确保我们的数据仓库高效运行,满足所有业务需求和成本优化目标。我们将通过使用 dbt 工具作为示例,讨论一些数据仓库设计中的知名最佳实践。我们还将更深入地探讨如何组织构建过程,测试我们的数据集,并使用宏的高级技术来更好地集成工作流和部署。

结构

假设我们有一个数据仓库,并且需要处理仓库中的大量 SQL 数据。

在我的案例中,使用的是 Snowflake。它是一个很棒的工具,也是当前市场上最流行的解决方案之一,绝对位列前三名。

那么,我们如何构建我们的数据仓库项目结构呢?请看下面这个初学者项目文件夹结构。这是我们运行dbt init命令后得到的结构。

.
├── README.md
├── analyses
├── dbt_project.yml
├── logs
│   └── dbt.log
├── macros
├──…

使用 LangGraph 构建幻想足球研究代理

原文:towardsdatascience.com/building-a-fantasy-football-research-agent-with-langgraph-ad8deb0126f1?source=collection_archive---------7-----------------------#2024-12-05

一份全面的指南,涵盖了与 Sleeper API 的集成、Streamlit 用户界面的创建以及通过 AWS CDK 的部署。

Evan DiewaldTowards Data Science Evan Diewald

·发布于 Towards Data Science ·阅读时长 9 分钟·2024 年 12 月 5 日

--

照片由 Dmitriy Demidov 提供,来源于 Unsplash

我花费如此多时间思考我的幻想足球队,真是令人尴尬。

管理一个队伍意味着要处理海量的信息——伤病报告、专家预测、即将到来的休赛周和有利的对阵情况。而且这不仅仅是数据量的问题,更是数据的短暂性——如果你的明星跑卫在周三的训练中拉伤了大腿筋,你最好不要根据周二的报告做出阵容决策。

这就是为什么像 Anthropic 的 Claude 和 OpenAI 的 ChatGPT 这样的通用聊天机器人在幻想足球推荐中基本上无用的原因,因为它们仅限于一个静态的训练语料库,该语料库早在几个月甚至几年前就已经停止更新。

例如,如果我们询问 Claude Sonnet 3.5 谁是当前最好的跑卫,我们会看到像 Christian McCaffrey、Breece Hall 和 Travis Etienne 这样的名字,这些球员在 2024 年的赛季中都受到伤病困扰或表现令人失望。此时没有提到 Saquon Barkley 或 Derrick Henry——这两位明显的领跑者。(不过值得一提的是,Claude 会披露其局限性。)

Perplexity 这样的应用程序更为准确,因为它们确实访问了一个拥有最新信息的搜索引擎。然而,它当然不了解我的整个阵容情况、我们联盟的季后赛局势或我们守护规则的细微差别。

有机会为每个用户定制一个以幻想足球为主题的代理,提供工具和个性化的上下文。

让我们深入探讨实现过程。

架构概览

聊天机器人的核心将是一个基于ReAct框架的LangGraph代理。我们将让它访问与Sleeper API集成的工具,用于常见操作,如查看联盟排名、名单、球员统计、专家分析等。

除了 LangGraph API 服务器,我们的后端还将包括一个小型 Postgres 数据库和 Redis 缓存,用于管理状态和路由请求。我们将使用Streamlit来构建一个简单但有效的用户界面。

在开发过程中,我们可以通过Docker Compose在本地运行所有这些组件,但我也将展示基础设施即代码(IaC)来部署一个可扩展的堆栈,使用AWS CDK

Sleeper API 集成

Sleeper 慷慨地提供了一个公开的只读 API,我们可以利用它获取用户和联盟的详细信息,包括完整的球员名单、名单和选秀信息。尽管没有明确文档化,但我还发现了一些 GraphQL 端点,提供关键的统计数据、预测以及——也许最有价值的——NFL 记者的最新专家分析。

我创建了一个简单的 API 客户端来访问各种方法,你可以在这里找到它。我想要强调的一个技巧是requests-cache 。我不想成为 Sleeper 免费数据集的贪婪客户端,所以我将响应缓存到本地的 Sqlite 数据库,并使用基本的 TTL 机制。

这样不仅减少了重复的 API 流量对 Sleeper 服务器的压力(降低了他们将我的 IP 地址列入黑名单的机会),而且大大降低了我的客户端的延迟,提供更好的用户体验。

设置和使用缓存非常简单,正如你在这个代码片段中看到的那样——

import requests_cache
from urllib.parse import urljoin
from typing import Union, Optional
from pathlib import Path

class SleeperClient:
    def __init__(self, cache_path: str = '../.cache'):

        # config
        self.cache_path = cache_path
        self.session = requests_cache.CachedSession(
            Path(cache_path) / 'api_cache', 
            backend='sqlite',
            expire_after=60 * 60 * 24,
        )

        ...

    def _get_json(self, path: str, base_url: Optional[str] = None) -> dict:
        url = urljoin(base_url or self.base_url, path)
        return self.session.get(url).json()

    def get_player_stats(self, player_id: Union[str, int], season: Optional[int] = None, group_by_week: bool = False):
        return self._get_json(
            f'stats/nfl/player/{player_id}?season_type=regular&season={season or self.nfl_state["season"]}{"&grouping=week" if group_by_week else ""}',
            base_url=self.stats_url,
        )

所以运行类似的命令

self.session.get(url)

首先检查本地 Sqlite 缓存中是否有未过期的响应。如果找到,我们可以跳过 API 调用,直接从数据库中读取。

定义工具

我想把 Sleeper API 客户端转化为一组关键功能,供代理使用以提供响应。因为这些功能将由 LLM 有效地调用,所以我认为为它们做清晰的注解并要求简单、灵活的参数是很重要的。

例如,Sleeper 的 API 通常要求提供数字化的球员 ID,这对于编程接口是有意义的。然而,我希望将这个概念从 LLM 中抽象出来,让它仅仅输入球员的名字进行这些功能。为了确保额外的灵活性,并允许像拼写错误这样的情况,我实现了一种基本的“模糊搜索”方法,将球员名字的搜索映射到他们对应的球员 ID。

# file: fantasy_chatbot/league.py

def get_player_id_fuzzy_search(self, player_name: str) -> tuple[str, str]:
  # will need a simple search engine to go from player name to player id without needing exact matches. returns the player_id and matched player name as a tuple
  nearest_name = process.extract(query=player_name, choices=self.player_names, scorer=fuzz.WRatio, limit=1)[0]
  return self.player_name_to_id[nearest_name[0]], self.player_names[nearest_name[2]]

# example usage in a tool
def get_player_news(self, player_name: Annotated[str, "The player's name."]) -> str:
    """
    Get recent news about a player for the most up-to-date analysis and injury status.
    Use this whenever naming a player in a potential deal, as you should always have the right context for a recommendation.
    If sources are provided, include markdown-based link(s)
    (e.g. [Rotoballer](https://www.rotoballer.com/player-news/saquon-barkley-has-historic-night-sunday/1502955) )
    at the bottom of your response to provide proper attribution
    and allow the user to learn more.
    """
    player_id, player_name = self.get_player_id_fuzzy_search(player_name)
    # news
    news = self.client.get_player_news(player_id, limit=3)
    player_news = f"Recent News about {player_name}\n\n"
    for n in news:
        player_news += f"**{n['metadata']['title']}**\n{n['metadata']['description']}"
        if analysis := n['metadata'].get('analysis'):
            player_news += f"\n\nAnalysis:\n{analysis}"
        if url := n['metadata'].get('url'):
            # markdown link to source
            player_news += f"\n[{n['source'].capitalize()}]({url})\n\n"

   return player_news

这比简单的名称到球员 ID 的映射更好,因为它允许拼写错误和其他打字错误,例如saquonSaquon Barkley

我基于这些原则创建了一些有用的工具:

  • 获取联盟状态(排名、当前周数、季后赛球队数等)

  • 获取队伍拥有者的名单

  • 获取球员新闻(关于球员的最新文章/分析)

  • 获取球员数据(本赛季每周得分以及对阵情况)

  • 获取球员当前拥有者(对于提出交易至关重要)

  • 获取每个位置上的最佳可用球员(自由球员市场)

  • 获取球员排名(到目前为止的表现,按位置分类)

你可能还能想到一些有用的功能可以添加,比如关于最近交易、联盟对战情况和选秀信息的详细数据。

LangGraph 代理

整个项目的推动力来源于一个学习 LangGraph 生态系统的机会,这可能正在成为构建智能工作流的事实标准。

我过去曾从零开始构建代理,如果当时我知道 LangGraph 就好了。它不仅仅是一个薄的封装层,围绕着各种 LLM 提供者,它为构建、部署和监控复杂工作流提供了巨大的实用性。如果你有兴趣深入了解,可以查看 LangChain Academy 的LangGraph 简介课程。

如前所述,图形本身基于 ReAct 框架,这是一种流行且有效的方法,可以使大语言模型(LLM)与外部工具进行交互,例如上述定义的工具。

我还添加了一个节点,用于持久化每个用户的长期记忆,以便信息可以跨会话保存。我希望我们的代理能够“记住”用户的关注点、偏好和之前推荐的交易,因为这是我在见过的聊天机器人中并没有特别好实现的功能。以图形的形式,它看起来是这样的:

很简单吧?再说一次,你可以查看完整的图定义在代码中,但我会重点介绍write_memory节点,它负责为每个用户写入和更新个人资料。这使我们能够在有效利用令牌的同时,跟踪关键的交互。

def write_memory(state: MessagesState, config: RunnableConfig, store: BaseStore):
    """Reflect on the chat history and save a memory to the store."""

    # get the username from the config
    username = config["configurable"]["username"]

    # retrieve existing memory if available
    namespace = ("memory", username)
    existing_memory = store.get(namespace, "user_memory")

    # format the memories for the instruction
    if existing_memory and existing_memory.value:
        memory_dict = existing_memory.value
        formatted_memory = (
            f"Team Name: {memory_dict.get('team_name', 'Unknown')}\n"
            f"Current Concerns: {memory_dict.get('current_concerns', 'Unknown')}"
            f"Other Details: {memory_dict.get('other_details', 'Unknown')}"
        )
    else:
        formatted_memory = None

    system_msg = CREATE_MEMORY_INSTRUCTION.format(memory=formatted_memory)

    # invoke the model to produce structured output that matches the schema
    new_memory = llm_with_structure.invoke([SystemMessage(content=system_msg)] + state['messages'])

    # overwrite the existing user profile
    key = "user_memory"
    store.put(namespace, key, new_memory)

这些记忆被展示在系统提示中,我在其中还向 LLM 提供了有关我们联盟的基本信息,以及我希望它如何处理常见的用户请求。

Streamlit 用户界面和演示

我不是前端开发人员,因此用户界面 heavily 依赖于 Streamlit 的组件和常见的聊天机器人模式。用户输入他们的 Sleeper 用户名,用于查找可用的联赛并在不同的线程间持久化记忆。

我还添加了一些附加功能,比如实现了令牌流式传输,以便用户能够从 LLM 中获得即时反馈。另一个重要部分是“研究面板”,它展示了代理工具调用的结果,用户可以检查每个回应背后的原始数据。

这是一个快速演示。

部署

对于开发,我建议通过提供的 docker-compose.yml 文件将组件本地部署。这将在 http://localhost:8123 上本地暴露 API,因此您可以快速测试更改并从本地 Streamlit 应用程序连接。

我还包含了一个基于 AWS CDK 的 IaC,用于将应用程序托管到互联网。大多数资源定义见此处。请注意 docker-compose.yml 和与 ECS 设置相关的 CDK 代码之间的相似之处:

docker-compose.yml 中的 LangGraph API 容器片段:

# from docker-compose.yml

langgraph-api:
    image: "fantasy-chatbot"
    ports:
        - "8123:8000"
    healthcheck:
        test: curl --request GET --url http://localhost:8000/ok
        timeout: 1s
        retries: 5
        interval: 5s
    depends_on:
        langgraph-redis:
            condition: service_healthy
        langgraph-postgres:
            condition: service_healthy
    env_file: "../.env"
    environment:
        REDIS_URI: redis://langgraph-redis:6379
        POSTGRES_URI: postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable// file: fantasy-football-agent-stack.ts

这里是 CDK 堆栈中类似的设置:

// fantasy-football-agent-stack.ts

const apiImageAsset = new DockerImageAsset(this, 'apiImageAsset', {
  directory: path.join(__dirname, '../../fantasy_chatbot'),
  file: 'api.Dockerfile',
  platform: assets.Platform.LINUX_AMD64,
});
const apiContainer = taskDefinition.addContainer('langgraph-api', {
  containerName: 'langgraph-api',
  image: ecs.ContainerImage.fromDockerImageAsset(apiImageAsset),
  portMappings: [{
    containerPort: 8000,
  }],
  environment: {
    ...dotenvMap,
    REDIS_URI: 'redis://127.0.0.1:6379',
    POSTGRES_URI: 'postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable'
  },
  logging: ecs.LogDrivers.awsLogs({
    streamPrefix: 'langgraph-api',
  }),
});

apiContainer.addContainerDependencies(
  {
    container: redisContainer,
    condition: ecs.ContainerDependencyCondition.HEALTHY,
  },
  {
    container: postgresContainer,
    condition: ecs.ContainerDependencyCondition.HEALTHY,
  },
)

除了一些微妙的差异,它实际上是 1:1 的翻译,这也是我在比较本地环境与“生产”部署时总是关注的地方。DockerImageAsset 是一个特别有用的资源,因为它在合成期间处理构建和部署(到 ECR)Docker 镜像的工作。

注意:通过npm run cdk deploy将堆栈部署到您的 AWS 账户将会产生费用。在这个演示代码中,我没有为 Streamlit 应用程序添加任何密码保护,这意味着任何拥有 URL 的人都可以使用聊天机器人!如果您计划自己部署,强烈建议添加一些额外的安全措施。

重点

你需要保持工具的简洁性。这个应用做了很多事情,但仍然缺少一些关键功能,如果我只是添加更多工具,它会开始崩溃。未来,我希望将图形拆分成任务特定的子组件,例如“新闻分析师”代理和“统计学家”代理。

可追溯性和调试在基于代理的应用程序中比传统软件更为重要。尽管模型在生成结构化输出方面取得了显著进展,但基于 LLM 的函数调用仍然本质上不如传统程序可靠。我在调试过程中广泛使用了 LangSmith。

在语言模型商品化的时代,可靠的记者无可替代。 我们正处于这样一个阶段,任何人都能在一个周末内制作出一个合理的聊天机器人,那么产品如何区分自己并建立护城河呢?没有分析师和专家提供的高质量报道,这个应用程序(或任何类似的应用程序)将毫无用处。换句话说,伊恩·拉帕波特(Ian Rapaport)和马修·贝里(Matthew Berry)这样的专家比以往任何时候都更加珍贵。

Repo

[## GitHub - evandiewald/fantasy-football-agent

在 GitHub 上创建帐户,为 evandiewald/fantasy-football-agent 开发做出贡献。

github.com

所有图片,除非另有说明,均为作者提供。

从零开始构建知识图谱,使用大型语言模型(LLMs)

原文:towardsdatascience.com/building-a-knowledge-graph-from-scratch-using-llms-f6f677a17f07?source=collection_archive---------0-----------------------#2024-11-25

使用大型语言模型(LLMs)将你的 Pandas 数据框架转化为知识图谱。从零开始构建自己的 LLM 图谱构建器,使用 LangChain 实现 LLMGraphTransformer,并对你的知识图谱进行问答(QA)。

Cristian LeoTowards Data Science Cristian Leo

·发布于 Towards Data Science ·36 分钟阅读·2024 年 11 月 25 日

--

来自维基百科的 1000 部电影知识图谱 — 作者提供的图片

在当今的人工智能世界中,知识图谱变得越来越重要,因为它们支撑了许多大型语言模型(LLMs)背后的知识检索系统。许多公司的数据科学团队正在大力投资检索增强生成(RAG),因为这是一种提高 LLM 输出准确性并防止幻觉生成的高效方法。

但事情远不止如此;从个人角度来看,图谱增强生成(graph-RAG)正在让人工智能领域变得更加开放和民主化。这是因为,在此之前,如果我们想要定制一个模型以适应某个应用场景——无论是为了娱乐还是商业——我们通常会有三种选择:对模型进行预训练,以便为你的应用场景所在行业的数据集提供更大的曝光,针对特定数据集进行微调,或者使用上下文提示。

至于预训练,这个选项极其昂贵且技术要求高,对于大多数开发者来说,并不是一个可行的选择。

微调比预训练更简单,尽管微调的成本取决于模型和训练语料库,但通常来说,它是一个更为经济的选择。这个选项是……

使用 LLM 和神经网络在你的 CPU 笔记本上构建本地语音助手

原文:towardsdatascience.com/building-a-local-voice-assistant-with-llms-and-neural-networks-on-your-cpu-laptop-95a876c11130?source=collection_archive---------6-----------------------#2024-11-19

使用 Python 运行轻量级 LLM 的实用指南

Yu-Cheng TsaiTowards Data Science Yu-Cheng Tsai

·发表于Towards Data Science ·6 分钟阅读·2024 年 11 月 19 日

--

图片来自Jacek Dylag,来自Unsplash

请享受阅读:免费链接!

随着多模态大型语言模型(LLM)的崛起,我们现在可以通过多种方式与它们互动,不仅限于输入文本,还可以使用音频输入。OpenAI 最近为 ChatGPT 推出了语音功能,允许用户直接与聊天平台进行对话。这为围绕这一功能构建各种新颖的应用和机会开辟了无数可能性。

作为机器学习和数据科学的从业者,现在是一个令人兴奋的时刻。使用 OpenAI 的实时语音转语音 API,你可以创建一个由这些多模态 LLM 支持的语音助手。然而,如果你对开源库感兴趣,你也可以在本地环境中构建一个语音助手,并且不需要订阅专有的 API!

为什么选择本地语音助手?

  1. 数据隐私

  2. 无 API 调用限制

  3. 微调模型

首先,我相信大多数使用主流生成式 AI 聊天机器人的人都清楚他们的数据是通过这些服务器传输的。很多人可能会对数据隐私问题感到担忧……

从零开始建立一个营销数据科学团队

原文:towardsdatascience.com/building-a-marketing-data-science-team-from-scratch-9988fc30ad89?source=collection_archive---------4-----------------------#2024-05-23

这不是关于炫酷的机器学习。评估出最大的挑战是什么。解决其中的 20%。其余的将自然跟进。

Jose ParreñoTowards Data Science Jose Parreño

·发布于Towards Data Science ·阅读时间:9 分钟·2024 年 5 月 23 日

--

2022 年,我在 Skyscanner 工作了几年,担任经理大约 4 年。突然间,我获得了一个机会,完全跳出我的领域知识,负责从零开始建立一个营销数据科学团队。这是一个不小的挑战,因为在此之前,数据科学并没有在营销部门下设立团队。这给我带来了一些令我忐忑不安的影响:

  1. 谁是主要决策者,我如何与他们建立良好的工作关系?

  2. 我该从哪里开始?第一个项目应该是什么?

  3. 我没有团队,那我该如何增加价值来推动更多的人员扩充?

  4. 我没有营销领域的知识,如何加速这个学习过程?

在这篇博客中,我想与大家分享,在这两年的时间里,我是如何从只有我一个人的情况开始,逐步建立一个包含我自己和 6 位数据科学家的营销数据科学团队的。

历史背景:为什么公司想要资助一个新的数据科学营销团队?

当时,构建营销数据科学团队的请求刚刚提出时,我们在 Skyscanner 的数据科学部门已有 4 个主要团队,分别专注于(1)推荐…

使用 LangChain 代理构建数学应用

原文:towardsdatascience.com/building-a-math-application-with-langchain-agents-23919d09a4d3?source=collection_archive---------0-----------------------#2024-03-19

一篇关于为什么大语言模型(LLMs)在数学方面存在困难,以及如何使用 LangChain 代理、OpenAI 和 Chainlit 解决这些限制的教程

Tahreem RasulTowards Data Science Tahreem Rasul

·发表于 Towards Data Science ·阅读时间 12 分钟·2024 年 3 月 19 日

--

在本教程中,我将演示如何使用 LangChain 代理创建一个自定义数学应用,利用 OpenAI 的 GPT3.5 模型。对于应用的前端,我将使用 Chainlit,这是一个易于使用的开源 Python 框架。这个生成型数学应用,暂且称之为“数学达人”,旨在帮助用户解决数学或推理/逻辑问题。

“数学达人”应用的架构图。图示由作者提供。

为什么大语言模型在数学方面存在困难?

大语言模型(LLMs)在数学以及推理任务方面表现得非常差,这是许多语言模型的共同特征。造成这种情况的原因有几种:

  • 缺乏训练数据: 其中一个原因是它们训练数据的局限性。语言模型虽然是在庞大的文本数据集上进行训练的,但可能缺乏足够的数学问题和解答。这可能导致对数字的误解、忽略重要的计算步骤,以及缺乏定量推理能力。

  • 缺乏数字表示: 另一个原因是大语言模型被设计为理解和生成文本,操作的是符号而非数字…

构建多用途的生成式 AI 驱动聊天机器人

原文:towardsdatascience.com/building-a-multi-purpose-genai-powered-chatbot-db20f1f81d90?source=collection_archive---------5-----------------------#2024-02-07

利用 SageMaker 推理组件高效地处理多个 LLM

Ram VegirajuTowards Data Science Ram Vegiraju

·发表于Towards Data Science ·阅读时间 10 分钟·2024 年 2 月 7 日

--

图片来自Unsplash

大型语言模型(LLMs)功能强大,可以帮助解决各种自然语言处理(NLP)任务,如问答、摘要、实体提取等。随着生成式 AI 应用场景的不断扩展,现实世界的应用往往需要能够解决多个 NLP 任务。例如,如果你有一个供用户使用的聊天机器人,一个常见的需求是总结与聊天机器人的对话。这在许多场景下都非常有用,比如医生与患者的对话记录、虚拟电话/预约等。

我们如何构建一个能够解决这些问题的系统呢?我们可以使用多个大型语言模型(LLMs),一个用于问答,另一个用于摘要。另一种方法是使用相同的 LLM,并在不同的领域进行微调,但我们将在这个用例中专注于前者方法。不过,使用多个 LLM 也会带来一些必须解决的挑战。

即使仅托管一个模型也需要大量计算资源,并且需要大规模的 GPU 实例。如果使用多个 LLM,则需要为每个 LLM 提供持续的端点/硬件。这还会导致管理多个端点的开销,并且需要为此付费……

使用 LangGraph 构建多语言多代理聊天应用 — 第一部分

原文:towardsdatascience.com/building-a-multilingual-multi-agent-chat-application-using-langgraph-i-262d40df6b4f?source=collection_archive---------6-----------------------#2024-09-06

在这个三部分的系列中,了解如何构建一个基于 RAG 的、多语言的、代理驱动的聊天应用,并与集成的 AI 助手一起流畅地完成职场任务

Roshan SanthoshTowards Data Science Roshan Santhosh

·发表于 Towards Data Science ·阅读时间:10 分钟·2024 年 9 月 6 日

--

背景

尽管科技不断进步,但语言障碍在今天的世界依然存在。无论是在工作中还是在外面,总有一些场景会因为语言差异而导致尴尬的局面。对于跨多个地区、讲不同语言的大型企业尤其如此。作为最近由 Cohere AI 研究社区组织的 Aya Expedition 的一部分,我有机会参与了一个旨在解决这一语言障碍以及其他职场低效问题的项目,通过开发一款多语言的职场代理聊天应用来解决这一问题。

与其多谈产品,我认为介绍该产品及我们将在本系列中构建的内容的最佳方式是实际观看它的运行。

聊天应用的最终演示

以下教程系列涵盖了该应用程序的开发,包括:

  1. 用于将内容翻译成用户首选语言的代理工作流

  2. 为 AI 助手构建功能:基于 RAG 的问答、随时文档和智能总结功能

  3. 通过 FastAPI 部署代理工作流,并开发一个 Web 用户界面与其进行交互

高级框架

考虑到 LangChain 及其基于图的对应工具 LangGraph 的流行,我不想将其变成一个讲解这些工具及其方法基础的教程。相反,我希望更多地关注在通过这些工具实现解决方案时所面临的设计选择和挑战,因为我认为这在长期来看会更有用。

LangChain 与 LangGraph

我们面临的第一个设计选择是选择 LangChain 还是 LangGraph。

在一个简单的场景下(如下图所示),每个用户提供的消息都发送给所有其他用户,并被翻译成他们偏好的语言,那么 LangChain 将是一个足够的选择。这是一个单向流动,从用户发送消息开始,到用户接收到消息结束:

没有 Aya 的单向信息流

然而,在我们的场景中,主要的限制是包含一个 AI 助手,我们称之为 Aya(以“远征”命名)。Aya 被计划成为此聊天应用的重要组成部分,并为我们的系统增加了新的复杂性。通过 Aya,发送用户的消息需要被分析,并根据消息的性质(如果是发给 Aya 的命令),系统需要发送回一个消息,而这个消息又需要再次发送给接收用户。

有 Aya 的情况下的信息流

定义一个运行(Run): 这里另一个相关的设计选择是定义一次“运行”或一次“迭代”消息循环。

在我们选择的定义中,我们认为每次运行由任何用户发送消息启动,并在所有与该初始消息相关的消息到达接收用户时结束。

所以,如果是一个没有提及 Aya 的消息,仅仅是直接发给其他用户的消息,那么当所有用户接收到初始翻译消息时,这次运行就算结束。而如果是一个涉及 Aya 的消息,那么当初始消息以及 Aya 的回复传递给所有用户时,运行才算结束。

因此,采用这种设计选择/定义一次运行时,我们希望有一个流程,在等待 Aya 生成并推送回应给用户后再终止运行。为了实现这样的流程,我们使用了 LangGraph,因为它专门为解决这种情况而构建。

构建代理

本应用的核心是代理及其相互作用。总体而言,我们有两种不同类型的代理:

  1. 用户代理:附加在每个用户上的代理,主要任务是将收到的消息翻译成用户偏好的语言

  2. Aya 代理:与 Aya 相关的各种代理,每个代理有其特定的角色/任务

用户代理

UserAgent 类用于定义一个代理,每个用户在聊天室中都会与之关联。UserAgent 类实现的部分功能包括:

1. 将传入的消息翻译成用户首选语言

2. 当用户发送消息时,激活/调用图

3. 维护聊天历史,以帮助提供翻译任务的上下文,从而实现“上下文感知”翻译

class UserAgent(object):

    def __init__(self, llm, userid, user_language):
        self.llm = llm
        self.userid = userid
        self.user_language = user_language
        self.chat_history = []

        prompt = ChatPromptTemplate.from_template(USER_SYSTEM_PROMPT2)

        self.chain = prompt | llm

    def set_graph(self, graph):
        self.graph = graph

    def send_text(self,text:str, debug = False):

        message = ChatMessage(message = HumanMessage(content=text), sender = self.userid)
        inputs = {"messages": [message]}
        output = self.graph.invoke(inputs, debug = debug)
        return output

    def display_chat_history(self, content_only = False):

        for i in self.chat_history:
            if content_only == True:
                print(f"{i.sender} : {i.content}")
            else:
                print(i)

    def invoke(self, message:BaseMessage) -> AIMessage:

        output = self.chain.invoke({'message':message.content, 'user_language':self.user_language})

        return output

在大多数情况下,UserAgent 的实现是标准的 LangChain/LangGraph 代码:

  • 定义一个 LangChain 链(一个提示模板 + LLM),负责进行实际的翻译。

  • 定义一个 send_text 函数,用于在用户发送新消息时调用图

在大多数情况下,该代理的性能取决于 LLM 的翻译质量,因为翻译是该代理的主要目标。而 LLM 的翻译性能会因语言的不同而有显著差异。某些低资源语言在一些模型的训练数据中代表性较差,这会影响这些语言的翻译质量。

Aya 代理

对于 Aya,我们实际上有一个由多个独立代理组成的系统,它们共同协作以实现整体助手功能。具体来说,我们有

  1. AyaSupervisor:控制代理,负责监督其他 Aya 代理的运行。

  2. AyaQuery:用于运行基于 RAG 的问答代理

  3. AyaSummarizer:用于生成聊天总结和进行任务识别的代理

  4. AyaTranslator:用于将消息翻译成英语的代理

class AyaTranslator(object):

    def __init__(self, llm) -> None:
        self.llm = llm 
        prompt = ChatPromptTemplate.from_template(AYA_TRANSLATE_PROMPT)
        self.chain = prompt | llm 

    def invoke (self, message: str) -> AIMessage:
        output = self.chain.invoke({'message':message})
        return output

class AyaQuery(object):

    def __init__(self, llm, store, retriever) -> None:
        self.llm = llm
        self.retriever = retriever
        self.store = store
        qa_prompt = ChatPromptTemplate.from_template(AYA_AGENT_PROMPT)
        self.chain = qa_prompt | llm

    def invoke(self, question : str) -> AIMessage:

        context = format_docs(self.retriever.invoke(question))
        rag_output = self.chain.invoke({'question':question, 'context':context})
        return rag_output

class AyaSupervisor(object):

    def __init__(self, llm):

        prompt = ChatPromptTemplate.from_template(AYA_SUPERVISOR_PROMPT)
        self.chain = prompt | llm

    def invoke(self, message : str) -> str:
        output = self.chain.invoke(message)
        return output.content

class AyaSummarizer(object):

    def __init__(self, llm):

        message_length_prompt = ChatPromptTemplate.from_template(AYA_SUMMARIZE_LENGTH_PROMPT)
        self.length_chain = message_length_prompt | llm 

        prompt = ChatPromptTemplate.from_template(AYA_SUMMARIZER_PROMPT)
        self.chain = prompt | llm

    def invoke(self, message : str, agent : UserAgent) -> str:

        length = self.length_chain.invoke(message)

        try:
            length = int(length.content.strip())
        except:
            length = 0

        chat_history = agent.chat_history

        if length == 0:
            messages_to_summarize = [chat_history[i].content for i in range(len(chat_history))]
        else:
            messages_to_summarize = [chat_history[i].content for i in range(min(len(chat_history), length))]

        print(length)
        print(messages_to_summarize)

        messages_to_summarize = "\n ".join(messages_to_summarize)

        output = self.chain.invoke(messages_to_summarize)
        output_content = output.content 

        print(output_content)

        return output_content

这些代理大多数有类似的结构,主要由一个 LangChain 链组成,该链包含一个自定义提示和一个 LLM。例外情况包括 AyaQuery 代理,它有一个额外的向量数据库检索器来实现 RAG,和 AyaSummarizer,它在其中实现了多个 LLM 功能。

设计考虑

AyaSupervisor 代理的角色:在图的设计中,我们有一个从 Supervisor 节点到用户节点的固定边。这意味着所有到达 Supervisor 节点的消息都会推送到用户节点本身。因此,在 Aya 被提及的情况下,我们必须确保只有 Aya 的最终输出被推送给用户。我们不希望中间消息(如果有的话)到达用户。因此,我们有 AyaSupervisor 代理,它作为 Aya 代理的单一接触点。这个代理主要负责解释传入消息的意图,将消息引导到适当的任务特定代理,并将最终消息输出与用户共享。

AyaSummarizer 的设计:与其他 Aya 代理相比,AyaSummarizer 代理稍微复杂一些,因为它执行了一个两步过程。在第一步中,代理首先确定需要总结的消息数量,这是一个带有自己提示的 LLM 调用。在第二步中,一旦我们知道了需要总结的消息数量,我们就将所需的消息整理起来并传递给 LLM 生成实际的总结。除了总结外,在这一步中,LLM 还会识别消息中存在的任何待办事项,并单独列出。

所以大体上有三个任务:确定需要总结的消息长度、总结消息、识别待办事项。然而,鉴于第一个任务对于没有任何明确示例的 LLM 来说有些困难,我决定将其作为单独的 LLM 调用,然后将后两个任务合并为一个 LLM 调用。

可能有办法消除额外的 LLM 调用,并将所有三个任务合并为一个调用。潜在的选项包括:

  1. 提供非常详细的示例,一步涵盖所有三个任务

  2. 生成大量示例,实际微调 LLM,使其能够在此任务中表现良好

AyaTranslator 的作用:关于 Aya 的一个目标是使其成为一个多语言 AI 助手,可以使用用户的首选语言进行交流。然而,在 Aya 代理内部处理不同语言会很困难。具体来说,如果 Aya 代理的提示是英语,而用户的消息是其他语言,这可能会引发问题。因此,为了避免这种情况,作为过滤步骤,我们将任何传入 Aya 的用户消息翻译成英语。因此,Aya 代理组内部的所有工作都是用英语进行的,包括输出。我们不需要将 Aya 的输出翻译回原语言,因为当消息到达用户时,用户代理会负责将消息翻译成各自分配的语言。

提示设计

在提示设计方面,大部分工作集中在让 LLM 以一致的方式输出特定格式的响应。在大多数情况下,我通过提供明确的指示实现了这一目标。在某些情况下,仅凭指示不足,我不得不提供示例,以确保代理的一致性。

大部分情况下,提示模板具有以下结构:

[High level task definition] You are an AI assistant that answers user's questions... 

[List of specific constraints related to the response]
Obey the following rules : 
1\. ....

[Providing context/user input]
Message : 

以用户代理使用的提示为例:

You are a {user_language} translator, translating a conversation between work colleagues. Translate the message provided by the user into {user_language}. 

Obey the following rules : 
1\. Only translate the text thats written after 'Message:' and nothing else
2\. If the text is already in {user_language} then return the message as it is.
3\. Return only the translated text
4\. Ensure that your translation uses formal language

Message:
{message}

关于这个代理,一个重要的约束是确保模型只输出翻译后的文本,而不会输出任何支持性文本,如“这是翻译后的文本”或“当然,以下是提供文本的翻译”。在这种情况下,添加一个特定的规则来遵守(规则#3)足以确保模型只输出翻译后的文本,而不会输出其他内容。

需要在提示中提供示例的一个实例是与摘要代理相关的提示。具体来说,是负责确定要总结的消息数量的代理。我发现很难让代理一致地提取列出的消息数量(如果有的话),并以特定格式输出。因此,提供示例变得必要,以便更好地解释我期待代理给出的响应。

其他实现细节

ChatMessage

熟悉 LangChain 的人应该已经了解 AIMessage、HumanMessage 类,它们用于存储 AI 和人类消息。对于我们的用例,我们需要能够存储发送者的 ID 以供后续任务使用。因此,为了解决这个问题,我们创建了一个名为 ChatMessage 的新派生类,用于存储消息以及发送者的 ID。

class ChatMessage(object):

    def __init__(self, message : BaseMessage, sender : str = None):
        self.message = message
        self.sender = sender
        self.content = message.content

    def __repr__(self) -> str:
        return f"{self.sender} | {self.content}"

图状态

在 LangGraph 中,图的一项关键元素是图状态。状态变量/对象对于代理之间的正确通信以及跟踪图工作流的进度至关重要。

def reducer(a : list, b : list | str ) -> list:

    if type(b) == list: 
        return a + b
    else:
        return a

class AgentState(TypedDict):
    messages: Annotated[Sequence[ChatMessage], reducer]

在大多数 LangGraph 示例中,状态变量是一个字符串列表,每经过一个代理就会继续追加。在我们的用例中,我希望排除某些节点的输出对图状态的影响,尽管工作流已经经过了该节点。为了适应这种情况,我通过将一种类型的状态变化设为列表,另一种类型设为字符串来区分这两种状态变化。当状态更新为列表时,它会被追加到整体状态对象中;当状态更新为字符串时,我们会忽略该更新,并传播现有状态。这是通过上面定义的自定义reducer函数来实现的。

结论

在这一阶段,我们已经覆盖了代理工作流中一个关键组件的设计选择:代理。在下一节教程中,我们将详细介绍实际的 LangGraph 图以及其实现方式,并进一步探讨与 Aya 相关的功能细节。

资源

对于代码,您可以参考此仓库:Multilingual Chatbot

除非另有说明,否则所有图像均由作者创作。

除了在 Medium 上,我还在 LinkedIn 分享我的想法、创意和其他更新

构建 PubMed 数据集

原文:towardsdatascience.com/building-a-pubmed-dataset-b1267408417c?source=collection_archive---------11-----------------------#2024-10-31

构建关于心血管疾病研究的 PubMed 收录文献数据集的逐步说明

Diana RozenshteynTowards Data Science Diana Rozenshteyn

·发表于 Towards Data Science ·6 分钟阅读·2024 年 10 月 31 日

--

图片由作者提供

挑战

当我开始撰写我的硕士论文《与 NIH 资助的心脏病研究中具有影响力的科学出版物相关的因素》时,第一项任务是构建一个原始数据集来进行研究。为了实现这一目标,我转向了 PubMed,这是由美国国立医学图书馆(NLM)提供的一个免费的生物医学文献研究数据库。

该数据集需要满足多个特定标准,包括:

  1. 跨越尽可能长的时间段。

  2. 包含美国国立卫生研究院(NIH)资助的研究。

  3. 仅专注于心血管疾病研究的文献。

  4. 提供第一作者的详细信息,例如全名、性别、所在机构以及研究机构所在国家。

  5. 包含每篇文章的引用次数、NIH 百分位排名、文章中的参考文献总数以及其他与引用相关的数据。

  6. 包括期刊的科学排名信息。

在本文中,我将解释如何根据这些标准创建一个 PubMed 收录文献的数据集。

两个限制因素——第一作者的完整姓名的可用性和引用发生所需的年数——被用来选择数据收集的时间段。PubMed 记录从 2002 年开始包含完整的作者姓名(Full Author, FAU)[1]。此外,三年是进行引用和出版影响分析的最低推荐年数[2]。为了最大化数据集的大小,采用了至少两年的时间框架来积累引用,因为数据集是在 2022 年构建的。此外,2020 年是当时用于数据分析的科学期刊排名(SJR)信息可用的最后一年[3]。因此,我在 PubMed 上搜索了 2002 年到 2020 年的记录,共创建了 18 个数据集——每年一个。限制因素的概述如下图所示。

图片由作者提供

我使用 PubMed 的高级搜索工具[4]构建了关于心血管疾病的出版物数据集。PubMed 数据元素(字段)描述[1]被用来构建查询。NIH 资助通过国立心脏、肺、血液研究所(NHLBI)资助来表示。我在查询中使用了 NHLBI 资助([GR])、出版日期([DP])等关键词,以及基于心血管疾病相关病症的关键词组合。这些关键词包括 cardiovascular、ischemic 和 heart。

图片由作者提供

2020 年 PubMed 查询示例:“cardiovascular OR ischemic OR heart AND NHLBI[GR] AND 2020[DP]”。

图片由作者提供

为了获取期刊名称、文章第一作者所属机构及其国家信息以便进一步解析,我通过选择显示选项菜单中的摘要格式选项和保存引用到文件菜单中的 PubMed 格式选项,保存了 PubMed 高级搜索查询。

图片由作者提供

为了获取每个出版物的 PMID(PubMed 唯一标识符)列表,以便进一步获取引用信息,我通过选择显示选项菜单中的摘要格式选项和保存引用到文件菜单中的 PMID 格式选项,保存了通过高级搜索 PubMed 查询收集的数据。

图片由作者提供

以下流程图概述了从 PubMed 网站下载 PubMed 和 PMID 文件后的步骤。更详细的解释见后文。

图片由作者提供

为了获取引用相关信息和(如有)完整未缩写的作者名字,我将每年的 PMID 数据集上传到 ICite Web 工具[5]。我将结果数据分析保存为 csv 文件。ICite 由 NIH 的投资组合分析办公室(OPA)运行。OPA 是 NIH 的一个部门,负责基于数据的研究评估,帮助 NIH 决定哪些当前或新领域的研究将对科学和人类健康产生更大的益处。ICite 提供了有关作者的完整名字、总引用次数、每年引用次数、一个经过领域和时间调整的科学影响力的引用衡量标准——相对引用比率(RCR),以及 NIH 百分位数的可用信息。

图片来自作者

PubMed 格式数据集不能以 CSV 格式保存,因此必须解析以提取期刊标题(JT)、第一作者所属机构(AD)和国家。我为此编写了一个 Python 3.10.1 解析脚本。本文不讨论该脚本的详细内容,但我计划在未来的出版物中涉及。第一作者所属机构是通过向研究组织登记处(ROR)API [5]发出应用程序编程接口(API)请求来确定的。由于数据元素字段提供了研究机构不一致的名称以及如地址和部门名称等不必要的信息,因此需要进行 ROR 匹配。ROR 匹配可以帮助查找在 PubMed 格式数据集中提到的研究机构,这些机构随后通过 API 调用提供。API 调用的结果以 JSON 格式返回。我使用 PubMed 数据元素(字段)描述从 PubMed 格式数据集中解析期刊标题和国家。我分别处理了每年的数据集。以下是 PubMed 格式文件条目的示例。

图片来自作者

我在 JupyterLab 中处理了每年查询的解析过的 PubMed 格式数据集,并通过 ICite 引用数据集在 PMID 上合并它们。然后,我根据期刊名称将每年的合并数据集与 SJR 数据集进行合并。我从 SCImago Journal & Country Rank 网站[3]下载了最后可用年份(2020 年)的 SJR 数据集。SCImago Journal & Country Rank 数据库排名基于 SJR 指标。SJR 指标是通过 Scopus®数据库中的信息开发的,是衡量期刊科学影响力的一个标准。

图片来自作者

随后的步骤包括使用 Gender-API Web 服务估算第一作者的性别,并进行数据清理。这些步骤将在本刊物中不做详细讨论。

总结来说,自己构建数据集是有多方面益处的:

  1. 定制化:你可以根据特定的研究需求定制数据集。

  2. 数据理解:构建自己的数据集帮助你深入理解数据本身,包括其局限性和偏差。

  3. 技能发展:它加强了如数据收集、清理、组织等关键研究技能。

  4. 质量控制:通过自行构建数据集,你可以控制数据质量。

  5. 灵活性:你可以在出现新问题时修改或扩展数据集。

  6. 问题解决:这一过程促进了创造性问题解决,因为你需要开发收集、筛选和结构化数据以进行分析的方法。

这些好处提升了你的研究能力,并有助于产生更有影响力和更精确的结果。

本文使用的 Jupyter Notebook 可以在GitHub找到。

这里引用的完整硕士论文也可以在GitHub找到。

感谢阅读,

Diana

参考文献

  1. 美国国立医学图书馆,“MEDLINE/PubMed 数据元素(字段)描述,”nlm.nih.gov。可用:www.nlm.nih.gov/bsd/mms/medlineelements.html#fau

  2. M. Thelwall,“1996–2014 年 27 个领域和 6 个英语国家的引用影响性别差异,”《定量科学研究》,第 1 卷,第 2 期,页码 599–617,2020 年 6 月。

  3. SCImago,(无日期),“SJR — SCImago 期刊与国家排名[门户]”,2020 年,scimagojr.com。可用:www.scimagojr.com

  4. 美国国立医学图书馆,“高级搜索结果 — pubmed,”可用:pubmed.ncbi.nlm.nih.gov/advanced/

  5. 研究组织注册,“ROR,”ror.org。可用:ror.org/

使用 Amazon Bedrock 和 LangChain 构建 QA 研究聊天机器人

原文:towardsdatascience.com/building-a-qa-research-chatbot-with-amazon-bedrock-and-langchain-677fbd19e3c1?source=collection_archive---------4-----------------------#2024-03-16

使用 Python 的概述和实现

Aashish NairTowards Data Science Aashish Nair

·发布于 Towards Data Science ·9 分钟阅读·2024 年 3 月 16 日

--

图片由Chen提供,来源:Pixabay

目录

∘ 介绍

∘ 目标

∘ 聊天机器人架构

∘ 技术栈

∘ 过程

∘ 步骤 1 — 加载 PDF 文档

∘ 步骤 2 — 构建向量存储

∘ 步骤 3 — 加载 LLM

∘ 步骤 4 — 创建检索链

∘ 步骤 5 — 构建用户界面

∘ 步骤 6 — 运行聊天机器人应用程序

∘ 步骤 7 — 容器化应用程序

∘ 未来步骤

∘ 结论

∘ 参考文献

介绍

不久前,我尝试了构建一个完全在我的 CPU 上运行的简单自定义聊天机器人

结果令人震惊,应用程序频繁崩溃。尽管如此,这并不令人惊讶。事实证明,将一个 13B 参数的模型放在一台 600 美元的电脑上,相当于让一个蹒跚学步的孩子爬山。

使用 LangChain 表达式语言(LCEL)构建 RAG 链

原文:towardsdatascience.com/building-a-rag-chain-using-langchain-expression-language-lcel-3688260cad05?source=collection_archive---------1-----------------------#2024-04-11

学习 LCEL 的构建模块,以开发越来越复杂的 RAG 链

Roshan SanthoshTowards Data Science Roshan Santhosh

·发布在 Towards Data Science ·7 分钟阅读·2024 年 4 月 11 日

--

在这篇文章中,我将介绍使用 LangChain 表达式语言(LCEL)实现自我评估 RAG 管道的问答功能。本文的重点是使用 LCEL 来构建管道,而不是实际的 RAG 和自我评估原理,这些原理已简化,以便于理解。

我将涵盖以下主题:

  1. 基本初始化步骤

  2. 使用 LCEL 开发不同复杂度的 RAG 管道变体

  3. 从 LCEL 脚本化管道中提取中间变量的方法

  4. 使用 LCEL 的原因

设置

在我们开始开发 RAG 链之前,需要执行一些基本的设置步骤以初始化此设置。这些步骤包括:

数据摄取

数据摄取包括两个关键步骤:

  1. 从 PDF 中读取文本

  2. 将 PDF 文本拆分成多个块以输入向量数据库

提示模板

我们将为问答任务和自我评估任务使用不同的提示。我们将有 3 个不同的提示模板:

  1. qa_prompt : 问答任务的基本提示

  2. qa_eval_prompt : 评估模型的提示,输入为问答对

  3. qa_eval_prompt_with_context : 与上述提示类似,但额外包含上下文以进行评估

数据库初始化

我们使用 FAISS 和 Open AI 嵌入初始化一个简单的向量数据库。对于检索,我们将 k 设置为 3(返回给定查询的前 3 个块)

RAG 开发

简单的 QA RAG

我们从一个基本的 RAG 链示例开始,执行以下步骤:

  1. 根据用户的问题,从向量数据库中检索相关的文本块(PDF 文本的分割),并将它们合并为一个单一字符串

  2. 将检索到的上下文文本和问题一起传递给提示模板以生成提示

  3. 将生成的输入提示传递给 LLM,以生成最终答案

使用 LangChain 表达式语言(LCEL),该 RAG 的实现方式如下:

rag_chain = ( 
            RunnableParallel(context = retriever | format_docs, question = RunnablePassthrough() ) |
            qa_prompt | 
            llm 
)

上述代码主要遵循管道架构,其中前一个元素的输出作为下一个元素的输入。下图展示了数据流。从用户的输入开始,首先通过 RunnableParallel 块,然后通过 qa_prompt 生成提示。这个提示随后被发送到 LLM,以生成最终输出。

基本的 LCEL 输入/输出流程

这个管道中有两个 LangChain 独有的关键添加项:

  1. RunnableParallel:顾名思义,这个类提供了并行运行多个进程的功能。因此,RunnableParallel 的输出是一个字典,键是初始化时提供的参数。在这种情况下,输出将包含两个键:contextquestion

    那么,为什么我们在当前情况下需要这个呢?这是因为 qa_prompt 模板需要两个输入值:上下文和问题。因此,我们需要分别计算这些值,然后将它们一起传递给 qa_prompt 模板。

  2. RunnablePassthrough:当你想将输入传递给下一个阶段而不做任何修改时,这是一个有用的类。本质上,这充当了一个恒等函数,返回传入的任何内容作为其输入。

上述 RAG 的流程图如下:

QA RAG 与自我评估 I

在之前的 RAG 链的基础上,我们现在将新的元素引入链中,以实现自我评估组件。

自我评估组件的实现相对直接。我们获取第一个 LLM 提供的答案,并将其与问题一起传递给评估器 LLM,并要求它提供一个二进制响应(正确/错误)。

rag_chain = ( 
            RunnableParallel(context = retriever | format_docs, question = RunnablePassthrough() ) |
            RunnableParallel(answer= qa_prompt | llm | retrieve_answer, question = itemgetter("question") ) |
            qa_eval_prompt | 
            llm_selfeval |
            json_parser
            )

第一个关键区别是添加了一个额外的 RunnableParallel 组件。这是必需的,因为,与 QA 的初始提示类似,自我评估提示也需要两个输入:基础 LLM 的答案以及用户的问题。

因此,第一个 RunnableParallel 的输出是上下文文本和问题,而第二个 RunnableParallel 的输出是 LLM 的答案和问题。

注意: 对于第二个 RunnableParallel,我们使用 itemgetter 方法仅保留前一个输入中的问题值并将其向前传递。这是为了避免使用 RunnablePassthrough,因为它会传递完整的输入(带有两个键的字典),而我们现在只关心传递问题而不是上下文。此外,还有格式化的问题,因为 qa_eval_prompt 期望的是一个 str -> str 映射,而使用 RunnablePassthrough 会导致 str -> dict 映射。

这个 RAG 实现的流程图如下所示:

QA 自评 RAG II

对于这个变体,我们对评估过程进行了更改。除了问答对外,我们还将检索到的上下文传递给评估器 LLM。

为了实现这一点,我们在第二个 RunnableParallel 中添加了一个额外的 itemgetter 函数,以收集上下文字符串并将其传递给新的 qa_eval_prompt_with_context 提示模板。

rag_chain = ( 
            RunnableParallel(context = retriever | format_docs, question = RunnablePassthrough() ) |
            RunnableParallel(answer= qa_prompt | llm | retrieve_answer, question = itemgetter("question"), context = itemgetter("context") ) |
            qa_eval_prompt_with_context | 
            llm_selfeval |
            json_parser
            )

实现流程图:

检索中间变量

使用像 LCEL 这样的链式实现时,一个常见的痛点是难以访问中间变量,而访问这些变量对于调试管道非常重要。我们查看了几个选项,通过操作 LCEL 来访问我们感兴趣的任何中间变量。

使用 RunnableParallel 传递中间输出

如前所述,RunnableParallel 允许我们将多个参数传递到链中的下一步。因此,我们利用 RunnableParallel 的这个能力,直到最后一步都将所需的中间值传递下去。

在下面的示例中,我们修改了原始的自评 RAG 链,以便输出检索到的上下文文本以及最终的自评输出。主要的变化是,我们在每个步骤中都添加了一个 RunnableParallel 对象,以将上下文变量传递下去。

此外,我们还使用了 itemgetter 函数来明确指定后续步骤的输入。例如,对于最后两个 RunnableParallel 对象,我们使用itemgetter('input')来确保仅将前一步的输入参数传递给 LLM/Json 解析器对象。

rag_chain = ( 
            RunnableParallel(context = retriever | format_docs, question = RunnablePassthrough() ) |
            RunnableParallel(answer= qa_prompt | llm | retrieve_answer, question = itemgetter("question"), context = itemgetter("context") ) |
            RunnableParallel(input =  qa_eval_prompt, context = itemgetter("context")) |
            RunnableParallel(input = itemgetter("input") | llm_selfeval , context = itemgetter("context") ) | 
            RunnableParallel(input = itemgetter("input") | json_parser,  context = itemgetter("context") )
            )

该链的输出如下所示:

更简洁的变体:

rag_chain = ( 
            RunnableParallel(context = retriever | format_docs, question = RunnablePassthrough() ) |
            RunnableParallel(answer= qa_prompt | llm | retrieve_answer, question = itemgetter("question"), context = itemgetter("context") ) |
            RunnableParallel(input =  qa_eval_prompt | llm_selfeval | json_parser, context = itemgetter("context"))
            )

使用全局变量保存中间步骤

这种方法本质上使用了日志记录器的原理。我们引入了一个新函数,将其输入保存到全局变量中,从而允许我们通过全局变量访问中间变量。

global context

def save_context(x):
    global context
    context = x
    return x

rag_chain = ( 
            RunnableParallel(context = retriever | format_docs | save_context, question = RunnablePassthrough() ) |
            RunnableParallel(answer= qa_prompt | llm | retrieve_answer, question = itemgetter("question") ) |
            qa_eval_prompt | 
            llm_selfeval |
            json_parser
            )

在这里,我们定义了一个全局变量context和一个名为save_context的函数,该函数在返回相同的输入之前将其输入值保存到全局context变量中。在链中,我们将save_context函数添加为获取上下文步骤的最后一步。

此选项允许您在不进行重大更改的情况下访问任何中间步骤。

使用全局变量访问中间变量

使用回调

将回调附加到链上是另一种常用于记录中间变量值的方法。在 LangChain 中,回调有很多内容需要探讨,因此我将在另一篇文章中详细讨论。

为什么使用 LCEL?

使用 LCEL 的原因最好由 LangChain 的作者在其官方文档中解释。

在文档中提到的要点中,以下是我认为特别有用的一些:

  1. 输入和输出模式 :将在另一篇文章中详细介绍

  2. 异步支持 :随着我们向生产应用推进,异步功能变得愈加重要。LCEL 管道允许无缝过渡到异步操作。

  3. 优化的并行执行

鉴于以上原因,作为个人偏好,我认为使用 LCEL 有助于提高代码的可读性,并允许更清晰的实现。

资源

完整代码笔记本

PDF 文档

图像 :所有图像均由作者创建

除了 Medium,我还在 Linkedin上分享我的想法、创意和其他更新。

使用 MongoDB 构建 RAG 流水线:个性化推荐的向量搜索

原文:towardsdatascience.com/building-a-rag-pipeline-with-mongodb-vector-search-for-personalized-movie-picks-46a58a2aaac9?source=collection_archive---------8-----------------------#2024-08-01

Pablo Merchán-Rivera, Ph.D.Towards Data Science Pablo Merchán-Rivera, Ph.D.

·发表于 Towards Data Science ·7 分钟阅读·2024 年 8 月 1 日

--

本文探讨了使用检索增强生成(RAG)流水线构建电影推荐系统的过程。目标是学习如何利用 MongoDB 的向量搜索功能,将数据描述转化为可搜索的数字指纹,并创建一个能够理解你偏好和沟通细节的系统。换句话说,我们的目标是构建一个不仅智能,而且高效的推荐系统。

在本文结束时,你将能够构建一个功能完整的电影推荐系统。该系统能够接受用户的查询,如 “我想看一部探讨人工智能的科幻电影”“什么是适合成人也能欣赏的好动画电影?为什么你的建议适合?” 并返回相关的电影建议及选择理由。

图片由 Alexandr Popadin 提供,来源于 Unsplash

什么是 RAG 流水线?

RAG 管道指的是数据通过一系列处理步骤的顺序流动,结合了大型语言模型(LLM)与结构化数据检索的优势。它的工作原理是首先从知识库中检索相关信息,然后利用这些信息增强大型语言模型的输入,从而生成最终的输出。此类管道的主要目标是生成更准确、更具上下文相关性且更具个性化的响应,以回答用户针对庞大数据库提出的查询。

为什么选择 MongoDB?

MongoDB 是一个开源的 NoSQL 数据库,它以灵活的、类似 JSON 的文档形式存储数据,允许轻松扩展,并能处理多种数据类型和结构。MongoDB 在这个项目中扮演了重要角色。它的文档模型与我们的电影数据非常契合,而它的向量搜索功能可以对我们的嵌入(即电影内容的数字表示)进行相似度搜索。我们还可以利用索引和查询优化功能,以保持即使数据集扩展时也能快速检索数据。

我们的项目

我们的管道流程如下所示:

  1. 设置环境并从 Hugging Face 加载电影数据

  2. 使用 Pydantic 对数据进行建模

  3. 为电影信息生成嵌入

  4. 将数据导入 MongoDB 数据库

  5. 在 MongoDB Atlas 中创建向量搜索索引

  6. 执行向量搜索操作,找到相关电影

  7. 使用 LLM 模型处理用户查询

  8. 使用 RAG 管道获取电影推荐

第一步:设置环境并加载数据集

首先,我们需要导入必要的库并设置我们的环境。这还包括设置 API 密钥和应用程序用于连接 MongoDB 数据库的连接字符串:

import warnings
warnings.filterwarnings('ignore')

import os
from dotenv import load_dotenv, find_dotenv
from datasets import load_dataset
import pandas as pd
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime
from pymongo.mongo_client import MongoClient
import openai
import time

_ = load_dotenv(find_dotenv())
MONGO_URI = os.environ.get("MONGO_URI")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
openai.api_key = OPENAI_API_KEY

接下来,我们加载我们的电影数据集:

dataset = load_dataset("Pablinho/movies-dataset", streaming=True, split="train")
dataset = dataset.take(200)  # 200 movies for the sake of simplicity
dataset_df = pd.DataFrame(dataset)

数据集包含超过 9000 条记录。然而,在这个练习中,我们将数据集限制为 200 部电影,使用dataset.take(200)。在实际应用中,您可能会使用更大的数据集。

第二步:使用 Pydantic 对数据建模

数据建模对于确保我们应用程序的一致性和类型安全至关重要。因此,我们使用 Pydantic 来实现这一目的:

class Movie(BaseModel):
    Release_Date: Optional[str]
    Title: str
    Overview: str
    Popularity: float
    Vote_Count: int
    Vote_Average: float
    Original_Language: str
    Genre: List[str]
    Poster_Url: str
    text_embeddings: List[float]

使用 Pydantic 提供了多个好处,例如自动数据验证、类型检查和简便的序列化/反序列化。注意,我们还创建了一个text_embeddings字段,用于存储我们生成的嵌入,作为浮动点数列表。

第三步:嵌入生成

现在,我们可以使用 OpenAI API,并编写一个生成嵌入的函数,如下所示:

def get_embedding(text):
    if not text or not isinstance(text, str):
        return None
    try:
        embedding = openai.embeddings.create(
            input=text,
            model="text-embedding-3-small", dimensions=1536).data[0].embedding
        return embedding
    except Exception as e:
        print(f"Error in get_embedding: {e}")
        return None

在之前的代码行中,我们首先检查输入是否有效(非空字符串)。然后,我们使用 OpenAI 的 embeddings.create 方法生成嵌入,采用“text-embedding-3-small”模型,该模型生成 1536 维的嵌入。

现在,我们可以处理每条记录并使用之前的函数生成嵌入。我们还添加了一些代码来处理 'Genre' 字段,将其从字符串(如果存在)转换为一组类型列表。

def process_and_embed_record(record):
    for key, value in record.items():
        if pd.isnull(value):
            record[key] = None

    if record['Genre']:
        record['Genre'] = record['Genre'].split(', ')
    else:
        record['Genre'] = []

    text_to_embed = f"{record['Title']} {record['Overview']}"
    embedding = get_embedding(text_to_embed)
    record['text_embeddings'] = embedding
    return record

records = [process_and_embed_record(record) for record in dataset_df.to_dict(orient='records')]

这些嵌入将使我们能够进行语义搜索,找到与给定查询在概念上相似的电影。请注意,这一过程可能需要一些时间,尤其是在数据集较大的情况下,因为我们为每部电影都要进行一次 API 调用。

第 4 步:将数据导入 MongoDB

我们建立与 MongoDB 数据库的连接:

def get_mongo_client(mongo_uri):
    client = MongoClient(mongo_uri, appname="pmr.movie.python")
    print("Connection to MongoDB successful")
    return client

mongo_client = get_mongo_client(MONGO_URI)
database_name = "movies_dataset"
collection_name = "movies"
db = mongo_client.get_database(database_name)
collection = db.get_collection(collection_name)

collection.delete_many({})

我们将处理并嵌入的数据插入 MongoDB,这使得我们能够高效地存储和查询我们的电影数据,包括高维嵌入:

movies = [Movie(**record).dict() for record in records]
collection.insert_many(movies)

第 5 步:在 MongoDB Atlas 中创建向量搜索索引

在执行向量搜索操作之前,我们需要创建一个向量搜索索引。此步骤可以直接在 MongoDB Atlas 平台上完成:

  1. 登录到您的 MongoDB Atlas 帐户

  2. 导航到您的集群

  3. 转到“搜索与向量搜索”标签

  4. 点击“创建搜索索引”

  5. 在“Atlas 向量搜索”部分选择“JSON 编辑器”,并使用以下配置:

{
  "fields": [
    {
      "numDimensions": 1536,
      "path": "text_embeddings",
      "similarity": "cosine",
      "type": "vector"
    }
  ]
}

目标是创建一个名为 "vector_index_text" 的向量搜索索引,索引的字段为 "text_embeddings"。我们使用余弦相似度,因为它有助于通过比较嵌入向量的方向来找到主题或内容相似的电影,忽略长度或细节的差异,这对于将用户的查询与电影描述进行匹配非常有效。

第 6 步:实现向量搜索

现在,我们实现向量搜索功能。以下函数用于在我们的 MongoDB 集合中执行向量搜索。它首先为用户的查询生成嵌入。然后,利用 $vectorSearch 运算符构建 MongoDB 聚合管道。搜索会在 150 个候选项中查找 20 个最近的邻居。

def vector_search(user_query, db, collection, vector_index="vector_index_text", max_retries=3):
    query_embedding = get_embedding(user_query)
    if query_embedding is None:
        return "Invalid query or embedding generation failed."

    vector_search_stage = {
        "$vectorSearch": {
            "index": vector_index,
            "queryVector": query_embedding,
            "path": "text_embeddings",
            "numCandidates": 150,
            "limit": 20
        }
    }

    pipeline = [vector_search_stage]

    for attempt in range(max_retries):
        try:
            results = list(collection.aggregate(pipeline))
            if results:
                explain_query_execution = db.command(
                    'explain', {
                        'aggregate': collection.name,
                        'pipeline': pipeline,
                        'cursor': {}
                    },
                    verbosity='executionStats')
                vector_search_explain = explain_query_execution['stages'][0]['$vectorSearch']
                millis_elapsed = vector_search_explain['explain']['collectStats']['millisElapsed']
                print(f"Total time for the execution to complete on the database server: {millis_elapsed} milliseconds")
                return results
            else:
                print(f"No results found on attempt {attempt + 1}. Retrying...")
                time.sleep(2)
        except Exception as e:
            print(f"Error on attempt {attempt + 1}: {str(e)}")
            time.sleep(2)

    return "Failed to retrieve results after multiple attempts."

我们实现了一个重试机制(最多 3 次尝试),以处理可能的临时问题。该函数还执行 explain 命令,提供有关查询执行的详细信息。

第 7 步:使用 LLM 处理用户查询

最后,我们可以处理用户查询。首先,我们定义一个 SearchResultItem 类来构建搜索结果。然后,handle_user_query 函数将所有内容结合在一起:它根据用户的查询执行向量搜索,将搜索结果格式化为 pandas DataFrame,并使用 OpenAI 的 GPT 模型(即 gpt-3.5-turbo)根据搜索结果和用户的查询生成回应,并显示结果及生成的回应:

class SearchResultItem(BaseModel):
    Title: str
    Overview: str
    Genre: List[str]
    Vote_Average: float
    Popularity: float

def handle_user_query(query, db, collection):
    get_knowledge = vector_search(query, db, collection)

    if isinstance(get_knowledge, str):
        return get_knowledge, "No source information available."

    search_results_models = [SearchResultItem(**result) for result in get_knowledge]
    search_results_df = pd.DataFrame([item.dict() for item in search_results_models])

    completion = openai.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=[
            {"role": "system", "content": "You are a movie recommendation system."},
            {"role": "user", "content": f"Answer this user query: {query} with the following context:\n{search_results_df}"}
        ]
    )

    system_response = completion.choices[0].message.content

    print(f"- User Question:\n{query}\n")
    print(f"- System Response:\n{system_response}\n")

    return system_response

这个功能实际上展示了 RAG 的核心价值:通过从我们的数据库中检索相关信息,生成一个情境适当的回应。

8. 使用 RAG 管道

要使用此 RAG 管道,现在可以进行如下查询:

query = """
I'm in the mood for a highly-rated action movie. Can you recommend something popular?
Include a reason for your recommendation.
"""
handle_user_query(query, db, collection)

系统将返回类似以下的响应:

I recommend "Spider-Man: No Way Home" as a popular and highly-rated action 
movie for you to watch. With a vote average of 8.3 and a popularity score 
of 5083.954, this film has garnered a lot of attention and positive 
reviews from audiences. 

"Spider-Man: No Way Home" is a thrilling action-packed movie that brings 
together multiple iterations of Spider-Man in an epic crossover event. It 
offers a blend of intense action sequences, emotional depth, and nostalgic
moments that fans of the superhero genre will surely enjoy. So, if you're
in the mood for an exciting action movie with a compelling storyline and
fantastic visual effects, "Spider-Man: No Way Home" is an excellent choice
for your movie night.

结论

构建一个 RAG 管道涉及多个步骤,从数据加载和建模到嵌入生成和向量搜索。这个示例展示了如何通过将我们数据库中的特定电影数据与语言模型的自然语言理解和生成能力结合,来提供信息丰富、上下文感知的回答。在此基础上,我们使用 MongoDB,因为它具有原生向量搜索功能、灵活的文档模型和可扩展性,非常适合这种工作流程。

你可以通过添加更多数据、微调你的嵌入,或实现更复杂的推荐算法来扩展这个系统。

有关完整代码和更多资源,请查看 GitHub 仓库。此项目使用的数据集来源于 Kaggle ,并已获得原作者授予的 CC0 1.0 通用公共领域授权(CC0 1.0)。你可以在 这里 找到数据集和更多信息

手动在 Python 中构建随机森林

原文:towardsdatascience.com/building-a-random-forest-by-hand-in-python-187ac0620875?source=collection_archive---------4-----------------------#2024-01-30

对一种强大且流行的算法的深入探讨

Matt SosnaTowards Data Science Matt Sosna

·发布于 Towards Data Science ·17 分钟阅读·2024 年 1 月 30 日

--

图片来源:FlyDUnsplash

药物发现物种分类,从 信用评分网络安全 等,随机森林是一个流行且强大的算法,用于建模我们复杂的世界。它的多功能性和预测能力似乎需要最前沿的复杂技术,但如果我们深入了解随机森林的实际构成,我们会发现它实际上只是一个出乎意料简单的重复步骤集。

我发现,学习某个东西最好的方式就是动手实践。因此,为了直观理解随机森林的工作原理,我们将手动在 Python 中构建一个,从决策树开始,逐步扩展到完整的森林。我们将亲自体验这个算法在分类和回归中的灵活性和可解释性。虽然这个项目听起来可能很复杂,但我们实际上只需要学习几个核心概念:1)如何迭代地划分数据,以及 2)如何量化数据划分的效果。

背景

决策树推理

决策树是一种监督学习算法,它识别一组将特征映射到标签的二元规则分支。与像逻辑回归这样的算法不同,后者的输出是一个方程,决策树算法是非参数的

构建可靠的文本分类管道:使用 LLMs 的分步指南

原文:towardsdatascience.com/building-a-reliable-text-classification-pipeline-with-llms-a-step-by-step-guide-87dc73213605?source=collection_archive---------2-----------------------#2024-11-12

克服 LLM 文本分类中的常见挑战

Youness MansarTowards Data Science Youness Mansar

·发表于Towards Data Science ·阅读时长 11 分钟·2024 年 11 月 12 日

--

图片来自Robert MurrayUnsplash

注意:编辑于 2024 年 11 月 15 日,修复了代码中的一个 bug。现在结果更好!

在本教程中,我们将一步一步地讲解如何使用大语言模型(LLMs)构建一个准确且可靠的文本分类管道。LLMs 是强大的通用模型,在各种自然语言处理任务中展现出了卓越的能力,且它们正逐渐取代许多 AI 应用中的专业模型。然而,如果使用不当,LLMs 在分类任务中可能会遇到挑战。

在应用大语言模型(LLMs)进行分类时,常见的问题是模型可能没有按照预期的输出或格式进行响应,从而导致需要额外的后处理,这些后处理可能复杂且耗时。在本文中,我们将介绍一些实用的技巧和方法来解决这些问题。这些策略都很简单易行,但能够显著提高 LLMs 作为文本分类器的准确性和可用性。让我们一起来深入了解如何让你的 LLM 文本分类系统既高效又可靠。

主要内容

构建一个能够写入 Google Docs 的研究代理(第一部分)

原文:towardsdatascience.com/building-a-research-agent-that-can-write-to-google-docs-part-1-4b49ea05a292?source=collection_archive---------6-----------------------#2024-11-20

Dalle-3 对“一个古怪的 AI 助手在辛勤工作中检查文档”的诠释。图片由作者生成。

可能帮助你完成作业的工具

Robert Martin-ShortTowards Data Science Robert Martin-Short

·发表于面向数据科学 ·阅读时间 15 分钟·2024 年 11 月 20 日

--

本文是两部分系列的第一篇,我们将使用 LangGraph 和 Tavily 构建一个简单的研究代理,能够编写并优化简短的文章。为了跟踪代理生成的计划、文章和评论,我们添加了通过编程方式创建和编辑 Google Docs 的功能。在本文中,我们将重点介绍代理部分,将 Google Docs 连接的部分留到第二篇文章中。你可以在这里找到所有相关代码。***

大型语言模型(LLMs)正在快速应用于与分析师和研究人员相关的各种场景,特别是在文本信息的提取、组织和总结方面。无论是商业界还是开源社区,都在不断简化构建和扩展所谓“代理型”应用程序的过程,在这些应用程序中,LLM 充当(希望是)熟练的分析师角色,并做出半自主决策。例如,在一个聊天机器人应用程序中,如果用户提出一个复杂或多步骤的问题,LLM 可能需要设计一个行动计划,正确查询多个外部工具——可能是计算器、网页搜索工具、向量数据库等——汇总结果并生成答案。

这种系统通常被认为使用了ReAct 框架的提示工程方法,其中“ReAct”代表“Reasoning-Action”(推理-行动)。基本上,提示的结构和顺序迫使 LLM 以非常有条理的方式回答问题,首先通过表达一个思想(通常是攻击计划),然后执行一个行动,再观察结果。在代理系统中,这个过程可以不断迭代,直到 LLM 认为已经得出了一个可接受的答案。

在这一系列文章中,我们将使用LangGraph库和Tavily搜索工具,构建一个简单的研究助手,展示一些这些概念,并且可能对我们那些希望快速生成关于任何主题的简洁、写得好的报告的人有帮助。我们的代理将受到同行评审研究中的计划 -> 研究 -> 写作 -> 提交 -> 审阅 -> 修订周期的启发,你可以在这里查看这些不同部分的提示。

为了让系统感觉更完整,我们还将添加将生成的材料自动添加到 Google 文档的功能,详细内容请参见第二部分。这应该被视为一个附加功能,而不是代理的集成组件,但它本身也很有趣,因此也可以作为一篇独立的文章阅读。

1. 我们的研究助手应该做些什么?

在看看我们如何构建这个助手以及它的“代理性”意味着什么之前,我们应该简要思考一下我们希望它做些什么。目标是构建一个可以计划并撰写关于给定主题的简短、信息丰富的文章,然后通过审阅和修订来改进自己的工作的系统。

为什么?主要这只是一次技术探索,但将 LLM 用作半自主研究员是一个活跃的研究领域,并且产生了一些有趣的项目,如GPT-researcher。它们有潜力加速分析师、学生、作家和研究人员的工作——尽管当然,如果目标是人类学习,仔细阅读、做笔记和讨论是不可替代的,AI 无法取而代之。

像 GPT4、Anthropic Claude Sonnet、Meta Llama 3、Google Gemini Pro 等大型语言模型(LLM)已经能够通过一个简单的提示写出很棒的文章。然而,这些 LLM 存在知识截止问题,因此需要访问额外的工具来获取最新信息,比如关于时事新闻的内容。有许多服务——特别是像 Perplexity、ChatGPT(现在可以通过 chat.com 访问)和 Google 的 AI 概览这些工具,它们已经具备了这种能力,但它们更倾向于提供快速总结,而不是精心编写的研究报告。

在这里,我们假设多次的审阅和修改将提升 LLM 生成文章的质量。这当然是人类写作中的工作方式。我们的助手将有以下几个组件,每个组件都有自己的指令提示。

  • 规划者。 将一个定义不清的任务转化为结构化的文章计划

  • 研究员。 接受计划并在互联网上搜索相关内容。

  • 写作者。 利用计划、检索到的内容和自身知识来撰写报告

  • 审阅者。 阅读报告并提供建设性的批评

  • 编辑。 阅读报告和审阅者的批评,决定报告是否需要修改。如果需要,报告将被送回研究员和写作者阶段。

在我们的实现中,这些组件将调用相同的 LLM,即 GPT4o-mini,但在实际应用中,它们完全可以使用不同的、更专业的模型。

输出将是一份写得很好的、信息丰富的报告——最好附带参考文献——我们可以程序化地将其放入 Google 文档中保存。通过调整提示,可以轻松修改我们研究员的“个性”。编辑特别重要,因为它是整个过程的把关人。如果我们让编辑非常严格,系统可能需要经过多次修改才能被接受。严格的编辑在多大程度上能提高结果质量?这是一个非常有趣的问题,正如他们所说,这超出了当前工作的范围!

2. 代理的结构

我们的研究助手在很大程度上基于这门关于 LangGraph 的优秀短期课程。LangGraph 是一个 LLM 编排库,旨在让我们更容易设计和构建可靠的代理。关于 LangGraph 与 LangChain 的深入对比,我推荐这篇优秀的文章。

代理究竟是什么?似乎社区尚未达成统一定义,但至少广义上来说,我们可以说代理是一个多步骤的系统,在这个系统中,LLM 被允许对结果做出有意义的决策。这使得它比链条更复杂(并且可能更不可预测),链条只是预先定义的一组 LLM 调用按顺序执行。

在代理框架中,LLM 对于如何解决其所给定的问题有一定的自主性,可能通过选择合适的工具来调用,或者决定在解决方案足够好时何时停止改进。从这个意义上讲,LLM 更像是系统的大脑,更像一个人类分析师,而不仅仅是一个 API 调用。这里的一个有趣挑战是,尽管代理可以自由做出决策,但它们通常嵌入在或与传统的软件系统交互,这些系统需要结构化的输入和输出。因此,迫使代理以这些其他系统能够理解的方式返回答案非常重要,无论它做出了什么决策。

对于在 LangGraph 上下文中讨论代理的更深入内容,这份文档非常有帮助。我们的研究代理将是一个相当简单的代理(部分原因是我也在学习这些材料!),但希望它能成为通向更复杂系统的一块垫脚石。

在 LangGraph 中,我们将系统的逻辑定义为一个图,其中包含节点和边。节点是进行 LLM 调用的地方,边则将信息从一个节点传递到下一个节点。边可以是有条件的,意味着它们可以根据做出的决策将信息指向不同的节点。信息以由状态定义的结构化格式在节点之间传递。

我们的研究助手只有一个阶段,叫做AgentState,看起来像这样:

class AgentState(TypedDict):
    """
    A dictionary representing the state of the research agent.

    Attributes:
        task (str): The description of the task to be performed.
        plan (str): The research plan generated for the task.
        draft (str): The current draft of the research report.
        critique (str): The critique received for the draft.
        content (List[str]): A list of content gathered during research.
        revision_number (int): The current revision number of the draft.
        max_revisions (int): The maximum number of revisions allowed.
        finalized_state (bool): Indicates whether the report is finalized.
    """

    task: str
    plan: str
    draft: str
    critique: str
    content: List[str]
    editor_comment: str
    revision_number: int
    max_revisions: int
    finalized_state: bool

这是存储与我们问题相关的所有信息的地方,并且可以通过 LLM 在图的某个节点内部进行更新。

现在我们可以定义一些节点。在代码中,所有节点都保存在AgentNodes类中,这只是我发现的一个有用的方式来对它们进行分组。例如,规划节点看起来像这样:

 def plan_node(self, state: AgentState) -> Dict[str, str]:
        """
        Generate a research plan based on the current state.

        Args:
            state (AgentState): The current state of the research agent.

        Returns:
            Dict[str, str]: A dictionary containing the generated research plan.
        """
        messages = [
            SystemMessage(content=ResearchPlanPrompt.system_template),
            HumanMessage(content=state["task"]),
        ]
        response = self.model.invoke(messages)
        return {"plan": response.content}

注意它如何接收一个AgentState并返回对其某个组件的修改,即研究计划的文本。当这个节点被运行时,计划会被更新。

节点函数中的代码使用标准的 LangChain 语法。self.modelChatOpenAI的一个实例,像这样:

model = ChatOpenAI(
    model="gpt-4o-mini", temperature=0, api_key=secrets["OPENAI_API_KEY"]
)

这个提示由来自ResearchPlanPrompt数据类的系统消息与 AgentState 的“task”元素拼接而成,后者是用户提供的研究课题。计划提示看起来像这样。

@dataclass
class ResearchPlanPrompt:
    system_template: str = """
    You are an expert writer tasked with creating a high-level outline for a research report.
    Write such an outline for the user-provided topic. Include relevant notes or instructions for each section.
    The style of the research report should be geared towards the educated public. It should be detailed enough to provide
    a good level of understanding of the topic, but not unnecessarily dense. Think of it more like a whitepaper to be consumed 
    by a business leader rather than an academic journal article. 
    """

需要为以下任务创建类似的节点:

  • 进行研究。在这里,我们使用 LLM 将研究任务转换为一系列查询,然后使用 Tavily 搜索工具在线查找答案并将其保存在 AgentStage 的“content”下。此过程将在第二部分中详细讨论。

  • 撰写报告。在这里,我们利用任务名称、研究计划、研究内容以及任何先前的审稿人评论来实际撰写研究报告。这些内容会保存在 AgentState 的“draft”下。每次运行时,revision_number指示器都会更新。

  • 审查报告。 调用 LLM 来批评研究报告,并将审查保存到“critique”下。

  • 根据反馈进行更多的研究。这将处理原始草稿和审查意见,并为 Tavily 生成更多的查询,帮助系统解决审稿人评论。再一次,这些信息会保存在“content”下。

  • 做出决策,判断报告是否满足审稿人的评论。LLM 会根据编辑提示的指导做出是/否决策,并解释其推理过程。

  • 虚拟节点,用于拒绝或接受研究。一旦我们到达这两个节点中的任何一个,我们就可以结束流程。最终的研究报告可以从 AgentState 中提取。

我们需要在图中的编辑节点处创建一个条件边:如果编辑器选择是,我们进入已接受节点。如果选择否,我们返回审查节点。

为了定义此逻辑,我们需要创建一个函数在条件边内运行。我选择将其放入一个 AgentEdges 类中,但这不是必须的。

 def should_continue(state: AgentState) -> str:
        """
        Determine whether the research process should continue based on the current state.

        Args:
            state (AgentState): The current state of the research agent.

        Returns:
            str: The next state to transition to ("to_review", "accepted", or "rejected").
        """
        # always send to review if editor hasn't made comments yet
        current_editor_comments = state.get("editor_comment", [])
        if not current_editor_comments:
            return "to_review"

        final_state = state.get("finalized_state", False)
        if final_state:
            return "accepted"
        elif state["revision_number"] > state["max_revisions"]:
            logger.info("Revision number > max allowed revisions")
            return "rejected"
        else:
            return "to_review"

在代码中,整个图的设置如下所示

from research_assist.researcher.AgentComponents import (
    AgentNodes,
    AgentState,
    AgentEdges,
)
# this is the predefined end node
from langgraph.graph import END

agent = StateGraph(AgentState)
nodes = AgentNodes(model, searcher)
edges = AgentEdges()

## Nodes
agent.add_node("initial_plan", nodes.plan_node)
agent.add_node("write", nodes.generation_node)
agent.add_node("review", nodes.review_node)
agent.add_node("do_research", nodes.research_plan_node)
agent.add_node("research_revise", nodes.research_critique_node)
agent.add_node("reject", nodes.reject_node)
agent.add_node("accept", nodes.accept_node)
agent.add_node("editor", nodes.editor_node)

## Edges
agent.set_entry_point("initial_plan")
agent.add_edge("initial_plan", "do_research")
agent.add_edge("do_research", "write")
agent.add_edge("write", "editor")

## Conditional edges
agent.add_conditional_edges(
  "editor",
  edges.should_continue,
  {"accepted": "accept", "to_review": "review", "rejected": "reject"},
)
agent.add_edge("review", "research_revise")
agent.add_edge("research_revise", "write")
agent.add_edge("reject", END)
agent.add_edge("accept", END)

在数据可以流经图之前,图必须被编译。从文档中的理解来看,它只是对图的结构做一些简单检查,并返回一个CompiledGraph对象,该对象具有像streaminvoke这样的函数。这些方法允许你将输入传递给起始节点,起始节点通过上面的代码中的set_entry_point来定义。

在构建这些图时,将所有节点和边可视化在笔记本中非常有帮助。这可以通过以下命令实现。

from IPython.display import Image

Image(agent.compile().get_graph().draw_png())

LangGraph 提供了几种不同的绘制图形的方式,具体取决于你安装的可视化包。我使用的是 pygraphviz,可以通过以下命令在 M 系列 Mac 上安装。

brew install graphviz
pip install -U --no-cache-dir  \
        --config-settings="--global-option=build_ext" \
        --config-settings="--global-option=-I$(brew --prefix graphviz)/include/" \
        --config-settings="--global-option=-L$(brew --prefix graphviz)/lib/" \
        pygraphviz

我们代理的控制流可视化。节点是 LLM 调用发生的地方,而边表示信息流动。图像由作者生成。

我们如何测试代理?最简单的方法是使用一些 AgentState 组件的初始值(例如任务、最大修订次数和修订号)调用invoke,这些值将进入图的入口节点。

graph = agent.compile()
res = graph.invoke(
    {
        "task": "What are the key trends in LLM research and application that you see in 2024",
        "max_revisions": 1,
        "revision_number": 0,
    }
)

经过一段时间(如果max_revisions设置为较大值,可能需要几分钟),这将返回一个包含所有组件的代理状态字典。我正在使用 gpt4o-mini 进行此操作,结果非常令人印象深刻,尽管关于“审查”和“编辑器”组件是否能真正提高文章质量的问题仍有争议,我们将在第三节中讨论这个问题。

如果我们希望更深入了解图中每个阶段节点的输入和输出怎么办?这是调试和解释性非常重要的,特别是在图形变得越来越复杂或我们希望将其部署到生产环境时。幸运的是,LangGraph 提供了一些很棒的工具,这些工具在其文档的持久性流式传输部分中有介绍。一个最小的实现可能类似于下面的样子,在这里我们使用内存存储来跟踪图的每个阶段的更新。

from langgraph.store.memory import InMemoryStore
from langgraph.checkpoint.memory import MemorySaver
import uuid

checkpointer = MemorySaver()
in_memory_store = InMemoryStore()
graph = agent.compile(checkpointer=checkpointer, store=self.in_memory_store)

# Invoke the graph
user_id = "1"
config = {"configurable": {"thread_id": "1", "user_id": user_id}}
namespace = (user_id, "memories")

for i, update in enumerate(graph.stream(
  {
     "task": task_description,
     "max_revisions": max_revisions,
     "revision_number": 0,
  }, config, stream_mode="updates"
        )):
   # print the data that just got generated 
   print(update)
   memory_id = str(uuid.uuid4())
   # store the data that just got generated in memory
   self.in_memory_store.put(namespace, memory_id, {"memory": update})
   results.append(update)

更复杂的应用程序将从节点内部访问存储,允许聊天机器人回忆与某个用户的先前对话。例如,在这里我们只是使用内存来保存每个节点的输出,这些输出可以用于调试目的查看。我们将在最后一节中进一步探讨这个问题。

3. “do_research”节点中有什么?Tavily 搜索的强大功能

也许上述控制流中最有趣的部分是do_researchresearch_revise节点。在这两个节点中,我们使用 LLM 生成与任务相关的一些网页搜索查询,然后我们使用Tavily API 实际进行搜索。Tavily 是一个相对较新的服务,提供针对 AI 代理优化的搜索引擎。实际上,这意味着该服务返回来自网站的相关文本块,而不是像典型的搜索引擎 API 那样仅返回网址列表(这些网址需要被抓取和解析)。

在背后,Tavily 很可能使用网页抓取工具和 LLM 来提取与用户搜索相关的内容,但所有这些都被抽象化了。你可以在这里注册 Tavily 的免费“研究员”计划,获得 1000 次免费的 API 调用。不幸的是,超过此次数后,你需要支付月费才能继续使用,可能只有在商业用例中才值得这样做。

让我们来看一个使用与AgentNodes.research_plan_node中非常相似的代码的例子。

 from langchain_core.messages import (
    SystemMessage,
    HumanMessage,
)
from research_assist.researcher.prompts import (
    ResearchPlanPrompt,
)
from langchain_openai import ChatOpenAI
from tavily import TavilyClient

class Queries(BaseModel):
    """
    A model representing a list of search queries.

    Attributes:
        queries (List[str]): A list of search queries to be executed.
    """

    queries: List[str]

# set up task
task = """
What are the key trends in LLM reseach and application that you see in 2024
"""

# set up LLM and Tavily
model = ChatOpenAI(
    model="gpt-4o-mini", temperature=0, api_key=secrets["OPENAI_API_KEY"]
)
tavily = TavilyClient(api_key=secrets["TAVILY_API_KEY"])

# generate some queries relevant to the task
queries = agent.nodes.model.with_structured_output(Queries).invoke(
            [
                SystemMessage(content=ResearchPlanPrompt.system_template),
                HumanMessage(content=task),
            ]
)

这会生成 5 个与我们定义的任务相关的搜索查询,结果如下所示:

['key trends in LLM research 2024',
 'LLM applications 2024',
 'latest developments in LLM technology 2024',
 'future of LLMs 2024',
 'LLM research advancements 2024']

接下来,我们可以对这些查询中的每一个调用 Tavily 搜索。

response = tavily.search(query=queries[0], max_results=2)

这将提供一个格式良好的结果,包含网址、标题和文本块。

来自 Tavily 搜索的示例结果。图片由作者生成。

这是一个非常强大且易于使用的搜索工具,可以让 LLM 应用程序访问网络,而无需额外的工作!

在我们的研究员代理中,我们目前只使用内容字段,并将其提取并附加到一个列表中,该列表被传递到 AgentState 中。然后,这些信息会被注入到用于写作节点的提示中,从而允许 LLM 在生成报告时访问这些信息。

Tavily 搜索可以做的事情还很多,但要注意,实验使用它会迅速消耗你的免费 API 调用。事实上,对于我们的报告写作任务,有很多应用场景 Tavily 调用可能不是必须的(即 LLM 已经有足够的知识来写报告),所以我建议添加一个额外的条件边,使系统在判断不需要进行网络搜索时跳过 do_researchresearch_revise 节点。我可能很快会在仓库中更新这个修改。

4. 演示一个例子

为了巩固我们刚刚学到的内容,让我们通过一个实际的例子来演示研究人员的工作,使用与上面相同的任务。

首先,我们导入库并设置我们的 LLM 和搜索模型

from research_assist.researcher.Agent import ResearchAgent, load_secrets
from langchain_openai import ChatOpenAI
from tavily import TavilyClient

secrets = load_secrets()
model = ChatOpenAI(
    model="gpt-4o-mini", temperature=0, api_key=secrets["OPENAI_API_KEY"]
)
tavily = TavilyClient(api_key=secrets["TAVILY_API_KEY"])

agent = ResearchAgent(model, tavily)

现在我们可以在任务上运行代理,并给它一个最大的修订次数。

task = """
What are the key trends in LLM reseach and application that you see in 2024
"""
result = agent.run_task(task_description=task,max_revisions=3)

现在代理将运行它的任务,这可能需要大约一分钟。已经添加了日志记录以显示它正在做什么,重要的是,结果正在保存到 in_memory_store 中,我们在第二部分末尾看到了它。

最终报告有几种方式可以访问。它存储在结果列表中,可以像这样在笔记本中可视化。

Markdown(result[-3]['write']['draft'])

它也存储在代理的记忆中,和所有其他输出一起。我们可以按照以下方式访问它。

agent.in_memory_store.search(("1", "memories"))[-3].dict()

报告本身大约有 1300 字——有点多,无法在这里复制——但我已经将其粘贴到了仓库的这里。我们也可以看看编辑器在经过一轮修订后的看法。

editor_comments = agent.in_memory_store.search(("1", "memories"))[-2].dict()
{'value': {'memory': {'editor': {'editor_comment': 
'The report has addressed the critiques by enhancing depth in key sections, 
adding clarity, and improving structure with subheadings. 
It provides specific examples and discusses ethical considerations, 
making it a valuable resource. The revisions are sufficient for publication.',
    'finalized_state': True}}},
 'key': '9005ad06-c8eb-4c6f-bb94-e77f2bc867bc',
 'namespace': ['1', 'memories'],
 'created_at': '2024-11-11T06:09:46.170263+00:00',
 'updated_at': '2024-11-11T06:09:46.170267+00:00'}

看起来编辑器很满意!

为了调试,我们可能需要阅读其他所有输出。不过,在笔记本中做这件事可能会很痛苦,所以在下一篇文章中,我们将讨论如何将这些输出程序化地插入到 Google Docs 中。感谢你坚持看到最后,我们将在第二部分继续

作者与本文讨论的任何工具都没有任何关联。

构建一个能够写入 Google Docs 的研究助手(第二部分)

原文:towardsdatascience.com/building-a-research-assistant-that-can-write-to-google-docs-part-2-ac9dcacff4ff?source=collection_archive---------9-----------------------#2024-11-20

Dalle-3 对“一个 AI 助手将文件随风抛向清澈蓝海”的解读。图像由作者生成。

可能对你做作业有所帮助的工具

Robert Martin-ShortTowards Data Science Robert Martin-Short

·发表于Towards Data Science ·12 分钟阅读·2024 年 11 月 20 日

--

本文是两部分系列中的第二部分,我们使用 LangGraph 和 Tavily 构建了一个简单的研究助手,该助手能够撰写和修改短篇文章。为了跟踪它生成的计划、文章和评论,我们为其增加了编程创建和编辑 Google Docs 文档的功能。在第一篇文章中,我们构建了该助手。现在,我们将构建文档连接功能。你可以在这里找到所有相关代码。

在本系列的第一部分中,我们讨论了代理,并使用 LangGraph 和 Tavily 的工具构建了一个最小化的代理,能够进行研究、写作、审阅和修订短篇文章。这对于展示很有用,但如果我们实际上希望在笔记本外阅读这些文章怎么办?或者,更雄心勃勃的目标是,如何将这个代理做成一个工具,可能对某个学习新主题的人有实际帮助?这有潜力成为一个完整的项目,但在这里,我将专注于其中一个有趣的元素——赋予我们的系统上传文章到 Google Docs 的能力。回想一下,我们还保存了代理在得出最终答案过程中所采取的中间步骤——或许也值得对这些步骤进行记录。

1. 最小可行产品

针对一个问题或话题提示,我们的代理会生成一长串输出。至少,我们希望将其导入到一个 Google 文档中,并附上标题和时间戳。我们还希望能够控制该文档写入 Google Drive 的位置,并最好能够创建和命名文件夹,以便将我们的文章进行有逻辑地存储。我们这里不会过多关注格式化——尽管使用 Google Docs API 完全可以实现——我们更关心的是将文本放入一个人们实际会阅读的地方。格式化可以稍后进行,或者直接留给读者的个人偏好。

一旦我们设置了文档连接,接下来有很多更高级的操作可以做——比如使用 LLM 来重新格式化它们以用于演示,并将其上传到 Google Slides 演示文稿中?或者抓取某个参考数据源并将其上传到 Google Sheets?我们可以将这些功能作为工具添加到代理的控制流程中,让它来决定执行什么。显然这里有很多选择,但最好还是从简单的开始。

2. 连接到 Google Drive

让我们首先写一些代码,以基本方式与 Google Docs 进行交互。首先需要进行一些设置:您需要一个 Google Cloud 账户和一个新的项目。然后,您需要启用 Google Drive 和 Google Docs API。为了为此项目创建一些凭证,我们将使用服务账户,可以按照这里的说明进行设置。此过程将生成一个 .json 文件格式的私钥,您将其存储在本地计算机上。接下来,最好在您的 Google Drive 中为此项目创建一个“主文件夹”。完成后,您可以将您的服务账户添加到该文件夹并授予其写入权限。现在,您的服务账户就具备了以编程方式与该文件夹内容进行交互的权限。

from google.oauth2 import service_account
from abc import ABC, abstractmethod
from googleapiclient.discovery import build
# path to your .json credentials file
from research_assist.gsuite.base.config import CREDENTIALS
from typing import Any

class GSuiteService(ABC):
    """
    An abstract base class for G Suite services.

    This class defines the structure for any G Suite service implementation,
    requiring subclasses to specify the scopes and service creation logic.

    Attributes:
        credential_path (str): The path to the credentials file.
        SCOPES (list): The scopes required for the service.
    """

    def __init__(self) -> None:
        """
        Initializes the GSuiteService with the credential path and scopes.
        """
        # The name of the file containing your credentials
        self.credential_path = CREDENTIALS
        self.SCOPES = self.get_scopes()

    @abstractmethod
    def get_scopes(self) -> list[str]:
        """
        Retrieves the scopes required for the G Suite service.

        Returns:
            list[str]: A list of scopes required for the service.
        """
        raise NotImplementedError("Subclasses must implement this method.")

    @abstractmethod
    def get_service(self, credentials: Any) -> Any:
        """
        Creates and returns the service object for the G Suite service.

        Args:
            credentials (Any): The credentials to use for the service.

        Returns:
            Any: The service object for the G Suite service.
        """
        raise NotImplementedError("Subclasses must implement this method.")

    def build(self) -> Any:
        """
        Builds the G Suite service using the provided credentials.

        Returns:
            Any: The constructed service object.
        """
        # Get credentials into the desired format
        creds = service_account.Credentials.from_service_account_file(
            self.credential_path, scopes=self.SCOPES
        )

        service = self.get_service(creds)
        return service

class GoogleDriveService(GSuiteService):
    """
    A service class for interacting with Google Drive API.

    Inherits from GSuiteService and implements the methods to retrieve
    the required scopes and create the Google Drive service.

    Methods:
        get_scopes: Returns the scopes required for Google Drive API.
        get_service: Creates and returns the Google Drive service object.
    """

    def get_scopes(self) -> list[str]:
        """
        Retrieves the scopes required for the Google Drive service.

        Returns:
            list[str]: A list containing the required scopes for Google Drive API.
        """
        SCOPES = ["https://www.googleapis.com/auth/drive"]
        return SCOPES

    def get_service(self, creds: Any) -> Any:
        """
        Creates and returns the Google Drive service object.

        Args:
            creds (Any): The credentials to use for the Google Drive service.

        Returns:
            Any: The Google Drive service object.
        """
        return build("drive", "v3", credentials=creds, cache_discovery=False)

代码是这样设置的,因为未来我们可能需要使用许多 GSuite API(如 drive、docs、sheets、slides 等)。它们都会继承自 GSuiteService,并将它们的 get_serviceget_scopes 方法重写为该 API 的特定细节。

一旦这一切设置完成,您就可以开始与 Google Drive 进行交互了。这是一篇很棒的文章,展示了几种主要的操作方法。

在我们的实现中,我们与 Google Drive 的交互方式是通过 GoogleDriveHelper 的方法,该方法在初始化时创建一个 GoogleDriveService 实例。我们首先为它提供我们的主文件夹的名称。

from research_assist.gsuite.drive.GoogleDriveHelper import GoogleDriveHelper

master_folder_name = ai_assistant_research_projects
drive_helper = GoogleDriveHelper(f"{master_folder_name}")

假设我们想创建一个关于“旅行者”系列太空探测器的项目。我们可以通过在主文件夹中设置一个文件夹来进行组织:

project_folder_id = drive_helper.create_new_folder("voyager")

这会创建一个文件夹并返回其 ID,我们可以利用这个 ID 在文件夹中创建文档。这个项目可能有多个版本,所以我们也可以创建相关的子文件夹。

version_folder_id = drive_helper.create_new_folder(
  "v1", 
  parent_folder_id=project_folder_id
)

现在我们准备创建一个空白文档,这也可以通过驱动服务来完成。

final_report_id = drive_helper.create_basic_document(
    "final report", parent_folder_id=version_folder_id
)

在后台,驱动助手运行以下代码,它传递了一些元数据,指示我们要通过googleapiclient.discovery.build的创建方法来创建文档(即运行GoogleDriveService().build()时返回的内容)。

document_metadata = {
            "name": document_name,
            "mimeType": "application/vnd.google-apps.document",
            "parents": [parent_folder_id],
}
# make the document
doc = (
  self.drive_service.files()
  .create(body=document_metadata, fields="id")
  execute()
)
doc_id = doc.get("id")

正如你可能想象的那样,Google Drive API 有很多不同的功能和选项,我们在这里并没有涉及。到目前为止,我找到的最全面的 Python 封装库是这个,如果你想进一步探索,这是一个很好的起点。

3. 写入 Google Docs

现在我们已经创建了一个空白文档,接下来让我们用最终的文章填充它!这时GoogleDocsServiceGoogleDocsHelper就派上用场了。GoogleDocsServiceGoogleDriveService非常相似,也继承自我们在第二节中讨论过的GSuiteServiceGoogleDocsHelper包含一些工具,可以将文本和图像写入 Google Docs。它们现在非常基础,但对于这个项目来说已经足够了。

我们可以首先使用我们在第一部分中构建的代理来写一篇关于“旅行者”的文章。

from research_assist.researcher.Agent import ResearchAgent, load_secrets
from langchain_openai import ChatOpenAI
from tavily import TavilyClient

secrets = load_secrets()
model = ChatOpenAI(
    model="gpt-4o-mini", temperature=0, api_key=secrets["OPENAI_API_KEY"]
)
tavily = TavilyClient(api_key=secrets["TAVILY_API_KEY"])

agent = ResearchAgent(llm, tavily)
agent.run_task(
    task_description="The Voyager missions: What did we learn?", 
    max_revisions=3

)

记住,代理的各种输出会存储在它的内存中,可以通过以下方式进行探索。在代码中,你可以看到我们在这里使用“user_id = 1”作为占位符,但在有多个用户的应用程序中,这个 ID 将允许模型访问正确的内存存储。

memories = agent.in_memory_store.search(("1", "memories"))

最终报告的文本可以在这里找到,关键名称与我们在第一部分中讨论过的 AgentState 相对应。它位于索引-3 的位置,因为它后面跟着一个调用编辑器节点(它返回了“是”)和接受节点,当前接受节点只是返回“True”。接受节点可以很容易地扩展,实际上将这个报告自动写入文档。

final_essay = agent.in_memory_store.search(("1", "memories"))[-3].dict()["value"][
    "memory"
]["write"]["draft"]

让我们看看如何将这些文本放入 Google 文档中。回想一下,在第二节中我们用doc_id创建了一个空白文档。GoogleDocsHelper有两个基本方法可以做到这一点。第一个方法用于提供标题和基本元数据,基本上就是文档编写的日期和时间。第二个方法则是将文本粘贴到文档中。

代码展示了如何控制文本的位置和格式,可能会有些混淆。我们定义了一个请求列表,包含像insertText这样的指令。当我们插入文本时,我们需要提供开始插入的索引,它对应于文档中的某个位置。

def create_doc_template_header(self, document_title: str, doc_id: str) -> int:
     """
     Creates a header template for the document, 
     including the title and the current date.

     Args:
         document_title (str): The title of the document.
         doc_id (str): The ID of the document to update.

     Returns:
         int: The index after the inserted header.
     """
     # add template header
     title = f"""
     {document_title}
     """
     template = f"""
     Written on {datetime.date.today()} at {datetime.datetime.now().strftime("%H:%M:%S")}
     """
     requests: List[Dict[str, Any]] = [
            {
                "insertText": {
                    "location": {
                        "index": 1,
                    },
                    "text": template,
                }
            },
            {
                "insertText": {
                    "location": {
                        "index": 1,
                    },
                    "text": title,
                }
            },
            {
                "updateParagraphStyle": {
                    "range": {
                        "startIndex": 1,
                        "endIndex": len(title),
                    },
                    "paragraphStyle": {
                        "namedStyleType": "TITLE",
                        "spaceAbove": {"magnitude": 1.0, "unit": "PT"},
                        "spaceBelow": {"magnitude": 1.0, "unit": "PT"},
                    },
                    "fields": "namedStyleType,spaceAbove,spaceBelow",
                }
            },
            {
                "updateParagraphStyle": {
                    "range": {
                        "startIndex": len(title) + 1,
                        "endIndex": len(title) + len(template),
                    },
                    "paragraphStyle": {
                        "namedStyleType": "SUBTITLE",
                        "spaceAbove": {"magnitude": 1.0, "unit": "PT"},
                        "spaceBelow": {"magnitude": 1.0, "unit": "PT"},
                    },
                    "fields": "namedStyleType,spaceAbove,spaceBelow",
                }
            },
        ]
     result = (
            self.docs_service.documents()
            .batchUpdate(documentId=doc_id, body={"requests": requests})
            .execute()
     )
     end_index = len(title) + len(template) + 1
     return end_index

def write_text_to_doc(self, start_index: int, text: str, doc_id: str) -> int:
     """
     Writes text to the document at the specified index.

     Args:
         start_index (int): The index at which to insert the text.
         text (str): The text to insert.
         doc_id (str): The ID of the document to update.

     Returns:
         int: The index after the inserted text.
     """
     end_index = start_index + len(text) + 1

     requests: List[Dict[str, Any]] = [
            {
                "insertText": {
                    "location": {
                        "index": start_index,
                    },
                    "text": text,
                }
            },
            {
                "updateParagraphStyle": {
                    "range": {
                        "startIndex": start_index,
                        "endIndex": start_index + len(text),
                    },
                    "paragraphStyle": {
                        "namedStyleType": "NORMAL_TEXT",
                        "spaceAbove": {"magnitude": 1.0, "unit": "PT"},
                        "spaceBelow": {"magnitude": 1.0, "unit": "PT"},
                    },
                    "fields": "namedStyleType,spaceAbove,spaceBelow",
                }
            },
        ]

     result = (
            self.docs_service.documents()
            .batchUpdate(documentId=doc_id, body={"requests": requests})
            .execute()
        )

     return end_index

你可以在这里了解有关索引如何定义的更多信息。当有多个 insertText 调用时,似乎先写最后一段文本更容易——例如在下面的代码中,template(即应出现在标题下方的元数据)首先出现在索引 1 的列表中。然后我们在索引 1 写入 title。这导致 title 在文档中首先出现,而 template 出现在其下方。注意,我们还需要指定 paragraphStyle 块的 startIndexendIndex,才能改变文本的格式。

上面代码中的两种方法都会返回当前文本块的结束索引,以便它可以用作后续要附加文本块的起始索引。如果你打算在文档的样式和格式上更加富有创意,这份指南可能会有所帮助。

现在我们已经看到了底层代码,我们可以调用它来将最终报告写入文档。

from research_assist.gsuite.docs.GoogleDocsHelper import GoogleDocsHelper

docs_helper = GoogleDocsHelper()

# add the document title 
title_end_index = docs_helper.create_doc_template_header(
    "voyager final report", doc_id
)

# add the text
doc_end_index = docs_helper.write_text_to_doc(
    start_index=title_end_index, text=final_essay, doc_id=doc_id
)

太好了!现在我们有了所有文档工具,可以用来编辑、格式化并分享我们的代理生成的报告。有趣的是,代理将文本格式化为 markdown,而 Google Docs 支持这种格式,但我没能找到一种方法来让文档自动识别并将 markdown 转换为漂亮的标题和副标题。毫无疑问,一定有办法做到这一点,这样报告看起来会更漂亮。

在运行上面的代码后,文档应该会呈现如下所示的内容。

包含代理生成的最终报告的文档截图。图像由作者生成

4. 那么其他代理输出呢?

我们应该能够将代理内存中存储的所有信息写入文档,这样我们就可以轻松浏览每个阶段的结果。一种稍微黑客一点的方式如下:

memories = agent.in_memory_store.search(("1", "memories"))

# this is needed because we may call some nodes several times 
# and we want to keep track of this so that we can make new documents
# for each call
seen_keys = set()
iterations = defaultdict(int)

# folder id where we want to write the documents
folder_id = f"{folder_id}"

for m in memories:
    data = m.dict()["value"]["memory"]
    available_keys = data.keys()
    node_key = list(available_keys)[0]
    unique_node_key = node_key + "_00"
    if unique_node_key in seen_keys:
        iterations[node_key] += 1
        unique_node_key = unique_node_key.replace("_00", "") + "_{:02d}".format(
            iterations[node_key]
        )

    print("-" * 20)
    print("Creating doc {}".format(unique_node_key))

    # get the text
    text = data[node_key][list(data[node_key].keys())[0]]

    # the tavily research output is a list, so convert it to a string
    if isinstance(text, List):
        text = "\n\n".join(text)

    # if anything else is not a string (e.g. the output of the accept node)
    # convert it to a string
    if not isinstance(text, str):
        text = str(text)

    # create document
    report_id = drive_service.create_basic_document(
        unique_node_key, parent_folder_id=folder_id
    )

    # create header
    end_index = docs_helper.create_doc_template_header(unique_node_key, report_id)

    # fill document
    end_index = docs_helper.write_text_to_doc(
        start_index=end_index, text=text, doc_id=report_id
    )

    seen_keys.add(unique_node_key)

这将生成 7 个文档,下面我们将查看一些示例截图。

运行上述代码的输出。图像由作者生成

初步计划概述了报告的结构。有趣的是,模型似乎更倾向于使用大量简短的章节,我认为这很合适,因为提示要求将报告做得简洁且易于普通读者消化。

上述代码片段编写的初步计划和研究文档的部分截图。图像由作者生成。

在研究阶段,调用了 Tavily 搜索,并返回了与使用的查询相关的格式良好的小块文本。其中一些小块被截断,这使得文档不太易读,但它很好地展示了从研究节点到写作节点传递的信息类型。

在审阅阶段,我们得到了对文章第一版本的精彩批评。通常这些审阅的结构与初始计划类似,提出许多非常笼统的建议,如“考虑使用更具描述性的标题”或“这一部分可以扩展,增加更多例子”。如果我们比较审阅前后的实际报告,通常会看到结构上的微小变化以及每个部分中增加的一些细节。这是否真正提高了文本的质量值得商榷,但通过在几个例子中进行尝试,我相信它确实有所帮助。

这是通过上述代码片段生成的部分审阅和编辑反馈文档的截图。图片由作者生成。

最后,我们得到了编辑对审阅后草稿的判断。我目前使用的提示使得编辑相当宽容,因此它通常会说一些类似这里所示的内容。通过调整一些提示,我们可以鼓励它在需要时将更多的报告发送回审阅。

这就是本文及本小系列的全部内容。感谢阅读,希望你能从中找到一些对你自己项目有用的内容。在让研究代理更加健壮、对其输出进行适当评估以及更好地与 Docs(或其他 GSuite API)集成方面,这里有很多潜在的扩展。如果你有任何其他酷炫的想法,请告诉我!

作者与本文讨论的任何工具没有关联。

构建一个稳健的数据可观察性框架以确保数据质量和完整性

原文:towardsdatascience.com/building-a-robust-data-observability-framework-to-ensure-data-quality-and-integrity-07ff6cffdf69?source=collection_archive---------5-----------------------#2024-08-27

我们如何通过开源工具提高可观察性?

Jurgita Motus | CoresignalTowards Data Science Jurgita Motus | Coresignal

·发布于Towards Data Science ·7 分钟阅读·2024 年 8 月 27 日

--

照片由 rivage 提供,来自 Unsplash

传统的监控方式已经无法满足复杂数据组织的需求。数据工程师不再依赖反应式系统来识别已知问题,而是必须创建互动式可观察性框架,帮助他们快速发现任何类型的异常。

虽然可观察性涵盖了许多不同的实践,但在本文中,我将分享一个高层次的概述以及我们在组织中使用开源工具构建可观察性框架的实际经验和技巧。

那么,如何构建一个能够有效显示数据健康状况并确保数据质量的基础设施呢?

什么是数据可观察性?

总的来说,可观察性定义了你从外部输出中能够了解多少有关内部系统的信息。这个术语最早由匈牙利裔美国工程师Rudolf E. Kálmán在 1960 年提出,他在讨论数学控制系统中的可观察性时首次定义了这一术语。

多年来,这一概念已经被应用于多个领域,包括数据工程。在这里,它解决了数据质量的问题,并能够追踪数据是如何收集的以及如何进行转化的。

数据可观察性意味着确保所有管道和系统中的数据都是完整且高质量的。这是通过实时监控和管理数据来解决质量问题。可观察性确保了清晰度,使得在问题蔓延之前能够采取行动。

什么是数据可观察性框架?

数据可观察性框架是一个监控和验证机构内数据完整性和质量的过程。它有助于主动确保数据的质量和完整性。

该框架必须基于五个强制性方面,这些方面由IBM定义:

  1. 数据新鲜度。必须找到并移除任何过时的数据。

  2. 分布。必须记录预期数据值,以帮助识别异常值和不可靠数据。

  3. 数据量。必须跟踪预期数据值的数量,以确保数据完整。

  4. 数据模式。必须监控数据表和组织的变化,以帮助找到破损的数据。

  5. 血缘追踪。收集元数据并映射数据源是帮助故障排除的必要步骤。

这五个原则确保数据可观察性框架有助于维护和提高数据质量。通过实施以下数据可观察性方法,您可以实现这些目标。

如何将可观察性实践添加到数据管道中

只有从可靠来源收集的高质量数据才能提供准确的见解。正如那句老话所说:垃圾进,垃圾出。您不能指望从组织混乱的数据集中提取任何实际的知识。

作为公共数据提供商 Coresignal 的高级数据分析师,我不断寻求改善数据质量的新方法。尽管在动态的技术环境中实现这一目标相当复杂,但许多路径可以通向它。良好的数据可观察性在这里发挥着重要作用。

那么,我们如何确保数据的质量呢?归根结底,就是在每个数据管道阶段中添加更好的可观察性方法——从数据摄取、转换到存储和分析。这些方法中的一些将在整个管道中起作用,而另一些只会在某个特定阶段相关。让我们来看看:

跨越数据管道不同阶段的数据可观察性。来源:Jurgita Motus

首先,我们需要考虑涵盖整个管道的五个项目:

  1. 端到端数据血缘追踪。追踪血缘关系可以让您快速访问数据库历史,并从原始数据源跟踪数据直到最终输出。通过理解结构及其关系,您将更容易发现不一致之处,从而避免它们成为问题。

  2. 端到端测试。验证过程会检查数据管道各个阶段的完整性和质量,帮助工程师确定管道是否正常运行,并发现任何不典型的行为。

  3. 根本原因分析。如果管道的任何阶段出现问题,工程师必须能够准确定位源头,并快速找到解决方案。

  4. 实时警报。可观察性的一个重要目标是迅速发现新出现的问题。在标记异常行为时,时间至关重要,因此任何数据可观察性框架都必须能够实时发送警报。这对数据接收以及存储和分析阶段尤其重要。

  5. 异常检测。数据管道中可能会出现丢失数据或性能低下等问题。异常检测是一种先进的可观察性方法,通常会在流程的后期实现。在大多数情况下,需要机器学习算法来检测数据和日志中的异常模式。

接下来,我们有五个其他项目,在不同的数据管道阶段中更为相关:

  1. 服务水平协议(SLAs)。SLA 有助于为客户和供应商设定标准,并定义数据的质量、完整性和一般责任。SLA 的阈值还可以在设置警报系统时提供帮助,通常,它们会在数据接收阶段之前或期间签署。

  2. 数据合同。这些协议定义了数据进入其他系统之前的结构方式。它们充当一套规则,明确你可以期望的数据的新鲜度和质量水平,通常会在数据接收阶段之前谈判确定。

  3. 架构验证。它保证数据结构的一致性,并确保与下游系统的兼容性。工程师通常在数据接收或处理阶段验证架构。

  4. 日志、指标和追踪。虽然这些对于监控性能至关重要,但收集和轻松访问这些关键信息将成为危机中的有力工具——它帮助你更快地找到问题的根本原因。

  5. 数据质量仪表板。仪表板帮助监控数据管道的整体健康状况,并提供可能出现问题的高级视图。它们确保通过其他可观察性方法收集到的数据以清晰、实时的方式呈现。

最后,数据可观察性无法实现,如果框架中没有自我评估,因此,持续审计和检查系统对于任何组织来说都是必须的。

接下来,让我们讨论一些可能有助于你简化工作流程的工具。

数据可观察性平台及其功能

那么,如果你开始在组织中构建数据可观察性框架,应该考虑哪些工具呢?虽然市场上有许多选择,但根据我的经验,你最好的选择是从以下工具开始。

在构建我们的数据基础设施时,我们专注于最大化利用开源平台。下面列出的工具在处理大量数据时,能够确保透明度和可扩展性。虽然它们中的大多数工具并非专门用于数据可观察性,但它们结合使用时能提供确保数据管道可见性的好方法。

下面是我推荐查看的五个必要平台的列表:

  1. Prometheus 和 Grafana 平台互为补充,帮助工程师实时收集和可视化大量数据。Prometheus 是一款开源监控系统,非常适合数据存储和观察,而可观察性平台 Grafana 则通过易于导航的可视化仪表板帮助追踪新趋势。

  2. Apache Iceberg 表格格式提供了数据库元数据的概览,包括跟踪表列的统计信息。跟踪元数据有助于更好地理解整个数据库,而无需不必要地处理数据。它不完全是一个可观察性平台,但其功能允许工程师更好地了解他们的数据。

  3. Apache Superset 是另一款开源数据探索和可视化工具,可以帮助展示大量数据、构建仪表板并生成警报。

  4. Great Expectations 是一个帮助测试和验证数据的 Python 包。例如,它可以使用预定义规则扫描样本数据集,并创建数据质量条件,稍后可用于整个数据集。我们的团队使用 Great Expectations 对新数据集进行质量测试。

  5. Dagster 数据管道编排工具可以帮助确保数据血缘关系并进行资产检查。尽管它不是作为数据可观察性平台而创建的,但它通过现有的数据工程工具和表格格式提供可视化。该工具有助于找出数据异常的根本原因。该平台的付费版本还包含由 AI 生成的洞察。这款应用程序提供自助式可观察性,并带有内置的资产目录,用于跟踪数据资产。

请记住,这些只是众多可选工具中的一部分。务必进行研究,找到适合你组织的工具。

如果忽视数据可观察性原则会发生什么?

一旦出现问题,组织通常依赖工程师的直觉来找出问题的根本原因。正如软件工程师 Charity Majors 在她的回忆录中生动地解释的那样,在 MBaaS 平台 Parse 工作时,大多数传统的监控系统都是由在公司工作时间最长的工程师推动的,他们能够迅速猜测系统的问题。这使得资深工程师变得不可替代,并且带来了其他问题,比如较高的职业倦怠率。

使用数据可观察性工具可以消除故障排除中的猜测,减少停机时间,并增强信任。如果没有数据可观察性工具,你可能会遇到较长的停机时间、数据质量问题以及对新出现问题反应迟缓等问题。因此,这些问题可能会迅速导致收入损失、客户流失,甚至损害品牌声誉。

数据可观察性对处理大量信息并必须保证数据质量和完整性不中断的大型企业至关重要。

数据可观察性未来会如何发展?

数据可观察性是每个组织必须具备的,尤其是那些从事数据收集和存储的公司。一旦所有工具就位,就可以开始使用先进的方法来优化这一过程。

机器学习,特别是大型语言模型(LLMs),是这里显而易见的解决方案。它们可以帮助快速扫描数据库,标记异常,并通过识别重复项或添加新的丰富字段来提高整体数据质量。同时,这些算法还能帮助跟踪模式和日志的变化,改善数据一致性并提升数据血缘关系。

然而,选择合适的时机来实施你的人工智能计划至关重要。提升你的可观察性能力需要资源、时间和投资。在开始使用自定义的 LLM 之前,你应该仔细考虑这是否真的能为你的组织带来好处。有时,坚持使用上述已经有效完成工作的标准开源数据可观察性工具,可能会更高效。

构建安全且可扩展的数据与 AI 平台

原文:towardsdatascience.com/building-a-secure-and-scalable-data-and-ai-platform-074e191b291f?source=collection_archive---------7-----------------------#2024-02-22

通过数据驱动的决策赋能业务

Adil RizviTowards Data Science Adil Rizvi

·发表于Towards Data Science ·7 分钟阅读·2024 年 2 月 22 日

--

图片由Igor Omilaev提供,来自Unsplash

在过去的四年里,我有幸领导了全球规模的大数据和 AI 平台的战略、设计和实施,涉及的不仅是一个而是两个公共云平台——AWS 和 GCP。此外,我的团队使 70 多个数据科学/机器学习(DSML)用例和 10 个数字应用得以投入运营,为公司贡献了约 1 亿美元的收入增长。

这段旅程充满了令人兴奋的挑战和一些陡峭的学习曲线,但最终的结果非常有影响力。通过这篇文章,我想分享我的学习和经验,这将帮助其他技术创新者思考他们的规划过程,并使他们的实施能够跨越式发展。

本文将主要集中在基础构建上,以提供整体生产生态系统的全貌。在之后的文章中,我将讨论技术选择,并分享更详细的规范性建议。

让我先给你展示一下数据和 AI 平台的构建模块。

数据和 AI 平台的端到端区块级架构

思考从端到端架构是一个极好的主意,因为这样你可以避免快速而粗糙完成工作的常见陷阱。毕竟,你的 ML 模型输出的质量取决于你输入的数据。而且,你不想在数据安全性和完整性上做出妥协。

1. 数据采集与摄取

创建一个良好的数据操作(DataOps)框架对于整体数据导入过程至关重要。很多因素取决于生成数据的来源(结构化与非结构化数据)以及接收数据的方式(批量、复制、近实时、实时)。

在获取数据时,有多种方式可以将其导入 -

  1. 提取 → 加载(无需转换)

  2. 提取 → 加载 → 转换(主要用于批量上传)

  3. 提取 → 转换 → 加载(适用于流式数据)

特征工程师必须进一步结合数据,创建用于机器学习用例的特征(特征工程)。

2. 数据存储

选择最佳的数据存储至关重要,像 S3、GCS 或 Blob Storage 这样的对象存储桶是导入原始数据的最佳选择,尤其适用于非结构化数据。

对于纯粹的分析用例,此外,如果你要导入 SQL 结构化数据,也可以将数据直接导入云数据仓库(如 Big Query 等)。许多工程团队也更倾向于使用数据仓库存储(与对象存储不同)。你的选择将取决于使用场景和涉及的成本。请谨慎选择!

通常,你可以直接从内部和外部(第一方和第三方)源导入数据,而无需任何中间步骤。

然而,在一些情况下,数据提供方可能需要访问你的环境进行数据交易。计划在 DMZ 设置中为第三方创建一个着陆区,以防止将整个数据系统暴露给供应商。

此外,对于合规相关的数据,如 PCI、PII 和受监管的数据(如 GDPR、MLPS、AAPI、CCPA 等),应创建结构化存储区,从一开始就妥善处理这些数据。

记得根据你的机器学习模型和分析报告的时间旅行或历史上下文要求,规划数据保留和备份策略。虽然存储便宜,但随着时间的推移,积累的数据会成倍增加成本。

3. 数据治理

虽然大多数组织擅长导入和存储数据,但大多数工程团队在使数据对最终用户可消费方面存在困难。

导致采纳不良的主要因素有 —

  1. 组织内的数据素养不足

  2. 缺乏明确定义的数据目录和数据字典(元数据)

  3. 无法访问查询接口

数据团队必须与法律、隐私和安全团队合作,了解国家和地区的数据法规以及合规要求,以确保数据治理的合规性。

实施数据治理的几种方法包括:

  1. 数据掩码和匿名化

  2. 基于属性的访问控制

  3. 数据本地化

如果未能妥善保护存储和数据访问,可能会使组织面临法律问题及相关处罚。

4. 数据消费模式

随着数据被转化并丰富为业务关键绩效指标(KPIs),数据的呈现和消费有不同的方面。

对于纯粹的可视化和仪表盘,简单的存储数据访问和查询接口就足够了。

随着需求变得更加复杂,比如向机器学习模型提供数据,你必须实施和增强功能存储。这个领域需要成熟,且大多数云原生解决方案仍处于生产级就绪的早期阶段。

同时,寻找一个水平数据层,通过 API 向其他应用程序提供数据消费。GraphQL 是一个很好的解决方案,能够帮助创建微服务层,从而显著提升访问的便捷性(数据即服务)。

随着这一领域的成熟,考虑将数据结构化为数据产品域,并在业务单元中找到数据管理员,作为该域的管理者。

5. 机器学习

在数据后处理后,机器学习采用两步走的方式——模型开发和模型部署与治理。

操作化 AI 平台

在模型开发阶段,机器学习工程师与数据科学家密切合作,直到模型被打包并准备好进行部署。选择机器学习框架和功能,并与数据科学家合作进行超参数调优和模型训练,都是开发生命周期的一部分。

创建部署流水线并选择技术栈来使模型能够投入生产并提供服务,这些都属于 MLOps 范畴。MLOps 工程师还提供机器学习模型管理,包括监控、评分、漂移检测和启动再训练。

自动化机器学习模型生命周期中的所有这些步骤有助于实现规模化。

不要忘记将所有训练过的模型存储在机器学习模型注册表中,并促进重用,以提高操作效率。

6. 生产操作

提供模型输出需要与其他功能领域的持续合作。提前规划和开放的沟通渠道对于确保发布日程的协调至关重要。请务必做到这一点,以避免错过截止日期、技术选择冲突以及集成层的问题。

根据消费层和部署目标,你将通过 API 发布模型输出(模型端点),或者让应用程序直接从存储中获取推断结果。结合 API 网关使用 GraphQL 是实现这一目标的高效方式。

7. 安全层

分离管理平面并创建共享服务层,这将成为你的云账户的主要进出口点。它也将是你组织内外部公有/私有云的会面室。

共享服务 — 谷歌云平台

共享服务 — 亚马逊 Web 服务

你的服务控制策略(AWS)或组织政策限制(GCP)应集中管理,并保护资源,防止在没有适当访问控制的情况下被创建或托管。

8. 用户管理界面 / 消费层

明智的做法是提前选择云账户的结构。您可以根据业务线(LOB)、产品领域或两者的混合来构建账户结构。同时,设计并分隔您的开发、测试和生产环境。

最好将您的 DevOps 工具链集中化。我更倾向于使用一个与云平台无关的工具集,以支持在混合多云生态系统之间的无缝集成和过渡。

对于开发者 IDE,可能会有个人 IDE 和共享 IDE 的混合。确保开发者经常将代码提交到代码库,否则他们可能会丢失工作进度。

使用云无关的 DevSecOps 工具链进行 GCP 设置

端到端数据科学过程

在组织动态中进行导航,并将利益相关者聚集在一个共同的对齐目标上,对于成功的生产部署和持续运营至关重要。

我正在分享使这个复杂系统顺畅运行的跨职能工作流和流程。

从头到尾的数据科学模型部署过程

结论

希望这篇文章能激发您的思考,激发新的想法,并帮助您勾画出您的工作全貌。这是一个复杂的任务,但通过深思熟虑的设计、精心规划的执行和大量的跨职能合作,您将能够轻松应对。

最后的建议:不要仅仅因为某项技术解决方案看起来很酷就去创建它。首先要理解业务问题,并评估潜在的投资回报。最终目标是创造业务价值,并为公司的收入增长做出贡献。

祝你在构建或完善数据和 AI 平台的过程中好运。

一路顺风!
Adil {LinkedIn}

<< 除非另有说明,所有图片均来自作者 >>

构建语义图书搜索:使用 Apache Spark 和 AWS EMR Serverless 扩展嵌入管道

原文:towardsdatascience.com/building-a-semantic-book-search-part-2-scaling-the-embedding-pipeline-with-apache-spark-and-aws-1d074ee9cb55?source=collection_archive---------8-----------------------#2024-01-31

图片来源:Unsplash

使用 OpenAI 的 Clip 模型支持对 70,000 本书封面的自然语言搜索

Eva RevearTowards Data Science Eva Revear

·发布在Towards Data Science ·阅读时长 8 分钟·2024 年 1 月 31 日

--

上一篇文章中,我做了一个小的 PoC,看看能否使用 OpenAI 的 Clip 模型来构建语义图书搜索。依我看,效果出乎意料地好,但我不禁想,是否有更多数据会更好。之前的版本仅使用了大约 3.5 千本书,但在Openlibrary 数据集中有数百万本书,我觉得尝试添加更多的搜索选项是值得的。

然而,完整的数据集大约有 40GB,试图在我的小笔记本电脑上,甚至在 Colab 笔记本中处理这么多数据有点困难,所以我不得不想办法构建一个管道来处理过滤和嵌入更大的数据集。

TLDR; 它改善了搜索吗?我认为是的!我们将数据量增加了 15 倍,这为搜索提供了更多可用数据。它还不完美,但我觉得结果相当有趣;虽然我没有做正式的准确度评测。

这是一个无论如何表述都无法在上一版本中运行的例子,但在使用更多数据的版本中效果相当不错。

作者图片

如果你感兴趣,可以在Colab上试试看!

总体来说,这是一次有趣的技术旅程,过程中遇到了许多障碍和学习机会。技术栈仍然包括 OpenAI 的 Clip 模型,但这次我使用了 Apache Spark 和 AWS EMR 来运行嵌入管道。

技术栈

图片由作者提供

这似乎是一个使用 Spark 的好机会,因为它允许我们将嵌入计算并行化。

我决定在 EMR Serverless 中运行管道,这是 AWS 提供的一项相对较新的服务,提供了一个无服务器环境来运行 EMR 并自动管理资源的扩展。我认为这对于这个用例很合适——而不是在 EC2 集群上启动 EMR——因为这是一个相对临时的项目,我对集群成本很敏感,而且最初我不确定这个作业需要什么资源。EMR Serverless 使得实验作业参数变得非常简单。

以下是我完成一切并使其运行的完整过程。我想可能有更好的方法来管理某些步骤,这只是对我有效的方式,如果你有想法或意见,请一定分享!

使用 Spark 构建嵌入管道作业

初步步骤是编写 Spark 作业。整个管道分为两个阶段,第一个阶段获取初始数据集并筛选出近十年内的小说(过去 10 年)。这导致了大约 25 万本书籍,其中约 7 万本有封面图片可以下载并嵌入到第二阶段中。

首先,我们从原始数据文件中提取相关列。

然后对数据类型进行一些通用的数据转换,并筛选出除英文小说(超过 100 页)以外的所有数据。

第二阶段获取第一阶段的输出数据集,并通过从 Hugging Face 下载的 Clip 模型处理图片。这里的关键步骤是将我们需要应用于数据的各种函数转化为 Spark UDF。最关键的函数是 get_image_embedding,它接受图片并返回嵌入信息。

我们将其注册为 UDF:

然后在数据集上调用 UDF:

设置向量数据库

作为代码中的最后一个可选步骤,我们可以设置一个向量数据库,在这个案例中是 Milvus,用于加载和查询。请注意,我没有在此项目的云作业中执行此操作,因为我将我的嵌入数据序列化存储,以便不需要保持集群长时间运行。然而,设置 Milvus 并将 Spark DataFrame 加载到集合中是相当简单的。

首先,创建一个集合,并在图像嵌入列上创建索引,数据库可以用来进行搜索。

然后我们可以在 Spark 脚本中访问该集合,并从最终的 DataFrame 中将嵌入加载到其中。

最后,我们可以使用与上述 UDF 相同的方法将搜索文本嵌入,并使用嵌入信息查询数据库。数据库负责进行繁重的匹配工作,找出最佳匹配。

在 AWS 中设置管道

前提条件

现在有一些设置需要完成,以便在 EMR Serverless 上运行这些作业。

我们需要以下先决条件:

  • 一个 S3 桶,用于存放作业脚本、输入和输出以及作业所需的其他工件

  • 一个具有 S3 读取、列出和写入权限的 IAM 角色,以及 Glue 的读取和写入权限。

  • 一个信任策略,允许 EMR 作业访问其他 AWS 服务。

AWS 文档中有关于角色和权限策略的详细描述,以及如何开始使用 EMR Serverless 的概述:开始使用 Amazon EMR Serverless

接下来,我们需要设置一个 EMR Studio:创建 EMR Studio

通过互联网网关访问网页

另一个特定于此作业的设置是,我们必须允许该作业访问互联网,而默认情况下 EMR 应用程序是无法做到这一点的。正如我们在脚本中看到的,作业需要访问要嵌入的图像,以及访问 Hugging Face 以下载模型配置和权重。

注意:可能有更高效的方式来处理模型,而不是将其下载到每个工作节点(例如广播、将其存储在系统中的某个地方等),但在这种情况下,对于一次数据运行,这就足够了。

无论如何,允许 Spark 作业运行的机器访问互联网需要配置具有 NAT 网关的私有子网的 VPC。所有这些设置都从访问 AWS VPC 界面开始 -> 创建 VPC -> 选择 VPC 和更多选项 -> 选择至少一个 NAT 网关选项 -> 点击创建 VPC。

图像由作者提供

VPC 设置需要几分钟。一旦完成,我们还需要在安全组界面中创建一个安全组,并将刚才创建的 VPC 附加到它。

创建 EMR Serverless 应用程序

现在,来看看将提交作业的 EMR Serverless 应用程序!创建并启动一个 EMR Studio 应该会打开一个界面,提供几个选项,包括创建应用程序。在创建应用程序的 UI 中,选择使用自定义设置 -> 网络设置。在这里,VPC、两个私有子网和安全组将发挥作用。

图像由作者提供

构建虚拟环境

最后,环境中没有太多库,因此为了添加额外的 Python 依赖项,我们可以使用本地 Python 或创建并打包一个虚拟环境:在 EMR Serverless 中使用 Python 库

我选择了第二种方式,最简单的方法是使用 Docker,因为它允许我们在运行 EMR 作业的 Amazon Linux 发行版中构建虚拟环境(在其他发行版或操作系统中做这件事可能会变得非常混乱)。

另一个警告:小心选择与您使用的 Python 版本对应的 EMR 版本,并相应地选择合适的包版本。

Docker 过程将打包好的虚拟环境输出为 pyspark_dependencies.tar.gz,然后将其与作业脚本一起上传到 S3 存储桶。

然后,我们可以将这个打包好的环境和其他 Spark 作业配置一起发送出去。

太好了!我们有了作业脚本、环境依赖项、网关和一个 EMR 应用程序,我们可以提交作业了!不过,别着急!接下来才是真正有趣的部分:Spark 调优。

如前所述,EMR Serverless 会自动扩展以处理我们的工作负载,这通常是很好的,但我发现(事后看来是显而易见的)它对于这个特定的用例并没有什么帮助。

几万条记录根本不算是“大数据”;Spark 需要处理的是 TB 级别的数据,而我发送的基本上只是几千个图片 URL(甚至连图片本身都没有)。如果让 EMR Serverless 自行决定,它会把任务发送到一个节点,在单线程中处理,完全违背了并行化的初衷。

此外,虽然嵌入作业处理的数据量相对较小,但它们会显著扩大数据量,因为嵌入体积相当大(在 Clip 的情况下是 512)。即使你让那个节点不停地运行几天,它也会在完成所有数据处理之前就耗尽内存。

为了让它运行,我尝试了几个 Spark 属性,目的是能够在集群中使用大型机器,但将数据拆分成非常小的分区,以便每个核心只需要处理少量数据并输出:

  • spark.executor.memory:每个执行器进程使用的内存量。

  • spark.sql.files.maxPartitionBytes:读取文件时单个分区中要打包的最大字节数。

  • spark.executor.cores:每个执行器使用的核心数。

你需要根据数据的具体性质调整这些设置,嵌入仍然不是一个快速的过程,但它能够处理我的数据。

结论

与我的上一篇文章一样,结果当然不是完美的,绝对不能替代其他人提供的真实书籍推荐!但话说回来,对于我搜索的一些问题,确实有一些准确的答案,我觉得还挺酷的。

如果你想自己玩一下这个应用,它在Colab上,整个管道的代码在Github上!

在 LangChain 中使用工具和工具包构建简单代理

原文:towardsdatascience.com/building-a-simple-agent-with-tools-and-toolkits-in-langchain-77e0f9bd1fa5?source=collection_archive---------1-----------------------#2024-04-10

熟悉 LangChain 中代理的构建模块

Sami MaameriTowards Data Science Sami Maameri

·发布于Towards Data Science ·13 分钟阅读·2024 年 4 月 10 日

--

图片由Dan LeFebvre提供,来源于Unsplash

让我们在 LangChain 中构建一个简单的代理,帮助我们理解一些基础概念以及代理如何工作的构建模块。

通过保持简单,我们可以更好地理解这些代理背后的基础理念,从而在未来构建更复杂的代理。

**Contents**

What are Agents?

Building the Agent
- The Tools
- The Toolkit
- The LLM
- The Prompt

The Agent

Testing our Agent

Observations

The Future

Conclusion

想要获得我未来文章的通知吗?在此订阅

什么是代理

LangChain 文档实际上有一个相当好的页面介绍了关于代理的高层次概念。它简短易懂,在开始之前浏览一下肯定值得。

如果你查找人工智能代理的定义,你会看到类似于“一个能够感知其环境、对环境采取行动、做出智能决策以达到目标,并且具有边走边学习能力的实体”的描述。

我认为这完全符合 LangChain 代理的定义。让这一切在软件中成为可能的是大型语言模型(LLM)的推理能力。LangChain 代理的大脑是 LLM。正是 LLM 被用来推理执行用户请求的最佳方法。

为了执行任务并操作事物以及获取信息,代理在 LangChain 中有一些叫做工具(Tool)的东西可供使用。正是通过这些工具,它能够与环境进行交互。

这些工具基本上就是代理可以访问的方法/类,它们可以做一些事情,比如通过 API 与股票市场指数交互、更新 Google 日历事件或对数据库执行查询。我们可以根据需要构建工具,这取决于我们试图通过代理完成的任务的性质。

在 LangChain 中,工具的集合称为 Toolkit。在实现上,这实际上只是一个包含可供代理使用的工具的数组。因此,LangChain 中代理的高级概述大致如下所示

图片由作者提供

所以,从基本的角度来看,一个代理需要

  • 一个 LLM 作为它的大脑,并赋予它推理能力

  • 工具,以便它能够与周围的环境进行交互,并实现其目标

构建代理

为了让这些概念更加具体,我们来构建一个简单的代理。

我们将创建一个数学代理,它可以执行一些简单的数学运算。

环境设置

首先,让我们设置我们的环境和脚本

mkdir simple-math-agent && cd simple-math-agent
touch math-agent.py
python3 -m venv .venv
. .venv/bin/activate
pip install langchain langchain_openai

另外,你也可以克隆这里使用的代码从 GitHub

git clone git@github.com:smaameri/simple-math-agent.git

或者也可以查看Google Colab中的代码。

工具

最简单的开始方式是首先为我们的数学代理定义工具。

让我们为它提供“add”、“multiply”和“square”工具,以便它能够对我们传递给它的问题执行这些操作。通过保持工具简单,我们可以专注于核心概念,自己构建工具,而不是依赖像 WikipediaTool 这样更复杂的现成工具,它作为 Wikipedia API 的包装器,需要我们从 LangChain 库中导入。

再次强调,我们并不打算做什么复杂的事情,只是保持简单,将代理的主要构建模块拼凑在一起,以便我们能够理解它们是如何工作的,并让我们的第一个代理启动并运行。

我们从“add”工具开始。在 LangChain 中创建工具的自底向上的方式是扩展BaseTool类,设置类中的namedescription字段,并实现_run方法。代码如下所示

from langchain_core.tools import BaseTool

class AddTool(BaseTool):
    name = "add"
    description = "Adds two numbers together"
    args_schema: Type[BaseModel] = AddInput
    return_direct: bool = True

    def _run(
        self, a: int, b: int, run_manager: Optional[CallbackManagerForToolRun] = None
    ) -> str:
        return a + b

注意,我们需要实现_run方法,以显示我们的工具如何处理传递给它的参数。

还要注意它需要一个 pydantic 模型作为args_schema。我们将在这里定义它。

AddInput
    a: int = Field(description="first number")
    b: int = Field(description="second number")

现在,LangChain 确实为我们提供了一种更简便的方式来定义工具,而不需要每次都扩展BaseTool类。我们可以借助@tool装饰器来做到这一点。使用@tool 装饰器在 LangChain 中定义“add”工具的代码如下所示

from langchain.tools import tool

@tool
def add(a: int, b: int) -> int:
 “””Adds two numbers together””” # this docstring gets used as the description
 return a + b # the actions our tool performs

更简单对吧。幕后,装饰器神奇地使用了提供的方法来扩展 BaseTool 类,就像我们之前做的那样。有一点需要注意:

  • 方法名称也成为工具名称

  • 方法参数定义了工具的输入参数

  • 文档字符串被转换为工具的描述

你也可以在工具上访问这些属性

print(add.name) # add
print(add.description) # Adds two numbers together.
print(add.args) # {'a': {'title': 'A', 'type': 'integer'}, 'b': {'title': 'B', 'type': 'integer'}}

请注意,工具的描述非常重要,因为 LLM 会根据这个描述来决定是否选择该工具。如果描述不准确,可能导致工具在该使用时没有被使用,或者在错误的时机被使用。

完成 add 工具后,让我们继续定义 multiplysquare 工具。

@tool
def multiply(a: int, b: int) -> int:
    """Multiply two numbers."""
    return a * b

@tool
def square(a) -> int:
    """Calculates the square of a number."""
    a = int(a)
    return a * a

就是这样,简单如斯。

所以我们定义了自己的三个 自定义工具。一个更常见的用例可能是使用 LangChain 中已提供和存在的一些工具,你可以在 这里 查看。不过,在源代码层面,它们都会使用类似上述描述的方法来构建和定义。

到这里为止,我们的工具就算完成了。现在是时候将工具组合成一个工具包了。

工具包

工具包听起来很复杂,但其实它们非常简单。它们实际上就是一组工具的列表。我们可以像这样定义我们的工具包,作为一个工具数组:

toolkit = [add, multiply, square]

就这样。非常简单,没有什么好混淆的。

通常,工具包是一些可以一起使用的工具组,对于那些试图执行特定任务的智能体来说非常有帮助。例如,SQLToolkit 可能包含用于生成 SQL 查询、验证 SQL 查询和执行 SQL 查询的工具。

LangChain 文档中的 集成工具包 页面列出了由社区开发的许多工具包,可能对你有用。

LLM

如上所述,LLM 是智能体的大脑。它根据传入的问题决定调用哪些工具,根据工具的描述决定下一步应该采取哪些最佳步骤。它还决定何时达到最终答案,并准备将其返回给用户。

在这里设置 LLM

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-3.5-turbo-1106", temperature=0)

提示词

最后,我们需要一个提示词传递给我们的智能体,这样它就能大致了解它是什么类型的智能体,以及应该解决哪些任务。

我们的智能体需要一个 ChatPromptTemplate 来工作(稍后会详细介绍)。这是一个基本的 ChatPromptTemplate,主要关注部分是系统提示,其他则是我们需要传递的默认设置。

在我们的提示词中,我们包含了一个示例答案,向智能体展示我们希望它只返回答案,而不附带任何描述性文字。

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", """
          You are a mathematical assistant. Use your tools to answer questions.
          If you do not have a tool to answer the question, say so. 

          Return only the answers. e.g
          Human: What is 1 + 1?
          AI: 2
          """),
        MessagesPlaceholder("chat_history", optional=True),
        ("human", "{input}"),
        MessagesPlaceholder("agent_scratchpad"),
    ]
)

就这样。我们已经设置好了代理所需的工具和工具包,代理需要这些作为设置的一部分,以便了解它可以执行哪些类型的操作和具有哪些能力。我们也已经设置好了 LLM 和系统提示。

现在是有趣的部分了。设置我们的代理!

代理

LangChain 有多种不同的代理类型可以创建,这些代理具有不同的推理能力和特性。我们将使用目前最强大和最有能力的代理——OpenAI 工具代理。根据 OpenAI 工具代理的文档,它也使用了更新的 OpenAI 模型。

更新后的 OpenAI 模型已经经过微调,以便能够检测何时应该调用一个或多个函数,并响应应传递给函数的输入。在 API 调用中,你可以描述函数,并让模型智能地选择输出一个包含调用这些函数所需参数的 JSON 对象。OpenAI 工具 API 的目标是比使用通用的文本完成或聊天 API 更可靠地返回有效且有用的函数调用。

换句话说,这个代理擅长生成正确的结构来调用函数,并能够理解我们的任务是否需要多个函数(工具)。这个代理还能够调用具有多个输入参数的函数(工具),就像我们的代理一样。有些代理只能处理具有单一输入参数的函数。

如果你熟悉OpenAI 的函数调用功能,在该功能中,我们可以使用 OpenAI 的 LLM 来生成正确的参数,以便调用函数,那么我们在这里使用的 OpenAI 工具代理也在利用其中的一部分功能,能够以正确的参数调用正确的工具。

为了在 LangChain 中设置代理,我们需要使用提供的工厂方法来创建我们选择的代理。

创建 OpenAI 工具代理的工厂方法是create_openai_tools_agent()。它需要传入我们上面设置的 LLM、工具和提示。因此,让我们初始化我们的代理。

agent = create_openai_tools_agent(llm, toolkit, prompt)

最后,为了在 LangChain 中运行代理,我们不能直接调用“run”类型的方法。它们需要通过 AgentExecutor 来运行。

我之所以在最后提到 Agent Executor,是因为我认为它不是理解代理工作原理的关键概念,放在开始时与其他内容一起讲解只会让整个过程看起来比实际需要的更复杂,而且还可能分散对一些更基本概念的理解。

所以,现在我们介绍一下,AgentExecutor 作为 LangChain 中代理的运行时,允许代理一直运行,直到准备好返回最终的响应给用户。在伪代码中,AgentExecutor 的工作原理大致如下(直接摘自LangChain 文档

next_action = agent.get_action(...)
while next_action != AgentFinish:
    observation = run(next_action)
    next_action = agent.get_action(..., next_action, observation)
return next_action

所以它们基本上是一个 while 循环,持续调用代理的下一个操作方法,直到代理返回最终响应。

所以,让我们将代理设置在代理执行器内。我们传递给它代理,并且还必须传递工具包。我们将“verbose”设置为 True,以便在代理处理我们的请求时了解它在做什么。

agent_executor = AgentExecutor(agent=agent, tools=toolkit, verbose=True)

就这些了。我们现在准备向代理传递命令。

result = agent_executor.invoke({"input": "what is 1 + 1"})

让我们运行我们的脚本,看看代理的输出。

python3 math-agent.py

图片来自作者

由于我们在 AgentExecutor 上设置了verbose=True,我们可以看到代理所采取的每个操作步骤。它已经识别出我们应该调用“加法”工具,并使用所需的参数调用了“加法”工具,最终返回了结果。

这就是完整的源代码

import os

from langchain.agents import AgentExecutor, create_openai_tools_agent
from langchain_openai import ChatOpenAI

from langchain.tools import BaseTool, StructuredTool, tool
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

os.environ["OPENAI_API_KEY"] = "sk-"

# setup the tools
@tool
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

@tool
def multiply(a: int, b: int) -> int:
    """Multiply two numbers."""
    return a * b

@tool
def square(a) -> int:
    """Calculates the square of a number."""
    a = int(a)
    return a * a

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", """You are a mathematical assistant.
        Use your tools to answer questions. If you do not have a tool to
        answer the question, say so. 

        Return only the answers. e.g
        Human: What is 1 + 1?
        AI: 2
        """),
        MessagesPlaceholder("chat_history", optional=True),
        ("human", "{input}"),
        MessagesPlaceholder("agent_scratchpad"),
    ]
)

# Choose the LLM that will drive the agent
llm = ChatOpenAI(model="gpt-3.5-turbo-1106", temperature=0)

# setup the toolkit
toolkit = [add, multiply, square]

# Construct the OpenAI Tools agent
agent = create_openai_tools_agent(llm, toolkit, prompt)

# Create an agent executor by passing in the agent and tools
agent_executor = AgentExecutor(agent=agent, tools=toolkit, verbose=True)

result = agent_executor.invoke({"input": "what is 1 + 1?"})

print(result['output'])

测试我们的代理

让我们向代理提几个问题,看看它的表现如何。

5 的平方是多少?

再次获得正确的结果,看到它确实使用了我们的平方工具

图片来自作者

5 的 6 次方是多少?

它采取了一个有趣的行动步骤。它首先使用平方工具。然后,使用该结果,尝试使用乘法工具多次来得到最终答案。坦率地说,最终答案 3125 是错误的,还需要再乘以 5 才能得到正确的答案。但有趣的是,看到代理尝试使用不同的工具,并通过多个步骤来尝试得到最终答案。

图片来自作者

1 减去 3 是多少?

我们没有减法工具。但它足够聪明,使用我们的加法工具,并将第二个值设置为-3。有时候它们真是太聪明和富有创意了,挺有趣的,甚至令人惊讶。

图片来自作者

64 的平方根是多少

作为最后的测试,如果我们要求它执行一个不在我们工具集中的数学运算会怎样?由于我们没有平方根工具,它不会尝试调用工具,而是直接使用 LLM 计算该值。

图片来自作者

我们的系统提示确实告诉它,如果没有正确的工具,它应该回答“不知道”,并且在测试过程中确实有时会这样做。一个改进的初始系统提示可能会有所帮助,至少在一定程度上能解决这个问题。

观察

基于对代理的使用,我注意到以下几点

  • 当直接询问它具备工具可以回答的问题时,它在使用正确工具并返回正确答案方面相当一致。所以,从这个角度来看,它是相当可靠的。

  • 如果问题稍微复杂一点,例如我们提到的“5 的 6 次方”问题,它并不总是返回正确的结果。

  • 它有时可以仅仅使用 LLM 的强大能力来回答我们的问题,而无需调用我们的工具。

未来

代理和能够自主推理的程序是编程中的一种新范式,我认为它们将成为构建许多事物的主流方式。显然,LLM 的非确定性(即不是完全可预测的)意味着代理的结果也会受到影响,这使我们质疑在需要确保答案准确无误的任务中,我们能在多大程度上依赖它们。

也许随着技术的成熟,它们的结果将变得更加可预测,我们可能会为此开发一些解决方法。

我还看到代理类型的库和软件包开始成为一个趋势。类似于我们如何将第三方库和软件包安装到软件中,例如通过 Python 的 pip 包管理器或 Docker 镜像的 Docker Hub,我想我们可能会开始看到一个代理的库和包管理器开始被开发出来,代理在某一特定任务上变得非常擅长,然后我们也可以将它们作为软件包安装到我们的应用程序中。

的确,LangChain 的工具包库列在其集成页面上,这些工具包是社区构建的工具集,供大家使用,这可能是社区构建的代理类型库的一个早期示例。

结论

希望这篇文章能为你提供一些有用的入门指导,帮助你开始在 LangChain 中构建代理。

记住,代理基本上就是一个大脑(即 LLM)和一堆工具,它们可以利用这些工具来完成我们周围世界中的任务。

快乐编程!

如果你喜欢这篇文章,并希望随时了解我发布的关于使用 LangChain 和 AI 工具构建项目的未来文章,欢迎 在这里订阅 ,这样当新文章发布时,你会通过电子邮件收到通知

打造出色的数据科学作品集:全面指南

原文:towardsdatascience.com/building-a-standout-data-science-portfolio-a-comprehensive-guide-6dabd0ec7059?source=collection_archive---------0-----------------------#2024-07-10

了解如何创建一个有影响力的数据科学作品集,展示你的技能并吸引潜在雇主

Yu DongTowards Data Science Yu Dong

·发表于Towards Data Science ·9 分钟阅读·2024 年 7 月 10 日

--

背景

我在 2018 年刚从学校毕业时开始了我的数据科学作品集网站。毫不奇怪,我创建它的目的就是希望它能帮助我的求职和职业发展。六年后,我为自己的进步和持续更新感到自豪。我的作品集已经成为我职业历程、项目和见解的丰富宝库。

拥有一个数据科学作品集可以帮助你记录学习成果、反思职业生涯,并与数据科学社区互动。它在求职过程中也非常宝贵,比传统的简历更深入地展示了你的技能和项目。

在这篇文章中,我将讨论如何建立数据科学作品集、其内容策略以及什么样的作品集是好的。如果你曾考虑过这个想法,但不知道从哪里开始,那么这篇文章就是为你准备的。

我的数据科学作品集网站截图

建立你的作品集

有多种方式可以建立你数据科学作品集。以下是五种最常见的选择及其优缺点。

从零开始构建产品经理的用户洞察收集工具

原文:towardsdatascience.com/building-a-user-insights-gathering-tool-for-product-managers-from-scratch-a6459dc1c3f6?source=collection_archive---------8-----------------------#2024-08-12

向付费的客户洞察中心告别吧!学习如何将五个开源 AI 模型结合起来,自动化从用户访谈中收集洞察。

Hugo ZaniniTowards Data Science Hugo Zanini

·发表于 Towards Data Science ·阅读时间 11 分钟 ·2024 年 8 月 12 日

--

图片来自 Daria NepriakhinaUnsplash

作为一个数据平台的技术产品经理,我经常进行用户访谈,以识别与数据开发过程相关的挑战。

然而,当我与用户一起探索一个新的问题领域时,我很容易被与组织内各个个体的众多对话所压倒。

随着时间的推移,我采用了一种系统化的方法来应对这一挑战。我专注于在每次访谈中做详细的笔记,然后再回顾这些笔记。这让我能够巩固理解并识别出用户讨论的模式。

然而,在做笔记和积极倾听之间分心往往会影响我对话的质量。我注意到,当别人为我做笔记时,我的访谈效果显著提升。这使我能够完全投入到与受访者的交流中,专注于他们所说的内容,从而进行更有意义和富有成效的互动。

为了提高效率,我从在会议中做笔记转变为每当有功能时,就进行录音并转录。这大大减少了我需要进行的访谈数量,因为我可以从较少的对话中获得更多的洞察。然而,这一变化要求我花时间回顾转录内容并观看视频。下图展示了我在绘制新的产品开发机会时所遵循的简化流程。

图片来源:作者

由于会议转录的体积和分类用户洞察的困难,整合与分析变得具有挑战性。

此外,现有的会议转录工具仅限于英语,而我大多数的对话是葡萄牙语。因此,我决定在市场上寻找一个能够帮助我应对这些挑战的解决方案。

我找到的解决了大多数痛点的工具是 DovetailMarvinCondensReduct。它们定位自己为客户洞察中心,主要产品通常是客户访谈的转录。

基本上,你可以在这里上传访谈视频,并获得一份转录,指明每一句话的发言人,并在每句话上附有指向原视频的超链接。在文本上,你可以添加高亮、标签和评论,还可以请求对话的总结。这些功能将解决我的问题;然而,这些工具价格昂贵,特别是考虑到我住在巴西,而他们收取美元费用。

这些工具没有任何革命性创新,所以我决定实现一个开源替代方案,可以在 Colab 笔记本上免费运行。

需求

作为一名优秀的产品经理,我做的第一件事是根据用户(我的)需求识别产品的必备功能。以下是我所列出的高层次需求:

成本与可访问性

  • 免费;

  • 无需编程经验即可使用;

数据隐私与安全

  • 保持数据私密——与外部服务无连接;

性能

  • 执行速度必须快于视频时长;

  • 高精度的多语言转录;

功能

  • 发言人识别;

  • 易于在转录内容中搜索;

  • 轻松突出显示转录内容;

  • 容易创建正在进行的研究的存储库;

集成

  • 与我公司现有工具(Google Workspace)集成;

  • 集成 LLM 模型以接收任务提示,基于转录内容执行;

解决方案

根据需求,我设计了我的解决方案应具备的功能:

图片来源:作者

然后,我设计了预期的输入和用户界面来定义这些功能:

图片来源:作者

用户将把他们的面试上传到 YouTube,并设置为未列出的视频,然后创建一个 Google Drive 文件夹来存储转录文件。接着,他们可以访问 Google Colab 笔记本,提供面试的基本信息、粘贴视频网址,并可以选择为大型语言模型(LLM)定义任务。输出结果将是 Google Docs,用户可以在其中整合洞察。

以下是产品架构。这个解决方案结合了五个不同的机器学习模型和一些 Python 库。接下来的部分将提供每个构建模块的概述;但是,如果你更感兴趣的是尝试这个产品,请跳到“I got it”部分。

图片由作者提供

面试设置与视频上传

为了创建一个用户友好的界面,用于设置面试并提供视频链接,我使用了Google Colab 的表单功能。它允许创建文本框、滑动条、下拉框等。代码被隐藏在表单后面,非常适合非技术用户使用。

面试选择表格 — 图片由作者提供

音频下载与转换

我使用了 yt-dlp 库来仅下载 YouTube 视频的音频,并将其转换为 mp3 格式。这个工具非常简单易用,你可以在这里查看它的文档

图片由yt-dlp提供

音频转录

为了转录会议内容,我使用了Open AI 的 Whisper。这是一个开源的语音识别模型,训练数据来自超过 68 万小时的多语言数据。

该模型运行非常迅速;一段一小时的音频大约需要 6 分钟就能在 16GB T4 GPU(Google Colab 提供的免费 GPU)上完成转录,并且它支持99 种不同语言

由于隐私是这个解决方案的一个要求,模型的权重被下载,所有推理操作都在 Colab 实例内部进行。我还在笔记本中添加了一个模型选择表单,用户可以根据他们所需要的精度选择不同的模型。

图片由作者提供

讲话者识别

讲话者识别是通过一种叫做讲话者分段技术(Speakers Diarization)来完成的。其原理是将音频划分为不同的语音段,每个段落对应一个特定的讲话者。通过这种方式,我们可以识别出谁在什么时候发言。

由于从 YouTube 上传的视频没有元数据来标识谁在说话,讲话者将被分为讲话者 1、讲话者 2 等……稍后,用户可以在 Google Docs 中查找并替换这些名字,以添加讲话者的身份标识。

图片由作者提供

对于说话者分离,我们将使用一个名为多尺度说话者分离解码器(MSDD)的模型,该模型由 Nvidia 研究人员开发。这是一种先进的说话者分离方法,利用多尺度分析和动态加权来实现高精度和灵活性。

该模型以在识别并正确分类多个讲者交替发言的时刻而闻名——这是访谈中常见的现象。

这个模型可以通过NVIDIA NeMo 框架使用。它让我能够获取 MSDD 检查点,并直接在 colab 笔记本中运行说话者分离,只需几行代码。

查看 MSDD 的说话者分离结果时,我注意到标点符号很差,长句子中某些像“”和“是的”的插入语被误认为是说话者的中断——这使得文本难以阅读。

因此,我决定在管道中添加一个标点模型,以提高转录文本的可读性并便于人工分析。于是,我从Hugging Face 获取了 punctuate-all 模型,这是一个非常精确且快速的解决方案,支持以下语言:英语、德语、法语、西班牙语、保加利亚语、意大利语、波兰语、荷兰语、捷克语、葡萄牙语、斯洛伐克语和斯洛文尼亚语。

视频同步

从我所对比的行业解决方案来看,一个强烈的需求是每个短语都应该与采访中讲述的时刻相关联。

Whisper 转录包含指示短语说出时间戳的元数据;然而,这些元数据并不十分精确。

因此,我使用了一个名为Wav2Vec2的模型来更准确地进行这种匹配。基本上,该解决方案是一个神经网络,旨在学习音频表示并执行语音识别对齐。该过程包括在音频信号中找到每个段落说出的确切时间戳,并相应地对齐文本。

通过准确地匹配转录文本与时间戳,我通过简单的 Python 代码创建了超链接,指向视频中开始说出短语的时刻。

LLM 模型

这个管道步骤有一个准备好在本地运行并分析文本的大型语言模型,提供关于访谈的洞见。默认情况下,我添加了 Gemma 模型 1.1b,并设置了一个提示来总结文本。如果用户选择进行总结,它将以项目符号列表的形式显示在文档顶部。

图片由作者提供

此外,通过点击显示代码,用户可以更改提示并要求模型执行不同的任务。

用于标签、重点和评论的文档生成

解决方案执行的最后一项任务是生成带有访谈转录和超链接的 Google Docs。这是通过Google API Python 客户端库完成的。

我知道了

由于该产品在我日常工作中变得非常有用,我决定给它起个名字,以便更容易引用。我将它称为Insights Gathering Open-source Tool,简称 iGot。

由 DALL·E-3 生成的图片

在首次使用该解决方案时,需要进行一些初始设置。让我通过一个实际的例子来指导您开始使用。

打开 iGot 笔记本并安装所需的库

点击此链接打开笔记本并运行第一个单元格以安装所需的库。大约需要 5 分钟。

作者提供的图片

如果系统提示您重启笔记本,请直接取消。无需重启。

作者提供的图片

如果一切按预期运行,您将看到“所有库已安装!”的消息。

作者提供的图片

获取 Hugging 用户访问令牌和模型访问权限

(此步骤仅在首次执行笔记本时需要)

为了运行 Gemma 和 punctuate-all 模型,我们将从 Hugging Face 下载权重文件。为此,您必须申请一个用户令牌并获得模型访问权限。

为此,您需要创建一个 Hugging Face 账户,并按照以下步骤获取具有阅读权限的令牌。

作者提供的图片

一旦您获得了令牌,复制它并返回到实验笔记本。进入“Secrets”选项卡,然后点击“Add new secret”。

作者提供的图片

将您的令牌命名为HF_TOKEN,并粘贴您从 Hugging Face 获得的密钥。

作者提供的图片

接下来,点击此链接打开 Hugging Face 上的 Gemma 模型。然后点击“确认许可”以获得模型访问权限。

作者提供的图片

发送访谈

要将访谈发送到 iGot,您需要先将其作为未列出的 YouTube 视频上传。为了本教程的目的,我从 Andrej Karpathy 与 Lex Fridman 的访谈中截取了一部分,并将其上传到我的账户。这是 Andrej 给机器学习初学者的一些建议部分。

然后,您需要获取视频的 URL,将其粘贴到Interview Selection笔记本单元格的video_url字段中,定义一个名称,并注明视频中的语言。

一旦你运行了该单元,你将收到一条消息,指示音频文件已生成。

作者提供的图片

模型选择与执行

在下一个单元中,你可以选择你想要用于转录的 Whisper 模型的大小。模型越大,转录精度越高。

默认情况下,选择的是最大的模型。做出选择后,运行该单元。

作者提供的图片

然后,运行模型执行单元,执行上一部分中显示的模型流程。如果一切顺利,你应该在最后看到消息“标点符号处理完毕!”。

作者提供的图片

如果你收到提示消息,询问是否允许访问 Hugging Face 令牌,请授予访问权限。

作者提供的图片

配置转录输出

最后一步是将转录保存到 Google Docs 文件中。为此,你需要指定文件路径,提供采访名称,并指示是否希望 Gemma 总结会议内容。

第一次执行该单元时,你可能会收到一条提示消息,询问是否允许访问你的 Google Drive。点击“允许”。

作者提供的图片

然后,给 Colab 完全访问你的 Google Drive 工作区。

作者提供的图片

如果一切顺利,你将会看到一个指向 Google Docs 文件的链接。只需点击它,你就可以访问你的采访转录。

作者提供的图片

从生成的文档中提取见解

最终文档将包含转录内容,每个短语都与视频中开始的相应时刻相关联。由于 YouTube 不提供讲者元数据,建议使用 Google Docs 的查找和替换工具,将“Speaker 0”、“Speaker 1”等替换为实际的讲者名字。

作者提供的图片

有了这个,你可以处理高亮、笔记、反应等内容。如最初设想的那样:

作者提供的图片

最后的思考

该工具仍处于第一版本,我计划将其发展为一个更用户友好的解决方案。也许会搭建一个网站,让用户不需要直接与笔记本交互,或者开发一个插件,用于在 Google Meets 和 Zoom 中使用。

我的这个项目的主要目标是创建一个高质量的会议转录工具,它不仅对他人有益,还能展示现有的开源工具如何与商业解决方案的能力相匹配。

希望你觉得它有用!如果你有任何反馈或对 iGot 的发展有兴趣,欢迎随时通过LinkedIn 联系我

为工业应用构建视觉检查 CNN

原文:towardsdatascience.com/building-a-vision-inspection-cnn-for-an-industrial-application-138936d7a34a?source=collection_archive---------2-----------------------#2024-11-21

使用 PyTorch 的逐步方法

Ingo NowitzkyTowards Data Science Ingo Nowitzky

·发表于 Towards Data Science ·28 分钟阅读·2024 年 11 月 21 日

--

在本文中,我们为汽车电子行业的视觉检查分类任务开发并编码了一个卷积神经网络(CNN)。在此过程中,我们深入研究了卷积层的概念和数学,并分析了 CNN 实际“看到”的内容以及哪些图像部分引导它们做出决策。

目录

第一部分:概念背景

第二部分:定义和编码 CNN

第三部分:在生产中使用训练好的模型

第四部分:CNN 在“决策”中考虑了什么?

第一部分:概念背景

1.1 任务:将工业部件分类为良品或废品

在自动化装配线的一个工位上,带有两个突出金属销的线圈需要精确地放置在外壳中。金属销插入小的插座中。在某些情况下,销钉略微弯曲,因此无法通过机器连接。视觉检查的任务是识别这些线圈,以便它们可以被自动筛选出来。

图 1:线圈、外壳和插座 | 图像来源:作者

对于检查,每个线圈会单独拾起,并放置在屏幕前。在这个位置,摄像机会拍摄一张灰度图像。然后,这张图像会被 CNN 检查并分类为良品或废品。

图 2:视觉检查的基本设置和生成的图像 | 图像来源:作者

现在,我们想要定义一个卷积神经网络,能够处理图像并从预先分类的标签中学习。

1.2 什么是卷积神经网络(CNN)?

卷积神经网络是 卷积滤波器全连接神经网络(NN)的组合。CNN 常用于 图像处理,如面部识别或视觉检测任务,像我们这个案例中的任务。卷积滤波器 是滑过图像并重新计算每个像素的矩阵操作。我们将在本文后面研究卷积滤波器。滤波器的权重不是预设的(例如 Photoshop 中的锐化功能),而是从数据中学习得到的。

1.3 卷积神经网络的架构

让我们检查一下 CNN 架构的一个例子。为了方便起见,我们选择 稍后将实现的模型。

图 3:我们的视觉检测 CNN 架构 | 图片来自作者

我们希望将大小为 400 像素高和 700 像素宽的检测图像输入到 CNN 中。由于图像是灰度图像,相关的 PyTorch 张量大小为 1x400x700。如果使用彩色图像,则会有 3 个输入通道:一个用于红色,一个用于绿色,一个用于蓝色(RGB)。在这种情况下,张量的大小将是 3x400x700。

第一个 卷积滤波器 有 6 个大小为 5x5 的 卷积核,它们滑过图像并生成 6 个独立的新图像,这些图像被称为 特征图,大小略微减小(6x396x696)。ReLU 激活函数 在图 3 中没有明确显示。它不会改变张量的维度,但会将所有负值设置为零。ReLU 后面是 MaxPooling 层,卷积核大小为 2x2。它会将每个图像的宽度和高度减半。

这三层——卷积层、ReLU 层和 MaxPooling 层——会再次实现。这最终给我们带来 16 个特征图,图像的高度为 97 像素,宽度为 172 像素。接下来,所有矩阵值会被展平,并输入到同样大小的全连接神经网络的第一层。它的第二层已经缩减为 120 个神经元。第三层和输出层只有 2 个神经元:一个表示标签“OK”,另一个表示标签“not OK” 或“scrap”。

如果你还不清楚维度变化的情况,请耐心等待。 我们将在接下来的章节中详细研究不同类型的层——卷积层、ReLU 层和 MaxPooling 层——如何工作,并如何影响张量维度。

1.4 卷积滤波器层

卷积滤波器的任务是寻找图像中的典型结构/模式。常用的卷积核尺寸是 3x3 或 5x5。卷积核的 9 个或 25 个权重并不是预先指定的,而是在训练过程中学习的(这里假设只有一个输入通道;否则,权重的数量会乘以输入通道数)。卷积核会以定义的步幅在图像的矩阵表示上滑动(每个输入通道都有自己的卷积核),在水平和垂直方向上进行卷积。卷积核与矩阵中对应的值相乘并求和。每个滑动位置的求和结果形成新的图像,我们称之为特征图。在一个卷积层中,我们可以指定多个卷积核。在这种情况下,我们会得到多个特征图作为结果。卷积核从左到右、从上到下滑动。因此,图 4 显示了卷积核在第五个滑动位置(不包括“...”)的状态。我们可以看到三个输入通道,分别表示红色、绿色和蓝色(RGB)。每个通道只有一个卷积核。在实际应用中,我们通常为每个输入通道定义多个卷积核。

图 4:具有 3 个输入通道,每个通道 1 个卷积核的卷积层 | 图片来源:作者

卷积核 1 在红色输入通道上执行其操作。在当前显示的位置,我们计算该位置在特征图中的新值,计算过程为 (-0.7)0 + (-0.9)(-0.2) + (-0.6)0.5 + (-0.6)0.6 + 0.6(-0.3) + 0.7(-1) + 00.7 + (-0.1)(-0.1) + (-0.2)(-0.1) = (-1.33).* 对应的绿色通道(卷积核 2)的计算结果为 -0.14, 蓝色通道(卷积核 3)的结果为 0.69。为了得到该滑动位置的最终特征图值,我们将三个通道的值相加并加上偏置(偏置和所有卷积核权重在 CNN 训练过程中定义): (-1.33) + (-0.14) + 0.69 + 0.2 = -0.58。该值被放置在特征图中黄色高亮的对应位置。

最后,如果我们将输入矩阵的大小与特征图的大小进行比较,会发现通过卷积操作后,我们丢失了两行和两列。

1.5 ReLU 激活层

卷积之后,特征图会通过激活层。激活是必需的,目的是赋予网络非线性能力。最常用的激活方法是SigmoidReLU(修正线性单元)。ReLU 激活将所有负值设为零,而正值保持不变。

图 5:特征图的 ReLU 激活 | 图片来源:作者

在图 5 中,我们可以看到特征图的值逐元素通过了 ReLU 激活。

ReLU 激活对特征图的维度没有影响。

1.6 最大池化层

池化层的主要任务是减少特征图的大小,同时保留对分类重要的信息。通常,我们可以通过计算卷积核区域的平均值或返回最大值来进行池化。在大多数应用中,MaxPooling 更有益,因为它减少了数据中的噪声。池化的典型卷积核大小为 2x2 或 3x3。

图 6:使用 2x2 卷积核的 MaxPooling 和 AvgPooling | 图像来自作者

在图 6 中,我们看到使用 2x2 卷积核的 MaxPooling 和 AvgPooling 的示例。特征图被划分为与卷积核大小相同的区域,在这些区域内,我们选择最大值(→ MaxPooling)或平均值(→ AvgPooling)。

通过使用 2x2 的卷积核进行池化,我们将特征图的高度和宽度减半。

1.7 卷积神经网络中的张量维度

现在我们已经研究了卷积滤波器、ReLU 激活函数和池化,我们可以回顾一下图 3 及张量的维度。我们从一个大小为 400x700 的图像开始。由于它是灰度图像,所以只有 1 个通道,相应的张量大小为 1x400x700。我们对图像应用 6 个大小为 5x5、步长为 1x1 的卷积滤波器。每个滤波器返回自己的特征图,因此我们获得 6 个特征图。由于与图 4 中使用的卷积核相比(5x5 代替了 3x3),这次卷积会丢失 4 列和 4 行。因此,返回的张量大小为 6x396x696。

在下一步中,我们对特征图应用 2x2 卷积核的 MaxPooling(每个图都有自己的池化核)。正如我们所学,这将特征图的维度缩小一倍。因此,张量现在的大小为 6x198x348。

现在我们应用 16 个大小为 5x5 的卷积滤波器。每个滤波器的内核深度为 6,这意味着每个滤波器为输入张量的 6 个通道提供一个单独的层。每个卷积核层在 6 个输入通道中的一个上滑动,如图 4 所示,6 个返回的特征图相加合成一个。因此,到目前为止,我们只考虑了一个卷积滤波器,但实际上我们有 16 个滤波器。这就是为什么我们得到 16 个新的特征图,每个比输入小 4 列和 4 行。此时张量的大小是 16x194x344。

再次,我们应用 2x2 卷积核的 MaxPooling。由于这会将特征图的大小减半,所以现在我们得到的张量大小为 16x97x172。

最后,张量被展平,这意味着我们将所有1697172 = 266,944个值排列成一行,并将它们输入到一个相应大小的全连接神经网络中。

第二部分:定义和编码 CNN

从概念上讲,我们已经具备了所需的一切。接下来,让我们进入第 1.1 章中描述的工业应用案例。

2.1 加载所需的库

我们将使用一些 PyTorch 库来进行数据加载、采样以及模型本身的构建。此外,我们还加载matplotlib.pyplot用于可视化,PIL用于转换图像。

import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset
from torch.utils.data.sampler import WeightedRandomSampler
from torch.utils.data import random_split
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
import os
import warnings
warnings.filterwarnings("ignore")

2.2 配置设备并指定超参数

device中,我们存储‘cuda’‘cpu’,具体取决于你的计算机是否有可用的 GPU。minibatch_size定义了在模型训练过程中每次矩阵操作处理的图像数量。learning_rate指定了反向传播过程中参数调整的幅度,而epochs定义了我们在训练阶段处理整个训练数据集的频率。

# Device configuration
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Using {device} device")

# Specify hyperparameters
minibatch_size = 10
learning_rate = 0.01
epochs = 60

2.3 自定义加载器函数

为了加载图像,我们定义了一个custom_loader。它以二进制模式打开图像,裁剪图像的内部 700x400 像素,将其加载到内存中,并返回加载的图像。作为图像的路径,我们定义了相对路径data/Coil_Vision/01_train_val_test。请确保数据存储在你的工作目录中。你可以从我的 Dropbox 下载文件:CNN_data.zip

# Define loader function
def custom_loader(path):
    with open(path, 'rb') as f:
        img = Image.open(f)
        img = img.crop((50, 60, 750, 460))  #Size: 700x400 px
        img.load()
        return img

# Path of images (local to accelerate loading)
path = "data/Coil_Vision/01_train_val_test"

2.4 定义数据集

我们将数据集定义为由图像数据和标签组成的元组,标签为0表示废品,1表示合格品。方法datasets.ImageFolder()从文件夹结构中读取标签。我们使用变换函数首先将图像数据加载为 PyTorch 张量(值介于 0 和 1 之间),然后用大约 0.5 的均值和 0.5 的标准差对数据进行归一化。经过变换后,图像数据大致呈标准正态分布(均值=0,标准差=1)。我们将数据集随机划分为 50%的训练数据、30%的验证数据和 20%的测试数据。

# Transform function for loading
transform = transforms.Compose([transforms.ToTensor(),
                                transforms.Normalize((0.5), (0.5))])

# Create dataset out of folder structure
dataset = datasets.ImageFolder(path, transform=transform, loader=custom_loader)
train_set, val_set, test_set = random_split(dataset, [round(0.5*len(dataset)), 
                                                      round(0.3*len(dataset)), 
                                                      round(0.2*len(dataset))])

2.5 平衡数据集

我们的数据是不平衡的。合格样本远多于废品样本。为了减少训练过程中对多数类的偏倚,我们使用WeightedRandomSampler在采样时给少数类分配更高的概率。在lbls中,我们存储了训练数据集的标签。使用np.bincount(),我们统计0标签(bc[0])和1标签(bc[1])的数量。接下来,我们计算两个类的概率权重(p_nOKp_OK),并按照数据集中的顺序将它们安排在列表lst_train中。最后,我们从WeightedRandomSampler实例化train_sampler

# Define a sampler to balance the classes
# training dataset
lbls = [dataset[idx][1] for idx in train_set.indices]
bc = np.bincount(lbls)
p_nOK = bc.sum()/bc[0]
p_OK = bc.sum()/bc[1]
lst_train = [p_nOK if lbl==0 else p_OK for lbl in lbls]
train_sampler = WeightedRandomSampler(weights=lst_train, num_samples=len(lbls))

2.6 定义数据加载器

最后,我们为训练、验证和测试数据定义了三个数据加载器。数据加载器按批次将数据集输入到神经网络中,每个批次包括图像数据和标签。

对于train_loaderval_loader,我们将批次大小设置为 10,并打乱数据。test_loader使用打乱的数据和批次大小为 1。

# Define loader with batchsize
train_loader = DataLoader(dataset=train_set, batch_size=minibatch_size, sampler=train_sampler)
val_loader = DataLoader(dataset=val_set, batch_size=minibatch_size, shuffle=True)
test_loader = DataLoader(dataset=test_set, shuffle=True)

2.7 检查数据:绘制 5 个合格品和 5 个废品

为了检查图像数据,我们绘制了五个正常样本(“OK”)和五个废料样本(“nOK”)。为此,我们定义了一个matplotlib图形,包含 2 行 5 列,并共享 x 轴和 y 轴。在代码核心部分,我们嵌套了两个 for 循环。外层循环从train_loader接收数据批次。每个批次包含十张图片和相应的标签。内层循环枚举批次标签。在其主体中,我们检查标签是否等于0——如果是,则将图像绘制在第二行的“nOK”下——或者标签是否等于1——如果是,则将图像绘制在第一行的“OK”下。一旦count_OKcount_nOK都大于或等于 5,我们就跳出循环,设置标题并显示图形。

# Figure and axes object
fig, axs = plt.subplots(nrows=2, ncols=5, figsize=(20,7), sharey=True, sharex=True)

count_OK = 0
count_nOK = 0

# Loop over loader batches
for (batch_data, batch_lbls) in train_loader:

    # Loop over batch_lbls
    for i, lbl in enumerate(batch_lbls):

        # If label is 0 (nOK) plot image in row 1
        if (lbl.item() == 0) and (count_nOK < 5):
            axs[1, count_nOK].imshow(batch_data[i][0], cmap='gray')
            axs[1, count_nOK].set_title(f"nOK Part#: {str(count_nOK)}", fontsize=14)
            count_nOK += 1

        # If label is 1 (OK) plot image in row 0
        elif (lbl.item() == 1) and (count_OK < 5):
            axs[0, count_OK].imshow(batch_data[i][0], cmap='gray')
            axs[0, count_OK].set_title(f"OK Part#: {str(count_OK)}", fontsize=14)
            count_OK += 1

    # If both counters are >=5 stop looping
    if (count_OK >=5) and (count_nOK >=5):
        break

# Config the plot canvas
fig.suptitle("Sample plot of OK and nonOK Parts", fontsize=24)
plt.setp(axs, xticks=[], yticks=[]) 
plt.show()

图 7:OK(上排)和非 OK(下排)部分的示例 | 图片由作者提供

在图 7 中,我们看到大多数 nOK 样本明显弯曲,但有少数样本肉眼难以区分(例如,右下角的样本)。

2.8 定义 CNN 模型

该模型对应于图 3 中所示的架构。我们将灰度图像(只有一个通道)输入到第一层卷积层,并定义 6 个大小为 5(即 5x5)的卷积核。卷积操作后接 ReLU 激活和一个大小为 2(2x2)的最大池化层,步幅为 2(2x2)。所有这三项操作都按照图 3 中显示的维度进行重复。在__init__()方法的最后一个模块中,16 个特征图被展平,并输入到一个大小相等的线性层,该层有 120 个输出节点。它经过 ReLU 激活,并在第二个线性层中被缩减至仅 2 个输出节点。

forward()方法中,我们简单地调用模型的各个层,并将x张量输入其中。

class CNN(nn.Module):

    def __init__(self):
        super().__init__()

        # Define model layers
        self.model_layers = nn.Sequential(

            nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2),

            nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2),

            nn.Flatten(),
            nn.Linear(16*97*172, 120),
            nn.ReLU(),
            nn.Linear(120, 2)
        )

    def forward(self, x):
        out = self.model_layers(x)
        return out

2.9 实例化模型并定义损失函数和优化器

我们从 CNN 类实例化model,并将其放置在 CPU 或 GPU 上。由于我们进行的是分类任务,我们选择了 CrossEntropyLoss 函数。为了管理训练过程,我们调用了随机梯度下降(SGD)优化器。

# Define model on cpu or gpu
model = CNN().to(device)

# Loss and optimizer
loss = nn.CrossEntropyLoss()

optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)

2.10 检查模型的大小

为了了解模型的参数规模,我们遍历model.parameters()并进行求和,首先求出所有模型参数的总和(num_param),然后是那些在反向传播过程中会被调整的参数(num_param_trainable)。最后,我们打印结果。

# Count number of parameters / thereof trainable
num_param = sum([p.numel() for p in model.parameters()])
num_param_trainable = sum([p.numel() for p in model.parameters() if p.requires_grad == True])

print(f"Our model has {num_param:,} parameters. Thereof trainable are {num_param_trainable:,}!")

打印输出告诉我们,模型有超过 3200 万个参数,其中所有参数都是可训练的。

2.11 定义用于验证和测试的函数

在开始模型训练之前,让我们准备一个函数来支持验证和测试。函数val_test()dataloader和 CNN model作为参数。它通过torch.no_grad()关闭梯度计算,并遍历dataloader。每次拿到一批图像和标签后,将图像输入到model中,并通过output.argmax(1)在返回的 logits 上确定模型预测的类别。该方法返回最大值的索引,在我们的例子中,代表类别的索引。

我们统计并汇总正确的预测结果,同时保存图像数据、预测类别和错误预测的标签。最后,我们计算准确率,并将其与误分类的图像一并作为函数的输出返回。

def val_test(dataloader, model):
    # Get dataset size
    dataset_size = len(dataloader.dataset)

    # Turn off gradient calculation for validation
    with torch.no_grad():
        # Loop over dataset
        correct = 0
        wrong_preds = []
        for (images, labels) in dataloader:
            images, labels = images.to(device), labels.to(device)

            # Get raw values from model
            output = model(images)

            # Derive prediction
            y_pred = output.argmax(1)

            # Count correct classifications over all batches
            correct += (y_pred == labels).type(torch.float32).sum().item()

            # Save wrong predictions (image, pred_lbl, true_lbl)
            for i, _ in enumerate(labels):
                if y_pred[i] != labels[i]:
                    wrong_preds.append((images[i], y_pred[i], labels[i]))

        # Calculate accuracy
        acc = correct / dataset_size

    return acc, wrong_preds

2.12 模型训练

模型训练包含两个嵌套的 for 循环。外层循环遍历预定义的epochs次数,内层循环遍历train_loader。枚举返回一批图像数据及其对应的标签。图像数据(images)传递给模型,我们接收模型的输出 logits(outputs)。outputs和真实的labels一起传入损失函数。根据损失l,我们执行反向传播,并通过optimizer.step更新参数。outputs是一个维度为batchsize x output nodes的张量,在我们这里是10 x 2。我们通过行中最大值的索引来接收模型的预测,值为01

最后,我们统计正确预测的数量(n_correct)、正确的 OK 部分(n_true_OK)和样本的数量(n_samples)。每隔一个 epoch,我们计算训练准确率、正确 OK 部分的比例,并调用验证函数(val_test())。训练过程中,每个 epoch 的这三个值都会打印出来供参考。在最后一行代码中,我们将模型及其所有参数保存在“model.pth”文件中。

acc_train = {}
acc_val = {}
# Iterate over epochs
for epoch in range(epochs):

    n_correct=0; n_samples=0; n_true_OK=0
    for idx, (images, labels) in enumerate(train_loader):
        model.train()
        # Push data to gpu if available
        images, labels = images.to(device), labels.to(device)

        # Forward pass
        outputs = model(images)
        l = loss(outputs, labels)

        # Backward and optimize
        optimizer.zero_grad()
        l.backward()
        optimizer.step()

        # Get prediced labels (.max returns (value,index))
        _, y_pred = torch.max(outputs.data, 1)

        # Count correct classifications
        n_correct += (y_pred == labels).sum().item()
        n_true_OK += (labels == 1).sum().item()
        n_samples += labels.size(0)

    # At end of epoch: Eval accuracy and print information
    if (epoch+1) % 2 == 0:
        model.eval()
        # Calculate accuracy
        acc_train[epoch+1] = n_correct / n_samples
        true_OK = n_true_OK / n_samples
        acc_val[epoch+1] = val_test(val_loader, model)[0]

        # Print info
        print (f"Epoch [{epoch+1}/{epochs}], Loss: {l.item():.4f}")
        print(f"      Training accuracy: {acc_train[epoch+1]*100:.2f}%")
        print(f"      True OK: {true_OK*100:.3f}%")
        print(f"      Validation accuracy: {acc_val[epoch+1]*100:.2f}%")

# Save model and state_dict
torch.save(model, "model.pth")

训练在我笔记本的 GPU 上只需几分钟。强烈建议从本地驱动加载图像,否则训练时间可能会增加几个数量级!

训练的输出表明损失已经显著减少,验证准确率——即模型未用于更新参数的数据上的准确率——已达到 98.4%。

如果我们绘制训练过程中每个 epoch 的训练和验证准确率,将能更好地了解训练进展。由于我们每隔一个 epoch 就保存一次值,因此可以轻松地做到这一点。

我们创建一个matplotlib图形和坐标轴,使用plt.subplots()并根据准确率字典的键绘制这些值。

# Instantiate figure and axe object
fig, ax = plt.subplots(figsize=(10,6))
plt.plot(list(acc_train.keys()), list(acc_train.values()), label="training accuracy")
plt.plot(list(acc_val.keys()), list(acc_val.values()), label="validation accuracy")
plt.title("Accuracies", fontsize=24)
plt.ylabel("%", fontsize=14)
plt.xlabel("Epochs", fontsize=14)
plt.setp(ax.get_xticklabels(), fontsize=14)
plt.legend(loc='best', fontsize=14)
plt.show()

图 8:模型训练期间的训练和验证准确率 | 图片由作者提供

2.13 加载训练好的模型

如果你想将模型用于生产而不仅仅是用于学习,强烈建议保存并加载包含所有参数的模型。保存已经是训练代码的一部分,从你的驱动器加载模型同样简单。

# Read model from file
model = torch.load("model.pth")
model.eval()

2.14 使用测试数据再次检查模型准确度

请记住,我们保留了 20%的数据用于测试。这些数据对模型来说是全新的,从未加载过。我们可以使用这组全新的数据来再次验证验证准确率。由于验证数据已经加载,但从未用于更新模型参数,我们预期其准确率与测试值相似。为了进行测试,我们在test_loader上调用val_test()函数。

print(f"test accuracy: {val_test(test_loader,model)[0]*100:0.1f}%")

在这个特定的例子中,我们达到了 99.2%的测试准确度,但这很大程度上取决于随机性(记住:图像随机分配到训练、验证和测试数据中)。

2.15 可视化错误分类的图像

错误分类图像的可视化非常直接。首先,我们调用val_test()函数。它返回一个元组,第一个索引位置是准确度值 0tup[0]),第二个索引位置是另一个元组(tup[1]),包含错误分类图像的数据(tup[1][0])、预测标签(tup[1][1])和真实标签(tup[1][2])。如果tup[1]不为空,我们会枚举它,并绘制错误分类图像,并加上适当的标题。

%matplotlib inline

# Call test function
tup = val_test(test_loader, model)

# Check if wrong predictions occur
if len(tup[1])>=1:

    # Loop over wrongly predicted images
    for i, t in enumerate(tup[1]):
        plt.figure(figsize=(7,5))
        img, y_pred, y_true = t
        img = img.to("cpu").reshape(400, 700)
        plt.imshow(img, cmap="gray")
        plt.title(f"Image {i+1} - Predicted: {y_pred}, True: {y_true}", fontsize=24)
        plt.axis("off")
        plt.show()
        plt.close()
else:
    print("No wrong predictions!")

在我们的例子中,只有一张错误分类的图像,它占测试数据集的 0.8%(我们有 125 张测试图像)。这张图像被分类为合格,但标签为 nOK。坦白说,我也会错误分类它 😃.

图 9:错误分类的图像 | 图片来自作者

第三部分:在生产中使用训练好的模型

3.1 加载模型、所需库和参数

在生产阶段,我们假设 CNN 模型已经训练好,并且参数可以加载。我们的目标是将新图像加载到模型中,让模型判断相应的电子组件是否适合用于组装(见章节 1.1 任务:将工业组件分类为合格或废料)。

我们首先加载所需的库,将设备设置为‘cuda’‘cpu’,定义CNN类(与章节 2.8 完全相同),然后使用torch.load()从文件中加载模型。我们需要在加载参数之前定义CNN类,否则参数将无法正确分配。

# Load the required libraries
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
from PIL import Image
import os

# Device configuration
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# Define the CNN model exactly as in chapter 2.8
class CNN(nn.Module):

    def __init__(self):
        super(CNN, self).__init__()

        # Define model layers
        self.model_layers = nn.Sequential(

            nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2),

            nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2),

            nn.Flatten(),
            nn.Linear(16*97*172, 120),
            nn.ReLU(),
            nn.Linear(120, 2),
            #nn.LogSoftmax(dim=1)
        )

    def forward(self, x):
        out = self.model_layers(x)
        return out

# Load the model's parameters
model = torch.load("model.pth")
model.eval()

通过运行这段代码,我们已经将 CNN 模型加载并在计算机内存中进行了参数化。

3.2 将图像加载到数据集中

对于训练阶段,我们需要准备图像以供 CNN 模型处理。我们从指定文件夹中加载图像,裁剪出内部 700x400 像素,并将图像数据转换为 PyTorch 张量。

# Define custom dataset
class Predict_Set(Dataset):
    def __init__(self, img_folder, transform):
        self.img_folder = img_folder
        self.transform = transform
        self.img_lst = os.listdir(self.img_folder)

    def __len__(self):
        return len(self.img_lst)

    def __getitem__(self, idx):
        img_path = os.path.join(self.img_folder, self.img_lst[idx])
        img = Image.open(img_path)
        img = img.crop((50, 60, 750, 460))  #Size: 700x400
        img.load()
        img_tensor = self.transform(img)
        return img_tensor, self.img_lst[idx]

我们在一个自定义数据集类 Predict_Set() 中执行所有这些步骤。在 __init__() 中,我们指定图像文件夹,接受一个 transform 函数,并将图像从文件夹加载到列表 self.img_lst 中。方法 __len__() 返回图像文件夹中的图像数量。__getitem__() 从文件夹路径和图像名称组合出图像路径,裁剪图像的内部部分(如同我们对训练数据集所做的那样),并对图像应用 transform 函数。最后,它返回图像张量和图像名称。

3.3 路径、变换函数和数据加载器

数据准备的最后一步是定义一个数据加载器,它允许我们遍历图像进行分类。在这个过程中,我们指定了图像文件夹的 path 并定义了 transform 函数作为一个管道,首先将图像数据加载为 PyTorch 张量,其次将数据标准化到大约 -1 到 +1 的范围。我们将自定义数据集 Predict_Set() 实例化为变量 predict_set,并定义数据加载器 predict_loader。由于我们没有指定批处理大小,predict_loader 每次返回一张图像。

# Path to images (preferably local to accelerate loading)
path = "data/Coil_Vision/02_predict"

# Transform function for loading
transform = transforms.Compose([transforms.ToTensor(),
                                transforms.Normalize((0.5), (0.5))])

# Create dataset as instance of custom dataset
predict_set = Predict_Set(path, transform=transform)

# Define loader
predict_loader = DataLoader(dataset=predict_set)

3.4 分类的自定义函数

到目前为止,图像数据的分类准备工作已经完成。然而,我们仍然缺少一个自定义函数,它将图像传输到 CNN 模型中,翻译模型的响应为分类结果,并返回分类结果。这正是我们通过 predict() 完成的任务。

def predict(dataloader, model):

    # Turn off gradient calculation
    with torch.no_grad():

        img_lst = []; y_pred_lst = []; name_lst = []
        # Loop over data loader
        for image, name in dataloader:
            img_lst.append(image)
            image = image.to(device)

            # Get raw values from model
            output = model(image)

            # Derive prediction
            y_pred = output.argmax(1)
            y_pred_lst.append(y_pred.item())
            name_lst.append(name[0])

    return img_lst, y_pred_lst, name_lst

predict() 接受数据加载器和 CNN 模型作为其参数。在其核心,它会遍历数据加载器,将图像数据传输到模型中,并通过 output.argmax(1) 解析模型的响应作为分类结果——0 表示报废部件(nOK),1 表示合格部件(OK)。图像数据、分类结果和图像名称被附加到列表中,列表作为函数的结果返回。

3.5 预测标签并绘制图像

最后,我们希望利用自定义函数和加载器来分类新的图像。在文件夹 “data/Coil_Vision/02_predict” 中,我们预留了四张电子元件的图像,等待检查。请记住,我们希望 CNN 模型告诉我们,是否可以将这些元件用于自动组装,或者是否需要将它们筛选出来,因为这些引脚可能会在插入插座时造成问题。

我们调用自定义函数 predict(),它返回一组图像列表、分类结果列表和图像名称列表。我们遍历这些列表并绘制图像,名称和分类结果作为标题。

# Predict labels for images
imgs, lbls, names  = predict(predict_loader, model)

# Iterate over classified images
for idx, image in enumerate(imgs):
    plt.figure(figsize=(8,6))
    plt.imshow(image.squeeze(), cmap="gray")
    plt.title(f"\nFile: {names[idx]}, Predicted label: {lbls[idx]}", fontsize=18)
    plt.axis("off")
    plt.show()
    plt.close()

图 10:生产阶段的分类结果 | 图片来源:作者

我们可以看到,左侧的两张图片被分类为合格(标签1),右侧的两张则被分类为废品(标签0)。由于我们的训练数据,模型非常敏感,即使是针脚的微小弯曲也会导致它们被分类为废品。

第四部分:CNN 在其“决策”中考虑了什么?

到目前为止,我们已经深入探讨了卷积神经网络(CNN)及其在工业应用中的使用案例。这似乎是一个很好的机会,可以进一步了解 CNN 模型在处理图像数据时“看到了”什么。为此,我们首先研究卷积层,然后检查图像的哪些部分对分类尤为重要。

4.1 研究卷积滤波器的维度

为了更好地理解卷积滤波器的工作原理及其对图像的影响,让我们更详细地检查我们工业示例中的层。

为了访问这些层,我们枚举model.children(),这是一个模型结构的生成器。如果某层是卷积层,我们将其添加到all_layers列表中,并将权重的维度保存在conv_weights中。如果层是 ReLU 或 MaxPooling 层,则没有权重。在这种情况下,我们将层和“”添加到相应的列表中。接下来,我们枚举all_layers,打印层的类型和权重的维度。

# Empty lists to store the layers and the weights
all_layers = []; conv_weights = []

# Iterate over the model's structure
# (First level nn.Sequential)
for _, layer in enumerate(list(model.children())[0]):
    if type(layer) == nn.Conv2d:
        all_layers.append(layer)
        conv_weights.append(layer.weight)
    elif type(layer) in [nn.ReLU, nn.MaxPool2d]:
        all_layers.append(layer)
        conv_weights.append("*")

# Print layers and dimensions of weights
for idx, layer in enumerate(all_layers):
    print(f"{idx+1}. Layer: {layer}")
    if type(layer) == nn.Conv2d:
        print(f"          weights: {conv_weights[idx].shape}")
    else:
        print(f"          weights: {conv_weights[idx]}")
    print()

图 11:层及权重的维度

请将代码片段的输出与图 3 进行对比。第一层卷积层有一个输入——只有一个通道的原始图像——并返回六个特征图。我们应用六个卷积核,每个深度为 1,大小为 5x5。因此,权重的维度是torch.Size([6, 1, 5, 5])。相比之下,第 4 层接受六个特征图作为输入,并返回 16 个特征图作为输出。我们应用 16 个卷积核,每个深度为 6,大小为 5x5。因此,权重的维度为torch.Size([16, 6, 5, 5])

4.2 可视化卷积滤波器的权重

现在,我们已经知道了卷积滤波器的维度。接下来,我们想查看它们在训练过程中获得的权重。由于我们有很多不同的滤波器(第一层有 6 个,第二层有 16 个),我们在这两种情况下都选择第一个输入通道(索引0)。

import itertools

# Iterate through all layers
for idx_out, layer in enumerate(all_layers):

    # If layer is a convolutional filter
    if type(layer) == nn.Conv2d:

        # Print layer name
        print(f"\n{idx_out+1}. Layer: {layer} \n")

        # Prepare plot and weights
        plt.figure(figsize=(25,6))
        weights = conv_weights[idx_out][:,0,:,:] # only first input channel
        weights = weights.detach().to('cpu')

        # Enumerate over filter weights (only first input channel)
        for idx_in, f in enumerate(weights):
            plt.subplot(2,8, idx_in+1)
            plt.imshow(f, cmap="gray")
            plt.title(f"Filter {idx_in+1}")

            # Print texts
            for i, j in itertools.product(range(f.shape[0]), range(f.shape[1])):
                if f[i,j] > f.mean():
                    color = 'black'
                else:
                    color = 'white'
                plt.text(j, i, format(f[i, j], '.2f'), horizontalalignment='center', verticalalignment='center', color=color)

            plt.axis("off")
        plt.show()
        plt.close() 

我们遍历all_layers。如果某层是卷积层(nn.Conv2d),则打印该层的索引及其核心数据。接下来,我们准备一个图表,并以第一个输入层为例提取权重矩阵。我们枚举所有输出层并用plt.imshow()绘制它们。最后,我们在图像上打印权重的值,以便直观地展示卷积滤波器。

图 12:6+16 个卷积滤波器的可视化(输入层索引 0)| 图片来源:作者

图 12 展示了第 1 层的六个卷积滤波器内核和第 4 层的 16 个内核(对于输入通道0)。右上角的模型示意图显示了带红色轮廓的滤波器。我们看到大部分值接近 0,部分值在正负 0.20–0.25 范围内。这些数字表示在图 4 中演示的卷积操作中使用的值。这些给出了特征图,接下来我们将检查这些特征图。

4.3 检查特征图

根据图 4,我们通过对输入图像进行卷积,获得了第一批特征图。因此,我们从 test_loader 中加载一张随机图像,并将其传送到 CPU(以防你在 GPU 上操作 CNN)。

# Test loader has a batch size of 1
img = next(iter(test_loader))[0].to(device)
print(f"\nImage has shape: {img.shape}\n")

# Plot image
img_copy = img.to('cpu')
plt.imshow(img_copy.reshape(400,700), cmap="gray")
plt.axis("off")
plt.show()

图 13:上述代码的随机图像输出 | 图片来源:作者

现在我们将图像数据 img 传递通过第一层卷积层(all_layers[0]),并将输出保存在 results 中。接下来,我们遍历 all_layers,并将前一层操作的输出传递给下一层。这些操作包括卷积、ReLU 激活和 MaxPooling。每个操作的输出我们都会追加到 results 中。

# Pass the image through the first layer
results = all_layers[0]

# Pass the results of the previous layer to the next layer
for idx in range(1, len(all_layers)):  # Start at 1, first layer already passed!
    results.append(all_layersidx)  # Pass the last result to the layer

最后,我们绘制了原始图像、经过第一层(卷积)、第二层(ReLU)、第三层(MaxPooling)、第四层(第二次卷积)、第五层(第二次 ReLU)和第六层(第二次 MaxPooling)处理后的特征图。

图 14:经过卷积、ReLU 和 MaxPooling 层处理后的原始图像和特征图 | 图片来源:作者

我们看到卷积核(对比图 12)重新计算了图像的每个像素。这在特征图中表现为灰度值的变化。与原始图像相比,一些特征图被锐化,或者具有更强的黑白对比,而其他特征图则显得更模糊。

ReLU 操作将深灰色转为黑色,因为负值被设置为零。

MaxPooling 保持图像几乎不变,同时在两个维度上将图像大小减半。

4.4 可视化对分类影响最大的图像区域

在我们完成之前,让我们分析一下图像中哪些区域对分类为废料(索引0)或良品(索引1)至关重要。为此,我们使用梯度加权类别激活映射(gradCAM)。该技术计算训练好的模型相对于预测类别的梯度(梯度显示输入——图像像素——如何影响预测)。每个特征图(即卷积层的输出通道)梯度的平均值构成了计算热图时,用于与特征图相乘的权重。

但让我们逐步看一下。

def gradCAM(x):

    # Run model and predict
    logits = model(x)
    pred = logits.max(-1)[-1] # Returns index of max value (0 or 1)

    # Fetch activations at final conv layer
    last_conv = model.model_layers[:5]
    activations = last_conv(x)

    # Compute gradients with respect to model's prediction
    model.zero_grad()
    logits[0,pred].backward(retain_graph=True)

    # Compute average gradient per output channel of last conv layer
    pooled_grads = model.model_layers[3].weight.grad.mean((1,2,3))

    # Multiply each output channel with its corresponding average gradient
    for i in range(activations.shape[1]):
        activations[:,i,:,:] *= pooled_grads[i]

    # Compute heatmap as average over all weighted output channels
    heatmap = torch.mean(activations, dim=1)[0].cpu().detach()

    return heatmap

我们定义一个函数gradCAM,它接受输入数据x,一个图像或特征图,并返回一个heatmap

在第一个模块中,我们将x输入到 CNN model中,并得到logits,这是一个形状为[1, 2]的张量,仅包含两个值。该值表示类别01的预测概率。我们选择较大值的索引作为模型的预测pred

在第二个模块中,我们提取模型的前五层——从第一个卷积层到第二个 ReLU 层——并将它们保存在last_conv中。我们将x通过选择的层进行计算,并将输出存储在activations中。顾名思义,这些是第二个卷积层的激活值(即特征图)(经过 ReLU 激活后)。

在第三个模块中,我们对预测类别的 logit 值logits[0,pred]进行反向传播。换句话说,我们计算 CNN 对于预测的所有梯度。梯度显示了输入数据(原始图像像素)变化对模型输出(即预测结果)的影响。计算结果保存在 PyTorch 计算图中,直到我们通过model.zero_grad()删除它。

在第四个模块中,我们计算输入通道上的梯度平均值,以及图像或特征图的高度和宽度。结果,我们得到 16 个平均梯度,对应于从第二个卷积层返回的 16 个特征图。我们将其保存在pooled_grads中。

在第五个模块中,我们遍历从第二个卷积层返回的 16 个特征图,并使用平均梯度pooled_grads对其加权。此操作赋予对预测具有重要性(及其像素)的特征图更大的权重,反之亦然。从现在起,activations不再保存特征图,而是保存加权后的特征图。

最后,在最后一个模块中,我们计算heatmap,即所有activations的平均特征图。这就是函数gradCAM返回的结果。

在我们可以绘制图像和热力图之前,我们需要将两者进行转换以便叠加。请记住,特征图比原始图像小(参见第 1.3 章和第 1.7 章),热力图也是如此。这就是为什么我们需要upsampleHeatmap()函数的原因。该函数将像素值缩放到 0 到 255 的范围,并将其转换为 8 位整数格式(这是cv2库所需的格式)。它将热力图调整为 400x700 像素,并对图像和热力图应用颜色映射。最后,我们将 70%的热力图和 30%的图像叠加,并返回合成图以供绘制。

import cv2

def upsampleHeatmap(map, img):
    m,M = map.min(), map.max()
    i,I = img.min(), img.max()
    map = 255 * ((map-m) / (M-m))
    img = 255 * ((img-i) / (I-i))
    map = np.uint8(map)
    img = np.uint8(img)
    map = cv2.resize(map, (700,400))
    map = cv2.applyColorMap(255-map, cv2.COLORMAP_JET)
    map = np.uint8(map)
    img = cv2.applyColorMap(255-img, cv2.COLORMAP_JET)
    img = np.uint8(img)
    map = np.uint8(map*0.7 + img*0.3)
    return map

我们希望将原始图像和热力图叠加展示在同一行中。为此,我们遍历数据加载器predict_loader,对图像运行gradCAM()函数,对热力图和图像运行upsampleHeatmap()函数。最后,我们使用matplotlib.pyplot将原始图像和热力图并排绘制。

# Iterate over dataloader
for idx, (image, name) in enumerate(predict_loader):

    # Compute heatmap
    image = image.to(device)
    heatmap = gradCAM(image)
    image = image.cpu().squeeze(0).permute(1,2,0)
    heatmap = upsampleHeatmap(heatmap, image)

    # Plot images and heatmaps
    fig = plt.figure(figsize=(14,5))
    fig.suptitle(f"\nFile: {names[idx]}, Predicted label: {lbls[idx]}\n", fontsize=24)
    plt.subplot(1, 2, 1)
    plt.imshow(image, cmap="gray")
    plt.title(f"Image", fontsize=14)
    plt.axis("off")
    plt.subplot(1, 2, 2)
    plt.imshow(heatmap)
    plt.title(f"Heatmap", fontsize=14)
    plt.tight_layout()
    plt.axis("off")
    plt.show()
    plt.close()

图 15:图像与热力图(输出的内两行)| 图像由作者提供

热力图中的蓝色区域对模型决策的影响较小,而黄色和红色区域则非常重要。我们看到,在我们的使用案例中,主要是电子元件的轮廓(尤其是金属引脚)对分类为废料或好件至关重要。当然,这一点非常合理,因为我们的用例主要处理的是弯曲引脚。

结论

卷积神经网络(CNN)如今已成为工业环境中常见且广泛使用的视觉检测工具。在我们的使用案例中,通过相对少量的代码行,我们成功定义了一个模型,能够高精度地将电子元件分类为好件或废料。与传统的视觉检测方法相比,最大的优势在于不需要过程工程师在图像中指定视觉标记来进行分类。相反,CNN 通过标签化的示例进行学习,并能够将这种知识复制到其他图像。在我们的具体用例中,626 张标签化的图像足以进行训练和验证。在更复杂的情况下,训练数据的需求可能会显著增加。

像 gradCAM(梯度加权类激活映射)这样的算法在理解图像中哪些区域对模型的决策特别相关方面具有重要帮助。通过这种方式,它们通过增强对模型功能的信任,支持卷积神经网络(CNN)在工业环境中的广泛应用。

在本文中,我们探讨了卷积神经网络的许多内部工作细节。希望您喜欢这次旅程,并且深入理解了 CNN 的工作原理。

使用 IBM Watsonx 和 Langchain 构建代理型检索增强生成(RAG)系统

原文:towardsdatascience.com/building-an-agentic-retrieval-augmented-generation-rag-system-with-ibm-watsonx-and-langchain-a0182c9f5b01?source=collection_archive---------8-----------------------#2024-08-23

快速入门教程

Lakshmi NarayananTowards Data Science Lakshmi Narayanan

·发布于 Towards Data Science ·阅读时间 5 分钟·2024 年 8 月 23 日

--

AI 生成图像(由 GPT-4o 生成)

人工智能(AI)领域,特别是在生成式 AI 方面,最近取得了显著进展。大型语言模型(LLMs)在这方面具有革命性意义。构建 LLM 应用程序的一种流行方法是检索增强生成(RAG),它结合了利用组织数据的能力和这些 LLM 的生成能力。代理是一种流行且有用的方式,可以将自主行为引入 LLM 应用程序中。

什么是代理型 RAG?

代理型 RAG代表了 AI 系统的一个高级演进,在这种系统中,自主代理利用 RAG 技术来增强决策和响应能力。与传统的 RAG 模型不同,后者通常依赖用户输入来触发行动,代理型 RAG 系统采取了主动的方法。这些代理主动寻找相关信息,分析并利用它生成响应或采取具体行动。代理被配备了一套工具,并能够谨慎地选择并使用适当的工具来解决特定问题

这种主动行为在许多应用场景中尤为宝贵,如客户服务、研究协助和复杂问题处理等…

使用 DSPy 构建 AI 助手

原文:towardsdatascience.com/building-an-ai-assistant-with-dspy-2e1e749a1a95?source=collection_archive---------0-----------------------#2024-03-07

一种编程和调整提示无关的 LLM 代理流水线的方法

Lak LakshmananTowards Data Science Lak Lakshmanan

·发表于Towards Data Science ·阅读时间 9 分钟·2024 年 3 月 7 日

--

我讨厌提示工程。有一方面,我不想在 LLM 面前俯首称臣(“你是世界上最棒的文案写手……”)、贿赂它(“如果你……,我会给你 10 美元小费”)或烦扰它(“确保……”)。另一方面,提示是脆弱的——对提示语做些许更改就能导致输出发生重大变化。这使得使用 LLM 开发可重复的功能变得困难。

不幸的是,今天开发基于 LLM 的应用程序涉及调整和修改提示语。从编写计算机精确执行的编程语言代码,到编写自然语言指令(计算机并不完全遵循)似乎并没有进步。这就是我发现使用 LLM 进行工作的原因感到沮丧——我更喜欢编写和调试我可以实际推理的计算机程序。

那么,如果你能使用一个高级编程框架在 LLM 之上进行编程,并让框架为你编写和调整提示语呢?那岂不是太好了?这——能够在不处理提示的情况下编程构建代理流水线,并且以数据驱动和与 LLM 无关的方式调整这些流水线——正是 DSPy背后的关键前提。

一个 AI 助手

为了说明 DSPy 是如何工作的,我将构建一个 AI 助手。

什么是 AI 助手?它是一个为人类执行任务提供帮助的计算机程序。理想的 AI 助手会主动地代表用户工作(聊天机器人可以作为功能备份,用于查找产品中不易找到的功能或为终端用户提供客户支持,但不应是应用程序中主要/唯一的 AI 助手)。因此,设计 AI 助手的过程包括思考工作流程,并确定如何通过 AI 来简化它。

一个典型的 AI 助手通过以下方式简化工作流程:(1)检索与任务相关的公司政策等信息,(2)从客户发送的文档中提取信息,(3)根据对政策和文档的文本分析填写表格或检查单,(4)收集参数并代表用户调用函数,(5)识别潜在错误并突出风险。

我将使用一个桥牌作为例子来说明 AI 助手的用例。尽管我正在为桥牌叫牌构建 AI 助手,但你并不需要了解桥牌就能理解这里的概念。我选择桥牌的原因是,桥牌中有大量术语、涉及相当多的人类判断,并且有多个外部工具供顾问使用。这些是你可能想为其构建 AI 助手的行业问题和后台流程的关键特征。但因为它是一个游戏,所以其中不涉及机密信息。

代理框架

当被问到类似“什么是 Stayman?”的问题时,助手会使用多个后台服务来执行其任务。这些后台服务通过代理进行调用,而这些代理本身是基于语言模型构建的。与软件工程中的微服务类似,使用代理和后台服务允许解耦和专业化——AI 助手不需要知道事情是如何完成的,只需要知道需要完成什么,而每个代理只需了解如何做自己的事情。

一个代理框架。图片由作者提供。图片中的草图是使用 Gemini 生成的。

在代理框架中,代理通常是较小的语言模型(LMs),这些模型需要准确,但不具备世界知识。代理能够进行“推理”(通过思维链)、搜索(通过检索增强生成)和执行非文本工作(通过提取参数传递给后台函数)。代理框架的前端是一个非常流利且连贯的大型语言模型(LLM)。这个 LLM 知道它需要处理的意图,以及如何路由这些意图。它还需要具备世界知识。通常,会有一个单独的政策或监管 LLM 作为过滤器。当用户发起查询时(聊天机器人用例)或发生触发事件时(主动助手用例),AI 助手会被调用。

使用 DSPy 的零样本提示

要构建上述整个架构,我将使用 DSPy。整个代码可以在 GitHub 上找到;从该目录下的bidding_advisor.py开始,跟着一起操作。

在 DSPy 中,发送提示给 LLM 并获取响应的过程如下:

class ZeroShot(dspy.Module):
    """
    Provide answer to question
    """
    def __init__(self):
        super().__init__()
        self.prog = dspy.Predict("question -> answer")

    def forward(self, question):
        return self.prog(question="In the game of bridge, " + question)

上面的代码段中发生了四个事情:

  1. 编写一个 dspy.Module 的子类

  2. 在 init 方法中,设置一个 LM 模块。最简单的方式是使用 dspy.Predict,它是一个单一的调用。

  3. Predict 构造函数接受一个签名。这里,我表示有一个输入(问题)和一个输出(答案)。

  4. 编写一个 forward()方法,接受指定的输入(这里是:问题),并返回签名中承诺的内容(这里是:答案)。它通过调用在 init 方法中创建的 dspy.Predict 对象来实现。

我本可以直接传递问题,但为了展示我可以在某种程度上影响提示,我添加了一些上下文。

注意,上面的代码完全与 LLM 无关,且提示中没有任何谄媚、贿赂等内容。

要调用上述模块,首先初始化 dspy 并配置一个 LLM:

gemini = dspy.Google("models/gemini-1.0-pro",
                         api_key=api_key,
                         temperature=temperature)
dspy.settings.configure(lm=gemini, max_tokens=1024)

然后,调用你的模块:

module = ZeroShot()
response = module("What is Stayman?")
print(response)

当我这么做时,得到了:

Prediction(
    answer='Question: In the game of bridge, What is Stayman?\nAnswer: A conventional bid of 2♣ by responder after a 1NT opening bid, asking opener to bid a four-card major suit if he has one, or to pass if he does not.'
)

想要使用不同的 LLM?将设置配置行更改为:

gpt35 = dspy.OpenAI(model="gpt-3.5-turbo",
                        api_key=api_key,
                        temperature=temperature)
dspy.settings.configure(lm=gpt35, max_tokens=1024)

文本提取

如果 DSPy 只是让调用 LLMs 更容易并且将 LLM 进行抽象化,那么人们也不会对 DSPy 如此兴奋。让我们继续构建 AI 助手,并在过程中展示一些其他的优势。

假设我们想使用 LLM 进行实体提取。我们可以通过指示 LLM 识别我们要提取的内容(日期、产品 SKU 等)来实现。在这里,我们会要求它找出桥牌术语:

class Terms(dspy.Signature):
    """
    List of extracted entities
    """
    prompt = dspy.InputField()
    terms = dspy.OutputField(format=list)

class FindTerms(dspy.Module):
    """
    Extract bridge terms from a question
    """
    def __init__(self):
        super().__init__()
        self.entity_extractor = dspy.Predict(Terms)

    def forward(self, question):
        max_num_terms = max(1, len(question.split())//4)
        instruction = f"Identify up to {max_num_terms} terms in the following question that are jargon in the card game bridge."
        prediction = self.entity_extractor(
            prompt=f"{instruction}\n{question}"
        )
        return prediction.terms

虽然我们本可以将模块的签名表示为“提示 -> 条件”,但我们也可以将签名表示为一个 Python 类。

在一个语句上调用此模块:

module = FindTerms()
response = module("Playing Stayman and Transfers, what do you bid with 5-4 in the majors?")
print(response)

我们将得到:

['Stayman', 'Transfers']

注意,这段代码是多么简洁和易读。

RAG

DSPy 内置了多个检索器。但这些本质上只是函数,你可以将现有的检索代码封装到 dspy.Retriever 中。它支持多个流行的检索器,包括 ChromaDB:

from chromadb.utils import embedding_functions
default_ef = embedding_functions.DefaultEmbeddingFunction()
bidding_rag = ChromadbRM(CHROMA_COLLECTION_NAME, CHROMADB_DIR, default_ef, k=3)

当然,我得先获取一本关于桥牌叫牌的文档,将其拆分并加载到 ChromaDB 中。如果你感兴趣,代码在仓库里,但由于与本文无关,我将略过不提。

编排

现在你已经实现了所有的代理,每个代理都是一个独立的 dspy.Module。接下来,构建编排 LLM,它接收命令或触发器并以某种方式调用代理模块。

模块的编排也发生在一个 dspy.Module 中:

class AdvisorSignature(dspy.Signature):
    definitions = dspy.InputField(format=str)  # function to call on input to make it a string
    bidding_system = dspy.InputField(format=str) # function to call on input to make it a string
    question = dspy.InputField()
    answer = dspy.OutputField()

class BridgeBiddingAdvisor(dspy.Module):
    """
    Functions as the orchestrator. All questions are sent to this module.
    """
    def __init__(self):
        super().__init__()
        self.find_terms = FindTerms()
        self.definitions = Definitions()
        self.prog = dspy.ChainOfThought(AdvisorSignature, n=3)

    def forward(self, question):
        terms = self.find_terms(question)
        definitions = [self.definitions(term) for term in terms]
        bidding_system = bidding_rag(question)
        prediction = self.prog(definitions=definitions,
                               bidding_system=bidding_system,
                               question="In the game of bridge, " + question,
                               max_tokens=-1024)
        return prediction.answer

我没有使用 dspy.Predict 作为最终步骤,而是使用了 ChainOfThought(COT=3)。

优化器

现在我们已经设置好了整个链条,我们当然可以直接调用调度模块来进行测试。但更重要的是,我们可以让 dspy 根据示例数据自动调整提示。

要加载这些示例并让 dspy 进行调整(这叫做提示器,但这个名称将改为优化器,这更准确地描述了它的功能),我这样做:

traindata = json.load(open("trainingdata.json", "r"))['examples']
trainset = [dspy.Example(question=e['question'], answer=e['answer']) for e in traindata]

# train
teleprompter = teleprompt.LabeledFewShot()
optimized_advisor = teleprompter.compile(student=BridgeBiddingAdvisor(), trainset=trainset)

# use optimized advisor just like the original orchestrator
response = optimized_advisor("What is Stayman?")
print(response)

我在上面的示例中只用了 3 个示例,但显然,你会使用成百上千个示例来获得一个经过适当调优的提示集。值得注意的是,调整是针对整个管道进行的;你不需要一个一个模块地调整。

优化后的管道更好吗?

原始管道对这个问题的返回结果如下(也显示了中间输出,并且“两颗梅花”是错误的):

a: Playing Stayman and Transfers, what do you bid with 5-4 in the majors?
b: ['Stayman', 'Transfers']
c: ['Stayman convention | Stayman is a bidding convention in the card game contract bridge. It is used by a partnership to find a 4-4 or 5-3 trump fit in a major suit after making a one notrump (1NT) opening bid and it has been adapted for use after a 2NT opening, a 1NT overcall, and many other natural notrump bids.', "Jacoby transfer | The Jacoby transfer, or simply transfers, in the card game contract bridge, is a convention initiated by responder following partner's notrump opening bid that forces opener to rebid in the suit ranked just above that bid by responder. For example, a response in diamonds forces a rebid in hearts and a response in hearts forces a rebid in spades. Transfers are used to show a weak hand with a long major suit, and to ensure that opener declare the hand if the final contract is in the suit transferred to, preventing the opponents from seeing the cards of the stronger hand."]
d: ['stayman ( possibly a weak ... 1602', '( scrambling for a two -  ... 1601', '( i ) two hearts is weak  ... 1596']
Two spades.

优化后的管道返回了正确答案“Smolen”:

a: Playing Stayman and Transfers, what do you bid with 5-4 in the majors?
b: ['Stayman', 'Transfers']
c: ['Stayman convention | Stayman is a bidding convention in the card game contract bridge. It is used by a partnership to find a 4-4 or 5-3 trump fit in a major suit after making a one notrump (1NT) opening bid and it has been adapted for use after a 2NT opening, a 1NT overcall, and many other natural notrump bids.', "Jacoby transfer | The Jacoby transfer, or simply transfers, in the card game contract bridge, is a convention initiated by responder following partner's notrump opening bid that forces opener to rebid in the suit ranked just above that bid by responder. For example, a response in diamonds forces a rebid in hearts and a response in hearts forces a rebid in spades. Transfers are used to show a weak hand with a long major suit, and to ensure that opener declare the hand if the final contract is in the suit transferred to, preventing the opponents from seeing the cards of the stronger hand."]
d: ['stayman ( possibly a weak ... 1602', '( scrambling for a two -  ... 1601', '( i ) two hearts is weak  ... 1596']
After a 1NT opening, Smolen allows responder to show 5-4 in the majors with game-forcing values.

原因在于 dspy 创建的提示。例如,对于问题“什么是 Stayman?”,请注意,它已经根据术语定义和 RAG 中的多个匹配项构建了一个推理过程:

该提示由 dspy.ChainOfThought 根据术语定义、RAG 等创建。

再次强调,我并没有编写上面调整过的提示。这些都是为我编写的。你也可以看到这未来的发展方向——你可能能够微调整个管道,使其在更小的语言模型上运行。

祝你愉快!

下一步

  1. 查看我的GitHub 代码,从bidding_advisor.py开始。

  2. 在这里了解更多关于 DSPy 的信息:dspy-docs.vercel.app/docs/intro

  3. 在这里学习如何玩桥牌:www.trickybridge.com/(抱歉,我忍不住了)。

构建一个 AI 驱动的业务管理系统

原文:towardsdatascience.com/building-an-ai-powered-business-manager-e2a31a2fe984?source=collection_archive---------3-----------------------#2024-04-23

使用 DALL·E 创建

将 AI 代理与 SQL 数据库连接的逐步指南——系列文章的第二部分

Lukasz KowejszaTowards Data Science Lukasz Kowejsza

·发布于 Towards Data Science ·29 分钟阅读·2024 年 4 月 23 日

--

想象一下通过一个简单易用的手机界面来简化整个业务管理。虽然同时使用多个应用程序是常见做法,但未来的趋势是将所有互动整合到一个基于聊天的平台中,这个平台由大型语言模型(LLM)驱动。

对于小型企业来说,这种方法具有显著优势。通过将数据管理任务集中在统一的聊天界面中,企业主可以节省时间,减少复杂性,并最小化对不同软件工具的依赖。最终的结果是资源分配更为高效,能够将更多精力集中在核心业务增长活动上。

然而,这一潜力不仅限于小型企业。本教程中详细介绍的概念和技术同样适用于个人使用案例。从管理待办事项和跟踪开支到整理收藏,基于聊天的界面为与数据交互提供了一种直观且高效的方式。

本文是一个系列文章中的第二篇,旨在引导你完成从最初的概念到实际实现的整个软件开发过程。在上一篇文章中介绍的组件基础上,我们将建立我们应用程序的基础元素,包括:

  • 设置数据库架构

  • 定义核心应用功能

  • 结构化项目仓库

  • 创建能够使用自然语言命令与多个 SQL 数据库表交互的工具

到本教程结束时,你将清楚地理解如何设计一个基于聊天界面的架构,利用大语言模型(LLM)简化数据管理任务。无论你是希望优化运营的小企业主,还是寻求个人组织优化的个体,这里讲解的原则将为你的项目提供一个坚实的起点。

让我们先简要回顾一下上一篇文章的关键要点,为我们当前的目标设定背景。

回顾

在本系列的第一部分中,我们构建了一个原型代理工作流,能够与工具对象进行交互。我们的目标是减少底层语言模型生成的工具参数中的幻觉现象,在我们的案例中是gpt-3.5-turbo

为了实现这一目标,我们实施了两个关键变更:

  1. 删除了工具模式中的必需参数

  2. 在执行所需功能之前增加了参数验证步骤

通过将所有工具参数设为可选,并手动检查缺失的参数,我们消除了代理/大语言模型生成缺失值的幻觉冲动。

在上一篇文章中介绍的关键对象是:

  • OpenAiAgent:主要的代理工作流类

  • Tool:一个表示代理可以使用的工具的类

  • ToolResultStepResult:用于封装工具执行结果的类

这些组件构成了我们代理系统的基础,使其能够处理用户请求,选择合适的工具,并生成响应。

如果你想要更详细的解释或了解特定设计选择背后的原因,可以查看上一篇文章:利用 OpenAI 工具调用:从零开始构建可靠的 AI 代理

记住这些回顾内容后,让我们进入项目的下一阶段——集成数据库功能以存储和管理业务数据。

为什么为小企业数据管理提供聊天界面

小企业在数据维护方面经常面临独特的挑战。与大公司一样,它们需要定期更新和维护各种类型的数据,如会计记录、时间跟踪、发票等。然而,现代 ERP(企业资源规划)系统的复杂性和成本对小企业而言可能是一个障碍。因此,许多小企业不得不依赖一系列 Excel 电子表格来捕捉和维护关键数据。

这种方法的问题在于,小企业主通常并非完全专注于行政任务,无法投入大量时间和精力进行复杂的行政管理和控制流程。关键在于定义精简的流程,并在数据出现时及时更新,最小化数据管理的开销。

通过利用大型语言模型的强大功能并创建聊天界面,我们旨在简化和优化小型企业的数据管理。该聊天机器人将充当统一接口,允许用户输入数据、检索信息,并通过自然语言命令执行各种任务。这消除了需要在多个电子表格之间切换或开发具有多个表单和仪表盘的复杂 web 应用程序的需求。

在这一系列教程中,我们将逐步增强聊天机器人的功能,添加诸如基于角色的访问控制、先进的查询与评估、多模态支持,以及与流行的通讯平台(如 WhatsApp)的集成等功能。到系列结束时,您将拥有一个强大而灵活的工具,能够根据您的具体需求进行调整,无论您是经营一家小型企业,还是仅仅希望更高效地组织个人生活。

让我们开始吧!

1. 项目结构

为了确保项目井井有条并易于维护,我们已经有系统地构建了我们的代码库,封装了不同的功能和组件。以下是代码库结构的概述:

project-root/
│
├── database/
│ ├── db.py # Database connection and setup
│ ├── models.py # Database models/schemas
| └── utils.py # Database utilities
│
├── tools/
│ ├── base.py # Base class for tools
│ ├── add.py # Tool for adding data to the database
│ ├── query.py # Tool for querying data from the database
| └── utils.py # Tool utilities
│
├── agents/
│ ├── base.py # Main AI agent logic
│ ├── routing.py # Specialized agent for routing tasks
│ ├── task.py # Tool wrapper for OpenAI subagents
| └── utils.py # agent utilities
│
└── utils.py # Utility functions and classes

这种结构使得关注点分离更加清晰,简化了应用程序的开发、维护和扩展。

2. 设置数据库

选择合适的数据库和 ORM(对象关系映射)库对我们的应用程序至关重要。对于这个项目,我们选择了以下框架:

  • SQLAlchemy:一个强大的 SQL 工具包和 Python 的对象关系映射(ORM)库。它提供了一套与数据库交互的工具,通过 Python 对象和类进行操作。

  • SQLModel:一个构建在 SQLAlchemy 和 Pydantic 之上的库,提供了一种简单直观的方式来定义数据库模型并执行数据库操作。

通过利用 SQLModel,我们可以与 Pydantic 和 SQLAlchemy 无缝集成,既能高效地进行数据验证和数据库操作,又能消除 SQL 注入攻击的风险。此外,SQLModel 使我们能够轻松构建我们之前设计的Tool类,该类使用 Pydantic 模型来创建工具模式。

为确保我们应用程序的安全性和稳健性,我们实施了以下措施:

  1. 基于角色的访问控制:可执行的操作与用户角色绑定,确保用户只能执行他们被授权的操作。这为系统增加了额外的安全层,防止未经授权访问敏感数据。

  2. 防止 SQL 注入攻击:通过利用 ChatGPT 的自然语言理解能力,我们可以验证和清理用户输入,从而减轻 SQL 注入漏洞的风险。SQLModel 与 Pydantic 的集成帮助我们强制执行严格的数据验证规则。

在确定了我们的技术栈后,让我们开始设置数据库并定义我们的模型。

2.1 数据库模型

为了开始构建我们的原型应用程序,我们将定义基本的数据库表和相应的 SQLModel 定义。对于本教程,我们将重点介绍三个核心表:

  • 支出

  • 收入

  • 客户

这些表将作为我们应用程序的基础,允许我们演示关键功能和交互。

database目录下创建一个名为models.py的新文件,并使用 SQLModel 定义表格:

# database\models.py
from typing import Optional  

from pydantic import BeforeValidator, model_validator  
from sqlmodel import SQLModel, Field  
from datetime import time, datetime  
from typing_extensions import Annotated 

def validate_date(v):  
    if isinstance(v, datetime):  
        return v  

    for f in ["%Y-%m-%d", "%Y-%m-%d %H:%M:%S"]:  
        try:  
            return datetime.strptime(v, f)  
        except ValueError:  
            pass  

    raise ValueError("Invalid date format")  

def numeric_validator(v):  
    if isinstance(v, int):  
        return float(v)  
    elif isinstance(v, float):  
        return v  
    raise ValueError("Value must be a number")  

DateFormat = Annotated[datetime, BeforeValidator(validate_date)]  
Numeric = Annotated[float, BeforeValidator(numeric_validator)]

class Customer(SQLModel, table=True):  
    id: Optional[int] = Field(primary_key=True, default=None)
    company: str
    first_name: str  
    last_name: str  
    phone: str  
    address: str  
    city: str  
    zip: str  
    country: str  

class Revenue(SQLModel, table=True):  
    id: Optional[int] = Field(primary_key=True, default=None)  
    description: str  
    net_amount: Numeric  
    gross_amount: Numeric  
    tax_rate: Numeric  
    date: DateFormat  

class Expense(SQLModel, table=True):  
    id: Optional[int] = Field(primary_key=True, default=None)  
    description: str  
    net_amount: Numeric = Field(description="The net amount of the expense")  
    gross_amount: Numeric  
    tax_rate: Numeric  
    date: DateFormat

除了标准的 SQLModel 字段外,我们还定义了三个自定义类型注解:DateFormatTimeFormatNumeric。这些注解利用了 Pydantic 的BeforeValidator,以确保在将输入数据存储到数据库之前,它们被正确格式化。validate_date函数处理将字符串输入转换为适当的datetime。这种方法允许我们接受来自大型语言模型的各种日期格式,从而减少了在提示中对格式的严格要求。

2.2 数据库引擎

定义了模型之后,我们需要一个脚本来设置数据库引擎并创建相应的表格。让我们在database目录中创建一个db.py文件来处理这个任务:

# database/db.py
from database.models import *  
from sqlmodel import SQLModel, create_engine  
import os  

# local stored database  
DATABASE_URL = "sqlite:///app.db"  

engine = create_engine(DATABASE_URL, echo=True) 

def create_db_and_tables():  
    SQLModel.metadata.create_all(engine)  

create_db_and_tables()

在这个脚本中,我们导入了我们的模型和必要的 SQLModel 组件。我们定义了DATABASE_URL,它指向名为app.db的本地 SQLite 数据库文件。我们使用 SQLModel 的create_engine创建了一个engine,并传入DATABASE_URLecho=True参数启用了详细输出,便于调试。

create_db_and_tables函数使用SQLModel.metadata.create_all根据我们定义的模型生成相应的数据库表。最后,我们调用这个函数以确保在运行脚本时创建数据库和表格。

数据库设置完成后,我们现在可以专注于更新我们的Tool类,使其与 SQLModel 无缝协作,并增强我们的工具架构转换过程。

3. 工具类

在这一部分,我们将讨论对Tool类所做的更新,以处理 SQLModel 实例并改进验证过程。如需更详细的Tool类说明,请访问我之前的文章。

首先,我们通过Union类型提示将Type[SQLModel]作为model字段的可能类型。这使得Tool类能够接受 Pydantic 的BaseModel和 SQLModel 的SQLModel作为有效的模型类型。

接下来,我们引入了一个新的属性exclude_keys,其类型为list[str],默认值为["id"]。这个属性的目的是指定哪些键应该从验证过程和 OpenAI 工具架构生成中排除。在这种情况下,默认排除的键是id,因为在使用SqlModel进行数据录入时,id会在数据导入过程中自动生成。

此外,我们在Tool类中引入了parse_model布尔属性。通过这个属性,我们可以决定工具函数是通过 Pydantic/SQLModel 调用,还是通过关键字参数调用。

validate_input()方法中,我们添加了一个检查,以确保在验证过程中,exclude_keys中指定的键不会被视为缺失键。这对于像id这样的字段特别有用,因为这些字段是由 SQLModel 自动生成的,不应作为输入的必需项。

同样,在openai_tool_schema属性中,我们添加了一个循环,以从生成的模式中移除被排除的键。这确保了排除的键不会被包括在发送到 OpenAI API 的模式中。为了总结,我们使用openai_tool_schema属性从我们的工具模式中移除required参数。这是为了消除语言模型的幻觉。

此外,我们将导入语句从from pydantic.v1 import BaseModel更改为from pydantic import BaseModel。由于SQLModel基于 Pydantic v2,我们希望在此时保持一致,使用 Pydantic v2。

这是Tool类的更新代码:

# tools/base.py
from typing import Type, Callable, Union

from tools.convert import convert_to_openai_tool
from pydantic import BaseModel, ConfigDict
from sqlmodel import SQLModel

class ToolResult(BaseModel):
    content: str
    success: bool

class Tool(BaseModel):
    name: str
    model: Union[Type[BaseModel], Type[SQLModel], None]
    function: Callable
    validate_missing: bool = True
    parse_model: bool = False
    exclude_keys: list[str] = ["id"]

    model_config = ConfigDict(arbitrary_types_allowed=True)

    def run(self, **kwargs) -> ToolResult:
        if self.validate_missing and model is not None:
            missing_values = self.validate_input(**kwargs)
            if missing_values:
                content = f"Missing values: {', '.join(missing_values)}"
                return ToolResult(content=content, success=False)

        if self.parse_model:
            if hasattr(self.model, "model_validate"):
                input_ = self.model.model_validate(kwargs)
            else:
                input_ = self.model(**kwargs)
            result = self.function(input_)

        else:
            result = self.function(**kwargs)
        return ToolResult(content=str(result), success=True)

    def validate_input(self, **kwargs):
        if not self.validate_missing or not self.model:
            return []
        model_keys = set(self.model.__annotations__.keys()) - set(self.exclude_keys)
        input_keys = set(kwargs.keys())
        missing_values = model_keys - input_keys
        return list(missing_values)

    @property
    def openai_tool_schema(self):
        schema = convert_to_openai_tool(self.model)
        # set function name
        schema["function"]["name"] = self.name

        # remove required field
        if schema["function"]["parameters"].get("required"):
            del schema["function"]["parameters"]["required"]
        # remove exclude keys
        if self.exclude_keys:
            for key in self.exclude_keys:
                if key in schema["function"]["parameters"]["properties"]:
                    del schema["function"]["parameters"]["properties"][key]
        return schema

这些对Tool类的更新提供了更多的灵活性和控制力,能够在处理 SQLModel 实例时进行更精细的验证过程和模式生成。

3.1 自定义工具模式转换

在我们的Tool类中,我们使用convert_to_openai_tool函数从 Langchain 创建一个 Pydantic 模型的模式。然而,这个函数是基于 Pydantic v1 的,而 SQLModel 使用的是 Pydantic v2。为了使转换函数兼容,我们需要对其进行调整。让我们创建一个新的脚本,命名为convert.py

# tools/convert.py
from langchain_core.utils.function_calling import _rm_titles
from typing import Type, Optional
from langchain_core.utils.json_schema import dereference_refs
from pydantic import BaseModel

def convert_to_openai_tool(
        model: Type[BaseModel],
        *,
        name: Optional[str] = None,
        description: Optional[str] = None,
) -> dict:
    """Converts a Pydantic model to a function description for the OpenAI API."""
    function = convert_pydantic_to_openai_function(
        model, name=name, description=description
    )
    return {"type": "function", "function": function}

def convert_pydantic_to_openai_function(
        model: Type[BaseModel],
        *,
        name: Optional[str] = None,
        description: Optional[str] = None,
        rm_titles: bool = True,
) -> dict:
    """Converts a Pydantic model to a function description for the OpenAI API."""

    model_schema = model.model_json_schema() if hasattr(model, "model_json_schema") else model.schema()

    schema = dereference_refs(model_schema)
    schema.pop("definitions", None)
    title = schema.pop("title", "")
    default_description = schema.pop("description", "")
    return {
        "name": name or title,
        "description": description or default_description,
        "parameters": _rm_titles(schema) if rm_titles else schema,
    }

这个调整后的转换函数处理了 Pydantic v1 和 v2 之间的差异,确保我们的Tool类能够生成与 OpenAI API 兼容的模式。

接下来,在tools/base.py中更新导入语句,以使用新的convert_to_openai_tool函数:

# tools/base.py
from typing import Type, Callable, Union

from tools.convert import convert_to_openai_tool
from pydantic import BaseModel
from sqlmodel import SQLModel
#...rest of the code ...

通过这些更改,我们的Tool类现在可以处理 SQLModel 实例并生成与 OpenAI API 兼容的模式。

注意:如果遇到依赖问题,您可以考虑完全移除 Langchain 依赖,并直接在convert.py文件中包含*_rm_titles**dereference_refs*函数。

通过调整工具模式转换过程,我们确保了我们的应用能够与 SQLModel 和 Pydantic v2 无缝协作,使我们能够在保持与 OpenAI API 兼容的同时,利用这些库的优势。

4. 定义 SQL 工具

在本节中,我们将创建函数和工具,以便使用 SQL 与我们的数据库表进行交互。

4.1 添加数据工具

首先,让我们定义一个通用函数add_row_to_table,它接受一个 SQLModel 实例并将其添加到相应的表中:

# tools/add.py
from sqlmodel import SQLModel, Session, select

def add_row_to_table(model_instance: SQLModel):  
    with Session(engine) as session:  
        session.add(model_instance)  
        session.commit()  
        session.refresh(model_instance)  
    return f"Successfully added {model_instance} to the table"

接下来,我们将创建一个特定模型的函数add_expense_to_table,它接受支出条目的输入参数并将其添加到表中:

# tools/add.py
# ...
def add_expense_to_table(**kwargs):
    model_instance = Expense.model_validate(kwargs)
    return add_row_to_table(model_instance)

add_expense_to_table中,我们使用model_validate()方法触发之前定义的 BeforeValidator 的执行,并确保数据验证。

为了避免为每个表或 SQLModel 编写单独的函数,我们可以动态生成这些函数:

# example usage

def add_entry_to_table(sql_model: Type[SQLModel]):  
    # return a Callable that takes a SQLModel instance and adds it to the table  
    return lambda **data: add_row_to_table(model_instance=sql_model.model_validate(data))

add_expense_to_table = add_entry_to_table(Expense)

这种方法产生相同的结果,并且可以用来动态生成所有其他模型的函数。

在这些功能就位后,我们可以使用我们的Tool类创建工具,通过 OpenAIAgent 向数据库表中添加条目:

add_expense_tool = Tool(
 name="add_expense_tool",
 description="useful for adding expenses to database",
 function=add_entry_to_table(Expense),
 model=Expense,
 validate_missing=True
)

add_revenue_tool = Tool(
 name="add_revenue_tool",
 description="useful for adding revenue to database",
 function=add_entry_to_table(Revenue),
 model=Revenue,
 validate_missing=True
)

4.2 查询工具

虽然我们需要为每个表创建一个 add_xxx_tool,因为输入模式不同,但我们只需要一个查询工具来查询所有表。为了消除 SQL 注入的风险,我们将使用 SQLAlchemy 和 SQLModel 提供的 SQL 清理功能。这意味着我们将通过标准的 Python 类和对象来查询数据库,而不是直接解析 SQL 语句。

对于我们想在表上执行的查询,我们需要以下逻辑:

  1. select 语句 -> SELECT * FROM table_name 参数:columnstable_name

  2. where 语句 -> WHERE column_name = value

    参数:columnoperatorvalue

在 SQLModel 中,当我们想在Expense表中查找所有咖啡的支出时,这对应于以下清理过的代码:

result = database.execute(
  select(Expense).where(Expense.description == "Coffee")
)

将其抽象为一个 pydantic 模型:

# tools/query.py
from typing import Union, Literal
from pydantic import BaseModel

class WhereStatement(BaseModel):  
    column: str  
    operator: Literal["eq", "gt", "lt", "gte", "lte", "ne", "ct"]  
    value: str  

class QueryConfig(BaseModel):  
    table_name: str  
    columns: list[str]  
    where: list[Union[WhereStatement, None]]

QueryConfig模型允许我们设置table_namecolumnswhere语句。where属性接受一个WhereStatement模型的列表或一个空列表(当我们想返回所有值且不进行进一步筛选时)。WhereStatement是一个子模型,定义了列、操作符和值。Literal类型用于将允许的操作符限制为预定义的集合。

接下来,我们定义一个根据QueryConfig执行查询的函数:

# tools/query.py
# ...
from database.models import Expense, Revenue, Customer

TABLES = {
    "expense": Expense,
    "revenue": Revenue,
    "customer": Customer
}

def query_data_function(**kwargs) -> ToolResult:  
    """Query the database via natural language."""
    query_config = QueryConfig.model_validate(kwargs)

    if query_config.table_name not in TABLES:  
        return ToolResult(content=f"Table name {query_config.table_name} not found in database models", success=False)  

    sql_model = TABLES[query_config.table_name]  

    # query_config = validate_query_config(query_config, sql_model)  
    data = sql_query_from_config(query_config, sql_model)  

    return ToolResult(content=f"Query results: {data}", success=True)  

def sql_query_from_config(  
        query_config: QueryConfig,  
        sql_model: Type[SQLModel]):  

    with Session(engine) as session:  
        selection = []  
        for column in query_config.select_columns:  
            if column not in sql_model.__annotations__:  
                return f"Column {column} not found in model {sql_model.__name__}"  
            selection.append(getattr(sql_model, column))  

        statement = select(*selection)  
        wheres = query_config.where  
        if wheres:  
            for where in wheres:  

                if where.column not in sql_model.__annotations__:  # noqa  
                    return (f"Column {where['column']} not found "
                       "in model {sql_model.__name__}")

                elif where.operator == "eq":  
                    statement = statement.where(
                     getattr(sql_model, where.column) == where.value)  
                elif where.operator == "gt":  
                    statement = statement.where(
                     getattr(sql_model, where.column) > where.value)  
                elif where.operator == "lt":  
                    statement = statement.where(
                     getattr(sql_model, where.column) < where.value)  
                elif where.operator == "gte":  
                    statement = statement.where(
                     getattr(sql_model, where.column) >= where.value)  
                elif where.operator == "lte":  
                    statement = statement.where(
                     getattr(sql_model, where.column) <= where.value)  
                elif where.operator == "ne":  
                    statement = statement.where(
                     getattr(sql_model, where.column) != where.value)  
                elif where.operator == "ct":  
                    statement = statement.where(
                     getattr(sql_model, where.column).contains(where.value))  

        result = session.exec(statement)  
        data = result.all()  
        try:  
            data = [repr(d) for d in data]  
        except:  
            pass  
    return data

query_data_function作为从TABLES字典中选择我们的表模型的高级抽象,而sql_query_from_config是执行QueryConfig查询表(SQLModel)的底层函数。

QueryConfig中,你可以选择将table_names定义为 Literal 类型,其中将可用的表名硬编码进去。你甚至可以通过我们的 TABLES 字典动态定义 Literal。通过这样做,可以减少对table_name的错误传参。目前我选择不使用枚举对象,因为我将向代理提供关于当前可用表及其底层 ORM 架构的上下文信息。我计划为我们未来的代理添加一个工具,使其能够自己创建新表。虽然我可以动态更改代理的提示,但在运行中的服务器中更改QueryConfig中的枚举对象将不是一件简单的事。

最后,我们可以定义我们的查询工具:

query_data_tool = Tool(  
    name="query_data_tool",  
    description = "useful to perform queries on a database table",
    model=QueryConfig,  
    function=query_data_function, 
)

有了这些工具,我们的 OpenAIAgent 现在能够使用自然语言命令向数据库表中添加和查询数据。

5. 配置代理

为了使我们之前定义的工具能够成功使用,前一篇文章中的 Agent 需要更多的上下文信息,尤其是用于查询工具时。Agent 的提示需要包括可用表格及其架构信息。由于目前我们只使用两个表格,我们可以在系统提示或用户提示中包含 ORM 架构和表格名称。这两种方式都可以,但我更倾向于在用户提示中包含像这样的变量信息。通过这样做,我们可以创建少量示例,演示基于上下文的工具使用。

为了使我们的 Agent 能够处理系统提示和用户提示中的变量上下文,我们可以按以下方式更新我们的 Agent 类:

import colorama  
from colorama import Fore  
from openai import OpenAI  
from pydantic import BaseModel  
from tools.base import Tool, ToolResult  
from agents.utils import parse_function_args, run_tool_from_response  

class StepResult(BaseModel):  
    event: str  
    content: str  
    success: bool  

SYSTEM_MESSAGE = """You are tasked with completing specific objectives and must report the outcomes. At your disposal, you have a variety of tools, each specialized in performing a distinct type of task.  

For successful task completion:  
Thought: Consider the task at hand and determine which tool is best suited based on its capabilities and the nature of the work. If you can complete the task or answer a question, soley by the information provided you can use the report_tool directly.  

Use the report_tool with an instruction detailing the results of your work or to answer a user question.  
If you encounter an issue and cannot complete the task:  

Use the report_tool to communicate the challenge or reason for the task's incompletion.  
You will receive feedback based on the outcomes of each tool's task execution or explanations for any tasks that couldn't be completed. This feedback loop is crucial for addressing and resolving any issues by strategically deploying the available tools.  

Return only one tool call at a time.  

{context}  
"""

class OpenAIAgent:  

    def __init__(  
            self,  
            tools: list[Tool],  
            client: OpenAI = OpenAI(),  
            system_message: str = SYSTEM_MESSAGE,  
            model_name: str = "gpt-3.5-turbo-0125",  
            max_steps: int = 5,  
            verbose: bool = True,  
            examples: list[dict] = None,  
            context: str = None,  
            user_context: str = None  
    ):  
        self.tools = tools  
        self.client = client  
        self.model_name = model_name  
        self.system_message = system_message  
        self.step_history = []  
        self.max_steps = max_steps  
        self.verbose = verbose  
        self.examples = examples or []  
        self.context = context or ""  
        self.user_context = user_context  

    def to_console(self, tag: str, message: str, color: str = "green"):  
        if self.verbose:  
            color_prefix = Fore.__dict__[color.upper()]  
            print(color_prefix + f"{tag}: {message}{colorama.Style.RESET_ALL}")  

    def run(self, user_input: str, context: str = None):  

        openai_tools = [tool.openai_tool_schema for tool in self.tools]  
        system_message = self.system_message.format(context=context)  

        if self.user_context:  
            context = f"{self.user_context}\n{context}" if context else self.user_context  

        if context:  
            user_input = f"{context}\n---\n\nUser Message: {user_input}"  

        self.to_console("START", f"Starting Agent with Input:\n'''{user_input}'''")  

        self.step_history = [  
            {"role": "system", "content": system_message},  
            *self.examples,  
            {"role": "user", "content": user_input}  
        ]  

        step_result = None  
        i = 0  

        while i < self.max_steps:  
            step_result = self.run_step(self.step_history, openai_tools)  
            if step_result.event == "finish":  
                break  
            elif step_result.event == "error":  
                self.to_console(step_result.event, step_result.content, "red")  
            else:  
                self.to_console(step_result.event, step_result.content, "yellow")  

            i += 1  

        self.to_console("Final Result", step_result.content, "green")  

        return step_result.content  

    def run_step(self, messages: list[dict], tools):  

        # plan the next step  
        response = self.client.chat.completions.create(  
            model=self.model_name,  
            messages=messages,  
            tools=tools  
        )  
        # check for multiple tool calls  
        if response.choices[0].message.tool_calls and len(response.choices[0].message.tool_calls) > 1:  
            messages = [  
                *self.step_history,  
                {"role": "user", "content": "Error: Please return only one tool call at a time."}  
            ]  
            return self.run_step(messages, tools)  

        # add message to history  
        self.step_history.append(response.choices[0].message)  
        # check if tool call is present  
        if not response.choices[0].message.tool_calls:  
            msg = response.choices[0].message.content  
            step_result = StepResult(event="Error", content=f"No tool calls were returned.\nMessage: {msg}", success=False)  
            return step_result  

        tool_name = response.choices[0].message.tool_calls[0].function.name  
        tool_kwargs = parse_function_args(response)  

        # execute the tool call  
        self.to_console("Tool Call", f"Name: {tool_name}\nArgs: {tool_kwargs}", "magenta")  
        tool_result = run_tool_from_response(response, tools=self.tools)  
        tool_result_msg = self.tool_call_message(response, tool_result)  
        self.step_history.append(tool_result_msg)  

        if tool_name == "report_tool":  
            try:  
                step_result = StepResult(  
                    event="finish",  
                    content=tool_result.content,  
                    success=True  
                )  
            except:  
                print(tool_result)  
                raise ValueError("Report Tool failed to run.")  

            return step_result  

        elif tool_result.success:  
            step_result = StepResult(  
                event="tool_result",  
                content=tool_result.content,  
                success=True)  
        else:  
            step_result = StepResult(  
                event="error",  
                content=tool_result.content,  
                success=False  
            )  

        return step_result  

    def tool_call_message(self, response, tool_result: ToolResult):  
        tool_call = response.choices[0].message.tool_calls[0]  
        return {  
            "tool_call_id": tool_call.id,  
            "role": "tool",  
            "name": tool_call.function.name,  
            "content": tool_result.content,  
        }

相较于我们之前的版本,主要的变化如下:

  • 我们在默认的系统提示中放置了一个“{context}”占位符。

  • 我们将contextuser_context作为输入参数添加到__init__()中。

  • 我们将context添加到run()方法中。

  • run()中,如果定义了context,我们将其添加到用户消息中。

  • 我们还在__init__()中添加了一个examples属性,如果设置了它,将在run()中的系统和用户消息之间传递。

现在我们可以在初始化代理时定义系统上下文和用户上下文。此外,我们还可以在调用 run 方法时传递用户上下文。如果context被传递给 run 方法,它将覆盖初始化时的user_context,只在本次运行中有效。

5.1 向 Agent 提供上下文

在我们能够运行 Agent 之前,让我们定义一个生成上下文信息的函数。我们希望自动生成user_context,然后将其传递给如上所述的 Agent 的 run 函数。为了简单起见,我们希望每个表格的上下文信息仅用一行来表示,内容应该包括:

  • 表格名称

  • 列名称:<type>

经过几次尝试和错误,以下函数可以完成这个任务:

# utils.py
from typing import Type  
import types  
import typing  

import sqlalchemy  
from pydantic import BaseModel

def orm_model_to_string(input_model_cls: Type[BaseModel]):  
    """Get the ORM model string from the input model"""  

    def process_field(key, value):  
        if key.startswith("__"):  
            return None  
        if isinstance(value, typing._GenericAlias):  
            if value.__origin__ == sqlalchemy.orm.base.Mapped:  
                return None  
            if isinstance(value, typing._AnnotatedAlias):  # noqa  
                return key, value.__origin__  
            elif isinstance(value, typing._UnionGenericAlias) or isinstance(value, types.UnionType):  
                return key, value.__args__[0]  
        return key, value  

    fields = dict(filter(None, (process_field(k, v) for k, v in input_model_cls.__annotations__.items())))  
    return ", ".join([f"{k} = <{v.__name__}>" for k, v in fields.items()])

def generate_context(*table_models) -> str:
   context_str = "You can access the following tables in database:\n"
   for table in table_models:
    context_str += f" - {table.__name__}: {orm_model_to_string(table)}\n" 
   return context_str

如果我们将ExpenseRevenue传递给generate_context(),我们应该得到以下上下文字符串:

我们希望 Agent 知道当前的日期和星期几,以便我们可以引用正确的日期。因此,接下来我们将在工具类中添加一些日期解析函数:

# utils.py
from datetime import datetime

#... rest of utils.py ...

def weekday_by_date(date: datetime):
    days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
    return days[date.weekday()]

def date_to_string(date: datetime):
    return f"{weekday_by_date(date)} {parse_date(date)}"

def parse_date(date: datetime):
    return date.strftime("%Y-%m-%d")

现在让我们为查询代理创建上下文

# utils.py

# ...

def generate_query_context(*table_models) -> str:
    today = f"Today is {date_to_string(datetime.now())}"
    context_str = "You can access the following tables in database:\n"
    for table in table_models:
        context_str += f" - {table.__name__}: {orm_model_to_string(table)}\n" 
    return f"{today}\n{context_str}"
from database.models import Expense, Revenue
print(generate_query_context(Expense, Revenue))
Today is Sunday 2024-04-21
You can access the following tables in database:
 - Expense: id = <int>, description = <str>, net_amount = <float>, gross_amount = <float>, tax_rate = <float>, date = <datetime>
 - Revenue: id = <int>, description = <str>, net_amount = <float>, gross_amount = <float>, tax_rate = <float>, date = <datetime>

5.2 路由 Agent

随着我们添加更多的工具,我们的设置复杂性可能会开始限制像“gpt-3.5-turbo”这样的便宜模型的可用性。在下一篇文章中,我们可能会考虑切换到 Anthropic Claude,因为它们新发布的工具使用 API 功能似乎在同时处理多个工具时很有前景,甚至适用于更便宜的 HAIKU 模型。然而,目前我们将继续使用 OpenAI 的 GPT 模型。

在进行个人使用的开发并创建生产就绪应用程序之前,我发现优化工作流程以适应较小的模型(如本例中的gpt-3.5-turbo)是非常有用的。这种方法迫使我们创建一个精简的处理逻辑和提示系统。虽然我们可能无法在不使用最强大模型的情况下实现 100%的可靠性,但我们能够发现缺陷并识别不清晰的指令。如果您的应用在 10 次中有 9 次能在较小模型下正常工作,您就拥有了一套生产就绪的逻辑,并且使用更强大的模型时,系统的表现会更好。

为了使多工具处理在gpt-3.5-turbo下更可靠,我们将实现一个路由代理,它的唯一目的是将用户查询路由到适当的任务代理。这使我们能够分离执行逻辑,减少复杂性。每个代理将有一个有限的范围,使我们能够在未来分离访问角色和操作。我观察到即使在使用 gpt-4 时,也会出现代理不知道何时任务完成的情况。

通过引入路由代理,我们可以将问题分解成更小、更易管理的部分。路由代理将负责理解用户的意图,并将查询引导到相关的任务代理。这种方法不仅简化了各个代理的职责,还使系统更加模块化,更易于维护。

此外,分离执行逻辑和复杂性将为未来实现基于角色的访问控制铺平道路。每个任务代理可以被分配特定的权限和访问级别,确保敏感操作仅由授权代理执行。

虽然路由代理在流程中增加了一个额外的步骤,但它最终会导致一个更强大、更具可扩展性的系统。通过优化较小的模型并专注于清晰、简洁的提示,我们可以创建一个坚实的基础,在切换到更强大的模型如 Claude Opus 或 GPT-4 时,系统的性能会更好。

让我们来看看路由代理的实现

# agents/routing.py
from openai import OpenAI
import colorama
from agents.task_agent import TaskAgent
from agents.utils import parse_function_args

SYSTEM_MESSAGE = """You are a helpful assistant.
Role: You are an AI Assistant designed to serve as the primary point of contact for users interacting through a chat interface. 
Your primary role is to understand users' requests related to database operations and route these requests to the appropriate tool.

Capabilities: 
You have access to a variety of tools designed for Create, Read operations on a set of predefined tables in a database. 

Tables:
{table_names}
"""

NOTES = """Important Notes:
Always confirm the completion of the requested operation with the user.
Maintain user privacy and data security throughout the interaction.
If a request is ambiguous or lacks specific details, ask follow-up questions to clarify the user's needs."""

class RoutingAgent:

    def __init__(
            self,
            tools: list[TaskAgent] = None,
            client: OpenAI = OpenAI(),
            system_message: str = SYSTEM_MESSAGE,
            model_name: str = "gpt-3.5-turbo-0125",
            max_steps: int = 5,
            verbose: bool = True,
            prompt_extra: dict = None,
            examples: list[dict] = None,
            context: str = None
    ):
        self.tools = tools or ROUTING_AGENTS
        self.client = client
        self.model_name = model_name
        self.system_message = system_message
        self.memory = []
        self.step_history = []
        self.max_steps = max_steps
        self.verbose = verbose
        self.prompt_extra = prompt_extra or PROMPT_EXTRA
        self.examples = self.load_examples(examples)
        self.context = context or ""

    def load_examples(self, examples: list[dict] = None):
        examples = examples or []
        for agent in self.tools:
            examples.extend(agent.routing_example)
        return examples

    def run(self, user_input: str, employee_id: int = None, **kwargs):
        context = create_routing_agent_context(employee_id)
        if context:
            user_input_with_context = f"{context}\n---\n\nUser Message: {user_input}"
        else:
            user_input_with_context = user_input

        self.to_console("START", f"Starting Task Agent with Input:\n'''{user_input_with_context}'''")
        partial_variables = {**self.prompt_extra, "context": context}
        system_message = self.system_message.format(**partial_variables)

        messages = [
            {"role": "system", "content": system_message},
            *self.examples,
            {"role": "user", "content": user_input}
        ]

        tools = [tool.openai_tool_schema for tool in self.tools]

        response = self.client.chat.completions.create(
            model=self.model_name,
            messages=messages,
            tools=tools
        )
        self.step_history.append(response.choices[0].message)
        self.to_console("RESPONSE", response.choices[0].message.content, color="blue")
        tool_kwargs = parse_function_args(response)
        tool_name = response.choices[0].message.tool_calls[0].function.name
        self.to_console("Tool Name", tool_name)
        self.to_console("Tool Args", tool_kwargs)

        agent = self.prepare_agent(tool_name, tool_kwargs)
        return agent.run(user_input)

    def prepare_agent(self, tool_name, tool_kwargs):
        for agent in self.tools:
            if agent.name == tool_name:
                input_kwargs = agent.arg_model.model_validate(tool_kwargs)
                return agent.load_agent(**input_kwargs.dict())
        raise ValueError(f"Agent {tool_name} not found")

    def to_console(self, tag: str, message: str, color: str = "green"):
        if self.verbose:
            color_prefix = colorama.Fore.__dict__[color.upper()]
            print(color_prefix + f"{tag}: {message}{colorama.Style.RESET_ALL}")

与我们的OpenAIAgent相比,最大的区别在于:

  • 无开环:我们希望路由代理将用户的查询路由到适当的代理。因此,我们通过工具调用选择所需的代理,并将用户查询传递给它,而不是创建一个开环。路由代理不应该执行其他任务或后续问题。

  • 代理作为工具:与其称一个工具为路由代理,不如设置一个子代理。因此,我们之前定义的OpenAIAgent现在成为路由代理中的一个工具。

5.3 代理作为工具——任务代理

为了将我们的OpenAIAgent作为工具使用,我们需要引入某种专门为代理设计的工具类。我们希望为每个代理定义一个名称和描述,并自动化初始化过程。因此,我们为本教程定义了最后一个类——TaskAgent

TaskAgent类的功能与Tool类类似。我们定义了一个名称、描述和一个输入模型,我们称之为arg_model

from typing import Type, Callable, Optional

from agents.base import OpenAIAgent
from tools.base import Tool
from tools.report_tool import report_tool
from pydantic import BaseModel, ConfigDict, Field

from tools.utils import convert_to_openai_tool

SYSTEM_MESSAGE = """You are tasked with completing specific objectives and must report the outcomes. At your disposal, you have a variety of tools, each specialized in performing a distinct type of task.

For successful task completion:
Thought: Consider the task at hand and determine which tool is best suited based on its capabilities and the nature of the work. 
If you can complete the task or answer a question, soley by the information provided you can use the report_tool directly.

Use the report_tool with an instruction detailing the results of your work or to answer a user question.
If you encounter an issue and cannot complete the task:

Use the report_tool to communicate the challenge or reason for the task's incompletion.
You will receive feedback based on the outcomes of each tool's task execution or explanations for any tasks that couldn't be completed. This feedback loop is crucial for addressing and resolving any issues by strategically deploying the available tools.

On error: If information are missing consider if you can deduce or calculate the missing information and repeat the tool call with more arguments.

Use the information provided by the user to deduct the correct tool arguments.
Before using a tool think about the arguments and explain each input argument used in the tool. 
Return only one tool call at a time! Explain your thoughts!
{context}
"""

class EmptyArgModel(BaseModel):
    pass

class TaskAgent(BaseModel):
    name: str
    description: str
    arg_model: Type[BaseModel] = EmptyArgModel

    create_context: Callable = None
    create_user_context: Callable = None
    tool_loader: Callable = None

    system_message: str = SYSTEM_MESSAGE
    tools: list[Tool]
    examples: list[dict] = None
    routing_example: list[dict] = Field(default_factory=list)

    model_config = ConfigDict(arbitrary_types_allowed=True)

    def load_agent(self, **kwargs) -> OpenAIAgent:

        input_kwargs = self.arg_model(**kwargs)
        kwargs = input_kwargs.dict()

        context = self.create_context(**kwargs) if self.create_context else None
        user_context = self.create_user_context(**kwargs) if self.create_user_context else None

        if self.tool_loader:
            self.tools.extend(self.tool_loader(**kwargs))

        if report_tool not in self.tools:
            self.tools.append(report_tool)

        return OpenAIAgent(
            tools=self.tools,
            context=context,
            user_context=user_context,
            system_message=self.system_message,
            examples=self.examples,
        )

    @property
    def openai_tool_schema(self):
        return convert_to_openai_tool(self.arg_model, name=self.name, description=self.description)

此外,我们将所有相关属性添加到我们的TaskAgent类中,这是我们需要为底层专用OpenAIAgent所做的:

  • create_context / create_user_context:在这里,我们可以传递一个函数来创建上下文或用户上下文,就像在第 5.1 节中一样。

  • tool_loader是另一个可调用函数,我们可能需要它来设置底层代理。正如我们之前解释的动态工具构建,我们可能需要根据用户输入或路由代理输入动态构建的工具。

  • system_message是代理的系统提示。在我们的示例中,它将是每个代理的默认系统提示,但它也可以是每个专门代理的优化版本。

  • tools:代理应该使用的预定义工具。

  • examples:包括在子代理消息历史中的示例。

  • routing_example:包括在路由代理消息历史中的示例。

此外,我们有一个空的 BaseModel,叫做EmptyArgModel,它是我们TaskAgent中的默认arg_model

作者创建:mermaid

让我们看看它是否都能协同工作!

运行代理

现在,是时候测试我们的路由和子代理是否能够很好地协作。由于我们引入了作为参数的示例,我们可以通过多次测试运行来检查执行中的主要缺陷,并为每个子代理定义示例用法。

让我们先定义我们的子代理:

from database.models import Expense, Revenue, Customer
from agents.task import TaskAgent
from utils import generate_query_context

from tools.base import Tool
from tools.query import query_data_tool
from tools.add import add_entry_to_table

query_task_agent = TaskAgent(
    name="query_agent",
    description="An agent that can perform queries on multiple data sources",
    create_user_context=lambda: generate_query_context(Expense, Revenue, Customer),
    tools=[query_data_tool]
)

add_expense_agent = TaskAgent(
    name="add_expense_agent",
    description="An agent that can add an expense to the database",
    create_user_context=lambda: generate_query_context(Expense) + "\nRemarks: The tax rate is 0.19\. The user provide the net amount you need to calculate the gross amount.",
    tools=[
        Tool(
            name="add_expense",
            description="Add an expense to the database",
            function=add_entry_to_table(Expense),
            model=Expense
        )
    ]
)

add_revenue_agent = TaskAgent(
    name="add_revenue_agent",
    description="An agent that can add a revenue entry to the database",
    create_user_context=lambda: generate_query_context(Revenue) + "\nRemarks: The tax rate is 0.19\. The user provide the gross_amount you should use the tax rate to calculate the net_amount.",
    tools=[
        Tool(
            name="add_revenue",
            description="Add a revenue entry to the database",
            function=add_entry_to_table(Revenue),
            model=Revenue
        )
    ]
)

add_customer_agent = TaskAgent(
    name="add_customer_agent",
    description="An agent that can add a customer to the database",
    create_user_context=lambda: generate_query_context(Customer),
    tools=[
        Tool(
            name="add_customer",
            description="Add a customer to the database",
            function=add_entry_to_table(Customer),
            model=Customer
        )
    ]
)

正如您所看到的,我们为收入和支出代理添加了一些备注字符串到create_user_context。我们希望子代理能够处理税率,并自动计算净额或毛额,以测试我们的子代理的推理能力。

from agents.routing import RoutingAgent

routing_agent = RoutingAgent(
    tools=[
        query_task_agent,
        add_expense_agent,
        add_revenue_agent,
        add_customer_agent
    ]
)

routing_agent.run("I have spent 5 € on a office stuff. Last Thursday")
START: Starting Routing Agent with Input:
I have spent 5 € on a office stuff. Last Thursday

Tool Name: add_expense_agent
Tool Args: {}

START: Starting Task Agent with Input:
"""Today is Sunday 2024-04-21
You can access the following tables in database:
 - expense: id = <int>, description = <str>, net_amount = <float>, gross_amount = <float>, tax_rate = <float>, date = <datetime>

Remarks: The tax rate is 0.19\. The user provide the net amount you need to calculate the gross amount.
---
User Message: I have spent 5 € on a office stuff. Last Thursday"""

Tool Call: Name: add_expense
Args: {'description': 'office stuff', 'net_amount': 5, 'tax_rate': 0.19, 'date': '2024-04-18'}
Message: None
error: Missing values: gross_amount

Tool Call: Name: add_expense
Args: {'description': 'office stuff', 'net_amount': 5, 'tax_rate': 0.19, 'date': '2024-04-18', 'gross_amount': 5.95}
Message: None
tool_result: Successfully added net_amount=5.0 id=2 gross_amount=5.95 description='office stuff' date=datetime.datetime(2024, 4, 18, 0, 0) tax_rate=0.19 to the table

Error: No tool calls were returned.
Message: I have successfully added the expense for office stuff with a net amount of 5€, calculated the gross amount, and recorded it in the database.

Tool Call: Name: report_tool
Args: {'report': 'Expense for office stuff with a net amount of 5€ has been successfully added. Gross amount calculated as 5.95€.'}
Message: None

Final Result: Expense for office stuff with a net amount of 5€ has been successfully added. Gross amount calculated as 5.95€.

现在让我们添加一笔收入:

routing_agent.run("Two weeks ago on Saturday we had a revenue of 1000 € in the shop")
START: Starting Routing Agent with Input:
Two weeks ago on Saturday we had a revenue of 1000 € in the shop

Tool Name: add_revenue_agent
Tool Args: {}

START: Starting Task Agent with Input:
"""Today is Sunday 2024-04-21
You can access the following tables in database:
 - revenue: id = <int>, description = <str>, net_amount = <float>, gross_amount = <float>, tax_rate = <float>, date = <datetime>

Remarks: The tax rate is 0.19\. The user provide the gross_amount you should use the tax rate to calculate the net_amount.
---
User Message: Two weeks ago on Saturday we had a revenue of 1000 € in the shop"""

Tool Call: Name: add_revenue
Args: {'description': 'Revenue from the shop', 'gross_amount': 1000, 'tax_rate': 0.19, 'date': '2024-04-06'}
Message: None
error: Missing values: net_amount

Tool Call: Name: add_revenue
Args: {'description': 'Revenue from the shop', 'gross_amount': 1000, 'tax_rate': 0.19, 'date': '2024-04-06', 'net_amount': 840.34}
Message: None
tool_result: Successfully added net_amount=840.34 gross_amount=1000.0 tax_rate=0.19 description='Revenue from the shop' id=1 date=datetime.datetime(2024, 4, 6, 0, 0) to the table

Error: No tool calls were returned.
Message: The revenue entry for the shop on April 6, 2024, with a gross amount of 1000€ has been successfully added to the database. The calculated net amount after applying the tax rate is 840.34€.

Tool Call: Name: report_tool
Args: {'report': 'completed'}
Message: None

Final Result: completed

对于最后的测试,让我们尝试查询从数据库中创建的收入:

routing_agent.run("How much revenue did we made this month?")
START: Starting Routing Agent with Input:
How much revenue did we made this month?

Tool Name: query_agent
Tool Args: {}

START: Starting Agent with Input:
"""Today is Sunday 2024-04-21
You can access the following tables in database:
 - expense: id = <int>, description = <str>, net_amount = <float>, gross_amount = <float>, tax_rate = <float>, date = <datetime>
 - revenue: id = <int>, description = <str>, net_amount = <float>, gross_amount = <float>, tax_rate = <float>, date = <datetime>
 - customer: id = <int>, company_name = <str>, first_name = <str>, last_name = <str>, phone = <str>, address = <str>, city = <str>, zip = <str>, country = <str>

---

User Message: How much revenue did we made this month?"""

Tool Call: Name: query_data_tool
Args: {'table_name': 'revenue', 'select_columns': ['gross_amount'], 'where': [{'column': 'date', 'operator': 'gte', 'value': '2024-04-01'}, {'column': 'date', 'operator': 'lte', 'value': '2024-04-30'}]}
Message: None
tool_result: content="Query results: ['1000.0']" success=True

Error: No tool calls were returned.
Message: The revenue made this month is $1000.00.

Tool Call: Name: report_tool
Args: {'report': 'The revenue made this month is $1000.00.'}
Message: None

Final Result: The revenue made this month is $1000.00.

所有工具都按预期工作。路由代理运行得非常完美。对于任务代理,我不得不多次更新提示。

我建议在没有使用像 gpt-4 这样的最新模型时,为每个任务代理添加一些示例工具调用。一般来说,我建议通过示例和更直观的设计来解决缺陷,而不是使用提示工程。重复出现的缺陷是设计不够直观的信号。例如,当代理在计算毛额或净额时遇到困难,只需添加一个‘calculate_gross_amount_tool’或‘calculate_net_amount_tool’。另一方面,GPT-4 会毫不犹豫地处理这种用例。

结论

在本文中,我们在创建一个全面的基于聊天的界面以管理小型企业的过程中迈出了重要的一步,利用大型语言模型(Large Language Models)。

通过设置我们的数据库模式、定义核心功能和构建项目仓库,我们为应用程序的开发奠定了坚实的基础。

我们从使用 SQLModel 设计数据库模型开始,这使我们能够无缝地与 Pydantic 和 SQLAlchemy 集成。该方法确保了高效的数据验证和数据库操作,同时最大限度地减少了 SQL 注入攻击的风险。

然后,我们更新了我们的 Tool 类,以处理 SQLModel 实例并改进验证过程。接着,我们实现了 SQL 工具,用于向我们的数据库表中添加数据,并使用自然语言命令查询数据。通过利用 SQLModel 和 Pydantic 的力量,我们能够创建一个强大而灵活的系统,能够处理各种用户输入并生成准确的 SQL 查询。

我们配置了 OpenAIAgent,以通过更新代理类来处理系统提示和用户提示中的可变上下文,从而提供上下文感知的工具使用。这使得我们的代理能够理解可用的表及其模式,从而实现更准确和高效的工具使用。虽然我们已经取得了显著进展,但仍有很多内容需要探索和实现。

为了进一步增强我们的聊天机器人,我们引入了 TaskAgent 类,它具有类似 Tool 类的功能。TaskAgent 允许我们为每个代理定义名称、描述和输入模型,自动化初始化过程。

最后,我们通过为查询数据、添加支出、增加收入定义子代理来测试我们的路由和子代理。我们展示了代理如何处理税率并自动计算净额或毛额,展示了我们子代理的推理能力。

下一步

在本系列的下一部分,我们将重点通过添加对更多工具的支持,并可能将 Claude 测试作为新的默认语言模型,来增强我们代理的能力。我们还将探索将我们的应用程序与流行的通讯平台(如 WhatsApp)集成,使其更加易于访问和用户友好。

随着我们不断完善和扩展应用程序,可能性是无穷无尽的。通过利用大语言模型的力量并创建直观的基于聊天的界面,我们可以彻底改变小型企业管理数据和简化操作的方式。敬请期待本系列的下一部分!

源代码

此外,所有涉及的项目源代码都可以在 GitHub 上找到。您可以通过 github.com/elokus/ArticleDemo2 访问它。

使用 Burr 构建邮件助手应用程序

原文:towardsdatascience.com/building-an-email-assistant-application-with-burr-324bc34c547d?source=collection_archive---------4-----------------------#2024-04-25

本教程演示了如何使用 Burr,通过简单的 OpenAI 客户端调用 GPT4,以及使用 FastAPI 来创建一个定制的邮件助手智能体。

Stefan KrawczykTowards Data Science Stefan Krawczyk

·发表于 Towards Data Science ·12 分钟阅读·2024 年 4 月 25 日

--

我们将创建的智能体应用程序的控制流程。图像由作者提供。

在本教程中,我将演示如何使用 Burr,一个开源框架(披露:我参与了它的创建),通过简单的 OpenAI 客户端调用 GPT4,以及 FastAPI 来创建一个定制的邮件助手智能体。我们将描述面对的挑战,然后讲解如何解决这些问题。对于应用的前端,我们提供一个参考实现,但不会深入细节。

为什么交互式智能体应用程序是一项挑战?

大型语言模型(LLMs)很少能单独完成复杂的任务,而且几乎从未在第一次尝试时成功。虽然目前流行一种说法,认为如果给 ChatGPT 连接互联网,它可以解决世界上的所有问题,但我们遇到的大多数高价值工具都使用了 AI 的创新和人类的引导。这是构建智能体(agent)的普遍趋势——一种由 AI 根据收到的信息做出决策的方法——这些信息可以是它查询的内容、用户提供的信息,或者是另一个大型语言模型提供的信息。

一个简单的例子是一个帮助你起草邮件回复的工具。你提供邮件内容和你的回复目标,它会为你写出回复内容。至少,你需要提供反馈,这样它才能调整回复内容。此外,你还希望它能有机会提出澄清性问题(一个过于自信但错误的聊天机器人对任何人都没有帮助)。

在设计这个交互时,你的系统将不可避免地成为用户/LLM 控制之间的反复往返。除了 AI 应用程序的标准挑战(不可靠的 API、随机实现等),你还将面临一系列新的问题,包括:

  1. 合理建模一组交互点/流程

  2. 持久化状态,以便用户可以从中断的地方继续交互/应用程序

  3. 监控 LLM 做出的决策(例如,是否向用户提问)

以此类推……在本文中,我们将讲解如何解决这些问题——我们将使用 Burr 库和 FastAPI 构建一个 Web 服务,以可扩展、模块化的方式解决这些挑战;这样你就可以将其作为自己代理助手需求的蓝图。

工具

Burr

Burr是一个轻量级的 Python 库,用于构建状态机应用程序。你可以通过一系列操作(这些可以是装饰函数或对象)构建你的应用程序,这些操作声明来自状态的输入,以及来自用户的输入。这些操作指定自定义逻辑(可以委托给任何框架),以及如何更新状态的指令。状态是不可变的,这使得你可以在任何时候检查它。Burr 处理编排、监控和持久化。

@action(reads=["counter"], writes=["counter"])
def count(state: State) -> Tuple[dict, State]:
    current = state["counter"] + 1
    result = {"counter": current}
    return result, state.update(counter=counter)

请注意,上述操作有两个返回值——结果(计数器),以及新的、修改后的状态(计数器字段已增加)。

你可以将 Burr 操作作为应用程序的一部分运行——这使得你可以将它们通过一系列(可选的)条件过渡串联起来。

from burr.core import ApplicationBuilder, default, expr
app = (
    ApplicationBuilder()
    .with_state(counter=0) # initialize the count to zero
    .with_actions(
        count=count, 
        done=done # implementation left out above
    ).with_transitions(
        ("count", "count", expr("counter < 10")), # Keep counting if the counter is less than 10
        ("count", "done", default) # Otherwise, we're done
    ).with_entrypoint("count") # we have to start somewhere
    .build()
)

Burr 提供了一个用户界面,支持监控/遥测功能,并提供钩子来在执行过程中持久化状态/执行任意代码。

你可以将其可视化为一个流程图,即图形/状态机:

我们应用程序的图像,由 Burr 生成。图像来自作者。

并使用本地遥测调试器进行监控:

Burr 自带 UI——这是我们检查计数器示例运行时的界面。图像来自作者。

虽然我们在上面展示了(非常简单的)计数器示例,Burr 通常用于构建聊天机器人/代理(我们将在本文中展示一个示例)。

FastAPI

FastAPI是一个框架,可以让你将 Python 函数暴露为 REST API。它有一个简单的接口——你编写你的函数并装饰它们,然后运行你的脚本——将其转换为一个具有自文档化端点的服务器,通过OpenAPI实现。

@app.get("/")
def read_root():
    return {"Hello": "World"}

@app.get("/items/{item_id}")
def read_item(item_id: int, q: Union[str, None] = None):
    """A very simpler example of an endpoint that takes in arguments."""
    return {"item_id": item_id, "q": q}

FastAPI 易于在任何云提供商上部署——它是基础设施无关的,通常可以横向扩展(只要考虑到状态管理)。有关更多信息,请参阅此页面

React(或任何前端框架)

你可以使用任何前端框架——然而,基于 React 的工具具有天然优势,因为它将一切建模为状态的函数,这可以与 Burr 中的概念 1:1 映射。在演示应用程序中,我们使用了reactreact-querytailwind,但我们将大部分跳过这部分内容(因为它与文章的主要目的无关)。

构建

让我们更深入地探讨一下概念模型。从高层次来看,我们的电子邮件助手将执行以下操作:

  1. 接受电子邮件和回应指令

  2. 提出一组澄清性问题(如果 LLM 认为有必要)

  3. 使用这些问题的答案生成草稿

  4. 接受反馈并生成另一个草稿,重复此过程直到用户满意

  5. 返回最终草稿(完成)

控制流建模

由于 Burr 要求你从动作过渡中构建控制流,我们最初可以将其建模为一个简单的流程图。

我们应用程序的外观。图片由作者提供。

我们在实际编写任何代码之前就已草拟了这个——你会看到它自然地转化为代码。

绿色节点代表动作(这些操作接收状态并修改它),蓝色节点代表输入(这些是应用程序必须暂停并向用户询问信息的点)。请注意,存在一个循环(formulate_draft ⇔process_feedback)——我们根据反馈进行迭代,直到对结果满意为止。

该图表仅是 Burr 所展示内容的样式化版本——建模应该尽量接近实际代码。我们没有显示状态信息(步骤中传入/返回的数据),但我们需要跟踪以下内容(这些内容在任何给定时刻可能会被填充,也可能不会),以便我们做出下一步决策:

  1. 初始输入:{email_to_respond: str, response_instructions: str}

  2. LLM 提出的问题和用户的回答(如果有的话):{clarifications: list[str], response_instructions: list[str]}

  3. 草稿和反馈的列表:{drafts: list[str], feedback_history: list[str]}

  4. 最终结果:{final_result: str}

实施/测试

根据上述需求,我们可以构建一个简单的 Burr 应用程序,因为我们的代码可以与上面的图表紧密匹配。举个例子,我们来看一下determine_clarifications步骤:

@action(
    reads=["response_instructions", "incoming_email"], 
    writes=["clarification_questions"]
)
def determine_clarifications(state: State) -> Tuple[dict, State]:
    """Determines if the response instructions require clarification."""

    incoming_email = state["incoming_email"]
    response_instructions = state["response_instructions"]
    client = _get_openai_client()

    result = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {
                "role": "system",
                "content": ("You are a chatbot that has the task of "
                            "generating responses to an email on behalf "
                            "of a user. "),
            },
            {
                "role": "user",
                "content": (
                    f"The email you are to respond to is: {incoming_email}."
                     # ... left out, see link above
                    "The questions, joined by newlines, must be the only "
                    "text you return. If you do not need clarification, "
                    "return an empty string."
                ),
            },
        ],
    )
    content = result.choices[0].message.content
    all_questions = content.split("\n") if content else []
    return {"clarification_questions": all_questions}, state.update(
        clarification_questions=all_questions)

请注意,这使用的是简单的 OpenAI 调用——如果你希望更抽象一些,你可以将其替换为LangchainLlamaIndexHamilton(或其他类似工具),并委托你喜欢使用的 LLM。而且,你应该可能使用一些更具体的工具(例如instructor)来保证输出格式。

为了将这些结合在一起,我们将它们放入应用构建器中——这使我们能够设置条件转移(例如len(clarification_questions>0)),从而连接不同的动作,重现上面的图表。

application = (
    ApplicationBuilder()
    # define our actions
    .with_actions(
        process_input,
        determine_clarifications,
        clarify_instructions,
        formulate_draft,
        process_feedback,
        final_result,
    )
    # define how our actions connect
    .with_transitions(
        ("process_input", "determine_clarifications"),
        (
            "determine_clarifications",
            "clarify_instructions",
            expr("len(clarification_questions) > 0"),
        ),
        ("determine_clarifications", "formulate_draft"),
        ("clarify_instructions", "formulate_draft"),
        ("formulate_draft", "process_feedback"),
        ("process_feedback", "formulate_draft", expr("len(feedback) > 0")),
        ("process_feedback", "final_result"),
    )
    .with_state(draft_history=[])
    .with_entrypoint("process_input")
    .build()
)

为了进行迭代,我们使用了一个jupyter notebook. 运行我们的应用非常简单——你只需要在 Application 上调用.run()方法,并设置合适的停止条件。我们希望它在任何需要用户输入的动作之前停止(clarify_instructionsprocess_feedback),以及在final_result之后停止。然后,我们可以在一个 while 循环中运行它,要求用户输入并将其反馈给状态机:

def request_answers(questions):
    """Requests answers from the user for the questions the LLM has"""
    answers = []
    print("The email assistant wants more information:\n")
    for question in questions:
        answers.append(input(question))
    return answers

def request_feedback(draft):
    """Requests feedback from the user for a draft"""
    print( 
        f"here's a draft!: \n {draft} \n \n What feedback do you have?",
    )
    return input("Write feedback or leave blank to continue (if you're happy)")
inputs = {
    "email_to_respond" : EMAIL,
    "response_instructions" : INSTRUCTIONS
}

# in our notebook cell:
while True:
    action, result, state = app.run(
        halt_before=["clarify_instructions", "process_feedback"], 
        halt_after=["final_result"],
        inputs=inputs
    )
    if action.name == "clarify_instructions":
        questions = state["clarification_questions"]
        answers = request_answers(questions)
        inputs = {
            "clarification_inputs" : answers
        }
    if action.name == "process_feedback":
        feedback = request_feedback(state["current_draft"])
        inputs = {"feedback" : feedback}
    if action.name == "final_result":
        print("final result is:", state["current_draft"])
        break

然后,你可以使用 Burr UI 来监控应用的运行!

使用 Burr UI(结合电子邮件应用 UI)并查看其执行情况的示例。图像由作者提供。

持久化

我们将把结果保存到一个 SQLite 服务器中(虽然正如你稍后看到的,这可以自定义)。为此,我们需要向 ApplicationBuilder 中添加几行代码。

state_persister = SQLLitePersister(
    db_path="sqllite.db", 
    table_name="email_assistant_table"
)

app = (
    ApplicationBuilder().
    ... # the code we had above
    .initialize(  
        initializer=state_persister,
        resume_at_next_action=True,
        default_state={"chat_history" : []},
        default_entrypoint="process_input"
    )
    .with_identifiers(app_id=app_id)
    .build()
)

这确保了我们创建的每个电子邮件草稿都会被保存,并且可以在每个步骤中加载。当你想恢复之前的电子邮件草稿时,你只需要重新运行代码,它将从上次停止的地方继续。

集成到 Web 服务器中

为了在 Web 服务器中暴露这些端点,我们将使用 FastAPI 来创建端点,并使用 Pydantic 来表示类型。在深入细节之前,我们需要注意,Burr 自然为每个应用实例提供了一个application_id(可以是生成的或指定的)。在这种情况下,application_id对应于某个特定的电子邮件草稿。这使得我们能够唯一地访问它,从数据库中查询等……它还支持分区键(例如 user_id),这样你可以在数据库中添加额外的索引。我们将 API 围绕输入/输出进行设计。

端点

我们将构建以下端点:

  1. POST /create: 这将创建一个新应用并返回 ID

  2. PUT /initialize_draft/{id}/: 这会调用 process_input,传入电子邮件和指令

  3. PUT /clarify_instructions/{id}: 这将把答案返回给 LLM

  4. PUT /process_feedback/{id}: 这将把反馈返回给 LLM

  5. GET /{id}/state: 这将返回应用程序的当前状态

GET 端点允许我们获取应用的当前状态——这使得用户可以在退出浏览器或被分心后重新加载。每个端点将返回应用的完整状态,可以在前端渲染。此外,它将指示我们调用的下一个 API 端点,从而让用户界面渲染正确的表单并提交到正确的端点。

使用 FastAPI + Pydantic,这变得非常简单。首先,我们添加一个工具来获取应用对象。它将使用缓存的版本或实例化它:

@functools.lru_cache(maxsize=128)
def get_application(app_id: str) -> Application:
    app = email_assistant_application.application(app_id=app_id)
    return app

这仅仅是调用我们在 email_assistant 中的应用函数,该函数重新创建了应用。我们没有在这里包含create函数,但它会调用相同的 API。

数据模型

然后我们定义一个 Pydantic 模型来表示状态,以及 FastAPI 中的应用对象:

class EmailAssistantState(pydantic.BaseModel):
    app_id: str
    email_to_respond: Optional[str]
    response_instructions: Optional[str]
    questions: Optional[List[str]]
    answers: Optional[List[str]]
    drafts: List[str]
    feedback_history: List[str]
    final_draft: Optional[str]
    # This stores the next step, which tells the frontend which ones to call
    next_step: Literal[
        "process_input", "clarify_instructions", 
        "process_feedback", None]

    @staticmethod
    def from_app(app: Application):
        # implementation left out, call app.state and translate to 
        # pydantic model we can use `app.get_next_action()` to get 
        #the next step and return it to the user
        ...

注意,每个端点将返回相同的 Pydantic 模型!

端点

由于每个端点返回相同的内容(当前状态的表示以及要执行的下一步),它们看起来都是一样的。我们可以首先实现一个通用的run_through函数,它将推动我们的状态机前进,并返回状态。

def run_through(
    project_id: str, 
    app_id: Optional[str], 
    inputs: Dict[str, Any]
) -> EmailAssistantState:
    email_assistant_app = get_application(project_id, app_id)
    email_assistant_app.run(
        halt_before=["clarify_instructions", "process_feedback"],
        halt_after=["final_result"],
        inputs=inputs,
    )
    return EmailAssistantState.from_app(email_assistant_app)

这代表了一个简单但强大的架构。我们可以继续调用这些端点,直到达到“终端”状态,在此时我们可以随时请求状态。如果我们决定添加更多的输入步骤,我们可以修改状态机并添加更多的输入步骤。我们不需要在应用中保持状态(所有状态都委托给 Burr 的持久化),因此我们可以轻松地从任何给定的点加载,允许用户在继续之前等待几秒钟、几分钟、几小时甚至几天。

由于前端只是根据当前状态和下一步渲染,它始终是正确的,用户总是可以从上次离开的地方继续。通过 Burr 的遥测功能,你可以调试任何与状态相关的问题,确保流畅的用户体验。

添加用户界面

现在我们有了一组端点,用户界面很简单。事实上,它几乎与 API 完全一致。我们不会深入探讨这一点,但总体思路是你将需要以下功能:

  1. 渲染当前状态(显示历史记录,最新草稿)

  2. 为下一步的输入操作包含一个表单(提供反馈,回答澄清问题)

  3. 将结果发布到你的 FastAPI 端点,暂停等待响应,转到(1)

你可以在这里查看用户界面。以下是它运行时的一个示例:

如果你下载了 burr(pip install “burr[start]” && burr),并导航到localhost:7241/demos/email-assistant,你可以随意尝试。

请注意,有 许多 工具使得原型制作更简单/更容易,包括 chainlitstreamlit 等等……我们构建的后端 API 也可以与这些工具进行交互。

额外功能

自定义持久化

虽然我们使用了简单的 SQLLite 持久化方式,但你可以使用 Burr 提供的其他持久化方法,或者实现自己的持久化方式,以匹配你的架构/数据库基础设施。为此,你需要实现 BaseStatePersister 类,并将其与 ApplicationBuilder 一起使用,而不是我们上面使用的 SQLLite 持久化方式。

额外的监控/可视化

使用 Burr UI 进行监控并不是唯一的方法。你可以通过利用 生命周期钩子 来集成自己的监控方式,允许你以自定义格式记录数据,并将其发送到比如 datadoglangsmithlangfuse

此外,你还可以利用额外的监控功能来跟踪跨度/追踪,直接记录到 Burr UI 或上述任何提供商。查看可用钩子列表 这里

异步/流式处理

虽然为了简化,我们暴露的 API 是同步的,但 Burr 也支持异步执行。Burr 还支持流式响应,以便为那些想要提供更互动 UI/减少 首次响应时间 的用户提供支持。

那么它在实践中如何运作呢?

与任何 LLM 应用一样,整个提示是至关重要的。如果你能提供正确的指导,结果会比不提供指导时更好。就像你给人类下达指令一样,更多的指导总是更好。也就是说,如果你总是在修正某些方面,那么改变基本的提示可能是最好的解决方案。例如,使用 单次或少次示例 方法可能是一个不错的选择,帮助指导 LLM 明确你希望根据特定上下文看到的内容。

文章总结

在本文中,我们讨论了如何解决构建人机协作代理工作流的一些挑战。我们演示了如何使用 Burr 构建并运行一个状态机的电子邮件助手示例,并使用 FastAPI 在 Web 服务中运行 Burr。最后,我们展示了如何扩展我们在这里使用的工具,以满足各种常见的生产需求——例如,监控和存储。

额外资源

构建一个可解释的强化学习框架

原文:towardsdatascience.com/building-an-explainable-reinforcement-learning-framework-084ef2d23d01?source=collection_archive---------9-----------------------#2024-03-13

通过符号化策略发现可解释的结果

符号化遗传算法、动作电位和方程树

Dani LisleTowards Data Science Dani Lisle

·发布于 Towards Data Science ·阅读时长 9 分钟 ·2024 年 3 月 13 日

--

我们已经学会了训练能够击败国际象棋和围棋等游戏世界冠军的模型,但有一个主要的局限性:可解释性。存在许多方法可以创建一个黑箱模型,使其能够比任何人类更好地玩游戏或操作系统,但创建一个具有可读闭式策略的模型是完全不同的问题。

在解决这个问题方面,提升的潜力是丰富的。人类能够快速理解的策略不会停留在代码库中——它们进入了科学文献,甚至可能被大众所知。它们可能促进人类和计算机之间的增强认知现实,并减少我们物种的知识与深藏在庞大高维张量中、有效加密的知识之间的隔阂。

但如果我们有更多的算法,能从训练中提供这样可解释的结果,我们该如何以人类可读的方式编码它们呢?

最可行的选择之一是使用微分方程(在离散情况下为差分方程)。这些方程的特点是它们定义了导数,或者量的变化率,提供了一种有效的方式来传达并直观地理解几乎任何系统的动态。这里有一个著名的例子,它描述了系统中热量在时间和空间上的导数:

“n”维热方程(维基百科:“热方程”

事实上,已有研究通过算法的方式直接演化这些方程,而不是试图从张量中提取它们(作为知识)。去年我撰写了一篇论文,详细阐述了一个使用动力学方程的博弈论模拟框架,该方程通过遗传算法按符号逐步演化。陈等人发表的另一篇论文展示了一种符号遗传算法,用于发现偏微分方程,这些方程像热方程一样,描述了物理系统的动态。该小组能够从生成的数据集中挖掘出这些方程。

但再考虑一下国际象棋这款游戏。如果我们在计算学习这些方程的能力不限于单纯的预测应用,那么会怎样?如果我们能利用这些进化技术来学习现实世界中社会经济博弈的最佳策略呢?

在一个新的人类与人机关系,以及复杂策略进入应用的时代,计算方法在发现直观且可转移的战略洞察方面从未如此重要。机遇和潜在威胁既吸引人又压倒性强。

让我们开始

本文讨论的所有 Python 代码都可以在我的项目的 GitHub 仓库中访问: https://github.com/dreamchef/abm-dynamics-viz

在我最近撰写的一篇文章中,我讨论了在一个理论游戏中模拟动态行为的智能体。尽管我非常希望通过符号进化来处理这样的多智能体游戏,但从原子化的角度出发,拓展我们的视野,并利用一些前人的工作是明智之举。像 DeepMind 这样的团队在创建具有世界级技能的竞争性棋盘游戏模型时,背后有一个机器学习的子学科:强化学习。在这一范式中,智能体有一个观察空间(可以测量并作为值使用的环境变量),一个动作空间(与环境互动或在环境中移动/变化的方式),以及一个奖励系统。通过反复实验,奖励动态使智能体能够构建出一个策略或政策,从而最大化奖励。

我们可以将符号遗传算法应用于一些经典的强化学习问题,以便探索和微调它们。Gymnasium 库提供了一系列适合强化学习实验的游戏和任务。我确定的一个非常适合我们目标的游戏是“月球着陆器”。

月球着陆器(来源:Gymnasium)

游戏的定义如下:

  • 观察空间(8):x,y 位置,x,y 速度,角度,角速度,左脚,右脚接触地面。连续。

  • 动作空间(4):无引擎,底部,左侧,右侧引擎点火。离散。

学习“登月者”任务的符号策略

你可能已经注意到,尽管像速度和角度这样的变量是连续的,但动作空间是离散的。那么我们如何定义一个接受连续输入和输出(有效地说,是一个分类)的函数呢?实际上,这是一个众所周知的问题,常见的方法是使用动作势能函数。

动作势能方程

以神经机制命名,动作势能函数像一个阈值一样工作,计算输入的连续值并输出:

  • 如果连续值处于阈值之上,则输出为 True。

  • 输出为 False 如下所示。

在我们的问题中,我们实际上需要获得一个 4 个可能值的离散输出。我们可以在设计这个系统时仔细考虑任务的动态,但我选择了一种简单的方式,作为一种半对抗性的努力,给我们的 SGA 算法施加更多的压力,从而最终展现其优势。它使用了一个普遍的直觉:接近目标时,我们可能不应该过多使用侧推力:

 def potential_to_action(potential):

    if abs(potential-0) < 0.5:
        return 0

    elif abs(potential-0) < 1:
        return 2

    elif potential < 0:
        return 1

    else:
        return 3

确定了这一点后,让我们为接下来的旅程制定一个路线图。我们的主要任务将是:

  1. 一种进化结构,其中方程的家族和代数可以存在并竞争。

  2. 用于存储方程的数据库结构(方便它们的遗传改造)。

  3. 符号变异算法——我们将如何变异?变异什么?

  4. 选择方法——我们将选择哪些候选者,并带入下一轮?

  5. 评估方法——我们将如何衡量方程的适应度?

进化结构

我们首先编写出一个高层次的代码,并将一些算法实现留到后续步骤。这通常以数组的形式呈现,我们可以在其中存储方程群体,并且有一个主循环在指定的代数中进化它们,同时调用变异、选择/淘汰和测试算法。

我们还可以为进化模型定义一组参数,包括代数数量,并指定每个父策略创建和选择多少变异。

以下代码

last_gen = [F]

for i in range(GENS):
    next_gen = []

    for policy in last_gen:
        batch = cull(mutants(policy))

        for policy in batch:
            next_gen.append(policy) 

    last_gen = next_gen

最终,它选择表现最好的策略,并通过另一轮测试(与 Lunar Lander 仿真回合进行对比)来验证它们:

last_gen.sort(key=lambda x: x['score'])

final_cull = last_gen [-30:]

for policy in final_cull:

    policy['score'] = score_policy(policy,ep=7)

final_cull.sort(key=lambda x: x['score'])

print('Final Popluation #:',len(last_gen))

for policy in final_cull:
    print(policy['AP'])
    print(policy['score'])
    print('-'*20)

env.close()

用于存储方程的数据库结构

我们首先选择一组二元和一元操作符及操作数(来自观察空间),并表示和变异它们:

BIN_OPS = ['mult','add','sub', 'div']
UN_OPS = ['abs','exp','log','sqrt','sin','cos']
OPNDS = ['x','y','dx','dy','angle','dangle','L','R']

然后,我们借鉴了 Chen 等人的思想,将方程编码为树的形式。这将允许我们遍历方程并将符号作为独立对象进行变异。具体来说,我选择使用嵌套数组来完成这个任务。这段代码编码了 xy + dxdy

F = {'AP': ['add', 
                ['mult','x','y'],
                ['mult','dx','dy']],
        'score': 0
        }

每个方程包括定义其形式的树结构,以及一个得分对象,用来存储它在 Lander 任务中的评估得分。

符号变异算法

我们可以通过多种方式接近算法的变异,具体取决于我们想要修改方程式中不同符号的概率分布。我使用了一种递归方法,在树的每一层,算法随机选择一个符号,如果是二元操作符,则继续向下进入下一层进行选择。

以下主要的变异函数接受一个源策略并输出一个数组,其中包括未更改的源策略和变异后的策略。

def mutants(policy, sample=1):
    children = [policy]
    mutation_target = policy

    for i in range(REPL):
        new_policy = copy.deepcopy(policy)
        new_policy['AP'] = mutate_recursive(new_policy['AP'])
        children.append(new_policy)

    return children

这个辅助函数包含递归算法:

def mutate_recursive(target, probability=MUTATE_P):

    # Recursive case
    if isinstance(target, list):
      random_element = random.choice(range(len(target)))
      target[random_element] = mutate_recursive(target[random_element])
      return target

    # Base cases
    elif(target in BIN_OPS):
      new = random.choice(BIN_OPS)
      return new

    elif(target in UN_OPS):
      new = random.choice(UN_OPS)
      return new

    elif(target in OPNDS):
      new = random.choice(OPNDS)
      return new

选择方法

选择最佳策略将涉及测试它们以获得分数,然后决定一种方式让它们竞争并进化到更高级的阶段。在这里,我使用了一个进化家族树结构,其中家族或批次中的每一代(例如左下角的两个)都包含一个突变,使其与父代有所不同。

 +----------+
                | x + dy² |
                +----------+
                     |
          +----------+----------+
          |                     |
     +----v----+           +----v----+
     | y + dy²|           | x / dy²|
     +---------+           +---------+
          |                      |
     +----+----+            +----+-----+
     |         |            |          |
 +---v--+-+ +--v---+-+   +--v-----+ +--v-----+
 |y - dy²| |y - dy²|   |x / dx²| |y - dy³|
 +--------+ +--------+   +--------+ +--------+

在对方程式进行评分后,每批方程式将被排名,最佳的 N 个会继续参与,其他的则被丢弃:

def cull(batch):

    for policy in batch[1:]:
        policy['score'] = score_policy(policy)

    batch.sort(key=lambda x: x['score'], reverse=True)

    return batch[:CULL]

通过模拟回合进行评分的方法

为了决定哪些方程式编码了最佳策略,我们使用 Gymnasium 框架进行 Lunar Lander 任务。

def score_policy(policy, ep=10, render=False):
    observation = env.reset()[0]  # Reset the environment to start a new episode
    total_reward = 0
    sample = 0

    for episode in range(ep):

        while True:
            if render:
                env.render()

            values = list(observation)
            values =    {'x': values[0],
            'y': values[1],
            'dx': values[2],
            'dy': values[3],
            'angle': values[4],
            'dangle': values[5],
            'L': values[6],
            'R': values[7]
            }

            potential = policy_compute(policy['AP'], values)
            action = potential_to_action(potential)

            sample += 1

            observation, reward, done, info = env.step(action)[:4]
            total_reward += reward

            if done:  # If the episode is finished
                break

    return total_reward/EPISODES

主要循环用于评分,执行指定的回合数(模拟运行次数),每一回合我们都能看到基本的强化学习范式。

从初始观察开始,信息通过我们的方法用来计算一个动作,动作与环境进行交互,获得下一步的观察结果。

由于我们将方程式存储为树结构,因此我们需要一个单独的方法来计算这种形式的潜力。以下函数使用递归来从编码的方程式中根据观察值获得结果:

def policy_compute(policy, values):

    if isinstance(policy, str):
        if policy in values:
            return values[policy]
        else:
            print('ERROR')

    elif isinstance(policy, list):
        operation = policy[0]
        branches = policy[1:]

        if operation in BIN_OPS:

            if len(branches) != 2:
                raise ValueError(f"At {policy}, Operation {operation} expects 2 operands, got {len(branches)}")

            operands = [operand for operand in branches]

            left = policy_compute(operands[0], values)
            right = policy_compute(operands[1], values)

            if operation == 'add':
                return left + right
            elif operation == 'sub':
                return left - right
            elif operation == 'mult':
                if left is None or right is None:
                    print('ERROR: left:',left,'right:',right)
                return left * right
            elif operation == 'div':
                if right == 0:
                    return 0
                return left / right

        elif operation in UN_OPS:
            if len(branches) != 1:
                raise ValueError(f"Operation {operation} expects 1 operand, got {len(branches)}")

            operand_value = policy_compute(next(iter(branches)), values)

            if operation == 'abs':
                return abs(operand_value)
            elif operation == 'exp':
                return math.exp(operand_value)
            elif operation == 'logabs':
                return math.log(abs(operand_value))
            elif operation == 'sin':
                return math.sin(operand_value)
            elif operation == 'cos':
                return math.cos(operand_value)
            elif operation == 'sqrtabs':
                return math.sqrt(abs(operand_value))

        else:
            raise ValueError(f"Unknown operation: {operation}")

    else:
        print('ERROR')
        return 0

上面的代码遍历树的每一层,检查当前符号是操作数还是操作符,并根据情况递归地计算左右两侧,或者返回递归栈中执行适当的操作符计算。

下一步

这就是实现的全部内容。在本系列的下一篇文章中,我将解释训练结果,激励实验框架中的变动,并探索通过改进变异和选择算法来扩展训练框架的路径。

与此同时,你可以通过此链接访问我在 2024 年科罗拉多大学丹佛分校举行的 SIAM 前沿学生会议上所做的一次讲座的幻灯片,讲座中讨论了初步的训练结果。

所有关于这个项目的代码都在我的仓库:github.com/dreamchef/abm-dynamics-viz。如果你有任何发现,或者对我的工作有任何想法,欢迎在评论中与我交流!也可以通过TwitterLinkedIn联系我。

除非另有注明,所有图片均由作者创作。

使用 FAISS 和 CLIP 构建图像相似度搜索引擎

原文:towardsdatascience.com/building-an-image-similarity-search-engine-with-faiss-and-clip-2211126d08fa?source=collection_archive---------3-----------------------#2024-08-23

一篇指导性教程,解释如何使用 CLIP 嵌入和 FAISS 索引,通过文本或照片查询搜索图像数据集。

Lihi Gur Arie, PhDTowards Data Science Lihi Gur Arie, 博士

·发布于 Towards Data Science ·6 分钟阅读·2024 年 8 月 23 日

--

图片由作者在 Flux-Pro 平台上生成

引言

你是否曾经想在你的无尽图像数据集中找到一张图像,却觉得这项任务太繁琐?在本教程中,我们将构建一个图像相似度搜索引擎,通过文本查询或参考图像轻松找到图像。为了方便起见,本教程的完整代码已提供在文章底部,作为一个Colab 笔记本

如果你没有付费的 Medium 账户,你可以在这里免费阅读。

流程概览

图像的语义意义可以通过一个称为嵌入(embedding)的数值向量表示。通过比较这些低维的嵌入向量,而不是原始图像,可以高效地进行相似度搜索。对于数据集中的每一张图片,我们都会创建一个嵌入向量并将其存储在索引中。当提供文本查询或参考图像时,会生成其嵌入并与索引中的嵌入进行比较,以检索最相似的图像。

这里是简要概览:

  1. 嵌入:图像的嵌入是通过 CLIP 模型提取的。

  2. 索引:嵌入向量被存储为 FAISS 索引。

  3. 检索:使用 FAISS,查询的嵌入与索引中的嵌入进行比较,从而检索最相似的图像。

CLIP 模型

CLIP(对比语言-图像预训练)模型是由 OpenAI 开发的多模态视觉与语言模型,它将图像和文本映射到相同的潜在空间。由于我们将使用图像和文本查询来搜索图像,我们将使用 CLIP 模型来嵌入我们的数据。关于 CLIP 的进一步阅读,您可以查看我之前的文章这里。

FAISS 索引

FAISS(Facebook AI 相似度搜索)是 Meta 开发的开源库。它围绕存储数据库嵌入向量的索引对象构建。FAISS 使得密集向量的高效相似度搜索和聚类成为可能,我们将使用它对我们的数据集进行索引,并检索与查询相似的照片。

代码实现

第 1 步 — 数据集探索

为了创建本教程的图像数据集,我从Pexels收集了 52 张各种主题的图像。为了帮助理解,我们来看一下 10 张随机图像:

第 2 步 — 从图像数据集中提取 CLIP 嵌入向量

要提取 CLIP 嵌入向量,我们将首先使用 HuggingFace SentenceTransformer 库加载 CLIP 模型:

model = SentenceTransformer('clip-ViT-B-32')

接下来,我们将创建一个函数,使用 glob 遍历我们的数据集目录,通过 PIL Image.open 打开每个图像,并使用 CLIP model.encode 为每个图像生成一个嵌入向量。它将返回一个嵌入向量列表和我们图像数据集路径的列表:

def generate_clip_embeddings(images_path, model):

    image_paths = glob(os.path.join(images_path, '**/*.jpg'), recursive=True)

    embeddings = []
    for img_path in image_paths:
        image = Image.open(img_path)
        embedding = model.encode(image)
        embeddings.append(embedding)

    return embeddings, image_paths

IMAGES_PATH = '/path/to/images/dataset'

embeddings, image_paths = generate_clip_embeddings(IMAGES_PATH, model)

第 3 步 — 生成 FAISS 索引

下一步是从嵌入向量列表创建 FAISS 索引。FAISS 提供了多种距离度量方法来进行相似度搜索,包括内积(IP)和 L2(欧几里得)距离。

FAISS 还提供了多种索引选项。它可以使用近似或压缩技术高效地处理大数据集,同时平衡搜索速度和精度。在本教程中,我们将使用“Flat”索引,它通过将查询向量与数据集中的每个向量进行比较来执行暴力搜索,确保精确结果,但代价是更高的计算复杂度。

def create_faiss_index(embeddings, image_paths, output_path):

    dimension = len(embeddings[0])
    index = faiss.IndexFlatIP(dimension)
    index = faiss.IndexIDMap(index)

    vectors = np.array(embeddings).astype(np.float32)

    # Add vectors to the index with IDs
    index.add_with_ids(vectors, np.array(range(len(embeddings))))

    # Save the index
    faiss.write_index(index, output_path)
    print(f"Index created and saved to {output_path}")

    # Save image paths
    with open(output_path + '.paths', 'w') as f:
        for img_path in image_paths:
            f.write(img_path + '\n')

    return index

OUTPUT_INDEX_PATH = "/content/vector.index"
index = create_faiss_index(embeddings, image_paths, OUTPUT_INDEX_PATH)

faiss.IndexFlatIP 初始化一个用于内积相似度的索引,封装在 faiss.IndexIDMap 中,将每个向量与一个 ID 关联。接下来,index.add_with_ids 将向量添加到索引中,并分配顺序 ID,索引连同图像路径一起保存到磁盘。

索引可以立即使用,也可以保存到磁盘以供将来使用。要加载 FAISS 索引,我们将使用以下函数:

def load_faiss_index(index_path):
    index = faiss.read_index(index_path)
    with open(index_path + '.paths', 'r') as f:
        image_paths = [line.strip() for line in f]
    print(f"Index loaded from {index_path}")
    return index, image_paths

index, image_paths = load_faiss_index(OUTPUT_INDEX_PATH)

第 4 步 — 通过文本查询或参考图像检索图像

在构建好 FAISS 索引后,我们现在可以使用文本查询或参考图像来检索图像。如果查询是图像路径,则通过 PIL Image.open 打开查询。接着,通过 CLIP model.encode 提取查询的嵌入向量。

def retrieve_similar_images(query, model, index, image_paths, top_k=3):

    # query preprocess:
    if query.endswith(('.png', '.jpg', '.jpeg', '.tiff', '.bmp', '.gif')):
        query = Image.open(query)

    query_features = model.encode(query)
    query_features = query_features.astype(np.float32).reshape(1, -1)

    distances, indices = index.search(query_features, top_k)

    retrieved_images = [image_paths[int(idx)] for idx in indices[0]]

    return query, retrieved_images

检索发生在index.search方法中。它实现了 k 近邻(kNN)搜索,用于查找与查询向量最相似的k个向量。我们可以通过更改top_k参数来调整 k 的值。在我们的实现中,kNN 搜索使用的距离度量是余弦相似度。该函数返回查询和一系列获取的图片路径。

使用文本查询进行搜索:

现在我们准备好检查搜索结果了。辅助函数visualize_results展示了这些结果。你可以在关联的 Colab 笔记本中找到它。让我们以文本查询“ball”为例,探索获取的三个最相似的图片:

query = 'ball'
query, retrieved_images = retrieve_similar_images(query, model, index, image_paths, top_k=3)
visualize_results(query, retrieved_images)

使用查询“a ball”获取的图片

对于查询“animal”,我们得到了:

使用查询“animal”获取的图片

使用参考图片进行搜索:

query ='/content/drive/MyDrive/Colab Notebooks/my_medium_projects/Image_similarity_search/image_dataset/pexels-w-w-299285-889839.jpg'
query, retrieved_images = retrieve_similar_images(query, model, index, image_paths, top_k=3)
visualize_results(query, retrieved_images)

查询和获取的图片

正如我们所见,我们对现成的预训练模型得到了相当不错的结果。当我们用一幅眼睛画作为参考图片进行搜索时,除了找到原始图片外,还找到了一张眼镜和一张不同画作的匹配。这展示了查询图片的语义含义的不同方面。

你可以在提供的 Colab 笔记本中尝试其他查询,查看模型在不同文本和图像输入下的表现。

结语

在本教程中,我们使用 CLIP 和 FAISS 构建了一个基本的图像相似性搜索引擎。获取的图片与查询具有相似的语义含义,表明该方法的有效性。尽管 CLIP 对零样本模型显示出不错的结果,但它可能在分布外数据、细粒度任务中表现较差,并且继承了它所训练数据的自然偏差。为了克服这些限制,你可以尝试使用其他类似 CLIP 的预训练模型,如在OpenClip中,或者在你自己的定制数据集上微调 CLIP。

感谢阅读!

恭喜你一路走到了这里。点击👍表示感谢,提升算法的自尊心🤓

想了解更多?

完整代码作为 Colab 笔记本:

Colab 笔记本链接

为 Llamaindex 工作流构建交互式 UI

原文:towardsdatascience.com/building-an-interactive-ui-for-llamaindex-workflows-842dd7abedde?source=collection_archive---------3-----------------------#2024-09-24

使用 Llamaindex、FastAPI 和 Streamlit 集成人机互动的指南

Lingzhen ChenTowards Data Science Lingzhen Chen

·发表于Towards Data Science ·阅读时间:10 分钟·2024 年 9 月 24 日

--

在上一篇文章中,我展示了如何使用 LlamaIndex 工作流来简化我的研究和展示过程。我构建了一个工作流,该工作流获取研究主题,在 arxiv.org 上查找相关文章,创建论文摘要,并生成一个 PowerPoint 幻灯片展示这些论文。你可以在这里阅读完整的操作步骤:

## 我如何通过 LlamaIndex 工作流简化我的研究和展示

一个协调 AI 工作流的示例,具有鲁棒性、灵活性和可控性

towardsdatascience.com

为了继续构建工作流并使其更具用户友好性,我使用 Streamlit 实现了一个 UI,以增强用户体验。该 UI 显示工作流执行的进度更新,集成用户输入,支持实时用户反馈,并呈现最终生成的幻灯片。

Streamlit UI(作者录屏)

你可以在我的Github上查看完整代码。在本文中,我将介绍 UI 实现的一些关键点,以及前端和后端之间的集成:

后端增强:

  • 更新工作流以支持发送流式事件

  • 更新工作流以暂停执行并等待用户输入

  • 使用 FastAPI 托管多个端点以运行工作流,接受用户输入和下载文件,支持异步处理和流式消息

前端 UI 功能:

  • 向后台发送请求并在扩展框中显示从后台流式传输的事件数据

  • 在容器中显示相关信息并收集用户输入(如果需要用户输入)

  • 渲染最终生成的幻灯片

  • 提供一个按钮供用户下载最终文件

将所有内容整合在一起:

  • 将前端和后端依赖项分开,并通过使用不同的pyproject.tomlDockerfile进行构建

  • 使用docker-compose构建并启动所有服务

从终端启动工作流时,可以很直观地看到当前正在执行的步骤以及我们在这些步骤中添加的日志信息。

工作流执行的终端日志(截图来自作者)

我们还可以通过简单地在工作流中使用user_feedback = input()来启用人机互动。这将暂停工作流并等待用户输入(请参见此官方 Llamaindex笔记本中的人机互动示例)。然而,为了在用户友好的界面中实现相同的功能,我们需要对原始工作流做出额外的修改。

从工作流发送流式事件

工作流执行可能需要很长时间,因此为了提供更好的用户体验,Llamaindex 提供了一种方法,通过发送流式事件来指示工作流的进度,如笔记本这里所示。在我的工作流中,我定义了一个WorkflowStreamingEvent类,包含有关事件消息的有用信息,如事件类型,以及它是从哪个步骤发送的:

class WorkflowStreamingEvent(BaseModel):
    event_type: Literal["server_message", "request_user_input"] = Field(
        ..., description="Type of the event"
    )
    event_sender: str = Field(
        ..., description="Sender (workflow step name) of the event"
    )
    event_content: Dict[str, Any] = Field(..., description="Content of the event")

为了启用发送流式事件,工作流步骤需要访问共享上下文,这通过在步骤定义中添加@step(pass_context=True)装饰器来实现。然后,在步骤定义中,我们可以通过上下文发送关于进度的事件消息。例如,在tavily_query()步骤中:

@step(pass_context=True)
async def tavily_query(self, ctx: Context, ev: StartEvent) -> TavilyResultsEvent:
    ctx.data["research_topic"] = ev.user_query
    query = f"arxiv papers about the state of the art of {ev.user_query}"
    ctx.write_event_to_stream(
        Event(
            msg=WorkflowStreamingEvent(
                event_type="server_message",
                event_sender=inspect.currentframe().f_code.co_name,
                event_content={"message": f"Querying Tavily with: '{query}'"},
            ).model_dump()
        )
    )

在这个示例中,我们将event_type设置为“server_message”,意味着这是一个更新消息,不需要用户操作。我们还有另一种事件类型"request_user_input",表示需要用户输入。例如,在工作流中的gather_feedback_outline()步骤中,在从原始论文摘要生成幻灯片文本大纲后,会发送一条消息,提示用户提供对大纲文本的批准和反馈:

@step(pass_context=True)
    async def gather_feedback_outline(
        self, ctx: Context, ev: OutlineEvent
    ) -> OutlineFeedbackEvent | OutlineOkEvent:
        """Present user the original paper summary and the outlines generated, gather feedback from user"""
        ...

        # Send a special event indicating that user input is needed
        ctx.write_event_to_stream(
            Event(
                msg=json.dumps(
                    {
                        "event_type": "request_user_input",
                        "event_sender": inspect.currentframe().f_code.co_name,
                        "event_content": {
                            "summary": ev.summary,
                            "outline": ev.outline.dict(),
                            "message": "Do you approve this outline? If not, please provide feedback.",
                        },
                    }
                )
            )
        )

        ...

这些事件在后台 API 和前端逻辑中有不同的处理方式,我将在本文后续部分详细描述。

暂停工作流以等待用户输入

需要用户反馈的工作流步骤(图像来自作者)

当向用户发送 "request_user_input" 事件时,我们只希望在收到用户输入后才继续执行下一步。如上面的工作流图所示,如果用户批准了大纲,它会进入 outlines_with_layout() 步骤;如果用户没有批准,则会再次进入 summary2outline() 步骤。

这是通过使用 Python 的 asyncio 库中的 Future() 对象来实现的。在 SlideGenerationWorkflow 类中,我们设置了一个属性 self.user_input_future = asyncio.Future(),这个属性可以在 gather_feedback_outline() 步骤中等待。工作流的后续执行取决于用户反馈的内容:

@step(pass_context=True)
async def gather_feedback_outline(
    self, ctx: Context, ev: OutlineEvent
) -> OutlineFeedbackEvent | OutlineOkEvent:
    ...

    # Wait for user input
    if not self.user_input_future.done():
        user_response = await self.user_input_future
        logger.info(f"gather_feedback_outline: Got user response: {user_response}")

        # Process user_response, which should be a JSON string
        try:
            response_data = json.loads(user_response)
            approval = response_data.get("approval", "").lower().strip()
            feedback = response_data.get("feedback", "").strip()
        except json.JSONDecodeError:
            # Handle invalid JSON
            logger.error("Invalid user response format")
            raise Exception("Invalid user response format")

        if approval == ":material/thumb_up:":
            return OutlineOkEvent(summary=ev.summary, outline=ev.outline)
        else:
            return OutlineFeedbackEvent(
                summary=ev.summary, outline=ev.outline, feedback=feedback
            )

FastAPI 后端

我们使用 FastAPI 设置后端,暴露一个 POST 端点来处理请求,并启动工作流运行。异步函数 run_workflow_endpoint() 接受 ResearchTopic 作为输入。在该函数中,定义了一个异步生成器 event_generator(),它创建一个任务来运行工作流,并在工作流进展时将事件流传输给客户端。当工作流完成时,它还会将最终的文件结果流传输给客户端。

 class ResearchTopic(BaseModel):
    query: str = Field(..., example="example query")

@app.post("/run-slide-gen")
async def run_workflow_endpoint(topic: ResearchTopic):
    workflow_id = str(uuid.uuid4())

    wf = SummaryAndSlideGenerationWorkflow(wid=workflow_id, timeout=2000, verbose=True)
    wf.add_workflows(
        summary_gen_wf=SummaryGenerationWorkflow(
            wid=workflow_id, timeout=800, verbose=True
        )
    )
    wf.add_workflows(
        slide_gen_wf=SlideGenerationWorkflow(
            wid=workflow_id, timeout=1200, verbose=True
        )
    )

    async def event_generator():
        loop = asyncio.get_running_loop()
        logger.debug(f"event_generator: loop id {id(loop)}")
        yield f"{json.dumps({'workflow_id': workflow_id})}\n\n"

        task = asyncio.create_task(wf.run(user_query=topic.query))
        logger.debug(f"event_generator: Created task {task}")
        try:
            async for ev in wf.stream_events():
                logger.info(f"Sending message to frontend: {ev.msg}")
                yield f"{ev.msg}\n\n"
                await asyncio.sleep(0.1)  # Small sleep to ensure proper chunking
            final_result = await task

            # Construct the download URL
            download_pptx_url = f"http://backend:80/download_pptx/{workflow_id}"
            download_pdf_url = f"http://backend:80/download_pdf/{workflow_id}"

            final_result_with_url = {
                "result": final_result,
                "download_pptx_url": download_pptx_url,
                "download_pdf_url": download_pdf_url,
            }

            yield f"{json.dumps({'final_result': final_result_with_url})}\n\n"
        except Exception as e:
            error_message = f"Error in workflow: {str(e)}"
            logger.error(error_message)
            yield f"{json.dumps({'event': 'error', 'message': error_message})}\n\n"
        finally:
            # Clean up
            workflows.pop(workflow_id, None)

    return StreamingResponse(event_generator(), media_type="text/event-stream")

除了这个端点外,还有接收来自客户端的用户输入和处理文件下载请求的端点。由于每个工作流都分配了一个唯一的工作流 ID,我们可以将从客户端接收到的用户输入映射到正确的工作流。通过调用等待中的 Future 对象的 set_result(),挂起的工作流可以恢复执行。

@app.post("/submit_user_input")
async def submit_user_input(data: dict = Body(...)):
    workflow_id = data.get("workflow_id")
    user_input = data.get("user_input")
    wf = workflows.get(workflow_id)
    if wf and wf.user_input_future:
        loop = wf.user_input_future.get_loop()  # Get the loop from the future
        logger.info(f"submit_user_input: wf.user_input_future loop id {id(loop)}")
        if not wf.user_input_future.done():
            loop.call_soon_threadsafe(wf.user_input_future.set_result, user_input)
            logger.info("submit_user_input: set_result called")
        else:
            logger.info("submit_user_input: future already done")
        return {"status": "input received"}
    else:
        raise HTTPException(
            status_code=404, detail="Workflow not found or future not initialized"
        )

下载端点还会根据工作流 ID 确定最终文件的位置。

@app.get("/download_pptx/{workflow_id}")
async def download_pptx(workflow_id: str):
    file_path = (
        Path(settings.WORKFLOW_ARTIFACTS_PATH)
        / "SlideGenerationWorkflow"
        / workflow_id
        / "final.pptx"
    )
    if file_path.exists():
        return FileResponse(
            path=file_path,
            media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
            filename=f"final.pptx",
        )
    else:
        raise HTTPException(status_code=404, detail="File not found")

Streamlit 前端

在前端页面中,在用户通过 st.text_input() 提交研究主题后,一个长时间运行的进程将在后台线程中启动,并在一个新的事件循环中接收来自后端的流式事件,而不会干扰页面的其他部分:

def start_long_running_task(url, payload, message_queue, user_input_event):
    try:
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        loop.run_until_complete(
            get_stream_data(url, payload, message_queue, user_input_event)
        )
        loop.close()
    except Exception as e:
        message_queue.put(("error", f"Exception in background thread: {str(e)}"))

...

def main():

  ...

  with st.sidebar:
      with st.form(key="slide_gen_form"):
          query = st.text_input(
              "Enter the topic of your research:",
          )
          submit_button = st.form_submit_button(label="Submit")

  if submit_button:
      # Reset the workflow_complete flag for a new workflow
      st.session_state.workflow_complete = False
      # Start the long-running task in a separate thread
      if (
          st.session_state.workflow_thread is None
          or not st.session_state.workflow_thread.is_alive()
      ):
          st.write("Starting the background thread...")

          st.session_state.workflow_thread = threading.Thread(
              target=start_long_running_task,
              args=(
                  "http://backend:80/run-slide-gen",
                  {"query": query},
                  st.session_state.message_queue,
                  st.session_state.user_input_event,
              ),
          )
          st.session_state.workflow_thread.start()
          st.session_state.received_lines = []
      else:
          st.write("Background thread is already running.")

从后端流式传输的事件数据由 httpx.AsyncClient 获取,并放入消息队列以供进一步处理。根据事件类型提取不同的信息。对于事件类型 "request_user_input",线程也会暂停,直到提供用户输入。

async def fetch_streaming_data(url: str, payload: dict = None):
    async with httpx.AsyncClient(timeout=1200.0) as client:
        async with client.stream("POST", url=url, json=payload) as response:
            async for line in response.aiter_lines():
                if line:
                    yield line

async def get_stream_data(url, payload, message_queue, user_input_event):
    # message_queue.put(("message", "Starting to fetch streaming data..."))
    data_json = None
    async for data in fetch_streaming_data(url, payload):
        if data:
            try:
                data_json = json.loads(data)
                if "workflow_id" in data_json:
                    # Send workflow_id to main thread
                    message_queue.put(("workflow_id", data_json["workflow_id"]))
                    continue
                elif "final_result" in data_json:
                    # Send final_result to main thread
                    message_queue.put(("final_result", data_json["final_result"]))
                    continue
                event_type = data_json.get("event_type")
                event_sender = data_json.get("event_sender")
                event_content = data_json.get("event_content")
                if event_type in ["request_user_input"]:
                    # Send the message to the main thread
                    message_queue.put(("user_input_required", data_json))
                    # Wait until user input is provided
                    user_input_event.wait()
                    user_input_event.clear()
                    continue
                else:
                    # Send the line to the main thread
                    message_queue.put(("message", format_workflow_info(data_json)))
            except json.JSONDecodeError:  # todo: is this necessary?
                message_queue.put(("message", data))
        if data_json and "final_result" in data_json or "final_result" in str(data):
            break  # Stop processing after receiving the final result

我们将消息存储在 st.session_state 中,并使用 st.expander() 来显示和更新这些流式数据。

if st.session_state.received_lines:
    with expander_placeholder.container():
        # Create or update the expander with the latest truncated line
        expander = st.expander(st.session_state.expander_label)
        for line in st.session_state.received_lines:
            expander.write(line)
            expander.divider()

为了确保 UI 保持响应,并在后台线程处理事件消息时显示这些消息,我们使用自定义的 autorefresh 组件,在设定的时间间隔内刷新页面:

if not st.session_state.workflow_complete:
    st_autorefresh(interval=2000, limit=None, key="data_refresh")

当流式事件的类型为 "request_user_input" 时,我们将在一个单独的容器中显示相关信息并收集用户反馈。由于一个工作流运行过程中可能会有多个需要用户输入的事件,我们将它们放入消息队列,并确保为与每个事件关联的 st.feedback()st.text_area()st.button() 分配一个唯一的键,以确保这些小部件互不干扰:

def gather_outline_feedback(placeholder):
    container = placeholder.container()
    with container:
        if st.session_state.user_input_required:
            data = st.session_state.user_input_prompt
            event_type = data.get("event_type")
            if event_type == "request_user_input":
                summary = data.get("event_content").get("summary")
                outline = data.get("event_content").get("outline")
                prompt_message = data.get("event_content").get(
                    "message", "Please review the outline."
                )

                # display the content for user input
                st.markdown("## Original Summary:")
                st.text_area("Summary", summary, disabled=True, height=400)
                st.divider()
                st.markdown("## Generated Slide Outline:")
                st.json(outline)
                st.write(prompt_message)

                # Define unique keys for widgets
                current_prompt = st.session_state.prompt_counter
                approval_key = f"approval_state_{current_prompt}"
                feedback_key = f"user_feedback_{current_prompt}"

                # Display the approval feedback widget
                approval = st.feedback("thumbs", key=approval_key)
                st.write(f"Current Approval state is: {approval}")
                logging.info(f"Current Approval state is: {approval}")

                # Display the feedback text area
                feedback = st.text_area(
                    "Please provide feedback if you have any:", key=feedback_key
                )

                # Handle the submission of user response
                if st.button(
                    "Submit Feedback", key=f"submit_response_{current_prompt}"
                ):
                    if not st.session_state.user_response_submitted:
                        # Retrieve approval and feedback using unique keys
                        approval_state = st.session_state.get(approval_key)
                        user_feedback = st.session_state.get(feedback_key, "")

                        # Ensure approval_state is valid
                        if approval_state not in [0, 1]:
                            st.error("Please select an approval option.")
                            return

                        user_response = {
                            "approval": (
                                ":material/thumb_down:"
                                if approval_state == 0
                                else ":material/thumb_up:"
                            ),
                            "feedback": user_feedback,
                        }
                        # Send the user's response to the backend

                        try:
                            response = requests.post(
                                "http://backend:80/submit_user_input",
                                json={
                                    "workflow_id": st.session_state.workflow_id,
                                    "user_input": json.dumps(user_response),
                                },
                            )
                            response.raise_for_status()
                            logging.info(
                                f"Backend response for submitting approval: {response.status_code}"
                            )
                        except requests.RequestException as e:
                            st.error(f"Failed to submit user input: {str(e)}")
                            return

     ...

最后,当工作流运行结束时,前端客户端将收到一个响应,其中包含最终生成文件的路径(相同的幻灯片文件,pdf 格式用于 UI 渲染,pptx 格式用于下载作为最终结果)。我们展示 pdf 文件,并创建一个按钮供用户下载 pptx 文件:

 if "download_url_pdf" in st.session_state and st.session_state.download_url_pdf:
      download_url_pdf = st.session_state.download_url_pdf
      try:
          # Fetch the PDF content
          pdf_response = requests.get(download_url_pdf)
          pdf_response.raise_for_status()
          st.session_state.pdf_data = pdf_response.content

          st.markdown("### Generated Slide Deck:")
          # Display the PDF using an iframe
          st.markdown(
              f'<iframe src="data:application/pdf;base64,{base64.b64encode(st.session_state.pdf_data).decode()}" width="100%" height="600px" type="application/pdf"></iframe>',
              unsafe_allow_html=True,
          )
      except Exception as e:
          st.error(f"Failed to load the PDF file: {str(e)}")

  # Provide the download button for PPTX if available
  if (
      "download_url_pptx" in st.session_state
      and st.session_state.download_url_pptx
  ):
      download_url_pptx = st.session_state.download_url_pptx
      try:
          # Fetch the PPTX content
          pptx_response = requests.get(download_url_pptx)
          pptx_response.raise_for_status()
          pptx_data = pptx_response.content

          st.download_button(
              label="Download Generated PPTX",
              data=pptx_data,
              file_name="generated_slides.pptx",
              mime="application/vnd.openxmlformats-officedocument.presentationml.presentation",
          )
      except Exception as e:
          st.error(f"Failed to load the PPTX file: {str(e)}")

使用docker-compose将一切组合起来

我们将使用docker-compose创建一个多服务的 Docker 应用程序,来运行前端和后端应用程序。

version: '3.8'

services:
  backend:
    build:
      context: ./backend
      args:
        - --no-cache
    ports:
      - "8000:80"
    networks:
      - app-network
    volumes:
      - .env:/app/.env
      - ./data:/app/data
      - ./workflow_artifacts:/app/workflow_artifacts
      - ~/.azure:/root/.azure

  frontend:
    build:
      context: ./frontend
      args:
        - --no-cache
    ports:
      - "8501:8501"
    networks:
      - app-network

networks:
  app-network:

就这样!只需运行docker-compose up,我们现在有一个应用程序,可以根据用户输入的查询运行研究工作流,在执行过程中提示用户提供反馈,并向用户显示最终结果。

感谢阅读!查看我的GitHub获取完整实现。我期待听到您的想法、建议和反馈。我目前在Inmeta担任数据科学顾问,Inmeta 是Crayon Group的一部分。欢迎在LinkedIn与我联系。😊

构建 LLMOPs 管道

原文:towardsdatascience.com/building-an-llmops-pipeline-08d367b36d64?source=collection_archive---------4-----------------------#2024-01-18

使用 SageMaker 管道、JumpStart 和 Clarify 微调和评估 Llama 7B 模型

Ram VegirajuTowards Data Science Ram Vegiraju

·发表于数据科学前沿 ·10 分钟阅读·2024 年 1 月 18 日

--

图片来自UnsplashSigmund提供

2023 年是各种大型语言模型(LLMs)在生成式 AI 领域崛起的一年。LLM 具有强大的能力和潜力,但将其投入生产一直是用户面临的持续挑战。一个特别普遍的问题是,应该使用哪个 LLM?更具体地说,如何评估一个 LLM 的准确性?当可以选择的模型数量众多,存在不同的用于微调/RAG 的数据集,并且需要考虑多种提示工程/调优技术时,这个问题尤为具有挑战性。

为了解决这个问题,我们需要为 LLM 建立DevOps最佳实践。建立一个可以帮助评估不同模型、数据集和提示的工作流或管道。这个领域开始被称为LLMOPs/FMOPs。以下是 LLMOPs 中可以考虑的一些参数,展示了一个(极度)简化的流程:

LLM 评估考虑因素(作者)

在本文中,我们将尝试通过构建一个管道来解决这个问题,该管道可以微调、部署并评估一个Llama 7B 模型。你还可以通过…来扩展这个示例。

使用 LangChain、Chainlit 和 Literal AI 构建一个可观察的 arXiv RAG 聊天机器人

原文:towardsdatascience.com/building-an-observable-arxiv-rag-chatbot-with-langchain-chainlit-and-literal-ai-9c345fcd1cd8?source=collection_archive---------2-----------------------#2024-05-13

使用 RAG 和 LangChain、Chainlit Copilot 应用程序以及 Literal AI 可观察性构建语义论文引擎的教程。

Tahreem RasulTowards Data Science Tahreem Rasul

·发表于 Towards Data Science ·阅读时长 17 分钟·2024 年 5 月 13 日

--

在本教程中,我将演示如何使用检索增强生成(RAG)构建一个语义研究论文引擎。我将利用LangChain作为构建我们语义引擎的主要框架,同时使用OpenAI的语言模型和Chroma DB的向量数据库。为了构建嵌入 Copilot 的 Web 应用程序,我将使用Chainlit的 Copilot 功能,并结合Literal AI的可观察性特性。这个工具可以通过简化查找相关论文的过程,促进学术研究。用户还可以通过询问推荐的论文问题,直接与内容互动。最后,我们将在应用程序中集成可观察性特性,以跟踪和调试对大语言模型(LLM)的调用。

嵌入 Copilot 的语义研究论文应用程序的应用架构。作者插图。

下面是我们将在本教程中涵盖的所有内容概述:

  • 开发一个 RAG 流水线,利用 OpenAI、LangChain 和 Chroma DB 处理和检索来自 arXiv API 的最相关 PDF 文档。

  • 开发一个带有 Copilot 的 Chainlit 应用程序,用于在线论文检索。

  • 提升应用程序……

使用 Tidymodels 构建与评估客户流失分类模型

原文:towardsdatascience.com/building-and-evaluating-classification-models-to-predict-customer-churn-with-tidymodels-de282075fc7b?source=collection_archive---------10-----------------------#2024-05-15

使用 Tidymodels 中的标准化语法来构建和比较各种模型与指标的指南

Deepsha MenghaniTowards Data Science Deepsha Menghani

·发布于Towards Data Science ·8 分钟阅读·2024 年 5 月 15 日

--

当我最初学习如何构建模型时,那是很久以前的事了,不同的软件包采用了不同的参数名称,有许多种构建模型的方法。然后,当我开始使用tidymodels时,我惊讶地发现,它让我能够以一致的方式高效地编写模型构建代码,适应各种场景和引擎。这意味着我不再需要记住一百种不同的格式和参数,比较输出变得更加容易。

在本文中,我将以一个非常常见的客户流失预测场景为例,带您通过标准化的方式构建模型,并进行结果比较。

代码

本文中所有的代码都可以在我的GitHub Repo中找到。

让我们开始建模吧

第 0 步:设置环境

通过运行单个命令(例如install.packages("package_name"))来安装所需的软件包,或者加载…

时间的构建模块:RNN 的数学基础与 Python 实现

原文:towardsdatascience.com/building-blocks-of-time-the-mathematical-foundation-and-python-implementation-of-rnns-55f5ef9b108c?source=collection_archive---------3-----------------------#2024-01-20

Najib Sharifi, Ph.D.Towards Data Science Najib Sharifi, Ph.D.

·发表于Towards Data Science ·阅读时间:7 分钟·2024 年 1 月 20 日

--

仅仅能够使用流行库构建和训练机器学习模型,对于机器学习用户来说足够吗?可能不久后就不够了。随着像 AutoAI 这样的工具崛起,许多传统的机器学习技能,如使用常见库如 Pytorch 构建模型架构,可能会变得不那么重要。

可能会持续存在的是对具备深厚机器学习(ML)基本原理理解的熟练用户的需求,特别是在那些需要新颖挑战、定制和优化的任务中。为了更具创新性和新颖性,深入理解这些算法的数学基础至关重要。在本文中,我们将研究一个重要模型——循环神经网络(RNN)的数学描述。

时间序列数据(或任何顺序数据,如语言)具有时间依赖性,并广泛应用于各个领域,从天气预测到医学应用。RNN 是一个强大的工具,用于捕捉此类数据中的顺序模式。在本文中,我们将深入探讨 RNN 的数学基础,并使用 Python 从零实现这些方程。

理解 RNN:数学描述

序列数据的一个重要元素是时间依赖性,其中过去的值决定了当前和未来的值(就像我们生活在一个预定的世界中,但我们不谈哲学,继续讨论 RNN 模型)。时间序列预测利用了序列数据的这一特性,重点在于根据前 n 个值预测下一个值。根据模型的不同,这包括对过去值的映射或回归。

图 1. 时间序列数据示例

考虑黑箭所指示的点 y 和 y 前面的点(位于红色虚线之间),记作 X = {x1 , x2 , ….xt …..xT},其中 T 是总时间步数。RNN 通过将每个输入传递到隐状态(有时称为记忆状态)来处理输入序列(X),并输出 y。这些隐状态使得模型能够捕捉并记住序列中早期点的模式。

图 2. RNN 模型的示意图,展示了输入、隐状态和输出

现在让我们来看一下 RNN 模型中的数学运算,首先考虑前向传播,模型优化问题稍后再处理。

前向传播

前向传播相当直接,如下所示:

时间反向传播

在机器学习中,优化(变量更新)是通过梯度下降法进行的:

因此,所有在训练过程中需要更新的参数都需要它们的偏导数。这里我们将推导损失函数对前向传播方程中每个变量的偏导数:

通过注意前向传播方程和图 2 中的网络示意图,我们可以看到,在时间 T 时,L 仅通过 y_T 依赖于 a_T,即:

然而,对于 t < T,L 通过 y_T 和 a_(T+1) 依赖于 a_T,因此我们使用链式法则对两者进行处理:

现在我们得到了损失函数对前向传播方程中所有参数的梯度方程。这种算法称为时间反向传播。需要澄清的是,对于时间序列数据,通常只有最后一个值对损失函数有贡献,即所有其他输出会被忽略,其对损失函数的贡献为 0。数学描述与上述相同。现在让我们用 Python 编写这些方程,并将其应用于一个示例数据集。

编码实现

在实现上述方程之前,我们需要导入必要的数据集,进行预处理并准备模型训练。所有这些工作在任何时间序列分析中都是非常标准的。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import plotly.graph_objs as go
from plotly.offline import iplot
import yfinance as yf
import datetime as dt
import math

#### Data Processing
start_date = dt.datetime(2020,4,1)
end_date = dt.datetime(2023,4,1)

#loading from yahoo finance
data = yf.download("GOOGL",start_date, end_date)

pd.set_option('display.max_rows', 4)
pd.set_option('display.max_columns',5)
display(data)

# #Splitting the dataset
training_data_len = math.ceil(len(data) * .8)
train_data = data[:training_data_len].iloc[:,:1]
test_data = data[training_data_len:].iloc[:,:1]

dataset_train = train_data.Open.values
# Reshaping 1D to 2D array
dataset_train = np.reshape(dataset_train, (-1,1))
dataset_train.shape
scaler = MinMaxScaler(feature_range=(0,1))
# scaling dataset
scaled_train = scaler.fit_transform(dataset_train)

dataset_test = test_data.Open.values
dataset_test = np.reshape(dataset_test, (-1,1))
scaled_test = scaler.fit_transform(dataset_test)

X_train = []
y_train = []
for i in range(50, len(scaled_train)):
    X_train.append(scaled_train[i-50:i, 0])
    y_train.append(scaled_train[i, 0])

X_test = []
y_test = []
for i in range(50, len(scaled_test)):
    X_test.append(scaled_test[i-50:i, 0])
    y_test.append(scaled_test[i, 0])

# The data is converted to Numpy array
X_train, y_train = np.array(X_train), np.array(y_train)

#Reshaping
X_train = np.reshape(X_train, (X_train.shape[0], X_train.shape[1],1))
y_train = np.reshape(y_train, (y_train.shape[0],1))
print("X_train :",X_train.shape,"y_train :",y_train.shape)

# The data is converted to numpy array
X_test, y_test = np.array(X_test), np.array(y_test)

#Reshaping
X_test = np.reshape(X_test, (X_test.shape[0], X_test.shape[1],1))
y_test = np.reshape(y_test, (y_test.shape[0],1))

模型 现在我们实现数学方程式。仔细阅读代码是绝对值得的,注意所有变量和相应导数的维度,以帮助你更好地理解这些方程式。

class SimpleRNN:
    def __init__(self,input_dim,output_dim, hidden_dim):
        self.input_dim = input_dim
        self.output_dim = output_dim
        self.hidden_dim = hidden_dim
        self.Waa = np.random.randn(hidden_dim, hidden_dim) * 0.01 # we initialise as non-zero to help with training later
        self.Wax = np.random.randn(hidden_dim, input_dim) * 0.01
        self.Way = np.random.randn(output_dim, hidden_dim) * 0.01
        self.ba = np.zeros((hidden_dim, 1))
        self.by = 0 # a single value shared over all outputs #np.zeros((hidden_dim, 1))

    def FeedForward(self, x):
        # let's calculate the hidden states
        a = [np.zeros((self.hidden_dim,1))]
        y = []
        for ii in range(len(x)):

            a_next = np.tanh(np.dot(self.Waa, a[ii])+np.dot(self.Wax,x[ii].reshape(-1,1))+self.ba)
            a.append(a_next)
            y_local = np.dot(self.Way,a_next)+self.by
            y.append(np.dot(self.Way,a_next)+self.by)

        # remove the first a and y values used for initialisation
        #a = a[1:]
        return y, a

    def ComputeLossFunction(self, y_pred, y_actual):
        # for a normal many to many model:
        #loss = np.sum((y_pred - y_actual) ** 2)
        # in our case, we are only using the last value so we expect scalar values here rather than a vector
        loss = (y_pred[-1] - y_actual) ** 2
        return loss

    def ComputeGradients(self, a, x, y_pred, y_actual):
        # Backpropagation through time
        dLdy = []
        dLdby = np.zeros((self.output_dim, 1))
        dLdWay = np.random.randn(self.output_dim, self.hidden_dim)/5.0
        dLdWax = np.random.randn(self.hidden_dim, self.input_dim)/5.0
        dLdWaa = np.zeros((self.hidden_dim, self.hidden_dim))
        dLda = np.zeros_like(a)
        dLdba = np.zeros((self.hidden_dim, 1))

        for t in range(self.hidden_dim-1, 0, -1):
            if t == self.hidden_dim-1:
                dldy = 2*(y_pred[t] - y_actual)
            else:
                dldy = 0
            dLdy.append(dldy)
            #dLdby.append(dldy)
            dLdby += dldy
            #print(dldy.shape)
            dLdWay += np.dot(np.array(dldy).reshape(-1,1), a[t].T)

            # Calculate gradient of loss with respect to a[t]
            if t == self.hidden_dim-1:
                dlda_t= np.dot(self.Way.T, np.array(dldy).reshape(-1,1))

            else:
                dlda_t = np.dot(self.Way.T, np.array(dldy).reshape(-1,1)) + np.dot(self.Waa, dLda[t+1]) * (1 - a[t]**2)
            dLda[t] = dlda_t
            #print(dlda_t.shape)

            rec_term = (1-a[t]*a[t])

            dLdWax += np.dot(dlda_t, x[t].reshape(-1,1))*rec_term
            dLdWaa += np.dot(dlda_t, a[t-1].T)*rec_term
            dLdba += dlda_t*rec_term

        return dLdy[::-1], dLdby[::-1], dLdWay, dLdWax, dLdWaa, dLdba

    def UpdateParameters(self,dLdby, dLdWay, dLdWax, dLdWaa, dLdba,learning_rate):
        self.Waa -= learning_rate * dLdWaa
        self.Wax -= learning_rate * dLdWax
        self.Way -= learning_rate * dLdWay
        self.ba -= learning_rate * dLdba
        self.by -= learning_rate * dLdby    

    def predict(self, x, n, a_training):
        # let's calculate the hidden states
        a_future = a_training
        y_predict = []

        # Predict the next n terms
        for ii in range(n):
            a_next = np.tanh(np.dot(self.Waa, a_future[-1]) + np.dot(self.Wax, x[ii]) + self.ba)
            a.append(a_next)
            y_predict.append(np.dot(self.Way, a_next) + self.by)

        return y_predict

训练与测试模型

input_dim = 1
output_dim = 1
hidden_dim = 50

learning_rate = 1e-3

# Initialize The RNN model
rnn_model = SimpleRNN(input_dim, output_dim, hidden_dim)

# train the model for 200 epochs

for epoch in range(200):
    for ii in range(len(X_train)):
        y_pred, a = rnn_model.FeedForward(X_train[ii])
        loss = rnn_model.ComputeLossFunction(y_pred, y_train[ii])
        dLdy, dLdby, dLdWay, dLdWax, dLdWaa, dLdba = rnn_model.ComputeGradients(a, X_train[ii], y_pred, y_train[ii])
        rnn_model.UpdateParameters(dLdby, dLdWay, dLdWax, dLdWaa, dLdba, learning_rate)
        print(f'Loss: {loss}')

y_test_predicted = []
for jj in range(len(X_test)):
    forecasted_values, _ = rnn_model.FeedForward(X_test[jj])
    y_test_predicted.append(forecasted_values[-1])

y_test_predicted_flat = np.array([val[0, 0] for val in y_test_predicted])
trace1 = go.Scatter(y = y_test.ravel(), mode ="lines", name = "original data")
trace2 = go.Scatter(y=y_test_predicted_flat, mode = "lines", name = "RNN output")
layout = go.Layout(title='Testing data Fit', xaxis=dict(title='X-Axis'), yaxis=dict(title='Dependent Variable'))
figure = go.Figure(data = [trace1,trace2], layout = layout)

iplot(figure)

这就带我们结束了本次演示,但希望这只是你深入阅读这些强大模型的开始。你可以通过尝试在前向传递中使用不同的激活函数来测试你的理解。或者进一步阅读像 LSTM 和 Transformer 这样的顺序模型,它们是非常强大的工具,特别是在与语言相关的任务中。探索这些模型可以加深你对处理时间依赖关系的更复杂机制的理解。最后,感谢你花时间阅读本文,希望它对你理解 RNN 及其数学背景有所帮助。

除非另有说明,所有图片均由作者提供

构建持久的数据管道

原文:towardsdatascience.com/building-durable-data-pipelines-cf3cbf68a7e6?source=collection_archive---------3-----------------------#2024-03-03

构建健壮且可持续的 ETL 数据工程技术

💡Mike ShakhomirovTowards Data Science 💡Mike Shakhomirov

·发表于 Towards Data Science ·12 分钟阅读·2024 年 3 月 3 日

--

使用 Kandinsky 生成的 AI 图像

数据管道设计中的数据持久性是数据工程领域一个广为人知的痛点。众所周知,数据可用性和数据质量问题会显著增加非增值任务的时间。在本文中,我将讨论确保数据始终可用的数据管道设计模式。我们将讨论一些可能帮助我们构建可持续数据转化过程的技术,这些过程能够确保数据准时交付,并且我们的数据管道可以被描述为健壮、持久,甚至可能是自我修复的。

如果数据管道失败,员工很可能需要执行一系列手动任务,包括不必要的数据来源获取、汇总和处理,以达到预期的结果。

数据持久性是数据工程中的一个著名风险因素。依我看,它是目前网上讨论最少的话题之一。然而,仅仅因为你看不见这个问题,并不意味着它不存在。数据工程师可能不会经常谈论它,但这个问题确实存在,它在数据从业者中播下了恐惧的种子,使得数据管道设计成为了一项真正的挑战。

数据可用性和数据质量问题可能导致数据处理的进一步延迟…

构建伦理人工智能从数据团队开始——这是为什么

原文:towardsdatascience.com/building-ethical-ai-starts-with-the-data-team-heres-why-ebf0ec7c162b?source=collection_archive---------5-----------------------#2024-03-20

生成性人工智能是一个伦理困境。数据负责人在其中应承担什么责任?本文将探讨伦理人工智能的必要性,以及为什么数据伦理就是人工智能伦理。

Barr MosesTowards Data Science Barr Moses

·发表于 Towards Data Science ·9 分钟阅读·2024 年 3 月 20 日

--

图片由 aniqpixel 提供,来源于 Shutterstock

在科技竞赛中,迅速行动一直是未来成功的标志。

不幸的是,行动过快也意味着我们可能会忽视潜伏的危险。

这是一个古老的故事。你一会儿还在测序史前蚊子的基因,下一秒你就要开设恐龙主题公园,设计世界上第一个失败的超级高铁(但肯定不会是最后一个)。

当谈到生成性人工智能(GenAI)时,生活仿佛在模仿艺术。

无论我们多么希望认为人工智能是一种已知的技术,残酷的现实是,甚至这个技术的创造者们也不能完全确定它是如何工作的

联合健康谷歌甚至加拿大法院等高调的人工智能失误事件之后,是时候考虑我们在哪些地方出了问题。

现在,明确一点,我相信生成式人工智能(以及更广泛的人工智能)最终将对每个行业至关重要——从加速工程工作流程到回答常见问题。然而,要实现人工智能的潜在价值,我们首先必须开始批判性地思考如何开发人工智能应用——以及数据团队在其中的角色。

在本文中,我们将探讨人工智能的三个伦理问题,数据团队的参与方式,以及作为数据领导者的你今天可以做些什么,以提供更加伦理和可靠的人工智能,为未来铺路。

人工智能伦理的三层次

当我与我的同事 Shane Murray——前纽约时报数据与洞察高级副总裁——聊天时,他分享了他第一次遇到真正的伦理困境的经历。在为纽约时报开发一个关于财务激励的机器学习模型时,讨论提出了一个问题:一个能够决定折扣的机器学习模型的伦理影响。

从表面上看,折扣码的机器学习模型似乎是一个相对无害的请求,考虑到所有因素。但是,尽管自动化一些折扣码看起来无害,但从商业问题中剔除人类的同理心,给团队带来了各种伦理上的考虑。

自动化简单但传统上由人类完成的活动,似乎是一个纯粹的务实决策——一个简单的二元选择:是提高效率,还是不提高效率。但一旦你从任何方程中剔除人类的判断,不论是否涉及人工智能,你也失去了直接管理该过程对人类产生的影响的能力。

这是一个真实的问题。

在人工智能开发中,有三个主要的伦理考虑:

1. 模型偏差

这正是我们在纽约时报讨论的核心问题。模型本身是否会带来一些未预见的后果,可能会使某个人相对于其他人占优势或处于不利地位?

这里的挑战是,要设计出一种生成式人工智能,使得——在其他考虑因素相同的情况下——它能够在每次互动中持续提供公平和公正的输出。

2. 人工智能的使用

无疑,人工智能伦理考量中最具存在性——也最有趣的——是理解技术将如何被使用,以及这种使用场景可能对公司或社会带来的影响。

这个人工智能是为了伦理目的而设计的吗?它的使用是否会直接或间接地伤害任何个人或群体?最终,这个模型是否会在长期内带来净收益?

正如伊恩·马尔科姆博士在《侏罗纪公园》第一幕中深刻定义的那样,仅仅因为你能建造某样东西,并不意味着你应该建造它。

3. 数据责任

最后,数据团队最重要的关切(也是我将在本文中大部分时间讨论的内容)是:数据本身如何影响人工智能的构建和负责任使用?

这个问题涉及理解我们使用的数据,在哪些情况下它可以安全使用,以及与之相关的风险。

比如,我们是否知道数据来自哪里,以及它是如何获取的?为某个特定模型提供数据是否存在隐私问题?我们是否在利用任何个人数据,这些数据可能让个体面临不必要的伤害风险?

在不知道它被训练用的是什么数据的情况下,构建在一个封闭源 LLM 上是否安全?

正如《纽约时报》对 OpenAI 提起的诉讼中所强调的——我们是否有权使用这些数据?

这也是我们数据的质量发挥作用的地方。我们能否信任供给特定模型的数据的可靠性?如果质量问题未被解决,允许它们进入 AI 生产环境,可能会产生什么后果?

既然我们已经从 30,000 英尺的高度审视了这些伦理问题,让我们考虑一下数据团队在其中的责任。

为什么数据团队要对 AI 伦理负责

在所有与数据团队相关的伦理 AI 考虑中,最突出的问题无疑是数据责任

就像 GDPR 强迫业务和数据团队合作,重新思考数据是如何被收集和使用的一样,GenAI 将迫使公司重新思考哪些工作流程可以——而哪些不能——被自动化。

尽管作为数据团队,我们确实有责任参与构建任何 AI 模型,但我们无法直接影响其设计结果。然而,通过避免将错误的数据放入该模型,我们可以在很大程度上缓解这些设计缺陷所带来的风险。

如果模型本身超出了我们的控制范围,那么关于能否是否应该的问题就完全是另一个层次了。再次强调,我们有责任在发现问题时指出,但最终,火箭无论我们是否上船,都会发射。

我们能做的最重要的事情是确保火箭安全发射。(或者偷走飞机的机身。)

所以——就像数据工程师生活中的所有领域一样——我们想花费时间和精力的地方,正是我们能为最多人带来最大直接影响的地方。而这个机会就在数据本身。

为什么数据责任对数据团队至关重要

这似乎太显而易见了,但我还是要说一遍:

数据团队需要对数据如何被用于 AI 模型中负责,因为说实话,他们是唯一能够做到这一点的团队。当然,也有合规团队、安全团队,甚至是法律团队会在忽视伦理时承担责任。但无论责任如何分担,最终,这些团队永远无法像数据团队一样深入理解数据。

想象一下,你的软件工程团队使用 OpenAI 或 Anthropic 的第三方 LLM 创建了一个应用程序,但没有意识到你们正在追踪和存储位置数据——除了他们实际上需要的应用数据外,他们还利用了整个数据库来支持模型。若逻辑上存在缺陷,恶意行为者可能会轻松构造一个提示语,利用存储在数据集中的数据追踪任何个人。(这正是开源与闭源 LLM 之间的张力

比如,假设软件团队知道那个位置数据,但他们没有意识到这个位置数据实际上可能是近似的。他们可能使用这些位置数据创建 AI 地图技术,而无意间导致一名 16 岁的少年晚上走进一条黑暗的巷子,而不是走到街角的必胜客。当然,这种错误并非故意的,但它突显了数据使用中固有的意外风险。

这些例子和其他类似的案例凸显了数据团队在伦理 AI 方面作为“看门人”的角色。

那么,数据团队如何保持伦理性呢?

在大多数情况下,数据团队习惯于处理近似数据和代理数据,以使他们的模型正常工作。但当涉及到为 AI 模型提供数据时,实际上你需要更高水平的验证。

为了有效地为消费者站稳脚跟,数据团队需要有意识地审视自己的数据实践,以及这些实践与整个组织的关系。

在我们考虑如何减轻 AI 的风险时,以下是数据团队必须采取的三步措施,以推动 AI 走向更加伦理的未来。

1. 获取席位

数据团队不是鸵鸟——他们不能埋头沙里,希望问题会消失。就像数据团队曾为获得领导层席位而奋斗一样,数据团队还需要为在 AI 领域中争取到一个席位而努力。

就像任何数据质量的应急演练一样,事后再跳进混战并不足够。当我们面对生成型 AI 所固有的存在性风险时,比以往任何时候都更需要主动应对我们个人的责任。

如果他们不让你坐在桌子旁,那么你有责任从外部进行教育。竭尽全力提供出色的发现、治理和数据质量解决方案,以便为那些掌舵的团队提供信息,使他们能够做出关于数据的负责任决策。教他们什么时候使用什么工具,并说明无法通过你们团队内部协议验证的第三方数据的使用风险。

这不仅仅是一个商业问题。正如 United Healthcare 和不列颠哥伦比亚省所证明的那样,在许多情况下,这些事关的是人们的生命——和生计——。因此,让我们确保从这个角度来操作。

2. 利用像 RAG 这样的方式策划更负责任的 — 以及更可靠的 — 数据

我们常常将检索增强生成(RAG)视为从 AI 中创造价值的一种资源。但它同样也是一项资源,能保障如何构建和使用该 AI。

例如,假设一个模型正在访问私人客户数据,并将其用于面向消费者的聊天应用。一个正确的用户提示可能会让各种关键的个人身份信息(PII)泄露出来,供不法分子利用。因此,验证和控制这些数据来源的能力对于保护 AI 产品的完整性至关重要。

有经验的数据团队通过利用像 RAG 这样的方式,大大降低了这些风险,精心策划符合规范、更安全、更适合模型的数据。

采取 RAG 方法开发 AI 还帮助最小化与摄取过多数据相关的风险 — 就像我们在位置数据的例子中提到的那样。

那么,实践中这看起来是什么样的呢?假设你是一家像 Netflix 这样的媒体公司,需要利用一定程度的客户数据和自有内容数据来创建个性化推荐模型。一旦你定义了该用例的特定 — 且有限 — 数据点,你就能更有效地定义:

  1. 谁负责维护和验证这些数据,

  2. 在什么情况下这些数据可以安全使用,

  3. 而且,谁最适合随着时间的推移来构建和维护这个 AI 产品。

像数据血缘(data lineage)这样的工具也能派上用场,它能帮助团队快速验证数据的来源,以及这些数据在团队的 AI 产品中是如何被使用 — 或误用 — 的。

3. 优先考虑数据可靠性

当我们谈论数据产品时,我们常常说“垃圾进,垃圾出”,但在生成式 AI(GenAI)的情况下,这个格言有些不完全准确。实际上,当垃圾数据输入到 AI 模型中时,输出的就不仅仅是垃圾 — 它还会带来真正的人类后果。

这就是为什么,除了需要一个 RAG 架构来控制输入模型的数据外,你还需要强大的数据可观察性,并连接到像Pinecone这样的向量数据库,确保数据实际上是干净、安全和可靠的。

我从开始使用 AI 的客户那里听到的最常见的抱怨之一是,如果你没有积极监控向向量数据管道中索引的输入数据,就几乎不可能验证数据的可信度。

事与愿违,数据和 AI 工程师往往只有在模型输出错误的提示响应时,才知道数据出了问题 — 而那时,已经为时已晚。

现在正是最佳时机

对更高数据可靠性和可信度的需求正是促使我们团队在 2019 年创建数据可观测性类别的动力。

今天,随着人工智能承诺颠覆我们日常依赖的许多过程和系统,数据质量的挑战——更重要的是,数据质量的伦理影响——变得更加严峻。

构建、评估和跟踪本地高级 RAG 系统 | Mistral 7b + LlamaIndex + W&B

原文:towardsdatascience.com/building-evaluating-and-tracking-a-local-advanced-rag-system-mistral-7b-llamaindex-w-b-5c9c69059f92?source=collection_archive---------1-----------------------#2024-01-19

探索如何在计算机上构建高级 RAG 系统。提供完整的、逐步的指南和代码。

Nikita KiselovTowards Data Science Nikita Kiselov

·发表于 Towards Data Science ·9 分钟阅读·2024 年 1 月 19 日

--

作者提供的图片 | Mistral + LlamaIndex + W&B

检索增强生成(RAG)是一种强大的自然语言处理技术,结合了大语言模型与对知识的选择性访问。它通过提供来自文档的相关上下文片段,帮助减少大语言模型的幻觉。本文的目的是展示如何使用本地运行的大语言模型构建 RAG 系统,介绍哪些技术可以用于改进它,以及如何在 W&B 中跟踪实验并比较结果。

引言

我们将涵盖以下关键方面:

  1. 使用 Mistral-7b 和 LlamaIndex 构建基准本地 RAG 系统。

  2. 评估其在忠实性相关性方面的表现。

  3. 跟踪使用 Weights & Biases (W&B)的端到端实验。

  4. 实施高级 RAG技术,如层次节点和重新排序。

完整的笔记本,包括详细的注释和完整的代码,可以在GitHub 上查看

🏠 构建本地 RAG 系统

构建 Fill.sg,一个 GenAI 报告工具包

原文:towardsdatascience.com/building-fill-sg-genai-report-toolkit-launch-afc8dfdacd78?source=collection_archive---------2-----------------------#2024-06-24

让 AI 为你写重复的报告,实现你的梦想。了解我们如何在两周内构建 Fill.sg,敬请关注 LAUNCH!

Nicole RenTowards Data Science Nicole Ren

·发表于Towards Data Science ·14 分钟阅读·2024 年 6 月 24 日

--

目录

  1. 介绍

  2. 问题陈述

  3. 我们的解决方案

  4. Fill.sg 背后的提示

    ∘ 大规模语言模型的突破:长文本模型

    ∘ 我们的提示方法

    ∘ 报告生成:分而治之

  5. UI/UX 考虑:为用户友好的 GenAI 工具设计

    ∘ 构建一个包容性的 AI 工具

    ∘ 定制界面用于编辑和审阅

  6. 黑客马拉松后:未来发展的潜力

  7. 结论

  8. 如何参与 LAUNCH!

  9. 致谢

介绍

梦之队。(从左到右)黑客马拉松团队:Li Shing、James、Nicole、Gawain。问题负责人:Alexia、Joy。GovTech FDT:Xuean,图像由作者提供

我们是来自社会与家庭发展部(MSF)和 GovTech 数据科学与人工智能部门的团队。我们共同致力于解决报告写作繁琐且耗时的问题,组成了团队,从创意到原型构建共同推动 Fill.sg 的开发。仅在两周内,我们完成了用户调研,构建了原型,并收集了初步的用户反馈,以评估解决方案的可行性。本文分享了我们在 2024 年 4 月参加的第一次LAUNCH!黑客马拉松中的历程以及我们开发解决方案的方法。

问题陈述

背景

当 ChatGPT 首次亮相时,它让我们看到了智能聊天机器人的潜力,远超我们之前所见的任何技术。这一突破激发了我们的想象力,鼓励我们探索从食谱创作到跨领域企业用例及其业务功能等逐步扩展的问题解决方案。

同样,新加坡政府的各个机构也强烈希望利用 AI 更好地为市民和公务员服务。在短短 12 个月的时间里,我们已经看到了超过 400 个多样化的创意。这些创意源自长期存在的痛点,AI 为解决这些痛点提供了可能性。这些痛点各不相同,各具独特挑战。在 GovTech,我们尽力在这个问题领域内解决尽可能多的问题——使用“问题空间”的概念。

为什么我们选择解决报告写作这一问题领域?

一个引起我们关注的关键问题领域是如何支持工作人员以更高效的方式起草报告。写报告是我们作为公共服务人员职责的重要组成部分——从简单的会议记录到更复杂的经济报告和法院报告。虽然我们的初衷不是用 AI 替代需要专业判断和评估的决策任务,但我们看到了利用 AI 合成和组织信息以帮助写报告的潜力。复杂的报告可能需要几个小时,甚至几天时间,并且需要从各种来源(包括图表、文本、Excel 表格等)中整合大量信息。同一类型的报告通常会为不同的案例多次撰写,且格式相同,这样的重复工作很容易变得单调乏味。显然,一款能够帮助起草 50% 重复报告的模板工具,将为公共服务人员节省大量时间,使他们能够通过审查和修改报告以确保其准确性,而非从头开始编写报告,从而将精力集中在更重要的任务上。

然而,这个问题领域非常困难且复杂——具体来说,如何抽象出处理各种长度的信息源的方法,指引大型语言模型(LLMs)提取关键信息,并生成相关输出?每个步骤都至关重要,只有在正确的背景下完成,才能生成优质报告。

考虑到这一点,我们开始了为期两周的努力,旨在让报告写作变得更轻松。我们的目标是减轻公务员繁琐的行政任务,让他们可以将更多时间投入到与市民互动和提供支持上。

我们的解决方案

介绍 Fill.sg 及其所提供的服务

登录页面,图片由作者提供

Fill.sg 是一款 Web 应用程序,它通过 AI 为你生成报告,帮助你实现让报告写作变得更简单、更高效、更快速的梦想,让你可以专注于更重要的任务。

业务用户流程

Fill.sg 提供了一个界面,供业务用户策划模块化和多功能的模板,以生成结构化报告。简而言之,用户可以选择一个先前定义的模板,上传多个非结构化或结构化的文本文件作为报告的上下文,然后生成一个完整的报告,无需触及键盘。报告甚至可以导出为 Microsoft Word 格式,保留标题和表格的格式。

在 Fill.sg 中创建的单个模板可以被重复使用,以生成多个具有相同结构的报告。例如,企业报告模板可以被重复使用,通过提供不同的上下文来生成关于公司 A、B、C 等的报告。

业务用户流程(示例仅供参考),图片来自作者

在上面的演示中,用户能够上传文档并使用这些文档作为上下文来生成报告。后台的 AI 会将这些上下文文档用作生成定制报告的依据。一旦生成,用户可以将其下载为 Word 文档(.docx),并且保留标题和表格的格式。

超级用户流程

超级用户是具备技术和领域知识的用户,能够理解如何正确提示 LLM 来填充报告模板的每个部分。这些超级用户在工具的成功中扮演着至关重要的角色,因为他们不仅拥有足够的领域知识,还具备有关提示工程的技术专长,可以指导 LLM 填充报告模板的各个部分。

超级用户可以进入编辑模式,在该模式下他们可以编辑模板的结构并添加新的生成模块。每个生成模块的目的是填充报告的特定部分。一旦模板创建并保存,业务用户将能够使用已策划的模板生成多个相同结构的报告。

超级用户流程(示例仅供参考),图片来自作者

在上述演示中,超级用户首先上传了一组示例上下文文档,这些文档用于预览模板生成。然后,他们进入编辑面板编辑模板。对于报告中的每个新部分,用户都会添加一个新的生成模块,在该模块中他们能够配置生成设置并指示模板该部分应生成什么内容。一旦生成设置被保存,LLM 会基于示例上下文文档生成一个样本结果,超级用户可以验证生成的预览。一旦超级用户对模板满意,他们就可以保存模板,并使其可供业务用户使用。

拥有简单、模块化且可编辑的模板使代理用户能够自力更生地使用该工具,因为他们可以创建和修改模板以适应不断变化的业务需求。

Fill.sg 背后的提示

LLM 的突破:长上下文模型

在过去几个月中,领先 LLM 的上下文窗口大小迅速增加。例如,OpenAI 的 GPT-4-Turbo 的上下文窗口为 128,000 个令牌,大约是其前身 GPT-4–32k 的 400%。术语“上下文窗口”指的是 LLM 在生成响应时可以考虑的令牌数量。

拥有更长的上下文窗口意味着可以通过提示向大型语言模型(LLM)提供更多信息,并且通常能表明 LLM 在管理更多令牌时的语义能力。

这种能力解决了 RAG 工作流中的一些初步挑战。我们不再优化分块、搜索和检索策略,而是可以使用上下文提示,并指示 LLM 根据相关来源进行参考。例如,我们可以将整个输入文档提供给 LLM,指示它专注于特定部分,并根据我们给出的指令提供输出(无论是项目符号、段落还是表格)。

我们的提示方法

对于此用例,我们通过在提示中提供更多相关信息(包括整个文档)来将其融入我们的解决方案。在我们的实验中,这种方法已被证明是有效的,假设输入文档与每个报告相关。

在这两周中,我们采取了迭代的提示工程方法来编写、评估和优化提示:

  • 编写初始提示,利用系统、用户和/或助手角色在概述任务定义和上下文时,作为起点。

  • 评估LLM 的响应是否符合预期输出,使用一致的成功标准,无论是通过人工评估还是像LLM-as-a-Judge 方法中的自我评估。

  • 根据评估结果,优化提示以提高性能,例如通过添加澄清或约束来引导 LLM 的响应

我们评估中的关键成功标准是能够跨多个报告部分和格式进行泛化,从而生成段落、表格、项目符号列表,甚至受限选择,以满足典型报告的需求。

我们精心设计的提示作为基础,抽象化提示工程中的挑战,并允许我们的最终用户提供领域特定的输入。这意味着,Fill.sg 的用户只需专注于提供领域特定的信息,如特定报告部分的标题和描述,而无需担心提示工程的繁琐细节。

报告生成:分而治之

单次提示生成的问题

对于任何尝试过使用单一提示生成完整报告的人来说,你一定知道,这通常效果不佳;输出往往过短,而且在第三段之后开始出现幻觉,后续部分原本要求的表格反而变成了一堆文字。

之所以会发生这种情况,是因为 LLM 通常并没有针对生成包含多种格式的极长报告进行训练,例如在单次响应中包含表格、文本或项目符号等格式。我们已经看到,当 LLM 一次只执行一个任务并生成单一格式时,它的表现会更好,而不是同时执行多个任务,更不用说在同一输出中处理不同格式了。

较小的但更多的模块化提示可能更具优势

在软件工程中,通常的做法是将复杂系统拆解为模块化组件。当这个原则应用到大语言模型(LLM)的任务时,我们发现它同样有效。

为了改善指导 LLM 在单一提示中生成完整报告的问题,我们深入研究了报告的写作方式,看看如何将这一复杂任务拆解。我们观察到一个趋势——大多数标准报告往往有多个部分,每个部分描述一个特定主题,通常采用单一格式。这可以为我们带来优势,因为我们可以将写作完整报告这一复杂任务分解成一个个小任务——即为每个部分写作并指定具体的输出要求。

通过分节生成任务的方式,可以帮助模型产生更好的输出,因为每个部分可以作为单独的任务进行分配,同时可以在每个部分的提示中注入局部上下文,为 LLM 提供更清晰的指示,帮助其更好地理解目标。此外,我们还可以为每个生成部分指定预期的类型,这样我们可以更有效地引导生成过程并验证输出格式。

除了结构化模块化提示在生成更高质量内容方面的好处外,模块化提示的优势还在于它能够简化编写、修改和调试的过程。模块化提示不仅有助于为 LLM 提供更清晰、更好的指示,还帮助开发者在迭代开发提示时更加高效。

生成模块与生成类型

在我们的应用中,我们将每个生成任务发生的部分称为生成模块(Generation Blocks)。这些生成模块会设置特定的生成类型,以便我们能够对模型生成的输出施加特定约束。

在我们的案例中,我们为黑客马拉松定下了几种生成类型来实施:

  • 长文本生成:长段落的文本

  • 表格生成:以表格格式输出,列由设置指定

  • 项目符号生成:以项目符号形式生成的输出

  • 选择生成:从用户定义的预设值列表中选择最合适的值作为输出

以下是每种生成类型的演示。如下面所示,应用程序允许用户轻松编辑内容,且基于报告要求的预配置设置。

长文本生成

长文本生成(示例仅用于说明),图片来源:作者

表格生成

表格生成(示例仅用于说明),图片来源:作者

项目符号生成

项目符号生成(示例仅用于说明),图片来源:作者

选择生成

选择生成(示例仅用于说明),图片来源:作者

用户友好型 GenAI 工具的 UI/UX 考虑因素

构建一个包容性的 AI 工具

我们在第一次用户访谈中获得了一个重要的教训。超级用户给出了很好的反馈,他们能够快速跟随我们最初提出的自定义模板流程。当我们展示这些想法时,他们也提出了如何改进工具的新想法。然而,我们注意到商业用户更愿意轻松完成报告任务,而不需要自定义任何模板。

这让我们明白,尽管技术可能足够强大来解决问题,但我们需要为具有不同背景和技术亲和力的用户设计工具。因此,我们对 Fill.sg 进行了迭代,并在设计时考虑了两类用户——超级用户商业用户

用户流程隔离(示例仅用于说明),图片来源:作者

定制化编辑和审查界面

Fill.sg 的目的是减少撰写报告所需的时间,同时平衡确保用户在使用任何生成内容时承担相应责任的需求。因此,我们希望在工作流程中保留用户的控制机制。用户需要确保 AI 生成的内容经过认真审查,并检查错误。因此,考虑到 AI 安全性,我们尽可能使编辑和审查体验流畅。我们为应用程序配备了一个合适的所见即所得(WYSIWYG)编辑器 Tiptap,提供了一个定制的图形用户界面,以便以更友好的方式与 AI 互动。

在当前可用的工具下,用户通常使用聊天界面来撰写报告。这种体验中存在几个痛点:

  1. 顺序格式使得难以并行提示 LLM,这意味着用户必须等待输出结果后才能发送下一个查询。

  2. 在聊天界面和实际文档之间需要大量的复制粘贴操作。

  3. 用户无法重用之前的聊天记录来生成相同结构的报告。

编辑器界面,图片由作者提供

使用编辑器界面而非线性聊天界面是有益的,因为它解决了标准方法中所有上述问题。

  1. 拥有并排的编辑器和预览面板,使用户能够在 LLM 在后台并行生成预览的同时,持续编辑模板。这意味着用户无需等待 LLM 生成即可继续编辑模板。

  2. 不再需要复制粘贴,因为 WYSIWYG 编辑器可以通过正确的设置直接导出为 Word。用户可以直接在我们的应用程序中进行编辑,然后直接将报告导出为 Word。

  3. 报告模板可以保存并在后续多个报告中重复使用。

Tiptap 是一个非常好的选择,因为它提供了多种生活质量功能,我们可以将其提供给用户,以改善用户体验,从而减少整理和阅读结构化报告的痛苦。此外,它还为新改进提供了空间,例如提供多用户协作和进一步的定制,以改善阅读和写作体验。

后黑客马拉松:未来发展的潜力

多模态输入

在本文撰写时,OpenAI 最近发布了一系列激动人心的关于新模型的公告。在一个 26 分钟的演示中,OpenAI 展示了 GPT-4o(“o”代表“全能”),这是迈向更加自然的人机交互的一步。该模型接受任何文本、音频、图像和视频的组合作为输入,并生成任何文本、音频和图像的组合作为输出。关键的是,由于我们在此用例中的方法是通过上下文提示,增强的标记化器压缩使得处理相同信息量所需的标记数量更少。

这对我们的用例来说尤其令人兴奋。正如我们所知,写报告需要一个人综合不同的输入,如文本、图像/信息图、图表和访谈脚本。LLM 在上下文窗口、标记限制和输入格式方面存在一些局限性,这使得为报告写作构建通用解决方案成为一项特别困难的工程壮举。

生成类型的扩展

尽管我们已定义的基本生成类型非常丰富,可以满足大多数重复性报告的需求,但报告写作过程仍有更多自动化和增强的可能性。我们还考虑了其他可能实施的生成类型:

  • 图表生成:使用函数调用代理输出图表

  • 数据表生成:输出带有特定聚合的数据表

  • 时间生成:输出日期、时间或持续时间

  • 图形生成:输出绘制出基于给定上下文的关系图

这些新扩展不仅解决了当前报告生成中的问题,而且还可能大大增强并改变我们写报告的方式。

结论

通过 LAUNCH!黑客松活动,我们开发了 Fill.sg——一款由大型语言模型驱动的网页应用程序,用于自动化报告编写。通过允许用户创建可重用的模板并从非结构化数据源生成报告,Fill.sg 为所有写报告的公共部门工作人员节省了大量时间和精力。

人工智能发展迅速,但业务逻辑更难改变,因为它涉及政策考量。因此,这款应用程序的一般方向是保持业务逻辑和用户需求,同时构建一个灵活的基础设施和前端体验,使其能够包含来自更强大 AI 模型及其外部工具的可能性。

展望未来,Fill.sg 可能会利用多模态人工智能的最新发展,这种技术能够理解除文本外的输入,例如图像、音频和视频,可能将工具的能力提升到不可想象的程度。

Fill.sg 代表了实现我们利用人工智能生成报告愿景的一小步。我们希望通过这一原型的学习和经验,能够激励政府中的其他有抱负的开发者,开发和整合人工智能,以更好地服务于公共部门工作人员和市民。

如何参与 LAUNCH!

LAUNCH! 是一项创新计划,旨在将伟大的想法转化为对公共部门产生影响的解决方案。由 GovTech 牵头,并与多个政府机构以及诸如MicrosoftAmazon Web Services (AWS)Databricks等知名行业合作伙伴共同合作,LAUNCH! 旨在推动在公共部门内培养创新和合作的文化。有兴趣的公共部门工作人员可以联系 LAUNCH!的组织者,了解如何贡献创意或在团队、部门、职能或组织内举办本地化的黑客松活动。您可以通过go.gov.sg/govtech-launch访问有关 LAUNCH!的官方网站。

致谢

感谢黑客松团队在这充实的两周中付出的努力:Chan Li Shing(产品经理)、Gawain Yeo(业务负责人)、James Teo(数据工程师)和 Nicole Ren(数据工程师),以及提供宝贵反馈的用户们!

特别感谢以下为本文作出贡献的人:Alexia Lee(MSF)| Chan Li Shing(GovTech)| Gawain Yeo(MSF)| James Teo(GovTech)| Lim Hock Chuan(GovTech)| Mindy Lim(GovTech)| Nicole Ren(GovTech)| Terrance Goh(MSF)

使用 LLM 图变换器构建知识图谱

原文:towardsdatascience.com/building-knowledge-graphs-with-llm-graph-transformer-a91045c49b59?source=collection_archive---------0-----------------------#2024-11-05

深入探讨 LangChain 在使用 LLM 构建图谱方面的实现

Tomaz BratanicTowards Data Science Tomaz Bratanic

·发表于 Towards Data Science ·阅读时长 17 分钟·2024 年 11 月 5 日

--

构建知识图谱。图像来自 ChatGPT。

从文本创建图谱令人非常兴奋,但无疑也充满挑战。本质上,这就是将非结构化文本转化为结构化数据。虽然这种方法已经存在一段时间,但随着大型语言模型(LLMs)的出现,它获得了显著的关注,逐渐走向主流。

从文本中提取实体和关系来构建知识图谱。图像来自作者。

在上图中,您可以看到信息提取如何将原始文本转换为知识图谱。在左侧,多个文档展示了关于个人及其与公司之间关系的非结构化句子。在右侧,这些相同的信息以图形的形式展示,图中展示了谁曾在或创办了不同的组织。

那么,为什么你想从文本中提取结构化信息并将其表示为图形呢?一个关键原因是为检索增强生成(RAG)应用程序提供支持。虽然在非结构化文本上使用文本嵌入模型是一种有用的方法,但当涉及到回答复杂的多跳问题时,它可能不够有效,因为这些问题需要理解多个实体之间的连接,或者问题要求进行如过滤、排序和聚合等结构化操作。通过从文本中提取结构化信息并构建知识图谱,你不仅能更有效地组织数据,还能为理解实体之间的复杂关系创建一个强大的框架。这种结构化的方法使得检索和利用特定信息变得更加容易,从而扩展了你能够回答的问题类型,同时提供更高的准确性。

大约一年前,我开始了使用 LLM 构建图形的实验,由于越来越多的兴趣,我们决定将这一功能集成到 LangChain 中,成为LLM 图形转换器。在过去的一年里,我们获得了宝贵的见解并引入了新功能,我们将在这篇博客文章中展示这些内容。

代码可以在GitHub上找到。

设置 Neo4j 环境

我们将使用 Neo4j 作为底层图形存储,它提供开箱即用的图形可视化。最简单的开始方式是使用免费的Neo4j Aura实例,它提供云实例的 Neo4j 数据库。或者,你也可以通过下载Neo4j Desktop应用程序并创建本地数据库实例来设置 Neo4j 数据库的本地实例。

from langchain_community.graphs import Neo4jGraph

graph = Neo4jGraph(
    url="bolt://54.87.130.140:7687",
    username="neo4j",
    password="cables-anchors-directories",
    refresh_schema=False
)

LLM 图形转换器

LLM 图形转换器被设计为提供一个灵活的框架,用于使用任何 LLM 构建图形。由于有如此多不同的提供商和模型可用,这个任务远非简单。幸运的是,LangChain 介入处理了大部分标准化过程。至于 LLM 图形转换器本身,它就像是两只猫堆叠在一件外套里——具有在两种完全独立的模式下操作的能力。

LLM 图形转换器由两种不同的模式组成,用于从文本中提取图形。图片由用户提供。

LLM 图形转换器在两种不同的模式下操作,每种模式都旨在在不同场景下使用 LLM 从文档中生成图形。

  1. 基于工具的模式(默认): 当 LLM 支持结构化输出或函数调用时,这种模式利用LLM 内建的[with_structured_output](https://python.langchain.com/docs/how_to/structured_output/)来使用工具。工具规范定义了输出格式,确保实体和关系以结构化、预定义的方式被提取。这在图像的左侧显示,其中展示了NodeRelationship类的代码。

  2. 基于提示的模式(回退模式): 当 LLM 不支持工具或函数调用时,LLM 图形转换器会回退到纯粹由提示驱动的方法。这种模式使用少量提示来定义输出格式,引导 LLM 以基于文本的方式提取实体和关系。然后,结果会通过一个自定义函数进行解析,该函数将 LLM 的输出转换为 JSON 格式。这个 JSON 会用来填充节点和关系,就像在基于工具的模式中一样,但在这里 LLM 完全由提示引导,而不是通过结构化工具。这在图像的右侧显示,其中提供了一个示例提示和相应的 JSON 输出。

这两种模式确保 LLM 图形转换器可以适应不同的 LLM,使其能够通过工具直接构建图形,或者通过解析基于文本的提示输出构建图形。

请注意,即使是支持工具/函数的模型,你也可以通过设置属性*ignore_tools_usage=True*来使用基于提示的提取。

基于工具的提取

我们最初选择了基于工具的提取方法,因为它减少了大量提示工程和自定义解析函数的需求。在 LangChain 中,with_structured_output方法允许你使用工具或函数提取信息,输出可以通过 JSON 结构或 Pydantic 对象来定义。就个人而言,我发现 Pydantic 对象更清晰,因此我们选择了这种方式。

我们首先定义一个Node类。

class Node(BaseNode):
    id: str = Field(..., description="Name or human-readable unique identifier")
    label: str = Field(..., description=f"Available options are {enum_values}")
    properties: Optional[List[Property]]

每个节点都有一个id、一个label和可选的properties。为了简洁起见,我在这里没有包含详细的描述。描述 ID 作为人类可读的唯一标识符很重要,因为一些大型语言模型(LLMs)往往以更传统的方式理解 ID 属性,例如随机字符串或递增的整数。而我们希望实体的名称作为 ID 属性来使用。我们还通过在label描述中简单列出可用的标签类型来限制可用的标签类型。此外,像 OpenAI 这样的 LLM 支持enum参数,我们也在使用这个参数。

接下来,我们来看一下Relationship类。

class Relationship(BaseRelationship):
    source_node_id: str
    source_node_label: str = Field(..., description=f"Available options are {enum_values}")
    target_node_id: str
    target_node_label: str = Field(..., description=f"Available options are {enum_values}")
    type: str = Field(..., description=f"Available options are {enum_values}")
    properties: Optional[List[Property]]

这是Relationship类的第二次迭代。最初,我们使用嵌套的Node对象表示源节点和目标节点,但我们很快发现嵌套对象降低了提取过程的准确性和质量。因此,我们决定将源节点和目标节点平铺为独立的字段——例如,source_node_idsource_node_label,以及target_node_idtarget_node_label。此外,我们在节点标签和关系类型的描述中定义了允许的值,以确保 LLM 遵守指定的图形模式。

基于工具的提取方法使我们能够为节点和关系定义属性。以下是我们用来定义这些属性的类。

class Property(BaseModel):
    """A single property consisting of key and value"""
    key: str = Field(..., description=f"Available options are {enum_values}")
    value: str

每个Property都被定义为一个键值对。虽然这种方法很灵活,但也有其局限性。例如,我们不能为每个属性提供独特的描述,也不能指定某些属性为必填项而其他为可选项,因此所有属性都被定义为可选。此外,属性并不是为每种节点或关系类型单独定义的,而是共享的。

我们还实现了一个详细的系统提示,帮助指导提取过程。不过根据我的经验,功能和参数描述往往比系统消息更具影响力。

不幸的是,目前没有简单的方法来定制 LLM 图形变换器中的功能或参数描述。

基于提示的提取

由于只有少数商业化的 LLM 和 LLaMA 3 支持本地工具,我们为不支持工具的模型实现了回退机制。即使使用支持工具的模型,你也可以设置ignore_tool_usage=True来切换到基于提示的方法。

大部分基于提示方法的提示工程和示例由Geraldus Wilsen贡献。

在基于提示的方法中,我们必须直接在提示中定义输出结构。你可以在这里找到完整的提示。在这篇博客文章中,我们将做一个高层次的概述。我们从定义系统提示开始。

You are a top-tier algorithm designed for extracting information in structured formats to build a knowledge graph. Your task is to identify the entities and relations specified in the user prompt from a given text and produce the output in JSON format. This output should be a list of JSON objects, with each object containing the following keys:

- **"head"**: The text of the extracted entity, which must match one of the types specified in the user prompt.
- **"head_type"**: The type of the extracted head entity, selected from the specified list of types.
- **"relation"**: The type of relation between the "head" and the "tail," chosen from the list of allowed relations.
- **"tail"**: The text of the entity representing the tail of the relation.
- **"tail_type"**: The type of the tail entity, also selected from the provided list of types.

Extract as many entities and relationships as possible. 

**Entity Consistency**: Ensure consistency in entity representation. If an entity, like "John Doe," appears multiple times in the text under different names or pronouns (e.g., "Joe," "he"), use the most complete identifier consistently. This consistency is essential for creating a coherent and easily understandable knowledge graph.

**Important Notes**:
- Do not add any extra explanations or text.

在基于提示的方法中,一个关键区别是我们要求 LLM 仅提取关系,而不是单独的节点。这意味着我们不会有任何孤立节点,与基于工具的方法不同。此外,由于缺乏本地工具支持的模型通常表现较差,我们不允许提取任何属性——无论是节点还是关系,以保持提取结果的简洁性。

接下来,我们向模型添加了一些少量示例。

examples = [
    {
        "text": (
            "Adam is a software engineer in Microsoft since 2009, "
            "and last year he got an award as the Best Talent"
        ),
        "head": "Adam",
        "head_type": "Person",
        "relation": "WORKS_FOR",
        "tail": "Microsoft",
        "tail_type": "Company",
    },
    {
        "text": (
            "Adam is a software engineer in Microsoft since 2009, "
            "and last year he got an award as the Best Talent"
        ),
        "head": "Adam",
        "head_type": "Person",
        "relation": "HAS_AWARD",
        "tail": "Best Talent",
        "tail_type": "Award",
    },
...
]

在这种方法中,目前不支持添加自定义的少量示例或额外指令。唯一的定制方式是通过 prompt 属性修改整个提示。扩展定制选项是我们正在积极考虑的事项。

接下来,我们将看看如何定义图谱架构。

定义图谱架构

在使用 LLM 图谱转换器进行信息提取时,定义图谱架构对于指导模型构建有意义且结构化的知识表示至关重要。一个明确定义的图谱架构指定了要提取的节点和关系的类型,以及与每个节点或关系相关的任何属性。这个架构作为蓝图,确保 LLM 始终以符合预期知识图谱结构的方式提取相关信息。

在这篇博文中,我们将使用玛丽·居里维基百科页面的开头段落进行测试,并在末尾添加一段关于罗宾·威廉姆斯的句子。

from langchain_core.documents import Document

text = """
Marie Curie, 7 November 1867 – 4 July 1934, was a Polish and naturalised-French physicist and chemist who conducted pioneering research on radioactivity.
She was the first woman to win a Nobel Prize, the first person to win a Nobel Prize twice, and the only person to win a Nobel Prize in two scientific fields.
Her husband, Pierre Curie, was a co-winner of her first Nobel Prize, making them the first-ever married couple to win the Nobel Prize and launching the Curie family legacy of five Nobel Prizes.
She was, in 1906, the first woman to become a professor at the University of Paris.
Also, Robin Williams.
"""
documents = [Document(page_content=text)]

在所有示例中,我们还将使用 GPT-4o。

from langchain_openai import ChatOpenAI
import getpass
import os

os.environ["OPENAI_API_KEY"] = getpass.getpass("OpenAI api key")

llm = ChatOpenAI(model='gpt-4o')

首先,让我们看看在没有定义任何图谱架构的情况下,提取过程是如何工作的。

from langchain_experimental.graph_transformers import LLMGraphTransformer

no_schema = LLMGraphTransformer(llm=llm)

现在我们可以使用 aconvert_to_graph_documents 函数处理文档,该函数是异步的。推荐在 LLM 提取中使用异步方式,因为它允许并行处理多个文档。这种方法可以显著减少等待时间并提高吞吐量,尤其是在处理多个文档时。

data = await no_schema.aconvert_to_graph_documents(documents)

LLM 图谱转换器的响应将是一个图谱文档,其结构如下:

[
    GraphDocument(
        nodes=[
            Node(id="Marie Curie", type="Person", properties={}),
            Node(id="Pierre Curie", type="Person", properties={}),
            Node(id="Nobel Prize", type="Award", properties={}),
            Node(id="University Of Paris", type="Organization", properties={}),
            Node(id="Robin Williams", type="Person", properties={}),
        ],
        relationships=[
            Relationship(
                source=Node(id="Marie Curie", type="Person", properties={}),
                target=Node(id="Nobel Prize", type="Award", properties={}),
                type="WON",
                properties={},
            ),
            Relationship(
                source=Node(id="Marie Curie", type="Person", properties={}),
                target=Node(id="Nobel Prize", type="Award", properties={}),
                type="WON",
                properties={},
            ),
            Relationship(
                source=Node(id="Marie Curie", type="Person", properties={}),
                target=Node(
                    id="University Of Paris", type="Organization", properties={}
                ),
                type="PROFESSOR",
                properties={},
            ),
            Relationship(
                source=Node(id="Pierre Curie", type="Person", properties={}),
                target=Node(id="Nobel Prize", type="Award", properties={}),
                type="WON",
                properties={},
            ),
        ],
        source=Document(
            metadata={"id": "de3c93515e135ac0e47ca82a4f9b82d8"},
            page_content="\nMarie Curie, 7 November 1867 – 4 July 1934, was a Polish and naturalised-French physicist and chemist who conducted pioneering research on radioactivity.\nShe was the first woman to win a Nobel Prize, the first person to win a Nobel Prize twice, and the only person to win a Nobel Prize in two scientific fields.\nHer husband, Pierre Curie, was a co-winner of her first Nobel Prize, making them the first-ever married couple to win the Nobel Prize and launching the Curie family legacy of five Nobel Prizes.\nShe was, in 1906, the first woman to become a professor at the University of Paris.\nAlso, Robin Williams!\n",
        ),
    )
]

图谱文档描述了提取的 节点关系。此外,提取的源文档会添加在 source 键下。

我们可以使用 Neo4j 浏览器来可视化输出,从而更清晰、更直观地理解数据。

没有定义图谱架构的情况下,对同一数据集进行两次提取的可视化。图片来自作者。

上面的图片显示了两次提取同一段关于玛丽·居里的段落。在这种情况下,我们使用了 GPT-4 和基于工具的提取方法,这也允许存在孤立的节点,如图中所示。由于没有定义图谱架构,LLM 在运行时决定提取哪些信息,这可能导致输出的变化,即使是同一段落。因此,有些提取比其他的更详细,结构也可能有所不同,即使是相同的信息。例如,在左侧,玛丽被表示为诺贝尔奖的获奖者,而在右侧,她被表示为赢得了诺贝尔奖。

现在,让我们尝试使用基于提示的方法进行相同的提取。对于支持工具的模型,可以通过设置 ignore_tool_usage 参数来启用基于提示的提取。

no_schema_prompt = LLMGraphTransformer(llm=llm, ignore_tool_usage=True)
data = await no_schema.aconvert_to_graph_documents(documents)

同样,我们可以在 Neo4j 浏览器中可视化两次独立的执行。

使用基于提示的方法,在没有定义图模式的情况下对相同数据集进行两次提取的可视化。图像来源:作者。

采用基于提示的方法,我们不会看到任何孤立的节点。然而,与之前的提取一样,模式在每次运行之间可能会有所不同,导致相同输入的输出结果不同。

接下来,让我们逐步了解如何通过定义图模式来帮助产生更一致的输出。

定义允许的节点

限制提取的图结构可以非常有益,因为它指导模型聚焦于特定的相关实体和关系。通过定义一个明确的模式,你可以提高提取的一致性,使输出结果更加可预测,并与实际需要的信息对齐。这减少了不同运行之间的变异性,确保提取的数据遵循标准化结构,捕捉预期的信息。通过一个明确的模式,模型不太可能忽视关键细节或引入意外元素,从而生成更清晰、更易用的图。

我们将从使用allowed_nodes参数定义预期的节点类型开始。

allowed_nodes = ["Person", "Organization", "Location", "Award", "ResearchField"]
nodes_defined = LLMGraphTransformer(llm=llm, allowed_nodes=allowed_nodes)
data = await allowed_nodes.aconvert_to_graph_documents(documents)

在这里,我们定义了 LLM 应提取五种类型的节点,如组织地点等。我们在 Neo4j 浏览器中可视化了两次独立的执行结果以进行比较。

使用预定义的节点类型进行两次提取的可视化。图像来源:作者。

通过指定预期的节点类型,我们实现了更一致的节点提取。然而,仍然可能会出现一些变化。例如,在第一次运行中,“放射性”被提取为研究领域,而在第二次运行中则没有。

由于我们没有定义关系,因此它们的类型也可能在不同的运行中有所不同。此外,一些提取可能比其他提取捕获更多的信息。例如,Marie 和 Pierre 之间的MARRIED_TO关系在两次提取中都没有出现。

现在,让我们探索如何通过定义关系类型来进一步提高一致性。

定义允许的关系

正如我们所观察到的,仅定义节点类型仍然会导致关系提取的变化。为了解决这个问题,让我们探讨如何定义关系。第一种方法是通过列出可用类型来指定允许的关系。

allowed_nodes = ["Person", "Organization", "Location", "Award", "ResearchField"]
allowed_relationships = ["SPOUSE", "AWARD", "FIELD_OF_RESEARCH", "WORKS_AT", "IN_LOCATION"]
rels_defined = LLMGraphTransformer(
  llm=llm, 
  allowed_nodes=allowed_nodes,
  allowed_relationships=allowed_relationships
)
data = await rels_defined.aconvert_to_graph_documents(documents)

让我们再次检查两次独立的提取。

使用预定义的节点和关系类型进行两次提取的可视化。图像来源:作者。

在定义了节点和关系后,我们的输出变得更加一致。例如,玛丽总是被显示为获奖者、皮埃尔的配偶,以及巴黎大学的工作人员。然而,由于关系被指定为一般列表,并未限制可以连接的节点,因此仍然会出现一些变化。例如,FIELD_OF_RESEARCH关系可能出现在PersonResearchField之间,但有时它也会将AwardResearchField连接。此外,由于关系的方向尚未定义,方向一致性可能会有所不同。

为了解决无法指定关系可以连接哪些节点以及强制关系方向的问题,我们最近引入了一个新的选项来定义关系,如下所示。

allowed_nodes = ["Person", "Organization", "Location", "Award", "ResearchField"]
allowed_relationships = [
    ("Person", "SPOUSE", "Person"),
    ("Person", "AWARD", "Award"),
    ("Person", "WORKS_AT", "Organization"),
    ("Organization", "IN_LOCATION", "Location"),
    ("Person", "FIELD_OF_RESEARCH", "ResearchField")
]
rels_defined = LLMGraphTransformer(
  llm=llm, 
  allowed_nodes=allowed_nodes,
  allowed_relationships=allowed_relationships
)
data = await rels_defined.aconvert_to_graph_documents(documents)

我们现在不再将关系定义为简单的字符串列表,而是使用三元素元组格式,其中元素分别代表源节点、关系类型和目标节点。

让我们再次可视化结果。

使用预定义节点和高级关系类型的两次提取过程的可视化。图像来自作者。

使用三元组方法提供了一个更加一致的图谱模式,适用于多次执行的提取。然而,由于 LLM 的特性,提取的细节层次仍可能存在一些差异。例如,在右侧,皮埃尔被显示为获奖,而左侧则缺少这条信息。

定义属性

我们可以对图谱模式做的最后一个增强是为节点和关系定义属性。在这里,我们有两个选择。第一个是设置node_propertiesrelationship_propertiestrue,允许 LLM 自主决定提取哪些属性。

allowed_nodes = ["Person", "Organization", "Location", "Award", "ResearchField"]
allowed_relationships = [
    ("Person", "SPOUSE", "Person"),
    ("Person", "AWARD", "Award"),
    ("Person", "WORKS_AT", "Organization"),
    ("Organization", "IN_LOCATION", "Location"),
    ("Person", "FIELD_OF_RESEARCH", "ResearchField")
]
node_properties=True
relationship_properties=True
props_defined = LLMGraphTransformer(
  llm=llm, 
  allowed_nodes=allowed_nodes,
  allowed_relationships=allowed_relationships,
  node_properties=node_properties,
  relationship_properties=relationship_properties
)
data = await props_defined.aconvert_to_graph_documents(documents)
graph.add_graph_documents(data)

让我们检查一下结果。

提取的节点和关系属性。图像来自作者。

我们已经允许 LLM 添加它认为相关的任何节点或关系属性。例如,它选择包括玛丽·居里的出生和死亡日期、她在巴黎大学担任教授的身份,以及她两次获得诺贝尔奖的事实。这些附加属性显著丰富了提取的信息。

我们的第二个选择是定义我们想要提取的节点和关系属性。

allowed_nodes = ["Person", "Organization", "Location", "Award", "ResearchField"]
allowed_relationships = [
    ("Person", "SPOUSE", "Person"),
    ("Person", "AWARD", "Award"),
    ("Person", "WORKS_AT", "Organization"),
    ("Organization", "IN_LOCATION", "Location"),
    ("Person", "FIELD_OF_RESEARCH", "ResearchField")
]
node_properties=["birth_date", "death_date"]
relationship_properties=["start_date"]
props_defined = LLMGraphTransformer(
  llm=llm, 
  allowed_nodes=allowed_nodes,
  allowed_relationships=allowed_relationships,
  node_properties=node_properties,
  relationship_properties=relationship_properties
)
data = await props_defined.aconvert_to_graph_documents(documents)
graph.add_graph_documents(data)

属性仅仅是通过两个列表来定义的。让我们看看 LLM 提取了什么。

提取的预定义节点和关系属性。图像来自作者。

出生和死亡日期与先前的提取结果一致。然而,这一次,LLM 还提取了玛丽在巴黎大学担任教授的起始日期。

属性确实为提取的信息增加了有价值的深度,尽管目前这种实现存在一些限制:

  • 属性只能通过基于工具的方法进行提取。

  • 所有属性都作为字符串提取。

  • 属性只能全局定义,而不能按节点标签或关系类型定义。

  • 没有选项可以自定义属性描述,以指导 LLM 进行更精确的提取。

严格模式

如果您认为我们已经完善了一种方法,让 LLM 完美遵循定义的模式,我必须澄清一下。尽管我们在提示工程上投入了相当多的努力,但要让 LLM,尤其是性能较差的 LLM,完全准确地遵循指令是具有挑战性的。为了解决这个问题,我们引入了一个后处理步骤,称为strict_mode,它会去除任何不符合定义图模式的信息,从而确保更清洁、更一致的结果。

默认情况下,strict_mode设置为True,但您可以使用以下代码禁用它:

LLMGraphTransformer(
  llm=llm, 
  allowed_nodes=allowed_nodes,
  allowed_relationships=allowed_relationships,
  strict_mode=False
)

在关闭严格模式的情况下,您可能会遇到图模式之外的节点或关系类型,因为 LLM 有时会在输出结构上采取创造性自由。

将图文档导入图数据库

从 LLM 图转换器中提取的图文档可以使用add_graph_documents方法导入到像 Neo4j 这样的图数据库中,以便进行进一步的分析和应用。我们将探讨不同的导入选项,以适应不同的使用场景。

默认导入

您可以使用以下代码将节点和关系导入到 Neo4j 中。

graph.add_graph_documents(graph_documents)

该方法直接将所有节点和关系从提供的图文档导入。我们在整篇博客中使用了这种方法,以回顾不同 LLM 和模式配置的结果。

默认导入设置。图片由作者提供。

基础实体标签

大多数图数据库支持索引,以优化数据的导入和检索。在 Neo4j 中,索引只能为特定的节点标签设置。由于我们可能无法提前知道所有的节点标签,我们可以通过使用baseEntityLabel参数为每个节点添加一个次级基础标签来处理这个问题。这样,我们仍然可以利用索引来实现高效的导入和检索,而不需要为图中每个可能的节点标签设置索引。

graph.add_graph_documents(graph_documents, baseEntityLabel=True)

如前所述,使用baseEntityLabel参数会导致每个节点拥有额外的__Entity__标签。

每个节点都使用baseEntityLabel参数获得一个次级标签。图片由作者提供。

包含源文档

最后的选项是同时导入提取节点和关系的源文档。这种方法让我们能够追踪每个实体出现在哪些文档中。您可以使用include_source参数导入源文档。

graph.add_graph_documents(graph_documents, include_source=True)

在检查导入的图时,我们应该看到类似以下的结果。

导入的源文档。图片由作者提供。

在这个可视化中,源文档被蓝色高亮显示,所有从中提取的实体通过 MENTIONS 关系连接。这种模式允许你构建利用结构化和非结构化搜索方法的检索器

总结

在这篇文章中,我们探讨了 LangChain 的 LLM 图谱转换器及其两种构建知识图谱的模式。基于工具的模式是我们主要的方法,它利用结构化输出和函数调用,减少了提示词工程,并允许提取属性。与此同时,当没有工具可用时,基于提示的模式通过少量示例来引导 LLM,尽管基于提示的提取不支持属性提取,也不会生成孤立节点。

我们观察到,定义一个清晰的图谱模式,包括允许的节点和关系类型,有助于提高提取的一致性和性能。受限的模式有助于确保输出符合我们期望的结构,使其更加可预测、可靠和适用。无论是使用工具还是提示,LLM 图谱转换器都能实现对非结构化数据的更有组织、结构化的表示,从而促进更好的 RAG 应用和多跳查询处理。

代码可以在 GitHub 上找到。你还可以在一个无代码环境中尝试使用 Neo4j 提供的LLM 图谱构建器应用程序。

[## Neo4j 图谱构建器

无代码

llm-graph-builder.neo4jlabs.com](https://llm-graph-builder.neo4jlabs.com/?source=post_page-----a91045c49b59--------------------------------)

构建 LLM 应用:清晰的逐步指南

原文:towardsdatascience.com/building-llm-apps-a-clear-step-by-step-guide-1fe1e6ef60fd?source=collection_archive---------0-----------------------#2024-06-10

构建 LLM 原生应用的全面步骤:从最初的构想到实验、评估和产品化

Almog BakuTowards Data Science Almog Baku

·发表于Towards Data Science ·12 分钟阅读·2024 年 6 月 10 日

--

大型语言模型(LLM)正迅速成为现代人工智能的基石。然而,目前并没有成熟的最佳实践,而且许多先驱者常常面临没有清晰路线图的困境,不得不重新发明轮子,或者陷入困境。

在过去的两年里,我帮助组织利用 LLM 构建创新应用。通过这些经验,我开发了一种经受考验的方法,用于创建创新解决方案(这些方法受到LLM.org.il社区的见解影响),我将在本文中分享这些方法。

本指南提供了一个清晰的路线图,帮助你在 LLM 原生开发的复杂领域中找到前进的方向。你将学习如何从构思到实验、评估和产品化,释放你的潜力,创造开创性的应用。

(由 Dall-E3 创建)

为什么标准化流程至关重要

LLM 领域变化如此之快,有时我们每天都会听到新的突破性创新。这虽然令人振奋,但也充满混乱——你可能会发现自己迷失在过程当中,不知道该做什么,或者如何将你的新创意付诸实践。

长话短说,如果你是一个AI 创新者(无论是管理者还是从业者),想要有效地构建 LLM 原生应用,这篇文章适合你

实施标准化流程有助于启动新项目,并带来几个关键的好处:

  1. 标准化流程 —— 标准化流程有助于协调团队成员,确保顺利的新人入职过程(特别是在这种混乱中)。

  2. 定义清晰的里程碑 —— 一种简单的方法来跟踪你的工作、衡量它并确保你走在正确的道路上。

  3. 确定决策点 —— LLM 原生开发充满了未知和“小规模实验”[见下文]。明确的决策点使我们能够降低风险,并始终保持精益开发。

LLM 工程师的必备技能

与软件研发中的任何其他既定角色不同,LLM 原生开发绝对需要一个新的角色:LLM 工程师AI 工程师

LLM 工程师是一个独特的混合型角色,涉及不同(既定)角色的技能:

  • 软件工程技能—— 就像大多数软件工程师一样,大部分工作是将乐高积木拼凑在一起,并将所有东西粘合在一起。

  • 研究技能 —— 正确理解 LLM 原生实验的性质是至关重要的。 尽管构建“酷炫的演示应用”相对容易,但“酷炫演示”和实际解决方案之间的距离需要实验和敏捷性。

  • 深入理解业务/产品—— 由于模型的脆弱性,理解业务目标和流程至关重要,而不是坚持我们定义的架构。能够建模手动流程是 LLM 工程师的金牌技能。

在写这篇文章时,LLM 工程学仍然是全新的领域,招聘可能非常具有挑战性。寻找有后端/数据工程或数据科学背景的候选人可能是一个不错的主意。

软件工程师 可能会期望一个 更平滑的过渡,因为实验过程更加“工程化”,而不像传统的数据科学工作那样“科学”。话虽如此,我也见过许多数据科学家成功完成这个过渡。只要你能接受你必须拥抱新的软技能这一事实,你就走在了正确的道路上!

LLM 原生开发的关键要素

与传统的后端应用(如 CRUD)不同,这里没有一步步的操作指南。像“AI”中的其他所有事物一样,LLM 原生应用需要一个研究和实验的思维方式

要驯服这只野兽,你必须通过将工作拆分成更小的实验来实现分而治之,尝试其中一些实验,并选择最有前景的实验。

我无法强调研究心态的重要性。 这意味着你可能会投入时间去探索一个研究方向,结果发现它“不可行”、“不够好”或“不值得”。完全没关系——这意味着你在正确的轨道上。

实验 LLM 是构建 LLM 原生应用的唯一途径(并且避免途中出现障碍)(由 Dall-E3 创建)

拥抱实验:过程的核心

有时候,你的“实验”会失败,然后你会稍微调整工作,结果另一个实验会成功得多。

这正是为什么,在设计我们的终极解决方案之前,我们必须从简单开始并对我们的风险进行对冲。

  1. 定义“预算”或时间框架。我们看看在 X 周内能做些什么,然后决定是否继续。通常,2 到 4 周的时间来理解基本的 PoC 就足够了。如果看起来有希望——继续投入资源以改善它。

  2. 实验—无论你在实验阶段选择自下而上还是自上而下的方法,你的目标是最大化结果成功率。在第一次实验迭代结束时,你应该拥有一些 PoC(供利益相关者使用)以及你所取得的基准。

  3. 回顾—在我们的研究阶段结束时,我们可以了解构建此类应用的可行性、局限性和成本。这有助于我们决定是否将其投入生产,并设计最终的产品及其用户体验。

  4. 产品化—开发项目的生产就绪版本,并通过遵循标准的软件工程最佳实践,将其与其他解决方案整合,实施反馈和数据收集机制。

LLM 原生应用开发生命周期(图示来自作者)

为了有效地实现面向实验的过程,我们必须做出有根据的决定来接近并构建这些实验:

精简起步:自下而上的方法

尽管许多早期采用者很快就跳入了最先进的多链条代理系统,像是完整的 Langchain 或类似的框架,但我发现自下而上的方法往往能取得更好的效果。

从精简开始,非常精简,拥抱“一个提示统治一切”的哲学。尽管这种策略看起来不太传统,并且一开始可能会产生不好的结果,但它为你的系统建立了一个基准

从那里开始,持续迭代和优化你的提示,运用提示工程技术来优化结果。当你发现精简方案中的弱点时,通过添加分支来拆分过程,解决这些不足。

在设计我的 LLM 工作流图的每个“叶子”或 LLM 原生架构时,我遵循LLM 三角原理³来决定在哪些时候、以何种方式剪枝、分支或加粗根部(通过使用提示工程技术),并挤出更多的“柠檬汁”。

自下而上的方法插图(图示来自作者)

例如,要使用自下而上的方法实现“本地语言 SQL 查询”,我们将从直接向 LLM 发送模式并要求它生成查询开始。

一个自下而上的方法示例(图示来自作者)

通常,这并不与“自上而下的方法”相矛盾,而是作为它的另一个步骤。这使我们能够展示快速的胜利,并吸引更多项目投资。

全局视角:自上而下的策略

“我们知道 LLM 工作流并不容易,为了实现我们的目标,我们可能最终会采用某种工作流或 LLM 本地架构。”

自上而下方法认识到这一点,并从第一天开始就设计 LLM 本地架构,并从一开始就实施其不同的步骤/链条。

通过这种方式,你可以测试整个工作流架构,而不是单独优化每一片叶子,真正做到榨干整个柠檬。

自上而下方法流程:一次性设计架构,实施、测试并衡量效果(图片由作者提供)

例如,要实现“本地语言 SQL 查询”并采用自上而下的方法,我们会在开始编码之前先设计架构,然后再跳到完整实现:

自上而下方法的一个例子(图片由作者提供)

找到正确的平衡

当你开始尝试实验 LLM(大型语言模型)时,你可能会从两个极端中的一个开始(过于复杂的自上而下方法或超级简单的一次性方法)。实际上,并没有绝对的“赢家”。

理想情况下——你会在编码和实验模型之前,先定义好一个好的 SoP¹,并建模一个专家。但在现实中,建模非常困难,有时你可能没有接触到这样的专家。

我发现要在第一次尝试时找到一个好的架构/SoP¹非常具有挑战性,因此在跳到更复杂的方案之前,轻微实验是值得的。然而,这并不意味着一切都必须过于精简。如果你已经有先验理解,知道某些东西必须被拆解成更小的部分——就去做。

你应该利用 LLM 三角原理³,正确地建模手动过程,同时设计你的解决方案。

优化你的解决方案:榨干柠檬

在实验阶段,我们不断地榨干柠檬并增加更多的“复杂性层次”:

  • 提示工程技术——如少量样本、角色分配,甚至是动态少量样本

  • 扩展上下文窗口,从简单的变量信息到复杂的 RAG 流程,可以帮助提高结果。

  • 尝试不同的模型——不同的模型在不同的任务上表现不同。此外,大型 LLM 往往不具备成本效益,因此值得尝试更多针对特定任务的模型。

  • 提示精简——我发现将 SOP¹(具体来说是提示和请求的输出)进行“精简”通常可以提高延迟。

    通过减少提示大小和模型需要经过的步骤,我们可以减少模型需要生成的输入和输出。你会感到惊讶,但有时候,提示精简甚至能提高质量!

    请注意,饮食可能也会导致质量下降,因此在进行之前设置一个合理性测试非常重要。

  • 将过程分解成更小的步骤也非常有利,并能使得优化 SOP¹ 中的子过程变得更容易且可行。

    请注意,这可能会增加解决方案的复杂性或影响性能(例如,增加处理的令牌数)。为了缓解这一点,尽量使用简洁的提示和更小的模型。

    一般来说,当系统提示的剧烈变化能为 SOP¹ 流程的这一部分带来更好的结果时,分割是个不错的选择。

挤压 AI 柠檬(由 Dall-E3 创建)

LLM 实验的解剖学

就个人而言,我更倾向于使用一个简单的 Jupyter Notebook,结合 Python、Pydantic 和 Jinja2,以轻量的方式开始:

  1. 使用 Pydantic 定义模型输出的架构。

  2. 使用 Jinja2 编写 提示模板

  3. 定义一个结构化输出格式(使用 YAML²)。这将确保模型遵循“思考步骤”并遵循我的 SOP。

  4. 通过你的 Pydantic 验证确保此输出;如果需要——请重试。

  5. 稳定你的工作——将代码结构化为功能单元,使用 Python 文件和包。

从更广泛的角度来看,你可以使用不同的工具,比如 openai-streaming 来轻松 利用流式处理(和工具)LiteLLM 来拥有一个 标准化的 LLM SDK,适用于不同的提供商,或者 vLLM提供开源 LLM 服务

通过健全性测试和评估确保质量

一项健全性测试评估你的项目质量,并确保你没有降低已经定义的某个成功率基准。

把你的解决方案/提示想象成一条短毛毯——如果你把它拉得太长,它可能突然无法覆盖以前能够覆盖的某些用例。

为此,定义一组你已经成功覆盖的案例,并确保它保持这样(或者至少值得这样做)。把它当作 表驱动测试 可能会有帮助。

评估“生成性”解决方案(例如,写文本)的成功比使用 LLM 处理其他任务(如分类、实体提取等)要复杂得多。对于这些任务,你可能需要引入更智能的模型(如 GPT4、Claude Opus 或 LLAMA3–70B)作为“裁判”。

另外,尝试让输出先包含“确定性部分”,再输出“生成部分”可能是一个好主意,因为这类输出更容易进行测试:

cities:
  - New York
  - Tel Aviv
vibes:
  - vibrant
  - energetic
  - youthful
target_audience:
  age_min: 18
  age_max: 30
  gender: both
  attributes:
    - adventurous
    - outgoing
    - culturally curious
# ignore the above, only show the user the `text` attr.
text: Both New York and Tel Aviv buzz with energy, offering endless activities, nightlife, and cultural experiences perfect for young, adventurous tourists.

有一些前沿的、🤩🤩 有前景的解决方案值得探索。我发现它们在评估基于 RAG 的解决方案时尤其相关:可以看看 DeepChecksRagasArizeAI

做出明智的决策:回顾的意义

在每次主要/定期实验或里程碑后,我们应该停下来并做出明智的决策,决定是否继续采用这种方法。

此时,您的实验将有一个明确的成功率基准,您也会了解需要改进的地方。

这是一个很好的时机来开始讨论这个解决方案的产品化影响,并开始进行“产品工作”:

  1. 这在产品中会是什么样子?

  2. 有哪些限制/挑战?您将如何应对?

  3. 目前的延迟是多少?够好吗?

  4. 用户体验应如何设计?可以使用哪些 UI 技巧?流媒体能否有所帮助?

  5. 预计的代币消耗是多少?我们能否使用更小的模型来减少消耗?

  6. 优先级是什么?有哪些挑战是不可妥协的?

假设我们达成的基准“足够好”,并且我们相信可以解决提出的问题。在这种情况下,我们将继续投资并改善项目,同时确保它永远不会退化,并使用理智测试。

(由 Dall-E3 创建)

从实验到产品:让您的解决方案落地

最后但同样重要的是,我们必须将我们的工作产品化。像任何其他生产级解决方案一样,我们必须实现生产工程概念,如日志记录、监控、依赖管理、容器化、缓存等。

这是一个庞大的世界,但幸运的是,我们可以借用许多来自传统生产工程的机制,甚至采用许多现有的工具。

话虽如此,在处理涉及 LLM 原生应用的细节时要特别小心:

  • 反馈循环—我们如何衡量成功?是仅仅一个“点赞/点踩”机制,还是更复杂的机制,考虑到我们解决方案的采用情况?

    收集这些数据也很重要;在未来,这将帮助我们重新定义我们的理智“基准”或通过动态少量样本来微调结果,或微调模型。

  • 缓存—与传统的软件工程不同,当我们在解决方案中引入生成性特征时,缓存可能会变得非常具有挑战性。为缓解这一问题,可以探索缓存相似结果(例如,使用 RAG)和/或减少生成性输出(通过严格的输出架构)。

  • 成本跟踪—许多公司发现,启动时使用“强大模型”(如 GPT-4 或 Opus)非常有吸引力,然而——在生产中,成本可能迅速上升。避免在最终账单上感到惊讶,并确保衡量输入/输出代币并跟踪工作流的影响(如果没有这些做法——稍后再进行分析可能就会非常困难)

  • 可调试性和追踪——确保你已经设置好正确的工具,以追踪“有问题”的输入并贯穿整个过程。这通常涉及保存用户输入以供后续调查,并设置一个追踪系统。记住:“与传统软件不同,AI 的失败是悄无声息的!”

结束语:你在推动 LLM 原生技术发展中的角色

这可能是文章的结束,但肯定不是我们工作的终结。LLM 原生开发是一个迭代过程,涉及更多的使用案例、挑战和功能,并不断改进我们的 LLM 原生产品。

在你继续进行 AI 开发之旅时,保持灵活,勇敢地进行实验,并始终关注最终用户。与社区分享你的经验和见解,让我们一起推动 LLM 原生应用的可能性边界。继续探索、学习和构建——无限的可能等待着你。

我希望这本指南在你进行 LLM 原生开发的过程中成为了一个有价值的伴侣!我很想听听你的故事——在下面的评论中分享你的成功与挑战吧。💬

如果你觉得这篇文章对你有帮助,请在 Medium 上给它点几个掌声 👏并分享给你的 AI 爱好者朋友们。你的支持对我意义重大!🌍

让我们继续对话——随时通过电子邮件或LinkedIn 联系 🤝

特别感谢Yonatan V. LevinGal PeretzPhilip TannorOri CohenNadavBen HubermanCarmel BarnivOmri AlloucheLiron Izhaki Allerhand提供的见解、反馈和编辑意见。

¹SoP——标准操作程序,这一概念来自于《LLM 三角原则》³

²YAML - 我发现使用 YAML 来结构化输出对于 LLM 来说效果更好。为什么?我的理论是,它减少了不相关的标记,行为更像是原生语言。本文深入探讨了这个话题。

³****LLM 三角原理 - 用于设计和构建 LLM 原生应用的软设计原则;更新 - 最近发布的白皮书,你可以在这里阅读。

为 GitHub 构建 LLM 驱动的编码助手:使用 Gemini 和 Redis 的 RAG

原文:towardsdatascience.com/building-llm-powered-coding-assitant-for-github-rag-with-gemini-and-redis-b88beeb42f2d?source=collection_archive---------6-----------------------#2024-08-12

如何构建一个能够回答用户问题的 GitHub 仓库助手

Ransaka RaviharaTowards Data Science Ransaka Ravihara

·发表于Towards Data Science ·阅读时间 8 分钟·2024 年 8 月 12 日

--

一个热衷于探索并帮助他人的极客。(使用Canva生成)

介绍

既然你正在阅读这篇文章,说明你可能对数据科学、机器学习或人工智能感兴趣;最终,你的目标是编程。程序员更常遇到的是 Bug、问题和错误。这时问题变得严肃,尤其是在处理一个相对较新的框架或库时。此时,我总是想到拥有一位智能助手的好处,那个助手掌握了各种知识,并且能够有效地提供指导。我的助手一旦接到任务,就可以浏览整个代码库,扫描每一行代码,记住所有的逻辑,迅速修复问题。

图片由作者提供,通过Canva

听起来很高大上,对吧?创建这样一个系统曾经看起来像是科幻小说中的情节,但如今它是可以实现的,只需要几个专注的晚上。

让我们准备好并开始编码,来实现我们的任务。如上所述,我们的助手具有以下功能:

使用 LangFlow 和 Ollama 构建本地 RAG 聊天机器人,无需编码

原文:towardsdatascience.com/building-local-rag-chatbots-without-coding-using-langflow-and-ollama-60760e8ed086?source=collection_archive---------0-----------------------#2024-04-08

基于 LangChain 快速原型化 RAG 应用程序

Yanli LiuTowards Data Science Yanli Liu

·发表于Towards Data Science ·阅读时间 10 分钟·2024 年 4 月 8 日

--

还记得以前构建智能聊天机器人需要几个月的编码吗?

像 LangChain 这样的框架无疑简化了开发,但对于非程序员来说,几百行代码仍然是一个障碍。⁤

有没有更简单的方法?(如果你还不是 Medium 会员,可以在这里 查看完整故事 并考虑订阅 Medium 会员支持作者)

图片由Ravi Palwe提供,来源于Unsplash

那时我发现了“Lang Flow”,一个基于 LangChain Python 版本的开源包。它让你无需编写一行代码就能创建 AI 应用程序。它为你提供了一个画布,你只需拖动组件并将它们连接起来,就能构建你的聊天机器人。

在这篇文章中,我们将使用LangFlow在几分钟内构建一个智能 AI 聊天机器人原型。对于后端,我们将使用Ollama进行嵌入模型和大型语言模型,这意味着该应用程序将在本地运行并且免费!最后,我们将把这个流程转化为一个Streamlit应用程序,几乎不需要编码。

检索增强生成管道简介……

构建可扩展的数据平台

原文:towardsdatascience.com/building-scalable-data-platforms-6621c9bde515?source=collection_archive---------0-----------------------#2024-09-01

数据平台设计中的数据网格趋势

💡Mike ShakhomirovTowards Data Science 💡Mike Shakhomirov

·发表在Towards Data Science·阅读 13 分钟·2024 年 9 月 1 日

--

使用Kandinsky生成的 AI 图像

在本文中,我旨在深入研究各种数据平台架构,更好地了解它们的演变、优势、劣势和实际应用。重点将放在数据网格架构上,以及它在现代数据堆栈(MDS)和当今数据驱动的景观中的作用。

众所周知,数据平台的架构深刻影响其性能和可扩展性。挑战通常在于选择一个最符合您特定业务需求的架构。

鉴于市场上现有的大量数据工具,很容易迷失方向。我时常看到关于这个主题的互联网文章往往高度推测性。关于哪些工具最好,谁领导行业,以及如何做出正确选择的问题可能非常令人沮丧。这个故事是为那些想要了解更多关于数据平台设计以及在每种情况下选择哪种的数据从业者而写的。

现代数据堆栈

我几乎在互联网上的每个与数据相关的网站上都听到这个术语。每个 LinkedIn 数据组都提供了十几篇关于这个主题的帖子。然而,其中大多数只涵盖了数据工具,没有强调重要性…

构建可持续算法:节能高效的 Python 编程

原文:towardsdatascience.com/building-sustainable-algorithms-energy-efficient-python-programming-54507944e731?source=collection_archive---------2-----------------------#2024-11-23

降低 Python 算法计算成本的 6 种技巧

Ari Joury, PhDTowards Data Science Ari Joury, PhD

·发表于 Towards Data Science ·9 分钟阅读·2024 年 11 月 23 日

--

你可以通过使用这些技巧来提高 Python 的性能。图像由 Leonardo AI 生成

一名初级软件开发人员若因代码能够运行而感到高兴,应该得到宽容。如果你是这样的人,我不做评判。

然而,如果你准备好提升你的 Python 软件开发技能,你的代码不应该只是运行并通过一些测试。它还应该考虑到可用的计算资源——以及电费——来编写。

每一个低效的循环、选择不当的数据结构或冗余的计算都会消耗更多的电力。与 C 语言不同,例如,在 C 中,你必须为每个新创建的变量保留磁盘空间,而 Python 会根据需要消耗资源。这使得 Python 对初学者极为友好,但如果使用不当,也会非常耗能。

粗心的算法不仅对代码性能不好,也对地球有害。像微软这样的软件公司因为为 AI 和其他任务消耗大量能源,正努力保持碳排放量低。与此同时,可持续性问题日益受到关注。因此,注重可持续性的程序员正成为许多公司宝贵的资源。

从零开始构建蛋白质的 Transformer 模型

原文:towardsdatascience.com/building-transformer-models-for-proteins-from-scratch-60884eab5cc8?source=collection_archive---------4-----------------------#2024-05-07

构建和评估蛋白质语言模型的实用指南

Yuan TianTowards Data Science 袁天

·发表于 Towards Data Science ·13 分钟阅读·2024 年 5 月 7 日

--

引言

在我们对蛋白质科学基础理解的基础上,参考上一篇文章,我们现在准备探索 AI/ML 与蛋白质科学的激动人心的交汇点。本文重点讨论基于 Transformer 的语言模型,这一技术支撑了像 ChatGPT 这样的高级聊天机器人。我不会在这里花费太多篇幅详细讲解 Transformer。对于这一点,我强烈推荐 Jay Alammar 的博客文章“The Illustrated Transformer”,该文章提供了详细的解析和精美的插图。相反,我们将重点介绍 Transformer 在蛋白质分析中的实际应用。

具体来说,我们将构建一个基础的蛋白质 Transformer 模型,预测抗体序列的抗原特异性。这个项目将增强我们对 Transformer 实现的理解,并探索它们在蛋白质科学中的潜在应用。

Transformer 是在开创性论文“Attention Is All You Need”中提出的神经网络模型,具有编码器和解码器组件。像 BERT(双向编码表示 Transformer)这样的模型利用编码器来理解语言,并在下游任务(如分类)中表现出色。在这里,我们将实现并训练一个基于编码器的模型,以将抗体分类为 HIV-1 或 SARS-CoV-2 特异性(图 1)。

构建 LLM 答案的信任:在 PDF 中突出源文本

原文:towardsdatascience.com/building-trust-in-llm-answers-highlighting-source-texts-in-pdfs-5d1342ecb811?source=collection_archive---------0-----------------------#2024-12-27

100%的准确性并不是一切:帮助用户导航文档才是真正的价值所在。

Angela & Kezhan ShiTowards Data Science Angela & Kezhan Shi

·发布于Towards Data Science ·6 分钟阅读·2024 年 12 月 27 日

--

所以,你正在构建一个 RAG 系统,或者使用 LLM 与文档进行对话。但用户经常会问:我们如何信任答案呢?

此外,我们经常听到“幻觉”这一现象,它会破坏用户的信任。

如果我们构建了一个应用程序,但没有向用户展示答案的来源,某些情况下这个应用可能变得无法使用。

在这篇文章中,我将分享一种方法来解决这个问题。通过将 LLM 生成的每个答案与文档中的源文本链接起来,我们可以建立透明度和信任。这种方法不仅提供了明确的答案证据,还允许用户直接在 PDF 中验证结果。

有时候,生成的答案可能并不完全准确,但能够找到正确的源文本对用户已经非常有帮助。

让我们以这篇论文为例,来自 arxiv.org。我们可以想象这个使用场景:

作者提供的图片——文档展示

第一步:从 PDF 中提取文本

该方法的第一步是从 PDF 中提取结构化格式的文本。

使用 PySide6 构建你的第一个桌面应用程序[数据科学家版]

原文:towardsdatascience.com/building-your-first-desktop-application-using-pyside6-a-data-scientist-edition-e2275cf0c977?source=collection_archive---------1-----------------------#2024-03-16

惊讶吧,惊讶吧。这并不像我想象的那么难。

Arunn ThevapalanTowards Data Science Arunn Thevapalan

·发布于Towards Data Science ·阅读时长 14 分钟·2024 年 3 月 16 日

--

图片由Linus Mimietz提供,来源于Unsplash

我作为数据科学家的工作中最难的部分是说服非技术性利益相关者,让他们意识到又一个数据科学解决方案如何帮助他们做出更好的决策。

不过,这对我来说并不新鲜。在我作为数据科学家和机器学习工程师的 5 年以上经验中,一直是这样的。

经过多次尝试和错误,对我来说,行得通的顺序是:

  • 通过简化技术概念分享定期进度更新(演示文稿幻灯片)

  • 构建一个机器学习网页应用程序,在项目的最后阶段,为利益相关者提供与我们共同构建的解决方案互动的体验。

然而,有一个转折点,我和同一个团队工作了大约 5 年,一位同事使用.NET 为一个不同的用例开发了一个桌面应用程序(而不是网页应用程序)。团队对这个应用程序非常喜欢。

所以我问自己:为什么不构建一个桌面应用程序,而不是网页应用程序呢?

不过有一个问题,那就是我一开始不懂.NET,而且之前从未开发过桌面应用程序……

构建模型不够——你还需要推销它

原文:towardsdatascience.com/building-your-model-is-not-enough-you-need-to-sell-it-42b3478a3f4c?source=collection_archive---------8-----------------------#2024-01-11

5 个有助于将你的模型推向生产环境的技巧

Antoine VillatteTowards Data Science Antoine Villatte

·发表于Towards Data Science ·8 分钟阅读·2024 年 1 月 11 日

--

图片由Daniele Franchi拍摄,来源于Unsplash

当我开始从事数据科学时,我的主要关注点是提高编程和模型构建等技术技能。几年后,我的兴趣转向了模型部署和 MLOps,这促使我转向了机器学习工程。公共演讲和演示一直是工作的一部分,特别是在向非技术观众传达结果时。然而,去年情况发生了变化,当时我开始处理更多复杂的项目,这些项目对招聘公司的声誉或财务可能带来风险。

到了这个阶段,模型需要经过一个由技术和非技术评审员组成的委员会的验证,才能在生产环境中上线。这需要充分的文档记录,涵盖从架构和训练方法到性能报告和实验历史的所有内容。这意味着仅仅拥有良好的性能是不够的;我必须说服其他人,从数据科学家到风险评估专家,证明我的模型不仅有效,而且安全。

本质上,我必须学会如何推销它们。

启动详细化模型的过程最初是为了验证模型的关键要求,但它很快发展成了一种常规…

构建你自己的 AI 群聊:一段进入定制宇宙与角色的旅程

原文:towardsdatascience.com/building-your-own-ai-group-chat-a-journey-into-custom-universes-and-characters-73634ee0253b?source=collection_archive---------10-----------------------#2024-10-29

如何使用 Ollama、FastAPI、开源 LLMs 和 React 创建沉浸式 AI 群聊

Maxime JabarianTowards Data Science Maxime Jabarian

·发布于Towards Data Science ·9 分钟阅读·2024 年 10 月 29 日

--

来源

欢迎来到本文,在这里我将展示如何创建一个沉浸式 AI 群聊。作为一名 LLM 工程师,我并不满足于到处可见的典型一对一 AI 聊天——因此在业余时间,我着手创建一个 AI 群聊,用户可以在任何想象中的宇宙中与多个角色同时互动,并且无需花费任何费用。

如果你不是会员,点击这里阅读.

在第一部分中,我将带你了解如何与多个角色同时互动。接着,我们将探索我使用的工具——Python、Ollama、React、FastAPI 和开源大型语言模型(LLMs)。最后,你将获得所有构建自己定制宇宙和角色的技巧与见解,并以我的代码为起点,同时提供一些如何改进它的思路(因为这是一个有趣的副项目)。

为什么要构建 AI 群聊?

图片由作者提供

构建个人 AI 助手:逐步指南,打造文本与语音本地大语言模型

原文:towardsdatascience.com/building-your-own-personal-ai-assistant-a-step-by-step-guide-to-text-and-voice-interaction-with-a-07389c5fd874?source=collection_archive---------0-----------------------#2024-03-14

如何创建一个可以进行语音交互的本地大语言模型 AI 助手

Amirarsalan RajabiTowards Data Science Amirarsalan Rajabi

·发表于Towards Data Science ·阅读时间 8 分钟·2024 年 3 月 14 日

--

由 DALL·E 3 生成的图片

在本教程中,我们将创建一个个人的本地大语言模型助手,你可以与其进行对话。你将能够使用麦克风录制你的声音并发送给大语言模型。大语言模型将以文本和语音的方式返回答案。

下面是应用程序的工作原理:

你可以在这个 GitHub 仓库中找到代码:

github.com/amirarsalan90/personal_llm_assistant

应用程序的主要组件包括:

llama-cpp-python

Llama-cpp-python 是一个 Python 绑定库,用于连接伟大的 llama.cpp,它在 C/C++中实现了许多大语言模型。由于其广泛的应用……

一份关于如何高效使用 BigQuery 的终极指南

原文:towardsdatascience.com/burn-data-rather-than-money-with-bigquery-the-definitive-guide-1b50a9fdf096?source=collection_archive---------2-----------------------#2024-03-03

充分利用你的 BigQuery 使用,烧掉数据而不是烧掉钱,用一些实用技巧创造真正的价值。

Volker JanzTowards Data Science Volker Janz

· 发布于Towards Data Science · 20 分钟阅读 · 2024 年 3 月 3 日

--

· 📝 引言

· 💎 BigQuery 基础和理解成本

∘ 存储

∘ 计算

· 📐 数据建模

∘ 数据类型

∘ 向去规范化转变

∘ 分区

∘ 聚类

∘ 嵌套重复列

∘ 索引

∘ 物理字节存储计费

∘ 使用主键和外键的连接优化

· ⚙️ 数据操作

∘ 复制数据/表

∘ 加载数据

∘ 删除分区

∘ 获取表的不同分区

∘ 不要持久化计算的度量

· 📚 摘要

∘ 遵循数据建模最佳实践

∘ 掌握数据操作以实现成本效益

∘ 为了效率而设计,避免不必要的数据持久化

免责声明:BigQuery 是一个不断发展的产品,定价可能随时变化,本文基于我个人的经验。

Konstantin Evdokimov拍摄,照片来源于Unsplash

📝 引言

在数据仓库领域,有一个普遍的真理:管理数据可能是昂贵的。就像一条守护财宝的龙,每个存储的字节和每个执行的查询都需要它的金币份额。但是让我给你一个魔法咒语来安抚这条龙:烧掉数据,而不是烧掉钱!

在本文中,我们将揭开 BigQuery 魔法的艺术,旨在在提高效率的同时降低成本,甚至更多。加入我们,一同探索成本优化的深度,在这里,每个字节都是珍贵的硬币。

图片由 Jonathan Kemper 提供,来自 Unsplash

💎 BigQuery 基础和成本理解

BigQuery 不仅仅是一个工具,而是一整套可扩展的计算和存储技术包,配备快速网络,一切由谷歌管理。在其核心,BigQuery 是一个无服务器的数据仓库,专为分析目的而设计,内置特性如机器学习(BigQuery ML)。BigQuery 将存储和计算分开,通过谷歌的 Jupiter 网络进行连接,以利用 1 Petabit/秒的总双向带宽。存储系统使用 Capacitor,这是谷歌为半结构化数据提供的专有列式存储格式,底层的文件系统是谷歌的分布式文件系统 Colossus。计算引擎基于 Dremel,并使用 Borg 进行集群管理,能够跨多个集群运行成千上万的 Dremel 作业。

BigQuery 不仅仅是一个工具,而是一整套可扩展的计算和存储技术包,配备快速网络,一切由谷歌管理

以下插图展示了 BigQuery 架构的基本结构:

BigQuery 架构(作者)

数据可以存储在 Colossus 中,但也可以在 Google Cloud Storage 中创建 BigQuery 表。在这种情况下,查询仍然通过 BigQuery 计算基础设施处理,但读取的数据来自 GCS。此类 外部表 有一些缺点,但在某些情况下,将数据存储在 GCS 中可能更具成本效益。另外,有时候并不是关于大数据,而仅仅是从现有的 CSV 文件中读取数据,这些文件以某种方式被导入到 GCS。为了简便起见,使用这种表格也可能带来好处。

BigQuery 外部表(作者)

要充分发挥 BigQuery 的潜力,常见的做法是将数据存储在 BigQuery 存储中。

成本的主要驱动因素是存储和计算,谷歌不会对其他部分收费,比如存储与计算之间的网络传输。

存储

存储费用为每 GB $0.02 — 每 GB $0.04 为活跃数据,和每 GB $0.01 — 每 GB $0.02 为非活跃数据(即在过去 90 天内未被修改的数据)。如果你的表或分区在连续 90 天内没有修改,则被视为长期存储,存储价格将自动下降 50%。折扣是基于每个表或每个分区应用的。修改操作会重置 90 天计时器。

计算

BigQuery 按扫描的数据量收费,而不是查询的运行时,也不会对从存储到计算集群的传输收费。计算成本取决于位置,例如 europe-west3 的费用为每 TB $8.13。

这意味着:

我们希望最小化每个查询要扫描的数据量!

左: Jp ValeryUnsplash,右: Gabriel JimenezUnsplash

在执行查询时,BigQuery 会估算要处理的数据量。在 BigQuery Studio 查询编辑器中输入查询后,你可以在右上角看到估算值。

BigQuery Studio

如果像上图所示显示为 1.27 GB,并且查询在 europe-west3 位置处理,则可以按以下方式计算费用:

1.27 GB / 1024 = 0.0010 TB * $8.13 = $0.0084 total costs

估算值通常是一个悲观的计算,优化器通常能够利用缓存结果、物化视图或其他技术,从而使实际计费字节数低于估算值。检查这个估算值仍然是一个好的做法,可以让你大致了解工作影响。

你也可以为查询设置最大计费字节数。如果查询超过该限制,它将失败,并且不会产生任何费用。这个设置可以通过导航到 更多 -> 查询设置 -> 高级选项 -> 最大计费字节数 来更改。

BigQuery 查询设置

BigQuery 超过了计费字节数的限制

不幸的是,直到现在,无法为每个查询设置默认值。只能限制每个用户每天每个项目的计费字节数,或限制一个项目每天的所有字节总计。

当你第一次开始使用 BigQuery 进行项目时,你很可能会选择按需计算定价模型。在按需定价模型下,你通常可以访问最多 2000 个并发槽,这些槽会在单个项目的所有查询之间共享,这在大多数情况下是足够的。一个槽类似于一个虚拟 CPU,处理查询 DAG 的一单位工作。

当每月支出达到一定金额时,值得考虑容量定价模型,这样可以让成本更具可预测性。

📐 数据建模

数据类型

为了减少存储和计算成本,始终使用最小的数据类型对于你的列非常重要。你可以轻松地通过以下概览估算一定数量行数据的成本:

Type       | Size
-----------|---------------------------------------------------------------
ARRAY      | Sum of the size of its elements 
BIGNUMERIC | 32 logical bytes
BOOL       | 1 logical byte
BYTES      | 2 logical bytes + logical bytes in the value
DATE       | 8 logical bytes
DATETIME   | 8 logical bytes
FLOAT64    | 8 logical bytes
GEOGRAPHY  | 16 logical bytes + 24 logical bytes * vertices in the geo type
INT64      | 8 logical bytes
INTERVAL   | 16 logical bytes
JSON       | Logical bytes in UTF-8 encoding of the JSON string
NUMERIC    | 16 logical bytes
STRING     | 2 logical bytes + the UTF-8 encoded string size
STRUCT     | 0 logical bytes + the size of the contained fields
TIME       | 8 logical bytes
TIMESTAMP  | 8 logical bytes

*NULL* 被计算为 0 逻辑字节

示例

CREATE TABLE gold.some_table (
  user_id INT64,
  other_id INT64,
  some_String STRING, -- max 10 chars
  country_code STRING(2),
  user_name STRING,   -- max 20 chars
  day DATE
);

通过这个定义和数据类型表,能够估算 100,000,000 行数据的逻辑大小:

100.000.000 rows * (
  8 bytes (INT64) +
  8 bytes (INT64) +
  2 bytes + 10 bytes (STRING) +
  2 bytes + 2 bytes (STRING(2)) +
  2 bytes + 20 bytes (STRING) +
  8 bytes (DATE)
) = 6200000000 bytes / 1024 / 1024 / 1024
  = 5.78 GB

假设我们在这个表上执行SELECT *,它将花费我们 5.78 GB / 1024 = 0.0056 TB * $8.13 = $0.05,在europe-west3区域。

在设计数据模型之前进行这些计算是一个好主意,这不仅有助于优化数据类型的使用,还能估算你所从事项目的成本。

向反规范化转变

在数据库设计和管理领域,数据规范化和反规范化是旨在优化数据结构以实现高效存储、检索和操作的基本概念。传统上,规范化被誉为最佳实践,强调减少冗余并保持数据完整性。然而,在 BigQuery 和其他现代数据仓库的背景下,动态发生变化,反规范化往往成为首选的方法。

在规范化数据库中,数据被组织成多个表,每个表代表一个独立的实体或概念,并通过一对一、一对多或多对多等关系进行连接。这种方法遵循数据库规范化形式的原则,如第一范式(1NF)、第二范式(2NF)和第三范式(3NF)等。

这带来了减少冗余、数据完整性以及因此减少存储使用的优势。

图片由Shubham Dhage提供,来源于Unsplash

尽管数据规范化在传统关系型数据库中具有优势,但在处理像 BigQuery 这样的现代分析平台时,范式发生了转变。BigQuery 旨在处理海量数据并执行大规模的复杂分析查询。在这种环境下,重点从最小化存储空间转向优化查询性能。

在 BigQuery 中,反规范化成为首选策略有几个原因:

  • 查询性能:BigQuery 的分布式架构在并行扫描大量数据方面表现出色。反规范化表减少了复杂连接的需求,从而缩短查询执行时间。

  • 成本效益:通过减少查询处理所需的计算资源,反规范化可以带来成本节省,因为 BigQuery 中的查询成本是基于处理的数据量计算的。

  • 简化数据建模:非规范化表简化了数据建模过程,使设计和维护分析用途的架构变得更加容易。

  • 优化分析工作负载:非规范化结构非常适合分析工作负载,在这种负载中,聚合、转换和复杂查询是常见的。

此外,存储比计算便宜得多,这意味着:

对于预先连接的数据集,你可以用存储资源换取计算资源!

非规范化(作者观点)

虽然非规范化并不是一种适合所有情况的解决方案,但它应该被考虑用于成本和性能优化。然而,也有一些方面可能导致不同的、成本效益更高的设计。

特别是当在JOIN右侧有较小的表时,BigQuery 利用广播连接将表的完整数据集广播到每个处理较大表的槽。通过这种方式,规范化不会对性能产生负面影响。事实上,情况恰恰相反,因为数据冗余减少了。

当 BigQuery 不使用广播连接时,它会采用哈希连接方法。在这种情况下,BigQuery 使用哈希和洗牌操作,以便匹配的键在同一槽中处理,从而执行本地连接。然而,与广播连接相比,这可能是一个代价高昂的操作,因为需要移动数据。

如果你发现自己处于哈希连接被使用的情况,仍然有方法可能改善性能。至少可以将连接列定义为集群列。这样可以将数据放置在同一列存储文件中,减少洗牌的影响。

最终,最佳方法取决于数据模型的具体情况以及规范化表的大小。如果通过规范化结构可以减少冗余,同时保持JOIN表的大小较小,从而使用广播连接,那么这比强制执行非规范化方法更为优越。然而,对于大于 10GB 的表,应该通过具体的基准测试来评估,这也引出了黄金法则:

基准测试是关键! 不要仅仅依赖理论。测试不同的方法(规范化、非规范化、嵌套/重复),以找到最适合你具体用例的高效解决方案。

分区

分区将表划分为基于一个特定列的多个段。分区列可以使用以下三种方法之一:

🗂️ 整数范围分区:根据整数列的范围进行分区,范围包括起始值、结束值和间隔

时间单位分区:按日期、时间戳或日期时间列对表进行分区,粒度可以是每小时、每日、每月或每年

⏱️ 摄取时间分区:根据当前时间,使用名为_PARTITIONTIME的伪列在插入数据时自动分配分区

由你来定义分区列,但强烈建议明智地选择该列,因为这可以减少处理/计费的字节数。

分区示例(按作者)

示例:

CREATE TABLE IF NOT EXISTS silver.some_partitioned_table (
  title STRING,
  topic STRING,
  day DATE
)
PARTITION BY day
OPTIONS (
  partition_expiration_days = 365
);

在上面的示例中,你还可以看到如何设置 partition_expiration_days 选项,该选项会删除超过 X 天的分区。

聚类

聚类根据一个或多个列在每个分区内对数据进行排序。当在查询筛选中使用聚类列时,这项技术将加速执行,因为 BigQuery 可以确定扫描哪些数据块。特别推荐在高基数列中使用该技术,例如以下示例中的 title 列。

你可以定义最多 四个 聚类列。

示例:

CREATE TABLE IF NOT EXISTS silver.some_partitioned_table (
  title STRING,
  topic STRING,
  day DATE
)
PARTITION BY day
CLUSTER BY topic
OPTIONS (
  partition_expiration_days = 365
);

嵌套重复列

数据反规范化通常也会引入信息的重复。数据冗余会增加额外的存储空间和查询时需要处理的字节数。然而,有一种方法可以在没有冗余的情况下使用嵌套重复列实现反规范化的表设计。

嵌套列使用 struct 类型并将某些属性组合成一个对象。嵌套的 重复 列是一个 struct 数组,为表格中的单行存储。例如:如果你有一个表格,每行存储一个用户的登录记录,包括用户 ID 和该用户的注册国家,那么你就会在每个用户的每次登录中出现 ID 和国家的冗余。

与其为每次登录存储一行数据,使用嵌套重复列,你可以为每个用户存储一行数据,并在一个类型为 ARRAY<STRUCT<...>> 的列中存储该用户的所有登录记录。该结构体包含与登录相关的所有属性,例如日期和设备。以下插图可视化了这个示例:

嵌套重复列示例(按作者)

示例:

CREATE TABLE silver.logins (
    user_id INT64,
    country STRING(2),
    logins ARRAY<STRUCT<
        login_date DATE,
        login_device STRING
    >>,
    day DATE
)
PARTITION BY day
CLUSTER BY country, user_id
OPTIONS (
    require_partition_filter=true
);

上面的示例还展示了 require_partition_filter 的使用,该功能会阻止任何不对分区列进行筛选的查询。

这种数据建模技术可以显著减少存储和处理的字节数。然而,它并不是所有反规范化或数据建模场景的万能解决方案。主要的缺点是:你不能在结构体属性上设置聚类或分区列

这意味着:在上面的示例中,如果用户按 login_device 进行筛选,则需要进行全表扫描,而且我们没有通过聚类进行优化的选项。特别是当你的表格被用作 Excel 或 PowerBI 等第三方软件的数据源时,这可能会成为一个问题。在这种情况下,你应仔细评估通过嵌套重复列去除冗余的好处是否足以弥补无法通过聚类进行优化的缺点。

索引

通过在一个或多个列上定义搜索索引,BigQuery 可以利用此索引加速使用 SEARCH 函数的查询。

可以使用CREATE SEARCH INDEX语句创建搜索索引:

CREATE SEARCH INDEX example_index ON silver.some_table(ALL COLUMNS);

使用ALL COLUMNS时,索引会自动为所有STRINGJSON列创建。你也可以更有选择性地只为特定列添加列名列表。使用SEARCH功能,索引可以在所有或特定列中进行搜索:

SELECT * FROM silver.some_table WHERE SEARCH(some_table, 'needle');

一项新功能,在撰写本文时处于预览状态,允许将索引用于如=INLIKESTARTS_WITH等操作符。这对于那些通过像 PowerBI 或 Excel 这样的第三方工具直接供最终用户使用的数据结构非常有利,可以进一步提高速度并降低某些过滤操作的成本。

关于这一点的更多信息可以在官方搜索索引文档中找到。

物理字节存储计费

BigQuery 提供了两种存储计费模型:标准模型和物理字节存储计费模型。选择合适的模型取决于你的数据访问模式和压缩能力。

标准模型非常直接。你按每 GB 数据支付固定费用,如果数据在 90 天内未修改,还会有轻微的折扣。这种方式简单易用,不需要管理不同的存储类别。然而,如果你的数据高度压缩,或者你不经常访问它,这种模型可能会更昂贵。

物理字节存储计费采用不同的方法。你支付的费用是基于数据在磁盘上占据的物理空间,而不是基于存储的逻辑数据量,无论你访问它的频率如何,或者它压缩得有多好。对于高度压缩的数据或不经常访问的数据,这种模型可能会便宜得多。然而,它要求你管理两种独立的存储类别:一种是频繁访问的数据,另一种是长期存储的数据,这可能会增加复杂性。

那么,你应该选择哪个模型呢?以下是一个简明指南

如果选择标准模型

  • 你的数据没有高度压缩。

  • 你更倾向于选择简单且易于管理的方法。

如果选择 PBSB 模型

  • 你的数据高度压缩。

  • 你能够管理不同的存储类别来优化成本。

你可以在数据集的高级选项中更改计费模型。你还可以在表格详细信息视图中检查逻辑字节与物理字节的差异,这使得选择模型更为方便。

存储计费模型的数据集高级选项

主键和外键的连接优化

2023 年 7 月起,BigQuery 引入了非强制性的主键和外键约束。请记住,BigQuery 并不是一个经典的关系数据库管理系统,尽管使用此功能定义数据模型可能会让你觉得它是。

如果这些键没有强制执行,并且这不是我们熟悉的关系数据库,那么意义何在?答案是:查询优化器可以利用这些信息来更好地优化查询,特别是在内部连接消除、外部连接消除和连接重排序等概念上。

定义约束类似于其他 SQL 方言,只不过你必须将其指定为 NOT ENFORCED

CREATE TABLE gold.inventory (
 date INT64 REFERENCES dim_date(id) NOT ENFORCED,
 item INT64 REFERENCES item(id) NOT ENFORCED,
 warehouse INT64 REFERENCES warehouse(id) NOT ENFORCED,
 quantity INT64,
 PRIMARY KEY(date, item, warehouse) NOT ENFORCED
);

⚙️ 数据操作

复制数据 / 表

从一个地方复制数据到另一个地方是我们作为数据工程师日常工作的一部分。假设任务是将名为 bronze 的 BigQuery 数据集中的数据复制到另一个名为 silver 的数据集,且该数据集位于名为 project_x 的 Google Cloud Platform 项目中。简单的方法是执行如下 SQL 查询:

CREATE OR REPLACE TABLE project_x.silver.login_count AS
SELECT
    user_id,
    platform,
    login_count,
    day
FROM project_x.bronze.login_count;

尽管这样可以进行转换,但在许多情况下,我们只是希望将数据从一个地方复制到另一个地方。上面查询的计费字节数基本上是我们需要从源头读取的数据量。然而,我们也可以通过以下查询免费获得这些数据:

CREATE TABLE project_x.silver.login_count
COPY project_x.bronze.login_count;

或者,可以使用 bq CLI 工具来实现相同的结果:

bq cp project_x:bronze.login_count project_x:silver.login_count

通过这种方式,你可以以0 费用复制数据。

加载数据

对于数据摄取,Google Cloud Storage 是解决该任务的务实方式。无论是 CSV 文件、来自 Hadoop 生态系统的 ORC / Parquet 文件,还是其他任何来源,都可以轻松上传并以低成本存储数据。

也可以在 GCS 上的数据基础上创建 BigQuery 表。这些外部表仍然利用 BigQuery 的计算基础设施,但不提供某些功能和性能。

假设我们从使用 ORC 存储格式的分区 Hive 表上传数据。上传数据可以使用 distcp 完成,或者通过先从 HDFS 获取数据,然后使用与 Cloud Storage 交互的可用 CLI 工具将其上传到 GCS。

假设我们有一个包含名为 month 的分区结构,那么文件可能看起来如下:

/some_orc_table/month=2024-01/000000_0.orc
/some_orc_table/month=2024-01/000000_1.orc
/some_orc_table/month=2024-02/000000_0.orc

当我们将这些数据上传到 GCS 时,可以像这样创建外部表定义:

CREATE EXTERNAL TABLE IF NOT EXISTS project_x.bronze.some_orc_table
WITH PARTITION COLUMNS
OPTIONS(
  format="ORC",
  hive_partition_uri_prefix="gs://project_x/ingest/some_orc_table",
  uris=["gs://project_x/ingest/some_orc_table/*"]
);

它将从 ORC 文件中推导出模式,甚至检测分区列。将这些数据从 GCS 移动到 BigQuery 存储的简单方法现在可能是,在 BigQuery 中创建一个表,然后按照务实的 INSERT INTO ... SELECT FROM 方法操作。

然而,类似于前面的例子,计费的字节数将反映存储在 gs://project_x/ingest/some_orc_table 中的数据量。还有另一种方式,可以通过使用 LOAD DATA SQL 语句以0 费用实现相同的结果。

LOAD DATA OVERWRITE project_x.silver.some_orc_table (
  user_id INT64,
  column_1 STRING,
  column_2 STRING,
  some_value INT64
)
CLUSTER BY column_1, column_2
FROM FILES (
  format="ORC",
  hive_partition_uri_prefix="gs://project_x/ingest/some_orc_table",
  uris=["gs://project_x/ingest/some_orc_table/*"]
)
WITH PARTITION COLUMNS (
  month STRING
);

使用此语句,我们直接获取包含数据的 BigQuery 表,无需先创建外部表!此外,此查询没有费用OVERWRITE 是可选的,因为数据也可以追加,而不是每次运行时都覆盖表。

如你所见,分区列也可以被指定。虽然不能应用任何转换,但有一个主要优点:我们可以提前定义集群列。这样,我们可以创建一个高效的目标表版本,用于后续的下游处理,且免费

删除分区

在某些 ETL 或 ELT 场景中,典型的工作流是将表按天分区,然后根据来自临时/摄取表的新数据替换特定分区。

分区摄取示例(作者)

BigQuery 提供了MERGE语句,但简单的方法是先从目标表中删除受影响的分区,然后插入数据。

在这种情况下,删除分区可以这样实现:

DELETE FROM silver.target WHERE day IN (
  SELECT DISTINCT day
  FROM bronze.ingest
);

即使day是两种情况下的分区列,该操作仍然涉及若干成本。然而,再一次,这里有一个零成本的替代方案:

DROP TABLE silver.target$20240101

使用DROP TABLE,你实际上也可以通过附加后缀$<partition_id>来删除单个分区。

当然,上面的示例只是删除一个分区。然而,使用 BigQuery 的过程语言,我们可以轻松地在循环中执行该语句。

FOR x IN (SELECT DISTINCT day FROM bronze.ingest)
DO
  SELECT x; -- replace with DROP TABLE
END FOR;

或者,可以使用 Airflow 和/或 dbt,先选择分区,然后在循环中运行某个模板化查询。

然而,获取分区表的不同分区可以像上述示例那样进行,但即使我们只读取单一列,这仍然会产生一些成本。但再次强调,有一种几乎免费的方法来实现这一点,我们将在下一章中探讨。

获取表的不同分区

在上面的示例中,我们使用了以下方法来获取分区的不同分区:

SELECT DISTINCT day
FROM bronze.ingest

这是我在一个示例用例中查询的成本:

Bytes billed: 149.14 GB (= $1.18 depending on location)

BigQuery 维护了大量关于表、列和分区的有价值元数据。这些信息可以通过INFORMATION_SCHEMA访问。我们可以通过简单地使用这些元数据来实现完全相同的结果:

SELECT PARSE_DATE('%Y%m%d', partition_id) AS day
FROM bronze.INFORMATION_SCHEMA.PARTITIONS
WHERE table_name = 'ingest'

与我之前提到的相同用例进行比较,这就是查询的成本:

Bytes billed: 10 MB (= $0.00008 depending on location)

如你所见,149GB 与 10MB 之间的差距非常大。通过这种方法,即使是巨大的表,你也可以以几乎零成本获取不同的分区。

不要持久化计算的度量

当你开始使用 BigQuery 进行第一个项目时,你很可能会选择按需计算定价模式。使用按需定价,你通常可以访问最多 2000 个并发槽位,所有查询共享这些槽位。但即使使用容量定价,你也会至少拥有 100 个槽位。

对于大部分日常的 ETL/ELT 工作负载,这些插槽实际上并不是性能的瓶颈。你可以通过进入 BigQuery -> 管理 -> 监控,选择正确的位置,并在“图表配置”下将图表改为 插槽使用情况 来自行检查。在很多情况下,你会惊讶于实际使用的插槽是多么少。

BigQuery 插槽监控

这与节省成本有什么关系呢?假设你有一个经典的事实表,或者一般的某个表,它提供了某些关键绩效指标(KPI)。这个表随后被用于 Looker、Excel、PowerBI 或其他工具中的分析/报告。

通常,这些工具会自动生成查询来为报告或仪表盘提供所需的数据。这些自动生成的查询可能在应用 BigQuery 最佳实践时并不理想。换句话说,它们可能会扫描比必要更多的数据,从而增加计费的字节数。

我们可以通过在事实表上方引入视图层来避免这一点。通过视图而非实际表提供数据给服务工具是一种非常有价值的最佳实践,因为这为你在模式变更时提供了更多灵活性,同时也能在视图中引入计算度量而不需要持久化数据。

当然,当使用这些度量时,这可能会增加 CPU 使用率,但另一方面,它也能显著减少底层表的总大小。

为了说明这一原则,以以下事实表为基础:

CREATE TABLE IF NOT EXISTS gold.some_fact_table (
  user_id INT64,
  payment_count INT64,
  value_1 INT64,
  value_2 INT64,
  day DATE
)
PARTITION BY day
CLUSTER BY user_id
OPTIONS (
  partition_expiration_days = 365
);

基本思路是为访问这些数据的利益相关者引入视图,并通过计算度量对其进行扩展:

CREATE OR REPLACE VIEW gold.some_fact_view AS
SELECT
  user_id,
  payment_count,
  value_1,
  value_2,
  payment_count > 0 AS is_paying_user,
  value_1 + value_2 AS total_value,
  day
FROM gold.some_fact_table;

在这个例子中,我们能够避免持久化两个 INT64 值。每一个值都使用 8 个逻辑字节。如果我们的事实表有 1,000,000,000 行,那么这意味着我们节省了:

1000000000 rows * 8 B * 2 columns / 1024 / 1024 / 1024 = 15 GB

这并不是大量数据,但它可能意味着在某些情况下 BigQuery 必须扫描的 15 GB 数据减少了。实际上,可能存在计算度量,它们能够节省更多的数据扫描量。

📚 总结

不要像守护宝藏的巨龙一样囤积每一个字节。相反,学会通过智能管理和优化来“燃烧”数据 🔥。通过采用这种激烈的方法,你将把 BigQuery 从一个成本中心转变为强大的数据探索引擎,让你燃烧的是数据,而不是金钱!

采用数据建模最佳实践

  • 利用尽可能小的数据类型以最小化存储和处理成本。

  • 在适当的时候利用去规范化,以优化查询性能并减少存储使用。

  • 实施分区和聚类,使 BigQuery 能够高效地仅扫描查询所需的相关数据。

  • 探索嵌套重复列作为消除冗余并保持数据完整性的一种方式,但要注意聚类的限制。

掌握数据操作以提高成本效益

  • 使用 CREATE TABLE ... COPYbq cp 命令在表之间复制数据,无需产生费用。

  • 使用 LOAD DATA 语句直接从 Cloud Storage 加载数据到 BigQuery 表中,且无需付费。

  • 利用 DROP TABLE 和分区后缀的功能高效地删除特定分区。

  • 利用 INFORMATION_SCHEMA 获取表的元数据,例如不同的分区值,相比传统查询大大降低成本。

设计时要注重效率,避免不必要的数据持久化。

  • 实现视图层来提供计算度量的数据,避免存储冗余数据。

  • 监控你的 BigQuery 插槽使用情况,以了解插槽限制是否是一个问题,从而让你可以集中精力优化查询结构。

通过采用这些策略,你可以释放 BigQuery 的真正潜力,将其转变为一个高效且具成本效益的数据探索和分析引擎。记住,在 BigQuery 的世界里,关键是消耗数据,而不是金钱!

欢迎在评论区分享你的经验!

一劳永逸地打破人工智能的炒作泡沫

原文:towardsdatascience.com/bursting-the-ai-hype-bubble-once-and-for-all-581a994fe762?source=collection_archive---------1-----------------------#2024-10-12

错误信息和糟糕的研究:一个案例研究

Pau Blasco i RocaTowards Data Science Pau Blasco i Roca

·发表于 Towards Data Science ·9 分钟阅读·2024 年 10 月 12 日

--

不容忽视的事实是,人工智能模型,如 ChatGPT,已经接管了互联网,遍布它的每个角落。

大多数人工智能的应用在广泛的任务中(如医疗保健、工程学、计算机视觉、教育等)都非常有用且具有益处,我们没有理由不投资时间和金钱来推动它们的发展。

这对于生成式人工智能(GenAI)来说并不适用,我将在本文中专门提到这一点。包括大语言模型(LLMs)和检索增强生成(RAGs)模型,如ChatGPT、Claude、Gemini、Llama 以及其他模型。我们需要非常具体地界定我们所称的人工智能、所使用的模型及其对环境的影响。

[1]: 在线关于“人工智能”和“ChatGPT”这两个词的兴趣趋势(过去四年)。截图由我拍摄。来源:Google Trends

那么,人工智能正在接管世界吗?它的智商有 120 吗?它能比人类思考得更快、更好吗?

什么是人工智能的炒作?

AI 炒作是社会对 AI 的广泛兴奋,特别是对变换器(类似 GPT 的)模型的兴奋。它已经渗透到每个领域——医疗、IT、经济学、艺术——以及生产链的每个层级。事实上,43%的高管和 CEO 已经使用生成式 AI 来指导战略决策 [2]。以下链接的文章将科技公司裁员与 AI 使用联系起来,包括 FAANG 等大公司[345]。

AI 炒作的影响也可以在股市中看到。英伟达公司(NVIDIA)的案例就是一个明确的例子:由于英伟达生产用于训练 AI 模型的关键硬件组件(GPU),其股价已经不可思议地上涨(可以说这并不反映公司真正的增长,而是更多地反映了人们对其重要性的认知)。

英伟达公司在过去五年的股价变化。可以看到,去年股价的增长惊人,市值翻了三倍(52 周最高值是 52 周最低值的 3.5 倍),过去三年甚至增长了更为惊人的 27.58 倍。截图由我拍摄,数据来自Refinitiv

为什么这是个问题?

人类一直抵制采纳新技术,尤其是那些他们无法完全理解的技术。这是一个令人害怕的步骤。每一个突破都像是对未知的“赌博”——因此我们感到恐惧。在我们确信其效用和安全性足以证明风险是值得的之前,大多数人不会轻易转向新事物。嗯,直到某些东西打破了我们的直觉,而这种东西和恐惧一样,根源于情感:炒作。

生成式 AI 存在许多问题,其中大多数几乎无法解决。一些例子包括模型幻觉(例如,草莓中有多少个“r”?[6])、没有自动辨识能力(模型无法判断自己是否正确完成任务[7]),以及其他问题,如安全漏洞。

生成式 AI 幻觉的模拟对话示例。图像由我生成。示例类似于[6]和[17]中展示的案例。

而且,考虑到伦理问题……

当我们考虑伦理问题时,情况并没有好转。AI 引发了一系列棘手问题:版权、隐私、环境和经济问题。为了简要总结,避免超出本文的篇幅:

AI 是通过盗用数据进行训练的:大多数用于训练的内容,如果不是绝大多数,都是盗用的。在我们社会对著作权保护和合理使用界限进行反思的同时,AI 引发的恐慌可能会带来与其真正盗窃行为同样严重的损害。《史密森学会》(8)、《大西洋月刊》(9)、IBM(10)和《自然》杂志(11)都在讨论这一问题。

经济不平等的延续:CEO 们进行的代理、大规模且回报低的投资通常会通过大规模裁员、降低工资或更糟的工作条件反噬工人阶级。这种情况延续了社会和经济的不平等,只是为了维持 AI 炒作的泡沫[12]。

对环境危机的贡献地球的研究[13]声称,ChatGPT-3(1750 亿参数)在训练过程中使用了 700,000 升淡水,并且每次与用户的对话消耗半升水。根据该研究的线性推算,ChatGPT-4(约 1.8 万亿参数)在训练中可能使用了 700 万升水,并且每次对话消耗 5 升水。

个案研究:误信息/研究不当的示例

最近,Maxim Lott[14] 发表了一项名为“AI 智能的重大突破:OpenAI 突破 IQ 120” [15]的研究,结果非常有希望。他通过 IQ 测试评估 AI,发现 OpenAI 的最新版本 o1 取得了 120 的 IQ 分数,远远领先于其他模型(Claude-3 Opus、GPT-4 Omni 和 Claude-3.5 Sonnet,它们的 IQ 分数仅略高于 90)。

这是七次 IQ 测试的平均结果。作为背景说明,IQ 120 的分数使得 OpenAI 在人类智力方面位于前 10%。

来自 Maxim Lott 的博客文章中的图片。Mensa 挪威 IQ 测试结果,问题在线(DuckDuckGo 搜索“Mensa Norway iq test”可以找到,第一个结果在这里)。

这其中有什么陷阱吗?就这些吗?我们是否已经编程出一个比普通人更聪明的模型?机器是否超越了它的创造者?

关键在于,和往常一样,训练集。Maxim Lott 声称测试问题不在训练集中,或者至少,它们是否在其中并不重要[15]。值得注意的是,当他用一个据称私密的、未公开(但已校准)的测试来评估模型时,智商分数被完全摧毁

来自 Maxim Lott 的博客文章中的图片。新测试包含了新的智商问题以及旧的、网上可得的问题。关于新旧问题的比例以及它们是否在复杂度上均匀分布,还不清楚。

为什么会发生这种情况?

之所以会发生这种情况,是因为模型在它们的训练数据集中有这些信息,通过搜索它们被问到的问题,它们能够得到答案,而不需要“思考”。

想象一下,在考试之前,如果一个人同时获得了问题和答案,并且只需要记住每一对问题-答案。你不会说他们因为得了 100%就很聪明吧?

此外,视觉模型在两项测试中表现极差,智商计算得分在 50 到 67 之间。它们的得分与一个随机回答的代理一致,在挪威门萨的测试中,这相当于每答对 1 个问题(6 个问题中答对 1 个)。根据 M. Lott 的观察以及实际测试如 WAIS-IV 的工作方式,如果 25/35 等于 120 智商,那么 17.5/35 相当于 100 智商,9/35 则略高于 80 智商,而随机选择答案(约 6/35 正确)会得出 69-70 智商的分数。

不仅如此,大多数问题的推理似乎最多也就是明显偏离或完全错误。模型似乎会发现不存在的模式,或者生成预写的、重复使用的答案来为它们的选择辩护。

此外,尽管他声称测试是仅限离线的,但似乎测试在网上发布了若干小时。引述:“我随后创建了一个调查,包含他的新问题以及一些挪威门萨的问题,并要求博客读者参与。大约 40 人参与了。然后我删除了调查。这样,这些问题就没有发布到搜索引擎等可以访问的公共互联网中,应该不会出现在 AI 训练数据中。”**[15]。

作者不断自相矛盾,提出模糊的说法,没有实际证据支持,并将这些说法作为真实证据呈现。

所以不仅问题被发布到互联网上,而且测试还包括了以前的旧问题(那些在训练数据中出现过的问题)。我们在这里再次看到 Lott 的矛盾说法。

可惜的是,我们没有详细的题目结果或比例的划分,无法区分新旧问题。结果一定会非常有趣。再次表现出研究的不完全性。

是的,确实有证据表明这些问题出现在训练数据中,而且没有任何模型真正理解它们在做什么,或者它们自己的“思维”过程。

更多的例子可以在这篇关于 AI 和创意生成的文章中找到。尽管它也在追逐炒作的浪潮,但它展示了模型如何无法区分好主意和坏主意,暗示它们并不理解其任务背后的基本概念[7]。

那么结果到底有什么问题呢?

根据科学方法,如果一个研究者得到了这些结果,接下来的逻辑步骤应该是接受 OpenAI 没有取得任何重大突破(或者即使有突破,也无法通过 IQ 测试来衡量)。然而,Lott 依然坚称他的“AI 巨大突破”论述。这就是虚假信息开始蔓延的地方。

错误信息的影响:一场连锁反应

让我们闭合这个循环:这些类型的文章是如何推动 AI 炒作泡沫的?

文章的 SEO [16] 非常巧妙。标题和缩略图都极具误导性,这反过来又制造了非常吸引眼球的推文、Instagram 和 Linkedin 帖子。IQ 正态分布曲线上的神奇分数实在太好看,令人无法忽视。

在这一部分,我将回顾一些“新闻”是如何在社交媒体上传播的例子。请注意,嵌入的推文可能需要几秒钟才能加载。

CC:根据挪威门萨 IQ 测试,OpenAI o1 现在比大多数人类更聪明。它得到了 120 分,比普通人类高出 20 分,比其他高级 AI 模型如 Claude 高出 30 分。如果这是真的,简直疯狂。完整的 IQ 测试结果请见这里:(文章链接)[18]

这条推文声称结果“根据挪威门萨 IQ 测试”,但这不是真的。这个声明并不是由测试本身提出的,而是由第三方提出的。它再次将其陈述为事实,并在后面给出了合理的否认空间(“如果是真的,简直疯狂”)。让我们看看下一条:

CC:AI 现在比普通人类更聪明。这项来自 maximlott@ 的令人难以置信的研究非常精彩,我强烈推荐关注他。当所有模型都超越人类时会发生什么?(文章的第一部分图片)[19]

这条推文没有改变立场,直接将 Lott 的研究呈现为事实(“AI 现在比普通人类更聪明”)。除此之外,观众只看到了第一张图表的截图(训练数据中的问题-答案,膨胀的分数),这非常具有误导性。

CC:它发生了:OpenAI 的新模型智商跃升了30 点,达到了 120 智商。[……] “担心人工智能接管世界吗?你可能真的应该担心……(查看更多)。” 注意:作者 maximlott@进行了另一次无污染测试,结果显示得分较低(约 100——普通人类),但智商跃升相对相似。因此,不管你看哪个分数,跃升都是巨大的,趋势显而易见。时间不多了。[20]

这个确实具有误导性。即使给出了某种免责声明,信息依然不准确。后续测试并没有做到无污染,因为报告中提到它包含了在线可用的题目,且在视觉测试部分表现仍然非常差。这里没有明显的趋势可供观察。

结论

对我们分享的信息进行双重甚至三重检查至关重要。虽然真理是一个无法完全达到的绝对值,但虚假或部分虚假的信息却是非常现实的。炒作、社会情绪的普遍化或类似的力量不应推动我们草率发布内容,无意中助长那些本应早已消亡的运动,而这些运动正在造成如此负面的经济和社会影响。

越来越多本应局限于情感和思想领域的内容正在影响我们的市场,股市每天变得更加波动。人工智能热潮的案例就是另一个炒作和错误信息如何结合的例子,以及它们可能带来的灾难性后果。

免责声明:一如既往,回复开放以供进一步讨论,我鼓励大家参与。任何形式的骚扰或仇恨言论,无论是针对原文作者、第三方,还是我本人,都将不被容忍。其他任何形式的讨论都非常欢迎,无论是建设性的还是尖锐的批评。研究应该始终能够被质疑和审查。

参考文献

[1] Google Trends,自 2021 年以来,网络上关于“AI”和“ChatGPT”的搜索可视化。trends.google.com/trends/explore?date=2021-01-01%202024-10-03&q=AI,ChatGPT&hl=en

[2] IBM 2023 年关于首席执行官及其如何看待和使用人工智能做出商业决策的研究。newsroom.ibm.com/2023-06-27-IBM-Study-CEOs-Embrace-Generative-AI-as-Productivity-Jumps-to-the-Top-of-their-Agendas

[3] CNN,技术行业裁员中的人工智能。edition.cnn.com/2023/07/04/tech/ai-tech-layoffs/index.html

[4] CNN,裁员与对人工智能的投资。edition.cnn.com/2024/01/13/tech/tech-layoffs-ai-investment/index.html

[5] Bloomberg,AI 导致的裁员比公司愿意承认的要多。 www.bloomberg.com/news/articles/2024-02-08/ai-is-driving-more-layoffs-than-companies-want-to-admit

[6] INC,草莓里有多少个 r?这个 AI 无法告诉你 www.inc.com/kit-eaton/how-many-rs-in-strawberry-this-ai-cant-tell-you.html

[7] ArXiv,LLMs 能否生成新颖的研究创意?一项涉及 100 多名 NLP 研究者的大规模人类研究。 arxiv.org/abs/2409.04109

[8] Smithsonian,AI 图像生成器是在盗取艺术家的作品吗? www.smithsonianmag.com/smart-news/are-ai-image-generators-stealing-from-artists-180981488/

[9] The Atlantic,生成型 AI 无法引用其来源。 www.theatlantic.com/technology/archive/2024/06/chatgpt-citations-rag/678796/

[10] IBM,关于 AI 隐私的话题 www.ibm.com/think/topics/ai-privacy

[11] Nature,知识产权与数据隐私:AI 的隐藏风险。 www.nature.com/articles/d41586-024-02838-z

[12] Springer,AI 炒作机制及其对地球和社会的成本

[13] Earth,ChatGPT-3 的环境影响 earth.org/environmental-impact-chatgpt/

[14] Twitter,用户“maximlott”。 x.com/maximlott

[15] Substack,AI 智能的重大突破:OpenAI IQ 超过 120。 substack.com/home/post/p-148891210

[16] Moz,什么是 SEO? moz.com/learn/seo/what-is-seo

[17] Thairath 技术创新,科技公司,AI 幻觉示例 www.thairath.co.th/money/tech_innovation/tech_companies/2814211

[18] Twitter,推文 1 x.com/rowancheung/status/1835529620508016823

[19] Twitter,推文 2 x.com/Greenbaumly/status/1837568393962025167

[20] Twitter,推文 3 x.com/AISafetyMemes/status/1835339785419751496

[1]: 关于“AI”和“ChatGPT”术语在网络上的兴趣变化。经过简化并修正了宽高比,适合用作缩略图。来源:Google Trends。由我编辑。

使用 Python 进行商业规划 — 库存与现金流管理

原文:towardsdatascience.com/business-planning-with-python-inventory-and-cash-flow-management-4f9beb7ecbec?source=collection_archive---------1-----------------------#2024-06-05

小型企业的商业规划,利用数据管理库存、预测流动性需求并最大化盈利。

Samir SaciTowards Data Science Samir Saci

·发布于 Towards Data Science ·阅读时间 13 分钟·2024 年 6 月 5 日

--

使用 Python 进行商业规划 — (图像由作者提供)

现金流管理可以定义为监控和优化现金收入减去现金支出的净额的过程。

在与一位管理中型企业的朋友交谈后,我发现现金可能是增长的最大瓶颈。

“我们必须拒绝订单,因为我们没有足够的现金支付供应商的库存补充款项。”

作为一名供应链数据科学家,我迅速将这个问题与采购、库存管理和分销规划联系起来。

我们能否开发一个 Python 模型来模拟财务和商品流,以支持商业规划?

在本文中,我将分享使用 Python 构建这个问题简单商业建模的方法和工具。

我朋友的商业模型 — (图像由作者提供)

我们将以我朋友的小企业为例。他们将由可再生材料制成的杯子销售给咖啡店和经销商。

Summary

I. Problem Statement: Business Planning
How to use business analytics to help a company selling renewable coffee cups?
  1\. Inventory Management Simulation
Implement an inventory management rule to meet customers' demand.
  2\. Financial Analysis: Costs & Revenue
Map all the financial flows covering costs and revenue along the year.
  3\. Cash Flow Simulation
How much cash on hand you have on a weekly basis to run your business?
II. Business Planning Optimization
What can we do to solve liquidity and profitability issues?
  1\. Scenario 1: Order Quantity Optimization
What if we reduce the order quantity from 8 weeks to 6 weeks coverage.
  2\. Scenario 2: Air Freight for Inbound Logistics
What if we cut the replenishment leadtime by using air freight?
  3\. Scenario 3: Sales Channel Optimization
What if we overpass sales representive by selling to distributors?
  4\. The Optimal Scenario
Let's combine the two best options.
III. Conclusion
  1\. Improve the model
Let's see the potential improvements we can bring in the model
  2\. Revenue optimization
Next step is to focus on the pricing strategy to maximize revenue.
  3\. Sustainable Business with Data Analytics
What about the environmental impact of this business?

问题陈述:商业规划

本部分将简要介绍我收集的元素,帮助你理解我朋友的商业模型。

这些要点包括

  • 库存管理:订单、接收、存储和交付产品

    ❓ 我们什么时候需要下订单以满足客户需求?

  • 财务:成本和收入流

    💡 利润与亏损分析,每周分析。

  • 商业:销售渠道、服务水平协议和佣金

    ❔ 如果我们销售给 XXX,会赚多少利润?

整体商业模型——(图片由作者提供)

我们将对这些元素进行建模,了解它们之间的互动关系,并优化整体价值链

库存管理模拟

首先,我们将在模型核心实施一个库存管理规则,以最低的成本满足客户需求。

库存管理模块——(图片由作者提供)

库存管理规则是机器中的一个关键环节,因为

  • 库存可能成为商业增长的瓶颈 你无法发货如果你没有现货。

  • 补货能力受限于你的财务状况 你需要**现有现金来支付订单。

  • 战略决策会影响你管理库存的方式。

    例如,运输(空运、海运)交货时间会影响库存的安全性。*

本模块基于客户需求、交货时间和安全库存参数生成补货订单。

2023 年托盘的历史销售——(图片由作者提供)

在这个练习中,我使用了2023 年的历史销售数据来模拟最优库存管理的情况。

“我们会持续检查库存,并希望每个订单至少覆盖 8 周。”

为了回答这个问题,我们引入一个持续审查政策(s, Q)

  • 持续审查意味着库存团队将每天检查库存水平。

  • (s, Q)如果库存水平低于某一水平 s (托盘),则必须订购 Q(托盘)

基于安全库存定义再订货点——(图片由作者提供)

再订货点是你需要的库存水平,以满足客户的需求,直到你收到货物。

库存管理参数——(图片由作者提供)

我们通过补货提前期目标周期服务水平客户需求的标准差来定义它。

我不会详细讨论这部分内容,因为它不是文章的重点。

如需更多详细信息,请查看下面链接的文章 👇,

## 零售库存管理 — 随机需求

模拟安全库存水平对库存管理绩效指标的影响,假设其符合正态分布…

[towardsdatascience.com

结果如下图所示。

库存管理规则——(图片由作者提供)

📈 图例

  • 蓝色散点图代表最佳订货政策

  • 绿色图表是现有库存(ioh),即仓库中存储的托盘数量。

  • 第三个图表中的虚线表示重新订购点 s

你可以观察到,当手头库存穿过虚线时,你就有了补货订单

💡 观察

  • 我不确定这个政策是否最优。

    我们只是将我朋友的标准操作模型转化为算法。

  • 我们记得,订单数量和补货提前期可以调节,以最小化库存。

既然我们知道了何时重新订购,我们可以加入财务流动来可视化手头现金

财务分析:成本与收入

上一节从物流角度描述了业务,而没有考虑财务流动。

但我朋友的主要问题是可用流动资金有限,无法订购商品以补充库存。

因此,我们将映射财务流动,以计算每周可用的现金。

收入 历史销售按销售渠道进行分配

  • 分销商在发货后 4 周付款。

    每笔销售后的 4 周,他们会收到开具发票的金额(单价 x 数量)

  • 咖啡店在下订单时付款。

    在每周结束时,他们会收到开具发票的金额(单价 x 数量)

按渠道的收入流(蓝色:咖啡店 / 绿色:分销商) — (图片由作者提供)

💡 观察

由于我们没有考虑上一年的销售数据,分销渠道在前四周没有收入是正常的。

固定与变动成本

  • 采购与入库物流成本

    供应商和货运代理在发货离开工厂时必须付款。

采购与入库物流成本 — (图片由作者提供)

💡 观察 订单在创建后的一周内准备发货。

  • 仓储与结构成本

    它们包括托盘的存储费用(使用每个托盘/天的单价)以及其他经常性成本,如人力成本和设备费用。

仓储与结构成本 — (图片由作者提供)

💡 观察 我朋友很幸运,没有为其托盘在仓库中的存储支付最低费用。

  • 非经常性成本

    这些一次性支付的成本可能包括采购营销材料、特别的员工奖金或分销商处罚。

  • 佣金成本

    我朋友与独立销售代表合作,销售到咖啡店时他们收取30%的佣金

非经常性与佣金成本 — (图片由作者提供)

如果我们总结一下,我们有:

  • 收入流包括来自两个渠道的销售。

    营业额 = (分销商营业额 + 咖啡店营业额)

  • 总成本包括固定成本、变动成本和非经常性成本。总成本 = (变动成本 + 固定成本 + 非经常性成本)

该活动的损益明细 — (作者提供的图片)

💡 观察

  • 我们的结构成本非常低,固定成本不到 10%。

  • 提成是第二大成本类别。

现在我们已经有了财务流的可视化,接下来我们来看每周的流动性平衡。

现金流模拟

计算每周现金流可以帮助我们了解维持此活动直到年末需要多少现金。

  • 现金流 = 营业额 — 成本

现金流可视化 — (作者提供的图片)

💡 观察

  • 现金流始终为正,除了支付供应商和货运代理时。

我们手头有多少现金?

如果我们假设年初时没有现金(这个主意不好),

现金流和现金余额 — (作者提供的图片)

  • 现金余额的最低值为 -124,733 $

  • 第 3 周和第 4 周的现金余额为负。

💡 结论

他们需要至少 125k $ 在年初才能顺利运营活动并按时支付供应商。

下一部分将定义几个绩效指标并模拟场景,以提供数据驱动的业务洞察。

业务规划优化

现在我们的模型已经建立,我们可以调整参数并模拟不同的场景。

每个场景将使用四个指标进行评估。

使用四个指标来评估每个场景 — (作者提供的图片)

  • 年初所需的初始现金:coh_0 (\()** *初始场景:* ***coh_0 = 124,733 (\))*

  • 平均销售成本(COGS):cogs (\(/Pallet)** *初始场景:* ***cogs *= 5,057 (\)/Pallet)*

  • 每托盘的平均物流成本:log_cost (\(/Pallet)** *初始场景:* ***log_cost= 417 (\)/Pallet)*

  • 每托盘的平均利润:avg_profit (\(/Year)** *初始场景:* ***avg_profit = 3,686 (\)/Year)*

这个想法是衡量整个价值链中业务和运营的表现,并与初始场景进行对比。

场景 1: 订货量优化

作为一名供应链工程师,我会从检查物流流和库存管理规则开始。

如果我们减少订货量会怎样?

当我的朋友向我解释他的流动性问题时,我的第一反应是质疑订货量。

你真的需要订购 8 周的库存吗?

平均订购 8 周是他确保有足够库存的方式,以避免担心缺货(即因库存不足而取消订单)

现在我们有了一个带安全库存的优化库存管理规则,我们可以尝试将订货量减少到 Q = 6 周的库存

库存管理规则 — (作者提供的图片)

从预计的库存来看,我们避免了缺货,且对盈利能力的影响不可忽视

  • 在练习开始时,你需要的现金较少。

    情景 1:coh_0 = 74,733 ($) | -41%

  • 销售商品成本(COGS)大幅减少。

    情景 1:cogs = 4,928 ($/托盘) | -2.6%

  • 每个托盘的盈利能力更好。

    情景 1:avg_profit = 3,815 ($/托盘) | +3%

💡 结论

这一快速胜利为流动性需求提供了更多缓冲,并带来了额外的利润。

这一反馈促使我们对该业务价值链的战略愿景进行了深刻反思。

  • 🙋‍♂️ 为什么不将空运用于入库物流?

    空运费用昂贵,但提供更多灵活性,即更低的平均库存。

  • 🙋‍♀️ 我们是否只向经销商销售?

    经销商的付款期限较长(4 周),但我们无需支付销售佣金,且出库物流成本较低。

这些问题是合理的,但回答这些问题需要复杂的计算,我们的模型可以完全自动化这些计算。

情景 2:空运用于入库物流

我体验到,空运主要用于高价值产品,需要快速交付(奢侈品或汽车零部件)。

然而,我建议我的朋友做一下这个练习。

  • 货代提议的空运费用是原来的三倍

  • 交货时间从4 周减少到 1 周

我们现在可以将订单数量从8 周的库存覆盖减少到3 周

1 周交货期的库存管理 — (作者图片)

💡 观察

  • 平均库存水平低于之前,这可能导致储存成本降低。

  • 我们现在订货更频繁,数量较少。

不幸的是,这并不足以弥补高昂的空运费用。

  • 这导致销售商品成本(COGS)的增加。

    情景 2:cogs = 5,511 ($/托盘) | +8 %

  • 这导致每个托盘的盈利能力较低。

    情景 2:avg_profit = 3,232 ($/托盘) | -12%

  • 幸运的是,你在年初所需的现金较少。

    情景 2:coh_0 = 17,288 ($) | -86 %

总结来说,这并不是一个好主意,因为它会降低长期盈利能力

情景 3:销售渠道优化

对于最后这一情景,我们将重点关注销售渠道策略。

我们将我们的杯子卖给谁,以及如何销售?

在当前的情景下,我们有直接销售给咖啡馆和与经销商合作的混合模式。

关注销售渠道策略 — (作者图片)

如果我们仅转向经销商,

  • 付款将在发货后 4 周收到

  • 我们不需要支付销售佣金。

    销售佣金为 0% vs. 直销佣金 30%

  • 我们可以通过合并发货来优化交付。

    出库物流成本减少 50% vs. 直销

第一个影响是我们必须等待四周才能收到首笔付款,这会影响流动性需求。

收入流 — (图片由作者提供)

  • 在开始这项工作时,你需要更多的现金。

    场景 3: coh_0 = 197,602 ($) | -58 %

然而,你正在削减佣金成本,这提高了盈利能力。

  • 对销售商品成本(COGS)有很大的影响。

    新场景: *cogs = 3,172 ($/托盘) | -38 %

  • 每托盘销售的盈利能力更强。

    新场景: avg_profit = 5,068 ($/托盘) | +37 %

最优场景

这个小练习提供了更好的可视性和见解,能够在不影响业务的情况下最大化盈利能力

所有场景的总结 — (图片由作者提供)

如果我的朋友想要最大化他的业务盈利能力,他需要

  • 获取更多来自分销商的订单,并停止直接销售。

  • 在从供应商下单时,改为六周的覆盖期。

如果他遵循这个计划,数据显示他可能将利润提高 33%

结论

这种方法使得不透明的操作流程和商业实践可以转化为一个简单的模型。

改进模型?

这个模型使我们能够理解价值链的每个组成部分是如何相互作用的。

企业的价值链及其关键组成部分 — (图片由作者提供)

这个想法是通过一次点击回答类似的问题:

  • 如果我将海运改为空运,会有什么影响?

  • 最佳销售渠道是什么?

  • 物流成本对整体利润的影响是什么?

接下来是什么?更细粒度和额外的成本结构。

尽管这个简单模型已经提供了关键的战略见解,但它也有局限性。

  • 采购成本结构应包括MOQ递减定价

基于这个结构,你可以找到最优的订单量,以最小化订单和接收产品的成本。

更多详情请参见本文,

## 使用 Python 进行采购流程优化

使用非线性编程来找到最优的订货政策,最小化资本、运输和仓储成本。

towardsdatascience.com

  • 转运公司和运输公司根据 体积服务水平协议开具发票。

如果我们为物流服务提供商提供灵活性,他们将有更多的机会优化路线并降低价格

作为供应链解决方案经理,这是一个常见的练习。

这个例子可以在本文中找到。

## 使用图论分析交通网络

使用图论优化零售公司道路交通网络

[towardsdatascience.com

  • 固定成本必须按类别详细列出:资本支出、人力资源、公共事业等……

我在我的 YouTube 频道上分享了仓储运营成本分解的示例,

  • 销售定价可以包括较短付款期限的折扣根据订单量的递减金额

  • 我们可以扩大范围,包括多个商品进行销售,并考虑产品组合来优化成本和收入。

我们可以利用线性规划和 Python 帮助我的朋友通过销售合适的商品,同时考虑流动性、库存和供应商能力的约束,最大化盈利。

您可以在本文中了解更多关于这种方法的内容,

## 使用 Python 最大化您的商业盈利能力

使用线性规划帮助您的本地面包店通过选择合适的商品来提高商业盈利能力……

[towardsdatascience.com

下一步:收入优化

在下面的文章中,我将探讨收入增长和盈利能力的话题。

收入优化是实施策略以最大化公司收入,同时保持盈利能力。

如果我们实施递减定价来促进销售会怎样?

我的朋友与一位在食品和饮料行业拥有超过 25 年经验的高级主管合作。

我的朋友:“我如何评估她的定价策略,以确保我们保持盈利?”

本文使用模型来评估五种增长情境下的定价策略。

企业提出的定价策略——(图像由作者提供)

在实施递减定价机制后,目标是估算为了保持每托盘销售的相同盈利水平所需的最小增长。

欲了解更多细节,请查看本文。

## 使用 Python 进行商业规划——收入优化

如何利用数据分析帮助小型企业在保持或提高盈利能力的同时最大化收入……

[towardsdatascience.com

那么这个业务的环境影响如何呢?

盈利能力 x 可持续性

我们可以基于盈利能力可持续性限制来优化供应商选择

这个初步模型考虑了我们的咖啡杯的单一供应商。

我们希望减少制造杯子时使用的水量。

然而,我的朋友正在通过在世界各地选择合格供应商来多元化他的采购来源。

在收集了来自这些不同供应商的数据后,我们可以使用我开发的简单网络应用,帮助我们设计最优的供应链网络。

供应链网络设计目标——(作者提供的图片)

该算法根据一个目标(例如最小化成本或特定的环境指标)自动选择最佳供应商。

解决方案示例及其影响——(作者提供的图片)

它创建了供应链流程,将商品生产并交付给您的客户。

欲了解更多详情,请查阅这篇文章。

## 创建可持续供应链优化网络应用

帮助您的组织将可持续采购与供应链优化相结合,以降低成本并减少环境影响……

towardsdatascience.com

关于我

让我们在LinkedinTwitter上联系。我是一个供应链工程师,利用数据分析改善物流运营并降低成本。

如果您需要关于供应链转型的咨询或建议,请通过Logigreen Consulting与我联系。

如果您对数据分析和供应链感兴趣,请访问我的网站。

[## Samir Saci | 数据科学与生产力

专注于数据科学、个人生产力、自动化、运筹学和可持续发展的技术博客……

samirsaci.com](https://samirsaci.com/?source=post_page-----4f9beb7ecbec--------------------------------)

💌 免费将最新文章直接发送到您的邮箱:Newsletter

📘 您的供应链分析完全指南:Analytics Cheat Sheet

使用 Python 进行商业规划——收入优化

原文:towardsdatascience.com/business-planning-with-python-revenue-optimization-83387074826d?source=collection_archive---------5-----------------------#2024-06-26

你如何利用数据分析帮助小型企业在保持或提高盈利能力的同时,最大化其收入?

Samir SaciTowards Data Science Samir Saci

·发表于 Towards Data Science ·13 分钟阅读·2024 年 6 月 26 日

--

使用 Python 进行收入优化——(图片由作者提供)

收入优化是通过实施策略来最大化公司收入,同时保持盈利能力的过程。

在上一篇文章中,我们开始构建一个模型,帮助小企业主(我的朋友)管理库存并避免流动性问题。

商业规划——库存和现金流管理 文章:[链接] ——(图片由作者提供)

多亏了这个模型,我们消除了限制商业增长的瓶颈,如库存和流动性问题。

如何在不降低盈利能力的情况下,增加收入增长的最佳策略是什么?

现在的重点是扩大市场并带来额外的收入,同时不危及商业模式。

本文将把我朋友的商业计划转化为一个 Python 模型,模拟不同场景以找到最佳收入优化策略

我朋友的商业模式——(图片由作者提供)

该案例研究基于一个现有的小公司,该公司将可再生材料制成的杯子销售给咖啡馆和分销商。

第一部分将更新 Python 模型,并加入新的业务和运营假设

定价策略 — (图片由作者提供)

在第二部分中,我们将测试几种以价格为驱动的增长策略,并衡量它们对不同销售增长场景下盈利能力的影响。

Summary

**I. Update the Business Planning Model**
Adapt the model to the new business practice
 **1\. Inventory Management Simulation**
Update the inventory management rule with a periodic rule.
 **2\. Degressive Tariff for Inbound Freight**
Update the pricing structure after negotications with the forwarder
 **3\. Economies of Scale**
Selling more to reduce the impact of fixed costs.
  **4\. Translating Business Ideas with Analytics**
**II. Revenue Optimization: Growth with Higher Profit**
What is the best strategy to increase turnover with the same profitability.
 **1\. Baseline & Profitability Indicators**
What is the current level of profitability.
 **2\. Five growth scenarios for each pricing strategy**
Assess 3 pricing strategies using their impact on profitability.
**III. Conclusion**
 **1\. What is the best strategy?**
Implement middle-risk pricing until reaching +50% growth
 **2\. Next Step: Go green**
Let us measure and reduce the environmental impact of this business.
 **3\. Automate Accounting Tasks using Python** Automatically extract and process data from multiple excel files

更新商业规划模型

在评估商业增长策略之前,我们将根据我从朋友那里收集的额外业务见解来更新模型。

在第一篇文章中,我介绍了构建一个销售可再生咖啡杯的业务价值链模型的方法。

使用 Python 构建模拟模型 — (图片由作者提供)

该模型使用历史销售数据、运营约束和成本信息作为输入,估算您的业务盈利能力和现金流。

它涵盖了从供应商到客户的完整价值链。

咖啡杯的价值链 — (图片由作者提供)

  • 一个库存管理模块,根据客户需求补货交货时间向供应商发送采购订单。

  • 入境/出境物流流程建模,包括交货时间和每个处理单元(托盘或纸箱)的成本。

  • 两个销售渠道(直接销售给咖啡店和经销商)各自有销售佣金(直接销售为 30%)和付款条款。

什么是最佳商业模型?

我们模拟了战略业务和运营决策对不同场景下盈利能力流动性需求的影响。

  • 初始场景是基准(他们现在如何运营业务)。

  • 以下场景测试了减少库存覆盖、使用空运进行入境或仅向经销商销售的影响。

找到最佳商业模型的 5 种场景 — (图片由作者提供)

结论是,最佳策略仅向经销商销售并保持6 周库存覆盖。

我的朋友:“我们不再每天检查库存了,而且我们与货运代理商达成了新的协议”。

随着情况的变化,我们需要通过更新模型来进行调整。

  • 切换到周期性审查政策进行库存管理。

  • 更新成本结构付款条款的入境物流。

库存管理模拟

初始模型基于持续审查政策(s,Q),当库存低于阈值(s)时触发补货订单(Q)。

2023 年客户需求(以托盘计) — (作者插图)

本模块使用客户需求、补货提前期和安全库存参数,确保我们拥有正确的库存水平。

然而,我的朋友没有专门的库存团队来每天监控库存。

我们的资源有限,既要管理库存,又要向供应商下订单。

因此,我使用周期性复审政策 Order-Up-To-Level (R, S) 来调整模型。

  • 周期性复审意味着库存团队每R 周进行一次复审。

  • (R, S)意味着团队每R 周下单,订购足够数量以达到库存水平 S

S级是您需要的库存,以满足客户在下次复审之前R 周内的需求。

库存管理规则参数 — (作者插图)

我们通过补货提前期目标周期服务水平客户需求的标准偏差来定义它。

库存管理参数 — (作者插图)

💡 关于周期性复审政策的更多细节,请查看这篇文章

## 零售库存管理 — 周期性复审政策

基于周期性复审政策实施库存管理规则,以减少商店补货的次数

towardsdatascience.com

结果如下图所示。

库存管理规则可视化 — (作者插图)

📈 图例

  • 蓝色图线代表最佳策略,即每 4 周订货

  • 绿色图线代表手头库存(ioh),即仓库中存储的托盘数量。

现在库存管理模块已更新,我们可以专注于进口货运成本和提前期。

进口货运的递减关税

与货运代理商成功谈判后,他们达成了一个有利的协议:

  • 付款条款:他们可以在交货后4 周内支付给货运代理。

  • 达到特定阈值时,递减关税并提供返利

递减海运运费 — (作者插图)

这对流动性和盈利能力有什么影响?

我们更新了进口物流成本的计算方式,并加入了返利机制。

进口成本结构 — (作者插图)

这里是最佳场景下的一些快速胜利

更新后的场景包含谈判后的合同条款和新的库存政策 — (作者插图)

  • 由于新的条款,维持业务不再需要现金流。

  • 13.6%物流成本减少,源自新的运费价格,这导致商品销售成本(COGS)平均降低2.4%

下图显示我们在支付供应商和货运代理之前先收取分销商的付款

更新商业模式下的现金流—(图源:作者)

我们只解决了最优情景下的流动性问题。

萨米尔:“下一步是什么?”

我的朋友:“我们需要增加收入。”

为了提高盈利能力,我的朋友现在希望通过增加销售量来减少固定成本的影响。

规模经济

规模经济指的是通过增加销售量,企业能够实现的成本优势,从而降低单位的固定成本。

这些成本与公司的结构相关,无法压缩。

受规模经济影响的指标—(图源:作者)

然而,如果我们增加销售并控制这些固定成本,我们就能减少它们对每个售出箱子的影响。

收入优化:通过更高的利润实现增长

经过 24 个月的运营,我的朋友找到了一位商业伙伴,他在食品和饮料行业有 25 年的经验。

我的朋友:“她带来了投资和市场专业知识,目的是推动增长。”

她估算了如果遵循特定商业策略,他们能够达到的增长。

每个销售渠道的增长潜力—(图源:作者)

在分析完商业模式后,她提议改变定价机制,以增加平均购物篮大小。

我的朋友:“她想通过阶梯定价折扣来激励批量购买。”

定价策略示例—(图源:作者)

然而,风险在于影响盈利能力并导致流动性崩溃,因为我们减少了每个箱子售出的营业额。

我的朋友:“如果我们实施策略 X,你能告诉我需要多少增长才能保持相同的盈利能力吗?”

该模型可以估算为了在每种策略中相较基线改善盈利能力所需的最小增长。

基线与盈利能力指标

对于这次分析,我们使用 2023 年的历史销售数据,并更新了条款和库存管理规则,来定义基线。

销售渠道营业额—(图源:作者)

超过 70%的营业额来自直接销售给咖啡店,其余来自具有四周付款期限的分销商。

基线现金流—(图源:作者)

感谢我们与供应商和咖啡店之间有利的付款条款,我们没有流动性问题。

基准指标 — (图像由作者提供)

考虑到固定和变动成本,对于这个情景,我们每售出一托盘可以获得3,910 美元的利润

这一盈利能力受到绿色指标的影响,这些指标涵盖了固定和变动成本。

每种定价策略的五种增长情景

我朋友的商业伙伴将定价策略建立在市场实践和她的行业知识之上。

该模拟旨在通过基于数据的讨论来支持商业决策。

模拟情景 — (图像由作者提供)

我的朋友:“如果你想应用定价策略 1,我们能确保至少有 50%的增长吗?如果不能,我们的盈利能力就会下降。”

让我们从第一种定价策略开始。

情景 1:低风险定价策略 1

这一策略通过在订单超过 50 箱时提供2.5%的折扣,激励客户至少订购一整托盘。

我们需要多大的增长才能保持相同的盈利能力?

正如我们在折扣情景中看到的,实施新定价会导致171 ($/托盘)的利润损失。

模拟定价策略 1 — (图像由作者提供)

然而,由于销售增长会机械性地降低单位成本,当我们达到 50%增长时,这一损失是会被弥补的

  • 由于入库流优化,变动成本也有所降低。

  • 由于所需的更高安全库存,托盘的储存成本增加。

如果她想实施这个策略,必须带来至少当前营业额的 1.5 倍

模拟结论 — (图像由作者提供)

商业伙伴:“如果没有额外的折扣,我们永远无法超过 50%的增长。”

情景 2:中风险定价策略 2

确实,第一种定价策略并未激励客户订购超过 1 托盘(50 箱)。

模拟定价策略 2 — (图像由作者提供)

因此,他们希望对订购超过150 箱(3 托盘)的客户增加 5%的折扣。

如果我们保持相同的销售量,实施这个额外的折扣会导致315 ($/单位)的利润损失。

如果我们只达到 50%的增长,是否会失去盈利能力?

是的,我们的利润是**3,751 (\(/单位)**,相比基准的 3,910 (\)/单位),增长了 50%。

因此,我们至少需要+200%的销售增长,才能恢复基准情景下的盈利水平。

  • 与第一种定价策略不同,每托盘的营业额在达到 100%增长后会下降,直到达到一个平稳状态。

  • 销售成本(COGS)低于第一种定价策略,因为销售代表的 30%销售佣金是基于开票金额。

如果我们实现+200%的销售增长,相较于第一种定价策略,我们会损失171 ($/托盘)的利润。

定价策略 2 的模拟结论 — (图像由作者提供)

然而,根据商业伙伴的说法,这一策略更有可能帮助我们实现这些目标。

我的朋友: “如果我们对大宗订单再实施 10%的折扣呢?”

情景 3:高风险定价 3

即使这看起来不是一个好主意,他们还是想估算在大于 500 箱的订单上增加 10%返利后的利润损失。

Samir: “这给了你关于你商业的有趣见解。”

定价策略 3 的模拟结果 — (图像来源:作者)

在返利情景下盈利的轻微减少(从策略 2 的 3,595 $/托盘到策略 3 的 3,588 $/托盘)表明,他们当前几乎没有超过 500 箱的大订单。

然而,他们将有一个糟糕的体验——当他们实现+50%的增长时,会看到盈利能力下降。

Samir: “你永远无法达到初始情景下的盈利水平。”

该模型提供了通过数据驱动的见解来评估商业伙伴建议的方法。

定价策略 3 的模拟结果 — (图像来源:作者)

用分析来转化商业思路

这个练习展示了如何将基于直觉的商业思路转化为实际数据,从而拯救利润并避免破产。

我的朋友曾是一名数据科学家,采用定量方法进行商业分析,这可能与餐饮行业的文化不太兼容。

基于你的直觉,你的商业决策的定量影响是什么?

该模型支持在讨论战略方法时,与商业伙伴一起通过第三方客观评判来评估思路。

根据模拟,如果我们选择定价策略 2,我们至少需要+50%的增长。我们能做到吗?

我已经开始在一家小型面包店中使用数据分析来帮助商业决策,该面包店希望优化其盈利能力。

使用 Python 最大化商业盈利能力 文章: [链接] — (图像来源:作者)

当时,这只是一个小练习,利用线性规划为商店老板提供战略性见解。

最大化盈利能力的最佳商品组合是什么?

## 使用 Python 最大化你的商业盈利能力

使用线性规划帮助你当地的面包店通过选择合适的商品来提高其商业盈利能力…

towardsdatascience.com

这个建模继续了尝试使用数据分析来支持战略商业决策的过程。

你有没有类似项目的例子,且这些项目有数据分析支持?

结论

最佳策略是什么?

根据当前的订单情况,我建议使用定价策略 2,直到他们达到 50%的增长。

然后,他们可以专注于向分销商销售,以避免销售佣金。

最优销售策略 — (图片来源:作者)

这样可以帮助我们设定更好的折扣,因为削减佣金能节省 30%的营业额。

他们可以根据分销商的订单情况重新进行分析,以设定更新的销售增长目标。

你同意这种方法吗?

下一步:走向绿色

我的朋友被他的客户挑战,要提供这些可再生杯子对环境影响的可见性。

他们将这些杯子宣传为“可持续的”,具有“低环境影响”,并且基于“公平贸易”。

因此,我们将开始沿着价值链估算这项活动的环境足迹,并进行完整的生命周期评估(LCA)。

什么是生命周期评估 文章: [链接] — (图片来源:作者)

这将提供关于采购、储存和交付这些杯子到最终客户的影响的可见性。

我们如何减少这一足迹?选择合适的供应商。

可持续采购是将社会和环境绩效因素融入选择供应商过程中的方法。

可持续采购方法 文章: [链接] — (图片来源:作者)

其目标是基于

  • 不同市场的客户需求(单位/月)。

  • 一份潜在供应商的列表(在不同地点),包括他们的生产成本和环境影响。

可持续供应链网络设计示例 — (图片来源:作者)

我们将使用线性编程和 Python 来选择最优的供应商组合,以最小化成本并减少二氧化碳排放或水资源使用。

最后,我们将使用绿色库存管理来查看分销网络和最后一公里交付。

绿色库存管理 文章: [链接] — (图片来源:作者)

这个理念是通过激励分销商减少订货频率并最大化每次交货的数量,来实现可持续的库存管理。

我的朋友是如何收集用于构建这个模型的财务数据的?

使用 Python 自动化会计任务

我的朋友正在寻找一种解决方案来自动化重复的会计任务,并提高他们团队的生产力。

因此,我开发了一个解决方案,基于本文中提供的示例。

数据收集与处理框架 —— (图片由作者提供)

这个想法是使用 Python 自动提取数据并处理 Excel 文件中的数据。

输入文件示例 —— (图片由作者提供)

在这个示例中,文件遵循相同的模板(如上所示)。

然后很容易创建一个 Python 脚本,能够打开多个文件,提取信息并生成报告。

该报告随后被用来训练这里介绍的模型。

有关会计任务自动化的更多信息,请查看这篇包含构建该工具详细步骤的文章。

## 使用 Python 自动化会计任务

构建自动化解决方案,简化财务审计中的重复任务,并与财务同事共享……

towardsdatascience.com

关于我

让我们在LinkedinTwitter上连接。我是一名使用数据分析来改善物流操作并降低成本的供应链工程师

如果你需要关于供应链转型的咨询或建议,请通过Logigreen Consulting与我联系。

如果你对数据分析和供应链感兴趣,请访问我的网站。

[## Samir Saci | 数据科学与生产力

专注于数据科学、个人生产力、自动化、运筹学和可持续性的技术博客…

samirsaci.com](https://samirsaci.com/?source=post_page-----83387074826d--------------------------------)

📘 你完整的供应链分析指南:分析备忘单

但反向传播到底是什么呢?(第一部分)

原文:towardsdatascience.com/but-what-is-backpropagation-really-part-1-3cf73653ddd6?source=collection_archive---------8-----------------------#2024-02-07

从零开始实现一个简单的神经网络框架

Matthew ChakTowards Data Science Matthew Chak

·发表于 Towards Data Science ·阅读时间:11 分钟·2024 年 2 月 7 日

--

树——计算的核心。来源:Adrian Infernus on Unsplash

尽管我在人工智能生态系统中做了一段时间的工作和研究,但直到最近,我才真正停下来思考神经网络中的反向传播和梯度更新。本文旨在纠正这一点,并希望通过从零开始实现一个简单(但有些强大的)神经网络框架,为读者提供一个深入且易于跟随的讲解。

基本操作——网络的核心

从本质上讲,神经网络只是从输入空间到目标输出空间的数学函数。实际上,我们可以有效地“解开”任何神经网络,转化为一个函数。比如,考虑下面这个简单的具有两层和一个输入的神经网络:

一个简单的神经网络,包含两层和一个 ReLU 激活。在这里,线性网络有权重 wₙ和偏置 bₙ

我们现在可以通过从输入层开始,逐层构建一个等效的函数。让我们逐层跟随我们的最终函数:

  1. 在输入层,我们从恒等函数开始 pred(x) = x

  2. 在第一层线性层,我们得到 pred(x) = wx + b

  3. ReLU 使我们得到 pred(x) = max(0, wx + b₁)

  4. 在最终层,我们得到 pred(x) = w(max(0, wx + b₁)) + b

对于更复杂的网络,这些函数当然变得更加复杂,但关键是我们可以构建出神经网络的这种表示形式。

然而,我们可以更进一步——这种形式的函数对于计算来说并不是特别方便,但我们可以将其解析为更有用的形式,即语法树。对于我们的简单网络,树形结构如下所示:

我们函数的树形表示

在这种树形结构中,我们的叶节点是参数、常量和输入,其他节点是基本操作,它们的参数是它们的子节点。当然,这些基本操作不一定是二元的——例如,Sigmoid 操作是单元的(如果我们不将 ReLU 表示为 0 和 x 的最大值的话,ReLU 也是单元的),我们还可以选择支持多个输入的乘法和加法。

通过将我们的网络看作是这些基本操作的树形结构,我们现在可以通过递归轻松地完成许多任务,这将构成我们反向传播和前向传播算法的基础。在代码中,我们可以定义一个递归的神经网络类,类似于以下形式:

from dataclasses import dataclass, field
from typing import List

@dataclass
class NeuralNetNode:
    """A node in our neural network tree"""
    children: List['NeuralNetNode'] = field(default_factory=list)

    def op(self, x: List[float]) -> float:
        """The operation that this node performs"""
        raise NotImplementedError

    def forward(self) -> float:
        """Evaluate this node on the given input"""
        return self.op([child.forward() for child in self.children])

    # This is just for convenience
    def __call__(self) -> List[float]:
        return self.forward()

    def __repr__(self):
        return f'{self.__class__.__name__}({self.children})'

反向传播 — 递归链式法则

现在假设我们有一个可微的损失函数,用于我们的神经网络,比如 MSE。回想一下,MSE(对于一个样本)的定义如下:

MSE 损失函数

我们现在希望根据损失的值来更新我们的参数(树形表示中的绿色圆圈)。为此,我们需要计算损失函数关于每个参数的导数。然而,直接从损失计算这一点是非常困难的——毕竟,我们的 MSE 是根据神经网络预测的值计算的,而这个值可能是一个极其复杂的函数。

这时非常有用的数学工具——链式法则发挥了作用。我们不需要一开始就计算复杂的导数,而是可以计算一系列更简单的导数。

事实证明,链式法则与我们的递归树结构非常契合。基本的思想如下:假设我们有足够简单的基本操作,每个基本操作都知道其关于所有参数的导数。通过父操作的导数,我们可以通过简单的乘法计算每个子操作关于损失函数的导数。对于使用均方误差(MSE)的简单线性回归模型,我们可以将其图示化如下:

一个简单线性分类器的前向和反向传递图,权重为 w1,偏置为 b1。注意 h₁ 只是我们乘法操作返回的变量,就像我们的预测是通过加法返回的一样。

当然,我们的一些节点在处理其导数时并没有做任何操作——也就是说,只有我们的叶节点关心导数。但现在每个节点都可以通过这种递归过程得到其输出关于损失函数的导数。因此,我们可以向我们的 NeuralNetNode 类添加以下方法:

def grad(self) -> List[float]:
    """The gradient of this node with respect to its inputs"""
    raise NotImplementedError

def backward(self, derivative_from_parent: float):
    """Propagate the derivative from the parent to the children"""
    self.on_backward(derivative_from_parent)
    deriv_wrt_children = self.grad()
    for child, derivative_wrt_child in zip(self.children, deriv_wrt_children):
        child.backward(derivative_from_parent * derivative_wrt_child)

def on_backward(self, derivative_from_parent: float):
    """Hook for subclasses to override. Things like updating parameters"""
    pass

练习 1: 尝试为一个简单的线性回归模型创建一个这样的树,并手动执行几步递归梯度更新。

注意:为了简化起见,我们要求节点只有一个父节点(或者根本没有)。如果每个节点可以有多个父节点,那么我们的 backwards() 算法会变得稍微复杂一些,因为每个子节点需要将父节点的导数相加以计算自身的导数。我们可以通过拓扑排序迭代进行处理(例如,参见 这里),或者仍然递归地进行,即通过反向累积(尽管在这种情况下,我们需要做第二遍遍历以真正更新所有参数)。这并不特别困难,所以我将其作为练习留给读者(并将在第二部分详细讨论,敬请期待)。

编辑:在撰写本文第二部分时,我注意到我们当前的实现确实支持多个父节点—— *on_backward** 将被多次调用,最终会正确更新权重(我鼓励你思考为什么这样做有效)。为了使一切正常工作,实施过程中需要进行一些小的更新(特别是在后面描述的 *find_input_nodes* 方法中),但算法本身无需更改。对此疏漏,我深感抱歉。*

构建模型

剩下的代码实际上只是实现参数、输入和操作,当然还包括运行训练。参数和输入是相对简单的构造:

import random

@dataclass
class Input(NeuralNetNode):
    """A leaf node that represents an input to the network"""
    value: float=0.0

    def op(self, x):
        return self.value

    def grad(self) -> List[float]:
        return [1.0]

    def __repr__(self):
        return f'{self.__class__.__name__}({self.value})'

@dataclass
class Parameter(NeuralNetNode):
    """A leaf node that represents a parameter to the network"""
    value: float=field(default_factory=lambda: random.uniform(-1, 1))
    learning_rate: float=0.01

    def op(self, x):
        return self.value

    def grad(self):
        return [1.0]

    def on_backward(self, derivative_from_parent: float):
        self.value -= derivative_from_parent * self.learning_rate

    def __repr__(self):
        return f'{self.__class__.__name__}({self.value})'

操作稍微复杂一些,但并不太难——我们只需要正确计算它们的梯度。以下是一些有用操作的实现:

import math

@dataclass
class Operation(NeuralNetNode):
    """A node that performs an operation on its inputs"""
    pass

@dataclass
class Add(Operation):
    """A node that adds its inputs"""
    def op(self, x):
        return sum(x)

    def grad(self):
        return [1.0] * len(self.children)

@dataclass
class Multiply(Operation):
    """A node that multiplies its inputs"""
    def op(self, x):
        return math.prod(x)

    def grad(self):
        grads = []
        for i in range(len(self.children)):
            cur_grad = 1
            for j in range(len(self.children)):
                if i == j:
                    continue
                cur_grad *= self.children[j].forward()
            grads.append(cur_grad)
        return grads

@dataclass
class ReLU(Operation):
    """
    A node that applies the ReLU function to its input.
    Note that this should only have one child.
    """
    def op(self, x):
        return max(0, x[0])

    def grad(self):
        return [1.0 if self.children[0].forward() > 0 else 0.0]

@dataclass
class Sigmoid(Operation):
    """
    A node that applies the sigmoid function to its input.
    Note that this should only have one child.
    """
    def op(self, x):
        return 1 / (1 + math.exp(-x[0]))

    def grad(self):
        return [self.forward() * (1 - self.forward())]

这里的操作超类目前并不重要,但稍后我们会需要它来更容易地找到模型的输入。

请注意,函数的梯度通常需要它们子节点的值,因此我们需要调用子节点的 forward() 方法。稍后我们会详细讨论这一点。

在我们的框架中定义一个神经网络有点冗长,但与构建树非常相似。例如,这里是我们框架中一个简单线性分类器的代码:

linear_classifier = Add([
    Multiply([
        Parameter(),
        Input()
    ]),
    Parameter()
])

使用我们的模型

要用我们的模型进行预测,我们首先需要填充树中的输入,然后调用父节点的 forward()。但为了填充输入,我们首先需要找到它们,因此我们向 Operation 类中添加以下方法(我们不会将其添加到 NeuralNetNode 类中,因为该类中尚未定义 Input 类型):

def find_input_nodes(self) -> List[Input]:
    """Find all of the input nodes in the subtree rooted at this node"""
    input_nodes = []
    for child in self.children:
        if isinstance(child, Input):
            input_nodes.append(child)
        elif isinstance(child, Operation):
            input_nodes.extend(child.find_input_nodes())
    return input_nodes

现在我们可以将 predict() 方法添加到 Operation 类中:

def predict(self, inputs: List[float]) -> float:
    """Evaluate the network on the given inputs"""
    input_nodes = self.find_input_nodes()
    assert len(input_nodes) == len(inputs)
    for input_node, value in zip(input_nodes, inputs):
        input_node.value = value
    return self.forward()

练习 2: 我们当前实现的 predict() 方法效率有些低,因为每次运行 predict() 时,我们都需要遍历树来找到所有输入。编写一个 compile() 方法,在执行时缓存操作的输入。

训练我们的模型现在非常直接:

from typing import Callable, Tuple

def train_model(
    model: Operation, 
    loss_fn: Callable[[float, float], float], 
    loss_grad_fn: Callable[[float, float], float],
    data: List[Tuple[List[float], float]], 
    epochs: int=1000,
    print_every: int=100
):
    """Train the given model on the given data"""
    for epoch in range(epochs):
        total_loss = 0.0
        for x, y in data:
            prediction = model.predict(x)
            total_loss += loss_fn(y, prediction)
            model.backward(loss_grad_fn(y, prediction))
        if epoch % print_every == 0:
            print(f'Epoch {epoch}: loss={total_loss/len(data)}')

例如,下面是我们如何使用框架训练一个线性华氏度到摄氏度的分类器:

def mse_loss(y_true: float, y_pred: float) -> float:
    return (y_true - y_pred) ** 2

def mse_loss_grad(y_true: float, y_pred: float) -> float:
    return -2 * (y_true - y_pred)

def fahrenheit_to_celsius(x: float) -> float:
    return (x - 32) * 5 / 9

def generate_f_to_c_data() -> List[List[float]]:
    data = []
    for _ in range(1000):
        f = random.uniform(-1, 1)
        data.append([[f], fahrenheit_to_celsius(f)])
    return data

linear_classifier = Add([
    Multiply([
        Parameter(),
        Input()
    ]),
    Parameter()
])

train_model(linear_classifier, mse_loss, mse_loss_grad, generate_f_to_c_data())

运行此操作后,我们得到

print(linear_classifier)
print(linear_classifier.predict([32]))

>> Add(children=[Multiply(children=[Parameter(0.5555555555555556), Input(0.8930639016107234)]), Parameter(-17.777777777777782)])
>> -1.7763568394002505e-14

这正确地对应于一个线性分类器,权重为 0.56,偏差为-17.78(这就是华氏度到摄氏度的公式)

当然,我们也可以训练更复杂的模型,例如,下面是一个用于预测一个点(x, y)是否位于 y = x 线的上方或下方的模型:

def bce_loss(y_true: float, y_pred: float, eps: float=0.00000001) -> float:
    y_pred = min(max(y_pred, eps), 1 - eps)
    return -y_true * math.log(y_pred) - (1 - y_true) * math.log(1 - y_pred)

def bce_loss_grad(y_true: float, y_pred: float, eps: float=0.00000001) -> float:
    y_pred = min(max(y_pred, eps), 1 - eps)
    return (y_pred - y_true) / (y_pred * (1 - y_pred))

def generate_binary_data():
    data = []
    for _ in range(1000):
        x = random.uniform(-1, 1)
        y = random.uniform(-1, 1)
        data.append([(x, y), 1 if y > x else 0])
    return data

model_binary = Sigmoid(
    [
        Add(
            [
                Multiply(
                    [
                        Parameter(),
                        ReLU(
                            [
                                Add(
                                    [
                                        Multiply(
                                            [
                                                Parameter(),
                                                Input()
                                            ]
                                        ),
                                        Multiply(
                                            [
                                                Parameter(),
                                                Input()
                                            ]
                                        ),
                                        Parameter()
                                    ]
                                )
                            ]
                        )
                    ]
                ),
                Parameter()
            ]
        )
    ]
)

train_model(model_binary, bce_loss, bce_loss_grad, generate_binary_data())

然后我们合理地得到

print(model_binary.predict([1, 0]))
print(model_binary.predict([0, 1]))
print(model_binary.predict([0, 1000]))
print(model_binary.predict([-5, 3]))
print(model_binary.predict([0, 0]))

>> 3.7310797619230176e-66
>> 0.9997781079343139
>> 0.9997781079343139
>> 0.9997781079343139
>> 0.23791579184662365

尽管这有合理的运行时间,但它的速度比我们预期的稍慢。这是因为我们必须调用forward()并且在调用backwards()时重新计算模型输入很多次。因此,以下是这个练习:

练习 3: 给我们的网络添加缓存。也就是说,在调用forward()时,如果输入自上次调用以来没有变化,模型应该返回上次调用forward()时缓存的值仅当输入未改变时。确保在输入发生变化时再次调用forward()

就是这样!我们现在有一个工作的神经网络框架,在这个框架中,我们可以训练很多有趣的模型(尽管不能训练节点输入多个其他节点的网络。这个并不难添加——请参见链式法则讨论中的注释),但确实有点冗长。如果你想改进它,可以尝试以下一些方法:

练习 4: 当你深入思考时,我们网络中的“复杂”节点(例如,线性层)实际上只是“宏”——也就是说,如果我们有一个神经网络树,形状如下:

一个线性分类模型

你真正做的事情是:

我们线性网络的等效公式

换句话说,Linear(inp)实际上只是一个包含|inp| + 1个参数的树的宏,其中第一个参数是乘法中的权重,最后一个参数是偏置。因此,每当我们看到Linear(inp)时,我们可以将其替换为仅由基本操作组成的等效树。

对于这个练习,你的任务是实现Macro类。该类应该是一个Operation,它递归地将自身替换为基本操作。

注意:这个步骤可以随时进行,尽管通常最容易的方法是向你必须在训练之前调用的 Operation 类中添加一个compile()方法(或者将其添加到你在练习 2 中已有的方法中)。当然,我们也可以以其他(或许更高效)的方式实现更复杂的节点,但这仍然是一个很好的练习。

练习 5: 尽管我们实际上不需要内部节点输出除一个数字以外的任何其他内容,但有时对于树的根节点(即输出层)输出其他内容(例如,在 Softmax 的情况下是一个数字列表)是很有用的。实现Output类,并允许它输出一个Listof[float]而不仅仅是一个float。作为奖励,尝试实现 SoftMax 输出。

注意:有几种方法可以实现这一点。你可以让 Output 类继承 Operation,然后修改 NeuralNetNode 类的 op() 方法,使其返回一个 List[float],而不仅仅是一个浮点数。或者,你可以创建一个新的 Node 超类,既让 Output 又让 Operation 继承。这样可能更容易。

进一步注意,尽管这些输出可以生成列表,它们仍然只会从损失函数中返回一个导数——损失函数只会接受一个浮点数列表,而不是单一浮点数(例如,分类交叉熵损失)。

练习 6: 还记得在文章前面我们提到过神经网络只是由基本操作组成的数学函数吗?在 NeuralNetNode 类中添加一个 funcify() 方法,将其转换为以人类可读的符号表示的函数(可以根据需要添加括号)。例如,神经网络 Add([Parameter(0.1), Parameter(0.2)]) 应该简化为 “0.1 + 0.2”(或 “(0.1 + 0.2)”)。

注意:为了使其正常工作,输入应该命名。如果你做了练习 2,请在 compile() 函数中为输入命名。如果没有,你需要想办法为输入命名——编写一个 compile() 函数仍然是最简单的方式。

现在就这些!如果你想查看代码,可以查看 这个 Google Colab,其中包含了所有内容(除了每个练习的解答,但 #6 练习的解答可能会在第二部分添加)。

如有任何问题,请通过 mchak@calpoly.edu 联系我。

除非另有说明,所有图片均由作者提供。

使用 Claude 的新计算机使用模型进行 C 编程

原文:towardsdatascience.com/c-programming-using-claudes-new-computer-use-model-feaad7e3e8db?source=collection_archive---------7-----------------------#2024-10-25

图片由作者提供

Sonnet 在为你编写和运行代码方面表现如何?

Thomas ReidTowards Data Science Thomas Reid

·发布于Towards Data Science ·11 分钟阅读·2024 年 10 月 25 日

--

正如你现在可能已经听说的那样,Claude 在几天前发布了几条重大新闻。

其中一条新闻是关于一款新的 3.5 Haiku 模型,它承诺模拟当前 3.0 Opus 模型的能力,但应该更快、更便宜。这是一个“即将发布”的公告,因为新模型实际发布的时间要到 11 月某个时候。

第二个重大讨论点,也是使 AI 界陷入一阵疯狂的新闻是,升级版的 Sonnet 3.5 模型也拥有了一项新能力。

被称为“计算机使用”模型,该模型现在可以控制 PC 桌面。

它以一种非常自然、类人化的方式实现这一点。

我的意思是,它可以打开窗口和应用程序,使用鼠标进行点击和指向,输入文本,使用 Google 进行网页搜索等…

在这个阶段,计算机使用功能仍处于实验阶段,容易出错,但依然是一个令人兴奋的前景。

现在,你不需要是火箭科学家也能认识到,赋予 AI 这种能力可能会稍微令人担忧。Claude 通过确保目前的新模型…

在 Power BI 中使用行级安全计算总百分比

原文:towardsdatascience.com/calculate-the-percentage-of-the-total-with-rls-in-place-in-power-bi-1ea5c3ab1fac?source=collection_archive---------13-----------------------#2024-02-05

大多数数据模型都有行级安全(RLS)机制,某些用户只能看到整个数据集的部分内容。但当他们必须看到与总体结果进行比较的结果时,情况就不那么简单了。

Salvatore CagliariTowards Data Science Salvatore Cagliari

·发表于 Towards Data Science ·阅读时间 11 分钟·2024 年 2 月 5 日

--

图片来自 Wim van 't EindeUnsplash

介绍

让我们看一下以下场景:

我的销售人员只能看到其指定地理区域的结果。

在这种情况下,它是大洲。

为了进行基准测试,他们必须能够将自己的结果与其他大陆的结果以及总体结果进行比较。

当启用行级安全(RLS)时,这是不可能的,因为用户无法查看其他大洲的结果。

必须对数据模型进行更改才能实现这一点。

那么,让我们来看看如何实现这样的变化。

由 SQLBI 提供的解决方案

SQLBI 已经在此主题上撰写了文章并制作了视频:

[## 在 Power BI 中使用行级安全计算准确的百分比 - SQLBI

本文展示了当行级安全隐藏部分数据时,如何计算比例。如果百分比也……

www.sqlbi.com](https://www.sqlbi.com/articles/computing-accurate-percentages-with-row-level-security-in-power-bi/?source=post_page-----1ea5c3ab1fac--------------------------------)

从理论上讲,我们可以到此为止:阅读文章或观看视频。都没问题,不是吗?

不要那么快,我的小马。

虽然 Alberto 已经使用 DAX 构建了解决方案,但我更愿意在数据模型之前尽早创建附加表,最好是在源(数据库)或 Power Query 中创建。

由于并非每个人都将数据存储在数据库中,我不想深入 SQL 代码来构建必要的表,尽管在 SQL 中创建解决方案是相当直接的。

所以,我去 Power Query 中创建解决方案。

就像我之前的文章一样:

## 在 Power Query 中将扁平表转换为良好的数据模型

当将一个宽表的 Excel 文件加载到 Power BI 中时,我们最终会得到一个不理想的数据模型。我们可以做些什么来创建一个好的...

[towardsdatascience.com

好的,我们开始吧。

第一步 — 创建没有客户的事实表

我将采用上述 SQLBI 文章中描述的相同场景和方法。

由于 RLS 规则已设置在客户表上,我创建了一个“在线销售”事实表的副本,但没有引用客户表。

如果没有这个参考,客户表上的 RLS 规则将不适用,我可以根据需要计算结果。

打开 Power Query 后,我创建了在线销售表的引用:

图 1 — 从在线销售表创建引用(图由作者提供)

通过创建引用,我不会在 Power Query 中重新读取源数据,而是重用原始在线销售表的结果。

我将表重命名为“在线销售(无客户)”。

第二步是分析数据,决定我必须按哪些列对数据进行分组,以及哪些列可以进行聚合。

这一步是必要的,因为我正在改变数据的粒度。

通过移除客户信息,我可以减少表的大小,因为我拥有的详细信息较少。

我可以按表中的所有维度键对数据进行分组。但不能包含客户键。而且,由于我不能确定哪个客户下了哪个订单,因此我还必须删除订单号和订单行详细信息。

但我必须花些时间弄清楚可以聚合哪些列。

让我们看一个例子:

当我取销售数量时,我可以毫无问题地对这个列进行求和,因为结果可以重用。

但是,当我查看单价时,情况就不再那么简单了。

我必须查看我可以如何处理这个列。

当我对这个列进行聚合时,会发生什么呢?

这个价格可能会随着时间的推移而变化。

当通过将单价与销售数量相乘来计算销售金额时,这会带来危险。这样可能导致错误的结果。

因此,我不能聚合单价。

最后,我按以下列对表进行了分组:

  • 订单日期

  • 门店键

  • 产品键

  • 促销键

  • 货币键

  • 日期键年份

  • 到期日期

  • 发货日期

然后,我汇总(求和)这些列:

  • 销售数量

  • 销售金额

  • 返回数量

  • 返回金额

  • 折扣金额

  • 总成本

  • 单位成本

此外,我还可以通过客户键添加不同的计数,以分析数据中的客户数量。

图 2 — 新表的分组和汇总(作者制作的图)

我必须点击“添加分组”以添加用于分组数据的列。

然后,我必须向下滚动,点击“添加聚合”以添加需要聚合的列。

对于每一列,我必须设置操作(聚合函数,如求和),并且可以设置一个新名称。

下一步是将所有关系添加到新创建的表中:

图 3 — 新表的数据模型(作者制作的图)

现在,让我们看看没有和有附加表的结果:

这是没有 RLS 时原始数据的起点:

图 4 — 没有 RLS 时的初始结果(作者制作的图)

现在,让我们为亚洲和澳大利亚应用 RLS 角色:

图 5 — 应用 RLS 角色后的初始结果(作者制作的图)

如您所见,结果在数学上是正确的,但它并没有满足我们的要求。

百分比仅计算在剩余的两个大陆上,而不是所有大陆。

现在,我可以通过将大陆移到切片器,并将产品品牌添加到矩阵中来更改报告:

图 6 — 更改后的报告,带有大陆切片器和品牌(作者制作的图)

由于这一变化,我可以看到值在有 RLS 和没有 RLS 时仍然会变化。

现在的问题是,激活 RLS 角色后的结果是错误的:

图 7 — 应用 RLS 后按品牌错误的结果(作者制作的图)

原因是,按品牌计算的“所有”大陆的销售百分比是基于可用的大洲(亚洲和澳大利亚)来计算的。因此,结果是错误的。

然而,当我添加一个新的度量值,它指向新表时,结果有所不同。

该度量值如下:

% over All Continents (No Customers) =
DIVIDE([Online Sales (By Order Date)]
  ,SUM('Online Sales (No Customers)'[Sales Amount]))

而结果(带有 RLS)是这样的:

图 8 — 在新表和 RLS 下的新度量值结果(作者制作的图)

请注意:如果您没有选择任何大陆,并且没有激活 RLS 角色,则新度量值的结果看起来会错误。

在构建和测试解决方案时,您必须考虑这一点。

根据报告,您可以使用这两个度量值中的一个,因为根据情况,它们都可能提供正确的结果。

例如,销售总监可能可以访问所有大洲的销售结果。对于他来说,原始度量的结果是正确的,因为它考虑了客户的所在大洲。

为了简化这个过程,我们可以添加一个新的客户属性表。

步骤 2 — 基于客户属性的报告

正如 SQL 文章中所描述的那样,可能存在一种情况,需要基于某些客户属性(如性别、教育或其他属性)创建报告。

在这种情况下,我必须为这些属性创建一个表,并使用这个表扩展我们的数据模型。

要构建这样的表格,我必须首先定义需求,然后是创建表格的过程。

例如:

  1. 我必须有一个不包含客户数据的表(例如,由于数据保护规则)。

  2. 我必须无法重建客户的数据。

  3. 我必须能够过滤新的和原始的在线销售表。

接下来,我定义完成要求所需的步骤:

  1. 复制客户表(命名为“客户属性”)。

  2. 移除不必要的列。

  3. 移除所有重复项。

  4. 添加索引(键 / ID)列。

  5. 将新表与原始客户表合并,以添加新创建的键列。

  6. 将键列合并到“在线销售”表中。

  7. 在“在线销售(无客户)”表中添加新的键列。

这应该可以工作。我们开始吧:

一条简短的说明:由于我必须将不包含不需要的客户属性的表合并到原始客户表中,因此无法创建引用,因为这会引入循环依赖。因此,我必须复制客户表:

图 9 — 复制客户表(图源自作者)

我将重复的表重命名为“客户属性”。

现在,我移除所有包含个人数据的列(除性别、教育和其他统计相关列外)。但我必须确保保留所有必要的列,以确保合并操作不会在后续合并步骤中导致行的乘法。

例如,在结果中我得到了没有出生日期的重复项。原因是有很多客户共享相同的属性,但没有出生日期。但是,包含出生日期可以确保行之间可以匹配。

你需要仔细检查这一步,因为这可能会因情况而异。

接下来,我添加索引列:

图 10 — 添加索引列(图源自作者)

索引列被重命名为“CustomerAttributesKey”。

我使用合并功能将新键传输到原始客户表中:

图 11 — 将新的客户属性表合并到原始客户表中(图源自作者)

我必须选择所有匹配的列,同时按住 Ctrl 键,确保所有列被合并,以合并正确的行。

存在不匹配的行问题。

如上所示,十三行无法匹配。不幸的是,我未能找到这种不匹配的原因。Power Query 并没有提供工具来查找并解决此类问题。

我在 Excel 中创建了一个 CustomerAttributes_Dummy 表,CustomerAttributesKey = -1,以解决此问题。然后,我使用“输入数据”功能添加了一个包含此行的表,并将该行附加到 CustomerAttributes 表中。

另附说明:我添加了虚拟行来简化本文的解决方案。在实际情况下,我会尝试找出原因,检查数据,并找到正确的解决方案,而不是使用这种变通方法。

现在,我可以扩展合并后的表以提取 CustomerAttributesKey:

图 12 — 扩展新的 Key 列(图示作者提供)

可以通过“替换值”功能,将 Key 列中不匹配的值填充为虚拟 Key -1:

图 13 — 用虚拟 Key “-1” 替换不匹配的 Key(图示作者提供)

下一步是将 CustomerAttributesKey 添加到“在线销售”表中,并因此添加到“无客户在线销售”表中。

我再次使用合并和扩展功能将列添加到“在线销售”表中:

图 14 — 将在线销售表与客户表合并(图示作者提供)

CustomerAttributeyKey 列的扩展方式与之前展示的相同。

最后,我需要将新的 CustomerAttributeKey 作为分组列添加到“无客户在线销售”表中:

图 15 — 将 CustomerAttributeKey 添加为分组列(图示作者提供)

通过这一步,我完成了数据准备任务。

修改数据模型

到此为止,我有两种数据模型的选择。

无论如何,我将向“无客户在线销售”表添加关系,因为这将满足一个核心需求。

但我可以向客户表或在线销售表添加关系:

  1. 将新的 CustomerAttributes 表链接到客户表,客户表与原始的在线销售表相关联。

  2. 向在线销售表直接添加关系,而不向客户表添加关系。

这里是两种选项并排显示:

图 16 — 客户属性表的数据模型变体(图示作者提供)

  1. 变体 1 使用客户表作为中介表。

    这样会产生以下后果:

  • 可以删除客户表中的重复属性。

  • 因此,属性将具有唯一的分配。

  • 客户表上的 RLS 始终适用。

2. 变体 2 使用来自在线销售表到每个客户表的两个关系:

  • 重复的属性。

  • 有两种可能性可以按相同属性过滤数据。

  • 客户属性表不受客户表上 RLS(行级安全性)的影响。

  • 是否有清晰的星型架构(Star Schema)?

我会选择变体 2,因为我希望消除在某些特定计算中 RLS 规则的影响,这样可以开启更多的可能性。

但这是针对每种情况的决策。

结果与结论

结果会根据数据模型和你如何使用每个表中的属性而有所不同。

例如,当我将两个表中的性别字段添加到一开始展示的矩阵中时,结果如下:

图 17 — 各表格中按性别分类的结果比较(图由作者提供)

虽然右侧按性别分类的结果可以总结为上面的品牌,但左侧的结果无法如此总结。

这些结果是非视觉总计(Non-Visual-Totals),如上文 SQLBI 文章中所述,可能会引起混淆且难以理解,尽管它们在数学上是正确的。

我强烈建议阅读本文,以更好地理解这个复杂的话题。

主要问题是:使用 DAX 还是 Power Query 来准备数据,哪个更好?

严格从数据工程师的角度来看,我会说,在源头或 Power Query 中进行转换,避免在 Power BI 中使用 DAX 进行数据处理。

这将遵循Roche 的准则

尽早进行数据转换,并尽可能晚地进行必要的转换。

使用 Power Query 的另一个理由是效率。

当数据在源头或 Power Query 中准备好时,Power BI 会比使用 DAX 表时更有效地压缩和优化数据存储。

这段非常技术性的视频详细解释了这一点:

然而,进行这种转换的复杂性大于 SQLBI 文章中展示的方法。

在为我的客户开发解决方案时,我会问以下问题:

  • 谁将维护这个解决方案?

  • 那个人的技能是什么?

  • 那个人是否愿意在保持现有解决方案的同时学习更多?

这些问题的答案将决定解决方案的方法。

我曾经遇到过这样的情况:当客户无法或不愿学习我首次方法中使用的技术时,我不得不以不同的方法重建解决方案。

所以,像往常一样,问题“使用 Power Query 还是 DAX”的答案是:这取决于……

本文的目标是向你展示一种替代的解决方案构建方式,这种方式可以在你决定哪个方法最适合你的时候提供更多的灵活性。

我希望我能够实现这一点,并且你学到了新的东西。

图片由Brett Jordan提供,来源于Unsplash

参考文献

我写了一篇关于如何使用 Power Query 将平面表转换为星型架构的文章,并在其中使用了一些这里描述的技术。你可以在这里找到它:

## 在 Power Query 中将平面表转换为良好的数据模型

当将一个宽的 Excel 表格加载到 Power BI 时,我们最终会得到一个次优的数据模型。我们能做些什么来创建一个好的模型呢……

towardsdatascience.com

我使用的是 Contoso 示例数据集,像我之前的文章中一样。你可以从微软这里免费下载 ContosoRetailDW 数据集。

Contoso 数据可以在 MIT 许可证下自由使用,详情请见这里

你可以通过以下方式支持我的工作,这是我在空闲时间进行的工作:

buymeacoffee.com/salvatorecagliari

或扫描此二维码:

任何支持都将不胜感激,并帮助我腾出更多时间为您创作更多内容。

非常感谢。

在 DAX 中计算线性外推(或趋势)

原文:towardsdatascience.com/calculating-a-linear-extrapolation-or-trend-in-dax-72a5705d949c?source=collection_archive---------5-----------------------#2024-12-26

计算趋势帮助我们识别我们的业务是否朝着正确的方向发展。虽然其他编程语言可以提供内置功能来计算这一点,但 DAX 没有,我们必须自己动手。

Salvatore CagliariTowards Data Science Salvatore Cagliari

·发表于Towards Data Science ·阅读时间 9 分钟·2024 年 12 月 26 日

--

图片由Adam Nowakowski提供,来源于Unsplash

介绍

曾经有一位客户要求我计算他数据的线性外推,以便根据过去的数据展示趋势。

这介于描述性和预测性分析之间,因为它不使用机器学习或 AI 技术。

它不会考虑数据中的季节性或其他影响因素。

这里描述的方法使用现有数据,并线性计算所有后续月份、季度或其他时期的外推。

让我们深入了解一下。

数据和场景

使用著名的 Contoso 零售数据(数据来源见下文的参考部分),我想分析购买我公司产品的客户数量。

假设当前日期是 2022 年 4 月,我看到以下是销售第一季度的数据……

计算接触

原文:towardsdatascience.com/calculating-contact-a-data-driven-look-at-alien-civilizations-2435267bd4ac?source=collection_archive---------5-----------------------#2024-09-07

通过数据驱动的方式探讨外星文明(德雷克方程系列第一部分)

James GearheartTowards Data Science James Gearheart

·发表于Towards Data Science ·8 分钟阅读·2024 年 9 月 7 日

--

如果我告诉你,银河系中可能有超过 2000 个外星文明,你会怎么想?听起来像是你最喜欢的科幻剧中的情节转折,对吧?但是如果我说我们可以利用数据科学来接近答案呢?这正是我们在本系列中要做的,使用真实数据来估算可能存在多少外星文明,它们可能离我们有多近,以及我们是否有任何机会与它们联系。

在这个系列中,我们将研究德雷克方程,自 1960 年代以来,科学家们一直使用它来估算宇宙中可能存在的高级外星文明数量。我们还将通过现代数据科学技术,如蒙特卡洛模拟,为这一过程增添趣味,简单来说,就是“我们将反复计算数千次,看看会发生什么。”

所有图片均由作者使用 Midjourney 开发。

大问题:大家都去哪儿了?

1950 年,物理学家恩里科·费米著名地提出了一个问题:“大家都去哪儿了?”宇宙的广袤无垠,我们的银河系中就有数十亿颗恒星,每颗恒星很可能都有行星。那么,为什么我们至今没有遇到外星人呢?这就是费米悖论——外星生命的高概率与缺乏任何外星文明证据或接触之间的矛盾。

为了解决这个难题,弗兰克·德雷克在 1961 年提出了德雷克方程式。它是将问题分解为更小的步骤的一种方式,提出了类似“有多少颗恒星?有多少颗恒星拥有行星?这些行星中有多少颗能支持生命?”这样的问题。每一个问题都在缩小搜索范围,最终我们得到一个数字,告诉我们宇宙中可能存在多少个文明,它们正在向外太空发送信号。

德雷克方程式的分解

方程式如下所示:

方程式的每一部分都代表着一个关键因素,用于计算宇宙中可能存在多少个文明:

  • R:我们银河系中新恒星形成的速率。

  • f_p:拥有行星的恒星比例。

  • n_e:每颗恒星上可以支持生命的行星的平均数量。

  • f_l:那些星球上实际出现生命的比例。

  • f_i:生命星球中进化出智慧生命的比例。

  • f_c:发展出跨越太空通信技术的文明比例。

  • L:这些文明广播信号的持续时间。

德雷克方程式中的步骤 1:银河系中的恒星数量

德雷克方程式中的第一个变量通常是R,即我们银河系中新恒星的形成速率。然而,在这项特定分析中,我们将关注目前银河系中存在的恒星总数。我们的目标是弄清楚现在有多少颗恒星可能支持适宜生命的行星。

我们不是在问有多少新恒星正在诞生——我们估计的是银河系中目前存在的恒星总数,这些恒星可能拥有能够孕育生命的行星。

恒星类型信息:G 型、K 型和 M 型恒星

虽然银河系中可能有1000 亿到 4000 亿颗恒星,但并非所有恒星都适合支持生命。我们将重点关注那些与我们的太阳相似,或者寿命足够长,能够给生命发展提供机会的恒星。具体来说,我们关注三种主要类型的恒星:

  • G 型恒星:这些恒星与我们的太阳相似。银河系中大约有25 亿到 62.5 亿颗 G 型恒星。

  • K 型恒星:比太阳略凉且暗淡,但依然寿命长且稳定。大约有75 亿到 125 亿颗 K 型恒星。

  • M 型恒星:这些是小型红矮星,比 G 型和 K 型恒星更为常见。M 型恒星的数量约为87.5 亿到 200 亿颗。

因此,总的来说,我们估计187.5 亿到 387.5 亿颗恒星可能是支持生命的行星的候选恒星。

步骤 1 的代码:计算银河系中的恒星总数

为了估算能够支持适宜生命的行星的恒星总数,我们使用蒙特卡罗模拟随机生成数字,基于这些恒星类型的可能分布。以下是模拟这些恒星数量的SAS 代码

data total_stars(keep=total_stars);
  do i = 1 to 100000;
    do while (1);
      total_stars = rand("normal", 28750000000, 5000000000);
      /* Check if the value is within the desired range */
      if total_stars >= 18750000000 and total_stars <= 38750000000 then leave;
    end;
    output;
  end;
  drop i;
run;

第 1 步的输出和解释:恒星数量

在运行蒙特卡罗模拟后,使用我们指定的范围和假设,我们得到了关于银河系中可能能够容纳适居行星的恒星数量的一些大数字:

  • 恒星的平均数量287.5 亿

  • 范围187.5 亿到 387.5 亿

这些结果意味着什么?

这些结果为我们提供了一个相当可靠的估算,说明可能有多少颗恒星拥有能够存在生命的行星。恒星的平均数量约为287.5 亿,这意味着我们正在看着相当多的潜在外星生命家园。这个估算并非凭空得出——我们是根据现有的天文学研究定义了这个范围,并假设分布呈钟形,以反映数据中的自然不确定性。

为什么分布的形状很重要?

  • 大多数模拟集中在平均值附近:钟形曲线并非随机选择——我们选择它是为了反映大多数恒星应该集中在287.5 亿这一均值附近。这让我们有信心,认为我们没有处理极端的异常值。

  • 范围和变异性187.5 亿到 387.5 亿恒星的范围是我们基于专家推理设定的。我们知道在处理这些巨大的数字时存在一些不确定性,但分布帮助我们更有信心地看待中间的数值。我们不太可能大幅偏离正确轨道。

这对德雷克方程意味着什么?

这一步为德雷克方程的其余部分提供了坚实的基础。我们现在对银河系中可能能够容纳适居行星的恒星数量有了一个清晰的概念。但仅仅因为一颗恒星可能拥有行星,并不意味着它确实有。下一步是弄清楚这些恒星中有多少颗实际上拥有行星系统——这就是第 2 步的作用。

德雷克方程中的第 2 步:拥有行星的恒星的比例(f_p)

现在我们已经有了恒星数量的估算,德雷克方程的下一步是f_p,即拥有行星系统的恒星的比例。

得益于开普勒TESS等任务,最近的天文学发现表明几乎每颗恒星至少都有一颗行星。在这项分析中,我们将估算约98%到 100%的恒星拥有行星,留有少许不确定的余地。

第 2 步的代码:计算拥有行星的恒星的比例

为了模拟拥有行星的恒星的比例,我们将运行另一个蒙特卡罗模拟。以下是用来模拟拥有行星的恒星比例的SAS 代码

/*Percent of Stars with Planets*/

data perc_stars_with_plan(keep=perc_stars_with_plan);
  do i = 1 to 100000;
    do while (1);
      perc_stars_with_plan = rand("normal", 0.99, 0.001);
      /* Check if the value is within the desired range */
      if perc_stars_with_plan >= 0.98 and perc_stars_with_plan <= 1 then leave;
    end;
    output;
  end;
  drop i;

  format perc_stars_with_plan percent7.4;
run;

第 2 步的输出和解释:拥有行星的恒星的比例(f_p)

一旦我们确定了恒星的数量,接下来的问题是:这些恒星中有多少实际上拥有行星?利用像开普勒等任务的最新数据,我们对这一步骤进行了建模,设定了一个非常狭窄的范围,假设98%到 100%的恒星都有行星。下面是模拟结果:

  • 拥有行星的恒星的平均比例99%

  • 范围98%到 100%

结果解析

结果很清楚:几乎每颗恒星都有行星。我们根据强有力的证据设定了这个范围,模拟结果确认了我们预期的结果——大约99%的银河系恒星可能拥有行星。98%到 100%的几乎完美的范围反映了大多数恒星是行星系统的压倒性可能性。

为什么这很重要?

  • 几乎每颗恒星都有行星:由于我们已经预期几乎所有的恒星都有行星,这个紧密的结果令人放心。这对我们寻找外星生命是个好消息——外面有数十亿个潜在的行星。

  • 不确定性空间很小:因为我们指定的范围非常窄,所以我们对这一步非常有信心。小的变化意味着我们可以继续前进,而不用太担心这个因素。我们已经掌握了这一点。

这对德雷克方程意味着什么?

这一步很好地缩小了范围。由于几乎每颗恒星都有行星,我们可以自信地集中精力解决下一个更具挑战性的问题:这些行星中有多少处于适居带?有数十亿颗恒星,而且几乎所有恒星都有行星,接下来的重点将是这些行星中有多少能够支持生命。这就是我们在方程的下一步中将要探讨的问题。

第一部分总结

我们现在估计,银河系中大约有287.6 亿颗恒星拥有行星。但是,并不是所有的行星都一样——有些行星太热、太冷,或者根本不适合我们所知的生命。

接下来,我们将深入探讨到底有多少行星可能适合居住,重点关注所谓的“黄金锁区”——这是围绕恒星的区域,条件刚好适合液态水的存在。敬请关注第二部分,我们将探讨寻找维持生命的行星的可能性。

系列下篇:从恒星到生命:基于数据的探索之旅(德雷克方程系列第二部分)

除非另有说明,所有图片均为作者提供

与宇宙的沟通

原文:towardsdatascience.com/calculating-contact-can-alien-civilizations-communicate-part-3-of-the-drake-equation-series-d339e1f6558b?source=collection_archive---------4-----------------------#2024-09-08

估算外星文明(德雷克方程系列第三部分)

James GearheartTowards Data Science James Gearheart

·发表于Towards Data Science ·13 分钟阅读·2024 年 9 月 8 日

--

欢迎回来!在第一部分中,我们首先估算了银河系中有多少颗恒星可能拥有行星。在第二部分中,我们进一步缩小了范围,估算了有多少颗行星可能支持生命,以及其中有多少可能进化出智能文明。现在,在第三部分中,我们将进一步推进这些估算,估计有多少智能文明已经发展出通信技术。

但这里有一个大问题:有多少文明现在可能正在与我们沟通?

德雷克方程快速回顾

在我们深入讨论之前,让我们快速回顾一下到目前为止我们已经涵盖的德雷克方程步骤:

德雷克方程:

  1. R = 银河系中有多少颗恒星?

  2. f_p = 有多少颗恒星拥有行星?

  3. n_e = 有多少颗行星位于宜居带?

  4. f_l = 有多少颗行星发展出了生命?

  5. f_i = 有多少颗拥有生命的行星进化出智能文明?

现在,我们关注的是步骤 6:有多少文明发展出了能够跨星际距离进行沟通的技术?

所有图片均由作者使用 Midjourney 制作。

步骤 6:发展通信技术的文明的比例(f_c)

我们已经确定智能生命可能存在,但这里有个关键点——仅仅因为一个文明很智能,并不意味着它们拥有(或有愿望)跨越太空进行通信的技术。事实上,许多先进文明可能正在使用我们完全无法探测到的技术。

就我们的目的而言,我们将通信能力定义为能够发展出允许文明发送我们当前仪器可以探测到的信号的技术——例如无线电波或激光。当然,某些文明可能使用更为特殊的方法(如引力波、量子纠缠等),但目前我们只关注我们能够实际探测到的信号。

为什么我们选择这个范围

我们估计大约10%到 20%的智能文明会发展出通信技术,平均值为15%。原因是什么呢?发展通信技术涉及多个“硬步骤”,并不是每个文明都会走同一条路。让我们来细分一下:

  • 幸存于灭绝级事件:就像地球曾经历过大规模灭绝,其他文明也可能面临自然灾害或战争。如果一个文明没有存活下来,就无法进行通信。

  • 发展技术:即使一个文明变得智能,也不能保证他们会优先发展通信技术。他们可能会保持地方性,甚至可能演化出超出我们探测能力的技术。

  • 以可探测的形式进行通信:即使他们发展出技术,也必须以我们可以探测到的方式进行通信——比如无线电波。如果他们使用更先进的方式,我们将无法听到他们的信号。

让我们看看模拟中的表现。首先,我们将运行一些 SAS 代码,根据我们为这一步选择的分布生成 100,000 个数值。

第 6 步代码:计算拥有通信技术的文明比例

/*Percent of Intelligent Life with Communication Ability*/

data perc_comm_ability(keep=perc_comm_ability);
  do i = 1 to 100000;
    do while (1);
      perc_comm_ability = rand("normal", 0.15, 0.015);
      /* Check if the value is within the desired range */
      if perc_comm_ability >= 0.1 and perc_comm_ability <= 0.2 then leave;
    end;
    output;
  end;
  drop i;

  format perc_comm_ability percent7.4;
run;

第 6 步输出及说明:发展通信技术的文明

在运行模拟后,我们确定了发展通信技术的文明平均比例约为 15%,其值范围为10%到 20%

结果分析

  • 每 6 个文明中有 1 个:大约每 6 个智能文明中就有 1 个能发展出跨越太空进行通信的技术。

  • 分布:结果大多数集中在15%左右,介于10%到 20%之间的波动。这为我们计算总的通信文明数目提供了坚实的基础。

为什么这些结果很重要

德雷克方程中的这一步至关重要。没有通信能力,即使智能文明存在,我们也永远不会知道。结果显示,虽然并非每个智能文明都会发展出通信技术,但相当一部分文明可能会。这增加了我们与其他文明接触的机会。

偏离传统德雷克方程的一小步

在这一点上,我们将从传统的德雷克方程中稍微偏离,继续进行文明总数的计算。我们不会在这一步直接将文明的通信持续时间(L)纳入方程,而是首先计算一个原始估算值,即曾经发展过通信能力的文明总数。

为什么会有这种偏离?

这种微调的原因是我们想要区分两个重要的概念:

  1. 曾经存在过的文明总数:这为我们提供了一个原始估算值,表示在银河系中那些曾达到发展通信能力的文明总数。这个估算尚未考虑到文明可能会随时间的推移而兴衰。

  2. 它们与我们寿命的重叠:这就是通信寿命概念出现的地方。我们在估算了文明的原始数量之后,稍后会调整这个估算,以了解这些文明中有多少可能与我们处于同一时期并保持活跃和沟通。

通过采取这条偏离路径,我们能更清楚地看到所有可能的通信文明的总潜力,然后缩小范围,看看有多少文明可能与我们当前的时代重叠。这个方法使我们在考虑通信寿命之前,能全面了解银河系中曾经存在的文明总数。

现在,既然已经解释清楚,我们继续进行下一步计算:将所有分布相乘,估算银河系历史上有多少文明发展了通信能力。

将分布结果相乘

现在我们已经计算出了发展通信技术的智能文明的比例,接下来是将所有的分布结果相乘。这将为我们提供一个估算,表示在银河系中曾经发展过通信技术的外星文明的总数。

这一步骤涉及将到目前为止德雷克方程中每个部分的结果相乘:

  • 恒星的总数

  • 拥有行星的恒星比例

  • 适宜生存带中行星的比例

  • 生命发展行星的比例

  • 发展出智能生命的行星比例

  • 发展通信技术的智能文明比例

输出及对总通信文明数量的解释

在完成最终计算后,我们估算出银河系历史上有 96,828 个外星文明已经发展了通信技术。

这些结果意味着什么?

  • 庞大的数量:总数为96,828 个文明的数字极其庞大,但它包括了所有曾经存在并开发出通信技术的文明。这个数字涵盖了数十亿年的银河历史。

  • 银河潜力:尽管这个数字的庞大令人印象深刻,但真正的问题是这些文明中有多少正在现在广播信号。

第 7 步:文明在“沟通阶段”停留多久?(L)

我们估算了可能发展出沟通能力的文明数量,但现在我们要问的是:它们在这个阶段停留多久? 文明可能会兴衰起落,而它们发送可探测信号的能力可能是短暂的。例如,人类仅在100 年左右的时间里开始向太空发送信号,且不确定我们还能持续多久。自然灾害、资源枯竭甚至自我毁灭可能会结束任何文明的沟通阶段。

为什么我们与传统的德雷克方程有所不同

在原始的德雷克方程中,文明的寿命是很早就被计算出来的。然而,在我们的方法中,我们首先计算所有已经发展出沟通能力的文明的粗略估计。然后,我们引入它们的寿命概念,看看其中有多少可能与我们自己的文明重叠。这帮助我们专注于那些目前仍然活跃的文明。

银河通信寿命

一个文明保持通讯状态的时间取决于几个因素:

  • 幸存于灭绝级事件:像小行星撞击或核战争等自然灾害或人为灾难,可能很容易削减一个文明的沟通能力。

  • 技术发展:一个文明必须发展出跨越太空传输信号的手段,这可能需要数千年。

  • 维持沟通:即便文明发展了通信技术,它们也可能在进化过程中放弃这些技术或转而使用无法探测的方法。

人类的沟通时代:一个极小的窗口

从视角来看,虽然智人已经存在了20 万年,但我们仅仅在100 多年前才开始能够发送信号。这仅占我们存在时间的极小一部分。更有目的的沟通尝试,比如 1974 年的阿雷西博信息,也仅仅开始于50 年前。这表明任何文明的沟通阶段可能非常短暂。

模拟文明寿命

在我们的分析中,L 因子代表了一个文明保持通讯状态的时间长度,即发送可以被其他文明探测到的信号的时间。我们用一种分布模型来表示,从短命文明(例如几百年或几千年)到长期存在的文明之间的过渡。

为什么我们选择这个范围

我们有意将L设置为较低值——大约100 年——但之后会逐渐上升。这个设定的理由很简单:如果一个文明能够度过其技术发展初期的100 到 500 年,它更有可能存续更长时间,甚至可能达到几千年

运行模拟

基于这些假设,我们运行模拟来估计文明维持通信的时间,考虑到短命文明和长寿文明的情况。

/*Years of Communicative Abilities*/

data lifetime_comm_civ(keep=lifetime_comm_civ);
  skewness = -6; /* Control the left skewness */
  sigma = (log((1 + (skewness ** 2)) ** 0.5)) / skewness; /* Calculate sigma for Lognormal distribution */

  /* Generate random values from a left-skewed Lognormal distribution */
  do i = 1 to 100000;
    u = rand("uniform"); /* Uniform random variable */
    lifetime_comm_civ = 100 + (1000000 - 100) * exp(sigma * rand("lognormal", 0, 1));
    output;
  end;

run;

第 7 步的输出与解释:文明生命周期

在我们对L 因子(文明维持通信的时间长度)进行模拟后,结果显示出多样化的生命周期。让我们来拆解一下关键点:

关键统计数据

  • 平均生命周期:680,330 年。

  • 中位生命周期:739,262 年。

  • 范围:最短为100 年,最长为996,193 年

分布洞察

  • 平均值和中位数:一个可通信文明的平均生命周期为680,330 年,但请注意,中位数稍微高一些,达到了739,262 年。这表明,尽管有些文明的生命周期较短,但分布更倾向于较长的文明——数十万年。

  • 最小值和最大值:最短的生命周期为 100 年,代表那些迅速退出通信阶段的文明。1%分位数(46,758 年)显示,即使是处于底层的文明,其存在时间在宇宙尺度上依然相当长。在上端,一些文明可能会持续接近100 万年

  • 偏态与上升:正如预期的那样,分布是左偏的,这意味着大多数文明会迅速达到 10,000 年的标志,并随后延续到更长的时间框架。这一点在1%分位数(46,758 年)中位数(739,262 年)之间的急剧增加中尤为明显。

这意味着什么?

分布表明,一旦文明克服了早期的难关(例如,10,000 到 50,000 年),它们中的许多能够存活更长时间,从而增加了持续通信的可能性。平均来看,文明可以存续超过 600,000 年,这对我们的搜索来说是个好消息——如果文明能够度过其初生期,那么它们很有可能在较长时间内保持通信,这也使得它们的信号有可能与我们文明的短暂通信窗口重叠。

从通信生命周期过渡到当前外星文明

现在我们已经估算出文明在其通讯阶段的寿命,下一个关键问题是:这些文明中有多少现在存在?这就是我们与传统德雷克方程不同之处。我们不再只是询问曾经存在过多少文明,而是需要考虑它们的通讯阶段与我们自己的重叠部分。毕竟,只有在我们存在的短暂窗口期内发出信号的文明,我们才有机会探测到它们。

鉴于文明的寿命从几百年到几百万年不等,我们必须考虑到许多文明可能早在我们出现之前就已经兴起和消亡,或者可能在我们消失很久之后才会出现。

为什么时间很重要:银河系时间框架 宇宙大约已有 140 亿年历史,而仅仅是银河系就有大约 136 亿年。文明可能在数十亿年前就已经出现并消失。因此,时间重叠如此重要。即使一颗星球上发展出了智慧生命,这个文明也可能在我们存在之前或之后繁荣和消亡,从未与我们交集。

那么,我们如何计算与我们同时存在的文明的概率呢?

我们估计我们文明的年龄大约为 10,000 年。比苏美尔人早 5000 年,这也假设我们文明及其技术进步将长期持续下去,不会遭遇自我毁灭或环境崩溃等中断。

为了找出与我们重叠的文明,我们取总的通讯文明数,并计算出在我们特定时间框架内存在的比例。

计算重叠文明的概率的代码

利用文明在其通讯阶段的寿命估计,我们将时间重叠部分与潜在文明的总数相乘。这个计算帮助我们弄清楚,可能有多少外星文明在与我们相同时期内处于通讯状态。

/* Define the timeframe of our civilization */
%let civilization_timeframe = 10000; /* in years */

/* Calculate the estimated number of alien civilizations that exist at the same time */
data time;
  set drake;
  civilizations_same_time = min(&civilization_timeframe, lifetime_comm_civ) * total_civ / lifetime_comm_civ;
run;
/*Taking the min value in the numerator ensures that we consider only the overlapping period between 
our civilization and the alien civilizations*/

这里发生了什么?

定义我们文明的时间框架:

  • 在这段代码中,我们将我们文明的“通讯阶段”设定为 10,000 年。这代表了人类文明可能具备发送和接收跨越太空信号能力的估计时间长度。我们假设人类比苏美尔人早出现 5000 年,这一数字也包括了我们自己的技术寿命。

计算重叠文明:

  • total_civ 代表了银河系中估计存在的总文明数(这是我们通过乘以德雷克方程所有项计算出来的数字)。

  • lifetime_comm_civ 是一个文明通讯阶段的寿命。

我们使用min()函数来找到两个值中的较小者。

  • 我们文明的通讯阶段 (civilization_timeframe),或者

  • 外星文明的通讯阶段lifetime_comm_civ)。

这确保了我们只考虑那些通讯寿命与我们重叠的文明。

  • 为什么要除以 **lifetime_comm_civ** 除以通讯寿命有助于标准化结果,确保我们只统计那些通讯窗口与我们重叠的文明。

重叠的重要性

文明可以在银河系 140 亿年的历史中兴衰更替。即便存在 96,000 个文明,许多文明可能在数百万年前就活跃过,现在已经不再发送信号。我们关心的只是那些与我们同时存在的文明。代码确保我们准确估算这种重叠,通过关注两个文明保持通讯能力的时间长度。只有重叠的寿命才会计入最终的可通讯文明数量。

我们一直在为这一刻做准备:现在有多少个文明存在?

经过模拟后,考虑到恒星数量、宜居行星、生命承载行星以及具有通讯能力的智慧文明的发展,我们得出了一个关键问题:这些文明中有多少现在存在,就在这一刻?

请敲响鼓声……

根据我们的计算,我们估计,平均而言,目前在我们的银河系中有 2,363 个文明具备通讯能力。没错——2,363 个文明,每个文明都可能在这一刻向宇宙广播它们的存在!

让我们换个角度来看:这不仅仅是一个数字。这代表了数千个可能分布在银河系中的智慧文明,它们可能也在像我们一样,思考着关于我们的事情。想到可能有成千上万的文明,其中一些可能比我们更先进,正在等待被发现,这种想法既令人震惊又令人兴奋。

但在我们过于激动之前,还有一个大问题需要解答……

它们有多近?

拥有一个估算的可通讯文明数量仅仅是战斗的一半。下一个挑战是确定它们在哪里。最近的外星文明可能在我们的技术范围内吗?或者它们可能远得连传输信号我们都可能永远无法接收到?在接下来的部分,我们将深入探讨这些文明之间令人难以置信的距离,并计算出我们需要走多远才能找到我们的宇宙邻居。

敬请关注第四部分,我们将探索将我们与潜在的改变生命的接触隔开的银河距离!

本系列的下一篇:银河系的距离:外星文明有多远?(德雷克方程系列第四部分)。或者,如果你错过了上一篇,可以返回这里

除非另有说明,所有图片均由作者提供

从恒星到生命

原文:towardsdatascience.com/calculating-contact-moving-from-stars-to-life-part-2-of-the-drake-equation-series-c110c018f174?source=collection_archive---------9-----------------------#2024-09-07

一次数据驱动的旅程(《德雷克方程式》系列第二部分)

James GearheartTowards Data Science James Gearheart

·发表于Towards Data Science ·阅读时间 11 分钟·2024 年 9 月 7 日

--

第一部分中,我们探讨了银河系中有多少颗恒星可能拥有行星,并使用数据估算了银河系中拥有行星的恒星总数。现在我们已经处理了恒星的问题,让我们更深入地了解这些行星本身。在第二部分中,我们将深入探讨有多少颗行星实际上能够支持生命,生命是多么频繁地出现,以及生命进化成智能文明——像我们一样——的可能性有多大。

随着我们继续探索德雷克方程式,事情变得有些推测性。但不用担心,我们将使用数据科学、蒙特卡洛模拟以及基于当前研究的合理假设来确保内容的严谨性。

所有图片由作者使用 Midjourney 开发。

快速提醒:德雷克方程式

为了帮助你回忆,德雷克方程式分解了估算活跃且能进行通讯的外星文明数量的步骤。以下是完整的方程式:

第三步:有多少颗行星位于适居带内?(n_e)

并非所有行星都相同——有些太热,有些太冷,只有少数几颗刚好适宜。这些“金发姑娘”行星位于其恒星的宜居带内,在那里条件刚好适合液态水的存在。液态水至关重要,因为它是我们所知的生命的关键成分。离恒星太近的行星可能过于炽热,水分会被蒸发掉,并可能遭受有害辐射的轰击。相反,离恒星太远的行星很可能是寒冷的冰冻世界,无法存在液态水。

那么,这些宜居带行星到底有多少呢?

为什么我们选择这个范围

最近对外行星系统的发现,如TRAPPIST-1,表明有几颗行星可能处于其恒星的宜居带内。根据这一发现和当前的研究,我们估计任何给定星系中1%到 20%的行星属于此类,平均大约是10%。我们为这一步选择了正态分布,假设宜居行星在这一范围的中间部分更为常见,但两端存在不确定性。

在我们深入分析结果之前,让我们通过一些 SAS 代码来设置舞台,计算这一估算值。

/*Percent of Habitable Planets*/

data habitable_planets(keep=habitable_planets);
  do i = 1 to 100000;
    do while (1);
      habitable_planets = rand("normal", 0.10, 0.025);
      /* Check if the value is within the desired range */
      if habitable_planets >= 0.01 and habitable_planets <= 0.2 then leave;
    end;
    output;
  end;
  drop i;

  format habitable_planets percent7.4;
run;

第 3 步的输出与解释:宜居行星

通过模拟计算后,宜居带中行星的平均百分比为10%,其值在1%到 20%之间。

这些结果意味着什么?

  • 平衡估算:大多数结果聚集在10%附近,这意味着,平均来说,每 10 颗行星中就有 1 颗可能位于其恒星的宜居带。

  • 不确定性余地:我们选择的范围承认了某些行星系统可能没有任何宜居行星,而其他系统可能有多个行星位于宜居带,从而为我们的估算提供了灵活性。

第 4 步:生命实际上发展多频繁?(f_l)

并非所有行星都相同——有些太热,有些太冷,只有少数几颗刚好适宜。这些“金发姑娘”行星位于其恒星的宜居带内,在那里条件完美适合液态水的存在。液态水至关重要,因为据我们所知,它是生命的关键成分。然而,我们必须承认,我们对于能承载生命的环境的样本量极其有限——实际上,只有一个样本:地球。

我们对生命所需条件的理解完全基于碳基生命形式,正如我们在地球上所见的那样。离恒星太近的行星可能过于炽热,蒸发掉任何可能的水分,并遭受高辐射水平的影响。另一方面,离恒星太远的行星可能是寒冷的冰冻世界,液态水无法存在。虽然我们知道生命在地球上仅在狭窄的条件范围内繁荣,但在地球之外的猜测仍然是未知的。

对于本次分析,我们只关注我们已知的生命形式——基于碳的生物,需要水,不考虑如基于硅的生命体或跨维度生物等更为奇特的生命形式。

为什么选择这个范围

我们估计生命在1%到 25%的宜居行星上发展,平均约为17%。为了反映生命在条件适宜时可能出现的更高机会,我们采用了左偏分布。这意味着,虽然生命可能并不总是出现,但一旦出现,它就有很大机会繁荣发展。

让我们进入代码,看看模拟告诉我们什么。

/*Percent of Habitable Planets where Life Develops*/

data perc_life_develop;
  mean_pct = 0.15; /* Mean percentage */
  skewness = -6; /* Control the left skewness */
  sigma = (log((1 + (skewness ** 2)) ** 0.5)) / skewness; /* Calculate sigma for Lognormal distribution */

  /* Generate random values from a left-skewed Lognormal distribution */
  do i = 1 to 100000;
    u = rand("uniform"); /* Uniform random variable */
    perc_life_develop = 0.001 + (0.25 - 0.001) * exp(sigma * rand("lognormal", 0, 1));
    output;
  end;

  format perc_life_develop percent7.4;
run;

第四步输出及解释:生命发展行星

经过模拟后,我们发现生命在宜居行星上发展的平均比例为17.08%,值范围在1%到 25%之间。让我们分析这对整体分析意味着什么。

分析结果

  • “生命总能找到出路”:正如斯皮尔伯格的名言所言,我们的模拟反映出,当条件适宜时,生命很可能会出现。平均值为 17.08%,这表明几乎1/6的宜居行星可能会发展生命。考虑到生命的出现需要多个因素完美对接,这是一个乐观的结果。

  • 分布形状和偏度:直方图显示了一个左偏分布。这种偏度表明,虽然模拟中的大多数行星发展生命的概率较低,但许多行星集中在20%到 24%的高端范围。正偏度表明,在条件有利时,生命更有可能出现。换句话说,一旦具备了正确的条件,生命往往会找到出路**。

  • 分位数和范围四分位范围(从 13.97%到 21.47%)表明,在大多数模拟中,生命发展的概率落在这个中高范围内。第 95 百分位数24.24%,这告诉我们,尽管生命出现的可能性有些不确定,但模型预测,在合适的条件下,生命将在相当数量的宜居行星上出现。

为什么这些结果重要

这是德雷克方程中的一个关键步骤,因为它为潜在的“含生命”行星的数量设定了基调。如果大部分宜居行星能够发展生命,那么我们找到外星生命的机会就会相应增加。

在范围上端的概率较高集中度表明,一旦条件适宜,生命很可能会发展。这一见解对后续步骤至关重要,因为在我们谈论智能或通信技术之前,首先需要有生命

然而,区间下端(1–5%)的轻微不确定性提醒我们,即使条件似乎合适,生命也可能并不总是会出现。这为讨论增添了细微的差别——一些星球可能具备所有正确的条件,但仍然保持荒凉。

德雷克方程的影响

这对我们更广泛的分析意味着什么?

  1. 生命的出现比不出现更可能:根据这些结果,合理的假设是生命将在相当一部分宜居星球上发展。这为我们对最终能够孕育智能文明的星球数量的整体估算提供了支持。

  2. 为智能奠定基础:既然我们知道生命很可能在大约17%的宜居星球上出现,我们可以继续探讨下一个重要问题:这些生命有多大可能进化成智能的形式,能够构建技术和文明?

步骤 5:智能生命出现的频率有多高?(f_i)

现在来看看最关键的问题——有多少这些宜居星球会进化出像我们一样的智能文明?这是德雷克方程中最具猜测性的步骤之一。智能的进化需要克服一些重大障碍,或者说从简单生命到高级生命体的“硬步骤”。

我们估计智能生命出现在0.01%到 1%的宜居星球上,平均为0.13%。换句话说,尽管生命可能相对频繁地出现,但智能的出现却是一个遥不可及的目标。

这里列出了一些生命必须克服的主要难关,以便变得智能,以及为什么这些难关使得这一过程如此罕见:

  • 从简单细胞(原核生物)到复杂细胞(真核生物)的飞跃:

    这是生命从“仅仅生存”到进化成更复杂形态的转折点。像人类一样复杂细胞的发展是一个巨大的步骤,需要特定的条件,这就是为什么它如此罕见。

  • 多细胞生命的进化:

    作为单细胞生物是一回事,但形成复杂的多细胞生物则完全是另一回事。多细胞性为专门化细胞(如脑细胞)提供了可能,但要达到这一点需要数百万,甚至数十亿年的时间。

  • 复杂大脑的发展:

    智能需要一个能够进行高阶思维的大脑。这不仅仅是为了生存,更是为了问题解决、交流和工具开发——这些飞跃只有地球上少数物种达成。高级脑结构的进化是一项巨大的挑战。

  • 克服灭绝事件:

    地球上的生命经历了一系列灭绝级事件,比如小行星撞击和火山爆发。智能生命能够幸存并继续进化,简直是奇迹。因为这些事件会消灭大量物种,因此能够幸存下来对于任何进化出智能的机会都是至关重要的。

  • 社会与技术进化:

    即使一个物种发展出智能,它还必须进化出能够保证生存和发展的社会结构和技术。物种需要协作、沟通,并发明能够塑造其环境的技术。这最后一步就是文明开始出现的地方。

每一个“艰难的步骤”都带来了一个新的挑战,这使得生命发展成为智能的、能够建立文明的存在的可能性更小。这就是我们认为智能生命如此稀有的原因。现在,让我们看看模拟结果是怎么说的。

为什么我们选择这个范围

我们估计,智能生命在0.01%到 1%的宜居行星上出现,平均值约为0.13%。我们使用了一个正态分布,其偏向较低端,因为智能生命非常稀有。需要一系列进化飞跃才能从简单的生命形式过渡到能够建立先进文明的智能生物。

这是计算此步骤的代码。

/*Percent of Planets where Intelligent Life Develops*/

data perc_intelligent(keep=perc_intelligent);
  do i = 1 to 100000;
    do while (1);
      perc_intelligent = rand("normal", 0.001, 0.001);
      /* Check if the value is within the desired range */
      if perc_intelligent >= 0.0001 and perc_intelligent <= 0.01 then leave;
    end;
    output;
  end;
  drop i;

  format perc_intelligent percent7.4;
run;

步骤 5 的输出与解释:智能生命出现的行星

在运行模拟后,我们发现智能生命出现的星球的平均百分比约为0.13%,数值在0.01%1%之间。乍一看,这似乎是一个极小的数字——但当我们考虑到其中的复杂性时,它就显得十分合理。

稀有,但触手可及

结果加强了一个广泛持有的信念:智能生命的出现是稀有的。大多数模拟结果集中在较低的范围,75%的模拟预测智能生命仅在不到0.18%的宜居行星上发展。这意味着智能文明的出现并不是一种频繁发生的事件。然而,值得注意的是,分布有轻微的偏向较高端,这意味着在某些行星上,智能生命可能出现得更频繁。它是稀有的,但并非不可能。

智能的复杂进化路径

当我们考虑一个星球从简单生命形式进化到能够开发先进技术的智能生物所需的进化步骤时,很容易理解为什么这一比例如此之低。生命必须克服许多进化障碍——我们称之为“艰难的步骤”——从复杂的多细胞生物的出现,到高级大脑和认知能力的演化,每一步都代表着一次关键的、往往是不太可能的飞跃。

在这种背景下,智能生命的稀有性似乎并不令人惊讶。它不仅需要合适的生命条件,还需要漫长且不可预测的进化路径才能发展出智能。

缩小范围

让我们回顾一下我们在德雷克方程中的位置。在第 4 步中,我们估计大约17%的适居带行星可能会发展出生命。现在,由于只有0.13%的这些拥有生命的行星可能演化为智能文明,我们大幅缩小了可能存在的外星智能生命候选者的范围。虽然这些数字看起来很小,但请记住,我们在面对的是天文数字级别的恒星和行星。即便是极小的比例,在如此庞大的数量面前,仍然为智能文明的可能性留下了空间。

这对德雷克方程的意义

这一步代表了我们搜索范围的关键缩小。虽然我们已经看到,处于适居带内的有生命的行星可能相对常见,但智能生命的出现则远不如此。尽管如此,即便这种几率较低,银河系的庞大规模意味着我们仍然可能在寻找多个智能文明,尽管它们的数量比我们最初设想的要小得多。

接下来是什么?

在这次分析中,我们进一步缩小了关注范围,明确了智能生命在浩瀚宇宙中的稀有性。但搜索仍未结束。在第三部分中,我们将探讨这些智能文明是否能发展出跨银河系通信的技术。因此,尽管我们现在知道智能生命的出现几率很小,但问题依然存在:它们是否在试图与我们沟通?

系列下一部分:与宇宙沟通:估算外星文明(德雷克方程系列第三部分)。或者,如果你错过了之前的部分,可以点击这里回顾。

除非另有说明,所有图片均来自作者

在 Power BI 中计算前一个值

原文:towardsdatascience.com/calculating-the-previous-value-in-power-bi-9ddc062ef2df?source=collection_archive---------8-----------------------#2024-04-19

基于仪表数据计算消耗量看起来很简单。然而,复杂的情况可能会带来挑战。让我们看看如何解决这个问题。

Salvatore CagliariTowards Data Science Salvatore Cagliari

·发表于 Towards Data Science ·10 分钟阅读·2024 年 4 月 19 日

--

当我们拥有仪表数据时,就像我们从家里的能源或水表中获得的数据一样,我们想计算这些值随时间的消耗量。在一个仪表的情况下非常简单,但如果我们有多个仪表,涉及不同区域、值等情况,就可能变得复杂。让我们看看如何在 Power BI 中解决这个问题。

照片由 Doris Morgan 提供,来源于 Unsplash

引言

大多数时候,我们处理的是事务数据。

每一行描述了来自源系统的一个事件(或交易)。

然后我们有库存数据,比如我们商店里某种特定产品的单位数,这些单位数可能随时间变化。

或者我们有仪表数据,随着时间变化,比如家里的电表。

当我们想计算消耗量时,我们根据获取仪表值的日期和时间对数据进行排序,然后将当前值减去前一个值。这样,我们就得到了消耗量。

现在,想象一下我们有多个不同地址的房屋,每个房屋里有多个仪表。

在这种情况下,我们必须为每个仪表计算前一个值,以获得正确的值。

当我们必须在 Power BI 中进行此操作时,这带来了一些挑战。

顺便说一下,在 SQL 中,我们有一些技术可以以最小的努力解决这个挑战。所以,当你把数据放在关系型数据库中时,就在那里处理它。那样会更容易。

那么,让我们看看如何在 Power BI 中解决这个问题。

我先在 Power Query 中做一次,然后再用 DAX 做一次。

数据

我通过我的 Date 表生成数据,并使用以下 SQL 查询将结果加载到一个表中:

DECLARE @dimId int = 2;
DECLARE @value decimal(18,5) = RAND() * 12;

INSERT INTO [dbo].[MeterData]
  ([DateKey]
  ,[Value]
  ,[House]
  ,[Meter])
SELECT [DateKey]
    ,DATEDIFF(dd, '20000101', [Date]) + (DATEDIFF(dd, '20000101', [Date]) * @value%@dimId)
    ,'House ID ' + CAST(([DateKey]%3) + 1 AS varchar(15)) AS [House]
    ,'Meter ID ' + CAST(@dimId - 1 AS varchar(15)) AS [Meter]
  FROM [dbo].[Date]
    WHERE [DateKey] BETWEEN 20210101 AND 20240415;

我多次执行此查询,以在将变量 @dimId 设置为 2 到 6 之间的值时获取所需的数据。

结果是每个 Meter ID 随时间变化的值列表:

图 1 — 我的场景数据(图源自作者)

我将此表导入 Power BI 两次,并命名为:

  • MeterData_PQ → 对于 Power Query 方法

  • MeterData_DAX → 对于 DAX 方法

我需要这两张表,在完成后将它们并排比较,分析哪种方法可能更好。

在 Power Query 中进行操作

我在互联网上稍作搜索后找到了这种方法。

我在下面的参考部分中添加了指向原始文章的链接。

为确保数据在下一步中按正确顺序排列,我通过 House、Meter 和 DateKey 添加排序表达式,以确保所有行在一起:

= Table.Sort(#"Changed Type",{{"House", Order.Ascending}, {"Meter", Order.Ascending}, {"DateKey", Order.Ascending}})

这是排序后的数据结果:

图 2 — 排序操作后的表格(图源自作者)

该模式会自动为每个嵌套表重复。

现在,我在 Power Query 中使用 Group By 转换,将每个 House 和 Meter 的组合的所有行分组在一起:

图 3 — 将所有的 Value 行组合在一起(图源自作者)

现在数据看起来是这样的:

图 4 — 分组后的数据(图源自作者)

当我点击 ValueTable 列的某个单元格时,我看到与该行关联的所有行作为嵌套表呈现:

图 5 — 每个 House 和 Meter 组合的嵌套表(图源自作者)

后续的转换必须应用于嵌套表。Power Query 界面不支持这种操作。

因此,我必须将连续的转换作为手动步骤添加:

图 6 — 向 Power Query 添加新步骤(图源自作者)

我输入以下表达式来为数据添加一个索引列:

= Table.TransformColumns(
    #"Grouped Rows",
    {{"ValueTable", each Table.AddIndexColumn(_,"Index")}}
    )

这是嵌套表中的结果:

图 7 — 向嵌套表添加索引(图源自作者)

索引列是根据数据的顺序计算的。这就是为什么在添加索引之前,我们必须按顺序排列数据。

为了使其更易读,我将此步骤重命名为“AddIndexColumn”。

现在,我添加另一个步骤来获取前一个值:

= Table.AddColumn(AddIndexColumn, "PrevValue", each let
    AllDataTable = [ValueTable],
    PrevRowValue = Table.AddColumn(AllDataTable, "PrevValue",
                        each try AllDataTable [Value] { [Index] -1 }
                        otherwise null
                        )

                    in
                    PrevRowValue)

嵌套表中新增列的结果如下:

图 8 — 带有前一个值的嵌套表(图源自作者)

接下来,我使用 Drill Down 转换,将嵌套表展开为原始表格:

图 9 — 深入嵌套表格(图由作者提供)

现在,我有一个包含所有嵌套表格的列表。我添加了一个新步骤,使用以下表达式来实现:

= Table.Combine( #"Drill down PrevValue"
              ,{"DateKey", "House", "Meter", "Value", "PrevValue"}
              )

结果是包含所有原始行的表格,但增加了“PrevValue”列。

为了完成任务,我可以添加一个新的计算列,将“PrevValue”从“Value”中减去,以得到所需的消耗量:

图 10 — 计算消耗量(图由作者提供)

最后,我必须将新数字列的数据类型设置为“十进制数”。

在将结果加载到 Power BI 后,我可以为每个仪表和房屋创建一个消耗图表:

图 11 — 每个房屋和仪表的时间消耗(图由作者提供)

这是预期的结果,我可以开始创建一个漂亮的报告和有用的可视化图表。

但首先,我想给你展示一个使用 DAX 的方法。

在 DAX 中实现

在了解如何在 Power Query 中实现之后,让我们在 DAX 中做一下。

这里有两种可能的情况:

  • 仪表读取之间的固定间隔。

  • 读取之间的间隔变化。

获取第一种情况的消耗量是简单的:

  1. 我必须识别出前一天的值所在的行。

  2. 获取该行的值。

  3. 计算消耗量。

让我们开始吧:

我通过创建两个关键列来实现:

  1. 一个是当前读数

  2. 一个是前一天的读取值(这可以是前一天、前一周、前一月,或任何你设置的间隔)。

由于数据生成方式的不同,每个仪表 ID 都有一个读数。

因此,为了创建第一个关键列,我创建了一个计算列,使用以下表达式(暂时忽略房屋列):

CurrentKey =
VAR RowKey = FORMAT('MeterData_DAX'[Date], "YYYYMMDD") & "_" & 'MeterData_DAX'[Meter]

RETURN
  RowKey

注意:我使用“YYYYMMDD”格式,以便更清晰地展示结果,因为这是一个通用格式。

我需要从日期表中获取前一天的日期,以便应用日期计算,例如 DATEADD()

然后我可以回到前一天:

PreviousKey =
VAR PreviousDate = FORMAT(DATEADD('MeterData_DAX'[Date], -1, DAY), "YYYYMMDD")
VAR RowKey =
      PreviousDate & "_" & 'MeterData_DAX'[Meter]

RETURN
  RowKey 

最后,我可以使用 LOOKUPVALUE() 来获取前一个值:

Previous Value = LOOKUPVALUE('MeterData_DAX'[Value]
                            ,'MeterData_DAX'[CurrentKey]
                                ,'MeterData_DAX'[PreviousKey]
                                )

或者,我可以使用 CALCULATE() 来实现相同的结果:

PrevValue =
VAR PreviousKey = 'MeterData_DAX'[PreviousKey]

RETURN
  CALCULATE(
      MAX('MeterData_DAX'[Value])
      ,REMOVEFILTERS('MeterData_DAX')
      ,'MeterData_DAX'[CurrentKey] = PreviousKey
      )

这是这三个表达式的结果:

图 12 — 按天读取的前值结果(图由作者提供)

但是这种方法不适用于不规则的读数。

当我查看我的数据(包括房屋数据)时,我看到这样的情况:

图 13 — 按房屋和仪表筛选的读数视图(图由作者提供)

如你所见,读取之间存在间隔。

为了获得正确的结果,我使用了一个包含两步的方案:

  1. 获取前一个读取的日期。

  2. 获取该日期的值。

我为第一步创建了一个度量值:

Previous reading date =
VAR CurrentDate = SELECTEDVALUE('MeterData_DAX'[DateKey])
VAR CurrentHouse = SELECTEDVALUE('MeterData_DAX'[House])
VAR CurrentMeter = SELECTEDVALUE('MeterData_DAX'[Meter])

RETURN
  CALCULATE(MAX('MeterData_DAX'[DateKey])
          ,REMOVEFILTERS('MeterData_DAX')
          ,'MeterData_DAX'[House] = CurrentHouse
          ,'MeterData_DAX'[Meter] = CurrentMeter
          ,'MeterData_DAX'[DateKey] < CurrentDate
          )

首先,我将当前的日期、房屋和仪表存储到变量中。

然后,我计算 DateKey 的最大值,同时移除表中的所有筛选器,添加当前房屋和表计的筛选器,并仅包含低于当前 DateKey 的 DateKey。

对于包含更多列的表格,我可能会使用稍微不同的方法,不是移除所有表格中的筛选器,而只是移除必须的列筛选器,例如,仅对 DateKey、House 和 Meter 列进行筛选。

但结果符合需求:

图 14 — 用于获取上次读取日期的度量结果(图由作者提供)

通过使用上下文转换,我可以使用这个度量创建一个新的“前键”列版本(我也在当前键列的表达式中包括了房屋):

PreviousKey =
VAR PreviousDate = [Previous reading date]
VAR RowKey = PreviousDate & "_" & 'MeterData_DAX'[House] & "_" & 'MeterData_DAX'[Meter]

RETURN
  RowKey

现在,我可以使用与之前相同的表达式,根据两个键列获取所需结果:

图 15 — 使用变量间隔计算前值的结果(图由作者提供)

在加入与之前相同的视觉效果后,结果与我通过 Power Query 操作数据得到的结果相同:

图 16 — 使用 DAX 计算的 2024 年图表(图由作者提供)

最后,我可以通过直接在一个压缩且自包含的“前值”列版本中计算它们,从而摆脱中间的键列:

PreviousValue Compact =
VAR PreviousDate = [Previous reading date]
VAR PreviousRowKey = PreviousDate & "_" & 'MeterData_DAX'[House] & "_" & 'MeterData_DAX'[Meter]

RETURN
  CALCULATE(MIN('MeterData_DAX'[Value])
          ,REMOVEFILTERS('MeterData_DAX')
          ,FORMAT('MeterData_DAX'[Date], "YYYYMMDD") & "_" & 'MeterData_DAX'[House] & "_" & 'MeterData_DAX'[Meter] = PreviousRowKey
)

这里是结果并排显示,它们是完全相同的:

图 17 — 使用中间键列和压缩(自包含)版本计算前值的结果(图由作者提供)

现在我们有了多种解决方案,哪个是更好的呢?

哪个更好?

我们应该如何决定哪种方法更好?

在我看来,这归结于可用的技能。

我的意思是,在必须维护该解决方案的团队中可用的技能。这可以是你,或者是客户团队。

哪个是我偏好的方法?

  • 我是否想尽早准备好所有数据?

  • 或者,我想要最简单的解决方案吗?

  • 或者,我是否更倾向于某种语言?

在这种情况下,这就是使用 Power Query 或 DAX。

我更倾向于尽早准备我的数据。

因此,我更倾向于使用 Power Query 准备数据,并使其准备好使用,而不需要在 Power BI 中添加计算列。

然而,考虑到简洁性,我必须承认,使用 DAX 的自包含计算列的方法是最好的解决方案。

但要完全理解发生了什么以及为什么发生并不容易。

现在,我们可以通过硬性数据分析这两种方法的效率:模型统计。

我使用 DAX Studio 获取度量(高级菜单和查看度量)。

我得到以下信息:

图 18 — 数据模型的度量,包含两张表(图由作者提供)

我可以看到,使用 DAX 中计算列的方法比 Power Query 方法占用更多内存。

但当我们减去两个关键列的大小(上方红色部分)时,得到的结果是:

930’634–332’745–332’813 = 265’076 字节

然后,我必须从两个列中减去前一个值(上方蓝色部分):265’076–48’248 = 207’828 字节。

与使用 Power Query 准备的表格相比,空间差异在这种情况下是微不足道的。

但我只有 6’005 行数据。当我们处理数十万甚至数百万行时,这个差异可能非常大。

我曾经遇到过客户希望以特定方式解决问题的情况,因为他不熟悉另一种方法,尽管后一种方法会更高效地提供解决方案。

决定最佳方法是具有挑战性的,因为你可能需要考虑不同的因素。

现在你已经有了两种解决方案的信息,轮到你选择最合适的一个了。

图片由Brendan Church提供,来源于Unsplash

参考资料

如上所述,数据是自生成的,与现实世界无关。

使用 Power Query 的方法源自这篇博客文章和视频:

gorilla.bi/power-query/get-previous-row-value/#:~:text=The%20earlier%20row%20has%20an,a%20table%20to%20merge%20with.

这里是我关于上下文转换的文章:

## DAX 中的上下文转换有什么特别之处

行上下文和筛选上下文是 DAX 中的常见概念。但我们可以通过上下文转换在这两者之间切换。

towardsdatascience.com

其他解决方案和方法包括在 Power Query 中使用单一的 M 表达式。我决定使用这个方法,因为它实现起来简单,而且很容易理解发生了什么。

请考虑关注我并订阅,以便我添加新内容时可以第一时间收到邮件:

[## 每当 Salvatore Cagliari 发布新内容时,你都会收到电子邮件。

每当 Salvatore Cagliari 发布新内容时,你都会收到电子邮件。通过注册,如果你还没有 Medium 账户,你将创建一个账户...

medium.com](https://medium.com/@salvatorecagliari/subscribe?source=post_page-----9ddc062ef2df--------------------------------)

我让我的文章对所有人开放,尽管 Medium 有付费墙。这让我能从每个读者那里赚取一些收入,但我关闭了付费墙,所以你可以免费阅读我的文章。

你可以通过以下方式支持我在空闲时间进行的工作:

buymeacoffee.com/salvatorecagliari

或扫描此二维码:

任何支持都非常感激,这将帮助我找到更多时间为你创作更多内容。

非常感谢。

posted @ 2025-01-09 18:54  绝不原创的飞龙  阅读(132)  评论(0)    收藏  举报