TowardsDataScience-2023-博客中文翻译-十四-

TowardsDataScience 2023 博客中文翻译(十四)

原文:TowardsDataScience

协议:CC BY-NC-SA 4.0

数据库和数据建模 — 一个快速入门课程

原文:towardsdatascience.com/databases-and-data-modelling-a-quick-crash-course-546891a49b67

数据仓库 101:初学者实用指南

Col JungTowards Data Science Col Jung

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

--

作者提供的图片

在我 五年的企业分析工作中,我观察到很多数据科学家在进入工作时对数据仓库和数据建模的了解有限。

这不应该让人感到惊讶。

数据科学家来自数学、统计学、心理学和编程等不同背景。许多人可能在大学时不会深入研究数据库系统的复杂性。

这包括我,一个从数学家转行的数据科学家。

我通过在线课程自学数据科学——这是获得 数据科学 职位的必要前提,我想——但只是在工作中才掌握了数据库基础。

况且,数据湖现在这么流行,谁还需要仓库呢,对吧?(这只是个玩笑!)

我写这篇文章是为了给那些刚刚进入分析工作且对数据仓库和数据建模了解不多的人提供一个快速的入门课程。

我们将讨论三个主题:

  • 企业数据仓库工作流 的样子是这样的

  • 数据库 规范化 实现的目标;

  • NoSQL 数据库的试验。

更新:我现在在 YouTube 上发布分析教程。

1. 数据仓库工作流

具体细节可能因解决方案而异,但企业级分析的常见架构如下:

  1. 数据存放在 数据湖 中。

  2. 数据被加载到一个 数据仓库 中。

  3. 创建一个 数据模型

  4. 分析师消费数据。

让我们更详细地看看这个过程。

分析数据处理。作者提供的图片

分析数据处理

来自操作或事务数据存储(通常来自 OLTP 数据库)、文件、实时流或其他来源的数据被加载到一个集中式的数据湖中。

数据湖在大规模数据分析处理场景中很常见,其中需要收集和分析大量基于文件的数据。

操作数据平面分析数据平面的负载操作通常涉及一个提取、转换和加载(ETL)或提取、加载和转换(ELT)过程,在该过程中,数据被清洗、过滤和重组以供分析。最终的数据结构针对分析查询进行了优化。

企业数据格局的 30,000 英尺俯瞰图。来源:Z. Dehghani 于MartinFowler.com,作者进行了修订

由于数据湖通常属于大数据领域,这涉及通过像Apache Hadoop这样的框架进行分布式计算和存储,ETL 处理由数据工程师负责,他们设置HiveSpark作业,以使用多节点集群并行处理大量数据。这些数据管道包括静态数据的批处理和流数据的实时处理。

ETL 管道连接操作数据存储和分析数据存储。图片由作者提供

数据湖中的数据已经准备好进一步的分析使用。这包括由数据科学家进行的探索、整理和建模,或由数据分析师处理以创建报告和可视化。

查看我的解释文章,了解从数据仓库到数据湖再到数据网格的整个企业数据格局。

对数据分析不熟悉?请查看这里,这里这里

对数据科学不熟悉?请查看这里,这里,这里 和这里。

数据仓库、表格、模式与规范化

好了,继续。是时候丰富我们在湖中的数据了。

首先,数据被复制到一个针对读操作进行优化的数据仓库中。

数据仓库是关系型数据库,其中数据存储在一个为数据分析而优化的模式中,而不是为事务工作负载优化的。仓库的设计优化了读取操作——主要是查询,以支持商业智能(BI),包括创建报告、仪表板和可视化,这些在优秀数据讲述者的帮助下可以传达价值并影响决策。

关系型数据库。图片由作者提供

关系型数据库通常用于存储和查询结构化数据。数据存储在表示实体表格中,如顾客产品销售订单。每个实体的实例被分配一个主键,以唯一标识它,这些键用于在其他表中引用该实体实例。

这使得通过连接表来丰富数据成为可能。

例如,顾客的主键可以在销售订单记录中被引用,以指示哪个顾客下了该订单:

-- Joining two tables using SQL
SELECT * FROM Customer C
JOIN Orders O
ON C.ID = O.Customer

使用键来引用数据实体使关系型数据库能够规范化——这部分意味着消除重复的数据值,例如,一个顾客的详细信息只存储一次,而不是每次顾客下订单时都存储。

关于规范化的内容稍后会讲解。

表格的管理和查询使用结构化查询语言(SQL),它基于 ANSI 标准,因此在多个数据库系统中类似。我们上面看到的简单查询连接了两个表。

让我们进一步讨论数据库模式。

数据湖采用Schema-on-Read方法,不需要预先定义模式,而数据仓库采用更计算密集但更有组织的Schema-on-Write范式,其中表模式必须提前定义。

这些模式由数据建模师和解决方案架构师在与下游分析和业务用户咨询后设计。

在常见的做法中,建模师将数据从事务存储转化为一种模式,其中数值被存储在中央事实表中。

这些数据然后与一个或多个维度表相关,这些表代表你希望用来聚合这些数值度量的实体——例如产品顾客

每个实体由具有唯一键值的行表示。

剩下的列代表实体的属性——例如,产品有名称和类别,顾客有地址和城市。在大多数分析模型中,包含时间维度是常见的,以便能够聚合与事件相关的数值度量。

所以这所有内容的样子是这样的:

常见的星型模式。图片由作者提供

我们可以看到,模型中通过各种维度(CustomerProductTime)汇总的数值度量(例如收入)位于一个中心事实表中,Sales

更具体地说:

事实表中的每一行代表一个记录事件,附有数值度量。在这里,我们的星型模式中的Sales表代表单个项目的销售交易,包括销售数量和收入的数值。

销售可以按客户产品商店时间维度进行汇总,使你可以轻松找到每个商店按产品划分的月度总销售收入。

这是洞察力得以揭示的地方。

总结:

通过键连接表格可以丰富数据,而聚合则提供洞察。

-- Find total sales for each customer and product in 2022
SELECT c.name, p.name, s.sum(s.Revenue) FROM Sales s
JOIN Customer s
ON c.Key = s.CustomerKey
JOIN Product p
ON p.Key = s.ProductKey
JOIN Time t
ON t.Key = s.TimeKey
GROUP BY 1, 2
WHERE t.Year = '2023'

星型模式是最常见的模式类型,可以通过添加与现有维度表相关的附加表扩展为雪花模式——通常用来表示维度层级。例如,产品有自己的子类别。

雪花模式。来源:维基百科

总体而言,当你有可以组织成结构化表格模式的事务性数据,并且希望在高性能环境中使用 SQL 查询时,数据仓库是一个很好的选择。

查看我的 PowerBI 教程了解数据建模的实际示例。

分析数据模型

尽管数据分析师和数据科学家可以直接在数据仓库中处理数据,但通常会创建一个或多个分析数据模型,这些模型预聚合数据以便更容易生成报告、仪表板和交互式可视化。

这些被称为在线分析处理(OLAP)模型或立方体

数据汇总跨越不同层级或层级的维度,使你能够向上/向下钻取,以在多个层级上查看汇总——例如,找到按地区、城市或单个地址的总销售额。

层级允许向上和向下钻取。图片由作者提供

由于 OLAP 数据是预先汇总的,因此可以快速运行查询以返回其中包含的摘要信息。

从事实表中汇总的数值(度量)是针对维度表中维度的交集计算的。从概念上讲,这意味着模型形成了一个多维结构,其中维度交汇的任何点代表这些维度的汇总度量。

例如,如前所述,销售收入可能按日期、客户和产品汇总,产生的查询输出看起来像立方体中的笛卡尔坐标。

非常酷对吧?!

“cube”。度量(例如销售)按时间、客户和产品维度进行汇总。作者提供的图片

重要! 尽管我们通常将分析模型称为cube,但其维度可以多于(或少于)三个——我们只是难以可视化超过三个的维度!

准备好供使用!

数据分析师从这些分析模型(步骤 3)中——或直接从数据仓库(步骤 2)——甚至从数据湖中的‘原始’数据集(步骤 1)中获取数据,以探索数据并创建仪表板、报告和可视化,以生成洞见。

一个 PowerBI 仪表板。请参阅我的教程获取操作指南!

组织中的非技术专业人员可以对数据进行自助数据分析和报告,特别是当数据已经在 BI 工具中可视化时,如 PowerBI 或 Tableau。

这些建立在良好分析数据模型基础上的可视化展示比较、趋势和关键绩效指标(KPIs),可以呈现为图表、图形、报告,通常以文档和PowerPoint演示文稿、基于网页的仪表板和交互式环境(例如 PowerBI 和 Tableau)的形式传播,在这些环境中,即使是高管也能轻松地以可视化方式探索数据,并做出基于数据的决策。

2. 数据库规范化

现在让我们深入探讨一些数据库规范化的细节。

这是组织我们关系型数据库中的数据,以减少冗余和提高数据完整性的过程。它包括将每个表分解为较小的表,并定义它们之间的关系,以消除重复数据和不一致性。

规范化的目标是创建一个更高效、灵活且不易发生数据异常的数据库模式。我们从一组表开始,通常会得到更多“更清洁”的表。

规范化有多个级别,每个级别都有一套规则。最常见的有:

第一范式(1NF)

表格的每一列必须包含原子(不可分割)值。也就是说,任何一列都不应包含列表或值集合。

例如,假设你有一个客户订单表。在每一行中,有一个客户姓名列和一个他们所订购商品的列表列。在 1NF 中,你会将这些商品列表拆分成单独的行,这样每行只包含关于一个商品的信息。这有助于防止信息重复。

第二范式(2NF)

如果一个表在 1NF 中,并且所有非关键列完全依赖于主键,则该表处于 2NF。换句话说,应该不存在部分依赖,其中非关键列仅依赖于主键的一部分。

继续以客户订单为例,假设你有一个新列用于记录订单日期。如果该日期仅依赖于客户的名字,则表中可能会出现重复。在 2NF 中,你会将表分为两个——一个表用于客户信息,一个表用于订单信息。这确保每条信息仅存储一次,并防止部分依赖。

第三范式 (3NF)

如果一个表在 2NF 中,并且所有非关键列彼此独立,则该表处于 3NF。换句话说,应该不存在非关键列依赖于另一个非关键列的传递依赖。

现在假设你有一个新列用于每个订单项的价格。如果价格依赖于项本身,而不是表中的其他列,那么你已经在 3NF 中了。但如果价格依赖于其他列,比如项的制造商,那么你需要将表分为多个表,以消除这些传递依赖。目标是确保每列仅依赖于表的主键,而不依赖于其他非关键列。

维基百科上有关于规范化的光辉详细信息

除了 3NF,还有额外的规范化级别,但在实践中不常用。

享受这个故事吗?当我发布类似文章时,请获取电子邮件

3. 非关系型数据库

顺便说一下,让我们快速介绍一下关系型数据库。

这些是数据管理系统,它们不对数据应用关系模式。非关系型数据库通常被称为NoSQL数据库,即使有些支持 SQL 语言的变体。

常用的非关系型数据库有四种常见类型。

  • 键值数据库,其中每条记录由唯一键和一个关联的值组成,值可以是任何格式。

  • 文档数据库是一种特定形式的键值数据库,其中值是 JSON 文档,系统被优化以解析和查询这些文档。

  • 列族数据库,存储由行和列组成的表格数据,但你可以将列划分为称为列族的组。每个列族包含一组逻辑上相关的列。

  • 图形数据库,将实体存储为节点,通过链接定义它们之间的关系。

NoSQL 数据库。图像由作者提供

4. 总结

分析模型使你能够结构化数据以支持分析。

分析数据从操作系统迁移到数据湖,然后到数据仓库。在这里,得到的关系数据库建模,这包括为了效率进行规范化并制定适合你业务用例的模式

这些模型基于拥有相关的数据表,并定义了你希望分析或报告的数值(度量)以及你希望聚合它们的实体(维度)。

规范化有三种常见级别,它将每个表拆分为更小、更具体的表,这些表以更逻辑和高效的方式相互连接。

为了进一步提高效率,数据可能会被预先聚合到OLAP 模型立方体中。从直观上看,这些模型形成了多个维度结构,当你正好在 3 个维度上进行聚合时,它们类似于一个立方体,例如销售收入客户产品时间上的表现。

然后,通过丰富所选择的数据(通过连接表格)并进行感兴趣的聚合,数据分析师和下游用户可以获得见解。

通过说服性数据讲述,分析师和商业专业人士将他们的发现展示给决策者,决策者可以基于这些数据做出行动。

如果你觉得这篇文章有用,请告诉我!

Twitter和 YouTube 上找到我,点击这里点击这里点击这里

我的热门 AI、机器学习与数据科学文章

  • AI 与机器学习:快速入门 — 点击这里

  • 机器学习与机械建模 — 点击这里

  • 数据科学:现代数据科学家的新时代技能 — 点击这里

  • 生成 AI:大公司如何争相采用 — 点击这里

  • ChatGPT 与 GPT-4:OpenAI 如何赢得 NLU 战争 — 点击这里

  • GenAI 艺术:DALL-E、Midjourney 和 Stable Diffusion 解释 — 点击这里

  • 超越 ChatGPT:寻找真正智能的机器 — 点击这里

  • 现代企业数据战略解读 — 这里

  • 从数据仓库和数据湖到数据网格 — 这里

  • 从数据湖到数据网格:最新架构指南 — 这里

  • Azure Synapse Analytics 实战:7 个用例解析 — 这里

  • 云计算 101:为你的业务利用云计算 — 这里

  • 数据仓库与数据建模 — 快速入门课程 — 这里

  • 数据产品:为分析建立坚实基础 — 这里

  • 数据民主化:5 个“大数据皆可用”策略 — 这里

  • 数据治理:分析师的 5 个常见痛点 — 这里

  • 数据讲故事的力量 — 销售故事,而非数据 — 这里

  • 数据分析入门:谷歌方法 — 这里

  • Power BI — 从数据建模到惊艳报告 — 这里

  • 回归分析:使用 Python 预测房价 — 这里

  • 分类:使用 Python 预测员工流失 — 这里

  • Python Jupyter 笔记本与 Dataiku DSS — 这里

  • 常见机器学习性能指标解读 — 这里

  • 在 AWS 上构建生成 AI — 我的第一次体验 — 这里

  • 数学建模与 COVID-19 机器学习 — 这里

  • 未来的工作:在 AI 时代你的职业安全吗 — 这里

DataHub 实操 第二部分

原文:towardsdatascience.com/datahub-hands-on-part-ii-169c36ee082d?source=collection_archive---------8-----------------------#2023-01-03

数据摄取、数据发现和实体链接

教授西蒙·J·普雷斯,博士Towards Data Science 教授西蒙·J·普雷斯,博士

·

关注 发表在 Towards Data Science · 10 分钟阅读 · 2023 年 1 月 3 日

--

图片由 Duy Pham 提供,来源于 Unsplash

介绍

欢迎来到我的 DataHub 实操故事的第二部分!在 上一篇 中,我们讨论了如何从头开始设置数据目录,如何使用业务术语进行语义建模以及数据治理(DG)的动机。在本部分中,我们将更详细地了解 DataHub 的一些技术部分,以便摄取和链接元数据。

数据摄取

DataHub 的数据摄取(DI)部分可以通过右上角的菜单栏访问:

Source: Author

一旦进入 DI 区域,我们会看到已配置的数据源列表,包括显示执行统计的概览。从这里,我们可以运行已配置的 DI 过程,可以编辑配置,可以删除或复制配置,或创建一个新源。

Source: Author

另有一个“Secrets”按钮,可用于配置访问数据源的密码。此功能有助于将密码安全地存储在 DataHub 数据库中。在源配置过程中,可以将秘密与数据源关联。

重要术语

为了理解 DI 配置的工作原理,让我们快速定义一些重要术语。如上所述,sources 指的是元数据的来源。Sources 指我们希望在数据目录中管理的数据库——通常来自生产环境。除了 sources,DataHub 还提供了 sinks 的概念。Sinks 是元数据的目的地。通常,DI 引擎将收集到的元数据发送到“datahub-rest” sink,使其在 DataHub UI 中可用。但是,如果需要,也可以选择 Kafka 和文件导出。接下来是 recipes,这是配置文件,指示摄取脚本从哪里提取数据以及将数据放在哪里。配方可以通过 UI 配置(如果有适配器),也可以通过 YAML 脚本[1]进行配置。

数据源和适配器

现在,让我们看看 DataHub 提供的预配置数据库适配器(即连接器):

Source: Author

我们看到一些已经建立的 SQL 和 NoSQL 技术图标。为了理解这不仅仅是为了营销目的,让我们比较两个选定的适配器。在下图的左侧,我们看到一个 BigQuery 的摄取配方,在右侧,我们看到一个 SQLServer 的配方。显然,摄取 UI 根据技术期望不同的信息来建立数据库连接。

Source: Author

大多数数据源使用基于 Python 的元数据摄取系统进行拉取式集成。在第一步中,元数据从源系统中拉取,在第二步中,通过 HTTP 或 Kafka²将信息推送到 DataHub 服务层。每个源适配器具有不同的功能,因此建议阅读在线文档以避免混淆。例如,MySQL和 SAP HANA 适配器支持数据分析和检测已删除的实体,而 MongoDB 或 Oracle 适配器不支持这些功能。相反,BigQuery适配器支持这些功能以及表级血缘关系等其他功能。要了解适配器功能之间的差异,我们只需访问 github 查看每个适配器的代码。例如,我们可以看到,sqlalchemy 用于连接MySQL源,而BigQuery适配器则使用 Google 提供的标准连接包。因此,可以得出结论,每个适配器都是独立实现的,并可能使用不同的连接包。这一点以及数据库技术在其能提供的元数据方面的不同(例如,MongoDB 中没有外键概念)对适配器的摄取能力有影响。

完成配方

还有一个过滤器部分,有助于缩小要摄取的模式或表。如果没有设置过滤器,DI 引擎会对配置的数据库用户有权限的所有方案、表和视图执行元数据扫描——这应考虑到以避免对扫描的数据库和摄取过程造成性能影响。从安全角度来看,建议直接在数据库中限制用户权限(例如,通过创建一个特定的只读 DataHub 用户)。你可以在附加部分切换高级设置,例如是否应考虑视图进行扫描,或仅考虑表,或两者都考虑。同样重要的是:你可以选择是否对表或列进行分析(例如,表中有多少记录,列中有多少缺失值)。这对于了解公司数据环境的透明度非常有用。

一旦配置了配方,你可以指定执行计划。在这里,你定义了 DI 执行的频率(例如每日、每月、每小时)。在创建向导的最后一步,你必须为新源分配一个唯一名称,然后点击“保存并运行”,这将直接触发第一次元数据扫描过程。

来源:作者

一旦过程执行完毕,你可以查看详细信息,以获得对导入资产的初步印象,或查看失败的详细信息。在我的测试用例中,我们有一个 MySQL 数据库服务器,包含来自 4 个数据库的 78 个视图和 38 个表。

来源:作者

数据发现

搜索和筛选

一旦我们的元数据被推送到 DataHub 服务层,我们可以通过 DataHub UI 开始数据发现之旅。作为 90 年代的粉丝,我在测试用例中使用了 Microsoft 的“northwind”数据库。如果我想搜索模式,我可以浏览表和视图,或者选择一些筛选条件(例如,排除视图)。

来源:作者

表格分析结果

假设我想更详细地查看产品数据,那么我只需点击列表中的产品数据集。我首先看到的信息是表格模式概览,显示字段、数据类型,甚至一些数据模型细节,如主键和外键。在顶部(数据集名称下方),我们还可以一目了然地看到我们的表中当前有 79 条记录。

来源:作者

第二个表单涉及文档,默认情况下为空。在这里,数据管理员可以在 DataHub 中添加关于该表的技术知识和/或添加指向已有材料的链接。这是促进公司数据架构成熟度的重要工具——数据目录的帮助程度仅取决于专家共享和输入的知识。数据集还有一些其他有趣的表单,如血缘和验证,我们将在第三部分中介绍——敬请关注 😃

列分析结果

现在,我想展示 DI 引擎提供的内容,只需在我们之前的配方中选择“列分析”选项。为此,让我们进入名为“stats”的表单。在这里,我们可以看到选定表中每一列的统计信息。

来源:作者

例如,我们在第一行看到字段“ProductID”具有 100%的唯一值。这一发现并不令人惊讶,因为这个字段是表的主键——从技术上讲,这些值在每条记录中必须不同。我们还看到 NULL 计数和 NULL 百分比信息,例如,我们看到两个产品记录没有供应商参考。这可以被视为一个关键的数据质量问题,因为没有关联的供应商,你无法实际将这些产品提供给客户。

还有什么呢?我们可以看到描述性度量,例如最小值、最大值、均值,这在记录量较多的交易表中更为有趣。至少,我们可以一眼看到有关我们产品组合的单位价格的统计度量(例如,半数产品的价格低于 $19.45 → 中位数)。此外,我们还可以看到数据集中每列的样本值。

将属于同一类别的内容结合起来

实体链接

当然,数据发现已经非常有启发性,并促进了透明度,特别是如果你从未在实践中体验过这种功能。然而,数据目录的真正力量只有在我们将 IT 世界和商业世界结合起来时才能释放出来。这听起来很棒——但这意味着什么呢?在这个意义上,“IT 世界”指的是所有与数据集相关的技术信息。“商业世界”是我们在前一部分讨论的内容:我们在术语表中维护的所有商业信息。以现实公司为例,必须强调的是(特别是在较大的公司中),有不同的小组负责数据集和术语表。一方面,我们有数据保管员,他们是技术话题的专家,例如开发和改进数据库。另一方面,我们有数据管理员,他们是商业话题的专家,例如如何根据条件指示哪些产品可以在哪个地点生产——以及做出决定所需的数据。因此,当我们谈论链接实体时,我们实际上是在连接人员!

大词汇!那么这与 DataHub 如何配合呢?让我们继续讨论我们的数据集“products”,它反映了一个实际的数据库表。这是我们在 DataHub 中的起点。在下面的屏幕截图中,我们可以看到各种标记的信息,这些信息指向可以共同使用的多种类型的与“商业世界”的链接。

  1. 选项:我们可以在表格列中添加术语表项。此链接指示某个术语的原始数据可以在该列中找到,例如实际的产品名称。

  2. 选项:我们可以在整个表格中添加术语表项。此链接指示某个术语相当复杂,并由多个属性描述,这些属性可以在该表中找到。

  3. 选项:我们可以添加一个数据集所属的领域。此链接类似于术语表项与领域的链接(有关更多信息,请参见第一部分)。

来源:作者

人员

然而,我们的模型中仍然没有人员。因此,让我们为数据集“products”添加一个所有者:

来源:作者

DataHub 建议这个人应该是“技术所有者”,这类似于其他数据治理框架中的“数据保管员”角色。现在我们需要将人员添加到相关的术语中。我们可以简单地点击“产品”术语,DataHub 将我们导航到术语记录。在那里,我们也可以添加所有者。例如,我们可以将 Brian 添加为“数据管理员”,将 Lilly 添加为“业务所有者”。如果我们将鼠标悬停在名字上,会看到分配给该名字的角色。DataHub 允许多次添加人员——如果一个人有多个角色(例如业务所有者+数据管理员),这可能会很有用。不幸的是,该工具也允许多次添加相同的人员和角色组合,这并不实用。

来源:作者

现在假设 Brian 是公司新员工,他想在产品主数据表上做一些更改,因此他需要找到合适的 IT 专家来澄清实施细节。在这种情况下,他只需点击“产品”数据集,该数据集在“相关实体”部分可见(见上图)。然后他会被转到数据集(我们在上面看到过)并可以看到:我的 IT 联系人是 John!当然,组织中需要支持或有产品数据更改请求的其他人员也可以使用这个链接信息——他们只需搜索“产品”,并只需 2 次点击即可收集正确的数据相关人员(Lilly、Brian 和 John)。

第二部分结论

我们已经看到,设置数据摄取过程只需几分钟,因为 DataHub 提供了多个已建立的数据库技术的配置向导。我们还看到,数据发现是一个强大的功能,可以提高公司数据架构的透明度。表和列的分析是自动执行的,统计结果可以在用户界面中查看。数据集和术语的链接或添加所有者也非常简单,不过,我们看到数据一致性检查并不总是由 DataHub 执行。我们可以多次添加相同角色的相同所有者,但该工具至少能阻止我们多次将相同术语添加到数据集中。将相同的术语分配给同一表中的多个列也是可能的——也许在实际应用中这种灵活性是有用的。总的来说,我的实践建议是——至少在一个表内——列和术语之间应保持 1:1 关系。如果不是这样,你应审查你的术语表的粒度,或者你的物理数据模型存在问题。

再次恭喜你读到这里!在下一部分,也是最后一部分,我们将深入探讨数据质量管理,包括数据溯源和数据验证。也许,我会写一个进一步的故事,讨论 DataHub 在数据治理(DG)角色概念方面与其他 DG 框架的比较。

[1] datahubproject.io/docs/metadata-ingestion

[2] datahubproject.io/docs/architecture/metadata-ingestion

训练、验证和评估机器翻译的数据集

原文:towardsdatascience.com/datasets-to-train-validate-and-evaluate-machine-translation-d61905d126aa?source=collection_archive---------2-----------------------#2023-02-04

选择、检查和拆分

Benjamin MarieTowards Data Science Benjamin Marie

·

关注 发表在 Towards Data Science ·13 min 阅读·2023 年 2 月 4 日

--

图片来源于 Pixabay

对于大多数自然语言处理(NLP)任务,一个重要的步骤是选择用于训练、验证和评估系统的数据集。机器翻译也不例外,但由于任务的多语言特性,它有一些特定的要求。

在这篇文章中,我解释了如何选择、检查和拆分数据集,以构建一个机器翻译系统。我通过实例展示了数据集中最重要的属性,并根据机器翻译系统的目标如何在数据的质量和数量之间进行权衡。

训练、验证和评估

要构建一个机器翻译系统,我们需要尽可能多的数据:

  • 训练:机器翻译系统必须经过训练才能学习如何翻译。如果我们计划使用神经模型,这一步骤在数据和计算资源方面是最昂贵的。

  • 验证:可以在训练过程中使用验证数据集来监控模型的表现。例如,如果表现一段时间后没有改善,我们可以决定提前停止训练。然后,如果我们在不同训练步骤保存了模型,我们可以选择在验证数据上表现最佳的模型,并用这个模型进行评估。

  • 评估:这一步骤自动生成我们选择的模型在尽可能接近我们系统实际部署后将要翻译的文本的数据集上的表现。如果表现令人满意,那么我们可以部署我们的模型。如果不满意,我们需要用不同的超参数或训练数据重新训练模型。

## 注意你的束搜索超参数

默认值永远不是最优的。

towardsdatascience.com

所有这些数据集都是平行语料库,包括源语言目标语言,并且理想情况下是目标领域的。

这句中包含了很多关键词。让我们逐一解释它们。

  • 源语言:这是将由我们的机器翻译系统翻译的文本的语言。

  • 目标语言:这是机器翻译系统生成的翻译的语言。

  • 目标领域:这个概念更复杂。假设用于构建我们系统的数据应尽可能接近系统部署后将要翻译的数据,例如相同的风格、体裁和主题。如果我们希望系统翻译推文,那么用推文训练模型比用科学摘要训练要好得多。这可能显而易见,但通常找到一个大型目标领域数据集是具有挑战性的,因此我们必须尽量接近它。

  • 平行语料库:这通常以源语言中的句子或段落形式出现,并与其在目标语言中的翻译配对。我们使用平行数据来教系统如何翻译。这类数据还有许多其他名称:平行数据、双语语料库、双语文本等。“平行数据”可能是最常见的名称。

例如,以下数据集是平行的:

从 Paracrawl 英荷平行语料库中提取(CC0)。作者截图。

质量

为了获得最佳的机器翻译系统,我们需要一个大规模的平行语料库来训练系统。但我们不应为数量而牺牲质量

无论我们讨论的是训练数据还是验证/评估数据,所使用数据的质量会产生不同的影响。

但首先,让我们定义一下构建系统所需的高质量平行数据的最重要特征。

正确

平行数据中的翻译应当正确且自然。理想情况下,这意味着翻译应该由专业翻译人员从零开始制作(即未后期编辑)并经过独立检查。平行语料库常常通过非专业翻译人员众包制作。数据也可以直接从网络上爬取并自动配对,这对于只有少量数据的领域和语言对而言绝对不完美。即便这类数据集的质量远非理想,我们可能也不得不在它们是唯一资源时使用它们。

对齐

平行数据中的段落或文档应当正确对齐。如果段落配对不正确,系统在训练时将学到错误的翻译。

原始

平行数据的源语言部分不应是其他语言的翻译。这一点可能稍微复杂一些。我们希望我们的系统学习如何翻译源语言中的文本。然而,如果在训练时,我们提供的文本不是原本的源语言文本,即已经是从其他语言翻译过来的文本,那么系统将更擅长翻译翻译文本而非原始文本。下面我会详细说明为什么这一点很重要。

领域内

数据应当在目标领域内。这是一个有争议的理想情境。我们可以在非目标领域的数据集上训练一个非常好的系统,然后在目标领域的较小数据集上进行微调。

原始

数据应该接近原始状态。使用已经预处理过的数据集通常是个坏主意。这里的预处理指的是任何改变了原始文本的过程。这可以是分词、大小写还原、标点规范化等。很常见的是,所有这些预处理步骤都没有明确说明,这导致我们无法在我们的系统实际翻译的文本上准确重现它们。定义我们自己的预处理步骤要安全得多,有时也更快捷。

要大致了解数据集的质量,我们应该始终知道数据的来源及其创建方式。我会在下面详细介绍。

在训练时,机器翻译系统将学习平行数据的属性。神经模型对噪声相对较为鲁棒,但如果我们的训练数据非常嘈杂,即对齐不准确或有许多翻译错误,系统将学习生成带有错误的翻译。

在验证/评估时,使用的平行数据的质量更加关键。如果我们的数据集质量较差,评估步骤将仅告诉我们系统在翻译不佳方面的表现。换句话说,这将是一次无用的评估,但可能会让我们误以为可以部署一个训练不充分的机器翻译系统。

数量

除了质量,数据的数量也至关重要。

“数量”通常指的是平行语料库中平行段落的数量。我在这里将使用这个定义。

对于训练来说,尽可能使用更多的数据是一个好的经验法则,前提是数据的质量合理。我将训练场景分为 3 类:

  • 低资源:训练数据包含少于 100,000 个平行段落(或称为句子)

  • 中等资源:训练数据包含 100,000 到 1,000,000 个平行段落

  • 高资源:训练数据包含超过 1,000,000 个平行段落

对于验证和评估,使用大量数据可能看似是获取我们模型准确评估的正确选择,但通常我们更倾向于将更多的数据用于训练而不是验证和评估

如果你查看研究和开发中的最佳实践,你会发现机器翻译的验证和评估数据集通常包含 1,000 到 3,000 个平行段落。请记住,这些数据集的质量比数量重要得多,与训练数据集不同。我们希望评估数据集的翻译尽可能完美,并且尽可能接近我们的系统将要翻译的文本。

单语数据

单语数据,与我之前描述的平行数据不同,是指单一语言的文本。可以是源语言或目标语言。

由于这些数据是单语的,因此比平行数据更容易以非常大的数量进行收集。

它通常被用于生成合成平行数据,然后用来增强训练平行数据。

生成合成数据有许多策略,如回译和正向翻译。如果处理不当,它们可能会对训练产生负面影响。

我将在另一篇博客文章中详细讨论这些策略。敬请关注!

数据泄漏预防

如果你对机器学习有所了解,你可能已经知道什么是数据泄漏。

我们希望训练数据与验证和评估数据尽可能接近,但没有任何重叠

如果存在重叠,我们就谈论数据泄漏。

这意味着我们的系统部分地在用于验证/评估的数据上进行训练。这是一个关键问题,因为它人为地提高了验证/评估结果。系统确实会特别擅长翻译其验证/评估数据,因为它在训练时已经见过这些数据,而一旦投入生产,系统很可能会面临未见过的文本进行翻译。

预防数据泄漏比听起来要困难得多,并且使问题更加复杂的是,数据泄漏有许多不同的层次。

最明显的数据泄漏情况是评估数据中的段落或文档也出现在训练数据中。这些段落应该被排除。

另一种数据泄漏的形式是当训练和评估数据来自相同的文档时。例如,将数据集的段落顺序打乱,然后选择前 95%用于训练,最后 5%用于验证/评估,可能会导致数据泄漏。在这种情况下,我们可能在训练和验证/评估数据中使用了原本来自同一文档的段落对,这些文档可能由同一译者创建。也有可能训练数据中的段落直接用于创建验证/评估数据段落的翻译。因此,验证/评估数据在翻译时人为变得更容易。

为了防止数据泄漏,总是要了解数据的来源,以及数据是如何制作和划分为训练/验证/评估数据集的。

关于翻译腔

平行语料库有两个方面。理想情况下,源语言端是由源语言母语者编写的原始文本,而目标语言端是由目标语言母语者生产的翻译。

目标端不是原始文本:它是翻译。翻译可能包含错误。研究还表明,翻译在词汇上不如原文多样,在句法上也比原文简单。这些翻译伪影定义了“翻译腔”。

为什么在机器翻译中这很重要?

假设你有一个平行语料库,原始源语言为西班牙语,翻译为英语。这对于一个西班牙语到英语的机器翻译系统来说是完美的。

但如果你想要一个英语到西班牙语的系统,你可能会想直接交换平行语料库的两边:原文在目标语言侧,翻译在源语言侧。

然后,你的系统将学习翻译……翻译!由于翻译比原文更容易翻译,神经网络学习任务要简单得多。但然后,机器翻译系统在翻译用户输入的原文时会表现不佳。

底线是:检查数据的来源,以确保至少你不会在源语言中找到翻译内容。

注意,有时这种情况是不可避免的,特别是在处理低资源语言时。

平行语料库的来源

幸运的是,网上有许多领域和语言的平行语料库。

我主要使用以下网站获取所需的内容:

  • OPUS:这可能是最广泛的平行语料库来源。为 300 多种语言提供了数十种语料库。它们可以以纯文本格式(2 个文件:1 个用于源语言,1 个用于目标语言)或 TMX 格式下载,TMX 是翻译行业中常用的 XML 格式。每个语料库的大小和长度(以段落和标记数量计)也会给出。

  • Hugging Face 的数据集:这个数据集并不是专门针对机器翻译资源,但你可以通过选择“translation”标签找到许多平行语料库。OPUS 和 Dataset 的交集非常大,但你会发现一些 OPUS 上没有的平行语料库。

这是目前最大的两个平行语料库来源。如果你知道其他来源,请在评论中指出。

请注意,你会发现的大多数平行语料库只能用于研究和学术目的,而不能用于商业目的。OPUS 不会显示每个数据集的许可信息。如果你需要知道,请直接检查数据集的原始来源或联系创建者。

示例

现在,让我们更实际地操作一些数据集。我创建了两个需要平行数据的任务:

  • 任务 1:一个通用的机器翻译系统,将西班牙语翻译成英语(Es→En)

  • 任务 2:一个专门的机器翻译系统,将 COVID-19 相关内容从斯瓦希里语翻译成英语(Sw→En)

我们将首先专注于任务 1

我们可以开始在 OPUS 上搜索,看是否有针对这个任务的平行语料库。

幸运的是,西班牙语到英语(Es→En)是一个高资源任务。在各种领域有大量平行语料库。例如,从 OPUS,我们可以获得:

来自OPUS的截图。

第一个,“ParaCrawl v9”是最大的之一。它是自动创建的,但足够好以训练机器翻译系统。我们应该始终检查许可证,以确保我们可以将其用于目标应用程序。如上所述,OPUS 没有提供许可证信息,但一旦点击,它会提供数据集的来源。有关许可证信息,我们必须检查数据的原始来源:www.paracrawl.eu/。该语料库在CC0 许可证下提供。允许学术和商业用途。

这是一个包含 264M 对段的大型语料库。这个数据量足够将其拆分为训练/验证/评估数据集。我会这样拆分数据以避免数据泄露:

作者插图。

由于段对数量很多,我们可以将数据拆分为连续的 10M 段对。我会提取一个段,对例如最后一个段进行重新拆分成更小的连续段对 1M。最后,我会从第一个较小段提取 3,000 个段用于验证,从最后一个较小段提取另外 3,000 个段用于评估。

训练、验证和评估数据集之间的距离足够。这是一种非常简单的方法,但远非最佳。如果语料库中的段对已经被打乱,它不会防止数据泄露。

还有其他方法,我在这里不讨论,以更好地保证在提取每个数据集最有用的段对时数据不会泄露。

对于训练,你可以从例如前两个 10M 段开始。如果对翻译质量不满意,你可以将更多的段加入训练数据中。

如果翻译质量没有太大改善,说明你可能不需要使用剩余的 200M+段对。

任务 2 要困难得多。

我们想要翻译斯瓦希里语。非洲语言通常资源较少。此外,我们针对的是一个相对较新的领域——COVID-19,因此我们可以预期这个任务可用的数据会非常少。

正如预期的那样,在 OPUS 上可用的数据集要少得多:

来自OPUS的截图。

一个好的点是,Paracrawl 也提供 Sw→En 的资源,但其 10 万个段对相对较小。然而,这是一个最大的 CC0 许可证资源之一。我会用它进行训练,然后尝试添加其他数据源(如 CCMatrix 或 CCAligned)以观察性能如何改进。

但如何评估一个专门用于翻译 COVID-19 内容的机器翻译系统?

在 COVID-19 爆发后,研究社区致力于制作多语言的翻译资源。 TICO-19 语料库就是其中之一,并且提供了 CC0 许可。它可以在 OPUS 上获取。虽然它很小,但提供了 3,100 段斯瓦希里语和英语的翻译。这足以制作验证/评估数据集。在这里,我会选择 1,000 段用于验证,其余段落用于评估。然后,你将了解你的系统在 Paracrawl 上训练后在翻译 COVID-19 内容方面的表现。

请注意,我没有讨论这两个任务中的翻译腔。Paracrawl 很可能在其源语言侧有非原始的西班牙语和斯瓦希里语。TICO-19 语料库是从英语创建的。斯瓦希里语侧是非原始的。换句话说,我们不能避免这两个任务中的翻译腔。

结论

在这篇文章中,我描述了如何选择和拆分你的数据集,以创建你自己的机器翻译系统。

总结来说,我认为最重要的点是找到质量与数量之间的最佳折中点,特别是当你针对低资源语言时。此外,深入了解你的数据集也至关重要。如果不加以检查,你可能会得到一个完全偏离目标且存在偏见和不公正的系统。

在下一篇文章中,我将展示如何预处理这些数据集以改进它们,并促进机器翻译的训练。

我所有的文章都发布在我的新闻通讯《The Kaitchup》中。订阅以接收每周有关运行大型语言模型和机器翻译系统的新闻、技巧和教程。

[## The Kaitchup - AI on a Budget | Benjamin Marie, PhD | Substack

订阅每周 AI 新闻、技巧和有关调优、大型语言模型运行和服务的教程…

kaitchup.substack.com](https://kaitchup.substack.com/?source=post_page-----d61905d126aa--------------------------------)

SQL 中的日期和子查询

原文:towardsdatascience.com/dates-and-subqueries-in-sql-eaf58a3c6cf9

在 SQL 中处理日期

Michael Grogan数据科学前沿 Michael Grogan

·发布于数据科学前沿 ·阅读时长 4 分钟·2023 年 1 月 27 日

--

来源:webandi提供的照片,来自Pixabay

在处理 SQL 数据库时,通常需要处理包含日期列的表,这些日期列显示每个相关记录的日期。

然而,SQL 处理日期并从这些数据类型中提取有价值见解的能力常常不被充分理解。

天气数据示例

我们考虑以下示例。假设存在一个天气数据库,其中记录了日期和相关的天气信息。以下是数据的片段:

来源:作者使用 PostgreSQL 创建的表(及数据)。表格显示在 pgAdmin4 中。

同时,假设表中定义了一个月份变量,并从表中提取了相关值,如下所示:

update weatherdata set month=extract(month from date);

现在,为了确保我们每个月都有足够的温度记录,并且记录之间的间隔不至于过长——我们假设希望计算表中每两个连续记录之间的平均持续时间,并按月分组。

这个任务将通过以下方式完成:

  1. 使用 LAG()函数计算每两个连续日期之间的差异

  2. 使用子查询计算步骤 1 中计算的每个记录之间的平均持续时间,然后按月分组

计算日期之间的持续时间

通过使用 LAG 函数,我们可以计算每两个连续日期之间的持续时间。然而,我们还希望在新表中显示日期和月份列——我们在随后按月分组平均持续时间时需要使用月份列。

为了实现这一点,我们必须:

  • 通过使用 LAG 函数,计算连续日期之间的持续时间

  • 通过使用 INNER JOIN 函数将相关表与自身进行内连接

具体操作如下:

select t1.date, t1.date - lag(t1.date) over (order by t1.date) as date_difference, t1.month from weatherdata as t1 inner join weatherdata as t2 on t1.date=t2.date;

这是从上述查询生成的表格:

来源:表格(和数据)由作者使用 PostgreSQL 创建。表格在 pgAdmin4 中显示。

我们可以看到,对于上个月记录的天气数据——大多数条目的持续时间不到一天——这意味着天气模式被定期记录,我们很可能获得了该月的代表性样本!

使用带有 GROUP BY 函数的子查询

在计算了上述表格后,我们现在希望按月份计算记录日期之间的平均持续时间。

为了使用我们刚刚生成的数据——我们必须现在使用子查询。也就是说,我们将把上述查询纳入一个更广泛的聚合查询中,该查询可以使用 GROUP BY 函数。

为了按月份分组持续时间,运行以下查询:

select month, avg(date_difference) from (select t1.date as date, t1.date - lag(t1.date) over (order by t1.date) as date_difference, t1.month as month from weatherdata as t1 inner join weatherdata as t2 on t1.date=t2.date) as subquery group by month order by month;

这是生成的数据:

来源:表格(和数据)由作者使用 PostgreSQL 创建。表格在 pgAdmin4 中显示。

从上面可以看出,在第 1 个月到第 12 个月(1 月到 12 月)之间——我们计算了每个月记录日期之间的平均持续时间。

结论

在这篇文章中,你已经看到:

  • 如何从日期中提取月份值

  • 如何使用 LAG 函数计算连续日期之间的持续时间差

  • 使用子查询以便使用诸如 GROUP BY 之类的聚合函数

非常感谢阅读,如有任何问题或反馈,欢迎提出!你还可以在这里找到原始文章,以及更多有用的 SQL 实践示例。

免责声明:本文是以“原样”基础和无担保的方式编写的。它旨在提供数据科学概念的概述,不应被解读为专业建议。本文中的发现和解释仅代表作者本人,并未得到文中提到的任何第三方的认可或关联。作者与本文提到的任何第三方没有任何关系。

如何在 Pandas 中更改日期时间格式

原文:towardsdatascience.com/datetime-format-pandas-541c661d41c2

在 Python 和 pandas 中处理日期时间

Giorgos MyrianthousTowards Data Science Giorgos Myrianthous

·发表于 Towards Data Science ·4 分钟阅读·2023 年 1 月 6 日

--

图片来源:Nathan DumlaoUnsplash

Pandas 是 Python 生态系统中最常用的包之一,它提供了丰富的功能,帮助开发者在内存中分析和转换数据。

在 pandas DataFrame 中,日期时间构造的表示有时可能会变得有些复杂,特别是如果你对该库如何处理这些对象不是很熟悉的话。根据你是否希望将日期时间表示为 pandas object dtype(string)还是 datetime,你可能会发现自己在处理这些列时来回切换。

在这篇文章中,我们将展示如何处理 pandas 中不同的日期时间对象表示方式,以及如何更改格式或甚至数据类型。

首先,让我们创建一个示例 DataFrame,在本教程中我们将引用它以展示一些概念。

import pandas as pd

df = pd.DataFrame(
  [
    (1, 'A', '12/02/1993', True),
    (2, 'C', '01/01/2000', False),
    (3, 'A', '10/05/2010', False),
    (4, 'B', '12/03/1967', True),
    (5, 'B', '19/10/2001', True),
    (6, 'D', '12/03/2002', True),
    (7, 'B', '04/12/2011', False),
    (8, 'A', '01/06/1989', True),
    (9, 'C', '30/11/1980', False),
    (10, 'C', '09/09/1976', False),
  ],
  columns=['colA', 'colB', 'colC', 'colD']
)

>>> df
   colA colB        colC   colD
0     1    A  12/02/1993   True
1     2    C  01/01/2000  False
2     3    A  10/05/2010  False
3     4    B  12/03/1967   True
4     5    B  19/10/2001   True
5     6    D  12/03/2002   True
6     7    B  04/12/2011  False
7     8    A  01/06/1989   True
8     9    C  30/11/1980  False
9    10    C  09/09/1976  False

>>> df.dtypes
colA     int64
colB    object
colC    object
colD      bool
dtype: object

将类型为对象的列转换为日期时间

在示例数据框中,我们将日期作为字符串提供,pandas 会自动将列解析为 object dtype。为了做到这一点,我们只需调用 to_datetime() 方法,并将结果赋回到原始列。此外,我们还指定日期的格式,以确保 pandas 正确解析它们:

df['colC'] = pd.to_datetime(df.colC, format='%d/%m/%Y')

现在,如果我们打印出数据框的新 dtypes,我们应该会看到 colC 列的类型已经改变:

>>> df.dtypes
colA             int64
colB            object
colC    datetime64[ns]
colD              bool
dtype: object

如果我们也打印出数据框,我们会注意到稍有不同的格式,因为 colC 的 dtype 已经改变:

>>> df
   colA colB       colC   colD
0     1    A 1993-02-12   True
1     2    C 2000-01-01  False
2     3    A 2010-05-10  False
3     4    B 1967-03-12   True
4     5    B 2001-10-19   True
5     6    D 2002-03-12   True
6     7    B 2011-12-04  False
7     8    A 1989-06-01   True
8     9    C 1980-11-30  False
9    10    C 1976-09-09  False

更改日期时间对象的格式

现在我们已经将colC的数据类型转换为datetime,我们可以利用strftime()方法来修改记录的日期格式。

假设我们希望日期以mm/dd/yyyy格式表示。我们需要做的就是在调用strftime()时指定这种格式。我们不覆盖colC中的值,而是创建一个新的列以新格式表示:

df['colE'] = df.colC.dt.strftime('%m/%d/%Y')

现在,新创建的列colE将遵循指定的格式:

>>> df
   colA colB       colC   colD        colE
0     1    A 1993-02-12   True  02/12/1993
1     2    C 2000-01-01  False  01/01/2000
2     3    A 2010-05-10  False  05/10/2010
3     4    B 1967-03-12   True  03/12/1967
4     5    B 2001-10-19   True  10/19/2001
5     6    D 2002-03-12   True  03/12/2002
6     7    B 2011-12-04  False  12/04/2011
7     8    A 1989-06-01   True  06/01/1989
8     9    C 1980-11-30  False  11/30/1980
9    10    C 1976-09-09  False  09/09/1976

但请注意,结果列将是object类型,因为strftime生成的输出是字符串格式:

>>> df.dtypes
colA             int64
colB            object
colC    datetime64[ns]
colD              bool
colE            object
dtype: object

最终想法

在 pandas 中处理日期时间对象有时可能是一项棘手的任务。这是因为这些对象可以使用各种数据类型表示,包括objectdatetime。此外,日期格式也有所不同,主要受到用户位置的影响(例如,美国人对mm/dd/yyyy格式有强烈的偏好)。

在这篇文章中,我们演示了如何处理各种不同类型,以及如何在objectdatetime对象之间来回切换。我希望本教程能帮助你更有效地处理这些数据类型!

成为会员 并阅读 Medium 上的每一个故事。你的会员费用直接支持我和你阅读的其他作者。你还将获得对 Medium 上每一个故事的完全访问权限。

[## 使用我的推荐链接加入 Medium — Giorgos Myrianthous

作为 Medium 会员,你的会员费用的一部分将用于支持你阅读的作者,你还将获得对每个故事的完全访问权限……

gmyrianthous.medium.com

你可能也喜欢的相关文章

## *args 和 **kwargs 在 Python 中

讨论位置参数和关键字参数之间的区别,以及如何在 Python 中使用*args 和 **kwargs

towardsdatascience.com ## Python 中的 pycache 是什么?

了解运行 Python 代码时创建的 pycache 文件夹

towardsdatascience.com

去噪声器的黎明:用于表格数据插补的多输出机器学习模型

原文:towardsdatascience.com/dawn-of-the-denoisers-multi-output-ml-models-for-tabular-data-imputation-317711d7a193?source=collection_archive---------6-----------------------#2023-06-28

方法概述与实际应用

Chinmay KakatkarTowards Data Science Chinmay Kakatkar

·

关注 发表在 Towards Data Science · 11 分钟阅读 · 2023 年 6 月 28 日

--

图片由 Jon Tyson 提供,来源于 Unsplash

处理表格数据中的缺失值是数据科学中的一个基本问题。如果由于某种原因不能忽略或省略缺失值,那么我们可以尝试填补它们,即用其他值替代缺失值。填补有几种简单(但过于简单)的方式和几种先进的(更准确但复杂且可能需要大量资源)方式。本文介绍了一种表格数据填补的新方法,旨在实现简洁性和实用性之间的平衡。

具体来说,我们将看到如何利用去噪的概念(通常与非结构化数据相关联)来迅速将几乎任何多输出机器学习算法转变为适用于实际的表格数据填补器。我们将首先介绍一些关于去噪、填补和多输出算法的基本概念,然后深入探讨如何使用去噪将多输出算法转变为填补器。接着,我们将简要地看一下这种新颖的方法如何在实际中应用,并以行业中的一个例子为例。最后,我们将讨论在生成式人工智能和基础模型时代,基于去噪的表格数据填补的未来相关性。为了便于解释,代码示例仅以 Python 语言展示,尽管概念方法本身是不依赖于语言的。

从去噪到填补

去噪是关于从数据中去除噪声的。去噪算法将含有噪声的数据作为输入,进行一些巧妙的处理以尽可能减少噪声,然后返回去噪后的数据。去噪的典型应用包括去除音频数据中的噪声和锐化模糊的图像。去噪算法可以通过多种方法来构建,从高斯和中值滤波器到自编码器。

尽管去噪的概念主要与涉及非结构化数据(例如,音频、图像)的应用场景相关,但对结构化表格数据的填补是一个密切相关的概念。有许多方法可以替代(或填补)表格数据中的缺失值。例如,数据可以简单地用零(或在特定上下文中等效的值)替代,或用相关行或列的一些统计数据(例如,均值、中位数、众数、最小值、最大值)替代——但这样做可能会扭曲数据,如果作为机器学习训练工作流程中的预处理步骤使用,这种简单的填补可能会对预测性能产生不利影响。其他方法如 K 近邻(KNN)或关联规则挖掘可能表现更好,但由于它们没有训练的概念,并直接在测试数据上工作,当测试数据量很大时,它们的速度可能会受到影响;这在需要快速在线推理的应用场景中尤其成问题。

现在,可以简单地训练一个 ML 模型,将具有缺失值的特征设置为输出,并使用其余特征作为预测器(或输入)。如果我们有多个具有缺失值的特征,构建每个特征的单输出模型可能既繁琐又昂贵,因此我们可以尝试构建一个多输出模型,一次性预测所有受影响特征的缺失值。至关重要的是,如果缺失值可以被视为噪声,那么我们可能能够应用去噪概念来填补表格数据——这是我们将在以下部分中深入探讨的关键见解。

多输出 ML 算法

正如其名所示,多输出(或多目标)算法可用于训练模型以同时预测多个输出/目标特征。Scikit-learn 网站提供了关于分类和回归的多输出算法的出色概述(参见 这里)。

虽然一些 ML 算法允许开箱即用的多输出建模,但其他算法可能仅原生支持单输出建模。像 Scikit-learn 这样的库提供了利用单输出算法进行多输出建模的方法,通过提供实现通常功能(如fitpredict)的包装器,并在后台独立应用这些包装器到单输出模型。以下示例代码展示了如何将原生仅支持单输出建模的 Scikit-learn 中的线性支持向量回归(Linear SVR)封装成一个多输出回归器,使用 MultiOutputRegressor 包装器。

from sklearn.datasets import make_regression
from sklearn.svm import LinearSVR
from sklearn.multioutput import MultiOutputRegressor

# Construct a toy dataset
RANDOM_STATE = 100
xs, ys = make_regression(
    n_samples=2000, n_features=7, n_informative=5, 
    n_targets=3, random_state=RANDOM_STATE, noise=0.2
)

# Wrap the Linear SVR to enable multi-output modeling
wrapped_model = MultiOutputRegressor(
    LinearSVR(random_state=RANDOM_STATE)
).fit(xs, ys)

虽然这种封装策略至少让我们在多输出用例中使用单输出算法,但它可能无法考虑输出特征之间的相关性或依赖性(即预测的输出特征集合是否整体上有意义)。相比之下,一些原生支持多输出建模的 ML 算法似乎确实考虑了输出间的关系。例如,当使用 Scikit-learn 中的决策树根据一些输入数据建模 n 个输出时,所有 n 个输出值都存储在叶子节点中,并使用考虑所有 n 个输出值作为一个集合的拆分标准,例如,通过对它们进行平均(参见 这里)。以下示例代码展示了如何构建一个多输出决策树回归器——你会注意到,在表面上,这些步骤与之前训练带有包装器的线性支持向量回归(Linear SVR)时的步骤非常相似。

from sklearn.datasets import make_regression
from sklearn.tree import DecisionTreeRegressor

# Construct a toy dataset
RANDOM_STATE = 100
xs, ys = make_regression(
    n_samples=2000, n_features=7, n_informative=5, 
    n_targets=3, random_state=RANDOM_STATE, noise=0.2
)

# Train a multi-output model directly using a decision tree
model = DecisionTreeRegressor(random_state=RANDOM_STATE).fit(xs, ys)

将多输出 ML 模型训练为表格数据插补的去噪器

现在我们已经涵盖了去噪、插补和多输出机器学习算法的基础知识,我们准备将所有这些构建块结合起来。通常,训练多输出机器学习模型以使用去噪插补表格数据包括以下步骤。请注意,与前一节中的代码示例不同,在接下来的步骤中我们不会明确区分预测变量和目标变量——这是因为,在表格数据插补的上下文中,如果特征在数据中存在,它们可以作为预测变量,如果缺失,它们可以作为目标变量。

第 1 步:创建训练和验证数据集

将数据拆分为训练集和验证集,例如,使用 80:20 的拆分比例。我们将这些数据集称为df_trainingdf_validation

第 2 步:创建训练和验证数据集的噪声/掩盖副本

复制df_trainingdf_validation,并在这些副本的数据中添加噪声,例如,通过随机掩盖值。我们将这些掩盖后的副本称为df_training_maskeddf_validation_masked。掩盖函数的选择可能会影响最终训练的插补器的预测准确性,因此我们将在下一节中讨论一些掩盖策略。此外,如果df_training的大小较小,可以考虑将行数上采样到某个因子k,这样如果df_trainingn行和m列,则上采样后的df_training_masked数据集将有nk行(列数m*保持不变)。

第 3 步:训练一个基于去噪的多输出模型作为插补器

选择一个你喜欢的多输出算法,并训练一个使用噪声/掩盖副本来预测原始训练数据的模型。从概念上讲,你可以进行类似model.fit(predictors = df_training_masked, targets = df_training)的操作。

第 4 步:将插补器应用于被掩盖的验证数据集

df_validation_masked传递给训练好的模型以预测df_validation。从概念上讲,这将类似于df_validation_imputed = model.predict(df_validation_masked)。注意,某些拟合函数可能会直接将验证数据集作为参数来计算拟合过程中的验证误差(例如,在 TensorFlow 中的神经网络)——如果是这样,那么记得在计算验证误差时,使用噪声/掩盖验证集(df_validation_masked)作为预测变量,使用原始验证集(df_validation)作为目标变量。

第 5 步:评估验证数据集的插补准确性

通过将df_validation_imputed(模型预测的结果)与df_validation(真实值)进行比较来评估填补准确性。评估可以按列(以确定每个特征的填补准确性)或按行(以检查预测实例的准确性)进行。为了避免每列得到夸大的准确性结果,可以在计算准确性之前,过滤掉在df_validation_masked中未掩盖的待预测列值的行。

最后,尝试上述步骤来优化模型(例如,使用另一种掩码策略或选择不同的多输出机器学习算法)。

以下代码展示了如何实现步骤 1–5 的一个示例。

import pandas as pd
import numpy as np
from sklearn.datasets import make_classification
from sklearn.tree import DecisionTreeClassifier

# Construct a toy dataset
RANDOM_STATE = 100
data = make_classification(n_samples=2000, n_features=7, n_classes=1, random_state=RANDOM_STATE, class_sep=2, n_informative=3)
df = pd.DataFrame(data[0]).applymap(lambda x: int(abs(x)))

#####
# Step 1: Create training and validation datasets
#####

TRAIN_TEST_SPLIT_FRAC = 0.8
n = int(df.shape[0]*TRAIN_TEST_SPLIT_FRAC)

df_training, df_validation = df.iloc[:n, :], df.iloc[n:, :].reset_index(drop=True)

#####
# Step 2: Create noisy/masked copies of training and validation datasets
#####

# Example of random masking where each decision to mask a value is framed as a coin toss (Bernoulli event)
def random_masking(value): return -1 if np.random.binomial(n=1, p=0.5) else value
df_training_masked = df_training.applymap(random_masking)
df_validation_masked = df_validation.applymap(random_masking)

#####
# Step 3: Train a multi-output model to be used as a denoising-based imputer
#####

# Notice that the masked data is used to model the original data
model = DecisionTreeClassifier(random_state=RANDOM_STATE).fit(X=df_training_masked, y=df_training)

#####
# Step 4: Apply imputer to masked validation dataset
#####

df_validation_imputed = pd.DataFrame(model.predict(df_validation_masked))

#####
# Step 5: Evaluate imputation accuracy on validation dataset
#####

# Check basic top-1 accuracy metric, accounting for inflated results
feature_accuracy_dict = {}
for i in range(df_validation_masked.shape[1]):
    # Get list of row indexes where feature i was masked, i.e., needed to be imputed
    masked_indexes = df_validation_masked.index[df_validation_masked[i] == -1]
    # Compute imputation accuracy only for those rows for feature i
    feature_accuracy_dict[i] = (df_validation_imputed.iloc[masked_indexes, i] == df_validation.iloc[masked_indexes, i]).mean()
print(feature_accuracy_dict)

数据掩码策略

通常,可以采用几种策略来掩盖训练和验证数据。从高层次来看,我们可以区分三种掩码策略:穷举随机领域驱动

穷举掩码

该策略涉及为数据集中每一行生成所有可能的掩码组合。假设我们有一个包含n行和m列的数据集。那么穷举掩码将把每一行扩展到最多 2*m*行,每种掩码组合对应一行;该行的最大组合总数等于帕斯卡三角形中行*m*的和,尽管我们可能选择忽略一些对特定用例不有用的组合(例如,所有值都被掩盖的组合)。因此,最终掩盖的数据集将最多包含*n**(2m)行和m列。虽然穷举策略具有相当全面的优点,但在m较大的情况下可能不太实际,因为生成的掩盖数据集可能过大,以至于大多数计算机难以处理。例如,如果原始数据集仅有 1000 行和 50 列,则穷举掩盖的数据集将大约有 10¹⁸行(即一千亿亿行)。

随机掩码

正如其名称所示,该策略通过使用某种随机函数来掩盖值。例如,在一个简单的实现中,决定掩盖数据集中的每个值可以被视为独立的伯努利事件,掩盖的概率为p。随机掩码策略的明显优点是,与穷举掩码不同,掩盖数据的大小是可以管理的。然而,特别是在小数据集的情况下,为了达到足够高的填补准确性,可能需要在应用随机掩码之前对训练数据集进行上采样,以便在生成的掩盖数据集中反映更多的掩码组合。

领域驱动掩码

该策略旨在以一种接近现实生活中缺失值模式的方式应用掩码,即在填补器将被使用的领域或用例中。为了发现这些模式,分析定量的观察数据以及结合领域专家的见解可能会很有用。

实际应用

本文讨论的基于去噪的填补器可以在实践中提供一种务实的“中间路径”,其中其他方法可能过于简单或过于复杂且资源密集。除了在更大的 ML 工作流中作为数据清洗的预处理步骤外,基于去噪的表格数据填补还可能用于推动某些实际用例中的核心产品功能。

AI 辅助完成在线表单是行业中的一个例子。随着各种业务流程的数字化,纸质表单正被数字在线版本所取代。提交工作申请、创建采购申请、企业旅行预订和注册活动等流程通常涉及填写某种在线表单。手动完成这样的表单可能会很繁琐、耗时且可能容易出错,尤其是当表单有多个需要填写的字段时。然而,在 AI 助手的帮助下,通过根据可用的上下文信息向用户提供输入建议,完成这样的在线表单可以变得更加轻松、快捷和准确。例如,当用户开始填写表单上的某些字段时,AI 助手可以推断剩余字段最可能的值,并实时向用户建议这些值。这样的用例可以被自然地框架为基于去噪的多输出填补问题,其中噪声/掩码数据由表单的当前状态(有些字段填写了,其他字段为空/缺失)给出,目标是预测缺失的字段。可以根据需要调整模型以满足各种用例要求,包括预测准确性和端到端响应时间(用户感知的)。

在生成性人工智能和基础模型时代的相关性

随着生成性人工智能和基础模型的最新进展,以及即使在非技术观众中也越来越意识到它们的潜力,自从 ChatGPT 于 2022 年末亮相以来,探讨基于去噪的填补器在未来的相关性是合理的。例如,大型语言模型(LLMs)可以在理论上处理表格数据的填补任务。毕竟,预测句子中的缺失标记是用于训练像双向编码器表示(BERT)这样的 LLMs 的典型学习目标。

然而,基于去噪的插补方法——或现今存在的其他更简单的表格数据插补方法——在生成式 AI 和基础模型的时代,不太可能很快变得过时。了解这一点的原因可以通过考虑 2010 年代末期的情况来感受,那时神经网络已成为在几个以前依赖简单算法(如逻辑回归、决策树和随机森林)的用例中,技术上更可行且经济上更具吸引力的选项。虽然神经网络确实在某些需要大规模训练数据且训练和维护成本被认为是合理的高端用例中取代了这些其他算法,但许多其他用例仍未受到影响。实际上,神经网络的普及得益于存储和计算资源成本的降低,而这种降低也使得其他简单算法受益。从这个角度来看,成本、复杂性、解释性需求、实时用例的快速响应时间以及对可能出现的寡头垄断外部预训练模型提供商的锁定威胁等因素,都似乎指向一个未来,在这个未来中,像基于去噪的表格数据插补这样的务实创新有可能与生成式 AI 和基础模型有意义地共存,而不是被它们取代。

dbt CLI 模型选择

原文:towardsdatascience.com/dbt-cli-model-selection-52ddd038d8b2

一个完整的选择特定模型的备忘单,当运行 dbt 命令时

Giorgos MyrianthousTowards Data Science Giorgos Myrianthous

·发表于 Towards Data Science ·7 分钟阅读·2023 年 1 月 25 日

--

照片由 Yulia Matvienko 提供,来源于 Unsplash

在处理 dbt 项目时,你需要确保用于运行或测试模型、种子和快照的 CLI 命令只包含感兴趣的资源(或其子集)。换句话说,你需要能够针对特定的模型、测试、种子或快照,以避免浪费资源和金钱。当你处理处理大量数据的大型模型时,这一点尤其重要。

默认情况下,dbt run|test|seed|snapshot 会在依赖图中执行所有相应的节点(即 dbt run 将运行所有模型,dbt test 将运行所有模型测试,依此类推)。在本文中,我们将介绍你在通过 dbt 命令行界面(CLI)运行或测试模型、种子或快照时可以利用的所有可能的模型选择简写。

如果你希望尝试我们在接下来的几个部分中介绍的命令,可以随时在本地创建一个示例 dbt 项目。你可以通过 这个逐步指南创建(可能不到两分钟)本地 dbt 环境。

运行 dbt 项目中的所有资源

为了选择 dbt 项目中的所有资源,你只需选择项目名称:

# Runs all models in project my_dbt_project
dbt run --select my_dbt_project

# Runs all tests in project my_dbt_project
dbt test --select my_dbt_project

# Runs all snapshots in project my_dbt_project
dbt snapshot --select my_dbt_project

# Runs all seeds in project my_dbt_project
dbt seed --select my_dbt_project

选择特定资源

为了执行 runtestsnapshotseed 命令以针对特定模型,你只需在 --select 选项中指定模型名称:

# Run model with name `my_model`
dbt run --select my_model

# Run test with id `not_null_orders_order_id`
dbt test --select not_null_orders_order_id

# Run snapshot with name `my_snapshot`
dbt snapshot --select my_snapshot

# Run seed with name `my_seed`
dbt seed --select my_seed

你甚至可以通过指向定义模型的 SQL 文件的特定路径来运行特定的模型、种子或快照:

# Run model my_model
dbt run --select path/to/my_model.sql

# Run snapshot my_snapshot
dbt snapshot --select path/to/my_snapshot.sql

# Run seed my_seed
dbt seed --select path/to/my_seed.sql

选择多个模型

--select接受多个参数,这意味着它能够同时运行多个模型(或测试、快照和种子)。要做到这一点,只需在运行命令时提供所有模式、测试、快照或种子名称:

# Run multiple models
dbt run --select my_model another_model

# Run multiple tests
dbr test --select not_null_orders_order_id unique_orders_order_id

# Run multiple snapshots
dbt snapshot --select my_snapshot another_snapshot

# Run multiple seeds
dbt seed --select my_seed another_seed

选择节点和下游依赖

为了运行一个 dbt 节点及其下游依赖,您需要在资源名称后指定+操作符。

# Run the model with name `my_model` as well as its downstream dependencies
dbt run --select my_model+

# Run my_model tests and the tests of its downstream dependencies 
dbt test --select my_model+

# Run seed my_seed and its downstream dependencies
dbt seed --select my_seed+

选择模型和上游依赖

同样地,要选择一个节点及其上游依赖,需要在节点名称之前指定+操作符:

# Run the upstream dependencies of model `my_model` and the model itself
dbt run --select +my_model

# Run the tests of my_model and the tests of its upstream dependencies
dbt test --select +my_model

# Run the upstream dependencies of snapshot my_snapshot and the snapshot itself
dbt snapshot --select +my_snapshot

# Run the upstream dependencies of seed my_seed and the seed itself
dbt seed --select +my_seed

选择具有下游和上游依赖的模型

现在,为了运行一个模型及其所有下游和上游依赖,您只需在两个+操作符之间指定模型名称:

# Run the model `my_model` including its parents and children nodes
dbt run --select +my_model+

# Run the tests for model `my_model` including the tests of its parents and children
dbt test --select +my_model+

# Run the snapshot `my_snapshot` and all downstream and upstream dependencies
dbt snapshot --select +my_snapshot+

# Run the seed `my_seed` and all of the downstream and upstream depdencies
dbt seed --select +my_seed+

选择模型和 N 个下游依赖

有可能您需要运行的不是模型的所有下游(子级)依赖,而是仅需遍历一定数量的边。这可以通过再次使用+操作符来实现,但这次需要指定要执行的父级模型的度数/级别。

# Run model my_model and its first-degree children
dbt run --select my_model+1

# Run tests for `my_model` model and the tests of its first-degree children
dbt test --select my_model+1

# Run `my_snapshot` snapshot and its first-degree children
dbt snapshot --select my_snapshot+1

# Run seed `my_seed` and its first-degree children
dbt seed --select my_seed+1 

选择模型和 N 个上游依赖

以相同的方式,您可以指定在上游(或父级)依赖中要遍历的边数

# Run my_model and its first and second degree parent nodes
dbt run --select 2+my_model

# Run tests of my_model and the tests of its first and second degree parents
dbt test --select 2+my_model

# Run snapshot my_snapshot and its first and second degree parent nodes
dbt snapshot --select 2+my_snapshot

# Run seed my_seed and its first and second degree parent nodes
dbt seed --select 2+my_seed

选择模型和 N 个上游以及 M 个下游依赖

最后,要选择一个模型以及 N 个父节点和 M 个子节点,您可以在上游和下游依赖的边数之间指定模型:

# Run model `my_model`, its parents up to the 4th level and its downstreams up to the 5th level
dbt run --select 4+my_model+5

# Run tests of model `my_model` and the tests of its parents up to the 4th level and its downstreams up to the 5th level
dbt test --select 4+my_model+5

# Run snapshot `my_snapshot`, its parents up to the 4th level and its downstreams up to the 5th level
dbt snapshot --select 4+my_snapshot+5

# Run seed `my_seed`, its parents up to the 4th level and its downstreams up to the 5th level
dbt seed --select 4+my_seed+5

排除一个模型

除了--select之外,dbt CLI 还提供了--exclude标志(其语义与--select相同)。在--exclude参数中指定的任何模型将从用--select选择的模型集合中移除。

以下命令将运行除名为my_model的模型之外的所有模型:

dbt run --exclude my_model

--exclude参数也适用于其他 dbt 命令:

# Run all tests except the one with id `not_null_orders_order_id`
dbt test --exclude not_null_orders_order_id
# Run all tests except the tests of customers model
dbt test --exclude customers

# Run all snapshots except `my_snapshot`
dbt snapshot --exclude my_snapshot

# Run all seeds except `my_seed`
dbt seed --exclude my_seed

请注意,--select--exclude参数可以在单个 dbt 命令中组合使用。

例如,以下命令将运行my_package包中的所有模型,但排除user_base_model及其下游依赖。

dbt run --select my_package --exclude my_package.user_base_model+

运行特定包中的模型

要运行属于特定 dbt 包的模型、测试、快照或种子,您需要遵循点表示法,如下命令所示:

# Runs model my_model in package mypackage
dbt run --select mypackage.my_model

# Runs tests of my_model model in package mypackage
dbt test --select mypackage.my_model

# Runs snapshot my_snapshot in package mypackage
dbt snapshot --select mypackage.my_snapshot

# Runs seed my_seed in package mypackage
dbt seed --select mypackage.my_seed

运行特定路径中的所有模型

为了运行位于特定目录下的模型、测试、快照或种子,您可以使用以下选择器表示法:

# Runs all models under path.to.my.models directory
dbt run --select path.to.my.models

# Runs all tests under path.to.my.models directory
dbt test --select path.to.my.models

# Runs all snapshots under path.to.my.snapshots directory
dbt snapshot --select path.to.my.snapshots

# Runs all seeds under path.to.my.seeds directory
dbt seed --select path.to.my.seeds

除了点表示法,您还可以如下面所示在特定路径中运行模型:

# Runs all models under path/to/my/models directory
dbt run --select path/to/my/models

# Runs all tests under path/to/my/models directory
dbt test --select path/to/my/models

# Runs all snapshots under path/to/my/snapshots directory
dbt snapshot --select path/to/my/snapshots

# Runs all seeds under path/to/my/seeds directory
dbt seed --select path/to/my/seeds

选择具有特定标签的模型

如果您有标记的资源并且希望执行所有这些资源,您可以提供tag选择器并跟上标签名称,如下命令所示:

# Run all models with "finance" tag
dbt run --select tag:finance

组合多个选择器

注意,你实际上可以将教程中描述的几乎任何选择器组合在一个命令中。例如,以下命令将运行每一个标记为 finance 标签的资源,单个模型 my_model 以及路径 path.to.my.marketing.models 中的所有模型:

dbt run --select tag:finance my_model path.to.my.marketing.models

像往常一样,这可以应用于几乎所有的资源,包括测试、种子和快照:

# Tests
dbt test --select tag:finance not_null_orders_order_id path.to.my.marketing.models

# Seeds
dbt seed --select tag:finance my_seed path.to.my.marketing.seeds

# Snapshots
dbt snapshot --select tag:finance my_snapshot path.to.my.marketing.snapshots

最终思考

总结来说,在处理 dbt 项目时,能够针对特定的模型、测试、种子或快照进行操作是很重要的,以避免浪费资源和金钱。 dbt 命令行界面(CLI)提供了多种简写方式,允许你选择特定的资源进行运行、测试、种子或快照。这些包括在运行任何 dbt 命令时包含或排除这些模型的能力。

成为会员 并阅读 Medium 上的每一个故事。你的会员费直接支持我和其他你阅读的作者。你还将获得 Medium 上每一个故事的完全访问权限。

[## 通过我的推荐链接加入 Medium — Giorgos Myrianthous

作为 Medium 的会员,你的一部分会员费会支付给你阅读的作者,同时你可以完全访问每一个故事…

gmyrianthous.medium.com

相关的文章你可能也会喜欢

## ETL vs ELT:有什么区别?

数据工程中的 ETL 和 ELT 的比较

[towardsdatascience.com ## dbt 中的分阶段 vs 中间 vs mart 模型

理解在数据构建工具(dbt)背景下,分阶段、中间和 mart 模型的目的

[towardsdatascience.com ## 如何构建你的 dbt 项目和数据模型

强化 dbt 数据模型的有效结构

[towardsdatascience.com

dbt Core、Snowflake 和 GitHub Actions:数据工程师的个人项目

原文:towardsdatascience.com/dbt-core-snowflake-and-github-actions-pet-project-for-data-engineers-815991a48b44?source=collection_archive---------7-----------------------#2023-12-01

数据/分析工程师的个人项目:探索现代数据堆栈工具——dbt Core、Snowflake、Fivetran 和 GitHub Actions。

Kateryna HerashchenkoTowards Data Science Kateryna Herashchenko

·

关注 发表在 Towards Data Science ·6 分钟阅读·2023 年 12 月 1 日

--

图片由 Gaining Visuals 提供,来源于 Unsplash

这是一个简单且快速的宠物项目,适合希望尝试现代数据栈工具的 Data/Analytics 工程师,包括 dbt Core、Snowflake、Fivetran 和 GitHub Actions。通过这个实践经验,你可以开发一个端到端的数据生命周期,从从你的 Google Calendar 提取数据到在 Snowflake 分析仪表板中展示数据。在本文中,我将带你了解这个项目,并分享一些见解和技巧。查看 GitHub 仓库

技术概述

项目架构如下所示:

Google Calendar -> Fivetran -> Snowflake -> dbt -> Snowflake Dashboard,由GitHub Actions协调部署。

架构

参考 Joe Reis 的《数据工程基础》,让我们根据数据生命周期的定义阶段来审视我们的项目:

数据工程生命周期 [1]

  • 数据生成 — Google Calendar, Fivetran 如果你是 Google Calendar 用户,你可能已经积累了大量的数据。现在,你可以通过利用 Fivetran 这样的“数据移动平台”轻松从你的账户中检索这些数据。该工具自动化了 ELT(提取、加载、转换)过程,将数据从 Google Calendar 源系统集成到我们的 Snowflake 数据仓库中。

    目前,Fivetran 提供 14 天的免费试用 — 链接。注册非常简单。

  • 存储 — Snowflake Snowflake 是一个针对分析需求的云数据仓库,将作为我们的数据存储解决方案。我们处理的数据量较小,因此不会过度使用数据分区、时间旅行、Snowpark 和其他 Snowflake 高级功能。然而,我们会特别关注访问控制(这将用于 dbt 访问)。你需要 设置试用账户,这将为你提供 30 天的免费使用和 400$ 的企业版额度。

  • 数据摄取 — Fivetran 数据摄取可以通过 Fivetran 和 Snowflake 的 Partner Connect 功能进行配置。选择你喜欢的方法并设置 Google Calendar 连接器。初始同步后,你可以通过 Snowflake UI 访问你的数据。你可以访问连接器网页查看模式图 这里

    将专门为 Fivetran 同步创建一个新数据库,以及相应的仓库以运行 SQL 工作负载。你应该知道,Snowflake 采用了解耦的存储和计算,因此费用也是分开的。作为最佳实践,你应该为不同的工作负载(如临时、同步、BI 分析)或不同的环境(如开发、生产)使用不同的仓库。

转到 Partner Connect 以连接 Fivetran。

在 Fivetran 中设置 Google Calendar 连接器。

同步的数据会出现在 Snowflake 中。

  • Transformation — dbt Core 数据存储在 Snowflake 中(默认每 6 小时自动同步),我们使用 dbt Core 进入变换阶段。dbt(数据构建工具)有助于 SQL 查询的模块化,使 SQL 工作流能够重用和版本控制,就像软件代码通常被管理一样。有两种方式可以访问 dbt:dbt Cloud 和 dbt Core。dbt Cloud 是一个付费的云版本服务,而 dbt Core 是一个提供所有功能的 Python 包,你可以免费使用。

    在你的机器上安装 dbt Core,通过 CLI 使用“dbt init”命令初始化项目,并设置 Snowflake 连接。请查看我的 profiles.yml 文件

    为了能够连接到 Snowflake,我们还需要运行一系列 DCL(数据控制语言)SQL 命令,详细信息请见 这里。遵循最小权限原则,我们将为 dbt 创建一个单独的用户,并仅授予其对源数据库(Fivetran 同步数据的位置)、开发数据库和生产数据库的访问权限(我们将在这些数据库中接收转换后的数据)。

  • 按照 最佳实践结构化方法,你需要创建三个文件夹,代表数据转换的 staging、intermediate 和 marts 层。在这里你可以试验你的模型,也可以复制我的 示例 来处理 Google Calendar 数据。

    在仓库中,你会找到列出 Google Calendar 架构中所有表的 “sources.yml” 文件。创建了 3 个 staging 模型(event.sql,

    包括 1 个变换模型(utc_event.sql)和 1 个数据集市模型(event_attendee_summary.sql)在内的 attendee.sql 和 recurrence.sql。

    dbt 的重要功能是 Jinja 和宏,你可以将其编织到 SQL 中,从而增强其影响力和可重用性。

选择不同类型的模型物化。

  • 使用 dbt 中的通用或单一测试设置数据期望,以确保数据质量。一些数据质量规则位于 “source.yml” 文件中,

    以及 “/tests” 文件夹中。在“dbt build”命令期间,这些数据质量检查将与模型构建一起运行,以防止数据损坏。

你可以使用 “dbt test” 运行测试。

  • 探索 dbt 的快照功能,用于变更数据捕获和类型 2 缓慢变化维度。在我们的示例中,我们捕获了“recurrence”表中的变化。

将快照存储在单独的模式中

  • 使用“dbt docs generate”命令生成 dbt 文档可能需要一些时间。你将看到从项目中自动创建的数据血缘图和元数据。你可以通过在你的 .yml 文件中添加对数据实体的描述来进一步增强它。

    良好的文档提供了更好的数据发现性和治理。

运行 dbt docs serve 以在浏览器中打开它。

  • 服务 — Snowflake 仪表板 最后,使用 Snowflake 仪表板可视化你的转换数据。在 Snowflake UI 中创建一个仪表板,并根据你的 SQL 查询尝试使用图块(绘图)。

仪表板示例

  • 部署 — GitHub Actions 虽然 dbt Cloud 提供了一个简单的部署选项,但我们将使用 GitHub Actions 工作流来进行我们的 dbt Core 项目。你需要创建一个workflow .yml 文件,该文件将在对 GitHub dbt repo 进行更改时触发,运行指定的操作。在我的示例工作流中,你可以看到一个两步的部署过程:dbt build用于开发环境,成功后还会执行**dbt

    用于生产环境的 build

    注意:将 Snowflake 账户和密码等机密替换为 GitHub 机密。为此,请在你的 repo 网页上,转到 Settings -> Secrets and Variables -> Actions。

    每次“master”分支更新时,你可以在 repo 的 Actions 标签中看到工作流的启动情况:

查看 Actions 结果

在这个项目中,我们仅仅触及了现代数据工程栈中各种技术的表面。这不仅是一个实际的成就,也是深入探索的绝佳起点。感谢阅读,祝编程愉快!

如果你读到了文章的末尾,也许你觉得它很有价值,并且可能想通过 LinkedIn与我联系。我对机会持开放态度!

除非另有说明,否则所有图片均为作者提供。

参考文献:

[1] Reis, J. (2022). 《数据工程基础:计划和构建强大的数据系统》。O'Reilly Media。

dbt 增量模型——正确的方式

原文:towardsdatascience.com/dbt-incremental-the-right-way-63f931263f4a?source=collection_archive---------1-----------------------#2023-07-21

从全面加载的痛苦到增量收益(以及途中一些错误)

Leah NguyenTowards Data Science Leah Nguyen

·

关注 发表在Towards Data Science ·9 分钟阅读·2023 年 7 月 21 日

--

图片来源:Lukas TennieUnsplash

当我在 GlamCorner 的团队开始从传统的 MySQL 数据库过渡到使用 dbt 作为转换和建模层的 Postgres 数据库的 ELT 时,我们感到非常高兴。我们设置了 dbt 项目和配置文件,为我们的模型专门编写了宏,并构建了更多的数据集市以满足下游需求。我们以为一切都完成了——我以为一切都完成了,直到我们遇到第一个障碍:模型运行时间。在这篇文章中,我解释了如何通过采用 dbt 增量模型来克服当时最艰难的性能挑战,犯错(谁没有呢?)并在过程中学到宝贵的经验教训。

进化中的怪物

在 GlamCorner,我们玩的是循环时尚游戏。我们的“后端”团队在仓库里使用 RFID 扫描器,像专业人士一样扫描进出商品。我们还使用像 Zendesk 和 Google Analytics 这样的高级平台,让我们的客户感觉特别棒。更棒的是,我们有自己内部的库存系统——多亏了我们出色的软件工程师——将所有前端和后端系统连接在一起。这就像是天作之合。但随着我们的成长和运营年限的增加,我们的数据库越来越大。可以说,传统的全表加载开始感觉有点像是个麻烦。

痛苦

你要么理解“我希望数据在早上 9 点前准备好”的痛苦,要么不理解。

图片来自作者

团队付出了努力来创建无瑕的(E)xtract 和(L)oad,我们一起庆祝。然后有一天,(T)ransformation 就像“哎,这里不是这样运作的”一样,把总运行时间从 10 分钟提高到 90 分钟。我可能夸张了 10 分钟到 90 分钟的部分,因为是的,一切都有其原因,但当你还没喝第一杯咖啡,早上 8:55 商务团队就敲门问:“最新数据在哪里?”这种感觉真的是每天上班的地狱。这就像把所有的辛勤工作扔进垃圾桶,我自己无法接受这个现实。

回到我说的事情:每件事都有它的理由,而曾经只需要 10 分钟的童话故事现在却变成了 90 分钟的红角魔鬼。为了说明这一点,我们以fct_booking数据表为例。这个表包含了每天从网站上获取的所有预订信息。每个**booking_id**代表一个在网站上预订的订单。

图片来自作者

每天,大约有 4 个订单被添加到预订表中,而该表已经包含 80 个订单。当使用 dbt 运行这个模型时,它会删除前一天的整个表,用 84 条记录(包括旧订单和新订单)替换所有记录(80 个历史累计订单+最新一天增加的 4 个新订单)。另外,每新增 4 条记录,查询时间会增加约 0.5 秒。

图片来自作者

现在,想象一下 4 个订单等于每天 4000 条记录,而 80 个订单实际上代表 80 万条记录。你能猜到转换 fct_bookings 表需要多长时间吗?例如,三个月后我们会在哪里?

好吧, 我会把数学留给你。

金蛋

所以,在无目的地浏览 dbt 社区线程并半心半意地浏览 dbt 文档之后(我意思是,谁没这样做过?),我发现了 dbt 增量的圣杯。这就像在稻草堆中找针,只不过那根针是金色的,而稻草堆是由代码组成的。

用通俗的话说,dbt 增量意味着你不必从头开始处理所有数据。你只需处理新数据和修改过的数据,从而节省时间和资源。这就像一个实际有效的快捷方式,不会让你在老板面前出丑。

作者提供的图片

如果你想了解更多关于 dbt 增量的细节,可以查看这个博客和文档:

## dbt 增量模型在大数据中的强大作用

在 BigQuery 上的实验

towardsdatascience.com [## 增量模型 | dbt 开发者中心

阅读此教程,了解如何在构建 dbt 时使用增量模型。

docs.getdbt.com](https://docs.getdbt.com/docs/build/incremental-models?source=post_page-----63f931263f4a--------------------------------)

要在你的 dbt 模型中设置这个模型,你需要在模型脚本的开头添加一个配置块,同时考虑这两个组件:

  • Materialized(物化视图): 默认情况下,dbt 模型的物化视图等于 ‘table’,当没有配置时。要设置增量模式,请将物化视图设置为 ‘incremental’。有关其他 dbt 物化视图的信息,请访问:

[## 物化视图 | dbt 开发者中心

阅读此教程,了解如何在构建 dbt 时使用物化视图。

docs.getdbt.com](https://docs.getdbt.com/docs/build/materializations?source=post_page-----63f931263f4a--------------------------------)

  • Unique_key(唯一键): 尽管根据 dbt 文档设置唯一键是可选的,但合理考虑如何设置这一点是极其重要的。实际上,唯一键将是主要驱动因素,帮助 dbt 确定记录是否应添加或更改。需要考虑的一些问题包括:

  • 唯一键真的唯一吗?

  • 这是两个或更多列的组合吗?

未设置唯一键可能导致数据丢失和模糊值,所以要小心!

这是一个如何为单个唯一键设置配置块的示例:

如果唯一键是多个列的组合,你可以调整配置为:

注意: 如果你使用 BigQuery 或 Snowflake 存储数据,你可能可以调整更多的配置,如设置 sync_mode。但由于我们公司的数据库基于 Redshift,具体来说是 Postgres,我们没有这些高级配置。

一旦解决了这个问题,我们还需要在 dbt 增量模型的脚本中添加一个重要步骤:为 **is_incremental()** 宏添加一个条件块。

is_incremental() 宏在满足以下条件时返回 True

  • 目标表已经存在于数据库中。

  • dbt 没有以 **full-refresh** 模式运行。

  • 运行的模型配置为 **materialized=’incremental’**

请注意,无论 **is_incremental()** 计算结果是 True 还是 False,你的模型中的 SQL 需要是有效的。

回到 fct_booking 的例子,以下是原始查询:

在应用上述增量设置后,我们有一个模型,其中包括唯一键、模型标签和 **is_incremental()** 宏的条件块,如下所示:

如代码中所示,unique_key 已设置为 **booking_id**,因为一个 booking_id 对应一个订单。

为了使其更高端,我还为任何其他与增量物化集成的模型添加了一个模型标签 incremental_model。主要原因是,通常当 dbt 模型增量出现问题时,它们往往会成批出现问题。因此,为了刷新这些模型而不影响其他模型,并且不需要记住每个启用了增量模式的模型,我可以运行上述代码,而不是单独指定每个模型名称。

dbt run — select tag:incremental_model --full-fresh

还要注意,如果增量模型设置不正确并且在生产表中更新了错误的数据,我需要使用 --full-refresh 命令重新运行模型。然而,你应该记住,以全量加载刷新而不是增量模式运行会更慢,因此记得选择合适的时间进行此操作 (提示:不要在早上 9 点进行)。

反击

到目前为止,生活再次美好!我完美地设置了表,性能查询显著提高。终于,我可以安然入睡。我的手可以触摸草地,dbt 增量模型也没有错过小 Leah —— 这是一个梦想成真。然而,不久之后,财务团队的一名员工急匆匆地跑到我的桌前,手里拿着一份报告,激动地声称:“你给了我错误的数据!”

结果是增量模型在一天中意外跳过了许多订单,然后进入了第二天。“这怎么可能发生?我按照专家教程操作——这不可能错!”我在心里低语。除非上游发生了一些我可能遗漏的事情。经过一番挖掘,问题浮出水面。

每天,在午夜进行数据提取和加载过程,以同步直到那一刻的所有数据。同步通常发生在午夜,但其时间可能会受到启动时间和包缓存等因素的影响。需要注意的是,提取过程可能会在午夜之后稍微开始。

考虑一种情况:提取在凌晨 12:02 开始,而某人决定在凌晨 12:01 进行预订。在这种情况下,数据也将包括当天的一小部分订单,这在更技术性的术语中被称为“迟到数据”。

然而,当前 WHERE 筛选器的逻辑存在一个缺点。筛选器的效率受到影响,因为它仅从 **created_at** 的最新日期值中追加新记录。这意味着它不会捕捉到整天的数据。

为了修复这个问题,我们将稍微调整这个逻辑:

新的筛选器涉及同步过去 7 天的所有数据。任何新数据将被添加到现有数据集中,而任何旧数据的更新字段值将被替换。

权衡

既然你已经跟随到了这里,你可能会想:“我应该使用 is_incremental 筛选器回溯多少天?为什么我选择了 7 天?如果我需要过去 30 天的数据怎么办?”答案并不简单——这取决于你的具体情况。

在我的情况中,我确保每天至少有一个订单。由于过去 7 天的数据可能会发生内部变化,我将筛选器设置为在该时间范围内追加新数据和更新现有数据。然而,如果你对你的查询性能有信心并且想要回溯更长的时间,比如过去 365 天,你可以自由选择!只需注意需要考虑的权衡。

使用增量模型的主要原因是降低模型运行性能的成本。然而,根据数据的大小和公司具体的使用案例,扫描过去 7 天的大数据集可能会降低性能。根据你的需求,找到合适的平衡是至关重要的。

对于更通用的方法,我建议将 7 天作为标准规则。你可以设置每周或每年进行一次完整刷新,以更新 dbt 增量模型。这种方法允许你考虑到意外问题,因为无论你的设置多么完善,仍然可能会有偶尔的停机时间。

在我的使用案例中,我通常会在周末安排增量运行的完整刷新,因为那时操作任务较少。然而,这个时间表可以根据你的团队要求进行自定义。

请记住,关键是找到数据新鲜度和查询性能之间的最佳平衡,确保数据保持准确和最新,同时优化模型的效率。

解码:用简单英语解释 Transformers

原文:towardsdatascience.com/de-coded-transformers-explained-in-plain-english-877814ba6429?source=collection_archive---------0-----------------------#2023-10-09

无需代码、数学或提及 Keys、Queries 和 Values

Chris HughesTowards Data Science Chris Hughes

·

关注 发表于 Towards Data Science ·15 分钟阅读·2023 年 10 月 9 日

--

自从2017 年引入以来,transformers 已成为机器学习领域的显著力量,彻底改变了主要翻译自动补全服务的能力。

最近,随着大型语言模型如 OpenAI 的ChatGPTGPT-4和 Meta 的LLama的出现,变换器的受欢迎程度更高了。这些模型获得了极大的关注和兴奋,都建立在变换器架构的基础上。通过利用变换器的力量,这些模型在自然语言理解和生成方面取得了显著突破,向公众展示了这些成就。

尽管有很多优质资源详细讲解了变换器的工作原理,但我发现自己在理解其数学机制的同时,很难直观地解释变换器是如何工作的。在进行许多访谈、与同事讨论以及在相关主题上做简短讲座之后,似乎很多人都面临这个问题!

在这篇博客文章中,我将旨在提供一个高级解释,讲解变换器是如何工作的,而不依赖于代码或数学。我的目标是避免令人困惑的技术术语和与以前架构的比较。虽然我会尽量保持简单,但由于变换器相当复杂,这不会很容易,但我希望它能提供更好的直观理解,了解它们的作用及其工作方式。

什么是变换器?

变换器是一种神经网络架构,特别适用于处理序列输入的任务。在这个上下文中,最常见的序列例子可能是句子,我们可以把它看作是一个有序的单词集合。

这些模型的目标是为序列中的每个元素创建一个数值表示;封装关于元素及其邻近上下文的关键信息。生成的数值表示可以传递给下游网络,这些网络可以利用这些信息来执行各种任务,包括生成和分类。

通过创建如此丰富的表示,这些模型使下游网络能够更好地理解输入序列中的潜在模式和关系,从而增强它们生成连贯且具有上下文相关性的输出的能力。

变换器的主要优势在于其处理序列中的长距离依赖的能力,以及高度的效率;能够并行处理序列。这对于机器翻译、情感分析和文本生成等任务尤其有用。

图片由 Azure OpenAI Service DALL-E model 生成,提示为:“绿色和黑色的矩阵代码,形状像 Optimus Prime

Transformer 中包含什么?

要将输入传递给 transformer,我们必须首先将其转换为一系列标记;一组整数表示我们的输入。

由于 transformers 最初应用于 NLP 领域,我们首先考虑这一场景。将句子转换为一系列标记的最简单方法是定义一个 词汇表,它充当查找表,将单词映射到整数;我们可以保留特定的数字来表示词汇表中未包含的任何单词,以便始终分配一个整数值。

实际上,这是一种简单的文本编码方式,因为像 catcats 这样的单词被视为完全不同的标记,尽管它们是相同动物的单数和复数描述!为了克服这一点,已经制定了不同的标记化策略——如 byte-pair encoding——它们在索引单词之前会将其拆分成更小的块。此外,通常还需要添加特殊标记,以表示句子的开始和结束,从而为模型提供额外的上下文。

让我们考虑以下示例,以更好地理解标记化过程。

“你好,今天在 Drosval 的天气不错吗?”

Drosval 是 GPT-4 使用以下提示生成的名称:“你能创建一个虚构的地名,它听起来像是属于 David Gemmell 的 Drenai 宇宙吗?;这个名字被故意选择,因为它不应该出现在任何训练模型的词汇中。

使用来自 transformers library[bert-base-uncased](https://huggingface.co/bert-base-uncased) 标记化器,这被转换为以下标记序列:

代表每个单词的整数将根据特定的模型训练和标记化策略而变化。解码后,我们可以看到每个标记代表的单词:

有趣的是,我们可以看到这与我们的输入并不相同。添加了特殊标记,我们的缩写被拆分为多个标记,我们的虚构地名由不同的“块”表示。由于我们使用了‘uncased’模型,我们也丢失了所有的大写上下文。

然而,尽管我们用句子作为例子,变压器模型并不限于文本输入;这种架构在视觉任务上也展示了良好的结果。为了将图像转换为序列,ViT 的作者将图像切割成不重叠的 16x16 像素块,并将这些块串联成一个长向量,然后输入到模型中。如果我们在推荐系统中使用变压器,一种方法可能是将用户浏览过的最后n个项目的 ID 作为输入传递到我们的网络中。如果我们能够为我们的领域创建有意义的输入标记表示,我们可以将其输入到变压器网络中。

嵌入我们的标记

一旦我们得到一个表示输入的整数序列,我们可以将它们转换为词嵌入。词嵌入是一种表示信息的方式,能够被机器学习算法轻松处理;其目的是通过将信息表示为数字序列,以压缩的格式捕捉编码的标记的意义。最初,词嵌入被初始化为随机数字序列,有意义的表示在训练过程中被学习。然而,这些词嵌入有一个固有的限制:它们不考虑标记出现的上下文。这有两个方面。

根据任务的不同,当我们嵌入标记时,我们可能还希望保留标记的顺序;这在 NLP 等领域尤为重要,否则我们基本上会得到一个词袋模型方法。为了解决这个问题,我们对词嵌入应用位置编码。虽然有多种创建位置嵌入的方法,但主要思想是我们有另一组嵌入,表示输入序列中每个标记的位置,这些位置嵌入与我们的标记嵌入结合。

另一个问题是标记的含义可以根据周围的标记而变化。考虑以下句子:

天黑了,谁关掉了灯?

哇,这个包裹真的很轻!

在这里,light 这个词在两个不同的上下文中使用,其含义完全不同!然而,根据分词策略的不同,词嵌入可能是相同的。在变压器模型中,这由其注意力机制处理。

从概念上讲,什么是注意力机制?

也许变换器架构中最重要的机制被称为注意力,它使网络能够理解输入序列中哪些部分对于给定任务最为相关。对于序列中的每个标记,注意力机制确定哪些其他标记在给定上下文中对理解当前标记是重要的。在我们探讨变换器中如何实现这一机制之前,让我们从简单开始,试图从概念上理解注意力机制的目标,以建立我们的直觉。

理解注意力的一种方式是将其视为一种方法,用来将每个标记嵌入替换为包含其邻近标记信息的嵌入;而不是在不考虑上下文的情况下对每个标记使用相同的嵌入。如果我们知道哪些标记与当前标记相关,那么捕捉这种上下文的一种方式是创建一个加权平均——或者更一般地说,是一个线性组合——这些嵌入。

让我们考虑一个简单的例子,看看这对于我们之前看到的一个句子会是什么样子。在应用注意力之前,序列中的嵌入没有邻居的上下文。因此,我们可以将light一词的嵌入可视化为以下线性组合。

在这里,我们可以看到我们的权重只是单位矩阵。应用我们的注意力机制后,我们希望学习一个权重矩阵,以便我们能够以类似于以下的方式表达我们的light嵌入。

这次,较大的权重被赋予对应于我们选择的标记最相关部分的嵌入;这应该确保最重要的上下文被捕捉到新的嵌入向量中。

含有当前上下文信息的嵌入有时被称为上下文化嵌入,这就是我们最终试图创建的东西。

现在我们对注意力机制的目标有了一个高层次的理解,让我们探讨一下它在下一部分是如何实际实现的。

注意力是如何计算的?

注意力有多种类型,主要区别在于用于执行线性组合的权重计算方式。这里,我们将考虑缩放点积注意力,如在原始论文中介绍的,这是最常见的方法。在这一部分中,假设我们所有的嵌入都已经进行位置编码。

记住,我们的目标是通过对原始嵌入的线性组合来创建上下文化嵌入,让我们从简单开始,假设我们可以将所有必要的信息编码到学习到的嵌入向量中,我们只需要计算权重。

为了计算权重,我们必须首先确定哪些 tokens 彼此相关。为此,我们需要建立两个嵌入之间相似性的概念。表示这种相似性的一种方法是使用点积,我们希望学习嵌入,使得较高的分数表示两个词更相似。

由于我们需要计算每个 token 与序列中其他每个 token 的相关性,我们可以将其概括为矩阵乘法,这为我们提供了权重矩阵,这些矩阵通常被称为注意力分数。为了确保我们的权重总和为 1,我们还应用了 SoftMax 函数。然而,由于矩阵乘法可能产生任意大的数字,这可能导致 SoftMax 函数对较大的注意力分数返回非常小的梯度,这可能在训练过程中导致 梯度消失问题。为了应对这一问题,在应用 SoftMax 之前,注意力分数会乘以一个缩放因子。

现在,为了得到我们的上下文化嵌入矩阵,我们可以将注意力分数与原始嵌入矩阵相乘;这相当于对我们的嵌入进行线性组合。

简化的注意力计算:假设嵌入是位置编码的

虽然模型可能能够学习到足够复杂的嵌入来生成注意力分数和后续的上下文化嵌入,但我们试图将大量信息压缩到通常非常小的嵌入维度中。

因此,为了让模型学习任务稍微简单一些,我们引入一些可学习的参数!我们不直接使用嵌入矩阵,而是通过三个独立的线性层(矩阵乘法)来处理它;这应该使模型能够“关注”嵌入的不同部分。如下图所示:

缩放的点积自注意力:假设嵌入是位置编码的

从图像中,我们可以看到线性投影被标记为 Q、K 和 V。在原始论文中,这些投影被称为 查询、键和值,显然受到信息检索的启发。就个人而言,我发现这个类比并没有帮助我理解,因此我通常不会关注这一点;我在这里遵循文献中的术语,以保持一致,并明确这些线性层是不同的。

现在我们了解了这个过程如何运作,我们可以把注意力计算视作一个有三个输入的单一块,这些输入将被传递到 Q、K 和 V。

当我们将相同的嵌入矩阵传递给 Q、K 和 V 时,这被称为 自注意力

什么是多头注意力?

在实践中,我们通常并行使用多个自注意力块,以使变换器能够同时关注输入序列的不同部分——这被称为 多头注意力

多头注意力的想法相当简单,多个独立的自注意力块的输出被连接在一起,然后通过一个线性层。这个线性层使模型能够学习如何结合来自每个注意力头的上下文信息。

在实践中,每个自注意力块中使用的隐藏维度大小通常选择为原始嵌入大小除以注意力头的数量;以保持嵌入矩阵的形状。

变换器还包含什么?

尽管引入变换器的论文(现在臭名昭著)被命名为Attention is all you need,但这有些令人困惑,因为变换器的组件不仅仅是注意力!

一个变换器块还包含以下内容:

  • 前馈神经网络(FFN):一个两层神经网络,独立应用于批次和序列中的每个令牌嵌入。FFN 块的目的是将额外的可学习参数引入变换器,这些参数是确保上下文嵌入是独特且分散的。原始论文使用了一个GeLU 激活函数,但 FFN 的组件可以根据架构的不同而有所变化。

  • 层归一化:有助于稳定深度神经网络的训练,包括变换器。它对每个序列的激活进行归一化,防止它们在训练过程中变得过大或过小,这可能导致梯度相关问题,如梯度消失或梯度爆炸。这种稳定性对于有效训练非常深的变换器模型至关重要。

  • 跳跃连接:如在ResNet 架构中,残差连接用于缓解梯度消失问题并提高训练稳定性。

尽管自引入以来,转换器架构保持了相当稳定,但层归一化块的位置可能会根据转换器架构而有所不同。原始架构,现在称为后层归一化,如下所示:

在最近的架构中,如下图所示,最常见的放置位置是预层归一化,它将归一化块放在自注意力和 FFN 块之前,位于跳跃连接中。

不同类型的 Transformer 有哪些?

虽然现在有许多不同的转换器架构,但大多数可以归纳为三种主要类型。

编码器架构

编码器模型旨在生成可以用于下游任务(如分类或命名实体识别)的上下文嵌入,因为注意力机制能够覆盖整个输入序列;这就是本文迄今为止探索的架构类型。最受欢迎的编码器-only 转换器家族是BERT及其变体。

在通过一个或多个转换器块处理数据后,我们得到了一个复杂的上下文化嵌入矩阵,表示序列中每个标记的嵌入。然而,为了用于下游任务,如分类,我们只需要做一个预测。传统上,取第一个标记,并通过分类头;分类头通常包含 Dropout 和线性层。这些层的输出可以通过 SoftMax 函数转换为类别概率。下面展示了这可能的样子。

解码器架构

几乎与编码器架构相同,关键区别在于解码器架构使用了掩蔽(或因果) 自注意力层,使得注意力机制只能关注输入序列的当前和先前元素;这意味着生成的上下文嵌入仅考虑先前的上下文。流行的解码器-only 模型包括GPT 家族

这通常是通过用二进制下三角矩阵掩蔽注意力分数,并用负无穷替换未掩蔽的元素来实现的;当通过以下 SoftMax 操作时,这将确保这些位置的注意力分数为零。我们可以更新之前的自注意力图以包括如下内容。

掩码自注意力计算:假设位置编码嵌入

由于它们只能从当前位置及之前的位置进行注意力计算,解码器架构通常用于自回归任务,如序列生成。然而,在使用上下文嵌入生成序列时,与使用编码器相比,有一些额外的考虑因素。下方展示了一个例子。

我们可以注意到,尽管解码器为输入序列中的每个 token 生成了一个上下文嵌入,但我们通常使用与最终 token 对应的嵌入作为生成序列时输入到后续层的内容。

此外,在对 logits 应用 SoftMax 函数后,如果没有进行过滤,我们将会得到模型词汇表中每个 token 的概率分布;这可能非常庞大!通常,我们希望使用各种过滤策略减少潜在选项的数量,一些常见的方法包括:

  • 温度调整: 温度是一个在 SoftMax 操作中应用的参数,影响生成文本的随机性。它通过改变输出单词的概率分布来决定模型输出的创造性或专注性。较高的温度会使分布变平,生成的输出更具多样性。

  • Top-P 采样: 这种方法根据给定的概率阈值筛选下一个 token 的潜在候选数量,并基于超过该阈值的候选重新分配概率分布。

  • Top-K 采样: 这种方法将潜在候选数量限制为基于其 logit 或概率评分(取决于实现)的 K 个最可能的 token。

这些方法的更多细节 可以在这里找到

一旦我们调整或减少了对下一个 token 潜在候选的概率分布,我们可以从中进行采样以获得预测结果——这只是从一个 多项分布 进行采样。预测的 token 然后附加到输入序列中,并反馈到模型中,直到生成所需数量的 tokens,或模型生成一个 停止 token;一个特殊的 token,用于标记序列的结束。

编码器-解码器架构

最初,变换器被提出作为一种机器翻译架构,使用了编码器和解码器来实现这一目标;先使用编码器创建中间表示,再使用解码器翻译成所需的输出格式。虽然编码器-解码器变换器已经变得不那么常见,诸如 T5 的架构展示了如何将任务如问答、摘要和分类框架化为序列到序列的问题,并利用这种方法进行解决。

编码器-解码器架构的关键区别在于,解码器使用编码器-解码器注意力,在其注意力计算过程中使用编码器的输出(作为 K 和 V)和解码器块的输入(作为 Q)。这与自注意力形成对比,在自注意力中,相同的输入嵌入矩阵用于所有输入。除此之外,总体生成过程与仅使用解码器架构非常相似。

我们可以通过下图来可视化编码器-解码器架构。在这里,为了简化图示,我选择了描绘原始论文中所见的后层归一化变种,其中层归一化层位于注意力块之后。

结论

希望这提供了对变换器工作原理的直观了解,帮助将一些细节以某种易于消化的方式分解,并且作为解开现代变换器架构神秘面纱的良好起点!

Chris Hughes 在 LinkedIn 上

除非另有说明,所有图像均由作者创建。

参考文献

处理转化指标?考虑使用 Beta-二项式模型

原文:towardsdatascience.com/dealing-with-conversion-metrics-consider-beta-binomial-model-29733906ff38

图片由 Karim MANJRA 提供,来源于 Unsplash

学习一种特征工程技术,使基于转化的指标如 CTR/CVR 更具代表性和稳定性

Pararawendy IndarjoTowards Data Science Pararawendy Indarjo

·发布于 Towards Data Science ·10 分钟阅读·2023 年 7 月 26 日

--

行业内有大量的转化指标。而且我们经常希望将它们用作我们机器学习模型中的一个特征。例如,产品在搜索页面上展示的印象到点击的产品详情点击率(CTR)可能是一个相关特征,用于建模产品是否会在电子商务平台上被购买。

在这篇博客中,我们将学习一种针对这些转化指标的特征工程技术。为了实现这一目标,博客的其余部分将按以下结构进行。

  1. 解释为什么我们需要谨慎处理转化特征(即,我们不应使用这些特征的原始形式)。

  2. 解决方案:使用 Beta-二项式模型将原始转化值转换为更稳定/更具代表性的版本

  3. Beta-二项式模型的理论基础

  4. 调整模型 Beta-prior 分布参数的指南

  5. 用于进行 Beta-二项式转换的 Python 代码(提示:非常简单!)

让我们开始吧!

使用原始转化值的缺点

假设我们正在构建一个分类模型,以预测产品是否会在电子商务平台上被购买。作为数据预处理的一部分,我们提取与每个产品相关的两列数据:获得的印象数和点击数。因为我们是具有强大领域知识的优秀数据科学家,所以我们衍生出一个名为“印象到点击转化”的新特征。

这一特征工程的原理是我们相信更高的印象到点击转换率表示更好的产品质量。逻辑是,如果一个产品在展示次数(印象)中获得了更高的点击百分比,这表明用户觉得这个产品有吸引力,从而增加了购买的可能性。

现在考虑以下三个产品场景。

原始转换值(图片来源于作者)

在表格中,产品 A 和产品 B 的印象到点击转换率都是 70%。这是否意味着产品 A 和 B 的质量相同?注意产品 A 在经过大量展示(1000 次印象)后达到了 70% 的转换率,表明测试和稳定性较高。另一方面,产品 B 从更少的展示次数(仅 10 次印象)中获得了相同的转换率,使其不那么可靠,更容易受到波动的影响。

鉴于这种测试和稳定性的差异,我认为将较高的转换评分分配给产品 A 比产品 B 更合适。

当我们考虑产品 C 时,使用原始转换值的问题变得更加明显。产品 C 在仅被展示 1 次后就达到了 100% 的印象到点击转换率。使用原始转换值暗示产品 C 比其他产品显著更好——但这不一定是真的。因为产品 C 可能只是“初学者运气”而已。产品 C 的真实/稳定转换率很可能低于 100%,随着用户的印象增加而变化。

Beta-Binomial 模型来救援!

有没有比使用原始转换值更好的方法?

确实有更好的方法!我们可以使用 Beta-Binomial 模型来获得更稳定的转换值,这可能更好地代表特征。我们可以通过以下转换,使用 Beta-Binomial 模型计算产品的点击倾向(我们用来指代印象到点击转换的更好/更稳定版本)。

Beta-binomial 转换公式(图片来源于作者)

α 和 β 是一些指定的值。

例如,使用 α = β = 10,我们可以得到之前考虑过的产品 A、B 和 C 的以下结果。

Beta-Binomial 转换(图片来源于作者)

比使用原始转换值更好,对吧?现在产品 A 在三者中得分最高,这似乎更公平,因为它已经经过了最多的用户测试(大量印象)。此外,产品 C 现在得分最低,因为我们对它看似完美的转换结果不确定(即需要更多的测试来证明其价值)。

Beta-Binomial 模型的理论基础

尽管其简单,但上述变换有着坚实的理论基础,这将在本节中讨论。

实用的读者可以直接跳过这个更技术性的部分,继续阅读“如何选择 α 和 β”部分。

二项分布

第一个相关概念是二项分布。回忆一下,它是一种概率分布,用于建模从一组独立的二项试验(n)中获得的成功次数(y),每次试验的成功概率为 pi(π)。

在我们的案例中,我们可以使用二项分布来建模产品获得的点击次数(y),这些点击是产品展示给用户的次数(n),假设产品的点击倾向是 pi(π)。

二项分布的概率质量函数如下所示。

二项分布 PMF(作者图像)

使用上述公式,我们可以计算(给定 n 和 π)成功次数等于 y 的概率。结果,我们可以确定哪个 y 值的发生概率高于其他值。

然而,在我们的案例中,我们感兴趣于学习 y 的最佳值(即,获得最高概率的点击次数)。这是因为我们有点击和展示数据作为输入(也就是说,我们可以查询产品 A 从我们的跟踪数据库中获得了多少展示次数(n)和点击次数(y))。

相反,我们想要推断产品的点击倾向。也就是说,给定 n 和 y,π 应该是什么值?

回答这个问题需要将概率质量函数翻转过来,如下所示。

二项分布的似然函数(作者图像)

上述函数被称为似然函数。值得注意的是,在这个函数中,π 现在被视为关注的变量。而 n 和 y 被认为是常数(它们的值是预先给定的)。

Beta 分布

Beta 分布是一种概率分布,用于建模一个取值范围在 0 和 1 之间的随机变量。因此,我们可以用它来建模我们之前感兴趣的点击倾向 π。

Beta 分布的概率密度函数如下所示。

Beta 分布 PDF(作者图像)

其中Γ是伽玛函数,α 和 β 是分布参数。更多细节,请参见 Beta 分布的维基页面

随机变量 Beta(α, β) 的期望值(均值)是。

Beta 分布均值(作者图像)

这个量对于我们的建模工作至关重要,因为我们将用它来定义我们所寻找的最佳 π 值。

贝叶斯统计

贝叶斯统计是一种统计思维方式,我们不仅检查观察到的数据,还考虑我们的先验知识,以便从数据中得出结论。我们通过在看到数据之前权衡观察到的数据(证据)与我们的初始假设来做到这一点。

在贝叶斯统计中,我们从一个初始信念开始,这个信念由一个先验概率分布表示,并将其与观察到的数据的可能性结合,以获得一个后验概率分布。这个后验分布反映了我们对研究现象的更新理解,结合了可用的数据和我们最初的假设。

后验公式(图片作者提供)

在我们的背景下,我们对找到最佳值以代表给定产品的点击倾向 π 感兴趣。为此,我们可以在未看到任何数据的情况下,使用 Beta 分布作为我们点击倾向 π 的先验分布。然后将该先验与给定观察数据(印象数 n 和点击数 y)的二项分布的可能性结合。

Beta-Binomial 后验推导(图片作者提供)

通过进行一些代数运算(这里略过,感兴趣的读者可以查看完整推导 这里),结果表明 π 的后验分布将等于 Beta(α+y, β+n-y)。请记住,n 和 y 分别是印象数和点击数。

因此,这个 π 的后验分布的均值如下。

转换公式推导(图片作者提供)

这正是我们之前示例中使用的转换公式。

如何选择 α 和 β?

让我们总结一下我们所做的工作。我们使用 Beta(α=10, β=10) 作为我们点击倾向 π 的先验分布。这导致了 Beta(α+点击数, β+印象数-点击数) 的后验分布。该后验分布的均值为 (点击数+α/印象数+α+β),我们用来转换原始的转化值。

这些 α 和 β 的值来自哪里?

为了明确,后验分布(因此 Beta 后验均值公式)仅仅是所选先验分布的一个结果。因此,选择 α 和 β 实际上是选择先验分布的问题。具体来说,我们希望在未观察任何数据的情况下(即尚无印象的产品)得到什么样的点击倾向 π 分布?

这里的关键思想是理解 α 和 β 对 Beta 分布 PDF(概率密度函数)形状的影响。为了对这个主题有一些直观认识,下面展示了几个具有不同 α 和 β 值的 Beta PDFs。

α 和 β 对 Beta 分布形状的影响(图片作者提供)

从图表中可以看到,使用 Beta(α=10, β=10)作为我们的先验(橙色曲线)意味着在没有数据的情况下,我们倾向于认为点击倾向π集中在 0.5 左右,且对称变化范围在 0.2 和 0.8 之间。

如果我们认为默认的点击倾向可能在 0.7 左右,我们可以使用 Beta(α=7, β=3)作为我们的先验(棕色曲线)。另一个例子是:如果我们对默认点击倾向没有偏好(即任何比例值都是一样可能的),我们可以使用 Beta(α=1, β=1)作为我们的先验(蓝色线),因为它类似于均匀分布。

现在,敏锐的读者可能会说:“我知道 Beta(α=10, β=10)和 Beta(α=2, β=2)都导致一个以 0.5 为中心的对称分布。那么,我们为什么选择前者而不是后者呢?”

回答这个问题需要理解α和β对 Beta 分布方差的影响。下面的图片清晰地展示了这个问题。

α和β对变异性的影响(图片来源:作者)

从图表中可以看到,更高的α和β值导致分布的变异性降低。Beta(α=10, β=10)的 PDF 比 Beta(α=2, β=2)更陡峭。而 Beta(α=50, β=50)的变异性低于 Beta(α=10, β=10),依此类推。

实际上,如果我们使用更大的α和β值,这意味着我们在先验上施加更大的权重以影响后验(回顾:后验是从先验和观察数据之间的平衡作用中得出的)。

为了更好地理解,让我们看看我们的产品 A、B 和 C 如何响应不同的α和β值(假设 Beta 先验均值/默认转换在 0.5 处相同)。

α和β大小的不同效果(图片来源:作者)

让我们关注产品 B。当α=2,β=2 时,Beta 后验均值为 64%(比先验均值偏移了近 15%)。当我们增加α=10,β=10 时,偏移缩小到不到 7%。而当我们使用α和β都大于 100 时,Beta 后验均值实际上没有从默认转换的 50%移动。

粗略地说,使用更大的α和β意味着我们对模型设置了更高的“固执程度”。也就是说,我们要求更多的数据(证据)才能使后验从我们的默认信念中有意义地移动。

Python 代码

最后,你可以在下面找到导出 beta-binomial 转换的代码。

总结

呼。那是一篇冗长的阅读。所以,恭喜你读到这里!🎉

在这篇博客中,我们学习了一种简单的特征工程技术,可以用来提高转换度量的代表性和稳定性。尽管它很简单,但该技术基于贝叶斯统计中的 Beta-Binomial 模型,具有优雅的基础。

在文章后面,我们学习了如何调整α和β以选择适当的先验分布。简而言之,更高的α和β实际上意味着我们需要更多的数据来使结果转化分数远离我们初始/默认的转化分数。

希望这篇文章对你遇到类似情况时有所帮助!总之,感谢阅读,欢迎在LinkedIn上与我联系!👋

参考文献

约翰逊, 艾丽西亚·A., 奥特, 迈尔斯·Q., 多古楚, 米娜. (2021). 贝叶斯规则!应用贝叶斯建模简介. CRC Press.

处理 Python 数据框中的日期,第一部分 — 日期系列创建

原文:towardsdatascience.com/dealing-with-dates-in-pythons-dataframe-part-1-date-series-creation-f4a800db9ae

Python 数据处理

Pandas 日期系列创建方法

KahEm ChuTowards Data Science KahEm Chu

·发布于 Towards Data Science ·阅读时间 10 分钟·2023 年 1 月 4 日

--

Jon Tyson 摄影,来自 Unsplash

大多数情况下,DateTime 对象是从数据中获取洞察的重要元素。我们可以通过日期理解数据中的趋势、周期和季节模式。基于这些模式,我们可以准备报告,并进一步研究和分析数据。

DateTime 对象在分析中的重要性激励我进一步研究在 pandas 模块中可以用 DateTime 对象做什么。然后,我记录下了我经常使用的方法和属性,以及我可能需要使用的一些方法。此外,我根据自己的理解将其分成了两个部分,具体如下:

图片来源于作者。

为了更好的阅读体验,我决定将这一组内容拆分为 2 篇文章。这是第一篇,你可以在这里找到 第二篇文章。

让我们从第一部分开始,处理 DateTime 系列的基础知识

第一部分 — 处理 DateTime 系列的基础知识

DateTime 系列创建

  • pandas.date_range

  • pandas.bdate_range

  • pandas.period_range

  • pandas.timedelta_range

DateTime 系列创建

当你想创建一个示例数据集以测试你正在编写的几个新功能时,创建 DateTime 系列是很实用的。以下是 pandas 模块中四种 DateTime 系列创建方法。

上述频率指的是生成日期之间的间隔,可能是每小时、每日、每月、每季度、每年等。你可以了解更多关于频率字符串别名的内容 [1]。

让我们一个一个看吧!

1. pandas.date_range

pandas.date_range()方法根据以下四个参数中的三种组合返回 DateTime 序列:

  1. start — 生成的日期范围的开始日期

  2. end — 生成的日期范围的结束日期

  3. periods — 生成的日期数量

  4. freq — 默认为“D”,日期之间的间隔,可能是每小时、每月或每年

注意: freq = “D” 表示每日频率。

要生成 DateTime 序列,以上 4 个参数中的至少三个必须指定。由于freq默认为“D”,如果你使用freq=D,只需指定其他两个参数。如果省略freq,即只指定startendperiod参数,则生成的日期将具有从开始日期到结束日期的线性间隔元素。在该方法中还有其他参数,但本文将重点关注这 4 个主要参数。

对于第一个示例,通过指定开始日期和周期生成日期。如上所述,默认情况下频率设置为每日。因此,将生成 10 个日期,频率为每日。

import pandas as pd
df = pd.DataFrame()
df["date_range"] = pd.date_range(start="2022/1/1", periods=10)
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片由作者提供。

对于第二个示例,指定了开始日期、周期和频率。以下示例创建了一个从 2020/1/1 开始的日期序列,共 10 个日期,每个日期之间间隔 3 个月。

import pandas as pd
df = pd.DataFrame()
df["date_range"] = pd.date_range(start="2022/1/1", periods=10, freq="3M")
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片由作者提供。

为什么日期从月末开始?🤨

其实这是因为“M”频率指的是月末频率,而“MS”指的是月初频率 [1]

对于第三个示例,提供了开始日期和结束日期,以及频率。如前所述,当你省略频率时,生成的日期将是线性间隔的。如果省略了周期,生成的日期将是开始日期和结束日期之间按指定频率间隔的日期。

import pandas as pd
df = pd.DataFrame()
df["date_range"] = pd.date_range(start="2022/1/1", end="2022-12-31", freq="3M")
print(df.head(10))
print("Data Type: ", df.dtypes)

图片来自作者。

图片来自作者。创建于 Excalidraw。

由于下一个周期将是 2023 年 1 月 31 日,因此在第三个示例中只生成了 4 个日期 😉。

这里是一个简单指南:

当你确定要生成的日期数量时,使用 period 参数。

当你不确定确切的日期数量但知道结束时间或不应超过时,使用 end 参数。

2. pandas.bdate_range

pandas.date_range() 方法类似,pandas.bdate_range() 也有 4 个主要参数,即 startendperiodsfreq,但 pandas.bdate_range()freq 默认为 “B”。“B” 指的是工作日频率,即跳过周末如星期六和星期天。

让我们看看第一个示例!在以下示例中,指定了开始日期和周期,并且如前所述,频率默认为 “B”。

import pandas as pd
df = pd.DataFrame()
# frequency is default to B, the weekend will be skipped
df["bdate_range"] = pd.bdate_range(start="2022/1/1", periods=10)
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片来自作者。

被跳过的两个日期,“2022–01–08”和“2022–01–09”分别是星期六和星期天。

你可能会注意到,pandas.date_range() 方法在设置 freq= “B” 时也可以只返回工作日,那么我们为什么还需要使用 pandas.bdate_range() 呢?🤷‍♀️

这是因为 pandas.bdate_range() 默认返回工作日,并且 pandas.bdate_range()weekmaskholidays 参数。

注意: 要使用 holidaysweekmask 参数,必须使用自定义工作日频率,其中 freq= “C”[2]

现在,让我们深入了解 holidays 参数。Holidays 指的是要从有效工作日集合中排除的日期列表。

对于第二个示例,指定了开始日期、周期、频率和假期参数。

import pandas as pd
df = pd.DataFrame()
# frequency is set to C, the weekend and holidays will be skipped
# only can set holiday when freq is set to "C"
holidays = [pd.datetime(2022,1,7)]
df["bdate_range"] = pd.bdate_range(start="2022/1/1", periods=10, freq="C", holidays=holidays)
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片来自作者。

指定的假期日期不在生成的日期列表中,由于 “C” 指的是自定义工作日频率,因此创建的日期范围中周末仍会被跳过。

注意: Holidays 参数仅接受 datetime 对象的列表。

现在,让我们看看 weekmask 参数。Weekmask 指的是对于不遵循传统工作日(如周一至周五)的企业有效的工作日。此外,weekmask 的默认值相当于 ‘Mon Tue Wed Thu Fri’。

对于第三个示例,我们指定了开始日期和自定义的工作日,weekmask = “Tue Wed Thu Fri Sat Sun”

import pandas as pd
df = pd.DataFrame()
df["bdate_range"] = pd.bdate_range(start="2022/1/1", periods=10, freq="C", weekmask="Tue Wed Thu Fri Sat Sun")
print(df.head(10))
print("Data Type: ", df.dtypes)

图片来源:作者。

星期一的日期(2022–01–10)将不会包含在生成的日期中。这个参数在业务不按正常工作日运行时非常有用。

结合这两个参数,你可以根据业务操作日生成 DateTime 系列,如下面的示例所示。

import pandas as pd
df = pd.DataFrame()
df["bdate_range"] = pd.bdate_range(start="2022/1/1", periods=10, freq="C", weekmask="Tue Wed Thu Fri Sat Sun", holidays=[pd.datetime(2022,1,7)])
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片来源:作者。

从输出中可以看出,星期一的日期(2022–01–10)和节假日的日期(2022–01–07)没有包含在生成的列表中。

3. pandas.period_range

pandas.period_range() 方法与之前的两个方法,即 pandas.date_range()pandas.bdate_range(),之间存在一些相似之处和不同之处。

类似于之前的两种方法,pandas.period_range() 可以通过指定四个主要参数中的三个,即 startendperiodsfreq,来生成日期系列。同时,频率仍默认为每日。

一个需要注意的不同点是,pandas.period_range() 生成的是周期对象,而不是 DateTime 对象。

对于第一个示例,我们生成了一系列按日频率的 5 个周期,默认从 2022–01–01 开始。

import pandas as pd
df = pd.DataFrame()
df["period_range"] = pd.period_range(start="2022/1/1", periods=5)
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片来源:作者。

对于第二个示例,我们生成了一系列的 5 个周期,频率为每月一次,从 2022–01–01 开始。

import pandas as pd
df = pd.DataFrame()
df["period_range"] = pd.period_range(start="2022/1/1", periods=5, freq="M")
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片来源:作者。

对于第三个示例,我们生成了一系列按年频率的 5 个周期,从 2022–01–01 开始。

import pandas as pd
df = pd.DataFrame()
df["period_range"] = pd.period_range(start="2022/1/1", periods=5, freq="Y")
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片来源:作者。

对于最后一个示例,我们生成了一系列按年为频率的周期,从 2022–01–01 到 2027–01–01。

import pandas as pd
df = pd.DataFrame()
df["period_range"] = pd.period_range(start="2022/1/1", end="2027/1/1", freq="Y")
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片来源:作者。

period_range 方法的工作方式与 pandas.date_range() 相同,只是它返回的是周期而不是日期。因此,如果省略 periods 参数,则创建的周期将是指定频率间隔的开始和结束日期之间的周期。

4. pandas.timedelta_range

类似于上述三种方法,pandas.timedelta_range() 方法根据四个主要参数中的三个参数组合返回日期系列,即 start、end、periods 和 frequency。频率仍默认为每日。与之前的三个示例方法不同的一点可以通过下面的例子进行解释。

以下示例来自我在运行脚本时犯的一个错误,以及随后发生的错误。

import pandas as pd
df = pd.DataFrame()
df["timedelta_range"] = pd.timedelta_range(start="2022/1/1", periods=5, freq="Y")
print(df.head(10))
print("Data Type: ", df.dtypes)

上面的脚本返回了如下的键错误和数值错误。

键错误。图片来源:作者。

值错误。图片由作者提供。

从错误脚本中,我们可以看到错误来源于我们为“start”参数提供的值。由于我们正在生成时间增量对象,因此为“start”参数提供的值也应该是 timedelta 格式。

因此,正确的示例应如下所示,其中起始时间以 timedelta 格式指定,周期数被指定,并使用默认的每日频率。

import pandas as pd
df = pd.DataFrame()
df["timedelta_range"] = pd.timedelta_range(start="1 days", periods=5)
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片由作者提供。

对于第二个示例,指定了起始时间增量、周期和频率。

import pandas as pd
df = pd.DataFrame()
df["timedelta_range"] = pd.timedelta_range(start="1 day", periods=5, freq="6H")
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片由作者提供。

对于第三个示例,指定了起始时间增量、结束时间增量和频率。

import pandas as pd
df = pd.DataFrame()
df["timedelta_range"] = pd.timedelta_range(start="1 day", end="5days", freq="8H")
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片由作者提供。

对于第四个示例,指定了起始时间增量、结束时间增量和周期。当没有设置频率时,生成的时间增量系列将会是线性分布的。

import pandas as pd
df = pd.DataFrame()
df["timedelta_range"] = pd.timedelta_range(start="1 day", end="5days", periods=3)
print(df.head(10))
print("Data Type: ", df.dtypes)

输出:

图片由作者提供。

注意:对于pandas.timedelta_range()方法,“start”参数仅接受时间增量对象,而对于其他三种方法,“start”参数则接受 DateTime 对象作为输入。

5. 使用时间戳创建 DateTime

在 pandas 模块中,我们还可以使用时间戳方法创建 datetime 对象。

创建 DateTime 对象有两种方法,第一种是使用如下的 datetime 参数。

# https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Timestamp.html
import pandas as pd
timestampsample = pd.Timestamp(year=2022,month=12,day=13,hour=21,minute=48, second=23, microsecond=35, nanosecond=58)
timestampsample

输出:

图片由作者提供。

第二种方法是从 DateTime 字符串创建时间戳。

import pandas as pd
str_timestamp = '2022-12-13 21:48:23.000035058'
timestampsample2 = pd.Timestamp(str_timestamp)
timestampsample2

图片由作者提供。

好的,上述内容演示了如何使用时间戳方法来创建一个 DateTime 对象。

结论

总之,我们已经看到与 DateTime 系列创建相关的 4 种方法,包括标准日期创建、工作日日期创建、周期创建和时间增量创建。此外,还演示了使用时间戳的日期创建方法。

关于 Python 中的 DateTime 系列创建就是这些了。希望你喜欢阅读这篇文章,并希望它能帮助你更好地理解 DataFrame 中的 DateTime 系列创建。谢谢!😊

保持联系

订阅 YouTube

附注

本文第二部分,处理 Python DataFrame 中的日期(第二部分)——基础知识。

我在处理 Python 中的日期中解释了你可以对 DateTime 变量进行的可能操作。

在使用 Python 进行报告自动化技巧中,我解释了一些关于报告自动化的技巧。查看一下吧!

参考

[1] 时间序列/数据功能 — 偏移别名。pandas:有用的常见时间序列频率的字符串别名

[2] pandas-指定自定义假期:在pandas.bdate_range()方法中指定自定义假期

感谢你阅读到最后 😊!

照片由 JOSHUA COLEMAN 拍摄,来源于 Unsplash

处理 Python DataFrame 中的日期 第二部分——基础知识

原文:towardsdatascience.com/dealing-with-dates-in-pythons-dataframe-part-2-the-basics-9ad5edacd2f8

数据处理在 Python 中

本文解释了处理数据框中 DateTime 系列的基本 pandas 方法和属性。

KahEm ChuTowards Data Science KahEm Chu

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

--

图片由 Lukas Blazek 提供,来自 Unsplash

正如标题所述,这篇文章是我处理 Python DataFrame 中日期系列的第二部分。以下展示了处理 Python DataFrame 中日期系列的每一部分内容。

图片来自作者。

在 我之前的文章 中,我展示了 DateTime 系列的创建方法。接下来,在这篇文章中,我将展示处理数据框中 DateTime 系列的基本属性和方法。

有了这些,这篇文章将按如下结构安排:

  1. 将数据类型转换为 DateTime

  2. 通用 DateTime 信息提取

  3. 检查日期是否为周期的开始或结束

  4. 检查日期是否属于闰年

  5. 检查月份中的天数

让我们开始吧!

将数据类型转换为 DateTime

在我之前的文章中展示的日期创建方法中,系列被创建为 DateTime 对象。当你从 Excel 或其他数据源读取数据时,如果没有将其解析为日期,DateTime 列将作为字符串对象读取。要从 DateTime 系列中提取 DateTime 信息,列需要先转换为 DateTime 数据类型。

有两种方法可以将数据类型转换为 DateTime。

  • pandas.Series.astype(“DateTime”)

  • pandas.datetime(pandas.SeriesSeries)

我用下面的脚本创建了一个 demo.csv 文件,以演示本节的方法和属性。

import pandas as pd
import datetime as dt
df = pd.DataFrame()
initial_date = pd.Timestamp("2023-01-04 21:55:00")
df["timedelta_range"] = pd.timedelta_range(start="1 day", end="5days", freq="4H")
df.insert(loc=0, column="initial_date",value=initial_date)
df.insert(loc=2, column="next_date",value=df["initial_date"] + df["timedelta_range"])
print(df.head(10))
print("Data Type: ", df.dtypes)
df.to_csv("demo.csv")

输出的数据框如下截图所示。

图像来源于作者。

现在,我们将读取生成的文件。

import pandas as pd
df = pd.read_csv(r"demo.csv", usecols=["next_date"])
print(df.info())
df.head()

为演示目的,仅读取 next_date 列。

图像来源于作者。

如你所见,当直接导入列而未将其解析为 DateTime 时,该列将是一个字符串列,其中 Dtype 为对象。以下是两种将列转换为 DateTime 数据类型的常用方法。

  • pandas.to_datetime(pandas.Series)
df["next_date"] = pd.to_datetime(df["next_date"])
df.info()
  • pandas.Series.astype(“datetime64”)
df["next_date"] = df["next_date"].astype("datetime64")
df.info()

上述两个脚本的输出结果:

图像来源于作者。

另外,你可以在导入数据时使用 parse_dates 参数将列解析为 DateTime 对象。

import pandas as pd
df = pd.read_csv(r"demo.csv", usecols=["next_date"], parse_dates=["next_date"])
print(df.info())
df.head()

图像来源于作者。

一般的日期时间信息提取

从日期时间系列中可以获得大量信息。

  • 时间戳

  • 年中的天数

  • 季度

  • ISO 日历

提取时间戳信息

以下是返回时间戳信息的属性和方法列表。

import datetime as dt

在使用 series.dt 下的方法或属性之前,需要导入 datetime 模块。以下是提取时间戳信息的示例。

df.insert(loc=1, column="Date_",value=df["next_date"].dt.date)
df.insert(loc=2, column="Time",value=df["next_date"].dt.time)
df.insert(loc=3, column="year",value=df["next_date"].dt.year)
df.insert(loc=4, column="month",value=df["next_date"].dt.month)
# note that the month_name is a method instead of properties
df.insert(loc=5, column="month_name",value=df["next_date"].dt.month_name())
df.insert(loc=6, column="day",value=df["next_date"].dt.day)
df.insert(loc=7, column="hour",value=df["next_date"].dt.hour)
df.insert(loc=8, column="minute",value=df["next_date"].dt.minute)
df.head()

输出:

图片来自作者。

需要注意的一点是创建的列不是 DateTime 对象,即使是“Date_”列也是如此。

图片来自作者。

你可能会注意到,第二、微秒和纳秒的示例没有展示。这是因为它们不适用于数据集。此外,应用的方式是相同的。列类型需要在使用属性或方法返回相应的值之前转换为 datetime。

提取周信息/年中的天数/季度/ISO 日历

以下是返回周数、年中的天数、季度和基于 ISO 日历的信息的属性和方法列表,用于 DateTime 系列。

周信息

一年中的天数

季度

ISO 日历

为了展示我们可以用上述方法/属性做的有趣的事情,我创建了一个日期列表,其中包含分布在全年中的随机日期,如下所示。

import pandas as pd
import datetime as dt
date_list = ["2022-10-03", "2022-11-17", "2022-12-14", "2023-01-23", "2023-02-14", "2023-03-23", "2023-04-11", "2023-05-28", "2023-06-24", "2023-07-04", "2023-08-06", "2023-09-08"]
df = pd.DataFrame(date_list, columns=["Date"])
df["Date"] = pd.to_datetime(df["Date"])
df.head(12)

由于我们不是从文件中读取,因此没有 parse_dates 函数可用。因此,必须手动将列转换为 datetime。

图片由作者提供。

以下是提取周、年中的天数、季度和 ISO 日历的示例。为了更好地理解,列名基于属性或方法名称。

df.insert(loc=1, column="Day of Week",value=df["Date"].dt.day_of_week)
df.insert(loc=2, column="Weekday",value=df["Date"].dt.weekday)
# note that the month_name is a method instead of properties
df.insert(loc=3, column="Day Name",value=df["Date"].dt.day_name())
# day of the year
df.insert(loc=4, column="Day of Year",value=df["Date"].dt.day_of_year)
# quarter
df.insert(loc=5, column="Quarter",value=df["Date"].dt.quarter)
# iso calendar
df.insert(loc=6, column="ISO Year",value=df["Date"].dt.isocalendar().year)
df.insert(loc=7, column="ISO Week",value=df["Date"].dt.isocalendar().week)
df.insert(loc=8, column="ISO Day",value=df["Date"].dt.isocalendar().day)
df[["Date", "Day of Week", "Weekday", "Day Name", "Day of Year", "Quarter", "ISO Year", "ISO Week", "ISO Day"]].head(12)

输出:

图片由作者提供。

以下是上述表格的总结:

  1. 对于day_of_weekweekday属性,它们返回从 0 开始计数的星期几。

  2. 对于day_of_yearquarter属性和isocalendar()方法,它们的返回值以从 1 开始的索引计数。

isocalendar()方法中,计算星期几的索引从 1 开始,而weekday从 0 开始。它们都以星期一为星期的起点。换句话说,第一个索引指的是星期一。

检查日期是否为周期的开始或结束

对于这一部分,将创建一个不同的日期列表,以更好地展示下面的属性。

示例:

date_list = ["2023-01-01", "2023-01-23", "2023-01-31", "2023-02-01", "2023-02-28", "2023-04-01", "2023-06-30", "2023-09-30", "2023-11-30", "2023-12-31"]
df = pd.DataFrame(date_list, columns=["Date"])
df["Date"] = pd.to_datetime(df["Date"])
df.insert(loc=1, column="Month Start",value=df["Date"].dt.is_month_start)
df.insert(loc=2, column="Month End",value=df["Date"].dt.is_month_end)
df.insert(loc=3, column="Quarter Start",value=df["Date"].dt.is_quarter_start)
df.insert(loc=4, column="Quarter End",value=df["Date"].dt.is_quarter_end)
df.insert(loc=5, column="Year Start",value=df["Date"].dt.is_year_start)
df.insert(loc=6, column="Year End",value=df["Date"].dt.is_year_end)
df.head(12)

输出:

图片由作者提供。

思考: 我认为这些属性对于需要每月、每季度或每年准备新报告的人来说是最有用的。

这些属性将帮助他们基于创建的自动化逻辑来刷新报告。除此之外,上述属性在需要定期重新开始的计算中也可能很有用。

检查日期是否属于闰年

闰年是指一年有 366 天(而不是 365 天),包括 2 月 29 日作为闰日。闰年是四的倍数的年份,但除去能被 100 整除但不能被 400 整除的年份。

我们可以通过创建的周期范围的日期来演示这个函数。

df = pd.DataFrame()
df["Year"] = pd.period_range(start="2022/1/1", periods=10, freq="Y")
df.insert(loc=1, column="Leap Year",value=df["Year"].dt.is_leap_year)
print(df.head(10))

输出:

图片由作者提供。

检查一个月中的天数

以下这两个属性都可以返回一个月中的天数。

df = pd.DataFrame()
df["Month"] = pd.period_range(start="2022/1/1", periods=12, freq="M")
df.insert(loc=1, column="Days in Month",value=df["Month"].dt.days_in_month)
df.head(12)

输出:

图片由作者提供。

结论

总结来说,解释了一些处理 DateTime 系列的基本属性和方法。展示了将包含日期时间对象的列的数据类型转换为日期时间的方法。然后,演示了提取或返回日期时间信息的基本属性和方法。日期时间信息如星期几在不同的方法中有不同的索引。

此外,还展示了一些检查日期属性的方法,例如日期是否为一个时期的开始或结束,或者日期是否属于闰年。最后,还介绍了检查一个月中日期的数量的方法。这些方法和属性可能对报告用途很有帮助。

这就是处理 Python 中日期的基础内容。希望你喜欢这篇文章,并且希望它能帮助你更好地理解如何处理 DataFrame 中的 DateTime 系列。谢谢! 😊

保持联系

订阅 YouTube

旁注

本文的第一部分,在 Python 的 DataFrame 中处理日期的第一部分 — 日期系列创建。

我在在 Python 中处理日期中解释了你可以对 DateTime 变量执行的操作。

在 使用 Python 进行报告自动化技巧 中,我解释了一些报告自动化的技巧。快去看看吧!

感谢你阅读到最后 😊!

祝你 2023 年快乐!

Adnan MistryUnsplash 上的照片

使用 Python 处理 MRI 和深度学习

原文:towardsdatascience.com/dealing-with-mri-and-deep-learning-with-python-c88f3dae0620?source=collection_archive---------3-----------------------#2023-12-20

使用 PyTorch 的深度学习模型进行 MRI 分析的综合指南

Carla Pitarch AbaigarTowards Data Science Carla Pitarch Abaigar

·

关注 发表在 Towards Data Science ·13 分钟阅读·2023 年 12 月 20 日

--

图片来源:Olga RaiAdobe Stock

介绍

首先,我想介绍一下自己。我叫 Carla Pitarch,是一名人工智能(AI)博士候选人。我的研究重点是通过使用深度学习(DL)模型,特别是卷积神经网络(CNNs),从磁共振图像(MRI)中提取信息,以开发自动化的脑肿瘤分级分类系统。

在我的博士研究之初,深入研究 MRI 数据和 DL 是一个全新的领域。在这个领域中运行模型的初步步骤并不像预期的那样简单。尽管花了一些时间在这个领域进行研究,我发现缺乏全面的资源来指导 MRI 和 DL 的入门。因此,我决定分享一些我在这一期间获得的知识,希望它能让你的旅程更加顺利。

通过 DL 进行计算机视觉(CV)任务通常涉及使用标准公共图像数据集,如[ImageNe](https://image-net.org/about.php)t,这些数据集以 3 通道 RGB 自然图像为特征。PyTorch 模型为这些规格做好了准备,期望输入图像为这种格式。然而,当我们的图像数据来自不同的领域,如医疗领域,与这些自然图像数据集在格式和特征上都有所不同时,就会带来挑战。本文深入探讨了这个问题,强调了在模型实施之前的两个关键准备步骤:使数据与模型的要求对齐,并准备模型以有效处理我们的数据。

背景

让我们首先简要概述一下 CNNs 和 MRI 的基本方面。

卷积神经网络

在本节中,我们将深入探讨 CNNs 领域,假设读者对深度学习(DL)有基本的理解。CNNs 作为计算机视觉(CV)中的黄金标准架构,专注于处理 2D 和 3D 输入图像数据。我们在这篇文章中的重点将集中在 2D 图像数据的处理上。

图像分类,将输出类别或标签与输入图像关联,是卷积神经网络(CNNs)的核心任务。由 LeCun 等人于 1989 年提出的开创性 LeNet5 架构为 CNNs 奠定了基础。该架构可以总结如下:

CNN 架构包含两个卷积层、两个池化层,以及一个位于输出层之前的全连接层。

2D CNN 架构通过接收图像像素作为输入来操作,期望图像是一个形状为Height x Width x Channels的张量。彩色图像通常包含 3 个通道:红色、绿色和蓝色(RGB),而灰度图像则包含一个通道。

CNNs 中的一个基本操作是卷积,通过在输入数据的所有区域应用一组滤波器内核来执行。下图展示了卷积在 2D 上下文中的工作原理。

一个对 5x5 图像进行 3x3 滤波器卷积的示例,生成一个 3x3 卷积特征。

这个过程涉及将滤波器滑过图像并计算加权和,以获得一个卷积特征图。输出将表示输入图像的该位置是否识别到特定的视觉模式,例如边缘。每个卷积层后,激活函数会引入非线性。常见的选择包括:ReLU(修正线性单元)、Leaky ReLU、Sigmoid、Tanh 和 Softmax。有关每个激活函数的更多详细信息,本文提供了清晰的解释 Activation Functions in Neural Networks | by SAGAR SHARMA | Towards Data Science。

不同类型的层对 CNNs 的构建做出贡献,每一层在定义网络功能方面扮演着独特的角色。除了卷积层,CNNs 中还包括几种其他显著的层:

  • 池化层,如最大池化或平均池化,有效地减少特征图维度,同时保留关键信息。

  • Dropout 层 通过在训练期间随机停用部分神经元来防止过拟合,从而增强网络的泛化能力。

  • 批量归一化层 关注对每层的输入进行标准化,从而加快网络训练速度。

  • 全连接(FC)层 在一个层中的所有神经元和前一层中的所有激活之间建立连接,整合学到的特征以促进最终分类。

CNNs 通过层次化的方式学习识别模式。初始层关注低级特征,逐渐移动到更深层次的高度抽象特征。当到达全连接(FC)层时,Softmax 激活函数会估计图像输入的类别概率。

除了 LeNet 的创始之外,像 AlexNet²、GoogLeNet³、VGGNet⁴、ResNet⁵ 和更新的 Transformer⁶ 等著名 CNN 架构也显著推动了深度学习领域的发展。

自然图像概述

探索自然 2D 图片可以提供对图像数据的基础理解。我们将从一些示例开始,深入探讨。

在我们的第一个示例中,我们将从广泛使用的MNIST数据集中选择一张图片。

这个图像的形状是 [28,28],表示一个具有单一通道的灰度图像。然后,神经网络的图像输入将是 (28*28*1)

现在,让我们探索来自ImageNet数据集的一张图片。你可以直接从 ImageNet 的网站 ImageNet (image-net.org) 访问该数据集,或者在 Kaggle 上探索一个可用的子集 ImageNet Object Localization Challenge | Kaggle

我们可以将这张图片分解为其 RGB 通道:

由于该图像的形状是 [500, 402, 3],神经网络的图像输入将表示为 (500*402*3)

磁共振成像

MRI 扫描是神经学和神经外科中使用最广泛的检查方法,提供了一种非侵入性的方法,能提供良好的软组织对比度⁷。除了可视化结构细节外,MR 成像还深入提供了对大脑结构和功能方面的宝贵见解。

MRI 是由 3D 体积组成的,能够在三个解剖平面:轴向、冠状面和矢状面上进行可视化。这些体积由称为 体素 的 3D 立方体组成,与标准的 2D 图像相比,后者由称为 像素 的 2D 方块构成。虽然 3D 体积提供了全面的数据,但也可以被分解为 2D 切片。

各种 MRI 模态或序列,如 T1、T1 磁性增强(T1ce)、T2 和 FLAIR(液体衰减反转恢复),通常用于诊断。这些序列通过提供对应于特定区域或组织的不同信号强度,使肿瘤区分成为可能。以下插图展示了来自一个被诊断为胶质母细胞瘤的病人的这四种序列,胶质母细胞瘤是胶质瘤中最具侵袭性的类型,也是最常见的原发性脑肿瘤。

脑肿瘤分割数据

脑肿瘤分割(BraTS)挑战赛 提供了一个最广泛的多模态脑 MRI 扫描数据集,涵盖了 2012 到 2023 年的胶质瘤病人。BraTS 比赛的主要目标是评估最先进的方法在多模态 MRI 扫描中分割脑肿瘤的效果,尽管随着时间的推移还添加了额外的任务。

BraTS 数据集提供了关于肿瘤的临床信息,包括一个二进制标签,指示肿瘤级别(低级别或高级别)。BraTS 扫描以 NIfTI 文件形式提供,描述了 T1、T1ce、T2 和 Flair 模态。这些扫描在经过一些预处理步骤后提供,包括共配准到相同的解剖模板、插值到均匀的各向同性分辨率(1mm³)和去颅骨处理。

在这篇文章中,我们将使用 Kaggle 的 2020 数据集 BraTS2020 数据集 (训练 + 验证) 来将胶质瘤 MRI 分类为低级别或高级别。

以下图片展示了低级别和高级别胶质瘤的例子:

MRI 模态和 BraTS 病人 287 的分割掩膜。

MRI 模态和 BraTS 病人 006 的分割掩膜。

Kaggle 存储库包含 369 个目录,每个目录代表一个患者并包含相应的图像数据。此外,它还包含两个 .csv 文件:name_mapping.csvsurvival_info.csv。为了我们的目的,我们将使用 name_mapping.csv,它将 BraTS 患者姓名与 TCGA-LGGTCGA-GBM 来自 Cancer Imaging Archive 的公开数据集相关联。这个文件不仅便于姓名映射,还提供了肿瘤等级标签(LGG-HGG)。

让我们探索每个患者文件夹的内容,以 Patient 006 为例。在 BraTS20_Training_006 文件夹中,我们可以找到 5 个文件,每个文件对应一种 MRI 模态和分割掩模:

  • BraTS20_Training_006_flair.nii

  • BraTS20_Training_006_t1.nii

  • BraTS20_Training_006_t1ce.nii

  • BraTS20_Training_006_t2.nii

  • BraTS20_Training_006_seg.nii

这些文件是 .nii 格式,代表 NIfTI 格式——神经成像中最流行的格式之一。

MRI 数据准备

为了在 Python 中处理 NIfTI 图像,我们可以使用 NiBabel 包。以下是数据加载的示例。通过使用 get_fdata() 方法,我们可以将图像解释为 numpy 数组。

数组形状 [240, 240, 155] 表示一个 3D 体积,由 240 个 x 和 y 维度的 2D 切片和 155 个 z 维度的切片组成。这些维度对应不同的解剖视角:轴向视图(x-y 平面)、冠状视图(x-z 平面)和矢状视图(y-z 平面)。

为了简化,我们将仅使用轴向平面的 2D 切片,生成的图像将具有 [240, 240] 的形状。

为了符合 PyTorch 模型的规格,输入张量必须具有 [batch_size, num_channels, height, width] 的形状。在我们的 MRI 数据中,四种模态(FLAIR、T1ce、T1、T2)各自强调图像中的不同特征,类似于图像中的通道。为了使数据格式符合 PyTorch 的要求,我们将这些序列堆叠为通道,从而实现 [4, 240, 240] 张量。

PyTorch 提供了两个数据工具,torch.utils.data.Datasettorch.utils.data.DataLoader,旨在迭代数据集和批量加载数据。Dataset 包含各种标准数据集的子类,如 MNISTCIFARImageNet。导入这些数据集涉及加载相应的类并初始化 DataLoader

考虑一个 MNIST 数据集的示例:

这使我们能够获得最终的张量,其维度为 [batch_size=32, num_channels=1, H=28, W=28] 张量。

由于我们有一个非平凡的数据集,在使用 DataLoader 之前需要创建一个自定义的 Dataset class。虽然本文未详细介绍创建此自定义数据集的步骤,但读者可以参考 PyTorch 关于 Datasets & DataLoaders 的教程以获取全面的指导。

PyTorch 模型准备

PyTorch 是由 Facebook AI 研究人员在 2017 年开发的深度学习框架。torchvision 包含流行的数据集、图像变换和模型架构。在 [torchvision.models](https://pytorch.org/vision/0.8/models.html) 中,我们可以找到用于不同任务的深度学习架构实现,例如分类、分割或目标检测。

对于我们的应用程序,我们将加载 ResNet18 架构。

ResNet(
  (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (relu): ReLU(inplace=True)
  (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False)
  (layer1): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
    (1): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer2): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(64, 128, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer3): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(128, 256, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer4): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
  (fc): Linear(in_features=512, out_features=1000, bias=True)
)

在神经网络中,输入层和输出层与具体问题密切相关。PyTorch 深度学习模型通常期望输入为 3 通道 RGB 图像,正如我们在初始卷积层配置中所观察到的 Conv2d(in_channels = 3, out_channels = 64, kernel_size=(7,7), stride=(2,2), padding=(3,3), bias=False)。此外,最终层 Linear(in_features = 512, out_features = 1000, bias = True) 默认输出大小为 1000,代表 ImageNet 数据集中的类别数量。

在训练分类模型之前,调整 in_channelsout_features 以与我们的特定数据集对齐是至关重要的。我们可以通过 resnet.conv1 访问第一个卷积层并更新 in_channels。类似地,我们可以通过 resnet.fc 访问最后一个全连接层并修改 out_features

ResNet(
  (conv1): Conv2d(4, 64, kernel_size=(7, 7), stride=(1, 1))
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (relu): ReLU(inplace=True)
  (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False)
  (layer1): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
    (1): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer2): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(64, 128, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer3): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(128, 256, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer4): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
  (fc): Linear(in_features=512, out_features=2, bias=True)
)

图像分类

准备好模型和数据后,我们可以继续进行实际应用。

以下示例展示了如何有效地利用我们的 ResNet18 将图像分类为低等级或高等级。为了管理我们张量中的批量尺寸维度,我们将简单地添加一个单位维度(注意,这通常由数据加载器处理)。

tensor(0.5465, device='cuda:0', grad_fn=<NllLossBackward0>)

这篇文章到此结束。希望这对那些进入 MRI 和深度学习交叉领域的人有所帮助。感谢你抽出时间阅读。欲了解我研究的深入见解,请随时查阅我的最新论文! 😃

Pitarch, C.; Ribas, V.; Vellido, A. 基于 AI 的胶质瘤分级以获得可信诊断:提高可靠性的分析管道。Cancers 202315,3369。 doi.org/10.3390/cancers15133369

除非另有说明,所有图片均由作者提供。

[1] Y. LeCun 等人。“反向传播应用于手写邮政编码

识别”。刊载于《Neural Computation》1(1989 年 12 月 4 日),第 541–551 页。

ISSN: 0899–7667。DOI: 10.1162/NECO.1989.1.4.541

[2] Alex Krizhevsky, Ilya Sutskever 和 Geoffrey E. Hinton. “ImageNet 分类与深度卷积神经网络”。

深度卷积神经网络的分类”。出现在:进展。

在神经信息处理系统第 25 卷(2012)。

[3] Christian Szegedy 等. “通过卷积进一步深入”。出现在:论文集。

IEEE 计算机学会计算机视觉会议论文集。

计算机视觉与模式识别 07–12-2015 年 6 月(2014 年 9 月),第 1–9 页。

ISSN: 10636919。DOI: 10.1109/CVPR.2015.7298594

[4] Karen Simonyan 和 Andrew Zisserman. “非常深的卷积网络”。

大规模图像识别的网络”。出现在:第三届国际。

学习表示大会,ICLR 2015 — 大会追踪。

论文集(2014 年 9 月)。

[5] Kaiming He 等. “用于图像识别的深度残差学习”。

出现在:IEEE 计算机学会计算机视觉会议论文集。

计算机视觉与模式识别 2016 年 12 月(2015 年 12 月),第 770–778 页。

778。ISSN: 10636919。DOI: 10.1109/CVPR.2016.90

[6] Ashish Vaswani 等. “注意力机制”。出现在:进展。

神经信息处理系统 2017 年 12 月(2017 年 6 月),第 5999–6009 页。ISSN: 10495258。DOI: 10.48550/arxiv.1706.03762

[7] Lisa M. DeAngelis. “脑肿瘤”。出现在:新英格兰医学杂志 344 卷(2001 年 8 月 2 日),第 114–123 页。ISSN: 0028–4793。DOI: 10.1056/NEJM200101113440207

亲爱的数据科学家,请保持组织有序!

原文:towardsdatascience.com/dear-data-scientist-be-organized-969ef0fdeb5e?source=collection_archive---------4-----------------------#2023-02-03

一个快速指南,帮助你提高组织技能,从而提升你的表现。

亚历山德雷·罗斯塞托·莱莫斯Towards Data Science 亚历山德雷·罗斯塞托·莱莫斯

·

关注 发布于 Towards Data Science ·8 min read·2023 年 2 月 3 日

--

照片由 Matthew Kwong 提供,来自 Unsplash

在我们开始之前

首先,我有几个简单的问题要问你:

  • 你是否曾经在自己的笔记本中迷失,不知道需要按什么顺序执行单元,以确保代码顺利运行?

  • 在项目开发过程中,你是否曾因试图记起之前进行的分析及其结果而浪费了宝贵的几分钟甚至几小时?

  • 你是否曾经遇到过快速、准确、轻松地找到你在项目中使用的数据的困难?

  • 你是否在解释你的代码时遇到困难,特别是在长时间未使用它们之后?

  • 当你去解释你的代码或向同事展示一些分析结果时,是否需要几分钟来记住你做了什么或分析结果在哪里?

如果你对这些问题中的任何一个回答是“是”,那么很抱歉地告诉你,你很可能有一个组织问题。

不要羞愧,这比看起来更普遍!

好消息是,这类问题很容易解决,但它们繁琐且需要投入精力。我曾经因为缺乏组织而遭受过很多问题,因此我最终创建了一个指南,帮助我在工作中更加有条理和方法化。

我喜欢将我的组织分为四个主题:

  • 数据组织

  • 文件组织(笔记本)

  • 笔记本结构组织

  • 代码组织

数据组织

照片由 Nana Smirnova 提供,来自 Unsplash

数据科学项目涉及使用数据,要么进行一些分析,要么开发机器学习模型,因此数据是这一领域任何项目的基础。

逻辑上,数据访问必须准确,以便在需要时选择正确的数据。然而,当我们处理大型项目或项目持续很长时间时,这项任务可能会变得困难。在开发过程中,可能会生成多个数据库,如果这些数据库没有正确分类,它们可能会导致混淆,从而做出错误的决策。

为了处理这些问题,我喜欢按照以下结构组织我的数据:

数据组织结构示意图(作者提供的图片)

是的,这是一种非常简单和直观的结构,但人们(包括我)往往会把所有的数据保存在同一个文件夹里。现在我喜欢为我参与的每个项目单独设立一个文件夹,并且对于每个项目,我总是有一个单独的原始数据文件夹,包含初始信息,还有一个处理后数据文件夹,包含经过某种预处理后的数据。

保存处理后的数据非常有用,这样你就不必运行整个预处理流程来生成你将用于构建模型或进行分析的数据集。

根据我的经验,我学到一个好习惯是保留在项目中使用的数据版本,因为这样你可以用来比较获得的不同结果,并且确保以前结果和分析的可重复性。然而,在项目结束后的一段时间,最好清理一些旧文件,以减少使用的存储量。

文件组织

图片由Maksym Kaharlytskyi拍摄,来源于Unsplash

通常,在数据科学项目中,需要完成几个步骤才能达到结果。例如,如果你正在为特定任务开发机器学习模型,一些步骤是相当常见的,如开发将要使用的数据库、探索性数据分析(EDA)、开发预处理管道、模型调整和结果评估。

每个任务通常需要合理数量的代码行来完成,这使得将所有任务放在同一个笔记本中变得不切实际,因为同一个文件中的信息量巨大。即使应用我将在接下来的主题中讨论的技巧,笔记本也很容易变得混乱和杂乱。

另一个值得提到的点是,笔记本可能变得计算开销如此之大,以至于如果尝试按顺序运行所有单元格,内核可能会变得不稳定,从而导致浪费宝贵的时间,甚至是小时。

因此,我一直使用并且在项目中对我帮助很大的文件组织方式具有以下格式:

文件组织结构示例(图片由作者提供)

基本上,我按照时间顺序整理我的笔记本(使用文件名中的数字进行排序),我给每本笔记本中开发的内容起非常清晰和明确的名字,我创建文件夹来存储生成的文件,以便将来更容易找到它们。除了方便信息组织之外,遵循这种结构还清楚地显示了达到结果的步骤和所使用的推理。

再次使用这种组织结构,或类似的结构,是一个极其简单的任务,只需自律即可完成。

接下来的两个步骤是最具挑战性的,因为它们要求你不仅要编写代码,还需要思考并且要有自律。

笔记本结构组织

图片由Kelly Sikkema拍摄,来源于Unsplash

我所说的笔记本结构组织是指在同一本笔记本的每一部分中明确你的工作内容。对于这个主题,使用 Markdown 将是你最好的朋友。因为你可以使用不同的样式来区分代码的主题和子主题。

对我而言,这部分几乎和写报告一样,你需要明确陈述每个实验的意图、假设、结果和分析。关键是你必须在目标和分析上非常明确,这需要前瞻性思维和客观性。

井然有序的笔记本结构在解释内容时非常有帮助,这在你向同行和上级展示你的分析时尤为重要。

笔记本结构组织示例(图像由作者提供)

正如你所见,这部分的工作量会根据笔记本的目标而有所不同。然而,我发现这项工作通常会带来更清晰的目标感,并帮助我理解每次分析的结果。这对我帮助很大,特别是在同时处理多个项目时。

代码组织

Fahrul RaziUnsplash 上的照片

解释这个主题的最佳方式是:像向别人解释一样编写代码。使用大量注释,尽量做到清晰。刚开始这个习惯可能会有点困难,你可能会觉得注释代码有些懒惰,但随着时间的推移,你会变得非常擅长快速有效地注释,做到几乎自动化。尽量直接,节省文字和理解所需的时间。

另一个非常重要的点是对你开发的函数进行注释。如果没有人知道它的作用,那么即使函数非常有用也没用!在这里,强烈推荐使用文档字符串。再次强调,要详细解释函数的功能,但尽量不要使用过多的文字。

我使用的模板基本上包含三部分信息:函数的目的是什么(在信息部分),函数的输入变量是什么(在输入部分),以及函数的响应是什么(如果有的话,在输出部分)。下面是我为我在 Medium 上的另一篇文章制作的函数的示例:遗传算法及其在机器学习中的实用性

注释和组织代码的实践非常有用,这不仅有助于你明确自己的工作,还能使你的代码对你组织中的其他人有用。可重用、制作良好且有条理的代码在团队中非常有价值,因为它们可以节省多个员工在执行重复或类似任务时的时间。

另一个重要点是,如果你在项目中途去度假或请假,如果你的代码组织得很好,你会比需要记住你几天前写的所有代码或原因要更快地接续工作。

总结

照片由Glenn Carstens-Peters拍摄,来自Unsplash

在这篇文章中,我展示了在工作中更有条理的几个好处。有条理使你更加高效、清晰和客观,同时也锻炼了你的纪律性和思维能力,因为在写下分析结果或代码中的注释之前,你必须先思考你要写什么。如果你发现自己需要重写某些内容,不要惊讶,这通常是你在锻炼批判性思维,并寻找更高效的表达方式,这也是锻炼这一重要技能的过程。

如果你在职业生涯的早期阶段,更加有条理会对你帮助很大。能够清楚地回答上级提出的问题或清晰地解释你在分析或代码中的目标,这些都是能为你的工作加分的因素。

最后,我的建议是逐步开始,并调整你整理事物的方式,以便找到最适合你的方法。我在这里描述的方法是我为自己调整的,它们对我非常有效,但有时你可能会更适合使用不同的整理方式。不管怎样,有条理只会给你带来好处。

非常感谢你阅读这篇文章,希望它对你有所帮助。

任何评论和建议都非常欢迎。

欢迎通过我的 LinkedIn 联系我,查看我的 GitHub,以及阅读我在 Medium 上的其他文章。

Medium

LinkedIn

GitHub

调试和调整 Amazon SageMaker 训练任务与 SageMaker SSH 帮助工具

原文:towardsdatascience.com/debugging-and-tuning-amazon-sagemaker-training-jobs-with-sagemaker-ssh-helper-51efeb4d03be?source=collection_archive---------6-----------------------#2023-12-27

一个新工具,提升了托管训练工作负载的调试能力

Chaim RandTowards Data Science Chaim Rand

·

关注 发表在 Towards Data Science ·10 分钟阅读·2023 年 12 月 27 日

--

照片由 James Wainscoat 提供,来自 Unsplash

考虑到过去一年(2023)宣布的所有新 Amazon SageMaker 功能,包括最近的 AWS re:invent,很容易忽视 SageMaker SSH Helper — 一种用于连接到远程 SageMaker 培训环境的新工具。但 有时正是这些安静的增强功能有潜力对你的日常开发产生最大的影响。 在这篇文章中,我们将回顾 SageMaker SSH Helper 并展示它如何提高你 1) 调查和解决培训应用程序中出现的错误的能力,以及 2) 优化其运行时性能。

在 之前的帖子 中,我们详细讨论了云端培训的好处。基于云的管理培训服务,如 Amazon SageMaker,简化了围绕 AI 模型开发的许多复杂问题,并大大提高了对 AI 特定机器和 预训练 AI 模型 的可访问性。要在 Amazon SageMaker 中进行培训,你只需定义一个培训环境(包括实例类型)并指向你希望运行的代码,培训服务将 1) 设置请求的环境,2) 将你的代码传送到培训机器,3) 运行你的培训脚本,4) 将培训输出复制到持久存储中,5) 在培训完成后拆除一切(以便你只需为你需要的部分付费)。听起来很简单…对吧?然而,管理培训也并非没有缺陷,其中之一 — 它对培训环境的有限访问性 — 将在本文中讨论。

免责声明

  1. 请不要将我们对 Amazon SageMaker、SageMaker SSH Helper 或任何其他框架或工具的使用解读为对它们使用的支持。开发 AI 模型有许多不同的方法。对你而言,最佳解决方案将取决于你的项目细节。

  2. 请务必在阅读时核实本文内容,特别是代码示例,与当时可用的最新软件和文档。AI 开发工具的环境在不断变化,我们提到的一些 API 可能会随着时间而变化。

管理培训的缺点 — 培训环境的不可访问性

正如经验丰富的开发者所知道的,应用开发时间的很大一部分实际上花在了调试上。我们的程序很少会“开箱即用”;更多时候,它们需要经过数小时的繁琐调试才能按预期运行。当然,要有效调试,你需要对应用环境有直接访问权限。尝试在没有环境访问权限的情况下调试应用程序,就像尝试修理水龙头却没有扳手一样。

AI 模型开发中的另一个重要步骤是调整训练应用的运行时性能。训练 AI 模型可能成本高昂,我们最大化计算资源利用的能力可能对训练成本产生决定性影响。在之前的一篇文章中,我们描述了分析和优化训练性能的迭代过程。类似于调试,直接访问运行时环境将大大提高并加速我们获得最佳结果的能力。

不幸的是,SageMaker 训练的“开火即忘”特性带来了一个副作用,那就是无法自由连接到训练环境。当然,你可以通过训练作业输出日志和调试打印(即,添加打印、研究输出日志、修改代码,并重复直到解决所有错误并达到所需性能)来调试和优化性能,但这将是一个非常原始且耗时的解决方案。

有一些最佳实践可以解决管理训练工作负载时的调试问题,每种方法都有其优缺点。我们将回顾其中的三种,讨论它们的局限性,然后展示新的SageMaker SSH Helper如何彻底改变游戏规则。

在本地环境中调试

建议在将任务提交到云端之前,先在本地环境中运行几步训练。尽管这可能需要对代码进行一些修改(例如,为了在 CPU 设备上进行训练),但通常是值得的,因为它使你能够识别并修复一些简单的编码错误。显然,这比在云端昂贵的 GPU 机器上发现这些错误更具成本效益。理想情况下,你的本地环境应尽可能类似于 SageMaker 训练环境(例如,使用相同版本的 Python 和 Python 包),但在大多数情况下,这种相似性是有限的。

在 SageMaker Docker 容器中本地调试

第二个选项是拉取 SageMaker 使用的 深度学习容器 (DLC) 镜像,并在你的本地 PC 上的容器中运行你的训练脚本。这种方法可以让你很好地了解 SageMaker 训练环境,包括已安装的包(和包版本)。它在识别缺失的依赖项和解决依赖项冲突方面极为有用。请参见 文档 以了解如何登录并拉取适当的镜像。请注意,SageMaker 的 API 支持通过其 本地模式 功能拉取和训练 DLC。然而,自己运行镜像将使你能够更自由地探索和研究镜像。

在未管理的实例上在云中调试

另一个选择是在云中使用未管理的 Amazon EC2 实例进行训练。这个选项的优势在于可以在与你在 SageMaker 中使用的相同实例类型上运行。这将使你能够重现可能在本地环境中无法重现的问题,例如,与你使用 GPU 资源相关的问题。最简单的方法是使用与 SageMaker 环境(例如相同的操作系统、Python 和 Python 包版本)最相似的 机器镜像 来运行你的实例。或者,你可以拉取 SageMaker 的 DLC 并在远程实例上运行它。然而,尽管这也在云中运行,但运行时环境可能与 SageMaker 的环境有显著差异。SageMaker 在初始化期间配置了一整套系统设置。尝试重现相同环境可能需要相当多的努力。鉴于在云中调试比前两种方法更昂贵,我们的目标应该是在求助于这一选项之前尽可能清理我们的代码。

调试限制

尽管上述每个选项对解决某些类型的错误都很有用,但没有一个能完美地复制 SageMaker 环境。因此,当在 SageMaker 中运行时,你可能会遇到这些方法无法复现的问题,从而无法进行修正。特别是,有许多功能仅在 SageMaker 环境中受支持(例如,SageMaker 的 Pipe inputFast File 模式来访问 Amazon S3 中的数据)。如果你的问题与这些功能相关,你将 无法 在 SageMaker 之外复现它。

调优限制

此外,上述选项未提供有效的性能调优解决方案。运行时性能对环境中的细微变化非常敏感。虽然模拟环境可能提供一些一般优化提示(例如,不同数据增强的比较性能开销),但准确的性能分析只能在 SageMaker 运行时环境中进行。

SageMaker SSH Helper

SageMaker SSH Helper 提供了连接到远程 SageMaker 训练环境的功能。这是通过 AWS SSM 上的 SSH 连接实现的。正如我们将演示的,设置这个功能的步骤非常简单,值得花费精力。 官方文档 包含了关于此工具的价值及其使用方法的详细信息。

示例

在下面的代码块中,我们演示了如何使用 sagemaker-ssh-helper(版本 2.1.0)启用对 SageMaker 训练作业的远程连接。我们传入完整的代码源目录,但将通常的 entry_pointtrain.py)替换为一个新的 run_ssh.py 脚本,该脚本放置在 source_dir 的根目录中。请注意,我们将 SSHEstimatorWrapper 添加到项目依赖项列表中,因为我们的 start_ssh.py 脚本将需要它。或者,我们也可以将 sagemaker-ssh-helper 添加到 requirements.txt 文件中。这里我们将 connection_wait_time_seconds 设置为两分钟。正如我们将看到的,这会影响训练脚本的行为。

from sagemaker.pytorch import PyTorch
from sagemaker_ssh_helper.wrapper import SSHEstimatorWrapper
MINUTE = 60

estimator = PyTorch(
    role='<sagemaker role>',
    entry_point='run_ssh.py',
    source_dir='<path to source dir>',
    instance_type='ml.g5.xlarge',
    instance_count=1,
    framework_version='2.0.1',
    py_version='py310',
    dependencies=[SSHEstimatorWrapper.dependency_dir()]
)

# configure the SSH wrapper. Set the wait time for the connection.
ssh_wrapper = SSHEstimatorWrapper.create(estimator.framework, 
                                    connection_wait_time_seconds=2*MINUTE)

# start job
estimator.fit()

# wait to receive an instance id for the connection over SSM
instance_ids = ssh_wrapper.get_instance_ids()

print(f'To connect run: aws ssm start-session --target {instance_ids[0]}')

和往常一样,SageMaker 服务将分配一个机器实例,建立所请求的环境,下载并解压我们的源代码,并安装所请求的依赖项。此时,运行环境将与我们通常运行训练脚本的环境完全相同。只是现在,我们将运行我们的start_ssh.py脚本,而不是训练:

import sagemaker_ssh_helper
from time import sleep

# setup SSH and wait for connection_wait_time_seconds seconds
# (to give opportunity for the user to connect before script resumes)
sagemaker_ssh_helper.setup_and_start_ssh()

# place any code here... e.g. your training code
# we choose to sleep for two hours to enable connecting in an SSH window
# and running trials there
HOUR = 60*60
sleep(2*HOUR)

setup_and_start_ssh 函数将启动 SSH 服务,然后在我们上面定义的分配时间(connection_wait_time_seconds)内阻塞,以允许 SSH 客户端连接,然后继续执行脚本的其余部分。在我们的例子中,它将睡眠两小时,然后退出训练作业。在这段时间里,我们可以使用aws ssm start-session 命令和由ssh_wrapper 返回的 instance-id(通常以“mi-”前缀开头,代表“管理实例”)连接到机器,尽情玩耍。特别是,我们可以显式运行我们原始的训练脚本(该脚本作为source_dir的一部分上传)并监控训练行为。

我们描述的方法使我们能够在识别和修复错误的同时,迭代运行我们的训练脚本。它还提供了一个优化性能的理想环境——在这个环境中,我们可以 1) 运行几个训练步骤,2) 识别性能瓶颈(例如,使用PyTorch Profiler),3) 调整我们的代码以解决这些瓶颈,然后 4) 重复这个过程,直到我们实现所需的运行时性能。

重要的是,请记住,一旦start_ssh.py 脚本完成,实例将被终止。在为时已晚之前,确保将所有重要文件(例如,代码修改、配置文件跟踪等)复制到持久存储中。

通过 AWS SSM 进行端口转发

我们可以扩展我们的aws ssm start-session 命令,以启用端口转发。这允许您安全地连接到云实例上运行的服务器应用程序。这对于习惯使用TensorBoard Profiler 插件分析运行时性能的开发人员尤其令人兴奋(正如我们所做的)。下面的命令演示了如何通过 AWS SSM 设置端口转发:

aws ssm start-session \
  --target mi-0748ce18cf28fb51b \
  --document-name AWS-StartPortForwardingSession
  --parameters '{"portNumber":["6006"],"localPortNumber":["9999"]}'

使用的其他模式

SageMaker SSH Helper 文档描述了几种使用 SSH 功能的方法。在基础示例中,将 setup_and_start_ssh 命令添加到现有训练脚本的顶部(而不是定义一个专门的脚本)。这使你有时间(根据 connection_wait_time_seconds 设置定义)在训练开始前连接到机器,以便你可以在训练运行时(通过一个独立的进程)监控其行为。

更加高级的示例包括使用 SageMaker SSH Helper 从本地环境中的 IDE 调试运行在 SageMaker 环境中的训练脚本的不同方法。虽然设置更为复杂,但能够从本地 IDE 进行逐行调试的奖励可能非常值得。

其他用例包括在 VPC 中训练、与SageMaker Studio的集成、连接到 SageMaker 推理端点等。请务必查看文档以获取详细信息。

何时使用 SageMaker SSH Helper

考虑到使用 SageMaker SSH Helper 进行调试的优势,你可能会想知道是否有理由使用我们上述描述的三种调试方法。我们认为,尽管你可以在云端进行所有调试,但仍然强烈建议你在本地环境中进行初步开发和实验阶段——尽可能地使用我们描述的前两种方法。只有在你已耗尽本地调试的能力后,才应转到使用 SageMaker SSH Helper 在云端进行调试。你最不希望的就是在一个极其昂贵的云端 GPU 机器上花费数小时清理简单的语法错误。

与调试相反,性能分析和优化的价值不大除非它在目标训练环境中直接进行。因此,建议在使用 SageMaker SSH Helper 的 SageMaker 实例上进行优化工作。

总结

到现在为止,在 Amazon SageMaker 上训练的一个最痛苦的副作用就是失去了对训练环境的直接访问。这限制了我们有效调试和调整训练工作负载的能力。最近发布的 SageMaker SSH Helper 以及对训练环境的直接访问支持,为开发、调试和调整提供了大量新的机会。这些机会可以对你的 ML 开发生命周期的效率和速度产生显著影响。这就是为什么 SageMaker SSH Helper 是我们 2023 年最喜欢的新云 ML 功能之一。

Pytest 教程:单元测试简介

原文:towardsdatascience.com/debugging-made-easy-use-pytest-to-track-down-and-fix-python-code-ecbad62057b8

如何使用 Pytest fixtures 和 mock 进行单元测试

Egor HowellTowards Data Science Egor Howell

·发布于 Towards Data Science ·7 min read·2023 年 4 月 18 日

--

照片由 Yancy Min 提供,来源于 Unsplash

背景

想象一下,你是一名数据科学家,刚刚开发了一个出色的新模型,这将为公司带来丰厚的利润。接下来的步骤是将其投入生产。你花了几天时间将代码调整为 PEP 标准,应用 linting 等等。最后,你在 GitHub 上创建了一个 pull request,对你的新发布感到兴奋。然后,一位软件工程师问道:‘我看不到任何测试?

这种情况发生在我身上,并且在初级数据科学家中相当频繁。测试 是任何软件项目的核心部分,数据科学也不例外。因此,掌握这个重要概念和工具将对你的职业生涯非常宝贵。在这篇文章中,我深入探讨了测试的必要性以及如何通过使用 Pytest 来轻松进行测试。

什么是测试?

测试是我们自然进行的,通过简单地推断输出是否符合预期,这被称为 exploratory testing。然而,这并不理想,尤其是当你有一个大型代码库和众多步骤时,因为很难检测问题发生的具体位置。

因此,通常会编写代码测试。你会有一些输入和预期输出。这自动化测试过程,并加快调试过程。

最常见和频繁编写的测试是单元测试。这些是测试小块代码的测试,通常是函数和类,以验证该块代码是否按预期工作。

单元测试的一般优点包括:

  • 加快调试和发现问题的速度

  • 更早识别错误

  • 代码更健壮且易于维护

  • 导致更好的代码设计,复杂度更低

单元测试是测试周期中的基础测试,随后是集成测试系统测试

软件测试金字塔。图示由作者提供。

Pytest 是什么?

Pytest 是一个易于使用的 Python 包,用于进行单元测试。它是最受欢迎的测试包之一,与 Python 原生的单元测试框架并列。Pytest 相对于其他测试框架有几个优点:

  • 开源

  • 跳过并标记测试

  • 并行测试执行

  • 使用起来非常简单直观

现在让我们开始测试吧!

安装和设置

你可以通过粗体 pip安装pytest,只需输入:

pip install pytest

在你的终端或命令行中。如果你需要特定版本:

pip install pytest==<version>

你可以通过以下命令验证它是否已安装在你的机器上:

pytest --version

最佳实践是将测试放在与主要代码分开的目录中,例如tests/。另一个要求是所有测试文件都以test_*.py为前缀或以*_test.py为后缀,使用蛇形命名法。类似地,所有测试函数和类应以test_Test驼峰命名法)开头。这确保了pytest知道哪些函数、类和文件是测试。

基本示例

让我们来看一个非常简单的例子。

首先,我们将创建一个新的目录pytest-example/,其中包含两个文件:calculations.pytest_calculations.py。在calculations.py文件中,我们将编写以下函数:

def sum(a: float, b: float) -> float:
    """
    Calculate the sum of the two numbers.

    :param a: The first number to be added.
    :param b: The second number to be added.
    :return: The sum of the two numbers.
    """
    return a + b

test_calculations.py文件中,我们编写相应的单元测试:

from calculations import sum

def test_sum():
    assert sum(5, 10) == 15

这个测试可以通过执行以下任意一个命令来运行:

pytest
pytest test_calculations.py

输出如下:

图片来自作者。

好消息,我们的测试通过了!

但是,如果我们的assert不正确:

def test_sum():
    assert sum(5, 10) == 10

输出将是:

图片来自作者。

若干测试

对于不同的函数,可以有几个测试。例如,让我们在calculations.py中添加另一个函数:

def sum(a: float, b: float) -> float:
    """
    Calculate the sum of the two numbers.

    :param a: The first number to be added.
    :param b: The second number to be added.
    :return: The sum of the two numbers.
    """
    return a + b

def multiply(a: float, b: float) -> float:
    """
    Calculate the product of the two numbers.

    :param a: The first number to be added.
    :param b: The second number to be added.
    :return: The product of the two numbers.
    """
    return a * b

然后在 test_calculations.py 中添加 multiply 函数的测试:

from calculations import sum, multiply

def test_sum():
    assert sum(5, 10) == 15

def test_multiply():
    assert multiply(5, 10) == 50

执行 pytest

图片来自作者。

两个测试都通过了!

然而,如果你只想运行 test_multiply 函数呢?你只需在执行 pytest 时将该函数名作为参数传递即可:

pytest test_calculations.py::test_multiply 

图片来自作者。

正如我们所见,pytest 只运行了 test_multiply,这正是我们所期望的!

如果我们现在想添加一个 divide 函数,最好将它们转换为类:

class Calculations:
    def __init__(self, a: float, b: float) -> None:
        """
        Initialize the Calculation object with two numbers.

        :param a: The first number.
        :param b: The second number.
        """
        self.a = a
        self.b = b

    def sum(self) -> float:
        """
        Calculate the sum of the two numbers.

        :return: The sum of the two numbers.
        """
        return self.a + self.b

    def multiply(self) -> float:
        """
        Calculate the product of the two numbers.

        :return: The product of the two numbers.
        """
        return self.a * self.b

    def divide(self) -> float:
        """
        Calculate the quotient of the two numbers.

        :return: The quotient of the two numbers.
        """
        return self.a / self.b
from calculations import Calculations
import pytest

class TestCalculations:
    def test_sum(self):
        calculations = Calculations(5, 10)
        assert calculations.sum() == 15

    def test_multiply(self):
        calculations = Calculations(5, 10)
        assert calculations.multiply() == 50

    def test_divide(self):
        calculations = Calculations(5, 10)
        assert calculations.divide() == 0.5

Pytest Fixtures

在上面的 TestCalculations 类中,注意到我们多次初始化了 Calculations 类。这并不是最优的,幸运的是,pytestfixtures 来解决这种情况:

from calculations import Calculations
import pytest

@pytest.fixture
def calculations():
    return Calculations(5, 10)

class TestCalculations:
    def test_sum(self, calculations):
        assert calculations.sum() == 15

    def test_multiply(self, calculations):
        assert calculations.multiply() == 50

    def test_divide(self, calculations):
        assert calculations.divide() == 0.5

与其多次初始化Calculations,我们可以将 fixture 作为一个 装饰器 附加,以包含输入数据的信息。

Pytest 参数化

到目前为止,我们只为每个测试函数通过了一个测试用例。然而,可能有多个边缘情况你想要测试和验证。Pytest 通过 parametrize 装饰器使这一过程变得非常简单:

from calculations import Calculations
import pytest

@pytest.fixture
def calculations():
    return Calculations(5, 10)

class TestCalculations:

    @pytest.mark.parametrize("a, b, expected_output",
                             [(1, 3, 4), (10, 50, 60), (100, 0, 100)])
    def test_sum(self, a, b, expected_output):
        assert Calculations(a, b).sum() == expected_output

    def test_multiply(self, calculations):
        assert calculations.multiply() == 50

    def test_divide(self, calculations):
        assert calculations.divide() == 0.5

我们使用了 pytest.mark.parametrize 装饰器来测试 sum 函数的多个输入。输出结果如下:

图片来自作者。

注意到我们有 5 个测试通过而不是 3 个,这是因为我们对 sum 函数传递了两个额外的测试。

总结与进一步思考

测试,特别是单元测试,是数据科学家必须学习和理解的重要技能,因为它有助于防止 bug 并加快开发速度。在 Python 中,最常见的测试包是 Pytest。这是一个易于使用的框架,具有直观的测试过程。在本文中,我们展示了如何使用 Pytest 的 fixturesparametrize 功能。

本文中使用的完整代码可以在这里找到:

[## Medium-Articles/Software Engineering /pytest-example at main · egorhowell/Medium-Articles

目前你无法执行该操作。你在另一个标签页或窗口中已登录。你在另一个标签页或窗口中已登出…

github.com

另外的事情!

我有一个免费的新闻通讯,Dishing the Data,在其中我分享成为更好数据科学家的每周技巧。

[## 数据分享 | Egor Howell | Substack

如何成为更好的数据科学家。点击阅读由 Egor Howell 发布的 Substack 刊物《数据分享》…

newsletter.egorhowell.com](https://newsletter.egorhowell.com/?source=post_page-----ecbad62057b8--------------------------------)

联系我!

参考文献及进一步阅读

使用 Docker 调试 SageMaker 端点

原文:towardsdatascience.com/debugging-sagemaker-endpoints-with-docker-7a703fae3a26

SageMaker 本地模式的替代方案

Ram VegirajuTowards Data Science Ram Vegiraju

·发表于Towards Data Science ·阅读时长 6 分钟·2023 年 6 月 16 日

--

图片来自Unsplash,作者Mohammad Rahmani

启动SageMaker 实时推理的痛点之一是有时很难调试。当创建端点时,需要确保有许多因素得到了妥善处理,以便成功部署。

  • 根据您使用的模型服务器和容器,正确的模型工件文件结构至关重要。本质上,您提供的 model.tar.gz 必须符合模型服务器的格式。

  • 如果您有一个自定义推理脚本,实现了模型的前处理和后处理,您需要确保实现的处理程序与模型服务器兼容,并且代码级别没有脚本错误。

之前我们讨论了 SageMaker 本地模式,但在本文撰写时,本地模式不支持所有 SageMaker 部署可用的托管选项和模型服务器。

为了克服这个限制,我们将探讨如何使用Docker及示例模型,以及如何在 SageMaker 部署之前测试/调试我们的模型工件和推理脚本。在这个具体示例中,我们将利用BART 模型,这是我在上一篇文章中介绍过的,看看如何使用 Docker 托管它。

注意:对于那些刚接触 AWS 的用户,如果你想跟随本文,请确保在以下 链接 上创建一个账户。本文还假设你对 SageMaker 部署有中级了解,我建议你阅读这篇 文章 以更深入地理解部署/推理。对 Docker 有中级了解也将有助于你完全理解这个示例。

SageMaker 托管是如何工作的?

在进入本文的代码部分之前,让我们先看看 SageMaker 实际是如何处理请求的。SageMaker Inference 的核心有两个构件:

  • Container:这建立了模型的运行时环境,它还与您正在使用的模型服务器集成。你可以使用现有的 深度学习容器(DLCs)之一,也可以 构建你自己的容器。

  • 模型工件:在 CreateModel API 调用中,我们指定了一个包含模型数据的 S3 URL,格式为 model.tar.gz(tarball)。这些模型数据被加载到容器上的 opt/ml/model 目录中,这也包括你提供的任何推理脚本。

关键在于容器需要实现一个 Web 服务器,响应端口 8080 上的 /invocations 和 /ping 路径。我们实现的一个 Web 服务器示例是 Flask,在 自定义容器 示例中提供了这些路径。

使用 Docker 时,我们将暴露这个端口,并指向我们的本地脚本和模型工件,这样我们就可以模拟 SageMaker Endpoint 预期的行为。

使用 Docker 进行测试

为了简单起见,我们将使用我上一篇文章中的 BART 示例,你可以从这个 仓库 获取相关工件。在这里,你应该能看到以下文件:

  • model.py:这是我们正在使用的推理脚本。在这种情况下,我们使用 DJL Serving,它期望一个包含处理推理的 handler 函数的 model.py。你的推理脚本仍然需要与模型服务器期望的格式兼容。

  • requirements.txt:任何你的 model.py 脚本所需的额外依赖项。对于 DJL Serving,PyTorch 已经预先安装,我们使用 numpy 进行数据处理。

  • serving.properties:这是一个 DJL 特有的文件,你可以在这里定义模型级别的任何配置(例如:每个模型的工作线程数)。

我们已经有了模型工件,现在需要我们将要使用的容器。在这种情况下,我们可以检索现有的 DJL DeepSpeed 镜像。有关 AWS 已提供的镜像的详细列表,请参考这个指南。你也可以在本地构建自己的镜像并指向它。在这种情况下,我们在一个 SageMaker Classic Notebook 实例环境中操作,该环境中也预装了 Docker。

要使用 AWS 提供的现有镜像,我们首先需要登录到 AWS Elastic Container Registry (ECR)以检索镜像,你可以使用以下 shell 命令来完成这一步。

$(aws ecr get-login --region us-east-1 --no-include-email --registry-ids 763104351884)

你应该看到类似于以下的登录成功消息。

登录成功(作者截图)

一旦登录成功,我们可以进入存储模型工件的路径,并运行以下命令来启动模型服务器。如果你还没有检索镜像,这也会从 ECR 中拉取镜像。

docker run \
-v /home/ec2-user/SageMaker:/opt/ml/model \
--cpu-shares 512 \
-p 8080:8080 \
763104351884.dkr.ecr.us-east-1.amazonaws.com/djl-inference:0.21.0-deepspeed0.8.0-cu117 \
serve

这里有几个关键点:

  • 我们暴露了 8080 端口,因为 SageMaker 推理期望如此。

  • 我们还指向现有的镜像。这个字符串取决于你所在的区域和操作的模型。你还可以使用 SageMaker Python SDK 的retrieve image_uri API 调用来识别合适的镜像以进行拉取。

图像正在检索中(作者截图)

在镜像拉取完成后,你会看到模型服务器已启动。

DJL 服务器已启动(作者截图)

我们还可以通过使用以下 Docker 命令来验证容器是否正在运行。

docker container ls

容器已启动(作者截图)

我们看到 API 通过 8080 端口暴露,我们可以通过 curl 向其发送示例请求。注意,我们指定了 SageMaker 容器期望的 /invocations 路径。

curl -X POST http://localhost:8080/invocations -H "Content-type: text/plain"
 "This is a sample test string"

然后我们看到推理返回了请求,并且模型服务器正在跟踪响应并从我们的推理脚本中发出日志语句。

示例请求(作者截图)

让我们拆解 model.py,看看是否能通过 Docker 早期捕获到错误。在推理函数中,我添加了一个语法错误的打印语句,并重启模型服务器以查看是否能捕获到这个错误。

def inference(self, inputs):
        """
        Custom service entry point function.

        :param inputs: the Input object holds the text for the BART model to infer upon
        :return: the Output object to be send back
        """

        #sample error
        print("=)

然后我们可以看到,当我们执行 docker run 命令时,模型服务器捕获了这个错误。

模型服务器捕获的错误(作者截图)

注意,你不仅限于使用 curl 来测试你的容器。我们还可以使用类似于 Python requests 库来与容器进行交互和操作。一个示例请求可能如下所示:

import requests

headers = {
    'Content-type': 'text/plain',
}

response = requests.post('http://localhost:8080/invocations', headers=headers)

利用类似 requests 的工具,你可以对容器进行大规模负载测试。请注意,你运行容器的硬件就是正在被利用的(可以将其视为 SageMaker Endpoint 后面的实例)。

额外资源与结论

## GitHub - RamVegiraju/SageMaker-Docker-Local: 如何使用 Docker 本地测试 SageMaker 推理

如何使用 Docker 本地测试 SageMaker 推理 - GitHub - RamVegiraju/SageMaker-Docker-Local:如何本地测试…

github.com

你可以在上面的链接找到整个示例的代码。使用 SageMaker Inference,你希望避免等待端点创建以捕捉错误的痛苦。通过这种方法,你可以使用任何 SageMaker 容器来测试和调试你的模型工件和推理脚本。

随时欢迎留下反馈或提问,感谢阅读!

如果你喜欢这篇文章,欢迎通过 LinkedIn 与我联系,并订阅我的 Medium Newsletter。如果你是 Medium 的新用户,可以通过我的 Membership Referral注册。

Decent Espresso DE1Pro vs Kim Express:第 2 轮

原文:towardsdatascience.com/decent-espresso-de1pro-vs-kim-express-round-2-80b9324d3fe3?source=collection_archive---------17-----------------------#2023-04-18

咖啡数据科学

更进一步

Robert McKeon AloeTowards Data Science Robert McKeon Aloe

·

关注 发表在 Towards Data Science ·5 分钟阅读·2023 年 4 月 18 日

--

自从回到办公室后,我有时间再次比较 Kim Express 和 Decent Espresso 机器再次。需要说明的是,Kim Express 在办公室。因此,在过去几个月里,我慢慢地收集了一些数据,现在准备分享一些结果。我很高兴自己已经缩小了 Decent 和 Kim 之间的差距,但仍有改进空间。

首先,我对 Decent 的拍摄配置进行了多次修改,特别是泵送和排出配置文件。这个配置文件影响最大,同时,我也在努力改善水输入

起初,我在咖啡饼上使用了一个纸星,后来我转而修改了喷头屏幕,将水流限制在咖啡饼中心的 25%。我也将这些更改应用到了 Kim 上,因此我的拍摄效果总体上有了改善。

数据注意事项

我还没准备好进行真正的配对比较,因为我需要在工作中使用我的研磨机或在家使用我的机器(虽然我还有其他 Kim)。这造成了三个问题:

  1. 对于 Kim,研磨和拍摄之间存在一些延迟(30 分钟到 3 小时),我进行了一些实验以了解研磨年龄如何影响咖啡的味道。确实有差异,但这也允许更高的提取率。

  2. Kim Express 需要更细的研磨。随着喷头屏幕的更改,我将水温从 116°C 降低到了 105°C,但流速非常快,部分原因是研磨非常均匀。

  3. 使用了两台 Kim Express 机器。我经历了一次密封圈故障,因此在更换密封圈时交换了机器。

我们在比较中失去了一些公平性,但我的目标是为每台机器制作最佳的咖啡。

设备/技术

浓缩咖啡机:Decent Espresso Machine 和 Kim Express

咖啡研磨机:Niche Zero 和 Rok

咖啡:自家烘焙咖啡,中度(第一次裂纹后+1 分钟)

拍摄准备:断奏式压实

预浸润:长时间,大约 25 秒

滤篮:20g Wafo Soe Spirit

其他设备:Acaia Pyxis 秤,DiFluid R2 TDS 计)

性能指标

我使用了两组指标来评估技术差异:最终评分和咖啡提取。

最终评分 是对 7 个指标(Sharp, Rich, Syrup, Sweet, Sour, Bitter, 和 Aftertaste)的评分卡的平均值。这些分数当然是主观的,但它们已经根据我的口味进行了校准,并帮助我改进了冲泡。分数存在一定的变化。我的目标是每个指标的一致性,但有时细微差别难以把握。

总溶解固体 (TDS) 是使用折射仪测量的,这个数值与冲泡的输出重量和咖啡的输入重量结合使用,用来确定杯中提取的咖啡百分比,这被称为提取率 (EY)

强度半径 (IR) 被定义为 TDS 与 EY 控制图中原点的半径,因此 IR = sqrt( TDS² + EY²)。这一指标有助于在产量或冲泡比例之间标准化射击性能。

数据

我在 Decent 上进行了 131 次冲泡,在 Kim 上进行了 35 次,涉及 16 次烘焙。从高层次来看,我们可以查看控制图,看到所有冲泡都落在相似的范围内。

所有图片均由作者提供

我们可以将口味加入其中,结果在两台机器之间没有明显差异。

我们可以查看每次烘焙,并找出平均 TDS、EY、IR 和口味。这些指标的表现接近。

然而,我们的目标是达到最佳性能。在最大性能方面,DE 似乎表现更好。

从一般统计角度来看,平均水平在口味上表现更佳,而在每次烘焙的最大值中,DE 表现更突出。

在这两台机器之间,我学到了比我曾经认为的更多。Kim 的主要优势是其群头位于锅炉内部,这带来了一些加热优势。此外,当你打开杠杆让水进入群头时,活塞室允许水蒸发,我想知道这是否使蒸汽预浸更高效。

Kim Express 的主要缺点是如果冲泡过程中出现问题,你不能减慢它的速度。一旦拉下杠杆,冲泡的结果就已经注定,无论是否会出现通道现象。虽然可以做一些小的调整,但 Decent 的流量控制潜力使其在性能上超越 Kim,非常有吸引力。

我想明确一点:我相信 Decent 还能做得更好。我希望 Decent 能大幅度胜出,并且我正在通过我在配置文件更改中的所有实验朝这个目标努力。

如果你喜欢,可以在TwitterYouTubeInstagram上关注我,我会发布关于不同机器上的浓缩咖啡镜头以及相关内容的视频。你也可以在LinkedIn找到我。你还可以在Medium上关注我,并订阅我的内容。

我的进一步阅读

我的书

我的链接

浓缩咖啡文章合集

工作和学校故事集

决策分析与 Python 中的决策树——奥克兰运动员队的案例

原文:towardsdatascience.com/decision-analysis-and-trees-in-python-the-case-of-the-oakland-as-786d746cdfb2

使用 Python 中的决策树来洞察奥克兰运动员队(A’s)迁往拉斯维加斯的决策

Giovanni MalloyTowards Data Science Giovanni Malloy

·发表在数据科学前沿 ·阅读时长 17 分钟·2023 年 5 月 24 日

--

图片由Rick Rodriguez提供,来源于Unsplash

最近,奥克兰运动员队的所有者约翰·费舍尔宣布球队已在内华达州拉斯维加斯购买了近 50 英亩的土地。[1] 这使得奥克兰最后一支职业体育球队的未来岌岌可危。在过去 5 年中,奥克兰见证了金州勇士(NBA)和拉斯维加斯突袭者(NFL)迁往其他城市的新球场(尽管金州勇士只是跨越湾桥迁往旧金山)。虽然奥克兰运动员队管理层的决策过程对我仍然是一个谜,但数据科学和决策分析的结合可以揭示约翰·费舍尔迁往拉斯维加斯的动机。

决策分析对于所有数据科学家都非常重要,因为它是概率和统计模型的高度技术工作与商业决策之间的桥梁。了解商业决策的制定过程有助于框定我们的工作及向非技术观众展示我们的发现,同时提供可行的建议和发现。运筹学与管理科学研究所(INFORMS)甚至有一个专门致力于决策分析的学会

此外,机器学习可以通过解锁概率敏感性分析的见解来帮助推广决策分析的结果。在使用决策分析初步构建分析奥克兰与拉斯维加斯情境的模型后,我们将使用机器学习挖掘可能揭示可操作建议的模式,以便在决策情况发生变化时为 A 队提供帮助。

什么是决策分析?

决策分析是致力于“系统性、定量化和可视化方法来解决和评估重要选择”的研究领域。[2] 它可以在数据较少的环境中成为强大的工具,并帮助个人利用主题领域的专业知识和先前知识来改善复杂决策的预期价值。它被广泛应用于经济学、管理学和政策分析等多个领域。

通常,在决策分析领域,我们采取贝叶斯视角。贝叶斯定理的基本公式如下:

图片由作者创建。

其中 P(A) 是事件 A 发生的概率,P(B) 是事件 B 发生的概率,P(A|B) 是在事件 B 发生的情况下事件 A 发生的概率,而 P(B|A) 是在事件 A 发生的情况下事件 B 发生的概率。通常,P(A) 代表关于 A 发生的先验信念,而 B 代表一些新数据。P(A|B) 是在观察到 B 之后对 A 发生概率的更新后验信念。

例如,假设我们去奥克兰-阿拉米达县体育场看比赛,但我们没有跟踪球员统计数据。我们从以下知识开始:外场手上垒的概率是 0.35,内场手上垒的概率是 0.25,而指定击球手上垒的概率是 0.4。设 A 为下一个击球员是外场手的事件,B 为下一个击球员是内场手的事件,C 为下一个击球员是指定击球手的事件。由于我们知道棒球队的名单,我们已经知道 P(A) = 0.33,P(B) = 0.56,和 P(C) = 0.11。现在,下一个击球员上场,令我们高兴的是,他成功上垒(事件 D)!根据我们之前的棒球知识,我们知道 P(D|A) = 0.35,P(D|B) = 0.25,和 P(D|C) = 0.4。利用全概率定理,我们可以计算出 P(D) = P(D|A)P(A) + P(D|B)P(B) + P(D|C)P(C) = 0.3。现在,我们可以更新我们对击球员类型的信念:P(A|D) = 0.39,P(B|D) = 0.47,和 P(C|D) = 0.15。看到球员上垒后,我们现在更倾向于相信这名球员不是内场手。既然你已经调整了心态,让我们继续。

决策分析中的关键工具是决策树(不要与同名的机器学习算法混淆)。[3] 决策树有两个基本组成部分:决策节点和选择节点。[3] 在这篇博客中,我将向你展示如何构建决策树,如何在 Python 中评估它,并理解奥克兰 A 队迁往拉斯维加斯的决策。

决策是什么?

霍华德和阿巴斯将决策定义为“在两个或更多备选方案之间做出选择,并涉及不可撤销的资源分配。”[3] 这是一个宽泛的定义,但在我们以奥克兰 A 队为例的情况下,决策是:运动家棒球队应该留在奥克兰还是迁往拉斯维加斯? 在这种情况下,决策是不可撤销的,因为无论选择哪个城市,他们都会建造一个新体育场。

不确定性是什么?

每个决策都存在不确定性。在是否留在奥克兰还是迁往拉斯维加斯的决策中,A 队不确定新体育场的成本和随后的运营收入:1)他们将获得多少公共资金用于建造新体育场,2)他们将从票务销售中产生多少收入,以及 3)他们将从地方电视合同中产生多少收入。

A 队目前希望在拉斯维加斯建造一座价值 15 亿美元的体育场。[1] 回到 2021 年,该组织曾要求 855 百万美元的公共资金来帮助在奥克兰建造新体育场,尽管之前与市政府和县政府达成了新体育场将由私人资金资助的协议。[1] 因此,我们可以合理地假设,建造体育场的成本在两个地方大致相同。唯一的不确定性是有多少纳税人的钱将用于资助体育场。

票务收入在不同球队之间差异巨大,从 2700 万到 1.31 亿美元不等,中位数约为 7500 万。[4] 奥克兰的票务收入估计为约 5500 万。[4]

MLB 的电视收入通过 MLB 谈判的全国电视合同均匀分配。然而,个别球队电视收入的一个重要组成部分来自地区体育网络(RSN)。球队可以保留来自地方电视合同的大部分收入,尽管仍有大量的收入共享。经过收入共享后,RSN 的电视合同收入从 3600 万到 1.31 亿美元不等,除了最有价值的球队之外,其余球队的收入都少于 6000 万。[4]

多亏了几年前突袭者(NFL)从奥克兰迁往拉斯维加斯,我们知道拉斯维加斯市愿意提供 7.5 亿美元的公共资金来建造一座全新的足球场。[5] 我们还知道,无论是当地人还是游客,都愿意加入并支持一支新的职业球队,因为突袭者在 2021 年以 1.19 亿美元的票务收入领先 NFL。[6]

有些方法超出了本博客的范围,这些方法旨在询问决策者对这些不确定性可能结果及其概率的先验信念。此外,我怀疑 John Fischer 是否准备为我的博客做出评论。因此,在此期间,我将使用从这些网络来源中汇总的信息,提供每个不确定性的一些可能场景。

图片由作者创建。

我们的决策时间范围是什么?

当然,收入是年度数据,体育场应该使用远超过一年。时间范围可以根据决策的背景和决策者对景观变化可能性的看法而有所不同。从数据科学的角度来看,这与数据漂移相符,其中用于训练模型的数据与当前数据不同。现在,假设这些估计在十年内保持相对稳定,我们将使用 10 年的时间范围以及 3%的折现率来计算我们的年度成本。

决策树是什么样的?

现在我们已经定义了决策树的所有组件,是时候建立树了。概念上,它看起来是这样的:

图片由作者创建。

方形节点是决策节点,圆形节点是机会节点,三角形节点是终端节点。由于空间限制,图像中无法显示整个树,但每个节点都有相关的概率和值。

我们如何在 Python 中构建模型?

在决策分析中,建立决策树的构造后,我们可以通过“回滚”树来识别最佳决策。在此示例中,我们假设决策者是理性的(即期望值)决策者。因此,我们首先列出终端状态的相关值(如果适用),这将成为我们的累计总额或期望值。在这种情况下,这不适用,因此我们从$0 开始。然后,我们迭代计算终端节点左侧每组节点的期望值,并将其添加到累计总额或期望值中。最后,我们将得到一个留在奥克兰的决策期望值和一个迁移到拉斯维加斯的决策期望值。

让我们从一个简单的基本情况设置开始。我们将创建一个包含所有可能的决策、公款、票务销售和 RSN 收入情景的数据框。

import numpy as np
import pandas as pd

# Create data frame of all possible outcomes
decision_list = ['Oakland', 'Las Vegas']

# First Node
chance_node_stadium_money_scenarios = ['Optimistic', 'Neutral', 'Pessimistic']
chance_node_stadium_money_probabilities_oakland = [0.1, 0.3, 0.6]
chance_node_stadium_money_probabilities_vegas = [0.5, 0.4, 0.1]
chance_node_stadium_money_values = [855, 500, 0]

#Second Node
chance_node_ticket_sales_scenarios = ['Optimistic', 'Neutral', 'Pessimistic']
chance_node_ticket_sales_probabilities_oakland = [0.2, 0.2, 0.6]
chance_node_ticket_sales_probabilities_vegas = [0.3, 0.4, 0.3]
chance_node_ticket_sales_values_per_year = [80, 55, 27]

# Third Node
chance_node_rsn_revenue_scenarios = ['Optimistic', 'Neutral', 'Pessimistic']
chance_node_rsn_revenue_probabilities_oakland = [0.15, 0.5, 0.35]
chance_node_rsn_revenue_probabilities_vegas = [0.1, 0.3, 0.6]
chance_node_rsn_revenue_values_per_year = [60, 45, 36]

# Convert annual values to NPV of 10 year time horizon
time_horizon = 10 # years
discount_rate = 0.03 # per year
chance_node_ticket_sales_values = [val * (1 - (1/((1 + discount_rate)**time_horizon)))/discount_rate for val in chance_node_ticket_sales_values_per_year]
chance_node_rsn_revenue_values = [val * (1 - (1/((1 + discount_rate)**time_horizon)))/discount_rate for val in chance_node_rsn_revenue_values_per_year]

# Create data frame of all possible scenarios
decision_list_list_for_df = []
chance_node_stadium_money_list_for_df = []
chance_node_stadium_money_probability_list_for_df = []
chance_node_stadium_money_value_list_for_df = []
chance_node_ticket_sales_list_for_df = []
chance_node_ticket_sales_probability_list_for_df = []
chance_node_ticket_sales_value_list_for_df = []
chance_node_rsn_revenue_list_for_df = []
chance_node_rsn_revenue_probability_list_for_df = []
chance_node_rsn_revenue_value_list_for_df = []

for i in decision_list:
    for j in range(len(chance_node_stadium_money_scenarios)):
        for k in range(len(chance_node_rsn_revenue_scenarios)):
            for m in range(len(chance_node_rsn_revenue_scenarios)):
                decision_list_list_for_df.append(i)
                chance_node_stadium_money_list_for_df.append(chance_node_stadium_money_scenarios[j])
                chance_node_stadium_money_value_list_for_df.append(chance_node_stadium_money_values[j])
                chance_node_ticket_sales_list_for_df.append(chance_node_ticket_sales_scenarios[k])
                chance_node_ticket_sales_value_list_for_df.append(chance_node_ticket_sales_values[k])
                chance_node_rsn_revenue_list_for_df.append(chance_node_rsn_revenue_scenarios[m])
                chance_node_rsn_revenue_value_list_for_df.append(chance_node_rsn_revenue_values[m])

                if i == 'Oakland':
                    chance_node_stadium_money_probability_list_for_df.append(chance_node_stadium_money_probabilities_oakland[j])
                    chance_node_ticket_sales_probability_list_for_df.append(chance_node_ticket_sales_probabilities_oakland[k])
                    chance_node_rsn_revenue_probability_list_for_df.append(chance_node_rsn_revenue_probabilities_oakland[m])
                elif i == 'Las Vegas':
                    chance_node_stadium_money_probability_list_for_df.append(chance_node_stadium_money_probabilities_vegas[j])
                    chance_node_ticket_sales_probability_list_for_df.append(chance_node_ticket_sales_probabilities_vegas[k])
                    chance_node_rsn_revenue_probability_list_for_df.append(chance_node_rsn_revenue_probabilities_vegas[m])

decision_tree_df = pd.DataFrame(list(zip(decision_list_list_for_df, chance_node_stadium_money_list_for_df,
                                         chance_node_stadium_money_probability_list_for_df,
                                         chance_node_stadium_money_value_list_for_df,
                                         chance_node_ticket_sales_list_for_df,
                                         chance_node_ticket_sales_probability_list_for_df,
                                         chance_node_ticket_sales_value_list_for_df,
                                         chance_node_rsn_revenue_list_for_df,
                                         chance_node_rsn_revenue_probability_list_for_df,
                                         chance_node_rsn_revenue_value_list_for_df)),
                               columns = ['Decision',
                                          'Stadium_Money_Result', 'Stadium_Money_Prob', 'Stadium_Money_Value',
                                          'Ticket_Sales_Result', 'Ticket_Sales_Prob', 'Ticket_Sales_Value', 
                                          'RSN_Revenue_Result', 'RSN_Revenue_Prob', 'RSN_Revenue_Value'])

现在,如果你打印你的决策树,你将得到一个包含 54 行和 10 列的 pandas dataframe。我们可以通过创造性地使用 groupby 和 merge 函数轻松地回滚决策树。我们从为每种决策、体育场资金和票务销售的组合列出 RSN 收入的期望值开始:

decision_tree_df['RSN_EV'] = decision_tree_df['RSN_Revenue_Prob'] * decision_tree_df['RSN_Revenue_Value']

# Consolidate the RSN_EV values
RSN_rollback_df = decision_tree_df.groupby(['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob', 'Ticket_Sales_Result', 'Ticket_Sales_Prob'])['RSN_EV'].sum().reset_index()

# Keep the rest of the columns
decision_tree_df = decision_tree_df.groupby(['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob', 'Ticket_Sales_Result', 'Ticket_Sales_Prob'])['Stadium_Money_Value', 'Ticket_Sales_Value'].mean().reset_index()

# merge two dataframes
decision_tree_df = pd.merge(decision_tree_df, RSN_rollback_df, on = ['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob', 'Ticket_Sales_Result', 'Ticket_Sales_Prob'])

结果表格已经缩小,现在你可以直观地看到回滚的 RSN 收入节点的期望值。

图片由作者创建

重复处理票务销售。我们有以下代码:

decision_tree_df['Ticket_Sales_RSN_EV'] = decision_tree_df['Ticket_Sales_Prob'] * decision_tree_df['Ticket_Sales_Value'] + decision_tree_df['RSN_EV']

# Consolidate the Ticket Sales and RSN_EV values
ticket_sales_rollback_df = decision_tree_df.groupby(['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob'])['Ticket_Sales_RSN_EV'].sum().reset_index()

# Keep the rest of the columns
decision_tree_df = decision_tree_df.groupby(['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob'])['Stadium_Money_Value'].mean().reset_index()

# merge two dataframes
decision_tree_df = pd.merge(decision_tree_df, ticket_sales_rollback_df, on = ['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob'])

结果如下:

图片由作者创建。

最后,重复进行公共资金贡献的标准计算:

decision_tree_df['Stadium_Money_Ticket_Sales_RSN_EV'] = decision_tree_df['Stadium_Money_Prob'] * decision_tree_df['Stadium_Money_Value'] + decision_tree_df['Ticket_Sales_RSN_EV']

# Consolidate the Stadium Money, Ticket Sales, and RSN_EV values
decision_tree_df = decision_tree_df.groupby(['Decision'])['Stadium_Money_Ticket_Sales_RSN_EV'].sum().reset_index()

图片由作者创建

在这里,我们可以看到模型计算出在 10 年的时间范围内,留在奥克兰的预期价值为 47 亿美元,而搬到拉斯维加斯的预期价值为 52 亿美元。

我们如何将模型进行概括?

当然,我们的数据和模型中都有不确定性,我们可以测试许多不同的情景。自然,我们可能会定义一些阈值或情景,在这些情况下,决策从留在奥克兰变为搬到拉斯维加斯(或反之)。这些决策点可以作为决策者的“业务规则”集合,并帮助我们作为数据科学家从分析中提取可操作的建议。

有很多方法可以实现这一目标,但在本博客中,我们将使用机器学习元建模。元建模涉及开发一个比原始数学或模拟模型更快(有时更简单)的模型,该模型采用相同的输入并产生非常相似的输出[7]。在这种情况下,我们将使用概率敏感性分析来测试决策分析决策树的大量参数空间,并记录每个参数集的结果决策。然后,我们将使用参数集作为特征,并将结果决策作为标签来训练机器学习决策树分类模型。机器学习模型的好处在于它可以揭示复杂的关系,这些关系仅靠多变量敏感性分析是难以解读的。我们的希望是,能从一个浅层树中获得足够的准确性,以描述 A 应该留在奥克兰还是搬到拉斯维加斯的情景。

首先,我们开始设计一个概率敏感性分析。对于这个例子,我们将假设机会节点的美元值保持不变,但各种结果的概率会有所不同。由于我们知道概率将在 0 到 1 之间变化,我们将假设所有情景概率是等可能的,并使用均匀分布进行建模,最小值为 0,最大值为 1。经过三次从均匀分布中抽样(分别对应乐观、中性和悲观情景),我们将结果归一化,使三个概率的总和为 1。

# Number of simulations
n_sim = 5000

# Track scenarios
oakland_stadium_money_probabilities_optimistic_list = []
oakland_stadium_money_probabilities_neutral_list = []
oakland_stadium_money_probabilities_pessimistic_list = []

oakland_ticket_sales_probabilities_optimistic_list = []
oakland_ticket_sales_probabilities_neutral_list = []
oakland_ticket_sales_probabilities_pessimistic_list = []

oakland_rsn_revenue_probabilities_optimistic_list = []
oakland_rsn_revenue_probabilities_neutral_list = []
oakland_rsn_revenue_probabilities_pessimistic_list = []

vegas_stadium_money_probabilities_optimistic_list = []
vegas_stadium_money_probabilities_neutral_list = []
vegas_stadium_money_probabilities_pessimistic_list = []

vegas_ticket_sales_probabilities_optimistic_list = []
vegas_ticket_sales_probabilities_neutral_list = []
vegas_ticket_sales_probabilities_pessimistic_list = []

vegas_rsn_revenue_probabilities_optimistic_list = []
vegas_rsn_revenue_probabilities_neutral_list = []
vegas_rsn_revenue_probabilities_pessimistic_list = []

oakland_EV_list = []
vegas_EV_list = []

decision_list = []

# Create data frame of all possible outcomes
decision_list = ['Oakland', 'Las Vegas']

# First Node
chance_node_stadium_money_scenarios = ['Optimistic', 'Neutral', 'Pessimistic']
chance_node_stadium_money_values = [855, 500, 0]

#Second Node
chance_node_ticket_sales_scenarios = ['Optimistic', 'Neutral', 'Pessimistic']
chance_node_ticket_sales_values_per_year = [80, 55, 27]

# Third Node
chance_node_rsn_revenue_scenarios = ['Optimistic', 'Neutral', 'Pessimistic']
chance_node_rsn_revenue_values_per_year = [60, 45, 36]

# Convert annual values to NPV of 10 year time horizon
time_horizon = 10 # years
discount_rate = 0.03 # per year
chance_node_ticket_sales_values = [val * (1 - (1/((1 + discount_rate)**time_horizon)))/discount_rate for val in chance_node_ticket_sales_values_per_year]
chance_node_rsn_revenue_values = [val * (1 - (1/((1 + discount_rate)**time_horizon)))/discount_rate for val in chance_node_rsn_revenue_values_per_year]

# Run the probabilistic sensitivity analysis n_sim times
for n in range(n_sim):

    ## Set up tree
    #First node
    chance_node_stadium_money_probabilities_oakland = np.random.uniform(0,1,3)
    chance_node_stadium_money_probabilities_oakland = chance_node_stadium_money_probabilities_oakland / np.sum(chance_node_stadium_money_probabilities_oakland)

    chance_node_stadium_money_probabilities_vegas = np.random.uniform(0,1,3)
    chance_node_stadium_money_probabilities_vegas = chance_node_stadium_money_probabilities_vegas / np.sum(chance_node_stadium_money_probabilities_vegas)

    #Second Node
    chance_node_ticket_sales_probabilities_oakland = np.random.uniform(0,1,3)
    chance_node_ticket_sales_probabilities_oakland = chance_node_ticket_sales_probabilities_oakland / np.sum(chance_node_ticket_sales_probabilities_oakland)

    chance_node_ticket_sales_probabilities_vegas = np.random.uniform(0,1,3)
    chance_node_ticket_sales_probabilities_vegas = chance_node_ticket_sales_probabilities_vegas / np.sum(chance_node_ticket_sales_probabilities_vegas)

    # Third Node
    chance_node_rsn_revenue_probabilities_oakland = np.random.uniform(0,1,3)
    chance_node_rsn_revenue_probabilities_oakland = chance_node_rsn_revenue_probabilities_oakland / np.sum(chance_node_rsn_revenue_probabilities_oakland)

    chance_node_rsn_revenue_probabilities_vegas = np.random.uniform(0,1,3)
    chance_node_rsn_revenue_probabilities_vegas = chance_node_rsn_revenue_probabilities_vegas / np.sum(chance_node_rsn_revenue_probabilities_vegas)

    # Evaluate Tree
    # Create data frame of all possible scenarios
    decision_list_list_for_df = []
    chance_node_stadium_money_list_for_df = []
    chance_node_stadium_money_probability_list_for_df = []
    chance_node_stadium_money_value_list_for_df = []
    chance_node_ticket_sales_list_for_df = []
    chance_node_ticket_sales_probability_list_for_df = []
    chance_node_ticket_sales_value_list_for_df = []
    chance_node_rsn_revenue_list_for_df = []
    chance_node_rsn_revenue_probability_list_for_df = []
    chance_node_rsn_revenue_value_list_for_df = []

    for i in decision_list:
        for j in range(len(chance_node_stadium_money_scenarios)):
            for k in range(len(chance_node_rsn_revenue_scenarios)):
                for m in range(len(chance_node_rsn_revenue_scenarios)):
                    decision_list_list_for_df.append(i)
                    chance_node_stadium_money_list_for_df.append(chance_node_stadium_money_scenarios[j])
                    chance_node_stadium_money_value_list_for_df.append(chance_node_stadium_money_values[j])
                    chance_node_ticket_sales_list_for_df.append(chance_node_ticket_sales_scenarios[k])
                    chance_node_ticket_sales_value_list_for_df.append(chance_node_ticket_sales_values[k])
                    chance_node_rsn_revenue_list_for_df.append(chance_node_rsn_revenue_scenarios[m])
                    chance_node_rsn_revenue_value_list_for_df.append(chance_node_rsn_revenue_values[m])

                    if i == 'Oakland':
                        chance_node_stadium_money_probability_list_for_df.append(chance_node_stadium_money_probabilities_oakland[j])
                        chance_node_ticket_sales_probability_list_for_df.append(chance_node_ticket_sales_probabilities_oakland[k])
                        chance_node_rsn_revenue_probability_list_for_df.append(chance_node_rsn_revenue_probabilities_oakland[m])
                    elif i == 'Las Vegas':
                        chance_node_stadium_money_probability_list_for_df.append(chance_node_stadium_money_probabilities_vegas[j])
                        chance_node_ticket_sales_probability_list_for_df.append(chance_node_ticket_sales_probabilities_vegas[k])
                        chance_node_rsn_revenue_probability_list_for_df.append(chance_node_rsn_revenue_probabilities_vegas[m])

    decision_tree_df = pd.DataFrame(list(zip(decision_list_list_for_df, chance_node_stadium_money_list_for_df,
                                             chance_node_stadium_money_probability_list_for_df,
                                             chance_node_stadium_money_value_list_for_df,
                                             chance_node_ticket_sales_list_for_df,
                                             chance_node_ticket_sales_probability_list_for_df,
                                             chance_node_ticket_sales_value_list_for_df,
                                             chance_node_rsn_revenue_list_for_df,
                                             chance_node_rsn_revenue_probability_list_for_df,
                                             chance_node_rsn_revenue_value_list_for_df)),
                                   columns = ['Decision',
                                              'Stadium_Money_Result', 'Stadium_Money_Prob', 'Stadium_Money_Value',
                                              'Ticket_Sales_Result', 'Ticket_Sales_Prob', 'Ticket_Sales_Value', 
                                              'RSN_Revenue_Result', 'RSN_Revenue_Prob', 'RSN_Revenue_Value'])
    decision_tree_df['RSN_EV'] = decision_tree_df['RSN_Revenue_Prob'] * decision_tree_df['RSN_Revenue_Value']

    # Consolidate the RSN_EV values
    RSN_rollback_df = decision_tree_df.groupby(['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob', 'Ticket_Sales_Result', 'Ticket_Sales_Prob'])['RSN_EV'].sum().reset_index()

    # Keep the rest of the columns
    decision_tree_df = decision_tree_df.groupby(['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob', 'Ticket_Sales_Result', 'Ticket_Sales_Prob'])['Stadium_Money_Value', 'Ticket_Sales_Value'].mean().reset_index()

    # merge two dataframes
    decision_tree_df = pd.merge(decision_tree_df, RSN_rollback_df, on = ['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob', 'Ticket_Sales_Result', 'Ticket_Sales_Prob'])

    decision_tree_df['Ticket_Sales_RSN_EV'] = decision_tree_df['Ticket_Sales_Prob'] * decision_tree_df['Ticket_Sales_Value'] + decision_tree_df['RSN_EV']

    # Consolidate the Ticket Sales and RSN_EV values
    ticket_sales_rollback_df = decision_tree_df.groupby(['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob'])['Ticket_Sales_RSN_EV'].sum().reset_index()

    # Keep the rest of the columns
    decision_tree_df = decision_tree_df.groupby(['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob'])['Stadium_Money_Value'].mean().reset_index()

    # merge two dataframes
    decision_tree_df = pd.merge(decision_tree_df, ticket_sales_rollback_df, on = ['Decision', 'Stadium_Money_Result', 'Stadium_Money_Prob'])

    decision_tree_df['Stadium_Money_Ticket_Sales_RSN_EV'] = decision_tree_df['Stadium_Money_Prob'] * decision_tree_df['Stadium_Money_Value'] + decision_tree_df['Ticket_Sales_RSN_EV']

    # Consolidate the Stadium Money, Ticket Sales, and RSN_EV values
    decision_tree_df = decision_tree_df.groupby(['Decision'])['Stadium_Money_Ticket_Sales_RSN_EV'].sum().reset_index()

    # Fill out lists for meta-model inputs
    oakland_stadium_money_probabilities_optimistic_list.append(chance_node_stadium_money_probabilities_oakland[0])
    oakland_stadium_money_probabilities_neutral_list.append(chance_node_stadium_money_probabilities_oakland[1])
    oakland_stadium_money_probabilities_pessimistic_list.append(chance_node_stadium_money_probabilities_oakland[2])

    oakland_ticket_sales_probabilities_optimistic_list.append(chance_node_ticket_sales_probabilities_oakland[0])
    oakland_ticket_sales_probabilities_neutral_list.append(chance_node_ticket_sales_probabilities_oakland[1])
    oakland_ticket_sales_probabilities_pessimistic_list.append(chance_node_ticket_sales_probabilities_oakland[2])

    oakland_rsn_revenue_probabilities_optimistic_list.append(chance_node_rsn_revenue_probabilities_oakland[0])
    oakland_rsn_revenue_probabilities_neutral_list.append(chance_node_rsn_revenue_probabilities_oakland[1])
    oakland_rsn_revenue_probabilities_pessimistic_list.append(chance_node_rsn_revenue_probabilities_oakland[2])

    vegas_stadium_money_probabilities_optimistic_list.append(chance_node_stadium_money_probabilities_vegas[0])
    vegas_stadium_money_probabilities_neutral_list.append(chance_node_stadium_money_probabilities_vegas[1])
    vegas_stadium_money_probabilities_pessimistic_list.append(chance_node_stadium_money_probabilities_vegas[2])

    vegas_ticket_sales_probabilities_optimistic_list.append(chance_node_ticket_sales_probabilities_vegas[0])
    vegas_ticket_sales_probabilities_neutral_list.append(chance_node_ticket_sales_probabilities_vegas[1])
    vegas_ticket_sales_probabilities_pessimistic_list.append(chance_node_ticket_sales_probabilities_vegas[2])

    vegas_rsn_revenue_probabilities_optimistic_list.append(chance_node_rsn_revenue_probabilities_vegas[0])
    vegas_rsn_revenue_probabilities_neutral_list.append(chance_node_rsn_revenue_probabilities_vegas[1])
    vegas_rsn_revenue_probabilities_pessimistic_list.append(chance_node_rsn_revenue_probabilities_vegas[2])

    oakland_EV_list.append(decision_tree_df['Stadium_Money_Ticket_Sales_RSN_EV'][0])
    vegas_EV_list.append(decision_tree_df['Stadium_Money_Ticket_Sales_RSN_EV'][1])

    print(n)

现在我们可以将结果放入一个新的数据框中,以便用于训练我们的机器学习模型:

decision_tree_psa_data_df = pd.DataFrame(list(zip(oakland_stadium_money_probabilities_optimistic_list, 
                                         oakland_stadium_money_probabilities_neutral_list,
                                         oakland_stadium_money_probabilities_pessimistic_list,
                                         oakland_ticket_sales_probabilities_optimistic_list,
                                         oakland_ticket_sales_probabilities_neutral_list,
                                         oakland_ticket_sales_probabilities_pessimistic_list,
                                         oakland_rsn_revenue_probabilities_optimistic_list,
                                         oakland_rsn_revenue_probabilities_neutral_list,
                                         oakland_rsn_revenue_probabilities_pessimistic_list,
                                         vegas_stadium_money_probabilities_optimistic_list,
                                         vegas_stadium_money_probabilities_neutral_list, 
                                         vegas_stadium_money_probabilities_pessimistic_list,
                                         vegas_ticket_sales_probabilities_optimistic_list,
                                         vegas_ticket_sales_probabilities_neutral_list,
                                         vegas_ticket_sales_probabilities_pessimistic_list,
                                         vegas_rsn_revenue_probabilities_optimistic_list,
                                         vegas_rsn_revenue_probabilities_neutral_list, 
                                         vegas_rsn_revenue_probabilities_pessimistic_list,
                                         oakland_EV_list, vegas_EV_list)),
                                   columns = ['oakland_stad_mon_prob_optimistic',
                                              'oakland_stad_mon_prob_neutral',
                                              'oakland_stad_mon_prob_pessimistic',
                                              'oakland_ticket_sales_prob_optimistic',
                                              'oakland_ticket_sales_prob_neutral',
                                              'oakland_ticket_sales_prob_pessimistic',
                                              'oakland_rsn_rev_prob_optimistic',
                                              'oakland_rsn_rev_prob_neutral',
                                              'oakland_rsn_rev_prob_pessimistic',
                                              'vegas_stad_mon_prob_optimistic',
                                              'vegas_stad_mon_prob_neutral',
                                              'vegas_stad_mon_prob_pessimistic',
                                              'vegas_ticket_sales_prob_optimistic',
                                              'vegas_ticket_sales_prob_neutral',
                                              'vegas_ticket_sales_prob_pessimistic',
                                              'vegas_rsn_rev_prob_optimistic',
                                              'vegas_rsn_rev_prob_neutral',
                                              'vegas_rsn_rev_prob_pessimistic',
                                              'oakland_EV', 'vegas_EV'])

# Add decision based on EV
decision_tree_psa_data_df['decision'] = 'Oakland'
decision_tree_psa_data_df.loc[decision_tree_psa_data_df['vegas_EV'] > decision_tree_psa_data_df['oakland_EV'],'decision'] = 'Las Vegas'

我们将使用sci-kit learn 包来训练一个基本的机器学习决策树。由于输入数据是 0 到 1 之间的概率,并且我们使用的是基于树的模型,所以不需要进行特征缩放或工程。为了博客的可视化目的,我将树的深度限制为 3。然而,树的深度越大,越有可能实现更高的准确度。

from sklearn.datasets import load_iris
from sklearn.model_selection import cross_val_score
from sklearn.model_selection import train_test_split
from sklearn import tree

#Features
X = decision_tree_psa_data_df.drop(['oakland_EV', 'vegas_EV', 'decision'], axis = 1)
#labels
y = decision_tree_psa_data_df['decision']

# split into train (70%) and test set (30%)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=32)

# Create decision tree model with maximum depth of 3 to keep recommendation managable
dec_tree_model = tree.DecisionTreeClassifier(random_state=32, max_depth = 3, class_weight = 'balanced')
dec_tree_model = dec_tree_model.fit(X_train, y_train)

我们的模型最终得到了一个不错但不完美的 AUC,接近 0.8。 (AUC 是基于真正和假正率来衡量模型准确性的一种方法。有关模型准确性度量的更多信息,请查看我之前关于评估 ESPN 幻想足球预测分数准确性的博客这里。) 这对于我们继续进行练习来说足够尊重。当然,还有很多方法可以提高决策树分类器的准确性,包括增加最大深度、超参数调整或运行更多的模拟以增加数据量。

from sklearn.metrics import roc_auc_score
roc_auc_score(y_test, dec_tree_model.predict_proba(X_test)[:, 1])

现在我们对性能满意,可以直观地检查训练的决策树。树中的每个分裂表示一组业务规则的另一个维度。在打印的树中的每个框(或叶子)中,第一行将表示模型用于分割数据的规则,第二行是基尼指数,它描述了叶子中的类别分布(其中 0.5 表示每个类别的数量相等,0 或 1 表示只有一个类别),第三行显示每个类别的样本数量,第四行显示模型分配给该叶子中所有样本的标签。我们可以打印出结果树如下:

# Plot decision tree results to see how decisions were made
import matplotlib.pyplot as plt
fig = plt.figure(figsize = (14,14))
tree.plot_tree(dec_tree_model, filled = True, feature_names = X.columns, fontsize = 8, class_names = ['Las Vegas', 'Oakland'])
plt.show()

图片由作者创建。

从我们的机器学习决策树中,我们可以看到,A 队是否应该留在奥克兰或迁移到拉斯维加斯的分类首先依赖于乐观的 RSN 收入概率,其次是与奥克兰票务销售相关的概率。

拉斯维加斯可能是首选目的地,当:

  • 拉斯维加斯的乐观 RSN 收入的概率大于 0.4(除非奥克兰的乐观 RSN 收入的概率大于 0.341 并且奥克兰的乐观票务销售的概率大于 0.355)

  • 或者乐观的 RSN 收入在奥克兰的概率小于或等于 0.468,并且乐观的票务销售在拉斯维加斯的概率大于 0.438。

有趣的是,尽管媒体对新球场的公共或私人资金进行了一番喧哗,我们的模型却指向了 RSN 收入和票务销售。这种差异可能是由于我们 10 年的时间范围,或者可能是组织寻找一个经过 MLB 批准的借口来离开奥克兰。不管怎样,这种方法突显了数据科学团队可以向决策者提供的一个重要见解,以便为商业战略提供信息。 这种方法可以将你的模型从有趣的理论练习转变为改变 C-suite 中的思维。

我们如何验证机器学习模型?

鉴于我们正在尝试提供一个极其重要的决策信息,确保我们的模型对输入数据的差异或标签的不平衡类集具有鲁棒性非常重要。为了考虑后者,你会注意到,我们在创建机器学习模型时包含了 class_weight = ‘balanced’。为了考虑前者以及模型验证,我们可以使用交叉验证得分来查看其他训练/测试分割性能指标是什么:

# 10-fold cross-validation scores
cross_val_score(dec_tree_model, X, y, cv=10)

输出如下:array([0.724, 0.722, 0.718, 0.72 , 0.722, 0.708, 0.732, 0.726, 0.76, 0.702]),这告诉我们,在 10 种不同的训练/测试分割中,我们的模型表现相似。

我们学到了什么?

有了这一点,我们已经从关于 A 队棒球队搬迁的商业问题,回溯决策分析决策树模型,揭示为什么 A 队可能会前往拉斯维加斯,再到利用机器学习决策树将我们的结果推广成管理层可以用来决定是否重新定位的可消化商业规则。希望你能利用类似的方法或方法来通知你自己组织或日常生活中的决策者。

参考文献

[1] Sutelan, E, 亚特兰大运动员拉斯维加斯搬迁时间表:球场挫折,资金失败通往 A 队离开奥克兰的道路 (2023), 体育新闻

[2] Kenton, W, 决策分析(DA):定义、用途和示例 (2022), Investopedia

[3] Howard, R. 和 Abbas, A, 决策分析基础 (2014)

[4] Morss, E., 大联盟棒球财务:数字告诉我们什么 (2019), Morss 全球金融

[5] Greer, J., 为什么突袭者队迁移到拉斯维加斯?解释 2020 年从奥克兰迁移到罪恶之城的球队 (2020), 体育新闻

[6] Andre, D. 报告:突袭者队在 2021 年 NFL 票务收入中排名第一 (2022), Fox 5 拉斯维加斯

[7] Malloy, G. 和 Brandeau, M. 何时大规模预防对流行病控制具有成本效益?决策方法的比较 (2022), 医学决策制定

对我的内容感兴趣吗?请考虑在 Medium 上关注我

所有代码和数据可以在 GitHub 上找到: gspmalloy/oakland_as_decision_trees: 我博客“决策分析和 Python 中的决策树——奥克兰 A 队的案例”的代码 (github.com)

在 Twitter 上关注我:@malloy_giovanni

你认为 A 队应该留在奥克兰吗?搬到拉斯维加斯?还是尝试其他城市?你使用机器学习进行元建模的体验如何?我很想听听你的想法!通过评论保持讨论的进行。

决策科学与设计的结合

原文:towardsdatascience.com/decision-science-meets-design-fb30eaa0ded9

深入探讨通过深度强化学习解决生成设计问题

Houssame E. HsainTowards Data Science Houssame E. Hsain

·发表于Towards Data Science ·阅读时间约 9 分钟·2023 年 10 月 27 日

--

图片由Igor Omilaev提供,来源于Unsplash

过去几十年间,设计过程发生了巨大的变化。曾经由人类直觉、判断和审美偏好驱动的领域,现在被计算方法和数据驱动过程所增强。这一过渡通过数据科学与设计的交集得到了体现,这是一个精确与创造力相遇的交汇点。

数据驱动技术在设计中的实用性在其子领域生成设计中得到了很好的展示,这是一种使用计算算法根据预定义标准生成多个设计变体的方法。然而,随着这些设计问题变得越来越复杂和多维,需要更复杂的技术来寻找令人满意的解决方案。这时,决策科学,特别是强化学习,就发挥了作用。

将决策科学应用于生成设计

设计的核心不仅仅是创造,而是一系列有目的的决策,这些决策导致了创造的形成。

决策科学的基本原则是通过评估在特定背景下可用选项的预测或已知后果来做出明智的选择。它包含定量统计方法与优化过程的结合。当应用于生成设计时,决策科学可以帮助确定哪些设计决策或决策序列可以改善某个配置或设计实例。这个过程需要三个组成部分:

  • 评估设计: 评估每种变体的性能或质量,以了解每个设计选择对预期结果的贡献

  • 优化:综合设计选择序列,以产生可行且令人满意的设计变体

  • 情景分析:通过在不同的背景和约束下做出设计决策来探索各种设计可能性

将生成设计问题框架化为马尔可夫决策过程(MDPs)

简单的马尔可夫决策过程(插图由作者制作)

在深入探讨生成设计中的深度强化学习(DRL)之前,将这些设计问题框架化为马尔可夫决策过程(MDPs)是至关重要的。但什么是 MDP?

MDPs 是一种数学框架,用于建模在结果部分由概率动态和部分由决策者行为决定的设置中的决策过程。它包括以下主要组成部分:

  • 状态(S):表示不同的情景或条件。

  • 行动(A):表示每个状态下可用的选择。

  • 过渡(P):表示在采取行动后,从一个状态转移到另一个状态的概率。

  • 奖励(R):表示在某状态下采取行动后的反馈或结果。

在生成设计的背景下,我们可以将状态视为设计配置,将行动视为设计修改,将过渡视为从一个初始设计配置转移到另一个的可能性。奖励则是向设计师传达设计实例性能度量的反馈,并指导整个设计过程。

通过深度强化学习(DRL)解决生成设计问题

强化学习训练闭环(插图由作者制作)

强化学习(RL)的目标是通过试错过程学习执行任务的最佳行动策略。在我们的上下文中,代理即适应性设计策略,通过采取行动(修改设计)并根据结果(效率或性能)获得奖励或惩罚,从环境中学习。

在处理设计问题中的大状态和行动空间时,挑战就会出现。这时,深度学习,特别是深度强化学习(DRL),变得非常宝贵。DRL 将 RL 的决策能力与深度学习的强大函数逼近能力结合起来。简单来说,它利用神经网络预测在大型和复杂设计场景中应采取的最佳行动。

深度强化学习(DRL)实践:优化建筑物在地形上的布局

将建筑物质量放置在地形上,并计算所需的开挖和填充体积(动画由作者制作)

考虑在不平坦地形上建造建筑物的挑战。设计师可能需要考虑将建筑物放置在减少土方(挖掘和填充)量的地方。地形中的每一个可能位置代表一个动作,产生的挖填量代表奖励(或在这种情况下的惩罚)。

我们将通过一个工作流程展示如何训练 DRL 代理在地形上放置建筑物,同时最小化所需的挖填量。

定义观察和动作空间

我们首先定义了 DRL 代理的动作空间。代理控制建筑质量的三个参数:其 x 和 y 坐标以及旋转角度(theta)。我们使用 3 维离散动作空间表示。至于观察空间,使用包含建筑位置的地形图像帧来表示我们环境的状态。

import numpy as np
import torch

# 3-dim action space
param1_space = np.linspace(start=0.1, stop=0.9, num=17)
param2_space = np.linspace(start=0.1, stop=0.9, num=17)
param3_space = np.linspace(start=0, stop=160, num=17)

# Define action space
param1_space = torch.from_numpy(param1_space)
param2_space = torch.from_numpy(param2_space)
param3_space = torch.from_numpy(param3_space)

奖励函数

代理的主要目标是最小化建造建筑物所需的土方。为此,奖励函数根据挖填量对代理进行惩罚。

代理在每一步都会收到一个等同于放置建筑所需的挖填量的惩罚值。在多建筑设置中,如果建筑质量在训练过程中与任何之前定位的建筑质量相交,还会有额外的-5 惩罚。奖励信号在Rhinoceros 3D Grasshopper环境中根据以下代码计算:

# Grasshopper reward computation code
try:
    from ladybug_rhino.grasshopper import all_required_inputs
except ImportError as e:
    raise ImportError('\nFailed to import ladybug_rhino:\n\t{}'.format(e))

if all_required_inputs(ghenv.Component):
    reward = 0
    reward -= Soil_volume / 1000
    done = False

    bInter_relationList = [list(i) for i in bInter_relation.Branches]

    if len(bInter_relationList[0]) > 1:
        for i in bInter_relationList[0]:
            # building mass is inside some previously placed one
            if i == 0:
                reward -= 5
            # building mass intersects with some previously placed one
            elif i == 1:
                reward -= 5
        # compensate for self-intersection
        reward += 5

代理与环境之间的连接

在建立了观察和动作空间以及奖励函数之后,有必要促进 DRL 代理与 Grasshopper 模拟环境之间的互动。这是通过使用套接字进行协调的,这是一种流行的进程间通信方法。

# Define Socket connection between Grasshopper and RL agent in Python
import socket

HOST = '127.0.0.1'
timeout = 20

def done_from_gh_client(socket):
    socket.listen()
    conn, _ = socket.accept()
    with conn:
        return_byt = conn.recv(5000)
    return_str = return_byt.decode() 

    return eval(return_str)

def reward_from_gh_client(socket):
    socket.listen()
    conn, _ = socket.accept()
    with conn:
        return_byt = conn.recv(5000)
    return_str = return_byt.decode()
    if return_str == 'None':
        return_float = 0
    else:
        return_float = float(return_str) 

    return return_float

def fp_from_gh_client(socket):
    socket.listen()
    conn, _ = socket.accept()
    with conn:
        return_byt = conn.recv(5000)
    fp = return_byt.decode()

    return fp

def send_ep_count_to_gh_client(socket, message):
    message_str = str(message)
    message_byt = message_str.encode()

    socket.listen()
    conn, _ = socket.accept()
    with conn:
        conn.send(message_byt)

def send_to_gh_client(socket, message):
    message_str = ''
    for item in message:
        listToStr = ' '.join(map(str, item))
        message_str = message_str + listToStr + '\n'

    message_byt = message_str.encode()
    socket.listen()
    conn, _ = socket.accept()
    with conn:
        conn.send(message_byt)

DRL 演员评论家模型定义

在定义了各种通信实用函数后,我们定义并初始化 DRL 模型和 ADAM 优化器用于训练:

import torch 
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.autograd import Variable
from torch.distributions import Categorical

# Actor Critic Model Architecture
def enc_block(in_c, out_c, BN=True):
    if BN:
        conv = nn.Sequential(
            nn.Conv2d(in_c, out_c, kernel_size=4, stride=2, 
                      padding=1, bias=True),
            nn.BatchNorm2d(out_c),
            nn.LeakyReLU(negative_slope=0.2, inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )
        return conv
    else:
        conv = nn.Sequential(
            nn.Conv2d(in_c, out_c, kernel_size=4, stride=2, 
                      padding=1, bias=True),
            nn.LeakyReLU(negative_slope=0.2, inplace=True)
        )
        return conv

class GRUpolicy(nn.Module):
    def __init__(self, n_gru_layers, hidden_size, lin_size1, lin_size2, 
                  enc_size1, enc_size2, enc_size3):
        super(GRUpolicy, self).__init__()

        #critic
        self.critic_enc1 = enc_block(3, enc_size1, BN=False)
        self.critic_enc2 = enc_block(enc_size1, enc_size2, BN=True)
        self.critic_enc3 = enc_block(enc_size2, enc_size3, BN=True)
        self.critic_enc4 = enc_block(enc_size3, 128, BN=True)

        self.critic_linear1 = nn.Linear(512, lin_size1)
        self.critic_linear2 = nn.Linear(lin_size1, lin_size2)
        self.critic_linear3 = nn.Linear(lin_size2, 1)

        # actor
        self.gru1 = nn.GRU(4, hidden_size, n_gru_layers, batch_first=True)
        self.gru2 = nn.GRU(4, hidden_size, n_gru_layers, batch_first=True)
        self.gru3 = nn.GRU(4, hidden_size, n_gru_layers, batch_first=True)
        self.actor_linear = nn.Linear(hidden_size, 17)

    def forward(self, state):
        state = Variable(state.unsqueeze(0))

        # critic
        enc = self.critic_enc1(state)
        enc = self.critic_enc2(enc)
        enc = self.critic_enc3(enc)
        enc = self.critic_enc4(enc)

        value = F.relu(self.critic_linear1(torch.flatten(enc)))
        value = F.relu(self.critic_linear2(value))
        value = self.critic_linear3(value)

        # actor
        seq = torch.reshape(enc, (1, 128, 4))

        out1, h_1 = self.gru1(seq)
        out_s1 = torch.squeeze(out1[:, -1, :])
        out_l1 = self.actor_linear(out_s1)
        prob1 = F.softmax(out_l1, dim=-1)
        dist1 = Categorical(prob1)

        out2, h_2 = self.gru2(seq, h_1)  
        out_s2 = torch.squeeze(out2[:, -1, :])
        out_l2 = self.actor_linear(out_s2)
        prob2 = F.softmax(out_l2, dim=-1)
        dist2 = Categorical(prob2)

        out3, _ = self.gru3(seq, h_2)
        out_s3 = torch.squeeze(out3[:, -1, :])
        out_l3 = self.actor_linear(out_s3)
        prob3 = F.softmax(out_l3, dim=-1)
        dist3 = Categorical(prob3)

        return value, dist1, dist2, dist3 

# Set device
is_cuda = torch.cuda.is_available()
device = torch.device('cuda' if is_cuda else 'cpu')
print(f'Used Device: {device}')

# Initialize DRL model
actorcritic = GRUpolicy(config.n_gru_layers, config.hidden_size, 
                        config.lin_size1, config.lin_size2, 
                        config.enc_size1, config.enc_size2, 
                        config.enc_size3).to(device)

# Initialize optimizer 
ac_optimizer = optim.Adam(actorcritic.parameters(), lr=config.lr, weight_decay = 1e-6)

代理架构在 GRUpolicy 类中定义。它是一种演员-评论家架构。演员在给定状态下提供动作的概率分布,而评论家估计该状态的价值,即从该状态开始并遵循代理策略的期望回报。

代理训练

一旦定义了代理与环境之间的套接字连接,模型架构得以实现,并且模型的一个实例被初始化,我们就准备好训练 DRL 代理以正确地在地形上放置建筑物质量。

实验的核心是训练循环。在这里,代理在多个训练回合中反复与环境互动,遵循以下步骤:

  • 一旦代理收到当前状态观察,它会根据其当前策略选择一个动作。
# model forward pass
value, dist1, dist2, dist3 = actorcritic.forward(state) 

# get action from probability distributions
param1 = param1_space[dist1.sample()]
param2 = param2_space[dist2.sample()]
param3 = param3_space[dist3.sample()] 

action = [param1, param2, param3]
  • 智能体随后将动作发送到 Grasshopper,并获得结果奖励和新状态。
# Send action through socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, 8080))
    s.settimeout(timeout)
    send_to_gh_client(s, action)

# Send episode count through socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, 8083))
    s.settimeout(timeout)
    send_ep_count_to_gh_client(s, episode)

####### Awaiting Grasshopper script response #######

# Receive observation from Grasshopper Client
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, 8084))
    s.settimeout(timeout)
    fp = fp_from_gh_client(s)

# Receive Reward from Grasshopper Client
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, 8081))
    s.settimeout(timeout)
    reward = reward_from_gh_client(s)

# Receive done from Grasshopper Client
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, 8082))
    s.settimeout(timeout)
    done = done_from_gh_client(s)
  • 然后,它会根据接收到的阶段性奖励更新其策略。
# compute loss functions
returns = []
for t in reversed(range(len(rewards))):
    Qval = rewards[t] + config.gamma * Qval
    returns.insert(0, Qval)

returns = torch.cat(returns).detach()
values = torch.cat(values)
log_probs = torch.cat(log_probs)

advantage = returns - values

actor_loss = -(log_probs * advantage.detach()).mean() 
critic_loss = 0.5 * advantage.pow(2).mean() 
ac_loss = actor_loss + critic_loss - config.beta * entropy

# update actor critic
ac_optimizer.zero_grad()
ac_loss.backward()
ac_optimizer.step()

这个过程会持续进行几个训练迭代,直到智能体收敛到一个最优的建筑质量布置,以最小化地形切割和填充体积。这个由奖励反馈指导的迭代试错学习过程,结果产生了优化的设计配置。

DRL 智能体收敛到一个最小化必要切割和填充体积的建筑位置。地形中的渐变着色代表了其坡度(动画作者提供)

通过这个实验,你可以看到 DRL 智能体如何被调整以应对现实世界中的设计挑战。这种由 DRL 驱动的生成设计方法,展示了数据科学与设计交汇处未来探索的有希望的途径。

所有关于这个实验的实现和细节,包括在 Grasshopper 中的环境实现以及相关的 Rhinoceros 3D 文件,都可以在 GitHub 上的 CutnFill_DeepRL 库中找到。

结论

通过将生成设计问题框架设为 MDP,并利用深度强化学习的力量,我们可以更广泛地探索设计空间,更客观地评估设计,并更有效地优化它们。像 DRL 这样的计算技术在设计实践中变得越来越普遍。数据科学与设计的融合预示着一个未来,在这个未来中,美观且经过严谨信息化的设计方案将被自动生成和评估,从而实现一个快速有效的迭代设计过程,其中人类和机器协作,产生智能设计解决方案。

决策树回归器——Scikit Learn 的可视化指南

原文:towardsdatascience.com/decision-tree-regressor-a-visual-guide-with-scikit-learn-2aa9e01f5d7f

图片由 niko photos 提供,发布在 Unsplash

无需数学知识了解决策树

Angela and Kezhan ShiTowards Data Science Angela and Kezhan Shi

·发布在 Towards Data Science ·5 分钟阅读·2023 年 3 月 27 日

--

在这篇文章中,我们将使用 Python 中的 scikit-learn 实现 DecisionTreeRegressor,以可视化该模型的工作原理。我们不会使用任何数学术语,而是通过可视化来展示决策树回归器的工作原理及一些超参数的影响。

在这个背景下,决策树回归器通过将特征变量切分成小区域来预测一个连续的目标变量,每个区域将有一个预测值。我们将从一个连续变量开始,然后是两个连续变量。我们不会使用分类变量,因为对于决策树,当进行切分时,连续变量最终会像分类数据一样处理。

我们将主要研究两个直观易懂的超参数的影响:max depth 和 min_samples_leaf。其他超参数类似,主要思想是限制规则的大小。

一个非线性变量

我们使用一些简单的数据,只有特征变量 x。

# Import the required libraries
from sklearn.tree import DecisionTreeRegressor
import numpy as np
# Define the dataset
X = np.array([[1], [3], [4], [7], [9], [10], [11], [13], [14], [16]])
y = np.array([3, 4, 3, 15, 17, 15, 18, 7, 3, 4])

我们可以在一个 (x,y) 图中可视化数据

import matplotlib.pyplot as plt

# Plot the dataset with the decision tree splits
plt.figure(figsize=(10,6))
plt.scatter(X, y, color='blue')
plt.show()

在下图中,对于第一次切分,我们可以直观地猜测出两种可能的分割,如下所示:

决策树回归器可视化——作者提供的图像

现在,决策树回归器模型准确地决定了哪个分割更好。我们指定参数 max_depth=1,以仅获得一个分割:

from sklearn.tree import DecisionTreeRegressor

# Fit the decision tree model
model = DecisionTreeRegressor(max_depth=1)
model.fit(X, y)

# Generate predictions for a sequence of x values
x_seq = np.arange(0, 17, 0.1).reshape(-1, 1)
y_pred = model.predict(x_seq)

决策树回归器可视化——作者提供的图像

如果我们决定得到 4 个区域,我们可以尝试 max_depth=2,并得到:

决策树回归器可视化 — 作者提供的图片

然后我们可以通过下面的图片可视化 max_depth 超参数对最终模型的影响:

决策树回归器可视化 — 作者提供的图片

我们也可以用另一个超参数 min_samples_leaf 绘制相同的图形,它是最终区域(我们称之为叶子,因为在树的分支末端,我们找到叶子)的最小观察数。

决策树回归器可视化 — 作者提供的图片

一个“线性”特征

决策树回归器是一个非线性回归器。我们可以从之前的示例中看到它如何表现/建模数据。对于“线性”数据会发生什么呢?

让我们以这个完美线性数据的简单例子开始:

import numpy as np

X=np.arange(1,13,1).reshape(-1,1)
y=np.concatenate((np.arange(1,12,1),12), axis=None)

plt.scatter(X,y)

你可以看到关系非常简单:y = x!

如果我们使用经典的决策树可视化,你可以立即看到模型的表现。

现在,我们可以创建下面相同的图形。有时,人们会惊讶地看到决策树将完美的线分割成区域,并为预测提供几个值,即使对于线性数据集也是如此。

然后人们会评论说这个模型根本不适合这个数据集,我们不应该使用它。

现在,事实是你无法提前知道数据集的行为。这就是“无免费午餐定理”的全部内容。

在实际操作中,你可以应用几种模型,如线性回归和决策树。如果有一个模型显著优于另一个模型,那么你可以对数据的线性与非线性行为做出结论。

两个连续特征

对于两个连续变量,我们需要创建一个 3D 图。

首先,让我们生成一些数据。

import numpy as np
from sklearn.tree import DecisionTreeRegressor
import plotly.graph_objs as go
from plotly.subplots import make_subplots

# Define the data
X = np.array([[1, 2], [3, 4], [4, 5], [7, 2], [9, 5], [10, 4], [11, 3], [13, 5], [14, 3], [16, 1],
              [10, 10], [16, 10], [12, 10]])
y = np.array([3, 4, 3, 15, 17, 15, 18, 7, 3, 4,8,10,13])

然后可以创建模型:

# Fit the decision tree model
model = DecisionTreeRegressor(max_depth=3)
model.fit(X, y)

最后,我们可以使用 Plotly 创建 3D 图。

# Create an interactive 3D plot with Plotly
fig = make_subplots(rows=1, cols=1, specs=[[{'type': 'surface'}]])

fig.add_trace(go.Surface(x=x_seq, y=y_seq, z=z_seq, colorscale='Viridis', showscale=True,opacity = 0.5),
              row=1, col=1)

fig.add_trace(go.Scatter3d(x=X[:, 0], y=X[:, 1], z=y, mode='markers', marker=dict(size=5, color='red')),
              row=1, col=1)

fig.update_layout(title='Decision Tree with Max Depth = {}'.format(max_depth),
                  scene=dict(xaxis_title='x1', yaxis_title='x2', zaxis_title='Predicted Y'))

fig.show()

我们可以比较不同深度的值,如下图所示:

决策树回归器可视化 — 作者提供的图片

如果你用 Python 创建一个图形,你可以操控它以从不同角度查看可视化效果。

结论

可视化模型对简单数据集的预测是理解模型如何工作的一个极好的方法。

对于决策树来说,它们通过树状规则的可视化已经相当直观。经典的 x, y(和 z)可视化可以作为补充。

我们也可以看到模型是高度非线性的。而且数据集不需要任何缩放。

我写关于机器学习和数据科学的文章,并以清晰的方式解释复杂的概念。请通过下面的链接关注我并获取我的文章完整访问权限:medium.com/@angela.shi/membership

Excel 中的决策树回归

原文:towardsdatascience.com/decision-tree-regressor-in-excel-2d29d16df1db

照片由Kevin Young提供,来源于Unsplash

面向机器学习初学者的逐步指南

Angela 和 Kezhan Shi数据科学探索 Angela 和 Kezhan Shi

·发布于数据科学探索 ·6 分钟阅读·2023 年 3 月 23 日

--

我正在写一系列关于使用 Excel 实现机器学习算法的文章,这是一个了解这些算法工作原理而无需编程的绝佳工具。

在本文中,我们将一步步实现决策树回归算法。

我将使用 Google 表格演示实现过程。如果您希望访问这个表格以及我开发的其他表格——例如梯度下降的线性回归、逻辑回归、带反向传播的神经网络、KNN、K 均值等——请考虑在 Ko-fi 上支持我。您可以在以下链接找到所有这些资源:ko-fi.com/s/4ddca6dff1

一个简单的数据集上的简单决策树

让我们使用一个只有一个连续特征的简单数据集。

Excel 中简单数据集的决策树回归——作者提供的图像

我们可以直观地猜测,对于第一次分裂,有两个可能的值,一个在 5.5 左右,另一个在 12 左右。现在的问题是,我们选择哪个?

为了确定这一点,我们可以查看使用 DecisionTreeRegressor 估计器的 scikit learn 的结果。下图显示了第一次分裂是 5.5,因为它导致了最低的平方误差。这到底意味着什么?

简单的决策树回归——作者提供的图像

这正是我们要找出的:如何通过在 Excel 中实现第一次分裂来确定其值?一旦确定第一次分裂的值,我们可以对随后的分裂应用相同的过程。这就是为什么我们将只在 Excel 中实现第一次分裂。

决策树回归器的算法原理

决策树算法的三步骤

我写了一篇文章来始终区分机器学习的三个步骤,以有效地学习它,让我们将这个原则应用于决策树回归器:

  • 1. 模型: 这里的模型是一组规则,值得注意的是,它与基于数学函数的模型不同,例如线性回归中,我们可以将模型写成 y=aX+b,其中参数 a 和 b 需要确定。而决策树模型则是非参数的。

  • 2. 模型拟合: 对于决策树,我们也称这一过程为完全生长一棵树。在决策树回归器的情况下,叶节点将仅包含一个观察值,因此 MSE 为零。

  • 3. 模型调整: 对于决策树,我们也称之为剪枝,包括优化超参数,如叶节点中的最小观察数和最大深度。

训练过程

生长一棵树包括递归地将输入数据划分为越来越小的块或区域。对于每个区域,可以计算预测值。在回归的情况下,预测值是该区域的目标变量的平均值。

在构建过程的每一步,算法选择特征和分裂值,以最大化一个标准,而对于回归器,这通常是实际值与预测值之间的均方误差(MSE)。

调整或剪枝

剪枝过程可以看作是从完全生长的树中删除节点和叶子,或者也可以等同于说当满足某个标准时(如最大深度或每个叶节点中的最小样本数)构建过程停止。这些就是可以通过调整过程优化的超参数。

下面是一些具有不同最大深度值的树的示例。

不同最大深度的决策树回归— 作者提供的图像

推理过程

一旦决策树回归器建立完成,它可以通过应用规则和从根节点遍历到与输入特征值对应的叶节点来预测新输入实例的目标变量。

对于输入实例的预测目标值是落在同一叶节点中的训练样本的目标值的均值。

在 Excel 中实现第一次分裂

以下是我们将遵循的步骤:

  • 列出所有可能的分裂

  • 对于每一个分割,我们将计算 MSE(均方误差)

  • 我们将选择最小化 MSE 的分割作为最优下一个分割

所有可能的分割

首先,我们需要列出所有可能的分割,这些分割是两个连续值的平均值。不需要测试更多的值。

Excel 中的决策树回归可能的分割 — 作者图片

每个可能分割的 MSE 计算

作为起点,我们可以在任何分割之前计算均方误差(MSE)。这也意味着预测只是 y 的平均值。而 MSE 相当于 y 的标准差。

现在,目标是找到一个分割,使得分割后的 MSE 低于之前的值。可能分割不会显著提高性能(或降低 MSE),那么最终的树将会很简单,即 y 的平均值。

对于每一个可能的分割,我们可以计算 MSE(均方误差)。下图显示了第一个可能分割的计算结果,即 x = 2。

Excel 中的决策树回归所有可能分割的 MSE — 作者图片

我们可以查看计算的详细信息:

  1. 将数据集切割成两个区域:以 x=2 为例,我们确定了两个可能性 x<2 或 x>2,因此 x 轴被分成两部分。

  2. 计算预测值:对于每一部分,我们计算 y 的平均值。这是 y 的潜在预测值。

  3. 计算误差:然后我们将预测值与实际的 y 值进行比较

  4. 计算平方误差:对于每个观察值,我们可以计算平方误差。

Excel 中的决策树回归所有可能分割 — 作者图片

最优分割

对于每一个可能的分割,我们做相同的操作以获得 MSE。在 Excel 中,我们可以复制并粘贴公式,唯一变化的是 x 的可能分割值。

Excel 中的决策树回归分割 — 作者图片

然后我们可以将 MSE 绘制在 y 轴上,将可能的分割绘制在 x 轴上,现在我们可以看到 x=5.5 时 MSE 最小,这正是通过 Python 代码得到的结果。

Excel 中的决策树回归 MSE 的最小化 — 作者图片

你可以做的练习

现在,你可以使用 Google Sheet 进行操作:

  • 你可以修改数据集

  • 你可以引入一个分类特征

  • 你可以尝试寻找下一个分割

  • 你可以改变标准,不仅使用 MSE,还可以使用绝对误差、泊松误差或 friedman_mse,正如 DecisionTreeRegressor 文档中所示

  • 你可以将目标变量更改为二进制变量,通常这会变成一个分类任务,但 0 或 1 也是数字,因此标准 MSE 仍然适用。但如果你想创建一个合适的分类器,你必须应用通常的标准EntroyGini。我会很快发布另一篇关于决策树分类器的文章,敬请关注。

结论

使用 Excel,可以进行一次分割,以深入了解决策树回归器如何工作。尽管我们没有创建完整的树,但这仍然很有趣,因为最重要的部分是找到所有可能分割中的最佳分割。

我写关于机器学习和数据科学的文章,并以清晰的方式解释复杂概念。请通过下面的链接关注我,并获得对我的文章的全部访问权限:medium.com/@angela.shi/membership

分类决策树——完整示例

原文:towardsdatascience.com/decision-trees-for-classification-complete-example-d0bc17fcf1c2?source=collection_archive---------1-----------------------#2023-01-01

关于如何构建分类决策树的详细示例

DatamapuTowards Data Science Datamapu

·

关注 发表在 Towards Data Science ·8 分钟阅读·2023 年 1 月 1 日

--

图片由 Fabrice Villard 提供,来自 Unsplash

本文解释了我们如何使用决策树来解决分类问题。在解释重要术语后,我们将为一个简单的示例数据集构建一个决策树。

介绍

决策树是一种决策支持工具,它使用树状模型展示决策及其可能的后果,包括随机事件结果、资源成本和效用。这是一种仅包含条件控制语句的算法展示方式。

传统上,决策树是手动绘制的,但可以通过机器学习进行学习。它们可用于回归和分类问题。在这篇文章中,我们将重点关注分类问题。让我们考虑以下示例数据:

示例数据(由作者构建)

使用这个简化的例子,我们将预测一个人是否会成为宇航员,取决于他们的年龄、是否喜欢狗以及是否喜欢重力。在讨论如何构建决策树之前,让我们看一下我们示例数据的最终决策树。

示例数据的最终决策树

我们可以跟踪路径来做出决定。例如,我们可以看到,不喜欢重力的人不会成为宇航员,与其他特征无关。另一方面,我们也可以看到,喜欢重力和喜欢狗的人将会成为宇航员,与年龄无关。

在详细讨论如何构建这棵树之前,让我们定义一些重要的术语。

术语

根节点

顶级节点。做出的第一个决策。在我们的例子中,根节点是“喜欢重力”。

分支

分支代表子树。我们的例子有两个分支。例如,一个分支是从“喜欢狗”开始的子树,另一个是从“年龄 < 40.5”开始的子树。

节点

一个节点代表进一步(子)节点的切分。在我们的例子中,节点有“喜欢重力”、“喜欢狗”和“年龄 < 40.5”。

叶子节点

叶子节点位于分支的末端,即不再进行切分。它们代表每个动作的可能结果。在我们的例子中,叶子节点由“是”和“否”表示。

父节点

一个在(子)节点之前的节点称为父节点。在我们的例子中,“喜欢重力”是“喜欢狗”的父节点,而“喜欢狗”是“年龄 < 40.5”的父节点。

子节点

一个节点在另一个节点下方称为子节点。在我们的例子中,“喜欢狗”是“喜欢重力”的子节点,而“年龄 < 40.5”是“喜欢狗”的子节点。

切分

将一个节点划分为两个(子)节点的过程。

剪枝

移除父节点的(子)节点称为剪枝。树通过切分生长,通过剪枝缩小。在我们的例子中,如果我们移除节点“年龄 < 40.5”,我们将对树进行剪枝。

决策树插图

我们还可以观察到,决策树允许我们混合数据类型。我们可以在同一棵树中使用数值数据(“年龄”)和分类数据(“喜欢狗”、“喜欢重力”)。

创建决策树

创建决策树中最重要的步骤是对数据的 分裂。我们需要找到一种方法将数据集 (D) 分裂为两个数据集 (D_1) 和 (D_2)。可以使用不同的标准来寻找下一个分裂,概览见例如 这里。我们将集中于其中一个标准:基尼不纯度,它是一个用于分类目标变量的标准,也是 Python 库 scikit-learn 使用的标准。

基尼不纯度

数据集 D 的基尼不纯度计算如下:

其中 n = n_1 + n_2 表示数据集 (D) 的大小,并且

D_1D_2D 的子集,𝑝_𝑗 是在给定节点上样本属于类别 𝑗 的概率,𝑐 是类别的数量。基尼不纯度越低,节点的同质性越高。纯节点的基尼不纯度为零。为了使用基尼不纯度来分裂决策树,需要执行以下步骤。

  1. 对于每个可能的分裂,计算每个子节点的基尼不纯度

  2. 计算每个分裂的基尼不纯度,作为子节点基尼不纯度的加权平均值

  3. 选择基尼不纯度值最低的分裂

重复步骤 1–3 直到不能再分裂为止。

为了更好地理解这一点,让我们来看一个例子。

第一个示例:具有两个二元特征的决策树

在为整个数据集创建决策树之前,我们将首先考虑一个子集,该子集仅考虑两个特征:“喜欢重力”和“喜欢狗”。

我们首先需要决定哪个特征将作为 根节点。我们通过仅用一个特征来预测目标,然后选择基尼不纯度最低的特征作为根节点。也就是说,在我们的案例中,我们构建了两个浅层树,只有根节点和两个叶子。在第一个案例中,我们使用“喜欢重力”作为根节点,在第二个案例中使用“喜欢狗”。然后我们计算两个树的基尼不纯度。这些树的样子如下:

图片由作者提供

这些树的基尼不纯度计算如下:

案例 1:

数据集 1:

数据集 2:

基尼不纯度是两者的加权均值:

案例 2:

数据集 1:

数据集 2:

基尼不纯度是两者的加权均值:

即,第一个案例的基尼不纯度较低,是选择的拆分。在这个简单的示例中,只剩下一个特征,我们可以构建最终的决策树。

只考虑特征‘喜欢重力’和‘喜欢狗’的最终决策树

第二个示例:添加一个数值变量

到现在为止,我们只考虑了数据集的一个子集——分类变量。现在我们将添加数值变量‘年龄’。拆分的标准相同。我们已经知道‘喜欢重力’和‘喜欢狗’的基尼不纯度。数值变量的基尼不纯度计算类似,但决策需要更多计算。需要执行以下步骤

  1. 按数值变量(‘年龄’)对数据框进行排序

  2. 计算邻近值的均值

  3. 计算这些均值的所有拆分的基尼不纯度

这又是我们的数据,按年龄排序,左侧给出了邻近值的均值。

按年龄排序的数据集。左侧显示了年龄的邻近值的均值。

我们得到以下可能的拆分。

年龄的可能拆分及其基尼不纯度。

我们可以看到,所有可能的‘年龄’拆分的基尼不纯度都高于‘喜欢重力’和‘喜欢狗’的基尼不纯度。当使用‘喜欢重力’时,基尼不纯度最低,即这是我们的根节点和第一次拆分。

树的第一次拆分。‘喜欢重力’是根节点。

子集数据集 2 已经是纯净的,即这个节点是一个叶子节点,无需进一步拆分。左侧的分支,数据集 1 不是纯净的,可以进一步拆分。我们像之前一样计算每个特征的基尼不纯度:‘喜欢狗’和‘年龄’。

数据集 2 的可能拆分。

我们看到最低的基尼不纯度是由“喜欢狗”的拆分给出的。我们现在可以构建我们的最终决策树。

最终决策树。

使用 Python

在 Python 中,我们可以使用 scikit-learn 方法DecisionTreeClassifier来构建分类决策树。请注意,scikit-learn 还提供了DecisionTreeRegressor,这是一个用于回归的决策树方法。假设我们的数据存储在数据框‘df’中,我们可以使用‘fit’方法进行训练:

from sklearn.tree import DecisionTreeClassifier
clf = DecisionTreeClassifier()
X = df['age', 'likes dogs', 'likes graviy']
y = df['going_to_be_an_astronaut']
clf.fit(X,y)

我们可以使用‘plot_tree’方法可视化生成的树。这与我们构建的树相同,只是分割标准用‘<=’代替了‘<’,而‘true’和‘false’路径的方向相反。也就是说,外观上存在一些差异。

plot_tree(clf, feature_names=[‘age’,‘likes_dogs’,‘likes_gravity’], fontsize=8);

使用 scikit-learn 生成的决策树。

决策树的优缺点

在使用决策树时,了解其优缺点很重要。以下是一些优缺点的列表,但这个列表并不完全。

优势

  • 决策树直观、易于理解和解释。

  • 决策树不受异常值和缺失值的影响。

  • 数据不需要进行缩放。

  • 数值数据和分类数据可以结合使用。

  • 决策树是非参数算法。

缺点

  • 过拟合是一个常见问题。剪枝可能有助于克服这个问题。

  • 虽然决策树可以用于回归问题,但它们不能真正预测连续变量,因为预测必须以类别形式分隔。

  • 训练决策树相对昂贵。

结论

在这篇文章中,我们讨论了一个简单但详细的示例,说明了如何为分类问题构建决策树,以及如何利用它进行预测。创建决策树的关键步骤是找到将数据分成两个子集的最佳分割方式。常用的方法是基尼不纯度。这也被 Python 中的 scikit-learn 库所使用,该库在实际中常用于构建决策树。重要的是要记住决策树的局限性,其中最突出的就是过拟合的倾向。

参考文献

除非另有说明,所有图片均为作者所用。

在这里找到更多数据科学和机器学习的文章:

[## 更多

数据科学和机器学习博客

datamapu.com](https://datamapu.com/?source=post_page-----d0bc17fcf1c2--------------------------------) [## 订阅 Pumaline 发布的内容时会收到电子邮件。

订阅 Pumaline 发布的内容时会收到电子邮件。通过注册,如果你还没有 Medium 账号,将会创建一个…

medium.com [## Pumaline

嗨,我喜欢学习和分享关于数据科学和机器学习的知识。

www.buymeacoffee.com

决策树:介绍与直观理解

原文:towardsdatascience.com/decision-trees-introduction-intuition-dac9592f4b7f

使用 Python 做数据驱动决策

Shaw TalebiTowards Data Science Shaw Talebi

·发表于 Towards Data Science ·阅读时间 10 分钟·2023 年 2 月 10 日

--

图片由 niko photos 提供,来源于 Unsplash,并配有思考的表情符号。

这是关于决策树系列文章中的第一篇。在这篇文章中,我介绍了决策树,并描述了如何使用数据来生成它们。文章最后包含了示例 Python 代码,展示了如何创建和使用决策树来帮助进行医学预测。

重点:

  • 决策树是一种广泛使用且直观的机器学习技术,用于解决预测问题。

  • 我们可以从数据中生成决策树。

  • 超参数调整可以用来帮助避免过拟合问题。

什么是决策树?

决策树是一种广泛使用且直观的机器学习技术。通常,它们用于解决预测问题。例如,预测明天的天气预报或估算一个人患心脏病的概率。

决策树通过一系列是非问题进行工作,这些问题用于缩小可能的选择范围并得出结果。下面展示了一个简单的决策树示例。

示例决策树预测我是否会喝茶或咖啡。图片由作者提供。

如上图所示,决策树由通过有向边连接的节点组成。决策树中的每个节点对应于一个基于预测变量的条件语句。

上面显示的决策树的顶部是根节点,它设置了数据记录的初始分裂。在这里,我们评估时间是否在下午 4 点之后。每个可能的响应(是或否)在树中遵循不同的路径。

如果是,我们沿着左侧分支前进,最终到达一个叶节点(也称为终端节点)。在这种类型的节点上不需要进一步分裂来确定结果。在这种情况下,我们选择茶而不是咖啡,以便能在合理的时间上床睡觉。

相反,如果时间是下午 4 点或更早,我们会沿着右侧分支前进,最终到达一个所谓的分裂节点。这些节点进一步分割数据记录,基于条件语句进行划分。接下来,我们评估昨晚的睡眠时间是否超过 6 小时。如果是,我们继续选择茶,但如果不是,我们则选择咖啡☕️。

使用决策树

在实际操作中,我们通常不会像刚才那样使用决策树(即查看决策树并跟随特定数据记录)。相反,我们让计算机为我们评估数据。我们只需将所需数据以表格形式提供给计算机即可。

下面是一个示例。这里我们有两个变量的表格数据:时间和前一晚的睡眠小时数(蓝色列)。然后,使用上述决策树,我们可以为每条记录分配适当的含咖啡因饮料(绿色列)。

输入数据的示例表格及其生成的决策树预测。图片由作者提供。

决策树的图形视图

另一种思考决策树的方法是图形化(这是我个人对决策树的直观理解。)

想象一下,我们将示例决策树中的两个预测变量绘制在一个二维图上。然后,我们可以将决策树的分裂表示为将图划分为不同区域的线条。这样,我们可以通过简单地查看数据点所在的象限来确定饮料选择。

从直观上看,这就是决策树的作用。将预测空间划分为不同的部分,并为每个部分分配一个标签(或概率)

决策树对茶或咖啡的预测的图形视图。图片由作者提供。

如何构建决策树?

决策树是一种直观的数据划分方法。然而,使用数据手动绘制一个合适的决策树可能并不容易。在这种情况下,我们可以使用机器学习策略来学习适用于给定数据集的“最佳”决策树。

数据可以用于在一种称为训练的优化过程中构建决策树。训练需要一个训练数据集,其中的预测变量预先标记了目标值。

一种标准的训练决策树的策略使用被称为贪婪搜索的方法。这是一种在优化中流行的技术,我们通过找到局部最优解来简化更复杂的优化问题,而不是全局最优解。(我在之前的一篇关于 因果发现的文章中给出了贪婪搜索的直观解释。)

在决策树的情况下,贪婪搜索确定每个可能的分裂选项的增益,然后选择提供最大增益的那个选项[1,2]。这里的“增益”由分裂准则决定,这可以基于几种不同的量度,例如基尼 impurity信息增益均方误差 (MSE)等。这个过程会递归地重复,直到决策树完全生成。

例如,如果使用基尼 impurity,数据记录会递归地分成两个组,以使结果组的加权平均 impurity 最小化。这个分裂过程可以继续,直到所有数据分区都是纯净的,即每个分区中的所有数据记录都对应于一个单一的目标值。

尽管这意味着决策树可以是完美的估计器,但这种方法可能会导致过拟合。训练好的决策树在与训练数据集有显著不同的数据上表现不佳

超参数调优

对抗过拟合问题的一种方法是超参数调优。超参数限制决策树增长的值

常见的决策树超参数包括最大分裂次数、最小叶子节点大小和分裂变量的数量。设置决策树超参数的关键结果限制树的大小,这有助于避免过拟合并提高泛化能力。

替代训练策略

虽然我上述描述的训练过程在决策树中广泛使用,但我们可以使用其他替代方法。

剪枝——一种这样的方式叫做剪枝[3]。从某种意义上讲,剪枝是决策树生长的反面。我们不是从根节点开始递归地添加节点,而是从完全生成的树开始,逐步去除节点。

虽然剪枝过程可以通过多种方式进行,但通常会删除那些不会显著增加模型误差的节点。这是一种替代超参数调优来限制树的生长以避免过拟合的方法[3]。

最大似然——我们可以使用最大似然框架训练决策树[4]。虽然这种方法不太为人知,但它基于一个强大的理论框架。它允许我们使用信息准则如 AIC 和 BIC 来客观优化树中的参数数量及其性能,这有助于避免广泛的超参数调优。

示例代码:使用决策树进行脓毒症生存预测

现在,了解了决策树的基本概念以及如何从数据中构建决策树后,让我们使用 Python 深入探讨一个具体的例子。在这里,我们将使用来自UCI 机器学习库的数据集来训练一个决策树,以预测患者是否会生存,基于他们的年龄、性别和经历的脓毒症发作次数[5,6]。

对于决策树的训练,我们将使用 sklearn Python 库[7]。此示例的代码可以在GitHub 仓库中自由获取。

我们从导入一些有用的库开始。

# import modules
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

from sklearn.model_selection import train_test_split
from sklearn import tree
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
from sklearn.metrics import precision_score, recall_score, f1_score
from imblearn.over_sampling import SMOTE

接下来,我们从.csv 文件中加载数据并进行一些数据准备。

# read data from csv
df = pd.read_csv('raw/s41598-020-73558-3_sepsis_survival_primary_cohort.csv')

# look at data distributions
plt.rcParams.update({'font.size': 16})

# plot histograms
df.hist(figsize=(12,8))

数据集中每个变量的直方图。图片由作者提供。

请注意,在右下角的直方图中,我们有更多的存活记录而非死亡记录。这被称为不平衡数据集。对于一个简单的决策树分类器,从不平衡的数据中学习可能会导致决策树过度预测多数类

为了处理这种情况,我们可以通过过采样少数类来使数据更平衡。一种方法是使用一种叫做合成少数类过采样技术SMOTE)的技术。虽然我会在未来的文章中详细介绍 SMOTE,但目前只需知道这有助于我们平衡数据并改进决策树模型即可。

# Balance data using SMOTE

# define predictor and target variable names
X_var_names = df.columns[:3]
y_var_name = df.columns[3]

# create predictor and target arrays
X = df[X_var_names]
y = df[y_var_name]

# oversample minority class using smote
X_resampled, y_resampled = SMOTE().fit_resample(X, y)

# plot resulting outcome histogram
y_resampled.hist(figsize=(6,4))
plt.title('hospital_outcome_1alive_0dead \n (balanced)')

SMOTE 之后的结果直方图。图片由作者提供。

数据准备的最后一步是将我们的重采样数据拆分为训练和测试数据集。训练数据将用于构建决策树,而测试数据将用于评估其性能。这里我们使用 80–20 的训练-测试拆分。

# create train and test datasets
X_train, X_test, y_train, y_test = \
      train_test_split(X_resampled, y_resampled, test_size=0.2, random_state=0)

现在有了训练数据,我们可以创建我们的决策树。Sklearn 使这一过程非常简单,仅用两行代码,我们就能得到一个决策树。

# Training
clf = tree.DecisionTreeClassifier(random_state=0)
clf = clf.fit(X_train, y_train)

让我们来看一下结果。

# Display decision tree
plt.figure(figsize=(24,16))

tree.plot_tree(clf)
plt.savefig('visuals/fully_grown_decision_tree.png',facecolor='white',bbox_inches="tight")
plt.show()

完全生长的决策树。图片由作者提供。

不用说,这是一棵非常大的决策树,这可能会使解释结果变得困难。然而,让我们暂时搁置这一点,评估模型的性能。

为了评估性能,我们使用混淆矩阵它显示了真正例(TP)、真负例(TN)、假正例(FP)和假负例(FN)的数量

我不会在这里讨论混淆矩阵,但目前我们希望的是对角线上的数字要大,而非对角线上的数字要小

完全生长的决策树的混淆矩阵。(左)训练数据集。(右)测试数据集。图片由作者提供。

我们可以从混淆矩阵中获取数据,并计算三个不同的性能指标:精确度、召回率和 f1-score 简单来说,精确度 = TP / (TP + FP),召回率 = TP / (TP + FN),f1-score 是精确度和召回率的调和均值。

完全成长的决策树的三个性能指标。 图片由作者提供。

生成这些结果的代码如下。

# Function to plot confusion matrix and print precision, recall, and f1-score
def evaluateModel(clf, X, y):

    # confusion matrix
    y_pred = clf.predict(X)
    cm = confusion_matrix(y, y_pred)
    cm_disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['dead', 'alive'])
    cm_disp.plot()

    # print metrics
    print("Precision = " + str(np.round(precision_score(y, y_pred),3)))
    print("Recall = " + str(np.round(recall_score(y, y_pred),3)))
    print("F1 = " + str(np.round(f1_score(y, y_pred),3)))

超参数调整

虽然决策树在这里使用的数据上表现不错,但仍然存在可解释性的问题。查看之前展示的决策树,临床医生从决策树的逻辑中提取有意义的见解将是具有挑战性的。

这就是超参数调整可以发挥作用的地方。为了使用 sklearn 完成这一点,我们只需在决策树训练步骤中添加输入参数即可。

在这里我们将尝试设置 max_depth = 3。

# train model with max_depth set to 3
clf_tuned = tree.DecisionTreeClassifier(random_state=0, max_depth=3)
clf_tuned = clf_tuned.fit(X_train, y_train)

现在,让我们看看结果决策树。

调整后的决策树,max_depth=3。 图片由作者提供。

由于我们限制了树的最大深度,我们可以清楚地看到这里发生了哪些分裂。

我们再次使用混淆矩阵和之前相同的三个性能指标来评估模型的表现。

超参数调整决策树的混淆矩阵。 (左)训练数据集。 (右)测试数据集。 图片由作者提供

超参数调整后的决策树的三个性能指标。 图片由作者提供。

虽然完全成长的树可能看起来比超参数调整的树更可取,但这回到了过拟合的讨论。是的,完全成长的树在当前数据上的表现更好,但我不认为这适用于其他数据。

换句话说,虽然简单的决策树在这里的表现较差,但它可能比完全成长的树更具泛化能力。

这个假设可以通过将每个模型应用于 GitHub repo 中的其他两个数据集来进行测试。

[## YouTube-Blog/decision-tree/decision_tree at main · ShawhinT/YouTube-Blog

代码补充了 YouTube 视频和 Medium 上的博客文章。 - YouTube-Blog/decision-tree/decision_tree at main ·…

github.com](https://github.com/ShawhinT/YouTube-Blog/tree/main/decision-tree/decision_tree?source=post_page-----dac9592f4b7f--------------------------------)

决策树集成

虽然超参数调优可以提高决策树的泛化能力,但在性能方面仍有不足。在上面的例子中,经过超参数调优后,决策树仍然将训练数据35%的时间标记错误,这在讨论生死问题时(如此处的例子所示)是一个大问题。

解决这个问题的一种流行方法是使用多个决策树而不是单一的决策树来进行预测。这些被称为决策树集成,将成为本系列的下一篇文章的主题。

## 10 决策树比 1 个更好

解析 bagging、boosting、随机森林和 AdaBoost

towardsdatascience.com

资源

联系: 我的网站 | 预约电话

社交媒体: YouTube 🎥 | LinkedIn | Twitter

支持: 请我喝咖啡 ☕️

[## 免费获取我写的每个新故事

免费获取我写的每个新故事 P.S. 我不会将你的邮箱与任何人分享。通过注册,你将创建一个…

shawhin.medium.com](https://shawhin.medium.com/subscribe?source=post_page-----dac9592f4b7f--------------------------------)

[1] 分类与回归树 由 Breiman 等人著

[2] 决策树:Kotsiantis, S. B. 最近的概述

[3] Esposito 等人对剪枝决策树方法的比较分析

[4] Su 等人提出的最大似然回归树

[5] Dua, D. 和 Graff, C. (2019). UCI 机器学习库 [http://archive.ics.uci.edu/ml]。加利福尼亚州尔湾: 加州大学信息与计算机科学学院。(CC BY 4.0)

[6] Chicco 和 Jurman 通过年龄、性别和脓毒症发作次数来预测脓毒症患者的生存率

[7] Scikit-learn: Python 中的机器学习,Pedregosa 等人,JMLR 12,2825–2830 页,2011 年。

解码 Auto-GPT

原文:towardsdatascience.com/decoding-auto-gpt-fae16ff1ee75

自主GPT-4 的机制

Maarten GrootendorstTowards Data Science Maarten Grootendorst

·发表于Towards Data Science ·阅读时间 8 分钟·2023 年 8 月 8 日

--

自 ChatGPT 发布以来,出现了许多有趣、复杂和创新的解决方案。社区探索了无数改进其能力的可能性。

其中之一是著名的Auto-GPT包。它拥有超过14 万个星标,是 Github 上排名最高的仓库之一!

Auto-GPT 在 Github 上的星标数量。数据来源于 star-history.com

Auto-GPT 尝试使 GPT-4 完全自主

Auto-GPT 赋予 GPT-4 自己做决定的能力

听起来令人难以置信,确实如此!但它是如何工作的呢?

在这篇文章中,我们将探讨 Auto-GPT 的架构,并探索它如何实现自主行为。

架构

Auto-GPT 具有一个整体架构,或者说是一种主要循环,用于建模自主行为。

让我们首先描述这个整体架构,然后我们将深入探讨每个步骤:

描述 Auto-GPT 主要自主行为机制的主要循环。

Auto-GPT 的核心是一个循环步骤序列:

  1. 用总结的信息初始化提示

  2. GPT-4 提出一个行动方案

  3. 行动被执行

  4. 嵌入这个循环的输入和输出

  5. 将嵌入保存到向量数据库

这 5 个步骤构成了 Auto-GPT 的核心,并代表了它的主要自主行为。

在深入探讨每个步骤之前,还有一个步骤是在这个循环序列之前,即初始化代理

0. 初始化代理

在 Auto-GPT 完全自主完成任务之前,它首先需要初始化一个代理。这个代理基本上描述了 GPT-4 的身份以及它应该追求的目标。

假设我们希望 Auto-GPT 创建一个素食巧克力配方

有了这个目标,我们需要给 GPT-4 一些关于代理应该是什么以及应该实现什么的背景信息:

提示:为我们的代理创建子目标和名称。

我们创建一个定义两个方面的提示:

  • 创建5 个高效目标(这些目标可以稍后更新)

  • 创建一个合适的基于角色的名称(_GPT)

名称有助于 GPT-4 持续记住它应该建模的内容。子目标特别有助于创建小任务供其完成。

接下来,我们提供一个期望输出的示例:

提示:如果我们提供一个期望输出的示例,GPT-4 的效果会更好。

向任何生成型大语言模型提供示例效果很好。通过描述输出应该是什么样的,它可以更容易生成准确的答案。

当我们使用 Auto-GPT 将此提示传递给 GPT-4 时,我们得到以下响应:

GPT-4 为我们创建了RecipeGPT的描述!

看起来 GPT-4 为我们创建了RecipeGPT的描述。我们可以将这个背景提供给 GPT-4 作为系统提示,以便它持续记住其目标。

现在 Auto-GPT 已经创建了其代理的描述,并设定了明确的目标,它可以开始执行其第一个自主行动。

1. 首次提示

其循环序列中的第一步是创建触发操作的提示。

Auto-GPT 自主周期中的第一步。我们要求 GPT-4 使用基于系统提示和过去事件总结的单一命令。

提示由三个组件组成:

  • 系统提示

  • 总结

  • 行动召唤

我们稍后会深入总结,但行动召唤无非是询问 GPT-4 应使用哪个命令。GPT-4 可以使用的命令在其系统提示中定义。

系统提示

系统提示是我们给 GPT-4 的背景信息,以便它记住应该遵循的某些指南。

系统提示的格式。

如上所示,它包含六项指南

  • 初始化代理的目标和描述

  • 约束它应该遵守

  • 命令它可以使用

  • 资源它可以访问

  • 评估步骤

  • 有效JSON输出的示例

最后五个步骤本质上是代理应该遵守的约束条件。

这是这些指南和约束的一种更深入的概述:

系统提示中给定的约束条件。

如你所见,系统提示勾画了 GPT-4 可以行动的边界。例如,在“资源”中,它描述了 GPT-4 可以使用 GPT-3.5 代理来委派简单任务。类似地,“评估” 告诉 GPT-4 它应不断自我批评自己的行为,以改进下一步行动。

第一个提示的示例

一开始的提示大致如下所示:

完整的第一个提示。注意三个组成部分:系统提示、总结和行动呼吁。

注意到蓝色的 “我被创建” 提到了。通常,这会包含它采取的所有行动的总结。由于它刚刚被创建,之前没有任何行动,所以总结仅仅是 “我被创建”

2. GPT-4 提出一个行动

在步骤 2 中,我们给 GPT-4 提供了在前一步定义的提示。然后它可以提出一个行动,该行动应遵循以下格式:

Auto-GPT 自主循环中的第二步。GPT-4 执行之前的命令,并使用一个叫做 ReACT 的框架来展示复杂的输出。

你可以看到提到了六个独立的步骤:

  • 思考

  • 推理

  • 计划

  • 批评

  • 说话

  • 动作

这些步骤描述了一种叫做 Reason and ACT (ReACT) 的提示格式。

ReACT 是 Auto-GPT 的超能力之一!

ReACT 允许 GPT-4 模仿自我批评,并展示比直接询问模型更复杂的推理能力。

ReACT 的一个基本和说明性示例。大多数 GPT 模型在基本提示下会正确回答这个问题,但它演示了如何将 ReACT 用于更复杂的问题。

每当我们使用 ReACT 框架向 GPT-4 提问时,我们要求 GPT-4 在得出结论之前输出单独的思考、行动和观察。

通过让模型模仿广泛的推理,它往往能给出比直接回答问题更准确的答案。

在我们的示例中,Auto-GPT 扩展了基础 ReACT 框架,并生成了以下响应:

ReACT 框架在 Auto-GPT 中的应用

如你所见,它遵循了我们之前描述的 ReACT 流程,但包括了额外的批评和推理步骤。

它提议 搜索网络 以提取关于流行食谱的更多信息。

3. 执行动作

在生成响应后,以有效的 JSON 格式。我们可以提取 RecipeGPT 想要做什么。在这种情况下,它调用了网络搜索:

GPT-4 提出的下一步行动。

并且,接下来将执行网络搜索:

Auto-GPT 自主循环中的第三步。Auto-GPT 执行之前提出的行为。

它可以采取的这个行动,搜索网页,只是它的一种工具,可以生成一个包含页面主体的文件。

由于我们在系统提示中向 GPT-4 解释了它可以使用网络搜索,它将此视为一种有效的行动。

Auto-GPT 的自主性取决于它拥有的工具数量。

请注意,如果它唯一的工具是搜索网页,那么我们可以开始讨论这样一个模型究竟有多自主!

无论如何,我们将输出保存到文件中以备后用。

4. 嵌入所有内容!

Auto-GPT 迄今为止采取的每一步都是任何下一步的关键信息。尤其是当它需要采取数十个步骤时,例如征服世界,记住它迄今为止做了什么是重要的。

一种方法是嵌入它生成的提示和输出。这使我们能够将文本转换为数值表示(嵌入),以便我们稍后可以保存。

Auto-GPT 自主循环的第四步。嵌入它迄今为止看到的所有相关文本。输入、输出、观察、行动等。

这些嵌入是使用 OpenAI 的text-embedding-ada-002模型生成的,该模型在许多用例中表现非常出色。

5. 向量数据库 + 总结

在生成嵌入后,我们需要一个地方来存储它们。Pinecone通常用于创建向量数据库,但只要能轻松找到相似向量,也可以使用许多其他系统。

Auto-GPT 自主循环的第五步。将所有嵌入保存在向量数据库中,以便可以轻松访问和搜索。

向量数据库使我们能够快速找到输入查询的信息。

我们可以查询向量数据库以找到它到目前为止采取的所有步骤。利用这些信息,我们要求 GPT-4 创建一个总结,涵盖它到目前为止采取的所有行动:

使用向量数据库和 GPT-4 创建一个到目前为止发生的一切的总结。

然后使用这个总结来构建提示,就像我们在第 1 步中做的那样。

这样,它可以“记住”它迄今为止所做的事情,并考虑接下来的步骤。

这完成了 Auto-GPT 自主行为的第一个循环!

6. 重新开始!

正如你可能猜到的,循环从我们开始的地方继续,要求 GPT-4 根据行动历史采取行动。

Auto-GPT 的自主循环。

Auto-GPT 将继续进行,直到它达到目标或被你中断。

在这个循环过程中,它可以跟踪估计的成本,以确保你不会在你的代理上花费过多。

未来,特别是随着Llama2的发布,我期望并希望本地模型能在 Auto-GPT 中可靠地使用!

感谢阅读!

如果你和我一样,对 AI 和/或心理学充满热情,请随时在LinkedIn上添加我,关注我的Twitter,或订阅我的Newsletter。你也可以在我的Personal Website上找到我的一些内容。

所有没有来源注明的图片均由作者创作

解码 LLMs:从零开始在 Python 中创建 Transformer 编码器和多头注意力层

原文:towardsdatascience.com/decoding-llms-creating-transformer-encoders-and-multi-head-attention-layers-in-python-from-scratch-631429553ce8

探索大型语言模型中编码器、多头注意力和位置编码的复杂性

Luís RoqueTowards Data Science Luís Roque

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

--

这篇文章由 Rafael Nardi 共同撰写。

介绍

今天,计算自然语言处理(NLP)是一个迅速发展的领域,其中计算的力量与语言学相结合。语言学方面主要归因于 John Rupert Firth 的分布式语义理论。他曾说过以下话:

“你可以通过一个词的陪伴来了解它”

因此,单词的语义表示由其使用的上下文决定。正是基于这一假设,Ashish Vaswani 等人的论文“Attention is all you need” [1] 具有开创性的相关性。它将 transformer 架构确立为许多快速发展的工具的核心,如 BERT、GPT4、Llama 等。

在这篇文章中,我们将探讨 transformer 架构中编码器部分核心数学操作的关键内容。

图 1:自注意力机制很复杂(图像来源:作者)

像往常一样,代码可以在我们的 GitHub 上找到。

标记化、嵌入和向量空间

处理自然语言处理(NLP)问题时,首先要面对的任务是如何将句子中包含的信息编码成机器能够处理的形式。机器只能处理数字,这意味着单词、其含义、标点等必须转换成数值表示。这本质上是嵌入的问题。

在深入了解嵌入(embeddings)之前,我们需要采取一个中间步骤并讨论分词(tokenization)。这里,单词块或单词片段被定义为基本构建块(所谓的 token),这些构建块将来会被表示为数字。一个重要的注意点是,我们不能用一个单一的数字来表征一个单词或单词片段,因此我们使用数字列表(向量)。这给了我们更大的表示能力。

它们是如何构建的?它们生活在哪个空间中?原始论文中使用的是 512 维的向量表示 token。在这里,我们将使用最简单的方式将一组单词表示为向量。如果我们有一个由 3 个单词组成的句子‘今天 是 星期天’,句子中的每个单词将由一个向量表示。最简单的形式,考虑这 3 个单词,是一个 3 维向量空间。例如,向量可以按照独热编码规则分配给每个单词:

‘今天’ — (1,0,0)

‘是’ — (0,1,0)

‘星期天’ — (0,0,1)

这个结构(由 3 个 3 维向量组成),虽然可以使用,但有其缺点。首先,它以一种每个单词都与其他单词正交的方式嵌入单词。这意味着无法为单词之间分配语义关系的概念。相关向量之间的内积总是零。

其次,这种特定的结构可以进一步用于表示任何其他由 3 个不同单词组成的句子。问题出现在尝试表示由三个单词组成的不同句子时。对于一个 3 维空间,你只能拥有 3 个线性独立的向量。线性独立性意味着集合中的任何向量都不能通过其他向量的线性组合来形成。在独热编码(one-hot encoding)的上下文中,每个向量已经是线性独立的。因此,所提出的嵌入所能处理的总单词数与向量空间的总维度相同。

一个流利的英语使用者通常知道的单词数量约为 30,000,这意味着我们需要如此大小的向量来处理任何典型的文本。这种高维空间带来了挑战,特别是在内存方面。回想一下,每个向量只有一个非零分量,这将导致内存和计算资源的非常低效的使用。

尽管如此,让我们坚持完成这个示例。为了描述这个句子的至少一种简单变体,我们需要扩展向量的大小。在这种情况下,我们允许使用‘星期天’或‘星期六’。现在,每个单词都由一个 4 维向量空间来描述:

‘今天’ — (1,0,0,0)

‘是’ — (0,1,0,0)

‘星期天’ — (0,0,1,0)

‘星期六’ — (0,0,0,1)

我们句子中的 3 个单词可以组合成一个矩阵 X,该矩阵有 3 行 4 列:

import numpy as np 
from scipy.special import softmax
import math

sentence = "Today is sunday"

vocabulary = ['Today', 'is', 'sunday', 'saturday']

# Initial Embedding (one-hot encoding):

x_1 = [1,0,0,0] # Today 
x_2 = [0,1,0,0] # is 
x_3 = [0,0,1,0] # Sunday
x_4 = [0,0,0,1] # Saturday

X_example = np.stack([x_1, x_2, x_3], axis=0)

单头注意力层:查询、键和值

X 开始,变换器架构通过构建另外三组向量(即(3×4)矩阵)QKV(查询、键和值)。如果你在线查找,你会发现以下内容:查询是你正在寻找的信息,键是你提供的信息,而值是你实际获得的信息。这确实通过与数据库系统的类比解释了一些关于这些对象的内容。尽管如此,我们相信它们的核心理解来自于它们在模型架构中所扮演的数学角色。

矩阵 QKV 通过将 X 乘以另外三个矩阵 WQ*、*WKW^V(形状为 4×4)构建。这些 W 矩阵包含在模型训练过程中会调整的参数——可学习参数。因此,W 最初是随机选择的,并在每个句子(或实际中每个批次的句子)中更新。

例如,考虑以下 3 个 W 矩阵:

我们可以通过从-1 到 1 的均匀分布中采样来创建它们:

W_Q = np.random.uniform(-1, 1, size=(4, 4))
W_K = np.random.uniform(-1, 1, size=(4, 4))
W_V = np.random.uniform(-1, 1, size=(4, 4))

我们来创建一个抽象层来存储我们的权重矩阵,以便稍后使用。

class W_matrices:
    def __init__(self, n_lines, n_cols):
        self.W_Q = np.random.uniform(low=-1, high=1, size=(n_lines, n_cols))
        self.W_K = np.random.uniform(low=-1, high=1, size=(n_lines, n_cols))
        self.W_V = np.random.uniform(low=-1, high=1, size=(n_lines, n_cols))

    def print_W_matrices(self):
        print('W_Q : \n', self.W_Q)
        print('W_K : \n', self.W_K)
        print('W_V : \n', self.W_V)

在与输入 X 相乘之后,我们得到:

Q = np.matmul(X_example, W_Q)
K = np.matmul(X_example, W_K)
V = np.matmul(X_example, W_V)

下一步是(点)乘查询和键矩阵以生成注意力分数。如上所述,结果矩阵是 QK 集合中每对向量之间点积(相似性)的结果:

Attention_scores = np.matmul(Q, np.transpose(K))

我们再次强调,本质上,注意力分数代表了向量在空间中的接近程度。也就是说,对于两个标准化的向量,它们的点积越接近 1,它们之间的距离就越近。这也意味着这些词更接近彼此。因此,模型考虑了词语在它们出现的句子上下文中的接近度。

然后,将矩阵 A 除以 4 的平方根。这个操作旨在避免梯度消失/爆炸的问题。这里出现这个问题是因为两个维度为 d_k 的向量,其分量随机分布,均值为 0,标准差为 1,会产生一个标量积,其均值为 0,但标准差为 d_k。由于下一步涉及这些标量积值的指数化,这意味着对于某些值会有很大的因子,如 exp(d_k)(考虑到论文中实际使用的维度为 512)。对于其他值,则会有非常小的因子,如 exp(−d_k)

Attention_scores = Attention_scores / 2

我们现在准备应用 softmax 映射:

其中 x_i 是一个通用向量的第 i 个分量。因此,它导致了概率的分布。值得提到的是,这个函数仅定义在向量上,而不是 2 维矩阵上。当说对 A_norm 应用 softmax 时,实际上是对 A_norm 的每一行(向量)分别应用 softmax。它假定格式为 1 维向量的堆栈,表示 V 中每个向量的权重。这意味着通常应用于矩阵的操作,比如旋转,在这种情况下没有意义。这是因为我们处理的不是一个整体的 2 维实体,而是一个以 2 维格式排列的单独向量的集合。

Softmax_Attention_Matrix = np.apply_along_axis(softmax, 1, Attention_scores)

我们的结果然后乘以 V,最终得到一个单头注意力矩阵,这是初始 V(以及初始 X)的更新版本:

One_Head_Attention = np.matmul(Softmax_Attention_Matrix,V)

现在我们来构建一个类,初始化我们的权重矩阵并实现计算单头注意力层的方法。注意,我们只关注前向传播,因此诸如反向传播的方法将在即将到来的文章中讨论。

class One_Head_Attention:
    def __init__(self, d_model, X):
        self.d_model = d_model
        self.W_mat = W_matrices(d_model, d_model)

        self.Q = np.matmul(X, self.W_mat.W_Q)
        self.K = np.matmul(X, self.W_mat.W_K)
        self.V = np.matmul(X, self.W_mat.W_V)

    def print_QKV(self):
        print('Q : \n', self.Q)
        print('K : \n', self.K)
        print('V : \n', self.V)

    def compute_1_head_attention(self):
        Attention_scores = np.matmul(self.Q, np.transpose(self.K)) 
        print('Attention_scores before normalization : \n', Attention_scores)
        Attention_scores = Attention_scores / np.sqrt(self.d_model) 
        print('Attention scores after Renormalization: \n ', Attention_scores)
        Softmax_Attention_Matrix = np.apply_along_axis(softmax, 1, Attention_scores)
        print('result after softmax: \n', Softmax_Attention_Matrix)
        # print('Softmax shape: ', Softmax_Attention_Matrix.shape)

        result = np.matmul(Softmax_Attention_Matrix, self.V)
        print('softmax result multiplied by V: \n', result)

        return result

    def _backprop(self):
        # do smth to update W_mat
        pass

多头注意力层

论文定义多头注意力为在并行中应用此机制 ℎ 次,每次使用自己独特的 WQ*、*WKW^V 矩阵。在程序结束时,我们会得到 ℎ 个自注意力矩阵,称为头:

其中 Q_iK_iV_i 是通过其各自的权重矩阵 W_iQ*、*W_iKW_i^V 的乘法来定义的。在我们的例子中,我们已经计算出了一个单头注意力:

让我们考虑一个第二个头矩阵,经过我们在这里进行的相同计算后,生成以下矩阵:

一旦我们得到单头注意力矩阵,我们可以将多头注意力定义为所有 head_i 的串联,乘以一个新的可学习矩阵 W_0

其中 W_0(在我们的例子中,是一个 8×4 的矩阵)被随机初始化为

用于生成我们的多头注意力:

最后,结果被添加到初始向量 X 中(一个称为残差连接的操作):

残差连接防止模型遇到消失/爆炸梯度问题(再次)。主要思想是,当我们将原始 X 向量添加到矩阵乘法结果中时,每个最终向量的范数被调整为与原始向量相同的数量级。

通过这个过程,将 3 个 4 维向量(X)映射到另一组 3 个 4 维向量(更新后的 V)。那么,这样做的好处是什么?答案是,现在我们拥有 3 个向量,这些向量以某种方式编码(并且这种编码随着训练的进行而变得更好)句子中出现的单词之间的注意力/语义关系,相对于最初通过简单的独热编码算法得到的 3 个向量。也就是说,现在我们有了这样一种向量嵌入,它以更精细的方式考虑了它们出现的上下文。

让我们将多头注意力层实现为一个新类:

class Multi_Head_Attention:
    def __init__(self, n_heads, d_model, X):
        self.d_model = d_model
        self.n_heads = n_heads
        self.d_concat = self.d_model*self.n_heads # 4*8
        self.W_0 = np.random.uniform(-1, 1, size=(self.d_concat, self.d_model))
        # print('W_0 shape : ', self.W_0.shape)
        self.heads = []
        self.heads_results = []
        i = 0
        while i < self.n_heads:
            self.heads.append(One_Head_Attention(self.d_model, X))
            i += 1

    def print_W_0(self):
        print('W_0 : \n', self.W_0)

    def print_QKV_each_head(self):
        i = 0
        while i < self.n_heads:
            print(f'Head {i}: \n')
            self.heads[i].print_QKV()
            i += 1

    def print_W_matrices_each_head(self):
        i = 0
        while i < self.n_heads:
            print(f'Head {i}: \n')
            self.heads[i].W_mat.print_W_matrices()
            i += 1

    def compute(self):
        for head in self.heads:
            self.heads_results.append(head.compute_1_head_attention())
            # print('head: ', self.heads_results[-1].shape)

        multi_head_results = np.concatenate(self.heads_results, axis=1)
        # print('multi_head_results shape = ', multi_head_results.shape)

        V_updated = np.matmul(multi_head_results, self.W_0)
        return V_updated

    def back_propagate(self):
        # backpropagate W_0
        # call _backprop for each head
        pass

位置编码和全连接前馈网络

我们已经涵盖了我们认为是论文《Attention is all you need》中编码器部分的核心内容。我们遗漏了两个重要部分,将在本节中讨论。第一个是出现在编码器堆栈最开头的,即位置编码过程。

为了简化,我们输入到注意力机制中的向量方式并未考虑句子中出现的单词顺序。这确实是一个重大缺陷。显然,单词的顺序是其语义价值的关键元素,因此,它必须存在于嵌入中。在论文中,作者提出了一个解决方案,利用正弦和余弦函数。它们用于对嵌入向量的每个分量进行顺序编码。对于句子中第 i 个位置的单词,它的每个 j 个分量与位置编码关联如下:

并且,由于PE_i是与嵌入向量大小相同的向量,它会被加到嵌入向量中,以包括单词在句子中的位置相关信息:

使用这种构造的一个很大优势在于我们包含了新的信息而无需额外空间。另一个优势是信息被分布在整个向量中,因此,它通过在层中发生的多次矩阵乘法与其他向量的所有组件进行通信。

我们现在准备实现我们的位置信息编码层:

class Positional_Encoding:
    def __init__(self, X):
        self.PE_shape = X.shape
        self.PE = np.empty(self.PE_shape)
        self.d_model = self.PE_shape[1]

    def compute(self, X):
        for i in range(self.PE_shape[0]): 
            for j in range(self.PE_shape[1]):
                self.PE[i,2*j] = math.sin(i/(10000**(2*j/self.d_model)))
                self.PE[i,2*j+1] = math.cos(i/(10000**(2*j/self.d_model)))
                # this way we are assuming that the vectors are ordered stacked in X

        return X + self.PE

最后,在编码器的末尾,有一个简单的全连接前馈网络,由 2 层组成。尽管它不是论文中创新的部分,但它通过 ReLu 激活函数添加了非线性,从而捕获其他语义关联[2]

让我们来实现这些:

class FFN:
    def __init__(self, V_updated, layer1_sz, layer2_sz):
        self.layer1_sz = layer1_sz
        self.layer2_sz = layer2_sz
        self.layer1_weights = np.random.uniform(low=-1, high=1, size=(V_updated.shape[1], layer1_sz))
        self.layer2_weights = np.random.uniform(low=-1, high=1, size=(layer2_sz, V_updated.shape[1]))

    def compute(self, V_updated):
        result = np.matmul(V_updated, self.layer1_weights)
        result = np.matmul(result, self.layer2_weights)

        return result

    def backpropagate_ffn(self):
        pass

结论

我们首先探讨了 NLP 中的嵌入概念,解释了词语及其语义如何被转换为 AI 模型可以处理的数值形式。接着,我们深入研究了 Transformer 架构,从单头注意力层开始,并解释了查询、键和值在这一框架中的作用。

然后我们讲解了注意力得分,它们代表了什么,以及如何对其进行归一化以解决梯度消失和爆炸的问题。在指导了如何理解单头注意力层的工作原理后,我们介绍了创建多头注意力机制的过程。这使得模型能够同时处理和整合多个视角的输入数据。

作为最后一个组成部分,我们涵盖了位置编码和简单的全连接前馈网络。前者使我们能够保持词语的顺序,这对理解句子的上下文意义至关重要。后者则通过激活函数发挥了增加非线性的关键作用。

关于我

连续创业者和 AI 领域的领袖。我为企业开发 AI 产品,并投资于以 AI 为重点的初创公司。

创始人 @ ZAAI | LinkedIn | X/Twitter

参考文献

[1] Ashish Vaswani 等人(2017),《注意力机制就是你所需要的一切》 doi.org/10.48550/arXiv.1706.03762

[2] Mor Geva 等人(2022)。《变压器前馈层通过在词汇空间中促进概念来构建预测》,2022 年自然语言处理实证方法会议论文集,第 30–45 页 arxiv.org/pdf/2203.14680.pdf

除非另有说明,所有图像均由作者提供。

解码 NumPy 的点积:对维度魔法的简要探索

原文:towardsdatascience.com/decoding-numpys-dot-product-a-brief-exploration-of-dimensional-wizardry-63d80f21a315

一劳永逸地澄清对 NumPy 点积的困惑

Mario LarcherTowards Data Science Mario Larcher

·发布在 Towards Data Science ·5 分钟阅读·2023 年 7 月 24 日

--

图片生成自 DreamStudio,提示词为“一片混乱、黑暗、阴沉、充满代码巫师的多维世界”。

介绍

我是唯一一个在处理 NumPy 中的维度时经常感到困惑的人吗?今天,在阅读 Gradio 的文档页面时,我遇到了以下代码片段:

sepia_filter = np.array([
    [0.393, 0.769, 0.189], 
    [0.349, 0.686, 0.168],
    [0.272, 0.534, 0.131],
])
# input_img shape (H, W, 3)
# sepia_filter shape (3, 3)
sepia_img = input_img.dot(sepia_filter.T)  # <- why this is legal??
sepia_img /= sepia_img.max()

嘿,嘿,嘿!为什么一个形状为 (W, H, 3) 的图像与一个形状为 (3, 3) 的滤镜的点积是合法的?我问了 ChatGPT,但它开始给我错误的答案(例如说这行不通)或忽略了我的问题,开始回答其他内容。因此,唯一的解决办法就是动脑筋(再加上阅读文档,唉)。

如果你对上面的代码也感到有些困惑,请继续阅读。

点积:一个通用示例

来自 NumPy 点积文档(略有修改):

如果 a.shape = (I, J, C) 且 b.shape = (K, C, L),那么 dot(a, b)[i, j, k, l] = sum(a[i, j, :] * b[k, :, l])。请注意,“a”的最后一个维度等于“b”的倒数第二个维度。

或者,以代码的形式:

I, J, K, L, C = 10, 20, 30, 40, 50
a = np.random.random((I, J, C))
b = np.random.random((K, C, L))
c = a.dot(b)
i, j, k, l = 3, 2, 4, 5
print(c[i, j, k, l])
print(sum(a[i, j, :] * b[k, :, l]))

输出(相同的结果):

13.125012901284713
13.125012901284713

理解 NumPy 点积形状

要提前确定点积的形状,请按照以下步骤操作:

步骤 1:考虑两个数组,“a”和“b”,以及它们各自的形状。

# Example shapes for arrays a and b
a_shape = (4, 3, 2)
b_shape = (3, 2, 5)
# Create random arrays with the specified shapes
a = np.random.random(a_shape)
b = np.random.random(b_shape)

在这个例子中,数组“a”的形状是 (4, 3, 2),而数组“b”的形状是 (3, 2, 5)。请注意,再次强调,“a”的最后一个维度和“b”的倒数第二个维度必须匹配。

步骤 2:取“a”的所有维度,除了最后一个维度,和“b”的所有维度,除了倒数第二个维度。

对于数组 “a”,我们排除最后一个维度(即 2),结果的形状为 (4, 3)。对于数组 “b”,我们排除倒数第二个维度(也是 2),结果的形状为 (3, 5)。

步骤 3:连接步骤 2 中获得的形状。

通过使用我们的规则连接形状,我们得到 (4, 3, 3, 5)。让我们验证一下这个结果是否正确:

c = a.dot(b)
print(c.shape)

输出:

(4, 3, 3, 5)

正如我们所看到的,点积的结果形状与我们计算的形状 (4, 3, 3, 5) 一致。因此,我们对点积形状的理解是正确的!

使用 sepia 滤镜澄清 RGB 像素的点积

让我们回到原始的例子,使用一个图像 (H, W, C) 和一个滤波器 (O, C),在这个例子中是 (3, 3)。

记住,在原始示例中,点积是与 sepia_filter.T 进行的,后者的形状是 (C, O)。在这种情况下,C = O = 3,但如果它们不同,这将是重要的。

我需要从图像的维度中取出所有维度,除了最后一个,这里是 H 和 W,从滤波器的维度中取出所有维度,除了倒数第二个,这里是 O。因此,结果的维度是 (H, W, O),在我们的例子中是 (H, W, 3),仍然是“类似 RGB”的。

使用 NumPy 文档的表示法:

sepia_filter_T = sepia_filter.T
dot(input_img, sepia_filter_T)[h, w, c] = sum(input_img[h, w, :] * sepia_filter_T[:, c])

请注意,这与(移除 sepia_filter 的转置)是一样的:

dot(input_img, sepia_filter)[h, w, c] = sum(input_img[h, w, :] * sepia_filter[c, :])

但直观地说,如何计算新图像中的每个 RGB 像素?基本上,每个新像素的每个通道值(假设位置为 4, 2 的 R,即“红色”)是旧 RGB 像素值的线性组合,其中这一线性组合的权重是 sepia_filter 中相应行的值(R 的行索引为 0,G 为 1,B 为 2)。

附加信息:你还可以使用 einsum 来实现这一点!(更多的困惑哈哈,我知道,NumPy 确实很复杂):

sepia_img = np.einsum("HWC, OC -> HWO", input_img, sepia_filter)
sepia_img /= sepia_img.max()
plt.imshow(sepia_img)
plt.axis("off")

输出:

我的头像“怀旧色调”

尝试一下,并试图理解它是如何工作的作为练习。

结论

恭喜你!你已经成功地深入了解了 NumPy 的点积运算,并揭示了它的奥秘。通过遵循形状连接的简单规则,你现在可以轻松确定任何一对数组的点积结果形状。

理解维度如何相互作用,使你能够在各种图像操作中有效地使用点积。例如,我们探索了使用 sepia 滤镜对图像进行变换,通过 RGB 值的线性组合创造了美丽的效果。

现在掌握了这些知识,你可以自信地探索 NumPy 点积在数值计算和图像处理任务中的广泛应用。因此,勇敢地深入探索,进行实验,让点积发挥它的魔力吧!

感谢你花时间阅读这篇文章,欢迎随时留言或联系我,分享你的想法或提问。要获取我最新文章的更新,你可以关注我在MediumLinkedInTwitter上的动态。

[## 使用我的推荐链接加入 Medium - Mario Namtao Shianti Larcher

作为 Medium 会员,你的部分会员费用将分配给你阅读的作者,你还可以全面访问每一个故事……

medium.com](https://medium.com/@mnslarcher/membership?source=post_page-----63d80f21a315--------------------------------)

大型语言模型中的解码策略

原文:towardsdatascience.com/decoding-strategies-in-large-language-models-9733a8f70539

从束搜索到核采样的文本生成指南

Maxime LabonneTowards Data Science 马克西姆·拉博讷

·发表于 Towards Data Science ·阅读时间 15 分钟·2023 年 6 月 4 日

--

图片由作者提供。

在大型语言模型(LLM)的迷人世界中,很多关注都集中在模型架构、数据处理和优化上。然而,像束搜索这样的解码策略,在文本生成中发挥着至关重要的作用,却常常被忽视。在本文中,我们将探讨 LLM 如何生成文本,通过深入了解贪婪搜索和束搜索的机制,以及使用 top-k 和 nucleus 采样的采样技术。

到本文结束时,你不仅会彻底理解这些解码策略,还会熟悉如何处理重要的超参数,如温度、num_beams、top_k 和 top_p。

本文的代码可以在 GitHubGoogle Colab 上找到,以供参考和进一步探索。

📚 背景

为了开始,让我们从一个例子开始。我们将“我有一个梦想”这个文本输入到 GPT-2 模型中,并要求它生成接下来的五个标记(单词或子单词)。

from transformers import GPT2LMHeadModel, GPT2Tokenizer
import torch

device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = GPT2LMHeadModel.from_pretrained('gpt2').to(device)
tokenizer = GPT2Tokenizer.from_pretrained('gpt2')
model.eval()

text = "I have a dream"
input_ids = tokenizer.encode(text, return_tensors='pt').to(device)

outputs = model.generate(input_ids, max_length=len(input_ids.squeeze())+5)
generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(f"Generated text: {generated_text}")
Generated text: I have a dream of being a doctor.

句子“我有一个成为医生的梦想”似乎是由 GPT-2 生成的。然而,GPT-2 并没有准确生成这个句子。

有一种普遍的误解是,像 GPT-2 这样的 LLM 直接生成文本。事实并非如此。相反,LLM 计算 logits,即分配给其词汇表中每个可能标记的分数。为了简化,这里有一个说明性过程分解:

图片由作者提供。

分词器,字节对编码 在这个实例中,将输入文本中的每个词翻译成对应的词 ID。然后,GPT-2 使用这些词 ID 作为输入并尝试预测下一个最可能的词。最后,模型生成 logits,这些 logits 通过 softmax 函数转换为概率。

例如,该模型为“of”作为 “I have a dream” 之后的下一个词分配了 17% 的概率。这个输出本质上表示了潜在下一个词的排名列表。更正式地,我们将这个概率表示为 P(of | I have a dream) = 17%

自回归模型如 GPT 会基于前面的词预测序列中的下一个词。考虑一个词序列 w = (w, w, …, w)。这个序列的联合概率 P(w) 可以被分解为:

对于序列中的每个词 wᵢP(wᵢ | w₁, w₂, …, wᵢ₋₁) 表示在给定所有前面的词(w₁, w₂, …, wᵢ₋₁)的情况下 wᵢ 的条件概率。GPT-2 为其词汇表中的每一个 50,257 个词计算这个条件概率。

这就引出了一个问题:我们如何利用这些概率生成文本?这就是解码策略(如贪婪搜索和束搜索)发挥作用的地方。

🏃‍♂️ 贪婪搜索

贪婪搜索是一种解码方法,它在每一步都选择最可能的词作为序列中的下一个词。简单来说,它只保留每个阶段中最可能的词,丢弃所有其他潜在的选项。以我们的例子为例:

  • 步骤 1:输入:“I have a dream” → 最可能的词: “ of”

  • 步骤 2:输入:“I have a dream of” → 最可能的词: “ being”

  • 步骤 3:输入:“I have a dream of being” → 最可能的词: “ a”

  • 步骤 4:输入:“I have a dream of being a” → 最可能的词: “ doctor”

  • 步骤 5:输入:“I have a dream of being a doctor” → 最可能的词: “.”

尽管这种方法听起来直观,但需要注意的是,贪婪搜索具有短视性:它只考虑每一步中最可能的词,而不考虑对整个序列的总体影响。这一特性使得它快速且高效,因为它不需要跟踪多个序列,但也意味着它可能错过了那些通过稍微不那么可能的下一个词可能出现的更好序列。

接下来,让我们使用 graphviz 和 networkx 来说明贪婪搜索的实现。我们选择得分最高的 ID,计算其对数概率(我们取对数以简化计算),并将其添加到树中。我们将为五个词重复这个过程。

import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import time

def get_log_prob(logits, token_id):
    # Compute the softmax of the logits
    probabilities = torch.nn.functional.softmax(logits, dim=-1)
    log_probabilities = torch.log(probabilities)

    # Get the log probability of the token
    token_log_probability = log_probabilities[token_id].item()
    return token_log_probability

def greedy_search(input_ids, node, length=5):
    if length == 0:
        return input_ids

    outputs = model(input_ids)
    predictions = outputs.logits

    # Get the predicted next sub-word (here we use top-k search)
    logits = predictions[0, -1, :]
    token_id = torch.argmax(logits).unsqueeze(0)

    # Compute the score of the predicted token
    token_score = get_log_prob(logits, token_id)

    # Add the predicted token to the list of input ids
    new_input_ids = torch.cat([input_ids, token_id.unsqueeze(0)], dim=-1)

    # Add node and edge to graph
    next_token = tokenizer.decode(token_id, skip_special_tokens=True)
    current_node = list(graph.successors(node))[0]
    graph.nodes[current_node]['tokenscore'] = np.exp(token_score) * 100
    graph.nodes[current_node]['token'] = next_token + f"_{length}"

    # Recursive call
    input_ids = greedy_search(new_input_ids, current_node, length-1)

    return input_ids

# Parameters
length = 5
beams = 1

# Create a balanced tree with height 'length'
graph = nx.balanced_tree(1, length, create_using=nx.DiGraph())

# Add 'tokenscore', 'cumscore', and 'token' attributes to each node
for node in graph.nodes:
    graph.nodes[node]['tokenscore'] = 100
    graph.nodes[node]['token'] = text

# Start generating text
output_ids = greedy_search(input_ids, 0, length=length)
output = tokenizer.decode(output_ids.squeeze().tolist(), skip_special_tokens=True)
print(f"Generated text: {output}")
Generated text: I have a dream of being a doctor.

我们的贪婪搜索生成的文本与 transformers 库中的文本相同:“I have a dream of being a doctor。”让我们可视化我们创建的树。

import matplotlib.pyplot as plt
import networkx as nx
import matplotlib.colors as mcolors
from matplotlib.colors import LinearSegmentedColormap

def plot_graph(graph, length, beams, score):
    fig, ax = plt.subplots(figsize=(3+1.2*beams**length, max(5, 2+length)), dpi=300, facecolor='white')

    # Create positions for each node
    pos = nx.nx_agraph.graphviz_layout(graph, prog="dot")

    # Normalize the colors along the range of token scores
    if score == 'token':
        scores = [data['tokenscore'] for _, data in graph.nodes(data=True) if data['token'] is not None]
    elif score == 'sequence':
        scores = [data['sequencescore'] for _, data in graph.nodes(data=True) if data['token'] is not None]
    vmin = min(scores)
    vmax = max(scores)
    norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
    cmap = LinearSegmentedColormap.from_list('rg', ["r", "y", "g"], N=256) 

    # Draw the nodes
    nx.draw_networkx_nodes(graph, pos, node_size=2000, node_shape='o', alpha=1, linewidths=4, 
                          node_color=scores, cmap=cmap)

    # Draw the edges
    nx.draw_networkx_edges(graph, pos)

    # Draw the labels
    if score == 'token':
        labels = {node: data['token'].split('_')[0] + f"\n{data['tokenscore']:.2f}%" for node, data in graph.nodes(data=True) if data['token'] is not None}
    elif score == 'sequence':
        labels = {node: data['token'].split('_')[0] + f"\n{data['sequencescore']:.2f}" for node, data in graph.nodes(data=True) if data['token'] is not None}
    nx.draw_networkx_labels(graph, pos, labels=labels, font_size=10)
    plt.box(False)

    # Add a colorbar
    sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
    sm.set_array([])
    if score == 'token':
        fig.colorbar(sm, ax=ax, orientation='vertical', pad=0, label='Token probability (%)')
    elif score == 'sequence':
        fig.colorbar(sm, ax=ax, orientation='vertical', pad=0, label='Sequence score')
    plt.show()

# Plot graph
plot_graph(graph, length, 1.5, 'token')

作者提供的图片。

在此图中,顶级节点存储输入令牌(因此概率为 100%),而所有其他节点表示生成的令牌。虽然这个序列中的每个令牌在预测时都是最可能的,但“being”和“doctor”被分配了相对较低的概率,分别为 9.68%和 2.86%。这表明“of”,我们的第一个预测令牌,可能不是最合适的选择,因为它导致了“being”,这是相当不可能的。

在接下来的部分中,我们将探讨光束搜索如何解决这个问题。

⚖️ Beam Search

与仅考虑下一个最可能令牌的贪婪搜索不同,光束搜索考虑了n个最可能的令牌,其中n代表光束的数量。这个过程会重复,直到达到预定义的最大长度或出现序列结束令牌。此时,具有最高总体分数的序列(或“光束”)被选为输出。

我们可以调整之前的函数,以考虑n个最可能的令牌,而不仅仅是一个。在这里,我们将保持序列分数日志P(w),它是光束中每个令牌对数概率的累积和。我们通过序列长度来归一化此分数,以防止对较长序列的偏倚(这个因子可以调整)。我们将生成五个额外的令牌来完成句子“I have a dream.”

from tqdm.notebook import tqdm

def greedy_sampling(logits, beams):
    return torch.topk(logits, beams).indices

def beam_search(input_ids, node, bar, length, beams, sampling, temperature=0.1):
    if length == 0:
        return None

    outputs = model(input_ids)
    predictions = outputs.logits

    # Get the predicted next sub-word (here we use top-k search)
    logits = predictions[0, -1, :]

    if sampling == 'greedy':
        top_token_ids = greedy_sampling(logits, beams)
    elif sampling == 'top_k':
        top_token_ids = top_k_sampling(logits, temperature, 20, beams)
    elif sampling == 'nucleus':
        top_token_ids = nucleus_sampling(logits, temperature, 0.5, beams)

    for j, token_id in enumerate(top_token_ids):
        bar.update(1)

        # Compute the score of the predicted token
        token_score = get_log_prob(logits, token_id)
        cumulative_score = graph.nodes[node]['cumscore'] + token_score

        # Add the predicted token to the list of input ids
        new_input_ids = torch.cat([input_ids, token_id.unsqueeze(0).unsqueeze(0)], dim=-1)

        # Add node and edge to graph
        token = tokenizer.decode(token_id, skip_special_tokens=True)
        current_node = list(graph.successors(node))[j]
        graph.nodes[current_node]['tokenscore'] = np.exp(token_score) * 100
        graph.nodes[current_node]['cumscore'] = cumulative_score
        graph.nodes[current_node]['sequencescore'] = 1/(len(new_input_ids.squeeze())) * cumulative_score
        graph.nodes[current_node]['token'] = token + f"_{length}_{j}"

        # Recursive call
        beam_search(new_input_ids, current_node, bar, length-1, beams, sampling, 1)

# Parameters
length = 5
beams = 2

# Create a balanced tree with height 'length' and branching factor 'k'
graph = nx.balanced_tree(beams, length, create_using=nx.DiGraph())
bar = tqdm(total=len(graph.nodes))

# Add 'tokenscore', 'cumscore', and 'token' attributes to each node
for node in graph.nodes:
    graph.nodes[node]['tokenscore'] = 100
    graph.nodes[node]['cumscore'] = 0
    graph.nodes[node]['sequencescore'] = 0
    graph.nodes[node]['token'] = text

# Start generating text
beam_search(input_ids, 0, bar, length, beams, 'greedy', 1)

该函数计算 63 个令牌的分数和 beams^length = 5² = 25 个可能序列。在我们的实现中,所有信息都存储在图表中。我们的下一步是提取最佳序列。

首先,我们确定具有最高序列分数的叶节点。接下来,我们找到从根到这个叶子节点的最短路径。沿着这条路径的每个节点都包含了最优序列中的一个令牌。以下是我们如何实现它:

def get_best_sequence(G):
    # Create a list of leaf nodes
    leaf_nodes = [node for node in G.nodes() if G.out_degree(node)==0]

    # Get the leaf node with the highest cumscore
    max_score_node = None
    max_score = float('-inf')
    for node in leaf_nodes:
        if G.nodes[node]['sequencescore'] > max_score:
            max_score = G.nodes[node]['sequencescore']
            max_score_node = node

    # Retrieve the sequence of nodes from this leaf node to the root node in a list
    path = nx.shortest_path(G, source=0, target=max_score_node)

    # Return the string of token attributes of this sequence
    sequence = "".join([G.nodes[node]['token'].split('_')[0] for node in path])

    return sequence, max_score

sequence, max_score = get_best_sequence(graph)
print(f"Generated text: {sequence}")
Generated text: I have a dream. I have a dream

最佳序列似乎是“I have a dream. I have a dream”,这是 GPT-2 的常见响应,尽管这可能令人惊讶。为了验证这一点,让我们绘制图表。

在这个可视化中,我们将显示每个节点的序列分数,这代表了到该点为止的序列分数。如果函数 get_best_sequence()是正确的,则序列“I have a dream. I have a dream”中的“dream”节点应该在所有叶节点中具有最高分数。

# Plot graph
plot_graph(graph, length, beams, 'sequence')

确实,“dream”令牌具有最高序列分数,值为-0.69。令人感兴趣的是,我们可以看到左侧贪婪序列“I have a dream of being a doctor.”的分数值为-1.16。

正如预期的那样,贪婪搜索导致了次优结果。但是,老实说,我们的新结果也没有特别引人注目。为了生成更多样化的序列,我们将实现两种采样算法:top-k 和 nucleus。

🎲 Top-k 采样

Top-k 采样是一种利用语言模型生成的概率分布来从最可能的k个选项中随机选择一个令牌的技术。

举例来说,假设我们有k = 3和四个 token:A、B、C 和 D,分别的概率为:P(A) = 30%P(B) = 15%P(C) = 5%P(D) = 1%。在 top-k 采样中,token D 被忽略,算法会在 60%的时间内输出 A,在 30%的时间内输出 B,在 10%的时间内输出 C。这种方法确保我们优先考虑最可能的 token,同时在选择过程中引入一定的随机性。

引入随机性的另一种方式是温度的概念。温度T是一个范围从 0 到 1 的参数,它影响 softmax 函数生成的概率,使最可能的 token 更具影响力。在实际操作中,它简单地包括将输入 logits 除以我们称之为温度的值:

这里是一个图表,展示了温度对给定输入 logits [1.5, -1.8, 0.9, -3.2] 生成的概率的影响。我们绘制了三种不同的温度值来观察差异。

温度为 1.0 相当于没有温度的默认 softmax。另一方面,低温设置(0.1)会显著改变概率分布。这在文本生成中常用于控制生成输出的“创造性”水平。通过调整温度,我们可以影响模型生成更具多样性或更可预测的响应的程度。

现在让我们实现 top-k 采样算法。我们将在 beam_search()函数中使用“top_k”参数。为了说明算法的工作原理,我们还将绘制 top_k = 20 的概率分布。

def plot_prob_distribution(probabilities, next_tokens, sampling, potential_nb, total_nb=50):
    # Get top k tokens
    top_k_prob, top_k_indices = torch.topk(probabilities, total_nb)
    top_k_tokens = [tokenizer.decode([idx]) for idx in top_k_indices.tolist()]

    # Get next tokens and their probabilities
    next_tokens_list = [tokenizer.decode([idx]) for idx in next_tokens.tolist()]
    next_token_prob = probabilities[next_tokens].tolist()

    # Create figure
    plt.figure(figsize=(0.4*total_nb, 5), dpi=300, facecolor='white')
    plt.rc('axes', axisbelow=True)
    plt.grid(axis='y', linestyle='-', alpha=0.5)
    if potential_nb < total_nb:
        plt.axvline(x=potential_nb-0.5, ls=':', color='grey', label='Sampled tokens')
    plt.bar(top_k_tokens, top_k_prob.tolist(), color='blue')
    plt.bar(next_tokens_list, next_token_prob, color='red', label='Selected tokens')
    plt.xticks(rotation=45, ha='right', va='top')
    plt.gca().spines['top'].set_visible(False)
    plt.gca().spines['right'].set_visible(False)
    if sampling == 'top_k':
        plt.title('Probability distribution of predicted tokens with top-k sampling')
    elif sampling == 'nucleus':
        plt.title('Probability distribution of predicted tokens with nucleus sampling')
    plt.legend()
    plt.savefig(f'{sampling}_{time.time()}.png', dpi=300)
    plt.close()

def top_k_sampling(logits, temperature, top_k, beams, plot=True):
    assert top_k >= 1
    assert beams <= top_k

    indices_to_remove = logits < torch.topk(logits, top_k)[0][..., -1, None]
    new_logits = torch.clone(logits)
    new_logits[indices_to_remove] = float('-inf')

    # Convert logits to probabilities
    probabilities = torch.nn.functional.softmax(new_logits / temperature, dim=-1)

    # Sample n tokens from the resulting distribution
    next_tokens = torch.multinomial(probabilities, beams)

    # Plot distribution
    if plot:
        total_prob = torch.nn.functional.softmax(logits / temperature, dim=-1)
        plot_prob_distribution(total_prob, next_tokens, 'top_k', top_k)

    return next_tokens

# Start generating text
beam_search(input_ids, 0, bar, length, beams, 'top_k', 1)

图片由作者提供。

这些图给出了 top-k 采样如何工作的良好直观印象,所有可能选择的 token 在水平条的左侧。虽然最可能的 token(红色)大多数时间被选择,但它也允许选择可能性较低的 token。这提供了一个有趣的权衡,可以使序列趋向于一个更不可预测但更自然的句子。现在让我们打印它生成的文本。

sequence, max_score = get_best_sequence(graph)
print(f"Generated text: {sequence}")
Generated text: I have a dream job and I want to

top-k 采样找到了一个新序列:“我有一个梦想工作,我想要”,这比“我有一个梦想。我有一个梦想”显得自然得多。我们在取得进展!

让我们看看这个决策树与之前的有何不同。

# Plot graph
plot_graph(graph, length, beams, 'sequence')

你可以看到节点与之前的迭代相比有显著不同,做出了更多样的选择。虽然这一新结果的序列分数可能不是最高的(-1.01,而之前为-0.69),但要记住,更高的分数并不总是能导致更现实或有意义的序列。

现在我们引入了 top-k 采样,我们必须介绍另一种最受欢迎的采样技术:核心采样。

🔬 核心采样

核采样,也称为 top-p 采样,与 top-k 采样采取了不同的方法。核采样不是选择前k个最可能的标记,而是选择一个截止值p,使得选择的标记的概率总和超过p。这形成了一个从中随机选择下一个标记的“核心”。

换句话说,模型按概率从高到低检查其最可能的标记,并不断将它们添加到列表中,直到总概率超过阈值p。与 top-k 采样不同,核中包含的标记数量可能在每一步变化。这种变化通常导致更具多样性和创造性的输出,使得核采样在文本生成等任务中颇受欢迎。

为了实现核采样方法,我们可以在beam_search()函数中使用“nucleus”参数。在这个示例中,我们将p的值设置为 0.5。为了简化操作,我们将包括一个最小的标记数,等于光束的数量。我们还将考虑累计概率低于p的标记,而不是高于的标记。值得注意的是,虽然细节可能有所不同,但核采样的核心思想保持不变。

def nucleus_sampling(logits, temperature, p, beams, plot=True):
    assert p > 0
    assert p <= 1

    # Sort the probabilities in descending order and compute cumulative probabilities
    sorted_logits, sorted_indices = torch.sort(logits, descending=True)
    probabilities = torch.nn.functional.softmax(sorted_logits / temperature, dim=-1)
    cumulative_probabilities = torch.cumsum(probabilities, dim=-1)

    # Create a mask for probabilities that are in the top-p
    mask = cumulative_probabilities < p

    # If there's not n index where cumulative_probabilities < p, we use the top n tokens instead
    if mask.sum() > beams:
        top_p_index_to_keep = torch.where(mask)[0][-1].detach().cpu().tolist()
    else:
        top_p_index_to_keep = beams

    # Only keep top-p indices
    indices_to_remove = sorted_indices[top_p_index_to_keep:]
    sorted_logits[indices_to_remove] = float('-inf')

    # Sample n tokens from the resulting distribution
    probabilities = torch.nn.functional.softmax(sorted_logits / temperature, dim=-1)
    next_tokens = torch.multinomial(probabilities, beams)

    # Plot distribution
    if plot:
        total_prob = torch.nn.functional.softmax(logits / temperature, dim=-1)
        plot_prob_distribution(total_prob, next_tokens, 'nucleus', top_p_index_to_keep)

    return next_tokens

# Start generating text
beam_search(input_ids, 0, bar, length, beams, 'nucleus', 1)

作者提供的图片。

在这个图中,你可以看到核中的标记数量(垂直条的左侧)波动很大。生成的概率分布变化很大,导致选择的标记不总是最可能的。这为生成独特而多样化的序列打开了大门。现在,让我们观察它生成的文本。

sequence, max_score = get_best_sequence(graph)
print(f"Generated text: {sequence}")
Generated text: I have a dream. I'm going to

核采样算法生成的序列是:“I have a dream. I’m going to”,与贪婪采样相比,显示了语义一致性的显著提升。

为了比较决策路径,让我们可视化新生成的树核采样。

# Plot graph
plot_graph(graph, length, beams, 'sequence')

与 top-k 采样一样,这棵树与贪婪采样生成的树非常不同,展示了更多的多样性。top-k 和核采样在生成文本时都提供了独特的优势,增强了多样性,并在输出中引入了创造力。你对这两种方法(甚至是贪婪搜索)的选择将取决于你项目的具体要求和限制。

结论

在这篇文章中,我们深入探讨了 LLM(尤其是 GPT-2)使用的各种解码方法。我们从简单的贪婪搜索开始,这种方法会立即(但往往不是最佳的)选择最可能的下一个标记。接下来,我们介绍了光束搜索技术,这种方法在每一步考虑几个最可能的标记。尽管它提供了更为细致的结果,但光束搜索有时在生成多样化和富有创造性的序列方面表现不佳。

为了引入更多的变异性,我们接着使用了top-k 采样nucleus 采样。Top-k 采样通过在最可能的 k 个标记中随机选择来多样化文本生成,而 nucleus 采样则通过基于累计概率动态地形成一个标记的核心来采取不同的方法。这些方法各自具有独特的优点和潜在的缺点,具体的选择将主要取决于你项目的要求。

最终,理解这些技术及其权衡将帮助你更好地引导 LLMs 生成越来越真实、细致和引人入胜的文本输出。

如果你对更多关于 LLMs 的技术内容感兴趣,可以在 Twitter 上关注我 @maximelabonne

生成式 AI 中的任务概念:智能系统的构建模块

原文:towardsdatascience.com/decoding-tasks-in-generative-ai-the-building-blocks-of-intelligent-systems-f677e8e2ee22

大型企业的生成式 AI:从治理到汇聚 API,第一部分

Eduard van ValkenburgTowards Data Science Eduard van Valkenburg

·发表于 Towards Data Science ·阅读时长 8 分钟·2023 年 9 月 7 日

--

欢迎,亲爱的科技爱好者和商业领袖们!我很高兴开始一系列关于生成式 AI的博客文章,这个话题正在世界上引起广泛关注。

在我深入探讨企业生成式 AI 的迷人世界时,我将不仅探讨治理、安全和可审计等高层次概念,还会提供关于汇聚 API 和理解生成式 AI 架构等主题的实用指导。

图片由 OpenAI 的 DALL-E 模型创建,提示:一位模仿维米尔风格的写作机器人

无论你是 AI 领域的资深专家还是好奇的新手,这个系列旨在阐明大型企业如何利用生成式 AI 的力量推动创新、效率和价值。我将深入探讨复杂问题,破解术语,并提供可操作的见解,帮助你自信地应对 AI 领域。因此,系好安全带,加入我这段激动人心的商业技术未来之旅吧。让我们一起揭开 AI 的神秘面纱!

免责声明:本文提供了一些建筑概念的概述,这些概念并不特定于 Azure,但由于我是微软的解决方案架构师,因此有时会用 Azure 服务进行说明。

任务

我们的第一个中途站是“任务”。在 AI 的宏大体系中,任务是那些小而强大的齿轮,保持着轮子的运转。它们是语言模型(LLM)可以为你执行的明确工作单元,作为你更广泛(AI)系统的构建块。

当我谈论任务时,我指的是具有指定输入和输出的操作。每次任务执行都是独立的,这意味着它不依赖于过去或未来的执行。它是一个在其自身范围内运行的独立操作。

这些任务中内置了提示以引导 LLM。这有点像给朋友提供一套指示——你需要明确且简洁,这样他们就知道该怎么做。有时这可能涉及添加几个示例(few-shots 是输入和预期输出的示例),但提示的构造方式有很多,这超出了本文的范围。

需要考虑的其他事项包括如何在将输出返回给发起用户或应用程序之前进行检查,这时日志记录、监控和其他常规 DevOps 过程就派上用场了。

但对于生成型 AI,还有其他需要考虑的技术。这通常表现为围绕你正在构建的服务的包装器,根据特定的过滤器捕获不同的内容,例如使用Azure AI Content Safety,但这也可能包括用一个人类参与的设置来包装执行,以确保输出在范围内,因为人们检查部分或全部生成的输出。

最后,另一种值得考虑的方法是使用像Prompt Flow这样的工具来编写不同的输入和输出变体,并持续测试这些变体是否仍能产生预期的结果。这对于评估升级的模型(例如当 GPT-35-Turbo 从版本 0301 升级到 0613 时)很有用,但它也可以用来验证并选择适合特定任务的模型,测试一个任务用较小(因此更便宜且更快)的模型,比如 Meta 的 Llama 模型,以及像 GPT-35-Turbo 和 GPT4 这样的较大模型,甚至是微调后的模型,并根据指标自动决定哪些模型应被用于优化成本、延迟和准确性。

集成与示例

所有这一切的美妙之处在于它通过一个文档齐全的 API 暴露出来。这意味着密钥、账单和日志记录通过某种 API 管理系统进行管理,使过程无缝且用户友好,同时以不同的方式将 LLM 的强大功能提供给许多应用程序。

为了让这个概念更具体,我们来看几个例子。假设你需要从文本中导出销售订单,或者用特定领域的重点来总结文本。这些都是 LLM 可以有效且高效完成的任务。

示例 1:根据要求列表和现有职位发布编写职位发布

这个任务是由 HR 部门创建的解决方案,但可能会被公司中的经理使用,最好嵌入到支持职位发布或职业网站的应用程序中(并从那里集成到 LinkedIn 等职位板)。

这种任务的用户输入包括职位名称、职责和资格要求,还包括部门和级别等内容。

使用这个输入,任务会加载相关的可比职位发布作为示例。它可能在提示中包含类似“始终以此文本结束:…”的语言,这作为 HR 在所有职位发布中执行强制性语言的一种方式。

最后,它会实际调用 LLM 来生成新的文本并将其返回给用户。在这个阶段,输出可能会检查某些术语或文本,以确保模型的幻觉不会妨碍结果。也可能会进行一些后处理,例如默认将文本翻译成多种语言。

返回的结果会提供给请求者以进行验证和批准,在进行任何更改或调整后,之前提到的检查和后处理也可能会在此之后触发,以确保用户不会删除强制性措辞,并且翻译是在最终产品上完成的。

示例 1 的流程示意图 — 图片由作者提供

示例 2:将来自电子邮件或电话的订单解析为 JSON

第二个示例专注于自动化那些难以编程的任务。在这个案例中,销售订单,例如来自零售商的订单,通过电话或电子邮件发送过来。

为了从这些自由文本格式中获取“技术”描述,大多数情况下,人工劳动是唯一的解决方法,即使你可能能用复杂的正则表达式和大量代码拼凑出一些东西,但这种工作是 LLM 真正擅长处理人类制造的“混乱”的地方。

对于电话消息,第一步是将其转录出来,目前有许多 API 和模型可以做到这一点,因此在这里我不会详细说明。输出结果与电子邮件相同。

电子邮件或转录的文本会发送到此任务的 API 端点,该任务有一个提示,其中包含指示,例如“从以下文本生成一个 json 对象,总结订单,包含以下字段:customer_name、product、SKU、amount 等…”,某些字段可能包括允许的值,如品牌或产品名称,而其他字段可能指定默认值,所有这些都是提示的一部分。

例如,提示可能如下所示:

*You must extract the following information from the user question below:

1\. Customer (key: customer)
2\. Location (key: location)
3\. Products and orders in a array (key: order_items)

Each order item should have the following information:
1\. Brand, can be Contoso, AdventureWorks (key: brand)
2\. Product, can be Soda, Water, Sparkling Water, default is 'Water' (key: product)
3\. Number of items (key: amount)
4\. Package type, i.e. bottle, can, crate, carton (key: type)

Make sure fields 1 to 3 are answered very short, e.g. for location just say the location name
Please answer in JSON machine-readable format, using the keys from above.

User question:
{{ email or phone transcription }}

JSON:*

使用 LLM 处理输入时,早上好,我在西雅图第 1 大道南的 AW Bar 工作,我想订购 5 箱 Contoso 气泡酒、2 箱 AW 苏打水和 4 瓶 AdventureWorks 的输出可能如下所示:

{
    "customer": "AW Bar",
    "location": "Seattle, WA",
    "order_items": [
        {
            "brand": "Contoso",
            "product": "Sparkling",
            "amount": 5,
            "type": "Crate"
        },
        {
            "brand": "AdventureWorks",
            "product": "Soda",
            "amount": 2,
            "type": "Carton"
        },
        {
            "brand": "AdventureWorks",
            "product": "Water",
            "amount": 4,
            "type": "Bottle"
        }
    ]
}

接下来,对该 JSON 对象进行后处理以验证这些值,检查品牌或产品名称、SKU、订购数量等。如果发现错误,将使用第二个提示请求 LLM 对该特定字段进行更正,如果仍然无法通过验证,则整个任务会被放入某种队列中,交由销售订单处理部门的人员手动修复遗漏或联系客户验证订单,这也可以用于向任务开发者提供反馈,以便他们能随时间捕捉到新的遗漏。

最后,如果订单正确,将其录入订单处理系统,根据情况,订单要么被处理,要么要求客户签字确认后再继续(这也可以依赖于订单,例如如果总订单金额超过某个阈值,客户需要确认,这个阈值可以通过与客户之前的订单进行比较来动态调整)。

图像由 OpenAI 的 DALL-E 模型生成,提示:一个写作机器人以维米尔风格创建销售订单

总之,将提示和 LLM 封装在 RESTful API 中以支持特定任务是一种非常有用的模式,确保不同的团队和应用程序可以重用组织中其他人和团队的智能思维,而无需重新开发所有逻辑,它还使企业能够确保满足某些标准,无论是输出,如第一个示例,还是结构和字段名称,如第二个示例。通过使这些 API 可用并添加 API 聚合层,可以使企业以可扩展、可预测和有价值的方式使用和利用 LLM。

所以,这就是本系列关于生成式 AI 在企业中应用的第一篇博客文章总结。我计划讨论的其他主题包括聊天机器人、治理,最后我将提出一个模型,如何将这些内容结合起来,使其在大型企业中有效运作。

请关注我的下一篇文章,我将深入探讨企业生成式 AI 的世界。在那之前,祝任务愉快!下一篇文章创建并发布时,我会用链接更新这篇文章。

解码数据科学家层级:从初级到高级——是什么使他们与众不同?

原文:towardsdatascience.com/decoding-the-data-scientist-hierarchy-from-junior-to-senior-what-sets-them-apart-566158a0d5ff

揭示初级、中级和高级数据科学家的工作期望范围

Guillaume ColleyTowards Data Science Guillaume Colley

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

--

图片由 Edvard Alexander Rølvaag 提供,来源于 Unsplash

经验和技术专长在定义初级或高级数据科学家时扮演重要角色——但在工作范围方面,这些级别的业务期望是什么?

尽管许多资源和 职位描述 讨论了与每个级别相关的技术职责(或 管理时间),但在数据科学家的不同资历水平的更广泛业务期望方面明显缺乏清晰度。

在这篇文章中,我将揭示初级、中级和高级数据科学家的工作期望范围,这一框架在我的管理角色中证明了其无价之宝——如果你也有以下需求,它可能对你同样有用:

  • 你正在建立一个正式的数据科学实践,并且想知道你需要哪些角色和级别。

  • 你是一名数据科学从业者,希望晋升到下一个级别(或者说服你的老板你已经达到了)。

所以——让我们深入探讨吧!

初级/助理数据科学家

图片由 John Schnobrich 提供,来源于 Unsplash

入门级数据科学家通常是刚毕业的本科或硕士学位的学生,专业为数据科学/应用统计/大数据,或是最近获得数据科学认证的经验丰富的数据分析师。

初级/助理数据科学家专注于明确的任务而非完整项目。通常,分析或模型由其经理或高级数据科学家设计,并设定项目时间表和交付节奏。

初级/助理数据科学家熟悉数据,并利用数据响应于明确的任务进行分析或建模。

初级/助理数据科学家在经理或高级数据科学家的指导下承担定义明确的数据科学任务。

初级/助理数据科学家的期望:

  • 在经理/高级数据科学家的指导下,开发预测模型并进行高级分析。

  • 计划接下来几周的工作。

  • 在经理/高级数据科学家的指导下,将洞察转化为商业建议。

  • 向同事和经理展示发现/技术工作。

  • 理解并建立与紧密跨职能业务领域和直接利益相关者的关系。

  • 学习组织的工具、流程和程序

数据科学家

图片由Glenn Carstens-Peters提供,发布在Unsplash

中级数据科学家拥有几年的经验,能够理解业务问题,设计并领导整个分析或模型的完成,几乎不依赖于经理或高级数据科学家的输出。他们分享中期结果并提出潜在障碍,但在其他方面独立解决技术问题。

拥有良好的组织知识和商业洞察力,他们提出改进建议并识别新数据科学应用的潜在机会。

数据科学家理解业务背景,独立设计和执行数据科学项目。

数据科学家期望:

  • 在跨职能领域识别潜在项目机会,并与经理/高级数据科学家进行优化/优先排序

  • 计划未来几个月的工作项目和交付节奏

  • 在经理/高级数据科学家的最低指导下,设计和开发预测模型及高级分析

  • 独立将洞察转化为商业建议

  • 向同事和业务利益相关者展示洞察/建议/技术工作。

  • 理解并建立与更广泛跨职能领域和利益相关者的关系。

  • 支持组织内的初级数据科学家和数据分析师

  • 了解并遵循组织的工具、流程和程序

高级/首席数据科学家

图片由Jason Goodman提供,来源于Unsplash

高级/首席数据科学家是分析组织和更广泛业务中的推动力量。通过对业务职能领域的深刻理解和建立的关系,他们始终识别改进机会或新的数据科学应用,以推动业务价值。他们优先考虑并推动这些机会的利用,需要最少的管理支持。

高级数据科学家识别更广泛跨职能领域的潜在机会。他们规划、启动、交付复杂的数据科学项目,并影响业务测试、部署和利用他们的解决方案。

高级/首席数据科学家的期望:

  • 在管理者的最小指导下,识别和优化更广泛跨职能领域内的潜在项目机会

  • 支持团队为接下来的六个月构建和完善路线图

  • 在最小的管理指导下,从构想到业务利用,领导数据科学项目的全过程

  • 开发和拥有项目路线图、利益相关者管理和交付成果。

  • 将见解转化为战略建议,并推动建议的实施。

  • 向同行、业务和高层利益相关者展示见解/建议/技术工作

  • 与更广泛的跨职能领域和利益相关者建立关系。

  • 为组织中的初级数据科学家、数据科学家和数据分析师提供参考点:导师和教练

  • 了解并遵循组织工具、流程和程序。推荐新的工具和/或改进流程和程序。

注意: 上述高级数据科学家的范围是“业务”高级数据科学家的角色。在某些情况下,可能更有意义为技术性人才制定一个“技术”高级数据科学家角色,使其成为技术专家(深入研究),而不是业务数据科学家(广泛研究)。

总结

在数据科学领域,区分初级数据科学家、数据科学家和高级数据科学家角色不仅仅涉及经验和技术专长。它取决于工作的范围和业务期望。

本质上,这些角色可以总结为管理“任务”、“项目”和“产品”:

初级数据科学家管理数据科学任务。

数据科学家管理数据科学项目。

高级数据科学家管理数据科学产品。

确定这些工作范围差异并与我的团队分享,确实有助于支持数据科学家的成长和发展。这清楚地设定了每个数据科学家层级所需的期望,使从业者明确知道他们必须展示什么才能晋升到下一级。

你在组织中的运作情况如何?使用了类似的框架吗?我是否遗漏了什么?请在下方评论中分享!

参考资料

[1] M. Crabtree, 如何编写数据科学家职位描述 (2022), www.datacamp.com/blog/data-scientist-job-description

[2] H. Brooks, S. Gutierrez, 初级、中级和高级数据科学家职位的区别 (2020), www.datascienceweekly.org/articles/the-difference-between-junior-mid-level-and-senior-data-scientist-jobs

解码曼哈顿计划的网络:揭示科学、合作与人类遗产

原文:towardsdatascience.com/decoding-the-manhattan-projects-network-unveiling-science-collaboration-and-human-legacy-418164a2b416

米兰·贾诺索夫Towards Data Science 米兰·贾诺索夫

·发表于 Towards Data Science ·阅读时长 7 分钟·2023 年 10 月 2 日

--

发表于《夜莺》数据可视化学会期刊,2023 年 9 月 12 日。编辑:凯瑟琳·赫奇拉。

曼哈顿计划是历史上最大规模的科学合作之一。它通过一张由杰出头脑组成的复杂社会网络运作,毫无疑问成为了人类历史上最显著的智力努力之一。然而,它在广岛和长崎原子弹爆炸期间及之后也产生了毁灭性的后果。尽管在轰炸及随后的事件中失去了数十万条人命,但科学之旅本身仍然证明了人类的成就,正如克里斯托弗·诺兰对奥本海默的电影描绘所强调的那样。

关于合作的科学文献,特别是网络连接在成功中所扮演的角色,已经非常丰富,并且在当前的数据爆炸中得到了进一步的丰富。这些数据的丰富性,如数以百万计的科学论文,就体现在像 D. Wang 和 A. L. Barabási 的《科学的科学》这样的著作中。利用网络分析来揭示曼哈顿计划中的复杂联系与我从物理学家转行到网络科学家的视角不谋而合。话不多说,这就是我如何将曼哈顿计划映射到数据中,并用这些数据创建该历史性合作项目的网络可视化。

收集数据

与许多数据科学项目一样,第一个问题涉及数据选择。虽然科学出版数据看起来很有逻辑,鉴于项目的科学性质,但这种方法证明是不足够的。主要原因有两个:首先,一些最重要的文档和论文可能仍然是机密;其次,并非所有人都活跃于科学领域,因为该操作也与政治和军事密切相关。因此,依靠集体智慧,我的重点转向了维基百科,一个全球众包的百科全书和潜在的数据源。维基百科提供了与该项目相关的显著人员名单 [2],包括来自各个领域的 400 多名贡献者。我使用了简单的网页抓取技术从维基百科收集数据——共 452 个可用的个人资料。然后我手动根据职业对每个人进行分类,得出了以下表格所示的分布情况。

不足为奇的是,列表的首位是物理学家,其次是化学家和工程师。然而,探索科学领域,尤其是那些在计划前沿的人物,仍待展开。让我们看看“其他”类别中的故事。这个组汇集了那些出现频率较低且似乎与专注于武器开发的科学项目无关的主要职业贡献者。其中不寻常的贡献者包括美国鸟类学家沃尔夫里德·鲁迪耶德·鲍尔顿,他也负责监控来自比利时刚果的铀矿供应,以及在洛斯阿拉莫斯经营茶室的伊迪丝·华纳,她的角色被认为对研究人员的士气产生了深远的影响。

其他一些显著的“其他”人物包括夏洛特·瑟伯,一位记者、统计学家、图书管理员,以及洛斯阿拉莫斯唯一的女性实验室组长。班·波特也难以归类,他既是艺术家、作家、出版商、表演者,又是物理学家——后来在纽约现代艺术博物馆展出了作品。选择的最后是詹姆斯·爱德华·韦斯科特,一位著名的曼哈顿计划摄影师,以及唐纳德·林德利·哈维,一位职业篮球运动员转为军人并参与了该项目。

构建网络

在掌握数据后,我选择了网络科学 [3],这种连接科学非常适合优雅地解码复杂结构,如曼哈顿计划的协作模式。每个网络由节点(实体)和链接(参考)组成,这些节点和链接编织了协作人员复杂的社会网络。在这个背景下,每个节点象征着曼哈顿计划的贡献者,链接则形成于那些互相引用维基百科页面的个人之间。共享参考的数量决定了链接的强度。利用这个简单的框架,我得到了一个由 316 名个体通过 1099 条不同强度的联系连接而成的网络。

图 1. 曼哈顿计划背后的协作网络。每个节点代表一个贡献者,当两个节点的维基百科页面相互引用时,它们会连接。标记了前 50 个连接最多的节点。

将颜色融入洞察力

下一阶段通过引入颜色丰富了网络可视化 —— 每种颜色代表一个不同的网络社区或群集。定义这些社区依赖于方法论,但总体前提不变:社区由节点组成,这些节点之间的内部链接密度高于外部链接[4, 5]。换句话说,节点主要彼此连接 —— 而不是与网络的其余部分连接 —— 便属于一个社区。所得到的视觉效果,如图 2 所示,揭示了贡献者如何在广泛的曼哈顿计划中组织成紧密相连的群集。在此图中,每种颜色编码不同的社区。

图 2. 曼哈顿计划背后的协作网络,如图 1 所示,其中每个节点的颜色基于其所属的网络社区。

解读网络叙事

有了图 2 中的生动可视化,我们可以开始解读协作网络。现代物理学的关键人物立刻显现,包括诺贝尔奖得主阿瑟·康普顿、恩里科·费米、尼尔斯·玻尔和欧内斯特·劳伦斯,还有像 J·罗伯特·奥本海默和爱德华·泰勒这样的天才。然而,这个故事及其背后的连接模式远不止是几个重要的节点。

在这个网络图的核心是由传奇人物尼尔斯·玻尔中心的红色社区。在这里,玻尔的联系揭示了他在第二次世界大战期间支持难民科学家的重要作用,这些科学家也参与了该计划,包括费利克斯·布洛赫、詹姆斯·弗兰克和乔治·普拉切克,他们都被标记为红色。与玻尔的领域相邻的是一个绿色群集,由意大利物理学家恩里科·费米突出显示。费米与他的合作者如安德森、塞拉德、康普顿和津恩一起,达到了使用铀进行自持链式反应的里程碑,并创造了第一个核反应堆——芝加哥堆-1。

图 3. 曼哈顿计划背后的协作网络的特写图,根据图 2 中的网络社区着色,其中每个节点都被标记。

尽管尤金·维格纳以对芝加哥堆 1 号的贡献而闻名,但他的联系将他与似乎散布在网络中的紫色社区更紧密地联系在一起。维格纳在网络的右上角显著可见。这个更加分散的社区,除了奥本海默没有其他关键人物,还将著名数学家约翰·冯·诺依曼与紫色联系在一起,位于图 3 的顶部中心部分。他(和维格纳一起,不幸的是被诺兰的大片电影遗漏了)和紫色社区中的其他几位杰出科学家一起出现在网络中,例如位于底部中心的詹姆斯·查德维克,他领导了英国团队;紧挨着奥本海默的罗伯特·威尔逊,他成为了其回旋加速器小组的负责人;以及位于奥本海默正上方的美国物理学家罗伯特·瑟伯,他为所有三个设计项目和原子弹创建了代号,如“小男孩”和“胖子”。最后,简要提及灰色群体,这被发现是理论部,其中明星如爱德华·泰勒位于中心,诺贝尔奖得主理查德·费曼(我个人最喜欢的科学家)位于左上方,汉斯·贝特位于中心。

最后一个个人观察:乍一看,匈牙利移民火星人[6]泰勒、维格纳、西拉德和诺依曼之间的联系很难发现,尽管他们在原子时代初期和无数共同项目中扮演了基础性角色。然而,一旦我在网络上突出显示他们,我的预期很快得到了确认。他们彼此紧密联系,尽管并非唯一,这意味着他们当时也深深嵌入了美国科学界。这一点通过所谓的爱因斯坦-西拉德信件得到最好的说明,这封信由西拉德撰写,并咨询了泰勒和维格纳,最终由爱因斯坦签署并发送给罗斯福总统。这封信的一个有趣事实是:在那些日子里,爱因斯坦正在海滩度假,所以西拉德就在那儿拜访了他。而由于西拉德没有驾驶执照,泰勒则负责开车[7]。

图 4. 图 2 的一个变体,突出显示火星人——爱德华·泰勒、尤金·维格纳、利奥·西拉德和约翰·冯·诺依曼。

结尾

超越历史的篇章,这个项目体现了人类努力的汇聚——来自各个学科的杰出思想者为共同目标团结在一起。这项分析揭示了使这些伟大思想者能够互相联系、协作并在如此宏大的规模上取得成功的复杂合作模式和共同努力。此外,我建立这个网络的方式展示了网络科学如何应用于几乎任何社会系统,通过定量捕捉看不见的关系,并帮助解释其背后的隐藏模式。

免责声明

本文的几个部分由 AI 工具升级,即 Grammarly 和 ChatGPT 3.5,而整篇文本最初由人类作者草拟并随后更新。

参考文献

[1] 《科学的科学》,王大顺Albert-László Barabási,剑桥大学出版社,2021 年。

[2] en.wikipedia.org/w/index.php?title=Category:Manhattan_Project_people

[3] Albert-László Barabási 的《网络科学》,剑桥大学出版社,2015 年。

[4] 《巫师的网络地图》,米兰·雅诺索夫,nightingaledvs.com/a-network-map-of-the-witcher/

[5] Blondel, Vincent D., 等人。“大型网络中的社区快速展开。” 统计力学期刊:理论与实验 2008 年。

[6] [en.wikipedia.org/wiki/The_Martians_(scientists)](https://en.wikipedia.org/wiki/The_Martians_(scientists)#:~:text="The Martians" (Hungarian%3A,half of the 20th century.)

[7] Marx György:火星人的声音

解码声音交响曲:用于音乐工程的音频信号处理

原文:towardsdatascience.com/decoding-the-symphony-of-sound-audio-signal-processing-for-musical-engineering-c66f09a4d0f5?source=collection_archive---------0-----------------------#2023-08-08

使用 Python 进行时间和频率域音频特征提取的终极指南

Naman AgrawalTowards Data Science Naman Agrawal

·

关注 发表在 Towards Data Science ·38 分钟阅读·2023 年 8 月 8 日

--

图片由 OpenClipart-Vectors 提供,来自 Pixabay

内容

  1. 介绍

  2. 时间域特征提取

    2.1 音频信号处理基础:帧大小和跳步长度

    2.2 特征 1:幅度包络

    2.3 特征 2:均方根能量

    2.4 特征 3:峰值因子

    2.5 特征 4:零交叉率

  3. 频域特征提取

    3.1 特征 5:带能量比

    3.2 特征 6:谱质心

    3.3 特征 7:谱带宽

    3.4 特征 8:谱平坦度

  4. 结论

  5. 参考文献

介绍

处理和分析不同类型数据以获得实际见解的能力是信息时代最重要的技能之一。数据无处不在:从我们阅读的书籍到观看的电影,从我们喜欢的 Instagram 帖子到我们听的音乐。在这篇文章中,我们将尝试理解音频信号处理的基础知识:

  1. 计算机如何读取音频信号

  2. 时域和频域特征是什么?

  3. 如何提取这些特征?

  4. 为什么需要提取这些特征?

特别是,我们将详细介绍以下特征:

  • 时域特征:幅度包络、均方根能量、峰值因子(以及峰值与平均功率比)、零交叉率。

  • 频域特征:带能量比、谱质心、谱带宽(扩展)、谱平坦度。

我们将描述理论并从头编写 Python 代码,以从三种不同的乐器(声学吉他、铜管乐器和鼓组)中提取这些特征。使用的样本音频数据文件可以在这里下载:github.com/namanlab/Audio-Signal-Processing-Feature-Extraction

完整的代码文件也可以在上述仓库中获取,或通过以下链接访问:github.com/namanlab/Audio-Signal-Processing-Feature-Extraction/blob/main/Audio_Signal_Extraction.ipynb

时域特征提取

让我们开始回顾什么是声音以及我们是如何感知它的。正如你们中的一些人可能记得的高中课程中所讲,声音是通过介质传播的振动。声音的产生使得周围的空气分子发生振动,这表现为交替的压缩(高压)和稀疏(低压)区域。这些压缩和稀疏通过介质传播并到达我们的耳朵,让我们感知声音。声音的传播涉及这些压力变化随时间的传递。声音的时域表示涉及在不同时间间隔捕获和分析这些压力变化,通过在离散时间点(通常使用数字音频录制技术)对声波进行采样。每个样本代表特定时刻的声音压力水平。通过绘制这些样本,我们获得一个波形,展示声音压力水平随时间的变化。横轴表示时间,而纵轴表示声音的振幅或强度,通常在 -1 和 1 之间缩放,其中正值表示压缩,负值表示稀疏。这有助于我们给出声音波形特征的视觉表现,如其振幅、频率和持续时间。

声音传播基础 [作者图像]

为了使用 Python 提取给定音频的波形,我们首先需要加载所需的包:

import numpy as np
import matplotlib.pyplot as plt

import librosa
import librosa.display
import IPython.display as ipd
import scipy as spp

NumPy 是一个流行的 Python 包,用于处理和操作数组和矩阵。它包含从线性代数到简化许多任务的广泛工具!

librosa 是 Python 的音频处理和分析包,包含多个函数和工具,使得利用不同的音频特征变得相当简单。如前所述,我们将分析三种不同乐器的波形:原声吉他、铜管乐器和鼓组。你可以从之前分享的链接下载音频文件并上传到你的本地库。为了听取音频文件,我们使用 IPython.display。代码如下:

# Listen to the audio files
# Ensure correct relative / absolute path to the sound files.

acoustic_guitar_path = "acoustic_guitar.wav"
ipd.Audio(acoustic_guitar_path)

brass_path = "brass.wav"
ipd.Audio(brass_path)

# Keep volume low!
drum_set_path = "drum_set.wav"
ipd.Audio(drum_set_path)

接下来,我们使用函数 librosa.load()librosa 中加载音乐文件。这个函数允许我们解析音频文件并返回两个对象:

  1. y(NumPy 数组):包含不同时间间隔的振幅值。试着打印数组看看!

  2. sr(数字 > 0):采样率

采样率指的是在将模拟信号转换为数字表示时每单位时间内采样的数量。如上所述,介质中的压力变化构成了一个模拟信号,这个信号的波形在时间上不断变化。理论上,存储连续数据需要无限的空间。因此,为了数字处理和存储这些模拟信号,它们需要被转换为离散的表示。这就是采样发挥作用的地方,采样在离散(均匀间隔)的时间间隔下捕捉声音波形的快照。这些间隔之间的间距由采样率的倒数来捕获。

采样率决定了从模拟信号中采样的频率,因此以每秒采样数或赫兹(Hz)来测量。更高的采样率意味着每秒采样的数量更多,从而更准确地表示原始模拟信号,但需要更多的内存资源。相比之下,更低的采样率意味着每秒采样的数量较少,从而对原始模拟信号的表示不够准确,但需要更少的内存资源。

通常的默认采样率是 22050。然而,根据应用程序/内存,用户可以选择较低或较高的采样率,这可以通过librosa.load()sr参数来指定。在选择适当的模拟到数字转换采样率时,了解奈奎斯特-香农采样定理可能很重要,该定理指出,为了准确捕捉和重建模拟信号,采样率必须至少是音频信号中最高频率成分的两倍(称为奈奎斯特率/频率)。

通过以高于奈奎斯特频率的频率进行采样,我们可以避免一种称为混叠的现象,这种现象可能会扭曲原始信号。讨论混叠现象对于本文的目的并不特别相关。如果你想了解更多内容,可以参考这个优秀的来源:thewolfsound.com/what-is-aliasing-what-causes-it-how-to-avoid-it/

以下是读取音频信号的代码:

# Load music in librosa
sr = 22050
acoustic_guitar, sr = librosa.load(acoustic_guitar_path, sr = sr)
brass, sr = librosa.load(brass_path, sr = sr)
drum_set, sr = librosa.load(drum_set_path, sr = sr)

在上述示例中,采样率为 22050(这也是默认值)。执行上述代码会返回 3 个数组,每个数组存储在离散时间间隔(由采样率指定)下的振幅值。接下来,我们使用librosa.display.waveshow()可视化 3 个音频样本的波形。为了更清晰地显示振幅在时间上的密度,添加了一些透明度(通过设置 alpha = 0.5)。

def show_waveform(signal, name=""):
    # Create a new figure with a specific size
    plt.figure(figsize=(15, 7))
    # Display the waveform of the signal using librosa
    librosa.display.waveshow(signal, alpha=0.5)
    # Set the title of the plot
    plt.title("Waveform for " + name)
    # Show the plot
    plt.show()

show_waveform(acoustic_guitar, "Acoustic Guitar")
show_waveform(brass, "Brass")
show_waveform(drum_set, "Drum Set")

原声吉他的波形 [图片来源:作者]

铜管乐器的波形 [图片来源:作者]

鼓组的波形 [作者提供的图像]

花些时间查看上述图表。思考一下你看到的模式。在原声吉他的波形中,我们可以识别出一个周期性模式,其特点是振幅的规律性波动,这反映了吉他声音的丰富谐波特性。这些波动对应于弹拨弦产生的振动,生成了一个复杂的波形,包含了多个谐波,贡献了吉他的特征音色和音质。

同样,铜管乐器的波形也表现出周期性模式,从而产生一致的音高和音色。铜管乐器通过演奏者的嘴唇在吹嘴中发出振动产生声音。这种振动动作生成了具有明显谐波和规律的振幅变化模式的波形。

相比之下,鼓组的波形并没有显示出明显的周期性模式,因为鼓的声音是通过鼓槌或手击打鼓皮或其他打击表面产生的,形成了复杂且不规则的波形,具有不同的振幅和持续时间。缺乏明显的周期性模式反映了鼓声音的打击性和非音调特性。

音频信号处理基础:帧大小和跳跃长度

在讨论重要的时域音频特征之前,必须讨论两个重要的特征提取参数:帧大小和跳跃长度。通常,一旦信号经过数字处理,它会被拆分成帧(可能重叠也可能不重叠的一组离散时间间隔)。帧长度描述了这些帧的大小,而跳跃长度则封装了有关帧重叠的多少的信息。但是,为什么帧处理如此重要呢?

帧的目的是捕捉信号中不同特征的时间变化。通常的特征提取方法给出的是输入信号的单一数字总结(例如,均值、最小值或最大值)。直接使用这些特征提取方法的问题在于,这完全消除了与时间相关的信息。例如,如果你想计算信号的均值振幅,你得到的是一个单一的数字总结,比如 x。然而,自然地,均值有时会较低,有时会较高。获取单一数字总结会消除有关均值时间变化的信息。解决方案是将信号拆分为帧,例如,[0 ms, 10 ms)、[10 ms, 20 ms) 等。然后,计算每个时间帧中信号的均值,这一集合特征给出了最终提取的特征向量,即时间依赖的特征总结,这不是很酷吗!

现在,让我们详细讨论这两个参数:

  • 框架大小(Frame Size): 描述了每个框架的大小。例如,如果框架大小是 1024,那么每个框架中包含 1024 个样本,并计算这些 1024 个样本集合所需的特征。一般推荐将框架大小设置为 2 的幂。这背后的原因对于本文的目的并不重要。但如果你感兴趣,这是因为快速傅里叶变换(一个非常高效的将信号从时间域转换到频率域的算法)要求框架大小是 2 的幂。我们将在后续部分更多地讨论傅里叶变换。

  • 跳跃长度(Hop Length): 指的是在数据序列中,每一步框架前进的样本数量,即生成新框架前我们向右移动的样本数。可以将框架视为一个在信号上滑动的窗口,滑动的步长由跳跃长度定义。在每一步,窗口会应用到信号或序列的新部分,并在该段上进行特征提取。因此,跳跃长度决定了连续音频框架之间的重叠情况。跳跃长度等于框架大小意味着没有重叠,因为每个框架恰好在前一个框架结束的地方开始。然而,为了减轻将信号从时间域转换到频率域时发生的一个现象称为谱泄漏的影响,应用了一个窗口函数,导致每个框架边缘附近的数据丢失(技术解释超出了本文的目的,但如果你感兴趣,可以查看这个链接:dspillustrations.com/pages/posts/misc/spectral-leakage-zero-padding-and-frequency-resolution.html)。因此,通常选择中间跳跃长度以保留边缘样本,从而导致框架之间的重叠程度不同。

一般来说,较小的跳跃长度提供了更高的时间分辨率,使我们能够捕捉信号中的更多细节和快速变化。然而,它也增加了内存需求。相反,较大的跳跃长度降低了时间分辨率,但也有助于减少空间复杂度。

框架大小和跳跃长度 [作者提供的图片]

注意:为了更清晰的可视化,以上图像中的框架大小显示得相当大。实际上,选择的框架大小要小得多(可能是几千个样本,约 20-40 毫秒)。

在继续讨论时间域不同特征提取方法之前,让我们澄清一些数学符号。我们将在本文中使用以下符号:

  • xᵢ: 第 i 个样本的幅度

  • K: 框架大小

  • H: 跳跃长度

特征 1:幅度包络

首先,我们来讨论包络线。这是时间域分析中最容易计算(但相当有用)的特征之一。音频信号一帧的包络线简单来说就是该帧内幅度的最大值。在数学上,第 k 帧的包络线(对于不重叠的帧)由下式给出:

一般而言,对于包含样本 xⱼ₁ , xⱼ₂ , · · · , xⱼₖ 的任意帧 k,包络线是:

下面是计算给定信号包络线的 Python 代码:

FRAME_SIZE = 1024
HOP_LENGTH = 512

def amplitude_envelope(signal, frame_size=1024, hop_length=512):
    """
    Computes the Amplitude Envelope of a signal using a sliding window.

    Args:
        signal (array): The input signal.
        frame_size (int): The size of each frame in samples.
        hop_length (int): The number of samples between consecutive frames.

    Returns:
        np.array: An array of Amplitude Envelope values.
    """
    res = []
    for i in range(0, len(signal), hop_length):
        # Get a portion of the signal
        cur_portion = signal[i:i + frame_size]  
        # Compute the maximum value in the portion
        ae_val = max(cur_portion)  
        # Store the amplitude envelope value
        res.append(ae_val)  
    # Convert the result to a NumPy array
    return np.array(res)

def plot_amplitude_envelope(signal, name, frame_size=1024, hop_length=512):
    """
    Plots the waveform of a signal with the overlay of Amplitude Envelope values.

    Args:
        signal (array): The input signal.
        name (str): The name of the signal for the plot title.
        frame_size (int): The size of each frame in samples.
        hop_length (int): The number of samples between consecutive frames.
    """
    # Compute the amplitude envelope
    ae = amplitude_envelope(signal, frame_size, hop_length)
    # Generate the frame indices
    frames = range(0, len(ae))  
    # Convert frames to time
    time = librosa.frames_to_time(frames, hop_length=hop_length)  
    # Create a new figure with a specific size
    plt.figure(figsize=(15, 7))  
    # Display the waveform of the signal
    librosa.display.waveshow(signal, alpha=0.5)  
    # Plot the amplitude envelope over time
    plt.plot(time, ae, color="r") 
    # Set the title of the plot
    plt.title("Waveform for " + name + " (Amplitude Envelope)")  
    # Show the plot
    plt.show()  

plot_amplitude_envelope(acoustic_guitar, "Acoustic Guitar")
plot_amplitude_envelope(brass, "Brass")
plot_amplitude_envelope(drum_set, "Drum Set")

在上述代码中,我们定义了一个名为 amplitude_envelope 的函数,该函数接受输入信号数组(由 librosa.load() 生成)、帧大小 (K) 和跳步长度 (H),并返回一个大小等于帧数的数组。数组中的第 k 个值对应于第 k 帧的包络线值。计算通过一个简单的 for 循环完成,该循环以跳步长度为步长遍历整个信号。定义了一个列表 (res) 来存储这些值,并在返回之前将其转换为 NumPy 数组。另一个名为 plot_amplitude 的函数被定义,它接受相同的一组输入(以及一个名称参数),并在原始帧上叠加包络线图。为了绘制波形,使用了传统的 librosa.display.waveform(),如前一节所述。

要绘制包络线,我们需要时间值和对应的包络线值。时间值通过非常有用的函数 librosa.frames_to_times() 获取,该函数接受两个输入:一个对应帧数的可迭代对象(由 range 函数定义),以及跳步长度)以生成每帧的平均时间。随后,使用 matplotlib.pyplot 来叠加红色图。上述描述的过程将一致用于所有时间域特征提取方法。

以下图显示了每种乐器计算出的包络线。它们以红线的形式叠加在原始波形上,并倾向于近似波形的上边界。包络线不仅保留了周期性模式,还显示了音频幅度的一般差异,如铜管乐器与原声吉他和鼓组相比的低强度所反映的那样。

原声吉他的包络线 [作者提供的图像]

铜管乐器的包络线 [作者提供的图像]

鼓组的包络线 [作者提供的图像]

特征 2:均方根能量

接下来,让我们谈谈均方根能量(RMSE),这是时域分析中的另一个重要特征。音频信号帧的均方根能量是通过对帧内所有振幅值的平方均值开方得到的。数学上,k 帧的均方根能量(对于非重叠帧)表示为:

一般来说,对于包含样本 xⱼ₁ , xⱼ₂ , · · · , xⱼₖ 的任意帧 k,均方根误差为:

均方根能量通过考虑波形的正负偏移,提供了声音信号的整体强度或力度的表征,比起峰值振幅等其他度量提供了更准确的信号功率测量。计算给定信号均方根误差的 Python 代码如下。代码结构与生成振幅包络的代码相同。唯一的变化在于提取特征所用的函数。通过对信号当前部分的平方值取平均后再开方来计算均方根误差值,而不是取最大值。

def RMS_energy(signal, frame_size=1024, hop_length=512):
    """
    Computes the RMS (Root Mean Square) energy of a signal using a sliding window.

    Args:
        signal (array): The input signal.
        frame_size (int): The size of each frame in samples.
        hop_length (int): The number of samples between consecutive frames.

    Returns:
        np.array: An array of RMS energy values.
    """
    res = []
    for i in range(0, len(signal), hop_length):
        # Extract a portion of the signal
        cur_portion = signal[i:i + frame_size]  
        # Compute the RMS energy for the portion
        rmse_val = np.sqrt(1 / len(cur_portion) * sum(i**2 for i in cur_portion))  
        res.append(rmse_val)
    # Convert the result to a NumPy array
    return np.array(res)

def plot_RMS_energy(signal, name, frame_size=1024, hop_length=512):
    """
    Plots the waveform of a signal with the overlay of RMS energy values.

    Args:
        signal (array): The input signal.
        name (str): The name of the signal for the plot title.
        frame_size (int): The size of each frame in samples.
        hop_length (int): The number of samples between consecutive frames.
    """
    # Compute the RMS Energy
    rmse = RMS_energy(signal, frame_size, hop_length)
    # Generate the frame indices
    frames = range(0, len(rmse))  
    # Convert frames to time
    time = librosa.frames_to_time(frames, hop_length=hop_length) 
    # Create a new figure with a specific size
    plt.figure(figsize=(15, 7))
    # Display the waveform as a spectrogram-like plot
    librosa.display.waveshow(signal, alpha=0.5)  
    # Plot the RMS energy values
    plt.plot(time, rmse, color="r") 
    # Set the title of the plot
    plt.title("Waveform for " + name + " (RMS Energy)")
    plt.show()

plot_RMS_energy(acoustic_guitar, "Acoustic Guitar")
plot_RMS_energy(brass, "Brass")
plot_RMS_energy(drum_set, "Drum Set")

以下图展示了每个乐器计算的均方根能量。它们被添加为原始波形上的红线,趋近于波形的质心。正如之前一样,这一度量不仅保留了周期模式,还近似了声波的整体强度水平。

声学吉他的均方根误差 [作者提供的图片]

铜管乐器的均方根误差 [作者提供的图片]

鼓组的均方根误差 [作者提供的图片]

特征 3:峰值因子

现在,让我们谈谈峰值因子,它是波形峰值极端程度的度量。音频信号帧的峰值因子是通过将峰值振幅(振幅的最大绝对值)除以均方根能量来获得的。数学上,k 帧的峰值因子(对于非重叠帧)表示为:

一般来说,对于包含样本 xⱼ₁ , xⱼ₂ , · · · , xⱼₖ 的任意帧 k,峰值因子为:

峰值因子表示波形的最高峰值水平与平均强度水平的比率。计算给定信号峰值因子的 Python 代码如下。结构与上述相同,涉及计算均方根误差值(分母)和最高峰值(分子),然后用来获得所需的分数(峰值因子!)。

def crest_factor(signal, frame_size=1024, hop_length=512):
    """
    Computes the crest factor of a signal using a sliding window.

    Args:
        signal (array): The input signal.
        frame_size (int): The size of each frame in samples.
        hop_length (int): The number of samples between consecutive frames.

    Returns:
        np.array: An array of crest factor values.
    """
    res = []
    for i in range(0, len(signal), hop_length):
        # Get a portion of the signal
        cur_portion = signal[i:i + frame_size]  
        # Compute the RMS energy for the portion
        rmse_val = np.sqrt(1 / len(cur_portion) * sum(i ** 2 for i in cur_portion))  
        # Compute the crest factor
        crest_val = max(np.abs(cur_portion)) / rmse_val  
        # Store the crest factor value
        res.append(crest_val)  
    # Convert the result to a NumPy array
    return np.array(res)  

def plot_crest_factor(signal, name, frame_size=1024, hop_length=512):
    """
    Plots the crest factor of a signal over time.

    Args:
        signal (array): The input signal.
        name (str): The name of the signal for the plot title.
        frame_size (int): The size of each frame in samples.
        hop_length (int): The number of samples between consecutive frames.
    """
    # Compute the crest factor
    crest = crest_factor(signal, frame_size, hop_length)  
    # Generate the frame indices
    frames = range(0, len(crest))  
    # Convert frames to time
    time = librosa.frames_to_time(frames, hop_length=hop_length)  
    # Create a new figure with a specific size
    plt.figure(figsize=(15, 7))  
    # Plot the crest factor over time
    plt.plot(time, crest, color="r")  
    # Set the title of the plot
    plt.title(name + " (Crest Factor)")  
    # Show the plot
    plt.show()  

plot_crest_factor(acoustic_guitar, "Acoustic Guitar")
plot_crest_factor(brass, "Brass")
plot_crest_factor(drum_set, "Drum Set")

以下图展示了每个乐器的计算峰值因子:

声学吉他的峰值因子 [作者提供的图片]

铜管乐器的峰值因子 [图片作者]

铜管乐器组的峰值因子 [图片作者]

更高的峰值因子,如在声学吉他和铜管乐器中所见,表示峰值水平和平均水平之间的差异较大,表明信号更具动态性或峰值特征,振幅变化较大。较低的峰值因子,如在鼓组中所见,表明信号更均匀或压缩,振幅变化较小。峰值因子在需要考虑系统头间隙或可用动态范围的情况下尤为重要。例如,在音乐录音中,高峰值因子可能需要仔细考虑,以防在有限头间隙的设备上播放时发生失真或削波。

实际上,还有一个特性叫做峰值与平均功率比(PAPR),它与峰值因子密切相关。PAPR 只是峰值因子的平方值,通常转换为分贝功率比。一般来说,对于任何包含样本 xⱼ₁ 、xⱼ₂ 、· · · 、xⱼₖ 的帧 k,峰值与平均功率比为:

作为一个有趣的挑战,尝试修改上述代码以生成每种音乐乐器的 PAPR 图,并分析你的发现。

特性 4:零交叉率

最后,我们将讨论零交叉率(ZCR)。音频信号的一帧的零交叉率就是信号穿过零点(x/时间轴)的次数。数学上,第 k 帧的 ZCR(对于非重叠帧)定义为:

如果连续的值具有相同的符号,则绝对值内部的表达式将抵消,结果为 0。如果它们具有相反的符号(表示信号已穿过时间轴),这些值相加将得到 2(取绝对值后)。由于每个零交叉给出的值为 2,我们将结果乘以半的系数以获得所需的计数。一般来说,对于任何包含样本 xⱼ₁ 、xⱼ₂ 、· · · 、xⱼₖ 的帧 k,ZCR 为:

请注意,在上述表达式中,零交叉率是通过简单地将信号交叉轴的次数相加来计算的。然而,根据应用需求,也可以对这些值进行归一化(通过除以帧的长度)。计算给定信号的峰值因子的 Python 代码如下。其结构与上述相似,并定义了一个名为 num sign changes 的函数,用于确定信号中符号变化的次数。

def ZCR(signal, frame_size=1024, hop_length=512):
    """
    Computes the Zero Crossing Rate (ZCR) of a signal using a sliding window.

    Args:
        signal (array): The input signal.
        frame_size (int): The size of each frame in samples.
        hop_length (int): The number of samples between consecutive frames.

    Returns:
        np.array: An array of ZCR values.
    """
    res = []
    for i in range(0, len(signal), hop_length):
        # Get a portion of the signal
        cur_portion = signal[i:i + frame_size]  
         # Compute the number of sign changes in the portion
        zcr_val = num_sign_changes(cur_portion) 
        # Store the ZCR value
        res.append(zcr_val)  
    # Convert the result to a NumPy array
    return np.array(res)  

def num_sign_changes(signal):
    """
    Computes the number of sign changes in a signal.

    Args:
        signal (array): The input signal.

    Returns:
        int: The number of sign changes.
    """
    res = 0
    for i in range(0, len(signal) - 1):
        # Check if there is a sign change between consecutive samples
        if (signal[i] * signal[i + 1] < 0):  
            res += 1
    return res

def plot_ZCR(signal, name, frame_size=1024, hop_length=512):
    """
    Plots the Zero Crossing Rate (ZCR) of a signal over time.

    Args:
        signal (array): The input signal.
        name (str): The name of the signal for the plot title.
        frame_size (int): The size of each frame in samples.
        hop_length (int): The number of samples between consecutive frames.
    """
    # Compute the ZCR
    zcr = ZCR(signal, frame_size, hop_length)  
    # Generate the frame indices
    frames = range(0, len(zcr)) 
    # Convert frames to time
    time = librosa.frames_to_time(frames, hop_length=hop_length)  
     # Create a new figure with a specific size
    plt.figure(figsize=(15, 7)) 
    # Plot the ZCR over time
    plt.plot(time, zcr, color="r")  
    # Set the title of the plot
    plt.title(name + " (Zero Crossing Rate)")  
    # Show the plot
    plt.show()  

plot_ZCR(acoustic_guitar, "Acoustic Guitar")
plot_ZCR(brass, "Brass")
plot_ZCR(drum_set, "Drum Set")

下图显示了各音乐乐器计算出的零交叉率。

声学吉他的零交叉率 [图片作者]

铜管乐器的零交叉率 [图片来源:作者]

鼓组的零交叉率 [图片来源:作者]

较高的零交叉率表明信号经常改变方向,暗示存在更高频率成分或更动态的波形。相反,较低的零交叉率则表示波形相对平滑或恒定。

零交叉率在语音和音乐分析中特别有用,因为它能够提供有关音色和节奏模式等属性的见解。例如,在语音分析中,零交叉率有助于区分有声和无声声音,因为有声声音由于声带的振动,往往具有更高的零交叉率。需要注意的是,虽然零交叉率是一个简单且计算高效的特征,但它可能无法捕捉信号复杂性的所有方面(如上图所示,周期性完全丢失)。因此,它通常与其他特征一起使用,以便对音频信号进行更全面的分析。

频率域特征提取

频率域提供了音频波形的另一种表示方式。与时间域不同,在频率域中,信号被分解为其组成的频率,揭示了与每个频率相关的幅度和相位信息,即信号作为频率的函数表示。我们不是查看信号在不同时间点的幅度,而是检查构成信号的不同频率分量的幅度。每个频率分量代表一个特定频率的正弦波,通过组合这些分量,我们可以在时间域中重建原始信号。

将信号从时间域转换到频率域的(最常见的)数学工具是傅里叶变换。傅里叶变换以信号为输入,将其分解为不同频率的正弦波和余弦波的总和,这些波具有各自的幅度和相位。得到的表示就是频率谱。数学上,连续信号在其时间域 g(t)的傅里叶变换定义如下:

其中 i = √−1 是虚数。是的,傅里叶变换会产生复杂的输出,其中相位和幅度对应于组成的正弦波!然而,对于大多数应用,我们只关心变换的幅度,而简单地忽略相关的相位。由于数字处理的声音是离散的,我们可以定义类似的离散傅里叶变换(DFT):

其中 T 是一个样本的持续时间。在采样率方面:

由于频率表示也是连续的,我们在离散化的频率区间上评估傅里叶变换,以获得音频波的离散频率域表示。这称为短时傅里叶变换。数学上,

不要紧张!让我们仔细复习一下。函数 hat-h(k) 是将整数 k ∈ {0, 1, · · · , N − 1} 映射到频率 k · Sᵣ/N 的幅度。注意,我们只考虑 Sᵣ/N 的整数倍的离散频率区间,其中 N 是信号中的样本数。如果你对这个如何运作仍然不确定,这里有一个关于傅里叶变换的优秀解释:www.youtube.com/watch?v=spUNpyF58BY&t=393s

傅里叶变换是最美丽的数学创新之一,因此值得了解,尽管讨论内容与本文的目的并不特别相关。在 Python 中,你可以轻松地使用 librosa.stft() 获得短时傅里叶变换。

注:对于大型音频数据,有一种更高效的傅里叶变换计算方法,称为快速傅里叶变换(FFT),如果你感兴趣的话可以查阅一下!

如前所述,我们不仅仅对哪些频率更为主导感兴趣:我们还希望展示这些频率何时主导。因此,我们寻求一种同时的频率-时间表示,显示哪些频率在何时占主导地位。这就是帧的作用:我们将信号分成时间帧,并在每个帧中获得傅里叶变换的幅度。这给我们一个值矩阵,其中行数由频率区间的数量(Φ,通常等于 K/2 + 1,其中 K 是帧大小)给出,列数由帧数给出。由于傅里叶变换给出的是复值输出,生成的矩阵是复值的。在 Python 中,帧大小和跳跃长度参数可以轻松指定为参数,并且结果矩阵可以使用librosa.stft(signal, n fft=frame size, hop length=hop length)简单计算。由于我们只关心幅度,我们可以使用 numpy.abs() 将复值矩阵转换为实值矩阵。绘制获得的矩阵是相当方便的,它提供了信号的视觉吸引力表现,并提供了对给定声音的频率内容和时间特征的宝贵见解。所谓的表示称为谱图。

谱图通过在 x 轴上绘制时间帧,在 y 轴上绘制频率箱来获得。然后使用颜色表示给定时间帧的频率强度或幅度。通常,频率轴被转换为对数尺度(因为人类在对数转换下的感知较好),幅度以分贝表示。

生成谱图的 Python 代码如下:

FRAME_SIZE = 1024
HOP_LENGTH = 512

def plot_spectrogram(signal, sample_rate, frame_size=1024, hop_length=512):
    """
    Plots the spectrogram of an audio signal.

    Args:
        signal (array-like): The input audio signal.
        sample_rate (int): The sample rate of the audio signal.
        frame_size (int): The size of each frame in samples.
        hop_length (int): The number of samples between consecutive frames.
    """
    # Compute the STFT
    spectrogram = librosa.stft(signal, n_fft=frame_size, hop_length=hop_length)  
    # Convert the STFT to dB scale
    spectrogram_db = librosa.amplitude_to_db(np.abs(spectrogram))  
    # Create a new figure with a specific size
    plt.figure(figsize=(15, 7))  
    # Display the spectrogram
    librosa.display.specshow(spectrogram_db, sr=sample_rate, hop_length=hop_length, x_axis='time', y_axis='log') 
    # Add a colorbar to show the magnitude scale
    plt.colorbar(format='%+2.0f dB') 
    # Set the title of the plot
    plt.title('Spectrogram')  
    # Set the label for the x-axis
    plt.xlabel('Time') 
    # Set the label for the y-axis
    plt.ylabel('Frequency (Hz)')  
    # Adjust the layout of the plot
    plt.tight_layout()  
    # Show the plot
    plt.show()  

plot_spectrogram(acoustic_guitar, sr)
plot_spectrogram(brass, sr)
plot_spectrogram(drum_set, sr)

在上述代码中,我们定义了一个名为 plot spectrogram 的函数,该函数接受 4 个参数:输入信号数组、采样率、帧大小和跳跃长度。首先,librosa.stft() 用于获取谱图矩阵。随后,np.abs() 用于提取幅度,然后通过 librosa.amplitude_to_db() 函数将幅度值转换为分贝。最后,librosa.display.specshow() 函数用于绘制谱图。该函数接受转换后的谱图矩阵、采样率、跳跃长度以及 x 轴和 y 轴的规格。可以使用 y-axis = ‘log’ 参数指定对数转换的 y 轴。还可以使用 plt.colorbar() 添加一个可选的颜色条。以下是 3 种乐器的谱图:

原声吉他的谱图 [作者提供的图片]

铜管乐器的谱图 [作者提供的图片]

鼓组的谱图 [作者提供的图片]

谱图提供了一种独特的时间-频率折衷可视化方式。时间域为我们提供了信号随时间演变的精确表示,而频率域则让我们看到不同频率下的能量分布。这使我们不仅能够识别特定频率的存在,还能掌握它们的持续时间和时间变化。谱图是表示声音的最有用的方式之一,并且常用于音频信号的机器学习应用(例如,将声音波形的谱图输入深度卷积神经网络进行预测)。

在继续进行不同频率域特征提取方法之前,让我们澄清一些数学符号。我们将在后续部分使用以下符号:

  • mₖ(i):第 k 帧的第 i 个频率的幅度。

  • K:帧大小

  • H:跳跃长度

  • Φ:频率箱的数量(= K/2 + 1)

特征 5:带能量比

首先,让我们谈谈带能量比。带能量比是用于量化给定时间帧中低频能量与高频能量之比的指标。从数学上讲,对于任何帧 k,带能量比为:

其中 σբ 表示分割频率 bin:一个区分低频和高频的参数。在计算频带能量比时,所有值低于 σբ(即分割频率)的频率被视为低频。低频的能量平方和决定了分子。类似地,所有值高于分割频率的频率被视为高频,高频的能量平方和决定了分母。计算信号频带能量比的 Python 代码如下所示:

def find_split_freq_bin(spec, split_freq, sample_rate, frame_size=1024, hop_length=512):
    """
    Calculate the bin index corresponding to a given split frequency.

    Args:
        spec (array): The spectrogram.
        split_freq (float): The split frequency in Hz.
        sample_rate (int): The sample rate of the audio.
        frame_size (int, optional): The size of each frame in samples. Default is 1024.
        hop_length (int, optional): The number of samples between consecutive frames. Default is 512.

    Returns:
        int: The bin index corresponding to the split frequency.
    """
    # Calculate the range of frequencies
    range_of_freq = sample_rate / 2
    # Calculate the change in frequency per bin
    change_per_bin = range_of_freq / spec.shape[0]
    # Calculate the bin corresponding to the split frequency
    split_freq_bin = split_freq / change_per_bin
    return int(np.floor(split_freq_bin))

def band_energy_ratio(signal, split_freq, sample_rate, frame_size=1024, hop_length=512):
    """
    Compute the band energy ratio (BER) of a signal.

    Args:
        signal (array): The input signal.
        split_freq (float): The split frequency in Hz.
        sample_rate (int): The sample rate of the audio.
        frame_size (int, optional): The size of each frame in samples. Default is 1024.
        hop_length (int, optional): The number of samples between consecutive frames. Default is 512.

    Returns:
        ndarray: The band energy ratios for each frame of the signal.
    """
    # Compute the spectrogram of the signal
    spec = librosa.stft(signal, n_fft=frame_size, hop_length=hop_length)
    # Find the bin corresponding to the split frequency
    split_freq_bin = find_split_freq_bin(spec, split_freq, sample_rate, frame_size, hop_length)
    # Extract the magnitude and transpose it
    modified_spec = np.abs(spec).T
    res = []
    for sub_arr in modified_spec:
        # Compute the energy in the low-frequency range
        low_freq_density = sum(i ** 2 for i in sub_arr[:split_freq_bin])
        # Compute the energy in the high-frequency range
        high_freq_density = sum(i ** 2 for i in sub_arr[split_freq_bin:])
        # Compute the band energy ratio
        ber_val = low_freq_density / high_freq_density
        res.append(ber_val)
    return np.array(res)

def plot_band_energy_ratio(signal, split_freq, sample_rate, name, frame_size=1024, hop_length=512):
    """
    Plot the band energy ratio (BER) of a signal over time.

    Args:
        signal (ndarray): The input signal.
        split_freq (float): The split frequency in Hz.
        sample_rate (int): The sample rate of the audio.
        name (str): The name of the signal for the plot title.
        frame_size (int, optional): The size of each frame in samples. Default is 1024.
        hop_length (int, optional): The number of samples between consecutive frames. Default is 512.
    """
    # Compute the band energy ratio (BER)
    ber = band_energy_ratio(signal, split_freq, sample_rate, frame_size, hop_length)
    # Generate the frame indices
    frames = range(0, len(ber))
    # Convert frames to time
    time = librosa.frames_to_time(frames, hop_length=hop_length)
    # Create a new figure with a specific size
    plt.figure(figsize=(15, 7))
    # Plot the BER over time
    plt.plot(time, ber)
    # Set the title of the plot
    plt.title(name + " (Band Energy Ratio)")
    # Show the plot
    plt.show()

plot_band_energy_ratio(acoustic_guitar, 2048, sr, "Acoustic Guitar")
plot_band_energy_ratio(brass, 2048, sr, "Brass")
plot_band_energy_ratio(drum_set, 2048, sr, "Drum Set")

上述代码的结构与时域提取的结构非常相似。第一步是定义一个名为 split_freq_bin() 的函数,该函数接受谱图、分割频率的值和采样率,确定与分割频率对应的分割频率 bin (σբ)。这个过程相当简单。它涉及到查找频率范围(如前所述,即 Nyquist 频率,Sᵣ/2)。频率 bin 的数量由谱图的行数给出,提取为 spec.shape[0]。通过将频率的总范围除以频率 bin 的数量,我们可以计算每个 bin 的频率变化,然后用给定的分割频率除以这个变化值,以确定分割频率 bin。

接下来,我们使用这个函数来计算频带能量比向量。函数 band_energy_ratio() 接收输入信号、分割频率、采样率、帧大小和跳跃长度作为参数。首先,它使用 librosa.stft() 提取谱图,然后计算分割频率 bin。接着,使用 np.abs() 计算谱图的幅度,并进行转置以便于遍历每一帧。在遍历过程中,使用定义的公式和找到的分割频率 bin 计算每帧的频带能量比。计算值存储在列表 res 中,最后作为 NumPy 数组返回。最后,使用 plot_band_energy_ratio() 函数绘制这些值。

下面显示了这三种乐器的频带能量比图。对于这些图,分割频率选择为 2048 Hz,即低于 2048 Hz 的频率被认为是低能量频率,而高于 2048 Hz 的频率则被视为高能量频率。

适用于吉他的频带能量比 [作者提供的图片]

适用于铜管乐器的频带能量比 [作者提供的图片]

适用于鼓组的频带能量比 [作者提供的图片]

高频带能量比(对于铜管乐器)表明低频成分相对于高频成分的存在量较大。因此,我们观察到铜管乐器在低频带产生的能量远大于高频带。声学吉他的 BER 相较于铜管乐器较低,表明在低频带的能量贡献相对较少。总体而言,声学吉他在频率谱上的能量分布较为均衡,相较于其他乐器,对低频的强调较少。最后,鼓组在三者中具有最低的 BER,表明与其他乐器相比,在低频带的能量贡献相对较低。

特征 6:谱质心

接下来,我们将讨论谱质心,这是一个量化信号在给定时间范围内的质心或平均频率的信息的度量。数学上,对于任何帧 k,谱质心为:

可以将其视为频率箱索引的加权和,其中权重由给定时间范围内箱的能量贡献决定。归一化通过将加权和除以所有权重的总和来完成,以便在不同信号之间进行均匀比较。计算信号谱质心的 Python 代码如下所示:

def spectral_centroid(signal, sample_rate, frame_size=1024, hop_length=512):
    """
    Compute the Spectral Centroid of a signal.

    Args:
        signal (array): The input signal.
        sample_rate (int): The sample rate of the audio.
        frame_size (int, optional): The size of each frame in samples. Default is 1024.
        hop_length (int, optional): The number of samples between consecutive frames. Default is 512.

    Returns:
        ndarray: The spectral centroids for each frame of the signal.
    """
    # Compute the spectrogram of the signal
    spec = librosa.stft(signal, n_fft=frame_size, hop_length=hop_length)
    # Extract the magnitude and transpose it
    modified_spec = np.abs(spec).T
    res = []
    for sub_arr in modified_spec:
        # Compute the spectral centroid
        sc_val = sc(sub_arr)
        # Store the value of spectral centroid for current frame
        res.append(sc_val)
    return np.array(res)

def sc(arr):
    """
    Computes the spectral centroid in a signal.

    Args:
        arr (array): Frequency domain array for current frame.

    Returns:
        float: The spectral centroid value for current frame.
    """
    res = 0
    for i in range(0, len(arr)):
        # Compute weighted sum
        res += i*arr[i]
    return res/sum(arr)

def bin_to_freq(spec, bin_val, sample_rate, frame_size=1024, hop_length=512):
    """
    Calculate the frequency corresponding to a given bin value

    Args:
        spec (array): The spectrogram.
        bin_val (): The bin value.
        sample_rate (int): The sample rate of the audio.
        frame_size (int, optional): The size of each frame in samples. Default is 1024.
        hop_length (int, optional): The number of samples between consecutive frames. Default is 512.

    Returns:
        int: The bin index corresponding to the split frequency.
    """
    # Calculate the range of frequencies
    range_of_freq = sample_rate / 2
    # Calculate the change in frequency per bin
    change_per_bin = range_of_freq / spec.shape[0]
    # Calculate the frequency corresponding to the bin
    split_freq = bin_val*change_per_bin
    return split_freq

def plot_spectral_centroid(signal, sample_rate, name, frame_size=1024, hop_length=512, col = "black"):
    """
    Plot the spectral centroid of a signal over time.

    Args:
        signal (ndarray): The input signal.
        sample_rate (int): The sample rate of the audio.
        name (str): The name of the signal for the plot title.
        frame_size (int, optional): The size of each frame in samples. Default is 1024.
        hop_length (int, optional): The number of samples between consecutive frames. Default is 512.
    """
    # Compute the STFT
    spectrogram = librosa.stft(signal, n_fft=frame_size, hop_length=hop_length)  
    # Convert the STFT to dB scale
    spectrogram_db = librosa.amplitude_to_db(np.abs(spectrogram)) 
    # Compute the Spectral Centroid
    sc_arr = spectral_centroid(signal, sample_rate, frame_size, hop_length)
    # Compute corresponding frequencies:
    sc_freq_arr = bin_to_freq(spectrogram_db, sc_arr, sample_rate, frame_size, hop_length)
    # Generate the frame indices
    frames = range(0, len(sc_arr))
    # Convert frames to time
    time = librosa.frames_to_time(frames, hop_length=hop_length)
    # Create a new figure with a specific size
    plt.figure(figsize=(15, 7))
    # Display the Spectrogram
    librosa.display.specshow(spectrogram_db, sr=sample_rate, hop_length=hop_length, x_axis='time', y_axis='log') 
    # Add a colorbar to show the magnitude scale
    plt.colorbar(format='%+2.0f dB')  
    # Plot the Spectral Centroid over time
    plt.plot(time, sc_freq_arr, color=col)
    # Set the title of the plot
    plt.title(name + " (Spectral Centroid)")
    # Show the plot
    plt.show()

plot_spectral_centroid(acoustic_guitar, sr, "Acoustic Guitar")
plot_spectral_centroid(brass, sr, "Brass", col = "white")
plot_spectral_centroid(drum_set, sr, "Drum Set")

在上述代码中,定义了谱质心函数,以生成所有时间帧的谱质心数组。随后,定义了sc()函数,通过简单的迭代过程计算一个帧的谱质心,该过程将索引值与幅度相乘,然后进行归一化以获得平均频率箱。在绘制由spectral_centroid()返回的谱质心值之前,定义了一个名为 bin to freq 的附加函数,作为绘图的辅助函数。该函数将平均箱值转换为相应的频率值,可以在原始谱图上绘制,以便对谱质心在时间上的变化有一致的了解。输出图(带有谱质心变化的叠加)如下所示:

对于声学吉他的谱质心 [图片由作者提供]

铜管乐器的谱质心 [图片由作者提供]

对于鼓组的谱质心 [图片由作者提供]

频谱质心与时间域分析中的 RMSE 度量非常类似,通常用作声音音色和亮度的描述符。具有较高频谱质心的声音往往具有更明亮或偏向高音的特质,而较低质心值则与较暗或偏向低音的特质相关联。频谱质心是音频机器学习中最重要的特征之一,常用于音频/音乐类型分类的应用中。

特征 7: 频谱带宽

现在,我们将讨论频谱带宽/扩展,它是量化信号频谱在给定时间框架内能量分布的信息的度量。可以这样理解:如果频谱质心是均值/平均值,那么频谱带宽就是它围绕质心的扩展/方差的度量。数学上,对于任何帧 k,频谱带宽为:

其中 SCₖ 表示第 k 帧的频谱质心。如前所述,通过将加权和除以所有权重的总和来进行归一化,以便在不同信号之间进行统一比较。用于计算信号频谱带宽的 Python 代码如下所示:

def spectral_bandwidth(signal, sample_rate, frame_size=1024, hop_length=512):
    """
    Compute the Spectral Bandwidth of a signal.

    Args:
        signal (array): The input signal.
        sample_rate (int): The sample rate of the audio.
        frame_size (int, optional): The size of each frame in samples. Default is 1024.
        hop_length (int, optional): The number of samples between consecutive frames. Default is 512.

    Returns:
        ndarray: The spectral bandwidths for each frame of the signal.
    """
    # Compute the spectrogram of the signal
    spec = librosa.stft(signal, n_fft=frame_size, hop_length=hop_length)
    # Extract the magnitude and transpose it
    modified_spec = np.abs(spec).T
    res = []
    for sub_arr in modified_spec:
        # Compute the spectral bandwidth
        sb_val = sb(sub_arr)
        # Store the value of spectral bandwidth for current frame
        res.append(sb_val)
    return np.array(res)

def sb(arr):
    """
    Computes the spectral bandwidth in a signal.

    Args:
        arr (array): Frequency domain array for current frame.

    Returns:
        float: The spectral bandwidth value for current frame.
    """
    res = 0
    sc_val = sc(arr)
    for i in range(0, len(arr)):
        # Compute weighted sum
        res += (abs(i - sc_val))*arr[i]
    return res/sum(arr)

def plot_spectral_bandwidth(signal, sample_rate, name, frame_size=1024, hop_length=512):
    """
    Plot the spectral bandwidth of a signal over time.

    Args:
        signal (ndarray): The input signal.
        sample_rate (int): The sample rate of the audio.
        name (str): The name of the signal for the plot title.
        frame_size (int, optional): The size of each frame in samples. Default is 1024.
        hop_length (int, optional): The number of samples between consecutive frames. Default is 512.
    """
    # Compute the Spectral bandwidth
    sb_arr = spectral_bandwidth(signal, sample_rate, frame_size, hop_length)
    # Generate the frame indices
    frames = range(0, len(sb_arr))
    # Convert frames to time
    time = librosa.frames_to_time(frames, hop_length=hop_length)
    # Create a new figure with a specific size
    plt.figure(figsize=(15, 7))
    # Plot the Spectral Bandwidth over time
    plt.plot(time, sb_arr)
    # Set the title of the plot
    plt.title(name + " (Spectral Bandwidth)")
    # Show the plot
    plt.show()

plot_spectral_bandwidth(acoustic_guitar, sr, "Acoustic Guitar")
plot_spectral_bandwidth(brass, sr, "Brass")
plot_spectral_bandwidth(drum_set, sr, "Drum Set")

与之前一样,在上述代码中,频谱带宽函数被定义为使用 sb 辅助函数生成所有时间帧的频谱扩展数组,该函数迭代地计算一个帧的带宽。最后,使用绘图频谱带宽函数绘制这些带宽值。输出的图示如下:

原声吉他的频谱带宽 [图像来源:作者]

铜管乐器的频谱带宽 [图像来源:作者]

鼓组的频谱带宽 [图像来源:作者]

频谱带宽可以用于各种音频分析/分类任务,因为它能够提供关于信号中频率分布或宽度的信息。较高的频谱带宽(如铜管乐器和鼓组所示)表示频率范围较宽,暗示信号更为多样或复杂。另一方面,较低的带宽则表示频率范围较窄,指示信号更集中或音调更纯。

特征 8: 频谱平坦度

最后,我们将讨论频谱平坦度(又称为维纳熵),这是一种衡量音频信号功率谱平坦度或均匀度的度量。它帮助我们了解音频信号距离纯音(与噪声相比)有多近,因此也被称为音调系数。对于任何帧 k,频谱平坦度是其几何均值与算术均值的比率。数学上,

用于计算信号频谱平坦度的 Python 代码如下所示:

def spectral_flatness(signal, sample_rate, frame_size=1024, hop_length=512):
    """
    Compute the Spectral Flatness of a signal.

    Args:
        signal (array): The input signal.
        sample_rate (int): The sample rate of the audio.
        frame_size (int, optional): The size of each frame in samples. Default is 1024.
        hop_length (int, optional): The number of samples between consecutive frames. Default is 512.

    Returns:
        ndarray: The spectral flatness for each frame of the signal.
    """
    # Compute the spectrogram of the signal
    spec = librosa.stft(signal, n_fft=frame_size, hop_length=hop_length)
    # Extract the magnitude and transpose it
    modified_spec = np.abs(spec).T
    res = []
    for sub_arr in modified_spec:
        # Compute the geometric mean
        geom_mean = np.exp(np.log(sub_arr).mean())
        # Compute the arithmetic mean
        ar_mean = np.mean(sub_arr)
        # Compute the spectral flatness
        sl_val = geom_mean/ar_mean
        # Store the value of spectral flatness for current frame
        res.append(sl_val)
    return np.array(res)

def plot_spectral_flatness(signal, sample_rate, name, frame_size=1024, hop_length=512):
    """
    Plot the spectral flatness of a signal over time.

    Args:
        signal (ndarray): The input signal.
        sample_rate (int): The sample rate of the audio.
        name (str): The name of the signal for the plot title.
        frame_size (int, optional): The size of each frame in samples. Default is 1024.
        hop_length (int, optional): The number of samples between consecutive frames. Default is 512.
    """
    # Compute the Spectral bandwidth
    sl_arr = spectral_flatness(signal, sample_rate, frame_size, hop_length)
    # Generate the frame indices
    frames = range(0, len(sl_arr))
    # Convert frames to time
    time = librosa.frames_to_time(frames, hop_length=hop_length)
    # Create a new figure with a specific size
    plt.figure(figsize=(15, 7))
    # Plot the Spectral Flatness over time
    plt.plot(time, sl_arr)
    # Set the title of the plot
    plt.title(name + " (Spectral Flatness)")
    # Show the plot
    plt.show()

plot_spectral_flatness(acoustic_guitar, sr, "Acoustic Guitar")
plot_spectral_flatness(brass, sr, "Brass")
plot_spectral_flatness(drum_set, sr, "Drum Set")

上述代码的结构与其他频域提取方法相同。唯一的区别在于 for 循环内的特征提取函数,它使用 NumPy 函数计算算术和几何均值,并计算其比率以生成每个时间帧的频谱平坦度值。输出图如下:

声学吉他的频谱平坦度 [作者提供的图片]

铜管乐器的频谱平坦度 [作者提供的图片]

鼓组的频谱平坦度 [作者提供的图片]

高频谱平坦度值(即接近 1 的值)表示信号中能量在不同频率上的分布更均匀或平衡。这在鼓组中表现得尤为明显,表明这种声音更具“噪声性”或宽频带,没有显著的峰值或对特定频率的强调(如之前从缺乏周期性中观察到的)。

另一方面,低频谱平坦度值(特别是对于声学吉他以及某程度上对于铜管乐器)表示功率谱更不均匀,能量集中在几个特定频率周围。这表明声音中存在音调或谐波成分(如其周期性时间域结构所反映的)。一般来说,具有明确音高/频率的音乐往往具有较低的频谱平坦度值,而噪声(以及非音调)声音则表现出较高的频谱平坦度值。

结论

在这篇文章中,我们深入探讨了构成音乐工程中音频信号处理的重要部分的特征提取的不同策略和技术。我们从学习声音产生和传播的基本知识开始,这些知识可以有效地转化为随时间变化的压力变化,从而产生其时间域表示。我们讨论了声音的数字表示及其重要参数,包括采样率、帧大小和跳跃长度。我们从理论上讨论了时间域特征,如振幅包络、均方根能量、峰值因子、峰值功率比和零交叉率,并在三种乐器上进行了计算评估:原声吉他、铜管乐器和鼓组。随后,我们介绍并分析了声音的频域表示,通过对傅里叶变换和谱图的各种理论讨论。这为包括带能量比、谱质心、带宽和音调系数在内的各种频域特征铺平了道路,每个特征都可以有效地用于评估输入音频的特定特征。信号处理应用还包括梅尔谱图、倒谱系数、噪声控制、音频合成等。我希望这篇解释能为进一步探索该领域的高级概念奠定基础。

希望你喜欢阅读这篇文章!如果你有任何疑问或建议,请在评论框中回复。

如有需要,请随时通过 邮件 联系我。

如果你喜欢我的文章并希望阅读更多,请关注我。

注意: 所有图片(封面图片除外)均由作者制作。

参考文献

峰值因子。(2023)。在 维基百科en.wikipedia.org/w/index.php?title=Crest_factor&oldid=1158501578

librosa — Librosa 0.10.1dev 文档。(无日期)。检索于 2023 年 6 月 5 日,librosa.org/doc/main/index.html

谱平坦度。(2022)。在 维基百科en.wikipedia.org/w/index.php?title=Spectral_flatness&oldid=1073105086

AI 的声音。(2020 年 8 月 1 日)。Valerio Velardovaleriovelardo.com/the-sound-of-ai/

解码美国参议院对 AI 的监督听证会:Python 中的 NLP 分析

原文:towardsdatascience.com/decoding-the-us-senate-hearing-on-oversight-of-ai-nlp-analysis-in-python-2a1e50a1fd0c?source=collection_archive---------7-----------------------#2023-06-02

摄影: Harold MendozaUnsplash

使用 NLTK 工具包进行词频分析、可视化和情感评分

Raul Vizcarra ChirinosTowards Data Science Raul Vizcarra Chirinos

·

关注 发表在 Towards Data Science ·21 分钟阅读·2023 年 6 月 2 日

--

上周日早晨,当我在换频道找早餐时可以看的节目时,我偶然发现了参议院对 AI 监管听证会的重播。距离开始已经过去了 40 分钟,所以我决定观看剩下的部分(谈到度过一个有趣的周日早晨!)。

当像参议院司法委员会对 AI 监管的听证会这样的事件发生时,如果你想了解关键要点,你有四个选择:观看直播,寻找未来的录音(这两个选项都需要你花费三小时);阅读书面版本(转录本),它们大约有 79 页,超过 29,000 个词;或者在网站或社交媒体上阅读评论以获取不同的观点并形成自己的看法(如果不是来自其他人)。

如今,随着一切变化如此迅速,我们的时间似乎总是过于短暂,人们很容易选择捷径,依赖评论而不是查阅原始来源(我也有过这样的经历)。如果你选择这个听证会的捷径,很可能你在网上或社交媒体上找到的大多数评论都会集中在 OpenAI CEO Sam Altman 呼吁监管 AI 上。然而,看过听证会后,我觉得还有更多内容值得探索,超越头条新闻。

所以,在我完成了周日的休闲早晨活动后,我决定下载参议院听证会的 transcript,并使用 NLTK 包(一个用于自然语言处理的 Python 包——NLP)来分析它,比较最常用的词汇,并对不同的兴趣群体(OpenAI、IBM、学术界、国会)应用一些情感评分,看看是否能发现其中的含义。剧透警告!在分析的 29,000 个词中,只有 70 个(0.24%)与“regulation”,“regulate”,“regulatory”或“legislation”等词相关。

需要注意的是,这篇文章并不是关于我对这次 AI 听证会或 ChatGPT 的 Sam Altman 的看法。相反,它关注的是在国会山这个屋檐下,各个社会部分(私人、学术界、政府)所代表的各方言辞背后的含义,以及我们从这些混杂的言辞中能学到什么。

鉴于未来几个月在人工智能监管方面的有趣时刻,因为 EU AI Act 的最终草案等待在欧洲议会辩论(预计在 6 月进行),探索大西洋这边围绕 AI 的讨论背后的内容是值得的。

步骤-01:获取数据

我使用了 Justin Hendrix 在 Tech Policy Press 发布的 transcript(可在这里访问)。

访问参议院听证会 transcript 这里

虽然亨德里克斯提到这是一个快速转录,并建议通过观看参议院听证会视频来确认引述,但我仍然发现它对这次分析非常准确和有趣。如果你想观看参议院听证会或阅读萨姆·奥特曼(OpenAI)、克里斯蒂娜·蒙哥马利(IBM)和加里·马库斯(纽约大学教授)的证词,你可以在这里找到它们。

最初,我计划将转录文本复制到 Word 文档中,并在 Excel 中手动创建一个包含参与者姓名、他们代表的组织及其评论的表格。然而,这种方法既耗时又低效。所以,我转向了 Python,并将 Microsoft Word 文件中的完整转录文本上传到数据框中。以下是我使用的代码:

# STEP 01-Read the Word document
# remember to install  pip install python-docx

import docx
import pandas as pd

doc = docx.Document('D:\....your word file on microsoft word')

items = []
names = []
comments = []

# Iterate over paragraphs 
for paragraph in doc.paragraphs:
    text = paragraph.text.strip()

    if text.endswith(':'):
        name = text[:-1]  
    else:
        items.append(len(items))
        names.append(name)
        comments.append(text)

dfsenate = pd.DataFrame({'item': items, 'name': names, 'comment': comments})

# Remove rows with empty comments
dfsenate = dfsenate[dfsenate['comment'].str.strip().astype(bool)]

# Reset the index
dfsenate.reset_index(drop=True, inplace=True)
dfsenate['item'] = dfsenate.index + 1
print(dfsenate)

输出应如下所示:

 item name comment
0 1 Sen. Richard Blumenthal (D-CT) Now for some introductory remarks.
1 2 Sen. Richard Blumenthal (D-CT) “Too often we have seen what happens when technology outpaces regulation, the unbridled exploitation of personal data, the proliferation of disinformation, and the deepening of societal inequalities. We have seen how algorithmic biases can perpetuate discrimination and prejudice, and how the lack of transparency can undermine public trust. This is not the future we want.”
2 3 Sen. Richard Blumenthal (D-CT) If you were listening from home, you might have thought that voice was mine and the words from me, but in fact, that voice was not mine. The words were not mine. And the audio was an AI voice cloning software trained on my floor speeches. The remarks were written by ChatGPT when it was asked how I would open this hearing. And you heard just now the result I asked ChatGPT, why did you pick those themes and that content? And it answered. And I’m quoting, Blumenthal has a strong record in advocating for consumer protection and civil rights. He has been vocal about issues such as data privacy and the potential for discrimination in algorithmic decision making. Therefore, the statement emphasizes these aspects.
3 4 Sen. Richard Blumenthal (D-CT) Mr. Altman, I appreciate ChatGPT’s endorsement. In all seriousness, this apparent reasoning is pretty impressive. I am sure that we’ll look back in a decade and view ChatGPT and GPT-4 like we do the first cell phone, those big clunky things that we used to carry around. But we recognize that we are on the verge, really, of a new era. The audio and my playing, it may strike you as curious or humorous, but what reverberated in my mind was what if I had asked it? And what if it had provided an endorsement of Ukraine, surrendering or Vladimir Putin’s leadership? That would’ve been really frightening. And the prospect is more than a little scary to use the word, Mr. Altman, you have used yourself, and I think you have been very constructive in calling attention to the pitfalls as well as the promise.
4 5 Sen. Richard Blumenthal (D-CT) And that’s the reason why we wanted you to be here today. And we thank you and our other witnesses for joining us for several months. Now, the public has been fascinated with GPT, dally and other AI tools. These examples like the homework done by ChatGPT or the articles and op-eds, that it can write feel like novelties. But the underlying advancement of this era are more than just research experiments. They are no longer fantasies of science fiction. They are real and present the promises of curing cancer or developing new understandings of physics and biology or modeling climate and weather. All very encouraging and hopeful. But we also know the potential harms and we’ve seen them already weaponized disinformation, housing discrimination, harassment of women and impersonation, fraud, voice cloning deep fakes. These are the potential risks despite the other rewards. And for me, perhaps the biggest nightmare is the looming new industrial revolution. The displacement of millions of workers, the loss of huge numbers of jobs, the need to prepare for this new industrial revolution in skill training and relocation that may be required. And already industry leaders are calling attention to those challenges.
5 6 Sen. Richard Blumenthal (D-CT) To quote ChatGPT, this is not necessarily the future that we want. We need to maximize the good over the bad. Congress has a choice. Now. We had the same choice when we face social media. We failed to seize that moment. The result is predators on the internet, toxic content exploiting children, creating dangers for them. And Senator Blackburn and I and others like Senator Durbin on the Judiciary Committee are trying to deal with it in the Kids Online Safety Act. But Congress failed to meet the moment on social media. Now we have the obligation to do it on AI before the threats and the risks become real. Sensible safeguards are not in opposition to innovation. Accountability is not a burden far from it. They are the foundation of how we can move ahead while protecting public trust. They are how we can lead the world in technology and science, but also in promoting our democratic values.
6 7 Sen. Richard Blumenthal (D-CT) Otherwise, in the absence of that trust, I think we may well lose both. These are sophisticated technologies, but there are basic expectations common in our law. We can start with transparency. AI companies ought to be required to test their systems, disclose known risks, and allow independent researcher access. We can establish scorecards and nutrition labels to encourage competition based on safety and trustworthiness, limitations on use. There are places where the risk of AI is so extreme that we ought to restrict or even ban their use, especially when it comes to commercial invasions of privacy for profit and decisions that affect people’s livelihoods. And of course, accountability, reliability. When AI companies and their clients cause harm, they should be held liable. We should not repeat our past mistakes, for example, Section 230, forcing companies to think ahead and be responsible for the ramifications of their business decisions can be the most powerful tool of all. Garbage in, garbage out. The principle still applies. We ought to beware of the garbage, whether it’s going into these platforms or coming out of them.

接下来,我考虑为未来的分析添加一些标签,通过所代表的社会群体来识别个人。

 def assign_sector(name):
    if name in ['Sam Altman', 'Christina Montgomery']:
        return 'Private'
    elif name == 'Gary Marcus':
        return 'Academia'
    else:
        return 'Congress'

# Apply function 
dfsenate['sector'] = dfsenate['name'].apply(assign_sector)

# Assign organizations based on names
def assign_organization(name):
    if name == 'Sam Altman':
        return 'OpenAI'
    elif name == 'Christina Montgomery':
        return 'IBM'
    elif name == 'Gary Marcus':
        return 'Academia'
    else:
        return 'Congress'

# Apply function
dfsenate['Organization'] = dfsenate['name'].apply(assign_organization)

print(dfsenate)

最后,我决定添加一个列来统计每个声明的字数,这也有助于我们进一步分析。

dfsenate['WordCount'] = dfsenate['comment'].apply(lambda x: len(x.split()))

此时,你的数据框应该如下所示:

 item                            name  ... Organization WordCount
0       1  Sen. Richard Blumenthal (D-CT)  ...     Congress         5
1       2  Sen. Richard Blumenthal (D-CT)  ...     Congress        55
2       3  Sen. Richard Blumenthal (D-CT)  ...     Congress       125
3       4  Sen. Richard Blumenthal (D-CT)  ...     Congress       145
4       5  Sen. Richard Blumenthal (D-CT)  ...     Congress       197
..    ...                             ...  ...          ...       ...
399   400         Sen. Cory Booker (D-NJ)  ...     Congress       156
400   401                      Sam Altman  ...       OpenAI       180
401   402         Sen. Cory Booker (D-NJ)  ...     Congress        72
402   403  Sen. Richard Blumenthal (D-CT)  ...     Congress       154
403   404  Sen. Richard Blumenthal (D-CT)  ...     Congress        98

STEP-02: 视觉化数据

让我们看看到目前为止的数据:404 个问题或证词,几乎 29,000 字。这些数字为我们提供了启动所需的材料。重要的是要知道一些声明被分成了较小的部分。当有长声明并且包含不同的段落时,代码将它们分成了独立的声明,即使它们实际上是一个贡献的一部分。为了更好地理解每个参与者的参与程度,我还考虑了他们使用的字数。这提供了另一个角度来衡量他们的参与。

监督人工智能的听证会:图 01

正如图 01 所示,国会议员的干预占所有听证会的一半以上,其次是萨姆·奥特曼的证词。然而,通过统计每一方的发言字数,另一种观点显示了国会(11 名成员)与由奥特曼(OpenAI)、蒙哥马利(IBM)和马库斯(学界)组成的专家小组之间的更平衡的代表性。

值得注意的是参与参议院听证会的国会议员之间的不同参与程度(见下表)。正如预期的那样,作为分委员会主席的布卢门萨尔参议员参与度很高。但其他成员呢?表格显示所有十一位参与者的参与程度有显著差异。请记住,贡献的数量不一定表示其质量。我会让你在查看数字时自行判断。

最后,尽管萨姆·奥特曼受到了大量关注,但值得注意的是,尽管加里·马库斯可能看似参与机会较少,但他的发言量与奥特曼相当,说明他有很多要说。或者这可能是因为学术界往往提供详细解释,而商业世界则更倾向于实用和直接?

好的,马克斯教授,如果你能具体一点就好了。这是你的机会,伙计。用简单的英语告诉我,我们是否应该实施任何规则。请不要只是使用概念。我需要具体的内容。

参议员约翰·肯尼迪(R-LA)。美国参议院关于 AI 监督的听证会(2023)

#*****************************PIE CHARTS************************************
import pandas as pd
import matplotlib.pyplot as plt

# Pie chart - Grouping by 'Organization' Questions&Testimonies
org_colors = {'Congress': '#6BB6FF', 'OpenAI': 'green', 'IBM': 'lightblue', 'Academia': 'lightyellow'}
org_counts = dfsenate['Organization'].value_counts()

plt.figure(figsize=(8, 6))
patches, text, autotext = plt.pie(org_counts.values, labels=org_counts.index, 
                                  autopct=lambda p: f'{p:.1f}%\n({int(p * sum(org_counts.values) / 100)})', 
                                  startangle=90, colors=[org_colors.get(org, 'gray') for org in org_counts.index])
plt.title('Hearing on Oversight of AI: Questions or Testimonies')
plt.axis('equal')
plt.setp(text, fontsize=12)
plt.setp(autotext, fontsize=12)
plt.show()

# Pie chart - Grouping by 'Organization' (WordCount)
org_colors = {'Congress': '#6BB6FF', 'OpenAI': 'green', 'IBM': 'lightblue', 'Academia': 'lightyellow'}
org_wordcount = dfsenate.groupby('Organization')['WordCount'].sum()

plt.figure(figsize=(8, 6))
patches, text, autotext = plt.pie(org_wordcount.values, labels=org_wordcount.index, 
                                  autopct=lambda p: f'{p:.1f}%\n({int(p * sum(org_wordcount.values) / 100)})', 
                                  startangle=90, colors=[org_colors.get(org, 'gray') for org in org_wordcount.index])

plt.title('Hearing on Oversight of AI: WordCount ')
plt.axis('equal')
plt.setp(text, fontsize=12)
plt.setp(autotext, fontsize=12)
plt.show()

#************Engagement among the members of Congress**********************

# Group by name and count the rows
Summary_Name = dfsenate.groupby('name').agg(comment_count=('comment', 'size')).reset_index()

# WordCount column for each name
Summary_Name ['Total_Words'] = dfsenate.groupby('name')['WordCount'].sum().values

# Percentage distribution for comment_count
Summary_Name ['comment_count_%'] = Summary_Name['comment_count'] / Summary_Name['comment_count'].sum() * 100

# Percentage distribution for total_word_count
Summary_Name ['Word_count_%'] = Summary_Name['Total_Words'] / Summary_Name['Total_Words'].sum() * 100

Summary_Name  = Summary_Name.sort_values('Total_Words', ascending=False)

print (Summary_Name)
+-------+--------------------------------+---------------+-------------+-----------------+--------------+
| index |              name              | Interventions | Total_Words | Interv_%        | Word_count_% |
+-------+--------------------------------+---------------+-------------+-----------------+--------------+
|     2 | Sam Altman                     |            92 |        6355 |     22.77227723 |  22.32252626 |
|     1 | Gary Marcus                    |            47 |        5105 |     11.63366337 |  17.93178545 |
|    15 | Sen. Richard Blumenthal (D-CT) |            58 |        3283 |     14.35643564 |  11.53184165 |
|    10 | Sen. Josh Hawley (R-MO)        |            25 |        2283 |     6.188118812 |  8.019249008 |
|     0 | Christina Montgomery           |            36 |        2162 |     8.910891089 |  7.594225298 |
|     6 | Sen. Cory Booker (D-NJ)        |            20 |        1688 |      4.95049505 |  5.929256384 |
|     7 | Sen. Dick Durbin (D-IL)        |             8 |        1143 |      1.98019802 |  4.014893393 |
|    11 | Sen. Lindsey Graham (R-SC)     |            32 |         880 |     7.920792079 |  3.091081527 |
|     5 | Sen. Christopher Coons (D-CT)  |             6 |         869 |     1.485148515 |  3.052443008 |
|    12 | Sen. Marsha Blackburn (R-TN)   |            14 |         869 |     3.465346535 |  3.052443008 |
|     4 | Sen. Amy Klobuchar (D-MN)      |            11 |         769 |     2.722772277 |  2.701183744 |
|    13 | Sen. Mazie Hirono (D-HI)       |             7 |         755 |     1.732673267 |  2.652007447 |
|    14 | Sen. Peter Welch (D-VT)        |            11 |         704 |     2.722772277 |  2.472865222 |
|     3 | Sen. Alex Padilla (D-CA)       |             7 |         656 |     1.732673267 |  2.304260775 |
+-------+--------------------------------+---------------+-------------+-----------------+--------------+

STEP-03: 标记化

现在是自然语言处理(NLP)有趣的开始阶段。为了分析文本,我们将使用 NLTK Package 这个 Python 库。它提供了用于词频分析和可视化的有用工具。以下库和模块将提供进行词频分析和可视化所需的工具。

 #pip install nltk
#pip install spacy
#pip install wordcloud
#pip install subprocess
#python -m spacy download en

首先,我们将开始标记化,这意味着将文本拆分成单独的词语,也称为“标记”。为此,我们将使用 spaCy,一个开源的 NLP 库,能够处理缩写、标点和特殊字符。接下来,我们将使用来自 NLTK 库的停用词资源去除那些没有太多意义的常见词,如“a”,“an”,“the”,“is”和“and”。最后,我们将应用词形还原,将词语还原为其基本形式,称为词根。例如,“running”变成“run”,“happier”变成“happy”。这种技术帮助我们更有效地处理文本并理解其含义。

总结一下:

o 标记化文本。

o 去除常见词。

o 应用词形还原。

#***************************WORD-FRECUENCY*******************************

import subprocess
import nltk
import spacy
from nltk.probability import FreqDist
from nltk.corpus import stopwords

# Download resources
subprocess.run('python -m spacy download en', shell=True)
nltk.download('punkt')

# Load spaCy model and set stopwords
nlp = spacy.load('en_core_web_sm')
stop_words = set(stopwords.words('english'))

def preprocess_text(text):
    words = nltk.word_tokenize(text)
    words = [word.lower() for word in words if word.isalpha()]
    words = [word for word in words if word not in stop_words]
    lemmas = [token.lemma_ for token in nlp(" ".join(words))]
    return lemmas

# Aggregate words and create Frecuency Distribution
all_comments = ' '.join(dfsenate['comment'])
processed_comments = preprocess_text(all_comments)
fdist = FreqDist(processed_comments)

#**********************HEARING TOP 30 COMMON WORDS*********************
import matplotlib.pyplot as plt
import numpy as np

# Most common words and their frequencies
top_words = fdist.most_common(30)
words = [word for word, freq in top_words]
frequencies = [freq for word, freq in top_words]

# Bar plot-Hearing on Oversight of AI:Top 30 Most Common Words
fig, ax = plt.subplots(figsize=(8, 10))
ax.barh(range(len(words)), frequencies, align='center', color='skyblue')

ax.invert_yaxis()
ax.set_xlabel('Frequency', fontsize=12)
ax.set_ylabel('Words', fontsize=12)
ax.set_title('Hearing on Oversight of AI:Top 30 Most Common Words', fontsize=14)
ax.set_yticks(range(len(words)))
ax.set_yticklabels(words, fontsize=10)

ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.spines['left'].set_linewidth(0.5)
ax.spines['bottom'].set_linewidth(0.5)
ax.tick_params(axis='x', labelsize=10)
plt.subplots_adjust(left=0.3)

for i, freq in enumerate(frequencies):
    ax.text(freq + 5, i, str(freq), va='center', fontsize=8)

plt.show()

AI 监督听证会:图 02

正如在条形图(图 02)中所见,“思考”的频率很高。也许前五个词语给我们提供了关于我们今天和未来在 AI 方面应该做些什么的有趣线索:

“我们需要****思考了解****AI应该去哪里。”

正如我在文章开头提到的,乍一看,“监管”在参议院 AI 听证会上并不是一个频繁使用的词。然而,得出它不是主要关注话题的结论可能是不准确的。关于 AI 是否应该受到监管的兴趣以不同的词汇表达,如“监管”“调控”“机构”“监管”。因此,让我们对代码进行一些调整,汇总这些词,并重新运行条形图,以查看它如何影响分析。

nlp = spacy.load('en_core_web_sm')
stop_words = set(stopwords.words('english'))

def preprocess_text(text):
    words = nltk.word_tokenize(text)
    words = [word.lower() for word in words if word.isalpha()]
    words = [word for word in words if word not in stop_words]
    lemmas = [token.lemma_ for token in nlp(" ".join(words))]
    return lemmas

# Aggregate words and create Frecuency Distribution
all_comments = ' '.join(dfsenate['comment'])
processed_comments = preprocess_text(all_comments)
fdist = FreqDist(processed_comments)
original_fdist = fdist.copy() # Save the original object

aggregate_words = ['regulation', 'regulate','agency', 'regulatory','legislation']
aggregate_freq = sum(fdist[word] for word in aggregate_words)
df_aggregatereg = pd.DataFrame({'Word': aggregate_words, 'Frequency': [fdist[word] for word in aggregate_words]})

# Remove individual words and add aggregation
for word in aggregate_words:
    del fdist[word]
fdist['regulation+agency'] = aggregate_freq

# Pie chart for Regulation+agency distribution
import matplotlib.pyplot as plt

labels = df_aggregatereg['Word']
values = df_aggregatereg['Frequency']

plt.figure(figsize=(8, 6))
plt.subplots_adjust(top=0.8, bottom=0.25)  

patches, text, autotext = plt.pie(values, labels=labels, 
                                  autopct=lambda p: f'{p:.1f}%\n({int(p * sum(values) / 100)})', 
                                  startangle=90, colors=['#6BB6FF', 'green', 'lightblue', 'lightyellow', 'gray'])

plt.title('Regulation+agency: Distribution', fontsize=14)
plt.axis('equal')
plt.setp(text, fontsize=8)  
plt.setp(autotext, fontsize=8)  
plt.show()

AI 监督听证会:图 03

如图 03 所示,监管的话题在参议院 AI 听证会上确实被提及了很多次。

STEP-04: 词语背后的含义

单独的词汇可能给我们一些线索,但词汇的相互关系才真正提供了视角。因此,让我们采用词云的方法,探索是否可以发现简单的条形图和饼图无法显示的见解。

# Word cloud-Senate Hearing on Oversight of AI
from wordcloud import WordCloud
wordcloud = WordCloud(width=800, height=400, background_color='white').generate_from_frequencies(fdist)
plt.figure(figsize=(10, 5))
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis('off')
plt.title('Word Cloud - Senate Hearing on Oversight of AI')
plt.show()

AI 监督听证会:图 04

让我们进一步探索,并比较 AI 听证会中不同利益集团(私营部门、国会、学术界)的词云,看看这些词汇是否揭示了对 AI 未来的不同看法。

# Word clouds for each group of Interest
organizations = dfsenate['Organization'].unique()
for organization in organizations:
    comments = dfsenate[dfsenate['Organization'] == organization]['comment']
    all_comments = ' '.join(comments)
    processed_comments = preprocess_text(all_comments)
    fdist_organization = FreqDist(processed_comments)

    # Word clouds
    wordcloud = WordCloud(width=800, height=400, background_color='white').generate_from_frequencies(fdist_organization)
    plt.figure(figsize=(10, 5))
    plt.imshow(wordcloud, interpolation='bilinear')
    plt.axis('off')
    if organization == 'IBM':
        plt.title(f'Word Cloud: {organization} - Christina Montgomery')
    elif organization == 'OpenAI':
        plt.title(f'Word Cloud: {organization} - Sam Altman')
    elif organization == 'Academia':
        plt.title(f'Word Cloud: {organization} - Gary Marcus')
    else:
        plt.title(f'Word Cloud: {organization}')
    plt.show()

AI 监督听证会:图 05

有趣的是,不同利益集团在参议院 AI 听证会中讨论人工智能时,一些词汇会出现(或消失)。

关于大标题“萨姆·奥特曼对监管 AI 的呼吁”;嗯,不管他是否支持监管,我真的无法判断,但在我看来,他的话语中似乎没有太多监管的内容。相反,萨姆·奥特曼谈论 AI 时似乎更关注人本身,重复使用“思考”、“人们”、“了解”、“重要”“使用”等词汇,更倾向于使用“技术”、“系统”“模型”这样的词汇,而不是使用“AI”这个词。

说到“风险”“问题”克里斯蒂娜·蒙哥马利(IBM)在谈论“技术”“公司”“AI”时不断重复这些词。在她的证词中,一个有趣的事实是她提到的词汇最常见的有“信任”“治理”“思考”,以及在 AI 方面“正确”的看法。

“我们需要立即让公司对他们部署的 AI 负责,并承担责任……”

克里斯蒂娜·蒙哥马利。美国参议院 AI 监督听证会(2023 年)

加里·马库斯在他的初步声明中提到,“我以科学家的身份出现,曾创办 AI 公司,并且真正热爱 AI……” 因此,为了这次 NLP 分析,我们将他视为学术界声音的代表。“需要”、“思考”、“了解”、“进行”“人们”等词汇在其中尤为突出。一个有趣的事实是,在他的证词中,“系统”这个词似乎比“AI”出现得更多。也许 AI 并不是一种单一的技术可以改变未来,未来的影响将来自于多种技术或系统的相互作用(物联网、机器人技术、生物技术等),而不是仅仅依赖于其中某一个。

最后,参议员约翰·肯尼迪提到的第一个假设似乎并非完全错误(不仅仅是对于国会,也对整个社会)。我们仍然处于试图理解 AI 发展方向的阶段。

请允许我向你们分享三个假设,我希望你们暂时接受这些假设为真。第一个假设,许多国会议员不了解人工智能。第二个假设,这种理解的缺乏可能不会阻止国会热情投入,并试图以可能对这一技术造成伤害的方式来监管它。第三个假设,我希望你们假设,人工智能社区中可能有一个失控的派别,无论是有意还是无意,都可能利用人工智能来杀死我们所有人,并在我们死去的整个过程中伤害我们……

参议员约翰·肯尼迪(R-LA)。美国参议院关于人工智能监管的听证会(2023 年)

STEP-05: 你话语背后的情感

我们将使用 NLTK 库中的SentimentIntensityAnalyzer类进行情感分析。这个预训练模型使用基于词典的方法,其中词典中的每个单词(VADER)都有一个预定义的情感极性值。将文本中单词的情感分数汇总以计算总体情感分数。数值范围从-1(负面情感)到+1(正面情感),0 表示中立情感。正面情感反映了有利的情感、态度或热情,而负面情感传达了不利的情感或态度。

#************SENTIMENT ANALYSIS************
from nltk.sentiment import SentimentIntensityAnalyzer
nltk.download('vader_lexicon')

sid = SentimentIntensityAnalyzer()
dfsenate['Sentiment'] = dfsenate['comment'].apply(lambda x: sid.polarity_scores(x)['compound'])

#************BOXPLOT-GROUP OF INTEREST************
import seaborn as sns
import matplotlib.pyplot as plt

sns.set_style('white')
plt.figure(figsize=(12, 7))
sns.boxplot(x='Sentiment', y='Organization', data=dfsenate, color='yellow', 
            width=0.6, showmeans=True, showfliers=True)

# Customize the axis 
def add_cosmetics(title='Sentiment Analysis Distribution by Group of Interest',
                  xlabel='Sentiment'):
    plt.title(title, fontsize=28)
    plt.xlabel(xlabel, fontsize=20)
    plt.xticks(fontsize=15)
    plt.yticks(fontsize=15)
    sns.despine()

def customize_labels(label):
    if "OpenAI" in label:
        return label + "-Sam Altman"
    elif "IBM" in label:
        return label + "-Christina Montgomery"
    elif "Academia" in label:
        return label + "-Gary Marcus"
    else:
        return label

# Apply customized labels to y-axis
yticks = plt.yticks()[1]
plt.yticks(ticks=plt.yticks()[0], labels=[customize_labels(label.get_text()) 
                                          for label in yticks])

add_cosmetics()
plt.show()

关于人工智能监管的听证会:图 06

箱型图总是很有趣,因为它显示了最小值和最大值、中位数、第一四分位数(Q1)和第三四分位数(Q3)。此外,添加了一行代码以显示平均值。(感谢 Elena Kosourova 设计了箱型图代码模板;我仅为我的数据集做了调整)。

总体而言,参议院听证会期间,大家似乎心情愉快,尤其是萨姆·奥特曼,他以最高的情感分数脱颖而出,其次是克里斯蒂娜·蒙哥马利。另一方面,加里·马库斯的体验似乎较为中立(中位数约为 0.25),他可能有时感到不太舒服,值接近 0 或甚至为负。此外,国会整体上在情感分数上表现出左偏分布,显示出对中立或积极情感的倾向。有趣的是,如果我们进一步观察,某些干预措施的情感分数非常高或非常低。

关于人工智能监管的听证会:图 07

也许我们不应将结果解读为参议院人工智能听证会上的人们感到快乐或不安。也许这表明参与听证会的人们对人工智能的未来并不持过于乐观的观点,但与此同时,他们也不悲观。评分可能表明存在一些担忧,并对人工智能的发展方向持谨慎态度。

那么时间线如何呢?听证会期间的情绪是否一直保持不变?每个利益集团的情绪如何变化? 为了分析时间线,我将陈述按捕获顺序进行整理,并进行了情感分析。由于有超过 400 个问题或证词,我定义了每个利益集团(国会、学术界、私人)情感评分的移动平均值,窗口大小为 10。这意味着移动平均值是通过对每 10 个连续陈述的情感评分取平均值来计算的:

#**************************TIMELINE US SENATE AI HEARING**************************************

import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
from scipy.interpolate import make_interp_spline

# Moving average for each organization
window_size = 10  
organizations = dfsenate['Organization'].unique()

# Create the line plot
color_palette = sns.color_palette('Set2', len(organizations))

plt.figure(figsize=(12, 6))
for i, org in enumerate(organizations):
    df_org = dfsenate[dfsenate['Organization'] == org]

    # moving average
    df_org['Sentiment'].fillna(0, inplace=True) # missing values filled with 0
    df_org['Moving_Average'] = df_org['Sentiment'].rolling(window=window_size, min_periods=1).mean()

    x = np.linspace(df_org.index.min(), df_org.index.max(), 500)
    spl = make_interp_spline(df_org.index, df_org['Moving_Average'], k=3)
    y = spl(x)
    plt.plot(x, y, linewidth=2, label=f'{org} {window_size}-Point Moving Average', color=color_palette[i])

plt.xlabel('Statement Number', fontsize=12)
plt.ylabel('Sentiment Score', fontsize=12)
plt.title('Sentiment Score Evolution during the Hearing on Oversight of AI', fontsize=16)
plt.legend(fontsize=12)
plt.grid(color='lightgray', linestyle='--', linewidth=0.5)
plt.axhline(0, color='black', linewidth=0.5, alpha=0.5)

for org in organizations:
    df_org = dfsenate[dfsenate['Organization'] == org]
    plt.text(df_org.index[-1], df_org['Moving_Average'].iloc[-1], f'{df_org["Moving_Average"].iloc[-1]:.2f}', ha='right', va='top', fontsize=12, color='black')

plt.tight_layout()
plt.show()

人工智能监管听证会:图 08

起初,会议似乎友好而乐观,大家讨论人工智能的未来。但随着会议的进行,情绪开始发生变化。国会议员变得不那么乐观,问题也变得更加具有挑战性。这影响了讨论小组的评分,甚至有些评分较低(你可以在会议结束时看到这一点)。有趣的是,即使在与国会议员的紧张时刻,模型仍将 Altman 视为中立或略微积极。

重要的是要记住,模型有其局限性,可能带有一定主观性。虽然情感分析并不完美,但它为我们提供了对那一天国会山上情感强度的有趣窥视。

最后的想法

在我看来,这次美国参议院关于人工智能的听证会背后的教训在于五个最常出现的词汇:“我们 需要 思考 知道 人工智能 应该 哪里。值得注意的是,像“人们”“重要性”这样的词在 Sam Altman 的词云中意外出现,超出了“呼吁监管”的标题范围。虽然我希望在 Altman 的 NLP 分析中看到更多“透明度”“问责制”“信任”“治理”“公平”等词,但发现这些词在 Christina Montgomery 的证词中经常出现,还是让人感到宽慰。这正是我们在讨论人工智能时期待听到的。

加里·马库斯强调了“system”“AI”一样多,或许是在邀请我们从更广的视角来看待人工智能。目前有多种技术正在出现,它们对社会、工作和未来就业的综合影响将来自这些技术之间的冲突,而不仅仅是某一种技术。学术界在引导这一过程方面发挥着至关重要的作用,如果需要某种形式的监管的话。我说的是“字面上的” 而不是 “精神上的”(来自六个月停顿信的内部笑话)。

最后,“Agency” 这个词在不同形式中被重复使用的频率与“Regulation”相当。这表明“Agency for AI”的概念及其作用可能会在不久的将来成为讨论的话题。理查德·布卢门撒尔参议员在参议院人工智能听证会上提到对此挑战的有趣反思:

“…我职业生涯的大部分时间都在执法。我告诉你们,你们可以创建 10 个新机构,但如果不给他们资源,我说的不是仅仅是资金,还包括科学专业知识,你们将把他们绕圈子。而且不仅仅是模型或生成性人工智能会把他们绕圈子,而是你们公司里的科学家。对于政府监管中的每一个成功故事,你可以想到五个失败案例……我希望我们这里的经验会有所不同……”

理查德·布卢门撒尔(D-CT)参议员。美国参议院关于人工智能监督的听证会(2023 年)

尽管对我来说,调和创新、意识和监管是具有挑战性的,我完全支持提升对人工智能在我们现在和未来角色的意识,但也要理解“research”“development”是不同的。前者应该得到鼓励和推广,而不是限制,后者则是需要额外努力在“thinking”“knowing”上的地方。

我希望你觉得这篇自然语言处理分析有趣,并且我想感谢 贾斯廷·亨德里克斯Tech Policy Press 允许我在本文中使用他们的稿件。你可以在这个 GitHub 库中访问完整代码。(同时感谢 ChatGPT 帮助我优化了一些代码,使其展示更佳)

我有遗漏什么吗? 欢迎提出建议,让对话持续进行。

面向 ChatGPT 的 LLM 聊天机器人解耦前端——后端微服务架构

原文:towardsdatascience.com/decoupled-frontend-backend-microservices-architecture-for-chatgpt-based-llm-chatbot-61637dc5c7ea

使用 Streamlit、FastAPI 和 OpenAI API 构建无头 ChatGPT 应用程序的实用指南

Marie Stephen LeoTowards Data Science Marie Stephen Leo

·发表于Towards Data Science ·阅读时间 8 分钟·2023 年 5 月 24 日

--

图片由作者使用 Midjourney V5.1 生成,提示词:“解耦前端后端软件应用”

我之前的文章中,我讨论了基于 LLM 的聊天机器人应用程序的单体与微服务架构模式之间的差异。选择微服务架构模式的一个显著优势是,它允许前端代码与数据科学逻辑分离,使得数据科学家可以专注于数据科学逻辑,而不必担心前端代码。在这篇文章中,我将向你展示如何使用 Streamlit、FastAPI 和 OpenAI API 构建微服务聊天机器人应用程序。我们将前端和后端代码解耦,以便可以轻松地将前端替换为其他前端框架,如 React、Swift、Dash、Gradio 等。

首先,创建一个新的 conda 环境并安装所需的库。

# Create and activate a conda environment
conda create -n openai_chatbot python=3.10
conda activate openai_chatbot

# Install the necessary libraries
pip install ipykernel streamlit "fastapi[all]" openai

后端:数据科学逻辑

像我之前的博客文章一样,我们将使用 FastAPI 构建后端。任何 API 中最关键的部分是 API 契约,它定义了 API 接受的输入格式和 API 将发送回客户端的输出格式。定义并遵循一个健全的 API 契约可以使前端开发人员独立于 API 开发人员进行工作,只要双方都尊重契约。这就是将前端与后端解耦的好处。FastAPI 允许我们使用 Pydantic 模型轻松地指定和验证 API 契约。我们的后端 API 契约如下:

API 合同细节。图片由作者提供

后端将负责以下任务:

  1. 首先,我们初始化一个新的 FastAPI 应用,加载 OpenAI API 密钥,并定义一个系统提示,以告知 ChatGPT 我们希望它扮演的角色。在这种情况下,我们希望 ChatGPT 扮演漫画书助手的角色,因此我们这样提示它。可以随意“设计”不同的提示,并查看 ChatGPT 的回应!

  2. 接下来,我们创建两个 Pydantic 模型,ConversationConversationHistory,用于验证 API 负载。Conversation 模型将验证对话历史记录中的每条消息,而 ConversationHistory 模型只是一个对话列表,用于验证整个对话历史记录。OpenAI ChatGPT API 只接受 assistantuser 作为 role 参数,因此我们在 Conversation 模型中指定了这个限制。如果尝试在 role 参数中发送其他值,API 将返回错误。使用 Pydantic 模型与 FastAPI 配合使用的好处之一就是验证。

  3. 接下来,我们为健康检查保留根路由。

  4. 最后,我们定义一个 /chat 路由,该路由接受一个 POST 请求。该路由将接收一个 ConversationHistory 负载,这是一系列对话。然后,该路由将负载转换为 Python 字典,使用系统提示和负载中的消息列表初始化对话历史记录,使用 OpenAI ChatGPT API 生成响应,并将生成的响应和令牌使用情况返回给 API 调用者。

# %%writefile backend.py
import os
from typing import Literal

import openai
from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

# Load your API key from an environment variable or secret management service
openai.api_key = os.getenv("OPENAI_API_KEY")

system_prompt = "You are a comic book assistant. You reply to the user's question strictly from the perspective of a comic book assistant. If the question is not related to comic books, you politely decline to answer."

class Conversation(BaseModel):
    role: Literal["assistant", "user"]
    content: str

class ConversationHistory(BaseModel):
    history: list[Conversation] = Field(
        example=[
            {"role": "user", "content": "tell me a quote from DC comics about life"},
        ]
    )

@app.get("/")
async def health_check():
    return {"status": "OK!"}

@app.post("/chat")
async def llm_response(history: ConversationHistory) -> dict:
    # Step 0: Receive the API payload as a dictionary
    history = history.dict()

    # Step 1: Initialize messages with a system prompt and conversation history
    messages = [{"role": "system", "content": system_prompt}, *history["history"]]

    # Step 2: Generate a response
    llm_response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo", messages=messages
    )

    # Step 3: Return the generated response and the token usage
    return {
        "message": llm_response.choices[0]["message"],
        "token_usage": llm_response["usage"],
    }

就这样!我们现在可以使用 uvicorn backend:app — reload 在本地机器上运行后端,并通过 127.0.0.1:8000/docs. 使用 Swagger UI 进行测试。

FastAPI 后端文档。图片由作者提供

前端:用户界面

我们将构建前端,使其完全独立于后端。我们只需要遵守后端使用的 API 合同。在构建前端用户界面之前,让我们定义一些辅助函数。

  1. display_conversation() 将帮助我们使用 原生 streamlit 聊天元素 显示对话历史记录。我们可以使用表情符号或文件路径为用户和助手消息选择独特的头像。

  2. clear_conversation() 将帮助我们清除对话历史记录。它还将初始化 conversation_history 会话状态变量以存储对话历史记录,以及 total_cost 会话状态变量以保存总对话成本。

  3. download_conversation() 将允许我们将对话历史记录下载为 CSV 文件。

  4. calc_cost(): 将帮助我们根据使用的令牌数量计算对话成本。OpenAI API 对每 1000 个输出令牌收费 $0.002,对每 1000 个输入令牌收费 $0.0015,所以我们将使用这些费用来计算对话成本。

# %%writefile utils.py
from datetime import datetime

import pandas as pd
import streamlit as st

user_avatar = "😃"
assistant_avatar = "🦸"

def display_conversation(conversation_history):
    """Display the conversation history"""

    # Loop over all messages in the conversation
    for message in conversation_history:
        # Change avatar based on the role
        avatar = user_avatar if message["role"] == "user" else assistant_avatar

        # Display the message content
        with st.chat_message(message["role"], avatar=avatar):
            st.markdown(message["content"])

            if "api_call_cost" in message:
                st.caption(f"Cost: US${message['api_call_cost']:.5f}")

def clear_conversation():
    """Clear the conversation history."""
    if (
        st.button("🧹 Clear conversation", use_container_width=True)
        or "conversation_history" not in st.session_state
    ):
        st.session_state.conversation_history = []
        st.session_state.total_cost = 0

def download_conversation():
    """Download the conversation history as a CSV file."""
    conversation_df = pd.DataFrame(
        st.session_state.conversation_history, columns=["role", "content"]
    )
    csv = conversation_df.to_csv(index=False)

    st.download_button(
        label="💾 Download conversation",
        data=csv,
        file_name=f"conversation_{datetime.now().strftime('%Y%m%d%H%M%S')}.csv",
        mime="text/csv",
        use_container_width=True,
    )

def calc_cost(token_usage):
    # https://openai.com/pricing

    return (token_usage["prompt_tokens"] * 0.0015 / 1000) + (
        token_usage["completion_tokens"] * 0.002 / 1000
    )

现在我们拥有了使用 Streamlit 构建用户界面所需的一切。让我们创建一个 frontend.py 文件并导入我们之前定义的助手函数。

  1. 首先,我们将定义我们 FastAPI 后端的 URL。

  2. openai_llm_response() 将使用 user 角色将最新的用户输入附加到 conversation_history 会话状态变量中。然后,我们将创建一个符合我们后端 FastAPI 应用程序期望的格式的有效负载,包含 history 字段。最后,我们将有效负载发送到后端,并将生成的响应及单次 API 调用的成本附加到 conversation_history 会话状态变量中。我们还将用生成响应的成本增加总成本。

  3. main(): 是 UI 设计的主要部分。在标题下方,我们使用 utils.py 中的助手函数添加了清除和下载对话的按钮。接着我们有一个聊天输入框,用户可以在其中输入问题。按下回车将把输入框中输入的文本发送到后端。最后,我们展示对话的成本和对话历史。

# %%writefile frontend.py
import requests
import streamlit as st
import utils

# Replace with the URL of your backend
app_url = "http://127.0.0.1:8000/chat"

@st.cache_data(show_spinner="🤔 Thinking...")
def openai_llm_response(user_input):
    """Send the user input to the LLM API and return the response."""

    # Append user question to the conversation history
    st.session_state.conversation_history.append(
        {"role": "user", "content": user_input}
    )

    # Send the entire conversation history to the backend
    payload = {"history": st.session_state.conversation_history}
    response = requests.post(app_url, json=payload).json()

    # Generate the unit api call cost and add it to the response
    api_call_cost = utils.calc_cost(response["token_usage"])
    api_call_response = response["message"]
    api_call_response["api_call_cost"] = api_call_cost

    # Add everything to the session state
    st.session_state.conversation_history.append(api_call_response)
    st.session_state.total_cost += api_call_cost

def main():
    st.title("🦸 ChatGPT Comic Book Assistant")

    col1, col2 = st.columns(2)
    with col1:
        utils.clear_conversation()

    # Get user input
    if user_input := st.chat_input("Ask me any comic book question!", max_chars=50):
        openai_llm_response(user_input)

    # Display the total cost
    st.caption(f"Total cost of this session: US${st.session_state.total_cost:.5f}")

    # Display the entire conversation on the frontend
    utils.display_conversation(st.session_state.conversation_history)

    # Download conversation code runs last to ensure the latest messages are captured
    with col2:
        utils.download_conversation()

if __name__ == "__main__":
    main()

就这样!我们已完成前端应用程序。现在我们可以使用 streamlit run frontend.py 进行测试。

Streamlit App 界面。图像来源:作者

结论

使用 OpenAI API 构建一个聊天机器人,采用微服务架构通过将前端与后端解耦是很简单的。以下是一些考虑何时采用解耦架构的想法:

  1. 你的应用相对复杂或需要支持中到大规模的流量。解耦架构允许前端和后端独立扩展,以处理大规模流量。

  2. 你有专门的前端开发资源来构建 UI,或者需要为外部客户提供高度精致的 UI。在本教程中,我们使用了 Streamlit 构建了一个简单的用户界面,但构建更复杂的 UI 可能会变得困难甚至不可能。最好使用像 React、Swift 等专业 UI 框架来构建面向客户的应用程序。

  3. 你想独立于前端改进数据科学逻辑。例如,你可以更新提示词或添加多个微服务,所有这些都由 API 服务器入口点进行协调,只要你遵守与前端工程师达成的相同 API 合同,就无需担心前端代码。

然而,可能会有一些情况下,解耦不是你应用的最佳架构选择。以下是一些关于何时不使用解耦架构的想法:

  1. 你的应用很简单或流量较低。你可以使用单体应用程序,因为扩展不是问题。

  2. 你没有专门的前端开发资源来构建用户界面,或者你的应用程序仅服务于内部客户,这些客户可能对粗糙的用户界面设计更为宽容。尤其是在构建最小可行产品或原型时,这一点尤为明显。

  3. 你是一个想要同时提升数据科学逻辑和前端界面的独角兽!

深度确定性策略梯度(DDPG)解释

原文:towardsdatascience.com/deep-deterministic-policy-gradients-explained-4643c1f71b2e

一种基于梯度的强化学习算法,用于学习连续动作空间中的确定性策略

Wouter van Heeswijk, PhDTowards Data Science Wouter van Heeswijk, PhD

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

--

图片由 Jonathan Ford 提供,来源于 Unsplash

本文介绍了深度确定性策略梯度(DDPG)——一种适用于连续动作空间中的确定性策略的强化学习算法。通过将演员-评论家范式与深度神经网络结合,可以在不依赖随机策略的情况下处理连续动作空间。

尤其适用于动作中的随机性不受欢迎的连续控制任务——例如,机器人技术或导航——DDPG 可能正是你需要的算法。

DDPG 在强化学习领域中适合什么位置?

DDPG 结合了基于策略的方法和基于价值的方法,形成了一种混合策略类。

## 强化学习的四种策略类别

[towardsdatascience.com

策略梯度方法REINFORCETRPO和 PPO 使用随机策略 π:a~P(a|s) 来探索和比较动作。这些方法从可微分分布 P_θ(a|s) 中提取动作,从而能够计算相对于 θ 的梯度。这些决策中的固有随机性可能在实际应用中不受欢迎。DDPG 消除了这种随机性,产生了更简单和更可预测的策略。

基于价值的方法如 SARSA、蒙特卡罗学习和深度 Q 学习基于确定性策略,该策略始终根据输入状态返回一个单一的动作。然而,这些方法假设动作的数量是有限的,这使得在具有无限多个动作的连续动作空间中评估它们的价值函数和选择最有回报的动作变得困难。

正如你猜测的那样,深度确定性策略梯度(DDPG)填补了这一空白,结合了深度 Q 学习和策略梯度方法的元素。DDPG 有效地处理连续动作空间,并已成功应用于机器人控制和游戏任务中。

如果你不熟悉策略梯度算法(特别是 REINFORCE)或基于价值的方法(特别是 DQN),建议在探讨 DDPG 之前先了解它们。

DDPG: 评论者

DDPG 与深度 Q 学习非常接近,共享了符号和概念。让我们快速了解一下。

DQN 对连续动作空间的适用性?

在普通(即,表格)Q 学习中,我们使用 Q 值来逼近贝尔曼价值函数 V。Q 值为每个状态-动作对定义,因此用 Q(s,a) 表示。表格 Q 学习需要一个查找表来包含每对的 Q 值,因此需要离散的状态空间和离散的动作空间。

是时候将 ‘深度’ 融入深度强化学习中了。与查找表相比,引入神经网络有两个优点:(i)它为整个状态空间提供了一个通用表达式,(ii)因此,它还可以处理连续的 状态 空间。

当然,我们需要处理连续的 动作 空间;因此我们不能为每个动作输出 Q 值。相反,我们提供一个动作作为 输入 并计算状态-动作对的 Q 值(这个过程也称为简单 DQN)。用数学术语来说,我们可以将网络表示为 Q:(s,a)→Q(s,a),即为给定的状态-动作对输出一个单一的 Q 值。

相应的评论员网络如下所示。

DDPG 中的评论员网络 Q:(s,a)示例。网络同时接受状态向量和动作向量作为输入,并输出一个 Q 值[图片由作者提供]

尽管提供了泛化能力,神经网络也引入了一些稳定性问题。作为所有状态的单一表示,每次更新也会影响所有 Q 值。由于观察元组(s,a,r,s’)是顺序收集的,它们之间往往存在高时间相关性,使得过拟合变得非常可能。在这里不深入细节,正确训练价值网络需要以下三种技术:

  • 经验回放:从经验缓冲区中采样观察数据(s,a,r,s’),打破随后收集的元组之间的相关性。

  • 批量学习:用观察数据批次训练价值网络,使更新更可靠且有影响力。

  • 目标网络:使用不同的网络来计算Q(s’,a’)Q(s,a),减少期望与观察之间的相关性。

如何建模经验回放、批量学习和目标网络 [## 如何建模经验回放、批量学习和目标网络

关于稳定且成功的深度 Q 学习的三个基本技巧的快速教程,使用 TensorFlow 2.0

如何建模经验回放、批量学习和目标网络

评论员网络更新

现在基础知识已经更新,让我们将上述概念与 DDPG 结合起来。我们定义一个评论员网络 Q_ϕ,如前所述,由ϕ(代表网络权重)参数化。

我们设定了一个损失函数,目标是最小化,这对于有 Q 学习经验的人应该是熟悉的:

与 DQN 相比,关键区别在于对于* s’对应的动作——而不是在动作空间中最大化——**我们通过目标演员网络μ_{θ_targ}确定动作a’***(稍后会详细讲解)。在这个旁道之后,我们像往常一样更新评论员网络。

除了更新主评论员网络,我们还必须更新目标评论员网络。在深度 Q 学习中,这通常是主价值网络的周期性副本(例如,每 100 集复制一次)。在 DDPG 中,通常使用滞后目标网络进行 Polyak 平均,使目标网络落后于主价值网络:

由于ρ通常接近 1,目标网络适应非常缓慢,这逐渐提高了训练稳定性。

DDPG:演员

在 DDPG 中,actor 和 critic 以离策略的方式紧密相连。我们首先探索算法的离策略特性,然后再讨论动作生成和 actor 网络更新。

离策略训练

在纯策略梯度方法中,我们直接更新策略 μ_θ(由θ 参数化)以最大化期望奖励,而不依赖于显式的价值函数来捕捉这些奖励。DDPG 是一种混合方法,它还使用 Q 值,但从 actor 的角度来看,最大化目标 J(θ) 表面上看是相似的:

然而,仔细观察期望值会发现 DDPG 是一种离策略方法,而典型的 actor-critic 方法是同策略的。大多数 actor-critic 模型最大化期望值 E_{τ~π_θ},其中τ 是由策略 π_θ 生成的状态-动作轨迹。相对而言,DDPG 对从经验缓冲区中抽取的样本状态进行期望值优化(E_{s~D})。由于 DDPG 使用不同策略生成的经验来优化策略,因此它是一种离策略算法

在这种离策略背景下,重放缓冲区的作用需要一些关注。为什么可以重用旧经验,为什么应该这样做?

首先,让我们探讨为什么缓冲区可以包含与当前策略不同的经验。随着策略的不断更新,重放缓冲区保存了源自过时策略的经验。由于最优 Q 值适用于任何过渡,因此生成这些经验的策略并不重要。

其次,重放缓冲区应该包含多样化的经验的原因在于我们部署的是确定性策略。如果算法是同策略的,我们可能会有有限的探索。通过借鉴过去的经验,我们还会在当前策略下不太可能遇到的观察值上进行训练。

DDPG 中的 actor 网络 μ_θ:s 示例。该网络将状态向量作为输入,并输出确定性动作 μ(s)。在训练过程中,通常会添加一个单独的随机噪声 ϵ [图像由作者提供]

动作探索

那么,政策梯度方法中著名的探索机制如何呢?毕竟,我们现在部署的是确定性策略而非随机策略,对吧?DDPG 通过在训练过程中添加一些噪声 ϵ 来解决这个问题,在部署策略时去除这些噪声。

早期的 DDPG 实现使用了相当复杂的噪声结构(例如,时间相关的奥恩斯坦-乌伦贝克噪声),但后来的实验证明,普通高斯噪声 ϵ~N(0,σ²) 的效果同样良好。噪声可能会随着时间的推移逐渐减少,但不像随机策略中的σ_θ那样是一个可训练的组件。最后一点是,我们可能会裁剪动作范围。显然,探索过程中涉及一些调优工作。

总之,演员生成动作如下。它以状态作为输入,输出一个确定的值,并添加一些随机噪声:

演员网络更新

关于策略更新的最终说明,这并不一定是简单的。我们根据评论家网络(由 ϕ 参数化)返回的 Q 值来更新演员网络参数 θ。因此,我们保持 Q 值不变——即,我们在这一步不更新 ϕ——并通过改变动作来最大化预期奖励。这意味着我们 假设评论家网络对动作是可微的,以便我们可以在一个最大化 Q 值的方向上更新动作:

尽管第二个梯度 ∇_θ 常常为了可读性而省略,但它提供了一些说明。我们训练演员网络以 输出更好的动作,从而改进获得的 Q 值。如果愿意,你可以通过应用链式法则来详细说明这个过程。

演员目标网络使用 Polyak 平均进行更新,与评论家目标网络类似。

综合整理

我们有一个演员,也有一个评论家,因此现在没有什么可以阻止我们完成算法!

DDPG 大纲 [作者提供的图像,初步大纲由 ChatGPT 生成]

让我们一步一步地进行详细说明。

初始化 [第 1–4 行]

DDPG 初始化 [作者提供的图像]

我们从以下四个网络开始:

演员网络 μ_θ

  • 由 θ 参数化

  • 基于输入 s 输出确定性动作

演员目标网络 μ_{θ_targ}

  • θ_targ 参数化

  • 在训练评论家网络时提供 s’ 的动作

评论家网络 Q_ϕ(s,a)

  • ϕ 参数化

  • 基于输入 (s,a) 输出 Q 值 Q(s,a)(期望)

评论家目标网络 μ

  • ϕ_tar 参数化

  • 在训练评论家网络时输出 Q 值 Q(s’,a’)(目标)

我们从一个空的重放缓冲区 D 开始。与策略方法不同,我们 在更新策略后不会清空缓冲区, 因为我们会重复使用旧的过渡。

最后,我们将学习率 ρ 设置为 更新目标网络。 为了简单起见,我们假设两个目标网络的学习率相同。请记住,ρ 应设置接近 1(例如,0.995),以便网络更新缓慢,目标保持相对稳定。

数据收集 [第 9–11 行]

DDPG 数据收集 [作者提供的图像]

动作通过演员网络生成,演员网络输出确定性动作。为了增加探索,向这些动作中添加噪声。生成的观测元组存储在重放缓冲区中。

更新演员和评论家网络 [第 12–17 行]

DDPG 主网络更新 [作者提供的图片]

从重放缓冲区中采样一个随机小批量 B⊆D(包括源自较旧策略的观察值)。

要更新评论者,我们最小化平方误差,即观察值(通过目标网络获得)和期望值(通过主网络获得)之间的误差。

为了更新演员,我们计算样本策略梯度,同时保持 Q 值固定。在神经网络设置中,我们计算伪损失,即生成动作的累积 Q 值。

训练过程可以通过下面的 Keras 代码片段进行澄清:

更新目标网络 [第 18–19 行]

DDPG 目标网络更新 [作者提供的图片]

演员目标网络和评论者目标网络使用Polyak 平均进行更新,它们的权重略微靠近更新后的主网络。

返回训练后的网络 [第 23 行]

DDPG 训练后的演员网络 [作者提供的图片]

尽管我们经历了一些麻烦,但结果策略看起来非常干净。与 DQN 不同,我们不对动作空间进行显式最大化,因此不再需要 Q 值 [注意我们从未用它们来做决策,只是用来改进它们]。

我们不再需要目标网络,这些网络只是为了稳定训练和防止振荡。理想情况下,主网络和目标网络将会收敛,从而使得我们有μ_θ=μ_{θ_targ}(以及 Q_ϕ=Q_{ϕ_targ})。这样,我们知道我们的策略确实已经收敛。

最后,我们去掉了探索噪声 ϵ,它从未是策略的一个核心部分。我们得到一个接受状态作为输入并输出确定性动作的演员网络,这在许多应用中正是我们所希望的简单性。

什么使 DDPG 与其他算法不同?

我们确定 DDPG 是一种混合类方法,结合了策略梯度方法和基于值的方法。这适用于所有演员-评论者方法,那么究竟是什么使 DDPG 独特呢?

  • DDPG 处理连续动作空间: 该算法专门设计用于处理连续动作空间,而不依赖于随机策略。确定性策略可能更容易学习,并且在实际应用中没有固有随机性的策略通常是更可取的。

  • DDPG 是离线策略的。 与常见的演员-评论者算法不同,经验来自于包括较旧策略的观察值的重放缓冲区。离线策略的性质对于充分探索是必要的(因为动作是确定性生成的)。它还提供了更高的样本效率和更好的稳定性。

  • DDPG 在概念上非常接近 DQN: 从本质上讲,DDPG 是 DQN 的一个变体,适用于连续动作空间。为了避免明确地在所有动作上进行最大化——DQN 通过枚举整个动作空间来识别最高的 Q(s,a) 值——动作由一个单独优化的演员网络提供。

  • DDPG 输出一个演员网络: 尽管在训练上接近 DQN,但在部署过程中我们只需要训练好的演员网络。这个网络将状态作为输入,并确定性地输出一个动作。

尽管乍一看可能不那么明显,DDPG 的确定性特性往往简化了训练,比在线方法更稳定、更高效。输出是一个全面的演员网络,该网络确定性地生成动作。由于这些特性,它已成为连续控制任务中的重要工具。

想了解更多关于 DDPG 构建模块的背景?查看以下文章。

深度 Q 学习(DQN):

## TensorFlow 2.0 中深度 Q 学习的最小工作示例

一个多臂赌博机的例子,用于训练 Q 网络。更新过程只需要几行代码,使用 TensorFlow。

## 深度 Q 学习在悬崖行走问题中的应用 ## 深度 Q 学习在悬崖行走问题中的应用 [## 深度 Q 学习在悬崖行走问题中的应用

一个完整的 Python 实现,使用 TensorFlow 2.0 进行悬崖导航。

## 深度 Q 学习在悬崖行走问题中的应用

策略梯度方法:

## 强化学习中的策略梯度解释 ## 强化学习中的策略梯度解释 [## 强化学习中的策略梯度解释

了解基于似然比的策略梯度算法(REINFORCE):直觉、推导…

## 深度策略梯度用于悬崖行走 ## 深度策略梯度用于悬崖行走 [## 深度策略梯度用于悬崖行走

一个用 Python 实现的 TensorFlow 2.0 解决方案。在这个方案中,演员由一个神经网络表示,该网络…

## 深度策略梯度用于悬崖行走

参考文献

OpenAI (2018). 深度确定性策略梯度。 spinningup.openai.com/en/latest/algorithms/ddpg.html

Keras (2020). 深度确定性策略梯度(DDPG)由 amifunny 制作。 keras.io/examples/rl/ddpg_pendulum/

Lillicrap, T. P., Hunt, J. J., Pritzel, A., Heess, N., Erez, T., Tassa, Y., … & Wierstra, D. (2015). 基于深度强化学习的连续控制。 arXiv 预印本 arXiv:1509.02971

Yang, A. & Philion, J. (2020). 深度强化学习中的连续控制www.pair.toronto.edu/csc2621-w20/assets/slides/lec3_ddpg.pdf

深入了解 ESA 的哨兵 API

原文:towardsdatascience.com/deep-dive-into-esas-sentinel-api-e6ff4f9d0730

基于 10 米分辨率哨兵数据的布达佩斯 RGB 卫星地图片段。

如何使用 Python 获取、分析和可视化卫星图像

Milan JanosovTowards Data Science Milan Janosov

·发表于Towards Data Science ·13 分钟阅读·2023 年 10 月 26 日

--

本文中的所有图像均由作者创建。

欧洲航天局一直在运行其哨兵任务,支持哥白尼,即欧盟空间计划的地球观测组件,提供各种类型的遥感数据,如雷达和多光谱成像仪器,用于陆地、海洋和大气监测。

目前有六个进行中的哨兵任务,其中三个可以通过它们的Python API轻松访问。这些任务,引用官方来源

哨兵-1是一个极轨、全天候、昼夜雷达成像任务,用于陆地和海洋服务。哨兵-1A 于 2014 年 4 月 3 日发射,哨兵-1B 于 2016 年 4 月 25 日发射。两者均通过苏联运载火箭从欧洲法属圭亚那的航天发射场送入轨道。哨兵-1B 任务于 2022 年结束,并计划尽快发射哨兵-1C。

哨兵-2是一个极轨、多光谱高分辨率成像任务,专用于陆地监测,例如提供植被、土壤和水体覆盖、内陆水道和沿海地区的图像。哨兵-2 还可以提供紧急服务的信息。哨兵-2A 于 2015 年 6 月 23 日发射,哨兵-2B 则于 2017 年 3 月 7 日发射。

Sentinel-3 是一个多仪器任务,用于高端准确性和可靠性地测量海表地形、海洋和陆地表面温度、海洋颜色和陆地颜色。该任务支持海洋预测系统以及环境和气候监测。Sentinel-3A 于 2016 年 2 月 16 日发射,Sentinel-3B 于 2018 年 4 月 25 日与其双胞胎一起进入轨道。

经过一些额外的挖掘,我们可以了解到Sentinel-1的数据在空间分辨率方面可以达到几米。而Sentinel-2的视觉数据最高分辨率为 10 米,Sentinel-3则根据传感器类型在 100 公里的规模上运行。

好的,所以我们知道如何获取卫星数据,看起来还有很多的源(传感器)和空间分辨率可以选择。有人可能会指出,这仅仅是冰山一角,正如这卫星数据源列表所概述的那样。那么,我们将这些不同类型的卫星数据用于什么呢?首先,这里有 50 多个用例的选择

一般来说,我认为用例、问题的具体细节以及目标区域的地理空间特征和地形都是确定适合的数据源的重要因素。然而,在实际操作中,根据我的经验,这些是主要的驱动因素:

  • 价格(最好是免费探索,适用于 Sentinel)

  • 具有几米的空间分辨率,甚至较小的城市结构也可以被捕捉到。

  • 至少具有几个波段,例如可见光和近红外。

  • 时间频率

这些方面使得 Sentinel-2 可能是地理空间数据社区中使用最广泛的卫星数据源。基于这些组件,在这篇文章中,我将向你展示如何获取 Sentinel 数据以及下载时应该期待什么。我还将深入探讨不同的可能性以及图像记录和存储的信息的时间演变。

在这篇文章中,使用了 2023 年的 Copernicus Sentinel 数据,因为欧盟法律允许免费访问 Copernicus Sentinel 数据和服务信息。

1. 数据获取

首先,我将按照官方文档和示例代码设置 API 连接。此外,我还需要一个目标区域来下载图像。为了方便调试,我选择了我的家乡布达佩斯。我将使用 OSMNx 下载其行政边界。

import osmnx as ox # version: 1.0.1
import matplotlib.pyplot as plt # version: 3.7.1

city = 'Budapest'
admin = ox.geocode_to_gdf(city)
admin.plot()

布达佩斯的行政边界。

现在点击 Sentinel API:

from sentinelsat import SentinelAPI, read_geojson, geojson_to_wkt # version 0.14

# to get an account, sign up here: https://apihub.copernicus.eu/apihub
user = <add your user name 
password = < add your password >
api = SentinelAPI(user, password, 'https://apihub.copernicus.eu/apihub') 

为了进行查询,最好有一个平滑的多边形来指定位置。为此,我创建了布达佩斯行政区的凸包:

# to simplify the query, I extract the convex hull of the input polygon
admin_polygon = admin.convex_hull.geometry.to_list()[0]
admin_polygon

输出:

布达佩斯的凸包。

在我们选择的平台和给定的时间框架内搜索卫星图像。后者应为 Sentinel-A。此外,我们还可以根据云覆盖进行筛选——这意味着如果图像过于多云,我们将立即丢弃它。

# here we can specifcy the location (based on a polygon)
# the time frame
# the space probe
# and the level of cloud-coverage accepted 

products = api.query(admin_polygon,
                     date=('20150623', '20231006'),
                     platformname='Sentinel-2',
                     cloudcoverpercentage=(0, 100))

len(products)

正如这些单元的输出所示,遵循 Sentinel 文档,结果显示在 2015 年 6 月 23 日(任务开始)和 2023 年 10 月 6 日(我撰写本文时),总共记录了 3876 张与布达佩斯行政边界重叠的卫星图像。我将云覆盖百分比设置为 100,这意味着没有基于云覆盖的筛选。因此,我们应该拥有过去八年的所有图像标识符。

我还注意到,结果产品列表包含了所有卫星图像的标识符和元数据,但不包含图像本身。此外,如果我用 Sentinel-3 重复相同的操作,结果将得到近 2 万条图像记录——尽管分辨率要低得多。

2. 探索元数据

让我们将产品列表转换为 Pandas DataFrame 并开始分析吧!

import pandas as pd # version: 1.4.2

products_gdf = api.to_geodataframe(products)
products_gdf = products_gdf.sort_values(['beginposition'], ascending=[True])
print(products_gdf.keys())
print(len(products_gdf.keys()))
products_gdf.head(3)

此块的结果:

查询结果预览。

通过计算表中由卫星图像标识符索引的键的数量,人们可以感受到这些数据有多丰富,其中包含 41 个特征列。

虽然这些领域中有很多技术信息,但我希望仔细查看几个特征。一方面,空间和时间维度编码在生成日期和开始位置(作为日期时间信息)以及几何形状(作为多边形、GIS、数据类型)中。另一方面,有几个有趣的指标基于图像描述土地覆盖类型:cloudcoverpercentage(我们在查询中已经看到过),vegetationpercentagewaterpercentagesnowicepercentage。这些环境指数是从不同材料的光谱特性中得出的。注意:这些值都是汇总得分,捕捉了整个瓦片的总体平均值。更多信息请见 这里

3. 空间维度

由于我们有几何维度,让我们看看这在地图上的样子!我将通过可视化一组随机图块来做到这一点,这些图块在几次运行后完全具有代表性。为了可视化,我使用了带有 CartoDB Dark_Matter 基础地图的 Folium。

import folium
import geopandas as gpd

x, y = admin_polygon.centroid.xy
m = folium.Map(location=[y[0], x[0]], zoom_start=8, tiles='CartoDB Dark_Matter')

# visualize a set of random tiles
polygon_style = { 'fillColor': '#39FF14', 'color': 'black',  'weight': 3, 'opacity': 0}
geojson_data = products_gdf[['geometry']].sample(10).to_json()
folium.GeoJson(
    geojson_data,
    style_function=lambda feature: polygon_style
).add_to(m)

# add the admin boundaries on top
admin_style = {'fillColor': '#00FFFF',  'color': 'black','weight': 3, 'opacity': 100.0  }
admin_geojson_data = admin[['geometry']].to_json()
folium.GeoJson(
    admin_geojson_data,
    style_function=lambda feature: admin_style
).add_to(m)

# show the map
m

该代码块的输出:

与布达佩斯行政区重叠或交叉的卫星图块的随机样本。

从这幅可视图可以看出,几部分图块不断重复。同时也明显有些图块将城市的行政边界分成了两半。这可能导致无法避免的情况,即你想分析完全覆盖你目标区域的数据,却发现它被分成了两半。一种可能的解决方法是过滤掉那些没有完全覆盖所需行政区域的图块:

def compute_overlapping_area(tile, admin):
    return tile.intersection(admin_polygon).area / admin_polygon.area

products_gdf['overlapping_area_fraction'] = products_gdf.geometry.apply(lambda x: compute_overlapping_area(x, admin_polygon))
products_gdf_f = products_gdf[products_gdf.overlapping_area_fraction==1]
print(len(products_gdf))
print(len(products_gdf_f))
products_gdf_f.head(3)

该单元格的结果:

过滤后的卫星图像产品数据集预览

通过应用此过滤器,我去掉了大约一半的图块。现在让我们看看地图,看看它们与城市边界的重叠情况,以及没有图块将城市分成两半的情况:

import folium
import geopandas as gpd

x, y = admin_polygon.centroid.xy
m = folium.Map(location=[y[0], x[0]], zoom_start=8, tiles='CartoDB Dark_Matter')

# visualize a set of random tiles
polygon_style = { 'fillColor': '#39FF14', 'color': 'black',  'weight': 3, 'opacity': 0}
geojson_data = products_gdf_f[['geometry']].sample(10).to_json()
folium.GeoJson(
    geojson_data,
    style_function=lambda feature: polygon_style
).add_to(m)

# add the admin boundaries on top
admin_style = {'fillColor': '#00FFFF',  'color': 'black','weight': 3, 'opacity': 100.0  }
admin_geojson_data = admin[['geometry']].to_json()
folium.GeoJson(
    admin_geojson_data,
    style_function=lambda feature: admin_style
).add_to(m)

# show the map
m

完全覆盖布达佩斯行政区的卫星图块的随机样本。

4. 数据集的时间维度

首先,让我们查看每天、每周和每月覆盖布达佩斯的图像数量。为了测量时间,我将依赖字段beginposition

# Assuming 'beginposition' is a Timestamp column in your GeoDataFrame
# You can convert it to a DateTime index
products_gdf_f_cntr = products_gdf_f.copy()
products_gdf_f_cntr['beginposition'] = pd.to_datetime(products_gdf_f_cntr['beginposition'])
products_gdf_f_cntr.set_index('beginposition', inplace=True)

# Resample the data to count rows per day, week, and month
daily_counts = products_gdf_f_cntr.resample('D').count()
weekly_counts = products_gdf_f_cntr.resample('W').count()
monthly_counts = products_gdf_f_cntr.resample('M').count()

fig, ax = plt.subplots(1, 3, figsize=(15, 5))
for idx, (count_name, count_val) in enumerate([('Daily Counts', daily_counts), ('Weekly Counts', weekly_counts), ('Monthly Counts', monthly_counts), ]): 

    ax[idx].plot(count_val.index[0:250], count_val['geometry'].to_list()[0:250])
    ax[idx].set_xlabel('Date')
    ax[idx].set_ylabel('Count')
    ax[idx].set_title(count_name)

plt.tight_layout()
plt.suptitle('Number of satellite images taken in various time-frames', fontsize = 20, y = 1.15)
plt.show()

每天、每周和每月在布达佩斯目标区域捕获的卫星图像数量。

这些图形展示了 Sentinel-2 探测器的前 250 天、前 250 周和前 250 个月(整个时间跨度)。第一幅图显示了每隔一天拍摄一次快照。第二幅图显示了对前一幅图的每周平均值的计算,显示在前两年中,卫星每周拍摄布达佩斯一次或两次,然后从 2017 年到 2018 年,拍摄次数增加到每周 5-6 张。最后一幅图展示了整个时间跨度,显示了相同的趋势以及在工作了 3 年后,这 25 张每月图像成为了标准水平。

5. 土地覆盖变量的时间维度

现在,来看一下植被百分比水体百分比雪冰百分比云量百分比的时间演变。如前图所示,早期年份可能会显示不同的,可能是噪声结果,所以我们要保持谨慎。在这里,我不会丢弃那些年份的数据,因为我们总共有八年,去掉其中 3 年可能会丢失太多信息。首先,只需查看随时间变化的原始值,并进行每周聚合:

import pandas as pd
import matplotlib.pyplot as plt

# Assuming 'beginposition' is a Timestamp column in your GeoDataFrame
# You can convert it to a DateTime index
products_gdf_f_cntr = products_gdf_f.copy()
products_gdf_f_cntr['beginposition'] = pd.to_datetime(products_gdf_f_cntr['beginposition'])
products_gdf_f_cntr.set_index('beginposition', inplace=True)

# Resample the data to calculate weekly averages
weekly_averages = products_gdf_f_cntr[['vegetationpercentage', 'waterpercentage', 'snowicepercentage', 'cloudcoverpercentage']].resample('W').mean()

# Create a multi-plot figure with four subplots
fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1, figsize=(10, 15))

# Plot 'vegetationpercentage' with a green line
ax1.plot(weekly_averages.index, weekly_averages['vegetationpercentage'], color='green', label='Weekly Average Vegetation Percentage')
ax1.set_xlabel('Date')
ax1.set_ylabel('Percentage')
ax1.set_title('Weekly Average Vegetation Percentage')
ax1.legend()

# Plot 'waterpercentage' with a blue line
ax2.plot(weekly_averages.index, weekly_averages['waterpercentage'], color='blue', label='Weekly Average Water Percentage')
ax2.set_xlabel('Date')
ax2.set_ylabel('Percentage')
ax2.set_title('Weekly Average Water Percentage')
ax2.legend()

# Plot 'snowicepercentage' with a cyan line
ax3.plot(weekly_averages.index, weekly_averages['snowicepercentage'], color='cyan', label='Weekly Average Snow/Ice Percentage')
ax3.set_xlabel('Date')
ax3.set_ylabel('Percentage')
ax3.set_title('Weekly Average Snow/Ice Percentage')
ax3.legend()

# Plot 'cloudcoverpercentage' with a gray line
ax4.plot(weekly_averages.index, weekly_averages['cloudcoverpercentage'], color='gray', label='Weekly Average Cloud Cover Percentage')
ax4.set_xlabel('Date')
ax4.set_ylabel('Percentage')
ax4.set_title('Weekly Average Cloud Cover Percentage')
ax4.legend()

plt.tight_layout()
plt.show() 

植被、水、雪和云量百分比的时间演变,以周为单位进行聚合。

以及月度聚合的结果:

植被、水、雪和云量百分比的时间演变,以月度聚合为单位。

这些时间序列告诉我们一些有趣的事情:

  • 植被百分比清楚地显示了季节性的变化,每年春天一切变绿,然后在秋天这种绿意逐渐消退,从 50-60%降到接近零。

  • 相比之下,水的百分比在全年和整个观察期间波动在 0.8%左右。这是因为研究区域的地表水量非常小。尽管如此,冬季的降水似乎更频繁,这意味着一些淡水体结冰。

  • 关于雪,最突出的峰值——大约 4-8%出现在冬季。尽管如此,基于个人经验,我可以说我们没有很多雪。因此,测量值仅为 1-2%,尤其是在非冬季,可能会导致一些噪声甚至云的错误分类。

  • 关于云层,我们看到它们大多与植被同步,遵循季节性模式。

一些观察结果在这些指标的相关性中也很明显:

products_gdf_f_cntr[['vegetationpercentage', 'waterpercentage', 'snowicepercentage', 'cloudcoverpercentage']].corr()

环境变量随时间变化的相关性。

6. 下载卫星图像

首先,对 Sentinel-2 和 Sentinel-3 进行查询,选择今年八月的同一周,并尽可能限制云覆盖。查看可用的快照数量:

# query tile product ids
products_sent = api.query(admin_polygon, date=('20230806', '20230813'), platformname='Sentinel-2', cloudcoverpercentage=(0, 1))
products_sent = api.to_geodataframe(products_sent)

f, ax = plt.subplots(1,1,figsize=(6,4))
admin.plot(ax=ax, color = 'none', edgecolor = 'k')
ax.set_title('Sentinel-2, number of tiles = ' + str(len(products_sent)))
products_sent.plot(ax=ax, alpha = 0.3)

# filter out the tiles not fully overlapping with Budapest
products_sent['overlapping_area_fraction'] = products_sent.geometry.apply(lambda x: compute_overlapping_area(x, admin_polygon))
products_sent = products_sent[products_sent.overlapping_area_fraction==1]

f, ax = plt.subplots(1,1,figsize=(6,4))
admin.plot(ax=ax, color = 'none', edgecolor = 'k')
ax.set_title('Sentinel-2, number of tiles = ' + str(len(products_sent)))
products_sent.plot(ax=ax, alpha = 0.3)

len(products_sent)

这个代码块的结果:

查询的瓷砖。

现在根据

# download the first tiles as sat images
product_ids = products_sent.index.to_list()

for prod in product_ids:
    api.download(prod)

注意 — 在这里你可能会收到这个通知,这种情况下只需等待几个小时,然后再次运行下载器。

Product a3c61497-d77d-48da-9a4d-394986d2fe1d is not online. Triggering retrieval from long term archive.

7. 打开并可视化下载的图像

这里你可以找到关于数据格式的详细描述,以及关于文件夹结构的漂亮可视化图像。打开图像目录后,可以找到不同的波段。每个波段的含义以及其空间分辨率在这篇文章中有很好的总结,13 个波段的空间分辨率范围从 10 到 60 米。几个亮点:

  • 蓝色(B2)、绿色(B3)、红色(B4)和近红外(B8)频道具有 10 米分辨率。

  • 然后,其植被红边(B5)、近红外(B6、B7 和 B8A)以及短波红外(B11 和 B12)具有 10 米分辨率。

  • 最终,其海岸气溶胶(B1)和短波红外气溶胶(B10)的像素大小为 60 米。

这就是它

# after unzipping the downloaded folder:
import os
image_path = 'S2B_MSIL1C_20230810T094549_N0509_R079_T34TCT_20230810T124346.SAFE/GRANULE/L1C_T34TCT_A033567_20230810T095651/IMG_DATA'
sorted(os.listdir(image_path))

存储在 .jp2 格式中的卫星图像波段列表。

这是一整张瓦片在使用rasterio可视化 B4 红色波段时的样子:

import rasterio
from rasterio.plot import show

image_file = 'T34TCT_20230810T094549_B04.jp2'

with rasterio.open(image_path + '/' + image_file) as src:

    image = src.read(1)  # Change the band index as needed
    plt.figure(figsize=(10, 10))
    plt.imshow(image, cmap='Reds')  # You can change the colormap
    plt.title(image_file)
    plt.colorbar()
    plt.show()

输出:

该瓦片的红色波段包含

现在集中在布达佩斯,并按城市的行政边界分别对 R、G 和 B 波段进行掩膜处理:

from rasterio import mask

f, ax = plt.subplots(1,3,figsize=(15,5))

for idx, (band_name, band_num, color_map) in enumerate([('Blue', 'B02', 'Blues'), ('Green', 'B03', 'Greens'), ('Red', 'B04', 'Reds')]):

    raster_path = image_path + '/T34TCT_20230810T094549_' + band_num + '.jp2'

    with rasterio.open(raster_path) as src:
        polygons = admin.copy().to_crs(src.crs)
        geom = polygons.geometry.iloc[0]
        masked_image, _ = mask.mask(src, [geom], crop=True)

    ax[idx].imshow(masked_image[0], cmap=color_map)
    ax[idx].set_title('Budapest Sentinel 2 - ' + band_name + ' band')

布达佩斯的三种可视化卫星波段。

最后,我们将这些图像拼接成一张布达佩斯的 RGB 图像。首先,我将完整的瓦片拼接成 RGB 图像,然后读取它,按照官方说明进行直方图均衡化,最后得到最终的图像。

# Get the band locations
band_blue = '/T34TCT_20230810T094549_B02.jp2'
band_green = '/T34TCT_20230810T094549_B03.jp2'
band_red = '/T34TCT_20230810T094549_B04.jp2'

# Read in the bands and create the full RGB tile
b2   = rasterio.open(image_path + '/' + band_blue)
b3   = rasterio.open(image_path + '/' + band_green)
b4   = rasterio.open(image_path + '/' + band_red)

# export the full tile as a tif file
meta = b4.meta
meta.update({"count": 3})
prefire_rgb_path = 'budapest_rgb.tif'
with rasterio.open(prefire_rgb_path, 'w', **meta) as dest:
    dest.write(b2.read(1),1)
    dest.write(b3.read(1),2)
    dest.write(b4.read(1),3)

# crop and save it to the admin boundaries of budapest
with rasterio.open('budapest_rgb.tif') as src:
    polygons = admin.copy().to_crs(src.crs)
    geom = polygons.geometry.iloc[0]
    out_image, out_transform  = mask.mask(src, [geom], crop=True)
    out_meta = src.meta.copy()
    out_meta.update({"driver": "GTiff",
                     "height": out_image.shape[1],
                     "width" : out_image.shape[2],
                     "transform": out_transform})

with rasterio.open('budapest_rgb_cropped.tif', "w", **out_meta) as dest:
    dest.write(out_image)

# read and show the cropped version
import numpy as np
from skimage import exposure

img = rasterio.open('budapest_rgb_cropped.tif')
image = np.array([img.read(3), img.read(2), img.read(1)])
image = image.transpose(1,2,0)

# do the histogram equalization
p2, p98 = np.percentile(image, (2,98))
image = exposure.rescale_intensity(image, in_range=(p2, p98)) / 100000

f, ax = plt.subplots(1,1,figsize=(15,15))
rasterio.plot.show(image.transpose(2,0,1), transform=img.transform, ax = ax)
ax.axis('off')
plt.savefig('budapest_rgb_cropped_2.png', dpi = 100, bbox_inches = 'tight')

输出:

基于 10 米分辨率 Sentinel 数据的布达佩斯 RGB 卫星图像。

结论

快速总结一下,看看这篇文章中发生了什么:

  • Sentinel 卫星平台的快速概述

  • 查询多个图像标识符及其元数据的示例

  • 如何仅基于瓦片的汇总信息在元数据中进行时间分析

  • 如何下载、存储和可视化单张图像

所有这些步骤的目的是将卫星图像处理和分析添加到你每天使用的地理空间数据科学工具中,这可以涵盖从城市规划到环境监测和农业等众多应用场景。

深入探讨 Apache Spark 数据倾斜的处理方法

原文:towardsdatascience.com/deep-dive-into-handling-apache-spark-data-skew-57ce0d94ee38

分布式计算中处理数据倾斜的终极指南

Chengzhi ZhaoTowards Data Science Chengzhi Zhao

·发表于 Towards Data Science ·阅读时间 10 分钟·2023 年 1 月 3 日

--

图片由 Lizzi Sassman 提供,来源于 Unsplash

为什么我的 Spark 作业运行很慢? 是使用 Apache Spark 时一个不可避免的问题。关于 Apache Spark 性能调优的常见场景之一是 数据倾斜。在本文中,我们将讨论如何识别 Spark 作业的慢速是否由数据倾斜引起,并深入探讨如何通过代码处理 Apache Spark 数据倾斜,解释包括“加盐”技术在内的三种处理数据倾斜的方法。

如何识别 Spark 中的数据倾斜

在 Spark 性能调优方面,有许多因素需要考虑。鉴于分布式计算的复杂性,如果你能将问题缩小到瓶颈位置,那么你已经成功了一半。

数据倾斜通常发生在分区需要处理的数据不均匀时。假设我们在 Spark 中有三个分区来处理 150 万条记录。理想情况下,每个分区均匀地处理 50 万条记录(图片 1 左侧)。然而,也可能出现某个分区处理的数据远多于其他分区的情况(图片 1 右侧)。

图片 1 | 作者提供的图片

为什么一个分区会处理比其他分区更多的数据? 这与分布系统的工作方式有关。在许多数据处理框架中,数据倾斜是由于数据洗牌引起的,即将数据从一个分区移动到另一个分区。数据洗牌需要在性能调优时加以关注,因为它涉及在集群中的节点之间转移数据。这可能会导致数据管道中出现不必要的延迟,并且难以发现。

数据洗牌很昂贵,但有时执行 宽操作(如 groupBy 和 joins)是不可避免的。这些操作通常是基于键的,即键被哈希后映射到分区。相同的哈希值会被保证洗牌到相同的分区。在上述示例中,许多键被哈希到体量巨大的分区 A 中,分区 A 成为处理近 99% 数据的“热点”。这就是为什么整个作业运行缓慢的原因——数据分布不均,分区 B 和 C 大部分时间闲置,而分区 A 成为处理重负荷的“试验品”。

如何识别 Spark 中的数据倾斜? 我们不能将所有的慢速归咎于数据倾斜。Spark Web UI 是识别 Spark 作业中数据倾斜的最佳本地解决方案。当你在 Spark UI 的 Stages 标签页时,倾斜的分区会在一个阶段内停滞,几乎没有进展。如果我们查看摘要指标,最大列的值通常远大于中位数,并且记录数更多。那么我们就知道我们遇到了数据倾斜问题。

如何知道代码中的哪个部分导致了数据倾斜? Spark UI 中的阶段详情页面只给我们 DAG 的可视化表示。

你怎么知道 Spark 中的哪个部分代码运行缓慢?这在 Spark 官方文档中提到:“整个阶段代码生成操作也会标注 代码生成 ID。对于 Spark DataFrame 或 SQL 执行的阶段,这允许将阶段执行详情与 Web-UI SQL 标签页中报告的 SQL 计划图和执行计划相关联。

在以下情况下,我们可以使用 WholeStageCodegen IDs:2、4 或 5。我们可以前往 Spark Data Frame 标签页找到代码,并在 SQL 计划图上悬停以了解代码中正在运行的详细信息。

代码生成 ID 示例 | 图片由作者提供

数据倾斜示例设置

我们首先通过设置具有数据倾斜的 Spark 环境来演示问题。我们将只为spark.executor.memory设置 1G,并设置一个具有三个核心的执行器,spark.sql.shuffle.partitions也设置为三,因此最终我们将得到三个分区。我们可以使用spark_partition_id来确定记录属于哪个分区,以验证数据分布。为了确保 Spark 不会自作聪明地进行更多优化,比如增加分区数量或将物理计划转换为广播连接,我们将通过将spark.sql.adaptive.enabled设置为 false 来关闭自适应查询执行(AQE)。

我们不需要导入额外的数据源来设置示例。我们可以创建随机数据并在本文中作为示例进行操作。

案例 1:均匀分布情况

我们将在 Spark 中创建一个包含 1,000,000 行的数据框。在这种情况下,从 0 到 999,999 的值是被哈希和洗牌的键。请注意,这里的键是唯一的,这意味着没有任何重复。这些确保了键是非确定性的。不能保证两个不同的键总是位于同一个分区。

df_evenly = spark.createDataFrame([i for i in range(1000000)], IntegerType())
df_evenly = df_evenly.withColumn("partitionId", spark_partition_id())

你可以通过使用getNumPartitions来验证分区数量,在这种情况下,它应该是三,因为我们只有一个执行器和三个核心。

df_evenly.rdd.getNumPartitions()
//output 3

如果一切均匀分布,我们将得到一个良好分布的计数,如果按 partitionId 分组。这是我们上面提到的完美情况图片 1 左

df_evenly.groupby([df_evenly.partitionId]).count().sort(df_evenly.partitionId).show()

按 PartitionId 平均分区的数据 | 图像由作者提供

然后我们可以执行自连接来查看计划是什么样的,我们期望会看到SortMergJoin,这是当两个数据集同等重要时通常能做到的最优计划。

df_evenly.alias(“left”).join(df_evenly.alias(“right”),”value”, “inner”).count()

在以下结果中,我们可以看到数据总大小在三个分区之间分布良好,如果查看每个分区所需的时间,它们似乎没有显著的差距。

按照 Spark 物理计划平均分区的数据 | 图像由作者提供

案例 2:倾斜情况

现在,让我们走极端来展示图片 1 右所示的情况,其中我们有一个极度倾斜的数据集。

我们仍将在 Spark 中创建一个包含 1000000 行的数据框。然而,我们不会让所有键都有不同的值,而是将大多数键设为相同。这确保了我们创建一个“热”键,无论我们尝试多少个哈希函数,都可能成为问题。它保证会在同一个分区中。

df0 = spark.createDataFrame([0] * 999998, IntegerType()).repartition(1)
df1 = spark.createDataFrame([1], IntegerType()).repartition(1)
df2 = spark.createDataFrame([2], IntegerType()).repartition(1)
df_skew = df0.union(df1).union(df2)
df_skew = df_skew.withColumn("partitionId", spark_partition_id())
## If we apply the same function call again, we get what we want to see for the one partition with much more data than the other two.
df_skew.groupby([df_skew.partitionId]).count().sort(df_skew.partitionId).show()

数据倾斜按 PartitionId| 作者提供的图像

在这种情况下,99.99%的数据在一个分区中。让我们用均匀分布的数据集进行连接,以检查计划是什么样的。在运行连接之前,让我们将倾斜数据集以轮询方式重新分区为三个分区,以模拟实际使用情况中的数据读取方式。

//simulate reading to first round robin distribute the key
df_skew = df_skew.repartition(3)

df_skew.join(df_evenly.select(“value”),”value”, “inner”).count() 

检查 Spark 物理计划,我们可以看到在一个分区中分布不均的大量数据(最大时间),而连接时间是指数级的。

数据分区倾斜| 作者提供的图像

如何解决数据倾斜问题

数据倾斜会导致 Spark 性能缓慢,作业会被卡在几个分区中而永远挂起。有多种策略可以解决倾斜问题。如今,借助 Spark 的自适应查询执行(AQE),Spark 更容易找到优化的方式。在极端情况下,AQE 并不能 100%提供最佳优化。此时,我们仍需介入,并熟悉需要使用的方法。

1. 利用分区数量

spark.sql.shuffle.partitions可能是 Spark 中最关键的配置之一。它配置了在洗牌数据用于连接或聚合时使用的分区数量。 配置这个 值并不总是意味着可以解决倾斜问题,但它可能是对 Spark 作业的一般优化。默认值为 200,这对于许多大数据项目在过去是合适的,现在仍然适用于小型/中型数据项目。

将其视为在数据在洗牌阶段被处理时的箱子数量。是否有过多的数据需要单个箱子处理,或者它们是否几乎已满?

2. 广播连接

广播连接可能是避免倾斜的最快连接类型。通过提供BROADCAST提示,我们明确向 Spark 提供了需要将哪个数据框发送到每个执行器的信息。

广播连接通常适用于较小的数据框,例如维度表或具有元数据的数据。它不适合具有百万行的事务表。

df_skew.join(**broadcast**(df_evenly.select(“value”)),”value”, “inner”).count()

3. Salting

来自密码学的 SALT 理念引入了随机性到密钥中,而无需了解数据集的上下文。这个理念是,对于给定的热点键,如果它与不同的随机数结合,我们将不会在单个分区中处理所有的该键的数据。SALT 的一个重要好处是它与任何键无关,你不必担心某些具有相似上下文的键再次出现相同的值。

我已经在Skewed Data in Spark? Add SALT to Compensate上发布了另一篇文章。你可以阅读更多内容了解详细信息。

## Spark 中的数据倾斜?添加 SALT 进行补偿

逐步指南:使用 SALT 技术处理数据倾斜

[towardsdatascience.com

然而,在上述文章中,我仅提供了聚合操作中的加盐代码。仍然没有提及如何在连接操作中执行加盐,这留下了一些问题:“我明白我们可以对键进行加盐以均匀分布数据,但这改变了我的连接键。加盐后如何将数据连接回原始键?” 我将在这篇文章中提供一些代码示例。

利用键加盐的核心思想是考虑空间与时间的权衡。

  • 将盐键作为新列的一部分添加到键中。我们还称原始键和盐键为复合键。新增的键迫使 Spark 对新键进行哈希处理,从而生成不同的哈希值,使数据被打乱到不同的分区。请注意,我们也可以通过从 spark.sql.shuffle.partitions 中获取值来动态获取盐键的随机性数量。
df_left = df_skew.withColumn(“salt”, (rand() * spark.conf.get(“spark.sql.shuffle.partitions”)).cast(“int”))

如下所示,尽管值和 partitionId 相同,我们还是创建了一个额外的“salt”列,以提供更多指导给 Spark 进行连接。

将盐键作为新列的一部分添加到键中 | 图片来源:作者

  • 将所有潜在盐键的数组作为新列添加。你可以选择一个行数较少的数据框(如果行数相同,随机选择一个),并且
df_right = df_evenly.withColumn(“salt_temp”, array([lit(i) for i in range(int(spark.conf.get(“spark.sql.shuffle.partitions”)))]))

将所有潜在盐键的数组作为新列添加 | 图片来源:作者

  • 使用该数组探索数据框。这将现有行复制 n 次(n=你选择的盐的数量)。当两个数据框连接时,由于我们已经在一侧(通常是右侧)有了复制的数据框,连接依然会被验证。这会产生与使用原始键相同的结果。

我们还可以在连接后验证最终分布。相同键“0”的连接数据框在三个分区中均匀分布。这种均匀分布展示了键加盐的技术。

连接后的最终 PartitionId | 图片来源:作者

从物理计划来看,数据分布均匀,并且在百分位指标上的处理时间类似。如果选择一个更大的数据集,我们可以明显看出差异。

键加盐以提高 Spark 性能物理计划 | 图片来源:作者

最终思考

在 Apache Spark 中,数据倾斜可以通过多种方式处理。它可以通过 Spark 配置、Spark 计划优化,或者通过“盐”键引导 Spark 平均分配数据来解决。识别 Spark 作业变慢的原因是任何 Spark 调优的基础。在这些原因中,数据倾斜是常见的罪魁祸首之一。

我写这篇文章是为了帮助大家更好地理解 Spark 中的数据倾斜及其潜在解决方案。然而,当涉及到 Spark 性能优化时,并没有万灵药。你需要投入更多精力查看查询计划,并弄清楚代码中发生了什么。更多知识是通过反复试验获得的。

我希望这篇文章对你有所帮助。这篇文章是我工程与数据科学故事系列的一部分,目前包括以下内容:

程志赵

程志赵

数据工程与数据科学故事

查看列表53 篇故事!

你也可以订阅我的新文章或成为推荐的 Medium 会员,享受 Medium 上所有故事的无限访问权限。

如有疑问/评论,请随时在本文评论区留言直接通过LinkedinTwitter联系我。

深入探讨 pandas Copy-on-Write 模式:第一部分

原文:towardsdatascience.com/deep-dive-into-pandas-copy-on-write-mode-part-i-26982e7408c6?source=collection_archive---------5-----------------------#2023-08-09

解释 Copy-on-Write 内部是如何工作的

Patrick HoeflerTowards Data Science Patrick Hoefler

·

关注 发表在 Towards Data Science ·8 分钟阅读·2023 年 8 月 9 日

--

照片由 Clint AdairUnsplash 提供

介绍

pandas 2.0于四月初发布,并带来了许多对新 Copy-on-Write (CoW) 模式的改进。该功能预计将成为 pandas 3.0 的默认模式,计划于 2024 年四月发布。目前没有遗留或非 CoW 模式的计划。

这一系列文章将解释 Copy-on-Write 如何在内部工作,以帮助用户了解发生了什么,展示如何有效使用它,并说明如何调整你的代码。这将包括如何利用机制以获得最有效的性能的示例,同时展示一些会导致不必要瓶颈的反模式。我几个月前写了一篇简短的介绍 介绍 Copy-on-Write。

我写了一篇简短的文章,解释了 pandas 的数据结构,这将帮助你理解 CoW 所需的一些术语。

我是 pandas 核心团队的一员,至今参与了 CoW 的实施和改进。我是Coiled的开源工程师,负责 Dask,包括改进 pandas 集成并确保 Dask 符合 CoW 标准。

Copy-on-Write 如何改变 pandas 行为

你们中的许多人可能已经熟悉 pandas 中的以下注意事项:

import pandas as pd

df = pd.DataFrame({"student_id": [1, 2, 3], "grade": ["A", "C", "D"]})

让我们选择 grade 列并用 "E" 覆盖第一行。

grades = df["grade"]
grades.iloc[0] = "E"
df

   student_id grade
0           1     E
1           2     C
2           3     D

不幸的是,这也更新了df而不仅仅是grades,这可能会引入难以发现的错误。CoW 将不允许这种行为,并确保仅更新grades。我们还看到一个无用的SettingWithCopyWarning,对我们没有帮助。

让我们看一个ChainedIndexing的示例,这个示例没有做任何事情:

df[df["student_id"] > 2]["grades"] = "F"
df

   student_id grade
0           1     A
1           2     C
2           3     D

我们再次得到SettingWithCopyWarning,但在这个示例中df 没有发生任何变化。所有这些问题都归结于 NumPy 中的复制和视图规则,这是 pandas 在底层使用的。pandas 用户必须了解这些规则以及它们如何应用于 pandas DataFrame,以理解类似的代码模式为何会产生不同的结果。

CoW 清理了所有这些不一致性。启用 CoW 时,用户只能一次更新一个对象,例如,在第一个示例中,df 将保持不变,因为那时只更新了grades,而第二个示例会引发ChainedAssignmentError,而不是什么都不做。一般来说,不可能一次更新两个对象,例如,每个对象的行为都像是前一个对象的副本。

还有更多这样的情况,但在这里讨论所有这些超出了范围。

如何工作

让我们更详细地探讨 Copy-on-Write,并突出一些值得了解的事实。这是本文的主要部分,相当技术性。

Copy-on-Write 承诺任何从其他 DataFrame 或 Series 派生的 对象始终表现为副本。这意味着不可能通过单个操作修改多个对象,例如我们上面的第一个示例仅会修改grades

为了保证这一点,采取非常防御性的方式是每次操作时复制 DataFrame 及其数据,这样可以完全避免 pandas 中的视图。这将保证 CoW 语义,但也会带来巨大的性能损失,因此这不是一个可行的选项。

我们现在将深入了解确保不会有两个对象通过单次操作更新 我们的数据不会不必要地被复制的机制。第二部分是使实现变得有趣的部分。

我们必须准确知道何时触发复制,以避免不必要的复制。只有在尝试改变一个 pandas 对象的值而不复制其数据时,潜在的复制才是必要的。如果这个对象的数据与另一个 pandas 对象共享,我们必须触发一个复制。这意味着我们必须跟踪是否一个 NumPy 数组被两个 DataFrame 引用(通常,我们需要知道一个 NumPy 数组是否被两个 pandas 对象引用,但为了简单起见,我将使用 DataFrame 这个术语)。

df = pd.DataFrame({"student_id": [1, 2, 3], "grade": [1, 2, 3]})
df2 = df[:]

这个语句创建了一个 DataFrame df 和这个 DataFrame df2 的视图。视图意味着这两个 DataFrame 是由相同的底层 NumPy 数组支撑的。当我们用 CoW 看待这个问题时,df 必须知道 df2 也引用了它的 NumPy 数组。但这还不够。df2 也必须知道 df 引用了它的 NumPy 数组。如果两个对象都知道另一个 DataFrame 引用相同的 NumPy 数组,我们可以在其中一个被修改时触发一个复制,例如:

df.iloc[0, 0] = 100

df 在这里被就地修改。df 知道有另一个对象引用相同的数据,例如,它触发了一个复制。它不知道哪个对象引用相同的数据,只是知道外面有另一个对象。

让我们看看如何实现这一点。我们创建了一个内部类 BlockValuesRefs,用于存储这些信息,它指向所有引用给定 NumPy 数组的 DataFrames。

创建 DataFrame 可以通过三种不同类型的操作:

  • DataFrame 是从外部数据创建的,例如通过 pd.DataFrame(...) 或通过任何 I/O 方法。

  • 通过一个 pandas 操作创建一个新的 DataFrame,这个操作会触发对原始数据的复制,例如 dropna 在几乎所有情况下都会创建一个复制。

  • 通过一个 pandas 操作创建一个新的 DataFrame,这个操作 不会 触发对原始数据的复制,例如 df2 = df.reset_index()

前两个案例很简单。当创建 DataFrame 时,支撑它的 NumPy 数组会连接到一个新的 BlockValuesRefs 对象。这些数组仅被新对象引用,因此我们不必跟踪任何其他对象。该对象创建一个 weakref,指向包裹 NumPy 数组的 Block 并在内部存储这个引用。Blocks 的概念在这里进行了解释。

weakref创建对任何 Python 对象的引用。它不会在对象通常超出作用域时保持该对象存活。

import weakref

class Dummy:
    def __init__(self, a):
        self.a = a

In[1]: obj = Dummy(1)
In[2]: ref = weakref.ref(obj)
In[3]: ref()
Out[3]: <__main__.Dummy object at 0x108187d60>
In[4]: obj = Dummy(2)

这个示例创建了一个 Dummy 对象及其弱引用。随后,我们将另一个对象赋给相同的变量,例如初始对象超出作用域并被垃圾回收。弱引用不会干扰这一过程。如果你解析弱引用,它将指向None而不是原始对象。

In[5]: ref()
Out[5]: None

这确保了我们不会保留任何本应被垃圾回收的数组。

让我们来看看这些对象是如何组织的:

作者提供的图片

我们的示例有两列"a""b",它们的 dtype 都是"int64"。它们由一个 Block 支持,该 Block 保存这两列的数据。Block 持有对引用跟踪对象的硬引用,确保只要 Block 没有被垃圾回收,它就会保持活跃。引用跟踪对象持有对 Block 的弱引用。这使得该对象能够跟踪此 Block 的生命周期,但不会阻止垃圾回收。引用跟踪对象尚未持有对任何其他 Block 的弱引用。

这些是简单的场景。我们知道没有其他 pandas 对象共享相同的 NumPy 数组,因此我们可以简单地实例化一个新的引用跟踪对象。

第三种情况更复杂。新对象查看的数据与原始对象相同。这意味着两个对象指向相同的内存。我们的操作将创建一个新的 Block,该 Block 引用相同的 NumPy 数组,这称为浅拷贝。我们现在必须在我们的引用跟踪机制中注册这个新的Block。我们将使用与旧对象连接的引用跟踪对象来注册我们的新Block

df2 = df.reset_index(drop=True)

作者提供的图片

我们的BlockValuesRefs现在指向支持初始df的 Block 和支持df2的新增 Block。这确保了我们始终了解所有指向相同内存的 DataFrame。

我们现在可以询问引用跟踪对象有多少个指向相同 NumPy 数组的 Block 仍然存在。引用跟踪对象评估弱引用,并告诉我们有多个对象引用相同的数据。这使我们能够在其中一个对象在原地修改时内部触发复制。

df2.iloc[0, 0] = 100

df2中的 Block 通过深拷贝进行复制,创建了一个新的 Block,该 Block 拥有自己的数据和引用跟踪对象。原始的 Block 现在可以被垃圾回收,这确保了dfdf2所支持的数组不会共享任何内存。

作者提供的图片

让我们看一个不同的场景。

df = None
df2.iloc[0, 0] = 100

在我们修改df2之前,df已被失效。因此,我们引用跟踪对象的弱引用,指向支持df的 Block,评估结果为None。这使我们能够在不触发复制的情况下修改df2

作者提供的图像

我们的引用跟踪对象仅指向一个 DataFrame,这使我们能够在不触发复制的情况下进行就地操作。

上述reset_index创建了一个视图。如果我们有一个内部触发复制的操作,机制会简单一些。

df2 = df.copy()

这立即为我们的 DataFrame df2 实例化了一个新的引用跟踪对象。

作者提供的图像

结论

我们已经研究了 Copy-on-Write 跟踪机制是如何工作的以及何时触发复制。该机制尽可能推迟 pandas 中的复制,这与非 CoW 行为有很大不同。引用跟踪机制跟踪所有共享内存的 DataFrame,从而在 pandas 中实现更一致的行为。

本系列的下一部分将解释用于提高此机制效率的技术。

感谢阅读。如有意见和反馈,请随时联系以分享您对 Copy-on-Write 的看法。

深入探讨 pandas Copy-on-Write 模式—第 II 部分

原文:towardsdatascience.com/deep-dive-into-pandas-copy-on-write-mode-part-ii-b023432a5334?source=collection_archive---------6-----------------------#2023-08-17

解释 Copy-on-Write 如何优化性能

Patrick HoeflerTowards Data Science Patrick Hoefler

·

跟进 发表在 Towards Data Science ·6 分钟阅读·2023 年 8 月 17 日

--

照片由 Joshua Brown 拍摄于 Unsplash

介绍

第一篇文章 first post 解释了 Copy-on-Write 机制的工作原理。它重点介绍了在工作流程中引入副本的一些领域。本文将专注于确保这些优化不会减慢平均工作流程的优化。

我们利用了 pandas 内部使用的一种技术,以避免在不必要时复制整个 DataFrame,从而提高性能。

我是 pandas 核心团队的一员,并且在实现和改进 CoW 方面参与了很多。我是 Coiled 的开源工程师,主要负责 Dask 的相关工作,包括改进 pandas 集成,并确保 Dask 符合 CoW 标准。

防御性复制的移除

从最具影响力的改进开始。许多 pandas 方法进行防御性复制以避免副作用,从而保护后续的就地修改。

df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
df2 = df.reset_index()
df2.iloc[0, 0] = 100

reset_index 中不需要复制数据,但返回视图在修改结果时会引入副作用,例如 df 也会被更新。因此,在 reset_index 中执行了防御性复制。

启用 Copy-on-Write 后,这些防御性复制将不再存在。这影响了许多方法。完整列表可以在这里找到。

此外,选择 DataFrame 的列子集现在总是返回视图,而不是像之前那样返回复制。

让我们看看当我们结合这些方法时,性能方面会有什么变化:

import pandas as pd
import numpy as np

N = 2_000_000
int_df = pd.DataFrame(
    np.random.randint(1, 100, (N, 10)), 
    columns=[f"col_{i}" for i in range(10)],
)
float_df = pd.DataFrame(
    np.random.random((N, 10)), 
    columns=[f"col_{i}" for i in range(10, 20)],
)
str_df = pd.DataFrame(
    "a", 
    index=range(N), 
    columns=[f"col_{i}" for i in range(20, 30)],
)

df = pd.concat([int_df, float_df, str_df], axis=1)

这会创建一个具有 30 列、3 种不同数据类型和 200 万行的 DataFrame。让我们在这个 DataFrame 上执行以下方法链:

%%timeit
(
    df.rename(columns={"col_1": "new_index"})
    .assign(sum_val=df["col_1"] + df["col_2"])
    .drop(columns=["col_10", "col_20"])
    .astype({"col_5": "int32"})
    .reset_index()
    .set_index("new_index")
)

启用 CoW 前,所有这些方法都会执行防御性复制。

没有 CoW 的性能:

2.45 s ± 293 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

启用 CoW 的性能:

13.7 ms ± 286 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

大约提升了 200 倍。我特意选择了这个例子来说明 CoW 的潜在好处,并不是每个方法的速度都会提升这么多。

优化因就地修改触发的复制

上一部分展示了许多方法在不再需要防御性复制的情况下。CoW 保证你不能同时修改两个对象。这意味着当两个 DataFrame 参考相同数据时,我们必须引入复制。让我们来看看如何使这些复制尽可能高效。

上一篇文章展示了以下情况可能会触发复制:

df.iloc[0, 0] = 100

如果 df 的数据被另一个 DataFrame 参考,则会触发复制。我们假设我们的 DataFrame 有 n 个整数列,例如由一个 Block 支持。

作者提供的图片

我们的参考跟踪对象也引用了另一个 Block,因此我们不能在不修改其他对象的情况下就地修改 DataFrame。一个简单的方法是复制整个块然后完成。

作者提供的图片

这将设置一个新的引用跟踪对象,并创建一个由新的 NumPy 数组支持的新块。这个块没有任何其他引用,因此另一个操作将能够再次原地修改它。这种方法复制了n-1列,而我们不一定需要复制这些列。我们利用一种称为块拆分的技术来避免这种情况。

图片由作者提供

内部只复制了第一列。所有其他列都作为对先前数组的视图。新块与其他列没有共享引用。旧块仍与其他对象共享引用,因为它只是对先前值的视图。

这种技术有一个缺点。初始数组有n列。我们创建了从列2n的视图,但这会保持整个数组的存在。我们还添加了一个只有一列的新数组用于第一列。这将比必要时多占用一点内存。

这个系统直接转换为具有不同数据类型的 DataFrames。所有未被修改的块会原样返回,只有被原地修改的块才会被拆分。

图片由作者提供

我们现在在列n+1的浮点块中设置一个新值,以创建对列n+2m的视图。新块将只支持列n+1

df.iloc[0, n+1] = 100.5

图片由作者提供

可以原地操作的方法

我们查看的索引操作通常不会创建新对象;它们会原地修改现有对象,包括该对象的数据。另一组 pandas 方法则完全不涉及 DataFrame 的数据。一个显著的例子是rename。Rename 只会更改标签。这些方法可以利用上述提到的惰性复制机制。

还有第三组方法实际上可以原地操作,如replacefillna。这些方法将始终触发复制。

df2 = df.replace(...)

修改数据时如果不触发复制,则会修改dfdf2,这违反了 CoW 规则。这是我们考虑保留这些方法的inplace关键字的原因之一。

df.replace(..., inplace=True)

这将解决这个问题。这仍然是一个开放提案,可能会朝不同的方向发展。也就是说,这仅涉及实际被更改的列;所有其他列仍然以视图形式返回。这意味着,如果你的值只出现在一列中,则只会复制一列。

结论

我们研究了 CoW 如何改变 pandas 的内部行为,以及这将如何转化为代码的改进。许多方法在使用 CoW 时会变得更快,而我们会看到一些与索引相关的操作变慢。以前,这些操作总是原地进行的,这可能产生副作用。这些副作用在 CoW 中消失了,对一个 DataFrame 对象的修改将永远不会影响另一个对象。

本系列的下一篇文章将解释如何更新你的代码以符合 CoW 标准。此外,我们还将说明未来应该避免哪些模式。

感谢阅读。如有任何关于写时复制(Copy-on-Write)的想法和反馈,请随时联系我们。

深入探讨 Pandas 的 Copy-on-Write 模式 — 第三部分

原文:towardsdatascience.com/deep-dive-into-pandas-copy-on-write-mode-part-iii-c024eaa16ed4?source=collection_archive---------10-----------------------#2023-09-29

解释 Copy-on-Write 的迁移路径

Patrick HoeflerTowards Data Science Patrick Hoefler

·

关注 发布于 Towards Data Science ·4 分钟阅读·2023 年 9 月 29 日

--

图片由 Zoe Nicolaou 提供,来源于 Unsplash

介绍

引入写时复制(CoW)是一个重大变更,将对现有的 pandas 代码产生一定影响。我们将研究如何调整我们的代码以避免在 CoW 默认启用时出现错误。这目前计划在 2024 年 4 月发布的 pandas 3.0 版本中实现。本系列的第一篇帖子解释了写时复制的行为,而第二篇帖子则深入探讨了与写时复制相关的性能优化。

我们计划添加一个警告模式,该模式将对所有使用 CoW(写时复制)可能改变行为的操作发出警告。由于警告可能会对用户产生很大的干扰,因此必须谨慎处理。本文解释了常见情况以及如何调整代码以避免行为变化。

链式赋值

链式赋值是一种通过两个连续操作更新一个对象的技术。

import pandas as pd

df = pd.DataFrame({"x": [1, 2, 3]})

df["x"][df["x"] > 1] = 100

第一个操作选择了列"x",而第二个操作限制了行数。这些操作有许多不同的组合(例如,与lociloc结合使用)。在 CoW 下,这些组合都不会起作用。相反,它们会引发ChainedAssignmentError警告,以便移除这些模式,而不是默默无闻地什么也不做。

通常,你可以使用loc来代替:

df.loc[df["x"] > 1, "x"] = 100

loc的第一个维度总是对应于row-indexer。这意味着你可以选择一个行的子集。第二个维度对应于column-indexer,这使你能够选择一个列的子集。

当你想要在行的子集上设置值时,使用loc通常会更快,因此这将清理你的代码并提供性能提升。

这是 CoW 将产生影响的明显情况。它也会影响链式的就地操作:

df["x"].replace(1, 100)

模式与上述相同。列选择是第一个操作。replace方法尝试在临时对象上操作,这将无法更新初始对象。你也可以通过指定要操作的列来轻松移除这些模式。

df = df.replace({"x": 1}, {"x": 100})

避免的模式

我之前的帖子解释了 CoW 机制如何工作以及 DataFrames 如何共享底层数据。如果两个对象共享相同的数据,而你在就地修改一个对象时,将会执行防御性复制。

df2 = df.reset_index()
df2.iloc[0, 0] = 100

reset_index 操作将创建底层数据的视图。结果分配给一个新变量 df2,这意味着两个对象共享相同的数据。这在 df 被垃圾回收之前一直有效。因此,setitem 操作会触发复制。如果你不再需要初始对象 df,这完全没有必要。只需重新分配到相同的变量将使对象所持有的引用失效。

df = df.reset_index()
df.iloc[0, 0] = 100

总结来说,在同一方法中创建多个引用会保持不必要的引用活跃。

链接不同方法时创建的临时引用是可以的。

df = df.reset_index().drop(...)

这只会保持一个引用活跃。

访问底层 NumPy 数组

pandas 目前通过 to_numpy.values 让我们访问底层的 NumPy 数组。如果你的 DataFrame 由不同的数据类型组成,例如:

df = pd.DataFrame({"a": [1, 2], "b": [1.5, 2.5]})
df.to_numpy()

[[1\.  1.5]
 [2\.  2.5]]

DataFrame 由两个数组支持,这两个数组必须合并为一个。这会触发复制。

另一种情况是 DataFrame 仅由一个 NumPy 数组支持,例如:

df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
df.to_numpy()

[[1 3]
 [2 4]]

我们可以直接访问数组并获取视图,而不是复制。这比复制所有数据要快得多。我们现在可以对 NumPy 数组进行操作,并可能就地修改它,这也会更新 DataFrame,并可能更新所有共享数据的其他 DataFrame。由于我们移除了许多防御性复制,这在写时复制的情况下变得更加复杂。现在,更多的 DataFrame 将相互共享内存。

to_numpy.values 将因而返回只读数组。这意味着结果数组是不可写的。

df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
arr = df.to_numpy()

arr[0, 0] = 1

这将触发一个 ValueError

ValueError: assignment destination is read-only

你可以通过两种不同的方式避免这种情况:

  • 如果你想避免更新与数组共享内存的 DataFrame,请手动触发复制。

  • 使数组可写。这是一种更高效的解决方案,但会绕过写时复制(Copy-on-Write)规则,因此应谨慎使用。

arr.flags.writeable = True

在某些情况下,这不可能实现。一个常见的情况是,当你访问由 PyArrow 支持的单列时:

ser = pd.Series([1, 2], dtype="int64[pyarrow]")
arr = ser.to_numpy()
arr.flags.writeable = True

这将返回一个 ValueError

ValueError: cannot set WRITEABLE flag to True of this array

Arrow 数组是不可变的,因此无法使 NumPy 数组可写。在这种情况下,Arrow 到 NumPy 的转换是零复制的。

结论

我们已经看到了最具侵入性的写时复制相关更改。这些更改将成为 pandas 3.0 的默认行为。我们还研究了如何调整代码,以避免在启用写时复制时破坏代码。如果你能避免这些模式,升级过程应该会非常顺利。

深入探讨模型可解释性的 PFI

原文:towardsdatascience.com/deep-dive-into-pfi-for-model-interpretability-f12f0c64226c?source=collection_archive---------11-----------------------#2023-07-20

另一个可供选择的可解释性工具

Tiago Toledo Jr.Towards Data Science Tiago Toledo Jr.

·

关注 发表在 Towards Data Science ·6 分钟阅读·2023 年 7 月 20 日

--

图片由 fabio 提供,来源于 Unsplash

了解如何评估你的模型对于数据科学家的工作至关重要。如果你不能完全理解并向利益相关者传达你的解决方案,没有人会批准它。这就是为什么了解可解释性方法如此重要的原因。

缺乏可解释性可能会毁掉一个非常好的模型。我还没有开发过一个我的利益相关者不关心理解预测如何产生的模型。因此,知道如何解释模型并将其传达给业务是数据科学家的核心能力。

在这篇文章中,我们将深入探讨置换特征重要性(PFI),这是一种与模型无关的方法,可以帮助我们识别模型中最重要的特征,从而更好地沟通模型在进行预测时的考虑因素。

置换特征重要性是什么

PFI 方法尝试估计一个特征对模型结果的重要性,基于我们改变与目标变量相关的特征时模型的表现。

为了做到这一点,对于每个特征,我们要分析其重要性,我们将其随机打乱,同时保持其他特征和目标不变。

这使得特征在预测目标时变得无用,因为我们通过改变它们的联合分布打破了它们之间的关系。

然后,我们可以使用模型来预测我们打乱的数据集。模型性能的减少量将指示该特征的重要性。

算法大致如下:

  • 我们在训练数据集上训练一个模型,然后在训练集和测试集上评估其表现。

  • 对于每个特征,我们创建一个新的数据集,其中该特征被打乱。

  • 然后我们使用训练好的模型来预测新数据集的输出。

  • 新的性能指标与旧指标的比值给出了特征的重要性。

请注意,如果一个特征不重要,模型的表现不应有太大变化。如果它重要,那么表现应该会有很大变化。

解释 PFI

现在我们知道如何计算 PFI,我们如何解释它呢?

这取决于我们将 PFI 应用到哪个折叠。我们通常有两个选项:将其应用于训练数据集或测试数据集。

训练解释

在训练过程中,我们的模型学习数据的模式并尝试表示它。当然,在训练过程中,我们无法知道我们的模型对未见数据的泛化效果如何。

因此,通过将 PFI 应用于训练数据集,我们将看到哪些特征对模型学习数据表示最为相关。

从业务角度来看,这表明哪些特征对模型构建最为重要。

测试解释

现在,如果我们将方法应用于测试数据集,我们将看到特征对模型泛化的影响。

让我们考虑一下。如果我们在打乱某个特征后看到模型在测试集上的表现下降,这意味着该特征对该数据集的表现很重要。由于测试集是我们用来测试泛化的(如果你做得对的话),那么我们可以说它对泛化很重要。

PFI 的问题

PFI 分析了特征对模型性能的影响,因此,它并没有说明原始数据的任何信息。如果模型性能较差,那么你通过 PFI 找到的任何关系都是没有意义的。

这对两种情况都适用,如果你的模型出现欠拟合(训练集上的预测能力低)或过拟合(测试集上的预测能力低),那么你不能从这个方法中获得有用的见解。

此外,当两个特征高度相关时,PFI 可能会误导你的解释。如果你打乱一个特征,但所需的信息编码在另一个特征中,那么性能可能不会受到影响,这可能会让你认为这个特征是无用的,但实际上可能并非如此。

在 Python 中实现 PFI

要在 Python 中实现 PFI,我们首先必须导入所需的库。为此,我们主要使用 numpy、pandas、tqdm 和 sklearn 这些库:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

from tqdm import tqdm
from sklearn.model_selection import train_test_split
from sklearn.datasets import load_diabetes, load_iris
from sklearn.ensemble import RandomForestRegressor, RandomForestClassifier
from sklearn.metrics import accuracy_score, r2_score

现在,我们必须加载数据集,将使用 Iris 数据集。然后,我们将对数据进行随机森林拟合。

X, y = load_iris(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
  X, y, test_size=0.3, random_state=12, shuffle=True
)

rf = RandomForestClassifier(
  n_estimators=3, random_state=32
).fit(X_train, y_train)

在模型拟合完成后,让我们分析其性能,以确定是否可以安全地应用 PFI 来查看特征如何影响我们的模型:

print(accuracy_score(rf.predict(X_train), y_train))
print(accuracy_score(rf.predict(X_test), y_test))

我们可以看到,在训练集上我们达到了 99% 的准确率,在测试集上达到了 95.5% 的准确率。目前看来不错。让我们获取原始错误评分以便后续比较:

original_error_train = 1 - accuracy_score(rf.predict(X_train), y_train)
original_error_test = 1 - accuracy_score(rf.predict(X_test), y_test)

现在让我们计算置换得分。为此,通常需要对每个特征进行多次打乱,以获得特征得分的统计数据,从而避免任何巧合。在我们的案例中,让我们对每个特征进行 10 次重复:

n_steps = 10

feature_values = {}
for feature in range(X.shape[1]):
  # We will save each new performance point for each feature
    errors_permuted_train = []
    errors_permuted_test = []

    for step in range(n_steps):
        # We grab the data again because the np.random.shuffle function shuffles in place
        X, y = load_iris(return_X_y=True)
        X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=12, shuffle=True)
        np.random.shuffle(X_train[:, feature])
        np.random.shuffle(X_test[:, feature])

    # Apply our previously fitted model on the new data to get the performance
        errors_permuted_train.append(1 - accuracy_score(rf.predict(X_train), y_train))
        errors_permuted_test.append(1 - accuracy_score(rf.predict(X_test), y_test))

    feature_values[f'{feature}_train'] = errors_permuted_train
    feature_values[f'{feature}_test'] = errors_permuted_test

现在我们有了一个包含每次打乱性能的字典。接下来,让我们生成一个表格,该表格对每个折叠中的每个特征显示其性能的平均值和标准差,并与模型的原始性能进行比较:

PFI = pd.DataFrame()
for feature in feature_values:
    if 'train' in feature:
        aux = feature_values[feature] / original_error_train
        fold = 'train'
    elif 'test' in feature:
        aux = feature_values[feature] / original_error_test
        fold = 'test'

    PFI = PFI.append({
        'feature': feature.replace(f'_{fold}', ''),
        'pfold': fold,
        'mean':np.mean(aux),
        'std':np.std(aux),
    }, ignore_index=True)

PFI = PFI.pivot(index='feature', columns='fold', values=['mean', 'std']).reset_index().sort_values(('mean', 'test'), ascending=False)

我们将得到如下结果:

我们可以看到,特征 2 似乎是数据集中最重要的特征,其次是特征 3。由于我们没有固定 numpy 打乱函数的随机种子,我们可以预期这个数字会有所变化。

然后我们可以绘制一个图表,以更好地可视化重要性:

结论

PFI 是一种简单的方法论,可以帮助你快速识别最重要的特征。继续尝试将它应用到你正在开发的模型中,看看它的表现如何。

但也要注意方法的局限性。如果不了解方法的不足之处,将会导致错误的解释。

另外,注意 PFI 显示的是特征的重要性,但并没有说明它对模型输出的影响方向。

那么,告诉我,你打算如何在下一个模型中使用这个方法?

敬请关注更多关于可解释性方法的帖子,这些方法可以提高你对模型的整体理解。

深入研究 Softmax 回归

原文:towardsdatascience.com/deep-dive-into-softmax-regression-62deea103cb8

理解 softmax 回归背后的数学原理以及如何用它解决图像分类任务

Roi Yehoshua 博士Towards Data Science Roi Yehoshua 博士

·发表于 Towards Data Science ·阅读时间 13 分钟·2023 年 5 月 25 日

--

Softmax 回归(或多项式逻辑回归)是逻辑回归在多类问题上的一种推广。

它可以用来预测某个事件不同可能结果的概率,例如,根据患者的特征(性别、年龄、血压、各种测试结果等),预测患者是否会患上某种特定疾病。

在这篇文章中,我们将从基本原理推导 softmax 回归模型,并展示如何用它解决图像分类任务。

在阅读这篇文章之前,我强烈推荐你阅读我之前关于逻辑回归的文章:

## 精通逻辑回归

从理论到 Python 实现

towardsdatascience.com

背景:多类分类问题

回顾一下,在监督式机器学习问题中,我们给定一个包含n个标记样本的训练集:D = {(x₁, y₁), (x₂, y₂), … , (xₙ, yₙ)},其中x 是一个m维向量,包含了样本i特征,而 yᵢ 表示该样本的标签。我们的目标是构建一个模型,使其预测尽可能接近真实标签。

多类分类问题中,目标标签可以是 k 个类别中的任何一个,即 y ∈ {1, …, k}。例如,在手写数字识别任务中,k = 10,因为有 10 个可能的数字(0–9)。

如同在二分类问题中,我们区分两种分类器:

  1. 确定性分类器为每个样本输出一个硬标签,而不提供类别的概率估计。此类分类器的示例包括 K-近邻决策树 和 SVM。

  2. 概率分类器输出 k 个类别的概率估计,然后基于这些概率分配标签(通常是概率最高的类别的标签)。此类分类器的示例包括 softmax 回归,朴素贝叶斯分类器 和在输出层使用 softmax 的 神经网络

Softmax 回归模型

给定一个样本(xy),softmax 回归模型输出一个概率向量 p = (p₁, …, pₖ),其中 pᵢ 表示样本属于类别 i: 的概率。

向量中的概率之和必须为 1:

在(二分类)逻辑回归中,我们假设 对数几率p 和 1 − p 之间的比率的对数)是输入特征(向量 x)的线性组合。

在 softmax 回归中,我们选择一个概率作为参考(假设为 pₖ),并假设每个概率 pᵢpₖ 之间的对数几率比是输入特征的线性组合。

换句话说,我们可以将 pᵢpₖ 之间的对数几率表示为某个权重向量w 和特征向量x的点积:

对数几率作为特征的线性组合

请注意,在 softmax 回归中,我们为每个类别 i 拥有一个单独的参数向量 w。模型的所有参数集合通常存储在一个大小为 (m + 1) × k 的矩阵 W 中,通过将 w₁, …, w 连接成列获得:

参数矩阵

通过对对数几率方程的两边取指数,我们得到:

由于所有 k 个概率之和为 1,我们可以写成:

我们现在可以使用 pₖ 的表达式来找到所有其他概率:

由于所有 k 个概率之和必须为 1,一旦知道了其他所有概率,概率 pₖ 就完全确定。因此,我们只有 k − 1 个可以单独识别的系数向量。这意味着我们可以任意选择 w = 0 来确保 exp(wₖᵗ) = 1。这样,我们可以更紧凑地写出上述方程:

将线性函数wx 转换为概率的函数称为softmax,接下来将对此进行描述。

软最大函数

软最大函数,σ(z):ℝ → ℝᵏ, 将一个包含k个实数的向量z = (z₁, …, zₖ) 转换为一个概率向量 (σ(z₁), …, σ(zₖ))

软最大函数

可以很容易地验证所有σ(z)的分量都在(0,1)范围内,并且它们的和为 1。

“softmax”这个名字源于该函数是 argmax 函数的平滑近似。例如,向量(1, 2, 6)的 softmax 大约为(0.007, 0.018, 0.976),这几乎将所有的权重集中在向量的最大元素上。

软最大函数是sigmoid(逻辑)函数在多类情况中的扩展。换句话说,可以证明当只有两个类别时,softmax 变成 sigmoid 函数(留给读者作为练习)。

软最大函数也常用于神经网络中,将网络最后一层的输出转换为概率。

以下图表总结了软最大回归模型的计算过程:

软最大回归模型

从数学角度来看,这个过程可以写作如下:

交叉熵损失

我们的目标是找到一组参数W,使得模型的预测p = σ(wx,…, wₖᵗx) 尽可能接近真实标签 y

请注意,我们模型的输出是一个概率向量,而真实标签是一个标量。为了使它们可比,我们使用独热编码对标签进行编码,即,我们将每个标签y 转换为一个二进制向量y = (y₁, …, yₖ),其中 yᵢ = 1 表示真实类别 i,其他位置为 0。

损失函数用于衡量我们模型的预测与真实标签之间的差距。软最大回归中使用的损失函数称为交叉熵损失,这是对多类情况的对数损失的扩展。它的定义如下:

交叉熵损失

例如,假设在一个三类问题中(k = 3),我们有一个真实类别为第 2 类的样本(即y = (0, 1, 0)),并且我们模型对该样本的预测是p = (0.3, 0.6, 0.1)。那么由该样本引起的交叉熵损失是:

类似于对数损失,我们可以证明交叉熵损失是模型对数似然的负值,前提是标签是从一个分类分布中抽取的(这是伯努利分布对k种可能结果的一种推广)。

证明

给定一个数据(标签)的模型作为具有概率p = (p₁, …, pₖ)的分类分布,则一个给定样本属于类别i的概率是pᵢ

因此,样本的真实标签为y的概率是:

解释:如果给定样本的正确类别是 i,则 yᵢ = 1,对于所有 j ≠ i,yⱼ = 0。因此,P(y|p) = pᵢ,这就是样本属于类别 i 的概率。

因此,我们模型的对数似然是:

交叉熵损失正好是这个函数的负值。因此,最小化交叉熵损失等价于最大化模型的对数似然。

梯度下降

与逻辑回归一样,没有解析解可以找到最小化交叉熵损失的最佳W。因此,我们需要使用迭代优化方法,如梯度下降,来寻找最小损失。

幸运的是,交叉熵损失的梯度非常简单(尽管其推导并不简单……)。交叉熵损失对每个参数向量w的梯度是:

证明

我们首先将交叉熵损失写成权重的显式函数:

使用链式法则,我们有:

我们从计算第一个偏导数开始。利用导数的加法规则、对数的导数规则和链式法则,我们得到:

接下来,我们使用商法则计算偏导数 ∂pᵢ/∂zⱼ(即 softmax 函数的导数)。我们区分两种情况:

  • 如果i = j,则:

  • 如果ij,则:

结合这两个方程,我们得到 softmax 函数的导数:

利用这一结果,我们可以完成对L的导数计算:

由于恰好一个yᵢ是 1,其他都是 0,我们可以进一步简化这个导数为:

zⱼ关于w的偏导数就是输入向量x

(参见 这篇文章 了解矩阵微积分的基本规则)。

因此,交叉熵损失相对于每个权重向量的导数是:

利用这些梯度,我们可以使用(随机)梯度下降来最小化给定训练集上的损失函数。

练习题

给定一组图像,你需要将它们分类为狗/猫和户外/室内。你应该实现两个逻辑回归分类器还是一个 softmax 回归分类器?

解决方案可以在文章末尾找到。

Scikit-Learn 中的 softmax 回归

类别 LogisticRegression 可以处理二分类和多分类问题。它有一个名为 multi_class 的参数,默认设置为‘auto’。这个选项的意思是,当 Scikit-Learn 检测到问题是多分类且选择的求解器支持多项式损失的优化时(所有求解器都支持,除了‘liblinear’),它将自动应用 softmax 回归。

示例:分类手写数字

例如,我们在 MNIST 数据集 上训练一个 softmax 回归模型,这是一个广泛使用的图像分类任务数据集。

数据集包含 60,000 张训练图像和 10,000 张手写数字测试图像。每张图像的大小为 28 × 28 像素,通常由一个范围为 [0, 255] 的 784 个数字组成。任务是将这些图像分类为十个数字(0–9)之一。

加载数据集

我们首先使用 fetch_openml() 函数获取 MNIST 数据集:

from sklearn.datasets import fetch_openml

X, y = fetch_openml('mnist_784', return_X_y=True, as_frame=False)

让我们检查一下 X 的形状:

print(X.shape)
(70000, 784)

X 由 70,000 个向量组成,每个向量有 784 个像素。

让我们显示数据集中前 50 个数字:

fig, axes = plt.subplots(5, 10, figsize=(10, 5))
i = 0
for ax in axes.flat:
    ax.imshow(X[i].reshape(28, 28), cmap='binary')
    ax.axis('off')    
    i += 1

MNIST 数据集中的前 50 个数字

接下来,我们将输入数据缩放到 [0, 1] 的范围,而不是 [0, 255]:

X = X / 255

特征缩放在你使用迭代优化方法(如梯度下降)来训练模型时非常重要。

现在我们将数据分为训练集和测试集。请注意,MNIST 中的前 60,000 张图像已被指定用于训练,因此我们可以仅使用简单的切片来进行分割:

train_size = 60000
X_train, y_train = X[:train_size], y[:train_size]
X_test, y_test = X[train_size:], y[train_size:]

构建模型

现在我们创建一个具有默认设置的 LogisticRegression 分类器,并将其拟合到训练集上:

from sklearn.linear_model import LogisticRegression

clf = LogisticRegression()
clf.fit(X_train, y_train)

我们收到一条警告信息,表明已经达到最大迭代次数:

ConvergenceWarning: lbfgs failed to converge (status=1):
STOP: TOTAL NO. of ITERATIONS REACHED LIMIT.

Increase the number of iterations (max_iter) or scale the data as shown in:
    https://scikit-learn.org/stable/modules/preprocessing.html
Please also refer to the documentation for alternative solver options:
    https://scikit-learn.org/stable/modules/linear_model.html#logistic-regression
  n_iter_i = _check_optimize_result(

让我们将 max_iter 增加到 1000(而不是默认的 100):

clf = LogisticRegression(max_iter=1000)
clf.fit(X_train, y_train)

这次学习在达到最大迭代次数之前已经收敛。我们实际上可以通过检查 n_iter_ 属性来查看收敛所需的迭代次数:

print(clf.n_iter_)
[795]

学习收敛花费了 795 次迭代。

模型评估

模型在训练集和测试集上的准确率为:

print('Training set accuracy: ', np.round(clf.score(X_train, y_train), 4))
print('Test set accuracy:' , np.round(clf.score(X_test, y_test), 4))
Training set accuracy: 0.9393
Test set accuracy: 0.9256

这些结果很好,但最近的深度神经网络在这个数据集上可以取得更好的结果(测试准确率高达 99.91%)。Softmax 回归模型大致相当于一个使用 softmax 激活函数的单层感知机神经网络。因此,深度网络能够取得比我们的模型更好的结果并不令人惊讶。

为了更好地理解我们模型的错误,让我们显示其在测试集上的混淆矩阵:

from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay

y_test_pred = clf.predict(X_test)
cm = confusion_matrix(y_test, y_test_pred)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=clf.classes_)
disp.plot(cmap='Blues')

测试集上的混淆矩阵

我们可以看到模型的主要混淆是在数字 5⇔8 和 4⇔9 之间。这是合理的,因为这些数字在手写时常常互相类似。为了帮助我们的模型区分这些数字,我们可以增加这些数字的更多示例(例如,通过数据增强)或从图像中提取额外特征(例如,数字中的闭环数量)。

我们还可以打印分类报告,以获取每个类别的精确度、召回率和 F1 分数:

from sklearn.metrics import classification_report

print(classification_report(y_test, y_test_pred))
 precision    recall  f1-score   support

           0       0.95      0.97      0.96       980
           1       0.96      0.98      0.97      1135
           2       0.93      0.90      0.91      1032
           3       0.90      0.92      0.91      1010
           4       0.94      0.94      0.94       982
           5       0.90      0.87      0.88       892
           6       0.94      0.95      0.95       958
           7       0.93      0.92      0.93      1028
           8       0.88      0.88      0.88       974
           9       0.91      0.92      0.91      1009

    accuracy                           0.93     10000
   macro avg       0.92      0.92      0.92     10000
weighted avg       0.93      0.93      0.93     10000

如预期,模型得分最低的数字是 5 和 8。

权重可视化

Softmax 回归的一个优势是高度可解释(与神经网络等“黑箱”模型不同)。每个特征相关的权重表示该特征的重要性。

例如,我们可以绘制每个数字类别中每个像素相关的权重(w 对于每个 j ∈ {1, …, 10})。这将展示用于检测每个数字的图像中的重要区域。

模型的权重矩阵存储在名为 coef_ 的属性中:

print(clf.coef_.shape)
(10, 784)

该矩阵的 i 行包含了模型对类别 i 的学习权重。我们可以将每一行显示为 28 × 28 像素的图像,以检查每个类别中与每个像素相关的权重:

fig, axes = plt.subplots(2, 5, figsize=(15, 5))

digit = 0
for coef, ax in zip(clf.coef_, axes.flat):
    im = ax.imshow(coef.reshape(28, 28), cmap='gray')
    ax.axis('off')
    ax.set_title(str(digit))
    digit += 1

fig.colorbar(im, ax=axes.flat)

亮色像素对预测有正面影响,而暗色像素有负面影响。灰色级别接近 0 的像素对预测没有影响(如图像边缘附近的像素)。

总结

Softmax 回归与其他多类分类模型相比的优缺点是:

优点

  • 提供类别概率估计

  • 高度可扩展,需要的参数数量与特征数量线性相关

  • 高度可解释(每个特征相关的权重表示其重要性)

  • 能处理冗余特征(通过将其权重分配接近 0)

缺点

  • 只能找到类别之间的线性决策边界

  • 通常被更复杂的模型超越

  • 无法处理缺失值

练习题的解答

由于类别之间并非完全互斥(所有四种狗/猫与户外/室内的组合都是可能的),你应该训练两个逻辑回归模型。这是一个多标签问题,一个 softmax 分类器无法处理。

最终说明

你可以在我的 GitHub 上找到本文的代码示例:github.com/roiyeho/medium/tree/main/softmax_regression

除非另有说明,所有图像均为作者提供。

MNIST 数据集信息:

  • 引用: Deng, L., 2012. 用于机器学习研究的手写数字图像的 mnist 数据库。IEEE 信号处理杂志, 29(6), 第 141–142 页。

  • 许可: Yann LeCun 和 Corinna Cortes 拥有 MNIST 数据集的版权,该数据集在知识共享署名-相同方式共享 4.0 国际许可协议下提供 (CC BY-SA)。

感谢阅读!

Deep GPVAR:升级 DeepAR 实现多维度预测

原文:towardsdatascience.com/deep-gpvar-upgrading-deepar-for-multi-dimensional-forecasting-e39204d90af3

Amazon 的新时间序列预测模型

Nikos KafritsasTowards Data Science Nikos Kafritsas

·发表于 Towards Data Science ·19 分钟阅读·2023 年 3 月 24 日

--

使用 DALLE 创建 [1]

警告: 关于该模型的信息,包括下面的教程,可能不是最新的。阅读更新版本 这里

阅读新论文时最令人愉快的事情是什么?对我而言,就是以下内容:

想象一下,一个流行的模型突然升级——只需几个优雅的调整。

三年后,Amazon 工程师发布了其改版版本,称为 Deep GPVAR [2] (Deep Gaussian-Process Vector Auto-regressive)

这是对原始版本的显著改进版。此外,它是开源的。本文中,我们讨论:

  • 深入了解 Deep GPVAR 的工作原理。

  • DeepAR 和 Deep GPVAR 的不同之处。

  • ** Deep GPVAR 解决了哪些问题,以及为何它比 DeepAR 更好?**

  • 有关能源需求预测的实践教程。

我推出了 AI Horizon Forecast 这是一个专注于时间序列和创新 AI 研究的新闻通讯。订阅 这里 扩展你的视野!

什么是 Deep GPVAR?

Deep GPVAR 是一种自回归深度学习模型,利用低秩高斯过程共同建模数千个时间序列,考虑它们之间的相互依赖性。

简要回顾 Deep GPVAR 的优点:

  • 多时间序列支持: 该模型使用多个时间序列数据来学习全局特征,从而提高其准确预测能力。

  • 额外协变量: Deep GPVAR 允许额外特征(协变量)。

  • 可扩展性: 该模型利用低秩高斯分布将训练扩展到多个时间序列同时进行

  • 多维建模: 与其他全球预测模型相比,Deep GPVAR模型将时间序列一起建模,而不是单独建模。这通过考虑它们的相互依赖性来提高预测准确性。

最后一部分是Deep GPVAR与 DeepAR 的区别所在。我们将在下一节中深入探讨这一点。

全球预测机制

在多个时间序列上训练的全球模型并不是一个新概念。但是为什么需要全球模型?

在我之前的公司,客户对时间序列预测项目感兴趣,主要需求如下:

“我们有 10,000 个时间序列,我们希望创建一个单一模型,而不是 10,000 个单独的模型。”

时间序列可以代表产品销售、期权价格、气候污染等——这并不重要。重要的是,公司需要大量资源来训练、评估、部署和监控(概念漂移10,000个生产中的时间序列。

所以,这是一个很好的理由。此外,那时没有N-BEATSTemporal Fusion Transformer

然而,如果我们要创建一个全球模型,它应该学习什么?模型是否仅仅学习一个聪明的映射,根据输入条件处理每个时间序列?但这假设时间序列是独立的

模型是否应该学习适用于数据集中所有时间序列的全球时间模式?

时间序列的相互依赖性

Deep GPVAR在 DeepAR 的基础上,寻求一种更先进的方法来利用多个时间序列之间的依赖关系,从而提高预测效果。

对于许多任务,这种做法是有意义的。

一个将全球数据集的时间序列视为独立的模型会失去在金融和零售等应用中有效利用它们关系的能力。例如,风险最小化的投资组合需要预测资产的协方差,而不同卖家的概率预测必须考虑竞争和侵蚀效应。

因此,一个强大的全球预测模型不能假设潜在的时间序列是独立的。

Deep GPVAR 与 DeepAR 的区别在于两个方面:

  • 高维估计: Deep GPVAR将时间序列一起建模,考虑它们的关系。为此,模型使用低秩高斯近似来估计它们的协方差矩阵

  • 扩展性: Deep GPVAR并不像其前身那样仅仅对每个时间序列进行标准化。相反,模型学习如何通过使用高斯 Copulas首先转换每个时间序列来缩放它们。

以下部分详细描述了这两个概念的工作原理。

低秩高斯近似——简介

正如我们之前所说,研究多个时间序列之间关系的最佳方法之一是估计协方差矩阵。

然而,由于内存和数值稳定性限制,将此任务扩展到数千个时间序列并不容易实现。此外,协方差矩阵估计是一个耗时的过程——在训练期间,需要为每个时间窗口估计协方差。

为了解决这个问题,作者使用低秩近似来简化协方差估计。

让我们从基础开始。下面是多变量正态分布N**∼**(μ,Σ)的矩阵形式,其中均值**μ** **∈** (k,1),协方差**Σ** **∈** (k,k)

方程 1: 矩阵形式的多变量高斯分布

这里的问题是协方差矩阵**Σ**的大小是与数据集中时间序列的数量N平方相关的。

我们可以使用一种近似形式来解决这个挑战,称为低秩高斯近似。这种方法源于因子分析,并与SVD(奇异值分解)密切相关。

我们可以通过计算以下内容来近似,而不是计算大小为**(**N,N**)****的完整协方差矩阵:

方程 2: 低秩协方差矩阵公式

其中**D** **∈** R(N,N)是对角矩阵,**V** **∈** R(N,r)

但为什么我们使用低秩格式来表示协方差矩阵呢?因为r<<N,证明了高斯似然可以使用O(Nr² + r³)操作来计算,而不是O(N³)(证明可以在论文的附录中找到)。

低秩正态分布是 PyTorch 的分布模块的一部分。可以随意尝试,看看它是如何工作的:

# multivariate low-rank normal of mean=[0,0], cov_diag=[1,1], cov_factor=[[1],[0]]

# covariance_matrix = cov_diag + cov_factor @ cov_factor.T

m = LowRankMultivariateNormal(torch.zeros(2), torch.tensor([[1.], [0.]]), 
torch.ones(2))
m.sample()  
#tensor([-0.2102, -0.5429])

深度 GPVAR 架构

符号: 从现在开始,所有粗体变量都被视为向量或矩阵。

注意:AI 项目文件夹中找到一个关于 Deep GPVAR 的动手项目,该文件夹会不断更新最新时间序列模型的教程!

现在我们已经了解了低秩正态近似的工作原理,接下来我们将深入探讨Deep GPVAR的架构。

首先,Deep GPVAR类似于 DeepAR——该模型也使用 LSTM 网络。假设我们的数据集中包含N个时间序列,索引从i= [1…N]

在每个时间步t,我们有:

  1. 一个 LSTM 单元将前一时间步t-1的目标变量z_t-1 用于部分时间序列作为输入。此外,LSTM 接收前一时间步的隐藏状态**h_t-1**

  2. 模型使用 LSTM 来计算其隐藏向量**h_t**

  3. 隐藏向量**h_t**将被用来计算多变量高斯分布N∼(μ,Σ)**μ****Σ**参数。这是一种特别的正态分布,称为高斯 copula(稍后会详细介绍

这个过程如图 1所示:

图 1:Deep GPVAR 的两个训练步骤。协方差矩阵 Σ 表示为 Σ=D+V*V^T (来源)

在每个时间步tDeep GPVAR 随机选择B << N个时间序列来实现低秩参数化:左侧,模型从(1、2 和 4)时间序列中选择,右侧,模型从(1、3 和 4)时间序列中选择。

作者在实验中将B配置为 20。对于可能包含超过N=1000个时间序列的数据集,这种方法的好处变得明显。

我们的模型估计了 3 个参数:

  • 正态分布的均值**μ**

  • 协方差矩阵参数是d**v**,根据方程 2

它们都从**h_t** LSTM 隐藏向量中计算得出。图 2展示了低秩协方差参数:

图 2:低秩协方差矩阵的参数化,如方程 2所示

注意符号:μ_id_i**v_i**指的是我们数据集中第i-th个时间序列,其中i ∈ [1..N]

对于每个时间序列i,我们创建**y_i**向量,它将**h_i****e_i**连接在一起——**e_i**向量包含第i-th时间序列的特征/协变量。因此,我们有:

图 3展示了时间步t的训练快照:

图 3:低秩参数化的详细架构

注意:

  • μd是标量,而**v**是向量。例如,μ = **w_μ**^T * **y**,维度为(1,p)*(p,1),因此μ是标量。

  • 所有时间序列都使用相同的 LSTM,包括密集层投影**w_μ****w_d****w_u**

因此,神经网络参数**w_μ****w_d****w_u****y**被用来计算**μ****Σ**参数,这些参数在图 3中展示。

高斯 Copula 函数

问题:**μ****Σ**参数化什么?

它们参数化了一种特殊的多变量高斯分布,称为高斯 Copula

但为什么 Deep GPVAR 需要高斯 Copula?

记住,Deep GPVAR 进行多维预测,因此我们不能像在 DeepAR 中那样使用简单的单变量高斯分布。

好吧。那么为什么不使用我们熟悉的多变量高斯分布,而是使用像方程 1中那样的 Copula 函数呢?

2 个原因:

1) 因为多元高斯分布需要高斯随机变量作为边际分布。 我们也可以使用混合分布,但它们过于复杂,不适用于每种情况。相反,高斯 Copulas 更易于使用,可以处理任何输入分布——这里的输入分布指的是我们数据集中单个时间序列。

因此,copula 学会在不对数据做出假设的情况下估计潜在的数据分布。

2) 高斯 Copula 可以通过控制协方差矩阵的参数化来建模这些不同分布之间的依赖结构 ***Σ**** 这就是 Deep GPVAR 学会考虑输入时间序列之间的相互依赖性,而其他全球预测模型则不做此类处理。

记住:时间序列可能具有不可预测的相互依赖性。 例如,在零售中,我们有产品的自相残杀:一个成功的产品会从其类别中的类似商品中吸引需求。因此,我们在预测产品销售时也应该考虑这种现象。通过 copulas,Deep GPVAR 自动学习这些相互依赖性。

什么是 copulas?

Copula 是一个描述多个随机变量之间相关性的数学函数。

注意: 如果你对 copulas 完全陌生,这篇文章 深入解释了 copulas 是什么以及我们如何构造它们。

Copulas 在定量金融中被广泛用于投资组合风险评估。它们的误用在 2008 年金融危机中也起到了重要作用。

更正式地说,copula 函数 **C**N 个随机变量的累积分布函数(CDF),其中每个随机变量(边际)均匀分布:

图 4 显示了一个由 2 个边际分布组成的双变量 copula 的图像。copula 定义在 [0–1]² 域(x、y 轴)中,输出值在 [0–1](z 轴):

图 4: 由 2 个 beta 分布作为边际分布组成的高斯 copula CDF 函数

对于 **C** 的一个流行选择是高斯 Copula——图 4 中的 copula 也是高斯的。

我们如何构造 copulas

我们不会在这里深入讨论,但可以简要概述一下。

起初,我们有一个随机向量——一组随机变量。在我们的例子中,每个随机变量 z_i 代表时间序列 i 在时间步 t 的观测值:

然后,我们通过使用概率积分变换来使我们的变量均匀分布:任何连续随机变量的累积分布函数(CDF)输出是均匀分布的,F(z)=U

最后,我们应用我们的高斯 copula:

其中Φ^-1 是标准高斯 CDF N∼(0,1)的逆函数,φ(小写字母)是一个用*μ**Σ*参数化的高斯分布*。注意Φ^-1[F(z)] = x,其中x~(-Inf, Inf),因为我们使用的是标准逆 CDF。

那么,这里发生了什么?

我们可以取任何连续随机变量,将其边际化为均匀分布,然后将其转化为高斯分布。操作链如下:

这里有 2 种变换:

  • F(z) = U,被称为概率积分变换。简单来说,这种变换将任何连续随机变量转换为均匀分布。

  • Φ^-1(U)=x,被称为逆采样。简单来说,这种变换将任何均匀随机变量转换为我们选择的分布——在这里,Φ 是高斯分布,因此 x 也成为高斯随机变量。

在我们的案例中,**z**是我们数据集中时间序列的过去观察值。由于我们的模型对过去观察值的分布没有假设,我们使用经验 CDF——一种非参数(经验)计算任何分布 CDF 的特殊函数。

注意: 如果你不熟悉经验 CDF,请参考我的文章以获取详细解释。

换句话说,在F(z) = U变换中,F是经验 CDF,而不是变量z的实际高斯 CDF。作者在实验中使用了m=100个过去的观察值来计算经验 CDF。

回顾 copulas

总结来说,高斯 copula 是一个多变量函数,使用μΣ直接参数化两个或更多随机变量的相关性。

但高斯 copula 如何与高斯多变量概率分布(PDF)不同?此外,高斯 copula 只是一个多变量 CDF。

  1. 高斯 copula 可以使用任何随机变量作为边际分布,而不仅仅是高斯分布。

  2. 数据的原始分布无关紧要——通过使用概率积分变换和经验 CDF,我们可以将原始数据转化为高斯分布,无论它们如何分布

深度 GPVAR 中的高斯 copula

现在我们已经了解了 copula 的工作原理,是时候看看Deep GPVAR如何使用它们了。

回到图 3。使用 LSTM,我们已经计算了*μ**Σ*参数。我们所做的是以下操作:

步骤 1:将我们的观察值转换为高斯分布

使用 copula 函数,我们将观察到的时间序列数据点 **z** 转换为高斯 **x**。转换公式为:

其中 f(z_i,t) 实际上是时间序列 i 的边际转换 Φ^-1(F(z_i))

在实际应用中,我们的模型对过去观察值 z 的分布没有假设。因此,无论原始观察值的分布如何,我们的模型都可以有效地学习它们的行为,包括它们的相关性——这一切都归功于高斯 copula 的强大功能。

步骤 2:使用计算出的高斯参数。

我提到我们应该将观察值转换为高斯分布,但高斯分布的参数是什么?换句话说,当我说 f(z_i) = Φ^-1(F(z_i)) 时,Φ 的参数是什么?

答案是 *μ**Σ* 参数——这些参数是从图 3中所示的密集层和 LSTM 计算得到的。

步骤 3:计算损失并更新我们的网络参数

总结一下,我们将观察值转换为高斯分布,并假设这些观察值由低秩正态高斯分布参数化。因此,我们有:

其中 f1(z1) 是第一个时间序列的转换后的预测,f2(z2) 指的是第二个时间序列,而 f_n(z_n) 指的是我们数据集中的第 N 个时间序列。

最后,我们通过最大化多元 高斯对数似然函数来训练我们的模型。论文采用了最小化损失函数即前面带有负号的高斯对数似然。

使用高斯对数似然损失,Deep GPVAR 更新其 LSTM 和图 3中显示的共享密集层权重。

另外,请注意:

  • **z** 不是单个观察值,而是所有 N 个时间序列在时间 t 的观察向量。求和会循环到 T,即最大查找窗口——在此之后计算高斯对数似然。

  • 由于 z 是观察向量,高斯对数似然实际上是多元的。相比之下,DeepAR 使用的是单变量高斯似然。

Deep GPVAR: 大致概况

我建议多次阅读文章,以掌握模型的工作原理。

一般来说,你可以将 Deep GPVAR 视为一个高斯随机过程。

对于那些不熟悉随机过程的人,你可以将随机过程视为一组随机变量——每个变量代表某一时刻随机事件的结果。

随机变量由某个集合(通常是时间)索引,这些随机变量的值彼此依赖。因此,一个事件的结果可以影响未来事件的结果。这种相互依赖性也解释了随机过程的时间性。

在我们的案例中,时间序列y_i的每个数据点可以视为一个随机变量。因此,Deep GPVAR可以被视为一个高斯过程GP,在每个数据点y上评估如下:

现在,高斯过程的参数是函数,而不是变量。在上述方程中,**μ****d****v** (带有波浪线) 是时间函数,由**y**索引——其中**y**是在某个时间步的向量观察值。

在每个时间步,函数**μ****d****v** (带有波浪线) 本质上是 LSTM 和 Dense 层,有一个实现 **μ****d****v** (不带波浪线)。这个实现参数化了时间步*t*下 copula 函数的*μ**Σ*。因此,在训练过程中,在每个时间步,*μ**Σ*会变化,以最佳地解释我们的观察结果。

因此,Deep GPVAR作为高斯随机过程的概念是完全合理的。

Deep GPVAR 变体

根据当前论文,亚马逊创建了 2 个模型,Deep GPVAR(我们在本文中描述)和DeepVAR

DeepVAR 类似于Deep GPVAR。不同之处在于 DeepVAR 使用一个全球多变量 LSTM,一次接收和预测所有时间序列。另一方面,Deep GPVAR在每个时间序列上分别展开 LSTM。

在他们的实验中,作者将 DeepVAR 称为Vec-LSTM,而Deep GPVAR称为GP*。

  • Vec-LSTMGP项在原始论文的表 1中提到。

  • Deep GPVARDeepVAR术语在亚马逊的预测库Gloun TS中提到。

本文描述了Deep GPVAR变体,该变体在平均情况下表现更好,并且比DeepVAR具有更少的参数。可以阅读原始论文以了解更多实验过程。

项目教程 — 需求能源预测

本教程使用了来自 UCI 的ElectricityLoadDiagrams20112014 [4]数据集。该示例的笔记本可以从这里下载:

注意: 目前,PyTorch Forecasting 库提供了 DeepVAR 版本。这就是我们在本教程中展示的变体。你还可以尝试所有 DeepAR 变体,包括亚马逊开源的Gluon TS 库中的 GPVAR。

下载数据

!wget https://archive.ics.uci.edu/ml/machine-learning-databases/00321/LD2011_2014.txt.zip
!unzip LD2011_2014.txt.zip

数据预处理

data = pd.read_csv('LD2011_2014.txt', index_col=0, sep=';', decimal=',')
data.index = pd.to_datetime(data.index)
data.sort_index(inplace=True)
data.head(5)

每一列代表一个消费者。每个单元格中的值是每 15 分钟的电力使用量。

此外,我们将数据汇总为每小时数据。由于模型的大小和复杂性,我们仅使用 5 个消费者进行模型训练(仅考虑非零值)。

data = data.resample('1h').mean().replace(0., np.nan)
earliest_time = data.index.min()
df=data[['MT_002', 'MT_004', 'MT_005', 'MT_006', 'MT_008' ]]

接下来,我们将数据集转换为 PyTorch Forecasting 理解的特殊格式——称为TimeSeriesDataset。这种格式非常方便,因为它允许我们指定特征的性质(例如,它们是时间变化的还是静态的)。

注意: 如果你想了解更多关于TimeSeriesDataset格式的信息,请查看我的Temporal Fusion Transformer 文章,该文章详细解释了该格式的工作原理。

为了将数据集修改为 TimeSeriesDataset 格式,我们将所有时间序列垂直堆叠,而不是水平堆叠。换句话说,我们将数据框从“宽”格式转换为“长”格式。

此外,我们从日期列创建新的特征,如daymonth—这些特征将帮助我们的模型更好地捕捉季节性动态。

df_list = []

for label in df:

    ts = df[label]

    start_date = min(ts.fillna(method='ffill').dropna().index)
    end_date = max(ts.fillna(method='bfill').dropna().index)

    active_range = (ts.index >= start_date) & (ts.index <= end_date)
    ts = ts[active_range].fillna(0.)

    tmp = pd.DataFrame({'power_usage': ts})
    date = tmp.index

    tmp['hours_from_start'] = (date - earliest_time).seconds / 60 / 60 + (date - earliest_time).days * 24
    tmp['hours_from_start'] = tmp['hours_from_start'].astype('int')

    tmp['days_from_start'] = (date - earliest_time).days
    tmp['date'] = date
    tmp['consumer_id'] = label
    tmp['hour'] = date.hour
    tmp['day'] = date.day
    tmp['day_of_week'] = date.dayofweek
    tmp['month'] = date.month

    #stack all time series vertically
    df_list.append(tmp)

time_df = pd.concat(df_list).reset_index(drop=True)

# match results in the original paper
time_df = time_df[(time_df['days_from_start'] >= 1096)
                & (time_df['days_from_start'] < 1346)].copy()

最终预处理的数据框称为time_df。让我们打印其内容:

time_df现在已转换为TimeSeriesDataset的正确格式。此外,TimeSeriesDataset格式要求我们的数据具有时间索引。在这种情况下,我们使用hours_from_start变量。此外,power_usage是我们模型将尝试预测的目标变量

创建数据加载器

在这一步,我们将time_df转换为TimeSeriesDataSet格式。我们这样做的原因是:

  • 这使我们免去了编写自己的数据加载器的麻烦。

  • 我们可以指定模型如何处理特征。

  • 我们可以轻松地对数据集进行归一化。在我们的例子中,归一化是强制性的,因为所有时间序列在幅度上有所不同。因此,我们使用GroupNormalizer来单独归一化每个时间序列。

我们的模型使用一周(7*24)的回溯窗口来预测接下来的 24 小时的功率使用。

另外,请注意,hours_from_start既是时间索引,又是时间变化特征。为了演示,我们的验证集是最后一天:

max_prediction_length = 24
max_encoder_length = 7*24
training_cutoff = time_df["hours_from_start"].max() - max_prediction_length

training = TimeSeriesDataSet(
    time_df[lambda x: x.hours_from_start <= training_cutoff],
    time_idx="hours_from_start",
    target="power_usage",
    group_ids=["consumer_id"],
    max_encoder_length=max_encoder_length,
    max_prediction_length=max_prediction_length,
    static_categoricals=["consumer_id"],
    time_varying_known_reals=["hours_from_start","day","day_of_week", "month", 'hour'],
    time_varying_unknown_reals=['power_usage'],
        target_normalizer=GroupNormalizer(
        groups=["consumer_id"]
    ),  # we normalize by group
    add_relative_time_idx=True,
    add_target_scales=True,
    add_encoder_length=True,
)

validation = TimeSeriesDataSet.from_dataset(training, time_df, predict=True, stop_randomization=True, min_prediction_idx=training_cutoff + 1)

# create dataloaders for  our model
batch_size = 64 
# if you have a strong GPU, feel free to increase the number of workers  
train_dataloader = training.to_dataloader(train=True, batch_size=batch_size, num_workers=2, batch_sampler="synchronized")
val_dataloader = validation.to_dataloader(train=False, batch_size=batch_size * 10, num_workers=2, batch_sampler="synchronized")

基线模型

同样,记得创建一个基线模型。

我们创建了一个朴素的基线模型,用于预测前一天的功率使用曲线:

actuals = torch.cat([y for x, (y, weight) in iter(val_dataloader)])
baseline_predictions = Baseline().predict(val_dataloader)
(actuals - baseline_predictions).abs().mean().item()

# ➢25.139617919921875

构建和训练我们的模型

我们现在可以开始训练我们的模型了。我们将使用来自 PyTorch Lightning 库的Trainer接口。

首先,我们需要配置超参数和回调函数:

  • 使用EarlyStopping回调函数来监控验证损失。

  • Tensorboard用于记录我们的训练和验证指标。

  • hidden_sizernn_layers分别指代 LSTN 单元的数量和 LSTM 层的数量。

  • 我们还使用gradient_clip_val(梯度裁剪)来防止过拟合。此外,模型的默认 dropout 为0.1——我们保留默认值。

我们的损失函数是**MultivariateNormalDistributionLoss**(rank : int)。该函数将rank参数作为一个参数——这是我们在开始时解释的R值。

记住,值越低,训练期间的加速效果越大。作者在他们的实验中使用rank=10——我们在这里也做了相同的处理:

pl.seed_everything(42)

early_stop_callback = EarlyStopping(monitor="val_loss", min_delta=1e-4, patience=4, verbose=True, mode="min")
lr_logger = LearningRateMonitor(logging_interval='step', log_momentum=True)  # log the learning rate
logger = TensorBoardLogger("lightning_logs")  # logging results to a tensorboard

trainer = pl.Trainer(
    max_epochs=50,
    accelerator='auto', 
    devices=1,
    enable_model_summary=True,
    gradient_clip_val=0.1,
    callbacks=[lr_logger, early_stop_callback],
    logger=logger,
)

net = DeepAR.from_dataset(
    training, 
    learning_rate=0.001, 
    hidden_size=40, 
    rnn_layers=3, 
    loss=MultivariateNormalDistributionLoss(rank=10)
)

trainer.fit(
    net,
    train_dataloaders=train_dataloader,
    val_dataloaders=val_dataloader
)

在 6 个周期后,EarlyStopping 触发,训练结束。

注意: 如果你想运行 DeepAR 而不是 DeepVAR,请使用NormalDistributionLoss

加载和保存最佳模型

best_model_path = trainer.checkpoint_callback.best_model_path
print(best_model_path)
best_deepvar = DeepAR.load_from_checkpoint(best_model_path)

#path:
#lightning_logs/lightning_logs/version_0/checkpoints/epoch=6-step=40495.ckpt

不要忘记下载你的模型:

#zip and download the model
!zip  -r model.zip lightning_logs/lightning_logs/version_0/*

这就是如何重新加载模型——你只需要记住最佳模型路径:

!unzip model.zip
best_model_path='lightning_logs/lightning_logs/version_0/checkpoints/epoch=6-step=40495.ckpt'
best_deepvar = DeepAR.load_from_checkpoint(best_model_path)

Tensorboard 日志

使用 Tensorboard 仔细查看训练和验证曲线。你可以通过执行以下命令启动它:

# Start tensorboard
%load_ext tensorboard
%tensorboard --logdir lightning_logs

模型评估

获取验证集上的预测并计算平均P50(分位数中位数)损失

actuals = torch.cat([y[0] for x, y in iter(val_dataloader)])
predictions = best_deepvar.predict(val_dataloader)

#average p50 loss overall
print((actuals - predictions).abs().mean().item())
#average p50 loss per time series
print((actuals - predictions).abs().mean(axis=1))

# 6.78986
# tensor([ 1.1948,  6.9811,  2.7990,  8.3856, 14.5888])

注意,我们获得的分数比TFT 实现略差。我们使用的损失函数是概率性的——因此,每次得到的分数会略有不同。

最后两个时间序列的损失略高,因为它们的相对幅度较大。

绘制验证数据上的预测

raw_predictions, x = best_deepvar.predict(val_dataloader, mode="raw", return_x=True)

for idx in range(5):  # plot all 5 consumers
    fig, ax = plt.subplots(figsize=(10, 4))
    best_deepvar.plot_prediction(x, raw_predictions, idx=idx, add_loss_to_title=True,ax=ax);

图 5: MT_002 的验证数据预测

图 6: MT_004 的验证数据预测

图 7: MT_005 的验证数据预测

图 8: MT_006 的验证数据预测

图 9: MT_008 的验证数据预测

验证集上的预测相当令人印象深刻。

同时,请注意我们没有进行任何超参数调整,这意味着我们可以获得更好的结果。

绘制特定时间序列的预测

之前,我们使用idx参数绘制了验证数据上的预测,该参数遍历了数据集中的所有时间序列。我们可以更具体地输出特定时间序列的预测:

fig, ax = plt.subplots(figsize=(10, 5))

raw_prediction, x = best_deepvar.predict(
    training.filter(lambda x: (x.consumer_id == "MT_004") & (x.time_idx_first_prediction == 26512)),
    mode="raw",
    return_x=True,
)
best_deepvar.plot_prediction(x, raw_prediction, idx=0, ax=ax);

图 10: MT_004 在训练集上的第二天预测

图 10中,我们绘制了时间索引=26512 的MT_004消费者的第二天预测。

记住,我们的时间索引列hours_from_start从 26304 开始,我们可以从 26388 开始获取预测(因为我们之前设置了min_encoder_length=max_encoder_length // 2,即26304 + 168//2=26388)。

绘制协方差矩阵

Deep (GP)VAR最大的优势在于协方差矩阵的估计——这提供了对数据集中时间序列相互依赖关系的一些洞察。

在我们的案例中,进行此计算没有意义,因为我们的数据集中只有 5 个时间序列。然而,以下是展示如何在有多个时间序列的数据集上进行计算的代码:

cov_matrix = best_deepvar.loss.map_x_to_distribution(
    best_deepvar.predict(val_dataloader, mode=("raw", "prediction"), n_samples=None)
).base_dist.covariance_matrix.mean(0)

# normalize the covariance matrix diagnoal to 1.0
correlation_matrix = cov_matrix / torch.sqrt(torch.diag(cov_matrix)[None] * torch.diag(cov_matrix)[None].T)

fig, ax = plt.subplots(1, 1, figsize=(10, 10))
ax.imshow(correlation_matrix, cmap="bwr");

最终,PyTorch Forecasting 库提供了额外的功能,如样本外预测和 Optuna 的超参数调优。你可以在 TFT 教程中深入了解这些内容。

结束语

Deep GPVAR及其变体是一种强大的时间序列预测家族,具备了亚马逊研究的专业知识。

该模型通过考虑数千个时间序列之间的相互依赖性来处理多变量预测。

此外,如果你想了解更多关于DeepAR初始架构的信息,欢迎查阅这篇相关文章。

感谢阅读!

## AutoGluon-TimeSeries : 创建强大的集成预测 - 完整教程

亚马逊的时间序列预测框架应有尽有。

aihorizonforecast.substack.com

参考文献

[1] 使用 Stable Diffusion 创建,CreativeML Open RAIL-M 许可证。文本提示:“在太空中旅行的星云,数字艺术,插图”,到 rg

[2] D. Salinas 等人,DeepAR: Probabilistic forecasting with autoregressive recurrent networks,《国际预测期刊》(2019)

[3] D. Salinas 等人,高维多变量预测与低秩高斯 Copula 过程

[4] ElectricityLoadDiagrams20112014 数据集,由 UCI 提供,CC BY 4.0。

所有图片均由作者创建,除非另有说明

深度学习用于预测:数据预处理和训练

原文:towardsdatascience.com/deep-learning-for-forecasting-preprocessing-and-training-49d2198fc0e2

如何使用多个时间序列训练深度神经网络

Vitor CerqueiraTowards Data Science Vitor Cerqueira

·发表于Towards Data Science ·8 分钟阅读·2023 年 3 月 22 日

--

图片由Tamara Malaniy拍摄,来源于Unsplash

这篇文章是对上一篇文章的后续。在那里,我们学习了如何为深度学习转换时间序列。

我们继续探索深度神经网络在预测中的应用。在这篇文章中,我们将:

  • 学习如何使用深度学习训练全球预测模型,包括基本的预处理步骤;

  • 探索 keras 回调函数以推动神经网络的训练过程。

深度学习用于预测

深度神经网络通过自回归解决预测问题。自回归是一种建模技术,涉及使用过去的观察值来预测未来的观察值

深度神经网络可以设计成不同的结构,如递归网络或卷积网络。递归神经网络通常更适合处理时间序列数据。除此之外,这种网络在建模长期依赖关系方面表现出色。这一特性对预测性能有着强大的影响。

这里介绍了一种特定类型的递归神经网络,称为 LSTM(长短期记忆)。注释提供了每个模型元素的简要描述。

from keras.models import Sequential
from keras.layers import (Dense,
                          LSTM,
                          TimeDistributed,
                          RepeatVector,
                          Dropout)

# Number of variables in the time series. 
# 1 means the time series is univariate
N_FEATURES = 1
# Number of lags in the auto-regressive model
N_LAGS = 24
# Number of future steps to be predicted
HORIZON = 12

# 'Sequential' instance is used to create a linear stack of layers 
# ... each layer feeds into the next one.
model = Sequential()
# Adding an LSTM layer with 32 units and relu activation
model.add(LSTM(32, activation='relu', input_shape=(N_LAGS, N_FEATURES)))
# Using dropout to avoid overfitting 
model.add(Dropout(.2))
# Repeating the input vector HORIZON times to match the shape of the output.
model.add(RepeatVector(HORIZON))
# Another LSTM layer, this time with 16 units
# Also returning the output of each time step (return_sequences=True)
model.add(LSTM(16, activation='relu', return_sequences=True))
# Using dropout again with 0.2 dropout rate
model.add(Dropout(.2))
# Adding a standard fully connected neural network layer
# And distributing the layer to each time step
model.add(TimeDistributed(Dense(N_FEATURES)))

# Compiling the model using ADAM and setting the objective to minimize MSE
model.compile(optimizer='adam', loss='mse')

之前,我们学习了如何转换时间序列以训练此模型。但是,有时你会有多个时间序列可用。

如何处理这些情况?

使用多时间序列进行深度学习

全球方法的兴起

预测模型通常是使用时间序列的历史数据创建的。这些模型可以被称为局部模型。相比之下,全球方法则通过汇总多个时间序列的历史数据来建立模型。

当一种称为 ES-RNN 的方法赢得 M4 竞赛——一个包含 100000 个不同时间序列的预测竞赛——时,对全球模型的兴趣激增。

何时以及为何使用全球模型

全球模型在涉及多个时间序列的预测问题中可以提供相当大的价值。例如,在零售领域,目标是预测多种产品的销售量。

另一个使用这种方法的动机是获得更多的数据。机器学习算法在训练集更大时表现更好。这一点在具有大量参数的方法中尤为明显,如深度神经网络。这些已知为数据需求大

全球预测模型不假设基础时间序列是相关的。也就是说,一个序列的滞后值可以用来预测另一个序列的未来值。

相反,这些技术利用来自多个时间序列的信息来估计模型的参数。在预测时间序列的未来时,模型的主要输入是该序列的过去近期滞后值。

实践操作

在本文的其余部分,我们将探讨如何使用多个时间序列训练深度神经网络。

数据

我们将使用关于美国 8 个地区电力消耗的数据集:

美国 8 个地区的日常电力消耗(对数)。数据来源于参考文献[1]。图像由作者提供。

目标是预测未来几天的电力消耗。这一问题对电力系统操作员至关重要。准确的预测有助于平衡能源的供需。

我们可以按如下方式读取数据:

import pandas as pd

# https://github.com/vcerqueira/blog/tree/main/data
data = pd.read_csv('data/daily_energy_demand.csv', 
                   parse_dates=['Datetime'], 
                   index_col='Datetime')

print(data.head())

预处理步骤

在训练多个时间序列的深度神经网络时,需要应用一些预处理步骤。在这里,我们将探讨以下两种方法:

  • 均值缩放

  • 对数变换

可用的时间序列集可能具有不同的尺度。因此,将每个序列归一化到一个共同的值范围是很重要的。对于全球预测模型,这通常是通过将每个观测值除以相应序列的均值来完成的。

from sklearn.model_selection import train_test_split

# leaving last 20% of observations for testing
train, test = train_test_split(data, test_size=0.2, shuffle=False)

# computing the average of each series in the training set
mean_by_series = train.mean()

# mean-scaling: dividing each series by its mean value
train_scaled = train / mean_by_series
test_scaled = test / mean_by_series

在均值缩放之后,对数变换也可以是有帮助的。

在上一篇文章中,我们探讨了如何通过对时间序列取对数来处理异方差性。对数变换还可以帮助避免神经网络的饱和区域。饱和发生在神经网络对不同输入变得不敏感时。这会阻碍学习过程,导致模型效果不佳。

import numpy as np

class LogTransformation:

    @staticmethod
    def transform(x):
        xt = np.sign(x) * np.log(np.abs(x) + 1)

        return xt

    @staticmethod
    def inverse_transform(xt):
        x = np.sign(xt) * (np.exp(np.abs(xt)) - 1)

        return x

# log transformation
train_scaled_log = LogTransformation.transform(train_scaled)
test_scaled_log = LogTransformation.transform(test_scaled)

自回归

在预处理每个时间序列之后,我们需要将它们从序列转换为观测数据集。对于单个时间序列,你可以查看之前的文章以了解此过程的详细信息。

对于多个时间序列,思路类似。我们为每个序列分别创建一组观测数据。然后,这些数据被连接成一个单一的数据集。

这是你可以做到的方法:

# src module here: https://github.com/vcerqueira/blog/tree/main/src
from src.tde import time_delay_embedding

N_FEATURES = 1 # time series is univariate
N_LAGS = 3 # number of lags
HORIZON = 2 # forecasting horizon

# transforming time series for supervised learning
train_by_series, test_by_series = {}, {}
# iterating over each time series
for col in data:
    train_series = train_scaled_log[col]
    test_series = test_scaled_log[col]

    train_series.name = 'Series'
    test_series.name = 'Series'

    # creating observations using a sliding window method
    train_df = time_delay_embedding(train_series, n_lags=N_LAGS, horizon=HORIZON)
    test_df = time_delay_embedding(test_series, n_lags=N_LAGS, horizon=HORIZON)

    train_by_series[col] = train_df
    test_by_series[col] = test_df

之后,你通过按行连接的方式将每个时间序列的数据组合在一起。

train_df = pd.concat(train_by_series, axis=0)

print(train_df)

最后,我们将目标变量与解释变量分开,如前所述

# defining target (Y) and explanatory variables (X)
predictor_variables = train_df.columns.str.contains('\(t\-|\(t\)')
target_variables = train_df.columns.str.contains('\(t\+')
X_tr = train_df.iloc[:, predictor_variables]
Y_tr = train_df.iloc[:, target_variables]

# transforming the data from matrix into a 3-d format for deep learning
X_tr_3d = from_matrix_to_3d(X_tr)
Y_tr_3d = from_matrix_to_3d(Y_tr)

# defining the neural network
model = Sequential()
model.add(LSTM(32, activation='relu', input_shape=(N_LAGS, N_FEATURES)))
model.add(Dropout(.2))
model.add(RepeatVector(HORIZON))
model.add(LSTM(16, activation='relu', return_sequences=True))
model.add(Dropout(.2))
model.add(TimeDistributed(Dense(N_FEATURES)))
model.compile(optimizer='adam', loss='mse')

# spliting training into a development and validation set
X_train, X_valid, Y_train, Y_valid = \
    train_test_split(X_tr_3d, Y_tr_3d, test_size=.2, shuffle=False)

# training the neural network
model.fit(X_train, Y_train, validation_data=(X_valid,Y_valid), epochs=100)

使用回调函数训练深度神经网络

图片由Jack B提供,来自Unsplash

深度神经网络是迭代方法。它们在训练数据集上循环多次,称为周期。

在上面的例子中,我们运行了 100 个周期。但不清楚应该运行多少个周期来训练一个网络。周期太少可能导致欠拟合;周期太多则可能导致过拟合。

处理这个问题的一种方法是监控每个周期后神经网络的性能。每当模型性能提高时,你会在继续训练过程之前保存它。然后,在训练结束后,你将得到保存的最佳模型。

在 keras 中,你可以使用回调函数来处理这个过程。回调函数是一个在训练过程中执行某些操作的函数。你可以查看keras 文档以获取完整的回调函数列表,或者学习如何编写你自己的回调函数!

用于在训练过程中保存模型的回调函数称为 ModelCheckPoint:

from keras.callbacks import ModelCheckpoint

model_checkpoint = ModelCheckpoint(
    filepath='best_model_weights.h5',
    save_weights_only=True,
    monitor='val_loss',
    mode='min',
    save_best_only=True)

model = Sequential()
model.add(LSTM(32, activation='relu', input_shape=(N_LAGS, N_FEATURES)))
model.add(Dropout(.2))
model.add(RepeatVector(HORIZON))
model.add(LSTM(16, activation='relu', return_sequences=True))
model.add(Dropout(.2))
model.add(TimeDistributed(Dense(N_FEATURES)))
model.compile(optimizer='adam', loss='mse')

history = model.fit(X_train, Y_train,
                    epochs=300,
                    validation_data=(X_valid,Y_valid),
                    callbacks=[model_checkpoint])

另一个有趣的回调函数是EarlyStopping。它可以在性能停止改善时停止训练。

做出预测

训练后,我们可以检索最佳模型并对测试集进行预测。

# The best model weights are loaded into the model.
model.load_weights('best_model_weights.h5')

# Inference on DAYTON region
test_dayton = test_by_series['DAYTON']

# spliting target variables from explanatory ones
X_ts = test_df.iloc[:, predictor_variables]
Y_ts = test_df.iloc[:, target_variables]
X_ts_3d = from_matrix_to_3d(X_ts)

# predicting on normalized data
preds = model.predict_on_batch(X_ts_3d)
preds_df = from_3d_to_matrix(preds, Y_ts.columns)

# reverting log transformation
preds_df = LogTransformation.inverse_transform(preds_df)
# reverting mean scaling
preds_df *= mean_by_series['DAYTON']

关键要点

  • 许多预测问题涉及多个时间序列,例如零售领域。在这种情况下,全球方法通常更适合构建模型;

  • 在训练神经网络之前,预处理时间序列很重要。均值缩放有助于将所有序列带入一个共同的值范围。取对数可以稳定方差,避免饱和区域;

  • 使用回调函数来驱动神经网络的训练过程。

感谢阅读,下次故事见!

参考文献

[1] 每小时能源消耗时间序列(许可证: CC0: 公共领域

[2] Hewamalage, Hansika, Christoph Bergmeir 和 Kasun Bandara. “用于时间序列预测的递归神经网络:现状与未来方向。” 国际预测学杂志 37.1 (2021):388–427。

[3] Slawek Smyl, Jai Ranganathan 和 Andrea Pasqua. M4 预测竞赛:介绍一种新的混合 ES-RNN 模型,2018. URL eng.uber.com/ m4-forecasting-competition/。

推荐系统中的深度学习:入门指南

原文:towardsdatascience.com/deep-learning-in-recommender-systems-a-primer-96e4b07b54ca

现代工业推荐系统背后最重要的技术突破概述

Samuel FlenderTowards Data Science Samuel Flender

·发表于 Towards Data Science ·阅读时间 9 分钟·2023 年 6 月 26 日

--

图片来源:Pixabay

推荐系统是目前发展最快的工业机器学习应用之一。从商业角度来看,这并不奇怪:更好的推荐带来更多的用户。就是这么简单。

然而,基础技术远非简单。自从深度学习的兴起——由 GPU 的商品化驱动——推荐系统变得越来越复杂。

在这篇文章中,我们将回顾过去十年中一些最重要的建模突破,粗略重建标志着深度学习在推荐系统中崛起的关键点。这是一个关于技术突破、科学探索,以及跨越大陆和合作的军备竞赛的故事。

准备好吧。我们的旅程从 2017 年的新加坡开始。

NCF(新加坡大学,2017 年)

图片来源:He 等(2017 年)

任何关于推荐系统中深度学习的讨论,如果不提到该领域最重要的突破之一——神经协作过滤(NCF),都将是不完整的。这个突破由He 等(2017 年)在新加坡大学提出。

在 NCF 之前,推荐系统的黄金标准是矩阵分解,其中我们为用户和项目学习潜在向量(也称为嵌入),然后通过计算用户向量和项目向量之间的点积来生成用户的推荐。正如我们在线性代数中所知,点积越接近 1,预测的匹配度就越高。因此,矩阵分解可以简单地被视为潜在因素的线性模型。

NCF 中的关键理念是用神经网络替代矩阵分解中的内积。在实践中,这通过首先将用户和项目的嵌入连接起来,然后将它们传递到一个具有单一任务头的多层感知器(MLP)中,任务头预测用户参与度,如点击量。然后,通过反向传播损失梯度,在模型训练期间学习 MLP 权重和嵌入权重(将 ID 映射到相应的嵌入)。

NCF 背后的假设是用户/项目的互动并非线性,如矩阵分解中假设的那样,而是非线性的。如果这一点成立,我们应该会看到随着 MLP 层数的增加,性能会有所提升。正如 He 等人发现的那样,使用 4 层,他们能够在 Movielens 和 Pinterest 基准数据集上比当时最好的矩阵分解算法高出约 5% 的命中率。

He 等人证明了深度学习在推荐系统中的巨大价值,标志着从矩阵分解到深度推荐系统的关键过渡。

Wide & Deep (Google, 2016)

图片来源:Cheng et al (2016)

我们的行程从新加坡继续到加州的山景城。

虽然 NCF 革新了推荐系统领域,但它缺乏一个对推荐系统成功至关重要的成分:交叉特征。交叉特征的理念在 Google 的 2016 年论文 “Wide & Deep Learning for Recommender Systems” 中得到了普及。

什么是交叉特征?它是通过“交叉”两个原始特征创建的二阶特征。例如,在 Google Play Store 中,一阶特征包括所需的应用或用户安装的应用列表。这两个特征可以结合起来创建强大的交叉特征,例如

AND(user_installed_app='netflix', impression_app='hulu')

如果用户安装了 Netflix 且所需的应用是 Hulu,则值为 1。

交叉特征也可以更为广泛,例如

AND(user_installed_category='video', impression_category='music')

诸如此类。作者认为,添加不同粒度的交叉特征能够实现记忆(来自更细粒度的交叉)和泛化(来自较少粒度的交叉)。

Wide&Deep 的关键架构选择是同时拥有一个宽模块,这是一个直接将所有交叉特征作为输入的线性层,以及一个深度模块,本质上是一个 NCF,然后将两个模块合并为一个单一的输出任务头,从用户/应用参与度中学习。

确实,Wide&Deep 的效果非常好:作者发现通过从仅深度模型转向宽度加深度,在线应用获取的提升达到 1%。考虑到谷歌每年从 Play Store 中获得数十亿的收入,很容易看出 Wide&Deep 的影响力。

DCN(谷歌,2017)

图片来源:Wang et al (2017)

Wide&Deep 证明了交叉特征的重要性,但它有一个巨大的缺点:交叉特征需要手动工程,这是一项繁琐的过程,需要工程资源、基础设施和领域专长。Wide&Deep 式的交叉特征成本高昂,不具备扩展性。

进入“深度和交叉神经网络”(DCN),这是 2017 年谷歌的一篇论文中介绍的。DCN 的核心思想是用“交叉神经网络”替代 Wide&Deep 中的宽度组件,这是一种专门学习任意高阶交叉特征的神经网络。

交叉神经网络与标准 MLP 有何不同?作为提醒,在 MLP 中,下一层的每个神经元都是前一层所有神经元的线性组合:

相比之下,在交叉神经网络中,下一层是通过形成第一层与自身的二阶组合来构建的:

因此,深度为 L 的交叉神经网络将以最高 L 次的多项式形式学习交叉特征。神经网络越深,学习的高阶交互作用也越高。

实验确实确认了 DCN 的有效性。与仅有深度组件的模型相比,DCN 在 Criteo 展示广告基准数据集上的 logloss 低了 0.1%(这被认为是统计显著的)。而且这是没有任何手动特征工程的,如同 Wide&Deep!

(如果能看到 DCN 与 Wide&Deep 之间的比较就好了。可惜的是,DCN 的作者没有找到合适的方法来手动创建 Criteo 数据集的交叉特征,因此跳过了这个比较。)

DeepFM(华为,2017)

图片来源:Guo et al (2017)

接下来,我们的旅程将从 2017 年的谷歌转向 2017 年的华为。

华为的深度推荐解决方案“DeepFM”同样用一个专门学习交叉特征的神经网络替代了 Wide&Deep 中的手动特征工程。然而,与 DCN 不同,宽度组件不是交叉神经网络,而是所谓的 FM(“矩阵分解机”)层。

FM 层的作用是什么?它只是计算所有嵌入对的点积。例如,如果电影推荐系统使用 4 个 id 特征作为输入,比如用户 id、电影 id、演员 id 和导演 id,那么模型将学习所有这些 id 特征的嵌入,FM 层计算 6 个点积,分别对应用户-电影、用户-演员、用户-导演、电影-演员、电影-导演和演员-导演。这是矩阵分解思想的回归。然后,FM 层的输出与深度组件的输出结合,通过一个 sigmoid 激活的输出,产生模型的预测结果。

的确,正如你可能猜到的,DeepFM 已经被证明有效。作者展示了 DeepFM 在公司内部数据上比一系列竞争者(包括 Google 的 Wide&Deep)在 AUC 和 Logloss 上分别高出 0.37%和 0.42%。

DLRM(Meta,2019)

图片来源:Naumov et al (2019)

让我们暂时搁置 Google 和华为。我们的下一站是 2019 年的 Meta。

Meta 的 DLRM(“用于推荐系统的深度学习”)架构,如Naumov et al (2019)所述,工作原理如下:所有类别特征通过嵌入表转换为嵌入。所有稠密特征也被传递到一个 MLP 中,该 MLP 为这些特征计算嵌入。重要的是,所有嵌入具有相同的维度。然后,我们简单地计算所有嵌入对的点积,将它们连接成一个单一的向量,并通过一个最终的 MLP,该 MLP 有一个 sigmoid 激活的任务头,生成预测。

DLRM,几乎可以看作是 DeepFM 的简化版:如果你取下 DeepFM 的深度组件(仅保留 FM 组件),你会得到类似 DLRM 的东西,但没有 DLRM 的稠密 MLP。

在实验中,Naumov 等人展示了 DLRM 在 Criteo 展示广告基准数据集上的训练和验证准确率上都优于 DCN。这一结果表明,DCN 中的深度组件可能确实是多余的,而我们真正需要的只是特征交互,在 DLRM 中,这些交互通过点积来捕获。

DHEN(Meta,2022)

图片来源:Zhang et al (2022)

与 DCN 相比,DLRM 中的特征交互仅限于二阶:它们只是所有嵌入对的点积。回到电影的例子(特征包括用户、电影、演员、导演),二阶交互包括用户-电影、用户-演员、用户-导演、电影-演员、电影-导演以及演员-导演。三阶交互可能是用户-电影-导演、演员-演员-用户、导演-演员-用户,等等。某些用户可能是斯蒂文·斯皮尔伯格执导、汤姆·汉克斯主演电影的粉丝,应该有一个交叉特征!然而,在标准的 DLRM 中,没有。这是一个主要的限制。

进入DHEN,这是我们现代推荐系统巡演中的最后一篇地标论文。DHEN 代表“深度层次集成网络”,其关键思想是创建一个交叉特征的“层次结构”,随着 DHEN 层数的增加而变得更深。

最容易理解 DHEN 的方法是先用一个简单的例子。假设我们有两个输入特征进入 DHEN,我们用 A 和 B 来表示它们(例如可以是用户 ID 和视频 ID)。一个 2 层的 DHEN 模块会创建到二阶的整个交叉特征层次结构,即:

A, AxA, AxB, B, BxB,

其中“x”可以是以下 5 种交互中的一种或多种组合:

  • 点积,

  • 自注意力,

  • 卷积,

  • 线性:y = Wx,或者

  • 来自 DCN 的交叉模块。

DHEN 是一只猛兽,其计算复杂度(由于其递归特性)令人头疼。为了使其正常工作,DHEN 论文的作者不得不发明一种新的分布式训练范式,称为“混合分片数据并行”,其吞吐量比(当时的)最先进技术高出 1.2 倍。

但最重要的是,这只猛兽确实有效:在他们对内部点击率数据的实验中,作者使用了一堆 8 (!) DHEN 层,相较于 DLRM,NE 提升了 0.27%。

总结

Criteo 展示广告竞赛排行榜的演变。截图来自 paperswithcode.com

这就结束了我们的巡演。请允许我用一个标题总结这些地标:

  • NCF:我们只需要用户和项目的嵌入。MLP 会处理其余的。

  • Wide&Deep:交叉特征很重要。实际上,它们如此重要,我们将它们直接输入到任务头中。

  • DCN:交叉特征很重要,但不应手动工程化。让交叉神经网络来处理这些特征。

  • DeepFM:让我们在 FM 层生成交叉特征,同时保留 Wide&Deep 的深度组件。

  • DRLM:FM 就是我们需要的一切——还有一个专门的 MLP 用于密集特征。

  • DHEN:FM 还不够。我们需要一个更高阶的(超越二阶)的层次结构特征交互。还有一系列优化以确保它在实践中有效。

旅程其实才刚刚开始。在撰写本文时,DCN 已经演变成了 DCN-M,DeepFM 也演变成了 xDeepFM,而 Criteo 比赛的排行榜已经被华为最新的发明 FinalMLP 占据。

鉴于对更好推荐的巨大经济激励,未来可预见的时间里我们一定会继续看到这一领域的新突破。敬请关注。

深度强化学习改进了排序算法

原文:towardsdatascience.com/deep-reinforcement-learning-improved-sorting-algorithms-2f6a1969e3af?source=collection_archive---------13-----------------------#2023-06-13

谷歌 DeepMind 如何创建出更高效的排序算法

Jonathan BogerdTowards Data Science Jonathan Bogerd

·

关注 发表在 数据科学前沿 · 8 分钟阅读 · 2023 年 6 月 13 日

--

上周,Google DeepMind 在《自然》杂志上发表了一篇论文,声称通过使用深度强化学习(DLR)发现了一种更高效的排序算法。DeepMind 以推动强化学习(RL)的边界而闻名。几年前,他们利用类似程序击败了围棋游戏中的最佳选手 AlphaGo,之后又用相似的程序战胜了现有的国际象棋引擎。在 2022 年,DeepMind 推出了 AlphaTensor,这是一种利用 DLR 找到更高效矩阵乘法算法的程序。所使用的技术类似于 DeepMind 最新的成就:改进标准排序算法。在本文中,我将讨论他们如何通过引入强化学习、蒙特卡洛树搜索以及 DeepMind 的方法和代理 AlphaDev 的细节来实现这一点。

图片由 AltumCode 提供,来源于 Unsplash

排序

编写排序算法很简单:逐个比较所有值以找到最低(或最高)值,将此值保存在返回列表的第一个元素中,然后继续处理下一个值,直到没有更多值为止。虽然这个算法有效,但远非高效。由于排序在计算机程序中非常常见,因此高效的排序算法受到深入研究。DeepMind 专注于两种排序变体:固定排序和变量排序。固定排序涉及对具有固定预定义长度的值序列进行排序。在变量排序中,值列表的大小没有预先定义。只提供了列表的最大大小。这两种排序变体在最先进的排序算法中广泛使用。通常,通过反复排序列表的小部分来对大列表进行排序。

目前,最先进的算法在固定排序和变量排序中都使用了所谓的排序网络。在本文中,我不会讨论排序网络的详细信息。你可以在这里找到更多信息。

强化学习

在这一部分,我将介绍强化学习的主要概念。强化学习是机器学习的一个分支,其中一个智能体被任务赋予在环境中找到最佳行动,基于当前状态。环境的状态包含了所有可能影响所采取行动的相关方面。最佳行动定义为最大化(折扣)累积奖励的行动。行动是依次进行的,在每次行动后,获得的奖励和新状态都会被记录。通常,环境有一些终止标准,在这些标准之后,下一个回合开始。早期版本的 RL 使用表格来跟踪某些状态和行动的值,目的是总是采取具有最高值的行动。深度神经网络常常替代这些表格,因为它们能够更好地泛化,而列举所有状态通常是不可能的。当深度神经网络用于强化学习时,称之为深度强化学习。

蒙特卡洛树搜索

AlphaDev 使用深度强化学习和蒙特卡洛树搜索进行训练,这是一种在给定初始状态下寻找最佳行动的搜索算法。它通常与 DRL 结合使用,例如在 AlphaZero 和 AlphaGo 中。它值得拥有自己的一篇文章,但这里提供了一个摘要。

蒙特卡洛树搜索(MCTS)构建了一个可能结果状态的树,其中当前状态是根节点。对于给定数量的模拟,将探索这棵树以找出采取某些行动的后果。在每次模拟中,使用回放(有时称为游戏过程)来扩展一个节点为子节点,如果游戏在此时没有结束。采取哪种行动基于选择标准。在我们的案例中,选择某个行动的概率由策略网络提供,下面将讨论这一点。节点的值是衡量该节点状态好坏的指标。它由值网络确定,这也将在未来的部分中讨论。如果达到终止状态,则该值会被环境的真实奖励值所替代。

值在搜索树中向上传播。通过这种方式,当前状态的值依赖于从当前状态可以到达的状态的值。

DeepMind 的方法

由 DeepMind 训练的 DRL 代理,旨在改进排序算法实现,称为 AlphaDev。AlphaDev 的任务是编写一个更高效的汇编排序算法。汇编语言是高层语言(如 C++、Java 和 Python)与机器代码之间的桥梁。如果你在 C++ 中使用 sort,编译器会将你的程序编译成汇编代码。汇编器随后将这些代码转换为机器代码。AlphaDev 的任务是选择一系列汇编指令,以创建一个既正确又快速的排序算法。这是一个困难的任务,因为添加一条指令可能会使程序完全错误,即使之前是正确的。

AlphaDev 通过创建并解决 AssemblyGame 来寻找高效的排序算法。在每一轮中,它必须选择一个与 CPU 指令对应的动作。通过将这个问题表述为一个游戏,它可以轻松适应标准的强化学习框架。

强化学习公式

标准的 RL 公式包含状态、动作、奖励和终止标准。

状态

AssemblyGame 的状态由两部分组成。首先是当前程序的表示。AlphaDev 使用 Transformer 的输出,Transformer 是一种神经网络架构,用于表示当前算法。最近 Transformer 在大规模语言模型中取得了很大成功,并且由于算法是基于文本的,Transformer 也非常适合对当前算法进行编码。状态的第二部分是运行当前算法后内存和寄存器状态的表示。这通过传统的深度神经网络来完成。

动作

AssemblyGame 的动作是将合法的汇编指令附加到程序中。AlphaDev 可以选择添加任何合法的指令。DeepMind 为合法动作创建了以下六条规则:

  1. 内存位置总是按递增顺序读取

  2. 寄存器按递增顺序分配

  3. 每个内存位置读取和写入一次

  4. 不允许连续比较指令

  5. 不允许将比较或条件移动到内存中

  6. 未初始化的寄存器不能使用

从编程角度来看,最后两个动作是非法的,其他的则是被强制执行的,因为它们不会改变程序。通过删除这些动作,搜索空间被限制而不影响算法。

奖励

AssemblyGame 的奖励由两部分组成。奖励的第一个元素是正确性分数。根据一系列测试序列,提供的算法会被评估。算法越接近正确排序测试序列,正确性的奖励就越高。奖励的第二个元素是程序的速度或延迟。这通过程序的行数来衡量,如果没有条件分支的话。在实践中,这意味着程序的长度用于固定排序,因为该程序不需要条件。对于可变排序,算法必须根据序列的实际长度进行条件判断。由于排序时并非所有部分的算法都会被使用,因此测量的是程序的实际延迟,而不是行数。

终止和目标

根据 DeepMind 发布的论文,他们在“有限步骤”后终止 AssemblyGame。这具体意味着什么不清楚,但我猜他们是根据当前的人工基准限制步骤数量。游戏的目标是找到一个正确且快速的算法。如果 AlphaDev 提供了一个错误或慢速的算法,游戏就会失败。

策略和价值网络

使用蒙特卡罗树搜索(Monte Carlo Tree Search)时,需要一个策略网络和一个价值网络。策略网络设置每个动作的概率,而价值网络则训练用于评估状态。策略网络根据特定状态的访问次数进行更新。这创建了一个迭代过程,其中策略用于执行 MCTS,策略网络则根据 MCTS 中状态的访问次数进行更新。

价值网络输出两个值,一个是算法的正确性,一个是算法的延迟。根据 DeepMind 的说法,这比通过网络将这些值组合成一个分数的结果更好。价值网络根据获得的奖励进行更新。

结果

在训练 AlphaDev 后,它找到了一些长度为 3 和 5 的固定排序的更短算法。对于长度为 4 的固定排序,它找到了当前的实现,因此没有改进。在固定排序中,AlphaDev 通过应用两个新想法实现了这些结果。这些新想法被称为交换(swap)和复制移动(copy move),它们减少了值之间的比较次数,从而使算法更快。对于长度为 3 的固定排序,DeepMind 通过穷举所有较短程序长度的选项证明了没有更短的程序存在。对于较长的程序,这种方法不可行,因为搜索空间呈指数级增长。

对于可变排序,AlphaDev 提出了算法的新设计。例如,对于长度最多为 4 的可变排序序列,AlphaDev 建议先对 3 个值进行排序,然后对最后剩余的元素执行简化版本的排序。下图展示了 AlphaDev 提供的 Varsort(4)的算法设计。

图片由 D. Mankowitz 等人提供,《使用深度强化学习发现更快的排序算法》(2023),《自然》

  • 总体而言,为固定和可变排序发现了更快的算法,证明了深度强化学习可以用于实现高效的算法。C++中的排序实现已经更新为使用这些新算法。有关 AlphaDev 实现的性能提升的详细信息,请参见下表。

表格由 D. Mankowitz 等人提供,《使用深度强化学习发现更快的排序算法》(2023),《自然》

  • 为了测试这种方法是否可以推广到其他编程实现,DeepMind 还在一个竞争性编码挑战和 Google 使用的协议缓冲区上测试了 AlphaDev。在这两个案例中,AlphaDev 能够提出更高效的实现,证明了使用蒙特卡洛树搜索的深度强化学习是一种找到常见算法高效实现的有效方法。

结论

  • 深度强化学习在许多不同的环境中取得了成功,从围棋和国际象棋等游戏到算法实现(如本文所示)。尽管该领域具有挑战性,通常取决于低级实现细节和超参数选择,但这些成功展示了这一强大概念能够取得的成果。

  • 我希望这篇文章让你对强化学习的最新成就有了一些了解。如果你想要更详细的蒙特卡洛树搜索或我讨论的其他算法的解释,请告诉我!

来源

[1] K. Batcher,《排序网络及其应用》(1968),《1968 年 4 月 30 日至 5 月 2 日春季联合计算机会议论文集》(第 307–314 页)

[2] D. Mankowitz 等人,《使用深度强化学习发现更快的排序算法》(2023),《自然》618.7964: 257–263

[3] D. Silver 等人,《通过自我对弈掌握国际象棋和将棋的通用强化学习算法》(2017),arXiv 预印本 arXiv:1712.01815

[4] D. Silver 等人,《通过深度神经网络和树搜索掌握围棋》(2016),《自然》529.7587: 484–489

[5] A. Fawzi 等人,《通过强化学习发现更快的矩阵乘法算法》(2022),《自然》610.7930: 47–53

[6] C. Browne 等人,《蒙特卡洛树搜索方法概述》(2012),《IEEE 计算智能与游戏中的人工智能》4.1: 1–43。

对简单线性回归的深刻理解

原文:towardsdatascience.com/deep-understanding-of-simple-linear-regression-3776afe34473

从零开始的线性回归:详细解释

Md. ZubairTowards Data Science Md. Zubair

·发表于数据科学之路 ·阅读时间 6 分钟·2023 年 1 月 10 日

--

作者提供的图片

动机

机器学习是一个过程,通过它,机器可以从数据中学习,并在没有明确编程的情况下对新数据做出合理决策。这些模型的基础是数学和统计学。线性回归是其中一种简单且广泛使用的回归算法。回归算法预测连续值。

例如,我们想预测价格、年龄、体重等。这些值是不可计数的。因此,它们被称为连续值。如果你仍然感到困惑,我建议你阅读这篇文章

目录

  1. **机器学习中的回归问题是什么?**

  2. **我们何时使用简单线性回归?**

  3. **简单线性回归详解**

  4. **使用 Python 的实践实现**

机器学习中的回归问题是什么?

在数据科学中,机器学习算法用于自动化系统。在实践中,主要有两种问题——i. 监督学习和 ii. 无监督学习。

在监督学习问题中,训练数据集是有标签的。这意味着算法有一个目标值。监督学习算法尝试预测类似目标值的值,并相应地优化其参数。但在无监督学习问题中,训练数据集没有目标值。无监督学习算法尝试找出数据之间的相似性,并相应地训练模型。

监督问题可以进一步分为两类——i. 分类和 ii. 回归。 分类问题是那些需要预测分类值的问题。相反,回归问题处理的是连续值。

我们何时使用简单线性回归?

我想在简单线性回归之前介绍线性回归。线性回归是通过拟合回归线来找到回归输出的过程。它仅在我们的数据呈线性分布时有效。

简单或单变量线性回归是在只有一个自变量或特征时的回归过程。 还有多变量线性回归。我将在接下来的文章中讨论它。

何时使用——

当数据线性分布且仅包含一个特征或自变量时,简单线性回归最为合适。

详细的简单线性回归

简单线性回归只接受一个自变量。看一下直线的简单方程。

在线性回归中,方程被称为*回归线方程*。这里,m 和 c 是系数。m 表示回归线的斜率,c 是表示回归线与 y 轴交点的常数值。x 表示自变量,y 是因变量。 让我们用下面的图示更清楚地说明。

简单线性回归(作者图像)

简单线性回归处理一个自变量(x)和一个因变量(y)。自变量是输入值,因变量是输出值或目标值。

看一下图片,让我逐步解释这个过程。**x 轴**代表自变量的值,**y 轴**代表因变量的值。黑点是训练数据点的散点图,数据似乎呈线性分布。这些数据将用于训练回归模型的参数。为此,我们将通过散点图拟合一条直线,并使回归线的累计距离尽可能最小。如果我们找到最佳回归线,我们可以通过输入**x**值轻松获得预测值。对于一个新的数据点,绘制一条垂直直线将与回归线相交。预测值将是从回归线交点绘制的水平线在y轴上的交点。为了拟合最佳回归线,找出上述方程的**m****c**的系数值是我们的主要挑战。

再次说明,回归线方程是***y=mx+c*** 我们将从数据集中获取x的值,但mc是未知的。***m******c***的最佳值可以产生***y***的最佳预测值。

对于简单线性回归,我们可以用以下公式找到斜率**m**

**m**的值代入以下方程中,我们将找到**c**的最佳值。

[在这个过程中存在一个限制。如果自变量多于一个,我们不能通过这个手动公式找到系数值。在这种情况下,我们使用梯度下降和代价函数来找到最佳值。我将在即将到来的文章中讨论它。]

使用 Python 进行实践操作

导入所需的 Python 库。

对于任何机器学习模型,数据集是主要的燃料。*我们使用的是* [*波士顿房价*](https://www.cs.toronto.edu/~delve/data/boston/bostonDetail.html) *数据集,该数据集公开可用,并且在公有领域许可下。*这里下载数据集。

让我们加载数据集。

由于主数据集中没有列名,所以需要设置列名。

寻找皮尔逊相关性以选择相关性最高的特征作为自变量。

我们的目标值是‘MEDV’。从上述相关性图中,‘RM’变量的相关性最高,为0.7。因此,我们选择了变量‘RM’。该变量的全称如下 []。

MEDV - Median value of owner-occupied homes in $1000's.
RM   - Average number of rooms per dwelling.

定义 x 和 y。

拆分训练集和测试集。

定义计算***m 和 c***系数值的函数。

绘制回归线。

预测函数。

我们数据集的实际值与预测值。

我们的模型在某些值上存在显著误差。这是因为我们的数据集有许多特征,而我们仅考虑了一个特征进行演示,因为我们处理的是简单线性回归。

让我们计算模型的平均绝对误差。

结论

实际上,有许多库和简单的方法可以实现线性回归。但我更喜欢从最基础的知识学习,因为这对绝对初学者很有益。我相信如果我们的基础足够坚固,我们可以在基础上构建高楼,否则可能会出现结构不稳定的问题。

这篇文章将对初学者非常有帮助,并将提供一个坚实的基础。读者将了解到简单线性回归是如何工作的。

完整的笔记本可以在 这里获取。

*[我正在编写一系列关于从零实现的机器学习算法的文章。你可以通过下面的链接阅读之前关于 KNN 和 K-means 聚类的文章。]*

## 从零实现 KNN 算法

KNN 算法的实现和详细解释

[towardsdatascience.com ## 从零开始的 K-means 聚类

K-means:最佳的 ML 算法来聚类数据

towardsdatascience.com

参考文献

  1. 波士顿数据集 (toronto.edu)

定义通用人工智能

原文:towardsdatascience.com/defining-artificial-general-intelligence-a4b167aa84ba

你如何判断一个系统是否达到了 AGI(通用人工智能)?

Zachary RaicikTowards Data Science Zachary Raicik

·发布于 Towards Data Science ·阅读时间 7 分钟·2023 年 11 月 23 日

--

照片由 Possessed Photography 提供,Unsplash

上周,萨姆·奥特曼被解除 OpenAI 的首席执行官职务。他离开的真正原因仍然未知。根据董事会的说法,他被解雇是因为董事会“得出结论认为他在与董事会的沟通中并不始终坦诚,这妨碍了董事会履行其职责。”这一模糊的解释引发了大量关于奥特曼被解雇原因的猜测。一些最有说服力的理论包括:

  • 奥特曼将市场渗透优先于安全与隐私测试

  • 奥特曼在一项重大交易中绕过了董事会

  • 奥特曼的自我膨胀过大,开始与公司的使命发生冲突

有趣的是,一些最有说服力的传言指向了对 AI 伦理的观点分歧,特别是在通用人工智能(“AGI”)的发展方面。虽然奥特曼一直是 AGI 潜力的 vocal 支持者,但传言称首席科学家伊利亚·苏茨克弗对 OpenAI 内部技术的快速进展产生了日益增长的担忧。

在这篇文章中,我们将总结谷歌 DeepMind 的新论文 Levels of AGI: Operationalizing Progress on the Path to AGI 中的一些概念。这篇论文有助于定义 AGI,建立评估 AGI 系统的框架,并总结一些 AGI 可能带来的风险。

定义 AGI(通用人工智能)

在人工智能(“AI”)领域,AGI 是能够执行大多数人类水平任务的系统的一个子集。假设这些系统能够像人类一样广泛理解、学习和应用其智能。一个完整的 AGI 系统不应局限于其所训练的数据。相反,系统在存在的过程中可以收集信息并随时间学习。对于许多 AI 公司来说,AGI 是明确或隐含的长期目标。目前,AGI 尚未实现。然而,鉴于 LLM 和人工智能领域的快速进展,AGI 感觉比以往任何时候都更接近。

在论文中,DeepMind 的研究人员概述了 AGI 的定义特征。 “<DeepMind 研究人员>认为,AGI 的任何定义应满足以下六个标准:”

  1. “专注于能力,而非过程”:AGI 系统的关键不在于其如何完成任务,而在于系统能够做什么任务。AGI 系统不要求以类似人类的方式思考或理解,也不需要具有人类意识。这并不是说这些系统不会表现出这些特性——但它们不必符合 AGI 的定义。

  2. “专注于通用性和性能”:一些系统选择强调通用性——即处理各种任务和适应不同环境的能力。然而,通用性不应以能力为代价。这些系统需要能够以高水平的性能执行广泛的任务。

  3. “专注于认知和元认知任务”:研究人员认为,系统是否能够执行物理任务并不是判断其是否为 AGI 的标准。相反,这些系统应专注于完成认知和元认知任务。在这种情况下,认知任务包括感知、记忆、语言和问题解决。元认知任务包括在需要时学习新任务或获取更多信息的能力。

  4. “专注于潜力,而非部署”:任何符合 AGI 标准的系统并不需要在现实世界中部署才能被认为是 AGI。相反,系统需要证明其能够满足 AGI 的标准。根据作者的说法,“要求部署作为衡量 AGI 的条件会引入非技术性障碍,例如法律和社会考虑,以及潜在的伦理和安全问题。”

  5. “专注于生态有效性”:任何致力于实现 AGI 定义的系统应关注人们在现实世界中重视的任务。换句话说,AGI 不应专注于高度专业化或抽象的任务,比如解决极其复杂的理论问题。这类任务对大多数人日常生活中并不有价值。

  6. “关注通向 AGI 的路径,而不是单一的终点”:概述不同等级的 AGI 并附上明确的指标、基准和风险,使政策和进展的讨论变得更加容易。不同的等级可以使系统之间的比较更为简单,并量化进展。

这六个 AGI 标准确保研究人员和其他相关方对 AGI 有一致的理解。这些标准旨在消除与不同术语、能力或结果相关的混淆,并将讨论集中在重要的方面上。我并不是说这六个标准是正确的,但它们确实使得在考虑与 AGI 相关的系统时更容易思考。

AGI 的等级

在上面的部分中,第六个标准提到一个系统来概述不同等级的 AGI。下表总结了 DeepMind 定义的各种 AGI 等级。请注意,对于每个等级,表格审视了狭义和广义 AI 任务,其中:

  • 狭义任务是明确界定或定义的。

  • 一般任务是包括学习新技能的各种任务。

AGI 的等级,Google DeepMind arxiv.org/pdf/2311.02462.pdf

当我们谈论狭义的 AI 时,我们会看到许多特定等级的产品实例(例如,等级 0 的简单计算器)。当使用案例范围明确且具体时,构建完全自主的解决方案更为容易。然而,这些系统由于其专业化的性质,未能满足 DeepMind 对 AGI 的标准。例如,AlphaFold 是一个旨在预测 3D 蛋白质结构的系统。尽管其专业化能力令人印象深刻,但其狭窄的关注点意味着它缺乏对 AGI 至关重要的生态有效性。

当我们谈论像 ChatGPT 这样更为通用的系统时,我们会遇到一系列不同的挑战。最初,ChatGPT 处理各种问题的能力令人印象深刻。然而,随着使用工具的时间增加,我逐渐意识到,尽管 ChatGPT 在表面上看似超人,但在需要深入专业知识的领域,它常常有所欠缺。我们可以将这个观点与上面提到的 AGI 等级联系起来——它仅能在无技能人类(等级 1)的水平上回答问题。它的许多回答看起来似乎是事实正确的,但实际上却充满了不准确性。这一认识突显了今天的 AI 系统,尽管具备“首创”的能力,但在性能的深度上往往有所欠缺。它们优先考虑广泛的功能,但并不擅长所有这些功能,这与 DeepMind 对 AGI 的第二个标准相冲突。

AGI 的风险

在 2004 年电影机器人总动员中,机器人存在是为了服务于人类主人。这些机器人不被允许伤害人类,必须随时服从人类,并且必须不惜一切代价保护主人。随着电影情节的发展,这些机器人开始不再遵守这些规则。它们发展出了自由意志和情感。结果,这些机器人成为了对人类的存在性威胁。

尽管这种世界末日场景可能在 AGI 世界中成为风险,但还有更多实际的风险。下表总结了与窄域和通用 AI 相关的各种风险,涉及不同级别的 AI 自主性。

AGI 的风险,Google DeepMind arxiv.org/pdf/2311.02462.pdf

诚然,当我首次开始使用高级 AI 工具并研究 AGI 这一概念时,我对工具的潜在风险视而不见。我下意识地拒绝承认这些工具的发展和进步可能会带来任何负面副作用。我是说,谁不喜欢全天候访问 30 秒代码审查的服务呢?

然而,当我偶然看到这张表格时,它改变了我的观点。它让我看到了这些系统的相关风险,包括我们今天使用的工具。将 AI 作为顾问(“自主级别 2”)类似于许多人今天使用 ChatGPT 或依赖推荐系统来获取产品或电影推荐的方式。以这种方式使用工具可能导致长期的过度信任或有针对性的操控。事实上,最近我找不到人来审查我的代码,于是我让 ChatGPT 审查了我的代码。ChatGPT 的审查结果显示代码无误,于是我将代码推向了生产环境。结果,代码中充满了 bug,几乎立即被回滚。我对 ChatGPT 过度信任,付出了代价!

随着我们继续推动窄域和通用 AI 系统的发展,风险也显著增加。目前的系统可能导致过度信任和针对性操控,而更强大的系统则可能导致大量失业和权力集中。作为技术导向的一方,我对我们取得的进展感到非常惊讶,并对未来的发展充满了激动。然而,作为实践的一方,我认识到需要在构建先进 AI 系统时仔细考虑风险和后果。

定义可解释的特征

原文:towardsdatascience.com/defining-interpretable-features-ebd7ed94897?source=collection_archive---------4-----------------------#2023-01-14

照片由 Kevin Ku 提供,发布在 Unsplash

这是 MIT 研究人员总结的发现和开发的分类法。

Nakul UpadhyaTowards Data Science Nakul Upadhya

·

关注 发表在 数据科学前沿 ·9 分钟阅读·2023 年 1 月 14 日

--

在 2022 年 2 月,麻省理工学院数据到人工智能(DAI)小组的研究人员发布了一篇名为《可解释特征的必要性:动机和分类法》的论文[1]。在这篇文章中,我旨在总结这些作者的一些主要观点和贡献,并讨论他们工作的潜在影响和批评。如果你对这些内容感兴趣,我强烈推荐阅读原始论文。此外,如果你对可解释机器学习不太熟悉,我强烈推荐Christopher Molnar 的免费书籍 [2]。虽然可解释性/解释性的定义在不同的出版物中常常变化[1],但这本书提供了理解该领域的坚实基础。

论文的核心发现是即使是像线性回归这样高度可解释的模型,非可解释的特征也可能导致难以理解的解释(例如,特征 x12 的权重为 4 对大多数人来说毫无意义)。鉴于此,论文提供了利益相关者的分类、可解释特征的现实世界应用场景、各种特征质量的分类,以及可能的可解释特征转换,这些都帮助数据科学家开发易于理解的特征。

利益相关者的定义

论文的第一个贡献是扩展了 Preece 等人提出的可能受益于机器学习解释的主要用户类型,并定义了一些他们的兴趣。虽然 Preece 等人提出了 4 种主要的利益相关者类型,但本文的作者将该列表扩展到 5 种:

  • 开发者:那些训练、测试和部署机器学习模型的人,他们关注特征以提高模型性能。

  • 理论家:那些对推进机器学习理论感兴趣的人,他们关注特征,以了解其对模型内部机制的影响。

  • 伦理学家:那些对模型的公平性感兴趣的人,他们关注特征,以确保模型的伦理使用。

  • 决策者:那些获取模型结果以完成任务和决策的人。他们对特征本身不感兴趣,但需要解释以确保他们的决策基于可靠的信息。

  • 受影响的用户:这些是受模型及其使用影响的个人,但不会直接与模型互动,除非是为了理解对他们自身的影响。

各种用户在特征工程方面有不同的需求,这些需求往往相互冲突。决策者可能希望模型中的特征越简单越好,以便更好地解释,而开发者可能会选择复杂的转换,以使特征具有极高的预测能力。

现实世界应用场景

除了介绍利益相关者之外,作者还介绍了 5 个实际领域,在这些领域中,他们在试图解释自己开发的模型时遇到了障碍。

案例研究

儿童福利

在这项案例研究中,DAI 团队与社会工作者和科学家(担任决策者和伦理学家)合作,开发了一个解释性 LASSO 模型,该模型拥有超过 400 个特征,并输出潜在儿童虐待案件的风险评分。在此过程中,DAI 团队发现模型的大多数不信任来源于特征而非机器学习算法。一个突出的问题是关于一热编码分类特征(例如role of child is sibling == False)的措辞。此外,许多社会工作者和科学家对他们认为与预测任务无关的特征表示担忧,基于他们的主题领域专业知识。

教育

在在线教育领域,作者致力于为大规模开放在线课程(例如 Coursera、edX 等免费课程)相关的各种决策任务添加可解释性。在与各种课程开发者和讲师合作时,作者发现,最有用的特征是那些将数据组合成对用户有意义的抽象概念(例如将工作完成情况和互动结合成参与特征)的特征。此外,研究人员发现,当这些抽象概念的数据来源易于追溯时,利益相关者的反应更好。

网络安全

在第三个领域,研究人员致力于开发检测领域生成算法的模型,以帮助安全分析师应对潜在的攻击。虽然为识别这些攻击工程了许多特征,但构建这些特征的原始 DNS 日志对用户的帮助更大,作者面临的挑战是如何将特征值追溯到相关日志。

医疗记录

在医疗保健领域,研究人员与六位临床医生合作开发了一个预测手术后并发症的模型。在这项案例研究中,作者使用了 SHAP 值来解释特征贡献,但很快发现仅靠 SHAP 解释是不够的。延续网络安全领域的趋势,作者发现基于聚合函数的特征不如原始信号数据那样易于解释。

卫星监测

在这项案例研究中,作者旨在可视化时间序列异常检测解决方案的结果,并与六位领域专家一起开发了一个工具。作者随后进行了两项用户研究,分别使用领域专家和普通终端用户的股票价格数据来评估该工具。在这项工作中,作者发现对插补过程的透明度需求更高,大多数问题集中在哪些值是插补的而非真实的。

经验教训

从所有案例中总结了三个关键经验:

  1. 文献中大多数关注点在于选择和工程化特性以最大化模型性能,但与人类用户和决策者接口的模型需要一个可解释的特性空间才有用。

  2. 为了具有可解释性,特性需要具备多种属性(稍后将在分类法中讨论)。

  3. 虽然将特性转换为模型准备状态的重要性不言而喻,但也需要有方法来撤销这些转换以保持可解释性。

特性分类法

作者结合他们工作的领域和大量文献搜索,开发了一种特性质量的分类法,识别了用户。作者将这些特性组织为两个主要质量——模型准备性和可解释性——其中一些特性同时具备这两个质量。

模型准备属性使特性在模型中表现良好,是开发者理论家伦理学家关注的重点。

可解释属性是使特性对用户更易理解的属性。这些属性主要惠及决策者用户伦理学家

模型准备特性属性

  1. 预测性:特性与预测目标的相关性。这并不意味着直接的因果关系,因为特性可能是混淆变量或虚假相关性

  2. 模型兼容性:特性由模型架构支持,但可能不具备预测性或实用性。

  3. 模型准备性:特性与模型兼容,能够帮助生成准确预测。模型准备特性还包括通过标准化和归一化等方法转换过的特性。

可解释特性属性

  1. 可读性:特性以普通文本书写,用户无需查看代码即可理解所指内容。

  2. 人性化:该特性既可读又以自然、友好的方式描述。作者发现,儿童福利领域的利益相关者特别受益于这一特性。

  3. 可理解性:特性指代用户理解的现实世界指标。这个属性在很大程度上依赖于用户的专业知识,但通常是那些没有经过复杂数学操作的特性(例如,年龄是可理解的,但log(humidity)可能不是)。

模型准备性和可解释性属性兼具

  1. 有意义:特性是学科专家认为与目标变量相关的特性。一些特性可能具有预测性,但由于虚假相关性而不具意义。类似地,一些特性可能有意义,但预测性不强。然而,最好还是尽量使用有意义的特性。

  2. 抽象概念:特性通过某些领域专家定义的原始特性的组合计算而得,通常是通用概念(例如,参与和成就)。

  3. 可追踪:该特征可以与其计算原始数据准确关联。

  4. 可模拟:如果需要,可以从原始数据中准确重新计算该特征。所有可模拟的特征都是可追踪的,但并非所有可追踪的特征都是可模拟的。例如,test grade over time 可能是可追踪的(它来自原始测试成绩),但不能模拟,因为这可能指的是每月或每年的平均成绩或成绩变化。

可解释变换

除了可解释特征的各种属性,作者还提出了一些特征工程方法及其可能对特征可解释性的贡献。虽然有些数据变换可以帮助特征准备模型,但这并不常见。可解释性变换旨在帮助弥补这一差距,但往往会撤销模型准备变换。这可能会降低模型的预测能力,但会引入可解释的特征属性,使其更受决策者、用户和伦理学家的信任。

  • 转换为分类变量:在解释特征时,将独热编码变量转换回其分类形式。

  • 语义分箱:在对数值数据进行分箱时,尽量基于现实世界的区别而不是统计区别进行分箱。例如,将 agechildyoung-adultadultsenior 分类,比按四分位数进行分箱更具可解释性。

  • 标记插补:如果使用数据插补,添加一个额外的特征来识别包含插补数据的点,可以大大增加对模型的信任。

  • 汇总数值特征:当数据中存在许多紧密相关的指标时,将它们汇总为一个特征可能有利于防止数据过载。例如,作者发现将各种身体和情感虐待的推荐汇总为一个单一的推荐计量,有助于决策者。

  • 修改分类粒度:当许多类别彼此相关时,通过选择变量的适当总结(例如,将 森林覆盖类型 数据集中的土壤区划汇总为主要的 8 种地质土壤区)可以提高可解释性和性能。

  • 转换为抽象概念:应用数值汇总和分类粒度变换器,开发一个手工制作的公式,以生成主题专家可以理解的抽象概念。

  • 反向缩放和特征工程:如果应用了标准化、归一化或数学变换,在分析特征之前反转这些变换可以提高可解释性。例如,报告 age 的特征权重比报告 sqrt(age) 的权重更有帮助。

  • 原始数据链接:此转换扩展了反向缩放和特征工程。如果可能,请明确展示如何从原始数据计算工程特征。

虽然这不是所有可能转换的详尽列表,但它确实为数据科学家提供了一个很好的起点,介绍了一些简单的步骤,以确保他们拥有一个可解释的特征空间。

讨论与结论

图 1:Zytek 等人提出的特征分类总结 [1](图源自论文)

阅读这篇论文时,我确实有一些批评。首先,尽管作者开发了各种利益相关者,但他们从未提供过 受影响用户决策者 不同的示例。虽然我们可以做出一些有根据的猜测(例如,学生在教育案例中可能是受影响的用户,而患者在医疗案例中可能是受影响的用户),但并没有提出解释性特征如何帮助这个群体的理由。

作者们自己也提出了一些可解释特征的风险。在他们的例子中,开发者可能会恶意地将种族特征包含到社会经济因素的抽象概念中,从而有效地掩盖了种族在模型中的预测作用。此外,作者承认,许多提出的可解释性转换可能会降低模型性能。一些可解释特征属性(如可读性)在数据隐私重要时也不适用。

尽管有这些批评,但不可否认的是,Zytek 等人 [1] 提供了很多关于特征如何变得可解释、如何实现可解释性以及为何这很重要的信息。此外,提出的转换相对容易实现,使其对初学者数据科学家更为友好。他们的分类在上面的图 1 中总结,可能是大多数数据科学家需要随时备份在桌上的图像。

资源与参考文献

[1] A. Zytek, I. Arnaldo, D. Liu, L. Berti-Equille, K. Veeramachaneni. 对可解释特征的需求:动机与分类(2022)。SIGKDD Explorations。

[2] C. Molnar. 可解释的机器学习(2020)。LeanPub

[3] A. Preece, D. Harborne, D. Braines, R. Tomsett, S. Chakraborty. 可解释 AI 中的利益相关者(2018)。《政府与公共部门的人工智能》第 6 页。

[3] S. Lundberg, S.I. Lee. 对模型预测的统一解释方法(2017)。《神经信息处理进展》第 31 卷第 10 页。

Delta Lake — 自动模式演变

原文:towardsdatascience.com/delta-lake-automatic-schema-evolution-11d32bd1aa99

合并演变数据框时发生了什么,以及你可以/不能做的事情

Vitor TeixeiraTowards Data Science Vitor Teixeira

·发表于Towards Data Science ·阅读时间 5 分钟·2023 年 3 月 10 日

--

图片由McDobbie Hu提供,来源于Unsplash

在上一篇文章中,我们讨论了事务日志和如何保持 Delta 表快速和干净。这次我们将讨论 Delta 表中的自动模式演变。

模式演变是管理数据随时间变化的关键方面。数据源不断发展以适应新的业务需求,这可能意味着需要从现有的数据模式中添加或删除字段。作为数据使用者,快速而灵活地适应数据源的新特性是至关重要的,而自动模式演变使我们能够无缝地适应这些变化。

在这篇文章中,我们将讨论在使用Delta时的自动模式演变,并使用people10m 公共数据集(这是在 Databricks Community Edition 上提供的)进行测试。我们将在几个场景中测试添加和删除字段。

设置

自动模式演变可以通过两种方式启用,具体取决于我们的工作负载。如果我们进行盲目追加,只需启用mergeSchema选项即可:

如果我们使用合并策略插入数据,则需要通过将spark.databricks.delta.schema.autoMerge.enabled设置为true来启用它。

在这篇文章中,我们将使用合并策略,因此我们将选择后者。

我们已经设置好了,可以加载我们的 Delta 表,它应该如下所示:

初始数据集

模式演变

为了模拟模式演变,我们将使用手动创建的模式创建自定义 DataFrame,并使用 Scala 的 Delta API 合并它们。

免责声明:我们将对模式进行的所有更新只是示例,并不意味着有太大意义。

初始 DataFrame 模式

模拟和合并新记录

添加字段

假设我们公司希望友好对待昵称,员工可以用他们喜欢的昵称被称呼(真棒!)。

我们将向当前模式中添加一个名为nickName的新字段,并更新 Pennie 的 nickName(id 编号 1)。

带有 nickName 的模式

添加新字段

如我们所见,添加了一个新字段,Pennie 现在可以用她的新喜欢的昵称称呼!注意到所有其他记录的值都自动填充为 null。

删除字段

由于添加了昵称,大家开始思考为何没有人使用他们的中间名,因此决定删除它。

无中间名的模式

我们还将更新 Quyen 的昵称,但由于源删除了字段,她的中间名将不会存在。表格应该如何处理?

删除中间名后的表

如果你什么也没猜到,那你是对的。每个当前目标表记录保持不变,只有新记录的middleName将是null

为了展示这一点,我们将插入一个新的 id(0)。

插入新记录后的表

重命名列

重命名列与删除一列并用新名称添加另一列相同。如果你希望在原地重命名列,请参阅 Delta 列名映射

我不会进一步深入这个话题,因为尽管这是一种模式演变,但它不是自动的。请记住,这个功能是不可逆的,一旦启用,你将无法关闭它。

更改列类型/顺序

更改列类型或列顺序也不是自动模式演变的一部分。

在结构中添加/删除字段

假设我们添加了一个员工历史结构,其中包括startDateendDate,以追踪员工何时开始和离开工作。

为了更完整的历史记录,我们现在希望包括title以追踪员工在公司的职业生涯。

更新了带有‘title’的结构

如我们所见,向结构中添加字段也不是问题。如果我们尝试删除新添加的字段,它也会成功。添加和删除结构中的字段与在根中执行的方式相同。

在结构数组中添加/删除字段

现在我们要处理更复杂的情况。在这种情况下,我们将向数组中的结构体添加一个新字段。假设我们现在有一个属于某个员工的设备数组:

为了展示在数组中添加新字段的情况,我们将向结构体中添加一个serial_num,以便更好地跟踪设备。

在数组中更新的结构体添加了‘serial_num’

正如我们所见,这也按预期工作。表模式已更新,新记录具有相应的serial_num,而旧记录的serial_num被填充为null值。

如果我们再次移除新添加的字段,它会按预期工作。

在结构体的映射中添加/删除字段

现在是时候在映射中测试相同的操作了。我们添加了一个名为connections的新列,该列将负责存储每个员工的层级。

为了模拟更新,我们将向connections列中的结构体添加一个名为title的新列。

在映射中更新的结构体添加了‘title’

这一次,删除返回AnalysisException的字段,这意味着 MapType 转换不被很好地支持。

经简要调查,我发现这是因为castIfNeeded函数尚不支持 MapTypes。我已报告了一个错误,并将尝试解决这个问题。

编辑: github.com/delta-io/delta/pull/1645

结论

在这篇文章中,我们讨论了在几种不同情况下添加和删除字段的问题。我们得出结论,Delta 中的自动模式演变非常完善,支持大多数复杂场景。通过允许这些场景,我们可以避免在数据源演变时手动干预以更新模式。这在处理数百个数据源时特别有用。

作为额外的奖励,我们还发现了 MapTypes 中不支持的一个遗漏案例,这是一个很好的机会为这样一个出色的开源项目做出贡献。

希望你喜欢这篇文章!请确保关注更多内容!

Delta Lake:删除向量

原文:towardsdatascience.com/delta-lake-deletion-vectors-65bc9dc90b63

删除向量与 DML 命令有何关联,它们如何改善写入性能?

维托·特谢拉Towards Data Science 维托·特谢拉

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

--

Sam Pak 提供的照片,来源于 Unsplash

能够更新和删除记录是从传统数据仓库过渡到数据湖时失去的一个功能。虽然数据湖在解决规模和成本问题上表现出色,但它们牺牲了更新和删除记录的能力。数据湖由许多文件组成,这些文件很快会变成数据沼泽,这正是问题出现的地方,而湖仓架构则解决了这一问题。

湖仓架构是一种结合了数据仓库和数据湖的混合型架构,旨在解决它们各自的问题。其中一个问题是数据仓库中备受喜爱的缺乏 DML 支持的 ACID 事务,而这正是 Delta Lake 的亮点。然而,由于 Delta 的 ACID 保证,文件的原地修改是不可能的。

在这篇文章中,我将讨论 Delta 如何支持 DML 命令、删除向量如何工作以及它们作为性能改进的重要性。

Delta Lake — DML 背后的实现

事务日志 的帮助下,Delta Lake 支持如 UpdateDeleteMerge 的 DML 命令。为了简便起见,我们将专注于 UpdateDelete 命令,因为它们更简单,并且在底层的工作方式相同。

那么,当执行以下查询时会发生什么?

更新前后的文件 — 图片来源于作者

涉及三个步骤:

  • 查找谓词 gender = ‘M’ 匹配的文件

  • 对于找到的每个文件,重写文件并更新记录

  • 从事务日志中删除文件 2 和文件 4,并添加文件 5 和文件 6

相同的逻辑也适用于删除命令。将文件重写为所需更新的过程称为写时复制

通过复制包含更新记录的整个文件,我们避免了就地修改,并能够同时浏览事务日志和构成 Delta 表最新状态的多个不同版本(时间旅行)。

对于上述查询,这种策略似乎没有问题,因为有很高比例的记录会被更新,但那这个呢?

在这种情况下,包含特定 id 的文件必须被没有该记录的新文件完全替换。对于小文件,这不应成为问题,但假设我们有几百 MB 的文件,这种做法是非常低效的。

总之,写时复制对于快速读取非常好,因为所有数据始终存在于同一个文件中,并且在 DML 操作不多的情况下表现良好。相反,写入操作成本高,当更新操作很多时,这种策略是不理想的,如上所述。

删除向量

Delta 删除向量(DVs)是一种机制,用于在由于写时复制而只更新文件中很小百分比的记录时,提高写入性能。

简单来说,它们是 RoaringBitmaps 的数组,直接映射到 Parquet 文件的行中(32 位整数的默认实现不足以覆盖可能的行数)。DVs 由删除或更新现有数据的命令创建,作为标记用于在扫描数据时过滤行。在删除的情况下,它们充当删除标记,正如名称所示;在更新的情况下,它们充当行无效标记,我将在下面进一步详细说明。

设置

让我们使用之前 Delta Lake 文章中使用的 people10m 公共数据集 并分析 DVs 实际上是如何工作的。

我们应该有这样的表:

Delta Table people10m — 作者提供的图片

我们需要做的第一件事是通过运行以下命令启用该功能:

设置删除向量标志

注意: 请注意,通过启用此功能,表协议会更新为readerVersion=3writerVersion=7,可能会与旧版本的读取器/写入器不兼容。

让我们通过从表中删除一个 id 来强制创建一个删除向量:

没有 DVs 的话,我们应该在事务日志中有一个新文件,包含原始文件中的所有 id,除了 id 1。让我们通过检查事务日志来看看发生了什么。

解包事务日志

CommitInfo

commitInfo 条目包含有关 DELETE 操作的信息。

事务中的提交信息条目

在这里我们看到 numDeletionVectorsAdded:”1"numAddedFiles:”0",这意味着我们避免了重写文件。

移除

你可能会觉得奇怪,因为它包括一个 remove 条目,这种行为类似于标准的写时复制行为,上述指标表明没有文件被移除 numRemovedFiles:”0"

在事务日志中移除条目

添加

add 条目解释了启用 DVs 后发生的所有事情。

在事务日志中添加条目

在这里我们看到,添加的文件的 path 与移除的文件相同,但现在包含了删除向量。添加和移除条目现在可以选择性地包含一个 deletionVector 字段,其中包含与文件关联的 DVs 信息。

在支持 DVs 的版本中,文件通过文件路径和唯一删除向量 id 唯一标识,当未使用 DVs 时,默认值为 NULL。

这个 id 是如何定义的?新的 DV 结构提供了什么信息?

它主要取决于存储类型。在我们的例子中,存储类型是“u”。对于这种存储类型,pathOrInlineDv<random prefix — optional><base85 encoded uuid>。它用于存储在相对于 Delta 表路径的路径中的 DVs。DV 文件名可以从 UUID 派生(见详细信息)。

Delta 表路径中的删除向量

在我们的例子中,我们可以看到删除向量与表文件一起存储。请注意,如果表具有分区值,DV 文件不会保存在分区目录中,而是保存在 Delta 表根目录中,因为 DV 可以跨多个分区。

存储类型可以采用其他值,例如 “i”,其中向量内联,因此 pathOrInlineDv<base85 encoded bytes>,以及 “p”,其中文件存储在由 pathOrInlineDv 提供的绝对路径中。

Offset 是一个可选字段,表示数据在由文件支持的存储类型中的起始位置。当 DV 内联(存储类型 “i”)时,该字段不存在。

SizeInBytes 是 DV 的字节大小。

Cardinality 是 DV 逻辑上删除的行数。

启用删除向量的 DML

启用 DVs 后,DML 命令的工作方式有所不同,以利用新的信息。

更新时没有现有的 DVs

启用 DVs 的更新 — 作者提供的图像

启用 DVs 后,更新命令逻辑如下:

  • 扫描所有满足条件并需要更新的文件(File 2, File 4)

  • 为需要失效的行写入 DVs(DV 2.1,DV 4.1)

  • 写入更新后的行的新文件(File 5)

  • 将 (File 5, NULL) 添加到事务日志中,移除 (File 2, NULL) 和 (File 4, NULL),并添加 (File 2, DV 2.1) 和 (File 4, DV 4.1) 文件

注意: 在写作时,DVs 不支持 UPDATEMERGE 命令,只支持 DELETE。不过,它们将在 未来支持

带有 DVs 的表的读取

带有 DVs 的表的读取 — 作者提供的图像

当文件有相关的 DVs 时,扫描将隐式读取 DV,并根据向量过滤匹配的结果行。没有 DVs 时,我们读取单个文件以获得一组结果,而有 DVs 时,我们必须同时读取文件和 DV 才能找到正确的结果集,这可能会影响读取性能。

删除现有的 DVs

带有 DVs 的删除 — 作者提供的图像

在这种情况下,我们将执行一个删除操作,这会影响一个已经有相关 DV 文件的文件。由于我们只是删除,不需要写入任何包含更新行的新文件,只需更新现有 DV。

  • 扫描 (File 2, DV 2.1) 以查找最新的行集

  • 写一个新的 DV,包含删除旧 DV 的行以及新的行。

  • 从事务日志中移除 (File 2, DV 2.1) 并添加 (File 2, DV 2.2)

带有 DVs 的清理和压缩

VACUUM

随着不断的更新,DVs 可能会不断被替换,留下许多旧文件。VACUUM 也会像处理常规文件一样处理 DVs。

OPTIMIZE

在没有 DVs 的情况下,OPTIMIZE 使用 minFileSize 属性选择应该进行压缩的文件。由于某些文件可能有大量行被作废,因此选择那些超出某个阈值的文件也是有意义的。maxDeletedRowsRatio 属性定义了文件被选择进行压缩并删除其 DV 的最大允许比例。

Z-ORDER OPTIMIZE

这个 OPTIMIZE 策略不是幂等的,每次执行时都会尝试生成一组新的有序文件,这意味着这个操作不会生成 DVs,所有以前的 DVs 和文件都标记为待删除。

性能差异

理论已经涵盖,但让我们通过运行一个简单的 DELETE 查询来查看实际中的改进。

为了模拟一个写入密集型应用程序的真实场景,并考虑平均目标文件大小,我将数据集压缩到一个大约 236 MB 和 1000 万条记录的单一文件中,并比较了启用和未启用 DVs 的情况下查询的性能。

两种方法的作业统计信息 — 作者提供的图像

我们首先注意到左侧的字节数增加,这与原始文件的字节数相同,减去刚刚删除的行。有两个文件被添加,总计 9,999,999 行,运行整个操作(包括扫描文件和重写更新后的文件)花费了 27.1 秒。

在右侧,我们可以看到 DVs 的实际应用。没有新增字节,因为没有新文件。我们看到一个包含一行(34 字节)的新 DV 被添加到一个仍包含 9,999,999 个有效行的现有文件中。总体而言,该操作耗时 2.7 秒,相比之前的方法写入速度提高了大约十倍! 通过写入一个耗时 117 毫秒的 DV,我们避免了为了在一个拥有 1000 万行的数据集中删除一行而重新写入整个文件。

总结

在这篇文章中,我们看到 DVs 在写入密集型用例中表现得非常好,尤其是在需要进行大量小的更新或删除时。虽然读取时间可能会受到读取更多文件的影响,但通过自动压缩作业或计划的OPTIMIZE作业,这些时间损失可以得到有效弥补。总的来说,这是写入性能和读取性能之间的权衡,因此在决定启用此功能之前,分析你所运行的工作负载类型至关重要。

如果你希望了解更多关于 Delta Lake 的信息,请务必阅读我之前的文章:

## Delta Lake — 自动模式演进

在合并演进型 DataFrame 时会发生什么,你可以/不能做什么

## Delta Lake—保持高效和清洁

曾经想过如何提升你的 Delta 表的性能吗?实践如何保持 Delta 表的高效和清洁。

## Delta Lake — 自动模式演进

Delta Lake:保持快速和清洁

原文:towardsdatascience.com/delta-lake-keeping-it-fast-and-clean-3c9d4f9e2f5e

曾经想过如何提高 Delta 表的性能吗?亲身体验如何保持 Delta 表的快速和清洁。

Vitor Teixeira数据科学前沿 Vitor Teixeira

·发布于数据科学前沿·阅读时间 11 分钟·2023 年 2 月 15 日

--

如何保持 Delta 表快速和清洁的简化流程图(作者提供的图片)

保持 Delta 表的快速和清洁对维护数据管道的效率非常重要。Delta 表可能会随着时间的推移变得非常庞大,导致查询性能下降和存储成本增加。然而,有几种操作和权衡可以积极影响表的速度。

在这篇博客文章中,我们将使用people10m 公共数据集,该数据集在 Databricks Community Edition 上可用,展示如何利用 Delta 操作保持表的快速和清洁,同时解释幕后发生的情况。

分析 delta 日志

我们将从检查数据集的内容开始。默认情况下,它在 Databricks 上可用,你可以在这里访问它。

数据集的小样本

来自原始 Delta 表的文件

我们有 16 个 parquet 条目和一个_delta_log*文件夹,其中包含所有交易日志,这些日志堆积在一起形成我们的 delta 表。

如果我们检查日志的内容,可以看到一个 JSON 文件,描述了 Databricks 创建这个 Delta 表时写入的第一次交易。

从分析中,我们可以看到这个交易包括几个操作:

提交信息

{
    "commitInfo": {
        "timestamp": 1602173340340,
        "userId": "360903564160648",
        "userName": "stephanie.bodoff@databricks.com",
        "operation": "WRITE",
        "operationParameters": {
            "mode": "ErrorIfExists",
            "partitionBy": "[]"
        },
        "notebook": {
            "notebookId": "1607762315395537"
        },
        "clusterId": "1008-160338-oil232",
        "isolationLevel": "WriteSerializable",
        "isBlindAppend": true,
        "operationMetrics": {
            "numFiles": "8",
            "numOutputBytes": "221245652",
            "numOutputRows": "10000000"
        }
    }
}

commitInfo 包含有关提交的所有信息:执行了什么操作、由谁执行、在哪里执行以及在什么时间。operationMetrics 字段显示写入了 8 个文件,总共 1000000 条记录。

Protocol

{
    "protocol": {
        "minReaderVersion": 1,
        "minWriterVersion": 2
    }
}

protocol 操作用于增加读取或写入给定表所需的 Delta 协议版本。这允许排除那些使用旧协议的读者/写者,因为旧协议可能缺少正确解释事务日志所需的功能。

Metadata

{
    "metaData": {
        "id": "ee2db204-0e38-4962-92b0-83e5570d7cd5",
        "format": {
            "provider": "parquet",
            "options": {}
        },
        "schemaString": "{\"type\":\"struct\",\"fields\":[{\"name\":\"id\",\"type\":\"integer\",\"nullable\":true,\"metadata\":{}},{\"name\":\"firstName\",\"type\":\"string\",\"nullable\":true,\"metadata\":{}},{\"name\":\"middleName\",\"type\":\"string\",\"nullable\":true,\"metadata\":{}},{\"name\":\"lastName\",\"type\":\"string\",\"nullable\":true,\"metadata\":{}},{\"name\":\"gender\",\"type\":\"string\",\"nullable\":true,\"metadata\":{}},{\"name\":\"birthDate\",\"type\":\"timestamp\",\"nullable\":true,\"metadata\":{}},{\"name\":\"ssn\",\"type\":\"string\",\"nullable\":true,\"metadata\":{}},{\"name\":\"salary\",\"type\":\"integer\",\"nullable\":true,\"metadata\":{}}]}",
        "partitionColumns": [],
        "configuration": {},
        "createdTime": 1602173313568
    }
}

metadata 操作包含所有表的元数据。它在表的第一个操作中是必需的,因为它包含了表的定义。对表元数据的后续修改将产生新的操作。

Add

{
    "add": {
        "path": "part-00000-373539c8-e620-43e4-82e0-5eba22bb3b77-c000.snappy.parquet",
        "partitionValues": {},
        "size": 27825521,
        "modificationTime": 1602173334000,
        "dataChange": true,
        "stats": "{\"numRecords\":1249744,\"minValues\":{\"id\":3766824,\"firstName\":\"Aaron\",\"middleName\":\"Aaron\",\"lastName\":\"A'Barrow\",\"gender\":\"F\",\"birthDate\":\"1951-12-31T05:00:00.000Z\",\"ssn\":\"666-10-1008\",\"salary\":-20858},\"maxValues\":{\"id\":5016567,\"firstName\":\"Zulma\",\"middleName\":\"Zulma\",\"lastName\":\"Zywicki\",\"gender\":\"M\",\"birthDate\":\"2000-01-30T05:00:00.000Z\",\"ssn\":\"999-98-9985\",\"salary\":180841},\"nullCount\":{\"id\":0,\"firstName\":0,\"middleName\":0,\"lastName\":0,\"gender\":0,\"birthDate\":0,\"ssn\":0,\"salary\":0}}"
    }
}
{
    "add": {
        "path": "part-00001-943ebb93-8446-4a6c-99f7-1ca12ec2511b-c000.snappy.parquet",
        "partitionValues": {},
        "size": 27781558,
        "modificationTime": 1602173334000,
        "dataChange": true,
        "stats": "{\"numRecords\":1249537,\"minValues\":{\"id\":1267751,\"firstName\":\"Abbey\",\"middleName\":\"Abbey\",\"lastName\":\"A'Barrow\",\"gender\":\"F\",\"birthDate\":\"1951-12-31T05:00:00.000Z\",\"ssn\":\"666-10-1005\",\"salary\":-20925},\"maxValues\":{\"id\":2517287,\"firstName\":\"Zulma\",\"middleName\":\"Zulma\",\"lastName\":\"Zywicki\",\"gender\":\"F\",\"birthDate\":\"2000-01-30T05:00:00.000Z\",\"ssn\":\"999-98-9981\",\"salary\":165757},\"nullCount\":{\"id\":0,\"firstName\":0,\"middleName\":0,\"lastName\":0,\"gender\":0,\"birthDate\":0,\"ssn\":0,\"salary\":0}}"
    }
}
...

add 操作,顾名思义,是通过添加单个 逻辑文件 来修改表中的数据。它包含相应文件的元数据以及一些可以用于优化的数据统计信息,我们将在文章的进一步部分讨论这些优化。

日志包含 8 个 add 条目,从 part-00000 到 part-00007,为简化起见已被截断。

如果你希望了解更多关于 Delta 协议的信息,请参阅:github.com/delta-io/delta/blob/master/PROTOCOL.md

Setup

现在我们已经分析了事务日志和数据集,我们将其复制到自己的目录中,以便我们可以修改表。

保持清洁

Vacuum

第一个明显的答案是 VACUUM 命令。它的作用是删除不再影响我们的 Delta 表的文件,前提是配置了 delta.deletedFileRetentionDuration,默认为 7 天。

在分析数据集和 Delta 日志后,我们发现有 16 个文件,所有文件都比默认保留时间间隔要旧,但日志中仅引用了 8 个文件。这意味着理论上,如果我们运行命令,另外 8 个文件将被清理。

让我们检查一下底层文件系统中的结果。

运行 VACUUM 后的文件,使用默认设置

令人惊讶的是,文件并没有被清理。发生了什么事?

我发现令人惊讶的是,VACUUM 内部使用的时间戳不是 add 操作中的事务日志文件中引用的时间戳,而是文件的 modificationTime。这样做的原因是为了避免读取大量 JSON 文件以查找应选择删除的文件。也就是说,在复制/迁移 Delta 表时,请确保保持 modificationTime 不变。

鉴于我们刚刚复制了整个数据集,modificationTime 现在的时间,因此不会被选择删除,至少在 7 天内不会。如果我们尝试删除,将会收到以下警告:

出于测试目的,我们将delta.retentionDurationCheck.enable=false,以便我们可以演示命令的实际效果,但这应该谨慎使用,因为如果其他活动的读取器或写入器依赖于被删除的数据,可能会导致表损坏。

VACUUM 后的文件

看,这样一来,一切看起来都更整洁了。那么事务日志呢?现在有 4 个新的 JSON 文件,每个文件代表一个新的事务。

每次请求VACUUM时,事务日志中会生成两个新的提交,分别包含VACUUM STARTVACUUM END操作。

{
    "commitInfo": {
        "timestamp": 1676202617353,
        "userId": "8019820830300763",
        "userName": "vitor",
        "operation": "VACUUM START",
        "operationParameters": {
            "retentionCheckEnabled": true,
            "defaultRetentionMillis": 604800000
        },
        "notebook": {
            "notebookId": "1087108890280137"
        },
        "clusterId": "0102-173902-b3a5lq4t",
        "readVersion": 0,
        "isolationLevel": "SnapshotIsolation",
        "isBlindAppend": true,
        "operationMetrics": {
            "numFilesToDelete": "0"
        },
        "engineInfo": "Databricks-Runtime/11.3.x-scala2.12",
        "txnId": "6b875d5e-4c0e-4724-a87b-a0a6bbfd8419"
    }
}

第一个没有影响任何文件,因此numFilesToDelete为 0。

{
    "commitInfo": {
        "timestamp": 1676206833338,
        "userId": "8019820830300763",
        "userName": "vitor",
        "operation": "VACUUM START",
        "operationParameters": {
            "retentionCheckEnabled": false,
            "specifiedRetentionMillis": 0,
            "defaultRetentionMillis": 604800000
        },
        "notebook": {
            "notebookId": "1087108890280137"
        },
        "clusterId": "0102-173902-b3a5lq4t",
        "readVersion": 2,
        "isolationLevel": "SnapshotIsolation",
        "isBlindAppend": true,
        "operationMetrics": {
            "numFilesToDelete": "8"
        },
        "engineInfo": "Databricks-Runtime/11.3.x-scala2.12",
        "txnId": "42f93d56-8739-46d5-a8f9-c2c1daffe0ec"
    }
}

第二个标记了 8 个文件进行删除,因此numFilesToDelete为 8。

总之,VACUUM作业对于减少存储成本是必不可少的。然而,我们需要确保定期安排这些作业(它们不会影响任何正在运行的作业),因为它们默认不会被安排。此外,我们还需要确保调整保留值,以便我们希望进行时间旅行时考虑modificationTime,并在迁移 Delta 表时加以考虑。

优化

我们需要注意的下一个命令是OPTIMIZE。这个命令的作用是将小文件压缩成较大的文件,同时保持所有数据完整,并重新计算增量统计信息。它可以显著提高查询性能,特别是当数据是通过流式作业写入时,根据触发间隔,可能会生成很多小文件。

目标文件大小可以通过调整delta.targetFileSize来改变。请记住,设置此值并不能保证所有文件都达到指定大小。该操作将尽最大努力接近目标大小,但这在很大程度上取决于我们处理的数据量以及并行性。

在这个例子中,我们将其设置为 80MB,因为数据集远小于默认大小 1GB。

让我们在运行命令后分析一下事务日志的提交情况:

{
    "commitInfo": {
        "timestamp": 1676215176645,
        "userId": "8019820830300763",
        "userName": "vitor",
        "operation": "OPTIMIZE",
        "operationParameters": {
            "predicate": "[]",
            "zOrderBy": "[]",
            "batchId": "0",
            "auto": false
        },
        "notebook": {
            "notebookId": "1087108890280137"
        },
        "clusterId": "0102-173902-b3a5lq4t",
        "readVersion": 2,
        "isolationLevel": "SnapshotIsolation",
        "isBlindAppend": false,
        "operationMetrics": {
            "numRemovedFiles": "8",
            "numRemovedBytes": "221245652",
            "p25FileSize": "59403028",
            "minFileSize": "59403028",
            "numAddedFiles": "3",
            "maxFileSize": "88873012",
            "p75FileSize": "88873012",
            "p50FileSize": "87441438",
            "numAddedBytes": "235717478"
        },
        "engineInfo": "Databricks-Runtime/11.3.x-scala2.12",
        "txnId": "55389d3e-4dd5-43a9-b5e1-de67cde8bb72"
    }
}

总共删除了 8 个文件,添加了 3 个。我们的新目标文件大小为 80MB,因此所有文件都被压缩成三个新文件。正如提交信息所示,日志中还包含了 8 个remove操作和 3 个add操作,为了简化起见,这些操作被省略了。

你可能会想知道OPTIMIZE命令在这个特定数据集上是否真的做了有用的事情,所以让我们尝试运行一个简单的查询。

使用OPTIMIZE后,我们改善了扫描时间,因为我们读取了更少的文件。然而,我们仍然在尝试查找薪资大于 80000 的记录时读取了整个数据集。我们将在文章的下一部分解决这个问题。

总结来说,应该定期安排OPTIMIZE任务,因为查询读取可以从减少读取文件数量中获益。Databricks 建议每天运行,但实际上这取决于更新的频率。请注意,OPTIMIZE可能需要一些时间,并会增加处理成本。

Z-Order 优化

Z-Ordering是一种用于将相关信息放置在同一组文件中的技术。

当文件被写入 Delta 表时,最小值、最大值和计数统计数据会自动添加到 add action 中的stats字段,如前所述。这些统计数据用于在查询表时进行数据跳过。数据跳过是一种优化,旨在优化包含WHERE子句的查询。默认情况下,数据集的前 32 列会收集统计数据。可以通过调整delta.dataSkippingNumIndexedCols到所需的数字来更改这一点。请注意,这可能会影响写入性能,特别是对于长字符串,建议将其移动到模式的末尾,并将属性设置为低于其索引的数字。

OPTIMIZE示例中,我们看到即使收集了这些统计数据,我们也不能真正利用它们,仍然会读取所有文件。这是因为我们没有任何明确的排序,薪资在所有文件之间基本是随机的。

通过在OPTIMIZE中添加ZORDER-BY列,我们可以轻松解决这个问题:

让我们分析事务日志:

{
    "commitInfo": {
        "timestamp": 1676217320722,
        "userId": "8019820830300763",
        "userName": "vitor",
        "operation": "OPTIMIZE",
        "operationParameters": {
            "predicate": "[]",
            "zOrderBy": "[\"salary\"]",
            "batchId": "0",
            "auto": false
        },
        "notebook": {
            "notebookId": "1087108890280137"
        },
        "clusterId": "0102-173902-b3a5lq4t",
        "readVersion": 2,
        "isolationLevel": "SnapshotIsolation",
        "isBlindAppend": false,
        "operationMetrics": {
            "numRemovedFiles": "8",
            "numRemovedBytes": "221245652",
            "p25FileSize": "113573613",
            "minFileSize": "113573613",
            "numAddedFiles": "2",
            "maxFileSize": "123467314",
            "p75FileSize": "123467314",
            "p50FileSize": "123467314",
            "numAddedBytes": "237040927"
        },
        "engineInfo": "Databricks-Runtime/11.3.x-scala2.12",
        "txnId": "0e9b6467-9385-42fa-bc1a-df5486fc997f"
    }
}

两个OPTIMIZE命令之间存在一些差异。我们首先注意到的是,如预期的那样,现在在operationParameters中有一个zOrderBy列。此外,尽管我们指定了相同的目标文件大小,但由于列的统计数据,OPTIMIZE结果为 2 个文件而不是 3 个文件。

以下是第一个文件的add操作。统计数据显示该文件包含所有薪资在-26884 和 73676 之间的记录。因此,我们的查询应该完全跳过这个文件,因为薪资值超出了我们WHERE子句的范围。

{
    "add": {
        "path": "part-00000-edb01f4d-18f1-4c82-ac18-66444343df9b-c000.snappy.parquet",
        "partitionValues": {},
        "size": 123467314,
        "modificationTime": 1676217320000,
        "dataChange": false,
        "stats": "{\"numRecords\":5206176,\"minValues\":{\"id\":1,\"firstName\":\"Aaron\",\"middleName\":\"Aaron\",\"lastName\":\"A'Barrow\",\"gender\":\"F\",\"birthDate\":\"1951-12-31T05:00:00.000Z\",\"ssn\":\"666-10-1010\",\"salary\":-26884},\"maxValues\":{\"id\":9999999,\"firstName\":\"Zulma\",\"middleName\":\"Zulma\",\"lastName\":\"Zywicki\",\"gender\":\"M\",\"birthDate\":\"2000-01-30T05:00:00.000Z\",\"ssn\":\"999-98-9989\",\"salary\":73676},\"nullCount\":{\"id\":0,\"firstName\":0,\"middleName\":0,\"lastName\":0,\"gender\":0,\"birthDate\":0,\"ssn\":0,\"salary\":0}}",
        "tags": {
            "INSERTION_TIME": "1602173334000000",
            "ZCUBE_ZORDER_CURVE": "hilbert",
            "ZCUBE_ZORDER_BY": "[\"salary\"]",
            "ZCUBE_ID": "493cfedf-fdaf-4d34-a911-b4663adefec7",
            "OPTIMIZE_TARGET_SIZE": "83886080"
        }
    }
}

通过在 Z-Ordering 文件后再次运行查询,我们可以看到只读取了一个文件,另一个文件被修剪掉了。

尽管 Z-Ordering 在数据跳过方面看起来是一个改变游戏规则的技术,但必须正确使用才能提高效率。下面我们将列出使用 Z-Ordering 时必须考虑的一些关键点:

  1. Z-Ordering 仅适用于高基数的列,如果列的基数较低,我们无法从数据跳过中受益。

  2. 我们可以在 Z-Order 上指定多个列,但每增加一列,其数据跳过的效果会降低。

  3. 确保仅在有统计数据的列上进行 Z-Order 操作。记住列的索引,只有前 32 列会被分析。

分区

另一种可以使用的技术是物理分区。虽然 Z-ordering 将具有相似值的数据分组到同一文件中,但分区将数据文件分组到同一文件夹下。

与 Z-Ordering 相反,分区在低基数列上效果最佳。如果我们选择其他列,可能会导致无限的分区,最终生成大量小文件,从而引发性能问题。

我们将使用性别作为分区列,因为它是数据集中唯一具有低基数的列。

通过这样做,我们最终得到了两个文件夹,每个文件夹对应一个性别。这种类型的分隔对于具有低基数并且在大表中经常用于WHERE子句的列非常有用。

假设我们现在希望能够根据性别和薪资提取见解。

OPTIMIZE 可以与分区列配对使用,如果我们只想优化数据的一个子集。下面我们将分析 Z-Ordered 表中有无分区的数据跳过,以展示如何同时利用这两种方法。我们已经减少了目标文件大小,以展示我们的数据在不同文件下按性别拆分后的差异。

如上所示,如果没有分区,我们必须读取两个文件才能获得结果。通过根据薪资进行 Z-Order,我们能够跳过 3 个文件,但必须完全读取这些文件以提取请求的性别。使用分区后,我们能够跳过整个分区,基本上“免费”过滤性别,并且由于 Z-Ordering 跳过了 3 个文件。

正如我们所见,同时使用这两种方法有其好处,但需要经过仔细考虑,因为它可能只对非常大的表格产生显著差异。

结论

总之,保持 Delta 表的清洁对于维持数据管道的性能和效率至关重要。清理和优化 Delta 表有助于回收存储空间并提高查询执行时间。深入了解每个操作的细节对于正确的微调非常重要,否则可能会导致不必要的存储和处理成本。

参考文献

docs.databricks.com/delta/vacuum.html

docs.databricks.com/delta/optimize.html

docs.databricks.com/delta/data-skipping.html

docs.databricks.com/tables/partitions.html

docs.databricks.com/delta/best-practices.html

www.databricks.com/blog/2018/07/31/processing-petabytes-of-data-in-seconds-with-databricks-delta.html

Delta Lake — 分区、Z-Order 和 Liquid Clustering

原文:towardsdatascience.com/delta-lake-partitioning-z-order-and-liquid-clustering-944030ff1828

Delta 中的不同分区/聚类方法是如何实现的?它们在实际中是如何工作的?

Vitor TeixeiraTowards Data Science Vitor Teixeira

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

--

照片由 frame harirak 提供,发布在 Unsplash

使大数据变得困难的问题之一一直在于它的名字,它就是“大”。分区,特别是当做得好时,一直是一种通过将需要读取的数据减少到一个子集来提高对大量数据的查询执行时间的方法。然而,分区数据是复杂的,需要仔细考虑和一些前期规划,因为今天适用的要求可能不适合未来的要求。例如,在 Hive 风格的分区中,列可能需要更改或增加其基数,从而使数据过度分区(小文件问题),这会导致数据的完全重组,这并不理想。

Z-Order 聚类是另一种用于数据跳过的技术,同样避免了全面的数据扫描。然而,这种技术有一些局限性。其中之一是新摄取的数据默认情况下未排序,用户需要重新聚类,这意味着已经聚类的数据将被重新聚类和重写,增加了操作所花费的时间。Z-Order 用户还需要每次运行命令时定义聚类列,因为它们不是表属性的一部分。

这就是 Liquid Clustering 进入游戏的地方。前提是它可以无缝地融入当前的数据布局,并且能够适应未来的需求,而无需重写任何已经聚类的数据。

在这篇文章中,我们将解释 Delta 中不同数据修剪策略的细节及其应用方式。

分区修剪 — Hive 风格的分区

Hive 样式分区 — 作者提供的图片

Hive 样式分区是一种将表组织成小块的方式。这些数据块被组织成几个子文件夹,包含分区值的数据。

dbfs://people10m/gender=M/data_0.json
dbfs://people10m/gender=M/data_1.json
dbfs://people10m/gender=F/data_0.json
dbfs://people10m/gender=F/data_1.json

这种方法不是 Delta 的原生方法,即不属于 Delta 协议的一部分。然而,由于 Delta 建立在 Apache Spark 之上,旧的 Hive 样式分区在某些场景下也可以很好地工作。

几种机制处理这种类型的分区,使得它对最终用户完全不可见。在 Apache Spark 中,当用户读取数据集时,gender 列会自动添加到模式中,并带有相应的值,可以像常规列一样进行查询。这种技术称为 分区发现,由 DataSource’s resolveRelation 处理,它从给定的基本路径推断分区列。另一方面,当用户使用 partitionBy 保存 DataFrame 时,会执行 InsertIntoHadoopFsRelationCommand 作为执行计划的一部分,这会调用 FileFormatWriter,为每个底层 RDD 的分区生成一个写入作业(从最终模式中排除分区列并为其创建桶)。

在上述示例中,由于查询仅选择性别为 F 的数据,它将只需要实际扫描该文件夹,从而有效跳过数据,因为它只读取数据集中的一半文件。这称为 分区剪枝

这种方法有一些缺点,特别是当选择具有非常高基数的分区列或多个分区级别时,这会导致许多小文件,从而导致更差的读取性能。此外,一旦定义了这种分区策略,就不能在不重写所有数据的情况下更改,因为它是在物理层面上定义的。

I/O 剪枝 — Z-Order

另一种有效跳过数据的技术是对文件级统计数据进行过滤。在这种技术中,每个文件都有可用的统计数据,可以作为是否值得读取文件的指标。默认情况下,Delta 存储前 32 列的最小值、最大值和空值计数的统计数据。

people10m公共数据集中的单列id为例。如果我们使用repartitionByRange在该列上将数据排序为 5 个不同的文件,最小/最大统计分布可能类似于以下内容:

按列 ID 范围分区后的文件 — 图片作者

选择公司的前 20,000 名员工 — 图片作者

运行上述查询将产生一个良好的计划,因为我们的查询仅筛选该列且所有文件包含不重叠的 ID 集合。这样,数据库引擎更容易选择正确的文件进行扫描,而不会产生假阳性。

如果我们想在查询中添加另一列怎么办?

假设我们还想按员工的薪资进行筛选。

选择薪资大于 40,000 的公司前 20,000 名员工 — 图片作者

在我们按两列范围分区文件之后,我们最终得到如下结果:

按列 ID 和薪资范围分区后的文件 — 图片作者

薪资与 ID 没有直接关系,使用之前的线性方法将文件组织成有效的数据跳过方式将导致数据仅按第一列排序。通过简单地筛选薪资大于 40,000,我们最终会读取所有五个文件,而不仅仅是一个。

我们如何解决这个问题?是否有办法在保持位置性的同时将多个统计信息分组到单一维度,从而使我们的范围分区正常工作?

如果你猜测了 Z-Ordering,你猜对了。如果你猜测了填充空间曲线,你就更对了!

什么是空间填充曲线,我需要关注它吗?空间填充曲线是一种遍历嵌入空间中所有点的曲线。一些曲线能够将这些高维点映射到一个维度,同时保持在原始空间中的邻近性。听起来复杂?其实并不复杂。下面我们将详细介绍这些曲线的工作原理。

Z-Order 曲线

Z-Order 曲线是 Delta 中空间填充曲线聚类的第一次实现,因此得名。

级别 1 Z-Order 曲线 — 图片作者

Z-Order 值,即形成 Z 形状的曲线的点,是使用称为位交错的技术计算得出的。位交错是一种使用位表示 N 维坐标的方法。例如,如果我们使用 4 位表示(0000 到 1111),我们能够通过逐位分配给每个轴来编码 4x4 网格坐标。接下来,我们将通过一个更直观的示例来展示这种技术。

在 Delta 中,Z-Ordering 用于以使数据跳过操作有效的方式对数据进行分组。所有 Z-Order 列都被“标记”为使用RangePartitionId表达式进行范围分区。该表达式只是一个占位符,将由一个优化器处理,该优化器将对 RDD 进行采样,以找到列的范围边界。(如果你曾经尝试对一个相当大的数据集进行多次 Z-Order,你可能会注意到其文件统计数据是不确定的。这是因为 Delta 使用水库抽样来避免在计算范围 ID 时读取整个数据集)。然后,所有计算出的范围被转换为字节并交错,这样就得出了行的 Z-Order 值。

以下我们将以简化的方式说明 Z-Order 在 Delta 中的工作原理,以一个 6 条记录和 3 个分区的组为例。

针对 6 条记录到 3 个不同范围 ID 的 Z-Order 优化—作者提供的图像

希尔伯特曲线

曲线在保持局部性的能力越强,我们由于误报需要读取的文件就越少。这就是为什么希尔伯特曲线在保持局部性至关重要的场景中更常使用的原因。

在撰写时,希尔伯特曲线尚未在 Delta 的开源版本中实现。然而,它们是 Databricks Z-Order 实现中使用的默认曲线,因为它们相比 Z-Order 曲线在处理高维数据时提供了更好的数据局部性。

希尔伯特曲线—作者提供的图像

希尔伯特曲线可以以四种不同的方式出现,每种方式都是在上述基础上旋转 90º得到的。

但为什么希尔伯特曲线在保持局部性方面比默认的 Z-Order 曲线更好?

希尔伯特曲线的相邻点之间的距离始终为 1。与 Z-Order 不同,这意味着这些跳跃可能会生成具有较大最小/最大差异的 Z-Order 文件,从而使其无用。

Z-Order 曲线上的相邻点之间的距离—作者提供的图像

该算法有几个实现,但在这篇文章中,我将介绍 John Skilling 在“编程希尔伯特曲线”中的一个整洁的迭代方法。这个算法可能会让人困惑,因为它包含了一些位操作。如果你不需要了解细节,可以直接跳到下一节。

请注意,由于 Databricks 代码是专有的,以下示例可能不代表当前实现。

J. Skilling 编码方法将位交错并使用格雷码进行编码。这样,每次只改变一个位,因此遍历网格时只会在垂直或水平方向进行。然后,它遍历编码后的位,并应用一系列位交换和反转,最终返回坐标的位表示,可以通过解交错来恢复。

将笛卡尔点转换为希尔伯特索引的 Skilling 变换 — 来源于 编程希尔伯特曲线

类似于 Z-Order,我们需要一种将任意维度的坐标组编码为单一点的方法。为了实现这一点,我们将运行之前的算法,但要反向运行,以便可以检索希尔伯特曲线中的点。然后有两个循环,一个循环将遍历编码位,从最重要到最不重要,直到 p-2,其中 p 是每个轴上的位数,另一个内循环将从最不重要的位迭代到 n-1,其中 n 是维度数。根据当前的位,我们将交换位或反转它们。最后,我们需要对位进行格雷解码,就能得到我们的点。

接下来,我们将介绍如何对坐标 (2, 0) 进行编码,它表示希尔伯特曲线中的点号 14。

将笛卡尔坐标转换为希尔伯特曲线点的算法 — 作者提供的图像

4x4 希尔伯特曲线 — 作者提供的图像

从这里开始,我们假设过程与 Z-Order 实现相同,其中数据被划分范围,并且相近的记录被写入同一文件。

液体聚类

那么,液体聚类究竟是什么?它不过是希尔伯特曲线加上一个名为 ZCube 的新特性,使得增量聚类成为可能!

OPTIMIZE ZORDER BY 命令要求完全重写数据,这对大型表非常昂贵。此外,当 OPTIMIZE ZORDER 命令中出现问题时,一切需要从头开始,这有时会非常麻烦。

什么是 ZCubes?

ZCubes 是由相同 OPTIMIZE 任务生成的一组文件。这样,一个大型表的 OPTIMIZE 任务可以分成几个不同的任务,这些任务会生成新的 ZCube,并在增量日志中生成新的条目,以实现增量聚类。每个新优化的文件将包含 AddFile 元数据中的 ZCUBE_ID 属性,这将使其可能区分优化和未优化的文件(即没有关联 ZCube 的文件)。

有两个新的可配置 ZCube 属性:

  • MIN_ZCUBE_SIZE 设置 ZCUBE 的最小尺寸。低于此尺寸的 ZCUBE 将被视为 OPTIMIZE 任务的一部分,新文件可以被合并,直到尺寸达到此阈值(默认为 100GB)。这些立方体被称为 部分 ZCubes

  • TARGET_CUBE_SIZE 设置完成的立方体的目标尺寸,包含超过目标尺寸的文件。这些立方体被称为 稳定 ZCubes

如果 Delete 命令使大量文件无效,从而使其小于 MIN_ZCUBE_SIZE,稳定 ZCubes 可能会重新变为部分 ZCubes。

它如何无缝适应新的分区列?

当用户更改聚类列时,只有包含相同聚类列的 ZCubes 会被考虑进行优化。其他立方体保持不变,新立方体会被创建。

这在实践中是如何工作的?

当发出 OPTIMIZE table 命令时,Delta 会选择有效的文件用于 ZCube 生成,这些文件是部分 ZCube 的一部分(可以进一步优化),以及新文件。然后,进行规划步骤,将文件打包到多个 ZCubes 下,这些 ZCubes 是相互独立运行的 OPTIMIZE 任务。

启用液态聚类的 OPTIMIZE 流程 — 作者图示

如何启用/禁用液态聚类?

--New tables
CREATE TABLE <table>
USING delta
CLUSTER BY (<col1>, <col2>, …)

--Existing tables
ALTER TABLE <table>
CLUSTER BY (<col1>, <col2>, …)

--Remove liquid clustering
ALTER TABLE <table>
CLUSTER BY NONE

由于聚类列是在表级别定义的,OPTIMIZE 命令不需要定义任何参数。

注意:这仍在 提议 中,可能会有所变化。

结论

在这篇博客文章中,我们详细讨论了 Delta Lake 中可用的不同分区和聚类选项。我们讨论了 Hive 风格分区、Z-Order 及其当前问题,展示了液态聚类如何解决这些问题。

液态聚类非常有前途,因为它使用起来更简单,具有增量和更好的聚类性能,并且支持在没有任何开销的情况下更改分区列。如果你对性能感兴趣,这里有几个性能比较,你也可以尝试使用 Databricks Runtime 13.3+。Databricks 推荐将所有当前的分区列和 ZOrder 列更改为聚类列,以获得更好的性能。

如果你在使用开源 Delta,尽管液态聚类功能不可用,请确保查看我之前的帖子,了解如何保持你的表格快速而干净:

## Delta Lake— Keeping it fast and clean

是否曾想过如何提升 Delta 表的性能?手把手教你如何保持 Delta 表快速而干净。

[towardsdatascience.com

参考文献

docs.databricks.com/en/delta/clustering.html

docs.google.com/document/d/e/2PACX-1vREkVPDxqlKrwnaQ7Et1EnaiCF-VhFXCwit7bGSomWKtGEfkxbuGhX4GP3cJ20LgllYfjzsjr2lyY5y/pub#kix.301alpimymwh

pubs.aip.org/aip/acp/article-abstract/707/1/381/719611/Programming-the-Hilbert-curve

en.wikipedia.org/wiki/Z-order_curve

en.wikipedia.org/wiki/Hilbert_curve

民主化 AI:MosaicML 对开源 LLM 运动的影响

原文:towardsdatascience.com/democratizing-ai-mosaicmls-impact-on-the-open-source-llm-movement-7972ff12dd92

高质量基础模型如何为整个行业开启新可能性……

Cameron R. Wolfe, Ph.D.Towards Data Science Cameron R. Wolfe, Ph.D.

·发布于 Towards Data Science ·13 min 阅读·2023 年 10 月 15 日

--

(照片由Raimond Klavins 提供,来源于Unsplash

最近,我们回顾了许多关于创建开源大型语言模型(LLM)的当前研究。在所有这些工作中,模型是通过一个包含几个简单组件的共同框架创建的;见下文。

创建和优化大型语言模型(来自[12, 13])的多步骤过程

尽管这个框架有几个步骤,但第一步可以说是最重要的。通过广泛的高质量预训练创建一个更强大的基础模型,可以在通过监督微调(SFT)和从人类反馈中进行强化学习(RLHF)时实现更好的结果。然后,下游应用由于使用了改进的模型而表现得更好。预训练(基础)模型是任何 LLM 应用程序的共同起点。

直到最近,开源基础模型要么与其专有对手相比表现不佳,要么只能用于研究。然而,这种情况随着 MosaicML 发布的 MPT-7B 和 MPT-30B [1, 2]的出现发生了变化。这些开源基础模型达到了令人印象深刻的性能水平,商业使用免费,并且配备了用于训练、微调和评估 LLM 的完整高效软件套件。这些开源工具使得可以以显著降低的成本探索多种专业应用场景,从而成为 AI 从业者的强大资源。

更快的 LLM 和更长的上下文长度

MPT-7B/30B 模型基于典型的 仅解码器变换器 架构。然而,进行了一些关键修改,包括:

在本节中,我们将深入了解这些组件,每个组件的工作原理,以及它们对 LLM 的影响。要全面理解本节的细节,可能需要回顾以下概念:

  • 自注意力 [link]

  • 因果自注意力(由仅解码器 LLM 使用) [link]

ALiBi 实现了上下文长度的外推

在 LLM 中嵌入一个令牌序列(由作者创建)

在一个普通的变换器架构中,我们通过首先 令牌化 原始文本并查找每个令牌的嵌入(令牌化器词汇表中的每个令牌都有一个唯一的嵌入)来创建一个输入令牌序列。然后,我们将位置嵌入添加到每个令牌嵌入中,从而将位置信息注入到序列中每个令牌的嵌入中;见上文。这是必要的,因为自注意力操作对序列中每个令牌的位置是无感知的。尽管位置嵌入工作良好,但有一个大问题:它们难以推广到比训练期间见过的更长的序列

(来自 [6])

解决方案。 带有线性偏差的注意力ALiBi)[6]通过完全去除位置嵌入来解决这个问题。相反,位置信息通过在自注意力操作中对键-查询注意力分数添加一个加性惩罚注入到变换器中;见上文。我们应当回顾,自注意力计算序列中每对令牌之间的注意力分数。ALiBi 通过为这个分数添加一个与令牌对之间的距离成比例的静态、非学习的偏差(或惩罚)来操作;见下文。

计算特定令牌对的键-查询注意力分数(由作者创建)

这种方法之所以有影响,是因为它依赖于令牌之间的成对距离,而不是序列中令牌的绝对位置。这一量度不那么依赖于基础序列的长度,并允许 ALiBi 对比训练期间看到的序列更长的序列进行更好的泛化;见下文。正如我们将看到的,使用 ALiBi 的 MPT 模型可以训练以支持比大多数开源替代方案更大的上下文长度甚至可以推断到长度为 84K 标记的序列

(来自 [6])

更快的推理

由于使用低精度层归一化和 FlashAttention [7],MPT 模型具有非常快的训练和推理速度(即,比使用标准HuggingFace 推理管道的同等规模的 LLaMA 模型快1.5-2X)。更进一步,这些模型的权重可以迁移到像FasterTransformerONNX这样的优化模块,以实现更快的推理。

低精度层归一化。简单来说,低精度层归一化以 16 位精度执行LayerNorm模块的操作。尽管这种方法在某些情况下可能导致损失峰值,但它改善了硬件利用率,从而加速了训练和推理。使用低精度层归一化对模型的最终性能也几乎没有影响。

Flash attention。在其经典形式中,自注意力是一个O(N²)操作,其中N是输入序列的长度。为了提高该操作的效率,已经提出了许多近似注意力变体,例如:

大多数这些技术的目标是推导出一种“线性”注意力变体——一种具有O(N)复杂度的类似/近似操作。尽管这些变体在理论上减少了FLOPs许多在实际场景中并没有实现任何墙钟速度的提升!Flash attention 通过以 IO 感知的方式重新构建注意力操作来解决这个问题;见下文。

(来自 [7])

FlashAttention 的硬件实现细节超出了本文的范围。然而,结果高效的注意力实现带来了各种积极的好处。例如,FlashAttention 可以:

  • 将 BERT-large [10]的训练时间提高 15%

  • 将 GPT-2 的训练速度提高3X [11]

  • 为 LLMs 启用更长的上下文长度(由于更好的内存效率)

关于 FlashAttention 的更多细节,请查看 这里

MPT-7B:一个商业可用的 LLaMA-7B

训练 MPT-7B 模型及其各种衍生模型的总计算成本(来自 [1])

在 [1] 中提出的 MPT-7B 是一个开源、商业可用的语言基础模型,性能广泛匹配类似规模的开源基础模型,如 LLaMA-7B [3](它是不可商业使用的!)。根据 Chinchilla [4] 的经验教训,MPT-7B 在一个大的语料库上进行预训练——总计一万亿个标记——这些文本是多样的、公开可用的。用于训练、微调和评估 MPT-7B 的代码完全开源,使得这个模型成为实践者们调整自己专门化大语言模型以解决各种不同下游应用的一个很好的资源或起点

创建基础模型

由于其修改后的架构,MPT-7B 具有几个理想的属性,例如能够泛化到更长的上下文长度和更快的推理速度。此外,我们在 [1] 中看到,这种修改后的架构消除了 MPT-7B 预训练过程中的损失峰值,使得模型可以在没有任何人工干预的情况下进行预训练(假设任何硬件故障都在大语言模型的训练代码中自动处理)!

MPT-7B 在其预训练过程中仅经历硬件故障,这些故障可以自动解决(来自 [1])

训练过程。 尽管大多数大语言模型使用 AdamW 优化器 进行训练,MPT 采用了 Lion 优化器 [8],这提高了训练过程的稳定性。整个训练框架基于 PyTorch 的 完全分片数据并行 (FSDP) 包,不使用管道或张量并行。简单来说,MPT-7B 的训练框架是 完全开源的,使用了流行/常见的组件,但进行了几个有用的修改,以提高训练的稳定性。

(来自 [1])

数据。 用于训练 MPT-7B 的文本语料库是由公开数据集(主要是英语数据)定制混合而成;见上文。在 [1] 中,我们看到用于训练 MPT-7B 的数据量非常大 — 总计 1T 个标记。作为对比,开源模型如 PythiaStableLM 分别在 300B 和 800B 个标记上进行预训练。有趣的是,我们看到 [1] 的作者采用了一种非常特定的分词器 — GPT-NeoX-20B BPE 分词器 — 进行模型训练。这个分词器是受欢迎的,因为它在一个大规模、多样化的数据集上进行训练,并且比其他流行的分词器更一致地处理空格。

“这个分词器具有许多令人满意的特性,其中大多数与代码分词相关:在包括代码的数据混合上训练,应用一致的空格分隔(不像 GPT2 分词器根据前缀空格的存在不一致地进行分词),并且包含了对重复空格字符的处理。” — 来源于 [1]

作为从业者,我们应该始终关注模型所使用的分词器。这个选择 — 尽管通常被忽视或忽略 — 会对我们的结果产生极大影响。例如,基于代码的语言模型需要一个以特定方式处理空格的分词器,而多语言模型则有各种独特的分词考虑因素。

它的表现如何?

(来源于 [1])

MPT-7B 在标准基准测试中与各种开源模型进行比较(例如,LLaMA,StableLMPythiaGPT-NeoXOPTGPT-J)。如上所示,LLaMA-7B 相较于开源替代方案取得了显著的改进,而 MPT-7B 的表现与 LLaMA 相匹配或超过其表现。近期的开源大型语言模型比其前身要好得多!LLaMA-7B 和 MPT-7B 相比其他开源模型都是极其高效的基础模型。然而,MPT-7B 可以用于商业用途,而 LLaMA 仅能用于研究。

MPT-7B 的衍生模型

除了发布 MPT-7B 基础模型外,[1] 的作者还利用 MPT 的开源训练代码来微调多个不同的基础模型衍生版本(见下文)。与从头开始预训练一个大型语言模型相比,微调的成本非常低(即,时间和成本减少 10–100 倍,甚至更多)。因此,开发 MPT-7B 的大部分时间和精力都投入到了创建基础模型上,该模型作为微调下述模型的起点。

(来自 [1])

MPT-StoryWriter-65K(商业版) 是 MPT-7B 的一个版本,经过了在非常长上下文长度的数据上进行微调。特别是,文献中的作者 [1] 利用了包含虚构书籍摘录的 books3 dataset,以创建一个用于微调的数据集(即仅使用下一个令牌预测目标),上下文长度为 65K 令牌。由于使用了 ALiBi [6] 和 FlashAttention [7],MPT-StoryWriter-65K 可以有效地在如此大的输入上进行训练,能够处理《了不起的盖茨比》的全部内容(68K 令牌)以编写后记(见上文),甚至可以推广到处理长度达 84K 令牌的序列。

“我们期望 LLMs 将输入视为需要遵循的指令。指令微调是训练 LLMs 以这种方式执行指令跟随的过程。通过减少对巧妙提示工程的依赖,指令微调使 LLMs 更加易于访问、直观和立即可用。” ——来自 [1]

MPT-7B-Instruct(商业版)MPT-7B-Chat(非商业版) 是 MPT-7B 的指令调整版本。指令版是在 Dolly-15KHelpful and Harmless dataset 数据上进行微调的,而聊天模型则使用了来自 ShareGPTHC3AlpacaEvol-Instruct 等来源的数据进行训练。如上文所述,指令调整是指在预训练语言模型的基础上,对其风格或行为进行修改,使其更加直观和易于访问,通常着重于指令跟随或问题解决。

MPT-30B:一个开源的 GPT-3 替代品

MPT-30B 在所有性能类别上都优于 MPT-7B(来自 [2])

在其提出后不久,MPT-7B 模型在 AI 研究界获得了显著认可 —— 它甚至在 HuggingFace 上累计下载量超过了 300 万次!MPT-7B 的成功并不令人意外,因为它为极受欢迎的 LLaMA-7B 模型提供了一个商业上可用的替代品。借此势头,MosaicML 的研究人员推出了一个稍大的模型,称为 MPT-30B [2],该模型被发现能与 GPT-3 [9] 的表现相匹敌或超过。因此,MPT-30B 的提出延续了为任何人提供强大基础 LLM 的商业可用版本的趋势。

深入了解 MPT-30B

MPT-30B 共享与 MPT-7B 相同的修改过的解码器架构,使用了 FlashAttention 和低精度层归一化,以提高效率。总体而言,这些模型非常相似,除了 MPT-30B 更大一些。值得注意的是,MPT-30B 的大小选择得非常具体。这个大小的模型可以在单个 GPU 上使用 8 位或 16 位精度进行部署,而像 Falcon-40B 这样的替代品稍微大了一些,无法以这种方式部署。

(来自 [2])

有什么不同? MPT-30B 与 MPT-7B 主要有两方面的不同:

  • 预训练数据混合

  • 上下文长度

MPT-30B 的预训练数据集类似于 MPT-7B,但数据的混合略有不同;见上文。此外,MPT-30B 部分使用 8K 上下文长度进行训练,而大多数其他开源模型(例如,LLaMA、Falcon 和 MPT-7B)使用较短的 2K 令牌上下文长度进行训练。更具体地说,我们看到 MPT-30B 使用一种训练课程,模型首先使用 2K 上下文长度进行训练,然后在训练后期切换到 8K 上下文长度。在第二阶段,数据集中代码的比例增加了 2.5X,使得最终模型在编程能力上比其他开源 LLM 更强。

模型变体。 除了 MPT-30B 基础模型外,[2] 中的作者还发布了 MPT-30B 的聊天和指令变体。这些模型遵循类似于 MPT-7B-Instruct 和 MPT-7B-Chat 的训练策略。然而,这些模型用于指令调优的数据显著增加。有趣的是,发现 MPT-30B-Chat 在编程技能上表现出色!

它表现得好吗?

(来自 [2])

除了在各种类别上优于 MPT-7B 外,MPT-30B 的表现与顶级开源替代品如 LLaMA-30B 和 Falcon-40B 相当;见上文。总体而言,我们发现 MPT-30B 在解决基于文本的任务时落后于 Falcon 和 LLaMA,但在编程相关问题上通常优于这些模型(可能是因为预训练数据集中代码的比例较高!)。值得注意的是,我们发现 MPT-30B 在各种上下文学习任务上优于 GPT-3;见下文。

(来自 [2])

结合这些结果来看,像 MPT-30B 这样的模型可能为开源 LLM 应用奠定了基础,能够与专有系统的质量相媲美。我们所需要的只是足够的精细调整和微调!

最终备注

“你可以训练、微调和部署你自己的私人 MPT 模型,可以从我们的检查点之一开始,或从头开始训练” — 来自 [2]

MosaicML 提供的基础模型是开源 LLM 社区的一大进步,因为它们提供了与 LLaMA 和 GPT-3 等流行基础模型相当的商用 LLM。然而,这一开源产品不仅仅限于 MPT 模型本身——它还包括一个 用于训练 LLM 的开源代码库,各种 在线演示 等。

(来自 [2])

MPT-7B 和 30B 模型配备了一个完整的开源工具生态系统,可用于创建专业化/个性化的 LLMs。鉴于创建基础模型是任何基于 LLM 系统中最昂贵的部分(见上文),这些工具显著降低了使用 LLM 的门槛,并提供了一个解决各种下游应用的起点。记住,当我们有一个特定的任务需要解决时,微调是极其有效的(即,仅通过提示一个更通用的 LLM 难以超越)!

与我联系!

非常感谢您阅读这篇文章。我是 Cameron R. WolfeRebuy 的 AI 总监。我研究深度学习的经验和理论基础。如果您喜欢这个概述,请订阅我的 Deep (Learning) Focus 新闻通讯,在其中我通过从基础上概述相关主题帮助读者理解 AI 研究。您还可以在 XLinkedIn 上关注我,或者查看我在 medium 上的 其他文章

参考文献

[1] “介绍 MPT-7B: 开源商用 LLM 的新标准。” MosaicML,2023 年 5 月 5 日,www.mosaicml.com/blog/mpt-7b.

[2] “MPT-30B: 提升开源基础模型的标准。” MosaicML,2023 年 6 月 22 日,www.mosaicml.com/blog/mpt-30b.

[3] Touvron, Hugo, 等。“Llama: 开放且高效的基础语言模型。” arXiv 预印本 arXiv:2302.13971 (2023)。

[4] Hoffmann, Jordan, 等。“训练计算最优的大型语言模型。” arXiv 预印本 arXiv:2203.15556 (2022)。

[5] Zhang, Susan, 等。“OPT: 开放的预训练变换器语言模型。” arXiv 预印本 arXiv:2205.01068 (2022)。

[6] Press, Ofir, Noah A. Smith, 和 Mike Lewis。“训练短期,测试长期:具有线性偏置的注意力机制实现输入长度外推。” arXiv 预印本 arXiv:2108.12409 (2021)。

[7] Dao, Tri, 等。“Flashattention: 快速且内存高效的准确注意力机制,具有 IO 感知能力。” 神经信息处理系统进展 35 (2022): 16344–16359。

[8] 陈向宁等人。“优化算法的符号发现。” arXiv 预印本 arXiv:2302.06675 (2023)。

[9] 布朗汤姆等人。“语言模型是少样本学习者。” 神经信息处理系统进展 33 (2020): 1877–1901。

[10] 德夫林雅各布等人。“Bert: 深度双向变换器的预训练以实现语言理解。” arXiv 预印本 arXiv:1810.04805 (2018)。

[11] 拉德福德亚历克等人。“语言模型是无监督的多任务学习者。”

[12] 欧阳龙等人。“通过人类反馈训练语言模型以遵循指令。” 神经信息处理系统进展 35 (2022): 27730–27744。

[13] 格莱斯艾米莉亚等人。“通过有针对性的人工评判改进对话代理的对齐。” arXiv 预印本 arXiv:2209.14375 (2022)。

使用 AWS SageMaker AutoML 实现机器学习的民主化

原文:towardsdatascience.com/democratizing-machine-learning-with-aws-sagemaker-automl-150299c70396

概述

Patrick BrusTowards Data Science Patrick Brus

·发表于 Towards Data Science ·16 分钟阅读·2023 年 4 月 25 日

--

图片由 Joshua Sortino 提供,来源于 Unsplash

介绍

AI 目前仍然是最热门的话题之一,尤其是随着 ChatGPT 的崛起。许多公司现在正尝试利用 AI 从数据中提取有用的见解,以优化他们的流程或开发更好的产品。

然而,构建有效的 AI 模型需要在不同领域具备大量专业知识,如数据预处理、模型选择、超参数调优等。所有这些领域都可能耗时且需要专业知识。

这就是 AutoML 发挥作用的地方。AutoML 自动化了构建 AI 模型所需的许多领域。

AutoML 正迅速成为企业和数据科学家们喜爱的解决方案。它使组织能够利用 ML 和 AI 做出明智的决策,而无需成为数据科学专家。随着企业对 ML 需求的增加,AutoML 提供了一种简单高效的方式来创建准确的模型,无论个人的专业知识水平如何。

在本文中,我们将考察市场上一个非常流行的 AutoML 工具——AWS SageMaker AutoML,并展示如何利用它解决复杂的 ML 使用案例。

我将用传统的手动方法训练一个模型,并将其结果与 AWS SageMaker AutoML 产生的结果进行比较。

我将使用 Kaggle 上的信用卡欺诈检测数据集进行对比 [1]。你可以在这里找到数据集。

在本文结束时,你将清楚了解 AutoML 如何帮助利用 ML 驱动有意义的见解并做出明智的决策。

AWS SageMaker AutoML

图 1:AWS SageMaker AutoML 概述(作者提供的图像,基于 [2])。

图 1 展示了 AWS SageMaker AutoML 解决的不同步骤概述。

其包含以下步骤:

  1. 数据准备: 你可以轻松地将数据上传到 Amazon S3。一旦数据上传完毕,SageMaker AutoML 会自动分析数据,以检测任何缺失值、异常值或需要转换的数据类型。

  2. 自动模型创建: AWS SageMaker AutoML 自动训练多个机器学习模型,使用不同的超参数和算法,以确定最适合你数据的模型。它还提供了自动模型调优,调整所选模型的超参数以进一步优化其性能。它还为你创建运行模型选择的笔记本,以便你可以全面了解在此过程中执行了什么。

  3. 模型部署: 一旦选择了最佳模型,AWS SageMaker 提供将模型部署到 SageMaker 端点或批处理转换作业的选项,在那里它可以用于对新数据进行预测。此外,AWS SageMaker Model Monitor 可以用于在出现问题(如数据漂移、概念漂移等)时发出警报。它还提供了重新训练模型的新数据、更新模型的超参数或算法以提升性能的工具。

AWS SageMaker AutoML 提供了一个 Python SDK,可以用于启动 AutoML 作业,并且有一个 GitHub 仓库,其中包含了各种不同的笔记本示例,展示了如何利用 AutoML SDK 处理具体的机器学习用例。

市场上还提供了其他强大且知名的 AutoML 工具,如 Google Cloud AutoML 和 H2O.ai,它们也有各自独特的优势和劣势。

Google Cloud AutoML 以其易用性和直观的界面而闻名,这使其非常适合机器学习新手,并且对编码要求不高。Google Cloud AutoML 支持图像数据、视频数据、文本数据和表格数据。你可以在 这里 了解更多信息。

H2O.ai 以其速度和可扩展性而闻名,使其成为处理大数据集和复杂模型的良好选择。H2O.ai 提供 R、Python 或网页 GUI 的接口。你可以在 这里 了解更多关于其功能的信息。

手动训练方法

在使用 AWS SageMaker AutoML 来生成信用卡数据集的分类器之前,我首先以传统方式训练一个模型:从头开始做所有事情。

这将有助于我建立一个基准,并将我的方法与 AWS 的 AutoML 方法进行比较,期望 AWS SageMaker AutoML 能超越我的手动半优化方法。

对于手动方法,我使用了 Scikit-learn,并将通过下一章中强调的步骤进行操作。

你也可以在我的 GitHub 仓库中找到完整的笔记本 这里

数据准备

我从 CSV 文件中加载数据集,首先检查数据集的分布。这显示数据集高度不平衡,只有0.17%的样本是正例。

数据集本身不包含任何缺失值。

然后我将数据集按 80/20 的比例拆分为训练集和测试集,并将数据缩放到 0–1 的范围内,同时标准化器仅在训练集上进行训练,以避免一些过于乐观的结果。

下面可以找到这些步骤的代码。

import sys
import os
import numpy as np
import seaborn as sns
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

# step 1: Load the dataset from the csv file. 
# You can download the dataset from Kaggle
filepath = os.path.join("data", "creditcard.csv")
df = pd.read_csv(filepath)

# step 2: check data imbalance on target
count_neg_class = np.sum(df["Class"] == 0)
count_pos_class = np.sum(df["Class"] == 1)

print(f"There are {count_neg_class} negative samples ({np.round(100 * count_neg_class / num_samples, 2)} % of total data).")
print(f"There are {count_pos_class} positive samples ({np.round(100 * count_pos_class / num_samples, 2)} % of total data).")

# step 3: split data into train and test set
X = df.drop(columns="Class").to_numpy()
y = df["Class"].to_numpy()

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

# step 4: scale the data
scaler = StandardScaler()

X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)

通常,广泛的探索性数据分析(EDA)也会是数据准备步骤的一部分。但是为了这个实验,我没有进行广泛的 EDA,因为数据集已经为 ML 做好了充分的准备。

但请记住,这部分对于你的 ML 训练的成功也是至关重要的,并且通常需要一些时间。

模型选择

下一步是找出哪种 ML 算法最适合数据。为此,我首先使用逻辑回归训练一个非常简单的基准模型。这是为了有一个简单的模型,我可以将其与更复杂的算法进行比较。

目标应始终是:保持简单!不要从神经网络开始,如果一个简单的算法,比如逻辑回归,能够完成任务,神经网络可能更难以解释。

逻辑回归模型的 F1-Score 为70.6%。我使用 F1-Score 来评估这个数据集,因为它高度不平衡,准确率不会提供有意义的模型评估,因为仅预测所有类别为负例就已经会导致超过99%的准确率!

下面可以找到训练基准模型的代码。

from sklearn.metrics import accuracy_score
from sklearn.metrics import f1_score
from sklearn.metrics import precision_score
from sklearn.metrics import recall_score
from sklearn.metrics import confusion_matrix

from sklearn.linear_model import LogisticRegression

log_model = LogisticRegression()
log_model.fit(X_train, y_train)

preds = log_model.predict(X_test)
print(f"Test Acc: {accuracy_score(y_test, preds)}")
print(f"Test F1-Score: {f1_score(y_test, preds)}")
print(f"Test Precision: {precision_score(y_test, preds)}")
print(f"Test Recall: {recall_score(y_test, preds)}")

好的,我们现在有了基准。接下来尝试不同的分类算法及其默认超参数,看看哪个算法在数据上表现最好。

我使用了 5 折交叉验证来训练以下每个模型:

  • 决策树

  • 支持向量机

  • k-近邻

  • 随机森林

  • 自适应提升

from sklearn.tree import DecisionTreeClassifier
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.ensemble import AdaBoostClassifier
from sklearn.model_selection import cross_val_predict

dict_models = {
    "Decision Tree": DecisionTreeClassifier(),
    "SVM": SVC(),
    "Nearest Neighbor": KNeighborsClassifier(),
    "Random Forest": RandomForestClassifier(),
    "Ada Boost": AdaBoostClassifier()
}

# train all models by using the models dictionary
results_dict = {}
for model_name, model in dict_models.items():
    print(f"Start training {model_name}...")
    preds = cross_val_predict(model, X_train, y_train, cv=5)

    f1 = f1_score(y_train, preds)
    precision = precision_score(y_train, preds)
    recall = recall_score(y_train, preds)

    print(f"F1-Score: {f1}")
    print(f"Precision: {precision}")
    print(f"Recall: {recall}")
    print("\n\n")
    results_dict[model_name] = (f1, precision, recall)

# create a pandas dataframe with the results on sort on f1-score
df_results = (pd.DataFrame.from_dict(results_dict, orient="index", columns=["F1-Score", "Precision", "Recall"])
             .sort_values(by="F1-Score", ascending=False))
df_results

随机森林算法以86.9%的 F1-Score 取得了最佳结果,其次是最近邻算法,F1-Score 为84.8%。不错!

下一步是对获胜者(随机森林)进行微调。

为此,我选择了一些要尝试的超参数值,并使用随机化交叉验证搜索来找到最佳超参数组合,从而获得最佳模型。

这次评估的代码:

from sklearn.model_selection import RandomizedSearchCV

params = {
    "n_estimators": [10, 20, 30, 60, 80, 100],
    "criterion" : ["gini", "entropy"],
    "max_depth" : [4, 5, 10, None],
    "min_samples_split": [2, 4, 6],
    "class_weight": [None, "balanced", "balanced_subsample"]
}

clf_rf = RandomizedSearchCV(RandomForestClassifier(), params, n_iter=50, scoring="f1", cv=5, verbose=1, n_jobs=-1)
clf_rf.fit(X_train, y_train)

# let's print the best score and save the best model
print(f"Best f1-score: {clf_rf.best_score_}")
print(f"Best parameters: {clf_rf.best_params_}")
best_random_forest_model = clf_rf.best_estimator_

最佳模型的得分多多少少与我在没有调整超参数的情况下获得的模型相同。真是浪费时间 😉

模型评估

最后但同样重要的是,我在留出的测试集上评估我的最终模型,以查看它在真实世界数据上的表现。

final_preds = best_random_forest_model.predict(X_test)

f1 = f1_score(y_test, final_preds)
precision = precision_score(y_test, final_preds)
recall = recall_score(y_test, final_preds)

print(f"F1-Score: {f1}")
print(f"Precision: {precision}")
print(f"Recall: {recall}")

这给了我一个82%的 F1 分数,接近我们的验证结果,但略低一些。

我知道通过更多地调整模型可以获得更多收益。但本文的目标只是做一些基本的机器学习,并将结果与 AutoML 进行比较,以查看 AutoML 的表现有多好,以及与仅自己做基础训练相比,运行 AutoML 作业的工作量有多大。

使用 AWS SageMaker AutoML 进行训练

好了,现在我有了基准,可以尝试使用 AWS SageMaker AutoML 以更少的努力获得更好的模型。

我将再次指导你完成不同的步骤,并提供所有这些步骤的代码片段。你还可以在我的 GitHub 仓库这里找到完整的笔记本。

数据上传

为了使 SageMaker AutoML 正常工作,数据需要存储在 s3 中。因此,我首先创建一个桶,然后将 CSV 文件上传到这个桶中(Gif 1)。

Gif 1:创建 s3 桶并将 CSV 文件上传到此桶中(Gif 由作者提供)。

设置 SageMaker 笔记本

下一步是设置我可以在其中运行 AutoML 作业的环境。

为了实现这一点,我首先在 SageMaker 中创建一个笔记本,然后在其中创建和运行代码。

在 AWS 中,其他服务对服务的访问由 IAM 角色处理。SageMaker 笔记本附带了一个带有默认访问权限的 IAM 角色。但为了访问我之前创建的 s3 桶,我首先必须明确调整附加到该角色的策略。

Gif 2 展示了创建笔记本的完整过程以及我如何调整笔记本角色的策略。质量不太好,但我仍然认为看到所采取的操作顺序是有价值的。此外,我还添加了一些创建笔记本时的具体设置截图(图 2),并在我的 GitHub 仓库这里添加了我附加到笔记本角色的完整策略。

Gif 2:创建笔记本的过程以及如何调整附加 IAM 角色的策略(Gif 由作者提供)。

图 2:AWS SageMaker 笔记本创建设置(图片由作者提供)。

运行 AWS SageMaker AutoML 作业

现在我终于可以开始运行一些代码了。

我运行的代码大多是从这个AWS 教程笔记本中复制并调整过来的。

首先,我将数据从 s3 加载到 Pandas 数据框中,并设置一些 SageMaker AutoML 之后所需的一般变量。

import numpy as np 
import pandas as pd
import boto3
import sagemaker
import os, sys

# get some variables required for AutoML later
sess   = sagemaker.Session()
bucket = sess.default_bucket()                     
region = boto3.Session().region_name
prefix = 'sagemaker/fraud-detection-auto-ml'
# Role when working on a notebook instance
role = sagemaker.get_execution_role()

# get some sagemaker clients
sm = boto3.Session().client(service_name='sagemaker',region_name=region)
sm_rt = boto3.Session().client('runtime.sagemaker', region_name=region)

# load data from s3
bucket_data = 'patrick-fraud-detection-ml-kaggle'
filename = 'creditcard.csv'
s3 = boto3.client('s3') 
obj = s3.get_object(Bucket=bucket_data, Key=filename) 
df = pd.read_csv(obj['Body']) # 'Body' is a key word

然后,我将数据集分成训练集和保留测试集。我将使用后者来比较 AutoML 模型和我自己训练的模型。然后,我将数据上传到 SageMaker 创建的 s3 桶中,以便 AutoML 任务可以直接从 s3 访问它。

from sklearn.model_selection import train_test_split

train_data, test_data = train_test_split(df, test_size=0.2)

# Save to CSV files and upload to S3
train_file = "automl-train.csv"
train_data.to_csv(train_file, index=False, header=True, sep=',') # Need to keep column names
train_data_s3_path = sess.upload_data(path=train_file, key_prefix=prefix + "/train")
print("Train data uploaded to: " + train_data_s3_path)

# save test file only to a CSV file 
# -> will be send as POST request to inference endpoint later
test_file = "automl-test.csv"
test_data.to_csv(test_file, index=False, header=False, sep=',')

现在我可以设置 AutoML 任务并启动它。你可以在 SageMaker SDK 文档这里找到有关所需输入参数和设置的更多信息。

from time import gmtime, strftime, sleep
# setup config for input data
input_data_config = [{
      'DataSource': {
        'S3DataSource': {
          'S3DataType': 'S3Prefix',
          'S3Uri': 's3://{}/{}/input'.format(bucket,prefix)
        }
      },
      'TargetAttributeName': 'Class'  # the column we want to predict
    }
]

# setup config for output data
output_data_config = { 'S3OutputPath': 's3://{}/{}/output'.format(bucket,prefix) }

# Optional parameters
problem_type = 'BinaryClassification'
job_objective = { 'MetricName': 'F1' } # using F1 because of highly imbalanced dataset

# launch the AutoML job 
# but: limit to max. 20 candidates to limit overall execution time
timestamp_suffix = strftime('%d-%H-%M-%S', gmtime())

auto_ml_job_name = 'fraud-detection-' + timestamp_suffix

sm.create_auto_ml_job(AutoMLJobName=auto_ml_job_name,
                      InputDataConfig=input_data_config,
                      OutputDataConfig=output_data_config,
                      AutoMLJobConfig={"CompletionCriteria": {"MaxCandidates": 20}},
                      AutoMLJobObjective=job_objective,
                      ProblemType=problem_type,
                      RoleArn=role)

任务现在在后台运行,为你创建所需的 AWS 资源。

然后,你可以运行以下代码来跟踪 AutoML 任务的进度:

job_run_status = sm.describe_auto_ml_job(AutoMLJobName=auto_ml_job_name)['AutoMLJobStatus']

print(job_run_status)

while job_run_status not in ('Failed', 'Completed', 'Stopped'):
    describe_response = sm.describe_auto_ml_job(AutoMLJobName=auto_ml_job_name)
    job_run_status = describe_response['AutoMLJobStatus']

    print (describe_response['AutoMLJobStatus'] + " - " + describe_response['AutoMLJobSecondaryStatus'])
    sleep(60)

任务正在通过以下阶段:

  1. 数据分析

  2. 特征工程

  3. 模型调优

  4. 合并 AutoML 任务报告

AWS SageMaker 为你生成了两个笔记本。一个用于探索数据,另一个用于定义在数据集上进行评估的不同候选模型。如果你对 AWS SageMaker 在这些阶段运行的代码感兴趣,可以查看这些笔记本。

可以列出 AWS SageMaker 执行的所有实验,并且你也可以列出所有探讨过的候选模型。我在这篇文章中没有添加代码,但如果你对其工作原理感兴趣,可以在我的笔记本中找到代码。

在测试集上评估最佳候选模型

现在是时候在保留测试集上测试最佳候选模型了。对我来说,这是最有趣的部分,因为它显示了 AutoML 是否能比我的手动方法得分更高。

首先,我从 AWS SageMaker AutoML 任务中检索最佳候选模型。

best_candidate = sm.describe_auto_ml_job(AutoMLJobName=auto_ml_job_name)['BestCandidate']
best_candidate_name = best_candidate['CandidateName']

接下来,我将在 AWS 中将此模型作为端点进行托管,以便我可以将数据发送给它进行推断。

timestamp_suffix = strftime("%d-%H-%M-%S", gmtime())
model_name = best_candidate_name + timestamp_suffix + "-model"

# create a model in SageMaker that can be hosted as endpoint
model_arn = sm.create_model(
    Containers=best_candidate["InferenceContainers"], ModelName=model_name, ExecutionRoleArn=role
)

# setup config for endpoint (including instance type)
epc_name = best_candidate_name + timestamp_suffix + "-epc"
ep_config = sm.create_endpoint_config(
    EndpointConfigName=epc_name,
    ProductionVariants=[
        {
            "InstanceType": "ml.m5.2xlarge",
            "InitialInstanceCount": 1,
            "ModelName": model_name,
            "VariantName": "main",
        }
    ],
)

# deploy endpoint
ep_name = best_candidate_name + timestamp_suffix + "-ep"
create_endpoint_response = sm.create_endpoint(EndpointName=ep_name, EndpointConfigName=epc_name)

# wait until endpoint is ready for inference
sm.get_waiter("endpoint_in_service").wait(EndpointName=ep_name)

最后但同样重要的是,读取包含测试数据的 CSV 文件,并将数据发送到最终模型进行推断。然后,我将预测结果与真实值进行比较,并使用相当手动的方法来计算真正例、假负例、假正例和真正例。

老实说,我只是懒得自己实现一些东西,而是再次从 AWS SageMaker 教程笔记本中适配了代码,你可以在这里找到。

tp = tn = fp = fn = count = 0

with open('automl-test.csv') as f:
    lines = f.readlines()
    for l in lines[1:]:   # Skip header
        l = l.split(',')  # Split CSV line into features
        label = l[-1]     # Store 0/1 label
        l = l[:-1]        # Remove label
        l = ','.join(l)   # Rebuild CSV line without label

        response = sm_rt.invoke_endpoint(EndpointName=ep_name, ContentType='text/csv', Accept='text/csv', Body=l)

        response = response['Body'].read().decode("utf-8")
        #print ("label %s response %s" %(label,response))

        if '1' in label:
            # Sample is positive
            if '1' in response:
                # True positive
                tp=tp+1
            else:
                # False negative
                fn=fn+1
        else:
            # Sample is negative
            if '0' in response:
                # True negative
                tn=tn+1
            else:
                # False positive
                fp=fp+1
        count = count+1
        if (count % 100 == 0):   
            sys.stdout.write(str(count)+' ')

# get final scores
 # Confusion matrix

accuracy  = (tp+tn)/(tp+tn+fp+fn)
precision = tp/(tp+fp)
recall    = tp/(tp+fn)
f1        = (2*precision*recall)/(precision+recall)

print ("Accuracy: %.4f, Precision: %.4f, Recall: %.4f, F1: %.4f" % (accuracy, precision, recall, f1))

最终模型在保留测试集上达到了96%的 F1-Score!

这太棒了!相比之下,我用 Scikit-Learn 训练的模型仅达到了82%的 F1-Score。

AutoML 的不足之处

AutoML 确实强大,可以帮助加快 ML 开发周期。但也存在一些使用 AutoML 的不足之处,需要认识到。

AutoML 的主要限制之一是它可能是一种黑箱方法,因为它自动化了构建机器学习模型的大部分过程。这可能使数据科学家很难完全理解模型的工作原理,并可能限制他们微调模型或调试出现的问题的能力。

AWS 通过提供 Jupyter 笔记本来应对这一问题,展示了数据探索或探索不同 ML 候选模型等阶段的代码。这已经有助于获取一些见解,但如果代码或 AutoML 任务的结果出现任何问题,数据科学家无法对背后的代码进行更改,因为一切都是自动生成的。

使用 AutoML 的另一个潜在缺点是,它可能比传统的机器学习模型构建方法灵活性差。AutoML 优化了效率和易用性,但这可能以定制选项或处理专用数据集或模型的能力为代价。

在这篇文章中,我使用了一个非常简单的数据集,AWS SageMaker AutoML 能够训练出一个不错的候选模型。但如果遇到更具挑战性的数据集,AutoML 在这些数据集上的表现如何还不清楚。

个人发现

在这一章中,我想强调我在使用 AWS SageMaker AutoML 时的个人发现。

我首先意识到使用和设置 AWS SageMaker AutoML 相当复杂。我个人在使用 AWS 和编码方面有一定经验,但对于没有太多先前知识的人来说,使用 AWS SageMaker AutoML 的学习曲线可能过于陡峭。

我还认为文档质量不够好。我一开始在文档中很难找到关于端到端用例的内容。后来我找到了视频培训和 GitHub 上的示例笔记本,但我个人更喜欢书面的文档而非视频。

另外,请注意成本。我最初在 SageMaker AutoML 中进行了一些尝试,频繁运行 AutoML 任务,因为我想在其中尝试不同的东西。但这并没有像我预期的那样便宜,结果我在这个实验上的花费比计划的要多。

结论

在这篇文章中,我将 AWS SageMaker AutoML 与使用 Scikit-Learn 手动训练模型进行了比较。

作为数据集,我决定使用欺诈检测数据集,因为它由于高度不平衡而存在一些困难。

然后我手动尝试找出一个好的分类器,并在 AWS SageMaker AutoML 中做了同样的尝试。

我最终将两种方法的结果在一个保留测试集上进行了比较,其中 AWS SageMaker AutoML 的得分高于我的手动方法,达到了96%的 F1 分数,而我的手动方法为82%

这表明,使用 AWS SageMaker AutoML 在你的数据集上训练 ML 模型以快速生成一个可用的分类器是完全合理的。

甚至不需要在机器学习领域有专门的知识就可以使用 AWS SageMaker AutoML。

当然,我的手动方法也需要谨慎对待。

我没有花很多时间来优化我的最终分类器,我相当确定,如果再多投入一些时间,我会在保留测试集上获得更好的结果。

至少我希望如此 😉

但这篇文章的目的就是展示如何轻松地利用 AutoML 库为你的机器学习数据集创建一个分类器。

最终,这不一定是你投入到产品中的最终版本,但你可以至少做一个快速的初步概念验证,以查看你是否能从数据中获得有用的信息。

展望

迄今为止,我只深入了解了 AWS SageMaker AutoML,但肯定也很有兴趣查看其他服务,例如 Google Cloud AutoML。

我计划在不久的将来对 Google Cloud 的 Vertex AI 进行全面评估,然后会在 Medium 上写出我的发现。

然后我还可以具体讨论 Sagemaker 与 Google Cloud 的表现相比如何。

所以如果你不想错过这些内容,请关注我!

感谢你读到我的文章的最后!我希望你喜欢这篇文章。如果你想将来阅读更多类似的文章,关注我以保持更新。

如果你想了解更多关于机器学习和云计算的信息,请加入我的邮件列表。

联系方式

LinkedIn | GitHub

参考文献

[1]: 机器学习组 — ULB,“信用卡欺诈检测”,Kaggle,2018 年,数据库内容许可证 (DbCL) v1.0

[2]: AWS,Amazon SageMaker Autopilot(访问于 2023 年 4 月 1 日)

解密数据回填

原文:towardsdatascience.com/demystify-data-backfilling-cf1713d7f7a3

让我们谈谈数据工程师的噩梦

Xiaoxu GaoTowards Data Science Xiaoxu Gao

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

--

作者创建

作为数据工程师,我们每天都会遇到独特的挑战。但如果有一项令人畏惧的任务,那一定是回填。回填不当意味着处理时间过长、数据污染以及高额的云账单。对了,这也意味着你需要另一个回填任务来修复它。

完成第一次成功的数据回填是数据工程师的一项重要经历。— Dagster

回填任务需要一系列数据工程技能才能有效完成,例如验证结果的领域知识、运行回填任务的工具专长以及优化流程的数据库扎实理解。当这些元素交织在一个任务中时,可能会出现问题。

在本文中,我们将深入探讨数据回填的概念、其必要性以及高效实施的方法。无论你是回填新手还是经常对这类任务感到恐慌的人,这篇文章都会让你平静下来,帮助你重拾信心。

什么是回填?

回填是将过去缺失的数据填充到之前不存在的新表中,或用新记录替换旧数据的过程。它通常不是一个定期任务,仅在数据管道增量更新表时才需要。

常规任务与回填任务的区别(作者创建)

例如,一个表按date列分区。一个常规的每日任务只更新最新的 2 个分区。相比之下,一个回填任务可以更新表中最初的所有分区。如果常规任务每次都更新整个表,那么回填任务就不必要了,因为历史数据会通过常规任务自然更新。

那么,我们什么时候需要回填呢?

通常,有一些常见的场景。让我们看看你是否觉得这些场景熟悉。

  • 创建新表并希望填补缺失的历史数据

你刚刚开发了一个新的表来分析每月的电子商务销售业绩。数据管道只选择发生在给定月份的交易。在部署数据管道时,它仅为当前月份生成报告。要生成历史月度报告,需要一个回填作业。回填作业中更新的分区数量取决于业务需求和源表中的数据可用性。

使用回填作业开发新表(由作者创建)

  • 修复数据管道中的错误并希望更新整个历史数据

哎呀,你发现了联接逻辑中的错误。它应该是左联接而不是内联接。你迅速修复了这个问题,以确保数据质量,但过去的数据呢?它仍在使用左联接。这里的回填作业是纠正历史数据。

使用回填作业修复错误(由作者创建)

  • 数据管道停机并希望追赶

数据管道可能会经历几天的停机,从而导致数据缺口。一旦管道恢复,它需要赶上其计划的运行。幸运的是,大多数现代数据编排工具提供自动追赶功能,因此相比其他情况,需要的人工干预较少。

使用回填作业追赶缺失的数据(由作者创建)

你有其他场景吗?欢迎在评论中与我们分享。

从图表中可以看出,回填是一个耗时的工作,因为它涉及许多分区。为了防止不必要的复杂性,最佳做法是首先询问团队和利益相关者关于回填数据的预期用途以及是否确实需要回填。回填的时间范围是什么?利益相关者是否能从回填中获得长期收益?随着公司的增长,表格也会增长。最终,我们可能会达到回填整个表格变得不可行的地步,需要决定在哪里截断。

另一个需要考虑的重要问题是我们是否有权限修改历史数据。在某些情况下,修改像财务数据这样的历史数据对公司可能意义重大,特别是当这些数据经过审计时。更新历史记录前理解业务影响至关重要,因为没有人愿意涉及法律问题。

表分区

现在,让我们看看回填的技术方面。如果不提及表分区,就无法讨论回填。分区是增量表更新的一种方法。分区列将表划分为一组分区。回填作业会一个接一个地更新这些分区。分区也是一种并行单元,允许多个回填作业同时执行。

每个分区的大小与分区数量之间存在权衡。更多的分区会导致数据更为细粒度地划分,回填作业会更加有针对性和具体。然而,通常建议不要创建过小的分区(例如,小于 1G),因为这样可能无法有效利用资源。想象一下比较打开一个 1GB 的文件和打开 10 个分别为 100MB 的文件——前者通常更高效。另一方面,过大的分区可能导致回填作业时间过长,因为这可能涉及到超出范围的数据。

大分区和小分区之间的权衡(由作者创建)

每个数据仓库根据元数据的访问方式和资源的最佳利用情况有不同的推荐分区大小。此外,还需要考虑哪种粒度对表最有意义。例如,一种常见的分区策略是在date列上进行分区,其中我们期望数据在各天之间均匀分布。然而,如果每一天的数据量很小,那么另一种方法可以是按月份或年份进行分区。

回填策略

如前所述,回填作业需要数据工程技能的综合运用。成功的回填作业的大部分关键因素实际上是在作业本身开始之前就已经确定了。

将数据回填纳入初始表设计讨论

早期涉及回填讨论是一种良好的实践,因为这可能会影响表的设计,例如前面提到的分区策略。特别是当常规作业并不每次都更新整个表时,必须有一个回填计划,以便在需要时更新历史数据。

对于某些表,例如小型维度表,每次进行全表刷新可能是一个更为理想的选择,以避免需要回填。另一方面,对于具有一致线性增长的事实表,增量表更新更为可取,因为我们不希望云账单或服务器成本随着数据增长而线性增长。

使数据管道具有幂等性

幂等性指的是多次运行相同操作而不改变结果。这是每个数据工程师应该了解的基本数据管道设计原则。在代码更改之前,使用相同输入重新运行相同的 Airflow 任务应始终产生相同的输出。你不希望看到任何重复或不同的输出。因此,对于增量表更新,使用 replace 而非 append 模式以避免重复。此外,在转换逻辑中使用 Airflow 变量如 data_interval_end,而不是像 current_date() 这样的时间敏感函数,因为 current_date() 的输出会根据作业的执行时间而不同。

幂等性是成功回填的关键前提,它确保数据只根据预期的变化进行更改,而不受其他因素影响。在这个例子中,数据框中的 date 列始终表示预期的计划时间,而不是与任务的实际执行时间相关联。

def transform_data(data_interval_end):
    # do not use date.today()
    return pd.DataFrame(data={'date': [data_interval_end], 'quantity': [10]})

transform_task = PythonOperator(
    task_id='transform_data',
    python_callable=transform_data,
    op_args=[{{ data_interval_end}}],  
    provide_context=False,
    dag=dag,
)

对回填范围做出明智决策并执行

如果范围选择不当,回填作业可能会非常繁重。一个分区所花费的时间和金钱在回填作业中会被放大。你可以利用历史运行来提前估算成本和时间。如果估算超出预算,那么首先了解用例。这是为了进行一次性的分析吗?那么考虑在现有表上创建一个临时视图。范围对于用例来说是否太大?那么将分区大小减少到更可管理的水平。如果仍然太多,那么可能需要考虑使用更高效或更具成本效益的技术。

另一个非常重要的点是评估下游影响。在回填源表时,可能需要将回填扩展到下游表。我知道揭示所有隐藏连接可能很具挑战性。但如果这是你团队面临的重大挑战,考虑利用数据血缘工具系统性地识别所有下游依赖关系。

一旦范围定义好,就该采取行动了。幸运的是,许多数据工具原生支持回填。在 Airflow 中,你可以通过 Airflow UI 重新运行任务,也可以使用命令 airflow dags backfill。在 dbt 中,你可以使用命令 dbt run --full-refresh 或传递自定义变量,例如 dbt run -s my_model --vars '{"start":"2023-11-01"}'。像 DagsterMage 等其他工具也有自己运行回填作业的方法。

对于模式更改要小心。对于兼容的更改,如添加新列,许多数据工具会在回填作业中为第一个分区之前的记录填充空值。对于不兼容的更改,如删除列或更改数据类型,你需要重新创建整个表。

使用 DDL 或 DML 回填表格

好消息是,有替代方法可以回填表格,从而无需执行许多耗时的 Airflow 运行。实际上,我们往往只希望回填特定的列,而不是所有列。因此,针对无关列进行计算是资源的低效使用。

一个捷径是使用 DDL 或 DML 更新表格。例如,在quantity的变换从quantity = amount * price改为quantity = amount * price * exchange_rate的情况下。我们可以简单地使用UPDATE语句回填表格:

UPDATE my_table
SET quantity = amount * price * exchange_rate
WHERE date >= '2023-11-01'

在大多数情况下,这比在 Airflow 中运行回填作业更高效。对于不兼容的模式更改,如果重新创建整个表非常昂贵,可以考虑使用DDL删除列或更改数据类型。

并行回填作业

另一个优化技巧是将回填作业并行化。如果 10 个 Airflow 回填作业更新 10 个分区,它们可以在这些配置到位的情况下并行运行:

depend_on_past = False
max_active_runs = X # The maximum number of active DAG runs allowed for the DAG.
max_active_tasks = X # The total number of tasks that can run at the same time for a given DAG run.
concurrency = X # The maximum number of task instances allowed to run concurrently across all active DAG runs for a given DAG.
max_active_tis_per_dag = X # The maximum number of times that the same task can run concurrently across all DAG runs.

这种方法允许同时更新多个分区,消除了顺序等待的需要。然而,我们需要确保数据仓库支持并发写入并检查其并发级别。此外,分区之间不应有任何依赖关系,例如今天的分区不能基于昨天的分区进行计算。

常规运行中的回填

有时我们也希望在正常运行中自动“回填”表格。这是什么意思?例如,当常规批处理包含一些迟到的记录,需要对历史数据进行回溯更新时,就会发生这种情况。由于这种情况非常频繁,因此应将其纳入常规运行中,而不是手动触发它。

一个例子是统计电子商务中的累计总购买订单。现在,假设一种情况,客户在 11 月 1 日下了订单,但由于系统延迟,订单信息直到 11 月 3 日才被处理。当 11 月 3 日收到订单信息时,应该更新 11 月 1 日和 11 月 2 日的数据。

延迟订单的交易数据(由作者创建)

回填前后的汇总表(由作者创建)

在这种情况下,“内部回填”是由输入数据的更新触发的,而不是由转换逻辑触发的。根据记录的延迟情况,作业可能会更新多个分区。延迟越大,需要调整的分区就越多。因此,监控性能至关重要,可能需要实施另一种流程以防止常规作业过载。

# pseudo code
earliest_order_date = find_earliest_date_in_batch(new_batch)
partitions_to_be_updated = f"select * from summary where date >={earliest_order_date}"
# can be heavy
updated_partitions = update_historical_data(partitions_to_be_updated, new_batch)
update_table(updated_partitions)

回填后

哇,到目前为止有很多阅读内容。我很高兴你能看到这里。触发回填作业并不是过程的结束。我们必须积极监控性能,因为问题可能在过程中任何时候出现。逐个分区回填表的一个关键好处是,如果过程中出现问题,你可以灵活地从失败的分区恢复,而不是从头开始。

沟通是任何数据变更的关键。确保利益相关者参与过程。考虑在作业完成后创建脚本,自动发送通知并请求对回填表的所有用户进行验证。

结论

就是这样了!希望你喜欢,并以某种方式获得启发。回填作业是具有挑战性的,但它不应该是一个黑箱或让你感到害怕的东西。下次,不必在按下按钮前深呼吸并祈祷:))

对于已经熟悉回填的人。我希望你仍然从这篇文章中获得了一些见解。如果你有额外的技巧或窍门,请随时分享——我们很想听到你的声音!干杯!

参考

[## 数据与机器学习中的回填:入门 | Dagster 博客]

从糟糕的回填中恢复对任何数据工程师来说都是一个痛苦的经历。

dagster.io

揭示贝叶斯模型的奥秘:通过 SHAP 值揭示可解释性

原文:towardsdatascience.com/demystifying-bayesian-models-unveiling-explanability-through-shap-values-8405f618f4e0?source=collection_archive---------14-----------------------#2023-05-12

通过一个引人入胜的玩具示例探索 PyMC 的见解与 SHAP 框架

Shuyang XiangTowards Data Science Shuyang Xiang

·

关注 发表在 Towards Data Science · 6 分钟阅读 · 2023 年 5 月 12 日

--

贝叶斯模型与可解释性之间的差距

SHAP 值(SHapley Additive exPlanations)是一种基于博弈论的方法,用于提高机器学习模型的透明度和可解释性。然而,这种方法以及其他机器学习可解释性框架,鲜有应用于贝叶斯模型,而贝叶斯模型提供了捕捉参数估计不确定性的后验分布,而不是经典机器学习模型使用的点估计。

虽然贝叶斯模型提供了一个灵活的框架来整合先验知识、调整数据限制和进行预测,但遗憾的是,使用 SHAP 对其进行解释是困难的。SHAP 将模型视为一个游戏,将每个特征视为该游戏中的一个玩家,但贝叶斯模型不是一个游戏。它更像是一个包含来自后验分布的参数的游戏集合。当模型不仅仅是一个游戏时,我们该如何解释它?

本文尝试通过玩具示例使用 SHAP 框架解释贝叶斯模型。该模型建立在 PyMC 上,PyMC 是一个用于 Python 的概率编程库,允许用户通过简单的 Python API 构建贝叶斯模型,并使用马尔可夫链蒙特卡罗方法对其进行拟合。

主要思想是将 SHAP 应用于从贝叶斯网络生成的确定性模型的集合。对于每个特征,我们将从生成的确定性模型中获得一个 SHAP 值样本。可解释性将由所有获得的 SHAP 值样本提供。我们将通过一个简单的示例来说明这种方法。

所有实现都可以在这个笔记本中找到。

使用 PyMC 进行贝叶斯建模

数据集

考虑以下由作者创建的数据集,其中包含 250 个点:变量 y 依赖于 x1 和 x2,两个变量都在 0 到 5 之间变化。下图说明了数据集:

图片作者提供:数据集

让我们使用配对图快速探索数据。从中我们可以观察到以下几点:

  1. 变量 x1 和 x2 不相关。

  2. 两个变量在某种程度上都对输出 y 有贡献。也就是说,单一变量不足以获得 y。

图片作者提供:数据的配对图

使用 PyMC 进行建模

让我们使用 PyMC 构建一个贝叶斯模型。在不深入讨论任何统计学书籍中可以找到的细节的情况下,我们只需回顾一下贝叶斯机器学习模型的训练过程涉及根据观察到的数据和先验知识使用贝叶斯规则来更新模型的参数。

我们将模型的结构定义如下:

图片作者提供:模型结构

在定义了先验和似然之后,我们将使用 PyMC 标准采样算法 NUTS,该算法旨在自动调整其参数,例如步长和 leapfrog 步数,以实现对目标分布的有效探索。它通过树探索重复模拟点在参数空间中的轨迹,并确定是否接受或拒绝样本。此类迭代在达到最大迭代次数或达到收敛水平时停止。

你可以在下面的代码中看到,我们设置了先验,定义了似然,然后使用 PyMC 运行了采样算法。

让我们使用 PyMC 构建一个贝叶斯模型。贝叶斯机器学习模型训练涉及基于观察数据和先验知识更新模型参数,使用贝叶斯规则。我们不会在这里详细介绍,因为你可以在任何统计学书籍中找到。

我们可以定义如下的模型结构:

作者提供的图像:模型结构

对于上述定义的先验和似然,我们将使用 PyMC 标准采样算法 NUTS。该算法旨在自动调整其参数,如步长和跳跃步数,以实现对目标分布的高效探索。它重复进行树形探索,以模拟点在参数空间中的轨迹,并决定是否接受或拒绝样本。迭代在达到最大迭代次数或实现收敛水平时停止。

在下面的代码中,我们设置先验,定义似然,然后使用 PyMC 运行采样算法。

with pm.Model() as model:

    # Set priors.
    intercept=pm.Uniform(name="intercept",lower=-10, upper=10)
    x1_slope=pm.Uniform(name="x1_slope",lower=-5, upper=5)
    x2_slope=pm.Uniform(name="x2_slope",lower=-5, upper=5)
    interaction_slope=pm.Uniform(name="interaction_slope",lower=-5, upper=5)
    sigma=pm.Uniform(name="sigma", lower=1, upper=5)

    # Set likelhood.
    likelihood = pm.Normal(name="y", mu=intercept + x1_slope*x1+x2_slope*x2+interaction_slope*x1*x2, \
                           sigma=sigma, observed=y)
    # Configure sampler.
    trace = pm.sample(5000, chains=5, tune=1000, target_accept=0.87, random_seed=SEED)

下面的踪迹图展示了模型中参数的后验分布。

作者提供的图像:模型的后验

使用 SHAP 解释模型

我们现在希望在上述模型上实现 SHAP。注意,对于给定的输入(x1, x2),模型的输出 y 是条件概率。因此,通过从获得的后验中绘制一个样本,我们可以获得一个确定性的模型及其所有特征的 SHAP 值。或者,如果我们绘制一个参数样本的集合,我们将得到一个确定性模型的集合,因此,所有特征的 SHAP 值样本。

可以使用以下代码获得后验分布,我们每条链绘制 200 个样本:

with model: 
    idata = pm.sample_prior_predictive(samples=200, random_seed=SEED)
    idata.extend(pm.sample(200, tune=2000, random_seed=SEED)here

以下是后验数据变量的表格:

作者提供的图像:后验样本

接下来,我们为每个绘制的模型参数样本计算一对 SHAP 值。下面的代码对参数进行循环,为每个参数样本定义一个模型,并计算感兴趣的 x_test=(2,3)的 SHAP 值。

background=np.hstack((x1.reshape((250,1)),x2.reshape((250,1))))
shap_values_list=[]
x_test=np.array([2,3]).reshape((-1,2))
for i in range(len(pos_intercept)): 
  model=SimpleModel(intercept=pos_intercept[i],
                    x1_slope=pos_x1_slope[i], 
                    x2_slope=pos_x2_slope[i], 
                    interaction_slope=pos_interaction_slope[i],
                    sigma=pos_sigma[i])
  explainer = shap.Explainer(model.predict, background)
  shap_values = explainer(x_test)
  shap_values_list.append(shap_values.values)

输入的二维 SHAP 值的结果集如下所示:

作者提供的图像:SHAP 值样本

从上面的图表中,我们可以推断出以下内容:

  1. 两个维度的 SHAP 值大致形成一个正态分布。

  2. 第一个维度对模型有正贡献(中位数为-1.75),而第二个维度有负贡献(中位数为 3.45)。不过,第二个维度的贡献绝对值更大。

结论

本文探讨了 SHAP 值的使用,这是一种基于博弈论的方法,用于提高机器学习模型的透明度和可解释性,应用于贝叶斯模型。通过一个玩具示例演示了 SHAP 如何应用于贝叶斯网络。

请注意,SHAP 是模型无关的。因此,随着其实现方式的变化,未来可能可以直接将 SHAP 应用于贝叶斯模型本身。

揭示依赖关系及其在因果推断和因果验证中的重要性

原文:towardsdatascience.com/demystifying-dependence-and-why-it-is-important-in-causal-inference-and-causal-validation-4263b18d5f04

一步步了解依赖关系的概念及如何使用 Python 应用于验证有向无环图

Graham HarrisonTowards Data Science Graham Harrison

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

--

照片由 Ana Municio 提供,来源于 Unsplash

介绍

因果推断是数据科学的一个新兴分支,关注事件和结果之间的因果关系,它具有显著提升机器学习为组织创造的价值的潜力。

例如,传统的机器学习算法可以预测哪些贷款客户可能违约,从而实现对客户的主动干预。然而,尽管这个算法有助于减少贷款违约,但它并不了解违约发生的原因,而了解违约原因能够解决根本问题。在这种情况下,主动干预可能不再必要,因为导致违约的因素已经被彻底解决。

这就是因果推断的承诺,它具有为能够利用这一潜力的组织带来显著影响和成果的潜力。

有多种不同的方法,但最常见的方法通常是通过增加“有向无环图”来开始,这种图能够封装和可视化数据中的因果关系,然后使用因果推断技术提出“如果如何”的问题。

问题

封装数据中因果关系的有向无环图(DAG)通常由数据科学家和领域专家一起手动(或半手动)构建。因此,DAG 可能是错误的,这将使任何因果计算无效,导致错误的结论和可能的错误决策。

机会

存在一系列用于“因果验证”的技术(验证 DAG 是否与数据一致的过程),如果这些技术有效,它们可以最小化或消除 DAG 中的错误,从而确保计算和结论是无误的。

前进的道路

随机变量之间的统计学依赖概念可以用来确定 DAG 中存在的关系是否也存在于数据中;如果存在,则 DAG 更有可能是正确的,如果不存在,则更可能是错误的。

入门

我们需要一个示例 DAG 来解决问题,这个 DAG 具有足够的节点和链接,以便深入探索因果验证……

本文中将使用的示例 DAG — 作者图片

DAG 中的每个节点要么对其他节点产生因果影响,要么其他节点对其产生因果影响,箭头的方向表示因果影响的方向。例如,“B”的一个原因是“C”,而“C”的一个原因是“F”。

示例 DAG 是虚构的,因此节点的字母/名称并不重要,不过“X”意图代表“处理”,而“Y”代表“效果”,所有其他节点对 X 对 Y 的真实效果在实际例子中会产生一些因果影响,从而掩盖 X 对 Y 的真实效果。

请注意,浅蓝色节点没有输入(在因果术语中称为外生),而深蓝色节点有一个或多个输入(在术语中称为内生)。

为了开始,我们还需要一些与 DAG 匹配的数据。下面的数据集完全是合成的,由作者生成。它完全封装并匹配 DAG 所建议的结构,并且没有错误或故障关系……

与 DAG 相关的合成虚构数据集 — 作者图片

我们开始之前还需要一种扩展 pandas DataFrameSeries 类以添加自定义方法的方式,以便我们编写的代码既简洁又易于理解。

这里有一个我之前文章的链接,提供了一个关于如何扩展数据框以及为什么这样做很有用的端到端教程……

## 如何通过自定义方法扩展 Pandas DataFrames 以增强代码功能性和可读性

一个逐步指南,介绍如何通过自定义方法扩展 pandas DataFrames,包括实施的完整示例 …

[towardsdatascience.com

理解依赖性

依赖性的一个定义如下 …

两个随机变量之间的依赖性意味着一个变量的发生或值会影响另一个变量的发生或值。如果一个变量的发生或值提供了关于另一个变量的发生或值的信息,则这两个变量被认为是相关的。

为了深入了解这一点,让我们再看一下我们的示例 DAG,并考虑影响节点 Y 的因果因素 …

突出显示影响 Y 的因果因素的 DAG — 作者提供的图片

在这个可视化中,我们可以看到节点 Y 是由 5 个不同因素造成的(因此也依赖于这些因素) — C、E、F、G 和 X。

现在让我们再看一下 DAG 所表示的数据 …

df_causal DataFrame 中前 5 行数据的回顾 — 作者提供的图片

这个合成数据集是作者为方便本文创建的,所以我知道节点 Y 与这些依赖因素之间的关系如下 …

Y = 3C + 3E + 2F + 2.5G + 1.5X + ε

(注:ε 代表误差项)

… 这一点可以通过选择一行(在这种情况下,我选择了第 3 行)并将该公式应用于数据来测试和验证 …

Y = -422.1827393983049, error term = 48.75941612372628

现在我们可以看到 Y 如何以及为何依赖于 C、E、F、G 和 X。如果这些依赖变量中的一个值发生变化,Y 的值也会变化。我们还可以从 DAG 中看到 Y 不应依赖(例如)节点 D,因为 D 和 Y 之间没有连接。

“Y 依赖于 C、E、F、G 和 X” 的表述可以用数学公式表示如下 …

作者提供的图片

… 以及“Y 与 D 独立” 的表述如下 …

作者提供的图片

⫫ 符号被称为“双向交叉符号”,但 ⫫̸ 符号没有一个普遍接受的名称,所以我个人习惯称之为“斜杠双向交叉符号”。

一些文章和文本使用单向交叉符号(⊥ 和 ⊥̸)代替双向交叉符号,但双向交叉符号更为常见,因此这是我在本文及相关 Python 代码中采用的标准。

回顾一下,两个随机变量之间的统计依赖意味着“一个变量的发生或数值影响另一个变量的发生或数值”,我们现在知道这在 DAG 中是如何可视化的,如何用数学公式表示(例如Y = 3C + 3E + 2F + 2.5G + 1.5X + ε),以及如何用斜杠双向箭头符号表示(例如 Y ⫫̸ C、E、F、G、X)。

从依赖关系到因果验证

因果推断通常从一组数据开始,然后用 DAG 扩充这些数据。虽然有些新兴技术可以从数据中反向工程生成 DAG,但它们并不准确或一致,因此开发 DAG 的最常见方法是询问领域专家他们认为的因果关系,然后验证或测试该 DAG 是否符合数据,并在验证失败时进行必要的修正。

DAG 已经提出 Y 依赖于 C、E、F、G 和 X,如果这种依赖在数据中存在,那么可以确信指向节点 Y 的因果链接是有效和正确的,并且可以用如下数学符号表示……

图片由作者提供

这个看起来吓人的公式其实非常容易理解。第一个斜杠双向箭头符号的“G”下标表示“在图中”(即 DAG),而第二个“D”下标表示“在数据中”(注意,我见过一些文献中使用“P”下标,但“D”对我来说更有意义,因此我采用了“D”)。

具备这些知识后,整个公式可以被解读为“如果图中的 Y 依赖于 C、E、F、G 和 X,那么 Y 在数据中也应该依赖于 C、E、F、G 和 X”。

因此,我们只需要一个 Python 机制来检测数据中的依赖关系。然后可以使用该机制检查 DAG 中具有传入连接的每个节点,如果在数据中检测到的依赖关系与 DAG 中的匹配,我们可以合理地确信没有虚假的连接(因果链接),并且 DAG 在这方面是数据的有效表示。

观察数据中的依赖关系

让我们开始可视化数据中 C、E、F、G 和 X 与我们关注的节点 Y 之间的关系……

图片由作者提供

右侧的图表将 Y 绘制在 x 轴上,将 C、E、F、G 和 X 分别绘制在 y 轴上。如果 Y 依赖于这些其他变量,那么改变其中一个变量的值应该会改变 Y 的值。这意味着应该存在一个正或负的系数,并且这些线应该表现出明显的斜率(向上或向下)。

由于存在明确的斜率,我们可以看到𝑌⫫̸ 𝐶,𝐸,𝐹,𝐺,𝑋是正确的,即 Y 在数据中依赖于 C、E、F、G 和 X**。

但是,如果没有依赖关系,那么改变变量的值对 Y 的影响很小或没有影响,系数应该接近零,且直线应该没有斜率,即应为平坦的。

通过将 Y 和 D 之间的关系添加到图表中可以证明这一点,记住在 DAG 中从 D 到 Y 没有因果联系,因此数据中 Y 和 D 之间也不应该有关系……

作者提供的图像

这正是我们期望的结果。C、E、F、G 和 X 都有明显的斜率,并且具有负或正的系数,清楚地表明如果这些变量的值发生变化,Y 的值也会发生变化,因此 Y 依赖于这些变量。

然而 D 的斜率平坦,系数非常小(仅为-0.029),因此改变 D 的值对 Y 的值几乎没有影响,因此因果关系𝑌⫫𝐷(Y 与 D 无关)在数据中存在。

在 Python 中实现数据中的依赖关系

检测数据中依赖关系的提议方法使用了来自 statsmodels.formula.api 库的 ols 类,以执行普通最小二乘(OLS)回归。

可以将 ols 类拟合到数据集中,然后提取和解释数据中存在的系数或斜率。以下是操作方法……

作者提供的图像

总结中的关键数据是中间的表格,该表格对变量 C、E、F、G 和 X 与 Y 之间的关系进行了一些分析。例如,ols 分析提出了以下内容——

𝑌=2.03𝐶+3.02𝐸+1.84𝐹+6.33𝐺+1.54𝑋−25.2

这与我用来创建数据集的公式相距不远,公式是……

𝑌=3𝐶+3𝐸+2𝐹+2.5𝐺+1.5𝑋+ε

最大的差异在于节点 G,但出于验证目的,系数的大小并不重要,只要系数存在且斜率不是平坦的即可。

除了coef列外,另一个值得关注的项目是P>|t|或 p 值列,其工作方式如下……

  1. 零假设是变量(例如 E)与因变量(例如 Y)之间没有关系。

  2. 如果 p 值大于 alpha(通常设定为 0.05),则拒绝零假设,即存在关系,即存在依赖性。

例如,E、G 和 X 的 p 值都低于 0.05,因此可以拒绝零假设并假定存在依赖关系。

那么 C 和 F 呢?C 的 p 值为 0.076,略高于 alpha,而 F 的值为 0.275,明显高于我们选择的 alpha(0.05)。

我们可以简单地增加 alpha,直到我们得出所有变量都是依赖的结论,但这种方法从长远来看效果不好,因为它会开始得出不存在的依赖结论。

当我进行最初开发时,我几乎在这一点上放弃了,认为 ols 不能作为检测我的 DAG 和数据中依赖关系的可靠方法,但随后我重新审视了 ols 分析。

对所有 5 个变量可以观察到系数,但 p 值仅在 5 个中的 3 个上具有决定性。我随后转而使用coef,但在后续的过程中,我发现 p 值有效而coef无效的情况。

在经历了许多令人沮丧的小时和大量的反复试验后,我建立了一种方法,它结合了两个值,并且展现出高度的准确性,经严格测试对比了大量不同的数据和 DAG。

这是我用来检测依赖关系的方法…

VALIDATION SUCCESS: Y is dependent on C in the data
VALIDATION SUCCESS: Y is dependent on E in the data
VALIDATION SUCCESS: Y is dependent on F in the data
VALIDATION SUCCESS: Y is dependent on G in the data
VALIDATION SUCCESS: Y is dependent on X in the data

我通过反复试验采用的测试方法如下…

如果 p 值大于 0.05 且系数小于或等于 1.0,则假定没有依赖关系,否则假定存在依赖关系。

这种方法并不遵循统计方法,仅仅考虑 p 值,但大量测试表明它非常可靠。

优化 Python 代码

上述方法的一个缺点是公式嵌入在代码中,即在ols_formula = "Y ~ C + E + F + G + X"以及dependent_variablevariables的声明中,这会在实际示例中导致代码重复。

如果能找到一种方法来扩展DataFrame类,以便能在任何数据集上通用地进行依赖性测试,那将会更好。

幸运的是,通过使用一种称为“猴子补丁”的技术,向DataFrame类添加自定义方法非常简单。如果你想要逐步教程,请查看我的教程文章…

## 如何扩展 Pandas DataFrames 以增强代码功能性和可读性

一步步扩展 pandas DataFrames 的自定义方法的指南,包括实现的完整示例…

[towardsdatascience.com

这是优化后的代码,它能够在任何数据集上执行任何依赖性测试…

一旦DataFrame类扩展了dependence方法,测试任何依赖性测试将变得非常简单。

例如,我们可以尝试𝑌⫫̸𝐶,𝐸,𝐹,𝐺,𝑋,这应该验证为True且没有错误…

作者提供的图片

我们可以尝试𝑌⫫̸𝐶,𝐸,𝐹,𝐺,𝑋,𝐷,这应该验证为False,表示"D"是一个错误,因为 Y 不依赖于它…

作者提供的图片

这些测试都通过了,并且在我尝试过的所有 DAG 和数据集中成功率非常高,以验证这种方法的准确性。

汇总所有内容

总结来说,上述相对较小的代码库实现了令人印象深刻的结果,即能够对任何数据集进行任何依赖性测试,以指示该测试是否通过,并在测试失败时具体突出显示错误。

但这还不够。让我们假设在咨询我们的领域专家时,他们产生的 DAG 包含一个错误,而这些专家假设节点 D 到节点 Y 之间存在因果链(或依赖关系)。

拟议的 DAG 现在看起来是这样的 …

作者提供的图片

凭借我们的新能力,我们可以轻松地对节点 Y 进行 DAG 测试,方法如下 …

… 正如我们在上面的结果中看到的,节点“D”将被准确识别为“错误”。因此,我们已经识别出一个“虚假边”,即在 DAG 中存在但在数据中不存在的链,这告诉我们 DAG 必须进行调整,以移除那个虚假边以确保准确性。

因此,以下条件必须成立 …

  1. 从一个拟议的 DAG 开始。

  2. 遍历所有节点。

  3. 对所有输入连接执行依赖性测试。

  4. 收集所有错误的列表。

积累的错误列表将立即指示所有虚假边/连接/依赖关系,这些都必须从拟议的 DAG 中移除,以生成一个没有所有虚假边的新 DAG(即在 DAG 中存在但数据中不存在的依赖关系)。

实现这一点的代码如下 …

测试完整算法以检测 DAG 中的虚假边

使用这几行代码,现在可以测试任何 DAG(由一组边表示)与任何数据(由 pandas DataFrame表示)以查看 DAG 中是否存在数据中不存在的“虚假”边。

让我们从测试 DAG 正确表示数据中所有因果链的情况开始(记住df_causal正确表示 DAG,因为它是作者合成创建的准确表示)…

A ⫫̸ D
B ⫫̸ A, C
C ⫫̸ D, F
E ⫫̸ C
X ⫫̸ A, B, E, F, G
Y ⫫̸ C, E, F, G, X
[]

在 DAG 与数据匹配的情况下没有检测到错误。

现在,让我们在 DAG 中添加一个不存在的因果链 D => Y,并重新运行代码 …

作者提供的图片

A ⫫̸ D
B ⫫̸ A, C
C ⫫̸ D, F
E ⫫̸ C
X ⫫̸ A, B, E, F, G
Y ⫫̸ C, D, E, F, G, X
[('D', 'Y')]

“虚假”边在 DAG 中被正确识别!但是当 DAG 中存在多个在数据中不存在的虚假因果关系时,我们的算法还会有效吗?

为了测试这一点,在 DAG 中添加一个第二个不存在的因果链 A => E …

作者提供的图片

A ⫫̸ D
B ⫫̸ A, C
C ⫫̸ D, F
E ⫫̸ A, C
X ⫫̸ A, B, E, F, G
Y ⫫̸ C, D, E, F, G, X
[('A', 'E'), ('D', 'Y')]

这个测试也通过了。如果向 DAG 中添加两个在数据中不存在的虚假因果关系,它们都能被正确检测到并识别为错误。

对算法进行破坏性测试

这些有希望的结果引发了一个问题:“那么,这种方法到底有多准确?”即在 DAG 中可以继续添加多少虚假因果关系而不被正确检测到。

为了回答这些问题,作者设计了一个具有挑战性的测试,首先识别 DAG 中每一个可能存在但实际上不存在的有效因果链接。对于这个特定的 DAG,所有可能链接的完整集合如下……

作者提供的图片

随后,使用测试工具随机选择任何 3 个可能缺失的链接,同时重复测试不同的集合,以确定验证算法的准确性。

结果令人震惊。这里提出的简单算法能够以 100% 的准确率检测任何 3 个虚假链接的组合(使用示例 DAG 和数据)。即便将测试改为选择任何 12 个可能的虚假链接,一样可以达到 90% 的准确率!

附录部分:分开与组合依赖测试

在整篇文章中,通过查看所有的“父”节点来建立给定节点的依赖集合,然后创建一个依赖声明,例如……

作者提供的图片

你可能会想知道相同的测试集是否等效……

作者提供的图片

作者面临的挑战之一是假设这些单独的测试等同于检测虚假边的单个整体测试,但测试中的试错过程得出了明确结论,即情况并非如此。

在寻找虚假边 Y => D 时,实现 𝑌⫫̸𝐶,𝐸,𝐹,𝐺,𝑋,𝐷 测试是 100% 可靠的,但单独测试 𝑌⫫̸𝐷 不起作用,这通过执行多轮自动化测试来比较这两种方法的准确性得到了证明。

假设是因为封装这些变量之间关系的公式是𝑌 = 3𝐶 + 3𝐸 + 2𝐹 + 2.5𝐺 + 1.5𝑋 + ε,实现依赖的 OLS 测试需要考虑所有变量在一起,这也验证了因果推断中的另一个真理……

从数据中逆向工程一个 DAG 是非常困难甚至可能不可能,但当进行“初步尝试”并接近目标时,任务变得可实现

本节的要点是:在测试依赖关系时,考虑每个节点的所有输入关系,因为如果单独测试,它根本不起作用。

连接并保持联系……

如果你喜欢这篇文章,你可以通过成为 Medium 会员,每月仅需 5 美元,即可无限访问更多内容,点击我的推荐链接(如果你通过此链接注册,我将获得费用的一部分,且对你没有额外费用)。

[## 通过我的推荐链接加入 Medium - Graham Harrison

作为 Medium 会员,你的会员费用的一部分将会流向你阅读的作者,你可以全面访问所有故事…

grahamharrison-86487.medium.com](https://grahamharrison-86487.medium.com/membership?source=post_page-----4263b18d5f04--------------------------------)

… 或者通过 … 连接

订阅我的免费电子邮件,以便在我发布新故事时及时获取

快速浏览我的上一篇文章

下载我的免费战略数据驱动决策框架

访问我的数据科学网站 — 数据博客

揭秘 DreamBooth:一种个性化文本到图像生成的新工具

原文:towardsdatascience.com/demystifying-dreambooth-a-new-tool-for-personalizing-text-to-image-generation-70f8bb0cfa30

探索将无聊图像转化为创意杰作的技术

Mario Larcher数据科学前沿 Mario Larcher

·发表于数据科学前沿 ·阅读时间 13 分钟·2023 年 6 月 13 日

--

Dougie 和他的新个性由作者使用 DreamBooth 创建。你能猜出提示是什么吗?

介绍

想象一下,你轻松地生成一张你心爱的幼犬在雅典卫城背景下的新图像的喜悦。如果还不满足,你还想看看梵高会如何绘制你的好友,或者他如果被狮子所构思会是什么样子😱!感谢 DreamBooth,这一切都变为现实,如今可以让任何动物、物体或我们自己从一小堆图像中旅行于幻想世界。

尽管我们许多人已经在社交媒体上看到了利用这项技术可以取得的令人瞩目的成果,而且有大量教程可以让我们在自己的照片上进行尝试,但很少有人尝试回答这样一个问题:是的,那么它到底是如何工作的呢?

在本文中,我将尽力解析 Ruiz 等人发表的科学论文DreamBooth: 针对主题驱动生成的文本到图像扩散模型微调,这篇论文是所有这一切的起点。但别担心,我会简化复杂的部分,并在需要一些先验知识的地方进行解释。现在,请注意,这是一项高级话题,因此我假设你已经掌握了深度学习及相关内容的基础知识。如果你想深入了解扩散模型或其他有趣的话题,我会在过程中提供一些参考。让我们开始吧!

相关工作

图 7 来自DreamBooth: 针对主题驱动生成的文本到图像扩散模型微调

在我们深入探讨 DreamBooth 的方法之前,让我们先仔细了解一下与该技术相关的工作和任务。

图像合成

在日常生活的喧嚣中,你心爱的背包已经很久没有踏上环球之旅。现在是给它注入刺激冒险的时刻,同时你也在规划下一次假期。通过图像合成,将你的背包无缝融入新的背景,让它在几秒钟内从大峡谷到波士顿。

如果简单地复制粘贴主题不能满足你对新视角的渴望,可以尝试探索 3D 重建技术的应用。然而,需要注意的是,这些技术主要针对刚性物体,并且通常需要大量的起始视图。

DreamBooth 引入了一项卓越的能力,可以在新的背景中生成新姿势,同时顺畅地融入关键元素,如光线、阴影和其他与场景相关的方面。实现这种一致性在以往的方法中一直是一个挑战。在论文中,这项任务也被称为重新背景化。

文本到图像编辑与合成

基于文本输入的图像编辑是许多照片编辑软件爱好者的一个秘密梦想。早期的方法,例如使用 GANs 的方法,展示了令人印象深刻的结果,但仅限于像编辑人脸这样结构良好的场景。

即使是利用扩散模型的新方法也有其局限性,通常仅限于全局编辑。直到最近,像Text2LIVE这样的进展才允许局部编辑。然而,这些技术都无法在新的背景中生成特定的主题。

尽管像ImagenDALL·E 2Stable Diffusion这样的文本图像合成模型取得了显著进展,但在合成图像中实现精细控制并保留主题身份仍然面临重大挑战。

可控生成模型

为了避免对主题进行修改,许多方法依赖于用户提供的掩码来限制修改的区域。逆转技术,如DALL·E 2使用的技术,提供了一个有效的解决方案,可以在修改背景的同时保留主题。

Prompt-to-Prompt使得本地和全局编辑成为可能,无需输入掩码。

然而,这些方法在生成新样本时无法充分保留主题的身份。

尽管一些基于 GAN 的方法专注于生成实例变体,但它们往往有局限性。例如,它们主要设计用于面部领域,需要大量的输入样本,难以处理独特的主题,并且无法保留重要的主题细节。

最后,最近 Gal 等人提出了文本反演,这是一种具有 DreamBooth 共同特征的方法论,但正如我们将看到的,它受到基于其的冻结扩散模型表现力的限制。

图 2 来自图像胜于千言:使用文本反演个性化文本到图像生成

由于这是作者用来与 DreamBooth 进行比较的工作,值得提供一个简要的描述。

文本反演从一个预训练的扩散模型开始,如潜在扩散模型,并定义一个新的占位符字符串 S*,以表示需要学习的新概念。在此阶段,保持扩散模型冻结,新的嵌入从仅 3-5 张图像中进行微调,类似于 DreamBooth。如果这个简要描述不够清楚,请等到你阅读更详细的 DreamBooth 描述时,它与这项工作有许多共同点。

方法

图 3 来自DreamBooth:为主题驱动生成微调文本到图像扩散模型

在详细描述DreamBooth的组件之前,让我们简要了解一下这项技术的工作原理:

  1. 选择 3-5 张你喜欢的主题图像,可以是动物、物体,甚至是像艺术风格这样的抽象概念。

  2. 将这个概念与一个稀有词汇关联,该词汇对应一个唯一的标记,将从现在开始表示它,在科学论文中,作者称这个词为[V]。

  3. 使用兴趣主题的图像,通过简单的提示如“一个[V] [类别名]”来微调模型,例如,如果输入图像是你的狗的照片,则为“一个[V] 狗”。

  4. 由于我们正在微调模型的所有参数,因此有风险在这个阶段所有的狗(或我们主题的任何类别)都会变成与我们的输入图像相同。为了避免模型的这种退化,我们从冻结的模型生成图像,使用像“狗”(或“[类别名]”)这样的提示,并添加一个损失函数,当我们为这个提示微调的模型生成的图像偏离冻结模型生成的图像时,会受到惩罚。

好的,现在我们对过程有了一个高层次的了解,让我们详细讨论各种组件。

文本到图像扩散模型

你真的想了解扩散模型的工作原理,尤其是像稳定扩散这样的潜在扩散模型吗?请阅读我之前的文章,当你读完后,我会在这里等你!

论文解读——基于潜在扩散模型的高分辨率图像合成

虽然 OpenAI 凭借其生成文本模型主导了自然语言处理领域,但他们的图像…

towardsdatascience.com

好吧,也许你不需要完整的解释,在这种情况下,我将提供扩散模型背后的直观理解,这非常简单。

图 2. 来自 Denoising Diffusion Probabilistic Models

  1. 取一个图像 x0,并添加一定量的噪声(例如,高斯噪声),噪声量与某个时间步* t 成比例。如果t为零,则添加的噪声为零,如果t* > 0,则添加的噪声将与t的大小一样,直到你得到一个仅由噪声组成的图像。

  2. 训练一个模型,如 U-Net,通过将时间步* t *和受损图像作为输入来预测无噪声图像(或添加的噪声)。

  3. 此时,经过训练一个可以去除图像噪声的模型,我们可以采样一个仅由噪声组成的图像,并逐渐去除噪声(一次性完成效果不好),可以通过预测无噪声的图像或预测噪声并从图像中减去来实现。

  4. 前三点描述了无条件扩散模型。为了根据文本提示生成条件输出,文本使用像 CLIP 的模型进行编码,或者使用如 BERTT5 等语言模型。这个编码步骤允许集成额外的信息,然后将其与受损图像和时间步* t *一起输入模型。

论文中的作者使用了两个扩散模型:Google 的 Imagen(DreamBooth 也来自 Google Research)和 Stable Diffusion来自 Stability AI,这是主要的开源文本到图像模型。

Imagen 采用多分辨率策略来提高生成图像的质量。最初,使用低分辨率 64x64 图像训练扩散模型。然后,低分辨率模型的输出通过两个额外的扩散模型进行放大,这些模型在更高分辨率下操作,分别为 256x256 和 1024x1024。第一个模型专注于捕捉宏观细节,而随后的模型则通过利用较低分辨率模型生成图像的条件效应来精细化输出。这种迭代优化有助于生成高分辨率的图像,具有更好的质量和保真度。

Stable Diffusion 作为一种潜在扩散模型,采用三步法来提高训练和生成高分辨率图像的效率。最初,训练一个变分自编码器(VAE)以压缩高分辨率图像。从此之后,过程与标准扩散模型非常相似,一个关键区别在于:不是使用原始图像作为输入,而是使用由 VAE 编码器生成的潜在表示。随后,逆扩散过程的输出通过 VAE 解码器恢复到原始分辨率。为了更全面地理解整个过程,我在上述文章中进行了更详细的探讨。

文本到图像模型的个性化

DreamBooth 旨在将主题实例(例如你的狗)置于模型的输出领域内,使模型能够在查询时生成主题的新图像。扩散模型的一个优势是,与 GANs 相比,它们能够有效地将新信息纳入其领域,同时保留对先前数据的知识,并避免对有限的训练图像集的过拟合。

为少样本个性化设计提示

如前所述,该模型通过使用“一个 [identifier] [class noun]”结构的简单提示进行训练。这里,[identifier] 代表与主题相关的独特标识符,而 [class noun] 作为主题类别的一般描述(如猫、狗、手表等)。作者将类名纳入提示中,以建立通用类别与我们个体主题之间的联系,观察到使用不正确或缺失的类名会导致更长的训练时间和语言漂移,最终影响性能。本质上,主要目的是利用特定类别与我们的主题之间的关系,利用模型对该类别已有的知识。这使我们能够在各种上下文中生成新颖的姿势和变体。

稀有标记标识符

论文强调,普通的英语单词在这种情况下并不理想,因为模型需要将它们与原始含义脱离,并重新整合以指代我们的主题。

为了解决这个问题,作者提出使用在语言和扩散模型中都有弱先验的标识符。虽然选择像“xxy5syt00”这样的随机字符可能最初看起来很吸引人,但这也存在潜在风险。需要考虑的是,分词器可能会将每个字母单独分词。那么解决方案是什么?最有效的方法是识别词汇表中不常见的标记,然后在文本空间中反转这些标记。这可以最小化标识符具有强先验的可能性。

有趣的是,大多数教程使用“sks”来实现这一目的,但正如其中一位作者指出的,这个看似无害的词可能会产生副作用……

类别特定先验保持损失

DreamBooth: Fine Tuning Text-to-Image Diffusion Models for Subject-Driven Generation中的图 6。

与文本反演不同,DreamBooth 微调模型的所有层以最大化性能。不幸的是,这样做会遇到众所周知的语言漂移问题,即当一个模型最初在一个广泛的文本语料库上进行预训练,然后再针对特定任务进行微调时,它会逐渐减少对语言语法和语义的理解。

另一个问题是输出多样性的潜在减少。这可以从图 6 的第二行中观察到,在该图中,模型,除非进一步调整,否则有倾向仅复制输入图像中找到的姿势。当模型训练时间较长时,这种效果变得更加明显。

为了减轻这些问题,作者引入了类别特定先验保持损失,让我们先看看其整体损失公式,然后再解释其组成部分。

DreamBooth: Fine Tuning Text-to-Image Diffusion Models for Subject-Driven Generation中的公式 2。

第一部分是标准的 L2 去噪误差,这是任何扩散模型的典型特征。α_t 将初始图像x 缩放,然后添加高斯噪声 εN (0, I),乘以 σ_t。随机变量 z_t := α_tx** + σ_tε** 的分布为 N(α_tx**, σ_t²)。此时,模型 xˆ 将尝试从 z_tt* 和条件向量 c = Γ(P) 预测原始图像,其中在 DreamBooth 的情况下,Γ 是 T5,而提示 P 的形式为“一个 [标识符] [类别名词]”。

第二部分是 先验保留损失,在这里,x 被替换为 xpr,即由模型生成的图像,模型的权重被冻结(在微调之前),从随机初始噪声 z1N (0, I) 和条件向量 c_pr = Γ(“一个 [类别名词]”)。这一部分促使模型从其损坏版本中重新获取 x_pr,从而促使模型生成类似于在微调过程之前生成的图像。

最后,w_tw_t’ 是与噪声调度相关的术语,λ 定义了两个损失之间的相对权重。

实验

图 4 来自 DreamBooth: Fine Tuning Text-to-Image Diffusion Models for Subject-Driven Generation

数据集

实验使用的数据集由作者生成,包含 30 个主题,包括独特的物品,如背包或太阳镜,以及动物,如狗、猫等。在这 30 个主题中,21 个是物体,9 个是活体主题/宠物。

作者定义了 25 个提示:20 个重新背景化提示和 5 个物体属性修改提示;10 个重新背景化提示,10 个配件化提示和 5 个活体主题/宠物属性修改提示。

评估指标

为了评估,每个主题和每个提示生成四张图像,共计 3,000 张图像。

为了测量 主题一致性,使用 CLIP-I 和 DINO。

CLIP-I,在之前的工作中已经使用,计算生成图像和真实图像的CLIP嵌入的平均成对余弦相似度。CLIP 的训练目标是使文本描述的嵌入与其所指的图像具有相同的嵌入,因此如果两个图像表示相同的文本,它们将具有相似的嵌入。

DINO,由作者引入的新指标,类似于 CLIP-I,但生成嵌入的方式是使用ViT-S/16 DINO,一个自监督训练的模型。

论文中观察到,由于 CLIP 的训练方式,CLIP-I 不区分可能具有非常相似文本描述的不同主题。另一方面,DINO(指的是模型,而不是指标)以自监督的方式进行训练,这有助于区分主题或图像中的独特特征。因此,他们将 DINO 视为主要指标。

最后,引入了第三个度量指标 CLIP-T,用来衡量另一个重要方面:提示一致性,即生成的图像与输入提示的接近程度。

CLIP-T 与之前的指标类似,测量从 CLIP 中获得的两个嵌入之间的平均余弦相似度:一个来自提示,另一个来自图像。值得注意的是,CLIP 特别训练以生成与对应图像的文本嵌入相似的嵌入。

比较

表 1 来自于 DreamBooth: Fine Tuning Text-to-Image Diffusion Models for Subject-Driven Generation

从表 1 可以看出,当使用 DINO 和 CLIP-T 指标测量时,DreamBooth 明显优于 Textual Inversion,而在使用 CLIP-I 测量时差距较小,但如前所述,CLIP-I 并不是一个好的衡量特定主题保真度的指标。

表 2 来自于 DreamBooth: Fine Tuning Text-to-Image Diffusion Models for Subject-Driven Generation

很难找到一个与个人判断结果好坏完全一致的指标。因此,作者们还测量了一组 72 名用户的偏好。结果突显出,对于主题保真度和提示保真度,偏好 DreamBooth 的用户百分比要高于仅凭之前的指标所能得出的结论。我们可以通过查看论文中的图 4 来判断,两种方法之间的显著差异在这个特定例子中是显而易见的。

结论

图像生成和生成式 AI 领域近年来获得了显著关注。特别是通过扩散模型的使用,图像合成的进展推动了这一领域的发展。

在本文中,我们深入探讨了 DreamBooth 的科学论文——这是一种令人印象深刻的解决方案,能够生成具有不同姿势和背景的新图像,同时保持对期望主题的忠实。这种创新的方法展示了图像合成领域取得的显著进展,并对未来的发展具有巨大潜力。

感谢您抽出时间阅读本文,如有任何意见或问题,请随时留言或与我联系。要了解我的最新文章,您可以关注我在 MediumLinkedInTwitter 上的动态。

[## 通过我的推荐链接加入 Medium - Mario Namtao Shianti Larcher

阅读 Mario Namtao Shianti Larcher 的每一个故事(以及 Medium 上的其他成千上万位作家的故事)。您的会员费……

medium.com](https://medium.com/@mnslarcher/membership?source=post_page-----70f8bb0cfa30--------------------------------)

解密 GQA — 高效 LLM 预训练的分组查询注意力

原文:towardsdatascience.com/demystifying-gqa-grouped-query-attention-3fb97b678e4a?source=collection_archive---------0-----------------------#2023-12-27

驱动像 LLaMA-2、Mistral7B 等大语言模型的多头注意力变体

Bhavin JawadeTowards Data Science Bhavin Jawade

·

关注 发布在 Towards Data Science ·6 分钟阅读·2023 年 12 月 27 日

--

一组“驼鹿”(来源 — 作者使用 Dalle-3 创建的图像)

在前一篇关于训练大规模模型的文章中,我们探讨了 LoRA。在这篇文章中,我们将研究另一种被不同大语言模型采用的高效训练策略——分组查询注意力(GQA)。简而言之,分组查询注意力(GQA)是多头注意力(MHA)和多查询注意力(MQA)的推广——它们都是 GQA 的特例。因此,在深入探讨分组查询注意力之前,我们先回顾一下 Vaswani 等人提出的经典“Attention is All You Need”论文中的传统多头注意力。接下来,我们将探索多查询注意力及其如何解决 MHA 面临的挑战。最后,我们将回答“什么是 GQA?”和“它如何使我们兼得两全?”的问题。

多头注意力是变换器模型的关键组件,使其能够高效地处理和理解复杂序列任务,如语言翻译、摘要生成等。要掌握其复杂性,我们必须深入研究其数学基础,并理解注意力机制中多个头的功能。

基本的注意力机制计算值的加权和,加权依赖于查询和一组键。在数学上,这表示为:

这被称为缩放点积注意力。在这个方程中,Q(查询)和 K(键)是表示查询和键的矩阵。V(值)是值的矩阵。“d_k”是键的维度,用于缩放。

扩展到多头注意力(MHA)

多头注意力使用多个‘头’的注意力层,使模型能够关注来自不同表示子空间的信息。在每个头中,有一组独立的线性层(投影矩阵)用于查询、键和值(这是一个重要的点,我们将在 GQA 中重述)。对于每个头(编号为 h):

headʰ = Attention(Q.Wqʰ,K.Wkʰ,V.Wvʰ)

连接头输出

各个头的输出被连接起来,然后进行线性变换。

MultiHead(Q,K,V) = Concat(head¹,head²,…,headʰ) .W

Wᵒ是另一个权重矩阵,用于将连接向量线性变换为最终输出维度。

多头注意力的直观理解是,通过并行地多次应用注意力机制,模型可以捕捉数据中不同类型的关系。

插图描绘了缩放点积注意力、多头注意力在变换器编码器块中的应用。(来源——图示部分来自《Attention is All You Need》论文 arxiv.org/abs/1706.03762,作者编排)

然而,MHA 使得对输入的不同部分之间关系的理解更加细致。尽管如此,这种复杂性也带来了代价 — 对内存带宽的巨大需求,尤其是在解码器推理期间。

多头注意力中的内存带宽挑战

问题的关键在于内存开销。像 Transformers 这样的自回归模型中的每个解码步骤都需要加载解码器权重以及所有注意力键和值。这一过程不仅计算密集,而且内存带宽密集。随着模型大小的增长,这种开销也会增加,使得扩展变得越来越困难。

多查询注意力(MQA)的出现

多查询注意力(MQA)作为缓解这一瓶颈的解决方案出现。这个想法简单却有效:使用多个查询头,但只有一个键和值头。这种方法显著减少了内存负担,提高了推理速度。它已经在多个大规模模型中得到应用,如 PaLM、StarCoder 和 Falcon。

在多查询注意力中,我们对键和值的头进行平均,以便所有查询头共享相同的键和值头。这是通过将平均池化的“头”复制 H 次来实现的,其中 H 是查询头的数量。

左侧 — 多头注意力,中间 — 多查询注意力,右侧 — 将现有的 MHA 检查点转换为 MQA(来源 — arxiv.org/pdf/2305.13245.pdf

这里一个有趣的问题是 — 如何将现有的预训练多头注意力模型转换为多查询注意力模型(MQA)?从现有的多头模型创建一个多查询注意力模型涉及两个步骤:模型结构的转换和随后的预训练。[1]

检查点转换:这一步将多头模型的结构转换为多查询模型。通过将原始模型中多个头的键和值的投影矩阵(线性层)合并(均值池化)为单个键和值的投影矩阵来实现。这种均值池化的方法被发现比选择现有的一个键和值头或从头初始化新的键和值头更有效。结果结构具有整合的键和值投影,具有多查询模型的特征。

预训练转换后的模型:在结构转换后,模型经历额外的训练。这次训练没有原始模型训练那么广泛;它是原始模型训练步骤的一部分(记作 α)。这个预训练阶段的目的是让模型根据其新的简化注意力机制调整和优化性能。训练遵循与原始模型相同的步骤,以确保学习动态的一致性。

然而,MQA 也并非没有缺点。减少的复杂性可能导致质量下降和训练不稳定。

分组查询注意力

分组查询注意力(GQA)是一种简单的方法,它结合了多头注意力(MHA)和多查询注意力(MQA)的元素,以创建一个更高效的注意力机制。GQA 的数学框架可以理解如下:

分组:在 GQA 中,传统多头模型中的查询头(Q)被分成 G 组。每组分配一个单独的键(K)和值(V)头。这种配置被称为 GQA-G,其中 G 代表组的数量。

GQA 的特殊情况:

  • GQA-1 = MQA:当只有一个组(G = 1)时,GQA 等同于 MQA,因为所有查询头只有一个键和值头。

  • GQA-H = MHA:当组的数量等于头的数量(G = H)时,GQA 的行为类似于传统的 MHA,每个查询头都有其独特的键和值头。

MHA、GQA 和 MQA 的区别(来源 — arxiv.org/pdf/2305.13245.pdf

我们对每组内原始头的键和值投影矩阵进行均值池化,将多头模型转换为 GQA 模型。这种技术对每组中每个头的投影矩阵进行平均,从而为该组生成一个单独的键和值投影。

通过利用 GQA,模型在 MHA 的质量和 MQA 的速度之间保持平衡。由于键值对较少,内存带宽和数据加载需求被最小化。G 的选择呈现一种折中:更多的组(接近 MHA)会导致更高的质量但较慢的性能,而较少的组(接近 MQA)则提高了速度,但可能牺牲质量。此外,随着模型规模的增长,GQA 允许内存带宽和模型容量按比例减少,与模型的规模相匹配。相比之下,对于更大的模型,在 MQA 中将其减少到一个单独的键和值头可能会过于严苛。

结论

在这篇文章中,我们首先介绍了传统的多头注意力(MHA)及其变体多查询注意力。然后我们探讨了一个更通用的公式 GQA,它被许多 LLM 模型用于有效的预训练。GQA 将多头注意力(MHA)与多查询注意力(MQA)结合起来,在质量和速度之间提供了一个公平的折中。GQA 通过将查询头分组来最小化内存带宽需求,使其适合于模型的扩展。GQA 已被用于取代典型的多头注意力,在最近的模型中如 LLaMA-2 和 Mistral7B。

参考文献: [1] GQA:从多头检查点训练通用化的多查询 Transformer 模型 — arxiv.org/pdf/2305.13245.pdf

[2] MQA:快速 Transformer 解码:一个写头就足够了 — arxiv.org/abs/1911.02150

[3] MHA: 注意力即一切: arxiv.org/abs/1706.03762

破解 Matplotlib 的神秘面纱

原文:towardsdatascience.com/demystifying-matplotlib-3895ab229a63

快速成功数据科学

你困惑是有原因的

李·沃恩数据科学导向 李·沃恩

·发布于 数据科学导向 ·16 分钟阅读·2023 年 11 月 2 日

--

图片由 Cederic Vandenberghe 在 Unsplash 上提供

你在使用 Matplotlib 时是否感到困难? 如果你是初学者,可能是因为你没有花时间去了解它的一些特性。如果你怀疑是这样,那么请给自己一个机会,继续阅读!这不会造成伤害,也不会花费太多时间。

Matplotlib

开源的 Matplotlib 库在 Python 中主导绘图。它允许你生成快速简单的图形以及复杂的图表,你可以控制显示的每一个方面。它的流行和成熟意味着你总能找到有用的建议和有用的代码示例。

像任何强大的软件一样,Matplotlib 也可以是“一些语法上的繁琐”。最简单的图形很容易,但难度很快上升。即使像Matplotlib 画廊这样的资源提供了有用的代码示例,如果你想要稍微不同的内容,你可能会发现自己在挠头。

实际上,许多人通过复制粘贴别人的代码,然后在边缘进行调整,直到得到他们喜欢的结果来使用 Matplotlib。正如一位用户曾告诉我,“无论我使用 Matplotlib 多少次,它总是感觉像是第一次!”

幸运的是,你可以通过花时间学习这个包的一些关键方面来大大减轻这种痛苦。因此,在这篇文章中,我们将重点关注可能导致困惑的术语和绘图接口。掌握这些知识后,你可能会发现 Matplotlib 是一个值得拥抱的工具,而不是一个需要回避或勉强使用的工具。

问题是什么?

根据我学习 Matplotlib 的经验,这里有三个导致困惑的问题:

  1. 绘图时使用的有些尴尬的术语。

  2. 我将称之为pyplot 方法面向对象风格两个绘图接口的共存。

  3. 两个接口中的绘图操作方法具有相似不同的名称。

让我们逐一查看这些内容。

绘图的解剖结构

理解 Matplotlib 的第一步是掌握绘图术语。为此,让我们剖析一个图及其组件。

Matplotlib 中的绘图都保存在 Figure 对象中。这是一个空白画布,代表所有绘图元素的顶层容器。除了提供绘图的画布外,Figure 对象还控制诸如绘图的大小、宽高比、在同一画布上绘制的多个图之间的间距以及将绘图输出为图像的能力。以下图示中最左侧的方块代表一个 Figure 对象。

绘图的解剖结构(来源:Python 科学工具 [1])

绘图本身——即你我认为的图形——由 Axes 类表示,如之前图示的中间所示。这个类包括大多数图形元素,如线条、多边形、标记(点)、文本、标题等,以及对这些元素进行操作的方法。它还设置了坐标系统。一个 Figure 对象可以包含多个 Axes 对象,但每个 Axes 对象只能属于一个 Figure

Axes 对象不应与表示图表 x 轴或 y 轴上数值的 Axis 元素混淆(在之前图示的最右侧显示)。这包括刻度标记、标签和限制。这些元素都包含在 Axes 类中。

之前图示中的每个组件都存在于下面显示的层次结构中。最底层包括各个轴、轴的刻度标记和标签以及曲线(Line2D)。最高层是 Figure 对象,它作为下面所有内容的容器。

之前图示中的绘图组件层次结构(来源:Python 科学工具 [1])

由于 Figure 对象可以包含多个 Axes 对象,因此你可以在之前的图示中有多个 Axes 对象指向同一个 Figure 对象。一个常见的例子是子图,其中一个 Figure 画布上包含两个或多个不同的图:

图示中包含两个子图的示例(由红框标出)(作者提供)

pyplot 和面向对象方法

使用 Matplotlib 主要有两种绘图接口。第一种,称为pyplot 方法,你依赖于 Matplotlib 的内部 pyplot 模块来自动创建和管理 FigureAxes 对象,然后使用 pyplot 方法进行绘图。该方法主要用于处理单个图形,它减少了你需要了解和编写的代码量。它是一个类似 MATLAB 的 API,适用于快速、交互式的工作。

这里是一个例子:

import matplotlib.pyplot as plt
import numpy as np

data = np.arange(5, 10)
plt.plot(data)

pyplot 方法的输出(作者提供)

整个图形只需要一行代码。pyplot 模块为你做出了所有决策,包括使用线条、线条的颜色和粗细、每个轴上的值范围,以及文本的字体和颜色。它还为每个 y 值提供了相应的 x 值,假设计数从 0 开始并以 1 单位递增。

使用第二种方法,称为面向对象风格,你明确地创建 FigureAxes 对象,然后对生成的对象调用方法。这种方法让你对自定义图形和跟踪大型程序中的多个图形拥有最大的控制权。如果你首先创建一个 Axes 对象,还更容易理解与其他库的交互。

import matplotlib.pyplot as plt
import numpy as np

data = np.arange(5, 10)
fig, ax = plt.subplots()
ax.plot(data)

面向对象风格的输出(作者提供)

结果与使用 pyplot 方法获得的结果相同。

一旦你看到以下这一行,你就知道你在处理面向对象风格:

fig, ax = plt.subplots()

plt.subplots() 方法创建一个 Figure 实例和一组子图(一个包含 Axes 对象的 NumPy 数组)。如果未指定子图的数量,则默认返回一个单一的子图。

由于返回了两个对象,你需要将结果解包两个变量中,按惯例称为 figax。请记住,使用 pyplot 方法时,这两个实体是在幕后创建的。

在接下来的章节中,我们将查看这两种方法。然而,根据 Matplotlib 文档,为了保持一致性,你应该选择一种方法并坚持使用。他们建议使用面向对象的风格,特别是对于复杂的图形以及那些打算作为更大项目的一部分重复使用的方法和脚本。

确实可以说,初学者发现 Matplotlib 令人望而生畏的原因之一是,他们在现有代码中看到了一种混合的这些方法,例如在问答网站如Stack Overflow上。由于这种情况是不可避免的,我建议你阅读这两种方法的描述,以便做出明智的选择。这样,当你在遗留代码或教程中遇到另一种方法时,你会对其有所了解。

使用 pyplot 方法

在上一节中,我们使用一行代码用pyplot创建了一个图表:

plt.plot(data)

这里有两点需要注意:我们没有明确在代码中引用FigureAxes对象,因为pyplot在幕后处理了这些问题。我们也没有指定要在图中显示哪些元素,包括显示在 x 轴和 y 轴上的刻度和数值。相反,Matplotlib 查看了你的数据,并对你想要的图类型及其注释做出了智能选择。

在这方面,plot()方法绘制折线图,scatter()绘制散点图,bar()绘制条形图,hist()绘制直方图,pie()绘制饼图等等。你可以在 Matplotlib 的图表类型索引中找到所有这些示例。

pyplot的图表创建方法的自动特性在你想快速探索数据集时很有用,但生成的图表通常过于简单,不适合演示或报告。一大问题是,像plt.plot()这样的默认配置假设你希望每个轴的大小与输入数据的范围匹配(例如,如果数据限制在 5 到 8 之间,则 x 轴范围为 5 到 8,而不是 0 到 10)。

它还假设你不需要图例、标题或轴标签,并且希望线条和标记绘制成蓝色。这并非总是如此,因此pyplot提供了许多方法来装饰图表,包括标题、轴标签、背景网格等等。我们接下来会看看这些。

使用 pyplot 方法创建和操作图表

尽管被认为是一种比面向对象风格更简单的方法,pyplot仍然可以生成一些非常复杂的图表。为了演示,让我们使用一些pyplot方法创建比以前更复杂的图表。

悬链线是链条悬挂在两端时所呈现的形状。这在自然界和建筑中是常见的形状,例如在风压下的方形帆和位于密苏里州圣路易斯的著名拱门。你可以使用以下代码生成悬链线,其中cosh(x)表示 x 值的双曲余弦。

import numpy as np
import matplotlib.pyplot as plt

x = np.arange(-5, 5, 0.1)
y = np.cosh(x)

plt.title('A Catenary')
plt.xlabel('Horizontal Distance')
plt.ylabel('Height')
plt.xlim(-8, 8)
plt.ylim(0, 60)
plt.grid()
plt.plot(x, y, lw=3, color='firebrick')

pyplot 悬链线程序的输出(作者提供)

尽管有些冗长,但代码逻辑清晰且可读。所有绘图步骤都调用了plt上的方法。

在 Matplotlib 中,渲染在Figure画布上的元素,如标题、图例或线条,被称为Artist 对象。标准图形对象,如矩形、圆形和文本,被称为基本 Artists。持有基本对象的对象,如FigureAxesAxis对象,称为容器 Artists

以下表格列出了制作图表和操作Artists时一些较常见的pyplot方法。要查看完整列表,请访问 Matplotlib pyplot 汇总页面. 点击在线列表中的方法名称,将带您到有关方法参数的详细信息以及示例应用程序。如果您想了解更多关于Artists的信息,请访问 Matplotlib 艺术家页面

一些用于创建图表的有用 pyplot 方法(来源:Python 科学工具[1])

一些用于操作图表的有用 pyplot 方法(来源:Python 科学工具[1])

请注意,表格中的代码示例代表了简单的情况。大多数方法需要许多参数,让您可以根据字体样式和大小、线条宽度和颜色、旋转角度、爆炸视图等属性来微调图表。

使用子图

到目前为止,我们一直在处理单一图形,但有时您可能希望并排比较两个图表或将几个图表汇总到一个摘要显示中。在这些情况下,Matplotlib 提供了 subplot() 方法。要了解其工作原理,让我们先生成两个不同正弦波的数据:

time = np.arange(-12.5, 12.5, 0.1)
amplitude = np.sin(time)
amplitude_halved = np.sin(time) / 2

比较这些波形的一种方法是将它们绘制在同一个Axes对象中,如下所示:

plt.plot(time, amplitude, c='k', label='sine1')
plt.plot(time, amplitude_halved, c='firebrick', ls='--', label='sine2')
plt.legend()

pyplot 正弦程序的输出(作者提供)

默认情况下,两条曲线将用不同的颜色(蓝色和橙色)绘制。我们用黑色(使用缩写‘k’)和“火砖”红色覆盖了这一点。我们还使用ls参数强制使用不同的线型。否则,两条线将是实线。(有关可用于标记和线型的字符列表,请访问[这个网站](https://matplotlib.org/stable/api/_as_gen/ matplotlib.pyplot.plot.html))。

如果您比较的曲线数量超过几个,单个图表可能会变得混乱且难以阅读。在这种情况下,您会想要使用subplot()方法创建的独立堆叠图。下图描述了此方法的语法,其中四个子图(Axes)被放置在一个Figure容器中。

理解subplot()方法(来源:Python 科学工具[1])

子图将以网格形式排列,传递给subplot()方法的前两个参数指定了该网格的维度。第一个参数表示网格的行数,第二个参数表示列数,第三个参数是活动子图的索引(在图中以灰色突出显示)。

活动子图是你当前正在绘制的子图,当你调用plot()scatter()等方法时。与 Python 中的大多数事物不同,第一个索引是 1,而不是 0。

Matplotlib 使用称为“当前图形”的概念来跟踪当前正在处理哪个Axes。例如,当你调用plt.plot()时,pyplot会创建一个新的“当前图形”Axes进行绘制。在处理多个子图时,索引参数告诉pyplot哪个子图代表“当前图形”。

为了方便,你不需要在subplot()参数中使用逗号。例如,plt.subplot(223)plt.subplot(2, 2, 3)效果相同,尽管前者可能阅读性较差。

现在,让我们将正弦波绘制为两个独立的堆叠图。过程是调用subplot()方法,并更改其活动子图参数以改变当前子图。对于每个当前子图,plot()方法将发布特定于该子图的数据,如下所示:

plt.subplot(2, 1, 1)
plt.plot(time, amplitude, label='sine1')
plt.legend(loc='upper right')

plt.subplot(2, 1, 2)
plt.ylim(-1, 1)
plt.plot(time, amplitude_halved, label='sine2')
plt.legend(loc='best')

pyplot正弦子图程序的输出(由作者提供)

注意,如果你没有设置第二个图的 y 轴限制,pyplot会自动调整图表,使两个子图看起来相同。由于我们使用ylim()方法手动设置了第二个子图的比例,因此很明显第二个正弦波的幅度是第一个的一半。

这是一瞥pyplot方法的一些语法。现在让我们来看一下面向对象的风格。

使用面向对象风格

面向对象的绘图风格通常需要比之前描述的pyplot方法更多的代码,但它让你能够最大限度地利用 Matplotlib。通过显式创建FigureAxes对象,你可以更轻松地控制图表,更好地理解与其他库的交互,创建具有多个 x 和 y 轴的图表等等。

使用面向对象风格创建和操作图表

为了熟悉面向对象的风格,让我们重新创建文章前面提到的链线图。为了演示该风格的一些增强功能,我们将强制 y 轴通过图表的中心。

import numpy as np
import matplotlib.pyplot as plt

x = np.arange(-5, 5, 0.1)
y = np.cosh(x)

fig, ax = plt.subplots()

上述代码将创建一个空的图形。要自定义配置图表,接下来调用Axes对象的set()方法,并传递标题、轴标签和轴限制的关键字参数。set()方法是一个便利方法,它允许你一次设置多个属性,而不是为每个属性调用特定的方法。

ax.set(title='A Catenary',
        xlabel='Horizontal Distance',
        ylabel='Height',
        xlim=(-8, 8.1),
        ylim=(0, 60))

接下来,我们将把 y 轴移到图表的中心,而不是沿着一侧。在 Matplotlib 中,spines 是连接轴刻度线并标记包含绘制数据的区域边界的线。

默认情况下,坐标轴的脊柱 围绕 绘图,刻度和标签沿左侧和底部边缘。然而,脊柱也可以放置在任意位置。通过面向对象风格,我们可以使用 Spine 子类的 set_position() 方法来实现这一点。

以下代码首先将左(y)轴移动到 x 轴上的 0 值。然后,将线宽设置为 2,使得该轴在我们稍后将使用的背景网格中更加突出。

ax.spines.left.set_position('zero')
ax.spines.left.set_linewidth(2)

以下代码通过将右边界的颜色设置为无来关闭绘图的右边界:

ax.spines.right.set_color('none')

接下来的三行分别对底部轴和顶部轴重复了这个整体过程:

ax.spines.bottom.set_position('zero')
ax.spines.bottom.set_linewidth(2)
ax.spines.top.set_color('none')

为了完成图形,我们添加一个背景网格并调用 plot() 方法,传入 x 和 y 数据,将线宽设置为 3,颜色设置为 firebrick

ax.grid()
ax.plot(x, y, lw=3, color='firebrick')

使用面向对象风格构建的链形线图(作者提供)

如果你省略有关脊柱的代码,你可以用基本相同的代码重现 pyplot 版本的图形。因此,面向对象风格的冗长程度很大程度上与它的功能有关,人们通常会利用这一点。

pyplot 方法在面向对象风格中有对应的等价方法。不幸的是,方法名称通常不同。例如,pyplot 中的 title() 变成了 set_title()xticks() 变成了 set_xticks()。这就是为什么选择一种方法并坚持使用它的原因之一。

以下表格列出了面向对象绘图的更常见方法。你可以在 图形类型索引Matplotlib 画廊 中找到更多方法,比如制作箱形图、小提琴图等。

一些用于创建图形的有用面向对象方法(来源:Python Tools for Scientists [1])

用于处理 FigureAxes 对象的常用方法列在以下表格中。在许多情况下,这些方法的工作方式与 pyplot 方法类似,但方法名称可能不同。

一些用于操控图形的有用面向对象方法(来源:Python Tools for Scientists [1])

一些用于处理 Axes 对象的有用方法(来源:Python Tools for Scientists [1])

pyplot 部分所述,这些表格中的代码示例表示 简单 情况。大多数方法需要多个参数,允许你根据字体样式和大小、线宽和颜色、旋转角度、爆炸视图等属性微调图形。要了解更多信息,请访问 Matplotlib 文档.

处理子图

与 pyplot 方法类似,对象导向风格支持使用子图。尽管有多种方式可以将子图分配给FigureAxes对象,plt.subplots()方法既方便又返回一个 NumPy 数组,使你可以使用标准索引或唯一名称如axs[0, 0]ax1来选择子图。另一个好处是你可以在绘制任何数据之前预览子图的几何布局。

创建子图的面向对象方法拼写为subplots,而 pyplot 方法使用subplot。你可以通过将最简单的技术(pyplot)与最短的名称联系起来记住这一点。

调用plt.subplots()而不带参数会生成一个空的单图。技术上,这会生成一个1×1 AxesSubplot对象。

fig, ax = plt.subplots()

一个空的子图(作者提供)

生成多个子图的工作方式类似于plt.subplot()方法,只是不需要为活动子图提供索引参数。第一个参数表示行数;第二个参数指定列数。按照惯例,多个Axes被赋予复数名称axs,而不是axes,以避免与单个Axes实例混淆。

传递两个参数给plt.subplots()方法可以控制子图的数量和几何布局。以下代码生成了下面显示的 2×2 子图网格,并将两个AxesSubplot对象的列表存储在axs变量中。

fig, axs = plt.subplots(2, 2)
axs

一个 2x2 的子图网格(作者提供)

要激活一个子图,你可以使用它的索引。在这个例子中,我们在第一行的第二个子图上绘图:

fig, axs = plt.subplots(2, 2)
axs[0, 1].plot([1, 2, 3])

一个 2x2 的子图网格,第二个子图处于活动状态(作者提供)

另外,你可以通过使用元组解包为多个Axes来单独命名和存储子图。每一行的子图都需要放在自己的元组中。然后,你可以通过名称选择子图,而不是使用不太易读的索引:

fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2)
ax3.plot([1, 2, 3])

一个 2x2 的子图网格,第三个子图处于活动状态(作者提供)

pyplot方法和面向对象风格中,你可以通过在Figure对象上调用tight_layout()方法来增加子图周围的空白。

fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2)
ax3.plot([1, 2, 3])
fig.tight_layout()

tight_layout()方法对子图间距的影响(作者提供)

现在子图看起来不那么拥挤了。对于pyplot方法,你会使用plt.tight_layout()

创建子图的替代方法

无论你使用哪种技术,都有更高级的替代方法来帮助你将图形拆分为网格状的子区域。这反过来有助于你创建具有不同宽度和高度的子图。生成的多面板显示对总结演示文稿和报告中的信息非常有用。

这些面板工具包括 Matplotlib 的 [GridSpec](https://matplotlib.org/stable/api/_as_gen/matplotlib.gridspec.GridSpec.html) 模块及其 subplot_mosaic() 方法。以下是一个使用 GridSpec 构建的示例:

使用 GridSpec 构建的多面板显示(来源:Python 工具用于科学家 [1])

要了解更多关于这些工具的信息,请访问 处理多个图形和坐标轴在一个图形中排列多个坐标轴 的 Matplotlib 文档,以及我在 Better Programming 上的 GridSpec 教程 文章

总结

如果您使用 Python 编程,您需要了解 Matplotlib。要了解 Matplotlib,您需要理解其主要的绘图术语和两种绘图接口。

Figure 对象代表您绘制的 画布。它控制诸如图表大小、纵横比、子图之间的间距、超级标题以及保存图表的能力等事项。

Figure 对象可以包含多个 Axes 对象,这些对象形成我们通常认为的图形或图表。这些包括线条、点、文本、标题、图表的坐标系统等。一个 Figure 对象中的多个 Axes 对象构成 子图

Axes 对象中,Axis 元素表示 x、y 或 z 轴上的数值,包括刻度线、标签和限制。

Matplotlib 提供了两种主要的绘图方法。pyplot 方法旨在快速和简单地绘图,例如用于探索性数据分析。使用此方法,FigureAxes 对象在幕后创建,大多数决策,如坐标轴缩放、颜色、线条样式等,都由系统为您做出(尽管您可以在一定程度上覆盖这些设置)。

对于更复杂的图形,例如报告和演示,面向对象风格 明确地创建 FigureAxes 对象(按惯例标记为 figax)。这为您提供了更多控制权,并使您更容易理解与其他 Python 库的交互。

如果您不了解这两种绘图范式,在使用在线找到的代码片段时很容易感到困惑,例如在 Stack Overflow 上。由于每种方法的使用方法相似但不同,Matplotlib 开发者建议您选择一种方法并始终如一地使用。

引用

  1. Lee Vaughan 的《Python 工具用于科学家:使用 Anaconda、JupyterLab 和 Python 的科学库简介》(No Starch Press,2023)。

谢谢!

感谢阅读,未来请关注我获取更多快速成功数据科学文章。如果你想深入了解 Matplotlib 和 Python 的其他绘图库,可以查看我的书籍,科学家的 Python 工具,该书在线和在像 Barnes and Noble 这样的书店均有售。

[## 科学家的 Python 工具:Anaconda、JupyterLab 和 Python 科学库入门…

《科学家的 Python 工具:Anaconda、JupyterLab 和 Python 科学库入门》…

a.co](https://a.co/d/67VvS3G?source=post_page-----3895ab229a63--------------------------------)

揭开 NDCG 的面纱

原文:towardsdatascience.com/demystifying-ndcg-bee3be58cfe0?source=collection_archive---------0-----------------------#2023-01-25

作者插图

如何最佳地使用这一重要指标来监控排名模型

Aparna DhinakaranTowards Data Science Aparna Dhinakaran

·

查看 发表在 Towards Data Science ·11 min read·2023 年 1 月 25 日

--

这篇文章由 Arize AI 的软件工程师 Jianshu Chi 和 Arize AI 的 ML 工程师 Amber Roberts 共同撰写

排名模型支撑着现代数字生活的许多方面,从搜索结果到音乐推荐。任何构建过推荐系统的人都理解开发和评估排名模型以服务客户所面临的种种挑战。

尽管这些挑战从数据准备和模型训练开始,并贯穿于模型开发和部署过程中,但通常给数据科学家和机器学习工程师带来最大麻烦的是在生产环境中维护他们的排名模型。维护生产中的模型 notoriously 困难,因为这些模型在适应动态环境时不断变化。

为了详细讲解如何在生产中监控标准化折扣累积增益(NDCG),本文涵盖了:

  • NDCG 是什么,它的用途是什么?

  • NDCG 背后的直觉

  • 什么是 NDCG@K?

  • NDCG 与其他指标相比如何?

  • NDCG 在模型监控中如何使用?

在解决了这些主要问题之后,你的团队将能够使用 NDCG 对生产中的排名模型进行实时监控和根本原因分析。

NDCG 是什么,它的用途是什么?

标准化折扣累积增益是排名质量的衡量指标。机器学习团队经常使用NDCG来评估搜索引擎、推荐系统或其他信息检索系统的性能。搜索引擎在与客户直接互动的应用程序中非常流行,如 Alphabet、Amazon、Etsy、Netflix 和 Spotify——这些仅仅是其中的一部分。

NDCG 的值是通过将搜索引擎返回的项目的相关性与假设的“理想”搜索引擎返回的项目的相关性进行比较来确定的。例如,如果你在一个流行的音乐流媒体应用上搜索“Hero”,你可能会得到 10 多个结果,这些结果中包含了“Hero”这个词,无论是在歌曲、艺术家还是专辑中。

每首歌曲或艺术家的相关性由一个分数(也称为“等级”)表示,该分数分配给搜索查询。这些推荐的分数会根据它们在搜索结果中的位置进行折扣——它们是首先被推荐还是最后被推荐?折扣后的分数会累积起来,并除以最大可能的折扣分数,这个折扣分数是如果搜索引擎按真实相关性顺序返回文档时获得的分数。

例如,如果用户想要 Foo Fighters 的歌曲“My Hero”,那么这首歌在推荐中离顶部越近,对用户来说搜索效果就越好。最终,返回结果或推荐的相对顺序对于客户满意度非常重要。

理解排名模型背后的直觉

排名模型根据模型中的搜索查询预测一组项目的排名。每个项目的相关性分数是根据它们在搜索中的相关性进行评估的。

这里是一个用于排名模型的简单版本数据集。共有两种不同的搜索查询:xy。在每组中,有五个不同的项目作为搜索结果显示,每个项目的排名基于它们在结果列表中的位置。最后,每个项目的增益代表了在搜索中的相关性。

图片来源:作者

要理解 NDCG 背后的直觉,必须深入了解名称中每个单词的含义。所以让我们来逐一解析…

1.) 累积增益(CG)

累积增益是与搜索查询中项目相关的增益的总和。它的公式如下:

作者提供的图片

使用上面的数据集,我们可以计算每个组的 CG:

作者提供的图片

在这个例子中,两个组的 CG 都是 3,因此我们仍然无法判断哪个搜索组更好。为了做到这一点,我们需要在公式中考虑排名——这引入了下一个部分:DCG。

2.) 折扣累计收益(DCG)

DCG 与 CG 的概念相同,但额外考虑了按排名折扣收益。以下是 DCG 的公式:

作者提供的图片

使用上面的数据集,我们可以计算每个组的 DCG:

作者提供的图片

作者提供的图片

好消息!现在我们可以看到 y 的 DCG 优于 x 的 DCG。这也很有道理,因为组 y 中较高排名的项目对搜索组 y 更相关(收益更高)。那么我们为什么还需要 NDCG 呢?为了回答这个问题,我们引入另一个搜索组 z 作为计数示例:

作者提供的图片

然后,我们再练习一次 DCG 计算:

作者提供的图片

z 的 DCG 是 1,但它在第一个排名上有最相关的项目。如果我们比较数据,它应该至少比组 x 更好。问题是组 x 有三个相关项目,而组 z 只有一个,单纯比较 DCG 不公平,因为它是累积和。这就是 NDCG 发挥作用的地方,因为它在比较之前会对 DCG 进行归一化——但问题是如何归一化以进行公平比较。为此,我们需要 IDCG。

3.) 理想折扣累计收益(IDCG)

IDCG 代表 理想折扣累计收益,它计算了基于收益的理想顺序的 DCG。它回答了这样一个问题:一个组的最佳可能 DCG 是什么?回到实际示例,当用户在线搜索某样东西时,他们总是希望最相关的项目在顶部,并在任何无关的项目之上。也就是说,所有相关的信息应该总是位于顶部,并且应该具有最佳的 DCG。让我们做更多的计算,使用上面数据集中每个搜索组的 IDCG:

作者提供的图片

4.) 归一化折扣累计收益(NDCG)

最终,我们完成了繁重的数学运算,终于可以使用 NDCG 了!NDCG 通过组的 IDCG 归一化 DCG。它可以解释为实际相关顺序与理想相关顺序的比较。NDCG 是 DCG 和 IDCG 的商;请参见下面的公式。

作者提供的图片

作者提供的图片

回到上面的数据集,我们来获取每组的最终 NDCG:

作者提供的图像

有了这些,我们可以自信地说组 z 拥有最佳的 NDCG。所有相关项都位于列表的顶部也很有意义。最后,值得注意的是 NDCG 的范围在 0 到 1 之间,1 是 NDCG 的最大值。

什么是 NDCG@K?

K 指的是列表中的前 K 个排名项,只有前 K 的相关性才会对最终计算产生影响。在计算 NDCG@K 时,我们首先计算实际相关性顺序和理想相关性顺序中的前 K 项的 DCG,然后得到该结果的标准化 DCG。

这里是 NDCG@K 的公式:

作者提供的图像

现在,让我们计算组 x 的 NDCG@3:

作者提供的图像

NDCG 与其他排名指标相比如何?

团队用来评估搜索和排名引擎性能的三种主要指标是:目标是根据给定查询或用户的相关性对项列表进行排名。

  • NDCG (标准化折扣累积增益):NDCG 是衡量排名系统有效性的指标,考虑了相关项在排名列表中的位置。它基于这样的思想:排名更高的项应比排名更低的项获得更多的信用。NDCG 的计算方法是将排名列表的折扣累积增益(DCG)除以理想排名列表的 DCG,理想排名列表是将相关项按照最优顺序排列的列表。NDCG 的范围从 0 到 1,值越高表示性能越好。

  • MAP (均值平均精度)均值平均精度 是衡量排名系统精度的指标,考虑了排名列表中的相关项数量。它是通过对排名列表中每个位置的精度进行平均来计算的,其中精度定义为列表中该位置之前的相关项数量除以该位置之前的总项数。MAP 的范围从 0 到 1,值越高表示性能越好。

  • MRR (均值倒数排名):MRR 是衡量排名列表中第一个相关项的排名的指标。它是通过取第一个相关项排名的倒数,并对所有查询或用户的这一值进行平均来计算的。例如,如果给定查询的第一个相关项的排名是 3,则该查询的 MRR 为 1/3。MRR 的范围从 0 到 1,值越高表示性能越好。

NDCG 通常用于信息检索,因为它考虑了搜索结果中返回项的相对顺序。这很重要,因为用户通常只查看前几个搜索结果,因此结果的相对顺序可能比任何绝对分数更重要。也就是说,NDCG 类似于排名指标 MAP,但由于它考虑了排名列表中相关项的位置,因此对排名顺序更为敏感。它基于这样一个观点:排名靠前的项应比排名靠后的项获得更多的信用。

NDCG 提供了微调哪些排名比其他排名更有价值的能力,并考虑了相关性分数的尺度(分级相关性)。虽然 NDCG 克服了 MAP 的不足之处,但它受到实际数据和部分反馈的限制,因此需要更手动的数据清理过程以确保准确计算。

每个排名指标测量排名性能的不同方面,选择使用哪个指标将取决于排名系统的具体目标以及其使用的背景。

NDCG 在模型监控中如何使用?

总结一下,NDCG 是评估排名模型表现的有用指标,确保最相关的项按降序显示在搜索结果的顶部。

如果你是一位构建搜索引擎以推荐相关项的机器学习工程师,你会希望确保在模型开发和实验阶段所取得的结果与在生产环境中看到的结果相似。然而,排名模型以及任何信息检索系统往往会随着时间的推移而性能下降。这就是为什么模型监控和 ML 可观测性在 ML 生命周期中变得至关重要的原因。

回到帖子开头的例子,想象你在音乐流媒体应用的搜索栏中输入“Hero”。如果在生产工作流程中使用 ML 监控,音乐流媒体应用可以使用 NDCG 来评估其搜索排名模型在用户搜索时如何预测歌曲或艺术家的列表,特别是当用户的相关性为“播放”时。

其他示例也很丰富,从社交媒体公司使用 NDCG 来评估推荐帖子和视频的相关性,到零售商使用它来优化产品列表。

这些公司还可以使用 NDCG@1、NDCG@5 和 NDCG@10 来评估这些推荐的相关性以及搜索引擎的强度。使用 ML 可观察性平台的公司,甚至可以监控其排名模型和具有多个相关性标签的搜索引擎(完全披露:我也是 Arize AI 的联合创始人)。这些可以用来生成增益(相关性评分),基于相关性目标(正类)是否匹配一个相关性标签。如果团队在每个搜索组中使用 NDCG@K,那么你应该对所有这些结果进行平均,以获得最终的 NDCG。对模型预测的所有相关搜索查询的 NDCG 进行平均,可以帮助团队很好地了解模型的表现。

上述x,yz组的综合 NDCG 值为:

图片由作者提供

如果你的模型没有相关性评分该怎么办?

计算 NDCG 时需要相关性评分。如果相关性评分不可用,可以使用归因模型生成二元相关性评分。如果你的预测标签、相关性标签和正类匹配,这个模型可能会产生一个score = 1

如果在多标签情况下(如[‘点击’,‘收藏’,‘购买’])没有相关性评分,并且正类是‘点击’,相关性将归因于 sum([1,0,0])。因此,尽可能地归因相关性评分很重要,以便计算更精确的 NDCG 以进行进一步的故障排除。

低 NDCG 值在生产环境中意味着什么?

以下示例展示了当推荐系统在生产中的性能开始下降时发生了什么。在图像插图中,你可能会注意到训练和生产数据集几乎相同,仅在生产数据集中交换了第一个和最后一个推荐。这导致了两个数据集之间性能的显著差异,将 NDCG 从 0.993 降低到 0.646。NDCG 是对整体评分顺序最敏感的排名指标,并且在你可以获得完全的相关性反馈时非常有利。

图片由作者提供

有了这些信息,我们可以说当需要向客户提供相关搜索结果时,我们的 NDCG 值在生产中表现不佳。现在我们知道模型开始退化,我们可以开始揭示性能问题的具体位置和原因。

更多资源

要了解如何通过适当的评估指标主动捕捉性能退化,然后识别表现最差的特征和切片,并轻松找出模型问题的根源,请查看我们以前关于性能跟踪监控排名模型的文章。

揭开旋转矩阵的神秘面纱

原文:towardsdatascience.com/demystifying-rotation-matrix-6c8885c691d6

如何在 R² 空间中旋转一个向量

Mert AtliTowards Data Science Mert Atli

·发布于 Towards Data Science ·6 分钟阅读·2023 年 11 月 1 日

--

旋转矩阵就像线性代数世界中的一种神奇工具,旨在精确而轻松地旋转空间中的向量。想象一下你有一个向量,一个指向某处的箭头,你想围绕某一点旋转它,就像在钥匙圈上旋转钥匙一样。这正是旋转矩阵帮助你完成的任务。

为了理解旋转矩阵的生成,我们从 R² 空间中的一个向量开始,尝试沿水平轴旋转它。

在 R² 空间中旋转一个向量

下图显示了一个在 R² 空间中与水平轴成角度 a 的向量 v。假设我们想将它沿水平轴逆时针旋转 ‘b 度’,这个向量记作 v’

从 v 到 v’ 的旋转

正如我们所看到的,旋转只是改变了 v 的方向,并保持长度(即‘大小’)不变。

在 R² 空间中,我们可以将向量 v 表示为有序元组 (m, n),其中第一个元素在水平轴上,第二个元素在垂直轴上。从三角学中,我们知道向量 v=(m, n) 的坐标可以表示为 (||v||.cos(a), ||v||.sin(a)):

给定向量的长度和角度来确定向量的坐标

同样地,v’ 可以表示为 (||v||.cos(a+b), ||v||.sin(a+b)):

v 和 v’ 的坐标

所以我们的问题是找到从 v 到 v’ 的映射。任何向量都可以通过矩阵乘法转换成另一个向量,尤其是在旋转的情况下。 如果你有一个向量 v,并且你想通过旋转将它转换为另一个向量 v′,那么存在一个旋转矩阵 A 能够完成这个任务:

矩阵 A 作为从 v 到 v’ 的映射

从上面的方程可以看出,将vv’表示为向量进行一些计算似乎是一个好主意。v 的向量表示如下(第一个元素是水平轴,第二个元素是垂直轴):

v 的向量表示

类似地,v’的向量表示如下:

v’的向量表示

因此,我们有 v 和 v’作为向量。我们还不知道旋转矩阵 A 的元素,所以让我们用变量 x、y、z 和 t 表示它们:

通过旋转矩阵从 v 到 v’

通过在左侧进行矩阵向量乘法,然后将其等于右侧的表达式,我们可以轻松获得 x、y、z 和 t 的值:

寻找变量 x、y、z 和 t 的值

这给出了如下旋转矩阵:

旋转矩阵

这是我们需要的旋转矩阵,用于将任何向量 v绕水平轴逆时针旋转 b 度。看起来很神秘?让我们进一步探讨。

R²中的旋转矩阵性质

让我们探讨一些特殊情况以证明旋转矩阵的合理性。

0 度旋转

0 度旋转意味着物体或向量没有任何旋转;它保持在其原始位置和方向。就变换矩阵而言,0 度旋转可以用单位矩阵表示。这一结果是合理的,因为 0 度旋转不应改变向量的位置或方向。

0 度旋转矩阵

180 度旋转

180 度旋转意味着物体或向量从其原始位置旋转半圈。对于 2D 向量,这种旋转将向量翻转到相对象限,将其 x 和 y 分量都变为其负值。这一结果是合理的,因为 180 度旋转应该将向量翻转到相反的方向。 例如,如果你有一个指向上方的向量,180 度旋转将使它指向下方。

先旋转β度,然后旋转-β

当一个向量经历了β角度的旋转,然后经历了-β角度的另一次旋转时,这两个旋转实际上会相互抵消,使向量返回到其原始位置和方向。

由于将进行两个矩阵乘法,一个用于β,另一个用于度旋转,这等同于将 v 乘以矩阵A’

当我们计算A’时,我们得到的是一个单位矩阵!所以A’ ⋅ v等于v本身!下面计算的关键是余弦是偶函数,正弦是奇函数。所以cos(-b) = cos(b),并且sin(-b) = -sin(b)

先旋转 b,然后旋转 -b 度

那么 R³ 呢?

使用矩阵进行旋转的基本概念确实可以从 2D 扩展到 3D,甚至更高维度的空间。

在 2D 中,可以使用旋转矩阵将向量绕原点旋转。旋转矩阵依赖于旋转角度,它将原始向量转换到一个新位置,同时保持其长度。

当我们进入 3D 时,思想是类似的,但我们必须考虑绕不同轴的旋转。 在 3D 空间中,你可以绕 x 轴、y 轴或 z 轴旋转向量。对于每种类型的旋转,都有一个对应的旋转矩阵。就像在 2D 中一样,向量在旋转过程中长度保持不变。

这是一个简化的分解:

  • 绕 x 轴旋转:向量的 y 和 z 分量会变化,但 x 分量保持不变。

  • 绕 y 轴旋转:向量的 x 和 z 分量会变化,但 y 分量保持不变。

  • 绕 z 轴旋转:向量的 x 和 y 分量会变化,但 z 分量保持不变。

3D 旋转矩阵考虑了旋转角度和旋转轴。 当你将一个向量与这些旋转矩阵之一相乘时,你会得到一个围绕指定轴旋转了指定角度的新向量。

相同的逻辑适用:如果你旋转一个向量,然后再按相同角度的相反方向旋转它(使用逆旋转),你会回到原始向量。

这个概念也可以扩展到更高维度,尽管当你超出 3D 时,旋转的可视化变得更加复杂。在任何维度中,使用矩阵进行旋转的概念及这些旋转的性质(如保持向量的长度)仍然是一致的。

除非另有说明,所有图片均由作者提供。

解密随机森林

原文:towardsdatascience.com/demystifying-the-random-forest-8a46f4fd416f?source=collection_archive---------7-----------------------#2023-02-07

解构并理解这个美妙的算法

Siddarth RameshTowards Data Science Siddarth Ramesh

·

关注 发表在Towards Data Science ·15 分钟阅读·2023 年 2 月 7 日

--

照片由Inggrid Koe拍摄,发布在Unsplash

在经典的机器学习中,随机森林一直是一种万灵药式的模型。

这个模型有几个优点:

  • 相比许多其他算法,需要更少的数据预处理,这使得设置更加简单

  • 作为分类模型或回归模型均可使用

  • 不容易过拟合

  • 可以轻松计算特征重要性

在这篇文章中,我想更好地理解构成随机森林的组件。为了实现这一点,我将随机森林分解为其最基本的组件,并解释每个计算层级的情况。最后,我们将更深入地了解随机森林的工作原理以及如何更直观地处理它们。我们将使用的示例将重点放在分类上,但许多原则也适用于回归场景。

运行随机森林

我们先来调用一个经典的随机森林模式。这是最高水平的,在 python 训练随机森林的时候,很多人都会这样做。

模拟数据。图片由作者提供

如果我想运行一个随机森林来预测我的target列,我只需要做以下操作

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(df.drop('target', axis=1), df['target'], test_size=0.2, random_state=0)

# Train and score Random Forest 
simple_rf_model = RandomForestClassifier(n_estimators=100, random_state=0)
simple_rf_model.fit(X_train, y_train)
print(f"accuracy: {simple_rf_model.score(X_test, y_test)}")

# accuracy: 0.93

运行随机森林分类器非常简单。我只定义了n_estimators参数,并将random_state设置为 0。我可以告诉你,从个人经验来看,很多人只会看到那个.93,感到高兴,并在实际应用中部署它。但我们今天不会这样做。

让我们重新审视一下这条无害的线

simple_rf_model = RandomForestClassifier(n_estimators=100, random_state=0)

随机种子是大多数数据科学模型的一个特性,确保别人可以复现你的工作。我们不会太担心这个参数。

让我们深入看看n_estimators。如果我们看看scikit-learn 文档,定义如下:

森林中树的数量。

调查决策树的数量

此时,让我们更具体地定义一下随机森林。随机森林是一个由许多决策树共识的集成模型。这个定义可能还不完整,但我们会再回来讨论。

许多决策树相互交流并达成共识。图片由作者提供

这可能会让你觉得,如果你将它分解成以下的东西,你可能会得到一个随机森林:

# Create decision trees
tree1 = DecisionTreeClassifier().fit(X_train, y_train)
tree2 = DecisionTreeClassifier().fit(X_train, y_train)
tree3 = DecisionTreeClassifier().fit(X_train, y_train)

# predict each decision tree on X_test
predictions_1 = tree1.predict(X_test)
predictions_2 = tree2.predict(X_test)
predictions_3 = tree3.predict(X_test)
print(predictions_1, predictions_2, predictions_3)

# take the majority rules
final_prediction = np.array([np.round((predictions_1[i] + predictions_2[i] + predictions_3[i])/3) for i in range(len(predictions_1))])
print(final_prediction)

在上面的例子中,我们在X_train上训练了 3 棵决策树,这意味着n_estimators = 3。在训练了这 3 棵树之后,我们对同样的测试集上的每棵树进行预测,最后取出其中 2 棵树的预测结果。

这种解释有点有道理,但这看起来不完全正确。如果所有的决策树都是在同样的数据上训练的,它们不会大部分达成相同的结论吗,从而抵消了集成的优势?

解密带替换的抽样

让我们在之前的定义中加上一个词:随机森林是一个由许多不相关的决策树共识集成模型。

决策树可以变得不相关,有两种方式:

  1. 你有足够大的数据集大小,可以对每个决策树抽样数据的唯一部分。这并不那么流行,通常需要大量的数据。

  2. 你可以利用一种叫做带放回抽样的技术。带放回抽样意味着从总体中抽取的样本在下一个样本抽取之前会被放回总体。

为了说明带放回抽样的概念,假设我有 5 颗珠子,颜色有 3 种,因此我的总体如下:

blue, blue, red, green, red

如果我想抽取一些珠子,我通常会抽出几颗,也许会得到:

blue, red

这是因为一旦我捡起了红色的珠子,我就没有把它放回到原来的珠子堆里。

但是,如果我在进行带放回抽样,我实际上可以两次抽取任何一颗珠子。因为红色的珠子被放回了我的堆里,所以我有可能再次抽到它。

red, red

在随机森林中,默认情况下是构建约 2/3 原始总体大小的样本。如果我的原始训练数据是 1000 行,那么我输入到树中的训练数据样本可能会有大约 670 行。也就是说,尝试不同的抽样比例将是构建随机森林时调节的一个很好的参数。

以下代码,与之前的代码片段不同,更接近于n_estimators = 3的随机森林。

import numpy as np
import pandas as pd
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split

# Take 3 samples with replacement from X_train for each tree
df_sample1 = df.sample(frac=.67, replace=True)
df_sample2 = df.sample(frac=.67, replace=True)
df_sample3 = df.sample(frac=.67, replace=True)

X_train_sample1, X_test_sample1, y_train_sample1, y_test_sample1 = train_test_split(df_sample1.drop('target', axis=1), df_sample1['target'], test_size=0.2)
X_train_sample2, X_test_sample2, y_train_sample2, y_test_sample2 = train_test_split(df_sample2.drop('target', axis=1), df_sample2['target'], test_size=0.2)
X_train_sample3, X_test_sample3, y_train_sample3, y_test_sample3 = train_test_split(df_sample3.drop('target', axis=1), df_sample3['target'], test_size=0.2)

# Create the decision trees
tree1 = DecisionTreeClassifier().fit(X_train_sample1, y_train_sample1)
tree2 = DecisionTreeClassifier().fit(X_train_sample2, y_train_sample2)
tree3 = DecisionTreeClassifier().fit(X_train_sample3, y_train_sample3)

# predict each decision tree on X_test
predictions_1 = tree1.predict(X_test)
predictions_2 = tree2.predict(X_test)
predictions_3 = tree3.predict(X_test)
df = pd.DataFrame([predictions_1, predictions_2, predictions_3]).T
df.columns = ["tree1", "tree2", "tree3"]

# take the majority rules 
final_prediction = np.array([np.round((predictions_1[i] + predictions_2[i] + predictions_3[i])/3) for i in range(len(predictions_1))])
preds = pd.DataFrame([predictions_1, predictions_2, predictions_3, final_prediction, y_test]).T.head(20)
preds.columns = ["tree1", "tree2", "tree3", "final", "label"]
preds

我们进行带放回抽样,将这些样本输入到树中,生成结果,并达成共识。图片由作者提供。

Bagging 分类器

之前的架构实际上是一个 Bagging 分类器。图片由作者提供。

我们将在此引入一种新的算法,称为Bootstrap Aggregation,也称为 Bagging,但请放心,这将与随机森林相关联。我们引入这个新概念的原因是因为正如我们在下图中看到的,直到现在我们所做的一切实际上就是BaggingClassifier所做的!

在下面的代码中,BaggingClassifier有一个叫做bootstrap的参数,它实际上执行了我们刚刚手动完成的带放回抽样步骤。这个参数在sklearn的随机森林实现中也存在。如果 bootstrapping 为 false,我们将使用整个总体来训练每个分类器。

import numpy as np
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import BaggingClassifier

# Number of trees to use in the ensemble
n_estimators = 3

# Initialize the bagging classifier
bag_clf = BaggingClassifier(
    DecisionTreeClassifier(), n_estimators=n_estimators, bootstrap=True)

# Fit the bagging classifier on the training data
bag_clf.fit(X_train, y_train)

# Make predictions on the test data
y_pred = bag_clf.predict(X_test)
pd.DataFrame([y_pred, y_test]).T

BaggingClassifiers非常棒,因为你可以将它们与非决策树的估计器一起使用!你可以插入许多算法,Bagging 将其转化为一个集成解决方案。随机森林算法实际上扩展了 Bagging 算法(如果bootstrapping = true),因为它部分利用了 Bagging 来形成不相关的决策树。

但是,即使bootstrapping = false,随机森林也会进一步确保树之间没有相关性——特征抽样。

解密特征抽样

特征抽样意味着不仅对行进行抽样,还对列进行抽样。与行不同,随机森林的列是不带放回抽样的,这意味着我们不会有重复的列训练一棵树。

采样特征的方法有很多种。你可以指定一个固定的特征最大数量进行采样,取特征总数的平方根,或者尝试使用对数。这些方法各有利弊,具体取决于你的数据和使用场景。

Bagging 扩展了特征采样。图片由作者提供。

下面的代码片段使用sqrt技术采样列,采样行,训练 3 棵决策树,并使用多数规则进行预测。我们首先进行有放回采样,然后采样列,训练我们的单独决策树,让树在测试数据上进行预测,然后采用多数规则的共识。

import numpy as np
import pandas as pd
import math
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split

# take 3 samples from X_train for each tree
df_sample1 = df.sample(frac=.67, replace=True)
df_sample2 = df.sample(frac=.67, replace=True)
df_sample3 = df.sample(frac=.67, replace=True)

# split off train set
X_train_sample1, y_train_sample1 = df_sample1.drop('target', axis=1), df_sample1['target']
X_train_sample2, y_train_sample2 = df_sample2.drop('target', axis=1), df_sample2['target']
X_train_sample3, y_train_sample3 = df_sample3.drop('target', axis=1), df_sample3['target']

# get sampled features for train and test using sqrt, note how replace=False now
num_features = len(X_train.columns)
X_train_sample1 = X_train_sample1.sample(n=int(math.sqrt(num_features)), replace=False, axis = 1)
X_train_sample2 = X_train_sample2.sample(n=int(math.sqrt(num_features)), replace=False, axis = 1)
X_train_sample3 = X_train_sample3.sample(n=int(math.sqrt(num_features)), replace=False, axis = 1)

# create the decision trees, this time we are sampling columns
tree1 = DecisionTreeClassifier().fit(X_train_sample1, y_train_sample1)
tree2 = DecisionTreeClassifier().fit(X_train_sample2, y_train_sample2)
tree3 = DecisionTreeClassifier().fit(X_train_sample3, y_train_sample3)

# predict each decision tree on X_test
predictions_1 = tree1.predict(X_test[X_train_sample1.columns])
predictions_2 = tree2.predict(X_test[X_train_sample2.columns])
predictions_3 = tree3.predict(X_test[X_train_sample3.columns])
preds = pd.DataFrame([predictions_1, predictions_2, predictions_3]).T
preds.columns = ["tree1", "tree2", "tree3"]

# take the majority rules 
final_prediction = np.array([np.round((predictions_1[i] + predictions_2[i] + predictions_3[i])/3) for i in range(len(predictions_1))])
preds = pd.DataFrame([predictions_1, predictions_2, predictions_3, final_prediction, y_test]).T.head(20)
preds.columns = ["tree1", "tree2", "tree3", "final", "label"]

当我运行这段代码时,我发现我的决策树开始预测不同的结果,这表明我们已经去除了树之间的许多相关性。

我的树不再总是彼此一致!图片由作者提供。

决策树基础

到目前为止,我们已经拆解了数据如何被输入到众多决策树中。在之前的代码示例中,我们使用了DecisionTreeClassifier()来训练决策树,但为了完全理解随机森林,我们需要进一步拆解决策树。

决策树,顾名思义,看起来像一棵倒立的树。从高层次来看,该算法试图通过提问将数据分裂成不同的节点。下图展示了决策树的样子。

示例树。图片由作者提供

决策树根据前一个问题的答案提出一系列问题。每个问题可能有多个答案,我们将其可视化为节点的分裂。前一个问题的答案将决定树接下来会提出什么问题。在提出一系列问题后,最终你会得到答案。

但你如何知道你的答案是准确的,或者你问了正确的问题?你实际上可以通过几种不同的方式评估你的决策树,我们当然也会详细解析这些方法。

熵与信息增益

在这一点上,我们需要讨论一个新的术语——。从高层次来看,熵是一种衡量节点中杂质随机性水平的方法。顺便提一下,还有一种叫做基尼杂质的流行方法来测量节点的杂质,但由于它与熵的概念重叠,我们在这篇文章中不会拆解这个方法,尽管它的计算方式略有不同。一般来说,熵或基尼杂质越高,节点中的方差越大,我们的目标是减少这种不确定性。

决策树试图通过将它们提问的节点分裂成更小、更均匀的节点来最小化熵。熵的实际公式是

为了分解熵,我们回到弹珠的例子:

假设我有 10 个弹珠,其中 5 个是蓝色的,5 个是绿色的。我数据集的熵为 1.0,计算熵的代码如下:

from collections import Counter
from math import log2

# my predictor classes are 0 or 1\. 0 is a blue marble, 1 is a green marble.
data = [0, 0, 0, 1, 1, 1, 1, 0, 1, 0]
# get length of labels
len_labels = len(data)
def calculate_entropy(data, len_labels):
    # we do a count of each class
    counts = Counter(labels)
    # we calculate the fractions, the output should be [.5, .5] for this example
    probs = [count / num_labels for count in counts.values()]
    # the actual entropy calculation 
    return - sum(p * log2(p) for p in probs)

calculate_entropy(labels, num_labels)

如果data完全被绿色弹珠填充,那么熵将为 0,而熵将随着接近 50%分裂而增加。

每次我们减少熵时,我们就获得了一些关于数据集的信息,因为我们减少了随机性。信息增益告诉我们哪个特征相对最好地减少了熵。计算信息增益的方法是:

entropy(parent) — [weighted_average_of_entropy(children)]

在这种情况下,父节点是原始节点,子节点是分裂节点后的结果。

分裂一个节点。图片来源:作者

要计算信息增益,我们执行以下操作:

  • 计算父节点的熵

  • 将父节点分裂为子节点

  • 为每个子节点创建weight。这是通过number_of_samples_in_child_node / number_of_samples_in_parent_node来测量的

  • 计算每个子节点的熵

  • 通过计算weight*entropy_of_child1 + weight*entropy_of_child2来创建[weighted_average_of_entropy(children)]

  • 从父节点的熵中减去加权熵

下面的代码实现了父节点被分裂成两个子节点的简单信息增益

def information_gain(left_labels, right_labels, parent_entropy):
    """Calculate the information gain of a split"""
    # calculate the weight of the left node
    proportion_left_node = float(len(left_labels)) / (len(left_labels) + len(right_labels))
    #calculate the weight of the right node
    proportion_right_node = 1 - proportion_left_node
    # compute the weighted average of the child node
    weighted_average_of_child_nodes = ((proportion_left_node * entropy(left_labels)) + (proportion_right_node * entropy(right_labels)))
    # return the parent node entropy - the weighted entropy of child nodes
    return parent_entropy - weighted_average_of_child_nodes

解构决策树

了解这些概念后,我们准备实现一个小的决策树!

没有任何指导下,决策树将不断分裂节点,直到所有最终的叶节点都是纯净的。控制树复杂度的想法称为剪枝,我们可以在树完全构建后剪枝,或者在生长阶段之前用某些参数进行预剪枝。预剪枝树复杂度的一些方法是控制分裂次数,限制最大深度(从根节点到叶节点的最长距离),或设置信息增益。

下面的代码将所有这些概念结合在一起

  • 从一个包含目标变量的数据集开始

  • 计算原始数据集(根节点)的熵(或基尼不纯度)

  • 查看每个特征并计算信息增益

  • 选择信息增益最佳的特征,即导致熵减少最多的特征

  • 继续生长,直到满足我们的停止条件——在这种情况下是我们的最大深度限制和熵为 0 的节点。

import pandas as pd
import numpy as np
from math import log2

def entropy(data, target_col):
    # calculate the entropy of the entire dataset
    values, counts = np.unique(data[target_col], return_counts=True)
    entropy = np.sum([-count/len(data) * log2(count/len(data)) for count in counts])
    return entropy

def compute_information_gain(data, feature, target_col):
    parent_entropy = entropy(data, target_col)
    # calculate the information gain for splitting on a given feature
    split_values = np.unique(data[feature])
    # initialize at 0
    weighted_child_entropy = 0
    # compute the weighted entropy, remember that this is related to the number of points in the new node
    for value in split_values:
        sub_data = data[data[feature] == value]
        node_weight = len(sub_data)/len(data)
        weighted_child_entropy += node_weight * entropy(sub_data, target_col)
    # same calculation as before, we just subtract the weighted entropy from the parent node entropy 
    return parent_entropy - weighted_child_entropy

def grow_tree(data, features, target_col, depth=0, max_depth=3):
    # we set a max depth of 3 to "pre-prune" or limit the tree complexity
    if depth >= max_depth or len(np.unique(data[target_col])) == 1:
        # stop growing the tree if maximum depth is reached or all labels are the same. All labels being the same means the entropy is 0
        return np.unique(data[target_col])[0]
    # we compute the best feature (or best question to ask) based on information gain
    node = {}
    gains = [compute_information_gain(data, feature, target_col) for feature in features]
    best_feature = features[np.argmax(gains)]

    for value in np.unique(data[best_feature]):
        sub_data = data[data[best_feature] == value]
        node[value] = grow_tree(sub_data, features, target_col, depth+1, max_depth)

    return node

# simulate some data and make a dataframe, note how we have a target
data = {
    'A': [1, 2, 1, 2, 1, 2, 1, 2],
    'B': [3, 3, 4, 4, 3, 3, 4, 4],
    'C': [5, 5, 5, 5, 6, 6, 6, 6],
    'target': [0, 0, 0, 1, 1, 1, 1, 0]
}
df = pd.DataFrame(data)

# define our features and label
features = ["A", "B", "C"]
target_col = "target"

# grow the tree
tree = grow_tree(df, features, target_col, max_depth=3)
print(tree)

在这棵树上进行预测意味着用你的新数据遍历已生长的树,直到到达叶节点。最终的叶节点就是预测结果。

关于随机森林的一些有趣事项

在上一节中,我们讨论了单棵决策树如何做出决策。下面的图像将这些概念与我们之前讨论的关于随机森林采样的概念联系起来。

随机森林架构与解构决策树。图像来源:作者

由于决策树实际上检查了每个特征的信息增益,你可以在随机森林中计算特征重要性。特征重要性的计算通常被视为所有树中杂质的平均减少。随机森林不像逻辑回归那样易于解释,因此特征重要性为我们提供了有关树如何生长的一些洞察。

最后,你可以通过几种方式来测试训练好的随机森林。你可以采用经典的机器学习方法,使用测试集来衡量模型对未见数据的泛化能力。然而,这通常需要额外的计算。随机森林具有一种独特的属性,称为袋外误差OOB 误差。还记得我们如何只对数据集的一部分进行采样来构建每棵树吗?实际上,你可以使用剩余的样本在训练时进行验证,这仅仅因为算法的集成性质才成为可能。这意味着我们可以一次性了解模型对未见数据的泛化能力。

结论和最终思考

总结我们所学到的:

  • 随机森林实际上是由多个不相关的决策树组成的集成,它们进行预测并达成共识。对于回归问题,这个共识是得分的平均值,对于分类问题则是多数规则。

  • 随机森林通过利用袋装特征采样来减轻相关性。通过利用这两种技术,个体决策树在观察数据集的特定维度,并基于不同因素做出预测。

  • 决策树通过在产生最高信息增益的特征上进行数据分裂来生长。信息增益是通过最大发生的杂质减少来度量的。杂质通常通过基尼杂质来度量。

  • 随机森林通过特征重要性实现了有限的可解释性,特征重要性是对特征的平均信息增益的度量。

  • 随机森林还具有在训练时进行交叉验证的能力,这是一种独特的技术,称为OOB误差。这是由于算法在上游采样数据的方式所使然。

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(df.drop('target', axis=1), df['target'], test_size=0.2, random_state=0)

# Train and score Random Forest 
simple_rf_model = RandomForestClassifier(n_estimators=100, random_state=0)
simple_rf_model.fit(X_train, y_train)
print(f"accuracy: {simple_rf_model.score(X_test, y_test)}")

# accuracy: 0.93

在查看训练随机森林的原始代码时,我对这些少数代码行中发生的各种计算和评估感到惊讶。为了防止过拟合、在树和森林层面进行评估并实现一定程度的可解释性,考虑了多种因素——而且由于各种框架的存在,设置起来非常简单。

我希望下次你训练一个随机森林模型时,能够查看一下有关随机森林的[scikit-learn](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html) 文档页面,更好地理解你所拥有的所有选项。虽然有一些直观的默认设置,但应该清楚你可以做多少不同的调整,以及这些技术有多少可以扩展到其他模型中。

我在写这篇文章时非常开心,并且个人对这个美妙算法的工作原理学到了很多。我希望你也能有所收获!

将自定义 ML 模型部署为 SageMaker 端点

原文:towardsdatascience.com/deploy-a-custom-ml-model-as-a-sagemaker-endpoint-6d2540226428?source=collection_archive---------0-----------------------#2023-12-08

照片由 Ricardo Gomez Angel 提供,来源于 Unsplash

SageMaker 端点部署

快速简便的 AWS SageMaker 端点创建指南

Hai RozencwajgTowards Data Science Hai Rozencwajg

·

关注 发表在 Towards Data Science · 10 分钟阅读 · 2023 年 12 月 8 日

--

开发机器学习(ML)模型涉及关键步骤,从数据收集到模型部署。在通过测试优化算法并确保性能后,最终的关键步骤是部署。这个阶段将创新转化为实用性,使他人能够从模型的预测能力中受益。部署的 ML 模型弥合了开发和现实世界影响之间的差距,为用户和利益相关者提供了切实的好处。

本指南涵盖了将自定义 ML 作为 SageMaker 端点开发所需的基本步骤。在这一点上,我假设你已经有一个工作模型,并希望通过端点将其公开给其他人。该指南将引导你部署一个基于 PyTorch 的模型,旨在预测视频片段中的异常。该模型,也称为AI VAD,基于论文《基于属性的准确且可解释的视频异常检测表示》,其实现可以在 anomalib GitHub 仓库中找到,仓库由 OpenVINO 提供。要了解更多有关这种有趣方法的信息,请滚动到本博客的末尾,查看附录部分。

在这一点上,我想强调的是,在这种情况下,我们不能使用专门为部署 PyTorch 模型构建的 PyTorchModel 抽象,原因有两个。第一个原因是我们有 anomalib 包作为额外的依赖项,而该依赖项不包含在预构建的 PyTorch SageMaker 镜像中。第二个原因是模型需要在训练步骤中学到的额外信息,而这些信息不是 PyTorch 模型权重的一部分。

实现这一目标的步骤如下:

  1. 编写 SageMaker 模型服务脚本

  2. 将模型上传到 S3

  3. 将自定义 Docker 镜像上传到 AWS ECR

  4. 在 SageMaker 中创建模型

  5. 创建端点配置

  6. 创建端点

  7. 调用端点

编写 SageMaker 模型服务脚本

SageMaker 模型服务脚本(inference.py)是创建 SageMaker 模型的重要组成部分。它在机器学习模型和现实数据之间架起桥梁。本质上,它处理传入请求,运行模型预测,并返回结果,从而影响应用程序的决策过程。

inference.py 脚本由几个关键方法组成,每个方法都具有独特的功能,共同促进模型服务过程。下面列出了四个主要方法。

  1. model_fn 方法负责加载训练好的模型。它读取已保存的模型工件,并返回一个可以用于预测的模型对象。此方法仅在启动 SageMaker 模型服务器时调用一次。

  2. input_fn 方法将请求数据格式化为适合进行预测的形式。例如,在下面的代码中,这个函数根据数据来源(图像字节或 S3 URI 列表)以及帧列表是否应视为一个视频片段来格式化数据。

  3. predict_fn 方法接受格式化的请求数据并对加载的模型进行推断。

  4. 最后,使用 output_fn 方法。它将预测结果格式化为响应消息。例如,将其打包为 JSON 对象。

Sagemaker 模型服务脚本的代码可以在下面找到。

import os
import json
import joblib
import torch
from PIL import Image
import numpy as np
import io
import boto3
from enum import Enum
from urllib.parse import urlsplit
from omegaconf import OmegaConf
from anomalib.data.utils import read_image, InputNormalizationMethod, get_transforms
from anomalib.models.ai_vad.torch_model import AiVadModel

device = "cuda"

class PredictMode(Enum):
    frame = 1
    batch = 2
    clip = 3

def model_fn(model_dir):
    """
    This function is the first to get executed upon a prediction request,
    it loads the model from the disk and returns the model object which will be used later for inference.
    """

    # Load the config file
    config = OmegaConf.load(os.path.join(model_dir, "ai_vad_config.yaml"))
    config_model = config.model

    # Load the model
    model = AiVadModel(
            box_score_thresh=config_model.box_score_thresh,
            persons_only=config_model.persons_only,
            min_bbox_area=config_model.min_bbox_area,
            max_bbox_overlap=config_model.max_bbox_overlap,
            enable_foreground_detections=config_model.enable_foreground_detections,
            foreground_kernel_size=config_model.foreground_kernel_size,
            foreground_binary_threshold=config_model.foreground_binary_threshold,
            n_velocity_bins=config_model.n_velocity_bins,
            use_velocity_features=config_model.use_velocity_features,
            use_pose_features=config_model.use_pose_features,
            use_deep_features=config_model.use_deep_features,
            n_components_velocity=config_model.n_components_velocity,
            n_neighbors_pose=config_model.n_neighbors_pose,
            n_neighbors_deep=config_model.n_neighbors_deep,
        )

    # Load the model weights
    model.load_state_dict(torch.load(os.path.join(model_dir, "ai_vad_weights.pth"), map_location=device), strict=False)

    # Load the memory banks
    velocity_estimator_memory_bank, pose_estimator_memory_bank, appearance_estimator_memory_bank = joblib.load(os.path.join(model_dir, "ai_vad_banks.joblib")) 
    if velocity_estimator_memory_bank is not None:
        model.density_estimator.velocity_estimator.memory_bank = velocity_estimator_memory_bank
    if pose_estimator_memory_bank is not None:
        model.density_estimator.pose_estimator.memory_bank = pose_estimator_memory_bank
    if appearance_estimator_memory_bank is not None:
        model.density_estimator.appearance_estimator.memory_bank = appearance_estimator_memory_bank
    model.density_estimator.fit()

    # Move the entire model to device
    model = model.to(device)

    # get the transforms
    transform_config = config.dataset.transform_config.eval if "transform_config" in config.dataset.keys() else None
    image_size = (config.dataset.image_size[0], config.dataset.image_size[1])
    center_crop = config.dataset.get("center_crop")
    center_crop = tuple(center_crop) if center_crop is not None else None
    normalization = InputNormalizationMethod(config.dataset.normalization)
    transform = get_transforms(config=transform_config, image_size=image_size, center_crop=center_crop, normalization=normalization)

    return model, transform

def input_fn(request_body, request_content_type):
    """
    The request_body is passed in by SageMaker and the content type is passed in 
    via an HTTP header by the client (or caller).
    """

    print("input_fn-----------------------")

    if request_content_type in ("application/x-image", "image/x-image"):
        image = Image.open(io.BytesIO(request_body)).convert("RGB")
        numpy_array = np.array(image)
        print("numpy_array.shape", numpy_array.shape)
        print("input_fn-----------------------")
        return [numpy_array], PredictMode.frame

    elif request_content_type == "application/json":
        request_body_json = json.loads(request_body)

        s3_uris = request_body_json.get("images", [])

        if len(s3_uris) == 0:
            raise ValueError(f"Images is a required key and should contain at least a list of one S3 URI")

        s3 = boto3.client("s3")
        frame_paths = []
        for s3_uri in s3_uris:
            parsed_url = urlsplit(s3_uri)
            bucket_name = parsed_url.netloc
            object_key = parsed_url.path.lstrip('/')
            local_frame_path = f"/tmp/{s3_uri.replace('/', '_')}"
            # Download the frame from S3
            s3.download_file(bucket_name, object_key, local_frame_path)
            frame_paths.append(local_frame_path)

        frames = np.stack([torch.Tensor(read_image(frame_path)) for frame_path in frame_paths], axis=0)

        predict_mode = PredictMode.clip if request_body_json.get("clip", False) else PredictMode.batch

        print("frames.shape", frames.shape)
        print("predict_mode", predict_mode)
        print("input_fn-----------------------")

        return frames, predict_mode

    # If the request_content_type is not as expected, raise an exception
    raise ValueError(f"Content type {request_content_type} is not supported")

def predict_fn(input_data, model):
    """
    This function takes in the input data and the model returned by the model_fn
    It gets executed after the model_fn and its output is returned as the API response.
    """

    print("predict_fn-----------------------")

    model, transform = model

    frames, predict_mode = input_data

    processed_data = {}
    processed_data["image"] = [transform(image=frame)["image"] for frame in frames]
    processed_data["image"] = torch.stack(processed_data["image"])

    image = processed_data["image"].to(device)

    # Add one more dimension for a batch size of one clip
    if predict_mode == PredictMode.clip:
        image = image.unsqueeze(0)

    print("image.shape", image.shape)

    model.eval()

    with torch.no_grad():
        boxes, anomaly_scores, image_scores = model(image)

    print("boxes_len", [len(b) for b in boxes])

    processed_data["pred_boxes"] = [box.int() for box in boxes]
    processed_data["box_scores"] = [score.to(device) for score in anomaly_scores]
    processed_data["pred_scores"] = torch.Tensor(image_scores).to(device)

    print("predict_fn-----------------------")

    return processed_data

def output_fn(prediction, accept):
    """
    Post-processing function for model predictions. It gets executed after the predict_fn.
    """

    print("output_fn-----------------------")

    # Check if accept type is JSON
    if accept != "application/json":
        raise ValueError(f"Accept type {accept} is not supported")

    # Convert PyTorch Tensors to lists so they can be JSON serializable
    for key in prediction:
        # If torch.Tensor convert it to list
        if isinstance(prediction[key], torch.Tensor):
            prediction[key] = prediction[key].tolist()
        # If list, convert every tensor in the list
        elif isinstance(prediction[key], list):
            prediction[key] = [tensor.tolist() if isinstance(tensor, torch.Tensor) else tensor for tensor in prediction[key]]

    print("output_fn-----------------------")

    return json.dumps(prediction), accept

P.S. 强烈建议在进入下一步之前测试模型服务脚本。这可以通过模拟调用管道轻松完成,如下面的代码所示。

import json
from inference import model_fn, predict_fn, input_fn, output_fn

response, accept = output_fn(
    predict_fn(
        input_fn(payload, "application/x-image"),
        model_fn("../")
    ),
    "application/json"
)
json.loads(response).keys()

将模型上传到 S3

要创建一个 SageMaker 端点,使其加载 AI VAD PyTorch 模型到完全相同的状态,我们需要以下文件:

  • AI VAD PyTorch 模型的权重(即 state_dict)

  • 密度估计器内存库(不属于模型的权重)

  • 包含 PyTorch 模型超参数的配置文件

  • Sagemaker 模型服务脚本 (inference.py)

以下代码演示了如何将所有所需的文件组织在一个目录中。

P.S. 我重写了内置的 PyTorch ModelCheckpoint 回调,以确保这些内存库作为检查点保存的一部分(实现可以在 这里 找到)。

import torch
import joblib
import shutil

checkpoint = "results/ai_vad/ucsd/run/weights/lightning/model.ckpt"
config_path = "results/ai_vad/ucsd/run/config.yaml"

model_weights = torch.load(checkpoint)
model_state_dict = model_weights["state_dict"]

torch.save(model_state_dict, "../ai_vad_weights.pth")

velocity_estimator_memory_bank = None
pose_estimator_memory_bank = None
appearance_estimator_memory_bank = None
if "velocity_estimator_memory_bank" in model_weights:
    velocity_estimator_memory_bank = model_weights["velocity_estimator_memory_bank"]
if "pose_estimator_memory_bank" in model_weights:
    pose_estimator_memory_bank = model_weights["pose_estimator_memory_bank"]
if "appearance_estimator_memory_bank" in model_weights:
    appearance_estimator_memory_bank = model_weights["appearance_estimator_memory_bank"]
banks = (velocity_estimator_memory_bank, pose_estimator_memory_bank, appearance_estimator_memory_bank)

joblib.dump(banks, "../ai_vad_banks.joblib")

shutil.copyfile(config_path, "../ai_vad_config.yaml")

然后,使用下面的命令将这四个文件压缩在一起,生成 tar.gz 文件。

tar -czvf ../ai_vad_model.tar.gz -C ../ ai_vad_weights.pth ai_vad_banks.joblib ai_vad_config.yaml inference.py

最后,使用 boto3 将文件上传到 S3。

import boto3
from datetime import datetime

current_datetime = datetime.now().strftime('%Y-%m-%d-%H-%M-%S')

s3 = boto3.resource('s3')
s3.meta.client.upload_file("../ai_vad_model.tar.gz", "ai-vad", f"{current_datetime}/ai_vad_model.tar.gz")

将自定义 Docker 镜像上传到 AWS ECR

如上所述,由于我们有一个不包含在预构建 PyTorch Sagemaker 镜像中的额外依赖项(即 anomalib 包),我们为此目的创建了一个新的 Docker 镜像。在构建自定义 Docker 镜像之前,需要进行 Amazon ECR 存储库认证。

REGION=<my_aws_region>
ACCOUNT=<my_aws_account>

# Authenticate Docker to an Amazon ECR registry
aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin <docker_registry_url>.dkr.ecr.$REGION.amazonaws.com

# Loging to your private Amazon ECR registry
aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $ACCOUNT.dkr.ecr.$REGION.amazonaws.com

Dockerfile 可以在下面找到,不同的 Docker 注册表路径可以在 这里 找到。确保根据模型的需求(CPU/GPU,Python 版本等)和您的 AWS 区域选择正确的注册表路径。例如,如果区域是 us-east-1,则完整的 Docker 注册表路径应类似于:

763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-inference:2.0.0-gpu-py310

# Use the SageMaker PyTorch image as the base image
FROM <docker_registry_url>.dkr.ecr.<my_aws_region>.amazonaws.com/pytorch-inference:2.0.0-gpu-py310

# Install the additional dependency
RUN pip install "git+https://github.com/hairozen/anomalib.git@ai-vad-inference-improvements"

现在,我们可以运行经典的 Docker 构建命令来构建这个自定义镜像。

docker build -t ai-vad-image .

下一步是为我们构建的新镜像创建 AWS ECR 存储库,对其进行标记,然后将镜像推送到 AWS ECR 存储库。

# Create the AWS ECR repository
aws ecr create-repository --repository-name ai-vad-image

# Tag the image
docker tag ai-vad-image:latest $ACCOUNT.dkr.ecr.$REGION.amazonaws.com/ai-vad-image:latest

# Push the tagged image to the AWS ECR repository
docker push $ACCOUNT.dkr.ecr.$REGION.amazonaws.com/ai-vad-image:latest

在 SageMaker 中创建一个模型

这一步非常简单。代码如下。

import boto3
import sagemaker

sagemaker_client = boto3.client(service_name="sagemaker")
role = sagemaker.get_execution_role()

model_name = f"ai-vad-model-{current_datetime}"

primary_container = {
    "Image": f"{my_aws_account}.dkr.ecr.{my_aws_region}.amazonaws.com/ai-vad-image:latest",
    "ModelDataUrl": f"s3://ai-vad/{current_datetime}/ai_vad_model.tar.gz"
}

create_model_response = sagemaker_client.create_model(
    ModelName=model_name,
    ExecutionRoleArn=role,
    PrimaryContainer=primary_container)

创建端点配置

下一步是创建一个端点配置。下面是一个基本的示例。

endpoint_config_name = f"ai-vad-model-config-{current_datetime}"

sagemaker_client.create_endpoint_config(
    EndpointConfigName=endpoint_config_name,
    ProductionVariants=[{
        "InstanceType": "ml.g5.xlarge",
        "InitialVariantWeight": 1,
        "InitialInstanceCount": 1,
        "ModelName": model_name,
        "VariantName": "AllTraffic"}])

创建一个端点

现在我们准备好创建端点本身了。

endpoint_name = f"ai-vad-model-endpoint-{current_datetime}"

sagemaker_client.create_endpoint(
    EndpointName=endpoint_name,
    EndpointConfigName=endpoint_config_name)

请注意,端点状态可能需要几分钟才能从“创建中”变为“服务中”。可以通过下面所示的方式检查当前状态。

response = sagemaker_client.describe_endpoint(EndpointName=endpoint_name)
response["EndpointStatus"]

调用端点

关键时刻到了。现在是调用端点测试一切是否按预期工作的时候。

with open(file_name, "rb") as f:
    payload = f.read()

predictor = sagemaker.predictor.Predictor(endpoint_name=endpoint_name)
predictor.serializer = DataSerializer(content_type="image/x-image")
predictor.predict(payload)

因此,这是一项很好的检查,但请注意,predictor.predict 函数并不会运行完整的调用管道,这个管道来自 SageMaker 服务脚本,其中包括:

output_fn(predict_fn(input_fn(input_data, model_fn(model_dir)),accept)

要测试它,我们可以通过 API 调用来调用模型。

with open(file_name, "rb") as f:
    payload = f.read()

sagemaker_runtime = boto3.client("runtime.sagemaker")
response = sagemaker_runtime.invoke_endpoint(
    EndpointName=endpoint_name,
    ContentType="image/x-image",
    Body=payload
)

response = json.loads(response["Body"].read().decode())

利用 anomalib 提供的出色可视化,我们可以绘制给定 UCSDped2 数据集中某帧的框和标签。

作者提供的图像。该图像是使用 anomalib 包生成的,基于 UCSD 异常检测数据集。绿色框表示那些行人的步态没有异常,而红色框表示的骑车者则可能由于 AI VAD 模型的速度和姿态特征而存在异常。

结论

好了,让我们快速回顾一下我们在这里讨论的内容。部署 SageMaker 模型以进行服务需要一系列步骤。

首先,必须编写 SageMaker 模型服务脚本,以定义模型的功能和行为。

然后将模型上传到 Amazon S3 进行存储和检索。

此外,定制的 Docker 镜像被上传到 AWS Elastic Container Registry (ECR),以容器化模型及其依赖项。

下一步是创建一个 SageMaker 模型,该模型将存储在 S3 中的模型工件与存储在 ECR 中的 Docker 镜像关联起来。

然后创建一个端点配置,定义用于托管模型的实例数量和类型。

最后,创建一个端点以在部署的模型和客户端应用程序之间建立实时连接,允许它们调用端点并进行实时预测。

通过这些步骤,部署 SageMaker 模型成为了一个简化的过程,确保了高效和可靠的模型服务。

附录

2023 年由 Reiss 等人发布的论文 基于属性的准确且可解释的视频异常检测,提出了一种使用基于属性的表示进行视频异常检测 (VAD) 的简单但极其有效的方法。

论文认为,传统的 VAD 方法通常依赖深度学习,这些方法往往难以解释,使得用户难以理解系统为何将某些帧或对象标记为异常。

为了解决这个问题,作者提出了一种方法,通过速度、姿态和深度来表示视频中的每个对象。这些属性易于理解和解释,并且可以使用基于密度的方法来计算异常分数。

论文表明,这种简单的表示方法足以在多个具有挑战性的 VAD 数据集上实现最先进的性能,包括上海科技大学的数据集,这是最大的和最复杂的 VAD 数据集。

除了准确之外,作者还展示了他们的方法具有可解释性。例如,他们可以向用户提供视频中对异常评分贡献最大的对象的列表,以及这些对象的速度、姿态和深度信息。这可以帮助用户理解系统为何将视频标记为异常。

总体而言,这篇论文是 VAD 领域的重要贡献。它提出了一种简单、准确且可解释的 VAD 方法,可以用于各种应用。

学习如何使用 Langchain 和 BentoML 构建和部署一个语音聊天机器人

原文:towardsdatascience.com/deploy-a-voice-based-chatbot-with-bentoml-langchain-and-gradio-7f25af3e45df

BentoML 对机器学习工程师就像乐高积木

Ahmed BesbesTowards Data Science Ahmed Besbes

·发表于Towards Data Science ·阅读时长 11 分钟·2023 年 5 月 2 日

--

Jason Leung的照片,来自Unsplash

在这篇文章中,我们将指导你如何构建一个基于语音的 ChatGPT 克隆,依赖于 OpenAI API 并使用维基百科作为额外的数据源。

要构建和部署这个应用程序,我们将使用BentoML:一个用于模型服务和部署的 Python 框架。

BentoML 不仅帮助你构建连接到第三方专有 API 的服务,还通过将这些服务与其他开源模型结合起来,超级增强了这些服务,形成复杂且强大的推理图。

实际上,我们将要构建的应用程序将包含语音转文本文本转语音任务,这些任务将由来自 HuggingFace hub的不同模型处理,LLM 任务将由LangChain管理。

在本地测试项目后,我们将把它推送到 BentoCloud,这个平台简化了版本控制、跟踪和将机器学习服务部署到云端的过程。

在本文结束时,你应该对使用 BentoML 构建和部署多模型服务有全面的了解。你还将学习到一些特定的功能,使模型工业化变得更容易。

不再赘言,让我们来看看 🔍。

演示

这是应用程序的一分钟演示。

作者的视频 — 快速演示

为什么选择 BentoML?🍱

随着越来越多的开源机器学习模型解决各种任务,软件应用程序将逐渐成为一种集成了预训练模型、自我训练模型或通过 API 访问模型的人工智能应用程序。

鉴于许多 SOTA 模型很大并且需要强大的硬件和分布式部署,将所有内容放入一台机器中并不是一个实际的解决方案,尤其是当应用程序结合了至少 2 或 3 个模型时。

→ BentoML 是一个框架,通过让用户编写简单的 Python 代码来解决这个问题,同时将模型部署为分布式微服务。

我已经玩弄和实验了 BentoML 一段时间,它绝对是我部署机器学习模型和服务的首选解决方案。凭借其自己的分发格式 bento,这个库使得将所有与 ML 相关的内容打包到一个地方变得非常简单:源代码和依赖项、API 定义、模型权重、Docker 镜像等。

部署变得更加容易,因为它依赖于将上述 bento 推送到云端。

在本教程中,我们将首先原型化应用程序,构建本地的 bento,并将其推送到 BentoCloud 进行部署。

你可以通过自我管理一个部署平台(查看 Yatai 项目了解更多细节)或使用名为 bentoctl 的部署工具,将你的 bento 部署到各种云服务中。

如果你想了解更多关于 BentoML 和不同部署策略的信息,可以查看我之前的帖子

## BentoML 如何帮助你服务和扩展机器学习模型的 10 种方式

从 Jupyter 笔记本迁移到生产其实并不是那么困难

## 快速在 AWS EC2 上使用 BentoML 部署机器学习 API

用例:一个端到端的服务来总结 YouTube 视频 🎥

## 如何将 PyTorch 模型部署为生产就绪的 API?

一个结合了 PyTorch Lightning 和 BentoML 的端到端用例 🚀

## 如何将 PyTorch 模型部署为生产就绪的 API?

代码 💻

本项目的代码可以在 Github 上找到。你可以克隆它并在本地运行应用程序,或构建一个自包含的 bento 以便后续部署。

依赖项

我们将使用 transformers 和其他几个处理音频数据的库,以及流行的 LangChain 包来轻松集成大型语言模型(LLMs)。

我们将使用 poetry 来管理项目的依赖。

git clone git@github.com:ahmedbesbes/BentoChain.git
cd BentoChain/
poetry install 

安装包后,你需要生成 SSL 密钥和证书。这将建立一个 HTTPS 连接,现代浏览器需要此连接以允许使用麦克风。

mkdir ssl
cd ssl
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -nodes

下载模型并保存

该项目假设没有训练。我们将从 HuggingFace 的 hub 下载模型权重,并将其保存为 BentoML 模型。

将模型保存为 BentoML 工件有助于将它们纳入 bento 归档中,以便它们不会构成外部依赖。

在精确确定所需模型后,你可以通过初始化模型先将其下载到本地。

import logging
import bentoml
from transformers import (
    SpeechT5Processor,
    SpeechT5ForTextToSpeech,
    SpeechT5HifiGan,
    WhisperForConditionalGeneration,
    WhisperProcessor,
)

logging.basicConfig(level=logging.WARN)

if __name__ == "__main__":
    t5_processor = SpeechT5Processor.from_pretrained("microsoft/speecht5_tts")
    t5_model = SpeechT5ForTextToSpeech.from_pretrained("microsoft/speecht5_tts")
    t5_vocoder = SpeechT5HifiGan.from_pretrained("microsoft/speecht5_hifigan")

    whisper_processor = WhisperProcessor.from_pretrained("openai/whisper-tiny")
    whisper_model = WhisperForConditionalGeneration.from_pretrained(
        "openai/whisper-tiny"
    )
    whisper_model.config.forced_decoder_ids = None

然后,你可以通过调用bentoml.transformers.save_model函数将其保存为 BentoML 模型:

 saved_t5_processor = bentoml.transformers.save_model(
        "speecht5_tts_processor", t5_processor
    )
    print(f"Saved: {saved_t5_processor}")

    saved_t5_model = bentoml.transformers.save_model(
        "speecht5_tts_model",
        t5_model,
        signatures={"generate_speech": {"batchable": False}},
    )
    print(f"Saved: {saved_t5_model}")

    saved_t5_vocoder = bentoml.transformers.save_model(
        "speecht5_tts_vocoder", t5_vocoder
    )
    print(f"Saved: {saved_t5_vocoder}")

    saved_whisper_processor = bentoml.transformers.save_model(
        "whisper_processor",
        whisper_processor,
    )
    print(f"Saved: {saved_whisper_processor}")

    saved_whisper_model = bentoml.transformers.save_model(
        "whisper_model",
        whisper_model,
    )
    print(f"Saved: {saved_whisper_model}")

完整代码在train.py脚本中,并应运行一次:

poetry shell
python train.py

保存模型 ✅ — 作者截图

应用架构概述

在详细说明之前,让我们首先澄清数据工作流程,以了解应用的工作方式:

  • 用户通过 HTTP POST 请求向 API 服务器发送音频消息

  • API 服务器将音频消息重定向到 speech2text 运行器,该运行器将其转录为文本并将其返回

  • API 服务器将转录的文本消息作为输入,通过 LangChain 代理传递,生成响应,然后将其发送到 text2speech 运行器

  • text2speech 运行器从输入文本生成音频片段,并将其返回给 API 服务器,后者再将其发送回用户

以下图表总结了这些步骤。

应用架构 ✅ — 作者图片

为什么我们使用两个运行器?

BentoML 的有趣之处在于,当部署到 BentoCloud(或任何自我管理的平台)时,运行器和 API 服务器可以在三个不同的 Kubernetes pod 上分别部署。

这提供了 3 个主要好处:

  • 关注分离:运行器专注于计算,并与网页服务解耦

  • 定制化:每个运行器可以根据其执行的任务具有特定的硬件配置:例如,text2speech 运行器的配置将包括 GPU,而 speech2text 运行器则不需要

  • 自动扩展:运行器还会根据资源使用情况独立自动扩展

想了解更多关于 BentoML 运行器的信息,请查看这个 页面

现在我们对应用有了整体了解,让我们关注每个运行器:

语音转文本运行器 🎤 → 📝

这个运行器将依赖 OpenAI 的 Whisper 模型将音频转录为文本。具体来说,它将使用 tiny 模型

这个模型将接收一个输入特征的张量,并生成一个转录结果。

代码非常直接:它只定义了一个**SpeechToTextRunnable**类,该类继承自**bentoml.Runnable**,实例化了模型和处理器,并定义了推理方法。

import torch
import bentoml

s2t_processor_ref = bentoml.models.get("whisper_processor:latest")
s2t_model_ref = bentoml.models.get("whisper_model:latest")

class Speech2TextRunnable(bentoml.Runnable):
    SUPPORTED_RESOURCES = ("nvidia.com/gpu", "cpu")
    SUPPORTS_CPU_MULTI_THREADING = True

    def __init__(self):
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.processor = bentoml.transformers.load_model(s2t_processor_ref)
        self.model = bentoml.transformers.load_model(s2t_model_ref)
        self.model.to(self.device)

    @bentoml.Runnable.method(batchable=False)
    def transcribe_audio(self, tensor):
        if tensor is not None:
            predicted_ids = self.model.generate(tensor.to(self.device))
            transcriptions = self.processor.batch_decode(
                predicted_ids, skip_special_tokens=True
            )
            transcription = transcriptions[0]
            return transcription 

一个文本到语音的运行器 📝 → 🎤

这个运行器执行完全相反的任务:它接受文本作为输入,并生成一个由 NumPy 数组表示的语音。

注意,当设备可用时,必须将其声明为 **Text2SpeechRunnable** 类的属性,以支持 GPU 加速。

import bentoml
import torch
from datasets import load_dataset

t2s_processor_ref = bentoml.models.get("speecht5_tts_processor:latest")
t2s_model_ref = bentoml.models.get("speecht5_tts_model:latest")
t2s_vocoder_ref = bentoml.models.get("speecht5_tts_vocoder:latest")

class Text2SpeechRunnable(bentoml.Runnable):
    SUPPORTED_RESOURCES = ("nvidia.com/gpu", "cpu")
    SUPPORTS_CPU_MULTI_THREADING = True

    def __init__(self):
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.processor = bentoml.transformers.load_model(t2s_processor_ref)
        self.model = bentoml.transformers.load_model(t2s_model_ref)
        self.vocoder = bentoml.transformers.load_model(t2s_vocoder_ref)
        self.embeddings_dataset = load_dataset(
            "Matthijs/cmu-arctic-xvectors",
            split="validation",
        )
        self.speaker_embeddings = torch.tensor(
            self.embeddings_dataset[7306]["xvector"]
        ).unsqueeze(0)
        self.model.to(self.device)
        self.vocoder.to(self.device)

    @bentoml.Runnable.method(batchable=False)
    def generate_speech(self, inp: str):
        inputs = self.processor(text=inp, return_tensors="pt")
        speech = self.model.generate_speech(
            inputs["input_ids"].to(self.device),
            self.speaker_embeddings.to(self.device),
            vocoder=self.vocoder,
        )
        return speech.cpu().numpy()

一个 BentoML 服务

在本节中,我们将创建一个服务,定义在 bento 部署时可以访问的 API 路由。

我们首先开始初始化我们定义的两个前一个运行器:

import bentoml
import gradio as gr
from chatbot import create_block, ChatWrapper
from fastapi import FastAPI
from speech2text_runner import s2t_processor_ref, s2t_model_ref, Speech2TextRunnable
from text2speech_runner import (
    t2s_processor_ref,
    t2s_model_ref,
    t2s_vocoder_ref,
    Text2SpeechRunnable,
)

speech2text_runner = bentoml.Runner(
    Speech2TextRunnable,
    name="speech2text_runner",
    models=[s2t_processor_ref, s2t_model_ref],
)
text2speech_runner = bentoml.Runner(
    Text2SpeechRunnable,
    name="text2speech_runner",
    models=[t2s_processor_ref, t2s_model_ref, t2s_vocoder_ref],
)

然后,我们创建一个依赖于这些路由的 Service 对象:

svc = bentoml.Service(
    "voicegpt",
    runners=[
        text2speech_runner,
        speech2text_runner,
    ],
)

一旦服务创建完成,我们将定义两个 API 路由:

  • generate_text: 这个路由将接受一个数组作为输入,并通过调用 speech2text_runner 生成文本
@svc.api(input=bentoml.io.NumpyNdarray(), output=bentoml.io.Text())
def generate_text(tensor):
    text = speech2text_runner.transcribe_audio.run(tensor)
    return text
  • generate_speech: 这个路由将接受一个文本作为输入,并通过调用 text2speech_runner 生成一个数组作为输出
 @svc.api(input=bentoml.io.Text(), output=bentoml.io.NumpyNdarray())
def generate_speech(inp: str):
    return text2speech_runner.generate_speech.run(inp)

ChatWrapper 实用程序类

我们还没有完成服务源代码。

在本节中,我们将把 FastAPI 应用挂载为“/chatbot”路径上的 HTTP 端点。

这个应用将提供一个 Gradio 聊天机器人界面,该界面将与之前定义的两个 API 路由进行交互:**generate_text****generate_speech**

chat = ChatWrapper(generate_speech, generate_text)
app = FastAPI()
app = gr.mount_gradio_app(app, create_block(chat), path="/chatbot")
svc.mount_asgi_app(app, "/")

“chat”变量是一个对象,它获取用户的音频输入,将其转录为文本,传递给 LangChain,提取响应,并返回一堆更新应用界面和状态的数据。

chat 对象是一个可调用的对象,它期望以下参数:

  • api_key: OpenAI API 密钥

  • audio_path: 当使用麦克风录制音频文件时的临时文件位置

  • text_message: 代替音频文件发送的文本消息

  • history: 一个包含问题及其对应响应的元组((“Hello”, “hi”),(“How are you?”, “Fine, thank you. What about you?”))

  • chain: 一个来自 LangChain 的 ConversationChain 对象

在以下代码片段中,ChatWrapper call 方法首先检查输入数据。如果是音频格式,它会使用 generate_text 方法进行转录,否则保持原样。

然后,它会检查 OpenAI 密钥是否正确加载。如果没有,它会打印出“Please paste your Open AI key.”的消息,并附上音频转录。

如果密钥正确加载,LangChain 代理将运行,并输出一条消息,然后将其传递给 generate_speech 方法以生成输出音频。

class ChatWrapper:
    def __init__(self, generate_speech, generate_text):
        self.lock = Lock()
        self.generate_speech = generate_speech
        self.generate_text = generate_text
        self.s2t_processor_ref = bentoml.models.get("whisper_processor:latest")
        self.processor = bentoml.transformers.load_model(self.s2t_processor_ref)

    def __call__(
        self,
        api_key: str,
        audio_path: str,
        text_message: str,
        history: Optional[Tuple[str, str]],
        chain: Optional[ConversationChain],
    ):
        """Execute the chat functionality."""
        self.lock.acquire()
        try:
            if audio_path is None and text_message is not None:
                transcription = text_message
            elif audio_path is not None and text_message in [None, ""]:
                audio_dataset = Dataset.from_dict({"audio": [audio_path]}).cast_column(
                    "audio",
                    Audio(sampling_rate=16000),
                )
                sample = audio_dataset[0]["audio"]

                if sample is not None:
                    input_features = self.processor(
                        sample["array"],
                        sampling_rate=sample["sampling_rate"],
                        return_tensors="pt",
                    ).input_features

                    transcription = self.generate_text(input_features)
                else:
                    transcription = None
                    speech = None

            if transcription is not None:
                history = history or []
                # If chain is None, that is because no API key was provided.
                if chain is None:
                    response = "Please paste your Open AI key."
                    history.append((transcription, response))
                    speech = (PLAYBACK_SAMPLE_RATE, self.generate_speech(response))
                    return history, history, speech, None, None
                # Set OpenAI key
                import openai

                openai.api_key = api_key
                # Run chain and append input.
                output = chain.run(input=transcription)
                speech = (PLAYBACK_SAMPLE_RATE, self.generate_speech(output))
                history.append((transcription, output))

        except Exception as e:
            raise e
        finally:
            self.lock.release()
        return history, history, speech, None, None

Gradio 用户界面

还记得我们之前看到的 create_block 函数吗?这个函数接受一个 ChatWrapper 实例作为输入并生成用户界面。

chat = ChatWrapper(generate_speech, generate_text)
app = FastAPI()
app = gr.mount_gradio_app(app, create_block(chat), path="/chatbot")
svc.mount_asgi_app(app, "/")

应用的 UI — 用户截图

让我们将 UI 拆解开来,以准确理解数据的流动方式。

openai_api_key_textbox: 这个文本框期待您在其中粘贴您的 OpenAI 密钥。

with block:
    with gr.Row():
        gr.Markdown("<h3><center>BentoML LangChain Demo</center></h3>")

        openai_api_key_textbox = gr.Textbox(
            placeholder="Paste your OpenAI API key (sk-...)",
            show_label=False,
            lines=1,
            type="password",
        )

当用户粘贴其密钥并提交时,该密钥会传递给执行的 set_openai_api_key 函数。然后,这个函数返回加载的链,并将其传递到应用的状态中。这样,链对象就不为 None,并且可以在传递给聊天对象时使用。

def set_openai_api_key(api_key: str):
    if api_key:
        os.environ["OPENAI_API_KEY"] = api_key
        chain = load_chain()
        os.environ["OPENAI_API_KEY"] = ""
        return chain

agent_state = gr.State()

openai_api_key_textbox.change(
  set_openai_api_key,
  inputs=[openai_api_key_textbox],
  outputs=[agent_state],
  show_progress=False,
)

这里是其他 UI 组件:

  • chatbot: 显示聊天机器人输出,展示用户提交的消息和回应。

  • audio: 一个播放用户录制的音频片段的小部件

  • state:应用的全局状态

  • audio_message: 用户提交的音频

  • text_message:用户提交的文本

那么,当用户从麦克风录制音频时会发生什么?(发送文本时也是如此)

聊天对象会使用来自 UI 的输入列表 [openai_api_key_textbox, audio_message, text_message, state, agent_state] 执行,并输出一个输出列表,该列表更新以下组件 [chatbot, state, audio, audio_message, text_message]

audio_message.change(
    chat,
    inputs=[
        openai_api_key_textbox,
        audio_message,
        text_message,
        state,
        agent_state,
    ],
    outputs=[chatbot, state, audio, audio_message, text_message],
    show_progress=False,
)

简而言之,这允许显示用户的问题和机器人的回答,以及聊天记录和最后响应的音频。

作者截图

在本地提供应用

要在本地运行应用程序,请执行以下命令:

poetry shell 
bentoml serve service:svc --reload --ssl-certfile ssl/cert.pem --ssl-keyfile ssl/key.pem

这将启动一个 SwaggerUI,您可以在其中尝试两个端点。

这也在“/chatbot”路径上提供 Gradio 应用。

用户截图

部署到 BentoCloud

在部署到 BentoCloud 之前,我们首先需要构建 bento:

bentoml build

作者截图

然后,我们需要使用以下命令推送它。

bentoml push voicegpt:jnalivxin2qcehqa

这将同时将我们的 bento 和底层的 models 上传到云端。

作者截图

现在,简单的部分:登录 BentoCloud,前往部署选项卡,然后点击创建。

选择一个部署名称,启用公共访问并选择合适的 bento 标签版本。

现在,定义 API 服务器配置:

API 服务器配置 — 作者截图

并为每个运行器设置配置。

Runners 配置 — 作者截图

当一切准备就绪时,点击提交按钮并等待部署。

当 bento 被标记为正在运行时,您会看到一个公共 URL,用于提供聊天机器人服务(不要忘记添加 HTTPS)

结论

这个项目是一个机会,参与到围绕 LLM 的持续热潮中,并从零开始构建一个不仅调用 LangChain,还结合其他模型的应用。

当然,我展示的工作流程中不同步骤还有改进的空间:我只是希望这篇文章能为你开始构建更高级的机器人提供一个良好的开端。

新加入 Medium?你可以以每月 $5 订阅,解锁各种主题(技术、设计、创业……)的无限文章。你可以通过点击我的推荐链接来支持我。

[## 通过我的推荐链接加入 Medium - Ahmed Besbes

阅读 Ahmed Besbes 的每个故事(以及 Medium 上其他数千位作者的文章)。你的会员费直接支持……

ahmedbesbes.medium.com](https://ahmedbesbes.medium.com/membership?source=post_page-----7f25af3e45df--------------------------------)

部署容器化的 Plotly Dash 应用程序与 CI/CD (P2: GCP)

原文:towardsdatascience.com/deploy-containerised-plotly-dash-app-with-ci-cd-p2-gcp-dfa33edc5f2f?source=collection_archive---------14-----------------------#2023-01-24

Robin OpdamTowards Data Science Robin Opdam

·

关注 发表在Towards Data Science ·5 分钟阅读·2023 年 1 月 24 日

--

照片由Dominik Lückmann拍摄,发布在Unsplash

在 Heroku 将其 dyno 改为付费层后,我希望尝试使用 Google Cloud Platform (GCP) 部署相同的容器,此前该容器已在 Heroku 上通过 CI/CD 部署,详见第一部分

示例应用已通过 GCP 使用 Github Actions CI/CD Pipeline 部署,镜像由作者提供。请注意最新的操作包含了测试

注意: 本指南将使用 GCP 中可计费的组件,初始部署后的成本将保持在最低(大多数情况下低于 1 欧元)。我们将在最后讲解如何限制和停止费用。

关于初始设置(Github、应用和部署到 Heroku),请参阅本指南的 P1

在 P2 中,我们将深入探讨 GCP 的部署部分及所需内容,从而修改 P1 的第 5 和第 6 步:

  1. 文件结构

  2. 创建 Plotly Dash 应用

  3. 创建 Dockerfile,本地运行

  4. 使用 Github Actions 构建 Docker 镜像

  5. 创建并配置 GCP 项目

  6. 通过 Github Actions 部署到 Google Cloud Run

你可以在 Github 上找到该仓库 和在 docker-dash-example.com/ 上找到应用。

5. 创建并配置 GCP 项目

在 GCP 中,一切都通过项目来组织,你可以启用 Cloud Storage 和 Cloud Run 等服务。通过控制台创建你的 Google Cloud 账户和项目,并启用计费。之后,确保安装 gcloud CLI,以便你可以通过 gcloud 命令 从本地机器与 GCP 交互。

按照这个教程 的步骤(步骤:Cloud Run)设置你的服务账户,启用所需的服务(运行、存储、IAM),并生成用于后续身份验证的 key.json。

注意:将 key.json 保存在安全的地方,最好不要放在你的 git 仓库中。

6. 通过 Github Actions 部署到 Google Cloud Run

6.1 推送容器

6.2 在管道中编写部署部分

6.3 限制计费和清理

6.1 在 GCP 上手动部署

在自动化这个过程之前,先通过 Google Container Register (GCRe) 手动部署你的应用到 Google Cloud Run (GCRu)。按照Google 的指南将你的镜像推送到 GCRe,最后你将运行:

docker push HOSTNAME/PROJECT-ID/IMAGE:TAG

注意,在使用 Apple 的 Mx 芯片时,你需要一个额外的标志来进行构建:

— platform linux/amd64

导航到你的项目的 GCRe,查看你刚刚推送的镜像。要在 GCRu 上部署此镜像,我们可以使用以下命令(假设所有权限正常):

gcloud run deploy SERVICE --image IMAGE_URL

其中 SERVICE 是你的 project_id,IMAGE_URL 包含我们在之前的 docker push 命令中填写的 HOSTNAME/PROJECT-ID/IMAGE:TAG。通过访问你应用的 GCRu 页面顶部的 URL 来检查你的容器是否在 Cloud Run 中运行。

GCRe(左)GCRu(右)包含 Docker 镜像和将在 Cloud Run 中运行的应用,作者提供的截图

6.2 CI/CD 在 GCP 上的部署

如果你的应用通过手动部署工作,让我们自动化这个过程,并将部署步骤纳入管道第一部分。简要回顾,第一部分重点介绍了通过 Github Actions 在 Heroku 上部署你的 Plotly Dash 应用。现在,Heroku 的部署被 Github 工作流中的 GCP 部署所取代。

从设置用于部署管道的Github Secrets 开始:

  • GCP_EMAIL: 你创建的服务账户的电子邮件,格式为: $ACCOUNT_NAME@$PROJECT_ID.iam.gserviceaccount.com

  • GCP_PROJECT_ID: 你的项目名称

  • GCP_CREDENTIALS: 你的 key.json(复制粘贴内容)

  • GCP_APP_NAME: 你应用的名称(在 GCRe 内部)

有了这些密钥,我们可以通过 Github Actions 构建 CI/CD 工作流。请注意,工作流的初始构建步骤保持不变,因为我们首先在 Github Packages 中推送和构建容器,请在第一部分中查看有关构建部分的完整说明。

构建步骤(未更改):

Github Actions 构建步骤,作者提供的摘要

部署步骤(更改为 GCP):

Github Actions 部署步骤,作者提供的摘要

  • (行: 1–24)开始与部署到 Heroku 相似,但在环境中我们现在定义 IMAGE_NAME 作为你镜像在 GCRe 内部的位置。我们检出 master,然后从 Github Packages 中拉取和构建镜像。

  • ‘登录到 Google Cloud’: 使用谷歌定义的工作流动作我们进行 Google Cloud 认证。在这里我们使用来自 Github Secrets 的 GCP_CREDENTIALS 密钥。

  • ‘配置 Docker’: 在谷歌推送和拉取指南中,他们还需要你设置Docker 的认证。这一步骤在部署期间配置镜像的认证。

  • ‘推送到注册表’: 将你的镜像推送到 GCRe

  • ‘部署’: 使用谷歌定义的部署到 GCRu 的工作流动作,我们像之前手动操作一样通过 Cloud Run 部署推送的容器。

  • 备注: — port=8080 是我在容器中暴露的端口,这可能与你的容器不同。— allow-unauthenticated 允许对你的 Cloud Run 应用程序进行无有效认证令牌的请求。我使用这个标志使我的应用程序公开可访问,请根据你的应用程序需要使用这个标志。

6.3 限制账单和清理

为了使应用程序运行,我们启用了多个服务。查看下面描述的服务以及如何监控和限制成本。

  • Google Cloud Storage (GCS) 成本,GCRe 在 GCS 中存储你的容器镜像。每 GB 每月约 2 美分(在欧洲),如果你保持 GCRe 的清洁,这不会花费你太多。我们可以通过添加以下行来确保 GCRe 中没有旧镜像,该行会删除没有 ‘latest’ 标签的镜像:
- name: Clean up old images
  run: gcloud container images list-tags ${{ env.IMAGE_NAME }} --filter='-tags:*' --format="get(digest)" --limit=10 > tags && while read p; do gcloud container images delete "${{ env.IMAGE_NAME }}@$p" --quiet; done < tags
  • Google Cloud Run 成本,让你的应用程序正常运行需要计算能力的费用,你将为此收费。如果你的应用程序使用不广泛,那么每月的费用应该也会低于 1 或 2 美元。

为了跟踪这些成本,我们可以使用 Google Cloud Billing 中心,该中心跟踪所有支出。在这里,你可以为你的帐户或更具体的项目或服务设置费用提醒。

此外,你可以通过使用 GCP 配额 来限制 Google 分配给你的 GCRu 的原始资源。在这里,你可以限制 Google Cloud Run 可以使用的资源。

结论

这让我学到了很多关于将应用程序容器化以便迁移到另一个云平台的优势。确保监控你的 Google Billing Overview 并设置警报,如果你让应用程序公开可访问。如果你是第一次使用 GCP,你可能仍然有资格获得他们的初始 $300 免费额度。

感谢阅读!

如有任何问题或意见,请随时联系我。

很高兴在 LinkedIn 上与您联系!

一些 我的其他项目

改进事项

  • 在 CI/CD 流水线的构建和部署步骤之间可以(并且应该)有一个测试步骤(在 repo 的最新更新中完成)。

  • 使用多步骤构建可以加速部署。

  • 通过适当的调整,可以使 Docker 镜像变得更小,这意味着构建和部署速度更快(使用 Alpine 基础镜像,跳过 pip 缓存等)。

额外的尝试事项

直接从你的 Jupyter Notebook 部署机器学习模型

原文:towardsdatascience.com/deploy-machine-learning-models-right-from-your-jupyter-notebook-3241d47408cd?source=collection_archive---------2-----------------------#2023-03-28

一行代码部署机器学习模型

Avi ChawlaTowards Data Science Avi Chawla

·

关注 发表在 Towards Data Science ·7 分钟阅读·2023 年 3 月 28 日

--

图片由 Roman Synkevych 🇺🇦 提供,来源于 Unsplash

在这场 AI 革命中,规模化构建智能系统最近引起了无数组织的极大关注。

虽然大量时间和精力被积极投入到训练大型机器学习模型中,但将这些模型投入生产并维护它们是一个独立的任务。

在某些情况下,这可能甚至需要专业化的团队。

尽管越来越多的组织使用人工智能(AI)来服务终端客户,但这些模型的顺利部署仍然有些繁琐,但在确保按承诺提供服务方面至关重要。

但你是否曾想过为什么部署是一个具有挑战性的过程?如果是的话,让我来帮助你。

在这篇博客中,我将详细概述为什么 ML 部署通常是一个繁琐的过程。

此外,我还将分享如何使用Modelbit API 从 jupyter notebook 简化这个过程并部署模型。

让我们开始 🚀!

什么是部署?

首先,部署是将训练好的机器学习模型集成到生产环境中的过程。

部署是机器学习产品开发生命周期的最后阶段。这时模型已经经过训练、验证和测试,最终准备好提供给终端用户。

你可以在这里阅读我之前关于机器学习部署的文章:

## 使用 Heroku 部署机器学习模型

不仅要训练,还要部署:逐步指南

[towardsdatascience.com

ML 模型部署的痛点

#1) 一致性挑战

在几乎所有的 ML 用例中,使用的算法通常从未从头开始编写。相反,人们会使用 PyTorch、Sklearn 等库提供的开源实现。

为了确保生产环境中的可重复性,生产环境应与模型训练时的环境保持一致。

开发和生产环境(图像由作者提供)

这涉及到安装类似版本的库、软件依赖、操作系统配置等多个方面。

实现这种一致性有时可能具有挑战性。

事实上,在我撰写上述的 Heroku 博客时,我遇到了许多错误和挑战,在尝试将机器学习模型部署到 Heroku 时,整体过程有些繁琐且耗时,这也是我在博客中讨论的内容。

#2) 基础设施挑战

ML 模型通常需要像 GPU 这样的专用处理器进行训练。

根据复杂程度,推断阶段,即部署后的阶段,可能还需要专门的基础设施。

设置这些专门的基础设施对于数据团队来说通常是一个挑战。

#3) 专业知识不足(或知识差距)

ML 工程师可能没有部署经验。他们可能在软件工程、DevOps 和基础设施管理等领域缺乏必要的专业知识。

这可能使他们在生产环境中有效部署和扩展模型变得困难。

在这种情况下,组织会招聘专业人才。

然而,专门招聘用于部署的工程师可能对 ML 算法和技术没有深入了解。

开发和生产团队(作者提供的图片)

这使得他们难以理解代码并进行必要的优化,从而导致扩展、性能和可靠性问题,并最终影响模型在生产环境中的有效性。

从 Jupyter Notebook 部署 ML 模型

上述痛点在某种程度上突显了数据科学家需要具备必要的部署专业知识。

现在,数据科学家大部分时间都在 Jupyter notebook 中工作。

因此,为了简化部署过程并将其与 Jupyter 集成以创建模型端点,我将使用 Modelbit API。

工作流

在构建应用程序之前,最好突出显示过程工作流,以便可以在任何项目中复制。

下面的图片描绘了部署过程中的步骤的高层次图示概览。

部署工作流(作者提供的图片)

首先,在 Jupyter notebook 中,我们将训练一个机器学习模型。

接下来,我们将创建一个预测函数,该函数将接受输入作为参数并返回模型的预测。

之后,我们将收集所使用的包列表及其版本,以及我们训练模型所用的 Python 版本。这些信息以及函数对象将被发送用于部署。

最后,我们将检索模型端点。

让我们来看一下下面的步骤。

重申一下,我们将从 Jupyter notebook 中完成所有操作。

第一步:训练机器学习模型

首先,我们将训练一个我们打算部署的机器学习模型。为了简化起见,我们考虑一个在以下虚拟数据集上训练的线性回归模型:

虚拟数据集(作者提供的图片)

接下来,我们将使用 scikit-learn 训练一个线性回归模型:

## my_notebook.ipynb

from sklearn.linear_model import LinearRegression
model = LinearRegression()
model.fit(x, y)

我们得到以下回归图:

回归拟合(作者提供的图片)

第二步:设置 Modelbit

#2.1) 安装 Modelbit

首先,通过 pip 安装 Modelbit 包:

## my_notebook.ipynb

!pip install modelbit

#2.2) 登录 Modelbit

要使用 Modelbit 部署模型,请在 这里 创建您的帐户。接下来,从 Jupyter 登录到 Modelbit:

## my_notebook.ipynb

import modelbit
mb = modelbit.login()

完成!

现在,我们可以开始将我们的模型推向部署。

第三步:部署模型

要使用 Modelbit 部署模型,我们应该设置一个 Python 函数,以确保部署和部署后的推理顺利进行。

实质上,这个函数将包含在运行时执行的代码,并负责返回预测结果。

我们应该在这个方法中根据模型的需要指定输入参数。另外,你可以随意命名它。

我们来创建一个my_lr_deployement()方法。

## my_notebook.ipynb

def my_lr_deployement(input_x):

    if isinstance(input_x, (int, float)):    ## check input type
        return model.predict([[input_x]])[0] ## prediction

    else:
        return None

注意: 函数的每个依赖项(在这个例子中是model)都会被序列化并与函数一起自动发送到生产环境。因此,你可以在此方法中自由引用任何内容。

要部署,请运行以下命令:

## my_notebook.ipynb

mb.deploy(my_lr_deployement)

就这样!模型已成功部署。下方展示了演示:

部署演示(作者提供的图片)

一旦你的模型成功部署,它将出现在你的 Modelbit 仪表盘中。

部署仪表盘(作者提供的图片)

如上所示,Modelbit 提供了一个 API 端点。我们可以用它进行推断。

## my_notebook.ipynb

!curl -s -XPOST "https://avichawla.app.modelbit.com/v1/my_lr_deployement/latest" 
 -d '{"data":[[1,input_x]]}' | json_pp

在上述请求中,data 是一个列表的列表。

列表中的第一个数字(1)是输入 ID。ID 可以是你喜欢使用的任何标识符。紧随 ID 的数字是函数参数。

例如,对于我们的my_lr_deployement(input_x)方法,数据列表的列表如下:

# Format: [id, input_x]

[[1,3],
 [2,5],
 [3,9]]

让我们用上述输入调用 API:

## my_notebook.ipynb

!curl -s -XPOST "https://avichawla.app.modelbit.com/v1/my_lr_deployement/latest" 
 -d '{"data":[[1,3], [2,5], [3,9]]}' | json_pp

端点会以 JSON 格式响应:

{
   "data" : [
      [
         1,               # Input ID
         12.41            # Output
      ],
      [
         2,               # Input ID
         19.33            # Output
      ],
      [
         3,               # Input ID
         33.16            # Output
      ]
   ]
}

调用已部署的模型不仅限于curl。我们还可以使用 Python 中的requests库:

## my_notebook.ipynb

import json, requests

requests.post("https://avichawla.app.modelbit.com/v1/my_lr_deployement/latest",
              headers={"Content-Type":"application/json"},
              data=json.dumps({"data":[[1,3], [2,5], [3,9]]})).json()

输出是一个 Python 字典:

{'data': [[1, 12.41], # [Input ID, Output]
          [2, 19.33], # [Input ID, Output]
          [3, 33.16]] # [Input ID, Output]
}

自定义环境

有时我们可能希望在部署模型时指定使用的库的特定版本。

我们可以将这些作为参数传递给md.deploy()方法调用:

## my_notebook.ipynb

mb.deploy(my_lr_deployement, 
          python_packages=["scikit-learn==1.1.2", "pandas==1.5.0"])

我们还可以部署到特定版本的 Python:

## my_notebook.ipynb

mb.deploy(my_lr_deployement, 
          python_version = "3.9")

结论

总结一下,在这篇文章中,我们学习了如何通过 Jupyter notebook 使用 Modelbit API 部署机器学习模型。

更具体地说,我首先演示了一个简单线性回归模型的训练,然后将 Modelbit API 集成到 Jupyter notebook 中以部署该模型。

感谢阅读!

使用 Triton 部署本地 GPT 服务器

原文:towardsdatascience.com/deploy-your-local-gpt-server-with-triton-a825d528aa5d?source=collection_archive---------3-----------------------#2023-04-14

如何在本地服务器上运行大型语言模型

Benjamin MarieTowards Data Science Benjamin Marie

·

查看 发表在Towards Data Science ·8 分钟阅读·2023 年 4 月 14 日

--

图片来自Pixabay

使用 OpenAI GPT 模型仅通过 OpenAI API 是可能的。换句话说,你必须与 OpenAI 分享数据才能使用其 GPT 模型。

数据机密性是许多企业的核心,也是大多数个人的优先事项。在互联网上向私人公司发送或接收高度私人数据通常不是一种选择。

出于这些原因,你可能会对在本地运行自己的 GPT 模型以处理个人或业务数据感兴趣。

幸运的是,有许多开源替代品可供选择,虽然它们尚不如 GPT-4,但能够与 GPT-3 竞争。

例如,EleutherAI 提供了几个 GPT 模型:GPT-J、GPT-Neo 和 GPT-NeoX。它们都有详细的文档,开源,并且在允许商业使用的许可证下。

这些模型也很大。最小的 GPT-J 在压缩后占用近 10 GB 的磁盘空间(60 亿个参数)。在某些机器上,加载这些模型可能需要很长时间。理想情况下,我们需要一个本地服务器来保持模型在后台完全加载并随时准备使用。

其中一种方法是使用专用框架(如 nVidia Triton)在本地服务器上运行 GPT (BSD-3 Clause 许可证)。注意:所谓“服务器”并不是指物理机器。Triton 只是一个可以安装在任何机器上的框架。

使用 FasterTransformer (Apache 2.0 许可证) 后端的 Triton 在整个提示处理过程中管理 CPU 和 GPU 的负载。一旦 Triton 托管了你的 GPT 模型,你的每一个提示都会由 FastTransformer 根据你的硬件配置以最佳方式进行预处理和后处理。

在本文中,我将向你展示如何使用 Triton Inference Server 为你的应用程序提供 GPT-J 模型。我选择 GPT-J 是因为它是最小的 GPT 模型之一,性能良好且可用于商业用途 (Apache 2.0 许可证)。

需求

硬件

你需要至少一张支持 CUDA 11 或更高版本的 GPU。我们将运行一个大型模型 GPT-J,因此你的 GPU 应该至少有 12 GB 的 VRAM。

设置 Triton 服务器和处理模型也需要相当大的硬盘空间。你应该至少有 50 GB 的可用空间。

操作系统

你需要一个 UNIX 操作系统,最好是 Ubuntu 或 Debian。如果你使用其他 UNIX 操作系统也可以,但你需要将所有下载和安装软件包的命令调整为适合你操作系统的包管理器。

我在 WSL2 的 Ubuntu 20.04 上运行了本教程中呈现的所有命令。注意:我遇到了一些 WSL2 的问题,我会进行说明,但如果你运行的是原生 Ubuntu,可能不会遇到这些问题。

对于一些命令,你需要“sudo”权限。

依赖项

FasterTransformer 需要 CMAKE 进行编译。

还有其他依赖项,但在必要时,我会在本教程中提供安装指南。

为 Triton 设置 Docker 容器

我们将使用 nVidia 已经准备好的 Docker 镜像。它提供在“fastertransformer_backend”的特定分支中。

所以首先我们需要克隆这个仓库并获取这个分支。

git clone https://github.com/triton-inference-server/fastertransformer_backend.git
cd fastertransformer_backend && git checkout -b t5_gptj_blog remotes/origin/dev/t5_gptj_blog

如果你没有 Docker,请跳到本文末尾,你将找到一个简短的安装教程。

以下命令用于构建 Triton 服务器的 Docker 镜像。

docker build --rm  --build-arg TRITON_VERSION=22.03 -t triton_with_ft:22.03 -f docker/Dockerfile .
cd ../

它应该顺利运行。注意:在我的情况下,我遇到了几个缺失或未正确安装的 GPG 密钥的问题。如果你有类似的问题,请在评论中留言。我很乐意帮助你!

然后,我们可以运行 docker 镜像:

docker run -it --rm --gpus=all --shm-size=4G  -v $(pwd):/ft_workspace -p 8888:8888 triton_with_ft:22.03 bash

如果成功,你将看到类似这样的内容:

作者提供的图像

注意:如果你看到错误“docker: Error response from daemon: could not select device driver “” with capabilities: [[gpu]].”,这可能意味着你没有安装 nVidia 容器。我在文章末尾提供了安装说明。

不要离开或关闭此容器:所有剩余的步骤必须在其中完成。

为 FasterTransformer 准备 GPT-J

接下来的步骤准备 GPT-J 模型。

我们需要获取并配置 FasterTransformer。注意:你需要 CMAKE 来完成这一步。

git clone https://github.com/NVIDIA/FasterTransformer.git

mkdir -p FasterTransformer/build && cd FasterTransformer/build
git submodule init && git submodule update
cmake -DSM=xx -DCMAKE_BUILD_TYPE=Release -DBUILD_PYT=ON -DBUILD_MULTI_GPU=ON ..
#I put -j8 below because my CPU has 8 cores.
#Since this compilation can take some time, I recommend that you change this number to the number of cores your CPU has.
make -j8

喝杯咖啡可能需要一点时间 ☕。

FasterTransformer 用于在 Triton 服务器内部运行模型。它可以管理输入/输出的预处理和后处理。

现在,我们可以获取 GPT-J:

cd ../../
mkdir models
wget https://the-eye.eu/public/AI/GPT-J-6B/step_383500_slim.tar.zstd
tar -axf step_383500_slim.tar.zstd -C ./models/ 

此命令首先下载模型,然后提取它。

如果你的互联网速度不高,可能需要一点时间喝两杯咖啡 ☕☕或者打个盹。下载内容大约为 10 GB。

下一步是将模型权重转换为 FasterTransformer 格式。

cd models
pip install nvidia-cuda-nvcc
python3 ../FasterTransformer/examples/pytorch/gptj/utils/gptj_ckpt_convert.py --output-dir ./gptj6b_ckpt/ --ckpt-dir ./step_383500/ --n-inference-gpus 1

我将“1”设置为“ — n-inference-gpus”,因为我只有 1 个 GPU,但如果你有更多,你可以设置更高的数字。注意:我添加了“nvidia-cuda-nvcc”,因为在我的环境中需要它。你的环境中可能已经安装了。如果你遇到其他名为“ptxas”的库的问题,请留言,我会回答。

如果你在运行之前的命令时遇到关于“jax”或“jaxlib”的错误,以下命令对我有帮助:

pip install --upgrade "jax[cuda11_local]" -f https://storage.googleapis.com/jax-releases/jax_cuda_releases.html

优化

在启动服务器之前,还建议运行内核自动调优。它会在所有低级算法中找到最适合 GPT-J 架构和你的机器硬件的算法。gpt_gemm会完成这项工作:

./FasterTransformer/build/bin/gpt_gemm 8 1 32 12 128 6144 51200 1 2

它应该生成一个名为“gemm_config.in”的文件。

FasterTransformer 配置

现在,我们要配置服务器以运行 GPT-J。找到并打开以下文件:

fastertransformer_backend/all_models/gptj/fastertransformer/config.pbtxt

设置你 GPU 的数量的参数。注意:它必须与转换 GPT-J 权重时用“n-inference-gpus”指示的数量相同。

parameters {
  key: "tensor_para_size"
  value: {
    string_value: "1"
  }
}

然后,指明 GPT-J 的位置:

parameters {
  key: "model_checkpoint_path"
  value: {
    string_value: "./models/gptj6b_ckpt/1-gpu/"
  }
}

运行 Triton 服务器

启动 Triton 服务器,请运行以下命令:注意:更改“CUDA_VISIBLE_DEVICES”以设置你的 GPU 的 ID,例如,如果你有两个 GPU 想要使用,可以设置为“0,1”。

CUDA_VISIBLE_DEVICES=0 /opt/tritonserver/bin/tritonserver  --model-repository=./triton-model-store/mygptj/ &

如果一切正常,你将看到终端中显示服务器等待加载 1 个由 FasterTransformer 加载的模型。

还需要创建一个客户端来查询服务器。例如,可以是你的应用程序,它将利用 GPT-J 模型。

nVidia 提供了一个客户端示例:

fastertransformer_backend/tools/end_to_end_test.py

这个脚本可能看起来很复杂,但它只是准备所有参数和批处理你的提示,然后将一切发送到负责其他所有操作的服务器。

修改变量input0以包含你的提示。它的位置如下:

作者提供的图片

最后,你可以运行这个脚本来提示你的 Triton 服务器。你应该能很快得到响应,因为模型已经完全加载并优化过。

就这样!你现在拥有了开始在应用程序中利用本地 GPT 模型所需的一切。

结论

本文中解释的步骤也适用于 FasterTransformer 支持的所有其他模型(除了你需要调整的特定部分)。你可以在此处查看列表。如果你想使用的模型不在列表中,它可能也能正常工作,或者你可能需要修改一些我提供的命令。

如果你有多个 GPU 可用,你可以直接将相同的步骤应用于 GPT-Neo* 模型。你只需修改“config.pbtxt”以适应这些模型。注意:nVidia 可能已经准备好了这些配置文件,因此在创建自己的配置文件之前,请查看 FasterTransformer 仓库

如果你想使用 T5 而不是 GPT 模型,你可以查看nVidia 编写的教程注意:nVidia 的教程已经过时,你需要修改一些命令。

成功安装和运行 Triton 推理服务器只需遵循这些步骤,但非常依赖于你的机器配置。如果遇到任何问题,请随时留言,我会尽力帮助。

安装缺失依赖项的进一步说明

安装 Docker(Ubuntu):

sudo apt-get update
sudo apt-get install ca-certificates curl gnupg

#Add the official GPG key
sudo mkdir -m 0755 -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

#Set up the repository
echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu  "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" |  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

#Update again
sudo apt-get update

#Then we can finally install docker
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

#Optional: If you run Ubuntu under WSL2, you may need to start Docker manually
sudo service docker start

#If everything is properly installed, this should work
sudo docker run hello-world

安装 nvidia-container-toolkit(Ubuntu):

#Add the repository
distribution=$(. /etc/os-release;echo $ID$VERSION_ID) \
      && curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg \
      && curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.list | \
            sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
            sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list

#Get and install the nvidia container toolkit
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit

#Restart docker
sudo systemctl restart docker

#or run "sudo service docker start" if you use WSL2

在本地使用 Docker 部署自己的 MLflow 工作区

原文:towardsdatascience.com/deploy-your-own-mlflow-workspace-on-premise-with-docker-b54294676f0b

像专业人士一样管理你的 ML 模型生命周期

Janik and Patrick TinzTowards Data Science Janik 和 Patrick Tinz

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

--

图片由 Isaac Smith 提供,来自 Unsplash

MLflow 是一个 开源平台,用于端到端管理 ML 模型的生命周期。它跟踪每次 ML 实验的代码、数据和结果,这意味着你可以随时查看所有实验的历史记录。每位数据科学家的梦想。此外,MLflow 是 库无关 的,这意味着你可以使用所有 ML 库,如 TensorFlow、PyTorch 或 scikit-learn。所有 MLflow 功能都可以通过 REST APICLIPython APIR APIJava API 访问。

作为数据科学家,你花费大量时间优化 ML 模型。最佳模型通常依赖于最佳的超参数或特征选择,而找到最佳组合具有挑战性。此外,你还需要记住所有实验,这非常耗时。MLflow 是解决这些挑战的高效平台。

在这篇文章中,我们简要介绍了 MLflow 的基础知识,并展示了如何在本地设置 MLflow 工作区。我们在 Docker 堆栈中设置了 MLflow 环境,以便在所有系统上运行它。在这个背景下,我们设置了 Postgres 数据库、SFTP 服务器、JupyterLab 和 MLflow 追踪服务器 UI。让我们开始吧。

MLflow 基础知识

目前,MLflow 提供了四个组件来管理 ML 生命周期。下图显示了一个概述。

MLflow 组件概述(图片来源:作者)

MLflow 追踪用于追踪和查询实验。它跟踪模型参数、代码、数据和模型工件。此外,MLFlow 的追踪服务器提供了一个 web 界面,显示所有实验的历史记录。MLflow 库已经提供了 web 界面。追踪服务器区分不同的实验。您可以通过可视化结果来比较实验中的 ML 模型。

MLflow 项目是一个用于以可重用和可复现的方式打包数据科学代码的组件。

MLflow 模型格式为使用不同库(例如 TensorFlow、PyTorch 或 scikit-learn)创建的 ML 模型提供了统一的存储格式。统一格式可以在多种环境中进行部署。

模型注册表组件允许将生成的模型从暂存阶段转移到生产阶段。它使得在中央模型库中管理 ML 模型成为可能。

您可以在官方文档或 MLflow 的GitHub 仓库中了解更多关于组件的信息。

技术要求

您将需要以下先决条件:

  • 您的机器上必须安装最新版本的 Docker。如果您尚未安装,请按照说明进行操作。

  • 您的机器上必须安装最新版本的 Docker Compose。请按照说明进行操作。

  • 访问终端(macOS、Linux 或 Windows)。

设置 MLflow 工作区

首先,您应该检查是否正确安装了 Docker 和 Docker Compose。打开您选择的终端并输入以下命令:

$ docker --version
# Output: $ Docker version 20.10.23

如果安装正确,您可以看到 Docker 版本(也许您有不同的版本)。接下来,您可以检查 Docker Compose 的安装情况。

$ docker-compose --version
# Output: Docker Compose version v2.15.1

好了,一切正常。现在我们可以开始设置 MLflow 工作区了。

几种方法可以使用 MLflow。您可以在本地主机上使用 MLflow,或者为生产环境部署一个完整的栈。本文将重点介绍第二种选项。我们已经设置了一个生产就绪的 MLflow 工作区,我们将在本文中进行说明。

请从我们的 GitHub 仓库下载或克隆MLflow 工作区。该项目包含四个服务,如下图所示。

各种服务 MLflow 工作区(图像由作者提供)

您可以通过在终端中执行以下命令来启动所有服务。

$ sh start_docker_stack.sh

第一次启动时,所有 Docker 镜像下载需要一些时间。现在正是去喝咖啡的时候。☕️

一切启动后,请打开你选择的 web 浏览器。接下来,你可以通过输入以下网址 127.0.0.1:8888 来访问 Jupyter 服务器。你可以使用密码 mlflow 登录。接下来,访问 MLflow UI 网站 127.0.0.1:5001。请注意,我们在本地主机上。如果你在远程服务器上运行 MLflow 工作区,请指定你服务器的 IP 地址。

你可以通过运行笔记本 /notebooks/mlflow_example.ipynb 来测试 MLflow 工作区。如果没有出现错误,则设置成功。祝贺你!

当你完成在笔记本上的工作后,可以关闭工作区。工作区会持久保存你的工作,以便你下次继续工作。你可以使用以下命令关闭工作区:

$ sh stop_docker_stack.sh

完成设置后,我们可以更深入地查看各个服务。

JupyterLab

该服务提供了一个 JupyterLab 环境。在 MLflow 工作区中,我们使用了来自 DockerHub 的 jupyter/scipy-notebook 镜像。如果你愿意,也可以使用其他 Jupyter 镜像。未来,我们计划将镜像更换为 JuypterHub 镜像,以便每个数据科学家都有自己的账户。这样做的一个好处是,这些账户还能与 MLflow 跟踪服务器进行通信,从而实现更好的用户管理。请期待有关工作区的更多更新。

SFTP 服务器

该服务提供了远程数据存储。SFTP(安全文件传输协议)是一种文件传输协议,提供对远程计算机的安全访问。有关 SFTP 的更多信息,请阅读以下文章。

[## 使用 Docker 设置 SFTP 服务器

免费 — 开源 — 安全

levelup.gitconnected.com](https://levelup.gitconnected.com/set-up-an-sftp-server-with-docker-353fb513ccfd?source=post_page-----b54294676f0b--------------------------------)

在这个项目中,我们使用了来自 DockerHub 的 atmoz/sftp 镜像。你也可以使用其他存储技术,如 AWS S3 或 HDFS。在 MLflow 工作区中,SFTP 服务器提供了工件存储。在这个存储中,我们保存 ML 模型以及其他工件,如 Jupyter 笔记本或 Python 脚本。

MLflow 跟踪服务器

该服务提供了 MLflow UI。这个 web UI 使得管理你的 ML 实验变得简单。你还可以通过 web UI 比较不同的 ML 模型。我们使用了 Docker 镜像 python 来构建该服务。可以通过 127.0.0.1:5001 访问 web UI。

Postgres 数据库

该服务为后端存储提供了一个 Postgres 数据库。PostgreSQL 是一个免费的开源关系数据库管理系统。我们使用它来存储参数和评估指标。MLflow 工作区使用来自 DockerHub 的官方Postgres Docker 镜像。

MLflow 工作区正在运行,我们已经理解了服务的基本功能。现在是时候用工作区实现一个实际示例了。

MLflow 工作区的使用

我们通过一个小的机器学习示例来解释工作区的功能。在这个示例中,我们将尝试在sklearn moon 数据集中分离两个类别。我们使用随机森林作为机器学习模型。首先,我们加载数据并进行可视化。

n = 1000
test_size = 0.25
data_seed = 73 

X, y = datasets.make_moons(
    n_samples = n, 
    noise = 0.25, 
    random_state = data_seed)

X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size = test_size,
    random_state = 42)

plt.scatter(
    x = X[:,0], 
    y = X[:,1], 
    s = 40, 
    c = y, 
    cmap = plt.cm.Accent);

月亮数据(图片来自作者)

你可以看到我们有两个类别。我们想用随机森林将这两个类别分开。此外,我们还希望跟踪各个实验的指标和参数。我们通过在代码中包含 MLflow 命令来实现这一点。你可以在我们的GitHub 仓库中查看完整代码。

with mlflow.start_run(run_name='random_forest_model') as run:
    model_rf = RandomForestClassifier(random_state = 42)
    model_rf.fit(X_train, y_train)
    y_pred = model_rf.predict(X_test)

    # metrics
    precision_0 = classification_report(
    y_true=y_test, 
    y_pred=y_pred, 
    target_names=target_names, 
    output_dict=True)["0"]["precision"]

    ...

    f1_score_1 = classification_report(
    y_true=y_test, 
    y_pred=y_pred, 
    target_names=target_names, 
    output_dict=True)["1"]["f1-score"]

    err, acc, tpr, tnr, fnr, fpr = get_confusion_matrix_metrics(y_test=y_test, y_pred=y_pred)

    # log metrics
    mlflow.log_metric("precision_0", precision_0)

    ...

    mlflow.log_metric("f1_score_1", f1_score_1)

    mlflow.log_metric("err", err)
    mlflow.log_metric("acc", acc)
    mlflow.log_metric("tpr", tpr)
    mlflow.log_metric("tnr", tnr)
    mlflow.log_metric("fnr", fnr)
    mlflow.log_metric("fpr", fpr)

    ...

    mlflow.log_artifact("logging_example.ipynb")

在使用不同参数运行随机森林模型的代码后,我们的实验会显示在网页用户界面上。

MLflow 跟踪用户界面(图片来自作者)

你可以看到两个运行。接下来,我们可以比较这两个运行。为此,勾选这两个运行的复选框,然后点击“比较”。一个新网页会打开,你可以在这里进行详细比较。下图显示了一个示例。

两次随机森林运行的比较(图片来自作者)

我们可以看到两个运行的详细信息。MLflow 通过运行 ID 区分运行。它还保存了开始时间、结束时间和运行持续时间。第二部分列出了模型参数。在第一次运行中我们使用了 100 棵树,而在第二次运行中仅用了两棵树。你可以按差异进行筛选,这一点很实用。第三部分列出了我们跟踪的所有指标。指标显示两个模型的效果相似,第一模型稍微好一些。MLflow 提供了许多其他比较功能,例如,你可以在图表中比较不同的指标或参数(平行坐标图、散点图、箱线图和轮廓图)。MLflow 工作区对数据科学家极为有用,因为它跟踪了所有模型组合的指标和代码。它避免了众多实验的混乱,并且你始终可以保持概览。

结论

在这篇文章中,我们展示了一个生产就绪的 MLflow 工作区。我们简要描述了使用 Docker 进行设置,并在工作区中运行了一个示例。此外,我们邀请所有数据科学家测试 MLflow 工作区,以便我们可以不断改进它。欢迎反馈。请在评论中写下你的想法。谢谢。

👉🏽 你可以在我们的数字产品页面找到所有免费资源!

👉🏽 加入我们免费的每周魔法 AI 新闻通讯,获取最新的 AI 更新!

免费订阅 以便在我们发布新故事时获得通知:

[## 每当 Janik 和 Patrick Tinz 发布文章时,获取电子邮件通知。

每当 Janik 和 Patrick Tinz 发布文章时,您会收到电子邮件。通过注册,您将创建一个 Medium 账户(如果您还没有的话)...

tinztwinspro.medium.com](https://tinztwinspro.medium.com/subscribe?source=post_page-----b54294676f0b--------------------------------)

在我们的 关于页面 了解更多关于我们的信息。别忘了在 X 上关注我们。非常感谢你的阅读。如果你喜欢这篇文章,欢迎分享。祝你有美好的一天!

使用 我们的链接 注册 Medium 会员,以便阅读无限量的 Medium 故事。

posted @ 2024-10-12 19:56  绝不原创的飞龙  阅读(457)  评论(0)    收藏  举报