精通-Spark-数据科学-全-

精通 Spark 数据科学(全)

原文:zh.annas-archive.org/md5/6A8ACC3697FE0BCDA4D2C7EE588C4E25

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

数据科学的目的是利用数据改变世界,这个目标主要通过颠覆和改变真实行业中的真实流程来实现。要在这个层面上运作,我们需要能够构建实质性的数据科学解决方案;解决真正的问题,并且能够可靠地运行,以便人们信任并采取行动。

本书解释了如何使用 Spark 提供生产级的数据科学解决方案,这些解决方案创新、颠覆性,并且足够可靠,值得信赖。在撰写本书时,作者的意图是提供一本超越传统食谱风格的作品:不仅提供代码示例,还要培养探索内容的技巧和心态,就像一位大师;正如他们所说,“内容为王”!读者会注意到本书在新闻分析方面的重点,并偶尔引入其他数据集,如推文和金融数据。这种对新闻的重视并非偶然;我们花了很多精力试图专注于提供全球范围内提供背景的数据集。

本书致力于解决的隐含问题是缺乏提供人们如何以及为什么做出决策的适当背景的数据。通常,直接可访问的数据源非常专注于特定问题,因此在提供行为背景所需的更广泛数据集方面可能非常有限,这导致我们无法真正理解人们做出决策的驱动因素。

考虑一个简单的例子,网站用户的关键信息,如年龄、性别、位置、购物行为、购买等都是已知的,我们可以利用这些数据来推荐产品,基于其他“和他们相似”的人购买的产品。

但要想成为杰出的人,需要更多的背景信息来解释人们为什么会表现出这样的行为。当新闻报道暗示一场大西洋飓风正在接近佛罗里达海岸线,并且可能在 36 小时内到达海岸时,也许我们应该推荐人们可能需要的产品。比如 USB 充电宝、蜡烛、手电筒、净水器等物品。通过了解决策背后的背景,我们可以进行更好的科学研究。

因此,尽管本书确实包含有用的代码,而且在许多情况下还包含独特的实现,但它进一步深入探讨了真正掌握数据科学所需的技术和技能;其中一些经常被忽视或根本没有被考虑。作者们凭借多年的商业经验,利用他们丰富的知识,将数据科学的真实而令人兴奋的世界呈现出来。

本书涵盖的内容

第一章, 大数据科学生态系统,本章介绍了一种在规模上实现数据成功的方法和相关生态系统。它侧重于数据科学工具和技术,这些工具和技术将在后面的章节中使用,并介绍了环境以及如何适当地配置它。此外,它解释了一些与整体数据架构和长期成功相关的非功能性考虑。

第二章, 数据获取,作为数据科学家,最重要的任务之一是将数据准确地加载到数据科学平台中。本章解释了如何构建 Spark 中的通用数据摄入管道,这个管道可以作为可重用的组件,服务于许多输入数据源。

第三章,“输入格式和模式”,本章演示了如何从原始格式加载数据到不同的模式,从而使得可以对相同数据运行各种不同类型的下游分析。考虑到这一点,我们将研究传统上理解的数据模式领域。我们将涵盖传统数据库建模的关键领域,并解释其中一些基石原则如何今天仍然适用于 Spark。此外,当我们磨练我们的 Spark 技能时,我们将分析 GDELT 数据模型,并展示如何以高效和可扩展的方式存储这个大型数据集。

第四章,“探索性数据分析”,一个常见的误解是,EDA 仅用于发现数据集的统计属性,并提供关于如何利用它的见解。实际上,这并不是全部。完整的 EDA 将扩展这个想法,并包括对“在生产中使用这个数据源的可行性”的详细评估。这要求我们也要了解如何为这个数据集指定一个生产级的数据加载程序,这个程序可能会在很多年内以“无人值守模式”运行。本章提供了一种使用“数据概要分析”技术加速数据质量评估的快速方法。

第五章,“地理分析的 Spark”,地理处理是 Spark 的一个强大的新用例,本章演示了如何入门。本章的目的是解释数据科学家如何使用 Spark 处理地理数据,以生成基于地图的大型数据集的强大视图。我们演示了如何通过 Spark 与 Geomesa 集成轻松处理时空数据,从而帮助将 Spark 转变为一个复杂的地理处理引擎。本章后来利用这些时空数据应用机器学习,以预测石油价格。

第六章,“抓取基于链接的外部数据”,本章旨在解释一种增强本地数据的常见模式,即在 URL 或 API 上找到的外部内容,如 GDELT 和 Twitter。我们提供了一个使用 GDELT 新闻索引服务作为新闻 URL 来源的教程,演示了如何构建一个从互联网上抓取感兴趣的全球突发新闻的 Web 规模新闻扫描器。我们进一步解释了如何使用专门的网络抓取组件来克服规模的挑战,随后总结了本章。

第七章,“构建社区”,本章旨在解决数据科学和大数据中的一个常见用例。随着越来越多的人互动,交流信息,或者简单地分享不同主题的共同兴趣,整个世界可以被表示为一个图。数据科学家必须能够检测社区,找到影响者/顶级贡献者,并检测可能的异常。

第八章,“构建推荐系统”,如果要选择一个算法来向公众展示数据科学,推荐系统肯定会成为其中之一。今天,推荐系统随处可见;它们之所以如此受欢迎,是因为它们的多功能性、实用性和广泛适用性。在本章中,我们将演示如何使用原始音频信号推荐音乐内容。

第九章,“新闻词典和实时标记系统”,虽然分层数据仓库将数据存储在文件夹中,但典型的基于 Hadoop 的系统依赖于扁平架构来存储数据。如果没有适当的数据治理或对数据的清晰理解,就有不可否认的可能性将数据湖变成沼泽,其中一个有趣的数据集,如 GDELT,将不过是一个包含大量非结构化文本文件的文件夹。在本章中,我们将描述一种创新的方式,以非监督的方式对即将到来的 GDELT 数据进行标记,并且几乎是实时的。

第十章,“故事去重和变异”,在本章中,我们对 GDELT 数据库进行去重和索引,然后跟踪故事的变化,了解它们之间的联系,它们如何变异,以及它们是否可能导致不久的将来发生的事件。本章的核心是使用 Simhash 检测近似重复项,并使用随机索引构建向量以降低维度。

第十一章,“异常检测和情感分析”,也许 2016 年最引人注目的事件是紧张的美国总统选举及其最终结果:唐纳德·特朗普当选总统,这场竞选将长久被人们铭记;尤其是因为其对社交媒体的空前利用以及激起用户激情的方式,其中大多数人通过使用标签表达了自己的情感。在本章中,我们将尝试使用实时 Twitter 信息流检测美国选举期间的异常推文,而不是试图预测选举结果本身。

第十二章,“趋势演算”,在“趋势”成为数据科学家研究的热门话题之前,早有一个更古老的概念,至今仍未得到数据科学的充分应用;那就是趋势。目前,对趋势的分析,如果可以这样称呼的话,主要是由人们“用眼睛看”时间序列图表并进行解释。但人们的眼睛究竟在做什么?本章描述了 Apache Spark 中用于数值研究趋势的新算法:趋势演算。

第十三章,“安全数据”,在本书中,我们涉及了许多数据科学领域,经常涉足那些传统上与数据科学家的核心工作知识不太相关的领域。在本章中,我们将讨论另一个经常被忽视的领域,安全数据;更具体地说,如何在数据生命周期的各个阶段保护您的数据和分析结果。本章的核心是构建一个用于 Spark 的商业级加密编解码器。

第十四章,“可扩展算法”,在本章中,我们将了解为什么有时即使基本算法在小规模下运行良好,但在“大数据”中却经常失败。我们将看到如何避免在编写运行在大规模数据集上的 Spark 作业时出现问题,并了解算法的结构以及如何编写能够在数据量达到 PB 级别时扩展的自定义数据科学分析。本章涵盖了诸如:并行化策略、缓存、洗牌策略、垃圾回收优化和概率模型等领域;解释了这些如何帮助您充分利用 Spark 范式。

阅读本书需要什么

本书中使用了 Spark 2.0 以及 Scala 2.11、Maven 和 Hadoop。这是基本的环境要求,还有许多其他在相关章节中介绍的技术。

这本书是为谁准备的

我们假设阅读本书的数据科学家对数据科学、常见的机器学习方法和流行的数据科学工具有所了解,并且在工作中进行了概念验证研究并构建了原型。我们为这个受众提供了一本介绍高级技术和方法来构建数据科学解决方案的书籍,向他们展示如何构建商业级数据产品。

约定

在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"下一行代码读取链接并将其分配给BeautifulSoup函数。"

代码块设置如下:

import org.apache.spark.sql.functions._      

val rdd = rawDS map GdeltParser.toCaseClass    
val ds = rdd.toDS()     

// DataFrame-style API 
ds.agg(avg("goldstein")).as("goldstein").show() 

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

spark.sql("SELECT V2GCAM FROM GKG LIMIT 5").show 
spark.sql("SELECT AVG(GOLDSTEIN) AS GOLDSTEIN FROM GKG WHERE GOLDSTEIN IS NOT NULL").show()

任何命令行输入或输出都以以下方式编写:

$ cat 20150218230000.gkg.csv | gawk -F"\t" '{print $4}'

新术语重要单词以粗体显示。屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:"为了下载新模块,我们将转到文件 | 设置 | 项目名称 | 项目解释器。"

注意

警告或重要说明会出现在这样的框中。

提示

提示和技巧会出现在这样。

第一章:大数据科学生态系统

作为一名数据科学家,您无疑对处理文件和处理大量数据非常熟悉。然而,正如您所同意的,除了简单分析单一类型的数据之外,需要一种组织和编目数据的方法,以便有效地管理数据。事实上,这是一名优秀数据科学家的基石。随着数据量和复杂性的增加,一种一致而坚固的方法可以决定泛化的成功和过度拟合的失败之间的差异!

本章是介绍一种在大规模数据上取得成功的方法和生态系统。它侧重于数据科学工具和技术。它介绍了环境以及如何适当配置,但也解释了一些与整体数据架构相关的非功能性考虑。虽然在这个阶段几乎没有实际的数据科学,但它为本书的其余部分的成功铺平了道路。

在本章中,我们将涵盖以下主题:

  • 数据管理责任

  • 数据架构

  • 伴侣工具

介绍大数据生态系统

数据管理尤为重要,特别是当数据处于不断变化或定期产生和更新的状态时。在这些情况下需要的是一种存储、结构化和审计数据的方式,允许对模型和结果进行持续处理和改进。

在这里,我们描述了如何最好地持有和组织您的数据,以便与 Apache Spark 和相关工具在数据架构的背景下进行整合。

数据管理

即使在中期,您只打算在家里玩一点数据;然而,如果没有适当的数据管理,往往会导致努力升级到您很容易迷失方向并且会发生错误的程度。花时间考虑数据的组织,特别是其摄入,是至关重要的。没有比等待长时间运行的分析完成,整理结果并生成报告,然后发现您使用了错误版本的数据,或者数据不完整,缺少字段,甚至更糟糕的是您删除了结果更糟糕的事情了!

坏消息是,尽管它很重要,但数据管理是一个在商业和非商业企业中一直被忽视的领域,几乎没有现成的解决方案。好消息是,使用本章描述的基本构建模块进行出色的数据科学要容易得多。

数据管理责任

当我们考虑数据时,很容易忽视我们需要考虑的范围的真正程度。事实上,大多数数据“新手”以这种方式考虑范围:

  1. 获取数据

  2. 将数据放在某个地方(任何地方)

  3. 使用数据

  4. 扔掉数据

实际上,还有许多其他考虑因素,我们有责任确定哪些适用于特定的工作。以下数据管理构建模块有助于回答或跟踪有关数据的一些重要问题:

  • 文件完整性

  • 数据文件是否完整?

  • 你怎么知道的?

  • 它是否属于一组?

  • 数据文件是否正确?

  • 在传输过程中是否被篡改?

  • 数据完整性

  • 数据是否符合预期?

  • 所有字段都存在吗?

  • 是否有足够的元数据?

  • 数据质量是否足够?

  • 是否有任何数据漂移?

  • 调度

  • 数据是否定期传输?

  • 数据多久到达一次?

  • 数据是否按时接收?

  • 你能证明数据是何时接收的吗?

  • 它需要确认吗?

  • 模式管理

  • 数据是结构化的还是非结构化的?

  • 数据应该如何解释?

  • 是否可以推断出模式?

  • 数据是否随时间改变?

  • 模式是否可以从上一个版本演变?

  • 版本管理

  • 数据的版本是多少?

  • 版本是否正确?

  • 如何处理不同版本的数据?

  • 您如何知道自己使用的是哪个版本?

  • 安全

  • 数据是否敏感?

  • 它包含个人可识别信息(PII)吗?

  • 它包含个人健康信息(PHI)吗?

  • 它包含支付卡信息(PCI)吗?

  • 我应该如何保护数据?

  • 谁有权读取/写入数据?

  • 是否需要匿名化/清理/混淆/加密?

  • 处置

  • 我们如何处理数据?

  • 我们何时处置数据?

如果经过所有这些之后,你仍然不确定,在你继续编写使用gawkcrontab命令的 bash 脚本之前,继续阅读,你很快就会发现有一种更快、更灵活、更安全的方法,可以让你从小处着手,逐步创建商业级的摄取管道!

合适的工具来完成工作

Apache Spark 是可扩展数据处理的新兴事实标准。在撰写本书时,它是最活跃的Apache 软件基金会ASF)项目,并且有丰富多样的伴随工具可用。每天都会出现新项目,其中许多项目在功能上有重叠。因此,需要时间来了解它们的功能并决定是否适合使用。不幸的是,这方面没有快速的方法。通常,必须根据具体情况做出特定的权衡;很少有一刀切的解决方案。因此,鼓励读者探索可用的工具并明智地选择!

本书介绍了各种技术,希望能为读者提供一些更有用和实用的技术的入门,以至于他们可以开始在自己的项目中利用它们。此外,我们希望展示,如果代码编写得当,即使决定被证明是错误的,也可以通过巧妙地使用应用程序接口API)(或 Spark Scala 中的高阶函数)来交换技术。

总体架构

让我们从数据架构的高层介绍开始:它们的作用是什么,为什么它们有用,何时应该使用它们,以及 Apache Spark 如何适应其中。

总体架构

在最一般的情况下,现代数据架构具有四个基本特征:

  • 数据摄取

  • 数据湖

  • 数据科学

  • 数据访问

现在让我们介绍每一个,这样我们可以在后面的章节中更详细地讨论。

数据摄取

传统上,数据是根据严格的规则摄取,并根据预定的模式进行格式化。这个过程被称为提取、转换、加载ETL),仍然是一个非常常见的做法,得到了大量商业工具以及一些开源产品的支持。

数据摄取

ETL 方法倾向于进行前期检查,以确保数据质量和模式一致,以简化后续的在线分析处理。它特别适用于处理具有特定特征集的数据,即与经典实体关系模型相关的数据。然而,并不适用于所有情况。

在大数据革命期间,对结构化、半结构化和非结构化数据的需求出现了象征性的爆炸,导致需要处理具有不同特征集的系统的创建。这些被定义为“4V:容量、多样性、速度和准确性”www.ibmbigdatahub.com/infographic/four-vs-big-data。传统的 ETL 方法在这种新负担下陷入困境,因为它们处理大量数据需要太长时间,或者在面对变化时过于僵化,于是出现了一种不同的方法。进入“读时模式”范式。在这里,数据以其原始形式(或至少非常接近)被摄入,规范化、验证等细节在分析处理时进行。

这通常被称为“提取加载转换”(ELT),是对传统方法的参考:

数据摄入

这种方法重视及时交付数据,延迟详细处理直到绝对需要。这样,数据科学家可以立即访问数据,使用一系列传统方法不可用的技术寻找洞见。

尽管我们在这里只提供了一个高层概述,但这种方法非常重要,因此在整本书中,我们将通过实施各种读时模式算法来进一步探讨。我们将假定数据摄入采用 ELT 方法,也就是说我们鼓励用户根据自己的方便加载数据。这可以是每隔n分钟、夜间或在低使用率时进行。然后可以通过运行离线批处理作业再次由用户自行决定检查数据的完整性、质量等等。

数据湖

数据湖是一个方便、无处不在的数据存储。它很有用,因为它提供了许多关键的好处,主要包括:

  • 可靠的存储

  • 可扩展的数据处理能力

让我们简要地看一下每一个。

可靠的存储

数据湖有许多可靠的底层存储实现,包括 Hadoop 分布式文件系统(HDFS)、MapR-FS 和 Amazon AWS S3。

在整本书中,HDFS 将被假定为存储实现。此外,在本书中,作者使用部署在 Hortonworks HDP 环境中运行的 YARN 的分布式 Spark 设置。因此,除非另有说明,否则 HDFS 是使用的技术。如果您对这些技术不熟悉,它们将在本章后面进一步讨论。

无论如何,值得知道的是,Spark 可以本地引用 HDFS 位置,通过前缀file://访问本地文件位置,并通过前缀s3a://引用 S3 位置。

可扩展的数据处理能力

显然,Apache Spark 将是我们首选的数据处理平台。此外,正如您可能记得的那样,Spark 允许用户在其首选环境中执行代码,无论是本地、独立、YARN 还是 Mesos,都可以通过配置适当的集群管理器来实现;在masterURL中。顺便说一句,这可以在以下三个位置之一完成:

  • 在发出spark-submit命令时使用--master选项

  • conf/spark-defaults.conf文件中添加spark.master属性

  • SparkConf对象上调用setMaster方法

如果您不熟悉 HDFS,或者没有访问集群的权限,那么可以使用本地文件系统运行本地 Spark 实例,这对于测试很有用。但是要注意,有时只有在集群上执行时才会出现不良行为。因此,如果您对 Spark 很认真,值得投资于分布式集群管理器,为什么不尝试 Spark 独立集群模式,或者亚马逊 AWS EMR?例如,亚马逊提供了许多负担得起的云计算路径,您可以探索aws.amazon.com/ec2/spot/上的抢购实例的想法。

数据科学平台

数据科学平台提供了服务和 API,使得有效的数据科学得以进行,包括探索性数据分析、机器学习模型的创建和完善、图像和音频处理、自然语言处理和文本情感分析。

这是 Spark 真正擅长的领域,也是本书剩下部分的主要重点,利用强大的本地机器学习库、无与伦比的并行图处理能力和强大的社区。Spark 为数据科学提供了真正可扩展的机会。

剩下的章节将深入探讨这些领域,包括第六章,“抓取基于链接的外部数据”,第七章,“构建社区”,和第八章,“构建推荐系统”。

数据访问

数据湖中的数据最常由数据工程师和科学家使用 Hadoop 生态系统工具访问,比如 Apache Spark、Pig、Hive、Impala 或 Drill。然而,有时其他用户,甚至其他系统,需要访问数据,而常规工具要么太技术化,要么无法满足用户对实时延迟的苛刻期望。

在这些情况下,数据通常需要被复制到数据仓库或索引存储中,以便可以暴露给更传统的方法,比如报告或仪表盘。这个过程通常涉及创建索引和重组数据以实现低延迟访问,被称为数据出口。

幸运的是,Apache Spark 有各种适配器和连接器,可以连接传统数据库、BI 工具以及可视化和报告软件。本书将介绍其中许多。

数据技术

当 Hadoop 刚开始时,Hadoop 这个词指的是 HDFS 和 MapReduce 处理范式的组合,因为这是原始论文的概要research.google.com/archive/mapreduce.html。自那时起,出现了大量的技术来补充 Hadoop,随着 Apache YARN 的发展,我们现在看到其他处理范式的出现,比如 Spark。

现在,Hadoop 通常被用作整个大数据软件堆栈的俗语,因此在这一点上,为本书定义该堆栈的范围是明智的。本书将在整本书中访问的一系列技术的典型数据架构如下所述:

数据技术

这些技术之间的关系是一个复杂的话题,因为它们之间存在复杂的相互依赖关系,例如,Spark 依赖于 GeoMesa,而 GeoMesa 又依赖于 Accumulo,Accumulo 又依赖于 Zookeeper 和 HDFS!因此,为了管理这些关系,有一些可用的平台,比如 Cloudera 或 Hortonworks HDP hortonworks.com/products/sandbox/。这些平台提供了集中的用户界面和集中的配置。平台的选择取决于读者,然而,不建议最初安装一些技术,然后转移到受管理的平台,因为遇到的版本问题会非常复杂。因此,通常更容易从一个干净的机器开始,并在前期做出决定。

我们在本书中使用的所有软件都是与平台无关的,因此适用于前面描述的一般架构。它可以独立安装,并且在单个或多个服务器环境中使用相对简单,而不需要使用受管理的产品。

Apache Spark 的作用

在许多方面,Apache Spark 是将这些组件联系在一起的粘合剂。它越来越多地代表了软件堆栈的中心。它与各种组件集成,但没有一个是硬连接的。事实上,甚至底层存储机制都可以被替换。将这个特性与利用不同处理框架的能力相结合,意味着最初的 Hadoop 技术有效地成为组件,而不是一个庞大的框架。我们的架构的逻辑图如下所示:

Apache Spark 的作用

随着 Spark 的发展和广泛的行业认可,许多最初的 Hadoop 实现已经被重构为 Spark。因此,为了给这个画面增加更多的复杂性,通常有几种可能的方法来以编程方式利用任何特定的组件;尤其是根据 API 是否从最初的 Hadoop Java 实现中移植出来的命令式和声明式版本。在接下来的章节中,我们尽量保持对 Spark 精神的忠实。

伴随工具

现在我们已经建立了一个要使用的技术堆栈,让我们描述每个组件,并解释它们在 Spark 环境中的用处。本书的这一部分旨在作为参考而不是直接阅读。如果您熟悉大多数技术,那么您可以刷新您的知识并继续阅读下一节,第二章,数据采集

Apache HDFS

Hadoop 分布式文件系统HDFS)是一个带有内置冗余的分布式文件系统。它默认优化为在三个或更多节点上工作(尽管一个节点也可以正常工作,且限制可以增加),这提供了存储数据的能力。因此,文件不仅被分割成多个块,而且这些块的三个副本在任何时候都存在。这巧妙地提供了数据冗余(如果一个丢失了,其他两个仍然存在),同时也提供了数据局部性。当对 HDFS 运行分布式作业时,系统不仅会尝试收集作业输入所需的所有块,还会尝试仅使用与运行该作业的服务器物理接近的块;因此,它有能力减少网络带宽,只使用其本地存储上的块,或者那些接近自身的节点上的块。实际上,这是通过将 HDFS 物理磁盘分配给节点,并将节点分配给机架来实现的;块是以节点本地、机架本地和集群本地的方式写入的。所有对 HDFS 的指令都通过一个名为NameNode的中央服务器传递,因此这提供了一个可能的单点故障;有各种方法可以提供 NameNode 的冗余。

此外,在多租户 HDFS 场景中,许多进程同时访问同一文件时,通过使用多个块也可以实现负载平衡;例如,如果一个文件占用一个块,这个块被复制三次,因此可能可以同时从三个不同的物理位置读取。尽管这可能看起来不是一个很大的优势,在数百或数千个节点的集群上,网络 IO 通常是运行作业的最大限制因素--作者在多千节点集群上确实经历过作业不得不等待数小时才能完成的情况,纯粹是因为网络带宽由于大量其他线程调用数据而达到最大值。

如果您正在运行笔记本电脑,需要将数据存储在本地,或者希望使用您已经拥有的硬件,那么 HDFS 是一个不错的选择。

优势

使用 HDFS 的优势如下:

  • 冗余:块的可配置复制提供了对节点和磁盘故障的容忍

  • 负载平衡:块复制意味着相同的数据可以从不同的物理位置访问

  • 数据本地性:分析尝试访问最接近的相关物理块,减少网络 IO。

  • 数据平衡:有一个算法可以在数据块变得过于集中或碎片化时重新平衡数据块。

  • 灵活的存储:如果需要更多空间,可以添加更多磁盘和节点;尽管这不是一个热过程,但集群将需要停机来添加这些资源

  • 额外成本:没有第三方成本涉及

  • 数据加密:隐式加密(打开时)

缺点

以下是缺点:

  • NameNode 提供了一个中心故障点;为了减轻这一点,有辅助和高可用性选项可用

  • 集群需要基本的管理和可能一些硬件工作

安装

要使用 HDFS,我们应该决定是以本地、伪分布式还是完全分布式的方式运行 Hadoop;对于单个服务器,伪分布式对于分析是有用的,因为分析应该可以直接从这台机器转移到任何 Hadoop 集群。无论如何,我们应该安装 Hadoop,至少包括以下组件:

  • NameNode

  • 辅助 NameNode(或高可用性 NameNode)

  • DataNode

Hadoop 可以通过hadoop.apache.org/releases.html进行安装。

Spark 需要知道 Hadoop 配置的位置,特别是以下文件:hdfs-site.xmlcore-site.xml。然后在 Spark 配置中设置配置参数HADOOP_CONF_DIR

然后 HDFS 将以本地方式可用,因此在 Spark 中可以简单地使用/user/local/dir/text.txt来访问文件hdfs://user/local/dir/text.txt

亚马逊 S3

S3 将所有与并行性、存储限制和安全性相关的问题都抽象化,允许非常大规模的并行读/写操作,并提供了极低的成本和极好的服务级别协议(SLA)。如果您需要快速启动、无法在本地存储数据,或者不知道未来的存储需求是什么,这是完美的选择。需要注意的是,s3nS3a采用对象存储模型,而不是文件存储,因此存在一些妥协:

  • 最终一致性是指一个应用程序所做的更改(创建、更新和删除)在一段时间内不可见,尽管大多数 AWS 区域现在支持写后读一致性。

  • s3ns3a利用了非原子重命名和删除操作;因此,重命名或删除大型目录需要与条目数量成比例的时间。然而,在此期间,目标文件可能对其他进程可见,直到最终一致性得到解决。

S3 可以通过命令行工具(s3cmd)通过网页和大多数流行语言的 API 访问;它通过基本配置与 Hadoop 和 Spark 进行本地集成。

优势

以下是优势:

  • 无限的存储容量

  • 无硬件考虑

  • 可用加密(用户存储的密钥)

  • 99.9%的可用性

  • 冗余

缺点

以下是缺点:

  • 存储和传输数据的成本

  • 没有数据局部性

  • 最终一致性

  • 相对较高的延迟

安装

您可以创建一个 AWS 账户:aws.amazon.com/free/。通过这个账户,您将可以访问 S3,并且只需要创建一些凭据。

当前的 S3 标准是s3a;要通过 Spark 使用它需要对 Spark 配置进行一些更改:

spark.hadoop.fs.s3a.impl=org.apache.hadoop.fs.s3a.S3AFileSystem 
spark.hadoop.fs.s3a.access.key=MyAccessKeyID 
spark.hadoop.fs.s3a.secret.key=MySecretKey

如果使用 HDP,您可能还需要:

spark.driver.extraClassPath=${HADOOP_HOME}/extlib/hadoop-aws-currentversion.jar:${HADOOP_HOME}/ext/aws-java-sdk-1.7.4.jar

然后,所有 S3 文件都可以在 Spark 中使用前缀s3a://来访问 S3 对象引用:

val rdd = spark.sparkContext.textFile("s3a://user/dir/text.txt") 

我们也可以内联使用 AWS 凭据,假设我们已经设置了spark.hadoop.fs.s3a.impl

spark.sparkContext.textFile("s3a://AccessID:SecretKey@user/dir/file") 

然而,这种方法不会接受键中的斜杠字符/。这通常可以通过从 AWS 获取另一个键来解决(直到没有斜杠出现为止)。

我们也可以通过 AWS 账户中的 S3 选项卡下的 Web 界面浏览对象。

Apache Kafka

Apache Kafka 是一个分布式的、用 Scala 编写的消息代理,可在 Apache 软件基金会许可下使用。该项目旨在提供一个统一的、高吞吐量、低延迟的平台,用于处理实时数据源。其结果本质上是一个大规模可扩展的发布-订阅消息队列,对于企业基础设施处理流式数据非常有价值。

优势

以下是优势:

  • 发布-订阅消息

  • 容错

  • 保证交付

  • 故障时重播消息

  • 高度可扩展的共享无架构

  • 支持背压

  • 低延迟

  • 良好的 Spark-streaming 集成

  • 客户端实现简单

缺点

以下是缺点:

  • 至少一次语义-由于缺乏事务管理器,无法提供精确一次性消息传递

  • 需要 Zookeeper 进行操作

安装

由于 Kafka 是一个发布-订阅工具,其目的是管理消息(发布者)并将其定向到相关的端点(订阅者)。这是通过经过 Kafka 实现时安装的代理来完成的。Kafka 可以通过 Hortonworks HDP 平台获得,也可以独立安装,链接如下kafka.apache.org/downloads.html

Kafka 使用 Zookeeper 来管理领导选举(因为 Kafka 可以分布式,从而实现冗余),在前面的链接中找到的快速入门指南可以用于设置单节点 Zookeeper 实例,并提供客户端和消费者来发布和订阅主题,这提供了消息处理的机制。

Apache Parquet

自 Hadoop 诞生以来,基于列的格式(而不是基于行)的想法得到了越来越多的支持。Parquet 已经开发出来,以利用压缩、高效的列式数据表示,并且设计时考虑了复杂的嵌套数据结构;它借鉴了 Apache Dremel 论文中讨论的算法。Parquet 允许在每一列上指定压缩方案,并且为添加更多编码做好了未来的准备。它还被设计为在整个 Hadoop 生态系统中提供兼容性,并且像 Avro 一样,将数据模式与数据本身一起存储。

优势

以下是优势:

  • 列式存储

  • 高度存储效率

  • 每列压缩

  • 支持谓词下推

  • 支持列剪枝

  • 与其他格式兼容,例如 Avro

  • 高效读取,设计用于部分数据检索

缺点

以下是缺点:

  • 不适合随机访问

  • 写入可能需要大量计算

安装

Parquet 在 Spark 中是原生可用的,并且可以直接访问如下:

val ds = Seq(1, 2, 3, 4, 5).toDS 
ds.write.parquet("/data/numbers.parquet") 
val fromParquet = spark.read.parquet("/data/numbers.parquet")

Apache Avro

Apache Avro 最初是为 Hadoop 开发的数据序列化框架。它使用 JSON 定义数据类型和协议(尽管还有另一种 IDL),并以紧凑的二进制格式序列化数据。Avro 既提供了持久数据的序列化格式,又提供了 Hadoop 节点之间通信的传输格式,以及客户端程序与 Hadoop 服务之间的通信格式。另一个有用的功能是它能够将数据模式与数据本身一起存储,因此任何 Avro 文件都可以在不需要引用外部源的情况下读取。此外,Avro 支持模式演变,因此旧模式版本编写的 Avro 文件可以与新模式版本兼容。

优点

以下是优点:

  • 模式演变

  • 节省磁盘空间

  • 支持 JSON 和 IDL 中的模式

  • 支持多种语言

  • 支持压缩

缺点

以下是缺点:

  • 需要模式才能读写数据

  • 序列化计算量大

安装

由于本书中使用 Scala、Spark 和 Maven 环境,因此可以导入 Avro 如下:

<dependency>   
   <groupId>org.apache.avro</groupId>   
   <artifactId>avro</artifactId>   
   <version>1.7.7</version> 
</dependency> 

然后就是创建模式并生成 Scala 代码,使用模式将数据写入 Avro。这在第三章 输入格式和模式中有详细说明。

Apache NiFi

Apache NiFi 起源于美国国家安全局(NSA),并于 2014 年作为其技术转移计划的一部分发布为开源项目。NiFi 能够在简单的用户界面中生成可扩展的数据路由和转换有向图。它还支持数据溯源,各种预构建的处理器以及快速高效地构建新处理器的能力。它包括优先级设置、可调的交付容忍度和反压功能,允许用户根据特定要求调整处理器和管道,甚至允许在运行时修改流程。所有这些都使其成为一个非常灵活的工具,可以构建从一次性文件下载数据流到企业级 ETL 管道的所有内容。通常使用 NiFi 构建管道和下载文件比编写快速的 bash 脚本甚至更快,加上用于此目的的功能丰富的处理器,这使其成为一个引人注目的选择。

优点

以下是优点:

  • 广泛的处理器范围

  • 集线器和辐射结构

  • 图形用户界面(GUI)

  • 可扩展

  • 简化并行处理

  • 简化线程处理

  • 允许运行时修改

  • 通过集群实现冗余

缺点

以下是缺点:

  • 没有横切错误处理程序

  • 表达语言只有部分实现

  • 流文件版本管理不足

安装

Apache NiFi 可以与 Hortonworks 一起安装,称为 Hortonworks Dataflow。它也可以作为 Apache 的独立安装程序使用,nifi.apache.org/。在第二章 数据采集中有关 NiFi 的介绍。

Apache YARN

YARN 是 Hadoop 2.0 的主要组件,它基本上允许 Hadoop 插入处理范式,而不仅仅限于原始的 MapReduce。YARN 由三个主要组件组成:资源管理器、节点管理器和应用程序管理器。本书不涉及深入研究 YARN;主要要理解的是,如果我们运行 Hadoop 集群,那么我们的 Spark 作业可以在客户端模式下使用 YARN 执行,如下所示:

spark-submit --class package.Class /  
             --master yarn / 
             --deploy-mode client [options] <app jar> [app options] 

优点

以下是优点:

  • 支持 Spark

  • 支持优先级调度

  • 支持数据本地性

  • 作业历史存档

  • 与 HDP 一起开箱即用

缺点

以下是缺点:

  • 没有 CPU 资源控制

  • 不支持数据谱系

安装

YARN 作为 Hadoop 的一部分安装;这可以是 Hortonworks HDP,Apache Hadoop,或其他供应商之一。无论如何,我们应该至少安装带有以下组件的 Hadoop:

  • 资源管理器

  • NodeManager(1 个或更多)

为了确保 Spark 可以使用 YARN,它只需要知道yarn-site.xml的位置,这是通过在 Spark 配置中使用YARN_CONF_DIR参数设置的。

Apache Lucene

Lucene 是一个最初用 Java 构建的索引和搜索库工具,但现在已经移植到其他几种语言,包括 Python。 Lucene 在其时间内产生了许多子项目,包括 Mahout,Nutch 和 Tika。这些现在已成为自己的顶级 Apache 项目,而 Solr 最近作为子项目加入。Lucene 具有全面的功能,但尤其以在问答搜索引擎和信息检索系统中的使用而闻名。

优点

以下是优点:

  • 高效的全文搜索

  • 可扩展的

  • 多语言支持

  • 出色的开箱即用功能

缺点

缺点是数据库通常更适合关系操作。

安装

如果您希望了解更多并直接与库交互,可以从lucene.apache.org/下载 Lucene。

在使用 Lucene 时,我们只需要在项目中包含lucene-core-<version>.jar。例如,使用 Maven 时:

<dependency> 
    <groupId>org.apache.lucene</groupId> 
    <artifactId>lucene-core</artifactId> 
    <version>6.1.0</version> 
</dependency> 

Kibana

Kibana 是一个分析和可视化平台,还提供图表和流数据汇总。它使用 Elasticsearch 作为其数据源(反过来使用 Lucene),因此可以利用规模上非常强大的搜索和索引功能。Kibana 可以以许多不同的方式可视化数据,包括条形图,直方图和地图。我们在本章末尾简要提到了 Kibana,并且在本书中将广泛使用它。

优点

以下是优点:

  • 在规模上可视化数据

  • 直观的界面,快速开发仪表板

缺点

以下是缺点:

  • 只与 Elasticsearch 集成

  • Kibana 发布与特定的 Elasticsearch 版本绑定

安装

Kibana 可以作为独立的部分轻松安装,因为它有自己的 Web 服务器。它可以从www.elastic.co/downloads/kibana下载。由于 Kibana 需要 Elasticsearch,因此还需要安装 Elasticsearch;有关更多信息,请参见前面的链接。Kibana 配置在config/kibana.yml中处理,如果安装了独立版本的 Elasticsearch,则不需要进行任何更改,它将立即运行!

Elasticsearch

Elasticsearch 是一个基于 Lucene(见前文)的基于 Web 的搜索引擎。它提供了一个分布式的,多租户的,无模式的 JSON 文档全文搜索引擎。它是用 Java 构建的,但由于其 HTTP Web 界面,可以从任何语言中利用。这使得它特别适用于要通过网页显示的交易和/或数据密集型指令。

优点

优点如下:

  • 分布式

  • 无模式

  • HTTP 接口

缺点

缺点如下

  • 无法执行分布式事务

  • 缺乏前端工具

安装

Elasticsearch 可以从www.elastic.co/downloads/elasticsearch下载。为了提供对 Rest API 的访问,我们可以导入 Maven 依赖项:

<dependency> 
    <groupId>org.elasticsearch</groupId> 
    <artifactId>elasticsearch-spark_2.10</artifactId> 
    <version>2.2.0-m1</version> 
</dependency> 

还有一个很好的工具可以帮助管理 Elasticsearch 内容。在chrome.google.com/webstore/category/extensions搜索 Chrome 扩展名 Sense。也可以在www.elastic.co/guide/en/sense/current/installing.html找到更多解释。或者,它也适用于 Kibana,网址为www.elastic.co/guide/en/sense/current/installing.html

Accumulo

Accumulo 是基于 Google 的 Bigtable 设计的 NoSQL 数据库,最初由美国国家安全局开发,随后于 2011 年发布给 Apache 社区。Accumulo 为我们提供了通常的大数据优势,如批量加载和并行读取,但还具有一些额外的功能;迭代器,用于高效的服务器和客户端预计算,数据聚合,最重要的是单元级安全。Accumulo 的安全方面使其在企业使用中非常有用,因为它在多租户环境中实现了灵活的安全性。Accumulo 由 Apache Zookeeper 提供支持,与 Kafka 一样,并且利用了 Apache Thrift,thrift.apache.org/,它实现了跨语言的远程过程调用(RPC)功能。

优点

优点如下:

  • Google Bigtable 的纯实现

  • 单元级安全

  • 可扩展的

  • 冗余

  • 为服务器端计算提供迭代器

缺点

缺点如下:

  • Zookeeper 在 DevOps 中并不普遍受欢迎

  • 并不总是批量关系操作的最有效选择

安装

Accumulo 可以作为 Hortonworks HDP 发布的一部分安装,也可以作为独立实例从accumulo.apache.org/安装。然后应根据安装文档进行配置,在撰写本文时为accumulo.apache.org/1.7/accumulo_user_manual#_installation

在第七章中,构建社区,我们演示了 Accumulo 与 Spark 的使用,以及一些更高级的功能,如迭代器输入格式。我们还展示了如何在 Elasticsearch 和 Accumulo 之间处理数据。

总结

在本章中,我们介绍了数据架构的概念,并解释了如何将责任分组为能力,以帮助管理数据的整个生命周期。我们解释了所有数据处理都需要一定程度的尽职调查,无论是由公司规定还是其他方式,没有这一点,分析及其结果很快就会变得无效。

在确定了我们的数据架构范围后,我们已经详细介绍了各个组件及其各自的优缺点,并解释了我们的选择是基于集体经验的。事实上,在选择组件时总是有选择的,他们各自的特性在做出任何承诺之前都应该仔细考虑。

在下一章中,我们将深入探讨如何获取和捕获数据。我们将建议如何将数据带入平台,并讨论与数据处理和处理相关的方面。

第二章:数据获取

作为数据科学家,将数据加载到数据科学平台是最重要的任务之一。本章解释了如何构建 Spark 中的通用数据摄入管道,而不是采用无控制的临时过程,这个管道可以作为跨多个输入数据源的可重用组件。我们将配置并演示它如何在各种运行条件下提供重要的数据管理信息。

读者将学习如何构建内容注册并使用它来跟踪加载到系统中的所有输入,并提供摄入管道的指标,以便这些流可以可靠地作为自动化的、无人值守的过程运行。

在本章中,我们将涵盖以下主题:

  • 介绍全球事件、语言和情绪数据库GDELT)数据集

  • 数据管道

  • 通用摄入框架

  • 实时监控新数据

  • 通过 Kafka 接收流数据

  • 注册新内容和保险库以进行跟踪

  • 在 Kibana 中可视化内容指标,以监控摄入过程和数据健康状况

数据管道

即使是最基本的分析,我们总是需要一些数据。事实上,找到正确的数据可能是数据科学中最难解决的问题之一(但这是另一本书的整个主题!)。我们已经在上一章中看到,我们获取数据的方式可以简单或复杂,视情况而定。实际上,我们可以将这个决定分解为两个不同的领域:临时定期

  • 临时数据获取:在原型设计和小规模分析中通常是最常见的方法,因为它通常不需要额外的软件来实现。用户获取一些数据,只需在需要时从源头下载。这种方法通常是点击一个网页链接并将数据存储在方便的地方,尽管数据可能仍然需要进行版本控制和安全保护。

  • 定期数据获取:在大规模和生产分析的受控环境中使用;还有一个很好的理由将数据集摄入数据湖以备将来使用。随着物联网IoT)的增长,在许多情况下产生了大量数据,如果数据不立即摄入,就会永远丢失。其中许多数据今天可能没有明显的用途,但将来可能会有用;因此,心态是收集所有数据以防需要时使用,并在确定不需要时稍后删除它。

很明显,我们需要一种灵活的数据获取方法,支持各种采购选项。

通用摄入框架

有许多方法可以处理数据获取,从自制的 bash 脚本到高端商业工具。本节的目的是介绍一个高度灵活的框架,我们可以用于小规模数据摄入,然后根据需求的变化扩展到完整的公司管理工作流程。这个框架将使用Apache NiFi构建。NiFi 使我们能够构建大规模的集成数据管道,将数据传输到全球各地。此外,它也非常灵活,易于构建简单的管道,通常甚至比使用 bash 或任何其他传统脚本方法更快。

注意

如果在多次采集同一数据集时采用了临时方法,那么应认真考虑是否应将其归类为定期类别,或者至少是否应引入更健壮的存储和版本控制设置。

我们选择使用 Apache NiFi,因为它提供了一个解决方案,可以创建许多不同复杂度的管道,并且可以扩展到真正的大数据和物联网级别,并且还提供了一个出色的拖放界面(使用所谓的基于流的编程 https://en.wikipedia.org/wiki/Flow-based_programming)。通过工作流生产的模式、模板和模块,它自动处理了许多传统上困扰开发人员的复杂功能,如多线程、连接管理和可扩展处理。对于我们的目的,它将使我们能够快速构建简单的原型管道,并在需要时将其扩展到完整的生产环境。

它已经被很好地记录下来,并且可以通过遵循nifi.apache.org/download.html上的信息来轻松运行。它在浏览器中运行,看起来像这样:

通用摄入框架

我们将 NiFi 的安装留给读者自己去练习,我们鼓励您这样做,因为我们将在接下来的部分中使用它。

介绍 GDELT 新闻流

希望现在我们已经启动并运行了 NiFi,并且可以开始摄入一些数据。因此,让我们从 GDELT 获取一些全球新闻媒体数据。以下是我们从 GDELT 网站blog.gdeltproject.org/gdelt-2-0-our-global-world-in-realtime/中摘取的简要信息:

在 GDELT 监控世界各地的新闻报道发布后的 15 分钟内,它已经将其翻译并加工处理,以识别所有事件、计数、引用、人物、组织、地点、主题、情感、相关图像、视频和嵌入式社交媒体帖子,并将其放入全球背景中,并通过实时开放的元数据火线提供所有这些内容,从而实现对地球本身的开放研究。

作为世界上最大的情感分析部署,我们希望通过将跨越多种语言和学科的许多情感和主题维度实时应用于来自全球各地的突发新闻,从而激发我们对情感的全新时代,以及它如何帮助我们更好地理解我们如何情境化、解释、回应和理解全球事件的方式。

我认为这是一个相当具有挑战性的任务!因此,与其拖延,暂停在这里指定细节,不如立即开始。我们将在接下来的章节中使用 GDELT 的各个方面。

为了开始使用这些开放数据,我们需要连接到元数据火线并将新闻流引入我们的平台。我们该如何做呢?让我们首先找出可用的数据。

实时发现 GDELT

GDELT 在其网站上发布了最新文件的列表。该列表每 15 分钟更新一次。在 NiFi 中,我们可以设置一个数据流,该数据流将轮询 GDELT 网站,从此列表中获取文件,并将其保存到 HDFS,以便我们以后使用。

在 NiFi 数据流设计师中,通过将处理器拖放到画布上并选择GetHTTP功能来创建一个 HTTP 连接器。

实时发现 GDELT

要配置此处理器,您需要输入文件列表的 URL,如下所示:

data.gdeltproject.org/gdeltv2/lastupdate.txt

还要为您将要下载的文件列表提供一个临时文件名。在下面的示例中,我们使用了 NiFi 的表达式语言来生成一个通用唯一键,以便不会覆盖文件(UUID())。

实时发现 GDELT

值得注意的是,对于这种类型的处理器(GetHTTP方法),NiFi 支持多种调度和定时选项,用于轮询和检索。目前,我们将使用默认选项,让 NiFi 为我们管理轮询间隔。

最新的 GDELT 文件列表示例如下:

实时发现 GDELT

接下来,我们将解析 GKG 新闻流的 URL,以便稍后可以获取它。通过将处理器拖放到画布上并选择ExtractText来创建一个正则表达式解析器。现在,将新处理器放在现有处理器下面,并从顶部处理器向底部处理器拖动一条线。最后,在弹出的连接对话框中选择success关系。

这在以下示例中显示:

实时发现 GDELT

接下来,让我们配置ExtractText处理器,使用一个只匹配文件列表相关文本的正则表达式,例如:

([^ ]*gkg.csv.*) 

从这个正则表达式中,NiFi 将创建一个新的属性(在本例中称为url),与流程设计相关联,每个特定实例通过流程时都会采用新值。它甚至可以配置为支持多个线程。

同样,这个示例如下所示:

实时发现 GDELT

值得注意的是,虽然这是一个相当具体的例子,但这种技术是故意通用的,可以在许多情况下使用。

我们的第一个 GDELT 数据源

现在我们有了 GKG 数据源的 URL,通过配置InvokeHTTP处理器来使用我们之前创建的url属性作为其远程端点,并像以前一样拖动线。

我们的第一个 GDELT 数据源

现在剩下的就是使用UnpackContent处理器(使用基本的.zip格式)解压缩压缩内容,并使用PutHDFS处理器保存到 HDFS,如下所示:

我们的第一个 GDELT 数据源

改进发布和订阅

到目前为止,这个流程看起来非常点对点,这意味着如果我们要引入新的数据消费者,例如 Spark-streaming 作业,流程必须更改。例如,流程设计可能必须更改为如下所示:

改进发布和订阅

如果我们再添加另一个,流程必须再次更改。事实上,每次添加新的消费者时,流程都会变得更加复杂,特别是当所有错误处理都添加进去时。显然,这并不总是可取的,因为引入或移除数据的消费者(或生产者)可能是我们经常甚至频繁想要做的事情。此外,尽可能保持流程简单和可重用也是一个好主意。

因此,为了更灵活的模式,我们可以将数据发布到Apache Kafka,而不是直接写入 HDFS。这使我们能够随时添加和移除消费者,而不必更改数据摄入管道。如果需要,我们还可以从 Kafka 向 HDFS 写入,甚至可以通过设计一个单独的 NiFi 流程,或者直接使用 Spark-streaming 连接到 Kafka。

为了做到这一点,我们通过将处理器拖放到画布上并选择PutKafka来创建一个 Kafka 写入器。

改进发布和订阅

我们现在有了一个简单的流程,不断轮询可用文件列表,定期检索新流的最新副本,解压内容,并将其逐条记录流入 Kafka,这是一个持久的、容错的、分布式消息队列,用于 Spark-streaming 处理或在 HDFS 中存储。而且,这一切都不需要写一行 bash 代码!

内容注册

我们在本章中看到,数据摄入是一个经常被忽视的领域,它的重要性不容小觑。在这一点上,我们有一个管道,使我们能够从源头摄入数据,安排摄入,并将数据定向到我们选择的存储库。但故事并不会在这里结束。现在我们有了数据,我们需要履行我们的数据管理责任。进入内容注册

我们将建立一个与我们摄入的数据相关的元数据索引。数据本身仍将被定向到存储(在我们的示例中是 HDFS),但另外,我们将存储有关数据的元数据,以便我们可以跟踪我们收到的数据,并了解一些基本信息,例如我们何时收到它,它来自哪里,它有多大,它是什么类型,等等。

选择和更多选择

我们使用的存储元数据的技术选择是基于知识和经验的。对于元数据索引,我们至少需要以下属性:

  • 易于搜索

  • 可扩展的

  • 并行写入能力

  • 冗余

有许多满足这些要求的方法,例如我们可以将元数据写入 Parquet,存储在 HDFS 中,并使用 Spark SQL 进行搜索。然而,在这里,我们将使用Elasticsearch,因为它更好地满足了要求,特别是因为它通过 REST API 方便地进行低延迟查询我们的元数据,这对于创建仪表板非常有用。事实上,Elasticsearch 具有与Kibana直接集成的优势,这意味着它可以快速生成我们内容注册的丰富可视化。因此,出于这个原因,我们将考虑使用 Elasticsearch。

随波逐流

使用我们当前的 NiFi 管道流,让我们从“从 URL 获取 GKG 文件”中分叉输出,以添加一组额外的步骤,以允许我们在 Elasticsearch 中捕获和存储这些元数据。这些是:

  1. 用我们的元数据模型替换流内容。

  2. 捕获元数据。

  3. 直接存储在 Elasticsearch 中。

在 NiFi 中的样子如下:

随波逐流

元数据模型

因此,这里的第一步是定义我们的元数据模型。有许多方面可以考虑,但让我们选择一组可以帮助解决之前讨论中的一些关键问题的集合。如果需要,这将为将来进一步添加数据提供一个良好的基础。因此,让我们保持简单,使用以下三个属性:

  • 文件大小

  • 摄取日期

  • 文件名

这些将提供接收文件的基本注册。

接下来,在 NiFi 流程中,我们需要用这个新的元数据模型替换实际的数据内容。一个简单的方法是,从我们的模型中创建一个 JSON 模板文件。我们将它保存到本地磁盘,并在FetchFile处理器中使用它,以用这个骨架对象替换流的内容。这个模板会看起来像这样:

{ 
  "FileSize": SIZE, 
  "FileName": "FILENAME", 
  "IngestedDate": "DATE" 
} 

请注意在属性值的位置上使用了占位符名称(SIZE, FILENAME, DATE)。这些将逐个被一系列ReplaceText处理器替换,这些处理器使用 NiFi 表达式语言提供的正则表达式,例如DATE变成${now()}

最后一步是将新的元数据负载输出到 Elasticsearch。同样,NiFi 已经准备了一个处理器来实现这一点;PutElasticsearch处理器。

Elasticsearch 中的一个元数据条目示例:

{
         "_index": "gkg",
         "_type": "files",
         "_id": "AVZHCvGIV6x-JwdgvCzW",
         "_score": 1,
         "source": {
            "FileSize": 11279827,
            "FileName": "20150218233000.gkg.csv.zip",
            "IngestedDate": "2016-08-01T17:43:00+01:00"
         }

现在我们已经添加了收集和查询元数据的能力,我们现在可以访问更多可用于分析的统计数据。这包括:

  • 基于时间的分析,例如,随时间变化的文件大小

  • 数据丢失,例如,时间轴上是否有数据空缺?

如果需要特定的分析,NIFI 元数据组件可以进行调整以提供相关的数据点。事实上,可以构建一个分析来查看历史数据,并根据需要更新索引,如果当前数据中不存在元数据。

Kibana 仪表板

在本章中我们多次提到了 Kibana。现在我们在 Elasticsearch 中有了元数据索引,我们可以使用该工具来可视化一些分析。这个简短的部分的目的是为了演示我们可以立即开始对数据进行建模和可视化。要查看 Kibana 在更复杂的场景中的使用,请参阅第九章,新闻词典和实时标记系统。在这个简单的示例中,我们完成了以下步骤:

  1. 设置选项卡中添加了我们的 GDELT 元数据的 Elasticsearch 索引。

  2. 发现选项卡下选择文件大小。

  3. 选择可视化以文件大小。

  4. 聚合字段更改为范围

  5. 输入范围的值。

生成的图显示了文件大小的分布:

Kibana 仪表板

从这里开始,我们可以自由地创建新的可视化,甚至是一个功能齐全的仪表板,用于监控我们文件摄取的状态。通过增加从 NiFi 写入 Elasticsearch 的元数据的多样性,我们可以在 Kibana 中提供更多字段,甚至可以从这里开始我们的数据科学之旅,获得一些基于摄取的可操作见解。

现在我们有一个完全运行的数据管道,为我们提供实时数据源,那么我们如何确保接收到的数据质量呢?让我们看看有哪些选项。

质量保证

实施了初始数据摄取能力,并将数据流入平台后,您需要决定“前门”需要多少质量保证。可以完全没有初始质量控制,并随着时间和资源的允许逐渐建立起来(对历史数据进行回顾扫描)。但是,最好从一开始就安装基本的验证。例如,基本检查,如文件完整性、奇偶校验、完整性、校验和、类型检查、字段计数、过期文件、安全字段预填充、去规范化等。

您应该注意,您的前期检查不要花费太长时间。根据您的检查强度和数据的大小,遇到无法在下一个数据集到达之前完成所有处理的情况并不罕见。您始终需要监视您的集群资源,并计算最有效的时间利用方式。

以下是一些粗略容量规划计算的示例:

示例 1 - 基本质量检查,没有竞争用户

  • 数据每 15 分钟摄取一次,从源头拉取需要 1 分钟

  • 质量检查(完整性、字段计数、字段预填充)需要 4 分钟

  • 计算集群上没有其他用户

其他任务有 10 分钟的资源可用。

由于集群上没有其他用户,这是令人满意的-不需要采取任何行动。

示例 2 - 高级质量检查,没有竞争用户

  • 数据每 15 分钟摄取一次,从源头拉取需要 1 分钟

  • 质量检查(完整性、字段计数、字段预填充、去规范化、子数据集构建)需要 13 分钟

  • 计算集群上没有其他用户

其他任务只有 1 分钟的资源可用。

您可能需要考虑:

  • 配置资源调度策略

  • 减少摄取的数据量

  • 减少我们进行的处理量

  • 向集群添加额外的计算资源

示例 3 - 基本质量检查,由于竞争用户,效用度为 50%

  • 数据每 15 分钟摄取一次,从源头拉取需要 1 分钟

  • 质量检查(完整性、字段计数、字段预填充)需要 4 分钟(100%效用)

  • 计算集群上有其他用户

*其他任务有 6 分钟的资源可用(15-1-(4 (100/50)))。由于还有其他用户,存在无法完成处理并出现作业积压的危险。

当遇到时间问题时,您有多种选择可以避免任何积压:

  • 在某些时候协商独占资源的使用权

  • 配置资源调度策略,包括:

  • YARN 公平调度程序:允许您定义具有不同优先级的队列,并通过在启动时设置spark.yarn.queue属性来定位您的 Spark 作业,以便始终优先考虑您的作业

  • 动态资源分配:允许同时运行的作业自动扩展以匹配它们的利用率

  • Spark 调度程序池:允许您在使用多线程模型共享SparkContext时定义队列,并通过设置spark.scheduler.pool属性来定位您的 Spark 作业,以便每个执行线程都优先考虑

  • 在集群安静时过夜运行处理作业

无论如何,您最终会对作业的各个部分的表现有一个很好的了解,然后就能够计算出可以提高效率的改变。在使用云提供商时,总是有增加更多资源的选项,但我们当然鼓励对现有资源进行智能利用-这样更具可扩展性,更便宜,并且建立数据专业知识。

总结

在本章中,我们详细介绍了 Apache NiFi GDELT 摄取管道的完整设置,包括元数据分支和对生成数据的简要介绍。本节非常重要,因为 GDELT 在整本书中被广泛使用,而 NiFi 方法是一种可扩展和模块化的数据来源方法。

在下一章中,我们将学习一旦数据到达后该如何处理数据,包括查看模式和格式。

第三章:输入格式和模式

本章的目的是演示如何将数据从原始格式加载到不同的模式中,从而使得可以对相同的数据运行各种不同类型的下游分析。在编写分析报告,甚至更好的是构建可重用软件库时,通常需要使用固定输入类型的接口。因此,根据目的在不同模式之间灵活转换数据,可以在下游提供相当大的价值,无论是在扩大可能的分析类型还是重复使用现有代码方面。

我们的主要目标是了解伴随 Spark 的数据格式特性,尽管我们也将深入探讨数据管理的细节,介绍能够增强数据处理并提高生产力的成熟方法。毕竟,很可能在某个时候需要正式化您的工作,了解如何避免潜在的长期问题在撰写分析报告时和很久之后都是非常宝贵的。

考虑到这一点,我们将利用本章来研究传统上理解良好的数据模式领域。我们将涵盖传统数据库建模的关键领域,并解释一些这些基石原则如何仍然适用于 Spark。

此外,当我们磨练我们的 Spark 技能时,我们将分析 GDELT 数据模型,并展示如何以高效和可扩展的方式存储这个大型数据集。

我们将涵盖以下主题:

  • 维度建模:与 Spark 相关的优点和缺点

  • 关注 GDELT 模型

  • 揭开按需模式的盖子

  • Avro 对象模型

  • Parquet 存储模型

让我们从一些最佳实践开始。

结构化生活是美好的生活

在了解 Spark 和大数据的好处时,您可能听过关于结构化数据与半结构化数据与非结构化数据的讨论。虽然 Spark 推广使用结构化、半结构化和非结构化数据,但它也为这些数据的一致处理提供了基础。唯一的约束是它应该是基于记录的。只要是基于记录的,数据集就可以以相同的方式进行转换、丰富和操作,而不管它们的组织方式如何。

然而,值得注意的是,拥有非结构化数据并不意味着采取非结构化的方法。在上一章中已经确定了探索数据集的技术,很容易就会有冲动直接将数据存储在可访问的地方,并立即开始简单的分析。在现实生活中,这种活动经常优先于尽职调查。再次,我们鼓励您考虑几个关键领域,例如文件完整性、数据质量、时间表管理、版本管理、安全性等等,在开始这项探索之前。这些都不应被忽视,许多都是非常重要的话题。

因此,虽然我们已经在第二章中涵盖了许多这些问题,数据获取,并且以后还会学习更多,例如在第十三章中,安全数据,但在本章中,我们将专注于数据输入和输出格式,探索一些我们可以采用的方法,以确保更好的数据处理和管理。

GDELT 维度建模

由于我们选择在本书中使用 GDELT 进行分析,我们将首先介绍使用这个数据集的第一个示例。首先,让我们选择一些数据。

有两个可用的数据流:全球知识图谱GKG)和事件

对于本章,我们将使用 GKG 数据来创建一个可以从 Spark SQL 查询的时间序列数据集。这将为我们提供一个很好的起点,以创建一些简单的入门分析。

在接下来的章节中,第四章, 探索性数据分析 和 第五章, 用于地理分析的 Spark,我们将更详细地讨论,但仍然与 GKG 保持联系。然后,在第七章, 构建社区,我们将通过生成自己的人员网络图来探索事件,并在一些酷炫的分析中使用它。

GDELT 模型

GDELT 已经存在了 20 多年,在这段时间里经历了一些重大的修订。为了保持简单,让我们限制我们的数据范围从 2013 年 4 月 1 日开始,当时 GDELT 进行了一次重大的文件结构改革,引入了 GKG 文件。值得注意的是,本章讨论的原则适用于所有版本的 GDELT 数据,但是在此日期之前的特定模式和统一资源标识符URI)可能与描述的不同。我们将使用的版本是 GDELT v2.1,这是撰写时的最新版本。但值得注意的是,这与 GDELT 2.0 只有轻微的不同。

GKG 数据中有两条数据轨道:

  1. 整个知识图,以及它的所有字段。

  2. 包含一组预定义类别的图的子集。

我们将首先查看第一条轨道。

首次查看数据

我们在第二章中讨论了如何下载 GDELT 数据,因此,如果您已经配置了 NiFi 管道来下载 GKG 数据,只需确保它在 HDFS 中可用。但是,如果您还没有完成该章节,我们鼓励您首先这样做,因为它解释了为什么应该采取结构化方法来获取数据。

虽然我们已经竭尽全力阻止临时数据下载的使用,但本章的范围当然是已知的,因此,如果您有兴趣跟随这里看到的示例,可以跳过使用 NiFi 直接获取数据(以便尽快开始)。

如果您希望下载一个样本,这里是在哪里找到 GDELT 2.1 GKG 主文件列表的提醒:

http://data.gdeltproject.org/gdeltv2/masterfilelist.txt

记下与.gkg.csv.zip匹配的最新条目,使用您喜欢的 HTTP 工具进行复制,并将其上传到 HDFS。例如:

wget http://data.gdeltproject.org/gdeltv2/20150218230000.gkg.csv.zip -o log.txt  
unzip 20150218230000.gkg.csv.zip 
hdfs dfs -put 20150218230000.gkg.csv /data/gdelt/gkg/2015/02/21/ 

现在您已经解压了 CSV 文件并将其加载到 HDFS 中,让我们继续并查看数据。

注意

在加载到 HDFS 之前,实际上不需要解压数据。Spark 的TextInputFormat类支持压缩类型,并且会自动解压缩。但是,由于我们在上一章中在 NiFi 管道中解压了内容,为了保持一致性,这里进行了解压缩。

核心全球知识图模型

有一些重要的原则需要理解,这将在长远来看节省时间,无论是在计算还是人力方面。就像许多 CSV 文件一样,这个文件隐藏了一些复杂性,如果在这个阶段不理解清楚,可能会在我们进行大规模分析时成为一个真正的问题。GDELT 文档描述了数据。可以在这里找到:data.gdeltproject.org/documentation/GDELT-Global_Knowledge_Graph_Codebook-V2.1.pdf

它表明每个 CSV 行都是以换行符分隔的,并且结构如图 1所示:

核心全球知识图模型

图 1 GDELT GKG v2.1

乍一看,这似乎是一个不错的简单模型,我们可以简单地查询一个字段并使用其中的数据,就像我们每天导入和导出到 Microsoft Excel 的 CSV 文件一样。然而,如果我们更详细地检查字段,就会清楚地看到一些字段实际上是对外部来源的引用,而另一些字段是扁平化的数据,实际上是由其他表表示的。

隐藏的复杂性

核心 GKG 模型中的扁平化数据结构代表了隐藏的复杂性。例如,查看文档中的 V2GCAM 字段,它概述了这样一个想法,即这是一个包含冒号分隔的键值对的逗号分隔块的系列,这些对表示 GCAM 变量及其相应计数。就像这样:

wc:125,c2.21:4,c10.1:40,v10.1:3.21111111

如果我们参考 GCAM 规范,data.gdeltproject.org/documentation/GCAM-MASTER-CODEBOOK.TXT,我们可以将其翻译为:

隐藏的复杂性

还有其他以相同方式工作的字段,比如V2LocationsV2PersonsV2Organizations等等。那么,这里到底发生了什么?所有这些嵌套结构是什么意思?为什么选择以这种方式表示数据?实际上,事实证明,这是一种方便的方法,可以将维度模型折叠成单行记录,而不会丢失数据或交叉引用。事实上,这是一种经常使用的技术,称为非规范化

非规范化模型

传统上,维度模型是一个包含许多事实和维度表的数据库表结构。它们通常被称为星型或雪花模式,因为它们在实体关系图中的外观。在这样的模型中,事实是一个可以计数或求和的值,通常在给定时间点提供测量。由于它们通常基于交易或重复事件,事实的数量很可能会变得非常庞大。另一方面,维度是信息的逻辑分组,其目的是为了限定或给事实提供背景。它们通常通过分组或聚合来解释事实的入口点。此外,维度可以是分层的,一个维度可以引用另一个维度。我们可以在图 2中看到扩展的 GKG 维度结构的图表。

在我们的 GCAM 示例中,事实是上表中的条目,维度是 GCAM 参考本身。虽然这可能看起来是一个简单的逻辑抽象,但这意味着我们有一个重要的关注点需要仔细考虑:维度建模对于传统数据库非常适用,其中数据可以分割成表 - 在这种情况下,GKG 和 GCAM 表 - 因为这些类型的数据库,本质上是针对这种结构进行了优化。例如,查找值或聚合事实的操作是本地可用的。然而,在使用 Spark 时,我们认为理所当然的一些操作可能非常昂贵。例如,如果我们想要对数百万条条目的所有 GCAM 字段进行平均,那么我们将有一个非常庞大的计算任务。我们将在下图中更详细地讨论这个问题:

非规范化模型

图 2 GDELT GKG 2.1 扩展

扁平化数据的挑战

在探索了 GKG 数据模式之后,我们现在知道,这个分类法是一个典型的星型模式,有一个单一的事实表引用多个维度表。有了这种层次结构,如果我们需要以与传统数据库相同的方式切片和切块数据,我们肯定会遇到困难。

但是,是什么让在 Spark 上处理变得如此困难呢?让我们来看看这种类型组织固有的三个不同问题。

问题 1 - 上下文信息的丢失

首先,数据集中的每条记录中使用的各种数组是一个问题。例如,V1LocationsV1OrganizationsV1Persons字段都包含一个或多个对象的列表。由于我们没有用于获取此信息的原始文本(尽管有时我们可以获取到,如果来源是 WEB、JSTOR 等,因为这些将包含指向源文件的链接),我们失去了实体之间关系的上下文。

例如,如果我们的数据中有[Barack Obama, David Cameron, Francois Hollande, USA, France, GB, Texaco, Esso, Shell],那么我们可以假设这篇文章与一场关于石油危机的国家元首会议有关。然而,这只是一个假设,也许并非如此,如果我们真的客观,我们同样可以假设这篇文章与拥有著名名字的公司有关。

为了帮助我们推断实体之间的关系,我们可以开发一个时间序列模型,它接受一定时间段内 GDELT 字段的所有个体内容,并执行扩展连接。因此,在简单的层面上,那些更常见的对可能更有关联,我们可以开始做一些更具体的假设。例如,如果我们在时间序列中看到[Barack Obama, USA]出现了 10 万次,而[Barack Obama, France]只出现了 5000 次,那么第一对之间很可能存在强关系,而第二对之间存在次要关系。换句话说,我们可以识别脆弱的关系,并在需要时移除它们。这种方法可以被用来在规模上识别明显无关的实体之间的关系。在第七章, 建立社区中,我们使用这个原则来识别一些非常不太可能的人之间的关系!

问题 2:重新建立维度

对于任何非规范化的数据,应该可以重建或膨胀原始的维度模型。考虑到这一点,让我们来看一个有用的 Spark 函数,它将帮助我们扩展数组并产生一个扁平化的结果;它被称为DataFrame.explode,下面是一个说明性的例子:

case class Grouped(locations:Array[String], people:Array[String]) 

val group = Grouped(Array("USA","France","GB"), 
       Array("Barack Obama","David Cameron", "Francois Hollande")) 

val ds = Seq(group).toDS 

ds.show 

+-----------------+--------------------+ 
|        locations|              people| 
+-----------------+--------------------+ 
|[USA, France, GB]|Barack Obama, Da...| 
+-----------------+--------------------+ 

val flatLocs = ds.withColumn("locations",explode($"locations")) 
flatLocs.show 

+---------+--------------------+ 
|Locations|              People| 
+---------+--------------------+ 
|      USA|[Barack Obama, Da...| 
|   France|[Barack Obama, Da...| 
|       GB|[Barack Obama, Da...| 
+---------+--------------------+ 

val flatFolk = flatLocs.withColumn("people",explode($"people")) 
flatFolk.show 

+---------+-----------------+ 
|Locations|           People| 
+---------+-----------------+ 
|      USA|     Barack Obama| 
|      USA|    David Cameron| 
|      USA|Francois Hollande| 
|   France|     Barack Obama| 
|   France|    David Cameron| 
|   France|Francois Hollande| 
|       GB|     Barack Obama| 
|       GB|    David Cameron| 
|       GB|Francois Hollande| 
+---------+-----------------+ 

使用这种方法,我们可以轻松扩展数组,然后执行我们选择的分组。一旦扩展,数据就可以使用DataFrame方法轻松聚合,甚至可以使用 SparkSQL 进行。我们的存储库中的 Zeppelin 笔记本中可以找到一个例子。

重要的是要理解,虽然这个函数很容易实现,但不一定高效,并且可能隐藏所需的底层处理复杂性。事实上,在本章附带的 Zeppelin 笔记本中有一个使用 GKG 数据的 explode 函数的例子,如果 explode 函数的范围不合理,那么函数会因为内存耗尽而返回堆空间问题。

这个函数并不能解决消耗大量系统资源的固有问题,因此在使用时仍需小心。虽然这个一般性问题无法解决,但可以通过仅执行必要的分组和连接,或者提前计算它们并确保它们在可用资源内完成来进行管理。你甚至可能希望编写一个算法,将数据集拆分并按顺序执行分组,每次持久化。我们在[第十四章中探讨了帮助我们解决这个问题以及其他常见处理问题的方法,可扩展算法

问题 3:包含参考数据

对于这个问题,让我们来看一下我们在图 3中扩展的 GDELT 事件数据:

问题 3:包含参考数据

图 3 GDELT 事件分类

这种图解表示方式引起了对数据关系的关注,并表明了我们可能希望如何扩展它。在这里,我们看到许多字段只是代码,需要将其翻译回原始描述,以呈现有意义的内容。例如,为了解释Actor1CountryCode(GDELT 事件),我们需要将事件数据与一个或多个提供翻译文本的单独参考数据集进行连接。在这种情况下,文档告诉我们参考位于这里的 CAMEO 数据集:data.gdeltproject.org/documentation/CAMEO.Manual.1.1b3.pdf

这种类型的连接在数据规模上一直存在严重问题,并且根据给定的情况有各种处理方法-在这个阶段重要的是要准确了解您的数据将如何使用,哪些连接可能需要立即执行,哪些可能推迟到将来的某个时候。

在我们选择在处理之前完全去规范化或展开数据的情况下,提前进行连接是有意义的。在这种情况下,后续的分析肯定会更有效,因为相关的连接已经完成:

因此,在我们的例子中:

wc:125,c2.21:4,c10.1:40,v10.1:3.21111111

对于记录中的每个代码,都要连接到相应的参考表,整个记录变为:

WordCount:125, General_Inquirer_Bodypt:4, SentiWordNet:40, SentiWordNet average: v10.1:3.21111111

这是一个简单的改变,但如果在大量行上执行,会占用大量磁盘空间。权衡的是,连接必须在某个时刻执行,可能是在摄取时或在摄取后作为定期批处理作业;将数据摄取为原样,并在方便用户的时候对数据集进行展开是完全合理的。无论如何,展开的数据可以被任何分析工具使用,数据分析师不需要关注这个潜在的隐藏问题。

另一方面,通常,推迟连接直到处理的后期可能意味着要连接的记录较少-因为可能在管道中有聚合步骤。在这种情况下,尽可能晚地连接到表是值得的,因为通常参考或维度表足够小,可以进行广播连接或映射端连接。由于这是一个如此重要的主题,我们将继续在整本书中探讨不同的处理连接场景的方法。

加载您的数据

正如我们在前几章中所概述的,传统的系统工程通常采用一种模式,将数据从其源移动到其目的地,即 ETL,而 Spark 倾向于依赖于读时模式。由于重要的是理解这些概念与模式和输入格式的关系,让我们更详细地描述这个方面:

加载您的数据

表面上看,ETL 方法似乎是合理的,事实上,几乎每个存储和处理数据的组织都已经实施了这种方法。有一些非常受欢迎、功能丰富的产品非常擅长执行 ETL 任务-更不用说 Apache 的开源产品 Apache Camel 了camel.apache.org/etl-example.html

然而,这种表面上简单的方法掩盖了实施甚至简单数据管道所需的真正努力。这是因为我们必须确保所有数据在使用之前都符合固定的模式。例如,如果我们想要从起始目录摄取一些数据,最小的工作如下:

  1. 确保我们始终关注接送目录。

  2. 数据到达时,收集它。

  3. 确保数据没有遗漏任何内容,并根据预定义的规则集进行验证。

  4. 根据预定义的规则集提取我们感兴趣的数据部分。

  5. 根据预定义的模式转换这些选定的部分。

  6. 使用正确的版本化模式将数据加载到存储库(例如数据库)。

  7. 处理任何失败的记录。

我们立即可以看到这里有一些必须解决的格式问题:

  1. 我们有一个预定义的规则集,因此必须进行版本控制。任何错误都将意味着最终数据库中存在错误数据,并且需要通过 ETL 过程重新摄入数据以进行更正(非常耗时和资源密集)。对入站数据集格式的任何更改都将导致此规则集的更改。

  2. 对目标模式的任何更改都需要非常谨慎的管理。至少,ETL 中需要进行版本控制的更改,甚至可能需要重新处理之前的一些或全部数据(这可能是一个非常耗时和昂贵的回程)。

  3. 对终端存储库的任何更改都将导致至少一个版本控制模式的更改,甚至可能是一个新的 ETL 模块(再次非常耗时和资源密集)。

  4. 不可避免地,会有一些错误数据进入数据库。因此,管理员需要制定规则来监控表的引用完整性,以确保损坏最小化,并安排重新摄入任何损坏的数据。

如果我们现在考虑这些问题,并大幅增加数据的数量、速度、多样性和真实性,很容易看出我们简单的 ETL 系统已经迅速发展成一个几乎无法管理的系统。任何格式、模式和业务规则的更改都将产生负面影响。在某些情况下,甚至可能没有足够的处理器和内存资源来跟上,因为需要进行所有的处理步骤。在所有 ETL 步骤达成一致并就位之前,数据无法被摄入。在大型公司中,可能需要数月时间来达成模式转换的一致意见,然后才能开始任何实施,从而导致大量积压,甚至丢失数据。所有这些都导致了一个难以改变的脆弱系统。

模式敏捷性

为了克服这一点,基于读取的模式鼓励我们转向一个非常简单的原则:在运行时对数据应用模式,而不是在加载时应用模式(即,在摄入时)。换句话说,当数据被读取进行处理时,会对数据应用模式。这在某种程度上简化了 ETL 过程:

模式敏捷性

当然,这并不意味着你完全消除了转换步骤。你只是推迟了验证、应用业务规则、错误处理、确保引用完整性、丰富、聚合和其他膨胀模型的行为,直到你准备使用它的时候。这个想法是,到了这个时候,你应该对数据有更多了解,当然也对你希望使用数据的方式有更多了解。因此,你可以利用对数据的增加了解来提高加载方法的效率。同样,这是一个权衡。你在前期处理成本上节省的部分,可能会在重复处理和潜在的不一致性上损失。然而,持久化、索引、记忆和缓存等技术都可以在这方面提供帮助。正如前一章所述,这个过程通常被称为 ELT,因为处理步骤的顺序发生了逆转。

这种方法的一个好处是,它允许更大的自由度,以便对数据的表示和建模方式做出适当的决策,以满足特定用例的相关特定要求。例如,数据可以以各种方式进行结构化、格式化、存储、压缩或序列化,因此选择最合适的方法是有意义的,考虑到你试图解决的特定问题集。

这种方法提供的最重要的机会之一是你可以选择如何物理布置数据,也就是决定数据存放的目录结构。通常不建议将所有数据存储在单个目录中,因为随着文件数量的增长,底层文件系统需要更长的时间来处理它们。但是,理想情况下,我们希望能够指定最小可能的数据拆分,以满足功能需求并有效地存储和检索所需的数据量。因此,数据应根据所需的分析和预期接收的数据量进行逻辑分组。换句话说,数据可以根据类型、子类型、日期、时间或其他相关属性分成不同的目录,但必须确保没有单个目录承担过重的负担。另一个重要的观点是,一旦数据落地,就可以在以后重新格式化或重新组织,而在 ETL 范式中,这通常更加困难。

此外,ELT 还可以对变更管理版本控制产生意想不到的好处。例如,如果外部因素导致数据架构发生变化,您可以简单地将不同的数据加载到数据存储的新目录中,并使用灵活的模式容忍序列化库,如 Avro 或 Parquet,它们都支持模式演化(我们将在本章后面讨论这些);或者,如果特定作业的结果不尽人意,我们只需要更改该作业的内部,然后重新运行它。这意味着模式更改变成了可以根据每个分析进行管理,而不是根据每个数据源进行管理,变更的影响得到了更好的隔离和管理。

顺便说一句,值得考虑一种混合方法,特别适用于流式使用情况,即在收集和摄取过程中可以进行一些处理,而在运行时可以进行其他处理。关于使用 ETL 或 ELT 的决定并不一定是二元的。Spark 提供了功能,让您控制数据管道。这为您提供了在合适的时候转换或持久化数据的灵活性,而不是采用一刀切的方法。

确定采取哪种方法的最佳方式是从特定数据集的实际日常使用中学习,并相应地调整其处理,随着经验的积累,识别瓶颈和脆弱性。还可能会有公司规定,如病毒扫描或数据安全,这将决定特定的路线。我们将在本章末尾更深入地讨论这一点。

现实检验

与计算机中的大多数事物一样,没有银弹。ELT 和基于读取的模式不会解决所有数据格式化问题,但它们是工具箱中有用的工具,一般来说,优点通常大于缺点。然而,值得注意的是,如果不小心,有时会引入困难的情况。

特别是在复杂数据模型上执行临时分析可能更加复杂(与数据库相比)。例如,在简单情况下,从新闻文章中提取提到的所有城市的名称列表,在 SQL 数据库中,您可以基本上运行select CITY from GKG,而在 Spark 中,您首先需要了解数据模型,解析和验证数据,然后创建相关表并在运行时处理任何错误,有时每次运行查询都要这样做。

再次强调,这是一个权衡。使用 schema-on-read,您失去了内置的数据表示和固定模式的固有知识,但您获得了根据需要应用不同模型或视图的灵活性。像往常一样,Spark 提供了旨在帮助利用这种方法的功能,例如转换、DataFramesSparkSQL和 REPL,当正确使用时,它们允许您最大限度地利用 schema-on-read 的好处。随着我们的学习,我们将进一步了解这一点。

GKG ELT

由于我们的 NiFi 管道将数据原样写入 HDFS,我们可以充分利用 schema-on-read,并立即开始使用它,而无需等待它被处理。如果您想要更加先进,那么您可以以可分割和/或压缩的格式(例如bzip2,Spark 原生支持)加载数据。让我们看一个简单的例子。

注意

HDFS 使用块系统来存储数据。为了以最有效的方式存储和利用数据,HDFS 文件应尽可能可分割。例如,如果使用TextOutputFormat类加载 CSV GDELT 文件,那么大于块大小的文件将被分割成文件大小/块大小的块。部分块不会占据磁盘上的完整块大小。

通过使用DataFrames,我们可以编写 SQL 语句来探索数据,或者使用数据集我们可以链接流畅的方法,但在任何情况下都需要一些初始准备。

好消息是,通常这可以完全由 Spark 完成,因为它支持通过 case 类将数据透明地加载到数据集中,使用Encoders,所以大部分时间您不需要过多地担心内部工作。事实上,当您有一个相对简单的数据模型时,通常定义一个 case 类,将数据映射到它,并使用toDS方法转换为数据集就足够了。然而,在大多数现实世界的场景中,数据模型更复杂,您将需要编写自己的自定义解析器。自定义解析器在数据工程中并不新鲜,但在 schema-on-read 设置中,它们通常需要被数据科学家使用,因为数据的解释是在运行时而不是加载时完成的。以下是我们存储库中可找到的自定义 GKG 解析器的使用示例:


import org.apache.spark.sql.functions._      

val rdd = rawDS map GdeltParser.toCaseClass    
val ds = rdd.toDS()     

// DataFrame-style API 
ds.agg(avg("goldstein")).as("goldstein").show()    

// Dataset-style API 
ds.groupBy(_.eventCode).count().show() 

您可以看到,一旦数据被解析,它可以在各种 Spark API 中使用。

如果您更喜欢使用 SQL,您可以定义自己的模式,注册一个表,并使用 SparkSQL。在任何一种方法中,您都可以根据数据的使用方式选择如何加载数据,从而更灵活地决定您花费时间解析的方面。例如,加载 GKG 的最基本模式是将每个字段都视为字符串,就像这样:

import org.apache.spark.sql.types._ 

val schema = StructType(Array( 
    StructField("GkgRecordId"           , StringType, true), 
    StructField("V21Date"               , StringType, true), 
    StructField("V2SrcCollectionId"     , StringType, true),        
    StructField("V2SrcCmnName"          , StringType, true),  
    StructField("V2DocId"               , StringType, true),  
    StructField("V1Counts"              , StringType, true),  
    StructField("V21Counts"             , StringType, true),  
    StructField("V1Themes"              , StringType, true),  
    StructField("V2Themes"              , StringType, true),  
    StructField("V1Locations"           , StringType, true),  
    StructField("V2Locations"           , StringType, true),  
    StructField("V1Persons"             , StringType, true),  
    StructField("V2Persons"             , StringType, true),  
    StructField("V1Orgs"                , StringType, true),  
    StructField("V2Orgs"                , StringType, true),  
    StructField("V15Tone"               , StringType, true),  
    StructField("V21Dates"              , StringType, true),  
    StructField("V2GCAM"                , StringType, true),  
    StructField("V21ShareImg"           , StringType, true),  
    StructField("V21RelImg"             , StringType, true),  
    StructField("V21SocImage"           , StringType, true), 
    StructField("V21SocVideo"           , StringType, true),  
    StructField("V21Quotations"         , StringType, true),  
    StructField("V21AllNames"           , StringType, true),  
    StructField("V21Amounts"            , StringType, true), 
    StructField("V21TransInfo"          , StringType, true),  
    StructField("V2ExtrasXML"           , StringType, true)   
)) 

val filename="path_to_your_gkg_files"  

val df = spark 
   .read 
   .option("header", "false") 
   .schema(schema) 
   .option("delimiter", "t") 
   .csv(filename) 

df.createOrReplaceTempView("GKG") 

现在你可以执行 SQL 查询,就像这样:

spark.sql("SELECT V2GCAM FROM GKG LIMIT 5").show 
spark.sql("SELECT AVG(GOLDSTEIN) AS GOLDSTEIN FROM GKG WHERE GOLDSTEIN IS NOT NULL").show() 

通过这种方法,您可以立即开始对数据进行概要分析,这对许多数据工程任务都是有用的。当您准备好时,您可以选择 GKG 记录的其他元素进行扩展。我们将在下一章中更多地了解这一点。

一旦你有了一个 DataFrame,你可以通过定义一个 case 类和转换来将其转换为一个 Dataset,就像这样:

val ds = df.as[GdeltEntity] 

位置很重要

值得注意的是,当从 CSV 加载数据时,Spark 的模式匹配完全是位置的。这意味着,当 Spark 根据给定的分隔符对记录进行标记时,它将根据其位置将每个标记分配给模式中的一个字段,即使存在标题。因此,如果在模式定义中省略了一个列,或者由于数据漂移或数据版本化而导致数据集随时间变化,您可能会遇到 Spark 不一定会警告您的错位!

因此,我们建议定期进行基本数据概要和数据质量检查,以减轻这些情况。您可以使用DataFrameStatFunctions中的内置函数来协助处理这些情况。一些示例如下所示:


df.describe("V1Themes").show 

df.stat.freqItems(Array("V2Persons")).show 

df.stat.crosstab("V2Persons","V2Locations").show 

接下来,让我们解释一种很好的方法来给我们的代码加上一些结构,并通过使用 Avro 或 Parquet 来减少编写的代码量。

Avro

我们已经看到了如何轻松地摄取一些数据并使用 Spark 进行分析,而无需任何传统的 ETL 工具。在一个几乎忽略所有模式的环境中工作非常有用,但这在商业世界中并不现实。然而,有一个很好的折中方案,它比 ETL 和无限数据处理都有很大的优势-Avro。

Apache Avro 是一种序列化技术,类似于 Google 的协议缓冲。与许多其他序列化技术一样,Avro 使用模式来描述数据,但其有用性的关键在于它提供了以下功能:

  • 它将模式与数据一起存储。这样可以有效地存储,因为模式只存储一次,位于文件顶部。这也意味着即使原始类文件不再可用,也可以读取数据。

  • 它支持读取时模式和模式演变。这意味着它可以实现不同的模式来读取和写入数据,提供了模式版本控制的优势,而不会带来大量的行政开销,每次我们希望进行数据修改时。

  • 它是与语言无关的。因此,它可以与允许自定义序列化框架的任何工具或技术一起使用。例如,直接写入 Hive 时特别有用。

Avro 将模式与封闭数据一起存储,它是自描述的。因此,我们可以简单地查询 Avro 文件,以获取写入数据的模式,而不是因为没有类而难以读取数据,或者尝试猜测哪个版本的模式适用,或者在最坏的情况下不得不放弃数据。

Avro 还允许以添加更改或附加的形式对模式进行修订,从而可以容纳这些更改,使特定实现向后兼容旧数据。

由于 Avro 以二进制形式表示数据,因此可以更有效地传输和操作。此外,由于其固有的压缩,它在磁盘上占用的空间更少。

基于上述原因,Avro 是一种非常流行的序列化格式,被广泛用于各种技术和终端系统,您无疑会在某个时候使用它。因此,在接下来的几节中,我们将演示读取和写入 Avro 格式数据的两种不同方法。第一种是一种优雅而简单的方法,使用一个名为spark-avro的第三方专门构建的库,第二种是一种底层方法,有助于理解 Avro 的工作原理。

Spark-Avro 方法

为了解决实现 Avro 的复杂性,开发了spark-avro库。这可以像往常一样使用 maven 导入:


<dependency> 
    <groupId>com.databricks</groupId> 
    <artifactId>spark-avro_2.11</artifactId> 
    <version>3.1.0</version> 
</dependency> 

对于这个实现,我们将使用StructType对象创建 Avro 模式,使用RDD转换输入数据,并从中创建一个DataFrame。最后,可以使用spark-avro库将结果以 Avro 格式写入文件。

StructType对象是上面使用的GkgCoreSchema的变体,在第四章中也是如此,探索性数据分析,构造如下:

val GkgSchema = StructType(Array(
   StructField("GkgRecordId", GkgRecordIdStruct, true), 
   StructField("V21Date", LongType, true), 
   StructField("V2SrcCollectionId", StringType, true), 
   StructField("V2SrcCmnName", StringType, true), 
   StructField("V2DocId", StringType, true), 
   StructField("V1Counts", ArrayType(V1CountStruct), true),            
   StructField("V21Counts", ArrayType(V21CountStruct), true),           
   StructField("V1Themes", ArrayType(StringType), true),
   StructField("V2EnhancedThemes",ArrayType(EnhancedThemes),true),    
   StructField("V1Locations", ArrayType(V1LocationStruct), true),         
   StructField("V2Locations", ArrayType(EnhancedLocations), true), 
   StructField("V1Persons", ArrayType(StringType), true), 
   StructField("V2Persons", ArrayType(EnhancedPersonStruct), true),   
   StructField("V1Orgs", ArrayType(StringType), true), 
   StructField("V2Orgs", ArrayType(EnhancedOrgStruct), true),      
   StructField("V1Stone", V1StoneStruct, true), 
   StructField("V21Dates", ArrayType(V21EnhancedDateStruct), true),    
   StructField("V2GCAM", ArrayType(V2GcamStruct), true), 
   StructField("V21ShareImg", StringType, true), 
   StructField("V21RelImg", ArrayType(StringType), true), 
   StructField("V21SocImage", ArrayType(StringType), true), 
   StructField("V21SocVideo", ArrayType(StringType), true), 
   StructField("V21Quotations", ArrayType(QuotationStruct), true), 
   StructField("V21AllNames", ArrayType(V21NameStruct), true), 
   StructField("V21Amounts", ArrayType(V21AmountStruct), true), 
   StructField("V21TransInfo", V21TranslationInfoStruct, true), 
   StructField("V2ExtrasXML", StringType, true) 
 ))

我们已经使用了许多自定义StructTypes,可以为GkgSchema内联指定,但为了便于阅读,我们已经将它们拆分出来。

例如,GkgRecordIdStruct是:

val GkgRecordIdStruct = StructType(Array(
  StructField("Date", LongType),
  StructField("TransLingual", BooleanType),     
  StructField("NumberInBatch";, IntegerType)
))

在使用此模式之前,我们必须首先通过解析输入的 GDELT 数据生成一个RDD

val gdeltRDD = sparkContext.textFile("20160101020000.gkg.csv")

val gdeltRowOfRowsRDD = gdeltRDD.map(_.split("\t"))
   .map(attributes =>
      Row(
       createGkgRecordID(attributes(0)),
       attributes(1).toLong,
       createSourceCollectionIdentifier(attributes(2),
       attributes(3),
       attributes(4),
       createV1Counts(attributes(5),
       createV21Counts(attributes(6),
       .
       .
       .
      )
   ))

在这里,您可以看到许多自定义解析函数,例如createGkgRecordID,它接受原始数据并包含读取和解释每个字段的逻辑。由于 GKG 字段复杂且通常包含嵌套数据结构,我们需要一种将它们嵌入Row中的方法。为了帮助我们,Spark 允许我们将它们视为Rows内部的Rows。因此,我们只需编写返回Row对象的解析函数,如下所示:

def createGkgRecordID(str: String): Row = {
   if (str != "") {
     val split = str.split("-")
     if (split(1).length > 1) {
       Row(split(0).toLong, true, split(1).substring(1).toInt)
     }
     else {
       Row(split(0).toLong, false, split(1).toInt)
     }
   }
   else {
     Row(0L, false, 0)
   }
 }

将代码放在一起,我们可以在几行代码中看到整个解决方案:

import org.apache.spark.sql.types._
import com.databricks.spark.avro._
import org.apache.spark.sql.Row

val df = spark.createDataFrame(gdeltRowOfRowsRDD, GkgSchema)

df.write.avro("/path/to/avro/output")

将 Avro 文件读入DataFrame同样简单:

val avroDF = spark
  .read
  .format("com.databricks.spark.avro")
  .load("/path/to/avro/output")

这为处理 Avro 文件提供了一个简洁的解决方案,但在幕后发生了什么呢?

教学方法

为了解释 Avro 的工作原理,让我们来看一个自定义解决方案。在这种情况下,我们需要做的第一件事是为我们打算摄取的数据版本或版本创建 Avro 模式。

有几种语言的 Avro 实现,包括 Java。这些实现允许您为 Avro 生成绑定,以便您可以高效地序列化和反序列化数据对象。我们将使用一个 maven 插件来帮助我们使用 GKG 模式的 Avro IDL 表示自动编译这些绑定。这些绑定将以 Java 类的形式存在,我们以后可以使用它们来帮助我们构建 Avro 对象。在您的项目中使用以下导入:

<dependency>  
   <groupId>org.apache.avro</groupId>  
   <artifactId>avro</artifactId>  
   <version>1.7.7</version>
</dependency>

<plugin>  
   <groupId>org.apache.avro</groupId>  
   <artifactId>avro-maven-plugin</artifactId>  
   <version>1.7.7</version>  
   <executions>    
      <execution>      
         <phase>generate-sources</phase>      
         <goals>        
            <goal>schema</goal>      
         </goals>      
         <configuration>           
           <sourceDirectory>
           ${project.basedir}/src/main/avro/
           </sourceDirectory>          
           <outputDirectory>
              ${project.basedir}/src/main/java/
           </outputDirectory>         
         </configuration>    
      </execution>  
   </executions>
</plugin>

现在我们可以看一下我们从可用 Avro 类型的子集创建的 Avro IDL 模式:

+----------------+-------------+
|       primitive|      complex|
+----------------+-------------+
|null            |       record|
|Boolean         |         enum|
|int             |        array|
|long            |          map|
|float           |        union|
|double          |        fixed|
|bytes           |             |
|string          |             |
+----------------+-------------+
Avro provides an extensible type system that supports **custom types**. It's also modular and offers namespaces, so that we can add new types and reuse custom types as the schema evolves. In the preceding example, we can see primitive types extensively used, but also custom objects such as `org.io.gzet.gdelt.gkg.v1.Location`.To create Avro files, we can use the following code (full example in our code repository):

val inputFile = new File(“gkg.csv”);

val outputFile = new File(“gkg.avro”);

val userDatumWriter = new

SpecificDatumWriter[Specification](classOf[Specification])

val dataFileWriter = new

DataFileWriter[Specification](userDatumWriter)

dataFileWriter.create(Specification.getClassSchema,outputFile)

对于(line <- Source.fromFile(inputFile).getLines())

dataFileWriter.append(generateAvro(line))

dataFileWriter.close()

def generateAvro(line:String):Specification = {

val values = line.split(“\t”,-1)

如果 values.length == 27){

val specification = Specification.newBuilder()

.setGkgRecordId(createGkgRecordId(values{0}))

.setV21Date(values{1}.toLong)

.setV2SourceCollectionIdentifier(

createSourceCollectionIdentifier(values{2}))

.setV21SourceCommonName(values{3})

.setV2DocumentIdentifier(values{4})

.setV1Counts(createV1CountArray(values{5}))

.setV21Counts(createV21CountArray(values{6}))

.setV1Themes(createV1Themes(values{7}))

创建 V2EnhancedThemes(values{8})

.setV1Locations(createV1LocationsArray(values{9}))

.

.

}

}


The `Specification` object is created for us once we compile our IDL (using the maven plugin). It contains all of the methods required to access the Avro model, for example `setV2EnhancedLocations`. We are then left with creating the functions to parse our GKG data; two examples are shown, as follows:

def createSourceCollectionIdentifier(str:String):SourceCollectionIdentifier = {

str.toInt match {

情况 1 => SourceCollectionIdentifier.WEB

情况 2 => SourceCollectionIdentifier.CITATIONONLY

情况 3 => SourceCollectionIdentifier.CORE

情况 4 => SourceCollectionIdentifier.DTIC

情况 5 => SourceCollectionIdentifier.JSTOR

情况 6 => SourceCollectionIdentifier.NONTEXTUALSOURCE

情况 _ => SourceCollectionIdentifier.WEB

}

}

def createV1LocationsArray(str:String):Array[Location] = {

val counts = str.split(“;”)

计数映射(createV1Location(_))

}


This approach creates the required Avro files, but it is shown here to demonstrate how Avro works. As it stands, this code does not operate in parallel and, therefore, should not be used on big data. If we wanted to parallelize it, we could create a custom `InputFormat`, wrap the raw data into an RDD, and perform the processing on that basis. Fortunately, we don't have to, as `spark-avro` has already done it for us.

何时执行 Avro 转换

为了最好地利用 Avro,接下来,我们需要决定何时最好转换数据。转换为 Avro 是一个相对昂贵的操作,因此应在最有意义的时候进行。再次,这是一个权衡。这一次,它是在灵活的数据模型支持非结构化处理、探索性数据分析、临时查询和结构化类型系统之间。有两个主要选项要考虑:

  1. 尽可能晚地转换:可以在每次作业运行时执行 Avro 转换。这里有一些明显的缺点,所以最好考虑在某个时候持久化 Avro 文件,以避免重新计算。您可以在第一次懒惰地执行此操作,但很快就会变得混乱。更容易的选择是定期对静态数据运行批处理作业。该作业的唯一任务是创建 Avro 数据并将其写回磁盘。这种方法使我们完全控制转换作业的执行时间。在繁忙的环境中,可以安排作业在安静的时期运行,并且可以根据需要分配优先级。缺点是我们需要知道处理需要多长时间,以确保有足够的时间完成。如果处理在下一批数据到达之前没有完成,那么就会积压,并且很难赶上。

  2. 尽早转换:另一种方法是创建一个摄取管道,其中传入的数据在飞行中转换为 Avro(在流式场景中特别有用)。通过这样做,我们有可能接近 ETL 式的场景,因此真的是一个判断,哪种方法最适合当前使用的特定环境。

现在,让我们看一下在 Spark 中广泛使用的相关技术,即 Apache Parquet。

Parquet

Apache Parquet 是专为 Hadoop 生态系统设计的列式存储格式。传统的基于行的存储格式被优化为一次处理一条记录,这意味着它们对于某些类型的工作负载可能会很慢。相反,Parquet 通过列序列化和存储数据,从而允许对存储、压缩、谓词处理和大型数据集的批量顺序访问进行优化-这正是适合 Spark 的工作负载类型!

由于 Parquet 实现了按列数据压缩,因此特别适用于 CSV 数据,特别是低基数字段,与 Avro 相比,文件大小可以大大减小。

+--------------------------+--------------+ 
|                 File Type|          Size| 
+--------------------------+--------------+ 
|20160101020000.gkg.csv    |      20326266| 
|20160101020000.gkg.avro   |      13557119| 
|20160101020000.gkg.parquet|       6567110| 
|20160101020000.gkg.csv.bz2|       4028862| 
+--------------------------+--------------+ 

Parquet 还与 Avro 原生集成。Parquet 采用 Avro 内存表示的数据,并映射到其内部数据类型。然后,它使用 Parquet 列式文件格式将数据序列化到磁盘上。

我们已经看到如何将 Avro 应用于模型,现在我们可以迈出下一步,使用这个 Avro 模型通过 Parquet 格式将数据持久化到磁盘上。再次,我们将展示当前的方法,然后为了演示目的,展示一些更低级别的代码。首先是推荐的方法:

val gdeltAvroDF = spark 
    .read
    .format("com.databricks.spark.avro")
    .load("/path/to/avro/output")

gdeltAvroDF.write.parquet("/path/to/parquet/output")

现在让我们来详细了解 Avro 和 Parquet 之间的关系:

val inputFile = new File("("/path/to/avro/output ")
 val outputFile = new Path("/path/to/parquet/output")

 val schema = Specification.getClassSchema
 val reader =  new GenericDatumReaderIndexedRecord
 val avroFileReader = DataFileReader.openReader(inputFile, reader)

 val parquetWriter =
     new AvroParquetWriterIndexedRecord

 while(avroFileReader.hasNext)  {
     parquetWriter.write(dataFileReader.next())
 }

 dataFileReader.close()
 parquetWriter.close()

与之前一样,低级别的代码相当冗长,尽管它确实提供了对所需各种步骤的一些见解。您可以在我们的存储库中找到完整的代码。

现在我们有一个很好的模型来存储和检索我们的 GKG 数据,它使用 Avro 和 Parquet,并且可以很容易地使用DataFrames来实现。

总结

在本章中,我们已经看到为什么在进行太多的探索工作之前,数据集应该被彻底理解。我们已经讨论了结构化数据和维度建模的细节,特别是关于这如何适用于 GDELT 数据集,并扩展了 GKG 模型以展示其潜在复杂性。

我们已经解释了传统 ETL 和新的基于模式读取 ELT 技术之间的区别,并且已经触及了数据工程师在数据存储、压缩和数据格式方面面临的一些问题,特别是 Avro 和 Parquet 的优势和实现。我们还演示了使用各种 Spark API 来探索数据的几种方法,包括如何在 Spark shell 上使用 SQL 的示例。

我们可以通过提到我们的存储库中的代码将所有内容汇总,并且是一个用于读取原始 GKG 文件的完整模型(如果需要一些数据,请使用 Apache NiFi GDELT 数据摄取管道来自第一章,数据获取)。

在下一章中,我们将深入探讨 GKG 模型,探索用于大规模探索和分析数据的技术。我们将看到如何使用 SQL 开发和丰富我们的 GKG 数据模型,并调查 Apache Zeppelin 笔记本如何提供更丰富的数据科学体验。

第四章:探索性数据分析

在商业环境中进行的探索性数据分析(EDA)通常作为更大的工作的一部分委托,这个工作是按照可行性评估的线索组织和执行的。这种可行性评估的目的,因此也是我们可以称之为扩展 EDA的焦点,是回答关于所检查的数据是否合适并且值得进一步投资的一系列广泛问题。

在这个一般性的任务下,数据调查预计将涵盖可行性的几个方面,包括在生产中使用数据的实际方面,如及时性、质量、复杂性和覆盖范围,以及适合于测试的预期假设。虽然其中一些方面从数据科学的角度来看可能不那么有趣,但这些以数据质量为主导的调查与纯粹的统计洞察力一样重要。特别是当涉及的数据集非常庞大和复杂,以及为数据科学准备数据所需的投资可能是巨大的时候。为了阐明这一点,并使主题更加生动,我们提出了对全球事件、语言和语调全球数据库(GDELT)项目提供的大规模和复杂的全球知识图(GKG)数据源进行探索的方法。

在本章中,我们将创建和解释一个 EDA,同时涵盖以下主题:

  • 理解规划和构建扩展探索性数据分析的问题和设计目标

  • 数据概要分析是什么,举例说明,并且如何围绕连续数据质量监控的技术形成一个通用框架

  • 如何构建一个围绕该方法的通用基于掩码的数据概要分析器

  • 如何将探索性指标存储到标准模式中,以便随时间研究指标的数据漂移,附有示例

  • 如何使用 Apache Zeppelin 笔记本进行快速探索性数据分析工作,以及绘制图表和图形

  • 如何提取和研究 GDELT 中的 GCAM 情感,包括时间序列和时空数据集

  • 如何扩展 Apache Zeppelin 以使用plot.ly库生成自定义图表

问题、原则和规划

在本节中,我们将探讨为什么可能需要进行探索性数据分析,并讨论创建探索性数据分析时的重要考虑因素。

理解探索性数据分析问题

在进行 EDA 项目之前的一个困难问题是:你能给我一个关于你提议的 EDA 成本的估算和分解吗?

我们如何回答这个问题最终塑造了我们的探索性数据分析策略和战术。过去,对这个问题的回答通常是这样开始的:基本上你按列付费……这个经验法则是基于这样的前提:数据探索工作的可迭代单元,这些工作单元驱动了工作量的估算,从而决定了进行探索性数据分析的大致价格。

这个想法有趣的地方在于,工作单元的报价是以需要调查的数据结构而不是需要编写的函数来报价的。其原因很简单。假定数据处理管道的函数已经存在,而不是新的工作,因此所提供的报价实际上是配置新输入数据结构到我们标准的数据处理管道以探索数据的隐含成本。

这种思维方式使我们面临的主要探索性数据分析问题是,探索在规划任务和估算时间方面似乎很难确定。建议的方法是将探索视为配置驱动的任务。这有助于我们更有效地组织和估算工作,同时有助于塑造围绕配置的思维,而不是编写大量临时代码。

配置数据探索的过程也促使我们考虑可能需要的处理模板。我们需要根据我们探索的数据形式进行配置。例如,我们需要为结构化数据、文本数据、图形数据、图像数据、声音数据、时间序列数据和空间数据配置标准的探索流程。一旦我们有了这些模板,我们只需要将输入数据映射到它们,并配置摄入过滤器,以便对数据进行聚焦。

设计原则

为基于 Apache Spark 的 EDA 处理现代化这些想法意味着我们需要设计具有一些通用原则的可配置 EDA 函数和代码:

  • 易重用的函数/特性*:我们需要定义我们的函数以一般方式处理一般数据结构,以便它们产生良好的探索特性,并以最小的配置工作量将它们交付给新数据集

  • 最小化中间数据结构*:我们需要避免大量中间模式,帮助最小化中间配置,并在可能的情况下创建可重复使用的数据结构

  • 数据驱动配置*:在可能的情况下,我们需要生成可以从元数据中生成的配置,以减少手动样板工作

  • 模板化可视化*:从常见输入模式和元数据驱动的通用可重复使用的可视化

最后,虽然这不是一个严格的原则,但我们需要构建灵活的探索工具,以便发现数据结构,而不是依赖于严格预定义的配置。当出现问题时,这有助于我们通过帮助我们反向工程文件内容、编码或文件定义中的潜在错误来解决问题。

探索的一般计划

所有 EDA 工作的早期阶段都不可避免地基于建立数据质量的简单目标。如果我们在这里集中精力,创建一个广泛适用的通用入门计划,那么我们可以制定一般的任务集。

这些任务构成了拟议的 EDA 项目计划的一般形状,如下所示:

  • 准备源工具,获取我们的输入数据集,审查文档等。必要时审查数据的安全性。

  • 获取、解密并在 HDFS 中分阶段存储数据;收集用于规划的非功能性需求(NFRs)。

  • 在文件内容上运行代码点级别的频率报告。

  • 在文件字段中运行缺失数据量的人口统计检查。

  • 运行低粒度格式分析器,检查文件中基数较高的字段。

  • 在文件中对受格式控制字段运行高粒度格式分析器检查。

  • 运行参照完整性检查,必要时。

  • 运行字典检查,验证外部维度。

  • 对数值数据进行基本的数字和统计探索。

  • 对感兴趣的关键数据进行更多基于可视化的探索。

注意

在字符编码术语中,代码点或代码位置是构成代码空间的任何数字值。许多代码点代表单个字符,但它们也可以具有其他含义,例如用于格式化。

准备工作

现在我们有了一个行动的一般计划,在探索数据之前,我们必须首先投资于构建可重复使用的工具,用于进行探索流程的早期单调部分,帮助我们验证数据;然后作为第二步调查 GDELT 的内容。

引入基于掩码的数据分析

快速探索新类型数据的一种简单而有效的方法是利用基于掩码的数据分析。在这种情况下,掩码是将数据项泛化为特征的字符串转换函数,作为掩码集合,其基数将低于研究领域中原始值的基数。

当数据列被总结为掩码频率计数时,通常称为数据概要,它可以快速洞察字符串的常见结构和内容,从而揭示原始数据的编码方式。考虑以下用于探索数据的掩码:

  • 将大写字母翻译为A

  • 将小写字母翻译为a

  • 将数字 0 到 9 翻译为9

乍一看,这似乎是一个非常简单的转换。例如,让我们将此掩码应用于数据的高基数字段,例如 GDELT GKG 文件的V2.1 Source Common Name字段。文档建议它记录了正在研究的新闻文章的来源的常见名称,通常是新闻文章被抓取的网站的名称,我们期望它包含域名,例如nytimes.com

在 Spark 中实施生产解决方案之前,让我们在 Unix 命令行上原型化一个概要工具,以提供一个我们可以在任何地方运行的示例:

$ cat 20150218230000.gkg.csv | gawk -F"\t" '{print $4}' | \ 
  sed "s/[0-9]/9/g; s/[a-z]/a/g; s/[A-Z]/A/g" | sort |    \ 
  uniq -c | sort -r -n | head -20 

 232 aaaa.aaa 
 195 aaaaaaaaaa.aaa 
 186 aaaaaa.aaa 
 182 aaaaaaaa.aaa 
 168 aaaaaaa.aaa 
 167 aaaaaaaaaaaa.aaa 
 167 aaaaa.aaa 
 153 aaaaaaaaaaaaa.aaa 
 147 aaaaaaaaaaa.aaa 
 120 aaaaaaaaaaaaaa.aaa 

输出是在 Source Common Name 列中找到的记录的排序计数,以及正则表达式(regex)生成的掩码。通过查看这个概要数据的结果,应该很清楚该字段包含域名-或者是吗?因为我们只看了最常见的掩码(在这种情况下是前 20 个),也许在排序列表的另一端的长尾部分可能存在潜在的数据质量问题。

我们可以引入一个微妙的改变来提高我们的掩码函数的泛化能力,而不是只看前 20 个掩码,甚至是后 20 个。通过使正则表达式将小写字母的多个相邻出现折叠成一个a字符,掩码的基数可以减少,而不会真正减少我们解释结果的能力。我们可以通过对我们的正则表达式进行微小的改进来原型化这个改进,并希望在一个输出页面上查看所有的掩码:


$ # note: on a mac use gsed, on linux use sed. 
$ hdfs dfs -cat 20150218230000.gkg.csv |                 \ 
  gawk -F"\t" '{print $4}' | sed "s/[0-9]/9/g; s/[A-Z]/A/g; \ 
  s/[a-z]/a/g; s/a*a/a/g"| sort | uniq -c | sort -r -n 

2356 a.a 
 508 a.a.a 
  83 a-a.a 
  58 a99.a 
  36 a999.a 
  24 a-9.a 
  21 99a.a 
  21 9-a.a 
  15 a9.a 
  15 999a.a 
  12 a9a.a 
  11 a99a.a 
   8 a-a.a.a 
   7 9a.a 
   3 a-a-a.a 
   2 AAA Aa     <---note here the pattern that stands out 
   2 9a99a.a 
   2 9a.a.a 
   1 a9.a.a 
   1 a.99a.a 
   1 9a9a.a 
   1 9999a.a 

非常快地,我们原型化了一个掩码,将三千多个原始值缩减为一个非常短的列表,可以轻松地通过眼睛检查。由于长尾现在变得更短了,我们可以很容易地发现这个数据字段中可能的异常值,这些异常值可能代表质量问题或特殊情况。尽管是手动的,但这种类型的检查可能非常有力。

请注意,例如输出中有一个特定的掩码,AAA Aa,其中没有,这与我们在域名中所期望的不符。我们解释这一发现意味着我们发现了两行不是有效域名的原始数据,而可能是一般描述符。也许这是一个错误,或者是所谓的不合逻辑的字段使用的例子,这意味着可能有其他值滑入了这一列,也许应该逻辑上属于其他地方。

这值得调查,而且很容易检查这两条记录。我们可以通过在原始数据旁边生成掩码,然后过滤掉有问题的掩码来定位原始字符串,以进行手动检查。

我们可以使用一个名为bytefreq字节频率的缩写)的传统数据概要工具来检查这些记录,而不是在命令行上编写一个非常长的一行代码。它有开关来生成格式化报告、数据库准备的指标,还有一个开关来输出掩码和数据并排。我们已经为本书的读者开源了bytefreq,建议您尝试一下,以真正理解这种技术有多有用:bitbucket.org/bytesumo/bytefreq

$ # here is a Low Granularity report from bytefreq
$ hdfs dfs –cat 20150218230000.gkg.csv |         \
gawk -F"\t" '{print $4}' | awk -F"," –f        \ ~/bytefreq/bytefreq_v1.04.awk -v header="0" -v report="0"  \
  -v grain="L"

-  ##column_100000001  2356  a.a    sfgate.com
-  ##column_100000001  508  a.a.a    theaustralian.com.au
-  ##column_100000001  109  a9.a    france24.com
-  ##column_100000001  83  a-a.a    news-gazette.com
-  ##column_100000001  44  9a.a    927thevan.com
-  ##column_100000001  24  a-9.a    abc-7.com
-  ##column_100000001  23  a9a.a    abc10up.com
-  ##column_100000001  21  9-a.a    4-traders.com
-  ##column_100000001  8  a-a.a.a  gazette-news.co.uk
-  ##column_100000001  3  9a9a.a    8points9seconds.com
-  ##column_100000001  3  a-a-a.a  the-american-interest.com
-  ##column_100000001  2  9a.a.a    9news.com.au
-  ##column_100000001  2  A Aa    BBC Monitoring
-  ##column_100000001  1  a.9a.a    vancouver.24hrs.ca
-  ##column_100000001  1  a9.a.a    guide2.co.nz

$ hdfs dfs -cat 20150218230000.gkg.csv | gawk                  \
-F"\t" '{print $4}'|gawk -F"," -f ~/bytefreq/bytefreq_v1.04.awk\
-v header="0" -v report="2" -v grain="L" | grep ",A Aa"

BBC Monitoring,A Aa
BBC Monitoring,A Aa

当我们检查奇怪的掩码A Aa时,我们可以看到找到的有问题的文本是BBC Monitoring,在重新阅读 GDELT 文档时,我们会发现这不是一个错误,而是一个已知的特殊情况。这意味着在使用这个字段时,我们必须记住处理这个特殊情况。处理它的一种方法可能是包括一个更正规则,将这个字符串值替换为一个更好的值,例如有效的域名www.monitor.bbc.co.uk,这是文本字符串所指的数据源。

我们在这里介绍的想法是,掩码可以用作检索特定字段中有问题记录的关键。这种逻辑引导我们到基于掩码的分析的下一个主要好处:输出的掩码是一种数据质量错误代码。这些错误代码可以分为两类:掩码的白名单,和用于查找低质量数据的掩码的黑名单。这样考虑,掩码就成为搜索和检索数据清理方法的基础,或者用于发出警报或拒绝记录。

这个教训是,我们可以创建处理函数来纠正使用特定掩码计算在特定字段数据上找到的原始字符串。这种思路导致了以下结论:我们可以创建一个围绕基于掩码的分析框架,用于在数据读取管道中进行数据质量控制和纠正。这具有一些非常有利的解决方案特性:

  • 生成数据质量掩码是一个读取过程;我们可以接受新的原始数据并将其写入磁盘,然后在读取时,我们只在查询时需要时生成掩码 - 因此数据清理可以是一个动态过程。

  • 处理函数可以动态应用于目标纠正工作,帮助在读取数据时清理我们的数据。

  • 因为以前未见过的字符串被概括为掩码,即使从未见过这个确切的字符串,新字符串也可以被标记为存在质量问题。这种普遍性帮助我们减少复杂性,简化我们的流程,并创建可重用的智能解决方案 - 即使跨学科领域也是如此。

  • 创建掩码的数据项如果不属于掩码白名单、修复列表或黑名单,可能会被隔离以供关注;人类分析师可以检查记录,并希望将它们列入白名单,或者创建新的处理函数,帮助将数据从隔离状态中取出并重新投入生产。

  • 数据隔离可以简单地作为一个读取过滤器实施,当新的纠正函数被创建来清理或修复数据时,读取时自动应用的动态处理将自动将更正后的数据释放给用户,而不会有长时间的延迟。

  • 最终将创建一个随时间稳定的数据质量处理库。新的工作主要是通过将现有处理映射并应用到新数据上来完成的。例如,电话号码重新格式化处理函数可以在许多数据集和项目中广泛重复使用。

现在解释了方法和架构的好处,构建一个通用的基于掩码的分析器的要求应该更清晰了。请注意,掩码生成过程是一个经典的 Hadoop MapReduce 过程:将输入数据映射到掩码,然后将这些掩码减少到总结的频率计数。还要注意的是,即使在这个简短的例子中,我们已经使用了两种类型的掩码,每种掩码都由一系列基础转换组成。这表明我们需要一个支持预定义掩码库的工具,同时也允许用户定义的掩码可以快速创建和按需使用。它还表明应该有方法来堆叠这些掩码,将它们组合成复杂的管道。

也许还不那么明显的是,以这种方式进行的所有数据概要都可以将概要度量写入一个通用输出格式。这有助于通过简化概要数据的记录、存储、检索和使用来提高我们代码的可重用性。

例如,我们应该能够使用以下模式报告所有基于掩码的概要度量:

Metric Descriptor 
Source Studied 
IngestTime 
MaskType 
FieldName 
Occurrence Count 
KeyCount   
MaskCount 
Description 

一旦我们的度量被捕获在这个单一的模式格式中,我们就可以使用用户界面(如 Zeppelin 笔记本)构建辅助报告。

在我们逐步实现这些函数之前,需要介绍一下字符类掩码,因为这些与普通的概要掩码略有不同。

引入字符类掩码

还有一种简单的数据概要类型,我们也可以应用它来帮助文件检查。它涉及对构成整个文件的实际字节进行概要。这是一种古老的方法,最初来自密码学,其中对文本中字母的频率进行分析用于在解密替换代码时获得优势。

虽然在今天的数据科学圈中并不常见,但在需要时,字节级分析是令人惊讶地有用。过去,数据编码是一个巨大的问题。文件以一系列代码页编码,跨 ASCII 和 EBCDIC 标准。字节频率报告通常是发现实际编码、分隔符和文件中使用的行结束的关键。那时,能够创建文件但在技术上无法描述它们的人数是令人惊讶的。如今,随着世界越来越多地转向基于 Unicode 的字符编码,这些古老的方法需要更新。在 Unicode 中,字节的概念被现代化为多字节代码点,可以使用以下函数在 Scala 中揭示。

val tst = "Andrew "

def toCodePointVector(input: String) = input.map{
    case (i) if i > 65535 =>
        val hchar = (i - 0x10000) / 0x400 + 0xD800
        val lchar = (i - 0x10000) % 0x400 + 0xDC00
        f"\\u$hchar%04x\\u$lchar%04x"
    case (i) if i > 0 => f"\\u$i%04x"
    // kudos to Ben Reich: http://k.bytefreq.com/1MjyvNz
    }

val out = toCodePointVector(tst)

val rows = sc.parallelize(out)
rows.countByValue().foreach(println)

// results in the following: [codepoint], [Frequency_count]
(\u0065,1)
(\u03d6,1)
(\u006e,1)
(\u0072,1)
(\u0077,1)
(\u0041,1)
(\u0020,2)
(\u6f22,1)
(\u0064,1)
(\u5b57,1)

利用这个函数,我们可以开始对我们在 GDELT 数据集中收到的任何国际字符级数据进行分析,并开始了解我们在利用数据时可能面临的复杂性。但是,与其他掩码不同,为了从代码点创建可解释的结果,我们需要一个字典,我们可以用它来查找有意义的上下文信息,比如 Unicode 类别和 Unicode 字符名称。

为了生成一个上下文查找,我们可以使用这个快速的命令行技巧从主要的unicode.org找到的字典中生成一个缩小的字典,这应该有助于我们更好地报告我们的发现:

$ wget ftp://ftp.unicode.org/Public/UNIDATA/UnicodeData.txt      
$ cat UnicodeData.txt | gawk -F";" '{OFS=";"} {print $1,$3,$2}' \ 
  | sed 's/-/ /g'| gawk '{print $1,$2}'| gawk -F";" '{OFS="\t"} \ 
  length($1) < 5 {print $1,$2,$3}' > codepoints.txt 

# use "hdfs dfs -put" to load codepoints.txt to hdfs, so  
# you can use it later 

head -1300 codepoints.txt | tail -4 
0513      Ll    CYRILLIC SMALL 
0514      Lu    CYRILLIC CAPITAL 
0515      Ll    CYRILLIC SMALL 
0516      Lu    CYRILLIC CAPITAL 

我们将使用这个字典,与我们发现的代码点结合起来,报告文件中每个字节的字符类频率。虽然这似乎是一种简单的分析形式,但结果往往会令人惊讶,并提供对我们处理的数据、其来源以及我们可以成功应用的算法和方法类型的法医级别的理解。我们还将查找一般的 Unicode 类别,以简化我们的报告,使用以下查找表:

Cc  Other, Control 
Cf  Other, Format 
Cn  Other, Not Assigned 
Co  Other, Private Use 
Cs  Other, Surrogate 
LC  Letter, Cased 
Ll  Letter, Lowercase 
Lm  Letter, Modifier 
Lo  Letter, Other 
Lt  Letter, Titlecase 
Lu  Letter, Uppercase 
Mc  Mark, Spacing Combining 
Me  Mark, Enclosing 
Mn  Mark, Nonspacing 
Nd  Number, Decimal Digit 
Nl  Number, Letter 
No  Number, Other 
Pc  Punctuation, Connector 
Pd  Punctuation, Dash 
Pe  Punctuation, Close 
Pf  Punctuation, Final quote 
Pi  Punctuation, Initial quote 
Po  Punctuation, Other 
Ps  Punctuation, Open 
Sc  Symbol, Currency 
Sk  Symbol, Modifier 
Sm  Symbol, Math 
So  Symbol, Other 
Zl  Separator, Line 
Zp  Separator, Paragraph 
Zs  Separator, Space 

构建基于掩码的概要度量

让我们通过创建一个基于笔记本的工具包来逐步分析 Spark 中的数据。我们将实现的掩码函数在几个细节粒度上设置,从文件级别到行级别,然后到字段级别:

  1. 应用于整个文件的字符级掩码是:
  • Unicode 频率,UTF-16 多字节表示(也称为代码点),在文件级别

  • UTF 字符类频率,文件级别

  • 分隔符频率,行级别

  1. 应用于文件中字段的字符串级掩码是:
  • ASCII 低粒度概要,每个字段

  • ASCII 高粒度概要,每个字段

  • 人口检查,每个字段

设置 Apache Zeppelin

由于我们将要通过可视化方式探索我们的数据,一个非常有用的产品是 Apache Zeppelin,它可以非常方便地混合和匹配技术。Apache Zeppelin 是 Apache 孵化器产品,使我们能够创建一个包含多种不同语言的笔记本或工作表,包括 Python、Scala、SQL 和 Bash,这使其非常适合使用 Spark 进行探索性数据分析。

代码以笔记本风格编写,使用段落(或单元格),其中每个单元格可以独立执行,这样可以轻松地处理小段代码,而无需反复编译和运行整个程序。它还作为生成任何给定输出所使用的代码的记录,并帮助我们集成可视化。

Zeppelin 可以快速安装和运行,最小安装过程如下所述:

  • 从这里下载并提取 Zeppelin:zeppelin.incubator.apache.org/download.html

  • 找到 conf 目录并复制zeppelin-env.sh.template,命名为zeppelin-env.sh

  • 修改zeppelin-env.sh文件,取消注释并设置JAVA_HOMESPARK_HOME条目为您机器上的相关位置。

  • 如果您希望 Zeppelin 在 Spark 中使用 HDFS,请将HADOOP_CONF_DIR条目设置为您的 Hadoop 文件的位置;hdfs-site.xmlcore-site.xml等。

  • 启动 Zeppelin 服务:bin/zeppelin-daemon.sh start。这将自动获取conf/zeppelin-env.sh中所做的更改。

在我们的测试集群上,我们使用的是 Hortonworks HDP 2.6,Zeppelin 作为安装的一部分。

在使用 Zeppelin 时需要注意的一点是,第一段应始终声明外部包。任何 Spark 依赖项都可以使用ZeppelinContext以这种方式添加,以便在 Zeppelin 中的每次解释器重新启动后立即运行;例如:

%dep
z.reset
// z.load("groupId>:artifactId:version")

之后,我们可以在任何可用的语言中编写代码。我们将通过声明每个单元格的解释器类型(%spark%sql%shell)在笔记本中使用 Scala,SQL 和 Bash 的混合。如果没有给出解释器,Zeppelin 默认为 Scala Spark(%spark)。

您可以在我们的代码库中找到与本章配套的 Zeppelin 笔记本,以及其他笔记本。

构建可重用的笔记本

在我们的代码库中,我们创建了一个简单、可扩展、开源的数据分析库,也可以在这里找到:bytesumo@bitbucket.org/gzet_io/profilers.git

该库负责应用掩码到数据框架所需的框架,包括将文件的原始行转换为仅有一列的数据框架的特殊情况。我们不会逐行介绍该框架的所有细节,但最感兴趣的类在文件MaskBasedProfiler.scala中找到,该文件还包含每个可用掩码函数的定义。

使用此库的一个很好的方法是构建一个用户友好的笔记本应用程序,允许对数据进行可视化探索。我们已经为使用 Apache Zeppelin 进行分析准备了这样的笔记本。接下来,我们将演示如何使用前面的部分构建我们自己的笔记本。我们示例中的数据是 GDELT event文件,格式为简单的制表符分隔。

构建笔记本的第一步(甚至只是玩弄我们准备好的笔记本)是将profilers-1.0.0.jar文件从我们的库复制到集群上 Zeppelin 用户可以访问的本地目录中,对于 Hortonworks 安装来说,这是 Namenode 上 Zeppelin 用户的主目录。

git clone https://bytesumo@bitbucket.org/gzet_io/profilers.git 
sudo cp profilers-1.0.0.jar /home/zeppelin/. 
sudo ls /home/zeppelin/  

然后我们可以访问http://{main.install.hostname}:9995来访问 Apache Zeppelin 主页。从该页面,我们可以上传我们的笔记本并跟随,或者我们可以创建一个新的笔记本,并通过单击创建新笔记来构建我们自己的笔记本。

在 Zeppelin 中,笔记本的第一段是我们执行 Spark 代码依赖关系的地方。我们将导入稍后需要的分析器 jar 包:

%dep 
// you need to put the profiler jar into a directory 
// that Zeppelin has access to.  
// For example, /home/zeppelin, a non-hdfs directory on  
// the namenode. 
z.load("/home/zeppelin/profilers-1.0.0.jar") 
// you may need to restart your interpreter, then run  
// this paragraph 

在第二段中,我们包括一个小的 shell 脚本来检查我们想要分析的文件,以验证我们是否选择了正确的文件。请注意columncolrm的使用,它们都是非常方便的 Unix 命令,用于在命令行上检查列式表数据:

%sh
# list the first two files in the directory, make sure the header file exists
# note - a great trick is to write just the headers to a delimited file
# that sorts to the top of your file glob, a trick that works well with
# Spark’s csv reader where headers are not on each file you
# hold in hdfs.
# this is a quick inspection check, see we use column and
# colrm to format it:

hdfs dfs -cat "/user/feeds/gdelt/events/*.export.CSV" \
|head -4|column -t -s $'\t'|colrm 68

GlobalEventID  Day       MonthYear  Year  FractionDate  Actor1Code
610182939      20151221  201512     2015  2015.9616              
610182940      20151221  201512     2015  2015.9616              
610182941      20151221  201512     2015  2015.9616     CAN 

在第 3、4、5 和 6 段中,我们使用 Zeppelin 的用户输入框功能,允许用户配置 EDA 笔记本,就像它是一个真正的基于 Web 的应用程序一样。这允许用户配置四个变量,可以在笔记本中重复使用,以驱动进一步的调查:YourMaskYourDelimiterYourFilePathYourHeaders。当我们隐藏编辑器并调整窗口的对齐和大小时,这看起来很棒:

构建可重用的笔记本

如果我们打开准备好的笔记本并点击任何这些输入段落上的显示编辑器,我们将看到我们如何设置它们以在 Zeppelin 中提供下拉框,例如:

val YourHeader = z.select("YourHeaders", Seq(  ("true", "HasHeader"), ("false", "No Header"))).toString 

接下来,我们有一个用于导入我们需要的函数的段落:

import io.gzet.profilers._ 
import sys.process._ 
import org.apache.spark.sql.SQLContext 
import org.apache.spark.sql.functions.udf 
import org.apache.spark.sql.types.{StructType, StructField, StringType, IntegerType} 
import org.apache.spark.sql.SaveMode 
import sqlContext.implicits._ 

然后我们继续到一个新的段落,配置和导入我们读取的数据:

val InputFilePath = YourFilePath    
// set our input to user's file glob 
val RawData = sqlContext.read                       
// read in tabular data 
        .option("header", YourHeader)               
// configurable headers 
        .option("delimiter", YourDelimiter )        
// configurable delimiters 
        .option("nullValue", "NULL")                
// set a default char if nulls seen 
        .option("treatEmptyValuesAsNulls", "true")  
// set to null  
        .option("inferschema", "false")             
// do not infer schema, we'll discover it 
        .csv(InputFilePath)                         
// file glob path. Can use wildcards 
RawData.registerTempTable("RawData")                
// register data for Spark SQL access to it 
RawData.cache()                                     
// cache the file for use 
val RawLines = sc.textFile(InputFilePath)           
// read the file lines as a string 
RawLines.toDF.registerTempTable("RawLines")      
// useful to check for schema corruption 
RawData.printSchema()                               
// print out the schema we found 

// define our profiler apps 
val ASCIICLASS_HIGHGRAIN    = MaskBasedProfiler(PredefinedMasks.ASCIICLASS_HIGHGRAIN) 
val CLASS_FREQS             = MaskBasedProfiler(PredefinedMasks.CLASS_FREQS) 
val UNICODE                 = MaskBasedProfiler(PredefinedMasks.UNICODE) 
val HEX                     = MaskBasedProfiler(PredefinedMasks.HEX) 
val ASCIICLASS_LOWGRAIN     = MaskBasedProfiler(PredefinedMasks.ASCIICLASS_LOWGRAIN) 
val POPCHECKS               = MaskBasedProfiler(PredefinedMasks.POPCHECKS) 

// configure our profiler apps 
val Metrics_ASCIICLASS_HIGHGRAIN    = ASCIICLASS_HIGHGRAIN.profile(YourFilePath, RawData)        
val Metrics_CLASS_FREQS             = CLASS_FREQS.profile(YourFilePath, RawLines.toDF)            
val Metrics_UNICODE                 = UNICODE.profile(YourFilePath, RawLines.toDF)               
val Metrics_HEX                     = HEX.profile(YourFilePath, RawLines.toDF)                   
val Metrics_ASCIICLASS_LOWGRAIN     = ASCIICLASS_LOWGRAIN.profile(YourFilePath, RawData)         
val Metrics_POPCHECKS               = POPCHECKS.profile(YourFilePath, RawData)

// note some of the above read tabular data, some read rawlines of string data

// now register the profiler output as sql accessible data frames

Metrics_ASCIICLASS_HIGHGRAIN.toDF.registerTempTable("Metrics_ASCIICLASS_HIGHGRAIN")
Metrics_CLASS_FREQS.toDF.registerTempTable("Metrics_CLASS_FREQS") 
Metrics_UNICODE.toDF.registerTempTable("Metrics_UNICODE") 
Metrics_HEX.toDF.registerTempTable("Metrics_HEX") 
Metrics_ASCIICLASS_LOWGRAIN.toDF.registerTempTable("Metrics_ASCIICLASS_LOWGRAIN") 
Metrics_POPCHECKS.toDF.registerTempTable("Metrics_POPCHECKS") 

现在我们已经完成了配置步骤,我们可以开始检查我们的表格数据,并发现我们报告的列名是否与我们的输入数据匹配。在一个新的段落窗口中,我们使用 SQL 上下文来简化调用 SparkSQL 并运行查询:

%sql 
select * from RawData 
limit 10 

Zeppelin 的一个很棒的地方是,输出被格式化为一个合适的 HTML 表,我们可以轻松地用它来检查具有许多列的宽文件(例如 GDELT 事件文件):

构建可重用的笔记本

我们可以从显示的数据中看到,我们的列与输入数据匹配;因此我们可以继续进行分析。

注意

如果您希望读取 GDELT 事件文件,您可以在我们的代码存储库中找到头文件。

如果此时列与内容之间的数据对齐存在错误,还可以选择之前配置的 RawLines Dataframe 的前 10 行,它将仅显示原始字符串数据输入的前 10 行。如果数据恰好是制表符分隔的,我们将立即看到另一个好处,即 Zeppelin 格式化输出将自动对齐原始字符串的列,就像我们之前使用 bash 命令column那样。

现在我们将继续研究文件的字节,以发现其中的编码细节。为此,我们加载我们的查找表,然后将它们与我们之前注册为表的分析器函数的输出进行连接。请注意,分析器的输出可以直接作为可调用的 SQL 表处理:

// load the UTF lookup tables

 val codePointsSchema = StructType(Array(
     StructField("CodePoint"  , StringType, true),     //$1       
     StructField("Category"   , StringType, true),     //$2     
     StructField("CodeDesc"   , StringType, true)      //$3
     ))

 val UnicodeCatSchema = StructType(Array(
     StructField("Category"         , StringType, true), //$1       
     StructField("Description"      , StringType, true)  //$2     
     ))

 val codePoints = sqlContext.read
     .option("header", "false")     // configurable headers
     .schema(codePointsSchema)
     .option("delimiter", "\t" )   // configurable delimiters
     .csv("/user/feeds/ref/codepoints2.txt")  // configurable path

 codePoints.registerTempTable("codepoints")
 codePoints.cache()
 val utfcats = sqlContext.read
      .option("header", "false")    // configurable headers
      .schema(UnicodeCatSchema)
      .option("delimiter", "\t" )   // configurable delimiters
      .csv("/user/feeds/ref/UnicodeCategory.txt")                   

 utfcats.registerTempTable("utfcats")
 utfcats.cache()

 // Next we build the different presentation layer views for the codepoints
 val hexReport = sqlContext.sql("""
 select
   r.Category
 , r.CodeDesc
 , sum(maskCount) as maskCount
 from
     ( select
              h.*
             ,c.*
         from Metrics_HEX h
         left outer join codepoints c
             on ( upper(h.MaskType) = c.CodePoint)
     ) r 
 group by r.Category, r.CodeDesc
 order by r.Category, r.CodeDesc, 2 DESC
 """)
 hexReport.registerTempTable("hexReport")
 hexReport.cache()
 hexReport.show(10)
 +--------+-----------------+---------+
 |Category|         CodeDesc|maskCount|
 +--------+-----------------+---------+
 |      Cc|  CTRL: CHARACTER|   141120|
 |      Ll|      LATIN SMALL|   266070|
 |      Lu|    LATIN CAPITAL|   115728|
 |      Nd|      DIGIT EIGHT|    18934|
 |      Nd|       DIGIT FIVE|    24389|
 |      Nd|       DIGIT FOUR|    24106|
 |      Nd|       DIGIT NINE|    17204|
 |      Nd|        DIGIT ONE|    61165|
 |      Nd|      DIGIT SEVEN|    16497|
 |      Nd|        DIGIT SIX|    31706|
 +--------+-----------------+---------+

在新的段落中,我们可以使用 SQLContext 来可视化输出。为了帮助查看偏斜的值,我们可以使用 SQL 语句来计算计数的对数。这将产生一个图形,我们可以在最终报告中包含,我们可以在原始频率和对数频率之间切换。

构建可重用的笔记本

因为我们已经加载了字符类别,我们还可以调整可视化以进一步简化图表:

构建可重用的笔记本

在进行 EDA 时,我们必须始终运行的基本检查是人口普查,我们使用 POPCHECKS 进行计算。 POPCHECKS 是我们在 Scala 代码中定义的特殊掩码,如果字段有值则返回1,如果没有则返回0。当我们检查结果时,我们注意到我们需要进行一些最终报告写作,以更直接地解释数字:

Metrics_POPCHECKS.toDF.show(1000, false)  

构建可重用的笔记本

我们可以分两步来做。首先,我们可以使用 SQL case 表达式将数据转换为populatedmissing的值,这应该有所帮助。然后,我们可以通过对文件名、metricDescriptorfieldname进行groupby并对已填充和缺失的值进行求和来旋转这个聚合数据集。当我们这样做时,我们还可以在分析器没有找到任何数据被填充或缺失的情况下包括默认值为零。在计算百分比时,这一点很重要,以确保我们从不会有空的分子或分母。虽然这段代码可能不像它本来可以那样简短,但它演示了在SparkSQL中操作数据的一些技术。

还要注意,在SparkSQL中,我们可以使用 SQL coalesce语句,这与 Spark 本机的coalesce功能不同,用于操作 RDD。在 SQL 中,此函数将 null 转换为默认值,并且通常被滥用以捕获生产级代码中数据不太可信的特殊情况。还值得注意的是,在SparkSQL中很好地支持子选择。您甚至可以大量使用这些,Spark 不会抱怨。这特别有用,因为它们是许多传统数据库工程师以及有各种数据库经验的人编程的最自然方式:

val pop_qry = sqlContext.sql("""
select * from (
    select
          fieldName as rawFieldName
    ,    coalesce( cast(regexp_replace(fieldName, "C", "") as INT), fieldName) as fieldName
    ,   case when maskType = 0 then "Populated"
             when maskType = 1 then "Missing"
        end as PopulationCheck
    ,     coalesce(maskCount, 0) as maskCount
    ,   metricDescriptor as fileName
    from Metrics_POPCHECKS
) x
order by fieldName
""")
val pivot_popquery = pop_qry.groupBy("fileName","fieldName").pivot("PopulationCheck").sum("maskCount")
 pivot_popquery.registerTempTable("pivot_popquery")
 val per_pivot_popquery = sqlContext.sql("""
 Select 
 x.* 
 , round(Missing/(Missing + Populated)*100,2) as PercentMissing
 from
     (select 
         fieldname
         , coalesce(Missing, 0) as Missing
         , coalesce(Populated,0) as Populated
         , fileName
     from pivot_popquery) x
 order by x.fieldname ASC
 """)
 per_pivot_popquery.registerTempTable("per_pivot_popquery")
 per_pivot_popquery.select("fieldname","Missing","Populated","PercentMissing","fileName").show(1000,false)

上述代码的输出是关于数据的字段级填充计数的干净报告表:

构建可重用的笔记本

当在我们的 Zeppelin 笔记本中以stacked条形图功能进行图形显示时,数据产生了出色的可视化效果,立即告诉我们文件中数据填充的水平:

构建可重用的笔记本

由于 Zeppelin 的条形图支持工具提示,我们可以使用指针来观察列的全名,即使它们在默认视图中显示不佳。

最后,我们还可以在我们的笔记本中包含进一步的段落,以显示先前解释的ASCII_HighGrainASCII_LowGrain掩码的结果。这可以通过简单地将分析器输出作为表格查看,也可以使用 Zeppelin 中的更高级功能来完成。作为表格,我们可以尝试以下操作:

val proReport = sqlContext.sql("""
 select * from (
 select 
      metricDescriptor as sourceStudied
 ,   "ASCII_LOWGRAIN" as metricDescriptor
 , coalesce(cast(  regexp_replace(fieldName, "C", "") as INT),fieldname) as fieldName
 , ingestTime
 , maskType as maskInstance
 , maskCount
 , description
 from Metrics_ASCIICLASS_LOWGRAIN 
 ) x
 order by fieldNAme, maskCount DESC
 """)
 proReport.show(1000, false)

构建可重用的笔记本

为了构建一个交互式查看器,当我们查看可能具有非常高基数的 ASCII_HighGrain 掩码时,我们可以设置一个 SQL 语句,接受 Zeppelin 用户输入框的值,用户可以在其中键入列号或字段名,以检索我们收集的指标的相关部分。

我们可以在新的 SQL 段落中这样做,SQL 谓词为x.fieldName like '%${ColumnName}%'

%sql
 select x.* from (
 select 
      metricDescriptor as sourceStudied
 ,   "ASCII_HIGHGRAIN" as metricDescriptor
 , coalesce(cast(  regexp_replace(fieldName, "C", "")
   as INT),fieldname) as fieldName
 , ingestTime
 , maskType as maskInstance
 , maskCount
 , log(maskCount) as log_maskCount
 from Metrics_ASCIICLASS_HIGHGRAIN 
 ) x
 where  x.fieldName like '%${ColumnName}%'
 order by fieldName, maskCount DESC

这创建了一个交互式用户窗口,根据用户输入刷新,生成具有多个输出配置的动态分析报告。在这里,我们展示的输出不是表格,而是一个图表,显示了应该具有低基数的字段Action在事件文件中的频率计数的对数:

构建可重用的笔记本

结果显示,即使像经度这样简单的字段在数据中也有很大的格式分布。

到目前为止审查的技术应该有助于创建一个非常可重用的笔记本,用于快速高效地对所有输入数据进行探索性数据分析,生成我们可以用来生成关于输入文件质量的出色报告和文档的图形输出。

探索 GDELT

探索 EDA 的一个重要部分是获取和记录数据源,GDELT 内容也不例外。在研究 GKG 数据集后,我们发现仅仅记录我们应该使用的实际数据源就是具有挑战性的。在接下来的几节中,我们提供了我们找到的用于使用的资源的全面列表,这些资源需要在示例中运行。

注意

关于下载时间的警告:使用典型的 5 Mb 家庭宽带,下载 2000 个 GKG 文件大约需要 3.5 小时。考虑到仅英语语言的 GKG 文件就有超过 40,000 个,这可能需要一段时间来下载。

GDELT GKG 数据集

我们应该使用最新的 GDELT 数据源,截至 2016 年 12 月的 2.1 版本。这些数据的主要文档在这里:

data.gdeltproject.org/documentation/GDELT-Global_Knowledge_Graph_Codebook-V2.1.pdf

在下一节中,我们已经包括了数据和次要参考查找表,以及进一步的文档。

文件

GKG-英语语言全球知识图谱(v2.1)

data.gdeltproject.org/gdeltv2/masterfilelist.txt

data.gdeltproject.org/gdeltv2/lastupdate.txt

GKG-翻译-非英语全球知识图谱

data.gdeltproject.org/gdeltv2/lastupdate-translation.txt

data.gdeltproject.org/gdeltv2/masterfilelist-translation.txt

GKG-TV(互联网档案馆-美国电视全球知识图谱)

data.gdeltproject.org/gdeltv2_iatelevision/lastupdate.txt

data.gdeltproject.org/gdeltv2_iatelevision/masterfilelist.txt

GKG-Visual-CloudVision

data.gdeltproject.org/gdeltv2_cloudvision/lastupdate.txt

特别收藏品

GKG-AME-非洲和中东全球知识图谱

data.gdeltproject.org/gkgv2_specialcollections/AME-GKG.CIA.gkgv2.csv.zip

data.gdeltproject.org/gkgv2_specialcollections/AME-GKG.CORE.gkgv2.csv.zip

data.gdeltproject.org/gkgv2_specialcollections/AME-GKG.DTIC.gkgv2.csv.zip

data.gdeltproject.org/gkgv2_specialcollections/AME-GKG.IADISSERT.gkgv2.csv.zip

data.gdeltproject.org/gkgv2_specialcollections/AME-GKG.IANONDISSERT.gkgv2.csv.zip

data.gdeltproject.org/gkgv2_specialcollections/AME-GKG.JSTOR.gkgv2.csv.zip

GKG-HR(人权收藏)

data.gdeltproject.org/gkgv2_specialcollections/HR-GKG.AMNESTY.gkgv2.csv.zip

data.gdeltproject.org/gkgv2_specialcollections/HR-GKG.CRISISGROUP.gkgv2.csv.zip

data.gdeltproject.org/gkgv2_specialcollections/HR-GKG.FIDH.gkgv2.csv.zip

data.gdeltproject.org/gkgv2_specialcollections/HR-GKG.HRW.gkgv2.csv.zip

data.gdeltproject.org/gkgv2_specialcollections/HR-GKG.ICC.gkgv2.csv.zip

data.gdeltproject.org/gkgv2_specialcollections/HR-GKG.OHCHR.gkgv2.csv.zip

data.gdeltproject.org/gkgv2_specialcollections/HR-GKG.USSTATE.gkgv2.csv.zip

参考数据

data.gdeltproject.org/documentation/GCAM-MASTER-CODEBOOK.TXT

data.gdeltproject.org/supportingdatasets/GNS-GAUL-ADM2-CROSSWALK.TXT.zip

data.gdeltproject.org/supportingdatasets/DOMAINSBYCOUNTRY-ENGLISH.TXT

data.gdeltproject.org/supportingdatasets/DOMAINSBYCOUNTRY-ALLLANGUAGES.TXT

www.unicode.org/Public/UNIDATA/UnicodeData.txt

www.geonames.org/about.html

探索 GKG v2.1

当我们审查现有的探索 GDELT 数据源的文章时,我们发现许多研究都集中在文章的人物、主题和语调上,还有一些集中在早期事件文件上。但是,几乎没有发表过探索现在包含在 GKG 文件中的全球内容分析指标GCAM)内容的研究。当我们尝试使用我们构建的数据质量工作簿来检查 GDELT 数据源时,我们发现全球知识图难以处理,因为文件使用了多个嵌套分隔符进行编码。快速处理这种嵌套格式数据是处理 GKG 和 GCAM 的关键挑战,也是本章其余部分的重点。

在探索 GKG 文件中的 GCAM 数据时,我们需要回答一些明显的问题:

  • 英语语言 GKG 文件和翻译后的跨语言国际文件之间有什么区别?在这些数据源之间,数据的填充方式是否有差异,考虑到一些实体识别算法可能在翻译文件上表现不佳?

  • 如果翻译后的数据在包含在 GKG 文件中的 GCAM 情感指标数据集方面有很好的人口统计,那么它(或者英文版本)是否可信?我们如何访问和规范化这些数据,它是否包含有价值的信号而不是噪音?

如果我们能够单独回答这两个问题,我们将对 GDELT 作为数据科学信号源的实用性有了很大的了解。然而,如何回答这些问题很重要,我们需要尝试并模板化我们的代码,以便在获得这些答案时创建可重用的配置驱动的 EDA 组件。如果我们能够按照我们的原则创建可重用的探索,我们将产生比硬编码分析更多的价值。

跨语言文件

让我们重复我们之前的工作,揭示一些质量问题,然后将我们的探索扩展到这些更详细和复杂的问题。通过对正常 GKG 数据和翻译文件运行一些人口统计(POPCHECK)指标到临时文件,我们可以导入并合并结果。这是我们重复使用标准化指标格式的好处;我们可以轻松地在数据集之间进行比较!

与其详细查看代码,我们不如提供一些主要答案。当我们检查英语和翻译后的 GKG 文件之间的人口统计时,我们确实发现了内容可用性上的一些差异:

跨语言文件

我们在这里看到,翻译后的 GKG 跨语言文件根本没有引用数据,并且在识别人员时非常低人口统计,与我们在一般英语新闻中看到的人口统计相比。因此,肯定有一些需要注意的差异。

因此,我们应该仔细检查我们希望在生产中依赖的跨语言数据源中的任何内容。稍后我们将看到 GCAM 情感内容中的翻译信息与母语英语情感相比如何。

可配置的 GCAM 时间序列 EDA

GCAM 的内容主要由字数计数组成,通过使用词典过滤器过滤新闻文章并对表征感兴趣主题的同义词进行字数计数而创建。通过将计数除以文档中的总字数,可以对结果进行规范化。它还包括得分值,提供似乎是基于直接研究原始语言文本的情感得分。

我们可以快速总结要在 GCAM 中研究和探索的情感变量范围,只需几行代码,其输出附有语言的名称:

wget http://data.gdeltproject.org/documentation/GCAM-MASTER-CODEBOOK.TXT 
cat GCAM-MASTER-CODEBOOK.TXT | \ 
gawk 'BEGIN{OFS="\t"} $4 != "Type" {print $4,$5}' | column -t -s $'\t' \  
| sort | uniq -c | gawk ' BEGIN{print "Lang Type Count" }{print $3, $2,\ $1}' | column -t -s $' ' 

Lang  Type         Count    Annotation 
ara   SCOREDVALUE  1        Arabic 
cat   SCOREDVALUE  16       Catalan 
deu   SCOREDVALUE  1        German 
eng   SCOREDVALUE  30       English 
fra   SCOREDVALUE  1        French 
glg   SCOREDVALUE  16       Galician 
hin   SCOREDVALUE  1        Hindi 
ind   SCOREDVALUE  1        Indonesian 
kor   SCOREDVALUE  1        Korean 
por   SCOREDVALUE  1        Portuguese 
rus   SCOREDVALUE  1        Russian 
spa   SCOREDVALUE  29       Spanish 
urd   SCOREDVALUE  1        Urdu 
zho   SCOREDVALUE  1        Chinese 
ara   WORDCOUNT    1        Arabic 
cat   WORDCOUNT    16       Catalan 
deu   WORDCOUNT    44       German 
eng   WORDCOUNT    2441     English 
fra   WORDCOUNT    78       French 
glg   WORDCOUNT    16       Galician 
hin   WORDCOUNT    1        Hindi 
hun   WORDCOUNT    36       Hungarian 
ind   WORDCOUNT    1        Indonesian 
kor   WORDCOUNT    1        Korean 
por   WORDCOUNT    46       Portuguese 
rus   WORDCOUNT    65       Russian 
spa   WORDCOUNT    62       Spanish 
swe   WORDCOUNT    64       Swedish 
urd   WORDCOUNT    1        Urdu 
zho   WORDCOUNT    1        Chinese 

基于字数计数的 GCAM 时间序列似乎是最完整的,特别是在英语中有 2441 个情感度量!处理如此多的度量似乎很困难,即使进行简单的分析也是如此。我们需要一些工具来简化事情,并且需要集中我们的范围。

为了帮助,我们创建了一个基于简单 SparkSQL 的探索者,从 GCAM 数据块中提取和可视化时间序列数据,专门针对基于字数计数的情感。它是通过克隆和调整我们在 Zeppelin 中的原始数据质量探索者创建的。

它通过调整以使用定义的模式读取 GKG 文件 glob,并预览我们想要专注的原始数据:

val GkgCoreSchema = StructType(Array(
     StructField("GkgRecordId"           , StringType, true), //$1       
     StructField("V21Date"               , StringType, true), //$2       
     StructField("V2SrcCollectionId"     , StringType, true), //$3       
     StructField("V2SrcCmnName"          , StringType, true), //$4
     StructField("V2DocId"               , StringType, true), //$5
     StructField("V1Counts"              , StringType, true), //$6
     StructField("V21Counts"             , StringType, true), //$7
     StructField("V1Themes"              , StringType, true), //$8
     StructField("V2Themes"              , StringType, true), //$9
     StructField("V1Locations"           , StringType, true), //$10
     StructField("V2Locations"           , StringType, true), //$11
     StructField("V1Persons"             , StringType, true), //$12
     StructField("V2Persons"             , StringType, true), //$13    
     StructField("V1Orgs"                , StringType, true), //$14
     StructField("V2Orgs"                , StringType, true), //$15
     StructField("V15Tone"               , StringType, true), //$16
     StructField("V21Dates"              , StringType, true), //$17
     StructField("V2GCAM"                , StringType, true), //$18
     StructField("V21ShareImg"           , StringType, true), //$19
     StructField("V21RelImg"             , StringType, true), //$20
     StructField("V21SocImage"           , StringType, true), //$21
     StructField("V21SocVideo"           , StringType, true), //$22
     StructField("V21Quotations"         , StringType, true), //$23
     StructField("V21AllNames"           , StringType, true), //$24
     StructField("V21Amounts"            , StringType, true), //$25
     StructField("V21TransInfo"          , StringType, true), //$26
     StructField("V2ExtrasXML"           , StringType, true)  //$27
     ))

val InputFilePath = YourFilePath

val GkgRawData = sqlContext.read
                           .option("header", "false")
                           .schema(GkgCoreSchema)
                           .option("delimiter", "\t")
                           .csv(InputFilePath) 

GkgRawData.registerTempTable("GkgRawData")

// now we register slices of the file we want to explore quickly

val PreRawData = GkgRawData.select("GkgRecordID","V21Date","V2GCAM", "V2DocId")
// we select the GCAM, plus the story URLs in V2DocID, which later we can //filter on.

PreRawData.registerTempTable("PreRawData")

早期列选择的结果将我们的内容隔离到要探索的领域;时间(V21Date),情感(V2GCAM)和源 URL(V2DocID):

+----+--------------+--------------------+--------------------+
|  ID|       V21Date|              V2GCAM|             V2DocId|
+----+--------------+--------------------+--------------------+
|...0|20161101000000|wc:77,c12.1:2,c12...|http://www.tampab...|
|...1|20161101000000|wc:57,c12.1:6,c12...|http://regator.co...|
|...2|20161101000000|wc:740,c1.3:2,c12...|http://www.9news....|
|...3|20161101000000|wc:1011,c1.3:1,c1...|http://www.gaming...|
|...4|20161101000000|wc:260,c1.2:1,c1....|http://cnafinance...|
+----+--------------+--------------------+--------------------+

在一个新的 Zeppelin 段落中,我们创建一个 SQLContext,并仔细解开 GCAM 记录的嵌套结构。请注意,V2GCAM 字段中逗号分隔的第一个内部行包含wc维度和代表该 GkgRecordID 故事的字数的度量,然后列出其他情感度量。我们需要将这些数据展开为实际行,以及将所有基于字数计数的情感除以wc文章的总字数以规范化分数。

在以下片段中,我们设计了一个SparkSQL语句,以典型的洋葱风格进行操作,使用子查询。这是一种编码风格,如果你还不了解,可能希望学会阅读。它的工作方式是 - 创建最内部的选择/查询,然后运行它进行测试,然后用括号包裹起来,继续通过选择数据进入下一个查询过程,依此类推。然后,催化剂优化器会进行优化整个流程。这导致了一个既声明性又可读的 ETL 过程,同时还提供了故障排除和隔离管道中任何部分问题的能力。如果我们想要了解如何处理嵌套数组过程,我们可以轻松重建以下 SQL,首先运行最内部的片段,然后审查其输出,然后扩展它以包括包装它的下一个查询,依此类推。然后我们可以逐步审查分阶段的输出,以审查整个语句如何一起工作以提供最终结果。

以下查询中的关键技巧是如何将单词计数分母应用于其他情感单词计数,以规范化值。这种规范化方法实际上是 GKG 文档中建议的,尽管没有提供实现提示。

值得注意的是,V21Date 字段是如何从整数转换为日期的,这对于有效绘制时间序列是必要的。转换需要我们预先导入以下库,除了笔记本中导入的其他库:

import org.apache.spark.sql.functions.{Unix_timestamp, to_date}  

使用Unix_timestamp函数,我们将 V21Date 转换为Unix_timestamp,这是一个整数,然后再次将该整数转换为日期字段,所有这些都使用本机 Spark 库来配置格式和时间分辨率。

以下 SQL 查询实现了我们期望的调查:

%sql 
 -- for urls containing “trump” build 15min “election fraud” sentiment time series chart.
 select 
   V21Date
 , regexp_replace(z.Series, "\\.", "_") as Series
 , sum(coalesce(z.Measure, 0) / coalesce (z.WordCount, 1)) as Sum_Normalised_Measure
 from
 (
     select
       GkgRecordID
     , V21Date
     , norm_array[0] as wc_norm_series
     , norm_array[1] as WordCount
     , ts_array[0] as Series
     , ts_array[1] as Measure
     from 
     (
         select
           GkgRecordID
         ,   V21Date
         , split(wc_row, ":")     as norm_array
         , split(gcam_array, ":") as ts_array
         from
             (
             select 
               GkgRecordID
             ,   V21Date
             , gcam_row[0] as wc_row
             , explode(gcam_row) as gcam_array
             from
                 (
                  select
                         GkgRecordID 
                     ,   from_Unixtime(
                              Unix_timestamp(
                                 V21Date, "yyyyMMddHHmmss")
                               , 'YYYY-MM-dd-HH-mm'
                               ) as V21Date
                     ,   split(V2GCAM, ",")  as gcam_row
                     from PreRawData
                     where length(V2GCAM) >1
                     and V2DocId like '%trump%'
                 ) w     
             ) x
     ) y
 ) z
 where z.Series <> "wc" and z.Series = 'c18.134'
                         -- c18.134 is "ELECTION_FRAUD"
 group by z.V21Date, z.Series
 order by z.V21Date ASC

查询的结果在 Zeppelin 的时间序列查看器中显示。它显示时间序列数据正在正确积累,并且看起来非常可信,2016 年 11 月 8 日有一个短暂的高峰:美国总统选举的那一天。

可配置的 GCAM 时间序列 EDA

现在我们有一个可以检查 GCAM 情绪分数的工作 SQL 语句,也许我们应该再检查一些其他指标,例如关于不同但相关主题的,比如英国的脱欧投票。

我们选择了三个看起来有趣的 GCAM 情绪指标,除了选举舞弊指标,希望能够提供与我们在美国选举中看到的结果有趣的比较。我们将研究的指标是:

  • 'c18.101' -- 移民

  • 'c18.100' -- 民主

  • 'c18.140' -- 选举

为了包括它们,我们需要扩展我们的查询以获取多个归一化的 Series,并且我们可能还需要注意结果可能不都适合 Zeppelin 的查看器,默认只接受前 1000 个结果,所以我们可能需要进一步总结到小时或天。虽然这不是一个很大的改变,但看到我们现有工作的可扩展性将是有趣的:

val ExtractGcam = sqlContext.sql("""
select  
   a.V21Date
, a.Series
, Sum(a.Sum_Normalised_Measure) as Sum_Normalised_Measure
from (
    select 
    z.partitionkey
    , z.V21Date
    , regexp_replace(z.Series, "\\.", "_") as Series
    , sum(coalesce(z.Measure, 0) / coalesce (z.WordCount, 1))
     as Sum_Normalised_Measure
    from
    (
        select
        y.V21Date
        , cast(cast(round(rand(10) *1000,0) as INT) as string)
         as partitionkey
        , y.norm_array[0] as wc_norm_series
        , y.norm_array[1] as WordCount
        , y.ts_array[0] as Series
        , y.ts_array[1] as Measure
        from 
        (
            select
               x.V21Date
            , split(x.wc_row, ":")     as norm_array
            , split(x.gcam_array, ":") as ts_array
            from
                (
                select 
                  w.V21Date
                , w.gcam_row[0] as wc_row
                , explode(w.gcam_row) as gcam_array
                from
                    (
                     select
                        from_Unixtime(Unix_timestamp(V21Date,
       "yyyyMMddHHmmss"), 'YYYY-MM-dd-HH-mm')
       as V21Date
                        ,   split(V2GCAM, ",")  as gcam_row
                        from PreRawData
                        where length(V2GCAM) > 20
                        and V2DocId like '%brexit%'
                    ) w
                    where gcam_row[0] like '%wc%'
                       OR gcam_row[0] like '%c18.1%'
                ) x

        ) y 
    ) z
    where z.Series <> "wc" 
        and 
        (   z.Series = 'c18.134' -- Election Fraud
         or z.Series = 'c18.101' -- Immigration
         or z.Series = 'c18.100' -- Democracy
         or z.Series = 'c18.140' -- Election
        )  
    group by z.partitionkey, z.V21Date, z.Series
) a
group by a.V21Date, a.Series
""")

在这个第二个例子中,我们进一步完善了我们的基本查询,删除了我们没有使用的不必要的 GKGRecordIDs。这个查询还演示了如何使用一组简单的谓词来过滤对许多Series名称的结果。请注意,我们还添加了一个使用以下内容的预分组步骤:

group by z.partitionkey, z.V21Date, z.Series 

-- Where the partition key is: 
-- cast(cast(round(rand(10) *1000,0) as INT) as string) as partitionkey 

这个随机数用于创建一个分区前缀键,我们在内部的 group by 语句中使用它,然后再次进行分组而不使用这个前缀。查询是以这种方式编写的,因为它有助于细分和预汇总热点数据,并消除任何管道瓶颈。

当我们在 Zeppelin 的时间序列查看器中查看此查询的结果时,我们有机会进一步总结到小时计数,并使用 case 语句将神秘的 GCAM 系列代码翻译成适当的名称。我们可以在一个新的查询中执行此操作,帮助将特定的报告配置与一般的数据集构建查询隔离开来:

Select
a.Time
, a.Series
, Sum(Sum_Normalised_Measure) as Sum_Normalised_Measure
from
(
        select
        from_Unixtime(Unix_timestamp(V21Date,
                      "yyyy-MM-dd-HH-mm"),'YYYY-MM-dd-HH')
         as Time
       , CASE 
           when Series = 'c18_134' then 'Election Fraud'
           when Series = 'c18_101' then 'Immigration'
           when Series = 'c18_100' then 'Democracy'
           when Series = 'c18_140' then 'Election'
       END as Series
       , Sum_Normalised_Measure
       from ExtractGcam 
       -- where Series = 'c18_101' or Series = 'c18_140'
) a
group by a.Time, a.Series
order by a.Time

这个最终查询将数据减少到小时值,这比 Zeppelin 默认处理的 1000 行最大值要少,此外它生成了一个比较时间序列图表:

可配置的 GCAM 时间序列 EDA

结果图表表明,在脱欧投票之前几乎没有关于选举舞弊的讨论,但是选举有高峰,而移民是一个比民主更热门的主题。再次,GCAM 英语情绪数据似乎具有真实信号。

现在我们已经为英语语言记录提供了一些信息,我们可以扩展我们的工作,探索它们与 GCAM 中的翻译数据的关系。

作为完成本笔记本中分析的最后一种方法,我们可以注释掉对特定Series的过滤器,并将所有脱欧的 GCAM 系列数据写入我们的 HDFS 文件系统中的 parquet 文件中。这样我们就可以永久存储我们的 GCAM 数据到磁盘,甚至随着时间的推移追加新数据。以下是要么覆盖,要么追加到 parquet 文件所需的代码:

// save the data as a parquet file
val TimeSeriesParqueFile = "/user/feeds/gdelt/datastore/BrexitTimeSeries2016.parquet"   

// *** uncomment to append to an existing parquet file ***
// ExtractGcam.save(TimeSeriesParqueFile
                     //, "parquet" 
                     //, SaveMode.Append)
// ***************************************************************
// *** uncomment to initially load a new parquet file ***
    ExtractGcam.save(TimeSeriesParqueFile
          , "parquet"
    , SaveMode.Overwrite)
// ***************************************************************

通过将 parquet 文件写入磁盘,我们现在建立了一个轻量级的 GCAM 时间序列数据存储,可以让我们快速检索 GCAM 情绪,以便在语言组之间进行探索。

Apache Zeppelin 上的 Plot.ly 图表

对于我们下一个的探索,我们还将扩展我们对 Apache Zeppelin 笔记本的使用,以包括使用一个名为 plotly 的外部图表库来生成%pyspark图表,该库是由plot.ly/开源的,可以用来创建打印质量的可视化。要在我们的笔记本中使用 plotly,我们可以升级我们的 Apache Zeppelin 安装,使用在github.com/beljun/zeppelin-plotly中找到的代码,该代码提供了所需的集成。在它的 GitHub 页面上,有详细的安装说明,在他们的代码库中,他们提供了一个非常有帮助的示例笔记本。以下是一些在 HDP 集群上安装 plotly 以供 Zeppelin 使用的提示:

  • 以 Zeppelin 用户身份登录 Namenode,并更改目录到 Zeppelin 主目录/home/zeppelin,在那里我们将下载外部代码:
        git clone https://github.com/beljun/zeppelin-plotly
  • 更改目录到保存 Zeppelin *.war文件的位置。这个位置在 Zeppelin配置标签中可以找到。例如:
        cd /usr/hdp/current/zeppelin-server/lib 

现在,按照说明,我们需要编辑在 Zeppelin war文件中找到的 index.html 文档:

   ls *war    # zeppelin-web-0.6.0.2.4.0.0-169.war
  cp zeppelin-web-0.6.0.2.4.0.0-169.war \
      bkp_zeppelin-web-0.6.0.2.4.0.0-169.war
  jar xvf zeppelin-web-0.6.0.2.4.0.0-169.war \
       index.html
  vi index.html
  • 一旦提取了index.html页面,我们可以使用诸如 vim 之类的编辑器在 body 标签之前插入plotly-latest.min.js脚本标签(按照说明),然后保存并执行文档。

  • 将编辑后的index.html文档放回 war 文件中:

        jar uvf zeppelin-web-0.6.0.2.4.0.0-169.war index.html 

  • 最后,登录 Ambari,并使用它重新启动 Zeppelin 服务。

  • 按照其余的说明在 Zeppelin 中生成一个测试图表。

  • 如果出现问题,我们可能需要安装或更新旧的库。登录 Namenode 并使用 pip 安装这些包:

        sudo pip install plotly 
        sudo pip install plotly --upgrade 
        sudo pip install colors 
        sudo pip install cufflinks 
        sudo pip install pandas 
        sudo pip install Ipython 
        sudo pip install -U pyOpenSSL 
        # note also install pyOpenSSL to get things running. 

安装完成后,我们现在应该能够创建 Zeppelin 笔记本,从%pyspark段落中生成内联 plot.ly 图表,并且这些图表将使用本地库离线创建,而不是使用在线服务。

使用 plot.ly 探索翻译源 GCAM 情绪

对于这个比较,让我们关注一下在 GCAM 文档中找到的一个有趣的度量:c6.6财务不确定性。这个度量计算了新闻报道和一个财务导向的不确定性词典之间的词匹配次数。如果我们追溯它的来源,我们可以发现驱动这个度量的学术论文和实际词典。然而,这个基于词典的度量是否适用于翻译后的新闻文本?为了调查这个问题,我们可以查看这个财务不确定性度量在英语、法语、德语、西班牙语、意大利语和波兰语这六个主要欧洲语言群中的差异,关于英国脱欧的主题。

我们创建一个新的笔记本,包括一个pyspark段落来加载 plot.ly 库并将其设置为离线模式运行:

%pyspark
# Instructions here: https://github.com/beljun/zeppelin-plotly
import sys
sys.path.insert(0, "/home/zeppelin/zeppelin-plotly")

import offline

sys.modules["plotly"].offline = offline
sys.modules["plotly.offline"] = offline

import cufflinks as cf
cf.go_offline()

import plotly.plotly as py
import plotly.graph_objs as go

import pandas as pd
import numpy as np

然后,我们创建一个段落来从 parquet 中读取我们缓存的数据:

%pyspark

GcamParquet = sqlContext.read.parquet("/user/feeds/gdelt/datastore/BrexitTimeSeries2016.parquet")

# register the content as a python data frame
sqlContext.registerDataFrameAsTable(GcamParquet, "BrexitTimeSeries")

然后,我们可以创建一个 SQL 查询来读取并准备数据进行绘图,并将其注册以供使用:

%pyspark 
FixedExtractGcam = sqlContext.sql(""" 
select  
  V21Date 
, Series 
, CASE 
    when LangLen = 0 then "eng" 
    when LangLen > 0 then SourceLanguage 
  END as SourceLanguage 
, FIPS104Country 
, Sum_Normalised_Measure 
from 
(   select *,length(SourceLanguage) as LangLen 
    from BrexitTimeSeries 
    where V21Date like "2016%" 
) a  
""") 

sqlContext.registerDataFrameAsTable(FixedExtractGcam, "Brexit") 
# pyspark accessible registration of the data 

现在我们已经定义了一个适配器,我们可以创建一个查询,总结我们 parquet 文件中的数据,使其更容易适应内存:

%pyspark 

timeplot = sqlContext.sql(""" 
Select 
from_Unixtime(Unix_timestamp(Time, "yyyy-MM-dd"), 'YYYY-MM-dd HH:mm:ss.ssss') as Time 
, a.Series 
, SourceLanguage as Lang 
--, Country 
, sum(Sum_Normalised_Measure) as Sum_Normalised_Measure 
from 
(       select 
          from_Unixtime(Unix_timestamp(V21Date,  
                        "yyyy-MM-dd-HH"), 'YYYY-MM-dd') as Time 
        , SourceLanguage 
        , CASE 
           When Series = 'c6_6' then "Uncertainty" 
          END as Series 
        , Sum_Normalised_Measure 
        from Brexit  
        where Series in ('c6_6') 
        and SourceLanguage in ( 'deu', 'fra', 'ita', 'eng', 'spa', 'pol') 
        and V21Date like '2016%'   
) a 
group by a.Time, a.Series, a.SourceLanguage order by a.Time, a.Series, a.SourceLanguage 
""") 

sqlContext.registerDataFrameAsTable(timeplot, "timeplot")  
# pyspark accessible registration of the data 

这个主要的负载查询生成了一组数据,我们可以将其加载到pyspark中的pandas数组中,并且具有一个 plot.ly 准备好的时间戳格式:

+------------------------+-----------+----+----------------------+ 
|Time                    |Series     |Lang|Sum_Normalised_Measure| 
+------------------------+-----------+----+----------------------+ 
|2016-01-04 00:00:00.0000|Uncertainty|deu |0.0375                | 
|2016-01-04 00:00:00.0000|Uncertainty|eng |0.5603189694252122    | 
|2016-01-04 00:00:00.0000|Uncertainty|fra |0.08089269454114742   | 
+------------------------+-----------+----+----------------------+ 

要将这些数据提供给 plot.ly,我们必须将我们生成的 Spark 数据框转换为一个pandas数据框:

%pyspark 
explorer = pd.DataFrame(timeplot.collect(), columns=['Time', 'Series', 'SourceLanguage','Sum_Normalised_Measure']) 

当我们执行这一步时,我们必须记住要collect()数据框架,以及重置列名以供pandas使用。现在我们的 Python 环境中有了一个pandas数组,我们可以轻松地将数据透视成便于进行时间序列绘图的形式:

pexp = pd.pivot_table(explorer, values='Sum_Normalised_Measure', index=['Time'], columns=['SourceLanguage','Series'], aggfunc=np.sum, fill_value=0) 

最后,我们包括一个调用来生成图表:

pexp.iplot(title="BREXIT: Daily GCAM Uncertainty Sentiment Measures by Language", kind ="bar", barmode="stack") 

使用 plot.ly 探索翻译源 GCAM 情绪

现在我们已经生成了一个工作的 plot.ly 数据图表,我们应该创建一个自定义的可视化,这是标准的 Zeppelin 笔记本无法实现的,以展示 plotly 库为我们的探索带来的价值。一个简单的例子是生成一些小多图,就像这样:

pexp.iplot(title="BREXIT: Daily GCAM Uncertainty by Language, 2016-01 through 2016-07",subplots=True, shared_xaxes=True, fill=True,  kind ="bar") 

生成以下图表:

使用 plot.ly 探索翻译来源的 GCAM 情感

这个小多图表帮助我们看到,在意大利新闻中,2016 年 6 月 15 日似乎出现了财务不确定性的局部激增;就在选举前一周左右。这是我们可能希望调查的事情,因为在西班牙语新闻中也以较小的程度存在。

Plotly 还提供许多其他有趣的可视化方式。如果您仔细阅读了代码片段,您可能已经注意到 parquet 文件包括来自 GKG 文件的 FIPS10-4 国家代码。我们应该能够利用这些位置代码,使用 Plotly 绘制不确定性指标的区域地图,并同时利用我们先前的数据处理。

为了创建这个地理地图,我们重用了我们之前注册的 parquet 文件读取器查询。不幸的是,GKG 文件使用 FIPS 10-4 两个字符的国家编码,而 Plotly 使用 ISO-3166 三个字符的国家代码来自动为绘图处理的用户记录进行地理标记。我们可以通过在我们的 SQL 中使用 case 语句来重新映射我们的代码,然后在整个调查期间对它们进行汇总来解决这个问题。

%pyspark 
mapplot = sqlContext.sql(""" 
Select 
  CountryCode 
, sum(Sum_Normalised_Measure) as Sum_Normalised_Measure 
from (  select 
        from_Unixtime(Unix_timestamp(V21Date, "yyyy-MM-dd-HH"),  
                                     'YYYY-MM') as Time 
        , CASE 
             when FIPS104Country = "AF" then "AFB" 
             when FIPS104Country = "AL" then "ALB" 
                -- I have excluded the full list of  
                -- countries in this code snippet 
             when FIPS104Country = "WI" then "ESH" 
             when FIPS104Country = "YM" then "YEM" 
             when FIPS104Country = "ZA" then "ZMB" 
             when FIPS104Country = "ZI" then "ZWE" 
          END as CountryCode 
        , Sum_Normalised_Measure 
        from Brexit  
        where Series in ('c6_6') 
        and V21Date like '2016%' 
) a 
group by a.CountryCode order by a.CountryCode 
""") 

sqlContext.registerDataFrameAsTable(mapplot, "mapplot") # python 

mapplot2 = pd.DataFrame(mapplot.collect(), columns=['Country', 'Sum_Normalised_Measure']) 

现在我们的数据已经准备在pandas数据框中,我们可以使用以下一行 Python 代码调用可视化:

mapplot2.iplot( kind = 'choropleth', locations = 'Country', z = 'Sum_Normalised_Measure', text = 'Country', locationmode = 'ISO-3', showframe = True, showcoastlines = False, projection = dict(type = 'equirectangular'), colorscale = [[0,"rgb(5, 10, 172)"],[0.9,"rgb(40, 60, 190)"],[0.9,"rgb(70, 100, 245)"],[1,"rgb(90, 120, 245)"],[1,"rgb(106, 137, 247)"],[1,"rgb(220, 220, 220)"]]) 

最终结果是一个交互式、可缩放的世界地图。我们将其政治解释留给读者,但从技术上讲,也许这张地图显示了与新闻数量有关的效果,我们可以稍后对其进行归一化;例如通过将我们的值除以每个国家的总故事数。

使用 plot.ly 探索翻译来源的 GCAM 情感

结束语

值得指出的是,有许多参数驱动了我们对所有调查的探索,我们可以考虑如何将这些参数化以构建适当的探索产品来监控 GDELT。需要考虑的参数如下:

  • 我们可以选择一个非 GCAM 字段进行过滤。在前面的示例中,它配置为 V2DocID,这是故事的 URL。在 URL 中找到诸如 BREXIT 或 TRUMP 之类的词将有助于将我们的调查范围限定在特定主题领域的故事中。我们还可以重复此技术以过滤 BBC 或 NYTIMES 等内容。或者,如果我们将此列替换为另一个列,例如 Theme 或 Person,那么这些列将提供新的方法来聚焦我们对特定主题或感兴趣的人的研究。

  • 我们已经转换并概括了时间戳 V21Date 的粒度,以提供每小时的时间序列增量,但我们可以重新配置它以创建我们的时间序列,以月、周或日为基础 - 或者以任何其他增量为基础。

  • 我们首先选择并限定了我们对感兴趣的一个时间序列c18_134,即选举舞弊,但我们可以轻松地重新配置它以查看移民仇恨言论或其他 2400 多个基于词频的情感分数。

  • 我们在笔记本的开头引入了一个文件 glob,它限定了我们在摘要输出中包含的时间量。为了降低成本,我们一开始将其保持较小,但我们可以重新聚焦这个时间范围到关键事件,甚至在有足够的处理预算(时间和金钱)的情况下打开它到所有可用的文件。

我们现在已经证明,我们的代码可以轻松调整,构建基于笔记本的 GCAM 时间序列探索器,从中我们将能够按需构建大量的专注调查;每个都以可配置的方式探索 GCAM 数据的内容。

如果您一直在仔细跟随笔记本中的 SQL 代码,并且想知道为什么它没有使用 Python API 编写,或者使用惯用的 Scala,我们将用最后一个观察来完成本节:正是因为它是由 SQL 构建的,所以它可以在 Python、R 或 Scala 上下文之间移动,几乎不需要重构代码。如果 R 中出现了新的图表功能,它可以轻松地移植到 R 中,然后可以将精力集中在可视化上。事实上,随着 Spark 2.0+的到来,也许在移植时需要最少审查的是 SQL 代码。代码可移植性的重要性无法强调得足够。然而,在 EDA 环境中使用 SQL 的最大好处是,它使得在 Zeppelin 中生成基于参数的笔记本变得非常容易,正如我们在早期的分析器部分所看到的。下拉框和其他 UI 小部件都可以与字符串处理一起创建,以在执行之前自定义代码,而不受后端语言的限制。这是一种非常快速的方式,可以在我们的分析中构建交互性和配置,而不需要涉及复杂的元编程方法。它还帮助我们避免解决 Apache Zeppelin/Spark 中可用的不同语言后端的元编程复杂性。

关于构建广泛的数据探索,如果我们希望更广泛地使用我们在 parquet 中缓存的结果,也有机会完全消除“眼睛观看图表”的需求。参见第十二章TrendCalculus,以了解我们如何可以以编程方式研究 GKG 中所有数据的趋势。

在使用 Zeppelin 时,一个需要注意的最后一个技巧是纯粹实用的。如果我们希望将图形提取到文件中,例如将它们包含在我们的最终报告中,而不是在笔记本中截图,我们可以直接从 Zeppelin 中提取可伸缩矢量图形文件(SVG),并使用此处找到的bookmarklet将它们下载到文件中nytimes.github.io/svg-crowbar/

可配置的 GCAM 时空 EDA

GCAM 的另一个问题仍然没有答案;我们如何开始理解它在空间上的细分?GCAM 的地理空间枢纽是否能够揭示全球新闻媒体如何呈现其聚合地缘政治观点,以详细的地理分析来深入国家级别的分析?

如果我们可以作为 EDA 的一部分构建这样的数据集,它将有许多不同的应用。例如,在城市层面上,它将是一个通用的地缘政治信号库,可以丰富各种其他数据科学项目。考虑假期旅行预订模式,与新闻中出现的地缘政治主题相结合。我们会发现全球新闻信号在城市层面上是否预测了媒体关注的地方的旅游率上升或下降?当我们将所得信息视为地缘政治态势感知的信息源时,这种数据的可能性几乎是无限的。

面对这样的机会,我们需要仔细考虑我们在这个更复杂的 EDA 中的投资。与以往一样,它将需要一个共同的数据结构,从而开始我们的探索。

作为目标,我们将致力于构建以下数据框架,以探索地缘政治趋势,我们将其称为“GeoGcam”。

val GeoGcamSchema = StructType(Array(
        StructField("Date"          , StringType, true),  //$1       
        StructField("CountryCode"   , StringType, true),  //$2
        StructField("Lat"           , DoubleType, true),  //$3       
        StructField("Long"          , DoubleType, true),  //$4
        StructField("Geohash"       , StringType, true),  //$5
        StructField("NewsLang"      , StringType, true),  //$6
        StructField("Series"        , StringType, true),    //$7      
        StructField("Value"         , DoubleType, true),  //$8  
        StructField("ArticleCount"  , DoubleType, true),  //$9  
        StructField("AvgTone"       , DoubleType, true)  //$10 
    ))

介绍 GeoGCAM

GeoGcam 是一个全球时空信号数据集,它源自原始的 GDELT 全球知识图(2.1)。它能够快速、轻松地探索全球新闻媒体情绪的演变地缘政治趋势。数据本身是使用一个转换管道创建的,该管道将原始的 GKG 文件转换为标准、可重复使用的全球时间/空间/情绪信号格式,这允许直接下游时空分析、地图可视化和进一步的广泛地缘政治趋势分析。

它可以用作预测模型的外部协变量的来源,特别是那些需要改进地缘政治情况意识的模型。

它是通过将 GKG 的 GCAM 情绪数据重新构建为一个空间定向模式而构建的。这是通过每个新闻故事的情绪放置在其 GKG 记录中识别的细粒度城市/城镇级别位置上来完成的。

然后,数据按城市聚合,跨越 15 分钟的 GKG 时间窗口内的所有索引故事。结果是一个文件,它提供了在该空间和时间窗口内所有故事的聚合新闻媒体情绪共识,针对那个地方。尽管会有噪音,但我们的假设是,大而广泛的地缘政治主题将会出现。

数据集的样本(与目标模式匹配)如下:

+--------------+-------+------+--------+------------+ 
|Date          |Country|Lat   |Long    |Geohash     | 
|              |Code   |      |        |            | 
+--------------+-------+------+--------+------------+ 
|20151109103000|CI     |-33.45|-70.6667|66j9xyw5ds13| 
|20151109103000|CI     |-33.45|-70.6667|66j9xyw5ds13| 
|20151109103000|CI     |-33.45|-70.6667|66j9xyw5ds13| 
|20151109103000|CI     |-33.45|-70.6667|66j9xyw5ds13| 
|20151109103000|CI     |-33.45|-70.6667|66j9xyw5ds13| 
|20151109103000|CI     |-33.45|-70.6667|66j9xyw5ds13| 
|20151109103000|CI     |-33.45|-70.6667|66j9xyw5ds13| 
|20151109103000|CI     |-33.45|-70.6667|66j9xyw5ds13| 
|20151109103000|CI     |-33.45|-70.6667|66j9xyw5ds13| 
|20151109103000|CI     |-33.45|-70.6667|66j9xyw5ds13| 
+--------------+-------+------+--------+------------+ 
+----+------+-----+-------+----------------+ 
|News|Series|SUM  |Article|AvgTone         | 
|Lang|      |Value|Count  |                | 
+----+------+-----+-------+----------------+ 
|E   |c12_1 |16.0 |1.0    |0.24390243902439| 
|E   |c12_10|26.0 |1.0    |0.24390243902439| 
|E   |c12_12|12.0 |1.0    |0.24390243902439| 
|E   |c12_13|3.0  |1.0    |0.24390243902439| 
|E   |c12_14|11.0 |1.0    |0.24390243902439| 
|E   |c12_3 |4.0  |1.0    |0.24390243902439| 
|E   |c12_4 |3.0  |1.0    |0.24390243902439| 
|E   |c12_5 |10.0 |1.0    |0.24390243902439| 
|E   |c12_7 |15.0 |1.0    |0.24390243902439| 
|E   |c12_8 |6.0  |1.0    |0.24390243902439| 
+----+------+-----+-------+----------------+ 

关于数据集的技术说明:

  • 只有标记有特定城市位置的新闻文章才会被包括在内,这意味着只有那些被 GKG 标记为具有位置类型代码 3=USCITY 或 4=WORLDCITY 的文章才会被包括在内。

  • 我们已经计算并包括了每个城市的完整 GeoHash(有关更多信息,请参见第五章,地理分析的 Spark),简化了数据的索引和总结,以供更大范围的地理区域使用。

  • 文件的粒度是基于用于生成数据集的聚合键,即:V21DateLocCountryCodeLatLongGeoHashLanguageSeries

  • 我们已经将 GKG 源中识别的主要位置国家代码字段传递到城市级别的聚合函数中;这使我们能够快速地按国家检查数据,而无需进行复杂的查找。

  • 提供的数据是未归一化的。我们应该稍后通过位置的总文章字数来对其进行归一化,这在名为wc的系列中是可用的。但这只适用于基于字数的情绪测量。我们还携带了文章的计数,以便测试不同类型的归一化。

  • 该数据源自英语 GKG 记录,但我们计划在相同的数据格式中包括国际跨语言源。为了做好准备,我们已经包括了一个字段,用于表示原始新闻故事的语言。

  • 我们为这个数据集设计了一个摄入例程,用于 GeoMesa,这是一个可扩展的数据存储,允许我们地理上探索生成的数据;这在我们的代码库中是可用的。有关 GeoMesa 的深入探讨,请参见第五章,地理分析的 Spark

以下是构建 GeoGCAM 文件的流水线:

// be sure to include a dependency to the geohash library
// here in the 1st para of zeppelin:
// z.load("com.github.davidmoten:geo:0.7.1")
// to use the geohash functionality in your code

val GcamRaw = GkgFileRaw.select("GkgRecordID","V21Date","V15Tone","V2GCAM", "V1Locations")
    GcamRaw.cache()
    GcamRaw.registerTempTable("GcamRaw")

def vgeoWrap (lat: Double, long: Double, len: Int): String = {
    var ret = GeoHash.encodeHash(lat, long, len)
    // select the length of the geohash, less than 12..
    // it pulls in the library dependency from       
    //   com.github.davidmoten:geo:0.7.1
    return(ret)
} // we wrap up the geohash function locally

// we register the vGeoHash function for use in SQL 
sqlContext.udf.register("vGeoHash", vgeoWrap(_:Double,_:Double,_:Int))

val ExtractGcam = sqlContext.sql("""
    select
        GkgRecordID 
    ,   V21Date
    ,   split(V2GCAM, ",")                  as Array
    ,   explode(split(V1Locations, ";"))    as LocArray
    ,   regexp_replace(V15Tone, ",.*$", "") as V15Tone 
       -- note we truncate off the other scores
    from GcamRaw 
    where length(V2GCAM) >1 and length(V1Locations) >1
""")

val explodeGcamDF = ExtractGcam.explode("Array", "GcamRow"){c: Seq[String] => c }

val GcamRows = explodeGcamDF.select("GkgRecordID","V21Date","V15Tone","GcamRow", "LocArray")
// note ALL the locations get repeated against
// every GCAM sentiment row

    GcamRows.registerTempTable("GcamRows")

val TimeSeries = sqlContext.sql("""
select   -- create geohash keys
  d.V21Date
, d.LocCountryCode
, d.Lat
, d.Long
 , vGeoHash(d.Lat, d.Long, 12)        as GeoHash
, 'E' as NewsLang
, regexp_replace(Series, "\\.", "_") as Series
, coalesce(sum(d.Value),0) as SumValue  
           -- SQL’s "coalesce” means “replaces nulls with"
, count(distinct  GkgRecordID )      as ArticleCount
, Avg(V15Tone)                       as AvgTone
from
(   select  -- build Cartesian join of the series
                -- and granular locations
      GkgRecordID
    , V21Date
    , ts_array[0]  as Series
    , ts_array[1]  as Value
    , loc_array[0] as LocType
    , loc_array[2] as LocCountryCode
    , loc_array[4] as Lat
    , loc_array[5] as Long
    , V15Tone
    from
       (select -- isolate the data to focus on
         GkgRecordID
       , V21Date
       , split(GcamRow,   ":") as ts_array
       , split(LocArray,  "#") as loc_array
       , V15Tone
       from GcamRows
       where length(GcamRow)>1
       ) x
    where
    (loc_array[0] = 3 or  loc_array[0] = 4) -- city level filter
) d
group by 
  d.V21Date
, d.LocCountryCode
, d.Lat
, d.Long
, vGeoHash(d.Lat, d.Long, 12)
, d.Series
order by 
  d.V21Date
, vGeoHash(d.Lat, d.Long, 12)
, d.Series
""")

这个查询基本上做了以下几件事:它在 GCAM 情绪和记录中识别的细粒度位置(城市/地点)之间建立了一个笛卡尔连接,并继续15 分钟窗口内所有新闻故事的 Tone 和情绪值放置在这些位置上。输出是一个时空数据集,允许我们在地图上地理化地映射 GCAM 情绪。例如,可以快速将这些数据导出并在 QGIS 中绘制,这是一个开源的地图工具。

我们的空间中心点是否有效?

当前面的 GeoGCAM 数据集被过滤以查看 GCAM 移民情绪作为 2015 年 2 月 GKG 数据的头两周的主题时,我们可以生成以下地图:

我们的空间中心点是否有效?

这说明了全球英语新闻媒体的语调,使用了轻(积极平均语调)和暗(消极平均语调),这些都可以在 GKG 文件中找到,并探讨了该语调如何在地图上的每个地理瓦片上映射(像素大小的计算相当准确地反映了分组的截断 GeoHash 的大小),并且与移民作为主题的情绪相关。

我们可以在这张地图上清楚地看到,移民不仅是与英国地区相关的热门话题,而且在其他地方也有强烈的空间集中。例如,我们可以看到与中东部分明显突出的浓厚负面语调。我们还看到了以前可能会错过的细节。例如,都柏林周围有关移民的浓厚负面语调,这并不是立即可以解释的,尼日利亚东北部似乎也发生了一些事情。

地图显示,我们也可能需要注意英语语言的偏见,因为非英语国家的讨论很少,这似乎有点奇怪,直到我们意识到我们还没有包括跨语言的 GKG 源。这表明我们应该扩展我们的处理,以包括跨语言数据源,以获得更全面和完整的信号,包括非英语新闻媒体。

GCAM 时间序列的完整列表在此处列出:data.gdeltproject.org/documentation/GCAM-MASTER-CODEBOOK.TXT

目前,在 GeoGCAM 格式中检查的英语新闻数据提供了对世界的迷人视角,我们发现 GDELT 确实提供了我们可以利用的真实信号。使用本章中开发的 GeoGCAM 格式化数据,您现在应该能够轻松快速地构建自己特定的地缘政治探索,甚至将此内容与您自己的数据集集成。

总结

在这一章中,我们回顾了许多探索数据质量和数据内容的想法。我们还向读者介绍了与 GDELT 合作的工具和技术,旨在鼓励读者扩展自己的调查。我们展示了 Zeppelin 的快速发展,并且大部分代码都是用 SparkSQL 编写的,以展示这种方法的出色可移植性。由于 GKG 文件在内容上非常复杂,本书的其余部分大部分致力于深入分析,超越了探索,我们在深入研究 Spark 代码库时也远离了 SparkSQL。

在下一章,也就是第五章,“地理分析的 Spark”,我们将探索 GeoMesa;这是一个管理和探索本章中创建的 GeoGCAM 数据集的理想工具,以及 GeoServer 和 GeoTools 工具集,以进一步扩展我们对时空探索和可视化的知识。

第五章:地理分析的 Spark

地理处理是 Spark 的一个强大用例,因此本章的目的是解释数据科学家如何使用 Spark 处理地理数据,以产生强大的基于地图的大型数据集视图。我们将演示如何通过 Spark 与 GeoMesa 集成轻松处理时空数据集,这有助于将 Spark 转变为一个复杂的地理处理引擎。随着物联网IoT)和其他位置感知数据变得越来越普遍,以及移动对象数据量的增加,Spark 将成为一个重要的工具,弥合空间功能和处理可伸缩性之间的地理处理差距。本章揭示了如何通过全球新闻进行高级地缘政治分析,以利用数据分析和进行石油价格数据科学。

在本章中,我们将涵盖以下主题:

  • 使用 Spark 摄取和预处理地理定位数据

  • 存储适当索引的地理数据,使用 GeoMesa 内部的 Geohash 索引

  • 运行复杂的时空查询,跨时间和空间过滤数据

  • 使用 Spark 和 GeoMesa 一起执行高级地理处理,以研究随时间的变化

  • 使用 Spark 计算密度地图,并可视化这些地图随时间的变化

  • 查询和整合跨地图层的空间数据以建立新的见解

GDELT 和石油

本章的前提是我们可以操纵 GDELT 数据,以更大或更小的程度确定石油价格基于历史事件。我们的预测准确性将取决于许多变量,包括我们事件的细节,使用的数量以及我们关于石油和这些事件之间关系性质的假设。

石油行业非常复杂,受到许多因素的驱动。然而,研究发现,大多数主要的石油价格波动主要是由原油需求的变化所解释的。价格在股票需求增加时也会上涨,并且在中东地区的地缘政治紧张时期价格历史上也很高。特别是政治事件对石油价格有很大影响,我们将集中讨论这一方面。

世界各地有许多国家生产原油;然而,有三个主要的基准价格,供应商用于定价:

  • 布伦特:由北海的各种实体生产

  • WTI:西德克萨斯中质原油WTI)覆盖北美中西部和墨西哥湾沿岸地区的实体

  • 欧佩克:由欧佩克成员国生产:

阿尔及利亚,安哥拉,厄瓜多尔,加蓬,印度尼西亚,伊朗,伊拉克,科威特,利比亚,尼日利亚,卡塔尔,沙特阿拉伯,阿联酋和委内瑞拉

很明显,我们需要做的第一件事是获取三个基准的历史定价数据。通过搜索互联网,可以在许多地方找到可下载的数据,例如:

现在我们知道,石油价格主要由供需决定,我们的第一个假设是供需受世界事件的影响更大,因此我们可以预测供需可能是什么。

我们想要确定原油价格在接下来的一天、一周或一个月内是上涨还是下跌,由于我们在整本书中都使用了 GDELT,我们将利用这些知识来运行一些非常大的处理任务。在开始之前,值得讨论我们将采取的路径以及决定的原因。首要关注的是 GDELT 与石油的关系;这将定义最初工作的范围,并为我们以后的工作奠定基础。在这里很重要的是我们决定如何利用 GDELT 以及这个决定的后果;例如,我们可以决定使用所有时间的所有数据,但是所需的处理时间确实非常大,因为仅一天的 GDELT 事件数据平均为 15 MB,GKG 为 1.5 GB。因此,我们应该分析这两组数据的内容,并尝试确定我们的初始数据输入将是什么。

GDELT 事件

通过查看 GDELT 模式,有一些可能有用的要点;事件模式主要围绕着识别故事中的两个主要参与者并将事件与他们联系起来。还可以查看不同级别的事件,因此我们将有很好的灵活性,可以根据我们的结果在更高或更低的复杂性水平上工作。例如:

EventCode字段是一个 CAMEO 动作代码:0251(呼吁放宽行政制裁),也可以在 02(呼吁)和 025(呼吁让步)级别使用。

因此,我们的第二个假设是,事件的详细程度将从我们的算法中提供更好或更差的准确性。

其他有趣的标签包括GoldsteinScaleNumMentionsLat/LonGoldsteinScale标签是一个从-10 到+10 的数字,它试图捕捉该类型事件对一个国家稳定性的理论潜在影响;这与我们已经确定的关于石油价格稳定性的情况非常匹配。NumMentions标签给出了事件在所有来源文件中出现的频率的指示;如果我们发现需要减少我们处理的事件数量,这可能有助于我们为事件分配重要性。例如,我们可以处理数据并找出在过去一小时、一天或一周中出现频率最高的 10、100 或 1000 个事件。最后,lat/lon标签信息试图为事件分配地理参考点,这在我们想要在 GeoMesa 中制作地图时非常有用。

GDELT GKG

GKG 模式与总结事件内容和提供特定于该内容的增强信息有关。我们感兴趣的领域包括CountsThemesGCAMLocationsCounts字段映射任何数字提及,因此可能允许我们计算严重性,例如 KILLS=47。Themes字段列出了基于 GDELT 类别列表的所有主题;这可能有助于我们随着时间的推移学习影响石油价格的特定领域。GCAM字段是对事件内容的内容分析的结果;快速浏览 GCAM 列表,我们发现有一些可能有用的维度需要注意:

c9.366  9   366   WORDCOUNT   eng   Roget's Thesaurus 1911 Edition   CLASS III - RELATED TO MATTER/3.2 INORGANIC MATTER/3.2.3 IMPERFECT FLUIDS/366 OIL

c18.172  18   172     WORDCOUNT   eng   GDELT   GKG   Themes   ENV_OIL
c18.314  18   314     WORDCOUNT   eng   GDELT   GKG   Themes   ECON_OILPRICE

最后,我们有Locations字段,它提供了与事件类似的信息,因此也可以用于制作地图的可视化。

制定行动计划

在检查了 GDELT 模式之后,我们现在需要做一些决定,确定我们将使用哪些数据,并确保我们根据我们的假设来证明这种用法。这是一个关键阶段,因为有许多方面需要考虑,至少我们需要:

  • 确保我们的假设清晰,这样我们就有一个已知的起点

  • 确保我们清楚地了解如何实施假设,并确定一个行动计划

  • 确保我们使用足够的适当数据来满足我们的行动计划;限定数据使用范围,以确保我们能够在给定的时间范围内得出结论,例如,使用所有 GDELT 数据将是很好的,但除非有一个大型处理集群可用,否则可能不太合理。另一方面,仅使用一天显然不足以评估任何时间段内的任何模式

  • 制定 B 计划,以防我们的初始结果不具有决定性

我们的第二个假设是关于事件的细节;为了清晰起见,在本章中,我们将首先选择一个数据源,以便在模型表现不佳时添加更多复杂性。因此,我们可以选择 GDELT 事件作为上述提到的字段,这些字段为我们的算法提供了一个很好的基础;特别是gcam字段将非常有用于确定事件的性质,而NumMentions字段在考虑事件的重要性时将很快实施。虽然 GKG 数据看起来也很有用,但我们希望在这个阶段尝试使用一般事件;因此,例如 GCAM 油数据被认为太具体,因为这些领域的文章很可能经常涉及对油价变化的反应,因此对于我们的模型来说考虑太晚了。

我们的初始处理流程(行动计划)将涉及以下步骤:

  • 获取过去 5 年的油价数据

  • 获取过去 5 年的 GDELT 事件

  • 安装 GeoMesa 和相关工具

  • 将 GDELT 数据加载到 GeoMesa

  • 构建一个可视化,显示世界地图上的一些事件

  • 使用适当的机器学习算法来学习事件类型与油价的涨跌

  • 使用模型预测油价的涨跌

GeoMesa

GeoMesa 是一个开源产品,旨在利用存储系统的分布式特性,如 Accumulo 和 Cassandra,来保存分布式时空数据库。有了这个设计,GeoMesa 能够运行大规模的地理空间分析,这对于非常大的数据集,包括 GDELT,是必需的。

我们将使用 GeoMesa 来存储 GDELT 数据,并在其中的大部分数据上运行我们的分析;这应该为我们提供足够的数据来训练我们的模型,以便我们可以预测未来油价的涨跌。此外,GeoMesa 还将使我们能够在地图上绘制大量点,以便我们可以可视化 GDELT 和其他有用的数据。

安装

GeoMesa 网站(www.geomesa.org)上有一个非常好的教程,指导用户完成安装过程。因此,我们在这里并不打算制作另一个操作指南;然而,有几点值得注意,可能会节省您在启动一切时的时间。

  • GeoMesa 有很多组件,其中许多组件有很多版本。确保软件堆栈的所有版本与 GeoMesa maven POMs 中指定的版本完全匹配非常重要。特别感兴趣的是 Hadoop、Zookeeper 和 Accumulo;版本位置可以在 GeoMesa 教程和其他相关下载的根pom.xml文件中找到。

  • 在撰写本文时,将 GeoMesa 与某些 Hadoop 供应商堆栈集成时存在一些额外问题。如果可能的话,使用 GeoMesa 与您自己的 Hadoop/Accumulo 等堆栈,以确保版本兼容性。

  • GeoMesa 版本依赖标签已从版本 1.3.0 更改。确保所有版本与您选择的 GeoMesa 版本完全匹配非常重要;如果有任何冲突的类,那么在某个时候肯定会出现问题。

  • 如果您以前没有使用过 Accumulo,我们在本书的其他章节中已经详细讨论过它。初步熟悉将在使用 GeoMesa 时大有裨益(参见第七章,“建立社区”)。

  • 在使用 Accumulo 1.6 或更高版本与 GeoMesa 时,有使用 Accumulo 命名空间的选项。如果您对此不熟悉,则选择不使用命名空间,并将 GeoMesa 运行时 JAR 简单地复制到 Accumulo 根文件夹中的/lib/text中。

  • GeoMesa 使用一些 shell 脚本;由于操作系统的性质,运行这些脚本可能会出现一些问题,这取决于您的平台。这些问题很小,可以通过一些快速的互联网搜索来解决;例如,在运行jai-image.sh时,在 Mac OSX 上会出现用户确认的小问题。

  • GeoMesa 的 maven 仓库可以在repo.locationtech.org/content/repositories/releases/org/locationtech/geomesa/找到

一旦您能够成功地从命令行运行 GeoMesa,我们就可以继续下一节了。

GDELT 摄入

下一阶段是获取 GDELT 数据并将其加载到 GeoMesa 中。这里有许多选择,取决于您打算如何进行;如果您只是在阅读本章,那么可以使用脚本一次性下载数据:

$ mkdir gdelt && cd gdelt
$ wget http://data.gdeltproject.org/events/md5sums
$ for file in `cat md5sums | cut -d' ' -f3 | grep '²⁰¹[56]'` ; do wget http://data.gdeltproject.org/events/$file ; done
$ md5sum -c md5sums 2>&1 | grep '²⁰¹[56]'

这将下载并验证 2015 年和 2016 年的所有 GDELT 事件数据。在这个阶段,我们需要估计所需的数据量,因为我们不知道我们的算法将如何运行,所以我们选择了两年的数据来开始。

脚本的替代方法是阅读第二章,数据获取,其中详细解释了如何配置 Apache NiFi 以实时下载 GDELT 数据,并将其加载到 HDFS 以供使用。否则,可以使用脚本将前述数据传输到 HDFS,如下所示:

$ ls -1 *.zip | xargs -n 1 unzip
$ rm *.zip
$ hdfs dfs -copyFromLocal *.CSV hdfs:///data/gdelt/

注意

HDFS 使用数据块;我们希望确保文件存储尽可能高效。编写一个方法来将文件聚合到 HDFS 块大小(默认为 64 MB)将确保 NameNode 内存不会被许多小文件的条目填满,并且还将使处理更加高效。使用多个块(文件大小> 64 MB)的大文件称为分割文件。

我们在 HDFS 中有大量的数据(大约为 2015/16 年的 48 GB)。现在,我们将通过 GeoMesa 将其加载到 Accumulo 中。

GeoMesa 摄入

GeoMesa 教程讨论了使用MapReduce作业从 HDFS 加载数据到 Accumulo 的想法。让我们来看看这个,并创建一个 Spark 等价物。

MapReduce 到 Spark

由于MapReduceMR)通常被认为已经死亡,或者至少正在消亡,因此了解如何从 MR 中创建 Spark 作业非常有用。以下方法可以应用于任何 MR 作业。我们将考虑 GeoMesa 教程中描述的 GeoMesa Accumulo 加载作业(geomesa-examples-gdelt)。

MR 作业通常由三部分组成:mapper、reducer 和 driver。GeoMesa 示例是一个仅包含 mapper 的作业,因此不需要 reducer。该作业接收 GDELT 输入行,从空的Text对象和创建的 GeoMesa SimpleFeature创建一个(Key,Value)对,并使用GeoMesaOutputFormat将数据加载到 Accumulo。MR 作业的完整代码可以在我们的仓库中找到;接下来,我们将逐步介绍关键部分并建议 Spark 所需的更改。

作业是从main方法启动的;前几行与从命令行解析所需选项有关,例如 Accumulo 用户名和密码。然后我们到达:

SimpleFeatureType featureType =
    buildGDELTFeatureType(featureName);
DataStore ds = DataStoreFinder.getDataStore(dsConf);
ds.createSchema(featureType);
runMapReduceJob(featureName, dsConf,
    new Path(cmd.getOptionValue(INGEST_FILE)));

GeoMesa SimpleFeatureType是用于在 GeoMesa 数据存储中存储数据的主要机制,需要初始化一次,以及数据存储初始化。完成这些后,我们执行 MR 作业本身。在 Spark 中,我们可以像以前一样通过命令行传递参数,然后进行一次性设置:

spark-submit --class io.gzet.geomesa.ingest /
             --master yarn /
             geomesa-ingest.jar <accumulo-instance-id>
...

jar 文件的内容包含了一个标准的 Spark 作业:

val conf = new SparkConf()
val sc = new SparkContext(conf.setAppName("Geomesa Ingest"))

像以前一样解析命令行参数,并执行初始化:

val featureType = buildGDELTFeatureType(featureName)
val ds = DataStoreFinder
   .getDataStore(dsConf)
   .createSchema(featureType)

现在我们可以从 HDFS 加载数据,如果需要可以使用通配符。这将为文件的每个块(默认为 64 MB)创建一个分区,从而产生一个RDD[String]

val distDataRDD = sc.textFile(/data/gdelt/*.CSV)

或者我们可以根据可用资源来固定分区的数量:

val distDataRDD = sc.textFile(/data/gdelt/*.CSV, 20) 

然后我们可以执行 map,其中我们可以嵌入函数来替换原始 MRmap方法中的过程。我们创建一个元组(Text,SimpleFeatureType)来复制一个(Key,Value)对,以便我们可以在下一步中使用OutputFormat。当以这种方式创建 Scala 元组时,生成的 RDD 会获得额外的方法,比如ReduceByKey,它在功能上等同于 MR Reducer(有关我们真正应该使用的mapPartitions的更多信息,请参见下文):

val processedRDD = distDataRDD.map(s =>{
   // Processing as before to build the SimpleFeatureType
   (new Text, simpleFeatureType)
})

然后,我们最终可以使用原始作业中的GeomesaOutputFormat输出到 Accumulo:

processedRDD.saveAsNewAPIHadoopFile("output/path", classOf[Text], classOf[SimpleFeatureType], classOf[GeomesaOutputFormat])

在这个阶段,我们还没有提到 MR 作业中的setup方法;这个方法在处理任何输入之前被调用,用来分配一个昂贵的资源,比如数据库连接,或者在我们的情况下,一个可重用的对象,然后使用cleanup方法来释放资源,如果它在作用域外持续存在的话。在我们的情况下,setup方法用来创建一个SimpleFeatureBuilder,它可以在每次调用 mapper 时重复使用来构建输出的SimpleFeatures;没有cleanup方法,因为当对象超出作用域时,内存会自动释放(代码已经完成)。

Spark 的map函数一次只对一个输入进行操作,并且没有办法在转换一批值之前或之后执行代码。在调用map之前和之后放置设置和清理代码似乎是合理的。

// do setup work 
val processedRDD = distDataRDD.map(s =>{ 
   // Processing as before to build the SimpleFeatureType 
   (new Text, simpleFeatureType) 
}) 
// do cleanup work 

但是,这失败的原因有几个:

  • 它将map中使用的任何对象放入 map 函数的闭包中,这要求它是可序列化的(例如,通过实现java.io.Serializable)。并非所有对象都是可序列化的,因此可能会抛出异常。

  • map函数是一个转换,而不是一个操作,它是惰性评估的。因此,在map函数之后的指令不能保证立即执行。

  • 即使前面的问题针对特定的实现进行了处理,我们只会在驱动程序上执行代码,而不一定会释放由序列化副本分配的资源。

Spark 中最接近 mapper 的方法是mapPartitions方法。这个方法不仅仅是将一个值映射到另一个值,而是将一个值的迭代器映射到另一个值的迭代器,类似于批量映射方法。这意味着mapPartitions可以在开始时在本地分配资源:

val processedRDD = distDataRDD.mapPartitions { valueIterator =>
   // setup code for SimpleFeatureBuilder
   val transformed = valueIterator.map( . . . )
   transformed
}

然而,释放资源(cleanup)并不简单,因为我们仍然遇到了惰性评估的问题;如果资源在map之后被释放,那么在这些资源消失之前,迭代器可能还没有被评估。解决这个问题的一个方法如下:

val processedRDD = distDataRDD.mapPartitions { valueIterator =>
  if (valueIterator.isEmpty) {
    // return an Iterator
  } else {
    //  setup code for SimpleFeatureBuilder
    valueIterator.map { s =>
// Processing as before to build the SimpleFeatureType
      val simpleFeature =
      if (!valueIterator.hasNext) {
       // cleanup here
      }
      simpleFeature
    }
  }
}

现在我们有了用于摄取的 Spark 代码,我们可以进行额外的更改,即添加一个Geohash字段(有关如何生成此字段的更多信息,请参见以下内容)。要将此字段插入代码,我们需要在 GDELT 属性列表的末尾添加一个额外的条目:

Geohash:String 

并设置simpleFeature类型的值的一行:

simpleFeature.setAttribute(Geomesa, calculatedGeoHash)

最后,我们可以运行我们的 Spark 作业,从 HDFS 加载 GDELT 数据到 GeoMesa Accumulo 实例。GDELT 的两年数据大约有 1 亿条目!您可以通过使用 Accumulo shell 来检查 Accumulo 中有多少数据,从accumulo/bin目录运行:

./accumulo shell -u username -p password -e "scan -t gdelt_records -np" | wc

地理哈希

地理哈希是由 Gustavo Niemeyer 发明的地理编码系统。它是一种分层的空间数据结构,将空间细分为网格形状的桶,这是所谓的 Z 顺序曲线和一般空间填充曲线的许多应用之一。

地理哈希提供了诸如任意精度和逐渐删除代码末尾的字符以减小其大小(逐渐失去精度)等属性。

由于逐渐精度下降的结果,附近的地理位置通常(但并非总是)会呈现相似的前缀。共享前缀越长,两个位置越接近;这在 GeoMesa 中非常有用,因为我们可以使用前面摄入代码中添加的Geohash字段,如果我们想要使用特定区域的点。

Geohashes 的主要用途是:

  • 作为唯一标识符

  • 例如,在数据库中表示点数据

在数据库中使用时,地理哈希数据的结构具有两个优点。首先,通过 Geohash 索引的数据将在给定矩形区域的所有点在连续的切片中(切片数量取决于所需的精度和 Geohash 故障线的存在)。这在数据库系统中特别有用,因为单个索引上的查询比多个索引查询更容易或更快:例如,Accumulo。其次,这种索引结构可以用于快速的近似搜索:最接近的点通常是最接近的 Geohashes。这些优势使 Geohashes 非常适合在 GeoMesa 中使用。以下是 David Allsopp 出色的 Geohash scala 实现的代码摘录github.com/davidallsopp/geohash-scala。此代码可用于基于lat/lon输入生成 Geohashes:

/** Geohash encoding/decoding as per http://en.wikipedia.org/wiki/Geohash */
object Geohash {

  val LAT_RANGE = (-90.0, 90.0)
  val LON_RANGE = (-180.0, 180.0)

  // Aliases, utility functions
  type Bounds = (Double, Double)
  private def mid(b: Bounds) = (b._1 + b._2) / 2.0
  implicit class BoundedNum(x: Double) { def in(b: Bounds): Boolean = x >= b._1 && x <= b._2 }

  /**
   * Encode lat/long as a base32 geohash.
   *
   * Precision (optional) is the number of base32 chars desired; default is 12, which gives precision well under a meter.
   */
  def encode(lat: Double, lon: Double, precision: Int=12): String = { // scalastyle:ignore
    require(lat in LAT_RANGE, "Latitude out of range")
    require(lon in LON_RANGE, "Longitude out of range")
    require(precision > 0, "Precision must be a positive integer")
    val rem = precision % 2 // if precision is odd, we need an extra bit so the total bits divide by 5
    val numbits = (precision * 5) / 2
    val latBits = findBits(lat, LAT_RANGE, numbits)
    val lonBits = findBits(lon, LON_RANGE, numbits + rem)
    val bits = intercalatelonBits, latBits)
    bits.grouped(5).map(toBase32).mkString // scalastyle:ignore
  }

  private def findBits(part: Double, bounds: Bounds, p: Int): List[Boolean] = {
    if (p == 0) Nil
    else {
      val avg = mid(bounds)
      if (part >= avg) true :: findBits(part, (avg, bounds._2), p - 1)
// >= to match geohash.org encoding
      else false :: findBits(part, (bounds._1, avg), p - 1)
    }
  }

  /**
   * Decode a base32 geohash into a tuple of (lat, lon)
   */
  def decode(hash: String): (Double, Double) = {
    require(isValid(hash), "Not a valid Base32 number")
    val (odd, even) =toBits(hash).foldRight((List[A](), List[A]())) { case (b, (a1, a2)) => (b :: a2, a1) }
    val lon = mid(decodeBits(LON_RANGE, odd))
    val lat = mid(decodeBits(LAT_RANGE, even))
    (lat, lon)
  }

  private def decodeBits(bounds: Bounds, bits: Seq[Boolean]) =
    bits.foldLeft(bounds)((acc, bit) => if (bit) (mid(acc), acc._2) else (acc._1, mid(acc)))
}

def intercalateA: List[A] = a match {
 case h :: t => h :: intercalate(b, t)
 case _ => b
}

Geohash 算法的一个局限性在于试图利用它来找到具有共同前缀的相邻点。接近的边缘情况位置,它们彼此靠近,但位于 180 度子午线的对立面,将导致没有共同前缀的 Geohash 代码(接近物理位置的不同经度)。在北极和南极附近的点将具有非常不同的 Geohashes(接近物理位置的不同经度)。

此外,赤道(或格林威治子午线)两侧的两个接近位置将不会有长的公共前缀,因为它们属于世界的不同半球;一个位置的二进制纬度(或经度)将是 011111...,另一个位置将是 100000...,因此它们不会有共同的前缀,大多数位将被翻转。

为了进行近似搜索,我们可以计算一个边界框的西南角(低纬度和经度的低 Geohash)和东北角(高纬度和经度的高 Geohash),并搜索这两者之间的 Geohashes。这将检索两个角之间 Z 顺序曲线上的所有点;这在 180 子午线和极点处也会中断。

最后,由于 Geohash(在此实现中)是基于经度和纬度坐标的,两个 Geohashes 之间的距离反映了两点之间纬度/经度坐标的距离,这并不等同于实际距离。在这种情况下,我们可以使用Haversine公式:

Geohash

这给我们提供了考虑到地球曲率的两点之间的实际距离,其中:

  • r是球体的半径,

  • φ1φ2:点 1 的纬度和点 2 的纬度,以弧度表示

  • λ1λ2:点 1 的经度和点 2 的经度,以弧度表示

GeoServer

现在我们已经成功通过 GeoMesa 将 GDELT 数据加载到 Accumulo 中,我们可以开始在地图上可视化这些数据;例如,这个功能对于在世界地图上绘制分析结果非常有用。GeoMesa 与 GeoServer 很好地集成在一起。GeoServer 是一个符合开放地理空间联盟OGC)标准的实现,包括Web 要素服务WFS)和Web 地图服务WMS)。"它可以发布来自任何主要空间数据源的数据"。

我们将使用 GeoServer 以清晰、可呈现的方式查看我们分析结果。同样,我们不会深入研究如何启动和运行 GeoServer,因为 GeoMesa 文档中有一个非常好的教程,可以实现两者的集成。需要注意的一些常见点如下:

  • 系统使用Java 高级图像JAI)库;如果您在 Mac 上遇到问题,通常可以通过从默认 Java 安装中删除库来解决这些问题:
        rm /System/Library/Java/Extensions/jai_*.

然后可以使用 GeoServer 版本,位于$GEOSERVER_HOME/webapps/geoserver/WEB-INF/lib/

  • 再次强调版本的重要性。您必须非常清楚您正在使用的主要模块的版本,例如 Hadoop,Accumulo,Zookeeper,最重要的是 GeoMesa。如果混合使用不同版本,您将遇到问题,而堆栈跟踪通常会掩盖真正的问题。如果确实遇到异常,请检查并反复检查您的版本。

地图图层

一旦 GeoServer 运行,我们就可以创建一个用于可视化的图层。GeoServer 使我们能够发布单个或一组图层以生成图形。创建图层时,我们可以指定边界框,查看要素(这是我们之前在 Spark 代码中创建的SimpleFeature),甚至运行通用查询语言CQL)查询来过滤数据(后面将更多介绍)。创建图层后,选择图层预览和 JPG 选项将生成一个类似以下的图形的 URL;这里的时间边界是 2016 年 1 月,以便地图不会过于拥挤:

地图图层

URL 可以用于通过操作参数生成其他图形。以下是 URL 的简要分解:

具有标准的geoserverURL:

http://localhost:8080/geoserver/geomesa/wms?

“请求”类型:

service=WMS&version=1.1.0&request=GetMap& 

“图层”和“样式”:

layers=geomesa:event&styles=& 

如果需要,设置图层的“透明度”:

transparency=true& 

在这种情况下,cql语句是任何具有GoldsteinScale>8条目的行:

cql_filter=GoldsteinScale>8& 

边界框bbox

bbox=-180.0,-90.0,180.0,90.0& 

图形的“高度”和“宽度”:

width=768&height=384& 

源和“图像”类型:

srs=EPSG:4326&format=image%2Fjpeg& 

通过时间查询边界过滤内容:

time=2016-01-01T00:00:00.000Z/2016-01-30T23:00:00.000Z 

本节的最后一步是将世界地图附加到此图层,以使图像更易读。如果您在互联网上搜索世界地图形状文件,会有许多选项;我们使用了thematicmapping.org上的一个选项。将其中一个添加到 GeoServer 作为形状文件存储,然后创建和发布一个图层,再创建我们的 GDELT 数据和形状文件的图层组,将产生类似于以下图像的图像:

地图图层

为了使事情更有趣,我们根据FeatureType中的GoldsteinScale字段过滤了事件。通过在 URL 中添加cql_filter=GoldsteinScale > 8,我们可以绘制所有GoldsteinScale分数大于八的点;因此,上面的图像向我们展示了 2016 年 1 月世界上积极情绪水平最高的地方在哪里!

CQL

通用查询语言CQL)是由 OGC 为目录 Web 服务规范创建的一种纯文本查询语言。它是一种人类可读的查询语言(不像,例如,OGC 过滤器),并且使用与 SQL 类似的语法。尽管与 SQL 类似,但 CQL 的功能要少得多;例如,它在要求属性在任何比较运算符的左侧时非常严格。

以下列出了 CQL 支持的运算符:

  • 比较运算符:=,<>,>,>=,<,<=

  • ID、列表和其他运算符:BETWEEN,BEFORE,AFTER,LIKE,IS,EXISTS,NOT,IN

  • 算术表达式运算符:+,-,*,/

  • 几何运算符:EQUALS,DISJOINT,INTERSECTS,TOUCHES,CROSSES,WITHIN,CONTAINS,OVERLAPS,RELATE,DWITHIN,BEYOND

由于 CQL 的限制,GeoServer 提供了一个名为 ECQL 的 CQL 扩展版本。ECQL 提供了 CQL 的许多缺失功能,提供了一种更灵活的语言,与 SQL 更相似。GeoServer 支持在 WMS 和 WFS 请求中使用 CQL 和 ECQL。

测试 CQL 查询的最快方法是修改图层的 URL,例如我们上面创建的图层,例如使用 JPG,或者在 GeoMesa 的图层选项底部使用 CQL 框。

如果我们在一个 WMS 请求中定义了几个图层,比如:

http://localhost:8080/geoserver/wms?service=WMS&version=1.1.0&request=GetMap&layers=layer1,layer2,layer3   ...   

然后我们可能想要使用 CQL 查询过滤其中一个图层。在这种情况下,CQL 过滤器必须按照图层的顺序进行排序;我们使用INCLUDE关键字来表示我们不想过滤的图层,并使用“;”进行分隔。例如,在我们的示例中,要仅过滤layer2,WMS 请求将如下所示:

http://localhost:8080/geoserver/wms?service=WMS&version=1.1.0&request=GetMap&layers=layer1,layer2,layer3&cql_filter=INCLUDE;(LAYER2_COL='value');INCLUDE...   

注意

在使用Date类型的列时要注意;我们需要确定它们的格式,然后再尝试使用 CQL。通常它们将采用 ISO8601 格式;2012-01-01T00:00:00Z。然而,根据数据加载的方式,可能会出现不同的格式。在我们的示例中,我们已确保 SQLDATE 的格式是正确的。

测量油价

现在我们的数据存储中有大量数据(我们可以始终使用前面的 Spark 作业添加更多数据),我们将继续查询这些数据,使用 GeoMesa API,准备好行以应用于我们的学习算法。当然,我们可以使用原始 GDELT 文件,但以下方法是一个有用的工具。

使用 GeoMesa 查询 API

GeoMesa 查询 API 使我们能够基于时空属性查询结果,同时利用数据存储的并行化,本例中是 Accumulo 和其迭代器。我们可以使用 API 构建SimpleFeatureCollections,然后解析以实现 GeoMesaSimpleFeatures,最终匹配我们查询的原始数据。

在这个阶段,我们应该构建通用的代码,这样我们可以很容易地改变它,如果我们决定以后没有使用足够的数据,或者也许如果我们需要改变输出字段。最初,我们将提取一些字段;SQLDATEActor1NameActor2NameEventCode。我们还应该决定我们查询的边界框;因为我们正在查看三种不同的石油指数,所以我们需要决定事件的地理影响如何与石油价格本身相关。这是最难评估的变量之一,因为在价格确定中涉及了很多因素;可以说边界框是整个世界。然而,由于我们使用了三个指数,我们将假设每个指数都有自己的地理限制,这是基于有关石油供应地区和需求地区的研究。如果我们有更多相关信息,或者结果不理想并且需要重新评估,我们随时可以稍后改变这些边界。建议的初始边界框是:

  • 布伦特:北海和英国(供应)和中欧(需求):34.515610,-21.445313 - 69.744748,36.914063

  • WTI:美国(供应)和西欧(需求):-58.130121,-162.070313,71.381635,-30.585938

  • 欧佩克:中东(供应)和欧洲(需求):-38.350273,-20.390625,38.195022,149.414063

从 GeoMesa 提取结果的代码如下(布伦特原油):

object CountByWeek {

   // specify the params for the datastore
   val params = Map(
     "instanceId" -> "accumulo",
     "zookeepers" -> "127.0.0.1:2181",
     "user"       -> "root",
     "password"   -> "accumulo",
     "tableName"  -> "gdelt")

   // matches the params in the datastore loading code
   val typeName      = "event"
   val geom          = "geom"
   val date          = "SQLDATE"
   val actor1        = "Actor1Name"
   val actor2        = "Actor2Name"
   val eventCode     = "EventCode"
   val numArticles   = "NumArticles"

   // specify the geographical bounding
   val bbox   = "34.515610, -21.445313, 69.744748, 36.914063"

  // specify the temporal bounding
  val during = "2016-01-01T00:00:00.000Z/2016-12-30T00:00:00.000Z"

  // create the filter
  val filter = s"bbox($geom, $bbox) AND $date during $during"

  def main(args: Array[String]) {
    // Get a handle to the data store
    val ds = DataStoreFinder
       .getDataStore(params)
       .asInstanceOf[AccumuloDataStore]

    // Construct a CQL query to filter by bounding box
    val q = new Query(typeName, ECQL.toFilter(filter))

    // Configure Spark
    val sc = new SparkContext(GeoMesaSpark.init(
       new SparkConf(true), ds))

     // Create an RDD from the query
     val simpleFeaureRDD = GeoMesaSpark.rdd(new Configuration,
       sc, params, q)

     // Convert RDD[SimpleFeature] to RDD[Row] for DataFrame creation below
     val gdeltAttrRDD = simpleFeaureRDD.mapPartitions { iter =>
       val df = new SimpleDateFormat("yyyy-MM-dd")
       val ff = CommonFactoryFinder.getFilterFactory2
       val dt = ff.property(date)
       val a1n = ff.property(actor1)
       val a2n = ff.property(actor2)
       val ec = ff.property(eventCode)
       val na = ff.property(numArticles)
       iter.map { f =>
         Row(
           df.format(dt.evaluate(f).asInstanceOf[java.util.Date]),
           a1n.evaluate(f),
           a2n.evaluate(f),
           ec.evaluate(f),
           na.evaluate(f)
         )
       }
     }
   }
}

RDD[Row]集合可以按以下方式写入磁盘以供将来使用:

gdeltAttrRDD.saveAsTextFile("/data/gdelt/brent-2016-rdd-row)

注意

我们应该在这一点上尽可能多地读取数据,以便为我们的算法提供大量的训练数据。我们将在以后的阶段将我们的输入数据分为训练和测试数据。因此,没有必要保留任何数据。

数据准备

在这个阶段,我们已经根据边界框和日期范围从 GeoMesa 获取了我们的数据,用于特定的石油指数。输出已经被组织起来,以便我们有一系列行,每一行包含一个事件的所谓重要细节。我们不确定我们为每个事件选择的字段是否完全相关,能够提供足够的信息来构建可靠的模型,因此,根据我们的结果,这是我们可能需要在以后进行实验的事情。接下来,我们需要将数据转换为可以被我们的学习过程使用的形式。在这种情况下,我们将数据聚合成为一周的数据块,并将数据转换为典型的“词袋”,首先从上一步加载数据开始:

val gdeltAttrRDD = sc.textFile("/data/gdelt/brent-2016-rdd-row)

在这个 RDD 中,我们有EventCodes(CAMEO 代码):这些将需要转换为它们各自的描述,以便构建词袋。通过从gdeltproject.org/data/lookups/CAMEO.eventcodes.txt下载 CAMEO 代码,我们可以为下一步创建一个Map对象:

var cameoMap = scala.collection.mutable.Map[String, String]()

val linesRDD = sc.textFile("file://CAMEO.eventcodes.txt")
linesRDD.collect.foreach(line => {
  val splitsArr = line.split("\t")
  cameoMap += (splitsArr(0) -> splitsArr(1).
replaceAll("[^A-Za-z0-9 ]", ""))
})

请注意,我们通过删除任何非标准字符来规范化输出;这样做的目的是尝试避免错误字符影响我们的训练模型。

现在我们可以通过在EventCode映射描述的两侧附加演员代码来创建我们的bagOfWordsRDD,并从日期和形成的句子创建一个 DataFrame:

val bagOfWordsRDD = gdeltAttrRDD.map(f => Row(
   f.get(0),
   f.get(1).toString.replaceAll("\\s","").
     toLowerCase + " " + cameoMap(f.get(3).toString).
     toLowerCase + " " + f.get(2).toString.replaceAll("\\s","").
     toLowerCase)
 )

 val gdeltSentenceStruct = StructType(Array(
   StructField("Date", StringType, true),
   StructField("sentence", StringType, true)
 ))

 val gdeltSentenceDF 
 spark.createDataFrame(bagOfWordsRDD,gdeltSentenceStruct)
 gdeltSentenceDF.show(false)

+----------+-----------------------------------------------------+
|Date      |sentence                                             |
+----------+-----------------------------------------------------+
|2016-01-02|president demand not specified below unitedstates    |
|2016-01-02|vladimirputin engage in negotiation beijing          |
|2016-01-02|northcarolina make pessimistic comment neighborhood  |
+----------+-----------------------------------------------------+

我们之前提到过,我们可以在每日、每周甚至每年的水平上处理我们的数据;通过选择每周,我们接下来需要按周对我们的 DataFrame 进行分组。在 Spark 2.0 中,我们可以使用窗口函数轻松实现这一点:

val windowAgg = gdeltSentenceDF.
    groupBy(window(gdeltSentenceDF.col("Date"),
      "7 days", "7 days", "1 day"))
val sentencesDF = windowAgg.agg(
    collect_list("sentence") as "sentenceArray")

由于我们将为每周末生成石油价格数据,因此我们应确保我们的句子数据在周五到周四之间分组,以便稍后可以将其与该周五的价格数据进行连接。这是通过更改window函数的第四个参数来实现的;在这种情况下,一天提供了正确的分组。如果我们运行命令sentencesDF.printSchema,我们将看到sentenceArray列是一个字符串数组,而我们需要的是学习算法的输入的一个字符串。下一个代码片段演示了这种变化,以及生成commonFriday列,它为我们每一行工作的日期提供了一个参考,以及一个我们稍后可以连接的唯一键:

val convertWrappedArrayToStringUDF = udf {(array: WrappedArray[String]) =>
  array.mkString(" ")
 }

val dateConvertUDF = udf {(date: String) =>
  new SimpleDateFormat("yyyy-MM-dd").
    format(new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").
      parse(date))
  }

val aggSentenceDF = sentencesDF.withColumn("text",
 convertWrappedArrayToStringUDF(
   sentencesDF("sentenceArray"))).
      withColumn("commonFriday", dateConvertUDF(sentencesDF("window.end")))

aggSentenceDF.show

+--------------------+-----------------+--------------+-------------+
|              window|    sentenceArray|          text| commonFriday|
+--------------------+-----------------+--------------+-------------+
|[2016-09-09 00:00...|[unitedstates app|unitedstates a|   2016-09-16|
|[2016-06-24 00:00...|[student make emp|student make e|   2016-07-01|
|[2016-03-04 00:00...|[american provide|american provi|   2016-03-11|
+--------------------+-----------------+--------------+-------------+

下一步是收集我们的数据并为下一阶段的使用进行标记。为了对其进行标记,我们必须对下载的油价数据进行归一化处理。在本章的前面部分,我们提到了数据点的频率;目前数据包含日期和当天结束时的价格。我们需要将我们的数据转换为元组(日期,变化),其中日期是该周五的日期,变化是基于从上周一开始的每日价格的平均值的上升或下降;如果价格保持不变,我们将把这视为下降,以便稍后可以实现二进制值学习算法。

我们可以再次使用 Spark DataFrames 中的窗口功能轻松地按周对数据进行分组;我们还将重新格式化日期,以便窗口组函数正确执行:

// define a function to reformat the date field
def convert(date:String) : String = {
  val dt = new SimpleDateFormat("dd/MM/yyyy").parse(date)
  new SimpleDateFormat("yyyy-MM-dd").format(dt)
}

val oilPriceDF = spark
  .read
  .option("header","true")
  .option("inferSchema", "true")
  .csv("oil-prices.csv")

// create a User Defined Function for the date changes
val convertDateUDF = udf {(Date: String) => convert(Date)}

val oilPriceDatedDF = oilPriceDF.withColumn("DATE", convertDateUDF(oilPriceDF("DATE")))

// offset to start at beginning of week, 4 days in this case
val windowDF = oilPriceDatedDF.groupBy(window(oilPriceDatedDF.col("DATE"),"7 days", "7 days", "4 days"))

// find the last value in each window, this is the trading close price for that week
val windowLastDF = windowDF.agg(last("PRICE") as "last(PRICE)"
).sort("window")

windowLastDF.show(20, false)

这将产生类似于这样的东西:

+---------------------------------------------+-----------+
|window                                       |last(PRICE)|
+---------------------------------------------+-----------+
|[2011-11-21 00:00:00.0,2011-11-28 00:00:00.0]|106.08     |
|[2011-11-28 00:00:00.0,2011-12-05 00:00:00.0]|109.59     |
|[2011-12-05 00:00:00.0,2011-12-12 00:00:00.0]|107.91     |
|[2011-12-12 00:00:00.0,2011-12-19 00:00:00.0]|104.0      |
+---------------------------------------------+-----------+

现在我们可以计算上周的涨跌幅;首先通过将上周的last(PRICE)添加到每一行(使用 Spark 的lag函数),然后计算结果:

val sortedWindow = Window.orderBy("window.start")

// add the previous last value to each row
val lagLastCol = lag(col("last(PRICE)"), 1).over(sortedWindow)
val lagLastColDF = windowLastDF.withColumn("lastPrev(PRICE)", lagLastCol)

// create a UDF to calculate the price rise or fall
val simplePriceChangeFunc = udf{(last : Double, prevLast : Double) =>
  var change = ((last - prevLast) compare 0).signum
  if(change == -1)
    change = 0
  change.toDouble
}

// create a UDF to calculate the date of the Friday for that week
val findDateTwoDaysAgoUDF = udf{(date: String) =>
  val dateFormat = new SimpleDateFormat( "yyyy-MM-dd" )
  val cal = Calendar.getInstance
  cal.setTime( dateFormat.parse(date))
  cal.add( Calendar.DATE, -3 )
  dateFormat.format(cal.getTime)
}

val oilPriceChangeDF = lagLastColDF.withColumn("label", simplePriceChangeFunc(
  lagLastColDF("last(PRICE)"),
  lagLastColDF("lastPrev(PRICE)")
)).withColumn("commonFriday", findDateTwoDaysAgoUDF(lagLastColDF("window.end"))

oilPriceChangeDF.show(20, false)

+--------------------+-----------+---------------+-----+------------+
|              window|last(PRICE)|lastPrev(PRICE)|label|commonFriday|
+--------------------+-----------+---------------+-----+------------+
|[2015-12-28 00:00...|       36.4|           null| null|  2016-01-01|
|[2016-01-04 00:00...|      31.67|           36.4|  0.0|  2016-01-08|
|[2016-01-11 00:00...|       28.8|          31.67|  0.0|  2016-01-15|
+--------------------+-----------+---------------+-----+------------+

您会注意到使用了signum函数;这对于比较非常有用,因为它产生以下结果:

  • 如果第一个值小于第二个值,则输出-1

  • 如果第一个值大于第二个值,则输出+1

  • 如果两个值相等,则输出 0

现在我们有了两个 DataFrame,aggSentenceDFoilPriceChangeDF,我们可以使用commonFriday列将这两个数据集连接起来,以产生一个带标签的数据集:

val changeJoinDF = aggSentenceDF
 .drop("window")
 .drop("sentenceArray")
 .join(oilPriceChangeDF, Seq("commonFriday"))
 .withColumn("id", monotonicallyIncreasingId)

我们还删除窗口和sentenceArray列,并添加一个 ID 列,以便我们可以唯一引用每一行:

changeJoinDF,show
+------------+---------+---------+-----------+---------+-----+------+
|commonFriday|     text|   window|last(PRICE)| lastPrev|label|    id|
+------------+---------+---------+-----------+---------+-----+------+
|  2016-09-16|unitedsta|[2016-09-|      45.26|    48.37|  0.0|   121|
|  2016-07-01|student m|[2016-06-|      47.65|    46.69|  1.0|   783|
|  2016-03-11|american |[2016-03-|      39.41|    37.61|  1.0|   356|
+------------+---------+---------+-----------+---------+-----+------+

机器学习

现在我们有了输入数据和每周的价格变动;接下来,我们将把我们的 GeoMesa 数据转换成机器学习模型可以处理的数值向量。Spark 机器学习库 MLlib 有一个叫做HashingTF的实用程序来做到这一点。HashingTF通过对每个术语应用哈希函数,将词袋转换为术语频率向量。因为向量有有限数量的元素,可能会出现两个术语映射到相同的哈希术语;哈希化的向量特征可能不完全代表输入文本的实际内容。因此,我们将设置一个相对较大的特征向量,容纳 10,000 个不同的哈希值,以减少这些碰撞的机会。这背后的逻辑是,可能事件只有那么多(不管它们的大小),因此先前看到的事件的重复应该产生类似的结果。当然,事件的组合可能会改变这一点,这是通过最初采取一周的时间块来考虑的。为了正确格式化输入数据以供HashingTF使用,我们还将在输入文本上执行一个Tokenizer

val tokenizer = new Tokenizer().
   setInputCol("text").
   setOutputCol("words")
 val hashingTF = new HashingTF().
   setNumFeatures(10000).
   setInputCol(tokenizer.getOutputCol).
   setOutputCol("rawFeatures")

最后的准备步骤是实现逆文档频率IDF),这是每个术语提供多少信息的数值度量:

val idf = new IDF().
  setInputCol(hashingTF.getOutputCol).
  setOutputCol("features")

为了这个练习的目的,我们将实现一个朴素贝叶斯实现来执行我们功能的机器学习部分。这个算法是一个很好的初始拟合,可以从一系列输入中学习结果;在我们的情况下,我们希望学习在给定上周一系列事件的情况下,油价的增加或减少。

朴素贝叶斯

朴素贝叶斯是一种简单的构建分类器的技术:模型将类标签分配给问题实例,表示为特征值向量,其中类标签来自某个有限集合。朴素贝叶斯在 Spark MLlib 中可用,因此:

val nb = new NaiveBayes() 

我们可以使用 MLlib Pipeline 将所有上述步骤绑在一起;Pipeline 可以被认为是一个简化多个算法组合的工作流程。从 Spark 文档中,一些定义如下:

  • DataFrame:这个 ML API 使用来自 Spark SQL 的 DataFrame 作为 ML 数据集,可以容纳各种数据类型。例如,一个 DataFrame 可以有不同的列存储文本、特征向量、真实标签和预测。

  • 转换器:转换器是一种可以将一个 DataFrame 转换为另一个 DataFrame 的算法。例如,一个 ML 模型是一个将带有特征的 DataFrame 转换为带有预测的 DataFrame 的转换器。

  • 估计器:估计器是一种可以“拟合”DataFrame 以产生转换器的算法。例如,学习算法是一个可以在 DataFrame 上进行训练并产生模型的估计器。

  • Pipeline:Pipeline 将多个转换器和估计器链接在一起,以指定一个 ML 工作流程。

pipeline被声明如下:

val pipeline = new Pipeline().
  setStages(Array(tokenizer, hashingTF, idf, nb))

我们之前注意到,所有可用的数据都应该从 GeoMesa 中读取,因为我们将在后期分割数据,以提供训练和测试数据集。这是在这里执行的:

val splitDS = changeJoinDF.randomSplit(Array(0.75,0.25))
val (trainingDF,testDF) = (splitDS(0),splitDS(1))

最后,我们可以执行完整的模型:

val model = pipeline.fit(trainingDF)

模型可以轻松保存和加载:

model.save("/data/models/gdelt-naivebayes-2016") 
val naivebayesModel = PipelineModel.load("/data/models/Gdelt-naivebayes-2016") 

结果

为了测试我们的模型,我们应该执行model转换器,如下所述:

model
  .transform(testDF)
  .select("id", "prediction", "label").
  .collect()
  .foreach {
    case Row(id: Long, pred: Double, label: Double) =>
       println(s"$id --> prediction=$pred --> should be: $label")
  }

这为每个输入行提供了一个预测:

8847632629761 --> prediction=1.0 --> should be: 1.0
1065151889408 --> prediction=0.0 --> should be: 0.0
1451698946048 --> prediction=1.0 --> should be: 1.0

结果,从结果 DataFrame 中取出(model.transform(testDF).select("rawPrediction", "probability", "prediction").show),如下所示:

+--------------------+--------------------+----------+
|       rawPrediction|         probability|prediction|
+--------------------+--------------------+----------+
|[-6487.5367247911...|[2.26431216092671...|       1.0|
|[-8366.2851849035...|[2.42791395068146...|       1.0|
|[-4309.9770937765...|[3.18816589322004...|       1.0|
+--------------------+--------------------+----------+

分析

在像石油价格预测这样的问题领域中,要创建一个真正成功的算法总是非常困难/几乎不可能的,因此本章始终是更多地向演示性质靠拢。然而,我们有了结果,它们的合法性并不无关紧要;我们用石油指数和 GDELT 的几年数据训练了上述算法,然后从模型执行的结果中获取了结果,再将其与正确的标签进行比较。

在测试中,先前的模型显示了 51%的准确性。这比我们从简单地随机选择结果所期望的稍微好一点,但为改进提供了坚实的基础。通过保存数据集和模型的能力,在努力提高准确性的过程中,对模型进行更改将是直截了当的。

有许多可以改进的地方,我们在本章已经提到了其中一些。为了改进我们的模型,我们应该以系统化的方式解决特定领域的问题。由于我们只能就哪些改变会带来改进做出合理猜测,因此重要的是首先尝试解决最关键的问题领域。接下来,我们简要总结一下我们可能如何处理这些改变。我们应该经常检查我们的假设,确定它们是否仍然有效,或者需要做出哪些改变。

假设 1:“石油的供需受世界事件的影响更大,因此我们可以预测供需可能会是什么样。”我们初步尝试建立的模型显示了 51%的准确性;虽然这还不足以确定这个假设是否有效,但在放弃这个假设之前,继续改进模型的其他方面是值得的。

假设 2:“事件的详细程度将从我们的算法中提供更好或更差的准确性。”在这里,我们有很大的改变空间;有几个领域我们可以修改代码并快速重新运行模型,例如:

  • 事件数量:增加是否会影响准确性?

  • 每日/每周/每月的数据汇总:每周汇总可能永远不会产生良好的结果

  • 有限的数据集:我们目前只使用了 GDELT 的少数字段,增加更多字段是否有助于提高准确性?

  • 排除其他类型的数据:引入 GKG 数据是否有助于提高准确性?

总之,我们可能比开始时有更多的问题;然而,我们现在已经做好了基础工作,建立了一个初步模型,希望能够提高准确性,并进一步了解数据及其对石油价格的潜在影响。

总结

在本章中,我们介绍了将数据以时空方式存储的概念,以便我们可以使用 GeoMesa 和 GeoServer 来创建和运行查询。我们展示了这些查询在这些工具本身以及以编程方式执行的情况,利用 GeoServer 来显示结果。此外,我们还演示了如何合并不同的工件,纯粹从原始的 GDELT 事件中创建见解,而不需要任何后续处理。在 GeoMesa 之后,我们涉及了高度复杂的石油定价世界,并致力于一个简单的算法来估计每周的石油变化。虽然在现有的时间和资源下创建一个准确的模型是不合理的,但我们已经探讨了许多关注领域,并试图至少在高层次上解决这些问题,以便提供可能在这个问题领域中可以采取的方法的见解。

在本章中,我们介绍了一些关键的 Spark 库和函数,其中关键的领域是 MLlib,我们将在本书的其余部分中更详细地了解它。

在下一章,第六章,“抓取基于链接的外部数据”,我们进一步实施 GDELT 数据集,构建一个用于跟踪趋势的网络规模新闻扫描器。

第六章:抓取基于链接的外部数据

本章旨在解释一种增强本地数据的常见模式,该模式使用从 URL 或 API 获取的外部内容。例如,当从 GDELT 或 Twitter 接收到 URL 时。我们为读者提供了一个使用 GDELT 新闻索引服务作为新闻 URL 来源的教程,演示如何构建一个从互联网上抓取感兴趣的全球突发新闻的网络规模新闻扫描器。我们解释了如何构建这个专门的网络抓取组件,以克服规模的挑战。在许多用例中,访问原始 HTML 内容是不足以提供对新兴全球事件的更深入洞察的。专业的数据科学家必须能够从原始文本内容中提取实体,以帮助构建跟踪更广泛趋势所需的上下文。

在本章中,我们将涵盖以下主题:

  • 使用Goose库创建可扩展的网络内容获取器

  • 利用 Spark 框架进行自然语言处理(NLP)

  • 使用双重音标算法去重名字

  • 利用 GeoNames 数据集进行地理坐标查找

构建一个网络规模的新闻扫描器

数据科学与统计学的不同之处在于强调可扩展处理以克服围绕收集数据的质量和多样性的复杂问题。而统计学家处理干净数据集的样本,可能来自关系数据库,数据科学家相反,处理来自各种来源的大规模非结构化数据。前者专注于构建具有高精度和准确性的模型,而后者通常专注于构建丰富的集成数据集,提供发现不那么严格定义的见解。数据科学之旅通常涉及折磨初始数据源,连接理论上不应该连接的数据集,丰富内容与公开可用信息,实验,探索,发现,尝试,失败,再次尝试。无论技术或数学技能如何,普通数据科学家与专业数据科学家之间的主要区别在于在提取数据中的潜在价值时所使用的好奇心和创造力的水平。例如,你可以构建一个简单的模型,并为业务团队提供他们要求的最低要求,或者你可以注意并利用数据中提到的所有这些 URL,然后抓取这些内容,并使用这些扩展结果来发现超出业务团队最初问题的新见解。

访问网络内容

除非你在 2016 年初非常努力地工作,否则你一定听说过歌手大卫·鲍伊于 2016 年 1 月 10 日去世,享年 69 岁。这一消息被所有媒体发布商广泛报道,在社交网络上传播,并得到了世界各地最伟大艺术家的致敬。这可悲地成为了本书内容的一个完美用例,并且是本章的一个很好的例证。我们将使用 BBC 的以下文章作为本节的参考:

访问网络内容

图 1:关于大卫·鲍伊的 BBC 文章,来源:http://www.bbc.co.uk/news/entertainment-arts-35278872

查看这篇文章背后的 HTML 源代码,首先要注意的是大部分内容都不包含任何有价值的信息。这包括标题、页脚、导航面板、侧边栏和所有隐藏的 JavaScript 代码。虽然我们只对标题、一些参考(如发布日期)感兴趣,最多只对文章本身的几十行感兴趣,但分析页面将需要解析超过 1500 行的 HTML 代码。虽然我们可以找到许多用于解析 HTML 文件内容的库,但创建一个足够通用的解析器,可以处理来自随机文章的未知 HTML 结构,可能会成为一个真正的挑战。

Goose 图书馆

我们将这个逻辑委托给优秀的 Scala 库Goosegithub.com/GravityLabs/goose)。该库打开一个 URL 连接,下载 HTML 内容,清理掉所有的垃圾,使用一些英文停用词的聚类对不同的段落进行评分,最后返回剥离了任何底层 HTML 代码的纯文本内容。通过正确安装imagemagick,该库甚至可以检测给定网站的最具代表性的图片(这里不在讨论范围内)。goose依赖项可在 Maven 中央库中找到:

<dependency>
  <groupId>com.gravity</groupId>
  <artifactId>goose</artifactId>
  <version>2.1.23</version>
</dependency>

与 Goose API 交互就像使用库本身一样愉快。我们创建一个新的 Goose 配置,禁用图像获取,修改一些可选设置,如用户代理和超时选项,并创建一个新的Goose对象:

def getGooseScraper(): Goose = {
  val conf: Configuration = new Configuration
  conf.setEnableImageFetching(false)
  conf.setBrowserUserAgent(userAgent)
  conf.setConnectionTimeout(connectionTimeout)
  conf.setSocketTimeout(socketTimeout)
  new Goose(conf)
}

val url = "http://www.bbc.co.uk/news/entertainment-arts-35278872"
val goose: Goose = getGooseScraper()
val article: Article = goose.extractContent(url)

调用extractContent方法返回一个具有以下值的 Article 类:

val cleanedBody: String = article.cleanedArticleText
val title: String = article.title
val description: String = article.metaDescription
val keywords: String = article.metaKeywords
val domain: String = article.domain
val date: Date = article.publishDate
val tags: Set[String] = article.tags

/*
Body: Singer David Bowie, one of the most influential musicians...
Title: David Bowie dies of cancer aged 69
Description: Tributes are paid to David Bowie...
Domain: www.bbc.co.uk
*/

使用这样一个库,打开连接并解析 HTML 内容不会花费我们超过十几行的代码,这种技术可以应用于任意来源或 HTML 结构的文章 URL 列表。最终的输出是一个干净解析的数据集,一致,并且在下游分析中非常有用。

与 Spark 集成

下一个逻辑步骤是集成这样一个库,并在可扩展的 Spark 应用程序中提供其 API。一旦集成,我们将解释如何有效地从大量 URL 中检索远程内容,以及如何在 Spark 转换中使用不可序列化的类,并且以高性能的方式。

Scala 兼容性

Maven 上的 Goose 库已经编译为 Scala 2.9,因此与 Spark 分发不兼容(Spark 2.0+需要 Scala 2.11)。为了使用它,我们不得不为 Scala 2.11 重新编译 Goose 分发,并为了您的方便,我们将其放在了我们的主 GitHub 存储库中。可以使用以下命令快速安装:

$ git clone git@bitbucket.org:gzet_io/goose.git
$ cd goose && mvn clean install

请注意,您将需要修改您的项目pom.xml文件以使用这个新的依赖项。

<dependency>
  <groupId>com.gravity</groupId>
  <artifactId>goose_2.11</artifactId>
  <version>2.1.30</version>
</dependency>

序列化问题

任何与第三方依赖项一起工作的 Spark 开发人员至少应该遇到过NotSerializableException。尽管在一个有很多转换的大型项目中找到确切的根本原因可能是具有挑战性的,但原因是非常简单的。Spark 试图在将它们发送到适当的执行器之前序列化所有的转换。由于Goose类不可序列化,并且由于我们在闭包外部构建了一个实例,这段代码是NotSerializableException的一个完美例子。

val goose = getGooseScraper()
def fetchArticles(urlRdd: RDD[String]): RDD[Article] = {
  urlRdd.map(goose.extractContent)
}

我们通过在map转换中创建一个Goose类的实例来简单地克服了这个限制。通过这样做,我们避免了传递任何我们可能创建的非可序列化对象的引用。Spark 将能够将代码原样发送到每个执行器,而无需序列化任何引用的对象。

def fechArticles(urlRdd: RDD[String]): RDD[Article] = {
  urlRdd map { url =>
    val goose = getGooseScraper()
    goose.extractContent(url)
  }
}

创建一个可扩展的、生产就绪的库

改进简单应用程序的性能在单个服务器上运行有时并不容易;但在并行处理大量数据的分布式应用程序上进行这样的改进通常更加困难,因为有许多其他因素会影响性能。接下来,我们将展示我们用来调整内容获取库的原则,以便它可以在任何规模的集群上自信地运行而不会出现问题。

构建一次,多次读取

值得一提的是,在前面的示例中,为每个 URL 创建了一个新的 Goose 实例,这使得我们的代码在大规模运行时特别低效。举个简单的例子来说明这一点,创建一个Goose类的新实例可能需要大约 30 毫秒。在我们数百万条记录中的每一条上都这样做将需要在一个 10 节点集群上花费 1 小时,更不用说垃圾回收性能将受到显著影响。使用mapPartitions转换可以显著改善这个过程。这个闭包将被发送到 Spark 执行器(就像map转换一样),但这种模式允许我们在每个执行器上创建一个单独的 Goose 实例,并为每个执行器的记录调用其extractContent方法。

def fetchArticles(urlRdd: RDD[String]): RDD[Article] = {
  urlRdd mapPartitions { urls =>
    val goose = getGooseScraper()
    urls map goose.extractContent
  }
}

异常处理

异常处理是正确软件工程的基石。这在分布式计算中尤其如此,因为我们可能与大量直接不受我们控制的外部资源和服务进行交互。例如,如果我们没有正确处理异常,那么在获取外部网站内容时发生的任何错误都会使 Spark 在抛出最终异常并中止作业之前多次重新安排整个任务在其他节点上。在生产级别的、无人值守的网络爬虫操作中,这种问题可能会危及整个服务。我们当然不希望因为一个简单的 404 错误而中止整个网络爬虫内容处理过程。

为了加强我们的代码对这些潜在问题的防范,任何异常都应该被正确捕获,并且我们应该确保所有返回的对象都应该一致地被设置为可选的,对于所有失败的 URL 来说都是未定义的。在这方面,关于 Goose 库唯一不好的一点是其返回值的不一致性:标题和日期可能返回 null,而缺少描述和正文的情况下会返回空字符串。在 Java/Scala 中返回 null 是一个非常糟糕的做法,因为它通常会导致NullPointerException,尽管大多数开发人员通常会在旁边写上"This should not happen"的注释。在 Scala 中,建议返回一个选项而不是 null。在我们的示例代码中,我们从远程内容中获取的任何字段都应该以可选的方式返回,因为它可能在原始源页面上不存在。此外,当我们获取数据时,我们还应该处理其他方面的一致性,例如我们可以将日期转换为字符串,因为在调用操作(如collect)时可能会导致序列化问题。因为这些原因,我们应该按照以下方式重新设计我们的mapPartitions转换。

  • 我们测试每个对象的存在并返回可选结果

  • 我们将文章内容封装到一个可序列化的Content类中

  • 我们捕获任何异常并返回一个具有未定义值的默认对象

修改后的代码如下所示:

case class Content(
     url: String,
     title: Option[String],
     description: Option[String],
     body: Option[String],
     publishDate: Option[String]
)

def fetchArticles(urlRdd: RDD[String]): RDD[Content] = {

  urlRdd mapPartitions { urls =>

    val sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")
    val goose = getGooseScraper()

    urls map { url =>

      try {

        val article = goose.extractContent(url)
        var body = None: Option[String]
        var title = None: Option[String]
        var description = None: Option[String]
        var publishDate = None: Option[String]

        if (StringUtils.isNotEmpty(article.cleanedArticleText))
          body = Some(article.cleanedArticleText)

        if (StringUtils.isNotEmpty(article.title))
          title = Some(article.title)

        if (StringUtils.isNotEmpty(article.metaDescription))
          description = Some(article.metaDescription)

        if (article.publishDate != null)
          publishDate = Some(sdf.format(article.publishDate))

        Content(url, title, description, body, publishDate)

      } catch {
        case e: Throwable => Content(url, None, None, None, None)
      }
    }
  }

}

性能调优

尽管大多数情况下,Spark 应用程序的性能可以通过对代码本身的更改大大改善(我们已经看到了使用mapPartitions而不是map函数来实现完全相同目的的概念),但您可能还需要找到总执行器数量、每个执行器的核心数量以及分配给每个容器的内存之间的正确平衡。

在进行这种第二种类型的应用程序调优时,首先要问自己的问题是,您的应用程序是 I/O 绑定(大量读/写访问)、网络绑定(节点之间大量传输)、内存绑定还是 CPU 绑定(您的任务通常需要太长时间才能完成)。

很容易发现我们的网络爬虫应用程序中的主要瓶颈。创建一个Goose实例大约需要 30 毫秒,获取给定 URL 的 HTML 大约需要 3 秒才能完成。基本上,我们花费了 99%的时间等待内容块被检索,主要是因为互联网连接和网站的可用性。克服这个问题的唯一方法是大幅增加我们 Spark 作业中使用的执行者数量。请注意,由于执行者通常位于不同的节点上(假设正确的 Hadoop 设置),更高的并行度不会在带宽方面受到网络限制(就像在单个节点上使用多个线程时肯定会发生的那样)。

此外,关键要注意的是,在这个过程的任何阶段都没有涉及减少操作(没有洗牌),因为这个应用是一个仅映射的作业,因此天然具有线性可扩展性。从逻辑上讲,两倍的执行者将使我们的爬虫性能提高两倍。为了反映这些设置在我们的应用程序上,我们需要确保我们的数据集被均匀地分区,至少有与我们定义的执行者数量一样多的分区。如果我们的数据集只能适应一个分区,那么我们的许多执行者中只有一个会被使用,使我们的新 Spark 设置既不足够又高度低效。重新分区我们的集合是一个一次性的操作(尽管是一个昂贵的操作),假设我们正确地缓存和实现我们的 RDD。我们在这里使用了200的并行性:

val urlRdd = getDistinctUrls(gdeltRdd).repartition(200)
urlRdd.cache()
urlRdd.count()

val contentRdd: RDD[Content] = fetchArticles(urlRdd)
contentRdd.persist(StorageLevel.DISK_ONLY)
contentRdd.count()

最后要记住的一件事是彻底缓存返回的 RDD,因为这样可以消除所有懒惰定义的转换(包括 HTML 内容获取)可能在我们调用任何进一步的操作时重新评估的风险。为了保险起见,因为我们绝对不想两次从互联网获取 HTML 内容,我们强制这种缓存明确地发生,通过将返回的数据集持久化到DISK_ONLY

命名实体识别

构建一个网络爬虫,用外部基于网页的 HTML 内容丰富包含 URL 的输入数据集,在大数据摄入服务中具有很大的商业价值。但是,虽然普通的数据科学家应该能够使用一些基本的聚类和分类技术来研究返回的内容,但专业的数据科学家将把这个数据丰富过程提升到下一个级别,通过进一步丰富和增加价值来进行后续处理。通常,这些增值的后续处理包括消除外部文本内容的歧义,提取实体(如人物、地点和日期),以及将原始文本转换为最简单的语法形式。我们将在本节中解释如何利用 Spark 框架来创建一个可靠的自然语言处理(NLP)管道,其中包括这些有价值的后处理输出,并且可以处理任何规模的英语内容。

Scala 库

ScalaNLP(http://www.scalanlp.org/)是 breeze(等等)的父项目,并且是在 Spark MLlib 中广泛使用的数值计算框架。如果它没有在不同版本的 breeze 和 epic 之间引起这么多依赖问题,这个库本来是 Spark 上 NLP 的完美候选者。为了克服这些核心依赖不匹配,我们要么重新编译整个 Spark 分发版,要么重新编译整个 ScalaNLP 堆栈,这两者都不是易事。因此,我们更倾向于使用来自计算语言理解实验室的一套自然语言处理器(https://github.com/clulab/processors)。它是用 Scala 2.11 编写的,提供了三种不同的 API:斯坦福 CoreNLP 处理器、快速处理器和用于处理生物医学文本的处理器。在这个库中,我们可以使用FastNLPProcessor,它对于基本的命名实体识别功能来说足够准确,并且在 Apache v2 许可下。

<dependency>
  <groupId>org.clulab</groupId>
  <artifactId>processors-corenlp_2.11</artifactId>
  <version>6.0.1</version>
</dependency>

<dependency>
  <groupId>org.clulab</groupId>
  <artifactId>processors-main_2.11</artifactId>
  <version>6.0.1</version>
</dependency>

<dependency>
  <groupId>org.clulab</groupId>
  <artifactId>processors-models_2.11</artifactId>
  <version>6.0.1</version>
</dependency>

NLP 演练

NLP 处理器注释文档并返回词形的列表(以其最简单的语法形式呈现的单词),命名实体类型的列表,如[ORGANIZATION][LOCATION][PERSON],以及标准化实体的列表(如实际日期值)。

提取实体

在下面的例子中,我们初始化一个FastNLPProcessor对象,注释并标记文档为一个Sentence列表,将词形和 NER 类型进行压缩,最后返回每个给定句子的识别实体数组。

case class Entity(eType: String, eVal: String)

def processSentence(sentence: Sentence): List[Entity] = {
  val entities = sentence.lemmas.get
    .zip(sentence.entities.get)
    .map {
      case (eVal, eType) =>
        Entity(eType, eVal)
    }
}

def extractEntities(processor: Processor, corpus: String) = {
  val doc = processor.annotate(corpus)
  doc.sentences map processSentence
}

val t = "David Bowie was born in London"
val processor: Processor = new FastNLPProcessor()
val sentences = extractEntities(processor, t)

sentences foreach { sentence =>
  sentence foreach println
}

/*
Entity(David,PERSON)
Entity(Bowie,PERSON)
Entity(was,O)
Entity(born,O)
Entity(in,O) 
Entity(London,LOCATION) 
*/

从上面的输出中,您可能会注意到所有检索到的实体都没有链接在一起,DavidBowie都是类型为[PERSON]的两个不同实体。我们使用以下方法递归聚合连续相似的实体。

def aggregate(entities: Array[Entity]) = {
  aggregateEntities(entities.head, entities.tail, List())
}

def aggregateEntity(e1: Entity, e2: Entity) = {
  Entity(e1.eType, e1.eVal + " " + e2.eVal)
}

def aggEntities(current: Entity, entities: Array[Entity], processed : List[Entity]): List[Entity] = {
  if(entities.isEmpty) {
// End of recusion, no additional entity to process
    // Append our last un-processed entity to our list
    current :: processed
  } else {
    val entity = entities.head
    if(entity.eType == current.eType) {
 // Aggregate consecutive values only of a same entity type      val aggEntity = aggregateEntity(current, entity)
*      // Process next record*
      aggEntities(aggEntity, entities.tail, processed)
    } else {
// Add current entity as a candidate for a next aggregation
      // Append our previous un-processed entity to our list      aggEntities(entity, entities.tail, current :: processed)
    }
  }
}

def processSentence(sentence: Sentence): List[Entity] = {
  val entities = sentence.lemmas.get
    .zip(sentence.entities.get)
    .map {
      case (eVal, eType) =>
        Entity(eType, eVal)
    }
  aggregate(entities)
}

现在打印相同的内容会给我们一个更一致的输出。

/*
(PERSON,David Bowie)
(O,was born in)
(LOCATION,London) 
*/

提示

在函数式编程环境中,尽量限制使用任何可变对象(如使用var)。作为一个经验法则,可以通过使用前置递归函数来避免任何可变对象。

抽象方法

我们意识到在一组句子(句子本身是一个实体数组)上工作可能听起来很模糊。根据经验,当在大规模运行时,对 RDD 进行简单转换将需要多个flatMap函数,这将更加令人困惑。我们将结果封装到一个Entities类中,并公开以下方法:

case class Entities(sentences: Array[List[(String, String)]])
 {

  def getSentences = sentences

  def getEntities(entity: String) = {
    sentences flatMap { sentence =>
      sentence
    } filter { case (entityType, entityValue) =>
      entityType == entity
    } map { case (entityType, entityValue) =>
      entityValue
    } toSeq
  }

构建可扩展的代码

我们现在已经定义了我们的 NLP 框架,并将大部分复杂逻辑抽象成一组方法和方便的类。下一步是将这段代码集成到 Spark 环境中,并开始大规模处理文本内容。为了编写可扩展的代码,需要特别注意以下几点:

  • 在 Spark 作业中使用非可序列化类时,必须在闭包内仔细声明,以避免引发NotSerializableException。请参考我们在前一节中讨论的 Goose 库序列化问题。

  • 每当我们创建一个FastNLPProcessor的新实例(每当我们首次调用其annotate方法时,因为它是懒惰定义的),所有所需的模型将从类路径中检索、反序列化并加载到内存中。这个过程大约需要 10 秒钟才能完成。

  • 除了实例化过程相当缓慢之外,值得一提的是模型可能非常庞大(大约 1GB),并且将所有这些模型保留在内存中将逐渐消耗我们可用的堆空间。

一次构建,多次读取

出于以上所有原因,将我们的代码原样嵌入map函数中将非常低效(并且可能会耗尽我们所有的可用堆空间)。如下例所示,我们利用mapPartitions模式来优化加载和反序列化模型的开销时间,以及减少执行器使用的内存量。使用mapPartitions强制处理每个分区的第一条记录以评估引导模型加载和反序列化过程,并且在该执行器上的所有后续调用将重用该分区内的模型,有助于将昂贵的模型传输和初始化成本限制为每个执行器一次。

def extract(corpusRdd: RDD[String]): RDD[Entities] = {
  corpusRdd mapPartitions {
    case it=>
      val processor = new FastNLPProcessor()
      it map {
        corpus =>
          val entities = extractEntities(processor, corpus)
          new Entities(entities)
      }
    }
  }

这个 NLP 可扩展性问题的最终目标是在处理尽可能多的记录时加载尽可能少的模型。对于一个执行器,我们只加载一次模型,但完全失去了并行计算的意义。对于大量的执行器,我们将花费更多的时间反序列化模型,而不是实际处理我们的文本内容。这在性能调优部分有所讨论。

可扩展性也是一种思维状态

因为我们在将代码集成到 Spark 之前在本地设计了我们的代码,我们一直记得以最方便的方式编写代码。这很重要,因为可扩展性不仅体现在大数据环境中代码运行的速度上,还体现在人们对其感觉如何,以及开发人员与您的 API 的交互效率如何。作为开发人员,如果你需要链接嵌套的flatMap函数来执行本应该是简单转换的操作,那么你的代码根本不具备可扩展性!由于我们的数据结构完全抽象在一个Entities类中,从我们的 NLP 提取中派生出不同的 RDD 可以通过一个简单的映射函数完成。

val entityRdd: RDD[Entities] = extract(corpusRdd)
entityRdd.persist(StorageLevel.DISK_ONLY)
entityRdd.count()

val perRdd = entityRdd.map(_.getEntities("PERSON"))
val locRdd = entityRdd.map(_.getEntities("LOCATION"))
val orgRdd = entityRdd.map(_.getEntities("ORGANIZATION"))

提示

关键要注意这里使用了persist。与之前在 HTML 获取器过程中所做的一样,我们彻底缓存返回的 RDD,以避免在调用任何进一步的操作时重新评估其所有基础转换的情况。NLP 处理是一个非常昂贵的过程,你必须确保它不会被执行两次,因此这里使用了DISK_ONLY缓存。

性能调优

为了使这个应用程序扩展,你需要问自己同样关键的问题:这个作业是 I/O、内存、CPU 还是网络绑定的?NLP 提取是一个昂贵的任务,加载模型需要大量内存。我们可能需要减少执行器的数量,同时为每个执行器分配更多的内存。为了反映这些设置,我们需要确保我们的数据集将被均匀分区,使用至少与执行器数量相同的分区。我们还需要通过缓存我们的 RDD 并调用一个简单的count操作来强制进行这种重新分区,这将评估我们所有先前的转换(包括分区本身)。

val corpusRdd: RDD[String] = inputRdd.repartition(120)
corpusRdd.cache()
corpusRdd.count()

val entityRdd: RDD[Entities] = extract(corpusRdd)

GIS 查找

在前一节中,我们涵盖了一个有趣的用例,即如何从非结构化数据中提取位置实体。在本节中,我们将通过尝试根据我们能够识别的实体的位置来检索实际的地理坐标信息(如纬度和经度),使我们的丰富过程变得更加智能。给定一个输入字符串伦敦,我们能否检测到伦敦-英国的城市以及其相对纬度和经度?我们将讨论如何构建一个高效的地理查找系统,该系统不依赖于任何外部 API,并且可以通过利用 Spark 框架和Reduce-Side-Join模式处理任何规模的位置数据。在构建此查找服务时,我们必须牢记世界上许多地方可能共享相同的名称(仅在美国就有大约 50 个名为曼彻斯特的地方),并且输入记录可能不使用所指的地方的官方名称(通常使用的瑞士日内瓦的官方名称是日内瓦)。

GeoNames 数据集

GeoNames (www.geonames.org/)是一个涵盖所有国家的地理数据库,包含超过 1000 万个地名和地理坐标,并可免费下载。在这个例子中,我们将使用AllCountries.zip数据集(1.5 GB),以及admin1CodesASCII.txt参考数据,将我们的位置字符串转换为具有地理坐标的有价值的位置对象。我们将仅保留与大洲、国家、州、地区和城市以及主要海洋、海洋、河流、湖泊和山脉相关的记录,从而将整个数据集减少一半。尽管管理代码数据集很容易放入内存中,但 Geo 名称必须在 RDD 中处理,并且需要转换为以下案例类:

case class GeoName(
  geoId: Long,
  name: String,
  altNames: Array[String],
  country: Option[String],
  adminCode: Option[String],
  featureClass: Char,
  featureCode: String,
  population: Long,
  timezone: Array[String],
  geoPoint: GeoPoint
)

case class GeoPoint(
  lat: Double,
  lon: Double
)

我们将不在这里描述将平面文件解析为geoNameRDD的过程。解析器本身非常简单,处理制表符分隔的记录文件,并根据上述案例类定义转换每个值。相反,我们将公开以下静态方法:

val geoNameRdd: RDD[GeoName] = GeoNameLookup.load(
  sc,
  adminCodesPath,
  allCountriesPath
)

构建高效的连接

主要的查找策略将依赖于对我们的地理名称和输入数据执行的join操作。为了最大限度地提高获取位置匹配的机会,我们将使用flatMap函数扩展我们的初始数据,以涵盖所有可能的替代名称,因此将初始大小从 500 万条记录大幅增加到约 2000 万条记录。我们还确保从名称中清除任何可能包含的重音符号、破折号或模糊字符:

val geoAltNameRdd = geoNameRdd.flatMap {
  geoName =>
    altNames map { altName =>
      (clean(altName), geoName)
    }
} filter { case (altName, geoName) =>
  StringUtils.isNotEmpty(altName.length)
} distinct()

val inputNameRdd = inputRdd.map { name =>
  (clean(name), name)
} filter { case (cleanName, place) =>
  StringUtils.*isNotEmpty*(cleanName.length)
 }

最后,剩下的过程是在清理后的输入和清理后的geoNameRDD之间进行简单的join操作。最后,我们可以将所有匹配的地点分组成一组简单的GeoName对象:

def geoLookup(
  inputNameRdd: RDD[(String, String)],
  geoNameRdd: RDD[(String, GeoName)]
): RDD[(String, Array[GeoName])] = {

  inputNameRdd
    .join(geoNameRdd)
    .map { case (key, (name, geo)) =>
      (name, geo)
    }
    .groupByKey()
    .mapValues(_.toSet)

}

这里可以讨论一个有趣的模式。Spark 如何在大型数据集上执行join操作?在传统的 MapReduce 中称为Reduce-Side-Join模式,它要求框架对来自两个 RDD 的所有键进行哈希,并将具有相同键(相同哈希)的所有元素发送到专用节点,以便在本地join它们的值。Reduce-Side-Join的原则如下图 2 所示。由于Reduce-Side-Join是一项昂贵的任务(受网络限制),我们必须特别注意解决以下两个问题:

  • GeoNames数据集比我们的输入 RDD 要大得多。我们将浪费大量精力洗牌数据,而这些数据无论如何都不会匹配,使我们的join不仅效率低下,而且主要是无用的。

  • GeoNames数据集随时间不会改变。在伪实时系统(如 Spark Streaming)中接收位置事件的批处理中,重新洗牌这个不可变的数据集是没有意义的。

我们可以构建两种不同的策略,一种是离线策略,一种是在线策略。前者将利用布隆过滤器大大减少要洗牌的数据量,而后者将按键对我们的 RDD 进行分区,以减少与join操作相关的网络成本。

构建高效的

图 2:Reduce-Side-Join

离线策略-布隆过滤

布隆过滤器是一种空间高效的概率数据结构,用于测试元素是否是有限概率的假阳性成员。在传统的 MapReduce 中被广泛使用,一些实现已经编译为 Scala。我们将使用 breeze 库的布隆过滤器,该库可在 maven 中心获得(与我们之前讨论的 ScalaNLP 模型相比,breeze 本身可以在很大程度上避免依赖不匹配)。

<dependency>
  <groupId>org.scalanlp</groupId>
  <artifactId>breeze_2.11</artifactId>
  <version>0.12</version>
</dependency>

因为我们的输入数据集比geoNameRDD要小得多,所以我们将通过利用mapPartitions函数对前者训练一个布隆过滤器。每个执行器将构建自己的布隆过滤器,我们可以通过其关联属性将其聚合成一个单一对象,使用reduce函数内的位运算符:

val bfSize = inputRdd.count()
val bf: BloomFilter[String] = inputRdd.mapPartitions { it =>
  val bf = BloomFilter.optimallySizedString
  it.foreach { cleanName =>
    bf += cleanName
  }
  Iterator(bf)
} reduce(_ | _)

我们针对完整的geoNameRDD测试我们的过滤器,以删除我们知道不会匹配的地点,最后执行相同的join操作,但这次处理的数据要少得多:

val geoNameFilterRdd = geoAltNameRdd filter {
  case(name, geo) =>
    bf.contains(name)
}

val resultRdd = geoLookup(inputNameRdd, geoNameFilterRdd)

通过减少geoNameRDD的大小,我们已经成功地减轻了洗牌过程的压力,使我们的join操作更加高效。产生的Reduce-Side-Join如下图 3 所示:

离线策略-布隆过滤

图 3:使用布隆过滤器的 Reduce-Side-Join

在线策略-哈希分区

在离线过程中,我们通过预处理我们的geoNameRDD来减少要洗牌的数据量。在流处理过程中,因为任何新的数据批次都是不同的,所以不值得一遍又一遍地过滤我们的参考数据。在这种情况下,我们可以通过使用HashPartitioner按键预分区我们的geoNameRDD数据,使用的分区数至少是执行器的数量,从而大大提高join性能。因为 Spark 框架知道重新分区的使用,只有输入 RDD 将被发送到洗牌,使我们的查找服务显着更快。这在图 4中有所说明。请注意,使用cachecount方法来强制分区。最后,我们可以安全地执行我们相同的join操作,这次对网络的压力要小得多:

val geoAltNamePartitionRdd = geoAltNameRdd.partitionBy(
  new HashPartitioner(100)
).cache()

geoAltNamePartitionRdd.count()
val resultRdd = geoLookup(inputNameRdd, geoAltNamePartitionRdd)

在线策略-哈希分区

图 4:使用哈希分区的减少端连接

内容去重

像曼彻斯特这样的城市在我们的数据集中被发现 100 次,我们需要为类似名称制定去重策略,考虑到一些城市在随机文本内容中被发现的概率可能不如其他城市重要。

上下文学习

对于去重地点内容最准确的方法可能是研究地点记录在其上下文中的情况,类似于苹果公司对谷歌和雅虎的关系,苹果水果对香蕉和橙子的关系。通过机器学习地点在其上下文中,我们可能会发现单词海狸在加拿大安大略省伦敦市的上下文中是相关的。据我们所知,在英国伦敦遇到野生熊的风险是非常小的。假设可以访问文本内容,训练模型不应该很困难,但访问地理坐标将需要建立一个带有每个地方的地理值和最能描述的主题的索引字典。因为我们没有访问这样的数据集(尽管我们可以从维基百科上获取),并且我们不想假设有人可以访问文本内容,所以我们将简单地将地点排名为重要性的顺序。

地点评分

考虑到我们从 GeoNames 网站获取的不同代码,我们假设一个大陆比一个国家更重要,一个国家比一个州或一个首都更重要,依此类推。这种天真的方法在 80%的时间内是有意义的,但在一些边缘情况下可能会返回不相关的结果。以曼彻斯特为例,我们会发现曼彻斯特是牙买加的一个重要州的教区,而不是英国的一个简单的城市。我们可以通过在评分方面放宽限制并按人口数量降序排序相同评分的地点来解决这个问题。返回最重要和相关的地点是有意义的,大多数在线 API 都是这样做的,但对于不太重要的城市来说公平吗?我们通过向上下文添加唯一的参考 ID 来改进我们的评分引擎,在那里可能会提到几个地点。如果一个文档只关注加拿大的城市,而没有提到英国,那么伦敦很可能是加拿大的地方。如果没有提到国家或州,或者加拿大和英国都被提到,我们将在我们的数据集中将伦敦作为英国的伦敦。通过按照上下文中提到的相似大陆/国家/州进行排序,然后按重要性,最后按人口进行去重。第一个结果将作为我们最佳候选返回。

名称去重

由于我们从 NLP 提取过程中提取实体而没有任何验证,我们能够检索到的名称可能以许多不同的方式书写。它们可以按不同的顺序书写,可能包含中间名或缩写,称谓或贵族头衔,昵称,甚至一些拼写错误和拼写错误。尽管我们不打算完全去重内容(比如学习到Ziggy StardustDavid Bowie代表同一个人),但我们将介绍两种简单的技术,通过结合 MapReduce 范式和函数式编程的概念,以最小的成本去重大量数据。

使用 Scalaz 进行函数式编程

本节主要是关于作为摄入管道的一部分丰富数据。因此,我们对使用先进的机器学习技术构建最准确的系统不太感兴趣,而是对构建最可扩展和高效的系统感兴趣。我们希望保留每条记录的替代名称字典,以便快速合并和更新它们,代码尽可能少,并且规模非常大。我们希望这些结构表现得像单子,代数上的可结合结构,适当地支持Scalazgithub.com/scalaz/scalaz)上的纯函数式编程库:

<dependency>
  <groupId>org.scalaz</groupId>
  <artifactId>scalaz-core_2.11</artifactId>
  <version>7.2.0</version>
</dependency>

我们的去重策略

我们在下面使用一个简单的示例来证明使用 Scalaz 编程构建可扩展的去重管道的需求,该管道由多个转换组成。使用人员的 RDD,personRDD,作为下面显示的测试数据集:

personRDD.take(8).foreach(println)

/*
David Bowie
david bowie
david#Bowie
David Bowie
david bowie
David Bowie
David Bowie
Ziggy Stardust
*/

在这里,我们首先计算每个条目的出现次数。实际上,这是一个简单的 Wordcount 算法,MapReduce 编程的101

val wcRDD = personRDD
  .map(_ -> 1)
  .reduceByKey(_+_)

wcRDD.collect.foreach(println)
/*
(David Bowie, 4)
(david bowie, 2)
(david#Bowie, 1)
(Ziggy Stardust, 1)
*/

在这里,我们应用第一个转换,比如lowercase,并生成一个更新的报告:

val lcRDD = wcRDD.map { case (p, tf) => 
  (p.lowerCase(), tf) 
} 
.reduceByKey(_+_) 

lcRDD.collect.foreach(println) 

/* 
(david bowie, 6) 
(david#bowie, 1) 
(ziggy stardust, 1) 
*/ 

在这里,我们然后应用第二个转换,删除任何特殊字符:

val reRDD = lcRDD.map { case (p, tf) =>
  (p.replaceAll("[^a-z]", ""), tf)
}
.reduceByKey(_+_)

reRDD.collect.foreach(println)

/*
(david bowie, 7)
(ziggy stardust, 1)
*/

我们现在已经将我们的六个条目减少到只有两个,但由于我们在转换过程中丢失了原始记录,我们无法构建一个形式为[原始值]->[新值]的字典。

使用 mappend 运算符

而不是使用 Scalaz API,我们预先初始化每个原始记录的名称频率字典(作为 Map,初始化为 1),并使用mappend函数(通过|+|运算符访问)合并这些字典。在每个转换之后,合并发生在reduceByKey函数中,将转换的结果作为键,术语频率映射作为值:

import scalaz.Scalaz._

def initialize(rdd: RDD[String]) = {
  rdd.map(s => (s, Map(s -> 1)))
     .reduceByKey(_ |+| _)
}

def lcDedup(rdd: RDD[(String, Map[String, Int])]) = {
  rdd.map { case (name, tf) =>
    (name.toLowerCase(), tf)
  }
  .reduceByKey(_ |+| _)
}

def reDedup(rdd: RDD[(String, Map[String, Int])]) = {
  rdd.map { case (name, tf) =>
    (name.replaceAll("\\W", ""), tf)
  }
  .reduceByKey(_ |+| _)
}

val wcTfRdd = initialize(personRDD)
val lcTfRdd = lcDedup(wcTfRdd)
val reTfRdd = reDedup(lcTfRdd)

reTfRdd.values.collect.foreach(println)

/*
Map(David Bowie -> 4, david bowie -> 2, david#Bowie -> 1)
Map(ziggy stardust -> 1)
*/

对于每个去重条目,我们找到最频繁的项目,并构建我们的字典 RDD 如下:

val dicRDD = fuTfRdd.values.flatMap {
  alternatives =>
    val top = alternatives.toList.sortBy(_._2).last._1
    tf.filter(_._1 != top).map { case (alternative, tf) =>
      (alternative, top)
    }
}

dicRDD.collect.foreach(println)

/*
david bowie, David Bowie
david#Bowie, David Bowie
*/

为了完全去重我们的人员 RDD,需要将所有david bowiedavid#bowie的出现替换为David Bowie。现在我们已经解释了去重策略本身,让我们深入研究一下转换集。

简单清理

第一个去重转换显然是从所有模糊字符或额外空格中清理名称。我们用它们匹配的 ASCII 字符替换重音符号,正确处理驼峰大小写,并删除任何停用词,例如[mr, miss, sir]。将此函数应用于汤加总理,[Mr. Sialeʻataongo Tuʻivakanō],我们返回[siale ataongo tu ivakano],这是一个更干净的版本,至少在字符串去重的情况下是这样。执行去重本身将是使用 MapReduce 范式和早期引入的单子概念的几行代码:

def clean(name: String, stopWords: Set[String]) = {

  StringUtils.stripAccents(name)
    .split("\\W+").map(_.trim).filter { case part =>
      !stopWords.contains(part.toLowerCase())
    }
    .mkString(" ")
    .split("(?<=[a-z])(?=[A-Z])")
    .filter(_.length >= 2)
    .mkString(" ")
    .toLowerCase()

}

def simpleDedup(rdd: RDD[(String, Map[String, Int])], stopWords: Set[String]) = {

  rdd.map { case (name, tf) =>
    (clean(name, stopWords), tf)
  }
  .reduceByKey(_ |+| _)

}

DoubleMetaphone

DoubleMetaphone是一种有用的算法,可以根据其英语发音索引名称。尽管它不能产生一个名字的精确音标表示,但它创建了一个简单的哈希函数,可以用于将具有相似音素的名称分组。

注意

有关 DoubleMetaphone 算法的更多信息,请参阅:Philips,L.(1990)。Hanging on the Metaphone(Vol. 7)。计算机语言。)

出于性能原因,我们转向这种算法,因为在大型词典中查找潜在的拼写错误和拼写错误通常是一项昂贵的操作;通常需要将候选姓名与我们正在跟踪的每个其他姓名进行比较。这种类型的比较在大数据环境中是具有挑战性的,因为它通常需要进行笛卡尔join,这可能会生成过大的中间数据集。metaphone 算法提供了一个更大、更快的替代方案。

使用 Apache commons 包中的DoubleMetaphone类,我们简单地利用 MapReduce 范式,将发音相同的姓名分组。例如,[david bowie][david bowi][davide bowie]都共享相同的代码[TFT#P],将被分组在一起。在下面的示例中,我们计算每条记录的双元音哈希,并调用reduceByKey来合并和更新所有我们姓名的频率映射:

def metaphone(name: String) = {
  val dm = new DoubleMetaphone()
  name.split("\\s")
    .map(dm.doubleMetaphone)
    .mkString("#")
}

def metaphoneDedup(rdd: RDD[(String, Map[String, Int])]) = {
  rdd.map { case (name, tf) =>
    (metaphone(name), tf)
  }
  .reduceByKey(_ |+| _)
}

我们还可以通过保留常见英文昵称(比如 bill、bob、will、beth、al 等)及其对应的主要名称的列表,极大地改进这种简单的技术,这样我们就可以在非音标同义词之间进行匹配。我们可以通过预处理我们的姓名 RDD,将已知昵称的哈希码替换为相关主要名称的哈希码,然后我们可以运行相同的去重算法来解决基于音标和同义词的重复。这将检测拼写错误和替代昵称,如下所示:

persons.foreach(p => println(p + "\t" + metaphoneAndNickNames(p))

/*
David Bowie  TFT#P
David Bowi   TFT#P
Dave Bowie   TFT#P
*/

再次强调,这种算法(以及上面显示的简单清洗例程)将不像适当的模糊字符串匹配方法那样准确,例如计算每对可能的姓名之间的Levenshtein距离。然而,通过牺牲准确性,我们创造了一种高度可扩展的方法,以最小的成本找到大多数常见的拼写错误,特别是在无声辅音上的拼写错误。一旦所有替代名称都已根据生成的哈希码分组,我们可以将最佳替代名称输出为我们从我们的词频对象返回的最频繁的名称。通过join将这个最佳替代应用于初始名称 RDD,以替换任何记录为其首选替代(如果有的话):

def getBestNameRdd(rdd: RDD[(String, Map[String, Int])]) = {
  rdd.flatMap { case (key, tf) =>
    val bestName = tf.toSeq.sortBy(_._2).last._1
    tf.keySet.map { altName =>
      (altName, bestName)
    } 
  }
}

val bestNameRdd = getBestNameRdd(nameTfRdd)

val dedupRdd = nameRdd
  .map(_ -> 1)
  .leftOuterJoin(bestNameRdd)
  .map { case (name, (dummy, optBest)) =>
    optBest.getOrElse(name)
  }

新闻索引仪表板

由于我们能够丰富输入 URL 中找到的内容,我们自然的下一步是开始可视化我们的数据。虽然探索性数据分析的不同技术已经在第四章中进行了详细讨论,探索性数据分析,我们认为值得用 Kibana 中的简单仪表板总结到目前为止我们所涵盖的内容。从大约 50,000 篇文章中,我们能够在 1 月 10 日至 11 日获取并分析,我们过滤掉任何提到David Bowie作为 NLP 实体并包含death一词的记录。因为我们所有的文本内容都被正确索引在 Elasticsearch 中,我们可以在几秒钟内提取 209 篇匹配的文章及其内容。

新闻索引仪表板

图 5:新闻索引仪表板

我们可以快速获取与David Bowie一起提到的前十位人物,包括他的艺名Ziggy Stardust、他的儿子Duncan Jones、他的前制作人Tony Visconti,或者英国首相David Cameron。由于我们建立的GeoLookup服务,我们展示了所有提到的不同地点,发现了梵蒂冈城国家周围的一个圈子,那里的红衣主教Gianfranco Ravasi,文化部主席,发推特提到David Bowie的著名歌词Space Oddity

新闻索引仪表板

图 6:梵蒂冈向推特致敬

最后,在争先发布关于David Bowie去世的新闻的竞赛中,找到第一个报道的人就像简单的点击一样容易!

总结

数据科学不仅仅是关于机器学习。事实上,机器学习只是其中的一小部分。在我们对现代数据科学的理解中,科学往往恰好发生在数据丰富化的过程中。真正的魔力发生在当一个人能够将一个无意义的数据集转化为有价值的信息集,并从中获得新的见解。在本节中,我们已经描述了如何使用简单的 URL 集合(和一点点努力)构建一个完全功能的数据洞察系统。

在本章中,我们演示了如何使用 Goose 库在 Spark 中创建高效的网络爬虫,以及如何使用 NLP 技术和 GeoNames 数据库从原始文本中提取和去重特征。我们还涵盖了一些有趣的设计模式,如mapPartitionsBloom filters,这些将在第十四章 可扩展算法中进一步讨论。

在下一章中,我们将专注于从所有这些新闻文章中提取出来的人们。我们将描述如何使用简单的联系链技术在它们之间建立联系,如何在 Spark 环境中高效存储和查询大型图表,以及如何使用GraphXPregel来检测社区。

第七章:建立社区

随着越来越多的人相互交流和沟通,交换信息,或者只是在不同主题上分享共同的兴趣,大多数数据科学用例都可以使用图形表示来解决。尽管很长一段时间以来,非常大的图仅被互联网巨头、政府和国家安全机构使用,但现在使用包含数百万个顶点的大图变得更加普遍。因此,数据科学家的主要挑战不一定是在图表上检测社区并找到影响者,而是以一种完全分布式和高效的方式来克服规模的限制。本章将通过使用我们在第六章中描述的 NLP 提取识别的人员来构建一个大规模的图表示例。

在本章中,我们将涵盖以下主题:

  • 使用 Spark 从 Elasticsearch 中提取内容,构建人员实体的图表,并了解使用 Accumulo 作为安全图数据库的好处

  • 使用GraphX和三角形优化从 A 到 Z 编写社区检测算法

  • 利用 Accumulo 特定功能,包括单元级安全性来观察社区的变化,并使用迭代器提供服务器和客户端计算

这一章节非常技术化,我们期望读者已经熟悉图论、消息传递和Pregel API。我们还邀请读者阅读本章中提到的每一篇白皮书。

构建人员图表

我们之前使用了 NLP 实体识别来从 HTML 原始文本格式中识别人物。在本章中,我们将尝试推断这些实体之间的关系,并检测围绕它们的可能社区。

联系链

在新闻文章的背景下,我们首先需要问自己一个基本问题。什么定义了两个实体之间的关系?最优雅的答案可能是使用斯坦福 NLP 库中描述的单词来研究,详情请参阅第六章中描述的抓取基于链接的外部数据。给定以下输入句子,该句子取自www.ibtimes.co.uk/david-bowie-yoko-ono-says-starmans-death-has-left-big-empty-space-1545160

"Yoko Ono 说她和已故丈夫约翰·列侬与大卫·鲍伊有着密切的关系"

我们可以轻松提取句法树,这是语言学家用来模拟句子语法结构的结构,其中每个元素都以其类型报告,例如名词(NN),动词(VR)或限定词(DT),以及其在句子中的相对位置。

val processor = new CoreNLPProcessor()
val document = processor.annotate(text)

document.sentences foreach { sentence =>
  println(sentence.syntacticTree.get)
}

/*
(NNP Yoko)
(NNP Ono)
(VBD said)
        (PRP she)
      (CC and)
        (JJ late)
        (NN husband)
          (NNP John)
          (NNP Lennon)
      (VBD shared)
        (DT a)
        (JJ close)
        (NN relationship)
        (IN with)
          (NNP David)
          (NNP Bowie)
*/

对每个元素、其类型、其前驱和后继的彻底研究将有助于构建一个有向图,其中边是存在于所有这三个实体之间关系的真实定义。从这个句子构建的图的示例如下所示:

联系链

图 1:大卫·鲍伊、Yoko Ono 和约翰·列侬的句法图

虽然从语法上讲是完全合理的,但是构建一个句法树图需要大量的编码,可能需要一个完整的章节来讲解,并且并没有带来太多附加值,因为我们建立的大多数关系(在新闻文章的背景下)都不是基于历史书籍中的真实事实,而是需要放在它们的背景中。为了说明这一点,我们有两个句子,这些句子取自www.digitalspy.com/music/news/a779577/paul-mccartney-pays-tribute-to-great-star-david-bowie-his-star-will-shine-in-the-sky-forever/

“保罗·麦卡特尼爵士称[大卫·鲍伊]为一颗伟大的星星”

“[保罗·麦卡特尼爵士]珍视他们在一起的时刻”

它将在[保罗·麦卡特尼]和[大卫·鲍伊]之间创建相同的语法链接,而只有后者假定它们之间存在物理联系(他们实际上在一起度过了一些时间)。

相反,我们使用了一种更快速的方法,即根据它们在文本中的位置对名称进行分组。我们的天真假设是,大多数作者通常首先提到重要人物的名字,然后写有关次要角色的内容,最后是不太重要的人物。因此,我们的联系链接是在给定文章中的所有名称上进行的简单嵌套循环,名称根据它们的实际位置从最重要的到最不重要的进行排序。由于其相对时间复杂度为O(n²),这种方法只对每篇文章的记录数有效,对于提及数以千计不同实体的文本来说,它肯定会成为一个限制因素。

def buildTuples(p: Array[String]): Array[(String, String)] = {
    for(i <- 0 to p.length - 2; j <- i + 1 to p.length - 1) yield {
      (p(i), p(j))
    }
  }

在我们的代码库中,您将看到另一种选择:Combinations,这是一个更通用的解决方案,允许指定一个变量r;这使我们能够指定每个输出组合中需要出现的实体数量,即本章为 2,但在其他情境中可能更多。使用Combinations.buildTuples在功能上等同于之前给出的buildTuples代码。

从 Elasticsearch 中提取数据

Elasticsearch 是一个存储和索引文本内容及其元数据属性的完美工具,因此它是我们在线数据存储的逻辑选择,使用我们在上一章中提取的文本内容。由于本节更加面向批处理,我们使用出色的 Spark Elasticsearch API 将数据从 Elasticsearch 获取到我们的 Spark 集群中,如下面的代码所示:

<dependency>
  <groupId>org.elasticsearch</groupId>
  <artifactId>elasticsearch-spark_2.11</artifactId>
  <version>2.4.0<version>
</dependency>

给定索引类型和名称,与 Elasticsearch API 交互的一种便捷方式是使用 Spark DataFrame。在大多数用例中效率足够高(下面显示了一个简单的例子),但在处理更复杂和嵌套的模式时可能会成为一个挑战:

val spark = SparkSession
  .builder()
  .config("es.nodes", "localhost")
  .config("es.port", "9200")
  .appName("communities-es-download")
  .getOrCreate()

spark
  .read
  .format("org.elasticsearch.spark.sql")
  .load("gzet/news")
  .select("title", "url")
  .show(5)

+--------------------+--------------------+
|               title|                 url|
+--------------------+--------------------+
|Sonia Meets Mehbo...|http://www.newind...|
|"A Job Well Done ...|http://daphneanso...|
|New reading progr...|http://www.mailtr...|
|Barrie fire servi...|http://www.simcoe...|
|Paris police stat...|http://www.dailym...|
+--------------------+--------------------+

事实上,Elasticsearch API 并不灵活,无法读取嵌套结构和复杂数组。使用最新版本的 Spark,人们很快就会遇到诸如“'persons'字段由数组支持,但相关的 Spark 模式并不反映这一点”之类的错误。通过一些实验,我们可以看到,使用一组标准的 JSON 解析器(例如下面的json4s)通常更容易从 Elasticsearch 中访问嵌套和复杂的结构:

<dependency>
  <groupId>org.json4s</groupId>
  <artifactId>json4s-native_2.11</artifactId>
  <version>3.2.11</version>
</dependency>

我们使用隐式的esJsonRdd函数从 spark 上下文查询 Elasticsearch:

import org.elasticsearch.spark._
import org.json4s.native.JsonMethods._
import org.json4s.DefaultFormats

def readFromES(query: String = "?q=*"): RDD[Array[String]] = {

  sc.esJsonRDD("gzet/news", query)
    .values
    . map {
      jsonStr =>
        implicit val format = DefaultFormats
        val json = parse(jsonStr)
        (json \ "persons").extract[Array[String]]
    }

}

readFromEs("?persons='david bowie'")
   .map(_.mkString(","))
   .take(3)
   .foreach(println)

/*
david bowie,yoko ono,john lennon,paul mc cartney
duncan jones,david bowie,tony visconti
david bowie,boris johnson,david cameron
*/

使用query参数,我们可以访问 Elasticsearch 中的所有数据,其中的一部分数据,或者甚至是与特定查询匹配的所有记录。最后,我们可以使用之前解释的简单联系链接方法来构建我们的元组列表。

val personRdd = readFromES()
val tupleRdd = personRdd flatMap buildTuples

使用 Accumulo 数据库

我们已经看到了从 Elasticsearch 读取personRdd对象的方法,这为我们的存储需求提供了一个简单而整洁的解决方案。然而,在编写商业应用程序时,我们必须始终牢记安全性,在撰写本文时,Elasticsearch 安全性仍在开发中;因此,在这个阶段引入具有本地安全性的存储机制将是有用的。这是一个重要的考虑因素,因为我们使用的是 GDELT 数据,当然,根据定义,它是开源的。在商业环境中,数据集很常见地是机密的或在某种程度上具有商业敏感性,客户通常会在讨论数据科学方面之前要求了解他们的数据将如何得到安全保护。作者的经验是,许多商业机会由于解决方案提供者无法展示健壮和安全的数据架构而丧失。

Accumulo (accumulo.apache.org) 是一个基于 Google 的 Bigtable 设计(research.google.com/archive/bigtable.html)的 NoSQL 数据库,最初由美国国家安全局开发,后来在 2011 年释放给 Apache 社区。Accumulo 为我们提供了通常的大数据优势,如批量加载和并行读取,但还具有一些额外的功能,如迭代器,用于高效的服务器和客户端预计算、数据聚合,最重要的是单元格级安全。

在我们的社区检测工作中,我们将使用 Accumulo 来特别利用其迭代器和单元格级安全功能。首先,我们应该设置一个 Accumulo 实例,然后从 Elasticsearch 加载一些数据到 Accumulo,你可以在我们的 GitHub 存储库中找到完整的代码。

设置 Accumulo

安装 Accumulo 所需的步骤超出了本书的范围;网上有几个教程可供参考。只需进行一个带有根用户的原始安装即可继续本章,尽管我们需要特别注意 Accumulo 配置中的初始安全设置。一旦成功运行 Accumulo shell,您就可以继续进行。

使用以下代码作为创建用户的指南。目标是创建几个具有不同安全标签的用户,这样当我们加载数据时,用户将有不同的访问权限。

# set up some users
createuser matt
createuser ant
createuser dave
createuser andy

# create the persons table
createtable persons

# switch to the persons table
table persons

# ensure all of the users can access the table
grant -s System.READ_TABLE -u matt
grant -s System.READ_TABLE -u ant
grant -s System.READ_TABLE -u dave
grant -s System.READ_TABLE -u andy

# allocate security labels to the users
addauths -s unclassified,secret,topsecret -u matt
addauths -s unclassified,secret -u ant
addauths -s unclassified,topsecret -u dave
addauths -s unclassified -u andy

# display user auths
getauths -u matt

# create a server side iterator to sum values
setiter -t persons -p 10 -scan -minc -majc -n sumCombiner -class
org.apache.accumulo.core.iterators.user.SummingCombiner

# list iterators in use
listiter –all

# once the table contains some records ...
user matt

# we'll see all of the records that match security labels for the user
scan

单元格安全

Accumulo 使用令牌来保护其单元格。令牌由标签组成;在我们的情况下,这些是[未分类], [机密], 和 [绝密], 但你可以使用任何逗号分隔的值。Accumulo 行是用visibility字段(参考下面的代码)编写的,它只是对访问行值所需的标签的字符串表示。visibility字段可以包含布尔逻辑来组合不同的标签,还允许基本的优先级,例如:

secret&topsecret (secret AND topsecret)
secret|topsecret (secret OR topsecret)
unclassified&(secret|topsecret) (unclassified AND secret, or unclassified AND topsecret)

用户必须至少匹配visibility字段才能获得访问权限,并且必须提供标签,这些标签是存储在 Accumulo 中的令牌的子集(否则查询将被拒绝)。任何不匹配的值在用户查询中将不会被返回,这是一个重要的观点,因为如果用户得知数据缺失,往往可以根据周围图的性质得出逻辑上正确(或者更糟糕的是错误)的结论,例如,在一个人的联系链中,如果一些顶点对用户可见而另一些不可见,但不可见的顶点被标记为不可见,那么用户可能能够根据周围的图确定有关这些缺失实体的信息。例如,调查有组织犯罪的政府机构可能允许高级员工查看整个图,但只允许初级员工查看其中的部分。假设图中显示了一些知名人物,并且一个顶点的条目为空白,那么可能很容易推断出缺失的实体是谁;如果这个占位符完全不存在,那么就没有明显的迹象表明链条延伸得更远,从而允许机构控制信息的传播。然而,对于对这些链接一无所知的分析人员来说,图仍然是有用的,并且可以继续在图的特定区域上工作。

迭代器

迭代器是 Accumulo 中非常重要的特性,提供了一个实时处理框架,利用 Accumulo 的强大和并行能力,以非常低的延迟产生修改后的数据版本。我们不会在这里详细介绍,因为 Accumulo 文档中有很多例子,但我们将使用一个迭代器来保持相同 Accumulo 行的值的总和,也就是我们看到相同的人员对的次数;这将存储在该行值中。每当扫描表时,这个迭代器就会生效;我们还将演示如何从客户端调用相同的迭代器(当它尚未应用于服务器时)。

Elasticsearch 到 Accumulo

让我们利用 Spark 能够使用 Hadoop 输入和输出格式的能力,利用本地 Elasticsearch 和 Accumulo 库。值得注意的是,我们在这里可以采取不同的路线,第一种是使用之前提供的 Elasticsearch 代码生成一个字符串元组数组,并将其输入到AccumuloLoader(在代码库中找到);第二种是探索另一种使用额外 Hadoop InputFormat 的方法;我们可以编写代码,使用EsInputFormat 从 Elasticsearch 读取数据,并使用AccumuloOutputFormat 类写入 Accumulo。

Accumulo 中的图数据模型

在深入代码之前,值得描述一下我们将在 Accumulo 中使用的存储人员图的模式。每个源节点(person A)将被存储为行键,关联名称(如“也被称为”)作为列族,目标节点(person B)作为列限定符,以及默认值1作为列值(这将通过我们的迭代器进行聚合)。如图 2 所示:

Accumulo 中的图数据模型

图 2:Accumulo 上的图数据模型

这种模型的主要优势在于,给定一个输入顶点(一个人的名字),可以通过简单的 GET 查询快速访问所有已知的关系。读者肯定会欣赏单元级别的安全性,我们可以隐藏一个特定的边三元组[personA] <= [relationB] => [personD],对大多数没有[SECRET]授权的 Accumulo 用户。

这种模型的缺点是,与图数据库(如 Neo4J 或 OrientDB)相比,遍历查询(如深度优先搜索)将非常低效(我们需要多次递归查询)。我们将任何图处理逻辑委托给本章后面的 GraphX。

Hadoop 输入和输出格式

我们使用以下 maven 依赖项来构建我们的输入/输出格式和我们的 Spark 客户端。版本显然取决于安装的 Hadoop 和 Accumulo 的发行版。

<dependency>
  <groupId>org.apache.accumulo</groupId>
  <artifactId>accumulo-core</artifactId>
  <version>1.7.0<version>
</dependency>

我们通过ESInputFormat类配置从 Elasticsearch 中读取。我们提取了一个TextMapWritable的键值对 RDD,其中键包含文档 ID,值包含所有 JSON 文档的可序列化 HashMap 包装在内:

val spark = SparkSession
  .builder()
  .appName("communities-loader")
  .getOrCreate()

val sc = spark.sparkContext
val hdpConf = sc.hadoopConfiguration

// set the ES entry points
hdpConf.set("es.nodes", "localhost:9200")
hdpConf.set("es.resource", "gzet/articles")

// Read map writable objects
import org.apache.hadoop.io.Text
import org.apache.hadoop.io.MapWritable
import org.elasticsearch.hadoop.mr.EsInputFormat

val esRDD: RDD[MapWritable] = sc.newAPIHadoopRDD(
  hdpConf,
  classOf[EsInputFormat[Text, MapWritable]],
  classOf[Text],
  classOf[MapWritable]
).values

Accumulo 的mutation类似于 HBase 中的put对象,包含表的坐标,如行键,列族,列限定符,列值和可见性。该对象构建如下:

def buildMutations(value: MapWritable) = {

  // Extract list of persons
  val people = value
    .get("person")
    .asInstanceOf[ArrayWritable]
    .get()
    .map(_.asInstanceOf[Text])
    .map(_.toString)

  // Use a default Visibility
  val visibility = new ColumnVisibility("unclassified")

  // Build mutation on tuples
  buildTuples(people.toArray)
    .map {
      case (src, dst) =>
        val mutation = new Mutation(src)
        mutation.put("associated", dst, visibility, "1")
        (new Text(accumuloTable), mutation)
    }

我们使用上述的buildTuples方法来计算我们的人员对,并使用 Hadoop 的AccumuloOutputFormat将它们写入 Accumulo。请注意,我们可以选择为我们的输出行应用安全标签,使用ColumnVisibility;参考Cell security,我们之前看到过。

我们配置用于写入 Accumulo。我们的输出 RDD 将是一个TextMutation的键值对 RDD,其中键包含 Accumulo 表,值包含要插入的 mutation:

// Build Mutations
val accumuloRDD = esRDD flatMap buildMutations

// Save Mutations to Accumulo
accumuloRDD.saveAsNewAPIHadoopFile(
  "",
  classOf[Text],
  classOf[Mutation],
  classOf[AccumuloOutputFormat]
)

从 Accumulo 读取

现在我们的数据在 Accumulo 中,我们可以使用 shell 来检查它(假设我们选择了一个有足够权限查看数据的用户)。在 Accumulo shell 中使用scan命令,我们可以模拟特定用户和查询,从而验证io.gzet.community.accumulo.AccumuloReader的结果。在使用 Scala 版本时,我们必须确保使用正确的授权-它通过String传递到读取函数中,例如可能是"secret,topsecret"

def read(
  sc: SparkContext,
  accumuloTable: String,
  authorization: Option[String] = None
)

这种应用 Hadoop 输入/输出格式的方法利用了 Java Accumulo 库中的static方法(AbstractInputFormatInputFormatBase的子类,InputFormatBaseAccumuloInputFormat的子类)。Spark 用户必须特别注意这些实用方法,通过Job对象的实例来修改 Hadoop 配置。可以设置如下:

val hdpConf = sc.hadoopConfiguration
val job = Job.getInstance(hdpConf)

val clientConfig = new ClientConfiguration()
  .withInstance(accumuloInstance)
  .withZkHosts(zookeeperHosts)

AbstractInputFormat.setConnectorInfo(
  job,
  accumuloUser,
  new PasswordToken(accumuloPassword)
)

AbstractInputFormat.setZooKeeperInstance(
  job,
  clientConfig
)

if(authorization.isDefined) {
  AbstractInputFormat.setScanAuthorizations(
    job,
    new Authorizations(authorization.get)
  )
}

InputFormatBase.addIterator(job, is)
InputFormatBase.setInputTableName(job, accumuloTable)

您还会注意到配置了 Accumulo 迭代器:

val is = new IteratorSetting(
  1,
  "summingCombiner",
  "org.apache.accumulo.core.iterators.user.SummingCombiner"
)

is.addOption("all", "")
is.addOption("columns", "associated")
is.addOption("lossy", "TRUE")
is.addOption("type", "STRING")

我们可以使用客户端或服务器端迭代器,之前我们已经在通过 shell 配置 Accumulo 时看到了一个服务器端的例子。关键区别在于客户端迭代器在客户端 JVM 中执行,而不是服务器端迭代器利用 Accumulo 表服务器的功能。在 Accumulo 文档中可以找到完整的解释。然而,选择客户端或服务器端迭代器的许多原因,包括是否应该牺牲表服务器性能,JVM 内存使用等。这些决定应该在创建 Accumulo 架构时进行。在我们的AccumuloReader代码的末尾,我们可以看到产生EdgeWritable的 RDD 的调用函数:

val edgeWritableRdd: RDD[EdgeWritable] = sc.newAPIHadoopRDD(
  job.getConfiguration,
  classOf[AccumuloGraphxInputFormat],
  classOf[NullWritable],
  classOf[EdgeWritable]
) values

AccumuloGraphxInputFormat 和 EdgeWritable

我们实现了自己的 Accumulo InputFormat,使我们能够读取 Accumulo 行并自动输出我们自己的 Hadoop WritableEdgeWritable。这提供了一个方便的包装器,用于保存我们的源顶点,目标顶点和作为边权重的计数,这在构建图时可以使用。这非常有用,因为 Accumulo 使用前面讨论的迭代器来计算每个唯一行的总计数,从而无需手动执行此操作。由于 Accumulo 是用 Java 编写的,我们的InputFormat使用 Java 来扩展InputFormatBase,从而继承了所有 AccumuloInputFormat的默认行为,但输出我们选择的模式。

我们只对输出EdgeWritables感兴趣;因此,我们将所有键设置为 null(NullWritable),值设置为EdgeWritable,另一个优势是 Hadoop 中的值只需要继承自Writable接口(尽管我们为了完整性继承了WritableComparable,因此如果需要,EdgeWritable也可以用作键)。

构建图

因为 GraphX 使用长对象作为存储顶点和边的基础类型,所以我们首先需要将从 Accumulo 获取的所有人员翻译成一组唯一的 ID。我们假设我们的唯一人员列表不适合存储在内存中,或者无论如何都不高效,所以我们简单地使用zipWithIndex函数构建一个分布式字典,如下面的代码所示:

val dictionary = edgeWritableRdd
  .flatMap {
    edge =>
      List(edge.getSourceVertex, edge.getDestVertex)
  }
  .distinct()
  .zipWithIndex()
  .mapValues {
    index =>
      index + 1L
  }
}

dictionary.cache()
dictionary.count()

dictionary
  .take(3)
  .foreach(println)

/*
(david bowie, 1L)
(yoko ono, 2L)
(john lennon, 3L)
*/

我们使用两次连续的连接操作来创建边 RDD,最终构建包含人员名称的顶点和包含每个元组频率计数的边属性的加权有向图。

val vertices = dictionary.map(_.swap)

val edges = edgeWritableRdd
  .map {
    edge =>
      (edge.getSourceVertex, edge)
  }
  .join(dictionary)
  .map {
    case (from, (edge, fromId)) =>
      (edge.getDestVertex, (fromId, edge))
  }
  .join(dictionary)
  .map {
    case (to, ((fromId, edge), toId)) =>
      Edge(fromId, toId, edge.getCount.toLong)
  }

val personGraph = Graph.apply(vertices, edges)

personGraph.cache()
personGraph.vertices.count()

personGraph
  .triplets
  .take(2)
  .foreach(println)

/*
((david bowie,1),(yoko ono,2),1)
((david bowie,1),(john lennon,3),1)
((yoko ono,2),(john lennon,3),1)
*/

社区检测算法

在过去几十年里,社区检测已经成为研究的热门领域。遗憾的是,它没有像真正的数据科学家所处的数字世界一样快速发展,每秒都在收集更多的数据。因此,大多数提出的解决方案对于大数据环境来说根本不合适。

尽管许多算法提出了一种新的可扩展的检测社区的方法,但实际上没有一种是在分布式算法和并行计算方面真正可扩展的。

Louvain 算法

Louvain 算法可能是检测无向加权图中社区最流行和广泛使用的算法。

注意

有关 Louvain 算法的更多信息,请参阅出版物:大型网络中社区的快速展开。文森特 D.布隆德,让-卢·吉约姆,勒诺·兰比奥特,艾蒂安·勒菲布尔。2008

这个想法是从每个顶点作为其自己社区的中心开始。在每一步中,我们寻找社区邻居,并检查合并这两个社区是否会导致模块化值的增益。通过每个顶点,我们压缩图形,使得所有属于同一个社区的节点成为一个唯一的社区顶点,所有社区内部边成为具有聚合权重的自边。我们重复这个过程,直到无法再优化模块化。该过程如图 3所示:

Louvain 算法

图 3:大型网络中社区的快速展开-文森特 D.布隆德尔,让-卢·吉约姆,勒诺·兰比奥特,艾蒂安·勒菲布尔,2008

因为每当顶点改变时,模块化都会更新,而且每个顶点的改变都将由全局模块化更新驱动,所以顶点需要按顺序处理;这使得模块化优化成为并行计算性质的一个分界点。最近的研究报告称,随着图的规模过度增加,结果的质量可能会下降,以至于模块化无法检测到小而明确定义的社区。

据我们所知,唯一公开可用的 Louvain 的分布式版本是由国家安全技术供应商 Sotera 创建的(github.com/Sotera/distributed-graph-analytics/tree/master/dga-graphx)。他们在 MapReduce、Giraph 或 GraphX 上有不同的实现,他们的想法是同时做出顶点选择,并在每次更改后更新图状态。由于并行性质,一些顶点选择可能是不正确的,因为它们可能无法最大化全局模块化,但在重复迭代后最终变得越来越一致。

这种(可能)略微不准确,但绝对高度可扩展的算法值得研究,但由于社区检测问题没有对错解决方案,而且每个数据科学用例都不同,我们决定构建我们自己的分布式版本的不同算法,而不是描述现有的算法。为了方便起见,我们重新打包了这个分布式版本的 Louvain,并在我们的 GitHub 存储库中提供了它。

加权社区聚类(WCC)

通过搜索一些关于图算法的文档材料,我们偶然发现了一份关于可扩展性和并行计算的出色且最新的白皮书。我们邀请我们的读者在继续实施之前先阅读这篇论文。

注意

有关WCC算法的更多信息,请参阅以下出版物:A. Prat-Perez, D. Dominguez-Sal, and J.-L. Larriba-Pey, "High quality, scalable and parallel community detection for large real graphs," in Proceedings of the 23rd International Conference on World Wide Web, ser. WWW '14. New York, NY, USA: ACM, 2014, pp. 225-236

尽管找不到任何实现,并且作者对他们使用的技术保持低调,但我们对作为图分区度量的启发式方法特别感兴趣,因为检测可以并行进行,而无需重新计算图模块度等全局度量。

描述

同样有趣的是他们使用的假设,受到现实生活社交网络的启发,作为检测社区的质量度量。因为社区是紧密连接在一起并与图的其余部分松散连接的顶点组成的群体,所以每个社区内应该有大量的三角形。换句话说,组成社区的顶点应该在自己的社区内关闭的三角形数量要比在外部关闭的要多得多。

Description

根据前述方程,给定顶点x在社区C中的聚类系数(WCC)将在x在其社区内部关闭的三角形数量多于外部时达到最大值(社区将被明确定义),和/或者当它与不关闭任何三角形的邻居数量最小时(所有节点相互连接)。如下方程所述,社区SWCC将是其每个顶点的平均WCC

Description

同样,图分区PWCC将是每个社区WCC的加权平均值:

Description

该算法包括三个不同的阶段,下面将对其进行解释。预处理步骤创建初始社区集,社区回传以确保初始社区一致,最后是一个迭代算法,优化全局聚类系数值。

预处理阶段

第一步是定义一个图结构,其中顶点包含我们需要在本地计算WCC指标的所有变量,包括顶点所属的当前社区,每个顶点在其社区内外关闭的三角形数量,它与其他节点共享三角形的数量以及当前WCC指标。所有这些变量将被封装到一个VState类中:

class VState extends Serializable {
  var vId = -1L
  var cId = -1L
  var changed = false
  var txV = 0
  var txC = 0
  var vtxV = 0
  var vtxV_C = 0
  var wcc = 0.0d
}

为了计算初始WCC,我们首先需要计算任何顶点在其邻域内关闭的三角形数量。通常计算三角形的数量包括为每个顶点聚合邻居的 ID,将此列表发送给每个邻居,并在顶点邻居和顶点邻居的邻居中搜索共同的 ID。给定两个相连的顶点 A 和 B,A 的邻居列表和 B 的邻居列表的交集是顶点 A 与 B 关闭的三角形数量,而 A 中的聚合返回顶点 A 在整个图中关闭的三角形的总数。

在具有高度连接的顶点的大型网络中,向每个邻居发送相邻顶点的列表可能会耗时且网络密集。在 GraphX 中,triangleCount函数已经经过优化,因此对于每条边,只有最不重要的顶点(按度数而言)将向其相邻节点发送其列表,从而最小化相关成本。此优化要求图形是规范的(源 ID 小于目标 ID)并且被分区。使用我们的人员图,可以按以下方式完成:

val cEdges: RDD[Edge[ED]] = graph.edges
  .map { e =>
    if(e.srcId > e.dstId) {
      Edge(e.dstId, e.srcId, e.attr)
    } else e
  }

val canonicalGraph = Graph
  .apply(graph.vertices, cEdges)
  .partitionBy(PartitionStrategy.EdgePartition2D)

canonicalGraph.cache()
canonicalGraph.vertices.count()

WCC 优化的先决条件是删除不属于任何三角形的边,因为它们不会对社区做出贡献。因此,我们需要计算三角形的数量,每个顶点的度数,邻居的 ID,最后删除邻居 ID 的交集为空的边。可以使用subGraph方法来过滤这些边,该方法接受边三元组的filter函数和顶点的filter函数作为输入参数:

val triGraph = graph.triangleCount()
val neighborRdd = graph.collectNeighborIds(EdgeDirection.Either)

val subGraph = triGraph.outerJoinVertices(neighborRdd)({ (vId, triangle, neighbors) =>
  (triangle, neighbors.getOrElse(Array()))
}).subgraph((t: EdgeTriplet[(Int, Array[Long]), ED]) => {
  t.srcAttr._2.intersect(t.dstAttr._2).nonEmpty
}, (vId: VertexId, vStats: (Int, Array[Long])) => {
  vStats._1 > 0
})

由于我们删除了没有闭合任何三角形的所有边,因此每个顶点的度数变成了给定顶点与三角形闭合的不同顶点的数量。最后,我们按照以下方式创建我们的初始VState图,其中每个顶点都成为其自己社区的中心节点:

val initGraph: Graph[VState, ED] = subGraph.outerJoinVertices(subGraph.degrees)((vId, vStat, degrees) => {
  val state = new VState()
  state.vId = vId
  state.cId = vId
  state.changed = true
  state.txV = vStat._1
  state.vtxV = degrees.getOrElse(0)
  state.wcc = degrees.getOrElse(0).toDouble / vStat._1 
  state
})

initGraph.cache()
initGraph.vertices.count()

canonicalGraph.unpersist(blocking = false)

初始社区

这个阶段的第二步是使用这些初始 WCC 值初始化社区。我们定义我们的初始社区集合只有在满足以下三个要求时才是一致的:

  • 任何社区必须包含单个中心节点和边界节点,并且所有边界顶点必须连接到社区中心

  • 任何社区中心必须具有其社区中最高的聚类系数

  • 连接到两个不同中心(因此根据规则 1 属于两个不同社区)的边界顶点必须属于其中心具有最高聚类系数的社区

消息传递

为了定义我们的初始社区,每个顶点都需要向其邻居发送信息,包括其 ID,其聚类系数,其度数和它当前所属的社区。为方便起见,我们将发送主要顶点属性VState类作为消息,因为它已经包含了所有这些信息。顶点将从其邻域接收这些消息,将选择具有最高 WCC 分数(在我们的getBestCid方法中),最高度数,最高 ID 的最佳消息,并相应地更新其社区。

顶点之间的这种通信是aggregateMessages函数的一个完美用例,它相当于 GraphX 中的映射-减少范式。这个函数需要实现两个函数,一个是从一个顶点向其相邻节点发送消息,另一个是在顶点级别聚合多个消息。这个过程被称为消息传递,并且描述如下:

def getBestCid(v: VState, msgs: Array[VState]): VertexId = {

  val candidates = msgs filter {

    msg =>
      msg.wcc > v.wcc ||
      (msg.wcc == v.wcc && msg.vtxV > v.vtxV) ||
      (msg.wcc == v.wcc && msg.vtxV > v.vtxV && msg.cId > v.cId)
    }

  if(candidates.isEmpty) {

    v.cId

  } else {

    candidates
     .sortBy {
       msg =>
         (msg.wcc, msg.vtxV, msg.cId)
      }
      .last
      .cId
  }

}

def sendMsg = (ctx: EdgeContext[VState, ED, Array[VState]]) => {

  ctx.sendToDst(
    Array(ctx.srcAttr)
  )

  ctx.sendToSrc(
    Array(ctx.dstAttr)
  )
}

def mergeMsg = (m1: Array[VState], m2: Array[VState]) => {
  m1 ++ m2
}

def msgs = subGraph.aggregateMessages(sendMsg, mergeMsg)

val initCIdGraph = subGraph.outerJoinVertices(msgs)((vId, vData, msgs) => {
  val newCId = getBestCid(vData, msgs.getOrElse(Array()))
  vData.cId = newCId
  vData
})

initCIdGraph.cache()
initCIdGraph.vertices.count()
initGraph.unpersist(blocking = false)

社区初始化过程的一个示例报告在图 4中。左图的节点按比例调整大小以反映其真实的 WCC 系数,已经初始化为四个不同的社区,1111621

消息传递

图 4:WCC 社区初始化

尽管人们肯定会欣赏到一个aggregateMessages函数返回了相对一致的社区,但这种初始分区违反了我们之前定义的规则中的第三条。一些顶点(如2345)属于一个中心不是中心节点的社区(顶点1属于社区21)。对于社区11也存在同样的问题。

社区回传

为了解决这种不一致性并满足我们的第三个要求,任何顶点x必须将其更新的社区广播给所有系数较低的邻居,因为根据我们的第二条规则,只有这些排名较低的顶点可能成为x的边界节点。任何进一步的更新都将导致向较低排名的顶点传递新消息,依此类推,直到没有顶点会改变社区,此时我们的第三条规则将得到满足。

由于迭代之间不需要图的全局知识(例如计算全局 WCC 值),使用 GraphX 的 Pregel API 可以广泛并行化社区更新。Pregel 最初由 Google 开发,允许顶点接收来自先前迭代的消息,向其邻域发送新消息,并修改自己的状态,直到不能再发送更多消息。

注意

有关Pregel算法的更多信息,请参阅以下出版物:G. Malewicz, M. H. Austern, A. J. Bik, J. C. Dehnert, I. Horn, N. Leiser, and G. Czajkowski, "Pregel: A system for large-scale graph processing," in Proceedings of the 2010 ACM SIGMOD International Conference on Management of Data, ser. SIGMOD '10. New York, NY, USA: ACM, 2010, pp. 135-146. [Online]. Available: doi.acm.org/10.1145/1807167.1807184

与之前提到的aggregateMessages函数类似,我们将顶点属性VState作为消息发送到顶点之间,作为 Pregel 超步的初始消息,使用默认值初始化的新对象(WCC 为 0)。

val initialMsg = new VState() 

当在顶点级别接收到多个消息时,我们只保留具有最高聚类系数的消息,如果系数相同,则保留具有最高度数的消息(然后是最高 ID)。我们为此目的在VState上创建了一个隐式排序:

implicit val VSOrdering: Ordering[VState] = Ordering.by({ state =>
  (state.wcc, state.vtxV, state.vId)
})

def compareState(c1: VState, c2: VState) = {
  List(c1, c2).sorted(VStateOrdering.reverse)
}

val mergeMsg = (c1: VState, c2: VState) => {
  compareState(c1, c2).head
}

遵循递归算法的相同原则,我们需要适当地定义一个中断子句,Pregel 应在该点停止发送和处理消息。这将在发送函数中完成,该函数以边三元组作为输入并返回消息的迭代器。如果顶点的社区在上一次迭代中发生了变化,顶点将发送其VState属性。在这种情况下,顶点将通知其排名较低的邻居其社区更新,但也会向自己发送信号以确认此成功广播。后者是我们的中断子句,因为它确保不会从给定节点发送更多消息(除非其社区在后续步骤中得到更新):

def sendMsg = (t: EdgeTriplet[VState, ED]) => {

  val messages = mutable.Map[Long, VState]()
  val sorted = compareState(t.srcAttr, t.dstAttr)
  val (fromNode, toNode) = (sorted.head, sorted.last)
  if (fromNode.changed) {
    messages.put(fromNode.vId, fromNode)
    messages.put(toNode.vId, fromNode)
  }

  messages.toIterator

}

最后要实现的函数是 Pregel 算法的核心函数。在这里,我们定义了在顶点级别应用的逻辑,给定我们从mergeMsg函数中选择的唯一消息。我们确定了四种不同的消息可能性,每种消息都定义了应用于顶点状态的逻辑。

  1. 如果消息是从 Pregel 发送的初始消息(顶点 ID 未设置,WCC 为空),我们不会更新顶点社区 ID。

  2. 如果消息来自顶点本身,这是来自sendMsg函数的确认,我们将顶点状态设置为静默。

  3. 如果消息(带有更高的 WCC)来自社区的中心节点,我们将更新顶点属性为这个新社区的边界节点。

  4. 如果消息(带有更高的 WCC)来自社区的边界节点,这个顶点将成为自己社区的中心,并将进一步将此更新广播给其排名较低的网络。

def vprog = (vId: VertexId, state: VState, message: VState) => {

  if (message.vId >= 0L) {

    // message comes from myself
    // I stop spamming people
    if (message.vId == vId) {
      state.changed = false
    }

    // Sender is a center of its own community
    // I become a border node of its community
    if (message.cId == message.vId) {
      state.changed = false
      state.cId = message.cId
    }

    // Sender is a border node of a foreign community
    // I become a center of my own community
    // I broadcast this change downstream
    if (message.cId != message.vId) {
      state.changed = true
      state.cId = vId
    }

  }
  state

}

最后,我们使用Pregel对象的apply函数将这三个函数链接在一起。我们将迭代的最大次数设置为无穷大,因为我们依赖于我们使用确认类型消息定义的中断子句:

val pregelGraph: Graph[VState, ED] = Pregel.apply(
  initCIdGraph, 
  initialMsg, 
  Int.MaxValue 
)(
  vprog,
  sendMsg,
  mergeMsg
)

pregelGraph.cache()
pregelGraph.vertices.count()

虽然 Pregel 的概念很迷人,但它的实现确实不是。作为对这一巨大努力的回报,我们在图 5中展示了结果图。顶点111仍然属于社区21,这仍然有效,但社区111现在分别被替换为社区155,顶点具有最高的聚类系数、度或 ID 在其社区中,因此验证了第三个要求:

社区反向传播

图 5:社区反向传播更新

我们使用 Pregel API 根据之前介绍的规则创建了我们的初始社区集,但我们还没有完成。前面的图表明了一些改进,这些将在下一小节中讨论。然而,在继续之前,可以注意到这里没有使用特定的分区。如果我们要在社区节点之间发送多条消息,并且这些顶点位于不同的分区(因此位于不同的执行器),我们肯定不能优化与消息传递相关的网络流量。GraphX 中存在不同类型的分区,但没有一种允许我们使用顶点属性(如社区 ID)作为分区的度量。

在下面的简单函数中,我们提取所有的图三元组,根据社区元组构建一个哈希码,并使用标准的键值HashPartitioner类重新分区这个边 RDD。最后,我们根据这个重新分区的集合构建一个新的图,以确保从社区 C1 连接到社区 C2 的所有顶点都属于同一个分区:

def repartitionED: ClassTag = {

  val partitionedEdges = graph
    .triplets
    .map {
      e =>
        val cId1 = e.srcAttr.cId
        val cId2 = e.dstAttr.cId
        val hash = math.abs((cId1, cId2).hashCode())
        val partition = hash % partitions
        (partition, e)
    }
    .partitionBy(new HashPartitioner(partitions))
    .map {
      pair =>
        Edge(pair._2.srcId, pair._2.dstId, pair._2.attr)
    }

  Graph(graph.vertices, partitionedEdges)

}

WCC 迭代

这个阶段的目的是让所有顶点在以下三个选项之间进行迭代选择,直到 WCC 值不能再被优化为止,此时我们的社区检测算法将收敛到其最佳图结构:

  • 留下:留在它的社区里

  • 转移:从它的社区移动并成为它的邻居的一部分

  • 移除:离开它的社区,成为自己社区的一部分

对于每个顶点,最佳移动是最大化总 WCC 值的移动。与 Louvain 方法类似,每个移动都取决于要计算的全局分数,但我们转向这个算法的原因是,这个分数可以使用 Arnau Prat-Pérez 等人在用于大型实际图的高质量、可扩展和并行社区检测中定义的启发式方法来近似。因为这个启发式方法不需要计算所有内部三角形,顶点可以同时移动,因此这个过程可以设计成完全分散和高度可扩展的。

收集社区统计信息

为了计算这个启发式方法,我们首先需要在社区级别聚合基本统计数据,比如元素数量和入站和出站链接数量,这两者都可以用简单的词频函数来表示。我们将它们组合在内存中,因为社区的数量将远远小于顶点的数量:

case class CommunityStats(
   r: Int,
   d: Double,
   b: Int
)

def getCommunityStatsED: ClassTag = {

  val cVert = graph
    .vertices
    .map(_._2.cId -> 1)
    .reduceByKey(_+_)
    .collectAsMap()

  val cEdges = graph
    .triplets
    .flatMap { t =>
      if(t.srcAttr.cId == t.dstAttr.cId){
        Iterator((("I", t.srcAttr.cId), 1))
      } else {
        Iterator(
          (("O", t.srcAttr.cId), 1), 
          (("O", t.dstAttr.cId), 1)
        )
      }
    }
    .reduceByKey(_+_)
    .collectAsMap()

  cVert.map {
    case (cId, cCount) =>
      val intEdges = cEdges.getOrElse(("I", cId), 0)
      val extEdges = cEdges.getOrElse(("O", cId), 0)
      val density = 2 * intEdges / math.pow(cCount, 2)
      (cId, CommunityStats(cCount, density, extEdges))
  } 

}

最后,我们收集顶点数量和社区统计信息(包括社区边缘密度),并将结果广播到我们所有的 Spark 执行器:

var communityStats = getCommunityStats(pregelGraph)
val bCommunityStats = sc.broadcast(communityStats)

提示

在这里理解broadcast方法的使用是很重要的。如果社区统计信息在 Spark 转换中使用,这个对象将被发送到执行器,以便后者处理每条记录。我们计算它们一次,将结果广播到执行器的缓存中,以便任何闭包可以在本地使用它们,从而节省大量不必要的网络传输。

WCC 计算

根据之前定义的一系列方程,每个顶点必须访问其所属的社区统计数据以及它与社区内任何顶点之间的三角形数量。为此,我们通过简单的消息传递来收集邻居,但只限于同一社区内的顶点,从而限制网络流量:

def collectCommunityEdgesED: ClassTag = {

  graph.outerJoinVertices(graph.aggregateMessages((e: EdgeContext[VState, ED, Array[VertexId]]) => {
    if(e.dstAttr.cId == e.srcAttr.cId){
      e.sendToDst(Array(e.srcId))
      e.sendToSrc(Array(e.dstId))
    }
  }, (e1: Array[VertexId], e2: Array[VertexId]) => {
    e1 ++ e2
  }))((vid, vState, vNeighbours) => {
    (vState, vNeighbours.getOrElse(Array()))
  })

}

同样,我们使用以下函数来计算共享三角形的数量。请注意,我们使用与默认的triangleCount方法相同的优化,只使用最小集合向最大集合发送消息。

def collectCommunityTrianglesED: ClassTag, ED]) = {

  graph.aggregateMessages((ctx: EdgeContext[(VState, Array[Long]), ED, Int]) => {
    if(ctx.srcAttr._1.cId == ctx.dstAttr._1.cId){
      val (smallSet, largeSet) = if (ctx.srcAttr._2.length < ctx.dstAttr._2.length) {
        (ctx.srcAttr._2.toSet, ctx.dstAttr._2.toSet)
      } else {
        (ctx.dstAttr._2.toSet, ctx.srcAttr._2.toSet)
      }
      val it = smallSet.iterator
      var counter: Int = 0
      while (it.hasNext) {
        val vid = it.next()
        if (
          vid != ctx.srcId &&
          vid != ctx.dstId &&
          largeSet.contains(vid)
        ) {
          counter += 1
        }
      }

      ctx.sendToSrc(counter)
      ctx.sendToDst(counter)

    }
  }, (e1: Int, e2: Int) => (e1 + e2))

}

我们计算并更新每个顶点的新 WCC 分数,作为社区邻域大小和社区三角形数量的函数。这个方程就是之前介绍 WCC 算法时描述的方程。我们计算一个分数,作为社区 C 内外闭合的三角形的比率,给定一个顶点x

def updateGraphED: ClassTag = {

  val cNeighbours = collectCommunityEdges(graph)
  val cTriangles = collectCommunityTriangles(cNeighbours)

  cNeighbours.outerJoinVertices(cTriangles)(
    (vId, vData, tri) => {
      val s = vData._1
      val r = stats.value.get(s.cId).get.r

      // Core equation: compute WCC(v,C)
      val a = s.txC * s.vtxV
      val b = (s.txV * (r - 1 + s.vtxV_C).toDouble) 
      val wcc = a / b

      val vtxC = vData._2.length
      s.vtxV_C = s.vtxV – vtxC

      // Triangles are counted twice (incoming / outgoing)
      s.txC = tri.getOrElse(0) / 2
      s.wcc = wcc
      s
  })

}

val wccGraph = updateGraph(pregelGraph, bCommunityStats)

全球 WCC 值是每个顶点 WCC 的简单聚合,经过每个社区中元素数量的归一化。这个值也必须广播到 Spark 执行器中,因为它将在 Spark 转换中使用:

def computeWCCED: ClassTag: Double = {

  val total = graph.vertices
    .map {
      case (vId, vState) =>
        (vState.cId, vState.wcc)
    }
    .reduceByKey(_+_)
    .map {
      case (cId, wcc) =>
        cStats.value.get(cId).get.r * wcc
    }
    .sum

  total / graph.vertices.count

}

val wcc = computeWCC(wccGraph, bCommunityStats)
val bWcc = sc.broadCast(wcc)

WCC 迭代

考虑到将顶点x插入到社区C的成本,从/向社区C移除/转移x的成本可以表示为前者的函数,并且可以从三个参数Θ[1]Θ[2]Θ[3]**中导出。这个启发式规定,对于每个顶点x*,需要对其周围的每个社区C进行一次计算,并且可以并行进行,假设我们首先收集了所有社区统计数据:

WCC 迭代

Θ[1]Θ[2]Θ[3]的计算将不在此处报告(可在我们的 GitHub 上找到),但取决于社区密度、外部边缘和元素数量,所有这些都包含在我们之前定义的广播的CommunityStats对象集合中。最后值得一提的是,这个计算具有线性时间复杂度。

在每次迭代中,我们将收集任何顶点周围的不同社区,并使用我们在第六章中介绍的 Scalaz 的mappend聚合来聚合边的数量,抓取基于链接的外部数据。这有助于我们限制编写的代码量,并避免使用可变对象。

val cDegrees = itGraph.aggregateMessages((ctx: EdgeContext[VState, ED, Map[VertexId, Int]]) => {

  ctx.sendToDst(
    Map(ctx.srcAttr.cId -> 1)
  )

  ctx.sendToSrc(
    Map(ctx.dstAttr.cId -> 1)
  )

}, (e1: Map[VertexId, Int], e2: Map[VertexId, Int]) => {
  e1 |+| e2
})

利用社区统计数据、上一次迭代的 WCC 值、顶点数量和上述边的数量,我们现在可以估算将每个顶点x插入到周围社区C中的成本。我们找到每个顶点的最佳移动以及其周围社区的最佳移动,最终应用最大化 WCC 值的最佳移动。

最后,我们回调之前定义的一系列方法和函数,以更新每个顶点、每个社区的新 WCC 值,然后更新图分区本身,以查看所有这些变化是否导致了 WCC 的改善。如果 WCC 值无法再进行优化,算法就已经收敛到了最佳结构,最终我们返回一个包含顶点 ID 和该顶点所属的最终社区 ID 的顶点 RDD。

我们的测试社区图已经经过优化(虽然不是没有付出努力),并如图 6所示报告:

WCC 迭代

图 6:WCC 优化的社区

我们观察到之前预期的所有变化。顶点111现在分别属于它们预期的社区,分别是511。我们还注意到顶点 16 现在已经包括在其社区 11 中。

GDELT 数据集

为了验证我们的实现,我们使用了我们在上一章中分析过的 GDELT 数据集。我们提取了所有的社区,并花了一些时间查看人名,以确定我们的社区聚类是否一致。社区的完整图片报告在图 7中,并且是使用 Gephi 软件实现的,只导入了前几千个连接。

GDELT 数据集

图 7:2021 年 1 月 12 日的社区检测

我们首先观察到,我们检测到的大多数社区与我们在力导向布局中可以直观看到的社区完全一致,这给算法准确性带来了很高的信心水平。

鲍伊效应

任何明确定义的社区都已经被正确识别,而不太明显的社区是围绕着像大卫·鲍伊这样的高度连接的顶点而形成的。大卫·鲍伊这个名字在 GDELT 文章中被频繁提及,与许多不同的人一起,以至于在 2016 年 1 月 12 日,它变得太大,无法成为其逻辑社区(音乐行业)的一部分,并形成了一个更广泛的社区,影响了其周围的所有顶点。这里绝对存在一个有趣的模式,因为这种社区结构为我们提供了关于特定人物在特定日期可能成为突发新闻文章的明确见解。

观察大卫·鲍伊在图 8中最接近的社区,我们观察到节点之间高度相互连接,这是因为我们将其称为鲍伊效应。事实上,来自许多不同社区的许多致敬使得跨不同社区形成的三角形数量异常高。结果是,它将不同的逻辑社区彼此靠近,这些社区在理论上本不应该靠近,比如70 年代的摇滚明星偶像与宗教人士之间的接近。

小世界现象是由斯坦利·米尔格拉姆在 60 年代定义的,它指出每个人都通过少数熟人相连。美国演员凯文·贝肯甚至建议他与其他任何演员之间最多只能通过 6 个连接相连,也被称为贝肯数oracleofbacon.org/)。

在那一天,教皇弗朗西斯和米克·贾格尔的凯文·贝肯数仅为 1,这要归功于主教吉安弗兰科·拉瓦西在推特上提到了大卫·鲍伊。

鲍伊效应

图 8:围绕大卫·鲍伊的社区,1 月 12 日

尽管鲍伊效应,由于其作为突发新闻文章的性质,在特定的图结构上是一个真正的模式,但它的影响可以通过基于名称频率计数的加权边来最小化。事实上,来自 GDELT 数据集的一些随机噪音可能足以关闭来自两个不同社区的关键三角形,从而将它们彼此靠近,无论这个关键边的权重如何。这种限制对于所有非加权算法都是普遍存在的,并且需要一个预处理阶段来减少这种不需要的噪音。

较小的社区

然而,我们可以观察到一些更明确定义的社区,比如英国政治家托尼·布莱尔、大卫·卡梅伦和鲍里斯·约翰逊,或者电影导演克里斯托弗·诺兰、马丁·斯科塞斯和昆汀·塔伦蒂诺。从更广泛的角度来看,我们可以检测到明确定义的社区,比如网球运动员、足球运动员、艺术家或特定国家的政治家。作为准确性的不容置疑的证据,我们甚至检测到马特·勒布朗、考特尼·考克斯、马修·佩里和詹妮弗·安妮斯顿作为同一个《老友记》社区的一部分,卢克·天行者、阿纳金·天行者、乔巴卡和帕尔帕廷皇帝作为《星球大战》社区的一部分,以及最近失去的女演员凯丽·费雪。职业拳击手社区的一个例子如图 9所示:

较小的社区

图 9:职业拳击手社区

使用 Accumulo 单元格级安全

我们之前已经讨论了 Accumulo 中单元级安全的性质。在这里我们生成的图的背景下,安全性的有用性可以很好地模拟。如果我们配置 Accumulo,使得包含大卫·鲍伊的行与所有其他行标记不同的安全标签,那么我们可以打开和关闭鲍伊的效应。任何具有完全访问权限的 Accumulo 用户将看到之前提供的完整图。然后,如果我们将该用户限制在除了大卫·鲍伊之外的所有内容(在AccumuloReader中对授权进行简单更改),那么我们将看到以下图。这个新图非常有趣,因为它具有多种用途:

  • 它消除了大卫·鲍伊死亡的社交媒体效应所产生的噪音,从而揭示了真正涉及的社区

  • 它消除了实体之间的许多虚假链接,从而增加了它们的 Bacon 数,并显示了它们真正的关系

  • 它证明了可以移除图中的一个关键人物,仍然保留大量有用信息,从而证明了之前关于出于安全原因移除关键实体的观点(如单元安全中讨论的)。

当然,还必须说,通过移除一个实体,我们也可能移除实体之间的关键关系;也就是说,联系链效应,这在特定试图关联个体实体时是一个负面因素,然而,社区仍然保持完整。

使用 Accumulo 单元级安全

图 10:大卫·鲍伊的受限访问社区

总结

我们已经讨论并构建了一个利用安全和稳健架构的图社区的实际实现。我们已经概述了在社区检测问题空间中没有正确或错误的解决方案,因为它严重依赖于使用情况。例如,在社交网络环境中,其中顶点紧密连接在一起(一条边表示两个用户之间的真实连接),边的权重并不重要,而三角形方法可能更重要。在电信行业中,人们可能对基于给定用户 A 对用户 B 的频率呼叫的社区感兴趣,因此转向加权算法,如 Louvain。

我们感谢构建这个社区算法远非易事,也许超出了本书的目标,但它涉及了 Spark 中图处理的所有技术,使 GraphX 成为一个迷人且可扩展的工具。我们介绍了消息传递、Pregel、图分区和变量广播的概念,支持了 Elasticsearch 和 Accumulo 中的实际实现。

在下一章中,我们将应用我们在这里学到的图论概念到音乐行业,学习如何使用音频信号、傅立叶变换和PageRank算法构建音乐推荐引擎。

第八章:构建推荐系统

如果要选择一个算法来向公众展示数据科学,推荐系统肯定会成为其中的一部分。今天,推荐系统无处不在。它们之所以如此受欢迎,原因在于它们的多功能性、实用性和广泛适用性。无论是根据用户的购物行为推荐产品,还是根据观看偏好建议新电影,推荐系统现在已经成为生活的一部分。甚至可能是这本书是基于你的社交网络偏好、工作状态或浏览历史等营销公司所知道的信息神奇地推荐给你的。

在本章中,我们将演示如何使用原始音频信号推荐音乐内容。为此,我们将涵盖以下主题:

  • 使用 Spark 处理存储在 HDFS 上的音频文件

  • 学习关于傅立叶变换用于音频信号转换

  • 使用 Cassandra 作为在线和离线层之间的缓存层

  • 使用PageRank作为无监督的推荐算法

  • 将 Spark 作业服务器与 Play 框架集成,构建端到端原型

不同的方法

推荐系统的最终目标是根据用户的历史使用和偏好建议新的物品。基本思想是对客户过去感兴趣的任何产品使用排名。这种排名可以是显式的(要求用户对电影进行 1 到 5 的排名)或隐式的(用户访问此页面的次数)。无论是购买产品、听歌曲还是阅读文章,数据科学家通常从两个不同的角度解决这个问题:协同过滤基于内容的过滤

协同过滤

使用这种方法,我们通过收集有关人们行为的更多信息来利用大数据。尽管个体在定义上是独特的,但他们的购物行为通常不是,总是可以找到一些与其他人的相似之处。推荐的物品将针对特定个人,但它们将通过将用户的行为与类似用户的行为相结合来推导。这是大多数零售网站的著名引用:

“购买这个的人也购买了那个……”

当然,这需要关于客户、他们的过去购买以及其他客户的足够信息进行比较。因此,一个主要的限制因素是物品必须至少被查看一次才能被列为潜在的推荐物品。事实上,直到物品被查看/购买至少一次,我们才能推荐该物品。

注意

协同过滤的鸢尾花数据集通常使用 LastFM 数据集的样本进行:labrosa.ee.columbia.edu/millionsong/lastfm

基于内容的过滤

与使用其他用户相似性不同的替代方法涉及查看产品本身以及客户过去感兴趣的产品类型。如果你对古典音乐速度金属都感兴趣,那么可以安全地假设你可能会购买(至少考虑)任何将古典节奏与重金属吉他独奏混合的新专辑。这样的推荐在协同过滤方法中很难找到,因为你周围没有人分享你的音乐口味。

这种方法的主要优势是,假设我们对要推荐的内容有足够的了解(比如类别、标签等),即使没有人看过它,我们也可以推荐一个新的物品。缺点是,模型可能更难建立,并且选择正确的特征而不丢失信息可能具有挑战性。

自定义方法

由于本书的重点是数据科学中的 Spark,我们希望为读者提供一种新颖的创新方式来解决推荐问题,而不仅仅是解释任何人都可以使用现成的 Spark API 构建的标准协同过滤算法,并遵循基本教程spark.apache.org/docs/latest/mllib-collaborative-filtering.html。让我们从一个假设开始:

如果我们要向最终用户推荐歌曲,我们是否可以构建一个系统,不是基于人们喜欢或不喜欢的歌曲,也不是基于歌曲属性(流派、艺术家),而是基于歌曲的真实声音和你对它的感觉呢?

为了演示如何构建这样一个系统(因为您可能没有访问包含音乐内容和排名的公共数据集,至少是合法的),我们将解释如何使用您自己的个人音乐库在本地构建它。随时加入!

未知数据

以下技术可以被视为现代大多数数据科学家工作方式的一种改变。虽然处理结构化和非结构化文本很常见,但处理原始二进制数据却不太常见,原因在于计算机科学和数据科学之间的差距。文本处理局限于大多数人熟悉的一套标准操作,即获取、解析和存储等。我们将直接处理音频,将未知信号数据转换和丰富为知情的转录。通过这样做,我们实现了一种类似于教计算机从音频文件中“听到”声音的新型数据管道。

我们在这里鼓励的第二个(突破性)想法是,改变数据科学家如今与 Hadoop 和大数据打交道的方式。虽然许多人仍然认为这些技术只是又一个数据库,但我们想展示使用这些工具可以获得的广泛可能性。毕竟,没有人会嘲笑能够训练机器与客户交谈或理解呼叫中心录音的数据科学家。

处理字节

首先要考虑的是音频文件格式。.wav文件可以使用AudioSystem库(来自javax.sound)进行处理,而.mp3则需要使用外部编解码库进行预处理。如果我们从InputStream中读取文件,我们可以创建一个包含音频信号的输出字节数组,如下所示:

def readFile(song: String) = {
  val is = new FileInputStream(song)
   processSong(is)
}
def processSong(stream: InputStream): Array[Byte] = {

   val bufferedIn = new BufferedInputStream(stream)
   val out = new ByteArrayOutputStream
   val audioInputStream = AudioSystem.getAudioInputStream(bufferedIn)

   val format = audioInputStream.getFormat
   val sizeTmp = Math.rint((format.getFrameRate *
                  format.getFrameSize) /
                  format.getFrameRate)
                .toInt

  val size = (sizeTmp + format.getFrameSize) -
             (sizeTmp % format.getFrameSize)

   val buffer = new ArrayByte

   var available = true
   var totalRead = 0
   while (available) {
     val c = audioInputStream.read(buffer, 0, size)
     totalRead += c
     if (c > -1) {
       out.write(buffer, 0, c)
     } else {
       available = false
     }
   }

   audioInputStream.close()
   out.close()
   out.toByteArray
 }

歌曲通常使用 44KHz 的采样率进行编码,根据奈奎斯特定理,这是人耳可以感知的最高频率的两倍(覆盖范围从 20Hz 到 20KHz)。

注意

有关奈奎斯特定理的更多信息,请访问:redwood.berkeley.edu/bruno/npb261/aliasing.pdf

为了表示人类可以听到的声音,我们需要每秒大约 44,000 个样本,因此立体声(两个声道)每秒需要 176,400 字节。后者是以下字节频率:

val format = audioInputStream.getFormat

val sampleRate = format.getSampleRate

val sizeTmp = Math.rint((format.getFrameRate *
                format.getFrameSize) /
                format.getFrameRate)
              .toInt

 val size = (sizeTmp + format.getFrameSize) -
           (sizeTmp % format.getFrameSize)

 val byteFreq = format.getFrameSize * format.getFrameRate.toInt

最后,我们通过处理输出的字节数组并绘制样本数据的前几个字节(在本例中,图 1显示了马里奥兄弟主题曲)来访问音频信号。请注意,可以使用字节索引和字节频率值检索时间戳,如下所示:

val data: Array[Byte] = processSong(inputStream)

val timeDomain: Array[(Double, Int)] = data
  .zipWithIndex
  .map { case (b, idx) =>
      (minTime + idx * 1000L / byteFreq.toDouble, b.toInt)
   }

处理字节

图 1:马里奥兄弟主题曲 - 时域

为了方便起见,我们将所有这些音频特征封装到一个Audio案例类中(如下面的代码段所示),随着我们在本章中的进展,我们将添加额外的实用方法:

case class Audio(data: Array[Byte],
                byteFreq: Int,
                sampleRate: Float,
                minTime: Long,
                id: Int= 0) {

  def duration: Double =
    (data.length + 1) * 1000L / byteFreq.toDouble

  def timeDomain: Array[(Double, Int)] = data
   .zipWithIndex
   .map { case (b, idx) =>
        (minTime + idx * 1000L / byteFreq.toDouble, b.toInt)
    }

  def findPeak: Float = {
    val freqDomain = frequencyDomain()
    freqDomain
     .sortBy(_._2)
     .reverse
     .map(_._1)
     .head
  }

 // Next to come

 }

创建可扩展的代码

现在我们已经创建了从.wav文件中提取音频信号的函数(通过FileInputStream),自然的下一步是使用它来处理存储在 HDFS 上的其余记录。正如在前几章中已经强调的那样,一旦逻辑在单个记录上运行,这并不是一个困难的任务。事实上,Spark 自带了一个处理二进制数据的实用程序,因此我们只需插入以下函数:

def read(library: String, sc: SparkContext) = {
   sc.binaryFiles(library)
     .filter { case (filename, stream) =>
       filename.endsWith(".wav")
     }
     .map { case (filename, stream) =>
       val audio =  processSong(stream.open())
       (filename, audio)
     }
}

val audioRDD: RDD[(String, Audio)] = read(library, sc)

我们确保只将.wav文件发送到我们的处理器,并获得一个由文件名(歌曲名)和其对应的Audio case 类(包括提取的音频信号)组成的新 RDD。

提示

Spark 的binaryFiles方法读取整个文件(不进行分割)并输出一个包含文件路径和其对应输入流的 RDD。因此,建议处理相对较小的文件(可能只有几兆字节),因为这显然会影响内存消耗和性能。

从时间到频率域

访问音频时域是一个很大的成就,但遗憾的是它本身并没有太多价值。然而,我们可以使用它来更好地理解信号的真实含义,即提取它包含的隐藏频率。当然,我们可以使用傅里叶变换将时域信号转换为频域。

注意

您可以在www.phys.hawaii.edu/~jgl/p274/fourier_intro_Shatkay.pdf了解更多关于傅里叶变换的知识。

总之,不需要过多细节或复杂的方程,约瑟夫·傅里叶在他的传奇和同名公式中所做的基本假设是,所有信号都由不同频率和相位的正弦波的无限累积组成。

快速傅里叶变换

离散傅里叶变换DFT)是不同正弦波的总和,并可以使用以下方程表示:

快速傅里叶变换

尽管使用蛮力方法实现这个算法是微不足道的,但它的效率非常低O(n²),因为对于每个数据点n,我们必须计算n个指数的和。因此,一首三分钟的歌曲将产生(3 x 60 x 176,400)²≈ 10¹⁵数量的操作。相反,Cooley 和 Tukey 采用了一种将 DFT 的时间复杂度降低到O(n.log(n))的分治方法,贡献了快速傅里叶变换FFT)。

注意

描述 Cooley 和 Tukey 算法的官方论文可以在网上找到:www.ams.org/journals/mcom/1965-19-090/S0025-5718-1965-0178586-1/S0025-5718-1965-0178586-1.pdf

幸运的是,现有的 FFT 实现是可用的,因此我们将使用org.apache.commons.math3提供的基于 Java 的库来计算 FFT。使用这个库时,我们只需要确保我们的输入数据用零填充,使得总长度是 2 的幂,并且可以分成奇偶序列:

def fft(): Array[Complex] = {

  val array = Audio.paddingToPowerOf2(data)
  val transformer = new FastFourierTransformer(
                         DftNormalization.STANDARD)

  transformer.transform(array.map(_.toDouble),
      TransformType.FORWARD)

}

这将返回一个由实部和虚部组成的Complex数字数组,并可以轻松转换为频率和幅度(或幅度)如下。根据奈奎斯特定理,我们只需要一半的频率:

def frequencyDomain(): Array[(Float, Double)] = {

   val t = fft()
   t.take(t.length / 2) // Nyquist
   .zipWithIndex
   .map { case (c, idx) =>
      val freq = (idx + 1) * sampleRate / t.length
      val amplitude =  sqrt(pow(c.getReal, 2) +
                         pow(c.getImaginary, 2))
      val db = 20 * log10(amplitude)
      (freq, db)
    }

 }

最后,我们将这些函数包含在Audio case 类中,并绘制马里奥兄弟主题曲前几秒的频域:

快速傅里叶变换

图 2:马里奥兄弟主题曲-频域

在图 2 中,可以看到在中高频范围(4KHz 至 7KHz 之间)有显著的峰值,我们将使用这些作为歌曲的指纹。

按时间窗口采样

尽管更有效,但 FFT 仍然是一个昂贵的操作,因为它的高内存消耗(记住,一首典型的三分钟歌曲将有大约3 x 60 x 176,400个点要处理)。当应用于大量数据点时,这变得特别棘手,因此必须考虑大规模处理。

我们不是查看整个频谱,而是使用时间窗口对我们的歌曲进行采样。事实上,完整的 FFT 无论如何都没有用,因为我们想知道每个主要频率被听到的时间。因此,我们将Audio类迭代地分割成 20 毫秒样本的较小的案例类。这个时间框应该足够小,以便进行分析,这意味着 FFT 可以被计算,并且足够密集,以确保提取足够的频率,以提供足够的音频指纹。20 毫秒的产生的块将大大增加我们 RDD 的总体大小:

def sampleByTime(duration: Double = 20.0d,
                padding: Boolean = true): List[Audio] = {

   val  size = (duration * byteFreq / 1000.0f).toInt
   sample(size, padding)

 }

 def sample(size: Int= math.pow(2, 20).toInt,
          padding: Boolean = true): List[Audio] = {

   Audio
    .sample(data, size, padding)
    .zipWithIndex
    .map { case (sampleAudio, idx) =>
      val firstByte = idx * size
       val firstTime = firstByte * 1000L / byteFreq.toLong
       Audio(
           sampleAudio,
           byteFreq,
           sampleRate,
           firstTime,
           idx
      )
    }

 }

val sampleRDD = audioRDDflatMap { case (song, audio) =>
   audio.sampleByTime()
    .map { sample =>
       (song, sample)
     }
 }

提示

虽然这不是我们的主要关注点,但可以通过重新组合内部和外部 FFT 的样本,并应用一个扭曲因子en.wikipedia.org/wiki/Twiddle_factor来重建整个信号的完整 FFT 频谱。当处理具有有限可用内存的大型记录时,这可能是有用的。

提取音频签名

现在我们有多个样本在规则的时间间隔内,我们可以使用 FFT 提取频率签名。为了生成一个样本签名,我们尝试在不同的频段中找到最接近的音符,而不是使用精确的峰值(可能是近似的)。这提供了一个近似值,但这样做可以克服原始信号中存在的任何噪音问题,因为噪音会干扰我们的签名。

我们查看以下频段 20-60 Hz,60-250Hz,250-2000Hz,2-4Kz 和 4-6Kz,并根据以下频率参考表找到最接近的音符。这些频段不是随机的。它们对应于不同乐器的不同范围(例如,低音提琴的频段在 50 到 200Hz 之间,短笛在 500 到 5KHz 之间)。

提取音频签名

图 3:频率音符参考表

图 4显示了我们马里奥兄弟主题曲在较低频段的第一个样本。我们可以看到 43Hz 的最大幅度对应于音符F的主音:

提取音频签名

图 4:马里奥兄弟主题曲-低频

对于每个样本,我们构建一个由五个字母组成的哈希(比如[E-D#-A-B-B-F]),对应于前面频段中最强的音符(最高峰)。我们认为这个哈希是该特定 20 毫秒时间窗口的指纹。然后我们构建一个由哈希值组成的新 RDD(我们在Audio类中包括一个哈希函数):

def hash: String = {
  val freqDomain = frequencyDomain()
  freqDomain.groupBy { case (fq, db) =>
    Audio.getFrequencyBand(fq)
  }.map { case (bucket, frequencies) =>
    val (dominant, _) = frequencies.map { case (fq, db) =>
      (Audio.findClosestNote(fq), db)
    }.sortBy { case (note, db) =>
      db
    }.last
    (bucket, dominant)
  }.toList
 .sortBy(_._1)
 .map(_._2)
 .mkString("-")
 }

*/** 
*001 Amadeus Mozart - Requiem (K. 626)        E-D#-A-B-B-F* 
*001 Amadeus Mozart - Requiem (K. 626)        G#-D-F#-B-B-F* 
*001 Amadeus Mozart - Requiem (K. 626)        F#-F#-C-B-C-F* 
*001 Amadeus Mozart - Requiem (K. 626)        E-F-F#-B-B-F* 
*001 Amadeus Mozart - Requiem (K. 626)        E-F#-C#-B-B-F* 
*001 Amadeus Mozart - Requiem (K. 626)        B-E-F-A#-C#-F* 
**/*

现在我们将所有共享相同哈希的歌曲 ID 分组,以构建一个唯一哈希的 RDD:

case class HashSongsPair(
                         id: String,
                         songs: List[Long]
                         )

 val hashRDD = sampleRDD.map { case (id, sample) =>
   (sample.hash, id)
  }
 .groupByKey()
 .map { case (id, songs) =>
    HashSongsPair(id, songs.toList)
  }

我们的假设是,当一个哈希在特定的时间窗口内在一首歌中被定义时,类似的歌曲可能共享相似的哈希,但两首歌拥有完全相同的哈希(并且顺序相同)将是真正相同的;一个可能分享我的部分 DNA,但一个拥有完全相同的 DNA 将是我的完美克隆。

如果一个音乐爱好者在听柴可夫斯基的 D 大调协奏曲时感到幸运,我们能否推荐帕赫贝尔的 D 大调卡农,仅仅是因为它们都有一个音乐节奏(即,D 音周围的共同频率)?

仅基于某些频段来推荐播放列表是否有效(和可行)?当然,仅仅频率本身是不足以完全描述一首歌的。节奏、音色或韵律呢?这个模型是否足够完整地准确表示音乐多样性和范围的所有细微差别?可能不是,但出于数据科学的目的,还是值得调查的!

构建歌曲分析器

然而,在深入研究推荐系统之前,读者可能已经注意到我们能够从信号数据中提取出一个重要的属性。由于我们在规则的时间间隔内生成音频签名,我们可以比较签名并找到潜在的重复项。例如,给定一首随机歌曲,我们应该能够根据先前索引的签名猜出标题。事实上,这是许多公司在提供音乐识别服务时采取的确切方法。更进一步,我们可能还可以提供关于乐队音乐影响的见解,甚至进一步,也许甚至可以识别歌曲剽窃,最终解决 Led Zeppelin 和美国摇滚乐队 Spirit 之间的Stairway to Heaven争议consequenceofsound.net/2014/05/did-led-zeppelin-steal-stairway-to-heaven-legendary-rock-band-facing-lawsuit-from-former-tourmates/

考虑到这一点,我们将从我们的推荐用例中分离出来,继续深入研究歌曲识别。接下来,我们将构建一个分析系统,能够匿名接收一首歌曲,分析其流,并返回歌曲的标题(在我们的情况下,是原始文件名)。

销售数据科学就像销售杯子蛋糕

可悲的是,数据科学旅程中经常被忽视的一个方面是数据可视化。换句话说,如何将结果呈现给最终用户。虽然许多数据科学家乐意在 Excel 电子表格中呈现他们的发现,但今天的最终用户渴望更丰富、更沉浸式的体验。他们经常希望与数据进行交互。事实上,为最终用户提供一个完整的、端到端的用户体验,即使是一个简单的用户体验,也是激发对你的科学兴趣的好方法;将一个简单的概念证明变成一个人们可以轻松理解的原型。由于 Web 2.0 技术的普及,用户的期望很高,但幸运的是,有各种免费的开源产品可以帮助,例如 Mike Bostock 的 D3.js,这是一个流行的框架,提供了一个工具包,用于创建这样的用户界面。

没有丰富的数据可视化的数据科学就像试图销售没有糖衣的蛋糕,很少有人会信任成品。因此,我们将为我们的分析系统构建一个用户界面。但首先,让我们从 Spark 中获取音频数据(我们的哈希目前存储在 RDD 内存中),并将其存储到一个面向 Web 的数据存储中。

使用 Cassandra

我们需要一个快速、高效和分布式的键值存储来保存所有我们的哈希值。尽管许多数据库都适用于此目的,但我们将选择 Cassandra 来演示其与 Spark 的集成。首先,使用 Maven 依赖项导入 Cassandra 输入和输出格式:

<dependency>
  <groupId>com.datastax.spark</groupId>
  <artifactId>spark-cassandra-connector_2.11</artifactId>            
  <version>2.0.0</version>
</dependency> 

正如你所期望的那样,将 RDD 从 Spark 持久化(和检索)到 Cassandra 相对来说是相当简单的:

import com.datastax.spark.connector._

 val keyspace = "gzet"
 val table = "hashes"

 // Persist RDD
 hashRDD.saveAsCassandraTable(keyspace, table)

 // Retrieve RDD
 val retrievedRDD = sc.cassandraTableHashSongsPair

这将在 keyspace gzet上创建一个新的hashes表,从HashSongsPair对象中推断出模式。以下是执行的等效 SQL 语句(仅供参考):

CREATE TABLE gzet.hashes (
  id text PRIMARY KEY,
  songs list<bigint>
)

使用 Play 框架

由于我们的 Web UI 将面对将歌曲转换为频率哈希所需的复杂处理,我们希望它是一个交互式的 Web 应用程序,而不是一组简单的静态 HTML 页面。此外,这必须以与我们使用 Spark 相同的方式和相同的功能完成(也就是说,相同的歌曲应该生成相同的哈希)。Play 框架(www.playframework.com/)将允许我们这样做,Twitter 的 bootstrap(getbootstrap.com/)将用于为更专业的外观和感觉添加润色。

尽管这本书不是关于构建用户界面的,但我们将介绍与 Play 框架相关的一些概念,因为如果使用得当,它可以为数据科学家提供巨大的价值。与往常一样,完整的代码可以在我们的 GitHub 存储库中找到。

首先,我们创建一个数据访问层,负责处理与 Cassandra 的连接和查询。对于任何给定的哈希,我们返回匹配歌曲 ID 的列表。同样,对于任何给定的 ID,我们返回歌曲名称:

val cluster = Cluster
  .builder()
  .addContactPoint(cassandraHost)
  .withPort(cassandraPort)
  .build()
val session = cluster.connect()

 def findSongsByHash(hash: String): List[Long] = {
   val stmt = s"SELECT songs FROM hashes WHERE id = '$hash';"
   val results = session.execute(stmt)
   results flatMap { row =>
     row.getList("songs", classOf[Long])
   }
   .toList
 }

接下来,我们创建一个简单的视图,由三个对象组成,一个text字段,一个文件Upload和一个submit按钮。这几行足以提供我们的用户界面:

<div>
   <input type="text" class="form-control">
   <span class="input-group-btn">
     <button class="btn-primary">Upload</button>
     <button class="btn-success">Analyze</button>
   </span>
</div>

然后,我们创建一个控制器,通过indexsubmit方法处理GETPOST HTTP 请求。后者将通过将FileInputStream转换为Audio case 类,将其分割成 20 毫秒的块,提取 FFT 签名(哈希)并查询 Cassandra 以获取匹配的 ID 来处理上传的文件:

def index = Action { implicit request =>
   Ok(views.html.analyze("Select a wav file to analyze"))
 }

 def submit = Action(parse.multipartFormData) { request =>
   request.body.file("song").map { upload =>
     val file = new File(s"/tmp/${UUID.randomUUID()}")
     upload.ref.moveTo(file)
     val song = process(file)
     if(song.isEmpty) {
       Redirect(routes.Analyze.index())
         .flashing("warning" -> s"No match")
     } else {
       Redirect(routes.Analyze.index())
         .flashing("success" -> song.get)
     }
   }.getOrElse {
     Redirect(routes.Analyze.index())
       .flashing("error" -> "Missing file")
   }
 }

 def process(file: File): Option[String] = {
   val is = new FileInputStream(file)
   val audio = Audio.processSong(is)
   val potentialMatches = audio.sampleByTime().map {a =>
     queryCassandra(a.hash)
   }
   bestMatch(potentialMatches)
 }

最后,我们通过闪烁消息返回匹配结果(如果有的话),并通过为我们的Analyze服务定义新的路由将视图和控制器链接在一起:

GET      /analyze      controllers.Analyze.index
POST     /analyze      controllers.Analyze.submit

生成的 UI 如图 5所示,并且与我们自己的音乐库完美配合:

使用 Play 框架

图 5:声音分析器 UI

下图图 6显示了端到端的过程:

使用 Play 框架

图 6:声音分析器过程

如前所述,Play 框架与我们的离线 Spark 作业共享一些代码。这是可能的,因为我们是以函数式风格编程,并且已经很好地分离了关注点。虽然 Play 框架在本质上不与 Spark(即 RDD 和 Spark 上下文对象)兼容,因为它们不依赖于 Spark,我们可以使用我们之前创建的任何函数(比如 Audio 类中的函数)。这是函数式编程的许多优势之一;函数本质上是无状态的,并且是六边形架构采用的关键组件之一:wiki.c2.com/?HexagonalArchitecture。隔离的函数始终可以被不同的执行者调用,无论是在 RDD 内部还是在 Play 控制器内部。

构建推荐系统

现在我们已经探索了我们的歌曲分析器,让我们回到推荐引擎。如前所述,我们希望基于从音频信号中提取的频率哈希来推荐歌曲。以 Led Zeppelin 和 Spirit 之间的争议为例,我们期望这两首歌相对接近,因为有指控称它们共享旋律。以这种思路作为我们的主要假设,我们可能会向对《天梯》感兴趣的人推荐《Taurus》。

PageRank 算法

我们不会推荐特定的歌曲,而是推荐播放列表。播放列表将由按相关性排名的所有歌曲列表组成,从最相关到最不相关。让我们从这样一个假设开始,即人们听音乐的方式与他们浏览网页的方式类似,也就是说,从链接到链接,沿着逻辑路径前进,但偶尔改变方向,或者进行跳转,并浏览到完全不同的网站。继续这个类比,当听音乐时,人们可以继续听相似风格的音乐(因此按照他们最期望的路径前进),或者跳到完全不同流派的随机歌曲。事实证明,这正是谷歌使用 PageRank 算法按照网站的受欢迎程度进行排名的方式。

有关 PageRank 算法的更多细节,请访问:ilpubs.stanford.edu:8090/422/1/1999-66.pdf

网站的受欢迎程度是通过它指向(并被引用)的链接数量来衡量的。在我们的音乐用例中,受欢迎程度是建立在给定歌曲与所有邻居共享的哈希数量上的。我们引入了歌曲共同性的概念,而不是受欢迎程度。

构建频率共现图

我们首先从 Cassandra 中读取我们的哈希值,并重新建立每个不同哈希的歌曲 ID 列表。一旦我们有了这个,我们就可以使用简单的reduceByKey函数来计算每首歌曲的哈希数量,因为音频库相对较小,我们将其收集并广播到我们的 Spark 执行器:

val hashSongsRDD = sc.cassandraTableHashSongsPair

 val songHashRDD = hashSongsRDD flatMap { hash =>
     hash.songs map { song =>
       ((hash, song), 1)
     }
   }

 val songTfRDD = songHashRDD map { case ((hash,songId),count) =>
     (songId, count)
   } reduceByKey(_+_)

 val songTf = sc.broadcast(songTfRDD.collectAsMap())

接下来,我们通过获取共享相同哈希值的每首歌曲的叉积来构建一个共现矩阵,并计算观察到相同元组的次数。最后,我们将歌曲 ID 和标准化的(使用我们刚刚广播的词频)频率计数包装在 GraphX 的Edge类中:

implicit class CrossableX {
      def crossY = for { x <- xs; y <- ys } yield (x, y)

val crossSongRDD = songHashRDD.keys
    .groupByKey()
    .values
    .flatMap { songIds =>
        songIds cross songIds filter { case (from, to) =>
           from != to
      }.map(_ -> 1)
    }.reduceByKey(_+_)
     .map { case ((from, to), count) =>
       val weight = count.toDouble /
                    songTfB.value.getOrElse(from, 1)
       Edge(from, to, weight)
    }.filter { edge =>
     edge.attr > minSimilarityB.value
   }

val graph = Graph.fromEdges(crossSongRDD, 0L)

我们只保留具有大于预定义阈值的权重(意味着哈希共现)的边,以构建我们的哈希频率图。

运行 PageRank

与运行 PageRank 时人们通常期望的相反,我们的图是无向的。事实证明,对于我们的推荐系统来说,缺乏方向并不重要,因为我们只是试图找到 Led Zeppelin 和 Spirit 之间的相似之处。引入方向的一种可能方式是查看歌曲的发布日期。为了找到音乐影响,我们可以确实地从最旧的歌曲到最新的歌曲引入一个时间顺序,给我们的边赋予方向性。

在以下的pageRank中,我们定义了一个 15%的概率来跳过,或者跳转到任意随机歌曲,但这显然可以根据不同的需求进行调整:

val prGraph = graph.pageRank(0.001, 0.15)

最后,我们提取了页面排名的顶点,并将它们保存为 Cassandra 中的播放列表,通过Song类的 RDD:

case class Song(id: Long, name: String, commonality: Double)
val vertices = prGraph
  .vertices
  .mapPartitions { vertices =>
    val songIds = songIdsB
  .value
  .vertices
  .map { case (songId, pr) =>
       val songName = songIds.get(vId).get
        Song(songId, songName, pr)
      }
  }

 vertices.saveAsCassandraTable("gzet", "playlist")

读者可能会思考 PageRank 在这里的确切目的,以及它如何作为推荐系统使用?事实上,我们使用 PageRank 的意思是排名最高的歌曲将是与其他歌曲共享许多频率的歌曲。这可能是由于共同的编曲、主题或旋律;或者可能是因为某位特定艺术家对音乐趋势产生了重大影响。然而,这些歌曲应该在理论上更受欢迎(因为它们出现的频率更高),这意味着它们更有可能受到大众的喜爱。

另一方面,低排名的歌曲是我们没有发现与我们所知的任何东西相似的歌曲。要么这些歌曲是如此前卫,以至于没有人在这些音乐理念上进行探索,要么是如此糟糕,以至于没有人想要复制它们!也许它们甚至是由你在叛逆的少年时期听过的那位新兴艺术家创作的。无论哪种情况,随机用户喜欢这些歌曲的机会被视为微不足道。令人惊讶的是,无论是纯粹的巧合还是这种假设真的有意义,这个特定音频库中排名最低的歌曲是 Daft Punk 的--Motherboard,这是一个相当原创的标题(尽管很棒),并且有着独特的声音。

构建个性化播放列表

我们刚刚看到,简单的 PageRank 可以帮助我们创建一个通用的播放列表。尽管这并不针对任何个人,但它可以作为一个随机用户的播放列表。这是我们在没有任何关于用户偏好的信息时能做出的最好的推荐。我们对用户了解得越多,我们就能越好地个性化播放列表以符合他们真正的喜好。为了做到这一点,我们可能会采用基于内容的推荐方法。

在没有关于用户偏好的预先信息的情况下,我们可以在用户播放歌曲时寻求收集我们自己的信息,并在运行时个性化他们的播放列表。为此,我们将假设我们的用户喜欢他们之前听过的歌曲。我们还需要禁用跳转,并生成一个从特定歌曲 ID 开始的新播放列表。

PageRank 和个性化 PageRank 在计算分数的方式上是相同的(使用传入/传出边的权重),但个性化版本只允许用户跳转到提供的 ID。通过对代码进行简单修改,我们可以使用某个社区 ID(参见第七章,构建社区,以获取社区的定义)或使用某种音乐属性,如艺术家或流派,来个性化 PageRank。根据我们之前的图,个性化的 PageRank 实现如下:

val graph = Graph.fromEdges(edgeRDD, 0L)
val prGraph = graph.personalizedPageRank(id, 0.001, 0.1)

在这里,随机跳转到一首歌的机会为零。仍然有 10%的跳过机会,但只在提供的歌曲 ID 的非常小的容差范围内。换句话说,无论我们当前正在听的歌曲是什么,我们基本上定义了 10%的机会播放我们提供的歌曲作为种子。

扩展我们的杯子蛋糕工厂

与我们的歌曲分析器原型类似,我们希望以一个漂亮整洁的用户界面向我们的想象客户呈现我们建议的播放列表。

构建播放列表服务

仍然使用 Play 框架,我们的技术栈保持不变,这次我们只是创建了一个新的端点(一个新的路由):

GET       /playlist      controllers.Playlist.index

就像以前一样,我们创建了一个额外的控制器来处理简单的 GET 请求(当用户加载播放列表网页时触发)。我们加载存储在 Cassandra 中的通用播放列表,将所有这些歌曲包装在Playlist case 类中,并将其发送回playlist.scala.html视图。控制器模型如下:

def getSongs: List[Song] = {
   val s = "SELECT id, name, commonality FROM gzet.playlist;"
   val results = session.execute(s)
   results map { row =>
     val id = row.getLong("id")
     val name = row.getString("name")
     val popularity = row.getDouble("commonality")
     Song(id, name, popularity)
   } toList
 }

 def index = Action { implicit request =>
   val playlist = models.Playlist(getSongs)
   Ok(views.html.playlist(playlist))
 }

视图保持相当简单,因为我们遍历所有歌曲以按常见程度(从最常见到最不常见)排序进行显示:

@(playlist: Playlist)

@displaySongs(playlist: Playlist) = {
   @for(node <- playlist.songs.sortBy(_.commonality).reverse) {
     <a href="/playlist/@node.id" class="list-group-item">
       <iclass="glyphiconglyphicon-play"></i>
       <span class="badge">
         @node.commonality
       </span>
       @node.name
     </a>
   }
 }

 @main("playlist") {
   <div class="row">
     <div class="list-group">
       @displaySongs(playlist)
     </div>
   </div>
 }

注意

注意每个列表项中的href属性 - 每当用户点击列表中的歌曲时,我们将生成一个新的REST调用到/playlist/id 端点(这在下一节中描述)。

最后,我们很高兴地揭示了图 7中推荐的(通用)播放列表。由于我们不知道的某种原因,显然一个对古典音乐一窍不通的新手应该开始听古斯塔夫·马勒,第五交响曲

构建播放列表服务

图 7:播放列表推荐器

利用 Spark 作业服务器

又来了一个有趣的挑战。尽管我们的通用播放列表和 PageRank 分数的歌曲列表存储在 Cassandra 中,但对于个性化播放列表来说,这是不可行的,因为这将需要对所有可能的歌曲 ID 的所有 PageRank 分数进行预计算。由于我们希望在伪实时中构建个性化播放列表,并且可能会定期加载新歌曲,所以我们需要找到一个比在每个请求上启动SparkContext更好的方法。

第一个限制是 PageRank 函数本质上是一个分布式过程,不能在 Spark 的上下文之外使用(也就是说,在我们的 Play 框架的 JVM 内部)。我们知道在每个 http 请求上创建一个新的 Spark 作业肯定会有点过度,所以我们希望启动一个单独的 Spark 作业,并且只在需要时处理新的图,最好是通过一个简单的 REST API 调用。

第二个挑战是我们不希望重复从 Cassandra 中加载相同的图数据集。这应该加载一次并缓存在 Spark 内存中,并在不同的作业之间共享。在 Spark 术语中,这将需要从共享上下文中访问 RDD。

幸运的是,Spark 作业服务器解决了这两个问题(github.com/spark-jobserver/spark-jobserver)。尽管这个项目还相当不成熟(或者至少还不够成熟),但它是展示数据科学的完全可行的解决方案。

为了本书的目的,我们只使用本地配置编译和部署 Spark 作业服务器。我们强烈建议读者深入了解作业服务器网站(参见上面的链接),以获取有关打包和部署的更多信息。一旦我们的服务器启动,我们需要创建一个新的上下文(意味着启动一个新的 Spark 作业),并为处理与 Cassandra 的连接的附加配置设置。我们给这个上下文一个名称,以便以后可以使用它:

curl -XPOST 'localhost:8090/contexts/gzet?\
  num-cpu-cores=4&\
  memory-per-node=4g&\
  spark.executor.instances=2&\
  spark.driver.memory=2g&\
  passthrough.spark.cassandra.connection.host=127.0.0.1&\
  passthrough.spark.cassandra.connection.port=9042'

下一步是修改我们的代码以符合 Spark 作业服务器的要求。我们需要以下依赖项:

<dependency>
   <groupId>spark.jobserver</groupId>
   <artifactId>job-server-api_2.11</artifactId>
   <version>spark-2.0-preview</version>
 </dependency>

我们修改我们的 SparkJob,使用作业服务器提供的SparkJob接口的签名。这是作业服务器所有 Spark 作业的要求:

object PlaylistBuilder extends SparkJob {

  override def runJob(
    sc: SparkContext,
    jobConfig: Config
  ): Any = ???

  override def validate(
    sc: SparkContext,
    config: Config
  ): SparkJobValidation = ???

}

validate方法中,我们确保所有作业要求将得到满足(例如该作业所需的输入配置),在runJob中,我们执行我们的正常 Spark 逻辑,就像以前一样。最后的变化是,虽然我们仍然将我们的通用播放列表存储到 Cassandra 中,但我们将在 Spark 共享内存中缓存节点和边缘 RDD,以便将其提供给进一步的作业。这可以通过扩展NamedRddSupport特性来实现。

我们只需保存边缘和节点 RDD(请注意,目前不支持保存Graph对象)以便在后续作业中访问图:

this.namedRdds.update("rdd:edges", edgeRDD)
this.namedRdds.update("rdd:nodes", nodeRDD)

从个性化的Playlist作业中,我们按以下方式检索和处理我们的 RDD:

val edgeRDD = this.namedRdds.getEdge.get
val nodeRDD = this.namedRdds.getNode.get

val graph = Graph.fromEdges(edgeRDD, 0L)

然后,我们执行我们的个性化 PageRank,但是不会将结果保存回 Cassandra,而是简单地收集前 50 首歌曲。当部署时,由于作业服务器的魔力,此操作将隐式地将此列表输出回客户端:

val prGraph = graph.personalizedPageRank(id, 0.001, 0.1)

prGraph
 .vertices
 .map { case(vId, pr) =>
   List(vId, songIds.value.get(vId).get, pr).mkString(",")
  }
 .take(50)

我们编译我们的代码,并通过给它一个应用程序名称将我们的阴影 jar 文件发布到作业服务器,如下所示:

curl --data-binary @recommender-core-1.0.jar \
 'localhost:8090/jars/gzet'

现在我们几乎准备好部署我们的推荐系统了,让我们回顾一下我们将要演示的内容。我们将很快执行两种不同的用户流程:

  • 当用户登录到推荐页面时,我们从 Cassandra 中检索最新的通用播放列表。或者,如果需要,我们会启动一个新的异步作业来创建一个新的播放列表。这将在 Spark 上下文中加载所需的 RDD。

  • 当用户播放我们推荐的新歌曲时,我们会同步调用 Spark 作业服务器,并基于这首歌曲的 ID 构建下一个播放列表。

通用 PageRank 播放列表的流程如图 8所示:

利用 Spark 作业服务器

图 8:播放列表推荐器流程

个性化 PageRank 播放列表的流程如图 9 所示:

利用 Spark 作业服务器

图 9:个性化播放列表推荐器流程

用户界面

最后剩下的问题是从 Play 框架的服务层调用 Spark 作业服务器。尽管这是通过java.net包以编程方式完成的,但由于它是一个 REST API,等效的curl请求在以下代码片段中显示:

# Asynchronous Playlist Builder
curl -XPOST 'localhost:8090/jobs?\
 context=gzet&\
 appName=gzet&\
 classPath=io.gzet.recommender.PlaylistBuilder'

# Synchronous Personalized Playlist for song 12
curl -XPOST -d "song.id=12" 'localhost:8090/jobs?\
 context=gzet&\
 appName=gzet&\
 sync=true&\
 timeout=60000&\
 classPath=io.gzet.recommender.PersonalizedPlaylistBuilder'

最初,当我们构建 HTML 代码时,我们引入了一个指向/playlist/${id}的链接或href。这个 REST 调用将被转换为对Playlist控制器的 GET 请求,并绑定到您的personalize函数,如下所示:

GET /playlist/:id controllers.Playlist.personalize(id: Long) 

对 Spark 作业服务器的第一次调用将同步启动一个新的 Spark 作业,从作业输出中读取结果,并重定向到相同的页面视图,这次是基于这首歌曲的 ID 更新的播放列表:

def personalize(id: Long) = Action { implicit request =>
   val name = cassandra.getSongName(id)
   try {
     val nodes = sparkServer.generatePlaylist(id)
     val playlist = models.Playlist(nodes, name)
     Ok(views.html.playlist(playlist))
   } catch {
     case e: Exception =>
       Redirect(routes.Playlist.index())
         .flashing("error" -> e.getMessage)
   }
 }

结果 UI 显示在图 10中。每当用户播放一首歌,播放列表都将被更新和显示,充当一个完整的排名推荐引擎。

用户界面

图 10:个性化播放列表推荐流程

总结

尽管我们的推荐系统可能并没有采用典型的教科书方法,也可能不是最准确的推荐系统,但它确实代表了数据科学中最常见技术之一的一个完全可演示且非常有趣的方法。此外,通过持久数据存储、REST API 接口、分布式共享内存缓存和基于现代 Web 2.0 的用户界面,它提供了一个相当完整和全面的候选解决方案。

当然,要将这个原型产品打造成一个生产级产品仍需要大量的努力和专业知识。在信号处理领域仍有改进空间。例如,可以通过使用响度滤波器来改善声压,并减少信号噪音,languagelog.ldc.upenn.edu/myl/StevensJASA1955.pdf,通过提取音高和旋律,或者更重要的是,通过将立体声转换为单声道信号。

所有这些过程实际上都是研究的一个活跃领域 - 读者可以查看以下一些出版物:www.justinsalamon.com/publications.htmlwww.mattmcvicar.com/publications/

此外,我们质疑如何通过使用简单(交互式)用户界面来改进数据科学演示。正如提到的,这是一个经常被忽视的方面,也是演示的一个关键特点。即使在项目的早期阶段,投资一些时间进行数据可视化也是值得的,因为当说服商业人士你的产品的可行性时,它可能特别有用。

最后,作为一个有抱负的章节,我们探索了在 Spark 环境中解决数据科学用例的创新方法。通过平衡数学和计算机科学的技能,数据科学家应该可以自由探索,创造,推动可行性的边界,承担人们认为不可能的任务,但最重要的是,享受数据带来的乐趣。因为这正是为什么成为数据科学家被认为是 21 世纪最性感的工作的主要原因。

这一章是一个音乐插曲。在下一章中,我们将通过使用 Twitter 数据来引导 GDELT 文章的分类模型来分类 GDELT 文章,这无疑是另一个雄心勃勃的任务。

第九章:新闻词典和实时标记系统

虽然分层数据仓库将数据存储在文件夹中的文件中,但典型的基于 Hadoop 的系统依赖于扁平架构来存储您的数据。如果没有适当的数据治理或对数据的清晰理解,将数据湖变成沼泽的机会是不可否认的,其中一个有趣的数据集,如 GDELT,将不再是一个包含大量非结构化文本文件的文件夹。因此,数据分类可能是大规模组织中最广泛使用的机器学习技术之一,因为它允许用户正确分类和标记其数据,将这些类别作为其元数据解决方案的一部分发布,从而以最有效的方式访问特定信息。如果没有一个在摄入时执行的适当标记机制,理想情况下,找到关于特定主题的所有新闻文章将需要解析整个数据集以寻找特定关键字。在本章中,我们将描述一种创新的方法,以一种非监督的方式和近乎实时地使用 Spark Streaming 和 1%的 Twitter firehose 标记传入的 GDELT 数据。

我们将涵盖以下主题:

  • 使用 Stack Exchange 数据引导朴素贝叶斯分类器

  • Lambda 与 Kappa 架构用于实时流应用程序

  • 在 Spark Streaming 应用程序中使用 Kafka 和 Twitter4J

  • 部署模型时的线程安全性

  • 使用 Elasticsearch 作为缓存层

机械土耳其人

数据分类是一种监督学习技术。这意味着您只能预测您从训练数据集中学到的标签和类别。因为后者必须被正确标记,这成为我们将在本章中解决的主要挑战。

人类智能任务

在新闻文章的背景下,我们的数据没有得到适当的标记;我们无法从中学到任何东西。数据科学家的常识是手动开始标记一些输入记录,这些记录将作为训练数据集。然而,因为类别的数量可能相对较大,至少在我们的情况下(数百个标签),需要标记的数据量可能相当大(数千篇文章),并且需要巨大的努力。一个解决方案是将这项繁琐的任务外包给“机械土耳其人”,这个术语被用来指代历史上最著名的骗局之一,一个自动国际象棋选手愚弄了世界上大多数领导人(en.wikipedia.org/wiki/The_Turk)。这通常描述了一个可以由机器完成的过程,但实际上是由一个隐藏的人完成的,因此是一个人类智能任务。

对于读者的信息,亚马逊已经启动了一个机械土耳其人计划(www.mturk.com/mturk/welcome),个人可以注册执行人类智能任务,如标记输入数据或检测文本内容的情感。众包这项任务可能是一个可行的解决方案,假设您可以将这个内部(可能是机密的)数据集分享给第三方。这里描述的另一种解决方案是使用预先存在的标记数据集引导分类模型。

引导分类模型

文本分类算法通常从术语频率向量中学习;一种可能的方法是使用具有类似上下文的外部资源训练模型。例如,可以使用从 Stack Overflow 网站的完整转储中学到的类别对未标记的 IT 相关内容进行分类。因为 Stack Exchange 不仅仅是为 IT 专业人士保留的,人们可以在许多不同的上下文中找到各种数据集,这些数据集可以服务于许多目的(archive.org/download/stackexchange)。

从 Stack Exchange 学习

我们将在这里演示如何使用来自 Stack Exchange 网站的与家酿啤酒相关的数据集来引导一个简单的朴素贝叶斯分类模型:

$ wget https://archive.org/download/stackexchange/beer.stackexchange.com.7z
$ 7z e beer.stackexchange.com.7z

我们创建了一些方法,从所有 XML 文档中提取正文和标签,从 HTML 编码的正文中提取干净的文本内容(使用第六章中介绍的 Goose 抓取器,基于链接的外部数据抓取),最后将我们的 XML 文档 RDD 转换为 Spark DataFrame。这里没有报告不同的方法,但它们可以在我们的代码库中找到。需要注意的是,Goose 抓取器可以通过提供 HTML 内容(作为字符串)和一个虚拟 URL 来离线使用。

我们提供了一个方便的parse方法,可用于预处理来自 Stack Exchange 网站的任何Post.xml数据。这个函数是我们的StackBootstraping代码的一部分,可以在我们的代码库中找到:

import io.gzet.tagging.stackoverflow.StackBootstraping

val spark = SparkSession.builder()
  .appName("StackExchange")
  .getOrCreate()

val sc = spark.sparkContext
val rdd = sc.textFile("/path/to/posts.xml")
val brewing = StackBootstraping.parse(rdd)

brewing.show(5)

+--------------------+--------------------+
|                body|                tags|
+--------------------+--------------------+
|I was offered a b...|              [hops]|
|As far as we know...|           [history]|
|How is low/no alc...|           [brewing]|
|In general, what'...|[serving, tempera...|
|Currently I am st...| [pilsener, storage]|
+--------------------+--------------------+

构建文本特征

有了正确标记的啤酒内容,剩下的过程就是引导算法本身。为此,我们使用一个简单的朴素贝叶斯分类算法,确定给定项目特征的标签的条件概率。我们首先收集所有不同的标签,分配一个唯一的标识符(作为Double),并将我们的标签字典广播到 Spark 执行器:

val labelMap = brewing
  .select("tags")
  .withColumn("tag", explode(brewing("tags")))
  .select("tag")
  .distinct()
  .rdd
  .map(_.getString(0)).zipWithIndex()
  .mapValues(_.toDouble + 1.0d)
labelMap.take(5).foreach(println)

/*
(imperal-stout,1.0)
(malt,2.0)
(lent,3.0)
(production,4.0)
(local,5.0)
*/

提示

如前所述,请确保在 Spark 转换中使用的大型集合已广播到所有 Spark 执行器。这将减少与网络传输相关的成本。

LabeledPoint由标签(作为Double)和特征(作为Vector)组成。构建文本内容特征的常见做法是构建词项频率向量,其中每个单词在所有文档中对应一个特定的维度。在英语中大约有数十万个维度(英语单词估计数量为 1,025,109),这种高维空间对于大多数机器学习算法来说将特别低效。事实上,当朴素贝叶斯算法计算概率(小于 1)时,由于机器精度问题(如第十四章中描述的数值下溢,可扩展算法),存在达到 0 的风险。数据科学家通过使用降维原理来克服这一限制,将稀疏向量投影到更密集的空间中,同时保持距离度量(降维原理将在第十章中介绍,故事去重和变异)。尽管我们可以找到许多用于此目的的算法和技术,但我们将使用 Spark 提供的哈希工具。

在* n (默认为 2²⁰)的向量大小下,其transform方法将所有单词分组到 n *个不同的桶中,根据它们的哈希值对桶频率进行求和以构建更密集的向量。

在进行昂贵的降维操作之前,可以通过对文本内容进行词干处理和清理来大大减少向量大小。我们在这里使用 Apache Lucene 分析器:

<dependency>
   <groupId>org.apache.lucene</groupId>
   <artifactId>lucene-analyzers-common</artifactId>
   <version>4.10.1</version>
 </dependency>

我们去除所有标点和数字,并将纯文本对象提供给 Lucene 分析器,将每个干净的单词收集为CharTermAttribute

def stem(rdd: RDD[(String, Array[String])]) = {

  val replacePunc = """\\W""".r
  val replaceDigitOnly = """\\s\\d+\\s""".r

  rdd mapPartitions { it =>

    val analyzer = new EnglishAnalyzer
    it map { case (body, tags) =>
      val content1 = replacePunc.replaceAllIn(body, " ")
      val content = replaceDigitOnly.replaceAllIn(content1, " ")
      val tReader = new StringReader(content)
      val tStream = analyzer.tokenStream("contents", tReader)
      val term = tStream.addAttribute(classOf[CharTermAttribute])
       tStream.reset()
      val terms = collection.mutable.MutableList[String]()
      while (tStream.incrementToken) {
        val clean = term.toString
        if (!clean.matches(".*\\d.*") && clean.length > 3) {
           terms += clean
        }
      }
      tStream.close()
      (terms.toArray, tags)
     }

  }

通过这种方法,我们将文本[Mastering Spark for Data Science - V1]转换为[master spark data science],从而减少了输入向量中的单词数量(因此减少了维度)。最后,我们使用 MLlib 的normalizer类来规范化我们的词项频率向量:

val hashingTf = new HashingTF()
val normalizer = new Normalizer()

val labeledCorpus = stem(df map { row =>
  val body = row.getString(0)
  val tags = row.getAs[mutable.WrappedArray[String]](1)
  (body, tags)
})

val labeledPoints = labeledCorpus flatMap { case (corpus, tags) =>
  val vector = hashingTf.transform(corpus)
  val normVector = normalizer.transform(vector)
  tags map { tag =>
    val label = bLabelMap.value.getOrElse(tag, 0.0d)
    LabeledPoint(label, normVector)
  }
}

提示

哈希函数可能会导致由于碰撞而产生严重的高估(两个完全不同含义的单词可能共享相同的哈希值)。我们将在第十章中讨论随机索引技术,以限制碰撞的数量同时保持距离度量。

训练朴素贝叶斯模型

我们按照以下方式训练朴素贝叶斯算法,并使用我们没有包含在训练数据点中的测试数据集测试我们的分类器。最后,在下面的例子中显示了前五个预测。左侧的标签是我们测试内容的原始标签;右侧是朴素贝叶斯分类的结果。ipa被预测为hangover,从而确证了我们分类算法的准确性:

labeledPoints.cache()
val model: NaiveBayesModel = NaiveBayes.train(labeledPoints)
labeledPoints.unpersist(blocking = false)

model
  .predict(testPoints)
  .map { prediction =>
     bLabelMap.value.map(_.swap).get(prediction).get
   }
  .zip(testLabels)
  .toDF("predicted","original")
  .show(5)

+---------+-----------+
| original|  predicted|
+---------+-----------+
|  brewing|    brewing|
|      ipa|   hangover|
| hangover|   hangover|
| drinking|   drinking|
| pilsener|   pilsener|
+---------+-----------+

为了方便起见,我们将所有这些方法抽象出来,并在稍后将使用的Classifier对象中公开以下方法:

def train(rdd: RDD[(String, Array[String])]): ClassifierModel
def predict(rdd: RDD[String]): RDD[String]

我们已经演示了如何从外部来源导出标记数据,如何构建词项频率向量,以及如何训练一个简单的朴素贝叶斯分类模型。这里使用的高级工作流程如下图所示,对于大多数分类用例来说都是通用的:

训练朴素贝叶斯模型

图 1:分类工作流程

下一步是开始对原始未标记数据进行分类(假设我们的内容仍然与酿酒有关)。这结束了朴素贝叶斯分类的介绍,以及一个自举模型如何从外部资源中获取真实信息。这两种技术将在以下部分中用于我们的分类系统。

懒惰,急躁和傲慢

接下来是我们在新闻文章环境中将面临的第二个主要挑战。假设有人花了几天时间手动标记数据,这将解决我们已知类别的分类问题,可能只在回测我们的数据时有效。谁知道明天报纸的新闻标题会是什么;没有人能定义将来将涵盖的所有细粒度标签和主题(尽管仍然可以定义更广泛的类别)。这将需要大量的努力来不断重新评估、重新训练和重新部署我们的模型,每当出现新的热门话题时。具体来说,一年前没有人谈论“脱欧”这个话题;现在这个话题在新闻文章中被大量提及。

根据我们的经验,数据科学家应该记住 Perl 编程语言的发明者 Larry Wall 的一句名言:

“我们将鼓励您培养程序员的三大美德,懒惰、急躁和傲慢”。

  • 懒惰会让你付出巨大的努力来减少总体能量消耗

  • 急躁会让你编写不仅仅是满足你需求的程序,而是能够预测你的需求

  • 傲慢会让你编写程序,别人不愿意说坏话

我们希望避免与分类模型的准备和维护相关的努力(懒惰),并在程序上预测新主题的出现(急躁),尽管这听起来可能是一个雄心勃勃的任务(但如果不是对实现不可能的过度自豪,那又是什么呢?)。社交网络是一个从中获取真实信息的绝佳地方。事实上,当人们在 Twitter 上发布新闻文章时,他们无意中帮助我们标记我们的数据。我们不需要支付机械土耳其人的费用,当我们潜在地有数百万用户为我们做这项工作时。换句话说,我们将 GDELT 数据的标记外包给 Twitter 用户。

Twitter 上提到的任何文章都将帮助我们构建一个词项频率向量,而相关的标签将被用作正确的标签。在下面的例子中,关于奥巴马总统穿着睡袍会见乔治王子的可爱新闻已被分类为[#Obama]和[#Prince] www.wfmynews2.com/entertainment/adorable-prince-george-misses-bedtime-meets-president-obama/149828772

懒惰,急躁和傲慢

图 2:奥巴马总统会见乔治王子,#Obama,#Prince

在以下示例中,我们通过机器学习主题[#DavidBowie],[#Prince],[#GeorgeMichael]和[#LeonardCohen]来向 2016 年音乐界的所有巨大损失致敬,这些主题都在同一篇来自《卫报》的新闻文章中(https://www.theguardian.com/music/2016/dec/29/death-stars-musics-greatest-losses-of-2016):

懒惰、急躁和傲慢

图 3:2016 年音乐界的巨大损失-来源

使用这种方法,我们的算法将不断自动重新评估,从而自行学习出现的主题,因此以一种非监督的方式工作(尽管在适当意义上是一种监督学习算法)。

设计 Spark Streaming 应用程序

构建实时应用程序在架构和涉及的组件方面与批处理处理有所不同。后者可以轻松地自下而上构建,程序员在需要时添加功能和组件,而前者通常需要在一个稳固的架构基础上自上而下构建。事实上,由于数据量和速度(或在流处理上下文中的真实性)的限制,不恰当的架构将阻止程序员添加新功能。人们总是需要清楚地了解数据流如何相互连接,以及它们是如何被处理、缓存和检索的。

两种架构的故事

在使用 Apache Spark 进行流处理方面,有两种新兴的架构需要考虑:Lambda 架构和 Kappa 架构。在深入讨论这两种架构的细节之前,让我们讨论它们试图解决的问题,它们有什么共同之处,以及在什么情况下使用每种架构。

CAP 定理

多年来,处理网络中断一直是高度分布式系统的工程师们关注的问题。以下是一个特别感兴趣的情景,请考虑:

CAP 定理

图 4:分布式系统故障

典型分布式系统的正常运行是用户执行操作,系统使用复制、缓存和索引等技术来确保正确性和及时响应。但当出现问题时会发生什么:

CAP 定理

图 5:分布式系统的分裂大脑综合症

在这里,网络中断实际上阻止了用户安全地执行他们的操作。是的,一个简单的网络故障引起了一个并不仅仅影响功能和性能的复杂情况,正如你可能期望的那样,还影响了系统的正确性。

事实上,系统现在遭受了所谓的分裂大脑综合症。在这种情况下,系统的两个部分不再能够相互通信,因此用户在一侧进行的任何修改在另一侧是不可见的。这几乎就像有两个独立的系统,每个系统都维护着自己的内部状态,随着时间的推移会变得截然不同。至关重要的是,用户在任一侧运行相同查询时可能会报告不同的答案。

这只是分布式系统中失败的一般情况之一,尽管已经花费了大量时间来解决这些问题,但仍然只有三种实际的方法:

  1. 在基础问题得到解决之前,阻止用户进行任何更新,并同时保留系统的当前状态(故障前的最后已知状态)作为正确的(即牺牲分区容忍性)。

  2. 允许用户继续进行更新,但要接受答案可能不同,并且在基础问题得到纠正时必须收敛(即牺牲一致性)。

  3. 将所有用户转移到系统的一部分,并允许他们继续进行更新。系统的另一部分被视为失败,并接受部分处理能力的降低,直到问题解决为止 - 系统可能因此变得不太响应(即牺牲可用性)。

前述的结论更正式地陈述为 CAP 定理(nathanmarz.com/blog/how-to-beat-the-cap-theorem.html)。它认为在一个故障是生活中的事实且你不能牺牲功能性的环境中(1),你必须在一致的答案(2)和完整的功能性(3)之间做出选择。你不能两者兼得,因为这是一种权衡。

提示

事实上,更正确的描述是将“故障”描述为更一般的术语“分区容错”,因为这种类型的故障可能指的是系统的任何分割 - 网络中断、服务器重启、磁盘已满等 - 它不一定是特定的网络问题。

不用说,这是一种简化,但尽管如此,在故障发生时,大多数数据处理系统都会属于这些广泛的类别之一。此外,事实证明,大多数传统数据库系统都倾向于一致性,通过使用众所周知的计算机科学方法来实现,如事务、预写日志和悲观锁定。

然而,在今天的在线世界中,用户期望全天候访问服务,其中许多服务都是收入来源;物联网或实时决策,需要一种可扩展的容错方法。因此,人们努力寻求确保在故障发生时可用性的替代方案的努力激增(事实上,互联网本身就是出于这种需求而诞生的)。

事实证明,在实现高可用系统并提供可接受水平一致性之间取得平衡是一种挑战。为了管理必要的权衡,方法往往提供更弱的一致性定义,即最终一致性,在这种情况下,通常容忍一段时间的陈旧数据,并且随着时间的推移,正确的数据会得到认可。然而,即使在这种妥协情况下,它们仍然需要使用更复杂的技术,因此更难以构建和维护。

提示

在更繁重的实现中,需要使用向量时钟和读修复来处理并发并防止数据损坏。

希腊人在这里可以提供帮助

Lambda 和 Kappa 架构都提供了对先前描述的问题更简单的解决方案。它们倡导使用现代大数据技术,如 Apache Spark 和 Apache Kafka 作为一致性可用处理系统的基础,逻辑可以在不需要考虑故障的情况下进行开发。它们适用于具有以下特征的情况:

  • 无限的、入站的信息流,可能来自多个来源

  • 对非常大的累积数据集进行分析处理

  • 用户查询对数据一致性有时间保证

  • 对性能下降或停机的零容忍

在具有这些条件的情况下,您可以考虑将任一架构作为一般候选。每种架构都遵循以下核心原则,有助于简化数据一致性、并发访问和防止数据损坏的问题:

  • 数据不可变性:数据只能创建或读取。它永远不会被更新或删除。以这种方式处理数据极大地简化了保持数据一致性所需的模型。

  • 人为容错:在软件开发生命周期的正常过程中修复或升级软件时,通常需要部署新版本的分析并通过系统重放历史数据以产生修订答案。事实上,在管理直接处理具有此能力的数据的系统时,这通常是至关重要的。批处理层提供了历史数据的持久存储,因此允许恢复任何错误。

正是这些原则构成了它们最终一致的解决方案的基础,而无需担心诸如读取修复或向量时钟之类的复杂性;它们绝对是更友好的开发人员架构!

因此,让我们讨论一些选择一个而不是另一个的原因。让我们首先考虑 Lambda 架构。

Lambda 架构的重要性

Lambda 架构,最初由 Nathan Marz 提出,通常是这样的:

Lambda 架构的重要性

图 6:Lambda 架构

实质上,数据被双路由到两个层:

  • 批处理层能够在给定时间点计算快照

  • 实时层能够处理自上次快照以来的增量更改

服务层然后用于将这两个数据视图合并在一起,产生一个最新的真相版本。

除了先前描述的一般特征之外,Lambda 架构在以下特定条件下最适用:

  • 复杂或耗时的批量或批处理算法,没有等效或替代的增量迭代算法(并且近似值是不可接受的),因此您需要批处理层。

  • 无论系统的并行性如何,批处理层单独无法满足数据一致性的保证,因此您需要实时层。例如,您有:

  • 低延迟写入-读取

  • 任意宽范围的数据,即年份

  • 重数据倾斜

如果您具有以下任一条件之一,您应该考虑使用 Lambda 架构。但是,在继续之前,请注意它带来的可能会带来挑战的以下特性:

  • 两个数据管道:批处理和流处理有单独的工作流程,尽管在可能的情况下可以尝试重用核心逻辑和库,但流程本身必须在运行时单独管理。

  • 复杂的代码维护:除了简单的聚合之外,批处理和实时层中的算法将需要不同。这对于机器学习算法尤其如此,其中有一个专门研究这一领域的领域,称为在线机器学习(en.wikipedia.org/wiki/Online_machine_learning),其中可能涉及实现增量迭代算法或近似算法,超出现有框架之外。

  • 服务层中的复杂性增加:为了将增量与聚合合并,服务层中需要进行聚合、联合和连接。工程师们应该小心,不要将其分解为消费系统。

尽管存在这些挑战,Lambda 架构是一种强大而有用的方法,已经成功地在许多机构和组织中实施,包括 Yahoo!、Netflix 和 Twitter。

Lambda 架构的重要性

Kappa 架构通过将分布式日志的概念置于中心,进一步简化了概念。这样可以完全删除批处理层,从而创建一个更简单的设计。Kappa 有许多不同的实现,但通常看起来是这样的:

Kappa 架构的重要性

图 7:Kappa 架构

在这种架构中,分布式日志基本上提供了数据不可变性和可重放性的特性。通过在处理层引入可变状态存储的概念,它通过将所有处理视为流处理来统一计算模型,即使是批处理,也被视为流的特例。当您具有以下特定条件之一时,Kappa 架构最适用:

  • 通过增加系统的并行性来减少延迟,可以满足数据一致性的保证。

  • 通过实现增量迭代算法可以满足数据一致性的保证

如果这两种选择中的任何一种是可行的,那么 Kappa 架构应该提供一种现代、可扩展的方法来满足您的批处理和流处理需求。然而,值得考虑所选择的任何实现的约束和挑战。潜在的限制包括:

  • 精确一次语义:许多流行的分布式消息系统,如 Apache Kafka,目前不支持精确一次消息传递语义。这意味着,目前,消费系统必须处理接收到的数据重复。通常通过使用检查点、唯一键、幂等写入或其他去重技术来完成,但这会增加复杂性,因此使解决方案更难构建和维护。

  • 无序事件处理:许多流处理实现,如 Apache Spark,目前不支持按事件时间排序的更新,而是使用处理时间,即系统首次观察到事件的时间。因此,更新可能会无序接收,系统需要能够处理这种情况。同样,这会增加代码复杂性,使解决方案更难构建和维护。

  • 没有强一致性,即线性一致性:由于所有更新都是异步应用的,不能保证写入会立即生效(尽管它们最终会一致)。这意味着在某些情况下,您可能无法立即“读取您的写入”。

在下一章中,我们将讨论增量迭代算法,数据倾斜或服务器故障如何影响一致性,以及 Spark Streaming 中的反压特性如何帮助减少故障。关于本节中所解释的内容,我们将按照 Kappa 架构构建我们的分类系统。

消费数据流

与批处理作业类似,我们使用SparkConf对象和上下文创建一个新的 Spark 应用程序。在流处理应用程序中,上下文是使用批处理大小参数创建的,该参数将用于任何传入的流(GDELT 和 Twitter 层,作为同一上下文的一部分,都将绑定到相同的批处理大小)。由于 GDELT 数据每 15 分钟发布一次,我们的批处理大小自然将是 15 分钟,因为我们希望基于伪实时基础预测类别:

val sparkConf = new SparkConf().setAppName("GZET")
val ssc = new StreamingContext(sparkConf, Minutes(15))
val sc = ssc.sparkContext

创建 GDELT 数据流

有许多将外部数据发布到 Spark 流处理应用程序的方法。可以打开一个简单的套接字并开始通过 netcat 实用程序发布数据,或者可以通过监视外部目录的 Flume 代理流式传输数据。生产系统通常使用 Kafka 作为默认代理,因为它具有高吞吐量和整体可靠性(数据被复制到多个分区)。当然,我们可以使用与第十章中描述的相同的 Apache NiFi 堆栈,故事去重和变异,但我们想在这里描述一个更简单的路线,即通过 Kafka 主题将文章 URL(从 GDELT 记录中提取)传送到我们的 Spark 应用程序中。

创建 Kafka 主题

在测试环境中创建一个新的 Kafka 主题非常容易。在生产环境中,必须特别注意选择正确数量的分区和复制因子。还要注意安装和配置适当的 zookeeper quorum。我们启动 Kafka 服务器并创建一个名为gzet的主题,只使用一个分区和一个复制因子:

$ kafka-server-start /usr/local/etc/kafka/server.properties > /var/log/kafka/kafka-server.log 2>&1 &

$ kafka-topics --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic gzet

将内容发布到 Kafka 主题

我们可以通过将内容传送到kafka-console-producer实用程序来向 Kafka 队列提供数据。我们使用awksortuniq命令,因为我们只对 GDELT 记录中的不同 URL 感兴趣(URL是我们的制表符分隔值的最后一个字段,因此是$NF):

$ cat ${FILE} | awk '{print $NF}' | sort | uniq | kafka-console-producer --broker-list localhost:9092 --topic gzet

为了方便起见,我们创建了一个简单的 bash 脚本,用于监听 GDELT 网站上的新文件,下载和提取内容到临时目录,并执行上述命令。该脚本可以在我们的代码存储库(gdelt-stream.sh)中找到。

从 Spark Streaming 中消费 Kafka

Kafka 是 Spark Streaming 的官方来源,可使用以下依赖项:

<dependency>
   <groupId>org.apache.spark</groupId>
   <artifactId>spark-streaming-kafka-0-8_2.11</artifactId>
   <version>2.0.0</version>
</dependency>

我们定义将用于处理来自 gzet 主题的数据的 Spark 分区数量(这里是 10),以及 zookeeper quorum。我们返回消息本身(传送到我们的 Kafka 生产者的 URL),以构建我们的文章 URL 流:

def createGdeltStream(ssc: StreamingContext) = {
   KafkaUtils.createStream(
     ssc,
     "localhost:2181",
     "gzet",
     Map("gzet" -> 10)
   ).values
 }

val gdeltUrlStream: DStream[String] = createGdeltStream(ssc)

从 Spark Streaming 中消费 Kafka

图 8:GDELT 在线层

在上图中,我们展示了 GDELT 数据将如何通过监听 Kafka 主题进行批处理。每个批次将被分析,并使用第六章中描述的 HTML 解析器基于链接的外部数据抓取下载文章。

创建 Twitter 数据流

使用 Twitter 的明显限制是规模的限制。每天有超过 5 亿条推文,我们的应用程序需要以最分布式和可扩展的方式编写,以处理大量的输入数据。此外,即使只有 2%的推文包含对外部 URL 的引用,我们每天仍然需要获取和分析 100 万个 URL(除了来自 GDELT 的数千个 URL)。由于我们没有专门的架构来处理这些数据,因此我们将使用 Twitter 免费提供的 1% firehose。只需在 Twitter 网站上注册一个新应用程序(apps.twitter.com),并检索其关联的应用程序设置和授权令牌。但是请注意,自 Spark Streaming 版本2.0.0以来,Twitter 连接器不再是核心 Spark Streaming 的一部分。作为 Apache Bahir 项目的一部分(bahir.apache.org/),可以使用以下 mavendependency

<dependency>
   <groupId>org.apache.bahir</groupId>
   <artifactId>spark-streaming-twitter_2.11</artifactId>
   <version>2.0.0</version>
</dependency>

因为 Spark Streaming 在后台使用twitter4j,所以配置是使用twitter4j库中的ConfigurationBuilder对象完成的:

import twitter4j.auth.OAuthAuthorization
import twitter4j.conf.ConfigurationBuilder

def getTwitterConfiguration = {

  val builder = new ConfigurationBuilder()

  builder.setOAuthConsumerKey("XXXXXXXXXXXXXXX")
  builder.setOAuthConsumerSecret("XXXXXXXXXXXX")
  builder.setOAuthAccessToken("XXXXXXXXXXXXXXX")
  builder.setOAuthAccessTokenSecret("XXXXXXXXX")

  val configuration = builder.build()
  Some(new OAuthAuthorization(configuration))

}

我们通过提供一个关键字数组(可以是特定的标签)来创建我们的数据流。在我们的情况下,我们希望收听所有 1%,无论使用哪些关键字或标签(发现新标签实际上是我们应用程序的一部分),因此提供一个空数组:

def createTwitterStream(ssc: StreamingContext) = {
   TwitterUtils.createStream(
     ssc,
     getTwitterConfiguration,
     Array[String]()
   )
}

val twitterStream: DStream[Status] = createTwitterStream(ssc)
getText method that returns the tweet body:
val body: String = status.getText()
val user: User = status.getUser()
val contributors: Array[Long] = status.getContributors()
val createdAt: Long = status.getCreatedAt()
../..

处理 Twitter 数据

使用 Twitter 的第二个主要限制是噪音的限制。当大多数分类模型针对数十个不同的类进行训练时,我们将针对每天数十万个不同的标签进行工作。我们只关注热门话题,即在定义的批处理窗口内发生的热门话题。然而,由于 Twitter 上的 15 分钟批处理大小不足以检测趋势,因此我们将应用一个 24 小时的移动窗口,其中将观察和计数所有标签,并仅保留最受欢迎的标签。

处理 Twitter 数据

图 9:Twitter 在线层,批处理和窗口大小

使用这种方法,我们减少了不受欢迎标签的噪音,使我们的分类器更加准确和可扩展,并显著减少了要获取的文章数量,因为我们只关注与热门话题一起提及的流行 URL。这使我们能够节省大量时间和资源,用于分析与分类模型无关的数据。

提取 URL 和标签

我们提取干净的标签(长度超过 x 个字符且不包含数字;这是减少噪音的另一种措施)和对有效 URL 的引用。请注意 Scala 的Try方法,它在测试URL对象时捕获任何异常。只有符合这两个条件的推文才会被保留:

def extractTags(tweet: String) = {
  StringUtils.stripAccents(tweet.toLowerCase())
    .split("\\s")
    .filter { word =>
      word.startsWith("#") &&
        word.length > minHashTagLength &&
        word.matches("#[a-z]+")
    }
}

def extractUrls(tweet: String) = {
  tweet.split("\\s")
    .filter(_.startsWith("http"))
    .map(_.trim)
    .filter(url => Try(new URL(url)).isSuccess)
}

def getLabeledUrls(twitterStream: DStream[Status]) = {
  twitterStream flatMap { tweet =>
    val tags = extractTags(tweet.getText)
    val urls = extractUrls(tweet.getText)
    urls map { url =>
      (url, tags)
    }
  }
}

val labeledUrls = getLabeledUrls(twitterStream)

保留热门标签

这一步的基本思想是在 24 小时的时间窗口内执行一个简单的词频统计。我们提取所有的标签,赋予一个值为 1,并使用 reduce 函数计算出现次数。在流处理上,reduceByKey函数可以使用reduceByKeyAndWindow方法在一个窗口上应用(必须大于批处理大小)。尽管这个词频字典在每个批处理中都是可用的,但当前的前十个标签每 15 分钟打印一次,数据将在一个较长的时间段(24 小时)内计数:

def getTrends(twitterStream: DStream[Status]) = {

  val stream = twitterStream
    .flatMap { tweet =>
      extractTags(tweet.getText)
    }
    .map(_ -> 1)
    .reduceByKeyAndWindow(_ + _, Minutes(windowSize))

  stream.foreachRDD { rdd =>
    val top10 = rdd.sortBy(_._2, ascending = false).take(10)
    top10.foreach { case (hashTag, count) =>
      println(s"[$hashTag] - $count")
    }
  }

  stream
}

val twitterTrend = getTrends(twitterStream)

在批处理上下文中,可以轻松地将标签的 RDD 与 Twitter RDD 连接,以保留只有“最热门”推文(提及与热门标签一起的文章的推文)。在流处理上下文中,数据流不能连接,因为每个流包含多个 RDD。相反,我们使用transformWith函数将一个DStream与另一个DStream进行转换,该函数接受一个匿名函数作为参数,并在它们的每个 RDD 上应用它。我们通过应用一个过滤不受欢迎推文的函数,将我们的 Twitter 流与我们的标签流进行转换。请注意,我们使用 Spark 上下文广播我们当前的前 n 个标签(在这里限制为前 100 个):

val joinFunc = (labeledUrls: RDD[(String, Array[String])], twitterTrend: RDD[(String, Int)]) => {

   val sc = twitterTrend.sparkContext
   val leaderBoard = twitterTrend
     .sortBy(_._2, ascending = false)
     .take(100)
     .map(_._1)

   val bLeaderBoard = sc.broadcast(leaderBoard)

   labeledUrls
     .flatMap { case (url, tags) =>
       tags map (tag => (url, tag))
     }
     .filter { case (url, tag) =>
       bLeaderBoard.value.contains(tag)
     }
     .groupByKey()
     .mapValues(_.toArray.distinct)

 }

 val labeledTrendUrls = labeledUrls
   .transformWith(twitterTrend, joinFunc)

因为返回的流将只包含“最热门”的 URL,所以数据量应该大大减少。虽然在这个阶段我们无法保证 URL 是否指向正确的文本内容(可能是 YouTube 视频或简单的图片),但至少我们知道我们不会浪费精力获取与无用主题相关的内容。

扩展缩短的 URL

Twitter 上的 URL 是缩短的。以编程方式检测真实来源的唯一方法是为所有 URL“打开盒子”,可悲的是,这浪费了大量的时间和精力,可能是无关紧要的内容。值得一提的是,许多网络爬虫无法有效地处理缩短的 URL(包括 Goose 爬虫)。我们通过打开 HTTP 连接、禁用重定向并查看Location头来扩展 URL。我们还为该方法提供了一个“不受信任”的来源列表,这些来源对于分类模型的上下文来说并没有提供任何有用的内容(例如来自www.youtube.com的视频):

def expandUrl(url: String) : String = {

  var connection: HttpURLConnection = null
  try {

    connection = new URL(url)
                    .openConnection
                    .asInstanceOf[HttpURLConnection]

    connection.setInstanceFollowRedirects(false)
    connection.setUseCaches(false)
    connection.setRequestMethod("GET")
    connection.connect()

    val redirectedUrl = connection.getHeaderField("Location")

    if(StringUtils.isNotEmpty(redirectedUrl)){
       redirectedUrl
     } else {
       url
     }

   } catch {
     case e: Throwable => url
   } finally {
     if(connection != null)
       connection.disconnect()
   }
 }

 def expandUrls(tStream: DStream[(String, Array[String])]) = {
   tStream
     .map { case (url, tags) =>
       (HtmlHandler.expandUrl(url), tags)
     }
     .filter { case (url, tags) =>
       !untrustedSources.value.contains(url)
     }
}

val expandedUrls = expandUrls(labeledTrendUrls)

提示

与上一章中所做的类似,我们彻底捕捉由 HTTP 连接引起的任何可能的异常。任何未捕获的异常(可能是一个简单的 404 错误)都会使这个任务在引发致命异常之前重新评估不同的 Spark 执行器,退出我们的 Spark 应用程序。

获取 HTML 内容

我们已经在上一章介绍了网络爬虫,使用了为 Scala 2.11 重新编译的 Goose 库。我们将创建一个以DStream作为输入的方法,而不是 RDD,并且只保留至少 500 个单词的有效文本内容。最后,我们将返回一个文本流以及相关的标签(热门的标签)。

def fetchHtmlContent(tStream: DStream[(String, Array[String])]) = {

  tStream
    .reduceByKey(_++_.distinct)
    .mapPartitions { it =>

      val htmlFetcher = new HtmlHandler()
      val goose = htmlFetcher.getGooseScraper
      val sdf = new SimpleDateFormat("yyyyMMdd")

      it.map { case (url, tags) =>
        val content = htmlFetcher.fetchUrl(goose, url, sdf)
        (content, tags)
      }
      .filter { case (contentOpt, tags) =>
        contentOpt.isDefined &&
          contentOpt.get.body.isDefined &&
          contentOpt.get.body.get.split("\\s+").length >= 500
      }
      .map { case (contentOpt, tags) =>
        (contentOpt.get.body.get, tags)
      }

}

val twitterContent = fetchHtmlContent(expandedUrls)

我们对 GDELT 数据应用相同的方法,其中所有内容(文本、标题、描述等)也将被返回。请注意reduceByKey方法,它充当我们数据流的一个不同函数:

def fetchHtmlContent(urlStream: DStream[String]) = {

  urlStream
    .map(_ -> 1)
    .reduceByKey()
    .keys
    .mapPartitions { urls =>

      val sdf = new SimpleDateFormat("yyyyMMdd")
      val htmlHandler = new HtmlHandler()
      val goose = htmlHandler.getGooseScraper
      urls.map { url =>
         htmlHandler.fetchUrl(goose, url, sdf)
      }

    }
    .filter { content =>
      content.isDefined &&
        content.get.body.isDefined &&
        content.get.body.get.split("\\s+").length > 500
    }
    .map(_.get)
}

val gdeltContent = fetchHtmlContent(gdeltUrlStream)

使用 Elasticsearch 作为缓存层

我们的最终目标是在每个批处理(每 15 分钟)中训练一个新的分类器。然而,分类器将使用不仅仅是我们在当前批次中下载的少数记录。我们不知何故必须在较长时间内缓存文本内容(设置为 24 小时),并在需要训练新分类器时检索它。考虑到 Larry Wall 的引用,我们将尽可能懒惰地维护在线层上的数据一致性。基本思想是使用生存时间TTL)参数,它将无缝地丢弃任何过时的记录。Cassandra 数据库提供了这个功能(HBase 或 Accumulo 也是如此),但 Elasticsearch 已经是我们核心架构的一部分,可以轻松用于此目的。我们将为gzet/twitter索引创建以下映射,并启用_ttl参数:

$ curl -XPUT 'http://localhost:9200/gzet'
$ curl -XPUT 'http://localhost:9200/gzet/_mapping/twitter' -d '
{
    "_ttl" : {
           "enabled" : true
    },
    "properties": {
      "body": {
        "type": "string"
      },
      "time": {
        "type": "date",
        "format": "yyyy-MM-dd HH:mm:ss"
      },
      "tags": {
        "type": "string",
        "index": "not_analyzed"
      },
      "batch": {
        "type": "integer"
      }
    }
}'

我们的记录将在 Elasticsearch 上存在 24 小时(TTL 值在插入时定义),之后任何记录将被简单丢弃。由于我们将维护任务委托给 Elasticsearch,我们可以安全地从在线缓存中拉取所有可能的记录,而不用太担心任何过时的值。所有检索到的数据将用作我们分类器的训练集。高层过程如下图所示:

使用 Elasticsearch 作为缓存层

图 10:使用 Elasticsearch 作为缓存层

对于数据流中的每个 RDD,我们从前 24 小时中检索所有现有记录,缓存我们当前的 Twitter 内容,并训练一个新的分类器。将数据流转换为 RDD 是使用foreachRDD函数的简单操作。

我们使用 Elasticsearch API 中的saveToEsWithMeta函数将当前记录持久化到 Elasticsearch 中。此函数接受TTL参数作为元数据映射的一部分(设置为 24 小时,以秒为单位,并格式化为字符串):

import org.elasticsearch.spark._
import org.elasticsearch.spark.rdd.Metadata._

def saveCurrentBatch(twitterContent: RDD[(String, Array[String])]) = {
  twitterContent mapPartitions { it =>
    val sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
    it map { case (content, tags) =>
      val data = Map(
        "time" -> sdf.format(new Date()),
        "body" -> content,
        "tags" -> tags
      )
      val metadata = Map(
        TTL -> "172800s"
      )
      (metadata, data)
     }
   } saveToEsWithMeta "gzet/twitter"
 }

值得在 Elasticsearch 上执行简单的检查,以确保TTL参数已经正确设置,并且每秒都在有效减少。一旦达到 0,索引的文档应该被丢弃。以下简单命令每秒打印出文档 ID [AVRr9LaCoYjYhZG9lvBl] 的_ttl值。这使用一个简单的jq实用程序(stedolan.github.io/jq/download/)从命令行解析 JSON 对象:

$ while true ; do TTL=`curl -XGET 'http://localhost:9200/gzet/twitter/AVRr9LaCoYjYhZG9lvBl' 2>/dev/null | jq "._ttl"`; echo "TTL is $TTL"; sleep 1; done

../..
TTL is 48366081
TTL is 48365060
TTL is 48364038
TTL is 48363016
../..

可以使用以下函数将所有在线记录(具有未过期 TTL 的记录)检索到 RDD 中。与我们在第七章中所做的类似,构建社区,使用 JSON 解析从 Elasticsearch 中提取列表比使用 Spark DataFrame 要容易得多:

import org.elasticsearch.spark._
import org.json4s.DefaultFormats
import org.json4s.jackson.JsonMethods._

def getOnlineRecords(sc: SparkContext) = {
  sc.esJsonRDD("gzet/twitter").values map { jsonStr =>
    implicit val format = DefaultFormats
     val json = parse(jsonStr)
     val tags = (json \ "tags").extract[Array[String]]
     val body = (json \ "body").extract[String]
     (body, tags)
   }
 }

我们从缓存层下载所有 Twitter 内容,同时保存我们当前的批处理。剩下的过程是训练我们的分类算法。这个方法在下一节中讨论:

twitterContent foreachRDD { batch =>

  val sc = batch.sparkContext 
  batch.cache()

  if(batch.count() > 0) {
    val window = getOnlineRecords(sc)
    saveCurrentBatch(batch)
    val trainingSet = batch.union(window)
    //Train method described hereafter
    trainAndSave(trainingSet, modelOutputDir)
  }

  batch.unpersist(blocking = false)
}

分类数据

我们应用的剩余部分是开始对数据进行分类。如前所介绍的,使用 Twitter 的原因是从外部资源中窃取地面真相。我们将使用 Twitter 数据训练一个朴素贝叶斯分类模型,同时预测 GDELT URL 的类别。使用 Kappa 架构方法的便利之处在于,我们不必太担心在不同应用程序或不同环境之间导出一些常见的代码。更好的是,我们不必在批处理层和速度层之间导出/导入我们的模型(GDELT 和 Twitter,共享相同的 Spark 上下文,都是同一物理层的一部分)。我们可以将我们的模型保存到 HDFS 以进行审计,但我们只需要在两个类之间传递一个 Scala 对象的引用。

训练朴素贝叶斯模型

我们已经介绍了使用 Stack Exchange 数据集引导朴素贝叶斯模型的概念,以及使用分类器对象从文本内容构建LabeledPoints。我们将创建一个ClassifierModel case 类,它包装了朴素贝叶斯模型及其相关的标签字典,并公开了predictsave方法:

case class ClassifierModel(
  model: NaiveBayesModel,
  labels: Map[String, Double]
) {

   def predictProbabilities(vectors: RDD[Vector]) = {
     val sc = vectors.sparkContext
     val bLabels = sc.broadcast(labels.map(_.swap))
     model.predictProbabilities(vectors).map { vector =>
       bLabels.value
         .toSeq
         .sortBy(_._1)
         .map(_._2)
         .zip(vector.toArray)
         .toMap
     }
   }

   def save(sc: SparkContext, outputDir: String) = {
     model.save(sc, s"$outputDir/model")
     sc.parallelize(labels.toSeq)
       .saveAsObjectFile(s"$outputDir/labels")
   }

}

因为可能需要多个标签来完全描述一篇文章的内容,所以我们将使用predictProbabilities函数来预测概率分布。我们使用保存在模型旁边的标签字典将我们的标签标识符(作为Double)转换为原始类别(作为String)。最后,我们可以将我们的模型和标签字典保存到 HDFS,仅供审计目的。

提示

所有 MLlib 模型都支持保存和加载功能。数据将以ObjectFile的形式持久化在 HDFS 中,并且可以轻松地检索和反序列化。使用 ML 库,对象被保存为 parquet 格式。然而,需要保存额外的信息;例如在我们的例子中,用于训练该模型的标签字典。

线程安全

我们的分类器是一个单例对象,根据单例模式,应该是线程安全的。这意味着并行线程不应该使用相同的状态进行修改,例如使用 setter 方法。在我们当前的架构中,只有 Twitter 每 15 分钟训练和更新一个新模型,这些模型将只被 GDELT 服务使用(没有并发更新)。然而,有两件重要的事情需要考虑:

  1. 首先,我们的模型是使用不同的标签进行训练的(在 24 小时时间窗口内找到的标签,每 15 分钟提取一次)。新模型将根据更新的字典进行训练。模型和标签都是紧密耦合的,因此必须同步。在 GDELT 在 Twitter 更新模型时拉取标签的不太可能事件中,我们的预测将是不一致的。我们通过将标签和模型都包装在同一个ClassifierModel case 类中来确保线程安全。

  2. 第二个(虽然不太关键)问题是我们的过程是并行的。这意味着相似的任务将同时从不同的执行器上执行,处理不同的数据块。在某个时间点,我们需要确保每个执行器上的所有模型都是相同版本的,尽管使用略旧的模型预测特定数据块仍然在技术上是有效的(只要模型和标签是同步的)。我们用以下两个例子来说明这个说法。第一个例子无法保证执行器之间模型的一致性:

val model = NaiveBayes.train(points)
vectors.map { vector =>
  model.predict(vector)
 }

第二个例子(Spark 默认使用)将模型广播到所有执行器,从而保证预测阶段的整体一致性:

val model = NaiveBayes.train(points)
val bcModel = sc.broadcast(model)
vectors mapPartitions { it =>
  val model = bcModel.value
  it.map { vector =>
    model.predict(vector)
  }
}

在我们的分类器单例对象中,我们将我们的模型定义为一个全局变量(因为它可能还不存在),在每次调用train方法后将更新该模型:

var model = None: Option[ClassifierModel]

def train(rdd: RDD[(String, Array[String])]): ClassifierModel = {
  val labeledPoints = buildLabeledPoints(rdd)
  val labels = getLabels(rdd)
  labeledPoints.cache()
  val nbModel = NaiveBayes.train(labeledPoints)
  labeledPoints.unpersist(blocking = false)
  val cModel = ClassifierModel(nbModel, labels)
  model = Some(cModel)
  cModel
}

回到我们的 Twitter 流,对于每个 RDD,我们构建我们的训练集(在我们的分类器中抽象出来),训练一个新模型,然后将其保存到 HDFS:

def trainAndSave(trainingSet: RDD[(String, Array[String])],  modelOutputDir: String) = {
  Classifier
     .train(trainingSet)
     .save(batch.sparkContext, modelOutputDir)
}

预测 GDELT 数据

使用分类器单例对象,我们可以访问 Twitter 处理器发布的最新模型。对于每个 RDD,对于每篇文章,我们只需预测描述每篇文章文本内容的标签概率分布:

gdeltContent.foreachRDD { batch =>

  val textRdd = batch.map(_.body.get)
  val predictions = Classifier.predictProbabilities(textRdd)

  batch.zip(predictions).map { case (content, dist) =>
    val hashTags = dist.filter { case (hashTag, proba) =>
      proba > 0.25d
    }
    .toSeq
    .map(_._1)
    (content, hashTags)
  }
  .map { case (content, hashTags) =>
    val sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
    Map(
      "time"  -> sdf.format(new Date()),
      "body"  -> content.body.get,
      "url"   -> content.url,
      "tags"  -> hashTags,
      "title" -> content.title
    )
  }
  .saveToEs("gzet/gdelt")

}

我们只保留高于 25%的概率,并将每篇文章与其预测的标签一起发布到我们的 Elasticsearch 集群。发布结果正式标志着我们分类应用的结束。我们在这里报告完整的架构:

预测 GDELT 数据

图 11:标记新闻文章的创新方式

我们的 Twitter 机械土耳其

分类算法的准确性应该根据测试数据集来衡量,即在训练阶段未包含的标记数据集。我们无法访问这样的数据集(这是我们最初引导模型的原因),因此我们无法比较原始与预测的类别。我们可以通过可视化我们的结果来估计整体置信水平,而不是真实的准确性。有了我们在 Elasticsearch 上的所有数据,我们构建了一个 Kibana 仪表板,并增加了一个用于标签云可视化的插件(github.com/stormpython/tagcloud)。

下图显示了在 2016 年 5 月 1 日分析和预测的 GDELT 文章数量。在不到 24 小时内下载了大约 18000 篇文章(每 15 分钟一个批次)。在每个批次中,我们观察到不超过 100 个不同的预测标签;这是幸运的,因为我们只保留了在 24 小时时间窗口内出现的前 100 个热门标签。此外,它给了我们一些关于 GDELT 和 Twitter 遵循相对正常分布的线索(批次不会围绕特定类别偏斜)。

我们的 Twitter 机械土耳其

图 12:预测文章于 5 月 1 日

除了这 18000 篇文章,我们还提取了大约 700 条 Twitter 文本内容,标记了我们 100 个热门标签,平均每个主题被七篇文章覆盖。尽管这个训练集已经是本书内容的一个良好开端,但我们可能可以通过在内容方面放宽限制或将类似的标签分组成更广泛的类别来扩展它。我们还可以增加 Elasticsearch 上的 TTL 值。增加观察数量同时限制 Twitter 噪音肯定会提高整体模型的准确性。

观察到在特定时间窗口内最流行的标签是[#mayday]和[#trump]。我们还观察到至少与[#maga]一样多的[#nevertrump],因此满足了美国两个政党的要求。这将在第十一章中使用美国选举数据进行确认,情感分析异常检测

最后,我们选择一个特定的标签并检索其所有相关关键词。这很重要,因为它基本上验证了我们分类算法的一致性。我们希望对于来自 Twitter 的每个标签,来自 GDELT 的重要术语足够一致,并且应该都与相同的标签含义相关。我们关注[#trump]标签,并在下图中访问特朗普云:

我们的 Twitter 机械土耳其

图 13:#特朗普云

我们观察到大多数重要术语(每篇文章预测为[#trump])都与总统竞选、美国、初选等有关。它还包含了参加总统竞选的候选人的名字(希拉里·克林顿和特德·克鲁兹)。尽管我们仍然发现一些与唐纳德·特朗普无关的文章和关键词,但这验证了我们算法的一定一致性。对于许多记录(超过 30%),结果甚至超出了我们最初的期望。

总结

尽管我们对许多整体模型的一致性印象深刻,但我们意识到我们肯定没有构建最准确的分类系统。将这项任务交给数百万用户是一项雄心勃勃的任务,绝对不是获得明确定义的类别的最简单方式。然而,这个简单的概念验证向我们展示了一些重要的东西:

  1. 它在技术上验证了我们的 Spark Streaming 架构。

  2. 它验证了我们使用外部数据集引导 GDELT 的假设。

  3. 它让我们变得懒惰、不耐烦和骄傲。

  4. 它在没有任何监督的情况下学习,并且在每个批次中最终变得更好。

没有数据科学家可以在短短几周内构建一个完全功能且高度准确的分类系统,尤其是在动态数据上;一个合适的分类器需要至少在最初的几个月内进行评估、训练、重新评估、调整和重新训练,然后至少每半年进行一次重新评估。我们的目标是描述实时机器学习应用中涉及的组件,并帮助数据科学家锐化他们的创造力(跳出常规思维是现代数据科学家的首要美德)。

在下一章中,我们将专注于文章变异和故事去重;一个话题随着时间的推移有多大可能会发展,一个人群(或社区)有多大可能会随时间变异?通过将文章去重为故事,故事去重为史诗,我们能否根据先前的观察来预测可能的结果?

第十章:故事去重和变异

全球网络有多大?虽然几乎不可能知道确切的大小 - 更不用说深网和暗网了 - 但据估计,2008 年它的页面数量超过了一万亿,那在数据时代,有点像中世纪。将近十年后,可以肯定地假设互联网的集体大脑比我们实际的灰质在我们的耳朵之间更多。但在这万亿以上的 URL 中,有多少网页是真正相同的,相似的,或者涵盖相同的主题?

在本章中,我们将对 GDELT 数据库进行去重和索引,然后,我们将随时间跟踪故事,并了解它们之间的联系,它们可能如何变异,以及它们是否可能导致不久的将来发生任何后续事件。

我们将涵盖以下主题:

  • 了解Simhash的概念以检测近似重复

  • 构建在线去重 API

  • 使用 TF-IDF 构建向量,并使用随机索引减少维度

  • 使用流式 KMeans 实时构建故事连接

检测近似重复

虽然本章是关于将文章分组成故事,但这一节是关于检测近似重复。在深入研究去重算法之前,值得介绍一下在新闻文章的背景下故事和去重的概念。给定两篇不同的文章 - 通过不同的 URL 我们指的是两个不同的 URL - 我们可能会观察到以下情况:

  • 文章 1 的 URL 实际上重定向到文章 2,或者是文章 2 中提供的 URL 的扩展(例如一些额外的 URL 参数,或者缩短的 URL)。尽管它们的 URL 不同,但具有相同内容的两篇文章被视为真正的重复

  • 文章 1 和文章 2 都涵盖了完全相同的事件,但可能由两个不同的出版商撰写。它们有很多共同的内容,但并不真正相似。根据下文解释的某些规则,它们可能被视为近似重复

  • 文章 1 和文章 2 都涵盖了相同类型的事件。我们观察到风格上的主要差异或相同主题的不同风味。它们可以被归为一个共同的故事

  • 文章 1 和文章 2 涵盖了两个不同的事件。两篇内容是不同的,不应该被归为同一个故事,也不应该被视为近似重复。

Facebook 用户一定注意到了相关文章功能。当你喜欢一篇新闻文章 - 点击一篇文章的链接或播放一篇文章的视频时,Facebook 认为这个链接很有趣,并更新其时间线(或者称之为)以显示更多看起来相似的内容。在图 1中,我真的很惊讶地看到三星 Galaxy Note 7 智能手机冒烟或着火,因此被大部分美国航班禁止。Facebook 自动为我推荐了这个三星惨案周围的类似文章。可能发生的事情是,通过打开这个链接,我可能已经查询了 Facebook 内部 API,并要求相似的内容。这就是实时查找近似重复的概念,这也是我们将在第一节中尝试构建的内容。

检测近似重复

图 1:Facebook 推荐相关文章

哈希处理的第一步

查找真正的重复很容易。如果两篇文章的内容相同,它们将被视为相同。但是,我们可以比较它们的哈希值,而不是比较字符串(可能很大,因此不高效);就像比较手写签名一样;具有相同签名的两篇文章应被视为相同。如下所示,一个简单的groupBy函数将从字符串数组中检测出真正的重复:

Array("Hello Spark", "Hello Hadoop", "Hello Spark")
  .groupBy(a => Integer.toBinaryString(a.hashCode))
  .foreach(println)

11001100010111100111000111001111 List(Hello Spark, Hello Spark)
10101011110110000110101101110011 List(Hello Hadoop)

但即使是最复杂的哈希函数也会导致一些碰撞。Java 内置的hashCode函数将字符串编码为 32 位整数,这意味着理论上,我们只有 2³²种可能性可以得到相同哈希值的不同单词。实际上,碰撞应该始终小心处理,因为根据生日悖论,它们会比 2³²的值更频繁地出现。为了证明我们的观点,以下示例认为四个不同的字符串是相同的:

Array("AaAa", "BBBB", "AaBB", "BBAa")
  .groupBy(a => Integer.toBinaryString(a.hashCode))
  .foreach(Sprintln)

11111000000001000000 List(AaAa, BBBB, AaBB, BBAa)

此外,有些文章有时可能只是在很小的文本部分上有所不同,例如广告片段、额外的页脚或 HTML 代码中的额外位,这使得哈希签名与几乎相同的内容不同。事实上,即使一个单词有一个小的拼写错误,也会导致完全不同的哈希值,使得两篇近似重复的文章被认为是完全不同的。

Array("Hello, Spark", "Hello Spark")
  .groupBy(a => Integer.toBinaryString(a.hashCode))
  .foreach(println)

11100001101000010101000011010111  List(Hello, Spark)
11001100010111100111000111001111  List(Hello Spark)

尽管字符串Hello SparkHello, Spark非常接近(它们只相差一个字符),它们的哈希值相差 16 位(32 位中的 16 位)。幸运的是,互联网的长者们可能已经找到了使用哈希值来检测近似重复内容的解决方案。

站在互联网巨头的肩膀上

不用说,谷歌在索引网页方面做得相当不错。拥有超过一万亿个不同的 URL,检测重复内容是索引网页内容时的关键。毫无疑问,互联网巨头们多年来一定已经开发出了解决这个规模问题的技术,从而限制了索引整个互联网所需的计算资源。这里描述的其中一种技术称为Simhash,它非常简单、整洁,但效率很高,如果你真的想要精通数据科学的 Spark,那么了解它是值得的。

注意

关于Simhash的更多信息可以在www.wwwconference.org/www2007/papers/paper215.pdf找到。

Simhashing

Simhash的主要思想不是一次计算一个单一的哈希值,而是查看文章的内容并计算多个单独的哈希值。对于每个单词,每对单词,甚至每个两个字符的 shingle,我们都可以使用前面描述的简单的 Java 内置hashCode函数轻松计算哈希值。在下面的图 2中,我们报告了字符串hello simhash中包含的两个字符集的所有 32 位哈希值(省略了前 20 个零值):

Simhashing

图 2:构建 hello simhash shingles

接下来报告了一个简单的 Scala 实现:

def shingles(content: String) = {
  content.replaceAll("\\s+", "")
    .sliding(2)
    .map(s => s.mkString(""))
    .map(s => (s, s.hashCode)) 
}

implicit class BitOperations(i1: Int) {
  def toHashString: String = {
    String.format(
      "%32s",
      Integer.toBinaryString(i1)
    ).replace(" ", "0")
  }
}

shingles("spark").foreach { case (shingle, hash) =>
  println("[" + shingle + "]\t" + hash.toHashString)
}

[sp]  00000000000000000000111001011101
[pa]  00000000000000000000110111110001
[ar]  00000000000000000000110000110001
[rk]  00000000000000000000111000111001

计算了所有这些哈希值后,我们将一个Simhash对象初始化为零整数。对于 32 位整数中的每个位,我们计算具有该特定位设置为 1 的哈希值的数量,并减去具有该列表中具有该特定位未设置的值的数量。这给我们提供了图 3中报告的数组。最后,任何大于 0 的值都将设置为 1,任何小于或等于 0 的值都将保留为 0。这里唯一棘手的部分是进行位移操作,但算法本身相当简单。请注意,我们在这里使用递归来避免使用可变变量(使用var)或列表。

Simhashing

图 3:构建 hello simhash

implicit class BitOperations(i1: Int) {

  // ../.. 

  def isBitSet(bit: Int): Boolean = {
    ((i1 >> bit) & 1) == 1
  }
}

implicit class Simhash(content: String) {

  def simhash = {
    val aggHash = shingles(content).flatMap{ hash =>
      Range(0, 32).map { bit =>
        (bit, if (hash.isBitSet(bit)) 1 else -1)
      }
    }
    .groupBy(_._1)
    .mapValues(_.map(_._2).sum > 0)
    .toArray

    buildSimhash(0, aggHash)
  }

 private def buildSimhash(
      simhash: Int,
      aggBit: Array[(Int, Boolean)]
     ): Int = {

    if(aggBit.isEmpty) return simhash
    val (bit, isSet) = aggBit.head
    val newSimhash = if(isSet) {
      simhash | (1 << bit)
    } else {
      simhash
    }
    buildSimhash(newSimhash, aggBit.tail)

  }
}

val s = "mastering spark for data science"
println(toHashString(s.simhash))

00000000000000000000110000110001

汉明重量

很容易理解,两篇文章共有的单词越多,它们的 Simhash 中都会有一个相同的位b设置为 1。但 Simhash 的美妙之处在于聚合步骤。我们语料库中的许多其他单词(因此其他哈希)可能没有设置这个特定的位b,因此当观察到一些不同的哈希时,这个值也会减少。共享一组共同的单词是不够的,相似的文章还必须共享相同的词频。以下示例显示了为字符串hello simhashhello minhashhello world计算的三个 Simhash 值。

汉明重量

图 4:比较 hello simhash

hello simhashhello world之间的差异为 3 位时,hello simhashhello minhash之间的差异只有1。实际上,我们可以将它们之间的距离表示为它们的异或(XOR)积的汉明重量。汉明重量是我们需要改变的位数,以将给定数字转换为零元素。因此,两个数字的XOR操作的汉明重量是这两个元素之间不同的位数,这种情况下是1

汉明重量

图 5:hello simhash 的汉明重量

我们简单地使用 Java 的bitCount函数,该函数返回指定整数值的二进制补码表示中的一位数。

implicit class BitOperations(i1: Int) {

  // ../..

  def distance(i2: Int) = {
    Integer.bitCount(i1 ^ i2) 
  }
}

val s1 = "hello simhash"
val s2 = "hello minhash"
val dist = s1.simhash.distance(s2.simhash)

我们已经成功构建了 Simhash 并进行了一些简单的成对比较。下一步是扩展规模并开始从 GDELT 数据库中检测实际的重复项。

在 GDELT 中检测近似重复项

我们在第二章中深入讨论了数据获取过程,数据采集。对于这个用例,我们将使用图 6 中的 NiFi 流,该流监听 GDELT 主 URL,获取并解压最新的 GKG 存档,并以压缩格式将此文件存储在 HDFS 中。

在 GDELT 中检测近似重复项

图 6:下载 GKG 数据

我们首先使用我们之前创建的一组解析器(在我们的 GitHub 存储库中可用)解析我们的 GKG 记录,提取所有不同的 URL 并使用第六章中介绍的 Goose 提取器获取 HTML 内容,抓取基于链接的外部数据

val gdeltInputDir = args.head
val gkgRDD = sc.textFile(gdeltInputDir)
  .map(GKGParser.toJsonGKGV2)
  .map(GKGParser.toCaseClass2)

val urlRDD = gkgRDD.map(g => g.documentId.getOrElse("NA"))
  .filter(url => Try(new URL(url)).isSuccess)
  .distinct()
  .repartition(partitions)

val contentRDD = urlRDD mapPartitions { it =>
  val html = new HtmlFetcher()
  it map html.fetch
}

因为hashcode函数是区分大小写的(Sparkspark会产生完全不同的哈希值),强烈建议在simhash函数之前清理文本。与第九章中描述的类似,我们首先使用以下 Lucene 分析器来词干化单词:

<dependency>
  <groupId>org.apache.lucene</groupId>
  <artifactId>lucene-analyzers-common</artifactId>
  <version>4.10.1</version>
</dependency>

正如您可能早些时候注意到的,我们在一个隐式类中编写了我们的 Simhash 算法;我们可以使用以下导入语句直接在字符串上应用我们的simhash函数。在开发的早期阶段付出额外的努力总是值得的。

import io.gzet.story.simhash.SimhashUtils._
val simhashRDD = corpusRDD.mapValues(_.simhash)

现在我们有了一个内容的 RDD(Content是一个包装文章 URL、标题和正文的案例类),以及它的 Simhash 值和一个稍后可能使用的唯一标识符。让我们首先尝试验证我们的算法并找到我们的第一个重复项。从现在开始,我们只考虑在它们的 32 位 Simhash 值中最多有 2 位差异的文章作为重复项。

hamming match {
  case 0 => // identical articles - true-duplicate
  case 1 => // near-duplicate (mainly typo errors)
  case 2 => // near-duplicate (minor difference in style)
  case _ => // different articles
}

但这里出现了一个可伸缩性挑战:我们肯定不想执行笛卡尔积来比较 Simhash RDD 中的成对文章。相反,我们希望利用 MapReduce 范式(使用groupByKey函数)并且只对重复的文章进行分组。我们的方法遵循扩展和征服模式,首先扩展我们的初始数据集,利用 Spark shuffle,然后在执行器级别解决我们的问题。因为我们只需要处理 1 位差异(然后我们将对 2 位应用相同的逻辑),所以我们的策略是扩展我们的 RDD,以便对于每个 Simhashs,我们使用相同的 1 位掩码输出所有其他 31 个 1 位组合。

def oneBitMasks: Set[Int] = {
  (0 to 31).map(offset => 1 << offset).toSet
}

00000000000000000000000000000001
00000000000000000000000000000010
00000000000000000000000000000100
00000000000000000000000000001000
...

对于 Simhash 值s,我们使用每个前置掩码和 Simhash 值s之间的 XOR 输出可能的 1 位组合。

val s = 23423
oneBitMasks foreach { mask =>
  println((mask ^ s).toHashString)
}

00000000000000000101101101111111
00000000000000000101101101111110
00000000000000000101101101111101
00000000000000000101101101111011
...

处理 2 位并没有太大的不同,尽管在可伸缩性方面更加激进(现在有 496 种可能的组合要输出,意味着 32 位中的任意 2 位组合)。

def twoBitsMasks: Set[Int] = {
  val masks = oneBitMasks
  masks flatMap { e1 =>
    masks.filter( e2 => e1 != e2) map { e2 =>
      e1 | e2
    }
  }
}

00000000000000000000000000000011
00000000000000000000000000000101
00000000000000000000000000000110
00000000000000000000000000001001
...

最后,我们构建我们的掩码集以应用(请注意,我们还希望通过应用 0 位差异掩码输出原始 Simhash)以检测重复,如下所示:

val searchmasks = twoBitsMasks ++ oneBitMasks ++ Set(0) 

这也帮助我们相应地扩展我们最初的 RDD。这肯定是一个昂贵的操作,因为它通过一个常数因子增加了我们的 RDD 的大小(496 + 32 + 1 种可能的组合),但在时间复杂度方面保持线性,而笛卡尔积连接是一个二次操作 - O(n²).

val duplicateTupleRDD = simhashRDD.flatMap {
  case ((id, _), simhash) =>
    searchmasks.map { mask =>
      (simhash ^ mask, id)
    }
}
.groupByKey()

我们发现文章 A 是文章 B 的副本,文章 B 是文章 C 的副本。这是一个简单的图问题,可以通过使用连接组件算法轻松解决GraphX

val edgeRDD = duplicateTupleRDD
  .values
  .flatMap { it =>
    val list = it.toList
    for (x <- list; y <- list) yield (x, y)
  }
  .filter { case (x, y) =>
    x != y
  }
  .distinct()
  .map {case (x, y) =>
    Edge(x, y, 0)
  }

val duplicateRDD = Graph.fromEdges(edgeRDD, 0L)
  .connectedComponents()
  .vertices
  .join(simhashRDD.keys)
  .values

在用于该测试的 15,000 篇文章中,我们提取了大约 3,000 个不同的故事。我们在图 7中报告了一个例子,其中包括我们能够检测到的两篇近似重复的文章,它们都非常相似,但并非完全相同。

在 GDELT 中检测近似重复在 GDELT 中检测近似重复

图 7:GDELT 数据库中的 Galaxy Note 7 惨败

对 GDELT 数据库进行索引

下一步是开始构建我们的在线 API,以便任何用户都可以像 Facebook 在用户时间线上那样实时检测近似重复的事件。我们在这里使用Play Framework,但我们会简要描述,因为这已经在第八章构建推荐系统中涵盖过。

持久化我们的 RDD

首先,我们需要从我们的 RDD 中提取数据并将其持久化到可靠、可扩展且高效的位置以供按键搜索。由于该数据库的主要目的是在给定特定键(键为 Simhash)的情况下检索文章,Cassandra(如下所示的 maven 依赖)似乎是这项工作的不错选择。

<dependency>
  <groupId>com.datastax.spark</groupId>
  <artifactId>spark-cassandra-connector_2.11</artifactId>
</dependency>

我们的数据模型相当简单,由一个简单的表组成:

CREATE TABLE gzet.articles (
  simhash int PRIMARY KEY,
  url text,
  title text,
  body text
);

将我们的 RDD 存储到 Cassandra 的最简单方法是将我们的结果包装在一个与我们之前表定义匹配的案例类对象中,并调用saveToCassandra函数:

import com.datastax.spark.connector._

corpusRDD.map { case (content, simhash) =>
  Article(
    simhash,
    content.body,
    content.title,
    content.url
  )
}
.saveToCassandra(cassandraKeyspace, cassandraTable)

构建 REST API

下一步是着手处理 API 本身。我们创建一个新的 maven 模块(打包为play2)并导入以下依赖项:

<packaging>play2</packaging>

<dependencies>
  <dependency>
    <groupId>com.typesafe.play</groupId>
    <artifactId>play_2.11</artifactId>
  </dependency>
  <dependency>
    <groupId>com.datastax.cassandra</groupId>
    <artifactId>cassandra-driver-core</artifactId>
  </dependency>
</dependencies>

首先,我们创建一个新的数据访问层,它可以根据输入的 Simhash 构建我们之前讨论过的所有可能的 1 位和 2 位掩码的列表,并从 Cassandra 中提取所有匹配的记录:

class CassandraDao() {

  private val session = Cluster.builder()
                               .addContactPoint(cassandraHost)
                               .withPort(cassandraPort)
                               .build()
                               .connect()

  def findDuplicates(hash: Int): List[Article] = {
    searchmasks.map { mask =>
      val searchHash = mask ^ hash
      val stmt = s"SELECT simhash, url, title, body FROM gzet.articles WHERE simhash = $searchHash;"
      val results = session.execute(stmt).all()
      results.map { row =>
        Article(
           row.getInt("simhash"),
           row.getString("body"),
           row.getString("title"),
           row.getString("url")
        )
      }
      .head
    }
    .toList
  }
}

在我们的控制器中,给定一个输入 URL,我们提取 HTML 内容,对文本进行标记化,构建 Simhash 值,并调用我们的服务层,最终以 JSON 格式返回我们的匹配记录。

object Simhash extends Controller {

  val dao = new CassandraDao()
  val goose = new HtmlFetcher()

  def detect = Action { implicit request =>
    val url = request.getQueryString("url").getOrElse("NA")
    val article = goose.fetch(url)
    val hash = Tokenizer.lucene(article.body).simhash
    val related = dao.findDuplicates(hash)
    Ok(
        Json.toJson(
          Duplicate(
            hash,
            article.body,
            article.title,
            url,
            related
          )
       )
    )
  }
}

以下play2路由将重定向任何 GET 请求到我们之前看到的detect方法:

GET /simhash io.gzet.story.web.controllers.Simhash.detect 

最后,我们的 API 可以如下启动并向最终用户公开:

curl -XGET 'localhost:9000/simhash?url= http://www.detroitnews.com/story/tech/2016/10/12/samsung-damage/91948802/'

{
  "simhash": 1822083259,
  "body": "Seoul, South Korea - The fiasco of Samsung's [...]
  "title": "Fiasco leaves Samsung's smartphone brand [...]",
  "url": "http://www.detroitnews.com/story/tech/2016/[...]",
  "related": [
    {
      "hash": 1821919419,
      "body": "SEOUL, South Korea - The fiasco of [...]
      "title": "Note 7 fiasco leaves Samsung's [...]",
      "url": "http://www.chron.com/business/technology/[...]"
    },
    {
      "hash": -325433157,
      "body": "The fiasco of Samsung's fire-prone [...]
      "title": "Samsung's Smartphone Brand [...]",
      "url": "http://www.toptechnews.com/[...]"
    }
  ]
}

恭喜!您现在已经构建了一个在线 API,可以用于检测近似重复,比如 Galaxy Note 7 惨败周围的事件;但我们的 API 与 Facebook 的 API 相比有多准确?这肯定足够准确,可以开始通过将高度相似的事件分组成故事来去噪 GDELT 数据。

改进领域

尽管我们已经对 API 返回的结果总体质量感到满意,但在这里我们讨论了新闻文章的一个重大改进。事实上,文章不仅由不同的词袋组成,而且遵循一个清晰的结构,其中顺序确实很重要。事实上,标题总是一个噱头,主要内容仅在前几行内完全涵盖。文章的其余部分也很重要,但可能不像介绍那样重要。鉴于这一假设,我们可以稍微修改我们的 Simhash 算法,通过为每个单词分配不同的权重来考虑顺序。

implicit class Simhash(content: String) {

  // ../..

  def weightedSimhash = {

    val features = shingles(content)
    val totalWords = features.length
    val aggHashWeight = features.zipWithIndex
      .map {case (hash, id) =>
        (hash, 1.0 - id / totalWords.toDouble)
      }
      .flatMap { case (hash, weight) =>
        Range(0, 32).map { bit =>
          (bit, if(hash.isBitSet(bit)) weight else -weight)
        }
      }
      .groupBy(_._1)
      .mapValues(_.map(_._2).sum > 0)
      .toArray

    buildSimhash(0, aggHashWeight)
  }

}

与其在设置相同的位值时每次添加 1 或-1,不如根据单词在文章中的位置添加相应的权重。相似的文章将共享相同的单词、相同的词频,但也具有相似的结构。换句话说,在文本的前几行发生的任何差异,我们要比在每篇文章的最后一行发生的差异更不容忍。

构建故事

Simhash应该只用于检测近似重复的文章。将我们的搜索扩展到 3 位或 4 位的差异将变得非常低效(3 位差异需要 5,488 个不同的查询到 Cassandra,而需要 41,448 个查询来检测高达 4 位的差异),并且似乎会带来比相关文章更多的噪音。如果用户想要构建更大的故事,那么必须应用典型的聚类技术。

构建词频向量

我们将开始使用 KMeans 算法将事件分组成故事,以文章的词频作为输入向量。TF-IDF 简单、高效,是一种构建文本内容向量的成熟技术。基本思想是计算一个词频,然后使用数据集中的逆文档频率进行归一化,从而减少常见词(如停用词)的权重,同时增加特定于文档定义的词的权重。它的实现是 MapReduce 处理的基础之一,Wordcount算法。我们首先计算每个文档中每个单词的词频的 RDD。

val tfRDD = documentRDD.flatMap { case (docId, body) =>
  body.split("\\s").map { word =>
    ((docId, word), 1)
  }
}
.reduceByKey(_+_)
.map { case ((docId, word), tf) =>
  (docId, (word, tf))
}

IDF 是文档总数除以包含字母w的文档数的对数值:

构建词频向量

val n = sc.broadcast(documentRDD.count())
val dfMap = sc.broadcast(
  tfRDD.map { case (docId, (word, _)) =>
    (docId, word)
  }
  .distinct()
  .values
  .map { word =>
    (word, 1)
  }
  .reduceByKey(_+_)
  .collectAsMap()
)

val tfIdfRDD = tfRDD.mapValues { case (word, tf) =>
  val df = dfMap.value.get(word).get
  val idf = math.log((n.value + 1) / (df + 1))
  (word, tf * idf)
}

由于我们的输出向量由单词组成,我们需要为语料库中的每个单词分配一个序列 ID。我们可能有两种解决方案。要么我们建立字典并为每个单词分配一个 ID,要么使用哈希函数将不同的单词分组到相同的桶中。前者是理想的,但会导致向量长度约为一百万个特征(与我们拥有的唯一单词数量一样多的特征),而后者要小得多(与用户指定的特征数量一样多),但可能会由于哈希碰撞而导致不良影响(特征越少,碰撞越多)。

val numFeatures = 256

val vectorRDD = tfIdfRDD.mapValues { case (word, tfIdf) =>
  val rawMod = word.hashCode % numFeatures
  rawMod + (if (rawMod < 0) numFeatures else 0)
  (word.hashCode / numFeatures, tfIdf)
}
.groupByKey()
.values
.map { it =>
  Vectors.sparse(numFeatures, it.toSeq)
}

尽管我们详细描述了 TF-IDF 技术,但这种散列 TF 只需要几行代码就可以完成,这要归功于 MLlib 实用程序,接下来我们将看到。我们构建了一个包含 256 个大向量的 RDD,(从技术上讲)可以用于 KMeans 聚类,但由于我们刚刚解释的哈希属性,我们将受到严重的哈希碰撞的影响。

val tfModel = new HashingTF(1 << 20)
val tfRDD = documentRDD.values.map { body =>
  tfModel.transform(body.split("\\s"))
}

val idfModel = new IDF().fit(tfRDD)
val tfIdfRDD = idfModel.transform(tfRDD)
val normalizer = new Normalizer()
val sparseVectorRDD = tfIdfRDD map normalizer.transform

维度诅咒,数据科学的灾难

将我们的特征大小从 256 增加到 2²⁰将大大限制碰撞的数量,但代价是我们的数据点现在嵌入在一个高度维度的空间中。

在这里,我们描述了一种聪明的方法来克服维度诅咒(www.stat.ucla.edu/~sabatti/statarray/textr/node5.html),而不必深入研究围绕矩阵计算的模糊数学理论(如奇异值分解),也不需要进行计算密集型的操作。这种方法被称为随机索引,类似于之前描述的Simhash概念。

注意

有关随机索引的更多信息可以在eprints.sics.se/221/1/RI_intro.pdf找到。

这个想法是生成每个不同特征(这里是一个单词)的稀疏、随机生成和唯一表示,由+1、-1 和主要是 0 组成。然后,每当我们在一个上下文(一个文档)中遇到一个单词时,我们将这个单词的签名添加到上下文向量中。然后,文档向量是其每个单词向量的总和,如下的图 8(或我们的情况下每个 TF-IDF 向量的总和)所示:

维度诅咒,数据科学的灾难

图 8:构建随机索引向量

我们邀请我们纯粹的数学极客读者深入研究Johnson-Lindenstrauss引理(ttic.uchicago.edu/~gregory/courses/LargeScaleLearning/lectures/jl.pdf),该引理基本上陈述了"如果我们将向量空间中的点投影到足够高维度的随机选择的子空间中,点之间的距离将被近似保留"。尽管Random Indexing技术本身可以实现(需要相当大的努力),Johnson-Lindenstrauss引理非常有用,但要理解起来要困难得多。幸运的是,Derrick Burns的优秀 spark-package generalized-kmeans-clusteringgithub.com/derrickburns/generalized-kmeans-clustering)中包含了该实现。

val embedding = Embedding(Embedding.MEDIUM_DIMENSIONAL_RI)
val denseVectorRDD = sparseVectorRDD map embedding.embed
denseVectorRDD.cache()

我们最终能够将我们的 2²⁰大向量投影到256 维。这项技术至少提供了巨大的好处。

  • 我们有固定数量的特征。如果将来遇到不在我们初始字典中的新单词,我们的向量大小将永远不会增长。这在流式上下文中将特别有用。

  • 我们的输入特征集非常大(2²⁰)。尽管仍会发生碰撞,但风险已经减轻。

  • 由于Johnson-Lindenstrauss引理,距离得以保留。

  • 我们的输出向量相对较小(256)。我们克服了维度诅咒。

由于我们将向量 RDD 缓存在内存中,现在我们可以看看 KMeans 聚类本身。

KMeans 的优化

我们假设我们的读者已经熟悉了 KMeans 聚类,因为这个算法可能是最著名和被广泛使用的无监督聚类算法。在这里再尝试解释将不如你能在超过半个世纪的积极研究后找到的许多资源那么好。

我们先前根据文章内容(TF-IDF)创建了我们的向量。下一步是根据它们的相似性将文章分组成故事。在 Spark 实现的 KMeans 中,只支持欧氏距离度量。有人会认为余弦距离更适合文本分析,但我们假设前者足够准确,因为我们不想重新打包 MLlib 分发以进行该练习。有关在文本分析中使用余弦距离的更多解释,请参阅www.cse.msu.edu/~pramanik/research/papers/2003Papers/sac04.pdf。我们在以下代码中报告了可以应用于任何双精度数组(密集向量背后的逻辑数据结构)的欧氏和余弦函数:

def euclidean(xs: Array[Double], ys: Array[Double]) = {
  require(xs.length == ys.length)
  math.sqrt((xs zip ys)
    .map { case (x, y) =>
      math.pow(y - x, 2)
    }
    .sum
  )
}

def cosine(xs: Array[Double], ys: Array[Double]) = {

  require(xs.length == ys.length)
  val magX = math.sqrt(xs.map(i => i * i).sum)
  val magY = math.sqrt(ys.map(i => i * i).sum)
  val dotP = (xs zip ys).map { case (x, y) =>
    x * y
  }.sum

  dotP / (magX * magY)
}

使用 MLlib 包训练新的 KMeans 聚类非常简单。我们指定一个阈值为 0.01,之后我们认为我们的聚类中心已经收敛,并将最大迭代次数设置为 1,000。

val model: KMeansModel = new KMeans()
  .setEpsilon(0.01)
  .setK(numberOfClusters)
  .setMaxIterations(1000)
  .run(denseVectorRDD)

但在我们特定的用例中,正确的聚类数是多少?在每 15 分钟批处理中有 500 到 1,000 篇不同的文章,我们可以构建多少个故事?正确的问题是,我们认为在 15 分钟批处理窗口内发生了多少个真实事件?实际上,为新闻文章优化 KMeans 与任何其他用例并无不同;这是通过优化其相关成本来实现的,成本是点到它们各自质心的平方距离的总和SSE)。

val wsse = model.computeCost(denseVectorRDD) 

k等于文章的数量时,相关成本为 0(每篇文章都是其自己聚类的中心)。同样,当k等于 1 时,成本将达到最大值。因此,k的最佳值是在添加新的聚类不会带来任何成本增益之后的最小可能值,通常在下图中显示的 SSE 曲线的拐点处表示。

使用迄今为止收集的所有 1.5 万篇文章,这里最佳的聚类数量并不明显,但可能大约在 300 左右。

优化 KMeans

图 9:使用成本函数的拐点法

一个经验法则是将k作为n(文章数量)的函数。有超过 1.5 万篇文章,遵循这个规则将返回k 优化 KMeans 100。

优化 KMeans

我们使用值为 100,并开始预测每个数据点的聚类。

val clusterTitleRDD = articleRDD
  .zip(denseVectorRDD)
  .map { case ((id, article), vector) =>
    (model.predict(vector), article.title)
  }

尽管这可能得到很大的改进,我们确认许多相似的文章被分在了同一个故事中。我们报告了一些属于同一聚类的与三星相关的文章:

  • 三星可以从泰诺、玩具和捷蓝学到什么...

  • 华为 Mate 9 似乎是三星 Galaxy Note 7 的复制品...

  • 鉴于 Note 7 的惨败,三星可能会...*

  • 三星股价的螺旋式下跌吸引了投资者的赌注...

  • Note 7 惨败让三星智能手机品牌...

  • Note 7 惨败让三星智能手机品牌受到打击...

  • Note 7 惨败让三星智能手机品牌蒙上疑问的阴影...

  • Note 7 惨败让三星智能手机品牌蒙上疑问的阴影...

  • Note 7 惨败让三星智能手机品牌受到打击...

  • 惨败让三星智能手机品牌蒙上疑问的阴影...

可以肯定的是,这些相似的文章不符合 Simhash 查找的条件,因为它们的差异超过了 1 位或 2 位。聚类技术可以用来将相似(但不重复)的文章分成更广泛的故事。值得一提的是,优化 KMeans 是一项繁琐的任务,需要多次迭代和彻底分析。然而,在这里,这并不是范围的一部分,因为我们将专注于实时的更大的聚类和更小的数据集。

故事变异

现在我们有足够的材料来进入主题的核心。我们能够检测到近似重复的事件,并将相似的文章分组到一个故事中。在本节中,我们将实时工作(在 Spark Streaming 环境中),监听新闻文章,将它们分组成故事,同时也关注这些故事如何随时间变化。我们意识到故事的数量是不确定的,因为我们事先不知道未来几天可能出现什么事件。对于每个批次间隔(GDELT 中的 15 分钟),优化 KMeans 并不理想,也不高效,因此我们决定将这一约束条件不是作为限制因素,而是作为在检测突发新闻文章方面的优势。

平衡状态

如果我们将世界新闻文章分成 10 到 15 个类别,并固定该数量不会随时间改变,那么训练 KMeans 聚类应该能够将相似(但不一定是重复的)文章分成通用的故事。为方便起见,我们给出以下定义:

  • 文章是在时间 T 涵盖特定事件的新闻文章。

  • 故事是一组相似的文章,涵盖了一段时间 T 内的事件

  • 主题是一组相似的故事,涵盖了一段时间内的不同事件 P

  • 史诗是一组相似的故事,涵盖了一段时间内相同的事件 P

我们假设在一段时间内没有任何重大新闻事件之后,任何故事都将被分组到不同的主题中(每个主题涵盖一个或多个主题)。例如,任何关于政治的文章 - 无论政治事件的性质如何 - 都可以被分组到政治桶中。这就是我们所说的平衡状态,在这种状态下,世界被平均分成了 15 个不同而清晰的类别(战争、政治、金融、技术、教育等)。

但是,如果一个重大事件突然发生会发生什么呢?一个事件可能变得如此重要,以至于随着时间的推移(并且由于固定数量的集群),它可能会掩盖最不重要的主题并成为其自己的主题的一部分。类似于 BBC 广播限制在 30 分钟的时间窗口内,一些次要事件,比如惠特斯特布尔的牡蛎节,可能会被跳过,以支持一个重大的国际事件(令牡蛎的粉丝非常沮丧)。这个主题不再是通用的,而是现在与一个特定的事件相关联。我们称这个主题为一个史诗。例如,通用的主题[恐怖主义、战争和暴力]在去年 11 月成为了一个史诗[巴黎袭击],当一个重大的恐怖袭击事件发生时,原本被认为是关于暴力和恐怖主义的广泛讨论变成了一个专门讨论巴黎事件的分支。

现在想象一个史诗不断增长;虽然关于巴黎袭击的第一篇文章是关于事实的,但几个小时后,整个世界都在向恐怖主义表示敬意和谴责。与此同时,法国和比利时警方进行了调查,追踪和解散恐怖主义网络。这两个故事都得到了大量报道,因此成为了同一个史诗的两个不同版本。这种分支的概念在下面的图 10中有所体现:

平衡状态

图 10:故事变异分支的概念

当然,有些史诗会比其他的持续时间更长,但当它们消失时 - 如果它们消失的话 - 它们的分支可能会被回收,以覆盖新的突发新闻(记住固定数量的集群),或者被重新用于将通用故事分组回到它们的通用主题。在某个时间点,我们最终达到了一个新的平衡状态,在这个状态下,世界再次完美地适应了 15 个不同的主题。我们假设,尽管如此,新的平衡状态可能不会是前一个的完美克隆,因为这种干扰可能已经在某种程度上雕刻和重新塑造了世界。作为一个具体的例子,我们现在仍然提到与 9/11 有关的文章;2001 年发生在纽约市的世界贸易中心袭击仍然对[暴力、战争和恐怖主义] 主题的定义产生影响。

随着时间的推移跟踪故事

尽管前面的描述更多是概念性的,可能值得一篇关于应用于地缘政治的数据科学博士论文,但我们想进一步探讨这个想法,并看看流式 KMeans 如何成为这种用例的一个奇妙工具。

构建流应用

第一件事是实时获取我们的数据,因此修改我们现有的 NiFi 流以将我们下载的存档分叉到一个 Spark Streaming 上下文。一个简单的方法是netcat将文件的内容发送到一个打开的套接字,但我们希望这个过程是有弹性和容错的。NiFi 默认带有输出端口的概念,它提供了一个机制来使用Site-To-Site将数据传输到远程实例。在这种情况下,端口就像一个队列,希望在传输过程中不会丢失任何数据。我们通过在nifi.properties文件中分配一个端口号来启用这个功能。

nifi.remote.input.socket.port=8055 

我们在画布上创建了一个名为[Send_To_Spark]的端口,每条记录(因此SplitText处理器)都将被发送到它,就像我们在 Kafka 主题上所做的那样。

构建流应用

图 11:将 GKG 记录发送到 Spark Streaming

提示

尽管我们正在设计一个流应用程序,但建议始终在弹性数据存储(这里是 HDFS)中保留数据的不可变副本。在我们之前的 NiFi 流中,我们没有修改现有的流程,而是将其分叉,以便将记录发送到我们的 Spark Streaming。当/如果我们需要重放数据集的一部分时,这将特别有用。

在 Spark 端,我们需要构建一个 Nifi 接收器。这可以通过以下 maven 依赖项实现:

<dependency>
  <groupId>org.apache.nifi</groupId>
  <artifactId>nifi-spark-receiver</artifactId>
  <version>0.6.1</version>
</dependency>

我们定义 NiFi 端点以及我们之前分配的端口名称[Send_To_Spark]。我们的数据流将被接收为数据包流,可以使用getContent方法轻松转换为字符串。

def readFromNifi(ssc: StreamingContext): DStream[String] = {

  val nifiConf = new SiteToSiteClient.Builder()
    .url("http://localhost:8090/nifi")
    .portName("Send_To_Spark")
    .buildConfig()

  val receiver = new NiFiReceiver(nifiConf, StorageLevel.MEMORY_ONLY)
  ssc.receiverStream(receiver) map {packet =>
    new String(packet.getContent, StandardCharsets.UTF_8)
  }
}

我们启动我们的流上下文,并监听每 15 分钟到来的新 GDELT 数据。

val ssc = new StreamingContext(sc, Minutes(15)) 
val gdeltStream: DStream[String] = readFromNifi(ssc) 
val gkgStream = parseGkg(gdeltStream) 

下一步是为每篇文章下载 HTML 内容。这里的棘手部分是仅为不同的 URL 下载文章。由于DStream上没有内置的distinct操作,我们需要通过在其上使用transform操作并传递一个extractUrlsFromRDD函数来访问底层 RDD:

val extractUrlsFromRDD = (rdd: RDD[GkgEntity2]) => {
  rdd.map { gdelt =>
    gdelt.documentId.getOrElse("NA")
  }
  .distinct()
}
val urlStream = gkgStream.transform(extractUrlsFromRDD)
val contentStream = fetchHtml(urlStream)

同样,构建向量需要访问底层 RDD,因为我们需要计算整个批次的文档频率(用于 TF-IDF)。这也是在transform函数中完成的。

val buildVectors = (rdd: RDD[Content]) => {

  val corpusRDD = rdd.map(c => (c, Tokenizer.stem(c.body)))

  val tfModel = new HashingTF(1 << 20)
  val tfRDD = corpusRDD mapValues tfModel.transform

  val idfModel = new IDF() fit tfRDD.values
  val idfRDD = tfRDD mapValues idfModel.transform

  val normalizer = new Normalizer()
  val sparseRDD = idfRDD mapValues normalizer.transform

  val embedding = Embedding(Embedding.MEDIUM_DIMENSIONAL_RI)
  val denseRDD = sparseRDD mapValues embedding.embed

  denseRDD
}

val vectorStream = contentStream transform buildVectors

流式 K 均值

我们的用例完全适用于流式 K 均值算法。流式 K 均值的概念与经典的 K 均值没有区别,只是应用于动态数据,因此需要不断重新训练和更新。

在每个批处理中,我们找到每个新数据点的最近中心,对新的聚类中心进行平均,并更新我们的模型。随着我们跟踪真实的聚类并适应伪实时的变化,跟踪不同批次中相同的主题将特别容易。

流式 K 均值的第二个重要特征是遗忘性。这确保了在时间 t 接收到的新数据点将对我们的聚类定义产生更大的贡献,而不是过去历史中的任何其他点,因此允许我们的聚类中心随着时间平稳漂移(故事将变异)。这由衰减因子及其半衰期参数(以批次数或点数表示)控制,指定了给定点仅贡献其原始权重一半之后的时间。

  • 使用无限衰减因子,所有历史记录都将被考虑在内,我们的聚类中心将缓慢漂移,并且如果有重大新闻事件突然发生,将不会做出反应

  • 使用较小的衰减因子,我们的聚类将对任何点过于敏感,并且可能在观察到新事件时发生 drastical 变化

流式 K 均值的第三个最重要的特征是能够检测和回收垂死的聚类。当我们观察到输入数据发生 drastical 变化时,一个聚类可能会远离任何已知数据点。流式 K 均值将消除这个垂死的聚类,并将最大的聚类分成两个。这与我们的故事分支概念完全一致,其中多个故事可能共享一个共同的祖先。

我们在这里使用两个批次的半衰期参数。由于我们每 15 分钟获取新数据,任何新数据点只会保持活跃1 小时。训练流式 K 均值的过程如图 12所示:

流式 K 均值

图 12:训练流式 K 均值

我们创建一个新的流式 K 均值如下。因为我们还没有观察到任何数据点,所以我们用 256 个大向量(我们的 TF-IDF 向量的大小)的 15 个随机中心进行初始化,并使用trainOn方法实时训练它:

val model = new StreamingKMeans()
  .setK(15)
  .setRandomCenters(256, 0.0)
  .setHalfLife(2, "batches")

model.trainOn(vectorStream.map(_._2))

最后,我们对任何新数据点进行聚类预测:

val storyStream = model predictOnValues vectorStream  

然后,我们使用以下属性将我们的结果保存到我们的 Elasticsearch 集群中(通过一系列连接操作访问)。我们不在这里报告如何将 RDD 持久化到 Elasticsearch,因为我们认为这在之前的章节中已经深入讨论过了。请注意,我们还保存向量本身,因为我们可能以后会重新使用它。

Map(
  "uuid" -> gkg.gkgId,
  "topic" -> clusterId,
  "batch" -> batchId,
  "simhash" -> content.body.simhash, 
  "date" -> gkg.date,
  "url" -> content.url,
  "title" -> content.title,
  "body" -> content.body,
  "tone" -> gkg.tones.get.averageTone,
  "country" -> gkg.v2Locations,
  "theme" -> gkg.v2Themes,
  "person" -> gkg.v2Persons,
  "organization" -> gkg.v2Organizations,
  "vector" -> v.toArray.mkString(",")
)

可视化

由于我们将文章与它们各自的故事和主题存储在 Elasticsearch 中,我们可以使用关键词搜索(因为文章已经完全分析和索引)或特定的人物、主题、组织等来浏览任何事件。我们在我们的故事之上构建可视化,并尝试在 Kibana 仪表板上检测它们的潜在漂移。不同的集群 ID(我们的不同主题)随时间的变化在 11 月 13 日(索引了 35,000 篇文章)的图 13中报告:

可视化

图 13:Kibana 巴黎袭击的可视化

结果相当令人鼓舞。我们能够在 11 月 13 日晚上 9:30 左右检测到巴黎袭击,距离第一次袭击开始只有几分钟。我们还确认了我们的聚类算法相对良好的一致性,因为一个特定的集群仅由与巴黎袭击相关的事件组成(5,000 篇文章),从晚上 9:30 到凌晨 3:00。

但我们可能会想知道在第一次袭击发生之前,这个特定的集群是关于什么的。由于我们将所有文章与它们的集群 ID 和它们的 GKG 属性一起索引,我们可以很容易地追踪一个故事在时间上的倒退,并检测它的变异。事实证明,这个特定的主题主要涵盖了与[MAN_MADE_DISASTER]主题相关的事件(等等),直到晚上 9 点到 10 点,当它转变为巴黎袭击史诗,主题围绕着[TERROR]、[STATE_OF_EMERGENCY]、[TAX_ETHNICITY_FRENCH]、[KILL]和[EVACUATION]。

可视化

图 14:Kibana 巴黎袭击集群的流图

不用说,我们从 GDELT 得到的 15 分钟平均语调在晚上 9 点后急剧下降,针对那个特定的主题

可视化

图 15:Kibana 平均语调-巴黎袭击集群

使用这三个简单的可视化,我们证明了我们可以随着时间追踪一个故事,并研究它在类型、关键词、人物或组织(基本上我们可以从 GDELT 中提取的任何实体)方面的潜在变异。但我们也可以查看 GKG 记录中的地理位置;有了足够的文章,我们可能可以在伪实时中追踪巴黎和布鲁塞尔之间的恐怖分子追捕活动!

尽管我们发现了一个特定于巴黎袭击的主要集群,并且这个特定的集群是第一个涵盖这一系列事件的集群,但这可能不是唯一的。根据之前的 Streaming KMeans 定义,这个主题变得如此庞大,以至于肯定触发了一个或多个随后的史诗。我们在下面的图 16中报告了与图 13相同的结果,但这次是过滤出与关键词巴黎匹配的任何文章:

可视化

图 16:Kibana 巴黎袭击的多个史诗

似乎在午夜左右,这个史诗产生了同一事件的多个版本(至少三个主要版本)。在袭击后一个小时(1 小时是我们的衰减因子)后,Streaming KMeans 开始回收垂死的集群,从而在最重要的事件(我们的巴黎袭击集群)中创建新的分支。

虽然主要的史诗仍然涵盖着事件本身(事实),但第二重要的是更多关于社交网络相关文章的。简单的词频分析告诉我们,这个史诗是关于#portesOuvertes(开放的大门)和#prayForParis标签,巴黎人以团结回应恐怖袭击。我们还发现另一个集群更关注所有向法国致敬并谴责恐怖主义的政治家。所有这些新故事都共享巴黎袭击 史诗作为共同的祖先,但涵盖了不同的风味。

构建故事连接

我们如何将这些分支联系在一起?我们如何随着时间跟踪一个史诗,并查看它何时、是否、如何或为什么会分裂?当然,可视化有所帮助,但我们正在解决一个图问题。

因为我们的 KMeans 模型在每个批次中都在不断更新,我们的方法是检索我们使用过时版本模型预测的文章,从 Elasticsearch 中提取它们,并根据我们更新的 KMeans 模型进行预测。我们的假设如下:

如果我们观察到在时间t时属于故事s的许多文章,现在在时间构建故事连接属于故事s'那么* s 很可能在 构建故事连接 时间内迁移到 s'。*

作为一个具体的例子,第一个#prayForParis文章肯定属于巴黎袭击 史诗。几个批次后,同一篇文章属于巴黎袭击/社交网络集群。因此,巴黎袭击 史诗可能产生了巴黎袭击/社交网络 史诗。这个过程在下面的图 17中有所报道:

构建故事连接

图 17:检测故事连接

我们从 Elasticsearch 中读取了一个 JSON RDD,并使用批处理 ID 应用了范围查询。在下面的例子中,我们想要访问过去一小时内构建的所有向量(最后四个批次),以及它们的原始集群 ID,并根据我们更新的模型重新预测它们(通过latestModel函数访问):

import org.json4s.DefaultFormats
import org.json4s.native.JsonMethods._

val defaultVector = Array.fillDouble(0.0d).mkString(",")
val minBatchQuery = batchId - 4
val query = "{"query":{"range":{"batch":{"gte": " + minBatchQuery + ","lte": " + batchId + "}}}}"
val nodesDrift = sc.esJsonRDD(esArticles, query)
  .values
  .map { strJson =>
    implicit val format = DefaultFormats
    val json = parse(strJson)
    val vectorStr = (json \ "vector").extractOrElseString
    val vector = Vectors.dense(vectorStr.split(",").map(_.toDouble))
    val previousCluster = (json \ "topic").extractOrElseInt
    val newCluster = model.latestModel().predict(vector)
    ((previousCluster, newCluster), 1)
  }
  .reduceByKey(_ + _)

最后,一个简单的reduceByKey函数将计算过去一小时内不同边的数量。在大多数情况下,故事s中的文章将保持在故事s中,但在巴黎袭击的情况下,我们可能会观察到一些故事随着时间的推移向不同的史诗漂移。最重要的是,两个分支之间共享的连接越多,它们就越相似(因为它们的文章相互连接),因此它们在力导向布局中看起来越接近。同样,不共享许多连接的分支在相同的图形可视化中看起来会相距甚远。我们使用 Gephi 软件对我们的故事连接进行了力导向图表示,并在下面的图 18中报告。每个节点都是批次b上的一个故事,每条边都是我们在两个故事之间找到的连接数量。这 15 行是我们的 15 个主题,它们都共享一个共同的祖先(在首次启动流上下文时生成的初始集群)。

构建故事连接

图 18:故事变异的力导向布局

我们可以做出的第一个观察是这条线形状。这一观察令人惊讶地证实了我们对平衡状态的理论,即在巴黎袭击发生之前,大部分主题都是孤立的并且内部连接的(因此呈现这种线形状)。事件发生之前,大部分主题都是孤立的并且内部连接的(因此呈现这种线形状)。事件发生后,我们看到我们的主要巴黎袭击 史诗变得密集、相互连接,并随着时间的推移而漂移。由于相互连接的数量不断增加,它似乎还拖着一些分支下降。这两个相似的分支是前面提到的另外两个集群(社交网络和致敬)。随着时间的推移,这个史诗变得越来越具体,自然地与其他故事有所不同,因此将所有这些不同的故事推向上方,形成这种散点形状。

我们还想知道这些不同分支是关于什么的,以及我们是否能解释为什么一个故事可能分裂成两个。为此,我们将每个故事的主要文章视为离其质心最近的点。

val latest = model.latestModel()
val topTitles = rdd.values
  .map { case ((content, v, cId), gkg) =>
    val dist = euclidean(
                  latest.clusterCenters(cId).toArray,
                  v.toArray
                  )
    (cId, (content.title, dist))
  }
  .groupByKey()
  .mapValues { it =>
    Try(it.toList.sortBy(_._2).map(_._1).head).toOption
  }
  .collectAsMap()

图 19中,我们报告了相同的图表,并附上了故事标题。虽然很难找到一个清晰的模式,但我们找到了一个有趣的案例。一个主题涵盖了(其他事情之间的)与哈里王子开玩笑有关他的发型,稍微转移到奥巴马就巴黎袭击发表声明,最终变成了巴黎袭击和政客们支付的致敬。这个分支并非凭空出现,而似乎遵循了一个逻辑流程:

  1. [皇室,哈里王子,笑话]

  2. [皇室,哈里王子]

  3. [哈里王子,奥巴马]

  4. [哈里王子,奥巴马,政治]

  5. [奥巴马,政治]

  6. [奥巴马,政治,巴黎]

  7. [政治,巴黎]

构建故事连接

图 19:故事突变的力导向布局 - 标题

总之,似乎一条突发新闻事件作为平衡状态的突然扰动。现在我们可能会想知道这种扰动会持续多久,未来是否会达到新的平衡状态,以及由此产生的世界形状会是什么样子。最重要的是,不同的衰减因子对世界形状会产生什么影响。

如果有足够的时间和动力,我们可能会对应用物理学中的摄动理论www.tcm.phy.cam.ac.uk/~bds10/aqp/handout_dep.pdf)的一些概念感兴趣。我个人对在这个平衡点周围找到谐波很感兴趣。巴黎袭击事件之所以如此令人难忘,当然是因为其暴力性质,但也因为它发生在巴黎查理周刊袭击事件仅几个月后。

总结

这一章非常复杂,故事突变问题在允许交付本章的时间范围内无法轻易解决。然而,我们发现的东西真是令人惊奇,因为它引发了很多问题。我们并不想得出任何结论,所以我们在观察到巴黎袭击干扰后立即停止了我们的过程,并为我们的读者留下了这个讨论。请随意下载我们的代码库,并研究任何突发新闻及其在我们定义的平衡状态中的潜在影响。我们非常期待听到您的回音,并了解您的发现和不同的解释。

令人惊讶的是,在撰写本章之前,我们对盖乐世 Note 7 惨败一无所知,如果没有第一节中创建的 API,相关文章肯定会与大众无异。使用Simhash进行内容去重确实帮助我们更好地了解世界新闻事件。

在下一章中,我们将尝试检测与美国选举和新当选总统(唐纳德·特朗普)有关的异常推文。我们将涵盖Word2Vec算法和斯坦福 NLP 进行情感分析。

第十一章:情感分析中的异常检测

当我们回顾 2016 年时,我们肯定会记得这是一个许多重大地缘政治事件的时期,从英国脱欧,即英国决定退出欧盟的投票,到许多深受喜爱的名人的不幸去世,包括歌手大卫·鲍伊的突然去世(在第六章,抓取基于链接的外部数据和第七章,构建社区中有介绍)。然而,也许今年最显著的事件是紧张的美国总统选举及其最终结果,即唐纳德·特朗普当选总统。这将是一个长久被记住的竞选活动,尤其是因为它对社交媒体的前所未有的使用,以及在其用户中激起的激情,其中大多数人通过使用标签表达了他们的感受:要么是积极的,比如#让美国再次伟大#更强大,要么是负面的,比如#扔掉特朗普#关起来。由于本章是关于情感分析的,选举提供了理想的用例。但是,我们不打算试图预测结果本身,而是打算使用实时 Twitter 信息流来检测美国选举期间的异常推文。我们将涵盖以下主题:

  • 实时和批量获取 Twitter 数据

  • 使用斯坦福 NLP 提取情感

  • Timely中存储情感时间序列

  • 使用Word2Vec从仅 140 个字符中提取特征

  • 介绍图遍历性最短路径的概念

  • 训练 KMeans 模型以检测潜在的异常

  • 使用TensorFlow嵌入式投影仪可视化模型

在 Twitter 上关注美国选举

2016 年 11 月 8 日,美国公民成千上万地前往投票站,为下一任美国总统投票。计票几乎立即开始,尽管直到稍后才正式确认,但预测的结果在第二天早上就已经众所周知。让我们从主要事件发生的几天前开始调查,即 2016 年 11 月 6 日,这样我们就可以在选举前保留一些背景信息。尽管我们事先不知道会发现什么,但我们知道Twitter将在政治评论中发挥超大作用,因为它在选举前的影响力很大,所以尽快开始收集数据是有意义的。事实上,数据科学家有时可能会有这种直觉 - 一种奇怪而令人兴奋的想法,促使我们开始做某事,没有明确的计划或绝对的理由,只是觉得会有回报。实际上,这种方法可能至关重要,因为在制定和实现这样的计划所需的正常时间和事件的瞬息万变之间,可能会发生重大新闻事件(参见第十章,故事去重和变异),可能会发布新产品,或者股票市场可能会有不同的趋势(参见第十二章,趋势演算);到那时,原始数据集可能已不再可用。

在流中获取数据

第一步是开始获取 Twitter 数据。由于我们计划下载超过 48 小时的推文,因此代码应该足够健壮,不会在过程中的某个地方失败;没有什么比在经过多小时的密集处理后发生致命的NullPointerException更令人沮丧的了。我们知道在未来某个时候我们将进行情感分析,但现在我们不希望用大型依赖项过度复杂化我们的代码,因为这可能会降低稳定性并导致更多未经检查的异常。相反,我们将开始收集和存储数据,随后的处理将在收集的数据上离线进行,而不是将此逻辑应用于实时流。

我们创建一个新的流上下文,使用第九章中创建的实用方法从 Twitter 1%的数据流中读取,新闻词典和实时标记系统。我们还使用优秀的 GSON 库将 Java 类Status(嵌入 Twitter4J 记录的 Java 类)序列化为 JSON 对象。

<dependency> 
  <groupId>com.google.code.gson</groupId>
  <artifactId>gson</artifactId>
  <version>2.3.1</version>
</dependency>

我们每 5 分钟读取一次 Twitter 数据,并可以选择作为命令行参数提供 Twitter 过滤器。过滤器可以是关键词,如TrumpClinton#MAGA#StrongerTogether。然而,我们必须记住,通过这样做,我们可能无法捕获所有相关的推文,因为我们可能永远无法完全跟上最新的标签趋势(如#DumpTrump#DrainTheSwamp#LockHerUp#LoveTrumpsHate),并且许多推文将被忽视,因为过滤器不足,因此我们将使用一个空的过滤器列表来确保我们捕捉到一切。

val sparkConf  = new SparkConf().setAppName("Twitter Extractor")
val sc = new SparkContext(sparkConf)
val ssc = new StreamingContext(sc, Minutes(5))

val filter = args

val twitterStream = createTwitterStream(ssc, filter)
  .mapPartitions { it =>
     val gson = new GsonBuilder().create()
     it.map { s: Status =>
       Try(gson.toJson(s)).toOption
     }
  }

我们使用 GSON 库对我们的Status类进行序列化,并将我们的 JSON 对象持久化在 HDFS 中。请注意,序列化发生在Try子句中,以确保不会抛出不需要的异常。相反,我们将 JSON 作为可选的String返回:

twitterStream
  .filter(_.isSuccess)
  .map(_.get)
  .saveAsTextFiles("/path/to/twitter")

最后,我们运行我们的 Spark 流上下文,并保持其活动状态,直到新总统当选,无论发生什么!

ssc.start()
ssc.awaitTermination()

批量获取数据

只有 1%的推文通过 Spark 流 API 检索,意味着 99%的记录将被丢弃。虽然能够下载大约 1000 万条推文,但这次我们可以潜在地下载更多的数据,但这次只针对选定的标签和在短时间内。例如,我们可以下载所有与#LockHerUp#BuildTheWall标签相关的推文。

搜索 API

为此,我们通过twitter4j Java API 消耗 Twitter 历史数据。这个库作为spark-streaming-twitter_2.11的传递依赖项。要在 Spark 项目之外使用它,应该使用以下 maven 依赖项:

<dependency>
  <groupId>org.twitter4j</groupId>
  <artifactId>twitter4j-core</artifactId>
  <version>4.0.4</version>
</dependency>

我们创建一个 Twitter4J 客户端,如下所示:

ConfigurationBuilder builder = new ConfigurationBuilder();
builder.setOAuthConsumerKey(apiKey);
builder.setOAuthConsumerSecret(apiSecret);
Configuration configuration = builder.build();

AccessToken token = new AccessToken(
  accessToken,
  accessTokenSecret
);

Twitter twitter =
  new TwitterFactory(configuration)
      .getInstance(token);

然后,我们通过Query对象消耗/search/tweets服务:

Query q = new Query(filter);
q.setSince(fromDate);
q.setUntil(toDate);
q.setCount(400);

QueryResult r = twitter.search(q);
List<Status> tweets = r.getTweets();

最后,我们得到了一个Status对象的列表,可以很容易地使用之前介绍的 GSON 库进行序列化。

速率限制

Twitter 是数据科学的一个很棒的资源,但它远非一个非营利组织,因此他们知道如何评估和定价数据。在没有任何特殊协议的情况下,搜索 API 限制为几天的回顾,每 15 分钟窗口最多 180 次查询和每次查询最多 450 条记录。可以在 Twitter DEV 网站(dev.twitter.com/rest/public/rate-limits)和 API 本身使用RateLimitStatus类来确认这一限制:

Map<String, RateLimitStatus> rls = twitter.getRateLimitStatus("search");
System.out.println(rls.get("/search/tweets"));

/*
RateLimitStatusJSONImpl{remaining=179, limit=180, resetTimeInSeconds=1482102697, secondsUntilReset=873}
*/

毫不奇怪,任何关于热门词汇的查询,比如 2016 年 11 月 9 日的#MAGA,都会达到这个阈值。为了避免速率限制异常,我们必须通过跟踪处理的推文 ID 的最大数量,并在每次搜索请求后监视我们的状态限制来分页和限制我们的下载请求。

RateLimitStatus strl = rls.get("/search/tweets");
int totalTweets = 0;
long maxID = -1;
for (int i = 0; i < 400; i++) {

  // throttling
  if (strl.getRemaining() == 0)
    Thread.sleep(strl.getSecondsUntilReset() * 1000L);

  Query q = new Query(filter);
  q.setSince(fromDate);
  q.setUntil(toDate);
  q.setCount(100);

  // paging
  if (maxID != -1) q.setMaxId(maxID - 1);

  QueryResult r = twitter.search(q);
  for (Status s: r.getTweets()) {
    totalTweets++;
    if (maxID == -1 || s.getId() < maxID)
     maxID = s.getId();
     writer.println(gson.toJson(s));
  }
  strl = r.getRateLimitStatus();
}

每天大约有 50 亿条推文,如果收集所有与美国相关的数据,这将是乐观的,如果不是天真的。相反,应该使用前面详细介绍的简单摄取过程来拦截与特定查询匹配的推文。作为装配 jar 中的主类,可以按照以下方式执行:

java -Dtwitter.properties=twitter.properties /
  -jar trump-1.0.jar #maga 2016-11-08 2016-11-09 /
  /path/to/twitter-maga.json

在这里,twitter.properties文件包含您的 Twitter API 密钥:

twitter.token = XXXXXXXXXXXXXX
twitter.token.secret = XXXXXXXXXXXXXX
twitter.api.key = XXXXXXXXXXXXXX
twitter.api.secret = XXXXXXXXXXXXXX

分析情感

经过 4 天的密集处理,我们提取了大约 1000 万条推文;大约 30GB 的 JSON 数据。

整理 Twitter 数据

Twitter 变得如此受欢迎的一个关键原因是任何消息都必须适应最多 140 个字符。缺点也是每条消息都必须适应最多 140 个字符!因此,结果是缩写词、首字母缩略词、俚语、表情符号和标签的使用大幅增加。在这种情况下,主要情感可能不再来自文本本身,而是来自使用的表情符号(dl.acm.org/citation.cfm?id=1628969),尽管一些研究表明表情符号有时可能导致情感预测不准确(arxiv.org/pdf/1511.02556.pdf)。表情符号甚至比表情符号更广泛,因为它们包括动物、交通工具、商业图标等图片。此外,虽然表情符号可以通过简单的正则表达式轻松检索,但表情符号通常以 Unicode 编码,并且没有专用库更难提取。

<dependency>
  <groupId>com.kcthota</groupId>
  <artifactId>emoji4j</artifactId>
  <version>5.0</version>
</dependency>

Emoji4J库易于使用(尽管计算成本高昂),并且给定一些带有表情符号/表情符号的文本,我们可以编码- 用实际代码名称替换 Unicode 值 - 或清理- 简单地删除任何表情符号。

整理 Twitter 数据

图 1:表情符号解析

因此,首先让我们清理文本中的任何垃圾(特殊字符、表情符号、重音符号、URL 等),以便访问纯英文内容:

import emoji4j.EmojiUtils

def clean = {
  var text = tweet.toLowerCase()
  text = text.replaceAll("https?:\\/\\/\\S+", "")
  text = StringUtils.stripAccents(text)
  EmojiUtils.removeAllEmojis(text)
    .trim
    .toLowerCase()
    .replaceAll("rt\\s+", "")
    .replaceAll("@[\\w\\d-_]+", "")
    .replaceAll("[^\\w#\\[\\]:'\\.!\\?,]+", " ")
    .replaceAll("\\s+([:'\\.!\\?,])\\1", "$1")
    .replaceAll("[\\s\\t]+", " ")
    .replaceAll("[\\r\\n]+", ". ")
    .replaceAll("(\\w)\\1{2,}", "$1$1") // avoid looooool 
    .replaceAll("#\\W", "")
    .replaceAll("[#':,;\\.]$", "")
    .trim
}

让我们也对所有表情符号和表情进行编码和提取,并将它们作为列表放在一边:

val eR = "(:\\w+:)".r

def emojis = {
  var text = tweet.toLowerCase()
  text = text.replaceAll("https?:\\/\\/\\S+", "")
  eR.findAllMatchIn(EmojiUtils.shortCodify(text))
    .map(_.group(1))
    .filter { emoji =>
      EmojiUtils.isEmoji(emoji)
    }.map(_.replaceAll("\\W", ""))
    .toArray
}

将这些方法写在implicit class中意味着它们可以通过简单的导入语句直接应用于字符串。

整理 Twitter 数据

图 2:Twitter 解析

使用斯坦福 NLP

我们的下一步是通过情感注释器传递我们清理过的文本。我们使用斯坦福 NLP 库来实现这一目的:

<dependency>
  <groupId>edu.stanford.nlp</groupId>
  <artifactId>stanford-corenlp</artifactId>
  <version>3.5.0</version>
  <classifier>models</classifier>
</dependency>

<dependency>
  <groupId>edu.stanford.nlp</groupId>
  <artifactId>stanford-corenlp</artifactId>
  <version>3.5.0</version>
</dependency>

我们创建一个斯坦福注释器,将内容标记为句子(tokenize),分割句子(ssplit),标记元素(pos),并在分析整体情感之前对每个词进行词形还原(lemma):

def getAnnotator: StanfordCoreNLP = {
  val p = new Properties()
  p.setProperty(
    "annotators",
    "tokenize, ssplit, pos, lemma, parse, sentiment"
  )
  new StanfordCoreNLP(pipelineProps)
}

def lemmatize(text: String,
              annotator: StanfordCoreNLP = getAnnotator) = {

  val annotation = annotator.process(text.clean)
  val sentences = annotation.get(classOf[SentencesAnnotation])
    sentences.flatMap { sentence =>
    sentence.get(classOf[TokensAnnotation])
  .map { token =>
    token.get(classOf[LemmaAnnotation])
  }
  .mkString(" ")
}

val text = "If you're bashing Trump and his voters and calling them a variety of hateful names, aren't you doing exactly what you accuse them?"

println(lemmatize(text))

/*
if you be bash trump and he voter and call they a variety of hateful name, be not you do exactly what you accuse they
*/

任何单词都被其最基本形式替换,即you're被替换为you bearen't you doing被替换为be not you do

def sentiment(coreMap: CoreMap) = {

 coreMap.get(classOf[SentimentCoreAnnotations.ClassName].match {
     case "Very negative" => 0
     case "Negative" => 1
     case "Neutral" => 2
     case "Positive" => 3
     case "Very positive" => 4
     case _ =>
       throw new IllegalArgumentException(
         s"Could not get sentiment for [${coreMap.toString}]"
       )
  }
}

def extractSentiment(text: String,
                     annotator: StanfordCoreNLP = getSentimentAnnotator) = {

  val annotation = annotator.process(text)
  val sentences = annotation.get(classOf[SentencesAnnotation])
  val totalScore = sentences map sentiment

  if (sentences.nonEmpty) {
    totalScore.sum / sentences.size()
  } else {
    2.0f
  }

}

extractSentiment("God bless America. Thank you Donald Trump!")
 // 2.5

extractSentiment("This is the most horrible day ever")
 // 1.0

情感范围从非常消极(0.0)到非常积极(4.0),并且每个句子的情感平均值。由于我们每条推文不会超过 1 或 2 个句子,我们预计方差非常小;大多数推文应该是中性(大约 2.0),只有极端情感会得分(低于~1.5 或高于~2.5)。

构建管道

对于我们的每条 Twitter 记录(存储为 JSON 对象),我们要做以下事情:

  • 使用json4s库解析 JSON 对象

  • 提取日期

  • 提取文本

  • 提取位置并将其映射到美国州

  • 清理文本

  • 提取表情符号

  • 对文本进行词形还原

  • 分析情感

然后,我们将所有这些值封装到以下Tweet案例类中:

case class Tweet(
            date: Long,
            body: String,
            sentiment: Float,
            state: Option[String],
            geoHash: Option[String],
            emojis: Array[String]
         )

如前几章所述,为我们数据集中的每条记录创建一个新的 NLP 实例并不可行。相反,我们每个迭代器(即每个分区)只创建一个注释器

val analyzeJson = (it: Iterator[String]) => {

  implicit val format = DefaultFormats
  val annotator = getAnnotator
  val sdf = new SimpleDateFormat("MMM d, yyyy hh:mm:ss a")

  it.map { tweet =>

    val json = parse(tweet)
    val dateStr = (json \ "createdAt").extract[String]
    val date = Try(
      sdf.parse(dateStr).getTime
    )
     .getOrElse(0L)

    val text = (json \ "text").extract[String] 
    val location = Try(
      (json \ "user" \ "location").extract[String]
    )
     .getOrElse("")
     .toLowerCase()

     val state = Try {
       location.split("\\s")
        .map(_.toUpperCase())
        .filter { s =>
          states.contains(s)
        }
        .head
     }
     .toOption

    val cleaned = text.clean

    Tweet(
     date,
     cleaned.lemmatize(annotator),
     cleaned.sentiment(annotator),
     state, 
     text.emojis
    )
  }
}

val tweetJsonRDD = sc.textFile("/path/to/twitter")
val tweetRDD = twitterJsonRDD mapPartitions analyzeJson
tweetRDD.toDF().show(5)

/*
+-------------+---------------+---------+--------+----------+
|         date|           body|sentiment|   state|    emojis|
+-------------+---------------+---------+--------+----------+
|1478557859000|happy halloween|      2.0|    None [ghost]   |            
|1478557860000|slave to the gr|      2.5|    None|[]      |                 
|1478557862000|why be he so pe|      3.0|Some(MD)|[]        |
|1478557862000|marcador sentim|      2.0|    None|[]        |
|1478557868000|you mindset tow|      2.0|    None|[sparkles]|
+-------------+---------------+---------+--------+----------+
*/

使用 Timely 作为时间序列数据库

现在我们能够将原始信息转换为一系列干净的 Twitter 情感,其中包括标签、表情符号或美国州等参数,这样的时间序列应该能够可靠地存储,并且可以快速查询。

在 Hadoop 生态系统中,OpenTSDBopentsdb.net/)是存储数百万时间点数据的默认数据库。然而,我们将介绍一个您可能以前没有接触过的数据库,名为Timelynationalsecurityagency.github.io/timely/)。Timely 是最近由国家安全局NSA)开源的项目,作为 OpenTSDB 的克隆,它使用 Accumulo 而不是 HBase 作为其底层存储。正如您可能记得的那样,Accumulo 支持单元级安全,我们稍后将看到这一点。

存储数据

每条记录由一个指标名称(例如,标签),时间戳,指标值(例如,情感),一组相关标签(例如,州),以及一个单元可见性组成:

case class Metric(name: String,
                 time: Long,
                 value: Double,
                 tags: Map[String, String],
                 viz: Option[String] = None
                 )

在这个练习中,我们将筛选出只提到特朗普或克林顿的推文数据:

def expandedTweets = rdd.flatMap { tweet =>
  List("trump", "clinton") filter { f =>
    tweet.body.contains(f)
  } map { tag =>
    (tag, tweet)
  }
}

接下来,我们将构建一个名为io.gzet.state.clintonio.gzet.state.trumpMetric对象,并附带一个可见性。在这个练习中,我们假设没有SECRET权限的初级分析师将不被授予访问高度负面的推文。这使我们能够展示 Accumulo 出色的单元级安全性:

def buildViz(tone: Float) = {
  if (tone <= 1.5f) Some("SECRET") else None: Option[String]
}

此外,我们还需要处理重复记录。如果在完全相同的时间收到多条推文(可能情感不同),它们将覆盖 Accumulo 上的现有单元:

def sentimentByState = {
  expandedTweets.map { case (tag, tweet) =>
    ((tag, tweet.date, tweet.state), tweet.sentiment)
  }
  .groupByKey()
  .mapValues { f =>
    f.sum / f.size
  }
  .map { case ((tag, date, state), sentiment) =>
    val viz = buildViz(sentiment)
    val meta = Map("state" -> state) 
    Metric("io.gzet.state.$tag", date, sentiment, meta, viz)
  }
}

我们可以通过POST请求插入数据,也可以通过打开的套接字将数据传送回 Timely 服务器:

def toPut = {

  val vizMap = if(viz.isDefined) {
    List("viz" -> viz.get)
  } else {
    List[(String, String)]()
  }

  val strTags = vizMap
    .union(tags.toList)
    .map { case (k, v) => s"$k=$v" }
    .mkString(" ")

  s"put $name $time $value $strTags"
}

implicit class Metrics(rdd: RDD[Metric]) {

  def publish = {

    rdd.foreachPartition { it: Iterator[Metric] =>

      val sock = new Socket(timelyHost, timelyPort)
      val writer = new PrintStream(
        sock.getOutputStream,
        true,
        StandardCharsets.UTF_8.name
      )

      it.foreach { metric =>
        writer.println(metric.toPut)
      }
      writer.flush()
    }

  }
}

tweetRDD.sentimentByState.publish

我们的数据现在安全地存储在 Accumulo 中,并且任何具有正确访问权限的人都可以使用。

我们已经创建了一系列的输入格式,以便将 Timely 数据检索回 Spark 作业中。这里不会涉及,但可以在我们的 GitHub 存储库中找到:

// Read  metrics from Timely
val conf = AccumuloConfig(
            "GZET",
            "alice",
            "alice",
            "localhost:2181"
            )

val metricsRDD = sc.timely(conf, Some("io.gzet.state.*"))

提示

在撰写本文时,Timely 仍在积极开发中,因此尚无法从 Spark/MapReduce 中使用干净的输入/输出格式。发送数据的唯一方式是通过 HTTP 或 Telnet。

使用 Grafana 来可视化情感

Timely 本身并不具备可视化工具。但是,它与Grafanagrafana.net/)集成良好且安全,使用 timely-grafana 插件。更多信息可以在 Timely 网站上找到。

处理的推文数量

作为第一个简单的可视化,我们显示了 2016 年 11 月 8 日和 9 日(协调世界时)两位候选人的推文数量:

处理的推文数量

图 3:Timely 处理的推文

随着选举结果的公布,我们观察到与特朗普有关的推文越来越多。平均而言,我们观察到与克林顿相关的推文约为特朗普相关推文的 6 倍。

还我推特账号

情感的快速研究显示,情感相对较消极(平均为 1.3),两位候选人的推文没有显著差异,这不会帮助预测美国大选的结果。

还我推特账号

图 4:Timely 时间序列

然而,仔细观察后,我们发现了一个真正有趣的现象。2016 年 11 月 8 日,格林尼治标准时间下午 1 点左右(东部标准时间上午 8 点,也就是纽约第一个投票站开放的时间),我们观察到情感方差出现了大幅下降。在前面的图中可以看到这种奇怪的现象,这不能完全解释。我们可以推测,要么第一张正式投票标志着动荡的总统竞选活动的结束,并且是选举后回顾期的开始 - 也许是一个比以前更加基于事实的对话 - 或者特朗普的顾问们真的把他的 Twitter 账号收走是他们最伟大的主意。

现在我们举一个 Accumulo 安全性的多功能性的例子,通过以另一个用户登录 Grafana,这次没有授予SECRET授权。正如预期的那样,在接下来的图像中,情感看起来积极得多(因为极端负面情感被隐藏了),从而确认了 Timely 上的可见性设置;Accumulo 的优雅自然显而易见:

还我推特账号

图 5:非秘密的及时时间序列

如何创建 Accumulo 用户的示例可以在第七章建立社区中找到。

识别摇摆州

我们将从 Timely 和 Grafana 中利用的最后一个有趣特性是树状图聚合。由于所有美国州的名称都存储为度量属性的一部分,我们将为两位候选人创建一个简单的树状图。每个框的大小对应观察次数,颜色与观察到的情感相关:

识别摇摆州

图 6:及时-希拉里·克林顿的美国州树状图

当我们之前使用 2 天情感平均值时,我们无法区分共和党和民主党州,因为情感在统计上是平坦的,而且相对糟糕(平均为 1.3)。然而,如果我们只考虑选举前一天,那么它似乎更有趣,因为我们观察到情感数据中有更多的变化。在前面的图像中,我们看到佛罗里达州、北卡罗来纳州和宾夕法尼亚州-12 个摇摆州中的 3 个-对希拉里·克林顿的情感表现出意外的糟糕。这种模式是否可能是选举结果的早期指标?

Twitter 和戈德温点

通过适当清理我们的文本内容,我们可以使用Word2Vec算法并尝试理解单词在其实际上下文中的含义。

学习上下文

正如它所说的,Word2Vec算法将一个单词转换为一个向量。其想法是相似的单词将嵌入到相似的向量空间中,并且因此在上下文中看起来彼此接近。

注意

有关Word2Vec算法的更多信息可以在papers.nips.cc/paper/5021-distributed-representations-of-words-and-phrases-and-their-compositionality.pdf找到。

很好地集成到 Spark 中,可以通过以下方式训练Word2Vec模型:

import org.apache.spark.mllib.feature.Word2Vec

val corpusRDD = tweetRDD
   .map(_.body.split("\\s").toSeq)
   .filter(_.distinct.length >= 4)

val model = new Word2Vec().fit(corpusRDD)

在这里,我们将每条推文提取为一个单词序列,只保留至少有4个不同单词的记录。请注意,所有单词的列表需要适应内存,因为它被收集回驱动程序作为单词和向量的映射(作为浮点数组)。向量大小和学习率可以通过setVectorSizesetLearningRate方法进行调整。

接下来,我们使用 Zeppelin 笔记本与我们的模型进行交互,发送不同的单词并要求模型获取最接近的同义词。结果相当令人印象深刻:

model.findSynonyms("#lockherup", 10).foreach(println)

/*
(#hillaryforprison,2.3266071900089313)
(#neverhillary,2.2890002973310066)
(#draintheswamp,2.2440446323298175)
(#trumppencelandslide,2.2392471034643604)
(#womenfortrump,2.2331140131326874)
(#trumpwinsbecause,2.2182999853485454)
(#imwithhim,2.1950198833564563)
(#deplorable,2.1570936207197016)
(#trumpsarmy,2.155859656266577)
(#rednationrising,2.146132149205829)
*/  

虽然标签通常在标准 NLP 中被忽略,但它们对语气和情感有很大的贡献。标记为中性的推文实际上可能比听起来更糟,因为使用#HillaryForPrison#LockHerUp等标签。因此,让我们尝试使用一个有趣的特征,称为word-vector association来考虑这一点。原始Word2Vec算法给出的这种关联的一个常见例子如下所示:

[KING] is at [MAN] what [QUEEN] is at [?????] 

这可以翻译为以下向量:

VKING - VQUEEN = VMAN - V???? 
V???? = VMAN - VKING + VQUEEN 

因此,最近的点应该是[WOMEN]。从技术上讲,这可以翻译如下:

import org.apache.spark.mllib.linalg.Vectors

def association(word1: String, word2: String, word3: String) = {

  val isTo = model
    .getVectors
    .get(word2)
    .get
    .zip(model.getVectors.get(word1).get)
    .map(t => t._1 - t._2)

 val what = model
   .getVectors
   .get(word3)
   .get

 val vec = isTo
   .zip(what)
   .map(t => t._1 + t._2)
   .map(_.toDouble)

 Vectors.dense(vec)

}

val assoc = association("trump", "republican", "clinton")

model.findSynonyms(assoc, 1)
     .foreach(println)

*// (democrat,1.6838367309269164)* 

保存/检索这个模型可以通过以下方式完成:

model.save(sc, "/path/to/word2vec")

val retrieved = Word2VecModel.load(sc, "/path/to/word2vec")

可视化我们的模型

由于我们的向量有 100 个维度,使用传统方法在图中表示它们很困难。但是,您可能已经了解到Tensor Flow项目及其最近开源的Embedding Projectorprojector.tensorflow.org/)。由于其快速渲染高维数据的能力,该项目提供了一种很好的可视化我们模型的方式。它也很容易使用-我们只需将我们的向量导出为制表符分隔的数据点,加载到 Web 浏览器中,就可以了!

可视化我们的模型

图 7:嵌入项目,计算机的邻居

嵌入投影仪将高维向量投影到 3D 空间,其中每个维度代表前三个主要成分PCA)之一。我们还可以构建自己的投影,基本上将我们的向量朝着四个特定方向拉伸。在下面的表示中,我们将我们的向量向左、向右、向上和向下拉伸到[特朗普]、[克林顿]、[]和[]:

可视化我们的模型

图 8:嵌入项目,自定义投影

现在我们有了一个大大简化的向量空间,我们可以更容易地理解每个单词以及它与邻居的关系(民主党共和党)。例如,明年法国大选即将到来,我们看到法国与特朗普的距离比与克林顿的距离更近。这可能被视为即将到来的选举的早期指标吗?

Word2Graph 和 Godwin 点

在您使用 Twitter 的Word2Vec模型很长时间之前,您可能已经遇到了敏感术语和对第二次世界大战的引用。事实上,这是迈克·戈德温在 1990 年最初提出的戈德温定律(www.wired.com/1994/10/godwin-if-2/),其规定如下:

随着在线讨论的延长,涉及纳粹或希特勒的比较的概率接近 1

截至 2012 年,它甚至是牛津英语词典的一部分。

Word2Graph 和 Godwin 点

图 9:戈德温定律

构建 Word2Graph

尽管戈德温定律更多是修辞手法而不是实际的数学定律,但它仍然是一个引人入胜的异常现象,并且似乎与美国大选相关。自然地,我们决定使用图论进一步探索这个想法。第一步是将我们的模型广播回执行器并将我们的单词列表并行化。对于每个单词,我们输出前五个同义词,并构建一个带有单词相似度作为边权重的Edge对象。让我们来看一下:

val bModel = sc.broadcast(model)
val bDictionary = sc.broadcast(
  model.getVectors
    .keys
    .toList
    .zipWithIndex
    .map(l => (l._1, l._2.toLong + 1L))
    .toMap
)

import org.apache.spark.graphx._

val wordRDD = sc.parallelize(
  model.getVectors
    .keys
    .toSeq
    .filter(s => s.length > 3)
)

val word2EdgeRDD = wordRDD.mapPartitions { it =>
  val model = bModel.value
  val dictionary = bDictionary.value

  it.flatMap { from =>
    val synonyms = model.findSynonyms(from, 5)
    val tot = synonyms.map(_._2).sum
    synonyms.map { case (to, sim) =>
      val norm = sim / tot
      Edge(
           dictionary.get(from).get,
           dictionary.get(to).get,
           norm
         )
      }
   }
}

val word2Graph = Graph.fromEdges(word2EdgeRDD, 0L)

word2Graph.cache()
word2Graph.vertices.count()

为了证明戈德温定律,我们必须证明无论输入节点如何,我们都可以从该节点找到一条通往Godwin 点的路径。在数学术语中,这假设图是遍历的。由于我们有多个连接的组件,我们的图不能是遍历的,因为一些节点永远不会通向 Godwin 点。因此:

val cc = word2Graph
  .connectedComponents()
  .vertices
  .values
  .distinct
  .count

println(s"Do we still have faith in humanity? ${cc > 1L}")
// false

由于我们只有一个连接的组件,下一步是计算每个节点到 Godwin 点的最短路径:

import org.apache.spark.graphx.lib.ShortestPaths

val shortestPaths = ShortestPaths.run(graph, Seq(godwin))

最短路径算法非常简单,可以很容易地使用Pregel实现,使用第七章中描述的相同技术,构建社区。基本方法是在目标节点(我们的 Godwin 点)上启动 Pregel,并向其传入的边发送消息,每个跳跃增加一个计数器。每个节点将始终保持最小可能的计数器,并将此值向下游传播到其传入的边。当找不到更多的边时,算法停止。

我们使用 Godwin 深度为 16 来标准化这个距离,该深度是每个最短路径的最大值:

val depth = sc.broadcast(
  shortestPaths.vertices
    .values
    .filter(_.nonEmpty)
    .map(_.values.min)
    .max()
)

logInfo(s"Godwin depth is [${depth.value}]")
// 16

shortestPaths.vertices.map { case (vid, hops) =>
  if(hops.nonEmpty) {
    val godwin = Option(
      math.min(hops.values.min / depth.value.toDouble, 1.0)
    )
    (vid, godwin)
   } else {
     (vid, None: Option[Double])
   }
}
.filter(_._2.isDefined)
.map { case (vid, distance) =>
  (vid, distance.get)
}
.collectAsMap()

下图显示了深度为 4-我们将 0、1、2、3 和 4 的分数标准化为0.00.250.50.751.0

构建 Word2Graph

图 10:标准化的 Godwin 距离

最后,我们收集每个顶点及其关联距离作为一个映射。我们可以很容易地将这个集合从最敏感的词到最不敏感的词进行排序,但我们不会在这里报告我们的发现(出于明显的原因!)。

在 2016 年 11 月 7 日和 8 日,这张地图包含了我们 Twitter 字典中的所有单词,意味着完全的遍历性。根据 Godwin 定律,任何单词,只要时间足够长,都可以导致 Godwin 点。在本章中,当我们从 Twitter 文本内容构建特征时,我们将稍后使用这张地图。

随机游走

通过Word2Vec算法模拟随机游走的一种方法是将图形视为一系列马尔可夫链。假设N个随机游走和转移矩阵T,我们计算转移矩阵T^N。给定一个状态S[1](表示一个单词w[1]),我们提取从S[1]N给定转移中的S[N]状态跳转的概率分布。实际上,给定一个约 100k 个单词的字典,这样一个转移矩阵的密集表示将需要大约 50GB 的内存。我们可以使用 MLlib 中的IndexedRowMatrix类轻松构建T的稀疏表示:

val size = sc.broadcast(
  word2Graph
    .vertices
    .count()
    .toInt
)

val indexedRowRDD = word2Graph.edges
  .map { case edge =>
    (edge.srcId,(edge.dstId.toInt, edge.attr))
  }
  .groupByKey()
  .map { case (id, it) =>
    new IndexedRow(id, Vectors.sparse(size.value, it.toSeq))
  }

val m1 = new IndexedRowMatrix(indexedRowRDD)
val m3 = m1.multiply(m2)

不幸的是,Spark 中没有内置的方法来执行支持稀疏矩阵的矩阵乘法。因此,矩阵 m2 需要是密集的,并且必须适合内存。一种解决方法是分解这个矩阵(使用 SVD)并利用 word2vec 矩阵的对称性质(如果单词w[1]是单词w[2]的同义词,那么w[2]w[1]的同义词)来简化这个过程。使用简单的矩阵代数,可以证明给定一个矩阵M

随机游走

M对称,那么

随机游走

对于n的偶数和奇数值分别。理论上,我们只需要计算对角矩阵S的乘积。实际上,这需要大量的工作量,计算成本高,而且没有真正的价值(我们只是想生成随机词语关联)。相反,我们使用我们的 Word2Vec 图、Pregel API 和蒙特卡洛模拟生成随机游走。这将从种子love开始生成词语关联。算法在 100 次迭代后停止,或者当路径达到我们的 Godwin 点时停止。该算法的详细信息可以在我们的代码库中找到。

Godwin.randomWalks(graph, "love", 100) 

提示

还值得一提的是,如果存在一个整数n,使得 M^n> 0,则矩阵M被称为遍历的(因此也证明了 Godwin 定律)。

对讽刺检测的一小步

检测讽刺是一个活跃的研究领域(homes.cs.washington.edu/~nasmith/papers/bamman+smith.icwsm15.pdf)。事实上,对于人类来说,检测讽刺通常并不容易,那么对于计算机来说又怎么可能容易呢?如果我说“我们将让美国再次伟大”;在不了解我、观察我或听到我使用的语气的情况下,你怎么知道我是否真的是认真的?现在,如果你读到我发的一条推文,上面写着“我们将让美国再次伟大 😦😦😦”,这有帮助吗?

构建特征

我们相信仅凭英文文本是无法检测出讽刺的,尤其是当纯文本不超过 140 个字符时。然而,我们在本章中展示了表情符号在情感定义中可以起到重要作用。一个天真的假设是,一条既有积极情绪又有负面表情符号的推文可能会导致讽刺。除了语气,我们还发现一些词语与一些可以被分类为相当负面的想法/意识形态更接近。

#爱战胜仇恨

我们已经证明了任何单词都可以在诸如[clinton]、[trump]、[love]和[hate]之类的单词之间的高维空间中表示。因此,对于我们的第一个提取器,我们使用这些单词之间的平均余弦相似度来构建特征:

case class Word2Score(
                     trump: Double,
                     clinton: Double,
                     love: Double,
                     hate: Double
                      )

def cosineSimilarity(x: Array[Float],
                    y: Array[Float]): Double = {

  val dot = x.zip(y).map(a => a._1 * a._2).sum
  val magX = math.sqrt(x.map(i => i*i).sum)
  val magY = math.sqrt(y.map(i => i*i).sum)

  dot / (magX * magY)
}

val trump = model.getVectors.get("trump").get
val clinton = model.getVectors.get("clinton").get
val love = model.getVectors.get("love").get
val hate = model.getVectors.get("hate").get

val word2Score = sc.broadcast(
   model.getVectors.map { case (word, vector) =>
     val scores = Word2Score(
                        cosineSimilarity(vector, trump),
                        cosineSimilarity(vector, clinton),
                        cosineSimilarity(vector, love),
                        cosineSimilarity(vector, hate)
                        )
     (word, scores)
   }
)

我们将这种方法公开为用户定义的函数,以便对每条推文可以根据这四个维度进行评分:

import org.apache.spark.sql.functions._
import collection.mutable.WrappedArray

val featureTrump = udf((words:WrappedArray[String]) => {
  words.map(word2Score.value.get)
       .map(_.get.trump)
       .sum / words.length
})

val featureClinton = udf((words:WrappedArray[String]) => {
  words.map(word2Score.value.get)
       .map(_.get.clinton)
       .sum / words.length
})

val featureLove = udf((words:WrappedArray[String]) => {
  words.map(word2Score.value.get)
       .map(_.get.love)
       .sum / words.length
})

val featureHate = udf((words:WrappedArray[String]) => {
  words.map(word2Score.value.get)
       .map(_.get.hate)
       .sum / words.length
})

评分表情符号

我们可以提取所有表情符号并运行基本的词频统计,以检索只使用最多的表情符号。然后我们可以将它们分类为五个不同的组:喜悦笑话悲伤哭泣

val lov = sc.broadcast(
  Set("heart", "heart_eyes", "kissing_heart", "hearts", "kiss")
)

val joy = sc.broadcast(
  Set("joy", "grin", "laughing", "grinning", "smiley", "clap", "sparkles")
)

val jok = sc.broadcast(
  Set("wink", "stuck_out_tongue_winking_eye", "stuck_out_tongue")
)

val sad = sc.broadcast(
  Set("weary", "tired_face", "unamused", "frowning", "grimacing", "disappointed")
)

val cry = sc.broadcast(
  Set("sob", "rage", "cry", "scream", "fearful", "broken_heart")
)

val allEmojis = sc.broadcast(
  lov.value ++ joy.value ++ jok.value ++ sad.value ++ cry.value
)

再次,我们将此方法公开为可以应用于 DataFrame 的 UDF。表情符号得分为 1.0 将非常积极,而 0.0 将非常消极。

训练 KMeans 模型

设置了 UDF 后,我们获得了我们的初始 Twitter DataFrame 并构建了特征向量:

val buildVector = udf((sentiment: Double, tone: Double, trump: Double, clinton: Double, love: Double, hate: Double, godwin: Double) => {
  Vectors.dense(
    Array(
      sentiment,
      tone,
      trump,
      clinton,
      love,
      hate,
      godwin
    )
  )
})

val featureTweetDF = tweetRDD.toDF
  .withColumn("words", extractWords($"body"))
  .withColumn("tone", featureEmojis($"emojis"))
  .withColumn("trump", featureTrump($"body"))
  .withColumn("clinton", featureClinton($"body"))
  .withColumn("godwin", featureGodwin($"body"))
  .withColumn("love", featureLove($"words"))
  .withColumn("hate", featureHate($"words"))
  .withColumn("features",
    buildVector(
      $"sentiment",
      $"tone",
      $"trump",
      $"clinton",
      $"love",
      $"hate",
      $"godwin")
    )

import org.apache.spark.ml.feature.Normalizer

val normalizer = new Normalizer()
  .setInputCol("features")
  .setOutputCol("vector")
  .setP(1.0)

我们使用Normalizer类对向量进行归一化,并将 KMeans 算法的输入限制为只有五个簇。与第十章相比,故事去重和变异,这里 KMeans 优化(以k表示)并不重要,因为我们不感兴趣将推文分组到类别中,而是检测异常值(远离任何簇中心的推文):

import org.apache.spark.ml.clustering.KMeans

val kmeansModel = new KMeans()
  .setFeaturesCol("vector")
  .setPredictionCol("cluster")
  .setK(5)
  .setMaxIter(Int.MaxValue)
  .setInitMode("k-means||")
  .setInitSteps(10)
  .setTol(0.01)
  .fit(vectorTweetDF)

我们建议使用 ML 包而不是 MLlib。在过去几个 Spark 版本中,这个包在数据集采用和催化剂优化方面有了巨大的改进。不幸的是,存在一个主要限制:所有 ML 类都被定义为私有的,不能被扩展。因为我们想要提取预测的簇旁边的距离,我们将不得不构建我们自己的欧几里得测量作为 UDF 函数:

import org.apache.spark.ml.clustering.KMeansModel

val centers = sc.broadcast(kmeansModel.clusterCenters)
import org.apache.spark.mllib.linalg.Vector

val euclidean = udf((v: Vector, cluster: Int) => {
   math.sqrt(centers.value(cluster).toArray.zip(v.toArray).map {
    case (x1, x2) => math.pow(x1 - x2, 2)
  }
  .sum)
})

最后,我们从我们的特色推文 DataFrame 中预测我们的簇和欧几里得距离,并将此 DataFrame 注册为持久的 Hive 表:

val predictionDF = kmeansModel
   .transform(vectorTweetDF)
   .withColumn("distance", euclidean($"vector", $"cluster"))

predictionDF.write.saveAsTable("twitter")

检测异常

如果特征向量与任何已知簇中心的距离太远(以欧几里得距离表示),我们将认为推文是异常的。由于我们将预测存储为 Hive 表,我们可以通过简单的 SQL 语句对所有点进行排序,并只取前几条记录。

从我们的 Zeppelin 笔记本查询 Hive 时,报告了一个示例,如下所示:

检测异常

图 11:用于检测异常的 Zeppelin 笔记本

不详细介绍(异常推文可能会敏感),以下是从 Hive 查询中提取的一些示例:

  • 今天祝你好运,美国 #投票 #我和她在一起 [鬼脸]

  • 这太棒了,我们让美国再次变得伟大 [哭泣,尖叫]

  • 我们爱你先生,谢谢你的不断爱 [哭泣]

  • 我无法描述我现在有多么开心 #maga [哭泣,愤怒]

然而,请注意,我们发现的异常值并不都是讽刺性的推文。我们刚刚开始研究讽刺,需要进行大量的细化(包括手动工作),可能还需要更先进的模型(如神经网络)才能编写全面的检测器。

总结

本章的目的是涵盖关于时间序列、词嵌入、情感分析、图论和异常检测的不同主题。值得注意的是,用来说明示例的推文绝不反映作者自己的观点:“美国是否会再次变得伟大超出了本书的范围”:(:(-讽刺与否?

在下一章中,我们将介绍一种创新的方法,使用TrendCalculus方法从时间序列数据中检测趋势。这将用于市场数据,但可以轻松应用于不同的用例,包括我们在这里构建的情感时间序列

第十二章:TrendCalculus

在数据科学家开始研究趋势成为一个热门话题之前,有一个更古老的概念,至今仍未得到很好的数据科学服务:趋势。目前,对趋势的分析,如果可以这样称呼的话,主要是由人们“用眼睛看”时间序列图表并提供解释。但人们的眼睛在做什么呢?

本章描述了在 Apache Spark 中实现的一种用于数值研究趋势的新算法,称为 TrendCalculus,由 Andrew Morgan 发明。原始的参考实现是用 Lua 语言编写的,并于 2015 年开源,代码可以在bitbucket.org/bytesumo/trendcalculus-public上查看。

本章解释了核心方法,它可以快速提取时间序列上的趋势变化点;这些是趋势改变方向的时刻。我们将详细描述我们的 TrendCalculus 算法,并在 Apache Spark 中实现它。结果是一组可扩展的函数,可以快速比较时间序列上的趋势,以推断趋势并检查不同时间范围内的相关性。使用这些颠覆性的新方法,我们演示了如何构建因果排名技术,以从成千上万的时间序列输入中提取潜在的因果模型。

在本章中,我们将学习:

  • 如何有效构建时间窗口摘要数据

  • 如何有效地总结时间序列数据以减少噪音,以进行进一步的趋势研究

  • 如何使用新的 TrendCalculus 算法从摘要数据中提取趋势反转变化点

  • 如何创建在复杂窗口功能创建的分区上操作的用户定义的聚合函数UDAFs),以及更常见的group by方法

  • 如何从 UDAFs 返回多个值

  • 如何使用滞后函数比较当前和先前的记录

当面临问题时,数据科学家首先考虑的假设之一与趋势有关;趋势是提供数据可视化的绝佳方式,特别适用于大型数据集,其中数据的一般变化方向通常是可见的。在第五章《用于地理分析的 Spark》中,我们制定了一个简单的算法来尝试预测原油价格。在那项研究中,我们集中于价格的变化方向,也就是价格的趋势。我们看到,趋势是一种自然的思考、解释和预测方式。

为了解释和演示我们的新趋势方法,本章分为两个部分。第一部分是技术性的,提供我们执行新算法所需的代码。第二部分是关于在真实数据上应用该方法。我们希望它能证明,趋势作为一个概念的表面简单性通常比我们最初想到的更复杂,特别是在存在噪音的情况下。噪音导致许多局部高点和低点(在本章中称为抖动),这可能使得确定趋势转折点和发现随时间变化的一般方向变得困难。忽略时间序列中的噪音,并提取可解释的趋势信号,提供了我们演示如何克服的核心挑战。

研究趋势

趋势的词典定义是某事物发展或变化的一般方向,但还有其他更专注的定义可能更有助于引导数据科学。其中两个定义来自研究社会趋势的 Salomé Areias 和欧盟官方统计机构 Eurostat:

“趋势是指在较长时期内缓慢变化,通常是几年,通常与影响所测量现象的结构性原因有关。” - 欧盟官方统计机构 EUROSTAT(ec.europa.eu/eurostat/statistics-explained/index.php/Glossary:Trend

“趋势是指行为或心态的转变,影响大量人群。” - Salomé Areias,社会趋势评论员(salomeareias.wordpress.com/what-is-a-trend/

我们通常认为趋势不过是股市价格的长期上涨或下跌。然而,趋势也可以指与经济、政治、流行文化和社会相关的许多其他用例:例如,媒体报道新闻时揭示的情绪研究。在本章中,我们将以石油价格作为简单的演示;然而,该技术可以应用于任何趋势发生的数据:

  • 上升趋势:当连续的峰值和低谷较高(高峰和低谷)时,称为向上或上升趋势。例如,以下图表中的第一个箭头是一系列峰值和低谷的结果,整体效果是增加。

  • 下降趋势:当连续的峰值和低谷较低(低峰和低谷)时,称为向下或下降趋势。例如,以下图表中的第二个箭头是一系列峰值和低谷的结果,整体效果是下降。

  • 水平趋势:这不是严格意义上的趋势,而是在任何方向上都没有明确定义的趋势。我们目前不特别关注这一点,但在本章后面会讨论。

研究趋势

注意

如果您搜索“higher highs”“higher lows”“trend”“lower highs”“lower lows”,您将看到超过 16,000 个结果,包括许多知名的金融网站。这是金融行业中趋势的标准做法和经验法则定义。

TrendCalculus 算法

在本节中,我们将使用第五章“地理分析的 Spark”中看到的布伦特原油价格数据集作为示例用例,解释 TrendCalculus 实现的细节。

趋势窗口

为了衡量任何类型的变化,我们必须首先以某种方式对其进行量化。对于趋势,我们将以以下方式定义:

  • 总体积极变化(通常表示为值增加)

Higher highs and higher lows => +1

  • 总体消极变化(通常表示为值减少)

Lower highs and lower lows => -1

因此,我们必须将我们的数据转换为趋势方向的时间序列,即+1 或-1。通过将我们的数据分割成一系列窗口,大小为n,我们可以计算每个窗口的日期高点和低点:

趋势窗口

由于这种窗口化在数据科学中是一种常见的做法,因此合理地认为 Spark 中一定有一个实现;如果您阅读了第五章,“地理分析的 Spark”,您将会看到它们,以 Spark SQL 窗口函数的形式。让我们读取一些布伦特原油数据,这种情况下只是日期和当天原油收盘价(示例数据位于我们的代码库中):

// Read in the data
val oilPriceDF = spark
   .read
   .option("header","true")
   .option("inferSchema", "true")
   .csv("brent_oil_prices.csv")

接下来,我们应该确保日期字段模式正确,以便我们可以在window函数中使用它。我们的示例数据集具有dd/MM/yyyy格式的String日期,因此我们将使用java.text.SimpleDateFormat将其转换为yyyy-MM-dd

// A date conversion UDF
def convertDate(date:String) : String = {
     val dt = new SimpleDateFormat("dd/MM/yyyy").parse(date)
     val newDate = new SimpleDateFormat("yyyy-MM-dd").format(dt)
     newDate
}

这将使我们能够创建一个用户定义函数UDF),我们可以用它来替换oilPriceDF DataFrame 中已有的日期列:

val convertDateUDF = udf {(Date: String) => convertDate(Date)}
val oilPriceDatedDF = oilPriceDF
    .withColumn("DATE", convertDate(oilPriceDF("DATE")))

作为一个快速的旁注,如果我们想要集中在数据的特定范围上,我们可以对其进行过滤:

val oilPriceDated2015DF = oilPriceDatedDF.filter("year(DATE)==2015")

现在我们可以使用 Spark 2.0 中引入的窗口函数来实现窗口:

val windowDF = oilPriceDatedDF.groupBy(
   window(oilPriceDatedDF.col("DATE"),"1 week", "1 week", "4 days"))

前述声明中的参数允许我们提供窗口大小、窗口偏移和数据偏移,因此这个模式实际上产生了一个带有数据开头偏移的滚动窗口。这样可以确保每个窗口都是构建的,以便始终包含星期一到星期五的数据(石油交易日),每个后续窗口都包含下一周的数据。

在这个阶段查看 DataFrame 以确保一切井然有序;我们不能像通常那样使用show方法,因为windowDF是一个RelationalGroupedDataset。因此,我们可以运行一个简单的内置函数来创建可读的输出。计算每个窗口的内容,显示前二十行并且不截断输出:

windowDF.count.show(20, false)

这将类似于这样:

+---------------------------------------------+-----+ 
|window                                       |count| 
+---------------------------------------------+-----+ 
|[2011-11-07 00:00:00.0,2011-11-14 00:00:00.0]|5    | 
|[2011-11-14 00:00:00.0,2011-11-21 00:00:00.0]|5    | 
|[2011-11-21 00:00:00.0,2011-11-28 00:00:00.0]|5    | 
+---------------------------------------------+-----+ 

这里,count 是窗口中的条目数,也就是我们的情况下的价格数。根据使用的数据,我们可能会发现一些窗口包含少于五个条目,因为数据缺失。我们将保留这些数据,否则输出中将会出现间断。

注意

在处理新数据集之前,绝对不能忽视数据质量,并且必须始终进行尽职调查,参见第四章探索性数据分析

更改窗口大小n(在本例中为 1 周)将调整我们的调查规模。例如,大小为 1 周的n将提供每周的变化,而大小为 1 年的n将提供每年的变化(每个窗口的大小将为:[交易的周数*5]使用我们的数据)。当然,这完全取决于数据集的结构,即是否为每小时或每日价格等。在本章后面,我们将看到如何可以轻松地迭代地检查趋势,将数据的变化点作为第二次迭代的输入。

简单趋势

现在我们有了窗口化的数据,我们可以计算每个窗口的+1 或-1 值(简单趋势),因此我们需要制定一个趋势计算方程。我们可以通过前面图表中的示例进行可视化处理:

简单趋势

对于计算出的窗口集,我们可以将当前窗口与上一个窗口进行比较,从而显示更高的高点、更低的低点和更低的高点、更低的低点。

我们通过从每个窗口中选择以下内容来实现这一点:

  • 最早的高价

  • 最新的低价

利用这些信息,我们可以推导出我们的 TrendCalculus 方程:

简单趋势

其中:

  • sign:是函数(x > 0)?1:((x < 0)?-1:0)

  • H:高

  • L:低

  • Pi:当前窗口

  • Pi -1:上一个窗口

例如,给定以下情景:

简单趋势

  • 简单趋势 = sign(sign(HighDiff) + sign(LowDiff))

  • 简单趋势 = sign(sign(1000-970) + sign(800-780))

  • 简单趋势 = sign(sign(30) + sign(20))

  • 简单趋势 = sign(1 + 1)

  • 简单趋势 = sign(2)

  • 简单趋势 = +1

也可能获得答案为 0。这将在本章后面详细解释,参见边缘案例

用户定义的聚合函数

有许多方法可以以编程方式执行上述任务,我们将看看用于聚合数据的 UDF(Spark UserDefinedAggregateFunction),以便我们可以使用先前收集的窗口化数据。

我们希望能够像以前的 UDF 示例一样在窗口上使用函数。但是,标准 UDF 是不可能的,因为我们的窗口被表示为RelationalGroupedDataset。在运行时,这样一个集合的数据可能保存在多个 Spark 节点上,因此函数是并行执行的,而不是 UDF 的数据必须是共同定位的。因此,UDAF 对我们来说是一个好消息,因为这意味着我们可以在程序逻辑中实现并行化效率的关注点被抽象化,并且代码将自动扩展到大规模数据集!

总之,我们希望输出最早的高价及其日期,以及最新的低价及其日期(对于每个窗口),以便我们可以使用这些数据来计算之前描述的简单趋势。我们将编写一个扩展UserDefinedAggregateFunction的 Scala 类,其中包含以下函数:

  • inputSchema:提供给函数的输入数据的结构

  • bufferSchema:为此实例保存的内部信息(聚合缓冲区)的结构

  • dataType:输出数据结构的类型

  • deterministic:函数是否是确定性的(即,相同的输入总是返回相同的输出)

  • initialize:聚合缓冲区的初始状态;合并两个初始缓冲区必须始终返回相同的初始状态

  • update:使用输入数据更新聚合缓冲区

  • merge:合并两个聚合缓冲区

  • evaluate:根据聚合缓冲区计算最终结果

我们的类的完整代码如下所示,请参阅前面的定义,以便在阅读时了解每个的目的。代码故意留得相当冗长,以便更容易理解功能。实际上,我们肯定可以重构updatemerge函数。

import java.text.SimpleDateFormat
import java.util.Date
import org.apache.spark.sql.Row
import org.apache.spark.sql.expressions.{MutableAggregationBuffer, UserDefinedAggregateFunction}
import org.apache.spark.sql.types._

class HighLowCalc extends UserDefinedAggregateFunction {

// we will input (date, price) tuples
def inputSchema: org.apache.spark.sql.types.StructType = StructType(
  StructField("date", StringType) ::
  StructField("price", DoubleType) :: Nil)

// these are the values we will keep a track of internally
def bufferSchema: StructType = StructType(
  StructField("HighestHighDate", StringType) ::
  StructField("HighestHighPrice", DoubleType) ::
  StructField("LowestLowDate", StringType) ::
  StructField("LowestLowPrice", DoubleType) :: Nil
)

// the schema of our final output data
def dataType: DataType = DataTypes.createStructType(
  Array(
    StructField("HighestHighDate", StringType),
    StructField("HighestHighPrice", DoubleType),
    StructField("LowestLowDate", StringType),
    StructField("LowestLowPrice", DoubleType)
  )
)

// this function is deterministic
def deterministic: Boolean = true

// define our initial state using the bufferSchema
def initialize(buffer: MutableAggregationBuffer): Unit = {
  // the date of the highest price so far
  buffer(0) = ""
  // the highest price seen so far
  buffer(1) = 0d
  // the date of the lowest price so far
  buffer(2) = ""
  // the lowest price seen so far
  buffer(3) = 1000000d
}

// how to behave given new input (date, price)
def update(buffer: MutableAggregationBuffer,input: Row): Unit = {

  // find out how the input price compares
  // to the current internal value - looking for highest price only
  (input.getDouble(1) compare buffer.getAsDouble).signum match {
    // if the input price is lower then do nothing
    case -1 => {}
    // if the input price is higher then update the internal status
    case  1 => {
      buffer(1) = input.getDouble(1)
      buffer(0) = input.getString(0)
    }
    // if the input price is the same then ensure we have the earliest date
    case  0 => {
      // if new date earlier than current date, replace
      (parseDate(input.getString(0)),parseDate(buffer.getAsString))
      match {
        case (Some(a), Some(b)) => {
          if(a.before(b)){
            buffer(0) = input.getString(0)
          }
        }
        // anything else do nothing
        case _ => {}
      }
    }
  }
  // now repeat to find the lowest price
  (input.getDouble(1) compare buffer.getAsDouble).signum match {
    // if the input price is lower then update the internal state
    case -1 => {
      buffer(3) = input.getDouble(1)
      buffer(2) = input.getString(0)
    }
    // if the input price is higher then do nothing
    case  1 => {}
    // if the input price is the same then ensure we have the latest date
    case  0 => {
      // if new date later than current date, replace
      (parseDate(input.getString(0)),parseDate(buffer.getAsString))
      match {
        case (Some(a), Some(b)) => {
          if(a.after(b)){
            buffer(2) = input.getString(0)
          }
        }
        // anything else do nothing
        case _ => {}
      }
    }
  }
}

// define the behaviour to merge two aggregation buffers together
def merge(buffer1: MutableAggregationBuffer, buffer2: Row): Unit = {
  // first deal with the high prices
  (buffer2.getDouble(1) compare buffer1.getAsDouble).signum match {
    case -1 => {}
    case  1 => {
      buffer1(1) = buffer2.getDouble(1)
      buffer1(0) = buffer2.getString(0)
    }
    case  0 => {
      // work out which date is earlier
      (parseDate(buffer2.getString(0)),parseDate(buffer1.getAsString))
      match {
        case (Some(a), Some(b)) => {
          if(a.before(b)){
            buffer1(0) = buffer2.getString(0)
          }
        }
        case _ => {}
      }
    }
  }
  // now deal with the low prices
  (buffer2.getDouble(3) compare buffer1.getAsDouble).signum match {
    case -1 => {
      buffer1(3) = buffer2.getDouble(3)
      buffer1(2) = buffer2.getString(2)
    }
    case  1 => {}
    case  0 => {
      // work out which date is later
      (parseDate(buffer2.getString(2)),parseDate(buffer1.getAsString))
      match {
        case (Some(a), Some(b)) => {
          if(a.after(b)){
            buffer1(2) = buffer2.getString(2)
          }
        }
        case _ => {}
      }
    }
  }
}

// when all is complete, output:
// (highestDate, highestPrice, lowestDate, lowestPrice)
def evaluate(buffer: Row): Any = {
  (buffer(0), buffer(1), buffer(2), buffer(3))
}

// convert a String to a Date for easy comparison
def parseDate(value: String): Option[Date] = {
  try {
    Some(new SimpleDateFormat("yyyy-MM-dd").parse(value))
  } catch {
    case e: Exception => None
  }
}

}

您会注意到signum函数的常见用法。这对于比较非常有用,因为它产生以下结果:

  • 如果第一个值小于第二个值,则输出-1

  • 如果第一个值大于第二个值,则输出+1

  • 如果两个值相等,则输出 0

当我们编写代码来计算实际的简单趋势值时,这个函数将在本章后面真正显示其价值。我们还使用了option类(在parseDate中),它使我们能够返回SomeNone的实例。这有许多优点:主要是通过消除立即检查空值来促进关注点的分离,还可以使用模式匹配,允许我们链式连接许多 Scala 函数,而无需冗长的类型检查。例如,如果我们编写一个返回Some(Int)None的函数,那么我们可以flatMap这些值而无需额外的检查:

List("1", "2", "a", "b", "3", "c").flatMap(a =>
   try {
      Some(Integer.parseInt(a.trim))
   } catch {
      case e: NumberFormatException => None
   }
}).sum

上述代码返回Int = 6

简单趋势计算

现在我们有了聚合函数,我们可以注册它并使用它来输出值到我们的 DataFrame:

val hlc = new HighLowCalc
spark.udf.register("hlc", hlc)

val highLowDF = windowDF.agg(expr("hlc(DATE,PRICE) as highLow"))
highLowDF.show(20, false)

生成类似于以下内容的输出:

+-----------------------------+----------------------+
|window                       |highLow               |        
|                             |                      |
+-----------------------------+----------------------+
|[2011-11-07 00:00:00.0,… ]   |[2011-11-08,115.61,… ]|
|[2011-11-14 00:00:00.0,… ]   |[2011-11-14,112.57,… ]|
|[2011-11-21 00:00:00.0,… ]   |[2011-11-22,107.77,… ]|

我们已经提到,我们需要将当前窗口与上一个窗口进行比较。我们可以通过实现 Spark 的lag函数创建一个包含上一个窗口详情的新 DataFrame:

// ensure our data is in correct date order by sorting
// on each first date in the window column window
// Struct contains the values start and end
val sortedWindow = Window.orderBy("window.start")

// define the lag of just one row
val lagCol = lag(col("highLow"), 1).over(sortedWindow)

// create a new DataFrame with the additional column "highLowPrev"
// where the previous row does not exist, null will be entered
val highLowPrevDF = highLowDF.withColumn("highLowPrev", lagCol)

现在我们有了一个 DataFrame,其中每一行都包含计算简单趋势值所需的所有信息。我们可以再次实现一个 UDF,这次使用先前提到的signum函数来表示简单趋势方程:

val simpleTrendFunc = udf {
  (currentHigh : Double, currentLow : Double,
   prevHigh : Double, prevLow : Double) => {
     (((currentHigh - prevHigh) compare 0).signum +
     ((currentLow - prevLow) compare 0).signum compare 0).signum }
}

最后,将 UDF 应用于我们的 DataFrame:

val simpleTrendDF = highLowPrevDF.withColumn("sign",   
    simpleTrendFunc(highLowPrevDF("highLow.HighestHighPrice"),
     highLowPrevDF("highLow.LowestLowPrice"),
     highLowPrevDF("highLowPrev.HighestHighPrice"),
     highLowPrevDF("highLowPrev.LowestLowPrice")
    )
)

// view the DataFrame
simpleTrendDF.show(20, false)

+----------------------+----------------------+-----+
|highLow               |highLowPrev           |sign |
+----------------------+----------------------+-----+
|2011-11-08,115.61,...|null                  |null |
|[2011-11-14,112.57,...|2011-11-08,115.61,... |-1   |
|[2011-11-22,107.77,...|[2011-11-14,112.57,...|1    |

反转规则

在所有识别的窗口上运行代码后,我们现在的数据表示为一系列+1 和-1,并且我们可以进一步分析这些数据以进一步了解趋势。您会注意到数据看起来是随机的,但我们可以识别出一个模式:趋势值经常翻转,要么从+1 到-1,要么从-1 到+1。在更仔细地检查这些点的图表时,我们可以看到这些翻转实际上代表了趋势的反转:

![反转规则

这可以总结如下:

  • 如果趋势从+1 移动到-1,则先前的高点是一个反转

  • 如果趋势从-1 移动到+1,则先前的低点是一个反转

使用这个简单的规则,我们可以输出一个新的时间序列,其中包含我们在比例上找到的反转点。在这个时间序列中,我们将创建元组(日期,价格),这些元组等同于+1 反转的更高高点和-1 反转的更低低点,如前面讨论的那样。我们可以通过使用与之前相同的方法来编写代码,即使用lag函数捕获先前的符号,并实现 UDF 来计算反转,如下所示:

// define the lag of just one row
val lagSignCol = lag(col("sign"), 1).over(sortedWindow)

// create a new DataFrame with the additional column signPrev
val lagSignColDF = simpleTrendDF.withColumn("signPrev", lagSignCol)

// define a UDF that calculates the reversals
val reversalFunc = udf {
  (currentSign : Int, prevSign : Int,
    prevHighPrice : Double, prevHighDate : String,
    prevLowPrice : Double, prevLowDate : String) => {
      (currentSign compare prevSign).signum match {
        case 0 => null
        // if the current SimpleTrend is less than the
        // previous, the previous high is a reversal
        case -1 => (prevHighDate, prevHighPrice)
        // if the current SimpleTrend is more than the
        // previous, the previous low is a reversal
        case 1 => (prevLowDate, prevLowPrice)
      }
    }
}

// use the UDF to create a new DataFrame with the
// additional column reversals
val reversalsDF = lagSignColDF.withColumn("reversals",
  reversalFunc(lagSignColDF("sign"),
    lagSignColDF("signPrev"),
    lagSignColDF("highLowPrev.HighestHighPrice"),
    lagSignColDF("highLowPrev.HighestHighDate"),
    lagSignColDF("highLowPrev.LowestLowPrice"),
    lagSignColDF("highLowPrev.LowestLowDate")
  )
)

reversalsDF.show(20, false)

+----------------------+------+--------+--------------------+
|highLowPrev           |sign  |signPrev|reversals           |
+----------------------+------+-----------------------------+
|null                  |null  |null    |null                |
|[2011-11-08,115.61,… ]|-1    |null    |null                |
|[2011-11-14,112.57,… ]|-1    |-1      |null                |
|[2011-11-22,107.77,… ]|1     |-1      |[2011-11-24,105.3]  |
|[2011-11-29,111.25,… ]|-1    |1       |[2011-11-29,111.25] |

总之,我们成功地从我们的价格数据中去除了抖动(非显著的上升和下降),并且我们可以从中受益,立即显示这些数据。它肯定会显示原始数据集的简化表示,并且假设我们主要关注价格显著变化的点,它保留了与重要峰值和谷值相关的关键信息。然而,我们可以做更多的工作来以一种可呈现和易于阅读的方式表示数据。

引入 FHLS 条形结构

在金融领域,开盘价、最高价、最低价、收盘价OHLC)图表非常常见,因为它们显示了每个分析师所需的关键数据;物品的开盘价和收盘价,以及该时期的最高价和最低价(通常为一天)。我们可以利用这个想法来达到我们自己的目的。第一、最高、最低、第二FHLS)图表将使我们能够可视化我们的数据并在此基础上产生新的见解。

FHLS 数据格式描述如下:

  • 开放日期

  • 首先是高/低值 - 无论是高点还是低点先出现

  • 高值

  • 低值

  • 第二个高/低值 - 高/低值中的另一个值先出现

  • 高日期

  • 低日期

  • 关闭日期

我们几乎已经在先前描述的reversalsDF中获得了所有需要的数据,我们尚未确定的只有第一和第二值,也就是在任何给定窗口中最高或最低价格是先出现的。我们可以使用 UDF 或选择语句来计算这一点,但是更新之前的UserDefinedAggregateFunction将使我们能够进行小的更改,同时确保方法的高效性。只有评估函数需要更改:

def evaluate(buffer: Row): Any = {
  // compare the highest and lowest dates
  (parseDate(buffer.getString(0)), parseDate(buffer.getString(2))) match {
     case (Some(a), Some(b)) => {
       // if the highest date is the earlier
       if(a.before(b)){
         // highest date, highest price, lowest date,
         // lowest price, first(highest price), second
         (buffer(0), buffer(1), buffer(2), buffer(3), buffer(1), buffer(3))
       }
       else {
         // the lowest date is earlier or they are
         // both the same (shouldn’t be possible)
         // highest date, highest price, lowest date,
         // lowest price, first(lowest price), second
         (buffer(0), buffer(1), buffer(2), buffer(3), buffer(3), buffer(1))
       }
     }
     // we couldn’t parse one or both of the dates -shouldn’t reach here
     case _ =>
       (buffer(0), buffer(1), buffer(2), buffer(3), buffer(1), buffer(3))
  }
}

最后,我们可以编写一个语句来选择所需的字段并将我们的数据写入文件:

val fhlsSelectDF = reversalsDF.select(
 "window.start",
 "highLow.firstPrice",
 "highLow.HighestHighPrice",
 "highLow.LowestLowPrice",
 "highLow.secondPrice",
 "highLow.HighestHighDate",
 "highLow.LowestLowDate",
 "window.end",
 "reversals._1",
 "reversals._2")

您会注意到反转列不像其他列一样实现了Struct,而是一个元组。如果您检查reversalsUDF,您将看到是如何做到的。为了演示目的,我们将展示如何在选择后重命名组件字段:

val lookup = Map("_1" -> "reversalDate", "_2" -> "reversalPrice")
val fhlsDF = fhlsSelectDF.select { fhlsSelectDF.columns.map(c =>
   col(c).as(lookup.getOrElse(c, c))):_*
}
fhlsDF.orderBy(asc("start")).show(20, false)

将数据写入文件:

   fhlsDF.write
     .format("com.databricks.spark.csv")
     .option("header", "true")
     .save("fhls");

您可以通过添加以下行对数据进行加密:

.option("codec", "org.apache.hadoop.io.compress.CryptoCodec")

这个重要的编解码器和其他安全相关技术在第十三章 安全数据中有描述。

可视化数据

现在我们已经有了文件中的数据,我们可以利用这个机会来展示它;有许多可用于创建图表的软件包,作为一名数据科学家,其中一个关键的软件包就是 D3.js。正如我们在本书的其他部分提到的 D3 一样,我们的目的不是在这里探索比必要的更多的细节,而是产生我们最终结果所需的。也就是说,值得概述的是,D3 是一个基于数据操作文档的 JavaScript 库,生态系统中有许多贡献者,因此可用的数据可视化数量是巨大的。了解基础知识将使我们能够以相对较少的努力提供真正令人印象深刻的结果。

使用 FHLS 格式,我们可以说服图表软件接受我们的数据,就好像它是 OHLC 格式的一样。因此,我们应该搜索互联网上可以使用的 D3 OHLC 库。在这个例子中,我们选择了techanjs.org,因为它不仅提供 OHLC,还提供了一些其他可能在以后有用的可视化。

实现 D3 代码通常就是将其剪切并粘贴到一个文本文件中,修改源代码中的任何数据目录路径。如果您以前从未在这个领域工作过,下面有一些有用的提示,可以帮助您入门:

  • 如果您正在使用 Chrome 浏览器的 Web 技术,可以在**选项** | 更多工具开发者工具 下找到一组非常有用的工具。即使没有其他内容,这也将提供您尝试运行的代码的错误输出,否则将会丢失,使得调试空白页面的结果更加容易。

  • 如果您的代码使用单个文件,就像下面的示例一样,请始终使用index.html作为文件名。

  • 如果您的代码引用本地文件,通常在实现 D3 时会这样,您需要运行一个 Web 服务器,以便它们可以被提供。默认情况下,Web 浏览器无法访问本地文件,因为存在固有的安全风险(恶意代码访问本地文件)。运行 Web 服务器的简单方法是在代码的源目录中执行:nohup python -m SimpleHTTPServer &。绝对不要让浏览器访问本地文件,因为这将使其完全暴露于攻击之下。例如,不要运行:chrome --allow-file-access-from-files

  • 在源代码中使用 D3 时,尽可能始终使用<script src="img/d3.v4.min.js"></script>来确保导入库的最新版本。

我们可以直接使用代码,唯一需要改变的是引用列的方式:

data = data.slice(0, 200).map(function(d) {
  return {
    date: parseDate(d.start),
    open: +d.firstPrice,
    high: +d.HighestHighPrice,
    low: +d.LowestLowPrice,
    close: +d.SecondPrice
  };
});

这将产生一个类似于这样的图表:

可视化数据

在这个图表上,绿色的条表示从第一个低价到第二个高价的增加,红色的条表示从第一个高价第二个低价的减少。这种与典型 OHLC 图表的微妙变化至关重要。一眼就能看到时间序列在总结条上的上升和下降流动。这有助于我们理解价格在我们固定的查询尺度或窗口大小上的上升和下降流动,而无需像在原始价格值的线图上那样解释时间尺度的影响。结果图表提供了一种减少较小时间框架上的噪音的方法,以一种整洁且可重复的方式对我们的时间序列进行可视化总结。然而,我们仍然可以做更多。

带有反转的 FHLS

我们之前使用我们的 TrendCalculus 方程计算了趋势反转,并将其与上面的 FHLS 摘要数据一起绘制,这将真正增强我们的可视化效果,显示高/低条和趋势反转点。我们可以通过修改我们的 D3 代码来实现 D3 散点图代码。所需的代码可以在互联网上的许多地方找到,就像以前一样;我们下面有一些代码,可以通过将相关部分添加到<script>中来集成。

添加reversalPrice字段:

data = data.slice(0, 200).map(function(d) {
  return {
    date: parseDate(d.start),
    open: +d.firstPrice,
    high: +d.HighestHighPrice,
    low: +d.LowestLowPrice,
    close: +d.secondPrice,
    price: +d.reversalPrice
  };
}).sort(function(a, b) {
  return d3.ascending(accessor.d(a), accessor.d(b));
});

并绘制点:

svg.selectAll(".dot")
  .data(data)
  .enter().append("circle")
  .attr("class", "dot")
  .attr("r", 1)
  .attr("cx", function(d) { return x(d.date); })
  .attr("cy", function(d) { return y(d.price); })
  .style("fill","black"); 

一旦成功集成,我们将看到一个类似于这样的图表:

带有反转的 FHLS

或者,反转可以使用简单的折线图非常有效。以下是一个这样的图表示例,用于演示趋势反转绘图的视觉影响:

带有反转的 FHLS

边界情况

在我们之前的计算中,我们简要提到在执行简单趋势算法时可能产生值 0。根据我们的算法,这可能发生在以下情况下:

  • sign ( -1 + (+1) )

  • sign ( +1 + (-1) )

  • sign ( 0 + (0) )

通过一个示例图,我们可以使用我们的算法识别出以下值:

边界情况

注意

在货币市场中,我们可以将每个窗口识别为内部条或外部条。内部是定义市场不确定性的条,没有更高的高点或更低的低点。外部是已经达到更高的高点或更低的低点;当然,这些术语只能在数据可用时分配。

到目前为止,我们所看到的这些零似乎会破坏我们的算法。然而,事实并非如此,实际上有一个有效的解决方案,使我们能够考虑到它们。

零值

在审查以前的图表时,我们可以想象价格在 FHLS 条形图上所走过的路径,这一过程变得容易,因为绿色条表示时间上的价格上涨,红色条表示时间上的价格下跌。了解时间路径如何帮助解决零趋势问题?有一个简单的答案,但不一定直观。

我们之前一直记录了我们数据处理过程中所有高点和低点的日期;尽管我们没有使用所有的日期。我们使用这些日期计算出的第一个第二值实际上指示了该局部趋势的流动或方向,如下图所示,一旦你研究了一段时间的摘要图表,你的眼睛自然会随着这种流动来解释时间序列:

零值

如果我们看下一个图表,我们会发现我们的眼睛如何解释时间流动的虚线不仅仅是暗示的。在我们的日期高点和低点之间,有一些数据值没有被我们特别构建的条形图总结,这意味着条形图之间存在时间间隙。我们可以利用这一特性来解决问题。考虑以下图表,加上价格线:

零值

填补间隙

使用同一个示例的延续,我们将取出一个已识别的间隙,并演示我们可以用来填补它们的方法:

填补间隙

步骤如下:

  • 找到 0 趋势(内/外部条)

  • 为了填补由于从前一个窗口借用第二个值和从当前窗口借用第一个值而暗示的间隙,插入一个新的 FHLS 摘要(见前面的图表)

  • 在正常的 FHLS 构建过程中发出这些特殊的条形图,按照常规的高/低窗口格式化它们,并使用它们以正常的方式找到趋势

现在我们已经创建了一个新的条形图,我们可以以已定义的方式使用它;我们方程式的一个标志(高差或低差)将有一个值为 0,另一个现在将是+1 或-1。然后进行反转计算。在前面的例子中,问号在我们的新系统下变成了-1,因为我们找到了一个更低的低点;因此最后一个高点是一个反转。

我们可以修改代码,从我们之前的努力中的simpleTrendDF开始:

  1. 过滤所有标志为 0 的行。

val zeroSignRowsDF = simpleTrendDF.filter("sign == 0").

  1. 删除 sign 列,因为我们将使用这个新 DataFrame 的模式。

val zeroRowsDF = zeroSignRowsDF.drop("sign").

  1. 迭代每一行并输出已经以以下方式修改的更新行:

窗口开始日期是highLowPrev列中第二个值的日期

window.end日期可以保持不变,因为它在 FHLS 计算中没有被使用。

highLow条目构造如下:

  1. HighestHighDate第一个highLow日期和第二highLowPrev日期中较早的日期

  2. HighestHighPrice:与上述相关的价格

  3. LowestLowDate第一个highLow日期和第二highLowPrev日期中较晚的日期

  4. LowestLowPrice:与上述相关的价格

  5. firstPrice:与最早的新highLow日期相关的价格

  6. secondPrice:与最新的highLow日期相关的价格

highLowPrev列可以保留,因为它将在下一步中被删除

val tempHighLowDF =
spark.createDataFrame(highLowDF.rdd.map(x => {
               RowFactory.create(x.getAs("window")., x.getAs("highLow"),
                                 x.getAs("highLowPrev"))

             }), highLowDF.schema)
  1. 删除highLowPrev

val newHighLowDF = tempHighLowDF.drop("highLowPrev")

  1. 将新的 DataFrame 与highLowDF联合,这将插入新的行

val updatedHighLowDF = newHighLowDF.union(highLowDF)

  1. 继续使用updatedHighLowDF而不是highLowDF进行简单的趋势处理,并从以下开始:

val sortedWindow = Window.orderBy("window.start")

继续前面的例子,我们可以看到(可能)不再有零值,反转仍然清晰且快速计算。如果选择的时间窗口非常小,例如秒或分钟,则输出中可能仍然有零值,表明价格在该时段内没有变化。可以重复间隙处理,或者将窗口的大小更改为延长静态价格期间的大小:

填补间隙

我们已经使用 D3 看到了时间序列,但现在可以使用图表软件来显示新添加的覆盖隐含间隙的条形图,这些条形图显示在下图中的白色条形图中。总体结果非常直观,我们可以很容易地用肉眼看到趋势及其反转:

填补间隙

可堆叠处理

现在我们有了这个功能,我们可以将趋势反转列表视为算法的第二次输入。为此,我们可以调整我们的窗口函数,使输入成为 N 个有序观察的窗口,而不是固定的时间块。如果这样做,我们可以堆叠并创建多尺度趋势树 TrendCalculus,这意味着我们可以将算法的输出反馈到后续的处理中。这将创建一个多尺度的反转查找器。以这种堆叠的方式进行多次处理是一种高效的过程,因为后续处理中固有的数据减少。通过多次运行,分区会自下而上地构建成一个分层结构。通过这种方式工作,我们可以使用这种方法来根据我们需要的详细程度缩放长期和短期的趋势范围;随着我们缩放,趋势模式变得更容易用肉眼看到。

从我们的reversalsDF DataFrame 中选择相关数据将使我们能够简单地再次运行该过程;highLow列包含:

  • HighestHigh的日期和价格

  • LowestLow的日期和价格

可以选择并输出为一个包含(日期,价格)的文件;正是我们用来摄取原始文件的格式:

val newColumnNames = Seq("DATE", "PRICE")

val highLowHighestDF = simpleTrendDF.select("highLow.HighestHighDate", "highLow.HighestHighPrice").toDF(newColumnNames:_*)

val highLowLowestDF = simpleTrendDF.select("highLow.LowestLowDate", "highLow.LowestLowPrice").toDF(newColumnNames:_*)

val stackedDF = highLowHighestDF.union(highLowLowestDF)

stackedDF.write
     .option("header", "true")
     .csv("stackData.csv")

让我们回顾一下我们已经构建的内容:

  • 我们已经构建了代码来处理时间序列,并有效地将其总结为固定时间窗口内的日期高点和低点

  • 我们已经为每个时间窗口分配了正向或负向趋势

  • 我们有一种处理边缘情况的方法,消除了零值趋势问题

  • 我们有一个计算方法来找到实际的时间点,以及趋势反转发生时的价格数值。

这样做的效果是,我们构建了一种非常快速的代理方法,可以将我们的时间序列简化为类似分段线性回归的压缩形式。从另一个角度来看,趋势逆转列表代表了我们的时间序列的简化形式,忽略了小时间尺度上的噪音。

实际应用

现在我们已经编写了我们的算法,让我们看看这种方法在真实数据上的实际应用。我们将首先了解算法的性能,以便确定我们可能在哪里使用它。

算法特性

那么,这种算法的特点是什么?以下是其优势和劣势的列表。

优点

优点如下:

  • 该算法是通用的,非常适合基于流和 Spark 的实现。

  • 该理论简单而有效

  • 实现速度快且高效

  • 结果是可视化和可解释的

  • 该方法可堆叠,并允许进行多尺度研究;在使用 Spark 窗口时非常简单

缺点

缺点如下:

  • 滞后指标,该算法找到了过去发生的趋势逆转,并不能直接用于预测趋势变化

  • 滞后累积到更高的尺度,意味着需要更多的数据(因此需要更多的时间滞后)才能找到长期趋势变化,而不是在较短时间尺度上找到趋势逆转

了解该算法的局限性很重要。我们已经创建了一个非常有用的分析工具,可用于研究趋势。但是,它本身并不是一个预测工具,而是一个更容易识别趋势以进行后续处理的工具。

可能的用例

有了我们新发现的将时间序列转换为变化点列表的能力,许多曾经困难的用例变得容易。让我们看看一些潜在的应用。

图表注释

我们可以在趋势变化发生时,即在主要高点或低点,从 GDELT feed 中检索新闻标题,从而为我们的图表添加上下文。

共同趋势

我们可以利用噪音的减少来比较不同时间序列的趋势,并设计计算来衡量哪些是共同趋势。

数据减少

我们可以使用该算法简化时间序列并减少数据量,同时保留关键时刻,堆叠该算法可以实现更大的减少。

索引

我们可以将变化点视为时间序列的一种新形式的指数,例如,允许检索数据的部分,其中短时间内的事物与长时间内的趋势相反。

分形维度

我们可以在不同的时间尺度上找到变化点,并使用信息来研究时间序列的分形维度。

分段线性回归的流式代理

该方法可以作为计算分段线性回归的一种非常快速的方法,需要这种方法时。

总结

在本章中,我们介绍了使用 TrendCalculus 分析趋势的方法。我们概述了尽管趋势分析是一个非常常见的用例,但除了非常通用的可视化软件外,几乎没有工具可以帮助数据科学家进行这种分析。我们引导读者了解了 TrendCalculus 算法,演示了我们如何在 Spark 中实现理论的高效可扩展性。我们描述了识别算法的关键输出的过程:在命名尺度上的趋势逆转。在计算了逆转之后,我们使用 D3.js 可视化了已经总结为一周窗口的时间序列数据,并绘制了趋势逆转。本章继续解释了如何克服主要的边缘情况:在简单趋势计算中发现的零值。最后,我们简要概述了算法特性和潜在用例,演示了该方法是优雅的,可以在 Spark 中轻松描述和实现。

在下一章中,我们将揭秘数据安全的话题。我们将从数据科学的角度描述安全的最重要领域,集中讨论高度机密数据处理的理论和实施授权访问。

第十三章:安全数据

在本书中,我们访问了许多数据科学领域,通常涉及那些传统上与数据科学家的核心工作知识不太相关的领域。特别是,我们专门在第二章 数据获取中,解释了如何解决一个始终存在但很少被承认或充分解决的问题,即数据摄取。在本章中,我们将访问另一个经常被忽视的领域,即安全数据。更具体地说,如何在数据生命周期的所有阶段保护您的数据和分析结果。这从摄取开始,一直到呈现,始终考虑到自然形成 Spark 范例的重要架构和可扩展性要求。

在本章中,我们将涵盖以下主题:

  • 如何使用 HDFS ACL 实现粗粒度数据访问控制

  • 使用 Hadoop 生态系统进行细粒度安全的指南和解释

  • 如何确保数据始终加密,以 Java KeyStore 为例

  • 混淆、掩码和令牌化数据的技术

  • Spark 如何实现 Kerberos

  • 数据安全-道德和技术问题

数据安全

我们数据架构的最后一部分是安全性,在本章中我们将发现数据安全性总是重要的,以及其原因。由于最近由许多因素引起的数据量和种类的巨大增加,但其中不乏因互联网及相关技术的普及所致,因此需要提供完全可扩展和安全的解决方案。我们将探讨这些解决方案以及与数据的存储、处理和处理相关的机密性、隐私和法律问题;我们将把这些与之前章节介绍的工具和技术联系起来。

我们将继续解释涉及在规模上保护数据的技术问题,并介绍使用各种访问、分类和混淆策略来解决这些问题的想法和技术。与之前的章节一样,这些想法将通过 Hadoop 生态系统的示例进行演示,并且公共云基础设施策略也将被介绍。

问题

在之前的章节中,我们探讨了许多不同的主题,通常集中在特定问题的细节和解决方法上。在所有这些情况下,都存在一个隐含的想法,即正在使用的数据和收集的见解内容不需要以任何方式进行保护;或者至少操作系统级别提供的保护,如登录凭据,是足够的。

在任何环境中,无论是家庭还是商业环境,数据安全都是一个必须始终考虑的重大问题。也许,在某些情况下,将数据写入本地硬盘并不再采取进一步的步骤就足够了;这很少是一种可以接受的做法,而且肯定应该是一种有意识的决定,而不是默认行为。在商业环境中,计算资源通常具有内置的安全性。在这种情况下,用户仍然重要理解这些影响,并决定是否应采取进一步的步骤;数据安全不仅仅是关于保护免受恶意实体或意外删除,还包括其中的一切。

例如,如果您在一个安全、受监管的商业空隙环境(无法访问互联网)中工作,并且在一个志同道合的数据科学家团队中工作,个人安全责任仍然与根本不存在安全性的环境中一样重要;您可能可以访问不得被同行查看的数据,并且可能需要生成可供不同和多样化用户组使用的分析结果,所有这些用户组都不得查看彼此的数据。强调可能明确或隐含地放在您身上,以确保数据不受损害;因此,对软件堆栈中的安全层有深刻的理解是至关重要的。

基础知识

安全考虑无处不在,甚至在您可能根本没有考虑过的地方。例如,当 Spark 在集群上运行并行作业时,您知道数据在生命周期中可能触及物理磁盘的时刻吗?如果您认为一切都是在 RAM 中完成的,那么您可能会在那里遇到潜在的安全问题,因为数据可能会泄漏到磁盘上。在本章的后面将更多地讨论这一点的影响。这里的要点是,您不能总是将安全责任委托给您正在使用的框架。事实上,您使用的软件越多,安全问题就越多,无论是用户还是数据相关的安全问题。

安全性可以大致分为三个领域:

  • 认证:确定用户身份的合法性

  • 授权:用户执行特定操作的权限

  • 访问:用于保护数据的安全机制,无论是在传输过程中还是在静止状态下

这些观点之间存在重要差异。用户可能具有访问和编辑文件的完全权限,但如果文件在用户安全领域之外加密,则文件可能仍然无法读取;用户授权会介入。同样,用户可能通过安全链接发送数据到远程服务器进行处理,然后返回结果,但这并不保证数据没有留下痕迹在远程服务器上;安全机制是未知的。

认证和授权

认证与机制有关,用于确保用户是其所说的人,并在两个关键级别上运行,即本地和远程。

认证可以采用各种形式,最常见的是用户登录,但其他例子包括指纹识别、虹膜扫描和 PIN 码输入。用户登录可以在本地基础上进行管理,例如在个人计算机上,或者在远程基础上使用诸如轻量级目录访问协议LDAP)之类的工具。远程管理用户提供了独立于任何特定硬件的漫游用户配置文件,并且可以独立于用户进行管理。所有这些方法都在操作系统级别执行。还有其他机制位于应用程序层,并为服务提供认证,例如 Google OAuth。

替代的身份验证方法各有优缺点,应该在宣称一个安全系统之前充分了解特定的实现方式;例如,指纹系统可能看起来非常安全,但情况并非总是如此。有关更多信息,请参阅www.cse.msu.edu/rgroups/biometrics/Publications/Fingerprint/CaoJain_HackingMobilePhonesUsing2DPrintedFingerprint_MSU-CSE-16-2.pdf。我们不会在这里进一步探讨身份验证,因为我们已经假设大多数系统只会实现用户登录;顺便说一句,这通常并不是一个安全的解决方案,实际上,在许多情况下根本没有提供安全性。有关更多信息,请参阅www.cs.arizona.edu/~collberg/Teaching/466-566/2012/Resources/presentations/2012/topic7-final/report.pdf

授权是我们非常感兴趣的一个领域,因为它构成了基本安全的关键部分,是我们最常控制的领域,并且是我们可以在任何现代操作系统中原生使用的东西。有各种不同的资源授权实现方式,其中两种主要方式是:

  • 访问控制列表ACL

  • 基于角色的访问控制RBAC

我们将依次讨论每一条规则。

访问控制列表(ACL)

在 Unix 中,ACL 在整个文件系统中都被使用。如果我们在命令行中列出目录内容:

drwxr-xr-x 6 mrh mygroup 204 16 Jun 2015 resources

我们可以看到有一个名为资源的目录,分配了所有者(mrh)和组(mygroup),有6个链接,大小为204字节,最后修改日期为2015 年 6 月 16 日。ACL drwxr-xr-x表示:

  • d这是一个目录(-如果不是)

  • rwx所有者(mrh)具有读取、写入和执行权限

  • r-x组中的任何人(mygroup)都有读取和执行权限

  • r-x其他所有人都有读取和执行权限

使用 ACL 是保护我们的数据的一个很好的第一步。它应该始终是首要考虑的事情,并且应该始终是正确的;如果我们不始终确保这些设置是正确的,那么我们可能会让其他用户轻松访问这些数据,而我们并不一定知道系统上的其他用户是谁。始终避免在 ACL 的all部分提供完全访问权限:

-rwx---rwx 6 mrh mygroup 204 16 Jun 2015 secretFile.txt

无论我们的系统有多安全,只要有权限访问文件系统的用户都可以读取、写入和删除这个文件!一个更合适的设置是:

-rwxr----- 6 mrh mygroup 204 16 Jun 2015 secretFile.txt

这为所有者提供了完全的访问权限,并为组提供了只读权限。

HDFS 原生实现了 ACL;这些可以使用命令行进行管理:

hdfs dfs -chmod 777 /path/to/my/file.txt

这为 HDFS 中的文件提供了所有人的完全权限,假设文件已经具有足够的权限让我们进行更改。

注意

当 Apache 在 2008 年发布 Hadoop 时,人们经常不理解,集群设置为所有默认值时不会对用户进行任何身份验证。如果集群没有正确配置,Hadoop 中的超级用户hdfs可以被任何用户访问,只需在客户机上创建一个hdfs用户(sudo useradd hdfs)。

基于角色的访问控制(RBAC)

RBAC 采用了一种不同的方法,通过为用户分配一个或多个角色。这些角色与常见任务或工作职能相关,因此可以根据用户的责任轻松添加或删除。例如,在公司中可能有许多角色,包括账户、库存和交付。会计可能会被赋予这三个角色,以便他们可以编制年终财务报表,而负责交付预订的管理员只会有交付角色。这样可以更轻松地添加新用户,并在他们更换部门或离开组织时管理用户。

RBAC 定义了三条关键规则:

  • 角色分配:用户只有在选择或被分配角色后才能行使权限。

  • 角色授权:用户的活动角色必须经过授权。

  • 权限授权:用户只能行使权限,如果该权限已经为用户的活动角色授权。

用户和角色之间的关系可以总结如下:

  • 角色-权限:特定角色向用户授予特定权限。

  • 用户-角色:用户类型和特定角色之间的关系。

  • 角色-角色:角色之间的关系。这些关系可以是层次化的,所以role1 => role2可能意味着,如果用户有role1,那么他们自动拥有role2,但如果他们有role2,这并不一定意味着他们有role1

RBAC 通过 Apache Sentry 在 Hadoop 中实现。组织可以定义对数据集的特权,这些特权将从多个访问路径(包括 HDFS、Apache Hive、Impala,以及通过 HCatalog 的 Apache Pig 和 Apache MapReduce/Yarn)强制执行。例如,每个 Spark 应用程序都作为请求用户运行,并需要访问底层文件。Spark 无法直接执行访问控制,因为它作为请求用户运行并且不受信任。因此,它受限于文件系统权限(ACL)。在这种情况下,Apache Sentry 为资源提供基于角色的控制。

访问

到目前为止,我们只集中在确保用户是他们所说的人,只有正确的用户才能查看和使用数据的具体想法上。然而,一旦我们采取了适当的步骤并确认了这些细节,我们仍然需要确保用户在实际使用数据时数据是安全的;有许多方面需要考虑:

  • 用户是否被允许查看数据中的所有信息?也许他们只能限制在某些行,甚至是某些行的某些部分。

  • 当用户在数据上运行分析时,数据是否安全?我们需要确保数据不以明文传输,因此容易受到中间人攻击。

  • 用户完成任务后,数据是否安全?确保数据在所有阶段都非常安全是没有意义的,只有将明文结果写入不安全的区域。

  • 可以从数据的聚合中得出结论吗?即使用户只能访问数据集的某些行,比如在这种情况下保护个人隐私,有时也可能在看似无关的信息之间建立联系。例如,如果用户知道A=>BB=>C,他们可能猜测,A=>C,即使他们不被允许在数据中看到这一点。实际上,这种问题很难避免,因为数据聚合问题可能非常微妙,发生在意想不到的情况下,通常涉及在较长时间内获取的信息。

我们可以使用一些机制来帮助我们防止上述情况。

加密

可以说,保护数据最明显和最知名的方法是加密。无论我们的数据是在传输中还是静止状态,我们都会使用它,所以除了数据实际在内存中被处理时,几乎所有时间都会使用。加密的机制取决于数据的状态。

静态数据

我们的数据总是需要存储在某个地方,无论是 HDFS、S3 还是本地磁盘。如果我们已经采取了所有必要的预防措施,确保用户已经得到授权和认证,仍然存在明文实际存在于磁盘上的问题。通过物理方式或通过 OSI 堆栈中的较低级别访问磁盘,非常容易流式传输整个内容并获取明文数据。

如果我们加密数据,那么我们就可以免受这种类型的攻击。加密也可以存在于不同的层面,可以通过软件在应用程序层对数据进行加密,也可以通过硬件级别对数据进行加密,也就是磁盘本身。

在应用程序层对数据进行加密是最常见的路线,因为它使用户能够对需要做出的权衡决策做出明智的选择,从而为他们的情况做出正确的产品选择。因为加密增加了额外的处理开销(数据需要在写入时加密并在读取时解密),因此在处理器时间与安全强度之间需要做出关键决策。需要考虑的主要决策有:

  • 加密算法类型:用于执行加密的算法,即 AES、RSA 等

  • 加密密钥位长度:加密密钥的大小大致相当于破解的难度,但也影响结果的大小(可能的存储考虑),即 64 位、128 位等。

  • 处理器时间允许的时间:较长的加密密钥通常意味着更长的处理时间;鉴于足够大的数据量,这可能会对处理产生严重影响

一旦我们确定了我们的用例的正确因素组合,要记住,一些算法密钥长度组合不再被认为是安全的,我们需要软件来实际进行加密。这可以是一个定制的 Hadoop 插件或商业应用程序。正如前面提到的,Hadoop 现在有一个本地的 HDFS 加密插件,因此您不需要编写自己的插件!该插件使用 Java KeyStore 安全存储加密密钥,可以通过 Apache Ranger 访问。加密完全在 HDFS 内部进行,并且基本上与文件的 ACLs 相关联。因此,在 Spark 中访问 HDFS 文件时,该过程是无缝的(除了加密/解密文件需要额外的时间)。

如果您希望在 Spark 中实现加密以将数据写入未在上述情况中涵盖的地方,则可以使用 Java javax.crypto 包。这里最薄弱的环节现在是密钥本身必须被记录在某个地方;因此,我们可能只是把我们的安全问题简单地转移到了其他地方。使用适当的 KeyStore,例如 Java KeyStore,可以解决这个问题。

在撰写本文时,尚无明显的方法可以在从 Spark 写入本地磁盘时加密数据。在下一节中,我们将自己编写!

这个想法是用尽可能接近原始的方式替换rdd.saveAsTextFile(filePath)函数,并进一步具备加密数据的能力。然而,这还不是全部,因为我们还需要能够读取数据。为此,我们将利用rdd.saveAsTextFile(filePath)函数的替代方案,该函数还接受压缩编解码器参数:

saveAsTextFile(filePath, Class<? extends
     org.apache.hadoop.io.compress.CompressionCodec> codec)

从表面上看,Spark 使用压缩编解码器的方式似乎与我们对数据加密的要求相似。因此,让我们为我们的目的调整现有的 Hadoop 压缩实现之一。查看几种不同的现有实现(GzipCodecBZip2Codec),我们发现我们必须扩展CompressionCodec接口以派生我们的加密编解码器,从现在起命名为CryptoCodec。让我们看一个 Java 实现:

import org.apache.hadoop.io.compress.crypto.CryptoCompressor;
import org.apache.hadoop.io.compress.crypto.CryptoDecompressor;

public class CryptoCodec implements CompressionCodec, Configurable {

    public static final String CRYPTO_DEFAULT_EXT = ".crypto";
    private Configuration config;

    @Override
    public Compressor createCompressor() {
        return new CryptoCompressor();
    }
    @Override
    public Decompressor createDecompressor() {
        return new CryptoDecompressor();
    }
    @Override
    public CompressionInputStream createInputStream(InputStream in)
          throws IOException {
        return createInputStream(in, createDecompressor());
    }
    @Override
    public CompressionInputStream createInputStream(InputStream in,
          Decompressor decomp) throws IOException {
        return new DecompressorStream(in, decomp);
    }
    @Override
    public CompressionOutputStream createOutputStream(OutputStream out)
          throws IOException {
        return createOutputStream(out, createCompressor());
    }
    @Override
    public CompressionOutputStream createOutputStream(OutputStream out,
          Compressor comp) throws IOException {
        return new CompressorStream(out, comp);
    }
    @Override
    public Class<? extends Compressor> getCompressorType() {
        return CryptoCompressor.class;
    }
    @Override
    public Class<? extends Decompressor> getDecompressorType() {
        return CryptoDecompressor.class;
    }
    @Override
    public String getDefaultExtension() {
        return CRYPTO_DEFAULT_EXT;
    }
    @Override
    public Configuration getConf() {
        return this.config;
    }
    @Override
    public void setConf(Configuration config) {
        this.config = config;
    }
}

值得注意的是,这个编解码器类只是作为一个包装器,用于将我们的加密和解密例程与 Hadoop API 集成;当调用加密编解码器时,这个类提供了 Hadoop 框架使用的入口点。两个主要感兴趣的方法是createCompressorcreateDeompressor,它们都执行相同的初始化:

public CryptoCompressor() { 
    crypto = new EncryptionUtils(); } 

我们已经使用明文密码使事情变得更简单。在使用此代码时,加密密钥应该从安全存储中提取;这在本章后面将详细讨论:

public EncryptionUtils() {
    this.setupCrypto(getPassword());
}

private String getPassword() {
    // Use a Java KeyStore as per the below code, a Database or any other secure mechanism to obtain a password
    // TODO We will return a hard coded String for simplicity
    return "keystorepassword";
}

private void setupCrypto(String password) {
    IvParameterSpec paramSpec = new IvParameterSpec(generateIV());
    skeySpec = new SecretKeySpec(password.getBytes("UTF-8"), "AES");
    ecipher = Cipher.getInstance(encoding);
    ecipher.init(Cipher.ENCRYPT_MODE, skeySpec, paramSpec);
    dcipher = Cipher.getInstance(encoding);
}

private byte[] generateIV() {
    SecureRandom random = new SecureRandom();
    byte bytes[] = new byte[16];
    random.nextBytes(bytes);
    return bytes;
}

接下来,我们定义加密方法本身:

public byte[] encrypt(byte[] plainBytes, boolean addIV) 
        throws InvalidAlgorithmParameterException,
               InvalidKeyException {

    byte[] iv = "".getBytes("UTF-8");
    if (!addIV) {
        iv = ecipher.getParameters()
                    .getParameterSpec(IvParameterSpec.class)
                    .getIV();
    }
    byte[] ciphertext = ecipher.update(
           plainBytes, 0, plainBytes.length);
    byte[] result = new byte[iv.length + ciphertext.length];
    System.arraycopy(iv, 0, result, 0, iv.length);
    System.arraycopy(ciphertext, 0,
                     result, iv.length, ciphertext.length);
    return result;
}

public byte[] decrypt(byte[] ciphertext, boolean useIV)
        throws InvalidAlgorithmParameterException,
               InvalidKeyException {

    byte[] deciphered;
    if (useIV) {
        byte[] iv = Arrays.copyOfRange(ciphertext, 0, 16);
        IvParameterSpec paramSpec = new IvParameterSpec(iv);
        dcipher.init(Cipher.DECRYPT_MODE, skeySpec, paramSpec);
        deciphered = dcipher.update(
            ciphertext, 16, ciphertext.length - 16);
    } else {
        deciphered = dcipher.update(
            ciphertext, 0, ciphertext.length);
    }
    return deciphered;

}

public byte[] doFinal() {
    try {
        byte[] ciphertext = ecipher.doFinal();
        return ciphertext;
    } catch (Exception e) {
        log.error(e.getStackTrace());
        return null;
    }
}

注意

每次加密文件时,初始化向量(IV)都应该是随机的。随机化对于加密方案实现语义安全至关重要,这是一种属性,即在相同密钥下重复使用方案不允许攻击者推断加密消息段之间的关系。

在实现加密范例时的主要问题是对字节数组的错误处理。正确加密的文件大小通常是密钥大小的倍数,当使用填充时,本例中为 16(字节)。如果文件大小不正确,加密/解密过程将因填充异常而失败。在先前使用的 Java 库中,数据以阶段方式提供给内部加密例程,大小为ciphertext.length,这些数据以 16 字节的块进行加密。如果有余数,这将被预先放置到下一个更新的数据中。如果进行doFinal调用,则余数再次被预先放置,并且数据在加密之前被填充到 16 字节块的末尾,从而完成例程。

现在我们可以继续完成我们的CryptoCodec的其余部分,即实现前面代码的压缩和解压实现。这些方法位于CryptoCompressorCryptoDecompressor类中,并由 Hadoop 框架调用:

@Override
public synchronized int compress(byte[] buf, int off, int len) throws IOException {
    finished = false;
    if (remain != null && remain.remaining() > 0) {
        int size = Math.min(len, remain.remaining());
        remain.get(buf, off, size);
        wrote += size;
        if (!remain.hasRemaining()) {
            remain = null;
            setFinished();
        }
        return size;
    }
    if (in == null || in.remaining() <= 0) {
        setFinished();
        return 0;
    }
    byte[] w = new byte[in.remaining()];
    in.get(w);
    byte[] b = crypto.encrypt(w, addedIV);
    if (!addedIV)
        addedIV = true;
    int size = Math.min(len, b.length);
    remain = ByteBuffer.wrap(b);
    remain.get(buf, off, size);
    wrote += size;
    if (remain.remaining() <= 0)
        setFinished();
    return size;
}

您可以在我们的代码存储库中看到CryptoCodec类的完整实现。

现在我们有了工作的CryptoCodec类,那么 Spark 驱动程序代码就很简单了:

val conf = new SparkConf() 
val sc = new SparkContext(conf.setAppName("crypto encrypt")) 
val writeRDD = sc.parallelize(List(1, 2, 3, 4), 2) 
writeRDD.saveAsTextFile("file:///encrypted/data/path",classOf[CryptoCodec]) 

我们现在有了本地磁盘加密!要读取加密文件,我们只需在配置中定义codec类:

val conf = new SparkConf() 
conf.set("spark.hadoop.io.compression.codecs", 
         "org.apache.hadoop.io.compress.CryptoCodec") 
val sc = new SparkContext(conf.setAppName("crypto decrypt")) 
val readRDD = sc.textFile("file:///encrypted/data/path") 
readRDD.collect().foreach(println) 

当 Spark 识别到适当的文件时,将自动使用CryptoCodec类,并且我们的实现确保每个文件使用唯一的 IV;IV 是从加密文件的开头读取的。

Java KeyStore

根据您的环境,上述代码可能足以保护您的数据安全。但是,存在一个缺陷,即用于加密/解密数据的密钥必须以明文形式提供。我们可以通过创建 Java KeyStore 来解决这个问题。这可以通过命令行或以编程方式完成。我们可以实现一个函数来创建JCEKS KeyStore 并添加一个密钥:

public static void createJceksStoreAddKey() {

       KeyStore keyStore = KeyStore.getInstance("JCEKS");
       keyStore.load(null, null);

       KeyGenerator kg = KeyGenerator.getInstance("AES");
       kg.init(128); // 16 bytes = 128 bit
       SecretKey sk = kg.generateKey();
       System.out.println(sk.getEncoded().toString());

       keyStore.setKeyEntry("secretKeyAlias", sk,
            "keystorepassword".toCharArray(), null);

       keyStore.store(new FileOutputStream("keystore.jceks"),
                  "keystorepassword".toCharArray());
}

我们可以通过命令行实现相同的功能:

keytool -genseckey-alias secretKeyAlias /
        -keyalg AES /
        -keystore keystore.jceks /
        -keysize 128 /
        -storeType JCEKS

检查它是否存在:

keytool -v -list -storetype JCEKS -keystore keystore.jceks

从 KeyStore 中检索密钥:

public static SecretKey retrieveKey()
        throws KeyStoreException,
               IOException,
               CertificateException,
               NoSuchAlgorithmException,
               UnrecoverableKeyException {

    KeyStore keyStore = KeyStore.getInstance("JCEKS");
    keyStore.load(new FileInputStream("keystore.jceks"),
        "keystorepassword".toCharArray());

    SecretKey key = (SecretKey) keyStore.getKey("secretKeyAlias",
        "keystorepassword".toCharArray());

    System.out.println(key.getEncoded().toString());
    return key;
}

注意

我们已经硬编码了具体内容以便阅读,但在实践中不应该这样做,因为 Java 字节码相对简单,容易被逆向工程,因此,恶意第三方可以轻松获取这些秘密信息。

我们的秘钥现在受到 KeyStore 的保护,只能使用 KeyStore 密码和秘钥别名访问。这些仍然需要受到保护,但通常会存储在数据库中,只有授权用户才能访问。

我们现在可以修改我们的EncryptionUtils.getPassword方法,以检索JCEKS密钥而不是明文版本,如下所示:

private String getPassword(){
    return retrieveKey();
}

现在我们有了CryptoCodec类,我们可以在整个 Spark 中使用它来保护数据,无论何时我们需要数据加密。例如,如果我们将 Spark 配置spark.shuffle.spill.compress设置为 true,并将spark.io.compression.codec设置为org.apache.hadoop.io.compress.CryptoCodec,那么任何溢出到磁盘的数据都将被加密。

S3 加密

HDFS 加密非常适合提供基本上是托管服务的功能。如果我们现在看 S3,它也可以做到同样的功能,但它还提供了使用以下功能进行服务器端加密的能力:

  • AWS KMS 管理的密钥(SSE-KMS)

  • 客户提供的密钥(SSE-C)

服务器端加密可以提供更多灵活性,如果您处于需要明确管理加密密钥的环境中。

硬件加密是在物理磁盘架构内处理的。一般来说,这具有更快的优势(由于专门用于加密的定制硬件)并且更容易保护,因为需要物理访问机器才能规避。缺点是所有写入磁盘的数据都是加密的,这可能会导致高度利用的磁盘的 I/O 性能下降。

数据在传输中

如果端到端的安全性是您的目标,一个经常关注的领域是数据在传输中的问题。这可能是从磁盘读取/写入或在分析处理期间在网络中传输数据。在所有情况下,重要的是要意识到您的环境的弱点。不能仅仅假设框架或网络管理员已经为您解决了这些潜在问题,即使您的环境不允许直接进行更改。

一个常见的错误是假设数据在不可读时是安全的。尽管二进制数据本身不可读,但它通常可以轻松转换为可读内容,并且可以使用诸如 Wireshark(www.wireshark.org)之类的工具在网络上捕获。因此,无论数据是否可读,都不要假设数据在传输过程中是安全的。

正如我们之前所看到的,即使在磁盘上加密数据,我们也不能假设它一定是安全的。例如,如果数据在硬件级别加密,那么一旦离开磁盘,它就会解密。换句话说,纯文本在穿越网络到任何机器时都是可读的,因此完全可以被未知实体在旅程中的任何时候读取。在软件级别加密的数据通常在被分析使用之前不会解密,因此通常是更安全的选择,如果网络拓扑未知的话。

在考虑处理系统本身的安全性时,例如 Spark,这里也存在问题。数据不断在节点之间移动,用户无法直接控制。因此,我们必须了解数据在任何给定时间可能以纯文本形式可用的位置。考虑以下图表,显示了 Spark YARN 作业期间实体之间的交互:

数据在传输中

我们可以看到每个连接都传输和接收数据。Spark 输入数据通过广播变量传输,所有通道都支持加密,除了 UI 和本地 shuffle/cache 文件(有关更多信息,请参见 JIRA SPARK-5682)。

此外,这里存在一个弱点,即缓存文件以纯文本形式存储。修复方法要么是实施前面的解决方案,要么是设置 YARN 本地目录指向本地加密磁盘。为此,我们需要确保 yarn-default.xml 中的yarn.nodemanager.local-dirs是所有 DataNodes 上的加密目录,可以使用商业产品或将这些目录托管在加密磁盘上。

现在我们已经考虑了整体数据,我们应该处理数据本身的各个部分。很可能数据中包含敏感信息,例如姓名、地址和信用卡号码。有许多处理此类信息的方法。

混淆/匿名化

通过混淆,数据的敏感部分被转换成永远无法追溯到原始内容的形式 - 通过模糊提供安全性。例如,包含字段:“名”,“姓”,“地址行 1”,“地址行 2”,“邮政编码”,“电话号码”,“信用卡号”的 CSV 文件可能会被混淆如下:

  • 原文
        John,Smith,3 New Road,London,E1 2AA,0207 123456,4659 4234 5678 
        9999
  • 混淆
        John,XXXXXX,X New Road,London,XX 2AA,XXXX 123456,4659 
        XXXXXXXXXXXXXX

数据混淆对于分析非常有用,因为它在保护敏感数据的同时仍允许有用的计算,比如计算完成的字段数量。我们还可以在混淆数据的方式上做得更智能,以保留某些细节同时保护其他细节。例如,信用卡号:4659 42XX XXXX XXXX可以给我们提供大量信息,因为付款卡的前六位数字,称为银行识别号BIN),告诉我们以下信息:

  • BIN 465942

  • 卡品牌:VISA

  • 发卡银行:汇丰银行

  • 卡类型:借记卡

  • 卡级别:经典

  • ISO 国家编号 826(英国)

数据混淆不一定要是随机的,但应该经过精心设计,以确保敏感数据被彻底删除。敏感的定义将完全取决于需求。在前面的例子中,能够按类型总结客户付款卡的分布可能非常有用,或者可以被视为应该删除的敏感信息。

还有一个现象需要注意,正如您可能从之前的章节中记得的那样,那就是数据聚合。例如,如果我们知道个人的姓名是约翰·史密斯,并且他的信用卡号以 465942 开头,那么我们就知道约翰·史密斯在英国汇丰银行有一个账户,这对于一个恶意实体来说是一个很好的信息基础。因此,必须小心确保应用了正确数量的混淆,要牢记我们永远无法恢复原始数据,除非我们在其他地方有另一个副本存储。数据的不可恢复可能是一个昂贵的事件,因此应明智地实施数据混淆。确实,如果存储允许,想要存储几个版本的数据,每个版本都有不同程度的混淆和不同级别的访问,这并不是不合理的。

在考虑在 Spark 中实现这一点时,最有可能的情况是我们将有许多需要转换的输入记录。因此,我们的起点是编写一个适用于单个记录的函数,然后将其包装在 RDD 中,以便可以并行运行这些函数。

以我们前面的例子为例,让我们在 Scala 中将其架构表达为一个枚举。除了定义之外,我们还将在我们的Enumeration类中包含有关如何混淆任何特定字段的信息:

  • xy掩盖了从xy的字符位置

  • 0len掩盖从字段文本的 0 到字段长度的整个字段

  • prefix掩盖最后一个空格字符之前的所有内容

  • suffix掩盖第一个空格字符之后的所有内容

  • ""什么都不做

这些信息在枚举中编码如下:

object RecordField extends Enumeration {
 type Obfuscation = Value
 val FIRSTNAME        = Value(0, "")
 val SURNAME          = Value(1, "0,len")
 val ADDRESS1         = Value(2, "0,1")
 val ADDRESS2         = Value(3, "")
 val POSTCODE        = Value(4, "prefix")
 val TELNUMBER       = Value(5, "prefix")
 val CCNUMBER         = Value(6, "suffix")
}

接下来,我们可以拆分输入字符串并编写一个函数,将正确的混淆参数应用于正确的字段:

def getObfuscationResult(text: String): String = {
   text
    .split(",")
    .zipWithIndex
    .map { case (field, idx) =>
      field match {
        case s: String if idx >= 0 && idx <= 6 => 
           stringObfuscator(s,RecordField(idx).toString, 'X')
        case _ => "Unknown field"
      }
    }
    .mkString(",")
 }

为了保持简单,我们已经硬编码了一些您可能希望以后更改的项目,例如分割参数(,),并且在所有情况下都使混淆符号保持不变(X)。

最后,实际的混淆代码:

def stringObfuscator(text: String,
                     maskArgs: String,
                     maskChar: Char):String = {
 var start = 0
 var end = 0

 if (maskArgs.equals("")) {
   text
 }

 if (maskArgs.contains(",")) {
   start = maskArgs.split(',')(0).toInt
   if (maskArgs.split(',')(1) == "len")
     end = text.length
   else
     end = maskArgs.split(',')(1).toInt
 }

 if (maskArgs.contains("prefix")){
   end = text.indexOf(" ")
 }

 if (maskArgs.contains("suffix")){
   start = text.indexOf(" ") + 1
   end = text.length
 }

 if (start > end)
   maskChar

 val maskLength: Int = end - start

 if (maskLength == 0)
   text

 var sbMasked: StringBuilder  = new StringBuilder(
         text.substring(0, start))

 for(i <- 1 to maskLength) {
   sbMasked.append(maskChar)
 }
 sbMasked.append(text.substring(start + maskLength)).toString
}

同样,我们保持了简单,没有过多地检查异常或边缘情况。这里是一个实际的例子:

getObfuscationResult(
  "John,Smith,3 New Road,London,E1 2AA,0207 123456,4659 4234 5678 9999")

它提供了期望的结果:

John,XXXXXX,X New Road,London,XX 2AA,XXXX 123456,4659 XXXXXXXXXXXXXX

这个方便的代码片段为大规模混淆提供了一个很好的基础。我们可以很容易地将其扩展到更复杂的场景,比如同一字段的不同部分的混淆。例如,通过更改StringObfuscator,我们可以在地址行 1字段中以不同的方式掩盖门牌号和街道名称:

val ADDRESS1 = Value(2, "0,1;2,len")

当然,如果您希望将其扩展到许多不同的用例,您也可以在StringObfuscator上应用策略模式,以允许在运行时提供混淆函数。

一种关键的软件工程技术,策略模式在这里描述:sourcemaking.com/design_patterns/strategy

在这一点上,值得考虑使用算法对数据进行混淆,例如单向哈希函数或摘要,而不仅仅是用字符(XXX)替换。这是一种多功能的技术,在各种用例中都适用。它依赖于执行某些计算的逆计算的计算复杂性,例如找因子和模平方,这意味着一旦应用,它们是不可逆的。然而,在使用哈希时应该小心,因为尽管摘要计算是 NP 完全的,但在某些情况下,哈希仍然容易受到使用隐含知识的威胁。例如,信用卡号的可预测性意味着它们已经被证明可以很快地通过穷举法破解,即使使用 MD5 或 SHA-1 哈希。

有关更多信息,请参阅www.integrigy.com/security-resources/hashing-credit-card-numbers-unsafe-application-practices

掩码

数据掩码是关于创建数据的功能替代,同时确保重要内容被隐藏。这是另一种匿名化方法,原始内容一旦经过掩码处理就会丢失。因此,确保变更经过仔细规划是非常重要的,因为它们实际上是最终的。当然,原始版本的数据可以存储以备紧急情况,但这会给安全考虑增加额外的负担。

掩码是一个简单的过程,它依赖于生成随机数据来替换任何敏感数据。例如,对我们之前的例子应用掩码会得到:

Simon,Jones,2 The Mall,London,NW1 2JT,0171 123890,1545 3146 6273 6262

现在我们有一行数据,它在功能上等同于原始数据。我们有一个全名、地址、电话号码和信用卡号码,但它们是不同的,因此它们不能与原始数据关联起来。

部分掩码对于处理目的非常有用,因为我们可以保留一些数据,同时掩盖其余部分。通过这种方式,我们可以执行许多数据审计任务,这些任务可能无法通过混淆来实现。例如,我们可以掩盖实际存在的数据,从而可以保证填充字段始终有效,同时也能够检测到空字段。

也可以使用完全掩码来生成模拟数据,而根本没有看到原始数据。在这种情况下,数据可以完全生成,比如用于测试或分析的目的。

无论用例如何,使用掩码时都应该小心,因为可能会无意中将真实信息插入记录中。例如,Simon Jones可能实际上是一个真实的人。在这种情况下,存储数据来源和历史记录是一个好主意。因此,如果真正的Simon, Jones根据数据保护法案提交了信息请求RFI),您就有必要的信息来提供相关的理由。

让我们扩展我们之前构建的代码,使用完全随机选择来实现基本的掩码方法。我们已经看到,掩码方法要求我们用一些有意义的替代内容替换字段。为了快速实现一些功能,我们可以简单地提供替代内容的数组:

val forenames = Array("John","Fred","Jack","Simon")
val surnames = Array("Smith","Jones","Hall","West")
val streets = Array("17 Bound Mews","76 Byron Place",
    "2 The Mall","51 St James")

稍后,我们可以扩展这些内容,从包含更多替代方案的文件中读取。我们甚至可以使用复合掩码一次性替换多个字段:

val composite = Array("London,NW1 2JT,0171 123890",
                      "Newcastle, N23 2FD,0191 567000",
                      "Bristol,BS1 2AA,0117 934098",
                      "Manchester,M56 9JH,0121 111672")

然后,处理代码就很简单了:

def getMaskedResult(): String = {

  Array(
    forenames(scala.util.Random.nextInt(forenames.length)),
    surnames(scala.util.Random.nextInt(surnames.length)),
    streets(scala.util.Random.nextInt(streets.length)),
    composite(scala.util.Random.nextInt(composite.length)).split(","),
    RandomCCNumber)
  .flatMap {
    case s:String => Seq(s)
    case a:Array[String] => a
  }
  .mkString(",")
}

我们可以定义一个RandomCCNumber函数来生成一个随机的信用卡号码。下面是一个简单的函数,它使用递归提供四组随机生成的整数:

def RandomCCNumber(): String = {

    def appendDigits(ccn:Array[String]): Array[String] = {
       if (ccn.length < 4) {
         appendDigits(ccn :+ (for (i <- 1 to 4) 
           yield scala.util.Random.nextInt(9)).mkString)
       }
       else {
         ccn
       }
     }
     appendDigits(Array()).mkString(" ")
}

将这些代码放在一起,并对我们的原始示例运行,得到以下结果:

getMaskedResult(
  "John,Smith,3 New Road,London,E1 2AA,0207 123456,4659 4234 5678 9999")

前面代码的输出如下:

Jack,Hall,76 Byron Place,Newcastle, N23 2FD,0191 567000,7533 8606 6465 6040

或者:

John,West,2 The Mall,Manchester,M56 9JH,0121 111672,3884 0242 3212 4704

再次,我们可以以许多方式开发这段代码。例如,我们可以生成一个在 BIN 方案下有效的信用卡号码,或者确保名称选择不会随机选择与其尝试替换的相同名称。然而,所述的框架在这里作为该技术的演示呈现,并且可以很容易地扩展和泛化以满足您可能有的任何额外要求。

标记化

标记化是用标记替换敏感信息的过程,如果需要,可以稍后使用该标记检索实际数据,前提是经过相关的认证和授权。使用我们之前的示例,标记化文本可能如下所示:

 John,Smith,[25AJZ99P],[78OPL45K],[72GRT55N],[54CPW59D],[32DOI01F]

其中括号中的值是可以在请求用户满足正确的安全标准时用于交换实际值的标记。这种方法是讨论过的方法中最安全的方法,允许我们恢复精确的原始基础数据。然而,标记化和去标记化数据需要大量的处理开销,当然,标记系统需要管理和仔细维护。

这也意味着标记化系统本身存在单点故障,因此必须遵守我们讨论过的重要安全流程:审计、认证和授权。

由于标记化的复杂性和安全问题,最流行的实现是商业产品,受到广泛的专利保护。这种类型的系统的大部分工作,特别是在大数据方面,是确保标记化系统能够以非常高的吞吐量提供完全安全、稳健和可扩展的服务。然而,我们可以使用 Accumulo 构建一个简单的标记化器。在第七章,建立社区中,有一个关于设置 Apache Accumulo 以便我们可以使用单元级安全的部分。Apache Accumulo 是 Google BigTable 论文的实现,但它增加了额外的安全功能。这意味着用户可以在并行和规模上加载和检索数据的所有优势,同时能够以非常精细的程度控制数据的可见性。该章描述了设置实例、为多个用户配置实例以及通过 Accumulo Mutations 加载和检索数据所需的所有信息。

对于我们的目的,我们希望获取一个字段并创建一个标记;这可以是 GUID、哈希或其他对象。然后,我们可以使用标记作为 RowID 并将字段数据本身作为内容写入 Accumulo:

val uuid: String = java.util.UUID.randomUUID.toString
val rowID: Text = new Text("[" + uuid + "]")
val colFam: Text = new Text("myColFam")
val colQual: Text = new Text("myColQual")
val colVis: ColumnVisibility = new ColumnVisibility("private")
val timestamp: long = System.currentTimeMillis()
val value: Value = new Value(field..getBytes())
val mutation: Mutation = new Mutation(rowID)

mutation.put(colFam, colQual, colVis, timestamp, value)

然后我们将uuid写入输出数据中的相关字段。当读取标记化数据时,任何以[开头的内容都被假定为标记,并使用 Accumulo 读取过程来获取原始字段数据,假设调用 Accumulo 读取的用户具有正确的权限:

val conn: Connector = inst.getConnector("user", "passwd")
val auths: Authorizations = new Authorizations("private")
val scan: Scanner = conn.createScanner("table", auths)

scan.setRange(new Range("harry","john"))
scan.fetchFamily("attributes")

for(Entry<Key,Value> entry : scan) {
    val row: String = e.getKey().getRow()
    val value: Value = e.getValue()
}

使用混合方法

混淆和掩码可以有效地结合使用,以最大化两种方法的优势。使用这种混合方法,我们的示例可能变成:

Andrew Jones, 17 New Road London XXXXXX, 0207XXXXXX, 4659XXXXXXXXXXXX

使用掩码和标记化的组合是保护信用卡交易的新兴银行标准。主帐号号码PAN)被替换为一个由一组唯一的、随机生成的数字、字母数字字符或截断的 PAN 和随机字母数字序列组成的标记。这使得信息可以被处理,就好像它是实际数据,例如审计检查或数据质量报告,但它不允许真实信息以明文存在。如果需要原始信息,可以使用标记来请求,只有在满足授权和认证要求的情况下用户才能成功。

我们可以重构我们的代码来执行这个任务;我们将定义一个新的函数,将混淆和掩码混合在一起:

def getHybridResult(text: String): String = {

  Array(
    forenames(scala.util.Random.nextInt(forenames.length)),
    RecordField.SURNAME,
    streets(scala.util.Random.nextInt(streets.length)),
    RecordField.ADDRESS2,
    RecordField.POSTCODE,
    RecordField.TELNUMBER,
    RandomCCNumber,
    "Unknown field")
  .zip(text.split(","))
  .map { case (m, field) =>
    m match {
      case m:String => m
      case rf:RecordField.Obfuscation =>
         stringObfuscator(field,rf.toString,'X')
    }
  }
  .mkString(",")
}

再次,我们的例子变成了:

Simon,XXXXXX,51 St James,London,XX 2AA,XXXX 123456,0264 1755 2288 6600

与所有标记化一样,您需要小心避免生成数据的副作用,例如,0264不是一个真实的 BIN 代码。再次,要求将决定这是否是一个问题,也就是说,如果我们只是想确保字段以正确的格式填充,那么这就不是一个问题。

为了以规模运行任何这些过程,我们只需要将它们包装在一个 RDD 中:

val data = dataset.map { case record =>
     getMixedResult(record)
}
data.saveAsTextFile("/output/data/path", classOf[CryptoCodec])

数据处置

安全数据应该有一个约定的生命周期。在商业环境中工作时,这将由数据管理机构确定,并且它将决定数据在生命周期的任何给定时刻应处于什么状态。例如,特定数据集在其生命周期的第一年可能被标记为敏感 - 需要加密,然后是私人 - 无加密,最后是处置。时间长度和适用的规则完全取决于组织和数据本身 - 一些数据在几天后到期,一些在五十年后到期。生命周期确保每个人都清楚地知道数据应该如何处理,它还确保旧数据不会不必要地占用宝贵的磁盘空间或违反任何数据保护法律。

从安全系统中正确处置数据可能是数据安全中最被误解的领域之一。有趣的是,这并不总是涉及完全和/或破坏性的移除过程。不需要采取任何行动的例子包括:

  • 如果数据只是过时了,可能不再具有任何内在价值 - 一个很好的例子是政府记录在过期后向公众发布;在二战期间是绝密的东西现在由于经过的时间通常不再具有敏感性。

  • 如果数据已加密,并且不再需要,只需丢弃密钥!

与需要一些努力的例子相反,导致可能会出现错误:

  • 物理破坏:我们经常听说使用锤子或类似工具摧毁硬盘,即使这样做也是不安全的,如果不彻底完成的话。

  • 多次写入:依赖多次写入数据块以确保原始数据被物理覆盖。Linux 上的 shred 和 scrub 等实用程序可以实现这一点;然而,它们在底层文件系统上的效果有限。例如,RAID 和缓存类型系统不一定会被这些工具覆盖以至于无法检索。覆盖工具应该谨慎对待,并且只有在完全了解其局限性的情况下才能使用。

当您保护您的数据时,开始考虑您的处置策略。即使您没有意识到存在任何组织规则(在商业环境中),您仍应考虑在不再需要访问时如何确保数据不可恢复。

Kerberos 认证

许多 Apache Spark 的安装使用 Kerberos 为 HDFS 和 Kafka 等服务提供安全性和认证。在与第三方数据库和传统系统集成时也特别常见。作为一名商业数据科学家,您可能会发现自己在必须在 Kerberized 环境中处理数据的情况下,因此,在本章的这一部分,我们将介绍 Kerberos 的基础知识 - 它是什么,它是如何工作的,以及如何使用它。

Kerberos 是一种第三方认证技术,特别适用于主要通信方式是通过网络的情况,这使其非常适合 Apache Spark。它被用于替代其他认证方法,例如用户名和密码,因为它提供以下好处:

  • 在应用程序配置文件中不以明文存储密码

  • 促进了服务、身份和权限的集中管理

  • 建立相互信任,因此两个实体都被识别

  • 防止欺骗 - 信任仅在有限时间内建立,仅用于定时会话,这意味着无法进行重放攻击,但会话可以为了方便而续订

让我们看看它是如何与 Apache Spark 一起工作的。

用例 1:Apache Spark 访问安全 HDFS 中的数据

在最基本的用例中,一旦您登录到安全 Hadoop 集群的边缘节点(或类似节点)并在运行 Spark 程序之前,必须初始化 Kerberos。这是通过使用 Hadoop 提供的kinit命令并在提示时输入用户密码来完成的。

> kinit 
Password for user: 
> spark-shell 
Spark session available as 'spark'. 
Welcome to 
      ____              __ 
     / __/__  ___ _____/ /__ 
    _\ \/ _ \/ _ `/ __/  '_/ 
   /___/ .__/\_,_/_/ /_/\_\   version 2.0.1 
      /_/ 

Using Scala version 2.11.8 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_101) 
Type in expressions to have them evaluated. 
Type :help for more information. 

scala> val file = sc.textFile("hdfs://...") 
scala> file.count 

在这一点上,您将完全经过身份验证,并且可以访问 HDFS 中的任何数据,受标准权限模型的约束。

因此,这个过程似乎足够简单,让我们更深入地看看这里发生了什么:

  1. 当运行kinit命令时,它立即向 Kerberos 密钥分发中心(KDC)发送请求,以获取票据授予票据(TGT)。该请求以明文形式发送,基本上包含了所谓的主体,这在本例中基本上是“username@kerberosdomain”(可以使用klist命令找到此字符串)。认证服务器(AS)对此请求做出响应,使用客户端的私钥签署了 TGT,这是事先共享并已知于 AS 的密钥。这确保了 TGT 的安全传输。

  2. TGT 与 Keytab 文件一起在客户端本地缓存,Keytab 文件是 Kerberos 密钥的容器,对于以相同用户身份运行的任何 Spark 进程都是可访问的。

  3. 接下来,当启动 spark-shell 时,Spark 使用缓存的 TGT 请求票据授予服务器(TGS)提供用于访问 HDFS 服务的会话票据。此票据使用 HDFS NameNode 的私钥进行签名。通过这种方式,保证了票据的安全传输,确保只有 NameNode 可以读取它。

  4. 拥有票据后,Spark 尝试从 NameNode 检索委托令牌。此令牌的目的是防止执行程序开始读取数据时向 TGT 发送大量请求(因为 TGT 并不是为大数据而设计的!),但它还有助于克服 Spark 在延迟执行时间和票据会话过期方面的问题。

  5. Spark 确保所有执行程序都可以访问委托令牌,方法是将其放在分布式缓存中,以便作为 YARN 本地文件可用。

  6. 当每个执行程序向 NameNode 请求访问存储在 HDFS 中的块时,它传递了先前获得的委托令牌。NameNode 回复块的位置,以及由 NameNode 使用私密签名的块令牌。此密钥由集群中的所有 DataNode 共享,并且只有它们知道。添加块令牌的目的是确保访问完全安全,并且仅发放给经过身份验证的用户,并且只能由经过验证的 DataNode 读取。

  7. 最后一步是执行程序向相关的 DataNode 提供块令牌并接收所请求的数据块。

用例 1:Apache Spark 访问安全 HDFS 中的数据

用例 2:扩展到自动身份验证

默认情况下,Kerberos 票据的有效期为 10 小时,然后过期,此后变得无用,但可以进行续订。因此,在执行长时间运行的 Spark 作业或 Spark 流作业(或者用户没有直接参与且无法手动运行kinit的作业)时,可以在启动 Spark 进程时传递足够的信息,以自动续订在先前讨论的握手期间发放的票据。

通过使用提供的命令行选项传递密钥表文件的位置和相关主体来完成此操作,如下所示:

spark-submit 
   --master yarn-client
   --class SparkDriver
   --files keytab.file
   --keytab keytab.file
   --principal username@domain
ApplicationName

当尝试以本地用户身份执行长时间运行的作业时,可以使用klist找到主体名称,否则,可以使用ktutilsktadmin在 Kerberos 中配置专用的服务主体

用例 3:从 Spark 连接到安全数据库

在企业环境中工作时,可能需要连接到使用 Kerberos 进行安全保护的第三方数据库,例如 PostgreSQL 或 Microsoft SQLServer。

在这种情况下,可以使用 JDBC RDD 直接连接到数据库,并让 Spark 并行发出 SQL 查询以摄取数据。在使用这种方法时需要小心,因为传统数据库并不是为高并行性而构建的,但如果使用得当,有时这是一种非常有用的技术,特别适合快速数据探索。

首先,您需要为特定数据库获取本机 JDBC 驱动程序 - 在这里,我们以 Microsoft SQLServer 为例,但是对于支持 Kerberos 的所有现代数据库应该都可以获得驱动程序(参见 RFC 1964)。

您需要在启动时配置 spark-shell 以使用 JDBC 驱动程序,如下所示:

> JDBC_DRIVER_JAR=sqljdbc.jar 
> spark-shell  
  --master yarn-client  
  --driver-class-path $JDBC_DRIVER_JAR  
  --files keytab.file   --conf spark.driver.extraClassPath=$JDBC_DRIVER_JAR 
  --conf spark.executor.extraClassPath=$JDBC_DRIVER_JAR 
  --jars $JDBC_DRIVER_JAR 

然后,在 shell 中,输入或粘贴以下内容(替换环境特定变量,这些变量已突出显示):

import org.apache.spark.rdd.JdbcRDD 

new JdbcRDD(sc, ()=>{ 
        import org.apache.hadoop.security.UserGroupInformation 
        import UserGroupInformation.AuthenticationMethod 
        import org.apache.hadoop.conf.Configuration 
        import org.apache.spark.SparkFiles 
        import java.sql.DriverManager 
        import java.security.PrivilegedAction 
        import java.sql.Connection 

        val driverClassName = "com.microsoft.sqlserver.jdbc.SQLServerDriver" 
        val url = "jdbc:sqlserver://" + 
                  "host:port;instanceName=DB;" + 
                  "databaseName=mydb;" +  
                  "integratedSecurity=true;" +  
                  "authenticationScheme=JavaKerberos" 

        Class.forName(driverClassName) 
        val conf = new Configuration 
        conf.addResource("/etc/hadoop/conf/core-site.xml") 
        conf.addResource("/etc/hadoop/conf/mapred-site.xml") 
        conf.addResource("/etc/hadoop/conf/hdfs-site.xml") 
        UserGroupInformation.setConfiguration(conf) 

        UserGroupInformation 
           .getCurrentUser 
           .setAuthenticationMethod(AuthenticationMethod.KERBEROS) 
        UserGroupInformation 
           .loginUserFromKeytabAndReturnUGI(principal, keytab.file) 
           .doAs(new PrivilegedAction[Connection] { 
             override def run(): Connection =  
                  DriverManager.getConnection(url) 
           }) 

},  
"SELECT * FROM books WHERE id <= ? and id >= ?",  
1,           // lowerBound    - the minimum value of the first placeholder 
20,          // upperBound    - the maximum value of the second placeholder 
4)           // numPartitions - the number of partitions 

Spark 运行传递给JdbcRDD构造函数的 SQL,但不是作为单个查询运行,而是使用最后三个参数作为指南进行分块。

因此,在这个例子中,实际上会并行运行四个查询:

SELECT * FROM books WHERE id <= 1 and id >= 5 
SELECT * FROM books WHERE id <= 6 and id >= 10 
SELECT * FROM books WHERE id <= 11 and id >= 15 
SELECT * FROM books WHERE id <= 16 and id >= 20 

正如您所看到的,Kerberos 是一个庞大而复杂的主题。数据科学家所需的知识水平可能会因角色而异。一些组织将拥有一个 DevOps 团队来确保一切都得到正确实施。然而,在当前市场上存在技能短缺的情况下,数据科学家可能不得不自己解决这些问题。

安全生态系统

最后,我们将简要介绍一些在使用 Apache Spark 开发时可能遇到的流行安全工具,并提供一些建议何时使用它们。

Apache Sentry

随着 Hadoop 生态系统的不断扩大,产品如 Hive、HBase、HDFS、Sqoop 和 Spark 都有不同的安全实现。这意味着通常需要在产品堆栈中重复使用策略,以便为用户提供无缝体验,并强制执行全面的安全清单。这很快就会变得复杂和耗时,通常会导致错误甚至安全漏洞(无论是有意还是无意)。Apache Sentry 将许多主流的 Hadoop 产品整合在一起,特别是与 Hive/HS2,以提供细粒度(高达列级)的控制。

使用 ACL 很简单,但维护成本很高。为大量新文件设置权限和修改 umask 非常繁琐和耗时。随着抽象的创建,授权变得更加复杂。例如,文件和目录的融合可以变成表、列和分区。因此,我们需要一个可信的实体来执行访问控制。Hive 有一个可信的服务 - HiveServer2HS2),它解析查询并确保用户可以访问他们请求的数据。HS2 以可信用户的身份运行,可以访问整个数据仓库。用户不直接在 HS2 中运行代码,因此不存在代码绕过访问检查的风险。

为了桥接 Hive 和 HDFS 数据,我们可以使用 Sentry HDFS 插件,它将 HDFS 文件权限与更高级别的抽象同步。例如,读取表的权限=读取表的文件的权限,同样,创建表的权限=写入数据库目录的权限。我们仍然使用 HDFS ACL 来进行细粒度的用户权限控制,但是我们受限于文件系统视图,因此无法提供列级和行级访问权限,只能是“全有或全无”。如前所述,当这种情况很重要时,Accumulo 提供了一个很好的替代方案。然而,还有一个产品也解决了这个问题-请参阅 RecordService 部分。

实施 Apache Sentry 的最快最简单的方法是使用 Apache Hue。Apache Hue 在过去几年中得到了发展,最初是作为一个简单的 GUI,用于整合一些基本的 Hadoop 服务,如 HDFS,现在已经发展成为 Hadoop 堆栈中许多关键构建块的中心;HDFS、Hive、Pig、HBase、Sqoop、Zookeeper 和 Oozie 都与集成的 Sentry 一起提供安全性。可以在demo.gethue.com/找到 Hue 的演示,为功能集提供了很好的介绍。我们还可以在实践中看到本章讨论的许多想法,包括 HDFS ACL、RBAC 和 Hive HS2 访问。

RecordService

Hadoop 生态系统的一个关键方面是解耦存储管理器(例如 HDFS 和 Apache HBase)和计算框架(例如 MapReduce、Impala 和 Apache Spark)。尽管这种解耦允许更大的灵活性,从而允许用户选择他们的框架组件,但由于需要做出妥协以确保一切无缝协同工作,这导致了过多的复杂性。随着 Hadoop 成为用户日益关键的基础设施组件,对兼容性、性能和安全性的期望也在增加。

RecordService 是 Hadoop 的一个新的核心安全层,位于存储管理器和计算框架之间,提供统一的数据访问路径,细粒度的数据权限,并在整个堆栈中执行。

RecordService

RecordService 只兼容 Cloudera 5.4 或更高版本,因此不能独立使用,也不能与 Hortonworks 一起使用,尽管 HDP 使用 Ranger 来实现相同的目标。更多信息可以在www.recordservice.io找到。

Apache ranger

Apache ranger 的目标与 RecordService 大致相同,主要目标包括:

  • 集中安全管理,以在中央 UI 中管理所有安全相关任务,或使用 REST API

  • 通过集中管理工具执行特定操作和/或操作的细粒度授权,管理 Hadoop 组件/工具

  • 在所有 Hadoop 组件中标准化授权方法

  • 增强对不同授权方法的支持,包括基于角色的访问控制和基于属性的访问控制

  • Hadoop 所有组件中用户访问和管理操作(与安全相关)的集中审计

在撰写本文时,Ranger 是 Apache 孵化器项目,因此尚未发布重要版本。尽管如此,它已完全集成到 Hortonworks HDP 中,支持 HDFS、Hive、HBase、Storm、Knox、Solr、Kafka、NiFi、YARN,以及 HDFS 加密的可扩展加密密钥管理服务。完整的详细信息可以在ranger.incubator.apache.org/hortonworks.com/apache/ranger/找到。

Apache Knox

我们已经讨论了 Spark/Hadoop 堆栈的许多安全领域,但它们都与保护单个系统或数据有关。一个没有详细提到的领域是保护集群本身免受未经授权的外部访问。Apache Knox 通过“环形围栏”来履行这一角色,并提供一个 REST API 网关,所有外部交易都必须经过该网关。

Knox 与 Kerberos 安全的 Hadoop 集群相结合,提供身份验证和授权,保护集群部署的具体细节。许多常见服务都得到了满足,包括 HDFS(通过 WEBHDFS)、YARN 资源管理器和 Hive。

Knox 是另一个由 Hortonworks 大力贡献的项目,因此完全集成到 Hortonworks HDP 平台中。虽然 Knox 可以部署到几乎任何 Hadoop 集群中,但在 HDP 中可以采用完全集成的方法。更多信息可以在knox.apache.org找到。

您的安全责任

现在我们已经涵盖了常见的安全用例,并讨论了数据科学家在日常活动中需要了解的一些工具,还有一个重要的事项需要注意。在他们的监管下,数据的责任,包括其安全性和完整性,都属于数据科学家。这通常是真实的,无论是否有明确的告知。因此,您需要认真对待这一责任,并在处理和处理数据时采取一切必要的预防措施。如果需要,还要准备好向他人传达他们的责任。我们都需要确保自己不因违反场外责任而受到责备;这可以通过强调这个问题,甚至与场外服务提供商签订书面合同来实现他们的安全安排。要了解当您不注意尽职调查时可能出现的问题的真实案例,请查看有关 Ashley-Madison 黑客攻击的一些安全说明:blog.erratasec.com/2015/08/notes-on-ashley-madison-dump.html#.V-AGgT4rIUv

另一个感兴趣的领域是可移动介质,最常见的是 DVD 和存储卡。这些应该与硬盘的处理方式相同,但要假设数据始终不安全且有风险。这些类型的介质存在相同的选项,意味着数据可以在应用程序级别或硬件级别(例如光盘,如 DVD/CD)上进行保护。对于 USB 存储设备,存在实现硬件加密的示例。数据写入它们时始终是安全的,因此减轻了用户的大部分责任。这些类型的驱动器应始终获得联邦信息处理标准(FIPS)的认证;通常是 FIPS 140(密码模块)或 FIPS 197(AES 密码)。

如果不需要 FIPS 标准,或者媒体是光学性质的,那么数据可以在应用层加密,也就是由软件加密。有许多方法可以做到这一点,包括加密分区、加密文件或原始数据加密。所有这些方法都涉及使用第三方软件在读/写时执行加密/解密功能。因此,需要密码,引入了密码强度、安全性等问题。作者曾经经历过这样的情况,即加密磁盘被从一家公司交接到另一家公司,同时交接的是手写密码!除了对数据安全的风险,还可能涉及对涉及个人的纪律行动的后果。如果数据面临风险,值得检查最佳实践并强调问题;在这个领域变得懈怠是非常容易的,迟早,数据将受到损害,某人将不得不承担责任——这可能不一定是丢失媒体本身的个人。

总结

在本章中,我们探讨了数据安全的主题,并解释了一些相关问题。我们发现,不仅需要掌握技术知识,而且数据安全意识同样重要。数据安全经常被忽视,因此,采取系统化的方法并教育他人是掌握数据科学的一个重要责任。

我们解释了数据安全生命周期,并概述了授权、认证和访问等最重要的责任领域,以及相关示例和用例。我们还探讨了 Hadoop 安全生态系统,并描述了目前可用的重要开源解决方案。

本章的一个重要部分是致力于构建一个 HadoopInputFormat压缩器,它可以作为与 Spark 一起使用的数据加密实用程序。适当的配置允许在各种关键领域使用编解码器,特别是在将洗牌记录溢出到本地磁盘时,目前没有解决方案

在下一章中,我们将探讨可扩展算法,展示我们可以掌握的关键技术,以实现真正的“大数据”规模的性能。

第十四章:可扩展算法

L1 cache                                 0.5 ns

L2 缓存 7 ns

主内存 100 ns

磁盘(随机查找) 2,000,000 ns

幸运的是,Spark 提供了内存处理能力,包括许多利用快速缓存(L1/L2/L3 缓存)的优化。因此,它可以避免不必要地从主内存读取或溢出到磁盘,重要的是你的分析要充分利用这些效率。这是作为 Tungsten 项目的一部分引入的,databricks.com/blog/2015/04/28/project-tungsten-bringing-spark-closer-to-bare-metal.html

  • 只有在观察后进行优化:有一句著名的话是由传奇计算机科学家和作家 Donald Knuth 说的,即过早的优化是万恶之源。虽然听起来很极端,但他的意思是所有与性能相关的调整或优化都应该基于经验证据,而不是预先的直觉。因为这样的预测往往无法正确识别性能问题,反而导致后来后悔的糟糕设计选择。但与你可能认为的相反,这里的建议并不是你直到最后才考虑性能,事实上恰恰相反。在数据的大小和因此任何操作所需的时间决定一切的环境中,从分析设计过程的早期开始优化是至关重要的。但这不是 Knuth 法则的矛盾吗?嗯,不是。在性能方面,简单通常是关键。这种方法应该是基于证据的,所以从简单开始,仔细观察你的分析在运行时的性能(通过分析调整和代码分析,见下一节),进行有针对性的优化来纠正所识别的问题,并重复。过度设计通常与选择缓慢的算法一样常见,但它可能更难以在后期修复。从小开始,逐步扩大:从小数据样本开始。虽然分析可能最终需要在一百万亿字节的数据上运行,但从一个小数据集开始绝对是明智的。有时只需要少数行就可以确定分析是否按预期工作。并且可以添加更多行来证明各种测试和边缘情况。这里更多的是关于覆盖面而不是数量。分析设计过程是极其迭代的,明智地使用数据抽样将在这个阶段产生回报;即使一个小数据集也能让你在逐渐增加数据大小时测量性能的影响。

底线是,编写分析,特别是对你不熟悉的数据,可能需要时间,没有捷径。

现在我们有了一些指导方针,让我们专注于它们如何适用于 Spark。

Spark 架构

Apache Spark 旨在简化费力且有时容易出错的高度并行分布式计算任务。为了了解它是如何做到这一点的,让我们探索其历史,并确定 Spark 带来了什么。

Spark 的历史

Apache Spark 实现了一种数据并行,旨在改进 Apache Hadoop 所推广的 MapReduce 范式。它在四个关键领域扩展了 MapReduce:

  • 改进的编程模型:Spark 通过其 API 提供了比 Hadoop 更高级的抽象层;创建了一个编程模型,大大减少了必须编写的代码量。通过引入一个流畅的、无副作用的、面向函数的 API,Spark 使得可以根据其转换和操作来推理分析,而不仅仅是映射器和减速器的序列。这使得更容易理解和调试。

  • 引入工作流:与传统的 MapReduce 通过将结果持久化到磁盘并使用第三方工作流调度程序来链接作业不同,Spark 允许将分析分解为任务,并将其表示为有向无环图DAGs)。这不仅立即消除了需要实现数据的需求,而且还意味着它对分析的运行方式有更多的控制,包括启用诸如基于成本的查询优化(在催化剂查询规划器中看到)等效率。

  • 更好的内存利用:Spark 利用每个节点上的内存来缓存数据集。它允许在操作之间访问缓存,以提高基本 MapReduce 的性能。这对于迭代工作负载(例如随机梯度下降SGD))特别有效,通常可以观察到性能显着提高。

  • 集成方法:支持流处理、SQL 执行、图处理、机器学习、数据库集成等,它提供了一个工具来统治它们所有!在 Spark 之前,需要专门的工具,例如 Storm、Pig、Giraph、Mahout 等。尽管在某些情况下,专门的工具可能会提供更好的结果,但 Spark 对集成的持续承诺令人印象深刻。

除了这些一般改进之外,Spark 还提供了许多其他功能。让我们来看看里面的情况。

移动部件

在概念层面上,Apache Spark 内部有许多关键组件,其中许多您可能已经了解,但让我们在我们已经概述的可伸缩性原则的背景下对它们进行审查:

移动部件

驱动程序

驱动程序是 Spark 的主要入口点。它是您启动的程序,它在单个 JVM 中运行,并启动和控制作业中的所有操作。

在性能方面,您可能希望避免将大型数据集带回驱动程序,因为运行此类操作(例如rdd.collect)通常会导致OutOfMemoryError。当返回的数据量超过由--driver-memory指定的驱动程序的 JVM 堆大小时,就会发生这种情况。

SparkSession

当驱动程序启动时,SparkSession类被初始化。SparkSession类通过相关上下文(如SQLContextSparkContextStreamingContext类)提供对所有 Spark 服务的访问。

这也是调整 Spark 运行时与性能相关属性的地方。

弹性分布式数据集(RDD)

弹性分布式数据集RDD)是表示分布式同类记录集的基础抽象。

尽管数据可能在集群中的许多机器上物理存储,但分析故意不知道它们的实际位置:它们只处理 RDD。在幕后,RDD 由分区或连续的数据块组成,就像蛋糕的切片。每个分区都有一个或多个副本,Spark 能够确定这些副本的物理位置,以决定在哪里运行转换任务以确保数据局部性。

注意

有关副本的物理位置是如何确定的示例,请参见:github.com/apache/spark/blob/master/core/src/main/scala/org/apache/spark/rdd/NewHadoopRDD.scala中的getPreferredLocations

RDD 还负责确保数据从底层块存储(例如 HDFS)中适当地缓存。

执行器

执行器是在集群的工作节点上运行的进程。启动时,每个执行器都会连接到驱动程序并等待运行数据操作的指令。

您决定您的分析需要多少执行器,这将成为您的最大并行级别。

注意

除非使用动态分配。在这种情况下,最大的并行级别是无限的,直到使用spark.dynamicAllocation.maxExecutors进行配置。有关详细信息,请参阅 Spark 配置。

洗牌操作

洗牌是指作为操作的一部分发生的数据在执行器之间的传输。它通常发生在数据分组时,以便具有相同键的所有记录都在单个机器上,但也可以被战略性地用于重新分区数据以获得更高级别的并行性。

然而,由于它涉及数据在网络上传输和持久性到磁盘,通常被认为是一个缓慢的操作。因此,洗牌对可扩展性非常重要,稍后会详细介绍。

集群管理器

集群管理器位于 Spark 之外,充当集群的资源协商者。它控制物理资源的初始分配,以便 Spark 能够在具有所需核心数和内存的机器上启动其执行程序。

尽管每个集群管理器的工作方式都不同,但你的选择不太可能对算法性能产生任何可测量的影响。

任务

任务代表对数据的单个分区运行一组操作的指令。每个任务都由驱动程序序列化到执行程序,并且实际上是指通过将处理移动到数据来实现的。

DAG

DAG代表执行操作所涉及的所有转换的逻辑执行计划。其优化对于分析的性能至关重要。在 SparkSQL 和数据集的情况下,优化是由催化剂优化器代表你执行的。

DAG 调度程序

DAG 调度程序创建一个物理计划,通过将 DAG 划分为阶段,并为每个阶段创建相应的任务集(每个分区一个任务)。

DAG 调度程序

转换

转换是一种操作类型。它们通常将用户定义的函数应用于 RDD 中的每条记录。有两种转换,

窄转换是应用于分区的本地操作,因此不需要移动数据才能正确计算。它们包括:filtermapmapValuesflatMapflatMapValuesglompipezipWithIndexcartesianunionmapPartitionsWithInputSplitmapPartitionsmapPartitionsWithIndexmapPartitionsWithContextsamplerandomSplit

相比之下,宽转换是需要移动数据才能正确计算的操作。换句话说,它们需要进行洗牌。它们包括:sortByKeyreduceByKeygroupByKeyjoincartesiancombineByKeypartitionByrepartitionrepartitionAndSortWithinPartitionscoalescesubtractByKeycogroup

注意

coalescesubtractByKeycogroup转换可能是窄的,具体取决于数据的物理位置。

为了编写可扩展的分析,重要的是要意识到你正在使用哪种类型的转换。

阶段

阶段代表可以物理映射到任务(每个分区一个任务)的一组操作。有几点需要注意:

  • 在 DAG 中连续出现的一系列窄转换会被合并成一个阶段。换句话说,它们按顺序在同一个执行器上执行,因此针对同一个分区,不需要进行洗牌。

  • 每当在 DAG 中遇到宽转换时,就会引入一个阶段边界。现在存在两个阶段(或更多,例如连接等),第二个阶段在第一个完成之前不能开始(有关详细信息,请参阅ShuffledRDD类)。

操作

操作是 Spark 中的另一种操作类型。它们通常用于执行并行写入或将数据传输回驱动程序。虽然其他转换是惰性评估的,但是操作会触发 DAG 的执行。

在调用操作时,其父 RDD 被提交给驱动程序中的SparkSessionSparkContext类,DAG 调度程序生成用于执行的 DAG。

任务调度程序

任务调度程序接收由 DAG 调度程序确定的一组任务(每个分区一个任务),并安排每个任务在适当的执行程序上与数据局部性一起运行。

挑战

现在我们已经了解了 Spark 架构,让我们通过介绍一些可能会遇到的挑战或陷阱来为编写可扩展的分析做好准备。如果您不小心,没有提前了解这些问题,您可能会花费时间来自己解决这些问题!

算法复杂度

除了数据大小的明显影响外,分析的性能高度依赖于您尝试解决的问题的性质。即使是一些看似简单的问题,如图的深度优先搜索,在分布式环境中也没有效率高的明确定义的算法。在这种情况下,设计分析时应该非常小心,以确保它们利用可以轻松并行化的处理模式。在开始之前花时间了解问题的复杂性的本质,从长远来看会得到回报。在下一节中,我们将向您展示如何做到这一点。

注意

一般来说,NC-complete问题是可以并行化的,而 P-complete 问题则不行:en.wikipedia.org/wiki/NC_(complexity)

还要注意的是,分布式算法在处理小数据时通常比单线程应用程序慢得多。值得注意的是,在所有数据都适合单台机器的情况下,Spark 的开销:生成进程、传输数据以及进程间通信引入的延迟,很少会有回报。只有在数据集足够大,无法轻松放入内存时,才会注意到使用 Spark 会提高吞吐量,即单位时间内可以处理的数据量。

数值异常

在处理大量数据时,您可能会注意到一些数字的奇怪效果。这些奇异性与现代计算机的通用数字表示以及精度的概念有关。

为了演示效果,请考虑以下内容:

scala> val i = Integer.MAX_VALUE
i: Int = 2147483647

scala> i + 1
res1: Int = -2147483648

注意到一个正数通过加一变成了负数。这种现象被称为数字溢出,当计算结果产生一个对于其类型来说太大的数字时就会发生。在这种情况下,一个Int有 32 位的固定宽度,所以当我们尝试存储一个 33 位的数字时,就会发生溢出,导致一个负数。这种行为可以针对任何数字类型进行演示,并且由于任何算术操作的结果而产生。

注意

这是由于大多数现代处理器制造商(因此也包括 Java 和 Scala)采用的有符号、固定宽度、二进制补码数表示。

尽管溢出在正常编程过程中会发生,但在处理大型数据集时更为明显。即使在执行相对简单的计算时,如求和或平均值,也可能发生溢出。让我们考虑最基本的例子:

scala> val distanceBetweenStars = Seq(2147483647, 2147483647)
distanceBetweenStars: Seq[Int] = List(2147483647, 2147483647)

scala> val rdd = spark.sparkContext.parallelize(distanceBetweenStars)
rdd: org.apache.spark.rdd.RDD[Int] =  ...

scala> rdd.reduce(_+_)
res1: Int = -2

数据集也不是免疫的:

scala> distanceBetweenStars.toDS.reduce(_+_)
res2: Int = -2

当然,有处理这个问题的策略;例如使用替代算法、不同的数据类型或更改测量单位。然而,在设计中应始终考虑解决这些问题的计划。

另一个类似的效果是由计算中的舍入误差引起的精度限制。为了说明问题,考虑这个非常基本(并不是非常复杂!)的例子:

scala> val bigNumber = Float.MaxValue
bigNumber: Float = 3.4028235E38

scala> val verySmall = Int.MaxValue / bigNumber
verySmall: Float = 6.310888E-30

scala> val almostAsBig = bigNumber - verySmall
almostAsBig: Float = 3.4028235E38

scala> bigNumber - almostAsBig
res2: Float = 0.0

在这里,我们期望得到答案6.310887552645619145394993304824655E-30,但实际上得到了零。这是明显的精度和意义的损失,展示了在设计分析时需要注意的另一种行为。

为了应对这些问题,Welford 和 Chan 设计了一个在线算法来计算meanvariance。它试图避免精度问题。在 Spark 的内部,实现了这个算法,可以在 PySpark StatCounter 中看到一个例子:

   def merge(self, value):
        delta = value - self.mu
        self.n += 1
        self.mu += delta / self.n
        self.m2 += delta * (value - self.mu)
        self.maxValue = maximum(self.maxValue, value)
        self.minValue = minimum(self.minValue, value)

让我们更深入地了解它是如何计算平均值和方差的:

  • deltadelta是当前运行平均值 mu 和考虑中的新值之间的差异。它衡量了数据点之间的值的变化,因此始终很小。它基本上是一个魔术数字,确保计算永远不涉及对所有值进行求和,因为这可能导致溢出。

  • mu:mu 代表当前运行平均值。在任何给定时间,它是迄今为止看到的值的总和,除以这些值的计数。mu通过不断应用delta来逐渐计算。

  • m2m2是均方差的总和。它通过在计算过程中调整精度来帮助算法避免精度损失。这减少了通过舍入误差丢失的信息量。

恰好,这个特定的在线算法是专门用于计算统计数据的,但在线方法可能被任何分析的设计所采用。

洗牌

正如我们在原则部分中所指出的,数据的移动是昂贵的,这意味着编写任何可扩展分析的主要挑战之一是尽量减少数据传输。管理和处理数据传输的开销在目前仍然是一个非常昂贵的操作。我们将在本章后面讨论如何解决这个问题,但现在我们将意识到数据局部性周围的挑战;知道哪些操作是可以使用的,哪些应该避免,同时也了解替代方案。一些主要的问题是:

  • 笛卡尔()

  • reduce()

  • PairRDDFunctions.groupByKey()

但要注意,经过一点思考,可以完全避免使用这些。

数据方案

为数据选择一个模式对于分析设计至关重要。显然,通常你对数据的格式没有选择权;要么会有一个模式强加给你,要么你的数据可能没有模式。无论哪种情况,通过诸如“临时表”和“读时模式”(详见第三章,“输入格式和模式”中的详细信息),你仍然可以控制数据如何呈现给你的分析 - 你应该利用这一点。这里有大量的选择,选择合适的选择是挑战的一部分。让我们讨论一些常见的方法,并从一些不太好的方法开始:

  • OOP面向对象编程OOP)是将问题分解为模拟现实世界概念的类的一般编程概念。通常,定义将同时组织数据和行为,使其成为确保代码紧凑和可理解的一种流行方式。然而,在 Spark 的上下文中,创建复杂的对象结构,特别是包含丰富行为的对象结构,不太可能有助于您的分析,以便提高可读性或维护性。相反,这可能会大大增加需要垃圾回收的对象数量,并限制代码重用的范围。Spark 是使用功能方法设计的,虽然您应该小心放弃对象,但应努力保持它们简单,并在安全的情况下重用对象引用。

  • 3NF:几十年来,数据库一直针对某些类型的模式进行优化-关系型,星型,雪花等等。而第三范式3NF)等技术可以很好地确保传统数据模型的正确性。然而,在 Spark 的上下文中,强制动态表连接或/和将事实与维度连接会导致洗牌,可能会有很多次洗牌,这对性能来说是不利的。

  • 去规范化:去规范化是确保您的分析具有所需数据而无需进行洗牌的实用方法。数据可以被安排成一起处理的记录也一起存储。这增加了存储大部分数据的重复成本,但通常是一种值得的权衡。特别是因为有技术和技术可以帮助克服重复成本,例如列式存储,列修剪等等。稍后再详细介绍。

现在我们了解了在设计分析时可能遇到的一些困难,让我们深入了解如何应用解决这些问题的模式,并确保您的分析运行良好的细节。

规划你的路线

当你专注于尝试最新的技术和数据时,很容易忽视规划和准备工作!然而,编写可扩展算法的过程与算法本身一样重要。因此,了解规划在项目中的作用并选择一个允许你应对目标需求的操作框架至关重要。第一个建议是采用敏捷开发方法

分析创作的独特起伏可能意味着项目没有自然的结束。通过纪律和系统化的方法,您可以避免许多导致项目表现不佳和代码性能不佳的陷阱。相反,没有创新的开源软件或大量的语料库也无法拯救一个没有结构的项目。

由于每个数据科学项目都略有不同,因此在整体管理方面没有对错答案。在这里,我们提供一套基于经验的指导方针或最佳实践,应该有助于应对数据领域的挑战。

处理大量数据时,即使在计算中出现小错误,也可能导致许多时间的浪费-等待作业处理,而不确定何时或是否会完成。因此,一般来说,应该以与设计实验相似的严谨程度来处理分析创作。这里的重点应该是实用性,并且应该注意预测更改对处理时间的影响。

以下是在开发过程中避免麻烦的一些提示。

迭代

采取迭代方法处理日常工作,并逐步构建您的分析。随着工作的进行添加功能,并使用单元测试确保在添加更多功能之前有一个坚实的基础。对于您进行的每个代码更改,考虑采用迭代循环,例如下图所示的循环:

迭代

让我们依次讨论这些步骤。

数据准备

与往常一样,第一步是了解您将要处理的数据。如前所述,您可能需要处理语料库中存在的所有边缘情况。您应该考虑从基本数据概要开始,以了解数据是否符合您的期望,包括真实性和质量,潜在风险在哪里,以及如何将其分成类别以便进行处理。在第四章中详细描述了这种方法,探索性数据分析

除了探索性数据分析EDA)外,了解数据的形状将使您能够推断出您的分析设计,并预测您可能需要满足的额外需求。

例如,这里是一个快速的数据概要,显示了给定日期的一些 GDELT 新闻文章下载的完整性:

content
  .as[Content]
  .map{
    _.body match {
      case b if b.isEmpty  => ("NOT FOUND",1)
      case _ => ("FOUND",1)
    }
  }
  .groupByKey(_._1)
  .reduceGroups {
     (v1,v2) => (v1._1, v1._2 + v2._2)
  }
  .toDF("NEWS ARTICLE","COUNT")
  .show

结果如下表所示:

+------------+------+
|NEWS ARTICLE| COUNT|
+------------+------+
|       FOUND|154572|
|   NOT FOUND|190285|
+------------+------+

对于这一天,您将看到实际上大多数 GKG 记录没有相关的新闻文章内容。尽管这可能是由于各种原因,但要注意的是这些缺失的文章形成了一类新的记录,需要不同的处理。我们将不得不为这些记录编写一个替代流程,并且该流程可能具有不同的性能特征。

缓慢扩大规模

在数据方面,从小规模开始逐步扩大是很重要的。不要害怕从语料库的子集开始。考虑在数据概要阶段选择一个重要的子集,或者在许多情况下,使用每个子集中的少量记录是有益的。重要的是所选择的子集足够代表特定的用例、功能或特性,同时又足够小以允许及时迭代

在前面的 GDELT 示例中,我们可以暂时忽略没有内容的记录,只处理包含新闻文章的子集。这样,我们将过滤掉任何麻烦的情况,并在后续迭代中处理它们。

说到这一点,最终你肯定会想要重新引入语料库中存在的所有子集和边缘情况。虽然可以逐步进行这样做,先包括更重要的类,然后留下边缘情况,但最终需要理解数据集中每条记录的行为,甚至是异常值,因为它们很可能不是一次性事件。您还需要了解任何数据在生产中的影响,无论频率如何,以避免由于单个异常记录而导致整个运行失败。

估算性能

在编写每个转换时,要注意复杂性方面的时间成本。例如,问自己,“如果我将输入加倍,运行时间会受到什么影响?”。在考虑这一点时,考虑大 O 符号是有帮助的。大 O 不会给出确切的性能数字;它不考虑实际因素,如核心数量、可用内存或网络速度。然而,它可以作为指南,以便获得处理复杂性的指示性度量。

作为提醒,以下是一些常见的符号,按时间复杂度顺序(首选-优先):

符号 描述 示例操作
O(1) 常数(快速)不依赖于大小 broadcast.value``printSchema
O(log n) 对数随 n 个节点的平衡树的高度增长 pregel``connectedComponents
O(n) 线性与 n(行)成比例增长 map``filter``count``reduceByKey``reduceGroups
O(n + m) 线性与 n 和 m(其他数据集)成比例增长 join``joinWith``groupWith``cogroup``fullOuterJoin
O(n²) 二次方随 n 的平方增长 笛卡尔积
O(n²c) 多项式(慢)与 n 和 c(列)成比例增长 LogisticRegression.fit

在设计分析时,使用这种符号可以帮助你选择最有效的操作。例如,如何用connectedComponents [O(log n)] 替换笛卡尔积 [O(n²)],请参见第十章,故事去重和变异

它还让你在执行作业之前估计你的分析性能特征。你可以将这些信息与集群的并行性和配置结合使用,以确保在执行作业的时候,最大限度地利用资源。

仔细地一步一步地进行

Spark 的出色、流畅、面向函数的 API 旨在允许链接转换。事实上,这是它的主要优势之一,正如我们所见,它特别方便构建数据科学管道。然而,正是因为这种便利,很容易写一系列命令,然后一次性执行它们。正如你可能已经发现的那样,如果发生故障或者得不到预期的结果,那么到目前为止的所有处理都将丢失,并且必须重新执行。由于开发过程具有迭代特性,这导致了一个过长的周期,往往会导致时间的浪费。

为了避免这个问题,在每次迭代过程中能够快速失败是很重要的。因此,在继续之前,考虑养成在小样本数据上逐步运行一步的习惯。通过在每个转换后发出一个动作,比如计数或小的取样,你可以检查正确性,并确保每一步都成功后再进行下一步。通过在前期投入一点关注和注意,你将更好地利用你的时间,你的开发周期也会更快。

除此之外,在开发生命周期中尽可能地考虑将中间数据集持久化到磁盘,以避免重复计算,特别是在计算量大或可重复使用的情况下。这是一种磁盘缓存的形式,类似于检查点(在 spark 流处理中存储状态时使用)。事实上,这是在编写 CPU 密集型分析时的常见权衡,特别是在开发运行在大型数据集上的分析时特别有用。然而,这是一个权衡,因此要决定是否值得,需要评估从头计算数据集所需的时间与从磁盘读取数据集所需的时间。

如果决定持久化,确保使用ds.write.save并格式化为parquet(默认),以避免定制类和序列化版本问题的泛滥。这样你就能保留读取时的模式的好处。

此外,在迭代分析开发生命周期时,编写自己的高性能函数时,保持一个回归测试包是个好主意。这有几个好处:

  1. 它让你确保在引入新的数据类时,你没有破坏现有的功能。

  2. 它给了你对代码正确性的一定程度的信心,直到你正在处理的步骤。

您可以使用单元测试轻松创建回归测试包。有许多单元测试框架可帮助实现这一点。一个流行的方法是通过将实际结果与预期结果进行比较来测试每个函数。通过这种方式,您可以逐渐构建一个测试包,通过为每个函数指定测试和相应的数据来完成。让我们通过一个简单的例子来解释如何做到这一点。假设我们有以下模型,取自 GDELT GKG 数据集:

case class PersonTone(article: String, name: String, tone: Double)

object Brexit {
  def averageNewsSentiment(df: DataFrame): Dataset[(String,Double)] = ???
}

我们希望测试给定PersonTone的 DataFrame,averageNewsSentiment函数是否正确计算了来自所有文章的各种人的平均语调。为了编写这个单元测试,我们对函数的工作原理不太感兴趣,只关心它是否按照预期工作。因此,我们将按照以下步骤进行:

  1. 导入所需的单元测试框架。在这种情况下,让我们使用ScalaTest和一个方便的 DataFrame 风格的解析框架,称为product-collections
        <dependency>
          <groupId>com.github.marklister</groupId>
          <artifactId>product-
          collections_${scala.binary.version}</artifactId>
          <version>1.4.5</version>
        <scope>test</scope>
        </dependency>

        <dependency>
         <groupId>org.scalatest</groupId>
         <artifactId>scalatest_${scala.binary.version}  </artifactId>
         <scope>test</scope>
        </dependency>
  1. 我们还将使用ScalaTest FunSuite的自定义扩展,称为SparkFunSuite,我们在第三章中介绍了这一扩展,输入格式和模式,您可以在代码库中找到。

  2. 接下来,模拟一些输入数据并定义预期结果。

  3. 然后,对输入数据运行该函数并收集实际结果。注意:这在本地运行,不需要集群。

  4. 最后,验证实际结果是否与预期结果匹配,如果不匹配,则测试失败。

完整的单元测试如下所示:

import java.io.StringReader
import io.gzet.test.SparkFunSuite
import org.scalatest.Matchers
import com.github.marklister.collections.io._

class RegressionTest extends SparkFunSuite with Matchers {

  localTest("should compute average sentiment") { spark =>

    // given
    val input = CsvParser(PersonTone)
                  .parse(new StringReader(
"""http://www.ibtimes.co.uk/...,Nigel Farage,-2.4725485679183
http://www.computerweekly.co.uk/...,Iain Duncan-Smith,1.95886385896181
http://www.guardian.com/...,Nigel Farage,3.79346680716544
http://nbc-2.com/...,David Cameron,0.195886385896181
http://dailyamerican.com/...,David Cameron,-5.82329317269076"""))

    val expectedOutput = Array(
      ("Nigel Farage", 1.32091823925),
      ("Iain Duncan-Smith",1.95886385896181),
      ("David Cameron",-5.62740678679))

    // when
    val actualOutput =
             Brexit.averageNewsSentiment(input.toDS).collect()

    // test
    actualOutput should have length expectedOutput.length
    actualOutput.toSet should be (expectedOutput.toSet)
  }
}

调优您的分析

分析调优的目的是确保在集群的实际限制内,您的分析能够平稳运行并实现最大效率。大多数情况下,这意味着尝试确认内存在所有机器上的有效使用,确保集群得到充分利用,并确保您的分析不会受到过多的 IO、CPU 或网络限制。由于处理的分布性质和涉及的机器数量众多,这在集群上可能很难实现。

值得庆幸的是,Spark UI 旨在帮助您完成这项任务。它集中并提供了有关运行时性能和分析状态的有用信息的一站式服务。它可以帮助指出资源瓶颈,并告诉您代码大部分时间都在哪里运行。

让我们仔细看一下:

  • 输入大小或 Shuffle Read Size/Records:用于窄转换和宽转换,无论哪种情况,这都是任务读取的数据总量,无论其来源(远程或本地)。如果您看到大的输入大小或记录数,考虑重新分区或增加执行器的数量。

调优您的分析

  • 持续时间:任务运行的时间。虽然完全取决于正在进行的计算任务的类型,但如果您看到小的输入大小和长时间运行,可能是 CPU 限制,考虑使用线程转储来确定时间花在哪里。

特别注意持续时间的任何变化。Spark UI 在Stages页面提供了最小值、25%、中位数、75%和最大值的数据。从中可以确定集群利用率的情况。换句话说,您的任务是否有数据的均匀分布,意味着计算责任的公平分配,或者您是否有严重偏斜的数据分布,意味着处理出现了长尾任务。如果是后者,查看处理数据分布的部分。

  • Shuffle Write Size/Records:作为洗牌的一部分要传输的数据量。可能会因任务而异,但通常您会希望确保总值尽可能低。

  • 本地性级别:数据本地性的度量出现在阶段页面上。在最佳情况下,这应该是 PROCESS_LOCAL。然而,您会看到在洗牌或宽转换后它会改变为任何。这通常是无法避免的。然而,如果您看到大量的NODE_LOCALRACK_LOCAL用于窄转换:考虑增加执行器的数量,或在极端情况下确认您的存储系统块大小和复制因子,或重新平衡您的数据。

  • GC 时间:每个任务花费在垃圾回收上的时间,即清理内存中不再使用的对象。它不应超过总时间的大约 10%(由持续时间显示)。如果它过高,这可能表明存在潜在问题。然而,在尝试调整垃圾收集器之前,值得审查与数据分发相关的分析的其他领域(即执行器数量、JVM 堆大小、分区数量、并行性、偏斜等)。

  • 线程转储(每个执行器):在执行器页面上显示,线程转储选项允许您随时窥视任何执行器的内部工作。在尝试了解您的分析行为时,这可能非常有价值。线程转储被排序,并列出了列表顶部最有趣的线程,寻找标记为Executor task launch worker的线程,因为这些线程运行您的代码。

通过反复刷新此视图,并查看单个线程的堆栈跟踪,可以大致了解它花费时间的地方,从而确定关注的领域。

注意

或者,您可以使用火焰图,详情请参阅www.paypal-engineering.com/2016/09/08/spark-in-flames-profiling-spark-applications-using-flame-graphs/

调整您的分析

  • 跳过的阶段:不需要运行的阶段。通常,当一个阶段在阶段页面的此部分中显示时,这意味着在缓存中找到了 RDD 血统的这一部分的完整数据集,DAG 调度程序不需要重新计算,而是跳过到下一个阶段。一般来说,这是一个良好缓存策略的标志。调整您的分析

  • 事件时间线:同样,在阶段页面上显示事件时间线提供了您运行任务的可视化表示。它对于查看并行性的水平以及任何给定时间每个执行器上执行的任务数量非常有用。

如果在初步调查后,您需要比 Spark UI 提供的更深入的信息,您可以使用操作系统提供的任何监控工具来调查基础设施的情况。以下是用于此目的的一些常见 Linux 工具的表格:

考虑的领域 工具 描述 示例用法
一般/CPU htop 进程活动监视器,刷新以显示接近实时的 CPU、内存和交换(以及其他内容)利用率 htop -p
dstat 高度可配置的系统资源利用率报告 dstat -t -l -c -y -i -p -m -g -d -r -n 3
ganglia 用于分布式系统的聚合系统资源监视器 基于 Web 的
Java 虚拟机 jvmtop 关于 JVM 的统计信息,包括资源利用率和实时查看其线程 jvmtop
jps 列出所有 JVM 进程 jps -l
jmap 包括堆上分配的所有对象的 JVM 内部内存映射 jmap -histo | head -20
jstack 包括完整线程转储的 JVM 快照 jstack
内存 free 内存利用的基本指南 free -m
vmstat 基于采样的详细系统资源统计,包括内存分配的细分 vmstat -s
磁盘 I/O iostat 提供磁盘 I/O 统计信息,包括 I/O 等待 iostat -x 2 5
iotop 磁盘 I/O 监视器,类似于 top。显示进程级别的 I/O iotop
网络 nettop 包括实时 I/O 的网络连接活动监视器 nettop -Pd
wireshark 交互式网络流量分析器 wireshark -i -ktshark -i

设计模式和技术

在这一部分,我们将概述一些设计模式和一般技术,用于编写自己的分析。这些是一些提示和技巧的集合,代表了使用 Spark 的经验积累。它们被提供作为有效的 Spark 分析编写指南。当您遇到不可避免的可扩展性问题并不知道该怎么办时,它们也可以作为参考。

Spark API

问题

由于有许多不同的 API 和函数可供选择,很难知道哪些是最有效的。

解决方案

Apache Spark 目前有一千多名贡献者,其中许多是经验丰富的世界级软件专业人士。它是一个成熟的框架,已经开发了六年多。在这段时间里,他们专注于从友好的 DataFrame API,基于 Netty 的洗牌机制,到催化剂查询计划优化器的每个部分的完善和优化。令人振奋的消息是,这一切都是“免费”的-只要您使用 Spark 2.0 中提供的最新 API。

最近的优化(由project tungsten引入),如离堆显式内存管理,缓存未命中改进和动态阶段生成,仅适用于较新的DataFrameDataset API,并且目前不受 RDD API 支持。此外,新引入的编码器比 Kryo 序列化或 Java 序列化快得多,占用的空间也更少。

在大多数情况下,这意味着数据集通常优于 RDD。

例子

让我们用一个简单的例子来说明基本的文章中提到的人数的计数:

personDS                             personRDD
  .groupBy($"name")                    .map(p => (p.person,1)) 
  .count                               .reduceByKey(_+_)
  .sort($"count".desc)                 .sortBy(_._2,false)
  .show

36 seconds (Dataset API)             99 seconds (RDD API)
RDDs (using ds.rdd) when you need the flexibility to compute something not available on the higher level API.

摘要模式

问题

我的时间序列分析必须在严格的服务级别协议SLA)内运行,并且没有足够的时间来计算整个数据集所需的结果。

解决方案

对于实时分析或具有严格 SLA 的分析,运行大型数据集上的漫长计算可能是不切实际的。有时需要使用两遍算法来设计分析,以便及时计算结果。为此,我们需要引入摘要模式的概念。

摘要模式是一种两遍算法,最终结果是仅从摘要的聚合中重建的。尽管只使用摘要,并且从未直接处理整个数据集,但聚合的结果与在整个原始数据集上运行时的结果相同。

基本步骤是:

  1. 在适当的时间间隔(每分钟,每天,每周等)上计算摘要。

  2. 将摘要数据持久化以供以后使用。

  3. 在较大的时间间隔(每月,每年等)上计算聚合。

这是一种在设计增量或在线算法进行流式分析时特别有用的方法。

例子

GDELT GKG 数据集是摘要数据集的一个很好的例子。

当然,每 15 分钟对全球媒体新闻文章进行情感分析或命名实体识别是不切实际的。幸运的是,GDELT 产生了这些 15 分钟的摘要,我们能够对其进行聚合,使这完全成为可能。

扩展和征服模式

问题

我的分析有相对较少的任务,每个任务的输入/洗牌大小(字节)很大。这些任务需要很长时间才能完成,有时执行者是空闲的。

解决方案

扩展和征服模式通过允许你增加并行性,将记录标记为更有效的并行执行。通过分解或解压每个记录,你使它们能够以不同的方式组合,分布在集群上,并由不同的执行者处理。

在这种模式中,通常与洗牌或repartition一起使用flatMap,以增加任务数量并减少每个任务处理的数据量。这产生了一个最佳情况,足够多的任务排队,以便没有执行者会空闲。它还可以帮助处理在一个机器的内存中处理大量数据并因此收到内存不足错误的情况。

这种有用且多才多艺的技术几乎在每个需要处理大型数据集的情况下都派上用场。它促进了简单数据结构的使用,并允许你充分利用 Spark 的分布式特性。

然而,需要注意的是,flatMap也可能导致性能问题,因为它有可能增加你的分析的时间复杂度。通过使用flatMap,你为每一行生成了许多记录,因此可能添加了需要处理的数据的另一个维度。因此,你应该始终考虑这种模式对算法复杂度的影响,使用大 O 符号表示法。

轻量级洗牌

问题

我的分析的洗牌读取阻塞时间占整体处理时间的很大比例(>5%)。我该怎么做才能避免等待洗牌完成?

解决方案

尽管 Spark 的洗牌经过精心设计,通过使用数据压缩和合并文件整合等技术来最小化网络和磁盘 I/O,但它有以下两个根本问题,这意味着它经常会成为性能瓶颈:

  • 它的 I/O 密集:洗牌依赖于(i)在网络上传输数据和(ii)将数据写入目标机器的磁盘。因此,它比本地转换要慢得多。为了说明慢了多少,这里是从各种设备顺序读取 1MB 数据的相对时间:它的 I/O 密集:洗牌依赖于(i)在网络上传输数据和(ii)将数据写入目标机器的磁盘。因此,它比本地转换要慢得多。为了说明慢了多少,这里是从各种设备顺序读取 1MB 数据的相对时间:

内存                     0.25 毫秒

10 GbE              10 毫秒

*磁盘                     20 毫秒        *

在这个例子中,由于洗牌操作同时使用了网络和磁盘,所以它的速度大约会比在缓存的本地分区上执行的速度慢 120 倍。显然,计时会根据使用的设备的物理类型和速度而有所不同,这里提供的数字只是相对指导。

  • 它是并发的同步点:一个阶段中的每个任务必须在下一个阶段开始之前完成。鉴于阶段边界涉及洗牌(参见ShuffleMapStage),它标志着执行中的一个点,任务必须等到该阶段的所有任务都完成才能开始。这产生了一个同步屏障,对性能有重大影响。

出于这些原因,尽量避免洗牌是可能的,或者至少最小化它的影响。

有时可以完全避免洗牌,事实上有一些模式,比如广播变量宽表模式,提供了如何做到这一点的建议,但通常是不可避免的,所有可以做的就是减少传输的数据量,从而减少洗牌的影响。

在这种情况下,尝试构建一个轻量级洗牌,专门最小化数据传输-只传输必要的字节。

再次,如果您使用DatasetDataFrameAPI,当 catalyst 生成逻辑查询计划时,它将执行 50 多个优化,包括自动修剪任何未使用的列或分区(请参阅github.com/apache/spark/blob/master/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala)。但如果您使用 RDD,您将不得不自己执行这些操作。以下是您可以尝试的一些技术:

  • 使用 map 来减少数据:在洗牌之前立即在数据上调用map,以便摆脱任何在后续处理中没有使用的数据。

  • 仅使用键:当您有键值对时,考虑使用rdd.keys而不是rdd。对于计数或成员资格测试等操作,这应该足够了。同样,考虑在适当的时候使用values

  • 调整阶段顺序:您应该先加入然后groupBy还是先groupBy然后加入?在 Spark 中,这主要取决于数据集的大小。使用每个转换前后的记录数量进行基于成本的评估应该是相当简单的。尝试找出哪种对您的数据集更有效。

  • 首先过滤:一般来说,在洗牌之前过滤行是有利的,因为它减少了传输的行数。尽可能早地进行过滤,前提是您修改后的分析功能上是等效的。

在某些情况下,您还可以过滤掉整个分区,就像这样:

        val sortedPairs = rdd.sortByKey() 
        sortedPairs.filterByRange(lower, upper) 

  • 使用 CoGroup:如果您有两个或更多个 RDD 都按相同的键分组,那么CoGroup可能能够在不引发洗牌的情况下将它们连接起来。这个巧妙的小技巧之所以有效,是因为任何使用相同类型K作为键的RDD[(K,V)],并使用HashPartitioner进行分组的数据,将始终落在同一个节点上。因此,当按键K进行连接时,不需要移动任何数据。

  • 尝试不同的编解码器:减少传输字节数的另一个提示是更改压缩算法。

Spark 提供了三个选项:lz4lzfsnappy。考虑审查每个选项,以确定哪个对您特定类型的数据最有效:

        SparkSession
          .builder()
          .config("spark.io.compression.codec", "lzf")

宽表模式

问题

我的数据集中的一对多或多对多关系产生了许多破坏所有分析性能的洗牌。

解决方案

为了优化您的数据结构,我们建议将数据去规范化为对您特定类型的处理有用的形式。这种方法,这里描述为宽表模式,涉及将经常一起使用的数据结构组合在一起,以便它们组成单个记录。这样可以保留数据局部性并消除执行昂贵连接的需要。关系使用得越频繁,您就越能从这种数据局部性中受益。

该过程涉及构建一个包含您需要进行后续处理的所有内容的数据表示、视图或表。您可以通过编程方式构建这个,或者通过标准的连接SparkSQL 语句。然后,它会提前实现,并在需要时直接在您的分析中使用。

在必要时,数据会在每一行之间复制,以确保自给自足。您应该抵制将额外的表因素出来的冲动,比如第三范式或雪花设计中的表,并依靠列式数据格式,如 Parquet 和 ORC,提供高效的存储机制,而不会牺牲快速的顺序访问。它们可以通过按列排列数据并在每列内压缩数据来实现这一点,这有助于在复制数据时减轻担忧。

同样,嵌套类型、类或数组通常可以在记录内部有效地表示子类或复合数据类。同样,避免在分析运行时进行必要的动态连接。

示例

有关如何使用非规范化数据结构(包括嵌套类型)的示例,请参见第三章,输入格式和模式

广播变量模式

问题

我的分析需要许多紧凑的参考数据集和维度表,尽管它们的大小较小,但会导致所有数据的昂贵洗牌。

解决方案

虽然某些数据集-例如交易日志或推文-在理论上是无限大的,但其他数据集具有自然限制,永远不会超出一定大小。这些被称为有界数据集。尽管它们可能会随时间偶尔发生变化,但它们是相当稳定的,并且可以说被保存在有限的空间内。例如,英国所有邮政编码的列表可以被视为有界数据集。

当加入到有界数据集或任何小集合时,有机会利用 Spark 提供的效率模式。与通常使用连接不同,连接通常会引发可能传输所有数据的洗牌,考虑改用广播变量。一旦分配,广播变量将分发并在集群中的所有执行程序中提供本地可用。您可以这样使用广播变量:

创建广播变量

val toBeBroadcast = smallDataset.collect
val bv = spark.sparkContext.broadcast(toBeBroadcast)

注意

确保收集要广播的任何数据。

访问广播变量

ds.mapPartitions { partition =>

    val smallDataset = bv.value
    partition map { r => f(r, bv.value) }
}

删除广播变量

bv.destroy() 

广播变量可以由RDD API 或Dataset API 使用。此外,您仍然可以在 SparkSQL 中利用广播变量-它将自动处理。只需确保阈值设置在要加入的表的大小以上,如下所示:

SparkSession
  .builder()
  .config("spark.sql.autoBroadcastJoinThreshold", "50MB")

例子

有关如何使用广播变量实现高效的连接和过滤的示例,请参见第九章,新闻词典和实时标记系统

组合器模式

问题

我的分析正在根据一组键执行聚合,因此必须对所有键的所有数据进行洗牌。因此,速度非常慢。

解决方案

Apache Spark 的洗牌能力的核心是一种强大而灵活的模式,这里称为组合器模式,它提供了一种大大减少洗牌数据量的机制。组合器模式非常重要,以至于可以在 Spark 代码的多个位置找到它的示例-要在此处看到它的实际效果,以下是其中一些示例:

  • ExternalAppendOnlyMap

  • CoGroupedRDD

  • 声明式聚合

  • ReduceAggregator

实际上,所有使用洗牌操作的高级 API,例如groupByreduceByKeycombineByKey等,都使用此模式作为其处理的核心。但是,先前提到的实现中存在一些变化,尽管基本概念是相同的。让我们仔细看看。

组合器模式提供了一种有效的方法,可以并行计算一组记录的函数,然后组合它们的输出以实现整体结果。

解决方案

通常,调用者必须提供三个函数:

  • 初始化(e) -> C[0]: 创建初始容器,也称为createCombinertype构造函数或zero

在此函数中,您应该创建和初始化一个实例,该实例将作为所有其他组合值的容器。有时还会提供每个键的第一个值,以预先填充最终将保存该键的所有组合值的容器。在这种情况下,该函数称为单元

值得注意的是,此函数在数据集中的每个分区上每个键执行一次。因此,对于每个键可能会多次调用,因此不能引入任何可能在数据集分布不同的情况下产生不一致结果的副作用。

  • 更新*(C[0], e) -> C[i]: *向容器中添加一个元素。也被称为mergeValuebind 函数reduce

在这个函数中,您应该将源 RDD 中的记录添加到容器中。这通常涉及以某种方式转换或聚合值,只有这个计算的输出才会在容器内继续前进。

更新以并行和任意顺序执行,因此此函数必须是可交换和可结合的。

  • 合并*(C[i], C[j]) -> C[k]: *将两个容器合并在一起。也被称为mergeCombinersmerge

在这个函数中,您应该组合每个容器所代表的值,形成一个新的值,然后将其带入下一步。

同样,由于合并顺序没有保证,这个函数应该是可交换和可结合的。

您可能已经注意到了这种模式与monads的概念之间的相似性。如果您还没有遇到 monads,它们代表一种抽象的数学概念,在函数式编程中用来表达函数,使得它们以一般的方式可组合。它们支持许多特性,比如组合、无副作用的执行、可重复性、一致性、惰性求值、不可变性,并提供许多其他好处。我们不会在这里对 monads 进行全面的解释,因为已经有很多很好的介绍了 - 例如www.simononsoftware.com/a-short-introduction-to-monads/,它采用的是实践而不是理论的观点。相反,我们将解释组合器模式的不同之处以及它如何帮助理解 Spark。

Spark 在数据集中的每条记录上执行update函数。由于其分布式性质,这可以并行进行。它还运行merge函数来合并每个分区输出的结果。同样,由于这个函数是并行应用的,因此可以以任何顺序组合,Spark 要求这些函数是可交换的,这意味着它们被应用的顺序对最终答案没有影响。正是这个可交换的合并步骤真正提供了定义的基础。

了解这种模式对于推理任何分布式聚合的行为是有用的。如果您对这种模式感兴趣,可以在github.com/apache/spark/blob/master/sql/core/src/main/scala/org/apache/spark/sql/expressions/Aggregator.scala中找到一个不错的实现。

除此之外,在尝试确定使用哪个高级 API 时,这也是有用的。由于有这么多可用的 API,有时很难知道选择哪一个。通过将types的理解应用到前面的描述中,我们可以决定使用最合适和性能最佳的 API。例如,如果eC[n]的类型相同,您应该考虑使用reduceByKey。然而,如果e的类型与C[n]不同,那么应该考虑使用combineByKey等操作。

为了说明这一点,让我们考虑一些使用 RDD API 上四种最常见操作的不同方法。

例子

为了提供一些背景,假设我们有一个 RDD,其中包含表示新闻文章中提到的人的键值对,其中键是文章中提到的人的姓名,值是经过预过滤、标记化、词袋化的文章的文本版本:

// (person:String, article:Array[String])
val rdd:RDD[(String,Array[String])] = ...

现在假设我们想要找出提到某个人的文章的一些统计数据,例如最小和最大长度,最常用的单词(不包括停用词)等。在这种情况下,我们的结果将是(person:String,stats:ArticleStats)的形式,其中ArticleStats是一个用于保存所需统计数据的 case 类:

case class ArticleStats(minLength:Long,maxLength:Long,mfuWord:(String,Int))

让我们从之前描述的三个组合器函数的定义开始:

val init = (a:Array[String]) => {
  ArticleStats(a)
}

val update = (stats:ArticleStats, a:Array[String]) => {
  stats |+| ArticleStats(a)
}

val merge = (s1:ArticleStats,s2:ArticleStats) => {
  s1 |+| s2
}

您可能会注意到,这些函数实际上只是我们模式的语法糖;真正的逻辑隐藏在伴随类和半群中:

object ArticleStats {
  def apply(a:Array[String]) =
    new ArticleStats(calcMin(a),calcMax(a),findMFUWord(a))
 ...
}

implicit object statsSemiGroup extends SemiGroup[ArticleStats] {
  def append(a: ArticleStats, b: ArticleStats) : ArticleStats = ???
}

对于我们的目的,我们不会详细介绍这些,让我们假设计算统计数据所需的任何计算都是由支持代码执行的-包括查找两个先前计算的指标的极端的逻辑-而不是专注于解释我们的不同方法。

GroupByKey 方法

我们的第一种方法是迄今为止最慢的选项,因为groupByKey不使用update函数。尽管存在这种明显的劣势,我们仍然可以实现我们的结果-通过在第一个映射和最后一个执行减少侧面聚合的地方夹入groupByKey

rdd.mapValues { case value => init(value) }
   .groupByKey()
   .mapValues { case list => list.fold(merge) } // note: update not used

但是,您会注意到它不执行任何 map-side 组合以提高效率,而是更喜欢在 reduce-side 上组合所有值,这意味着所有值都作为洗牌的一部分在网络上复制。

因此,在采用这种方法之前,您应该始终考虑以下替代方案。

ReduceByKey 方法

为了改进这一点,我们可以使用reduceByKey。与groupByKey不同,reduceByKey通过使用update函数提供了 map-side 组合以提高效率。在性能方面,它提供了最佳方法。但是,仍然需要在调用之前手动将每个值转换为正确的类型:

rdd.map(init(_._2)).reduceByKey(merge)

通过将源RDD中的记录映射到所需的类型,结果分两步实现。

AggregateByKey 方法

再次,aggregateByKey提供了与reduceByKey相同的性能特征-通过实现 map-side combine-但这次作为一个操作:

rdd.aggregateByKey(ArticleStats())(update,merge) 

update and merge being called, however init is not used directly. Instead, an empty container, in the form of a blank ArticleStats object, is provided explicitly for the purposes of initialization. This syntax is closer to that of fold, so it's useful if you're more familiar with that style.

CombineByKey 方法

一般来说,combineByKey被认为是最灵活的基于键的操作,可以完全控制组合器模式中的所有三个函数:

rdd.combineByKey(init,update,merge)

虽然将init作为函数而不仅仅是单个值可能会在某些情况下为您提供更多的灵活性,但实际上对于大多数问题来说,initupdatemerge之间的关系是这样的,以至于在功能或性能方面您并没有真正获得任何好处。而且无论如何,所有三个都由combineByKeyWithClassTag支持,因此在这种情况下,可以随意选择更适合您问题的语法或者只选择您更喜欢的语法。

优化的集群

问题

我想知道如何配置我的 Spark 作业的执行器,以充分利用集群的资源,但是有这么多选项,我感到困惑。

解决方案

由于 Spark 设计为水平扩展,一般来说,您应该更喜欢更多的执行器而不是更大的执行器。但是每个执行器都带来了 JVM 的开销,因此最好通过在每个执行器内运行多个任务来充分利用它们。由于这似乎有点矛盾,让我们看看如何配置 Spark 以实现这一点。

Spark 提供以下选项(在命令行或配置中指定):

--num-executors (YARN-only setting [as of Spark 2.0])
--executor-cores
--executor-memory
--total-executor-cores

可以使用以下公式估算执行器数量:

执行器数量=(总核心数-集群开销)/每个执行器的核心数

例如,当使用基于 YARN 的集群访问 HDFS 并在 YARN 客户端模式下运行时,方程如下:

((T-(2N + 6))/5)*

其中:

T:集群中的总核心数。

N:集群中的节点总数。

2:去除 HDFS 和 YARN 的每个节点开销。

假设每个节点上有两个 HDFS 进程-DataNodeNodeManager

6:去除 HDFS 和 YARN 的主进程开销。

假设平均有六个进程-NameNodeResourceManagerSecondaryNameNodeProxyServerHistoryServer等等。显然,这只是一个例子,实际上取决于集群中运行的其他服务,以及其他因素,如 Zookeeper quorum 大小、HA 策略等等。

5:据说,每个执行器的最佳核心数,以确保最佳任务并发性而不会产生严重的磁盘 I/O 争用。

内存分配可以使用以下公式估算:

每个执行器的内存=(每个节点的内存/每个节点的执行器数)安全系数*

例如,当使用基于 YARN 的集群以 YARN-client 模式运行,每个节点为 64 GB 时,方程如下:

(64 / E) 0.9 => 57.6 / E*

其中:

E:每个节点的执行器数(如前面的示例中计算的)。

0.9:在扣除堆外内存后分配给堆的实际内存的比例。

开销(spark.yarn.executor.memoryOverhead,默认 10%)。

值得注意的是,虽然通常将更多的内存分配给执行器(允许更多的空间用于排序、缓存等)是有益的,但增加内存也会增加垃圾收集压力。GC 必须扫描整个堆以查找不可达的对象引用,因此,它必须分析的内存区域越大,它消耗的资源就越多,而在某个时候,这会导致收益递减。虽然没有绝对的数字来说明在什么时候会发生这种情况,但作为一个经验法则,保持每个执行器的内存低于 64 GB 可以避免问题。

上述方程应该为调整集群大小提供一个良好的起点估计。为了进一步调整,您可能希望通过调整这些设置并使用 Spark UI 来测量性能的影响来进行实验。

重新分配模式

问题

我的分析总是在同几个执行器上运行。如何增加并行性?

解决方案

DatasetsRDDs相对较小时,即使使用flatMap扩展它们,衍生的任何子代都将采用父代的分区数。

因此,如果您的一些执行器处于空闲状态,调用repartition函数可能会提高您的并行性。您将立即付出移动数据的成本,但这可能会在整体上得到回报。

使用以下命令确定数据的分区数和并行性:

ds.rdd.getNumPartitions()

如果分区数少于集群允许的最大任务数,则没有充分利用执行器。

相反,如果有大量的任务(10,000+)并且运行时间不长,那么您可能应该调用coalesce来更好地利用您的资源-启动和停止任务相对昂贵!

示例

在这里,我们将Dataset的并行性增加到400。物理计划将显示为RoundRobinPartitioning(400),如下所示:

ds.repartition(400)
  .groupByKey($"key")
  .reduceGroups(f)
  .explain

...

+- Exchange RoundRobinPartitioning(400)
                  +- *BatchedScan parquet

这里是RDD的等效重新分区,只需在reduceByKey函数中指定要使用的分区数:

rdd.reduceByKey(f,400)
    .toDebugString

res1: String =
(400) ShuffledRDD[11] at reduceByKey at <console>:26 []
  +-(7) MapPartitionsRDD[10] at map at <console>:26 []
     |  MapPartitionsRDD[6] at rdd at <console>:26 []
     |  MapPartitionsRDD[5] at rdd at <console>:26 []
     |  MapPartitionsRDD[4] at rdd at <console>:26 []
     |  FileScanRDD[3] at rdd at <console>:26 []

盐键模式

问题

我的大多数任务都在合理的时间内完成,但总会有一两个任务花费更长的时间(>10 倍),重新分区似乎没有任何好处。

解决方案

如果您遇到必须等待少数慢任务的情况,那么您可能遭受数据分布不均的影响。这种情况的症状是,您会看到一些任务所花费的时间远远超过其他任务,或者一些任务的输入或输出要多得多。

如果是这种情况,首先要做的是检查键的数量是否大于执行器的数量,因为粗粒度分组可能会限制并行性。查找RDD中键的数量的快速方法是使用rdd.keys.count。如果这个值低于执行器的数量,那么请重新考虑您的键策略。例如,扩展和征服等模式可能会有所帮助。

如果前面的事情都有序,需要审查的下一件事是键分布。当您发现少数键具有大量关联值时,考虑盐化键模式。在此模式中,通过附加一个随机元素来细分热门键。例如:

rdd filter {
   case (k,v) => isPopular(k)
}
.map {
   case (k,v) => (k + r.nextInt(n), v)
}

这导致了更加平衡的键分布,因为在洗牌过程中,HashPartitioner将新的键发送到不同的执行器。您可以选择 n 的值来适应您需要的并行性 - 数据中更大的偏斜需要更大范围的盐。

当然,所有这些盐化都意味着您需要重新聚合到旧键上,以确保最终计算出正确的答案。但是,根据数据中的偏斜程度,两阶段聚合可能仍然更快。

您可以将这种盐应用于所有键,或者像前面的示例中那样进行过滤。您在示例中决定的过滤阈值,由isPopular决定,也完全由您自己选择。

二次排序模式

问题

当按键分组时,我的分析必须在它们分组之后显式对值进行排序。这种排序发生在内存中,因此大的值集需要很长时间,它们可能涉及溢出到磁盘,并且有时会出现OutOfMemoryError。以下是问题方法的示例:

rdd.reduceByKey(_+_).sortBy(_._2,false) // inefficient for large groups

相反,当按键分组时,应该在每个键内预先排序值,以便进行即时和高效的后续处理。

解决方案

使用二次排序模式通过使用洗牌机制高效地对组中的项目列表进行排序。这种方法在处理最大的数据集时也能够扩展。

为了有效地进行排序,此模式利用了三个概念:

  1. 复合键:包含您想要按组进行分组的元素您想要按排序进行排序的元素。

  2. 分组分区器:了解复合键的哪些部分与分组相关。

  3. 复合键排序:了解复合键的哪些部分与排序相关。

这些概念中的每一个都被注入到 Spark 中,以便最终的数据集呈现为分组和排序。

请注意,为了执行二次排序,您需要使用RDDs,因为新的Dataset API 目前不受支持。跟踪以下 JIRA 的进展issues.apache.org/jira/browse/SPARK-3655

示例

考虑以下模型:

case class Mention(name:String, article:String, published:Long) 

这里有一个实体,代表了人们在新闻文章中被提及的场合,包括人名、提及的文章以及其发布日期。

假设我们想要将所有提到相同名称的人的提及内容分组在一起,并按时间排序。让我们看看我们需要的三种机制:

复合键

case class SortKey(name:String, published:Long)

包含名称和发布日期。

分组分区器

class GroupingPartitioner(partitions: Int) extends Partitioner {

    override def numPartitions: Int = partitions

    override def getPartition(key: Any): Int = {

      val groupBy = key.asInstanceOf[SortKey]
      groupBy.name.hashCode() % numPartitions
    }
  }

它只是按名称分组。

复合键排序

implicit val sortBy: Ordering[SortKey] = Ordering.by(m => m.published)

它只是按发布日期排序。

一旦我们定义了这些,我们就可以在 API 中使用它们,就像这样:

val pairs = mentions.rdd.keyBy(m => SortKey(m.name, m.published))
pairs.repartitionAndSortWithinPartitions(new GroupingPartitioner(n))

这里使用SortKey来配对数据,使用GroupingPartitioner在分区数据时使用,使用Ordering在合并时使用,当然,它是通过 Scala 的implicit机制找到的,该机制是基于类型匹配的。

过滤过度模式

问题

我的分析使用白名单来过滤相关数据进行处理。过滤发生在管道的早期阶段,因此我的分析只需要处理我感兴趣的数据,以获得最大的效率。然而,白名单经常更改,这意味着我的分析必须针对新列表每次都要重新执行。

解决方案

与您在这里阅读的其他一些建议相反,在某些情况下,通过删除过滤器在所有数据上计算结果实际上可以增加分析的整体效率。

如果您经常在数据集的不同部分上重新运行分析,请考虑使用一种流行的方法,这里描述为Filter Overkill 模式。这涉及在 Spark 中省略所有过滤器,并在整个语料库上进行处理。这种一次性处理的结果将比经过过滤的版本大得多,但可以很容易地在表格数据存储中进行索引,并在查询时动态过滤。这避免了在多次运行中应用不同的过滤器,并且在过滤器更改时重新计算历史数据。

概率算法

问题

计算我的数据集的统计数据需要太长时间,因为它太大了。到收到响应的时候,数据已经过时或不再相关。因此,及时收到响应,或者至少提供时间复杂度的最大限制,比完整或正确的答案更重要。事实上,即使有小概率的错误,及时的估计也会被优先考虑,而不是在运行时间未知的情况下得到正确的答案。

解决方案

概率算法使用随机化来改进其算法的时间复杂度,并保证最坏情况下的性能。如果您对时间敏感,而“差不多就行”的话,您应该考虑使用概率算法。

此外,对于内存使用的问题也可以同样适用。有一组概率算法可以在受限的空间复杂度内提供估计。例如:

  • Bloom Filter是一种成员测试,保证不会错过集合中的任何元素,但可能会产生误报,即确定一个元素是集合的成员,而实际上不是。在更准确计算之前,它对快速减少问题空间中的数据非常有用。

  • HyperLogLog计算列中不同值的数量,使用固定的内存占用量提供一个非常合理的估计。

  • CountMinSketch提供了用于计算数据流中事件发生次数的频率表。在 Spark 流处理中特别有用,其中固定的内存占用量消除了内存溢出的可能性。

Spark 在org.apache.spark.sql.DataFrameStatFunctions中提供了这些实现,可以通过访问df.stat来使用。Spark 还通过RDD API 包括一些访问:

rdd.countApprox() 
rdd.countByValueApprox() 
rdd.countApproxDistinct() 

示例

有关如何使用Bloom Filter的示例,请参见第十一章情感分析中的异常检测

有选择地缓存

问题

我的分析正在缓存数据集,但如果说有什么变化的话,它比以前运行得更慢。

解决方案

缓存是提高 Spark 性能的关键;然而,使用不当时,它可能会产生不利影响。当您打算多次使用 RDD 时,缓存特别有用。这通常发生在以下情况下:(i)在不同阶段使用数据,(ii)数据出现在多个子数据集的谱系中,或者(iii)在迭代过程中,例如随机梯度下降。

当您不考虑重用而随意缓存时,问题就会出现。这是因为缓存在创建、更新和刷新时会增加开销,而在不使用时必须进行垃圾回收。因此,不正确的缓存实际上可能会减慢您的作业。因此,改进缓存的最简单方法是停止这样做(当然是有选择地)。

另一个考虑因素是是否有足够的内存分配和可用来有效地缓存您的 RDD。如果您的数据集无法放入内存,Spark 将抛出OutOfMemoryError,或者将数据交换到磁盘(取决于存储级别,这将很快讨论)。在后一种情况下,这可能会因为(i)移动额外数据进出内存所花费的时间,以及(ii)等待磁盘的可用性(I/O 等待)而产生性能影响。

为了确定您的执行程序是否分配了足够的内存,首先将数据集缓存如下:

ds.cache 
ds.count 

然后,在 Spark UI 的Storage页面查看。对于每个 RDD,这提供了缓存的比例、其大小以及溢出到磁盘的数量。

Solution

这应该使您能够调整分配给每个执行程序的内存,以确保您的数据适合内存。还有以下可用的缓存选项:

  • NONE:不缓存(默认)

  • MEMORY:在调用cache时使用

  • DISK:溢出到磁盘

  • SER:与 MEMORY 相同,但对象存储在字节数组中

  • 2REPLICATED):在两个不同的节点上保留缓存副本

上述选项可以以任何组合方式使用,例如:

  • 如果您遇到OutOfMemoryError错误,请尝试切换到MEMORY_AND_DISK以允许将缓存溢出到磁盘

  • 如果您遇到高的垃圾回收时间,请考虑尝试一种序列化的字节缓冲形式的缓存,例如MEMORY_AND_SER,因为这将完全规避 GC(稍微增加序列化的成本)。

这里的目标是确保Fraction Cached为 100%,并在可能的情况下,最小化Size on Disk,以建立数据集的有效内存缓存。

垃圾回收

问题

我的分析的GC 时间占整体处理时间的比例很大(>15%)。

解决方案

Spark 的垃圾收集器在开箱即用时效率非常高,因此只有在确定它是问题的原因而不是问题的症状时,才应尝试调整它。在更改 GC 设置之前,您应该确保已经审查了分析的所有其他方面。有时,您可能会在 Spark UI 中看到高的 GC 时间,原因并不一定是糟糕的 GC 配置。大多数情况下,首先调查这些情况是值得的。

如果您看到频繁或持续的 GC 时间,首先要确认您的代码是否表现得合理,并确保它不是过度/不规则内存消耗的根源。例如,审查您的缓存策略(参见上一节)或使用unpersist函数明确删除不再需要的 RDD 或数据集。

另一个需要考虑的因素是您在作业中分配的对象数量。尝试通过(i)简化您的领域模型,或者(ii)通过重复使用实例,或者(iii)在可以的情况下优先使用基本类型来最小化您实例化的对象数量。

最后,如果您仍然看到持续的 GC 时间,请尝试调整 GC。Oracle 提供了一些关于如何做到这一点的很好的信息(docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc_tuning.html),但特别是有证据表明 Spark 可以使用 G1 GC 表现良好。可以通过在 Spark 命令行中添加XX:UseG1GC来切换到此 GC。

在调整 G1 GC 时,主要的两个选项是:

  • InitiatingHeapOccupancyPercent:堆在触发 GC 周期之前应该有多满的阈值百分比。百分比越低,GC 运行得越频繁,但每次运行的工作量就越小。因此,如果将其设置为小于45%(默认值),您可能会看到更少的暂停。可以在命令行上使用-XX:InitiatingHeapOccupancyPercent进行配置。

  • ConcGCThread:后台运行的并发 GC 线程数。线程越多,垃圾回收完成得越快。但这是一个权衡,因为更多的 GC 线程意味着更多的 CPU 资源分配。可以在命令行上使用-XX:ConcGCThread进行配置。

总之,这是一个通过调整这些设置并调整您的分析来找到最佳配置的实验过程。

图遍历

问题

我的分析具有一个迭代步骤,只有当满足全局条件时才会完成,例如所有键都报告没有更多值要处理,因此运行时间可能会很慢,难以预测。

解决方案

一般来说,基于图的算法的效率是这样的,如果您可以将问题表示为标准的图遍历问题,那么您可能应该这样做。基于图的解决方案的问题示例包括:最短路径、深度优先搜索和页面排名。

示例

有关如何在GraphX中使用Pregel算法以及如何根据图遍历来解释问题的示例,请参见第七章 构建社区

总结

在本章中,我们通过讨论分布式计算性能的各个方面以及在编写可扩展分析时要利用的内容来结束了我们的旅程。希望您对涉及的一些挑战有所了解,并且对 Spark 在幕后的工作原理有了更好的理解。

Apache Spark 是一个不断发展的框架,每天都在添加新功能和改进。毫无疑问,随着不断地对框架进行智能调整和改进,它将变得越来越容易使用,自动化许多今天必须手动完成的工作。

关于接下来的事情,谁知道下一个转角会是什么?但是,由于 Spark 再次击败竞争对手赢得了 2016 年 CloudSort 基准测试(sortbenchmark.org/),并且新版本将每四个月发布一次,有一件事是肯定的,它将是快节奏的。希望通过本章学到的坚实原则和系统指南,您将能够开发可扩展、高性能的算法,为未来多年做好准备!

posted @ 2024-05-21 12:54  绝不原创的飞龙  阅读(45)  评论(0编辑  收藏  举报