实体解析实用指南-全-

实体解析实用指南(全)

原文:zh.annas-archive.org/md5/4e35ee51118670fc815bae773646f567

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

我们都希望做出更好的决策。无论是为了更好地服务我们的客户还是保护他们的安全,我们都希望做出正确的判断并做正确的事情。为了自信地行动,我们需要了解我们正在服务的人是谁,以及他们在这个世界上的位置。虽然我们经常可以获得大量的数据,但往往这些数据并不联结,不能告诉我们面前这个个体的完整故事。

实体解析是连接数据、连接点,并看到整体图像的艺术与科学。这本书是一本实用指南,帮助您揭示更广泛的背景,并在行动之前全面了解情况。通常被视为理所当然,但在本书中您会发现,匹配数据并不总是一帆风顺——但请放心,在最后一章,您将具备充分的能力来克服这些挑战,并让您的数据集活跃起来。

谁应该读这本书

如果您是金融服务、制药或其他大型企业内的产品经理、数据分析师或数据科学家,这本书适合您。如果您正在应对数据孤岛的挑战,无法将不同数据库中的客户视图整合,或者负责合并来自不同组织或附属机构的信息,这本书也适合您。

负责打击金融犯罪和管理声誉与供应链风险的风险管理专业人士,也将受益于理解本书中提出的数据匹配挑战及其克服技术。

我为什么写这本书

实体解析的挑战无处不在——我们可能没有使用这些词,但每天这个过程一次又一次地重复。在完成本书的几周前,我的妻子要求我帮她检查名单上的姓名,因为她正在念出银行对账单上的付款人名单。名单上的人都付款了吗?这就是实体解析在实际中的应用!

编写本书的想法源于希望解释为什么检查与名单中的名称是否匹配并不像听起来那么简单,并展示目前可用于大规模解决此问题的一些惊人工具和技术。

希望通过一些现实生活的例子来引导您,使您能够自信地匹配您的数据集,以便为客户提供服务和保护。我很乐意听听您的经历,以及对本书本身的任何反馈。请随时在GitHub上提出任何伴随本书的代码问题,或者讨论实体解析问题,请联系我在LinkedIn

实体解析既是艺术,也是科学。没有一种大小适合所有数据集的预定义解决方案。您需要决定如何调整您的流程以达到您想要的结果。我希望本书的读者能够互相帮助找到最佳解决方案,并从共享的经验中受益。

浏览本书

本书旨在作为实践指南,因此我鼓励您在阅读每一章节时跟着代码操作。本书的一个关键设计原则是使用实际的开源数据展示挑战和解决方案。如果您按照本书操作,由于源数据集自出版日期以来可能会更新,因此您的结果可能会略有不同。请查看 GitHub 页面 获取最新更新和访问伴随本书的代码。

  • 第一章 提供了实体解析的基本介绍,说明了其必要性以及实施过程中的逻辑步骤。

  • 第二章 阐明了在尝试匹配记录之前标准化和清洗数据的重要性。

  • 第 3 到第六章展示了如何使用近似比较和概率匹配技术比较数据记录以解决实体。

  • 第七章 描述了将描述同一实体的记录分组成唯一可识别的集群的过程。

  • 第 8 和 9 章说明了如何使用云计算服务扩展实体解析过程。

  • 第十章 展示了如何在保护数据所有者隐私的同时链接记录。

  • 最后,第十一章 描述了在设计实体解析过程时需要考虑的一些进一步问题,并对可能的未来发展提出了一些结论性的思考。

我建议按顺序阅读第 2 到第九章,因为它们逐步使用共享的问题数据集构建实体解析解决方案。

本书假设读者具备基本的 Python 理解。您可以通过http://learnpython.org开始交互式教程,或者我推荐阅读 Wes McKinney 的 Python 数据分析(O’Reilly)进一步学习。更进一步的读者可能会从掌握 pandas、Spark 和 Google Cloud Platform 中获益。

本书中使用的约定

本书使用以下排版约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序清单,以及在段落内引用程序元素,例如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

等宽粗体

显示用户应直接输入的命令或其他文本。

等宽斜体

显示应由用户提供值或由上下文确定值的文本。

注意

此元素表示一般说明。

警告

此元素表示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可在https://github.com/mshearer0/HandsOnEntityResolution下载。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至support@oreilly.com

本书旨在帮助您完成工作。通常情况下,如果本书提供示例代码,您可以在程序和文档中使用它。除非您复制了代码的大部分内容,否则无需联系我们以获得许可。例如,编写一个使用本书多个代码块的程序不需要许可。销售或分发 O’Reilly 图书示例需要许可。通过引用本书并引用示例代码来回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。

我们感谢,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Hands-On Entity Resolution by Michael Shearer (O’Reilly)。2024 年版权所有 Michael Shearer, 978-1-098-14848-5。”

如果您认为您使用的代码示例超出了公平使用范围或上述许可,请随时与我们联系:permissions@oreilly.com

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media一直致力于为公司提供技术和商业培训、知识和见解,帮助其取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。有关更多信息,请访问https://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-889-8969 (美国或加拿大)

  • 707-827-7019 (国际或本地)

  • 707-829-0104 (传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.html

本书有一个网页,我们在那里列出勘误、示例和任何额外信息。您可以访问这个页面:https://oreil.ly/handsOnEntityResolution

欲了解有关我们的书籍和课程的新闻和信息,请访问:https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上观看我们:https://youtube.com/oreillymedia

致谢

我学到写书是一个团队合作的工作。我很感激能有时间和空间编写这本指南,并感谢所有支持我的人,他们毫不犹豫地投入时间,使这一切成为可能。

首先,我要感谢 Aurélien Géron,他的书《使用 Scikit-Learn、Keras 和 TensorFlow 实战机器学习》激发了我写一本实战指南的想法。我还要感谢在汇丰银行工作过的所有前同事,他们在打击金融犯罪中充分利用了实体解析技术。

我要感谢所有奥莱利媒体的同事,特别是高级内容采编 Michelle Smith,感谢她对最初的想法的支持和制定提案。由衷感谢 Jeff Bleiel 在起草过程中的编辑技巧和指导。感谢制作编辑 Aleeya Rahman 在格式化和 LaTeX 艺术方面的指导,以及内容服务经理 Kristen Brown 在早期发布方面的支持,这是一个令人鼓舞的里程碑。我还要感谢 Karen Montgomery 为本书设计的封面插图——那些鸟是不是很匹配呢?

特别感谢审稿人 Robin Linacre、Olivier Binette 和 Juan Amador。感谢 Juan 几年前向我介绍实体解析的主题并激励我进一步学习;感谢 Olivier 在最新技术和评估方面的专业指导以及他在实验评估方面的开拓工作;感谢 Robin 在实体解析复杂性的解释中表现出的承诺,以一种实用和易理解的方式。我还要感谢 Splink 和 OpenMined 团队提供的开源框架,本书的很多内容都基于这些框架——"站在巨人的肩膀上"。

最后,我要向我心爱的妻子 Kay 表示敬意,感谢她在整个过程中的支持和耐心。我还要感谢我的女儿们:Abigail 挑战我以易于理解的方式表述主题的能力,Emily 鼓励我永不放弃!

第一章:介绍实体解析

在全球范围内,大量的数据正在被收集和存储,每天都在增加。这些数据记录了我们生活的世界,以及我们周围的人、地点和事物的变化属性和特征。

在这个全球数据处理的生态系统中,组织独立地收集关于同一现实世界实体的重叠信息集。每个组织都有自己的方法来组织和编目其持有的数据。

公司和机构希望从这些原始数据中获取有价值的洞见。已经开发了先进的分析技术来识别数据中的模式,提取含义,甚至尝试预测未来。这些算法的性能取决于输入的数据质量和丰富程度。通过结合来自多个组织的数据,通常可以创建更丰富、更完整的数据集,从中可以得出更有价值的结论。

本书将指导您如何合并这些异构数据集,创建关于我们生活世界的更丰富的数据集。这个合并数据集的过程被称为各种名字,包括名称匹配、模糊匹配、记录链接、实体协调和实体解析。在本书中,我们将使用术语实体解析来描述解析即连接数据的整体过程,这些数据涉及到现实世界中的实体。

什么是实体解析?

实体解析是一种关键的分析技术,用于识别指向同一现实世界实体的数据记录。这种匹配过程使得可以在单个来源内去重条目,并在没有公共唯一标识符的情况下连接不同的数据源。

实体解析使企业能够构建丰富和全面的数据资产,揭示关系,并为营销和风险管理目的构建网络。这往往是充分利用机器学习和人工智能潜力的关键前提。

例如,在医疗服务领域,经常需要从不同的实践或历史档案中联合记录。在金融服务中,需要调和客户数据库,以提供最相关的产品和服务或启用欺诈检测。为了增强抗灾能力或提供环境和社会问题的透明度,公司需要将供应链记录与风险情报来源进行联接。

为什么需要实体解析?

在日常生活中,作为个体,我们被分配了很多编号——根据我的医疗服务提供者,我通过一个编号进行识别,通过我的雇主又通过另一个编号,再通过我的国家政府,等等。当我注册服务时,通常我的银行、选择的零售商或在线服务提供商会为我分配一个(有时是多个)编号。为什么会有这么多编号?在更简单的时代,当服务是在本地社区提供时,客户是以个人身份认识的,互动是面对面进行的,很明显你知道你在处理谁。交流通常是离散的交易,没有必要参考任何先前的业务,也不需要保留与个别客户关联的记录。

随着越来越多的服务开始远程提供,并在更广泛的区域甚至国家范围内提供,有必要找到一种识别谁是谁的方法。名字显然不够唯一,因此通常将名字与位置结合起来创建一个复合标识符:琼斯夫人变成了来自布罗姆利的琼斯夫人,而不是来自哈罗的琼斯夫人。随着记录从纸质形式迁移到电子形式,分配一个唯一的机器可读编号开始了今天围绕我们的数字和字母数字混合标识符的时代。

在它们各自领域的限制内,这些标识符通常运作良好。我用我的唯一编号来识别自己,很明显我是同一个回头客。这种标识符允许快速建立两方之间的共同语境,并减少误解的可能性。这些标识符通常没有共同之处,在长度和格式上有所不同,并根据不同的方案分配。没有机制可以在它们之间进行转换,或者识别它们单独和集体地指代的是我,而不是另一个个体。

然而,当业务被去人化时,我不认识我正在交易的人,他们也不认识我,如果我多次注册相同的服务会发生什么?也许我忘记了用我的唯一编号进行标识,或者有人代表我提交了新的申请。将创建第二个也能标识我身份的编号。这种重复使得服务提供者更难以提供个性化服务,因为他们现在必须合并两个不同的记录才能充分了解我是谁以及我的需求是什么。

在较大的组织中,匹配客户记录的问题变得更加具有挑战性。不同的功能或业务线可能会维护适合其目的的记录,但这些记录是独立设计的。 一个常见的问题是如何构建客户的综合(或 360 度)视图。客户可能在多年来与组织的不同部分进行了交互。他们可能在不同的上下文中进行交互——作为个人,作为联合家庭的一部分,或者可能是与公司或其他法律实体相关联的官方能力。在这些不同的交互过程中,同一个人可能在各种系统中被分配了多个标识符。

这种情况通常是由于(经常是历史性的)合并和收购而引起的,其中要将重叠的客户集合合并并一致地对待为一个整体人口。我们如何将一个领域的客户与另一个领域的客户进行匹配?

当将由不同组织提供的数据集合并在一起时,也会出现记录合并的挑战。由于通常不存在广泛采用的标准或个体之间的公共键,特别是与个人相关的键,因此合并它们的数据通常被忽视并且不是一项微不足道的任务。

实体解析的主要挑战

如果我们分配的唯一标识符都不同且无法匹配,我们如何确定两个记录指的是同一个实体?我们最好的方法是比较这些实体的各个属性,例如他们的名称,如果它们有足够多的相似之处,就做出我们最好的判断,即它们是匹配的。这听起来足够简单,对吧?让我们深入了解一些为什么这并不像听起来那么简单的原因。

名称的缺乏唯一性

首先,存在着识别名称或标签之间的唯一性的挑战。将相同的名称重复分配给不同的现实实体明显存在一个难题,即区分谁是谁。也许你在互联网上搜索过自己的名字。除非你的名字特别不常见,否则你很可能会发现有很多与你完全相同的同名者。

命名规范不一致

名称以各种方式和数据结构记录。有时名称会完整描述,但通常会出现缩写或省略名称的不太重要的部分。例如,我的名字可能像表 1-1 中的任何一个变体一样完全正确地表达。

表 1-1. 名称变体

名称
迈克尔·谢拉
迈克尔·威廉·谢拉
迈克尔·威廉·罗伯特·谢拉
迈克尔·W·R·谢拉
M W R 谢拉
M W 谢拉

这些名字互不完全匹配,但都指向同一个人,同一个现实世界的实体。头衔、昵称、缩写形式或重音字符都会使找到精确匹配的过程受挫。复姓或带连字符的姓氏会进一步增加变数。

在国际背景下,命名惯例在全球范围内差异巨大。个人姓名可能出现在名字的开头或结尾,而姓氏可能有也可能没有。姓氏也可能根据个体的性别和婚姻状况而异。姓名可能用各种字母表/字符集写成,或在不同语言之间翻译得不同。^(1)

数据捕获的不一致性

捕捉和记录名字或标签的过程通常反映了获取者的数据标准。在最基本的层次上,一些数据获取过程将仅使用大写字母,其他人则使用小写字母,而许多人则允许混合大小写,其中首字母大写。

名字可能仅在对话中听到,没有机会澄清正确的拼写,或者可能在匆忙中被错误地转录。在手动重新键入过程中,名字或标签经常会被误输入或者意外省略。有时,如果原始上下文丢失,可能会使用不同的约定,这些约定很容易被误解。例如,即使是一个简单的名字,也可能被记录为“名字,姓氏”,或者“姓氏,名字”,甚至完全错误地转置到错误的字段中。

国际数据捕获可能导致不同脚本之间的音译不一致,或在口头捕获时出现转录错误。

工作示例

让我们考虑一个简单的虚构例子,来说明这些挑战可能如何显现。首先,想象我们唯一拥有的信息是如 表 1-2 中所示的名字。

表 1-2. 示例记录

**名称  **
迈克尔·谢拉
迈克尔·威廉·谢拉

“迈克尔·谢拉”和“迈克尔·威廉·谢拉”是否指的是同一个实体?在没有其他信息的情况下,两者很可能指的是同一个人。第二个名字增加了一个中间名,但除此之外它们几乎相同,比较两个姓氏将产生完全匹配。请注意,我偷偷加了一个常见的拼写错误。你发现了吗?

如果我们再增加一个属性,能帮助提高匹配准确性吗?如果你记不住会员号码,服务提供商通常会要求提供出生日期来帮助识别您(出于安全考虑)。出生日期是一个特别有用的属性,因为它不会改变,并且具有大量潜在值(称为高基数)。此外,日期的组合结构,包括日、月和年的个体值,可能会在确立精确等价关系时为我们提供线索。例如,参考表 1-3。

表 1-3. 示例记录—2

**姓名    ** **出生日期 **
迈克尔·谢勒 1970 年 1 月 4 日
迈克尔·威廉·谢勒 1970 年 1 月 14 日

乍一看,两个记录的出生日期并不相等,因此我们可能会认为它们不匹配。如果这两个人的出生日期相差 10 天,他们不太可能是同一个人!然而,这两者之间只有个位数的差异,前者在日期子字段中缺少前导数字 1——这可能是打字错误吗?很难说。如果这些记录来自不同的来源,我们还需要考虑数据格式是否一致——是英国的 DD/MM/YYYY 格式还是美国的 MM/DD/YYYY 格式?

如果我们增加了出生地点呢?虽然这个属性不应改变,但可以用不同级别的细化或不同的标点符号来表达。表 1-4 展示了增强记录。

表 1-4. 示例记录—3

**姓名    ** **出生日期 ** **出生地点   **
迈克尔·谢勒 1970 年 1 月 4 日 斯托·奥恩·瓦尔德
迈克尔·威廉·谢勒 1970 年 1 月 14 日 斯托·奥恩·瓦尔德

这里没有任何一个记录的出生地点完全匹配,尽管两者都可能属实。

因此,出生地点,可能以不同的精确级别记录,并不能像我们之前想象的那样帮助我们。那么像手机号码这样更私人化的信息呢?当然,我们中的许多人在一生中会更换电话号码,但是如果能够在换供应商时保留一部受喜爱和社交广泛的手机号码,这个号码就成为一个更具粘性的属性,我们可以使用它。然而,即使在这里,我们也面临挑战。个人可能拥有多个号码(例如工作和个人号码),或者标识符可能以各种格式记录,包括空格或连字符。它可能包含或不包含国际拨号前缀。

表 1-5 展示了我们的完整记录。

表 1-5. 示例记录—4

**姓名    ** **出生日期 ** **出生地点   ** 手机号码
迈克尔·谢勒 1970 年 1 月 4 日 斯托·奥恩·瓦尔德 07700 900999
迈克尔·威廉·谢勒 1970 年 1 月 14 日 斯托·奥恩·瓦尔德 0770-090-0999

正如您所见,这个解析挑战很快就变得非常复杂。

故意模糊化

大多数导致匹配过程中数据不一致的情况,都是通过粗心但出于善意的数据捕获过程引起的。然而,对于某些用途,我们必须考虑数据被恶意混淆的情况,以掩盖实体的真实身份,并防止可能揭示犯罪意图或关联的关联。

匹配排列

如果我让你将你的名字与一个简单的表格,比如说 30 个名字的表格,进行匹配,你可能可以在几秒钟内完成。一个更长的列表可能需要几分钟,但这仍然是一个实际的任务。然而,如果我要求你将一个包含 100 个名字的列表与另一个包含 100 个名字的列表进行比较,这个任务就变得更加繁琐和容易出错了。

不仅潜在匹配数量增加到 10,000(100 × 100),而且如果您想在第二个表中一次通过这样做,您必须将第一个表中的所有 100 个名称都记在脑子里——这并不容易!

同样,如果我让你在一个列表中对 100 个名字进行去重,你实际上需要进行比较:

  1. 第一个名字与剩余的 99 个名字,然后

  2. 第二个名字与剩余的 98 个名字等等。

实际上,您需要进行 4,950 次比较。以每秒一次的速度计算,仅仅对两个短列表进行比较就需要大约 80 分钟的工作时间。对于更大的数据集,潜在的组合数量变得不切实际,即使对于性能最佳的硬件也是如此。

盲匹配?

到目前为止,我们假设我们寻求匹配的数据集对我们是完全透明的——即属性的值是 readily available 的,完整的,并且没有以任何方式被模糊或掩盖。在某些情况下,由于隐私约束或地缘政治因素阻止数据跨越国界移动,这种理想情况是不可能的。如何在看不到数据的情况下找到匹配项?这看起来像魔术,但正如我们将在第十章中看到的那样,有加密技术可以使匹配仍然发生,而不需要完全暴露要匹配的列表。

实体解析过程

为了克服上述挑战,基本的实体解析过程被分为四个连续步骤:

  1. 数据标准化

  2. 记录阻塞

  3. 属性比较

  4. 匹配分类

在匹配分类之后,可能需要进行额外的后处理步骤:

  • 聚类

  • 规范化

让我们依次简要描述每一个步骤。

数据标准化

在我们比较记录之前,我们需要确保我们有一致的数据结构,以便我们可以测试属性之间的等价性。我们还需要确保这些属性的格式一致。这个处理步骤通常涉及到字段的拆分,删除空值和多余字符。它通常是针对源数据集定制的。

记录阻塞

为了克服记录比较的数量不切实际高的挑战,通常会使用一种称为阻塞的过程。该过程不是将每个记录与每个其他记录进行比较,而是仅对根据某些属性之间的就绪等价性预先选择的记录对进行全面比较。这种过滤方法集中了解析过程在那些最有可能匹配的记录上。

属性比较

接下来是通过阻塞过程选择的记录对之间比较各个属性的过程。等价度可以根据属性之间的精确匹配或相似性函数来确定。该过程产生了两个记录对之间的等价度量集合。

匹配分类

基本实体解析过程的最后一步是确定个体属性之间的集体相似性是否足以声明两个记录匹配,即解析它们是否指向同一现实世界的实体。这种判断可以根据一组手动定义的规则进行,也可以基于机器学习的概率方法。

聚类

一旦我们的匹配分类完成,我们可以通过它们的匹配对将记录分组为连接的群集。将记录对包含在群集中可能是通过额外的匹配置信度阈值来确定的。未达到此阈值的记录将形成独立的群集。如果我们的匹配标准允许不同的等价标准,则我们的群集可能是不传递的;即记录 A 可能与记录 B 配对,记录 B 可能与记录 C 配对,但记录 C 可能无法与记录 A 配对。因此,群集可能高度相互关联或松散耦合。

规范化

解析后可能需要确定应使用哪些属性值来表示实体。如果使用近似匹配技术确定了等价性,或者如果一对或群集中存在但未在匹配过程中使用的附加可变属性,则可能需要决定哪个值最具代表性。然后,生成的规范属性值用于后续计算中描述解析的实体。

工作示例

回到我们简单的示例,让我们将这些步骤应用到我们的数据上。首先,让我们标准化我们的数据,分割名字属性,标准化出生日期,并删除出生地点和手机号码字段中的额外字符。表 1-6 显示了我们经过清理的记录。

表 1-6. 第 1 步:数据标准化记录

**名字  ** 姓氏 **出生日期 ** **出生地点   ** 手机号码
迈克尔 谢拉 1970 年 1 月 4 日 斯托·翁·沃尔德 07700 900999
迈克尔 谢拉 1970 年 1 月 14 日 斯托·翁·沃尔德 07700 900999

在这个简单的例子中,我们只需要考虑一个配对,因此不需要应用阻塞技术。我们将在第五章中讨论这个问题。

接下来,我们将比较每个属性的精确匹配情况。表格 1-7 显示了每个属性的比较结果,可以是“匹配”或“无匹配”。

表格 1-7. 第三步:属性比较

属性 值记录 1 值记录 2 比较结果
名字 迈克尔 米迦勒 无匹配
姓氏 谢勒 谢勒 匹配
出生日期 1970 年 1 月 4 日 1970 年 1 月 14 日 无匹配
出生地 斯托-瓦尔德 斯托-瓦尔德 匹配
手机号码 07700 900999 07700 900999 匹配

最后,我们应用第 4 步确定是否存在总体匹配。一个简单的规则可能是,如果大多数属性匹配,则我们得出总体记录匹配的结论,就像在这种情况下一样。

或者,我们可以考虑各种匹配属性的组合是否足以声明匹配。在我们的例子中,为了声明匹配,我们可以寻找以下任一条件:

  • 姓名匹配和(出生日期或出生地匹配),或

  • 姓名匹配和手机号匹配

我们可以进一步采取这种方法,并为我们的每个属性比较分配一个相对权重;例如,手机号码匹配可能比出生日期匹配的价值高出两倍,等等。结合这些加权分数产生一个总体匹配分数,可以根据给定的置信度阈值来考虑。

我们将更多地研究不同方法来确定这些相对权重,使用统计技术和机器学习,在第四章中。

正如我们所见,不同的属性在帮助我们确定是否存在匹配时可能具有不同的强度。之前,我们考虑了在找到一个相当常见的名字与找到一个较少见的名字之间找到匹配的可能性。例如,在英国的情况下,史密斯姓的匹配可能比谢勒姓的信息量要少—谢勒姓的人比史密斯姓的人少,因此匹配本身的可能性从一开始就较低(较低的先验概率)。

这种概率方法在某些分类属性的值(即有限值集合的属性)中特别有效,其中某些值比其他值更常见。如果我们考虑一个城市属性作为英国数据集中地址匹配的一部分,那么伦敦出现的频率可能远远高于巴斯,因此可能会受到较少的加权。

请注意,我们尚未能够确定哪个出生日期是确切正确的,因此我们面临一个规范化的挑战。

衡量性能

统计方法可能帮助我们决定如何评估和结合比较各个属性所提供的所有线索,但我们如何决定组合是否足够好?如何设置置信度阈值来声明匹配?这取决于我们重视什么以及我们打算如何使用我们新发现的匹配。

我们更关心确保发现每一个潜在的匹配,如果在这个过程中声明了一些后来被证明是错误的匹配,我们也能接受吗?这个度量称为召回率。或者,我们不想浪费时间在不正确的匹配上,但如果在此过程中错过了一些真实的匹配,我们可以接受。这称为精确度

比较两条记录时,可能出现四种不同的情况。表 1-8 列出了匹配决策和实际情况的不同组合。

表 1-8. 匹配分类

你决定 实际情况 实例
匹配 匹配 真正阳性 (TP)
匹配 不匹配 假阳性 (FP)
不匹配 匹配 假阴性 (FN)
不匹配 不匹配 真负 (TN)

如果我们的召回率测量很高,那么我们只宣布相对较少的假阴性,即当我们声明匹配时,我们很少会错过一个好的候选。如果我们的精确度很高,那么当我们声明匹配时,我们几乎总是做对的。

在一个极端情况下,假设我们声明每一个候选对都是匹配的;我们将没有任何假阴性,我们的召回率度量将是完美的(1.0);我们永远不会漏掉一个匹配。当然,我们的精确度将非常低,因为我们会错误地声明大量不匹配为匹配。或者,想象在理想情况下,当每个属性完全等效时,我们才宣布匹配;那么我们将永远不会错误地宣布匹配,我们的精确度将是完美的(1.0),但代价是我们的召回率将非常低,因为很多好的匹配都会错过。

理想情况下,我们当然希望同时具备高召回率和精确度——我们的匹配既正确又全面——但这很难实现!第六章 更详细地描述了这个过程。

入门指南

那么,我们如何解决这些挑战呢?

希望本章为您提供了对实体解析是什么,为什么需要它以及过程中的主要步骤的良好理解。接下来的章节将通过一组基于公开数据的实际工作示例,手把手地指导您。

幸运的是,除了商业选项外,还有几个开源的 Python 库可以为我们做大部分的繁重工作。这些框架为我们构建适合我们数据和背景的定制匹配过程提供了支持。

在我们开始之前,我们将在下一章节中进行一个小的偏离,来设置我们的分析环境,并回顾我们将使用的一些基础 Python 数据科学库,然后我们将考虑我们实体解析过程的第一步——准备我们的数据以便匹配。

^(1) 关于全局命名惯例的详细信息,请参阅此指南

第二章:数据标准化

正如我们在第一章中讨论的,要成功地匹配或去重数据源,我们需要确保我们的数据呈现一致的方式,并删除或修正任何异常。我们将使用术语数据标准化来涵盖数据集转换为一致格式以及清洗数据以删除否则会干扰匹配过程的无用额外字符。

在本章中,我们将动手操作,并通过一个真实的例子来进行这个过程。我们将创建我们的工作环境,获取我们需要的数据,清洗这些数据,然后执行一个简单的实体解析练习,以便进行一些简单的分析。我们将通过检查我们的数据匹配过程的性能,并考虑如何改进它来结束。

首先,让我们介绍我们的示例以及为什么我们需要实体解析来解决它。

示例问题

让我们通过一个示例问题来说明在解决数据源之间实体匹配中常见的一些挑战,以及为什么数据清洗是必不可少的第一步。由于我们受限于使用公开可用的公共数据源,这个例子有点刻意,但希望能说明实体解析的必要性。

让我们想象我们正在研究可能影响英国下议院(议会的下议院)议员是否连任的因素。我们推测,拥有活跃的社交媒体存在可能会更有利于确保连任。为了本例,我们将考虑 Facebook 存在,因此我们查看了最后一次英国大选,并检查了保住议席的议员中有多少人拥有 Facebook 账号。

维基百科有一个网页列出了 2019 年大选中当选的议员名单,包括他们是否再次当选,但缺乏这些个人的社交媒体信息。然而,TheyWorkForYou 网站记录了当前议员的信息,包括链接到他们的 Facebook 账号。因此,如果我们结合这些数据集,我们可以开始测试我们的假设,即连任和社交媒体存在相关性。

TheyWorkForYou

TheyWorkForYou 的成立旨在使议会更加透明和负责任。TheyWorkForYou 由英国慈善组织 mySociety 运营,通过使用数字工具和数据来增加更多人的权力。

我们如何将这两个数据集连接起来?尽管两个数据集都包括每位议员所代表选区的名称,但我们不能将此作为公共键,因为自 2019 年大选以来,已经发生了一些补选,选出了新的议员。^(1) 这些新成员可能有 Facebook 账户,但不应被视为再选人群,因为这可能会扭曲我们的分析结果。因此,我们需要通过匹配议员姓名来连接我们的数据集,即解决这些实体,以便我们可以为每位议员创建一个单一的合并记录。

环境设置

我们的第一个任务是设置我们的实体解析环境。在本书中,我们将使用 Python 和 JupyterLab IDE。

要开始,您需要在计算机上安装 Python。如果尚未安装,请从官网下载。^(2)

将 Python 添加到 PATH

如果首次安装 Python,请确保选择“将 Python 添加到 PATH”选项,以确保您可以从命令行运行 Python。

要下载本书附带的代码示例,建议使用 Git 版本控制系统非常方便。有关安装 Git 的指南可以在GitHub 网站找到。

安装 Git 后,您可以从选择的父目录克隆(即复制)本书附带的 GitHub 仓库到您的计算机。请从您选择的父目录运行此命令:

>>>git clone https://github.com/mshearer0/HandsOnEntityResolution

这将创建一个名为HandsOnEntityResolution的子目录。

Python 虚拟环境

我建议您使用虚拟 Python 环境来完成本书中的示例。这将允许您在不干扰其他项目的情况下维护所需的 Python 软件包配置。以下命令将在由 Git 创建的HandsOnEntityResolution目录中创建一个新环境:

>>>python -m venv HandsOnEntityResolution

要激活环境,请运行以下命令:

>>>.\HandsOnEntityResolution\Scripts\activate.bat
(Windows)

>>>source HandsOnEntityResolution/bin/activate 
(Linux)

这将在命令提示符前缀中显示基于目录名称的环境名称:

>>>(HandsOnEntityResolution) 
    your_path\HandsOnEntityResolution

完成后,请务必停用环境:

>>>deactivate (Windows)

>>>deactivate (Linux)

接下来,切换到此目录:

>>>cd HandsOnEntityResolution

要设置我们的 JupyterLab 代码环境及所需的软件包,我们将使用 Python 软件包管理器 pip,这应该已包含在您的 Python 安装中。您可以使用以下命令检查:

>>>python -m pip --version
pip 23.0.1 from your_path\HandsOnEntityResolution\lib\
   site-packages\pip (python 3.7)

您可以从requirements.txt文件中安装本书中需要的软件包:

>>>pip install -r requirements.txt

接下来,配置一个与我们虚拟环境关联的 Python 内核,以便我们的笔记本可以使用:

>>>python -m ipykernel install --user
   --name=handsonentityresolution

然后使用以下命令启动 JupyterLab:

>>>jupyter-lab

虽然这相当简单明了,但如何开始使用 Jupyter 的说明可在文档中找到。

获取数据

现在我们已经配置好了环境,我们的下一个任务是获取我们需要的数据。通常我们需要的数据以各种格式和展示方式呈现。本书中的示例将演示如何处理我们遇到的一些最常见的格式。

Wikipedia 数据

在我们的 Jupyter 环境中打开 Chapter2.ipynb,我们首先定义了 2019 年英国大选中返回的议员列表的 Wikipedia URL:

url = "https://en.wikipedia.org/wiki/
       List_of_MPs_elected_in_the_2019_United_Kingdom_general_election"

然后,我们可以导入 requests 和 Beautiful Soup Python 包,并使用它们下载 Wikipedia 文本的副本。然后运行 html parser 来提取页面上存在的所有表格:

import requests
from bs4 import BeautifulSoup

website_url = requests.get(url).text
soup = BeautifulSoup(website_url,'html.parser')
tables = soup.find_all('table')

Beautiful Soup

Beautiful Soup 是一个 Python 包,可以轻松地从网页中抓取信息。更多详细信息请参阅在线文档

接下来,我们需要找到页面中我们想要的表格。在这种情况下,我们选择包含“Member returned”(一个列名)文本的表格。在该表格内,我们提取列名作为标题,然后迭代所有剩余的行和元素,构建一个列表的列表。然后,将这些列表加载到 pandas DataFrame 中,并设置提取的标题作为 DataFrame 的列名:

import pandas as pd

for table in tables:
   if 'Member returned' in table.text:
      headers = [header.text.strip() for header in table.find_all('th')]
      headers = headers[:5]
      dfrows = []
      table_rows = table.find_all('tr')
      for row in table_rows:
         td = row.find_all('td')
         dfrow = [row.text for row in td if row.text!='\n']
         dfrows.append(dfrow)

df_w = pd.DataFrame(dfrows, columns=headers)

结果是一个 pandas DataFrame,如图 2-1 所示,我们可以使用 info 方法来查看。

图 2-1. Wikipedia 的议员信息

我们有 652 条记录,5 列。这看起来很有前景,因为在每列中,有 650 行具有非空值,这与英国下议院议席的数量相匹配。

最后,我们可以通过保留我们需要的列来简化我们的数据集:

df_w = df_w[['Constituency','Member returned','Notes']]

TheyWorkForYou 数据

现在我们可以继续下载我们的第二个数据集,并将其加载到单独的 DataFrame 中,如图 2-2 所示:

url = "https://www.theyworkforyou.com/mps/?f=csv"
df_t = pd.read_csv(url, header=0)

图 2-2. TheyWorkForYou 的议员信息

2024/25 年英国大选后

如果你在 2024/25 年英国大选后阅读本书,那么 TheyWorkForYou 网站可能会更新新的议员信息。如果你在自己的机器上跟着操作,请使用附带本书的 GitHub 仓库中提供的 mps_they_raw.csv 文件。原始的 Wikipedia 数据 mps_wiki_raw.csv 也已提供。

图 2-3 列出了 DataFrame 的前几行,以便我们可以查看这些字段通常包含的信息。

图 2-3. TheyWorkForYou 数据集的前五行

要发现每个议员是否有关联的 Facebook 账户,我们需要跟随 URI 列中的链接查看他们的 TheyWorkForYou 主页。我们需要为每一行执行此操作,因此我们定义一个函数,可以沿着 DataFrame 的轴应用该函数。

添加 Facebook 链接

此函数使用了与我们用来解析维基百科网页的 Beautiful Soup 包相同的方法。在这种情况下,我们提取所有指向facebook.com的链接。然后我们检查第一个链接。如果这个链接是 TheyWorkForYou 的账户,那么该网站没有列出该议员的 Facebook 账户,因此我们返回一个空字符串;如果有,那么我们返回该链接:

def facelink(url):
   website_url = requests.get(url).text
   soup = BeautifulSoup(website_url,'html.parser')
   flinks = [f"{item['href']}" for item in soup.select
      ("a[href*='facebook.com']")]
   if flinks[0]!="https://www.facebook.com/TheyWorkForYou":
      return(flinks[0])
   else:
      return("")

我们可以使用apply方法将这个函数应用到 DataFrame 的每一行,调用facelink函数,将URI值作为 URL 传递。函数返回的值被添加到一个新列中,该列由 Flink 附加到 DataFrame 中。

df_t['Flink'] = df_t.apply(lambda x: facelink(x.URI), axis=1)

请耐心等待—这个函数需要做很多工作,所以在您的机器上可能需要几分钟才能运行完毕。一旦完成,我们可以再次查看前几行,如图 2-4 所示,检查我们是否得到了期望的 Facebook 链接。

图 2-4. TheyWorkForYou 数据集中带有 Facebook 链接的前五行

最后,我们可以简化我们的数据集,只保留我们需要的列:

df_t = df_t[['Constituency','First name','Last name','Flink']]

数据清洗

现在我们有了原始数据集,我们可以开始我们的数据清洗过程。我们将首先对维基百科数据集进行一些初始清洗,然后是 TheyWorkForYou 的数据。然后我们将尝试连接这些数据集,并查看我们需要标准化的进一步不一致性。

维基百科

让我们来看看维基百科数据集中的前几行和最后几行,如图 2-5 所示。

图 2-5. 维基百科数据的前五行和最后五行

我们数据清洗过程中的第一个任务是标准化我们的列名:

df_w = df_w.rename(columns={ 'Member returned' : 'Fullname'})

我们还可以看到我们的解析器的输出在 DataFrame 的开头和结尾有空白行,并且似乎每个元素末尾都有\n字符。这些附加内容显然会干扰我们的匹配,所以需要移除它们。

要删除空白行,我们可以使用:

df = df.dropna()

要去除末尾的\n字符:

df_w['Constituency'] = df_w['Constituency'].str.rstrip("\n")
df_w['Fullname'] = df_w['Fullname'].str.rstrip("\n")

为了确保我们现在有一个干净的Fullname,我们可以检查是否还有其他的\n字符。

df_w[df_w['Fullname'].astype(str).str.contains('\n')]

这个简单的检查显示,我们也有需要移除的前导值:

df_w['Fullname'] = df_w['Fullname'].str.lstrip("\n")

我们的下一个任务是将我们的Fullname拆分为FirstnameLastname,以便我们可以独立匹配这些值。为了本例的目的,我们将使用一个简单的方法,选择第一个子字符串作为Firstname,剩余的由空格分隔的子字符串作为Lastname

df_w['Firstname'] = df_w['Fullname'].str.split().str[0]
df_w['Lastname'] = df_w['Fullname'].astype(str).apply(lambda x:
   ' '.join(x.split()[1:]))

我们可以通过查看包含空格的Lastname条目来检查这种基本方法的工作情况。图 2-6 展示了仍然存在空格的Lastname条目。

图 2-6. 检查维基百科数据中复合Lastname条目

现在我们有了一个足够干净的数据集,可以尝试第一次匹配,所以我们将转向我们的第二个数据集。

TheyWorkForYou

正如我们之前看到的,TheyWorkForYou 的数据已经相当干净,所以在这个阶段,我们所需要做的就是将列名与前一个 DataFrame 的列名标准化。这将使我们在尝试匹配时更加轻松:

df_t = df_t.rename(columns={'Last name' : 'Lastname',
                             'First name' : 'Firstname'})

属性比较

现在我们有两个格式类似的 DataFrame,我们可以尝试实体解析过程的下一阶段。因为我们的数据集很小,我们不需要使用记录阻塞,所以我们可以直接尝试对FirstnameLastnameConstituency进行简单的精确匹配。merge方法(类似于数据库的join)可以为我们执行这种精确匹配:

len(df_w.merge(df_t, on=['Constituency','Firstname','Lastname']))
599

我们发现 650 个中有 599 个完美匹配所有三个属性——不错!仅在ConstituencyLastname上进行匹配,我们得到 607 个完美匹配,因此显然有 8 个不匹配的Firstname条目:

len(df_w.merge(df_t, on=['Constituency','Lastname']))
607

FirstnameLastnameConstituency的剩余排列重复这个过程,得到了匹配计数的维恩图,如图 2-7 所示。

图 2-7. 维恩图

简单地在Firstname上进行连接给出了 2663 个匹配,而在Lastname上的等效匹配则有 982 个匹配。这些计数超过了议员的数量,因为有重复的常见名称在两个数据集之间匹配了多次。

到目前为止,我们在 650 个选区中有 599 个匹配,但是我们能做得更好吗?让我们从检查数据集中的Constituency属性开始。作为一个分类变量,我们预计这应该是相当容易匹配的:

len(df_w.merge(df_t, on=['Constituency'] ))
623

我们有 623 个匹配项,还剩下 27 个未匹配的。为什么?我们肯定期望两个数据集中存在相同的选区,那么问题出在哪里?

选区

让我们看看两个数据集中未匹配人口的前五名。为此,我们使用Constituency属性在 DataFrame 之间执行外部连接,然后选择那些在右侧(维基百科)或左侧(TheyWorkForYou)DataFrame 中找到的记录。结果显示在图 2-8 中。

图 2-8. 选区不匹配

我们可以看到,TheyWorkForYou 网站的第一个数据集中选区名称中嵌有逗号,而维基百科的数据集中没有。这解释了它们为什么不匹配。为了确保一致性,让我们从两个 DataFrame 中都删除逗号:

df_t['Constituency'] = df_t['Constituency'].str.replace(',', '')
df_w['Constituency'] = df_w['Constituency'].str.replace(',', '')

在应用此清理后,我们在所有 650 个选区上都实现了完美匹配:

len(df_w.merge(df_t, on=['Constituency']))
650

区分大小写

在这个简单的例子中,我们在两个数据集之间有匹配的大小写约定(例如,初始大写)。在许多情况下,情况可能并非如此,您可能需要标准化为大写或小写字符。我们将在后面的章节中看到如何做到这一点。

在所有三个属性上重复我们的完美匹配,现在我们可以匹配 624 条记录:

len(df_w.merge(df_t, on=['Constituency','Firstname','Lastname']))
624

那其他的 26 个呢?

在这里一点领域知识是有用的。正如我们在本章开头所考虑的那样,在 2019 年选举和写作时期之间,发生了一些补选。如果我们看看既不匹配名字也不匹配姓氏的选区,那么至少对于这个简单的例子来说,我们可以确定可能的候选人,如图 2-9 所示。

图 2-9. 潜在的补选

在我们的 14 个补选候选人中,有 13 个案例,名字完全不同,这表明我们有理由排除它们,但牛顿阿伯特的候选人似乎是一个潜在的匹配,因为在一个数据集中的中间名“莫里斯”已经包含在姓氏中,在另一个数据集中包含在名字中,这使得我们在两个属性上的精确匹配受到阻碍。

实际上,我们可以用来自英国议会网站的数据来验证我们的结论。这证实了在匹配的选区内已经举行了补选。这解释了我们 26 条未匹配记录中的 13 条——剩下的呢?让我们挑选出只有名字或姓氏匹配但另一个不匹配的情况。这个子集在图 2-10 中展示。

图 2-10. 潜在的补选

我们可以看到剩下的 12 条记录,如表 2-1 所示,展示了我们在第一章中讨论的各种匹配问题。

表 2-1. 匹配问题总结表

匹配问题 他们为你工作 维基百科
缩写名 丹尼尔
坦曼吉特
丽兹 伊丽莎白
克里斯 克里斯托弗
努斯 努斯拉特
包括中间名 黛安娜·R. 黛安娜
杰弗里·M. 杰弗里
包含中间名 普里特·卡尔 普里特
约翰·马丁 约翰
姓氏后缀 佩斯利(二世) 佩斯利
双姓 多克蒂 多克蒂-休斯

还有一个剩余的难以解决的不匹配情况:在伯顿选区,上一次的名字是格里菲斯,现在是克尼维顿。现在我们已经统计了所有 650 个选区。

如果我们进一步从 TheyWorkForYou 数据中清除Firstname,删除任何中间名或姓名,我们可以进一步提高我们的匹配度:

df_t['Firstname'] = df_t['Firstname'].str.split().str[0]

我们现在可以匹配另外四条记录:

df_resolved = df_w.merge(df_t, on=['Firstname','Lastname'] )

len(df_resolved)
628

这使我们结束了基本数据清理技术的介绍。现在我们只剩下九条未解决的记录,如图 2-11 所示。在下一章中,我们将看到更多近似文本匹配技术如何帮助我们解决其中一些问题。

图 2-11. 未解决实体

测量表现

让我们使用基于我们在第一章中定义的指标的简单精确匹配方法来评估我们的表现。我们的总人口规模是 650,其中:

T r u e p o s i t i v e m a t c h e s ( T P ) = 628

F a l s e p o s i t i v e m a t c h e s ( F P ) = 0

T r u e n e g a t i v e m a t c h e s ( T N ) = 13 ( B y - e l e c t i o n s )

F a l s e n e g a t i v e m a t c h e s ( F N ) = 9

我们可以计算我们的表现指标如下:

P r e c i s i o n = TP (TP+FP) = 628 (628+0) = 100 %

R e c a l l = TP (TP+FN) = 628 (628+9) 98 . 6 %

A c c u r a c y = (TP+TN) (TP+TN+FP+FN) = (628+13) 650 98 . 6 %

我们的精确度非常高,因为我们设定了一个非常高的标准:在名字、姓氏和选区完全匹配的情况下;如果我们宣布匹配,我们总是正确的。我们的召回率也非常高;我们很少找不到应该找到的匹配项。最后,我们的总体准确率也非常高。

当然,这只是一个简单的例子,数据质量相对较高,我们有一个非常强的分类变量(选区)来进行匹配。

示例计算

我们已成功解决了两个数据集之间的姓名冲突,所以现在我们可以使用合并后的信息来验证我们关于社交媒体存在与议员连任可能性相关性的假设。我们解决后的数据现在在一个表格中包含了我们需要的所有信息。图 2-12 展示了这个表格的前几行。

图 2-12. 已解决实体的示例

我们可以计算目前在 Facebook 上有账号并在 2019 年选举中保住席位的议员数量:

df_heldwithface = df_resolved[(df_resolved['Flink']!="") &
      (df_resolved['Notes']=="Seat held\n")]
len(df_heldwithface)
474

以百分比表示:474 628 75 %

最后,我们会将我们清洗后的数据集保存在本地,以便在接下来的章节中使用:

df_w.to_csv('mps_wiki_clean.csv', index=False)
df_t.to_csv('mps_they_clean.csv', index=False)

摘要

总结一下,我们使用了五种简单的技术来标准化和清理我们的数据:

  • 移除空记录

  • 移除前导和尾随的不需要的字符

  • 将全名拆分为名字和姓氏

  • 从选区中移除逗号

  • 从名字中移除中间名和首字母缩写

由于这一操作,我们能够合并我们的数据集,然后计算一个简单的度量标准,否则我们是无法做到的。唉,没有普适的清理过程;它取决于你所拥有的数据集。

在下一章中,我们将看到模糊匹配技术如何进一步提升我们的性能。

^(1) 补选,也称为美国的特别选举,是用来填补在大选之间出现空缺的职位的选举。在英国议会,众议院的一个席位在议员辞职或去世时可能会出现空缺。

^(2) 本书中标识的软件产品仅供参考。您有责任评估是否使用任何特定软件并接受其许可条款。

第三章:文本匹配

正如我们在第二章中看到的,一旦我们的数据经过清洗并且格式一致,我们可以通过检查它们的数据属性的精确匹配来找到匹配的实体。如果数据质量很高,并且属性值不重复,那么检查等价性就很简单了。然而,在实际数据中,情况很少这样。

通过使用近似(通常称为模糊匹配技术,我们可以增加匹配所有相关记录的可能性。对于数值,我们可以设置一个容差来确定数值需要多接近。例如,出生日期可能会匹配,如果在几天内,或者位置可能会匹配,如果它们的坐标相距一定距离。对于文本数据,我们可以查找可能会偶然产生的字符串之间的相似性和差异。

当然,通过接受非精确匹配作为等效,我们也开放了错误匹配记录的可能性。

在本章中,我们将介绍一些经常使用的文本匹配技术,然后将它们应用到我们的示例问题中,看看是否能提高我们的实体解析性能。

编辑距离匹配

在文本匹配中,一种最有用的近似匹配技术之一是测量两个字符串之间的编辑距离。编辑距离是将一个字符串转换为另一个字符串所需的最小操作次数。因此,这个度量可以用来评估两个字符串描述相同属性的可能性,即使它们记录方式不同。

第一个,也是最普遍适用的近似匹配技术是莱文斯坦距离。

莱文斯坦距离

莱文斯坦距离 是一种著名的编辑距离度量,以其创造者苏联数学家弗拉基米尔·莱文斯坦命名。

两个字符串 a 和 b(长度分别为 |a| 和 |b|)之间的莱文斯坦距离由 lev(a,b) 给出,其中

l e v ( a , b ) = | a | if | b | = 0 , | b | if | a | = 0 , l e v ( t a i l ( a ) , t a i l ( b ) ) if a [ 0 ] = b [ 0 ] , 1 + m i n l e v ( t a i l ( a ) , b ) l e v ( a , t a i l ( b ) ) otherwise l e v ( t a i l ( a ) , t a i l ( b ) )

在这里,某个字符串 x 的尾部是除 x 的第一个字符外的所有字符的字符串,x[n] 是字符串 x 的第 n 个字符,从 0 开始计数。

打开Chapter3.ipynb笔记本,我们可以看到这在实践中是如何工作的。幸运的是,我们不必自己编写莱文斯坦算法——Jellyfish Python 包已经实现了我们可以使用的算法。这个库还包含许多其他模糊和语音字符串匹配函数。

Jellyfish

Jellyfish 是一个用于近似和语音匹配字符串的 Python 库。

如果您没有安装这个包,可以使用 Jupyter Notebook 的魔法命令 %pip 在导入之前安装它:

%pip install jellyfish
import jellyfish as jf

内核重启

在安装新的 Python 包后,您可能需要重新启动内核并重新运行笔记本。

然后,我们可以计算编辑距离度量来检查如何测量常见的拼写错误:

jf.levenshtein_distance('Michael','Micheal')
2

逻辑上,Levenshtein 算法逐个字符地遍历两个字符串的字符,从第一个到最后一个,如果字符不匹配则增加距离分数。在本例中,由于 M、i、c 和 h 字符都匹配,第一次增加距离分数是在第五个字符遇到字母 a 和 e 不匹配时。此时,我们遍历剩余字符的三个变体,并选择剩余字符串之间的最小分数:

  • “el” 和 “ael”

  • “ael” 和 “al”

  • “el” 和 “al”

所有三个选项在下一个字符上也有不匹配,再次增加分数。对每个选项重复这个过程会生成另外三个子选项,最后一个是每个字符串最后一个“l”的简单匹配,总最小分数为 2。

我将留给读者作为练习去处理其余选项,所有这些选项都产生相同的分数为 2。

Jaro 相似度

在 1989 年,Matthew Jaro 提出了一种评估字符串相似性的替代方法。维基百科给出了以下公式。

两个字符串 s[1]s[2] 的 Jaro 相似度 sim[j]

s i m j = 0 if m = 0 1 3 ( m |s 1 | + m |s 2 | + m-t m ) otherwise

其中:

  • |s[i]| 是字符串 s[i] 的长度

  • m 是匹配字符数(见下文)

  • t 是转置数(见下文)

  • 如果两个字符串完全不匹配,则 Jaro 相似度分数为 0,如果它们完全匹配,则为 1。

在第一步中,将 s[1] 的每个字符与 s[2] 中所有匹配的字符进行比较。仅当 s[1]s[2] 中的两个字符相同且不超过 max(s 1 ,s 2 ) 2 - 1 个字符时,才考虑它们匹配。如果找不到匹配的字符,则字符串不相似,算法通过返回 Jaro 相似度分数 0 而终止。如果找到非零匹配的字符,则下一步是找到转置数。转置是不按正确顺序排列的匹配字符数除以 2。

再次,我们可以使用 Jellyfish 库来计算这个值:

jf.jaro_similarity('Michael','Micheal')
0.9523809523809524

在这里,该值计算如下:

| s 1 | = | s 2 | = 7 (length of both strings)

m = 7 (all characters match)

t = 1 (a and e transposition)

因此,Jaro 相似度值计算如下:

= 1 3 ( 7 7 + 7 7 + (7-1) 7 ) = 20 21 = 0 . 9523809523809524 .

在 Levenshtein 和 Jaro 方法中,字符串中的所有字符都对得分有贡献。然而,当匹配名称时,前几个字符通常更为重要。因此,如果它们相同,则更有可能表示等价。为了认识到这一点,William E. Winkler 在 1990 年提出了 Jaro 相似度的修改方法。

Jaro-Winkler 相似度

Jaro-Winkler 相似度使用前缀比例 p,给与从开始匹配的字符串更有利的评分。给定两个字符串 s[1]s[2],它们的 Jaro-Winkler 相似度 sim[w]sim[j] + lp(1 − sim[j]),其中:

  • sim[j]s[1] 和 s[2] 的 Jaro 相似度。

  • l 是字符串开始处的共同前缀长度,最多为四个字符。

  • p 是用于调整得分上升量的常数缩放因子,因为具有共同前缀的匹配。

  • p 不应超过 0.25(即 1/4,其中 4 是被考虑的前缀的最大长度),否则相似度可能大于 1。

  • Winkler 工作中此常数的标准值为 p = 0.1。

使用这个度量:

jf.jaro_winkler_similarity('Michael','Micheal')

0.9714285714285714

这是如何计算的:

= 20 21 + 4 × 0 . 1 × ( 1 - 20 21 ) = 0 . 9714285714285714

其中:

  • sim[j] = 20 21

  • l = 4(“Mich”的常见前缀)

  • p = 0.1(标准值)

值得注意的是,Jaro-Winkler 相似度度量对大小写敏感,因此:

jf.jaro_winkler_similarity('michael','MICHAEL')
0

因此,通常的做法是在匹配前将字符串转换为小写。

jf.jaro_winkler_similarity('michael'.lower(),'MICHAEL'.lower())
1.0

语音匹配

与编辑距离匹配的另一种选择是比较单词发音的相似性。这些语音算法大多基于英语发音,其中两个最流行的是 MetaphoneMatch Rating ApproachMRA)。

Metaphone

Metaphone 算法将每个单词编码成来自“0BFHJKLMNPRSTWXY”集合的字母序列,其中 0 代表“th”音,而 X 代表“sh”或“ch”。例如,使用 Jellyfish 软件包,我们可以看到 'michael' 被简化为 'MXL',而 'michel' 也是如此。

jf.metaphone('michael')
MXL

这种转换产生了一个常见的键,可以精确匹配以确定等效性。

匹配等级方法

MRA 语音算法是在 1970 年代末开发的。与 Metaphone 类似,它使用一组规则将单词编码为简化的语音表示。然后使用一组比较规则来评估相似度,该相似度根据它们的组合长度得出的最小阈值来确定是否匹配。

比较这些技术

为了比较编辑距离和语音相似度技术,让我们来看看它们如何评估 Michael 的常见拼写错误和缩写:

mylist = ['Michael','Micheal','Michel','Mike','Mick']
combs = []

import itertools

for a, b in itertools.combinations(mylist, 2):
   combs.append([a,b,
      jf.jaro_similarity(a,b),
      jf.jaro_winkler_similarity(a, b),   
      jf.levenshtein_distance(a,b),
      jf.match_rating_comparison(a,b),
      (jf.metaphone(a)==jf.metaphone(b))])

pd.DataFrame(combs, columns=['Name1','Name2','Jaro','JaroWinkler','Levenshtein',
 'Match Rating','Metaphone'])

这给出了在 表 3-1 中显示的结果。

表 3-1. 文本匹配比较

Name1 Name2 Jaro Jaro-Winkler Levenshtein Match rating Metaphone
Michael Micheal 0.952381 0.971429 2 True True
Michael Michel 0.952381 0.971429 1 True True
Michael Mike 0.726190 0.780952 4 False False
Michael Mick 0.726190 0.808333 4 True False
Micheal Michel 0.952381 0.971429 1 True True
Micheal Mike 0.726190 0.780952 4 False False
迈克尔 米克 0.726190 0.780952 4 True False
米歇尔 迈克 0.750000 0.808333 3 False False
米歇尔 米克 0.750000 0.825000 3 True False
迈克 米克 0.833333 0.866667 2 True True

正如我们从这个简单的例子中可以看到的那样,技术之间存在相当一致性,但没有一种方法在所有情况下都明显优越。许多其他字符串匹配技术已被开发出来,各自具有其优势。为了本书的目的,我们将使用 Jaro-Winkler 算法,因为它在匹配名字时表现良好,由于其偏向于初始字符,这些字符往往更为重要。它也广泛支持我们将要使用的数据后端。

示例问题

在第二章中,我们匹配了两个名单,这些名单是英国下议院议员的成员,以探索社交媒体存在与连任之间的相关性。我们使用精确字符串匹配来建立成员的ConstituencyFirstnameLastname属性的等价性。

我们发现了 628 个真正的正面匹配。但是由于名字之间的差异,我们没有找到非精确匹配,导致我们有 9 个假负面匹配。让我们看看通过使用字符串相似度指标是否可以改善我们的性能。我们首先加载在第二章中保存的不匹配记录,如图 3-1 所示。

图 3-1. 不匹配人口

使用apply函数,我们可以计算 Jaro-Winkler 相似度指标,以比较两个数据集之间的名字(姓和名)。我们使用 Jaro-Winkler 算法,因为它在匹配名字时性能更好:

df_w_un['Firstname_jaro'] = df_w_un.apply(
    lambda x: jf.jaro_winkler_similarity(x.Firstname_w, x.Firstname_t), axis=1)

df_w_un['Lastname_jaro'] = df_w_un.apply(
    lambda x: jf.jaro_winkler_similarity(x.Lastname_w, x.Lastname_t), axis=1)

然后我们可以在FirstnameLastname属性上应用 0.8 的阈值,得到 6 个匹配,如图 3-2 所示。

图 3-2. Jaro-Winkler 匹配人口

不错!我们现在又找到了先前错过的另外 6 个潜在匹配。如果我们将阈值提高到 0.9,我们只会找到额外的两个匹配;如果我们将阈值降低到 0.4,所有九个都将匹配。

作为提醒,在第二章中,我们使用了准确匹配选区。然后,为了识别不匹配的人口,我们选择了那些名字的记录,其中名或姓不匹配。这使我们能够区分因补选而产生的真负面结果和假负面结果,其中我们需要更灵活的匹配技术。然而,我们很少有像选区这样的高基数分类变量来帮助我们,因此我们需要考虑如何仅通过名字匹配这些实体。

在这种情况下,我们无法再使用精确属性匹配上的简单合并方法来连接我们的数据集。相反,我们需要手动构建一个联合数据集,包括每对记录的所有可能组合,然后对每对名字和姓氏应用我们的相似性函数,以查看哪些足够相似。然后,我们可以剔除那些等价分数低于所选阈值的组合。显然,这种方法可能导致第一个数据集的记录与第二个数据集中的多个记录匹配。

全部相似性比较

从第二章获取清理后的数据集,我们可以使用交叉合并功能生成所有记录的组合。这样产生的是数据集之间每个名字组合的行,生成 650 × 650 = 422,500 条记录:

df_w = pd.read_csv('mps_wiki_clean.csv')
df_t = pd.read_csv('mps_they_clean.csv')
cross = df_w.merge(df_t, how='cross',suffixes=('_w', '_t'))
cross.head(n=5)

图 3-3 显示了交叉产品数据集中的前几条记录。

图 3-3. 维基百科,他们的工作交叉产品

然后,我们可以计算每行的名字和姓氏的 Jaro-Winkler 相似度指标。应用 0.8 的阈值,我们可以确定每行的这些值是否大致匹配:

cross['Firstname_jaro'] = cross.apply(lambda x: True if 
    jf.jaro_winkler_similarity(x.Firstname_w, x.Firstname_t);0.8 
    else False, axis=1)

cross['Lastname_jaro'] = cross.apply(lambda x: True if 
    jf.jaro_winkler_similarity(x.Lastname_w, x.Lastname_t);0.8 
    else False, axis=1)

然后,我们可以选择那些FirstnameLastname属性都大致等效于我们的潜在匹配记录。我们可以通过使用Constituency属性来验证我们的结果是否正确。我们知道,当选区不匹配时,我们不是在指同一名议会议员。

现在让我们看看我们现在有多少真正的正匹配:

tp = cross[(cross['Firstname_jaro'] & cross['Lastname_jaro']) & 
    (cross['Constituency_w']==cross['Constituency_t'])]

len(tp)
634

这些真正的正例包括从第二章中的 628 个精确匹配加上我们之前确定的 6 个近似匹配。但是我们来看看我们拾取了多少假正例,即属性名大致等效但Constituency不匹配的情况:

fp = cross[(cross['Firstname_jaro'] & cross['Lastname_jaro']) & 
    (cross['Constituency_w']!=cross['Constituency_t'])]

len(fp)
19

让我们来看看图 3-4 中这 19 条不匹配记录。

图 3-4. 完全匹配的假正例

我们可以看到这些名称之间存在相似性,尽管它们并不指代同一人。这些不匹配是我们采用相似匹配以最大化真正正例数量所付出的代价。

我们还可以通过检查选区匹配但名字的第一个或姓氏不匹配的地方来检查我们拒绝的候选人。我们必须手动检查这些候选人,以确定它们是真负例还是假负例。

fntn = cross[(~cross['Firstname_jaro'] | ~cross['Lastname_jaro']) & 
    (cross['Constituency_w']==cross['Constituency_t'])]

len(fntn)
16

图 3-5 显示了这 16 条负匹配记录。

在这 16 个负例中,我们可以看到我们在第二章中宣布为真负例的 13 个补选选区,以及在伯顿、南西诺福克和纽顿艾伯特等选区中的 3 个假负例,这些选区的名称有足够的不同,导致它们的 Jaro-Winkler 匹配分数低于我们的 0.8 阈值。

图 3-5. 完全匹配的真假负例

性能评估

现在让我们考虑我们的性能如何与第二章中的仅精确匹配相比:

R e c a l l = TP (TP+FN) = 634 (634+3) 99 . 2 %

P r e c i s i o n = TP (TP+FP) = 634 (634+19) 97 %

A c c u r a c y = (TP+TN) (TP+TN+FP+FN) = (634+13) (634+13+19+3) 96 . 7 %

我们可以在表 3-2 中看到,引入相似度阈值而不是要求精确匹配已经提高了我们的召回率。换句话说,我们错过了更少的真正匹配,但以一些错误匹配为代价,这降低了我们的精确度和整体准确性。

表 3-2. 精确匹配与近似匹配性能

精确匹配 近似匹配
精确度 100% 97%
召回率 98.6% 99.2%
准确度 98.5% 96.7%

在这个简单的例子中,我们为名字和姓氏都设置了 0.8 的阈值,并要求两个属性的分数都超过这个分数才宣布为匹配。这样赋予了两个属性相同的重要性,但也许名字的匹配并不像姓氏的匹配那么强?

让我们来看看在维基百科数据集中我们在名字和姓氏中看到了多少重复:

df_w['Firstname'].value_counts().mean()
1.8950437317784257

df_w['Lastname'].value_counts().mean()
1.1545293072824157

在这个数据集中,每个Firstname平均出现 1.89 次,而每个Lastname平均出现 1.15 次。因此,我们可以说Lastname的匹配比Firstname的匹配更具有区分性,相差 64%(1.89/1.15)。在下一章中,我们将研究如何使用概率技术来权衡每个属性的重要性,并将其结合以产生整体匹配置信度分数。

摘要

在本章中,我们探讨了如何使用近似匹配技术来评估两个属性之间的等价程度。我们检查了几种近似文本匹配算法,并设置了一个等价阈值,高于这个阈值我们宣布为匹配。

我们看到近似匹配如何帮助我们找到本来会错过的真正正匹配,但以需要手动排除一些误报为代价。我们看到我们设置的等价阈值如何影响性能上的权衡。

最后,我们考虑在评估两条记录是否指向同一实体时,是否应给予具有不同唯一性级别的匹配属性相等的权重。

第四章:概率匹配

在第三章中,我们探讨了如何使用近似匹配技术来衡量属性值之间的相似程度。我们设定了一个阈值,超过此阈值我们宣布它们等价,并将这些匹配特征以相等的权重结合起来,以确定两条记录指代同一实体。我们仅针对精确匹配评估了我们的性能。

本章中,我们将探讨如何使用基于概率的技术来计算每个等效属性的最佳加权,以计算整体实体匹配的可能性。这种基于概率的方法允许我们在最具统计显著性的属性等价(精确或近似)时宣布匹配,但那些重要性较低的属性不足够相似时则不匹配。它还允许我们对匹配声明的信心进行分级,并应用适当的匹配阈值。本节将介绍的模型被称为 Fellegi-Sunter(FS)模型。

我们还将介绍一种概率实体解析框架 Splink,该框架将帮助我们计算这些指标并解决我们的实体问题。

示例问题

让我们回到从第二章末尾的精确匹配结果。打开Chapter4.ipynb笔记本,我们重新加载维基百科和 TheyWorkForYou 网站的标准化数据集。与第三章一样,我们首先通过以下方式计算两个数据集的笛卡尔积或交叉乘积:

cross = df_w.merge(df_t, how='cross', suffixes=('_w', '_t'))

这为我们提供了 650 × 650 = 422,500 对记录的总人口——维基百科和 TheyWorkForYou 数据集之间每个姓名组合的一对。

在本章中,我们将多次使用每个记录对的FirstnameLastnameConstituency字段之间的精确匹配。因此,一次计算这些匹配并将它们存储为额外的特征列更为高效:

cross['Fmatch']= (cross['Firstname_w']==cross['Firstname_t'])
cross['Lmatch']= (cross['Lastname_w']==cross['Lastname_t'])
cross['Cmatch']= (cross['Constituency_w']==cross['Constituency_t'])

我们还计算了后续将使用的匹配列的总数:

cross['Tmatch'] =
    sum([cross['Fmatch'],cross['Lmatch'],cross['Cmatch']]) 

根据我们在第二章中对数据的探索,我们知道在总共 422,500 个组合中,有 637 对记录具有选区和名字中的第一个名字或姓氏的精确匹配。这是我们的match人口:

match = cross[cross['Cmatch'] & (cross['Fmatch'] |
   cross['Lmatch'])]

剩余的notmatch人口则是反向提取:

notmatch = cross[(~cross['Cmatch']) | (~cross['Fmatch'] &
    ~cross['Lmatch'])]

这些组合总结在表 4-1 中。

Table 4-1. 匹配与不匹配的组合

匹配/不匹配人口 选区匹配 第一个名字匹配 姓氏匹配
不匹配
不匹配
不匹配
不匹配
不匹配
匹配
匹配
匹配

现在我们将检查名字和姓氏等价性,无论是单独还是一起,能多大程度上预测一个个体记录应属于matchnotmatch人群。

单属性匹配概率

让我们首先考虑单单以名字等价作为一个记录对中的两个实体是否指向同一个人的良好指标。我们将检查matchnotmatch人群,并在每个子集内部建立,有多少个名字匹配和多少个不匹配。

命名约定

当我们处理这些人群的各种子集时,采用标准的命名约定是有帮助的,这样我们可以一眼看出每个记录人群是如何被选中的。当我们选择记录时,我们将选择标准添加到人群名称中,从右向左添加,例如,first_match应该被理解为首先选择那些属于match人群的记录,并在该人群子集中进一步选择只有名字相等的行。

名字匹配概率

match人群开始,我们可以选择那些名字等于的记录,以获得我们的first_match人群:

first_match = match[match['Fmatch']]

len(first_match)
632

对于其他三种匹配/不匹配组合以及名字等价性或非等价性的重复,我们可以制作一个人口分布图,如图 4-1 所示。

图 4-1. 名字人口分布图

因此,基于名字等价性,我们有:

T r u e p o s i t i v e m a t c h e s ( T P ) = 632

F a l s e p o s i t i v e m a t c h e s ( F P ) = 2052

T r u e n e g a t i v e m a t c h e s ( T N ) = 419811

F a l s e n e g a t i v e m a t c h e s ( F N ) = 5

现在我们可以计算一些概率值。首先,一个名字等价的记录对实际上是真正匹配的概率可以计算为在match人群中,名字匹配的记录对数除以在matchnotmatch人群中名字匹配的记录对数:

p r o b m a t c h f i r s t = len(firstmatch) (len(firstmatch)+len(first_notmatch)) = 632 (632+2052) 0 . 2355

从中可以看出,仅有约 23%的名字等价性并不是两个记录匹配的很好预测器。这个值是一个条件概率,即在名字匹配的条件下是真正匹配的概率。可以写成:

P ( m a t c h | f i r s t )

管道字符(|)被读作“给定于”。

姓氏匹配概率

将相同的计算应用于姓氏,我们可以制作第二个人口分布图,如图 4-2 所示。

图 4-2. 姓氏人口分布图

至于名字,一个姓氏等价的记录对实际上是匹配的概率可以计算为在match人群中,姓氏匹配的记录对数除以在matchnotmatch人群中姓氏匹配的记录对数。

p r o b m a t c h l a s t = len(lastmatch) (len(lastmatch)+len(last_notmatch)) = 633 (633+349) 0 . 6446

对于这些记录来说,姓氏等价性显然是一个比名字更好的真实匹配预测器,这从直觉上讲是有道理的。

再次,这可以写成:

P ( m a t c h | l a s t )

多属性匹配概率

现在,如果我们考虑同时名字和姓氏的等效性,我们可以进一步将我们的人口地图细分。从我们的名字地图开始,进一步将每个名字类别细分为姓氏等效和非等效,我们可以查看我们的人口如图 4-3 所示。

图 4-3. 名字,姓氏人口地图

将我们的计算扩展到同时名字和姓氏完全匹配,我们可以计算给定名字和姓氏等效的真正正匹配的概率为:

p r o b m a t c h l a s t f i r s t = len(lastfirstmatch) (len(lastfirstmatch)+len(lastfirst_notmatch) = 628 (628+0) = 1 . 0

如果名字匹配但姓氏不匹配,那么它是匹配的概率是多少?

p r o b m a t c h n o t l a s t f i r s t = len(notlastfirstmatch) (len(notlastfirstmatch)+len(notlastfirst_notmatch)) = 4 (4+2052) 0 . 0019

如果名字不匹配但姓氏匹配,那么它是匹配的概率是多少?

p r o b m a t c h l a s t n o t f i r s t = len(lastnotfirstmatch) (len(lastnotfirstmatch)+len(lastnotfirst_notmatch)) = 5 (5+349) 0 . 0141

正如我们所预期的那样,如果名字或姓氏任一不完全匹配,那么真正正匹配的概率是低的,但姓氏匹配比名字匹配给我们更多的信心。

如果既没有名字匹配也没有姓氏匹配,那么它是匹配的概率是多少?

p r o b m a t c h n o t l a s t _ n o t f i r s t =

len(notlastnotfirstmatch) (len(notlastnotfirstmatch)+len(notlastnotfirstnotmatch)) = 0 (0+419462) = 0

这并不奇怪,因为我们定义了真正正匹配为在成分上具有完全匹配和名字或姓氏之一的记录。

总之,我们可以利用这些概率来指导我们是否可能有一个真正的正匹配。在这个例子中,我们会更加重视姓氏匹配而不是名字匹配。这是我们在第三章中方法的改进,我们在那里给了它们相同的权重(并要求它们都等效)来声明匹配。

但是等等,我们有一个问题。在前面的例子中,我们从已知的匹配人口开始,用于计算名字和姓氏等效是否等于匹配的概率。然而,在大多数情况下,我们没有已知的match人口;否则我们一开始就不需要执行匹配!我们如何克服这一点呢?为了做到这一点,我们需要稍微重新构思我们的计算,然后使用一些聪明的估算技术。

概率模型

在前一节中,我们了解到一些属性比其他属性更具信息量;也就是说,它们具有更多预测能力来帮助我们决定匹配是否可能是正确的。在本节中,我们将探讨如何计算这些贡献以及如何结合它们来评估匹配的总体可能性。

我们先从一点统计理论开始(以使用名字相等为例),然后我们将其推广为我们可以大规模部署的模型。

贝叶斯定理

贝叶斯定理,以托马斯·贝叶斯命名,陈述了一个事件的条件概率,基于另一个事件的发生,等于第一个事件的概率乘以第二个事件发生的概率。

考虑随机选择两条记录是真正正匹配的概率 P(match),乘以在这些匹配中名字匹配的概率 P(first|match):

P ( f i r s t | m a t c h ) × P ( m a t c h )

同样地,我们可以按相反顺序计算相同的值,从匹配的第一个名字的概率开始,乘以此人口内的记录是真正的正匹配的概率:

P ( m a t c h | f i r s t ) × P ( f i r s t )

等价这些概率,我们有:

P ( m a t c h | f i r s t ) × P ( f i r s t ) = P ( f i r s t | m a t c h ) × P ( m a t c h )

重新排列后,我们可以计算:

P ( m a t c h | f i r s t ) = P(first|match)×P(match) P(first)

我们可以计算 P(first)为matchnotmatch人口的概率之和:

P ( f i r s t ) = ( P ( f i r s t | m a t c h ) × P ( m a t c h ) + P ( f i r s t | n o t m a t c h ) × P ( n o t m a t c h ) )

代入上述方程,我们有:

P ( m a t c h | f i r s t ) = P(first|match)×P(match) P(first|match)×P(match)+P(first|notmatch)×P(notmatch)

或者,我们可以将其重新排列为:

P ( m a t c h | f i r s t ) = 1 - (1+P(first|match) P(first|notmatch)×P(match) P(notmatch)) -1

如果我们可以估算出这个方程中的值,我们就能确定如果一个名字相等,那么记录对确实是一次匹配的概率。

让我们稍微详细地检查这些值,随着符号的简化而进行。

m 值

在整个match人口中,一个属性将会相等的条件概率被称为m 值。使用我们的Firstname示例,我们可以表示为:

m f = P ( f i r s t | m a t c h )

在完美的数据集中,match人口中的所有名字将完全相等,m值将为 1。因此,这个值可以被认为是数据质量的一种度量,即属性在数据集中被捕捉到的变异程度。更高的值表示更高质量的属性。

u 值

在整个notmatch人口中,一个属性将会相等的条件概率被称为u 值。同样地,使用我们的Firstname示例,我们可以表示为:

u f = P ( f i r s t | n o t m a t c h )

这个值反映了在数据集中此属性有多少共同性。较低的值表示较不常见、更具区别性的属性,如果在特定情况下发现等效,则会使我们质疑它是否属于notmatch人口,并且是否真的匹配。相反,较高的u值告诉我们,这个特定的属性不太有价值,不能确定整体匹配。

u值的一个很好的例子是出生月份属性,假设人口在全年内均匀分布,将有一个u值为1 12

Lambda(λ)值

λ值,也称为先验,是两个随机选取的记录匹配的概率。

λ = P ( m a t c h )

mu值相比,λ值是一个与任何特定属性都不相关的记录级值。这个值是数据集整体重复的程度的度量,并且是我们概率计算的起点。

其倒数,即两个随机选取的记录不匹配的可能性,可以写为:

1 - λ = P ( n o t m a t c h )

贝叶斯因子

代入这些紧凑的符号可能会导致以下结果:

P ( m a t c h | f i r s t ) = 1 - (1+m f u f ×λ (1-λ)) -1

比率  m f u f 也被称为贝叶斯因子,在本例中是关于Firstname参数的。贝叶斯因子作为mu值的组合,用于衡量我们应该给予Firstname值等效性的重要性。

费勒吉-桑特模型

费勒吉-桑特模型,以伊凡·P·费勒吉和艾伦·B·桑特命名,^(1) 描述了我们如何扩展简单的贝叶斯方法,结合多个属性的贡献,计算匹配的总体可能性。它依赖于属性之间条件独立的简化假设,也称为朴素贝叶斯

使用 FS 模型,我们可以通过简单地将记录中每个属性的贝叶斯因子相乘来组合它们。以我们的Firstname示例为例,考虑Lastname也等效时:

P ( m a t c h | l a s t | f i r s t ) = 1 - (1+m f u f ×m l u l ×λ (1-λ)) -1

当属性不等效时,贝叶斯因子被计算为其倒数, (1-m l ) (1-u l ) 。因此,当Firstname相等而Lastname不等时,我们计算整体匹配的概率为:

P ( m a t c h | n o t l a s t | f i r s t ) = 1 - (1+m f u f ×(1-m l ) (1-u l )×λ (1-λ)) -1

一旦我们可以计算每个属性的mu值,以及整体数据集的 λ 值,我们可以轻松地计算每对记录的概率。我们只需确定每个属性的等效性(精确或适当的近似),选择适当的贝叶斯因子,并使用前述公式将它们相乘,以计算该记录对的总体概率。

对于我们的简单示例,我们的贝叶斯因子如 Table 4-2 所示计算。

表 4-2. FirstnameLastname 匹配因子计算

Firstname 等效性 Lastname 等效性 Firstname 贝叶斯因子 Lastname 贝叶斯因子 组合贝叶斯因子
1m f 1u f 1m l 1u l 1m f 1u f × 1m l 1u l
1m f 1u f m l u l 1m f 1u f × m l u l
m f u f (1-m l ) (1-u l ) m f u f × (1-m l ) (1-u l )
m f u f m l u l m f u f × m l u l

匹配权重

为了使整体匹配计算更直观,有时会使用贝叶斯因子的对数,这样它们可以相加而不是相乘。这样可以更容易地可视化每个属性对总体分数的相对贡献。

对于我们简单的名字等价示例,可以计算对数匹配权重(使用基数 2)如下:

M a t c h W e i g h t = l o g 2 m f u f + l o g 2 m l u l + l o g 2 λ (1-λ)

我们可以从匹配权重计算概率:

P r o b a b i l i t y = 1 - (1+2 MatchWeight ) -1

现在我们了解了如何将个体属性的概率或匹配权重组合在一起,让我们考虑在没有已知match群体时如何估计我们的 λ 值以及每个属性的mu值。我们可以使用的一种技术称为期望最大化算法(EM 算法)

期望最大化算法

期望最大化算法使用迭代方法来逼近 λmu 值。让我们看一个简化形式的示例,应用于我们的样本问题。

第一次迭代

在第一次迭代中,我们做出假设,即大多数特征列等效的记录对是匹配的:

it1_match = cross[cross['Tmatch']>=2]
it1_notmatch = cross[cross['Tmatch']<2]

len(it1_match)
637

这为我们提供了一个伪匹配人口 it1_match,共 637 条记录。除了我们在 第二章 中找到的 628 个完美匹配外,我们还有 9 个匹配,其中 FirstnameLastname(但不是两者同时)不匹配,如图 4-4 所示:

it1_match[~it1_match['Fmatch'] | ~it1_match['Lmatch']]
   [['Constituency_w','Firstname_w','Firstname_t',
      'Lastname_w','Lastname_t']]

图 4-4. 期望最大化迭代 1 附加匹配

因此,我们的初始 λ 值是:

λ 1 = 637 650×650 0 . 0015

( 1 - λ 1 ) = ( 1 - 0 . 0015 ) 0 . 9985

因此,我们的初始先验匹配权重是 l o g 2 λ 1 (1-λ 1 ) - 9 . 371

因此,作为起点,两个记录匹配的可能性极低。现在让我们计算我们的 mu 值,以便我们可以根据每个记录更新我们的概率。

由于我们有一个伪匹配和 notmatch 人口,因此可以直接计算我们的 mu 值,作为每种人口中具有等效属性的比例。对于 选区,我们使用:

mfi1 = len(it1_match[it1_match['Fmatch']])/len(it1_match)
mli1 = len(it1_match[it1_match['Lmatch']])/len(it1_match)
mci1 = len(it1_match[it1_match['Cmatch']])/len(it1_match)

ufi1 = len(it1_notmatch[it1_notmatch['Fmatch']])/len(it1_notmatch)
uli1 = len(it1_notmatch[it1_notmatch['Lmatch']])/len(it1_notmatch)
uci1 = len(it1_notmatch[it1_notmatch['Cmatch']])/len(it1_notmatch)

表 4-3 显示了这些值以及每个属性的匹配权重值。

表 4-3. 迭代 1 的 mu

属性 m u 匹配贝叶斯因子 匹配权重 不匹配贝叶斯因子 不匹配权重
0.9921 0.0049 203.97 7.67 0.0079 –6.98
0.9937 0.0008 1201.19 10.23 0.0063 –7.31
选区 1.0 0.0 0 -

notmatch 人群中,没有记录对其“选区”等价,因此其 u 值为 0,因此其 match 权重在数学上为无穷大,而 notmatch 权重为负无穷大。

现在我们可以将这些值用于 Fellegi-Sunter 模型中,计算完整人口中每对记录的匹配概率。我们使用一个辅助函数基于 选区 的匹配特征值来计算这些概率:

def match_prb(Fmatch,Lmatch,Cmatch,mf1,ml1,mc1,uf1,ul1,uc1, lmbda):
    if (Fmatch==1):
        mf = mf1
        uf = uf1
    else:
        mf = (1-mf1)
        uf = (1-uf1)
    if (Lmatch==1):
        ml = ml1
        ul = ul1
    else:
        ml = (1-ml1)
        ul = (1-ul1)
    if (Cmatch==1):
        mc = mc1
        uc = uc1
    else:
        mc = (1-mc1)
        uc = (1-uc1)
    prob = (lmbda * ml * mf * mc) / (lmbda * ml * mf * mc +
           (1-lmbda) * ul * uf * uc)
    return(prob)

我们将此函数应用于整个人口,得到:

cross['prob'] = cross.apply(lambda x: match_prb(
      x.Fmatch,x.Lmatch,x.Cmatch,
      mfi1,mli1,mci1,
      ufi1,uli1,uci1,
      lmbda), axis=1)

一旦我们计算了这些值,我们可以再次迭代,根据计算出的匹配概率重新将我们的人口分成matchnotmatch人口。

迭代 2

为了说明目的,我们使用大于 0.99 的总体匹配概率来定义我们的新假设match人口,并将任何匹配概率等于或低于此值的记录分配给我们的notmatch人口:

it2_match = cross[cross['prob']>0.99]
it2_notmatch = cross[cross['prob']<=0.99]

len(it2_match)
633

将这个 0.99 的阈值应用于我们略微减少的match人口,即 633 人。让我们看看为什么。如果我们选择略低于阈值的记录,我们可以看到:

it2_notmatch[it2_notmatch['prob']>0.9]
   [['Constituency_w', 'Lastname_w','Lastname_t','prob']]

图 4-5。迭代 2 下线匹配阈值的记录

正如我们在图 4-5 中看到的,如果Lastname不等效,新的匹配概率就会略低于我们的 0.99 阈值。使用这些新的matchnotmatch人口,我们可以修订我们的 λmu 值,并再次迭代,重新计算每对记录的匹配概率。

在这种情况下,我们的 λ 实际上没有太大变化:

λ 2 = 633 650×650 0 . 0015

只有Lastname的值稍微改变,如表格 4-4 所示。

表格 4-4。迭代 2 mu

属性 m u 匹配贝叶斯因子 匹配权重 不匹配贝叶斯因子 不匹配权重
Firstname 0.9921 0.0049 203.97 7.67 0.0079 –6.98
Lastname 1.0 0.0008 1208.79 10.24 0 -
Constituency 1.0 0.0 0 -

Iteration 3

在这个简单的例子中,这一次迭代不会改变match人口,仍然为 633,因为 EM 算法已经收敛。

这给我们我们的最终参数值:

λ 0 . 0015

m f = P ( f i r s t | m a t c h ) 0 . 9921

m l = P ( l a s t | m a t c h ) 1 . 0

m c = P ( c o n s t i t u e n c y | m a t c h ) 1 . 0

u f = P ( f i r s t | n o t m a t c h ) 0 . 0049

u l = P ( l a s t | n o t m a t c h ) 0 . 0008

u c = P ( c o n s t i t u e n c y | n o t m a t c h ) 0

这种直觉感觉对。我们知道,每次匹配都会有一个相应的选区,名字要么是姓要么是名字匹配,姓氏比名字更有可能是等效的(在前述样本中,九个中的五个对九个中的四个)。

同样地,我们知道在一个notmatch记录对中选区永远不会相同,而且名字或姓氏意外匹配的可能性也非常小(名字比姓氏稍有可能)。

我们可以使用前一节中的方程将这些估计值转换为匹配概率:

P ( m a t c h | l a s t | f i r s t ) = 1 - (1+m f u f ×m l u l ×λ (1-λ)) -1 = 1 . 0

P ( m a t c h | n o t l a s t | f i r s t ) = 1 - (1+m f u f ×(1-m l ) (1-u l )×λ (1-λ)) -1 0 . 0019

P ( m a t c h | n o t f i r s t | l a s t ) = 1 - (1+(1-m f ) (1-u f )×m l u l ×λ (1-λ)) -1 0 . 0141

P ( m a t c h | n o t f i r s t | n o t l a s t ) = 1 - (1+(1-m f ) (1-u f )×(1-m l ) (1-u l )×λ (1-λ)) -1 = 0

如预期的那样,这些概率与我们在图 4-3 中使用概率图计算的值相匹配,当我们预先知道matchnotmatch人口时。

总之,我们现在能够对属性等价的各种排列组合进行匹配概率估计,而无需事先了解match人口。这种概率方法既强大又可扩展,适用于具有多个属性的大型数据集。为了帮助我们更轻松地应用这些技术,我们在下一节中介绍了一个性能卓越且易于使用的开源库 Splink。

引入 Splink

Splink 是用于概率实体解析的 Python 包。Splink 实现了 Fellegi-Sunter 模型,并包含各种交互式输出,帮助用户理解模型并诊断链接问题。

Splink 支持多种后端来执行匹配计算。首先,我们将使用 DuckDB,这是一个在本地笔记本电脑上可以运行的进程内 SQL 数据库管理系统。

要在我们的笔记本中导入 Splink,请使用:

import splink

Splink 需要每个数据集中都有一个唯一的 ID 列,因此我们需要通过复制它们各自的 DataFrame 索引来创建这些列:

df_w['unique_id'] = df_w.index
df_t['unique_id'] = df_t.index

Splink 还需要在两个数据集中存在相同的列。因此,我们需要在只有一组记录中存在的情况下创建空白列,然后删除不必要的列:

df_w['Flink'] = None
df_t['Notes'] = None

df_w = df_w[['Firstname','Lastname','Constituency','Flink','Notes',
   'unique_id']]
df_t = df_t[['Firstname','Lastname','Constituency','Flink','Notes',
   'unique_id']]

我们的下一步是配置 Splink 设置:

from splink.duckdb.linker import DuckDBLinker
from splink.duckdb import comparison_library as cl

settings = {
   "link_type": "link_only", "comparisons": [
       cl.exact_match("Firstname"),
       cl.exact_match("Lastname"),
       cl.exact_match("Constituency"),
   ],
}
linker = DuckDBLinker([df_w, df_t], settings)

Splink 支持对单个数据集中的记录进行去重,也支持在一个或多个独立数据集之间进行链接。在这里,我们将link_type设置为link_only,告诉 Splink 我们只想在两个数据集之间进行匹配,不想进行去重。我们还告诉 Splink 我们希望使用哪些比较,本例中是在我们的三个属性上进行精确匹配。最后,我们使用这些设置和我们的源 DataFrames 实例化链接器。

为了帮助我们理解我们的数据集,Splink 提供了匹配列的分布可视化:

linker.profile_columns(['Firstname','Lastname','Constituency'])

我们在 图 4-6 中看到的图表显示了两个数据集的综合人口。

从名字分布开始,我们可以从图表的右下方看到,在 352 个不同名称的人口中,大约有 35% 仅出现两次,很可能一次在每个数据集中。然后,从右到左移动,我们看到频率逐渐增加到最受欢迎的名称,有 32 次出现。按值计数查看前 10 个值时,我们发现 John 是最流行的名字,其次是 Andrew、David 等。这告诉我们,Firstname是一个合理的匹配属性,但单独使用,它会导致一些误报。

对于姓氏,模式更加明显,有 574 个不同的姓氏,其中近 80% 仅出现两次。查看前 10 个值,最常见的姓氏,Smith 和 Jones,出现了 18 次,几乎是最流行的名字的一半。正如预期的那样,这告诉我们Lastname是比Firstname更丰富的属性,因此其等价性是匹配实体的更好预测器。

预料之中,两个数据集之间的选区是唯一配对的,因此所有数值都恰好出现两次。

在这个简单的示例中,我们将要求 Splink 使用我们之前介绍的期望最大化算法来计算模型的所有参数。初始的True参数告诉 Splink 比较两个数据集中所有的记录而不进行阻塞(我们将在下一章看到)。我们还告诉 Splink 在每次迭代时重新计算u值,通过设置fix_u_probabilitiesFalse。将fix_probability_two_random_records_match设置为False意味着λ值(两个数据集之间的总体匹配概率)将在每次迭代时重新计算。最后,我们告诉 Splink 在计算记录对的概率时使用更新后的λ值:

em_session = linker.estimate_parameters_using_expectation_maximisation(
   'True',
   fix_u_probabilities=False,
   fix_probability_two_random_records_match=False,
   populate_probability_two_random_records_match_from_trained_values
     =True)

EM 模型在三次迭代后收敛。Splink 生成一个交互式图表,显示相对匹配权重值的迭代进展:

em_session.match_weights_interactive_history_chart()

图 4-7 显示了 Splink 在第三次迭代后计算的最终匹配权重。首先,我们有先验(起始)匹配权重,这是两个随机选择的记录匹配的可能性的度量。如果你将鼠标悬停在匹配权重条上,你可以看到计算出的匹配权重值以及底层的mu参数。这些计算方法如下:

P r i o r ( s t a r t i n g ) m a t c h w e i g h t = l o g 2 λ (1-λ) - 9 . 38

F i r s t n a m e m a t c h w e i g h t ( e x a c t m a t c h ) = l o g 2 m f u f 7 . 67

F i r s t n a m e m a t c h w e i g h t ( n o t e x a c t m a t c h ) = l o g 2 (1-m f ) (1-u f ) - 6 . 98

L a s t n a m e m a t c h w e i g h t ( e x a c t m a t c h ) = l o g 2 m l u l 10 . 23

L a s t n a m e m a t c h w e i g h t ( n o t e x a c t m a t c h ) = l o g 2 (1-m l ) (1-u l ) - 7 . 32

C o n s t i t u e n c y m a t c h w e i g h t ( e x a c t m a t c h ) = l o g 2 m c u c 14 . 98

为了说明,Splink 将Constituency的非精确匹配权重近似为负无穷,并以不同颜色显示。这是因为没有情况下FirstnameLastname属性匹配但Constituency不匹配。

我们可以看到 Splink 使用以下方法计算的离散值:

linker.save_settings_to_json("Chapter4_Splink_Settings.json",
   overwrite=True)
{'link_type': 'link_only',
'comparisons': [{'output_column_name': 'Firstname',
   'comparison_levels': [{'sql_condition': '"Firstname_l" IS NULL OR
       "Firstname_r" IS NULL',
     'label_for_charts': 'Null',
     'is_null_level': True},
    {'sql_condition': '"Firstname_l" = "Firstname_r"',
     'label_for_charts': 'Exact match',
     'm_probability': 0.992118804074688,
     'u_probability': 0.004864290128404288},
    {'sql_condition': 'ELSE',
     'label_for_charts': 'All other comparisons',
     'm_probability': 0.007881195925311958,
     'u_probability': 0.9951357098715956}],
   'comparison_description': 'Exact match vs. anything else'},
  {'output_column_name': 'Lastname',
   'comparison_levels': [{'sql_condition': '"Lastname_l" IS NULL OR
       "Lastname_r" IS NULL',
     'label_for_charts': 'Null',
     'is_null_level': True},
    {'sql_condition': '"Lastname_l" = "Lastname_r"',
     'label_for_charts': 'Exact match',
     'm_probability': 0.9937726043638647,
     'u_probability': 0.00082730840955421},
    {'sql_condition': 'ELSE',
     'label_for_charts': 'All other comparisons',
     'm_probability': 0.006227395636135347,
     'u_probability': 0.9991726915904457}],
   'comparison_description': 'Exact match vs. anything else'},
  {'output_column_name': 'Constituency',
   'comparison_levels': [{'sql_condition': '"Constituency_l" IS NULL OR
       "Constituency_r" IS NULL',
     'label_for_charts': 'Null',
     'is_null_level': True},
    {'sql_condition': '"Constituency_l" = "Constituency_r"',
     'label_for_charts': 'Exact match',
     'm_probability': 0.9999999403661186,
     'u_probability': 3.092071473132138e-05},
    {'sql_condition': 'ELSE',
     'label_for_charts': 'All other comparisons',
     'm_probability': 5.963388147277392e-08,
     'u_probability': 0.9999690792852688}],
   'comparison_description': 'Exact match vs. anything else'}],
'retain_intermediate_calculation_columns': True,
'retain_matching_columns': True,
'sql_dialect': 'duckdb',
'linker_uid': 'adm20und',
'probability_two_random_records_match': 0.0015075875293170335}

mu概率与我们在本章早些时候使用期望最大化算法手动计算的那些匹配。

最后,和之前一样,我们应用一个阈值匹配概率,并选择高于阈值的记录对:

pres = linker.predict(threshold_match_probability =
   0.99).as_pandas_dataframe()

len(pres)
633

对这些预测的分析显示,所有的 633 个都是真正例,剩下 13 个补选真负例和 4 个假负例。我们可以用以下方式查看这 4 个假负例:

m_outer = match.merge(
   pres,
   left_on=['Constituency_t'],
   right_on=['Constituency_l'],
   how='outer')

m_outer[m_outer['Constituency_t']!=m_outer['Constituency_l']]
   [['Constituency_w','Lastname_w','Lastname_t']]

输出结果,如图 4-8 所示,显示Lastname不匹配是这些实体未达到匹配阈值的原因。

与第三章中的非加权结果相比,Splink 认为“Liz Truss”与“Elizabeth Truss”匹配,但不将“Anne Marie Morris”与“Anne Morris”,以及“Martin Docherty-Hughes”与“Martin Docherty”匹配。这是因为它更受到Lastname不匹配的影响,统计上它是一个更好的负面预测因子,而不是Firstname不匹配。

摘要

总结一下,我们拿到了两组记录,并将它们合并成一个包含每对记录组合的复合数据集。然后,我们计算了等效字段之间的精确匹配特征,再根据它们在匹配和非匹配人群中出现的频率加权组合这些特征,以确定匹配的总体可能性。

我们看到如何在没有已知match人群的情况下,利用概率论来使用迭代期望最大化算法计算匹配权重。

最后,我们介绍了概率实体解析框架 Splink,它在组合多个属性时大大简化了计算,并帮助我们可视化和理解我们的匹配结果。

现在我们已经通过一个小规模示例了解了如何在更大规模上应用近似和概率匹配的技术。

^(1) 原始论文可以在网上找到。

第五章:记录阻塞

在第四章中,我们介绍了概率匹配技术,以允许我们将单个属性上的确切等价性组合成加权的复合分数。该分数允许我们计算两个记录指向同一实体的总体概率。

到目前为止,我们只试图解决小规模数据集,其中我们可以逐个比较每条记录,以找到所有可能的匹配项。然而,在大多数实体解析场景中,我们将处理更大的数据集,这种方法并不实用或负担得起。

在本章中,我们将介绍记录阻塞以减少我们需要考虑的排列组合数量,同时最小化漏掉真正匹配的可能性。我们将利用上一章介绍的 Splink 框架,应用 Fellegi-Sunter 模型,并使用期望最大化算法来估计模型参数。

最后,我们将考虑如何测量我们在这个更大数据集上的匹配性能。

示例问题

在之前的章节中,我们考虑了解决包含有关英国下议院议员信息的两个数据集之间的实体的挑战。在本章中,我们将这一解决方案挑战扩展到一个包含注册英国公司的实质控制人列表的规模更大的数据集。

在英国,公司注册处是由商业和贸易部赞助的执行机构。它合并和解散有限公司,注册公司信息并向公众提供信息。

在注册英国有限公司时,有义务声明谁拥有或控制公司。这些实体称为具有重大控制权的人(PSC);他们有时被称为“受益所有人”。公司注册处提供一个可下载的数据快照,其中包含所有 PSC 的完整列表。

对于此练习,我们将尝试解决此数据集中列出的实体与我们从维基百科获取的国会议员名单。这将向我们展示哪些国会议员可能是英国公司的 PSC。

数据获取

在此示例中,我们将重复使用我们在之前章节中审查的 2019 年英国大选返回的相同维基百科来源数据。但是,为了允许我们与一个规模更大的数据集进行匹配,而不产生不可控制的假阳性,我们需要通过附加属性来丰富我们的初始数据。具体而言,我们将寻求使用从每个国会议员关联的个人维基页面中提取的出生日期信息来增强我们的数据集,以帮助增强我们匹配的质量。

我们还将下载由公司注册处发布的最新 PSC 数据快照,然后将该数据集归一化并过滤到我们匹配所需的属性。

维基百科数据

为了创建我们增强的维基百科数据集,我们从维基页面中选择了 MPs,就像我们在 第二章 中所做的那样;但是,这次我们还提取了每个个人 MP 的维基百科链接,并将其作为我们 DataFrame 中的额外列追加。

url = "https://en.wikipedia.org/wiki/
       List_of_MPs_elected_in_the_2019_United_Kingdom_general_election"

website_page = requests.get(url).textsoup = 
    BeautifulSoup(website_page,'html.parser')
tables = soup.find_all('table')

for table in tables:
   if 'Member returned' in table.text:
      headers = [header.text.strip() for header in table.find_all('th')]
      headers = headers[:5]
      dfrows = []
      table_rows = table.find_all('tr')
      for row in table_rows:
         td = row.find_all('td')
         dfrow = [row.text for row in td if row.text!='\n']
         tdlink = row.find_all("td", {"data-sort-value" : True})
         for element in tdlink:
             for link in element.select("a[title]"):
                 urltail = link['href']
                 url = f'https://en.wikipedia.org{urltail}' 
      dfrow.append(url)
      dfrows.append(dfrow)
   headers.append('Wikilink')
df_w = pd.DataFrame()

现在我们可以跟随这些链接,并从网页信息框中提取出生日期信息(如果有的话)。与之前一样,我们可以使用 Beautiful Soup html parser来查找并提取我们需要的属性,或者返回一个默认的空值。apply 方法允许我们将此函数应用于维基百科数据集中的每一行,创建一个名为 Birthday 的新列:

def get_bday(url):
   wiki_page = requests.get(url).text
   soup = BeautifulSoup(wiki_page,'html.parser')
   bday = ''
   bdayelement = soup.select_one("span[class='bday']")
   if bdayelement is not None:
      bday = bdayelement.text
      return(bday)

df_w['Birthday'] = df_w.apply(lambda x: get_bday(x.Wikilink), axis=1)

英国公司注册处数据

公司注册处以 JSON 格式发布 PSC 数据的快照。这些数据既可以作为单个 ZIP 文件提供,也可以作为多个 ZIP 文件提供以便下载。依次提取每个部分的 ZIP 文件允许我们标准化 JSON 结构,将其拼接成一个复合 DataFrame,包括我们需要用于匹配的属性以及相关联的唯一公司编号:

url = "http://download.companieshouse.gov.uk/en_pscdata.html"

>df_psctotal = pd.DataFrame()
with requests.Session() as req:
   r = req.get(url)
   soup = BeautifulSoup(r.content, 'html.parser')
   snapshots = [f"{url[:38]}{item['href']}" for item in soup.select(
      "a[href*='psc-snapshot']")]
   for snapshot in snapshots:
      print(snapshot)
      response = requests.get(snapshot).content zipsnapshot =
         zipfile.ZipFile(io.BytesIO(response))
      tempfile = zipsnapshot.extract(zipsnapshot.namelist()[0])
      df_psc = pd.json_normalize(pd.Series(open(tempfile,
         encoding="utf8").readlines()).apply(json.loads))

      must_cols =  ['company_number',
                    'data.name_elements.surname',
                    'data.name_elements.middle_name',
                    'data.name_elements.forename',
                    'data.date_of_birth.month',
                    'data.date_of_birth.year',
                    'data.name_elements.title',
                    'data.nationality']  
       all_cols =list(set(df_psc.columns).union(must_cols))

>      df_psc=df_psc.reindex(columns=sorted(all_cols))
      df_psc = df_psc.dropna(subset=['company_number',
                    'data.name_elements.surname',
                    'data.name_elements.forename',
                    'data.date_of_birth.month',
                    'data.date_of_birth.year'])
      df_psc = df_psc[must_cols]
      df_psctotal = pd.concat([df_psctotal, df_psc],
         ignore_index=True)

数据标准化

现在我们拥有了所需的原始数据,我们标准化了两个数据集的属性和列名。由于我们将使用 Splink 框架,我们还添加了一个唯一的 ID 列。

维基百科数据

为了标准化日期增强的维基百科数据,我们将日期列转换为月份和年份的整数。如同 第二章 中所述,我们提取 FirstnameLastname 属性。我们还添加了一个唯一的 ID 列和一个空的公司编号列,以匹配公司注册处数据中的相应字段。最后,我们保留我们需要的列:

df_w = df_w.dropna()
df_w['Year'] =
   pd.to_datetime(df_w['Birthday']).dt.year.astype('int64')
df_w['Month'] =
   pd.to_datetime(df_w['Birthday']).dt.month.astype('int64')

df_w = df_w.rename(columns={ 'Member returned' : 'Fullname'})
df_w['Fullname'] = df_w['Fullname'].str.rstrip("\n")
df_w['Fullname'] = df_w['Fullname'].str.lstrip("\n")
df_w['Firstname'] = df_w['Fullname'].str.split().str[0]
df_w['Lastname'] = df_w['Fullname'].astype(str).apply(lambda x:
   ' '.join(x.split()[1:]))

df_w['unique_id'] = df_w.index
df_w["company_number"] = np.nan

df_w=df_w[['Firstname','Lastname','Month','Year','unique_id',
   'company_number']]

英国公司注册处数据

为了标准化英国公司注册处数据,我们首先删除了任何缺少出生年月日列的行,因为我们无法匹配这些记录。与维基百科数据一样,我们标准化列名,生成唯一 ID,并保留匹配的子集:

df_psc = df_psc.dropna(subset=['data.date_of_birth.year',
                                'data.date_of_birth.month'])

df_psc['Year'] = df_psc['data.date_of_birth.year'].astype('int64')
df_psc['Month'] = df_psc['data.date_of_birth.month'].astype('int64')
df_psc['Firstname']=df_psc['data.name_elements.forename']
df_psc['Lastname']=df_psc['data.name_elements.surname']
df_psc['unique_id'] = df_psc.index

df_psc = df_psc[['Lastname','Firstname','company_number',
   'Year','Month','unique_id']]

让我们来看看几行(其中 FirstnameLastname 已经过清理),如 图 5-1 所示。

图 5-1. 英国公司注册处具有重要控制人数据的示例行

记录阻塞和属性比较

现在我们有了一致的数据,可以配置我们的匹配过程。在我们开始之前,值得一提的是挑战的规模。我们有 650 个 MP 记录,而我们标准化后的 PSC 数据超过 1000 万条记录。如果我们考虑所有的排列组合,我们将有大约 60 亿次比较要进行。

通过在具有匹配 MonthYear 值的记录上执行简单的连接,我们可以看到交集的大小约为 1100 万条记录:

df_mp = df_w.merge(df_psc, on=['Year','Month'],
    suffixes=('_w','_psc'))

len(df_mp)
11135080

对所有四个属性进行简单的精确匹配,我们得到了 266 个潜在的匹配项:

df_result = df_w.merge(df_psc, on= ['Lastname','Firstname','Year','Month'], 
    suffixes=('_w', '_psc'))

df_result

这些简单连接匹配的经过清理的样本如 图 5-2 所示。

图 5-2. 简单地根据 LastnameFirstnameYearMonth 进行连接

为了减少需要考虑的记录组合数量,Splink 允许我们配置阻塞规则。这些规则确定了哪些记录对在评估是否引用同一实体时进行比较。显然,仅考虑人口的一个子集会导致错过真实匹配的风险,因此选择能够最小化这一风险并尽可能减少数量的规则非常重要。

Splink 允许我们创建组合规则,实质上是OR语句,如果满足任何条件,则选择组合进行进一步比较。但在本例中,我们将仅使用一个选择仅具有匹配的年份和月份记录的单个阻塞规则:

from splink.duckdb.linker import DuckDBLinker
from splink.duckdb import comparison_library as cl
settings = {
   "link_type": "link_only",
   "blocking_rules_to_generate_predictions":
      ["l.Year = r.Year and l.Month = r.Month"],
   "comparisons": [
      cl.jaro_winkler_at_thresholds("Firstname", [0.9]),
      cl.jaro_winkler_at_thresholds("Lastname", [0.9]),
      cl.exact_match("Month"),
      cl.exact_match("Year", term_frequency_adjustments=True),
      ],
    "additional_columns_to_retain": ["company_number"]
}

属性比较

对于由阻塞规则产生的记录比较,我们将使用近似匹配分数的组合来确定它们是否指向同一人,其中包括名字和姓氏的近似匹配以及月份和年份的精确匹配。因为我们在比较名字,所以使用了来自第三章的 Jaro-Winkler 算法。

我们可以配置 Splink 的一组最小阈值值,这些值共同分割人群;Splink 将为那些得分低于提供的最小值的属性对添加一个精确匹配段和一个默认的零匹配段。在这种情况下,我们将仅使用一个阈值 0.9 来说明这个过程,为每个名称组件给出三个段。每个段被视为单独的属性,用于计算记录对的整体匹配概率。

现在我们已经完成了设置,让我们实例化我们的链接器并配置匹配的列:

linker = DuckDBLinker([df_w, df_psc], settings,
   input_table_aliases = ["df_w", "df_psc"])
linker.profile_columns(["Firstname","Lastname","Month","Year"],
   top_n=10, bottom_n=5)

您可以在图 5-3 中看到结果。

图 5-3. 名字、姓氏、月份和年份分布

我们可以看到,我们有一些常见的名字和姓氏,还有一些不太常见的数值。出生月份的值分布相对均匀,但年份则有些年份比其他年份更常见。在我们的匹配过程中,可以通过设置以下内容考虑这种频率分布:

term_frequency_adjustments=True

每个年份值将单独考虑,用于计算匹配概率;因此,对于不太流行的年份匹配,将比对较常见的值匹配更高地加权。

正如我们在第四章中所做的那样,我们可以使用期望最大化算法来确定mu值,即每个属性段的匹配和不匹配概率。默认情况下,这些计算考虑应用阻塞规则之前的整体人口。

为了估计u值,Splink 采取了略有不同的方法,通过随机选择成对记录的对比,假设它们不匹配,并计算这些巧合发生的频率。由于两个随机记录匹配(代表相同实体)的概率通常非常低,这种方法生成了u值的良好估计。这种方法的额外好处是,如果u概率正确,它会“锚定”EM 估计过程,并大大提高其收敛到全局最小值而不是局部最小值的机会。要应用这种方法,我们需要确保我们的随机人群足够大,以代表可能组合的全部范围:

linker.estimate_u_using_random_sampling(max_pairs=1e7)

Splink 允许我们设置阻止规则以估计匹配概率。在这里,根据第一个条件在人群的子集上估计每个段的属性参数,然后重复第二个条件选择的子集。由于包含在阻止条件中的属性本身不能被估计,因此条件的重叠至关重要,允许每个属性至少在一个条件下进行评估。

随机样本

注意,期望最大化方法是随机选择记录的,因此如果您按照本书进行操作,可以预期计算参数与本书中的计算参数有所不同。

在本例中,我们在等效的姓和月份上进行阻止,允许我们估计名字和年份段的概率,然后我们以相反的组合重复此过程。这样每个属性段至少被评估一次:

linker.estimate_parameters_using_expectation_maximisation
   ("l.Lastname = r.Lastname and l.Month = r.Month",
      fix_u_probabilities=False)
linker.estimate_parameters_using_expectation_maximisation
   ("l.Firstname = r.Firstname and l.Year = r.Year",
      fix_u_probabilities=False)

我们可以使用以下方法检查生成的匹配权重:

linker.match_weights_chart()

图 5-4. 模型参数

在 图 5-4 中,我们可以看到强烈的负先验(起始)匹配权重,以及每个属性精确匹配和在 FirstnameLastname 上近似匹配的正权重:

linker.m_u_parameters_chart()

在 图 5-5 中,我们可以看到期望最大化算法为每个段计算的匹配和非匹配记录比例。

图 5-5. 记录比例

匹配分类

现在我们已经有了针对每个属性优化的匹配参数的训练模型,我们可以预测未被阻止的记录对是否指向相同的实体。在本示例中,我们将总体阈值匹配概率设置为 0.99:

results = linker.predict(threshold_match_probability=0.99)
pres = results.as_pandas_dataframe()

然后我们根据唯一标识将预测结果与 PSC 数据集连接起来,以便可以获取与匹配实体关联的公司编号。

然后我们重新命名输出列,并仅保留我们需要的列:

pres = pres.rename(columns={"Firstname_l": "Firstname_psc",
                             "Lastname_l": "Lastname_psc",
                             "Firstname_r":"Firstname_w",
                             "Lastname_r":"Lastname_w",
                             "company_number_l":"company_number"})
pres = pres[['match_weight','match_probability',
             'Firstname_psc','Firstname_w',
             'Lastname_psc','Lastname_w','company_number']]

这给我们提供了 346 个预测匹配,精确和近似匹配,如 图 5-6 中所示(PSC 的名字和姓已经过处理)。

图 5-6. LastnameFirstname 的精确匹配

如果我们移除精确匹配,我们可以检查额外的近似匹配,看看我们的概率方法执行得有多好。这在 图 5-7 中展示(PSC 的名字和姓氏已经过清理):

pres[(pres['Lastname_psc']!=pres['Lastname_w']) |
      (pres['Firstname_psc']!=pres['Firstname_w'])]

图 5-7. 近似匹配 — 非精确的FirstnameLastname

检查结果,如表 5-1 所示,我们可以看到几位可能是真正的正向匹配的候选人。

表 5-1. 近似匹配 — 手动比较

match_weight match_probability Firstname_psc Firstname_w Lastname_psc Lastname_w company_​num⁠ber
13.51481459 0.999914572 John John Mcdonnell McDonnell 5350064
11.66885836 0.999692963 Stephen Stephen Mcpartland McPartland 7572556
11.50728191 0.999656589 James James Heappey Mp Heappey 5074477
9.637598832 0.998746141 Matt Matthew Hancock Hancock 14571407
13.51481459 0.999914572 John John Mcdonnell McDonnell 4662034
9.320995827 0.998438931 Siobhan Siobhan Mcdonagh McDonagh 246884
11.46050878 0.999645277 Alison Alison Mcgovern McGovern 10929919
9.57364719 0.998689384 Jessica Jess Phillips Phillips 560074
12.14926274 0.999779904 Grahame Grahame Morris Mp Morris 13523499
11.66885836 0.999692963 Stephen Stephen Mcpartland McPartland 9165947
13.51481459 0.999914572 John John Mcdonnell McDonnell 6496912
11.62463457 0.999683409 Anna Anna Mcmorrin McMorrin 9965110

尽管我们最初进行了数据标准化,但我们可以看到姓氏的大写不一致,而且我们还有几个 PSC 记录的姓氏末尾附加了“Mp.” 这在实体解析问题中经常出现——我们经常需要多次迭代,随着对数据集了解的增加,不断完善我们的数据标准化。

测量性能

如果我们假设所有在表 5-1 中的精确匹配和近似匹配都是真正的正向匹配,那么我们可以计算我们的精度指标如下:

T r u e p o s i t i v e m a t c h e s ( F P ) = 266 + 12 = 278

F a l s e p o s i t i v e m a t c h e s ( F P ) = 80 - 12 = 68

P r e c i s i o n = TP (TP+FP) = 278 (278+68) 80 %

没有手动验证,我们无法确定我们的notmatch人群中哪些是真正的假阴性,因此我们无法计算召回率或整体准确度指标。

总结

在本章中,我们使用概率框架内的近似匹配来识别可能对英国公司具有重大控制权的国会议员。

我们看到如何使用阻塞技术来减少我们需要评估的记录对数量,使其保持在一个实际的范围内,同时又不会太大幅度地增加我们错过重要潜在匹配的风险。

我们看到数据标准化对优化性能的重要性,以及在实体解析中获得最佳性能通常是一个迭代的过程。

第六章:公司匹配

在第五章中,我们讨论了解决更大规模的个体实体集合的挑战,匹配名称和出生日期。在本章中,我们考虑另一种典型情景,解决组织实体,以便能够更全面地了解其业务。

或许我们可以使用组织成立日期作为区分因子,类似于我们使用出生日期帮助识别唯一个体的方式。然而,公司的这一成立日期信息通常不包括在组织数据集中;更常见的是公司通过其注册地址来进行识别。

因此,在本章中,我们将使用公司地址信息以及公司名称来识别可能的匹配项。然后,我们将考虑如何评估新记录与原始数据源的匹配情况,而无需进行耗时的模型重新训练。

样本问题

在本章中,我们将解析由英国海事及海岸警卫局(MCA)发布的公司名称列表,与公司注册处发布的基本组织详情进行匹配。这个问题展示了仅基于名称和地址数据识别同一公司唯一引用的一些挑战。

英国公司注册处提供了一个免费可下载的数据快照,包含注册公司的基本公司数据。这些数据是我们在第五章中使用的“有重大控制权的人”数据的补充。

MCA 发布了根据 2006 年《海事劳工公约》1.4 条款批准的招聘和安置机构名单。^(1)

数据获取

为了获取数据集,我们使用了与第五章相同的方法。MCA 数据以单个逗号分隔值(CSV)文件的形式发布,下载并将其载入 DataFrame。公司注册处快照数据以 ZIP 文件形式下载,解压后的 JSON 结构再解析为 DataFrame。然后移除不需要的列,并将快照 DataFrame 串联成一个单一的复合 DataFrame。两个原始数据集都以 CSV 文件的形式存储在本地,以便于重新加载。

代码可以在GitHub 存储库中的Chapter6.ipynb文件中找到。

数据标准化

为了将 MCA 公司列表与公司注册处的组织数据集进行匹配,我们需要将名称和地址数据标准化为相同的格式。我们已经看到了如何清理名称。然而,地址则更具挑战性。即使在来自同一来源的合理一致数据中,我们经常看到地址格式和内容上有相当大的变化。

例如,考虑 MCA 列表中的前三条记录,如表 6-1 所示。

表 6-1. MCA 样本地址

地址属性
48 Charlotte Street, London, W1T 2NS
苏格兰格拉斯哥乔治街 105 号四楼,邮编 G2 1PB
英国爱丁堡 Beaverbank 商业园区 16 号单元,邮编 EH7 4HG

第一个地址由三个逗号分隔的元素组成,第二个记录由四个元素组成,第三个再次由两个元素组成。在每种情况下,邮政编码都包含在最后一个元素中,但在第三个记录中,它与地址本身的一部分分组在一起。建筑编号出现在第一个元素或第二个元素中。

要查看 MCA 名单中地址元素数量的直方图分布,可以使用:

import matplotlib.pyplot as plt
plt.hist(df_m.apply(lambda row: len(row['ADDRESS & CONTACT
   DETAILS'].split(',')), axis=1).tolist())

这使我们得到了图表 6-1 中呈现的分布图。

图表 6-1. MCA 地址元素计数

这种一致性不足使得将地址一致地解析为用于匹配的相同离散元素变得非常困难。因此,对于本例,我们将仅使用精确的邮政编码匹配来比较地址。更高级的解析和匹配技术,如自然语言处理和地理编码,在第十一章中进行了讨论。

公司注册数据

在许多司法管辖区,公司需要在其名称末尾声明其组织形式,例如,如果它们作为有限责任公司成立,则添加“有限公司”或“Ltd”。这些可变后缀可能并不总是存在,因此标准化具有挑战性。

为确保不匹配不会对匹配过程造成不必要的负面干扰,建议在标准化过程中将这些低价值术语与名称记录分开。这将消除由于后缀格式不一致而错过潜在匹配的机会,但也有可能会声明出现错误匹配,例如,公众有限公司与名称相似的有限公司之间。

除了删除公司名称中不区分公司并且其包含会使我们的名称匹配过于相似的常见术语外,还可以删除并入后缀。

尽管我们选择从公司名称属性中删除这些术语或停用词,它们仍包含一些在决定声明匹配时可能有用的价值。

以下辅助函数剥离这些停用词,返回清洗后的公司名称和已移除的术语:

def strip_stopwords(raw_name):
   company_stopwords = { 'LIMITED', 'LTD', 'SERVICES', 'COMPANY',
      'GROUP', 'PROPERTIES', 'CONSULTING', 'HOLDINGS', 'UK',
      'TRADING', 'LTD.', 'PLC','LLP' }
   name_without_stopwords = []
   stopwords = []
   for raw_name_part in raw_name.split():
      if raw_name_part in company_stopwords:
         stopwords.append(raw_name_part)
      else:
         name_without_stopwords.append(raw_name_part)
   return(' '.join(name_without_stopwords),
          ' '.join(stopwords))

我们可以使用以下方法将此函数应用于公司注册数据:

df_c[['CompanyName','Stopwords']] =  pd.DataFrame(
   zip(*df_c['CompanyName'].apply(strip_stopwords))).T

* 运算符解压了由辅助函数返回的元组序列(包含 CompanyNameStopwords)。我们将这些值列表组装成一个两行的 DataFrame,然后将其转置为列,以便我们可以作为新属性添加。这种方法效率高,因为我们只需创建一个新的 DataFrame,而不是每行都创建一个。

因为我们已经有一个包含离散邮政编码的离散列,所以只需标准化列名即可:

df_c = df_c.rename(columns={"RegAddress.PostCode": "Postcode"})

海事与海岸警卫局数据

要使 MCA 公司名称标准化,我们首先将名称转换为大写:

df_m['CompanyName'] = df_m['COMPANY'].str.upper()

我们还会去除停用词,然后需要从地址字段提取邮政编码。一个方便的方法是使用正则表达式

正则表达式

正则表达式是一系列字符的序列,用于指定文本中的匹配模式。通常这些模式由字符串搜索算法用于字符串的“查找”或“查找和替换”操作,或者用于输入验证。

英国的邮政编码由两部分组成。第一部分由一个或两个大写字母,后跟一个数字,然后是一个数字或一个大写字母。在一个空格后,第二部分以一个数字开头,后跟两个大写字母(不包括 CIKMOV)。这可以编码为:

r'([A-Z]{1,2}[0-9][A-Z0-9]? [0-9][ABD-HJLNP-UW-Z]{2})'

我们可以构建一个辅助函数来查找、提取和返回字符匹配模式,如果找不到则返回空值:

import re
def extract_postcode(address):
   pattern = re.compile(r'([A-Z]{1,2}[0-9][A-Z0-9]?
      [0-9][ABD-HJLNP-UW-Z]{2})')
   postcode = pattern.search(address)
   if(postcode is not None):
   return postcode.group()
      else:
   return None

就像以前一样,我们可以将此函数应用于每一行:

df_m['Postcode'] = df_m.apply(lambda row:
   extract_postcode(row['ADDRESS & CONTACT DETAILS']), axis=1)

记录阻塞和属性比较

与前一章一样,我们将使用 Splink 工具执行匹配过程。让我们考虑一下可以执行此操作的设置。

首先,我们可以期望具有相同邮政编码的组织成为合理的匹配候选者,同样地,那些名字完全相同的组织也是如此。我们可以将这些条件作为我们的阻塞规则,只有在满足其中任一条件时才计算预测:

 "blocking_rules_to_generate_predictions":
   ["l.Postcode = r.Postcode",
    "l.CompanyName = r.CompanyName", ],

Splink 为我们提供了一个便捷的可视化工具,用来查看将通过阻塞规则的记录对的数量。正如预期的那样,有大量的邮政编码匹配,但几乎没有完全相同的名称匹配,如图 6-2 所示。

linker.cumulative_num_comparisons_from_blocking_rules_chart()

图 6-2. 阻塞规则比较

在潜在组合的子集中,我们评估所选的CompanyName条目在四个段落中的相似性:

  • 精确匹配

  • Jaro-Winkler 得分大于 0.9

  • Jaro-Winkler 得分在 0.8 到 0.9 之间

  • Jaro-Winkler 得分小于 0.8

我们还以类似的方式评估停用词。

对应的 Splink 设置为:

"comparisons": [
   cl.jaro_winkler_at_thresholds("CompanyName", [0.9,0.8]),
   cl.jaro_winkler_at_thresholds("Stopwords",[0.9]),
], 

当然,那些通过阻塞规则作为完全相同名称等同物的配对将被评估为完全匹配,而仅具有邮政编码匹配的配对则将被评估为精确和近似名称匹配的候选者。

在应用阻塞规则并计算我们的匹配概率之前,我们需要训练我们的模型。两个数据框的笛卡尔积有超过 5 亿个成对组合,因此我们使用随机抽样在 5000 万个目标行上训练u值,以获得合理的样本:

linker.estimate_u_using_random_sampling(max_pairs=5e7)

如同第五章中所述,我们使用期望最大化算法来估计m值。在这里,我们仅根据匹配的邮政编码进行阻塞,因为姓名匹配的微小相对比例对参数估计没有好处:

linker.estimate_parameters_using_expectation_maximisation(
   "l.Postcode = r.Postcode")

我们可以使用以下方法显示训练模型在每个段落中观察到的记录比例:

linker.match_weights_chart()

记录比较图表,见图 6-3,显示了匹配和不匹配记录之间CompanyName相似性的明显差异。对于停用词,仅在相似度阈值大于或等于 0.9 时,近似匹配记录之间才有显著差异,而不是精确匹配。

图 6-3. 记录比较比例

如预期的那样,参数图(如图 6-4 所示)显示精确和近似的CompanyName匹配具有较强的匹配权重:

linker.m_u_parameters_chart()

图 6-4. 模型参数

匹配分类

在这个例子中,我们期望在公司注册局的数据集中为每个 MCA 组织找到一个匹配项,因此我们将匹配阈值设置得很低,为 0.05,以确保尽可能多地显示潜在的匹配项:

df_pred = linker.predict(threshold_match_probability=0.05)
   .as_pandas_dataframe()

要识别我们未能找到至少一个匹配项的 MCA 实体,我们可以通过unique_id将我们的预测与 MCA 数据集合并,然后选择那些匹配权重为空的结果:

results = df_m.merge(df_pred,left_on=['unique_id'], right_on=
   ['unique_id_r'],how='left', suffixes=('_m', '_p'))
results[results['match_weight'].isnull()]

正如图 6-5 所示,这产生了 11 条我们找不到任何匹配项的记录。

图 6-5. 未匹配的记录

在撰写时,通过对公司注册局的手动搜索,我们发现这 11 个实体中有 7 个有候选匹配项,但这些候选项没有精确匹配的邮政编码或名称,因此被我们的阻塞规则过滤掉了。其中两个实体有具有精确匹配邮政编码但名称显著不同的候选项,因此低于我们的近似相似度阈值。最后,剩下的两个候选项已经解散,因此不包括在我们的实时公司快照中。

检查预测匹配项及其对总体匹配得分的贡献的方便方法是绘制匹配权重瀑布图:

linker.waterfall_chart(df_pred.to_dict(orient="records"))

在图 6-6 的示例中,我们可以看到先前的匹配权重,这是两个随机选择的记录指向同一实体的可能性的度量,为-13.29。从这个起点开始,当我们发现CompanyName“Bespoke Crew”的精确匹配时,我们添加了 20.92 的匹配权重。这代表了在match人口中找到CompanyName精确等价性的概率大于在notmatch人口中找到的概率。

图 6-6. 匹配权重瀑布图

然而,由于“Limited”上的精确匹配更有可能发生在notmatch中而不是match中,因此我们还需要减去 0.45。这使我们得到了一个最终匹配权重为 7.19,这相当于几乎是 1.0 的概率。

测量表现

标准化后,MCA 数据有 96 个组织。

在 0.05 的匹配阈值下,我们的结果显示在表 6-2 中。

表 6-2. MCA 匹配结果—低阈值

匹配阈值 = 0.05 匹配数量 匹配的唯一实体
名称和邮政编码匹配 47 45
仅名称匹配 37 31
仅邮政编码匹配 116 27
总匹配数 200 85 (去重后)
未匹配 11 (其中 2 个解散)
总组织数 96

如果我们假设去重后的唯一匹配是真正的正匹配,那么 11 个未匹配的实体中有 9 个是假阴性,2 个解散的实体是真阴性,那么我们可以评估我们的表现为:

T r u e p o s i t i v e m a t c h e s ( T P ) = 85

F a l s e p o s i t i v e m a t c h e s ( F P ) = 200 - 85 = 115

F a l s e n e g a t i v e m a t c h e s ( F N ) = 11 - 2 = 9

T r u e n e g a t i v e s = 2

P r e c i s i o n = TP (TP+FP) = 85 (85+115) 42 %

R e c a l l = TP (TP+FN) = 85 (85+9) 90 %

A c c u r a c y = (TP+TN) (TP+TN+FP+FN) = (85+2) (85+2+115+9) 41 %

在阈值为 0.9 时重新计算我们的预测将删除仅有邮政编码匹配的结果,给出了表格 6-3 中显示的结果。

表格 6-3. MCA 匹配结果—高阈值

匹配阈值 = 0.9 匹配数量 唯一匹配的实体
名称和邮政编码匹配 47 45
仅名称匹配 37 31
仅邮政编码匹配 3 1
总匹配数 87 73 (去重后)
未匹配 23 (其中 2 个解散)
总组织数 96

T r u e p o s i t i v e m a t c h e s ( T P ) = 73

F a l s e p o s i t i v e m a t c h e s ( F P ) = 87 - 73 = 14

F a l s e n e g a t i v e m a t c h e s ( F N ) = 23 - 2 = 21

T r u e n e g a t i v e s = 2

P r e c i s i o n = TP (TP+FP) = 73 (73+14) 84 %

R e c a l l = TP (TP+FN) = 73 (73+21) 78 %

A c c u r a c y = (TP+TN) (TP+TN+FP+FN) = (73+2) (73+2+14+21) 69 %

因此,正如预期的那样,我们看到更高的匹配阈值将我们的精确度从 42%提高到 86%,但代价是几乎错过了两倍的潜在匹配(从 9 个假阴性增加到 21 个)。

调整实体解析解决方案需要一定的试错,调整阻塞规则、相似度阈值和整体匹配阈值,以找到最佳平衡点。这将大大依赖于您数据的特性以及对于未能识别潜在匹配的风险偏好。

匹配新实体

正如我们所见,模型训练并不是一个快速的过程。如果我们有一个新实体,比如说 MCA 列表中的新条目,我们想要与公司注册数据进行解决,Splink 提供了一种选择,可以对新记录与先前匹配的数据集进行匹配而无需重新训练。我们还可以利用此功能找出所有潜在匹配,而不受阻塞规则或匹配阈值的约束,以帮助我们理解为什么这些候选者没有被识别。例如,如果我们考虑未匹配人群中的最后一个实体:

record = {
   'unique_id': 1,
   'Postcode': "BH15 4QE",
   'CompanyName':"VANTAGE YACHT RECRUITMENT",
   'Stopwords':""
}

df_new = linker.find_matches_to_new_records([record],
   match_weight_threshold=0).as_pandas_dataframe()
df_new.sort_values("match_weight", ascending=False)

这导致了一个候选匹配的完整列表,其中前四个具有最高的匹配概率,列在图 6-7 中。

图 6-7. 新潜在匹配

表格中的第一条目是 MCA 数据集中的原始记录。接下来的三条记录作为公司注册数据的候选匹配,由于没有精确的邮政编码或名称匹配,因此会被我们的阻塞规则排除。然而,第二条记录的名称有些相似,并且邮政编码也很接近,看起来是一个很好的潜在候选者。

总结

在本章中,我们使用名称和地址匹配的组合解决了两个数据集中公司实体的问题。我们从组织名称中去除了停用词,并采用正则表达式提取邮政编码进行比较。

我们采用了精确等价阻塞规则,然后根据姓名相似性在一定阈值以上计算我们的匹配概率。通过评估结果,我们发现在设置较低的匹配阈值以产生相对较多的误报之间进行权衡,以及使用较高阈值导致可能错过一些潜在有希望的匹配候选者之间存在着折衷。

本章还表明,即使采用阻塞技术,大规模实体解析也可能成为一项耗时且计算密集的任务。在接下来的章节中,我们将探讨如何利用云计算基础设施,将我们的匹配工作负载分布到多台机器上并行处理。

^(1) 根据 2006 年《海员劳工公约》(MLC 2006),规定航运公司需向寻求在非本国旗船只上工作的海员提供相关招聘和安置服务信息。

第七章:聚类

到目前为止,我们考虑了两个独立数据源之间的实体解析:一个定义了要匹配的目标人群的较小的主数据集和一个规模更大的次要数据集。我们还假设主数据集中的实体仅出现一次,并且没有重复项。因此,我们没有试图将主数据集中的实体与彼此进行比较。

例如,在第五章中,我们根据公司注册处的记录解析了维基百科上列出的英国议员与英国公司的实际控制人。我们假设每位议员在维基百科列表中只出现一次,但他们可能对多家公司具有重大控制权,即,单个维基百科实体可以与多个实际控制人实体匹配。例如,维基百科上名为 Geoffrey Clifton-Brown 的议员很可能与控制公司的同名人士相同,其参考编号为 09199367。对参考编号为 02303726 和 13420433 的公司也同样适用。

我们可以将这些实体关系表示为一个简单的网络,其中类似命名的个人表示为 节点,它们之间的三对比较表示为 ,如图 7-1 所示。

请注意,我们没有评估 PSC 数据中的三个具名个体之间的成对等价性——我们只是试图识别与主要维基百科实体的链接。但在此过程中,我们通过关联得出结论,所有三个 PSC 条目很可能指的是同一个现实世界的个体。

图 7-1. 简单的个人匹配聚类

在实践中,我们经常面临着多个需要解析的数据源,以及单个源中的潜在重复项。为了生成一个实体的解析视图,我们需要收集所有成对匹配的记录,并将它们分组到一个单一的可唯一标识的参考下。

这个收集示例的过程称为 聚类。聚类过程不试图确定哪个示例(如果有的话)是正确的,而只是识别该集合作为一个离散的有界集合,其成员都具有相似的特征。

在本章中,我们将探讨如何利用基本的聚类技术根据成对比较将实体分组在一起。我们将重用我们在第五章中获得的实际控制人数据集,但首先,让我们将问题缩小到一个小规模,以便我们可以理解需要采取的步骤。

简单的精确匹配聚类

首先,让我们考虑一个简单的数据集,其中包含名、姓和出生年份,如表 7-1 所示。该表包含一个精确重复(ID 0 和 1)以及其他几个相似的记录。

表 7-1. 简单聚类示例数据集

ID 出生年份
0 Michael Shearer 1970
1 Michael Shearer 1970
2 Mike Shearer 1970
3 Michael Shearer 1971
4 Michelle Shearer 1971
5 Mike Sheare 1971

每个 ID 是否代表一个单独的实体,还是它们指的是同一个人?

根据我们拥有的有限信息,我们可以例如按照名字和姓氏的精确等价性进行分组,但不包括出生年份。

table = [
    [0,'Michael','Shearer',1970],
    [1,'Michael','Shearer',1970],
    [2,'Mike','Shearer',1970],
    [3,'Michael','Shearer',1971],
    [4,'Michelle','Shearer',1971],
    [5,'Mike','Sheare',1971]]

clmns = ['ID','Firstname','Lastname','Year']
df_ms = pd.DataFrame(table, columns = clmns)

df_ms['cluster'] =
   df_ms.groupby(['Firstname','Lastname']).ngroup()

这样我们得到了四个群集。与 ID 0、1 和 3 关联的实体被分组在群集 0 中,因为它们具有完全相同的名称拼写,而 ID 2、4 和 5 具有唯一的拼写变体,因此分配了它们自己的个体群集,正如我们在图 7-2 中所见。

图 7-2. 简单精确匹配群集表格

近似匹配聚类

现在让我们考虑如果我们包括近似名称匹配会发生什么,就像在第三章介绍的那样。我们不能再简单地使用groupby函数来计算我们的群集,因此我们需要逐步完成比较步骤。这是一个有用的练习,用来说明在大数据集内部和跨数据集之间比较记录所面临的综合挑战。

我们的第一步是生成一个表格,其中包含所有可能的记录比较组合。我们希望将记录 ID 0 与每个其他记录进行比较,然后将记录 ID 1 与剩余记录进行比较,但不再重复与 ID 0 的比较(在成对比较中方向并不重要)。总共我们有 15 次比较:ID 0 对其同行有 5 次,ID 1 对其同行有 4 次,依此类推。

从我们的简单基础表格开始,我们可以使用我们在第三章介绍的 itertools 包生成一个 DataFrame,其中包含复合列 A 和 B,每个列包含从我们的简单表格中提取的要比较的属性列表:

import itertools

df_combs = pd.DataFrame(list(itertools.combinations(table,2)),
   columns=['A','B'])

图 7-3 展示了 DataFrame 的前几行。

图 7-3. 复合匹配组合的示例行

接下来,我们需要创建多级索引列,以保存 A 和 B 标题下的各个属性值:

clmnsA = pd.MultiIndex.from_arrays([['A']*len(clmns), clmns])
clmnsB = pd.MultiIndex.from_arrays([['B']*len(clmns), clmns])

现在我们可以分离属性并重新组合生成的列及其相关索引标签,形成单个 DataFrame:

df_edges = pd.concat(
   [pd.DataFrame(df_combs['A'].values.tolist(),columns = clmnsA),
    pd.DataFrame(df_combs['B'].values.tolist(),columns = clmnsB)],
   axis=1)

首几行展开如图 7-4 所示。

图 7-4. 近似匹配组合的示例行

现在我们已经准备好进行成对评估的属性,我们可以使用在第三章介绍的 Jaro-Winkler 相似度函数大致比较 A 和 B 值之间的名字和姓氏。如果两者匹配,比如等价分数大于 0.9,那么我们声明它们整体匹配:

import jellyfish as jf

def is_match(row):
   firstname_match = jf.jaro_winkler_similarity(row['A'] 
      ['Firstname'],row['B']['Firstname']) > 0.9
   lastname_match = jf.jaro_winkler_similarity(row['A']
      ['Lastname'], row['B']['Lastname']) > 0.9
   return firstname_match and lastname_match

df_edges['Match'] = df_edges.apply(is_match, axis=1)

df_edges

结果匹配列在图 7-5 中。我们可以看到记录 ID 0 在行 0 和 2 中与 ID 1 和 ID 3 完全匹配。在行 3 中,ID 0 和 ID 4 之间也宣布了一次匹配,因为“Michael”和“Michelle”之间有足够的相似性。请注意,行 6、7 和 12 还记录了 ID 0 以外的 ID 1、3 和 4 之间的直接匹配。

ID 2 也在行 11 中与 ID 5 匹配,“Shearer”和“Sheare”足够相似。

图 7-5。近似匹配表

根据这些结果,我们可以手动识别两个聚类,第一个包括 ID 0、1、3 和 4,第二个包括 ID 2 和 5。

然而,我们现在面临一个问题。我们允许非精确匹配作为单一实体进行聚类。现在我们应该使用哪些属性值来描述这个解决的实体?对于第一个聚类,包括 ID 0、1、3 和 4,名字应该是“Michael”还是“Michelle”?ID 0、1 和 3 的名字是“Michael”,但 ID 4 的名字是“Michelle”。正确的出生年份是 1970 还是 1971?

对于第二个聚类,我们面临相同的出生年份困境,以及是否应该使用“Sheare”还是“Shearer”的问题——这一点不太清楚。选择最具代表性值的挑战,有时被称为规范化,是一个积极研究的领域,但超出了本书的范围。

即使在这个简单的例子中,我们也可以看到在将实体聚类在一起时需要考虑的许多挑战和权衡。首先,随着要进行聚类的记录数量增加,成对比较的数量增长非常迅速。对于一个 n 行的表格,有 n × (n–1)/2 种组合。如果包括近似匹配,那么产生的计算负担是显著的,可能需要大量时间来计算。其次,最具挑战性的是,在聚类中的个体实体具有不同的属性值时,如何确定一个单一的属性集来定义一个聚类。

现在我们已经介绍了一些与聚类相关的挑战,让我们回到 PSC 数据集,考虑一个更大规模的例子。

样本问题

回到我们在第五章中的例子,假设我们希望研究对英国公司的控制集中度,识别对几家公司有影响力的个人。为此,我们需要对 PSC 数据集中的所有匹配个人所有者实体进行聚类。此外,考虑到 PSC 条目的数据质量不一,我们要考虑将近似匹配纳入我们的计算中。

在我们的 PSC 数据集中约有 1150 万条记录,我们需要进行的总比较次数超过 66 万亿次。我们有大量工作要做!

数据采集

让我们从我们在第五章中下载的原始数据开始。在本章中,我们将使用更广泛的属性范围进行匹配:

df_psc = pd.read_csv('psc_raw.csv',dtype=
   {'data.name_elements.surname':'string',
    'data.name_elements.forename':'string',
    'data.name_elements.middle_name':'string',
    'data.name_elements.title':'string',
    'data.nationality':'string'})

数据标准化

现在我们有了原始数据,下一步是标准化,并为简单起见重命名属性。我们还会删除任何缺少出生年份或月份的记录,因为我们将使用这些作为阻塞值来帮助减少我们需要进行的比较数量:

df_psc = df_psc.dropna(subset
   ['data.date_of_birth.year','data.date_of_birth.month'])
df_psc['Year'] = df_psc['data.date_of_birth.year'].astype('int64')
df_psc['Month'] =
   df_psc['data.date_of_birth.month'].astype('int64')

df_psc = df_psc.rename(columns=
   {"data.name_elements.surname" : "Lastname",
    "data.name_elements.forename" : "Firstname",
    "data.name_elements.middle_name" : "Middlename",
    "data.name_elements.title" : "Title",
    "data.nationality" : "Nationality"})

df_psc = df_psc[['Lastname','Middlename','Firstname',
   'company_number','Year','Month','Title','Nationality']]
df_psc['unique_id'] = df_psc.index

记录阻塞和属性比较

与以前一样,我们使用 Splink 框架执行比较,对年、月和姓氏的精确等价性作为预测阻塞规则,即仅当年、月和姓氏字段之间存在精确匹配时才将记录与其他记录进行比较。显然,这是一种权衡,因为我们可能会错过一些具有姓氏不一致或拼写错误的匹配,例如。

请注意,对于这个单一来源示例,我们将 link_type 设置为 dedupe_only 而不是 link_only。Splink 支持 dedupe_onlylink_onlylink_and_dedupe

我们还为 EM 算法指定了收敛容差,并设置了最大迭代次数(即使尚未达到收敛):

from splink.duckdb.linker import DuckDBLinker
from splink.duckdb import comparison_library as cl

settings = {
   "link_type": "dedupe_only",
   "blocking_rules_to_generate_predictions":
      [ "l.Year = r.Year and l.Month = r.Month and
          l.Lastname = r.Lastname" ],
   "comparisons":
      [ cl.jaro_winkler_at_thresholds("Firstname", [0.9]),
        cl.jaro_winkler_at_thresholds("Middlename", [0.9]),
        cl.exact_match("Lastname"),
        cl.exact_match("Title"),
        cl.exact_match("Nationality"),
        cl.exact_match("Month"),
        cl.exact_match("Year", term_frequency_adjustments=True), ],
   "retain_matching_columns": True,
   "retain_intermediate_calculation_columns": True,
   "max_iterations": 10,
   "em_convergence": 0.01,
   "additional_columns_to_retain": ["company_number"],
   }
linker = DuckDBLinker(df_psc, settings)

数据分析

和之前一样,查看我们比较属性的数据分布是很有用的:

linker.profile_columns(["Firstname","Middlename","Lastname",
   "Title","Nationality","Month","Year"], top_n=10, bottom_n=5)

正如我们在图 7-6 中看到的,我们有着预期的名、中间名和姓的分布。在图 7-7 中,我们还可以看到头衔和国籍的分布偏向于少数常见值。图 7-8 显示,出生月份在一年中分布相对均匀,而出生年份在某种程度上偏向于 1980 年代。

图 7-6. 名字、中间名和姓氏分布

图 7-7. 头衔和国籍分布

图 7-8. 出生年月分布

期望最大化阻塞规则

鉴于潜在组合数量非常之高,我们需要尽可能严格地指定 EM 算法的阻塞规则,以便使流程能够在合理的时间内完成。

我们可以使用 count_num_comparisons_from_blocking 函数测试给定阻塞规则将生成的比较数量;例如:

linker.count_num_comparisons_from_blocking_rule(
   "l.Lastname = r.Lastname and
    l.Month = r.Month and
    l.Title = r.Title and
    l.Nationality = r.Nationality")

请记住,每个属性比较级别必须通过阻塞规则(即不被阻塞)中的至少一个估计参数步骤,以便为该属性生成 mu 值。

给出了几种属性阻塞规则组合的比较计数,在表 7-2 中。

表 7-2. 阻塞规则比较计数

属性组合阻塞规则 比较计数

| 1 | l.Lastname = r.Lastname and l.Month = r.Month and

l.Title = r.Title and

l.Nationality = r.Nationality | 7774 万 |

| l.Firstname = r.Firstname and l.Year = r.Year and

l.Middlename = r.Middlename | 6970 万 |

2 l.Lastname = r.Lastname and l.Middlename = r.Middlename 1199 万

| l.Firstname = r.Firstname and l.Month = r.Month and

l.Year = r.Year and

l.Title = r.Title and

l.Nationality = r.Nationality | 281M |

我们可以看到,第一对阻塞规则需要评估大量的比较,而第二对规则允许对所有属性进行参数估计,但整体比较次数较少。

名字、中间名和姓氏等价是减少比较量最具歧视性的因素,其次是出生年份,月份的影响较小。由于国籍和头衔值的基数有限,它们并不特别有帮助,正如我们在图 7-6 中看到的那样。

我们可以使用这些阻塞规则:

linker.estimate_parameters_using_expectation_maximisation(
   "l.Lastname = r.Lastname and l.Middlename = r.Middlename",
      fix_u_probabilities=False)

linker.estimate_parameters_using_expectation_maximisation(
   "l.Firstname = r.Firstname and l.Month = r.Month and
    l.Year = r.Year and l.Title = r.Title and
    l.Nationality = r.Nationality",
      fix_u_probabilities=False)

计算时间

即使采用了这些更优化的阻塞规则,在大数据集上执行期望最大化算法可能需要一些时间,特别是如果在性能适中的机器上运行的话。

或者,如果您想跳过训练步骤,可以简单地加载预训练模型:

linker.load_settings("Chapter7_Splink_Settings.json")

匹配分类与聚类

完成 EM 步骤(见第四章)后,我们就有了一个训练好的模型,用于评估我们单一数据集中记录对的相似性。请记住,这些对是通过预测阻塞规则选择的(在本例中是确切的姓氏、出生年份和月份)。预测匹配的阈值设置为 0.9:

df_predict = linker.predict(threshold_match_probability=0.9)

在进行成对预测之后,Splink 提供了一个聚类函数,用于在匹配概率超过指定阈值的共享实体对中将它们分组在一起。请注意,聚类阈值应用于完整的成对组合集合,而不是超过 0.9 预测阈值的子集;即,所有比较中都未达到等价阈值的记录仍将出现在输出中,被分配到它们自己的群集中。

clusters = linker.cluster_pairwise_predictions_at_threshold(
   df_predict, threshold_match_probability=0.9)
df_clusters = clusters.as_pandas_dataframe()

df_clusters.head(n=5)

记录的结果数据集,标记有其父类群,可以轻松转换为 DataFrame,其中的前几行(经过消除姓名和出生年月信息后)显示在图 7-9 中。

图 7-9. 示例行

然后,我们可以根据 cluster_id 将这些行分组,保留来自每个源记录的所有不同属性值列表。在我们的情况下,预测使用这些属性的确切等价作为我们的阻塞规则,我们不希望在姓氏、月份或年份上有任何变化。这给我们带来了大约 680 万个唯一的群集:

df_cgroup =
   df_clusters.groupby(['cluster_id'], sort=False)
      [['company_number','Firstname','Title','Nationality','Lastname']]
         .agg(lambda x: list(set(x)))
            .reset_index() 

为了说明我们在一个群集中看到的属性变化,我们可以选择一些具有不同名字、头衔和国籍的群集子集。为了方便手动检查,我们限制自己仅检查包含确切六条记录的群集:

df_cselect = df_cgroup[
   (df_cgroup['Firstname'].apply(len) > 1) &
   (df_cgroup['Title'].apply(len) > 1) &
   (df_cgroup['Nationality'].apply(len) > 1) &
   (df_cgroup['company_number'].apply(len) == 6)]

df_cselect.head(n=5)

在经过消除姓名和出生年月信息后的结果表格中,我们可以在图 7-10 中以表格形式看到一些选定的群集。

图 7-10. 显示大小为六的簇中归属变化的样本行

簇可视化

现在我们已经将我们的 PSC 聚集在一起,我们可以统计每个实体控制的公司数量,然后在直方图中绘制这些值的分布:

import matplotlib.pyplot as plt
import numpy as np

mybins =[1,2,10,100,1000,10000]
fig, ax = plt.subplots()
counts, bins, patches = ax.hist(df_cgroup['unique_id'].apply(len),
   bins=mybins )
bin_centers = 0.5 * np.diff(bins) + bins[:-1]

for label, x in zip(['1','2-10','10-100','100-1000','1000+'],
   bin_centers):
   ax.annotate(label, xy=(x, 0), xycoords=('data', 'axes fraction'),
               xytext=(0,-10), textcoords='offset points', va='top',
               ha='right')
ax.tick_params(labelbottom=False)
ax.xaxis.set_label_coords(0,-0.1)
ax.xaxis.set_tick_params(which='minor', bottom=False)

ax.set_xlabel('Number of controlled companies')
ax.set_ylabel('Count')
ax.set_title('Distribution of significant company control')
ax.set_yscale('log')
ax.set_xscale('log')

fig.tight_layout()
plt.show()

图 7-11 显示了结果图,允许我们开始回答我们的样本问题——英国公司控制的集中度有多高?我们可以看到,大多数个人只控制一家公司,较小但仍非常重要的数字控制着 2 到 10 家公司的影响力。之后,数量急剧下降,直到我们的数据表明,我们有一些个人对 1000 多家公司有影响力。

图 7-11. 显著公司控制的直方图分布

如果你和我一样,认为对 1000 多家公司进行显著控制听起来有些不太可能,那么现在是时候我们更详细地检查我们的簇结果,看看可能发生了什么。为了了解问题,让我们看看由恰好六条记录组成的簇的子集。

簇分析

Splink 为我们提供了一个簇工作室仪表板,我们可以与之互动,探索我们生成的簇,以了解它们是如何形成的。仪表板以 HTML 页面形式持久保存,我们可以在 Jupyter 环境中显示它,作为 Python 内联框架(IFrame):

linker.cluster_studio_dashboard(df_predict, clusters,
   "Chapter7_cluster_studio.html",
   cluster_ids = df_cselect['cluster_id'].to_list(), overwrite=True)

from IPython.display import IFrame
IFrame( src="Chapter7_cluster_studio.html", width="100%",
   height=1200

图 7-12 显示了一个工作室仪表板的示例。

让我们考虑一个示例簇,参考:766724.^(1) 请记住,由于阻断规则,所有节点在此簇中都在同一姓氏、出生月份和出生年份上完全匹配。

簇工作室提供了每个簇的图形视图,节点用其分配的唯一标识符标记,并通过与超过设定阈值的每对比较相关的边连接在一起。这在 图 7-13 中显示。

图 7-13. 示例簇

在这个例子中,我们可以看到并不是所有节点都相互连接。实际上,在这 6 个节点之间,我们只有 9 条连接边,而可能有 15 条。显然有两个完全互联的迷你簇,通过节点 766724 连接在一起。让我们详细看看这个问题。

簇工作室还提供了节点的表格视图,以便我们更详细地检查属性,如 图 7-14 所示。我们已对 Firstname 列进行了清理——在这种情况下,第一行和第三行的拼写相同,与其他四行略有不同。

图 7-14. 示例簇节点

节点 8261597、4524351 和 766724 的顶级迷你集群都具有相同的 Nationality,并且也缺少 Middlename。第二个迷你集群的节点 766724、5702850、4711461 和 9502305 具有完全匹配的 Firstname 值。

在 图 7-15 中显示的经过清理的表边视图,为我们提供了这些成对比较的匹配权重和相关概率。

图 7-15. 示例集群边缘

如果我们将匹配阈值提高到 3.4 以下的边缘进行过滤,我们会打破两个得分最低的成对链接。如图 7-16 所示,我们的第二个迷你集群保持完整,但我们的第一个迷你集群已经分裂,节点 8261597 和 4524351 现在因为他们不同的名字拼写而分开。

图 7-16. 示例集群 — 高匹配阈值

进一步增加匹配权重阈值到 8.7 完全打破我们的第一个迷你集群,因为缺少 Middlename 成为一个决定性的负面因素。如 图 7-17 所示。

图 7-17. 示例集群 — 更高的匹配阈值

将匹配权重增加到非常高的阈值 9.4 导致节点 766724 因其稍有不同的名字拼写而分裂,如 图 7-18 所示。

图 7-18. 示例集群 — 最高匹配阈值

正如我们所见,我们的集群的大小和密度高度依赖于我们为将成对比较分组设置的阈值。

公司注册局网站为我们提供了与这些 PSC 记录关联的地址信息。公司编号 8261597、4711461 和 4524351 都由同一地址的个人注册,以及公司编号 5702850 和 9502305。这使我们更有信心,这个集群确实代表了一个个人。

更广泛的审查表明,我们对评估英国公司控制集中度的第一步可能过于乐观了。将我们的匹配和聚类阈值设置在 0.9,导致过度连接,产生了具有较弱关联的更大的集群。这可能在一定程度上解释了对控制超过 1,000 家公司的几个个人的相当可疑评估。

我希望通过处理这个示例问题,说明了在处理混乱的真实世界数据时,实体解析并不是一门精确的科学。没有一个单一的正确答案,需要判断来设置匹配阈值,以达到你所追求的结果的最佳值。

总结

在本章中,我们看到如何在多个数据集内和跨数据集之间进行实体解析,这产生了大量的成对比较。我们学习了如何选择和评估阻塞规则,以减少这些组合到一个更实际的数量,使我们能够在合理的时间范围内训练和运行我们的匹配算法。

使用近似匹配和概率实体解析,我们能够从成对比较中生成集群,允许某些属性的变化。然而,我们面临着归一化的挑战,即如何决定使用哪些属性值来描述我们的统一实体。

我们还学习了如何使用图形可视化来帮助我们理解我们的集群。我们看到集群的大小和组成在很大程度上受到我们选择的匹配阈值的影响,并且在特定数据集和期望的结果背景下,需要平衡过度连接或欠连接的风险。

^(1) 注意:如果您正在使用自己的笔记本和 PSC 数据集进行跟进,您的集群引用可能会有所不同。

第八章:在 Google Cloud 上扩展

在本章中,我们将介绍如何扩展我们的实体解析过程,以便在合理的时间内匹配大型数据集。我们将使用在 Google Cloud Platform (GCP) 上并行运行的虚拟机群集来分担工作量,减少解析实体所需的时间。

我们将逐步介绍如何在 Cloud Platform 上注册新账户以及如何配置我们将需要的存储和计算服务。一旦我们的基础设施准备好,我们将重新运行第六章中的公司匹配示例,将模型训练和实体解析步骤分割到一个托管的计算资源群集中。

最后,我们将检查我们的性能是否一致,并确保我们完全清理,删除集群并返回我们借用的虚拟机,以确保我们不会继续产生额外的费用。

Google Cloud 设置

要构建我们的云基础设施,我们首先需要在 GCP 上注册一个账户。为此,请在浏览器中访问cloud.google.com。从这里,您可以点击“开始”以开始注册过程。您需要使用 Google 邮箱地址注册,或者创建一个新账户。如图 8-1 所示。

图 8-1. GCP 登录

您需要选择您的国家,阅读并接受 Google 的服务条款,然后点击“继续”。见图 8-2。

图 8-2. 注册 GCP,账户信息第一步

在下一页上,您将被要求验证您的地址和支付信息,然后才能点击“开始我的免费试用”。

Google Cloud Platform 费用

请注意,了解使用 Google Cloud Platform 上任何产品所带来的持续费用是您的责任。根据个人经验,我可以说,很容易忽略持续运行的虚拟机或忽略您仍需付费的持久性磁盘。

在撰写本文时,Google Cloud 提供首次使用平台的 300 美元免费信用额,可在前 90 天内使用。此外,他们还声明在免费试用结束后不会自动收费,因此如果您使用信用卡或借记卡,除非您手动升级到付费账户,否则不会收取费用。

当然,这些条款可能会更改,因此请在注册时仔细阅读条款。

一旦您注册成功,您将被带到 Google Cloud 控制台。

设置项目存储

您的第一个任务是创建一个项目。在 GCP 上,项目是您管理的资源和数据的逻辑组。为了本书的目的,我们所有的工作将被组织在一个项目中。

首先,选择您喜欢的项目名称,Google 将为您建议一个相应的项目 ID。您可能希望编辑他们的建议,以简化或缩短这个项目 ID,因为您可能需要多次输入这个项目 ID。

作为个人用户,您不需要指定项目的组织所有者,如图 8-3 所示。

图 8-3. “创建项目”对话框

创建项目后,您将进入项目仪表板。

我们首先需要在 GCP 上存储我们的数据的地方。标准数据存储产品称为 Cloud Storage,其中具体的数据容器称为存储桶。存储桶具有全局唯一的名称和存储桶及其数据内容存储的地理位置。如果您愿意,存储桶的名称可以与您的项目 ID 相同。

要创建一个存储桶,您可以单击导航菜单主页(屏幕左上角的三个水平线内的圆圈)选择云存储,然后从下拉导航菜单中选择桶。图 8-4 显示了菜单选项。

图 8-4. 导航菜单—云存储

从这里,从顶部菜单中单击创建存储桶,选择您喜欢的名称,然后单击继续。参见图 8-5。

图 8-5. 创建存储桶—命名

接下来,您需要选择首选存储位置,如图 8-6 所示。对于本项目,您可以接受默认设置,或者如果您愿意,可以选择不同的区域。

您可以按“继续”查看剩余的高级配置选项,或者直接跳转到“创建”。现在我们已经定义了一些存储空间,下一步是保留一些计算资源来运行我们的实体解析过程。

图 8-6. 创建存储桶—数据存储位置

创建 Dataproc 集群

与前几章类似,我们将使用 Splink 框架执行匹配。为了将我们的过程扩展到多台计算机上运行,我们需要将后端数据库从 DuckDB 切换到 Spark。

在 GCP 上运行 Spark 的一个方便的方法是使用 Dataproc 集群,它负责创建一些虚拟机并配置它们来执行 Spark 作业。

要创建一个集群,我们首先需要启用 Cloud Dataproc API。返回导航菜单,然后选择 Dataproc,然后像图 8-7 那样选择 Clusters。

图 8-7. 导航菜单—Dataproc 集群

然后,您将看到 API 屏幕。确保您阅读并接受条款和相关费用,然后单击启用。参见图 8-8。

图 8-8. 启用 Cloud Dataproc API

启用 API 后,您可以单击创建集群以配置您的 Dataproc 实例。Dataproc 集群可以直接构建在 Compute Engine 虚拟机上,也可以通过 GKE(Google Kubernetes Engine)构建。对于本示例,两者之间的区别并不重要,因此建议您选择 Compute Engine,因为它是两者中较简单的一个。

接下来,您将看到图 8-9 中的屏幕。

图 8-9. 在 Compute Engine 上创建集群

在这里,您可以为您的集群命名,选择其所在的位置,并选择集群的类型。接下来,滚动到“组件”部分,并选择“组件网关”和“Jupyter 笔记本”,如 图 8-10 所示。这一点很重要,因为它允许我们配置集群并使用 Jupyter 执行我们的实体解析笔记本。

图 8-10. Dataproc 组件

当您配置完组件后,您可以接受本页其余部分的默认设置—参见 图 8-11—然后选择“配置节点”选项。

图 8-11. 配置工作节点

下一步是配置我们集群中的管理节点和工作节点。同样,您可以接受默认设置,但在移动到“自定义集群”之前,请检查工作节点数量是否设置为 2。

最后一步,但同样重要的是,考虑安排删除集群以避免在您完成使用后忘记手动删除集群而产生的任何持续费用。我还建议配置 Cloud Storage 临时存储桶以使用您之前创建的存储桶;否则,Dataproc 进程将为您创建一个可能在清理操作中被遗漏的存储桶。参见 图 8-12。

图 8-12. 自定义集群—删除和临时存储桶

最后,点击“创建”指示 GCP 为您创建集群。这将需要一些时间。

配置一个 Dataproc 集群

基本集群运行后,我们可以通过点击集群名称,然后在显示的“Web 界面”部分选择 Jupyter 来连接到集群,如 图 8-13 所示。

图 8-13. 集群 Web 界面—Jupyter

这将在新的浏览器窗口中启动一个熟悉的 Jupyter 环境。

我们的下一个任务是下载并配置我们需要的软件和数据。从“新建”菜单中,选择“终端”以在第二个浏览器窗口中打开命令提示符。切换到主目录:

>>>cd /home

然后从 GitHub 仓库克隆存储库,并切换到新创建的目录中:

>>>git clone https://github.com/mshearer0/handsonentityresolution

>>>cd handsonentityresolution

接下来,返回到 Jupyter 环境,并打开 Chapter6.ipynb 笔记本。运行笔记本中的数据获取和标准化部分,以重新创建干净的 Mari 和 Basic 数据集。

编辑“保存到本地存储”部分,将文件保存到以下位置:

df_c.to_csv('/home/handsonentityresolution/basic_clean.csv')

df_m.to_csv('/home/handsonentityresolution/mari_clean.csv',
   index=False)

现在我们已经重建了我们的数据集,我们需要将它们复制到我们之前创建的 Cloud Storage 存储桶中,以便所有节点都可以访问。我们在终端中执行以下命令:

>>>gsutil cp /home/handsonentityresolution/* gs://<*your
   bucket*>/handsonentityresolution/
注意

注意:记得替换您的存储桶名称!

这将在您的存储桶中创建目录 handsonentityresolution 并复制 GitHub 仓库文件。这些文件将在本章和下一章中使用。

接下来我们需要安装 Splink:

>>>pip install splink

以前,我们依赖于内置于 DuckDB 中的近似字符串匹配函数,如 Jaro-Winkler。这些例程在 Spark 中默认情况下不可用,因此我们需要下载并安装一个包含这些用户定义函数(UDF)的 Java ARchive(JAR)文件,Splink 将调用它们:

​>>>wget https://github.com/moj-analytical-services/
   splink_scalaudfs/raw/spark3_x/jars/scala-udf-similarity-
   0.1.1_spark3.x.jar

再次,我们将此文件复制到我们的存储桶中,以便这些函数可以供集群工作节点使用:

>>>gsutil cp /home/handsonentityresolution/*.jar
   gs://<*your bucket*>/handsonentityresolution/

要告知我们的集群在启动时从哪里获取此文件,我们需要在 Jupyter 中的路径 /Local Disk/etc/spark/conf.dist/ 中浏览到 spark-defaults.conf 文件,并添加以下行,记得替换您的存储桶名称:

spark.jars=gs://<*your_bucket*>/handsonentityresolution/
   scala-udf-similarity-0.1.1_spark3.x.jar

要激活此文件,您需要关闭您的 Jupyter 窗口,返回到集群菜单,然后停止并重新启动您的集群。

Spark 上的实体解析

最后,我们准备开始我们的匹配过程。在 Jupyter Notebook 中打开 Chapter8.ipynb

首先,我们加载之前保存到我们存储桶中的数据文件到 pandas DataFrames 中:

df_m = pd.read_csv('gs://<*your bucket*>/
   handsonentityresolution/mari_clean.csv')
df_c = pd.read_csv('gs://<*your bucket*>/
   handsonentityresolution/basic_clean.csv')

接下来我们配置 Splink 设置。这些与我们在 DuckDB 后端使用的设置略有不同:

from pyspark import SparkContext, SparkConf
from pyspark.sql import SparkSession
from pyspark.sql import types

conf = SparkConf()
conf.set("spark.default.parallelism", "240")
conf.set("spark.sql.shuffle.partitions", "240")

sc = SparkContext.getOrCreate(conf=conf)
spark = SparkSession(sc)
spark.sparkContext.setCheckpointDir("gs://<*your bucket*>/
    handsonentityresolution/")

spark.udfspark.udf.registerJavaFunction(
   "jaro_winkler_similarity",
   "uk.gov.moj.dash.linkage.JaroWinklerSimilarity",
   types.DoubleType())

首先,我们导入 pyspark 函数,允许我们从 Python 创建一个新的 Spark 会话。接下来,我们设置配置参数来定义我们想要的并行处理量。然后我们创建 SparkSession 并设置一个 Checkpoint 目录,Spark 将其用作临时存储。

最后,我们注册一个新的 Java 函数,以便 Splink 可以从之前设置的 JAR 文件中获取 Jaro-Winkler 相似度算法。

接下来,我们需要设置一个 Spark 模式,我们可以将我们的数据映射到其中:

from pyspark.sql.types import StructType, StructField, StringType, IntegerType

schema = StructType(
   [StructField("Postcode", StringType()),
    StructField("CompanyName", StringType()),
    StructField("unique_id", IntegerType())]
)

然后我们可以从 pandas DataFrames (df) 和我们刚刚定义的模式创建 Spark DataFrames (dfs)。由于两个数据集具有相同的结构,我们可以使用相同的模式:

dfs_m = spark.createDataFrame(df_m, schema)
dfs_c = spark.createDataFrame(df_c, schema)

我们的下一步是配置 Splink。这些设置与我们在第六章中使用的设置相同:

import splink.spark.comparison_library as cl

settings = {
   "link_type": "link_only",
   "blocking_rules_to_generate_predictions": [ "l.Postcode = r.Postcode",
   "l.CompanyName = r.CompanyName", ],
   "comparisons": [ cl.jaro_winkler_at_thresholds("CompanyName",[0.9,0.8]), ],
   "retain_intermediate_calculation_columns" : True,
   "retain_matching_columns" : True
}

然后我们使用我们创建的 Spark DataFrames 和设置来设置一个 SparkLinker

from splink.spark.linker import SparkLinker
linker = SparkLinker([dfs_m, dfs_c], settings, input_table_aliases=
["dfs_m", "dfs_c"])

正如在第六章中一样,我们使用随机抽样和期望最大化算法分别训练 um 值:

linker.estimate_u_using_random_sampling(max_pairs=5e7)
linker.estimate_parameters_using_expectation_maximisation
   ("l.Postcode = r.Postcode")
注意

这是我们开始看到切换到 Spark 的好处的地方。以前的模型训练需要超过一个小时,现在仅需几分钟即可完成。

或者,您可以从存储库加载预训练模型 Chapter8_Splink_Settings.json

linker.load_model("<*your_path*>/Chapter8_Splink_Settings.json")

然后我们可以运行我们的预测并获得我们的结果:

df_pred = linker.predict(threshold_match_probability=0.1)
   .as_pandas_dataframe()
len(df_pred)

性能测量

正如预期的那样,切换到 Spark 并没有实质性地改变我们的结果。在 0.1 的匹配阈值下,我们有 192 个匹配项。我们的结果显示在表 8-1 中。

表 8-1. MCA 匹配结果(Spark)—低阈值

匹配阈值 = 0.1 匹配数量 唯一匹配实体
名称和邮政编码匹配 47 45
仅名称匹配 37 31
仅邮政编码匹配 108 27
总匹配数 192 85(去重后)
未匹配 11(其中 2 个溶解)
总组织数 96

这会因模型参数计算中的轻微变化而略微提高精确度和准确性。

整理一下!

为了确保您不会继续支付虚拟机及其磁盘的费用,请确保您从集群菜单中删除您的集群(不仅仅是停止,这将继续积累磁盘费用)

如果您希望在下一章中继续使用 Cloud Storage 存储桶中的文件,请确保删除任何已创建的暂存或临时存储桶,如图 8-14 所示。

图 8-14. 删除暂存和临时存储桶

摘要

在本章中,我们学习了如何将我们的实体解析过程扩展到多台机器上运行。这使我们能够匹配比单台机器或合理的执行时间范围更大的数据集。

在这个过程中,我们已经看到如何使用 Google Cloud Platform 来提供计算和存储资源,我们可以按需使用,并且只支付我们所需的带宽费用。

我们还看到,即使是一个相对简单的示例,我们在运行实体解析过程之前需要大量的配置工作。在下一章中,我们将看到云服务提供商提供的 API 可以抽象出大部分这些复杂性。

第九章:云实体解析服务

在上一章中,我们看到了如何将我们的实体解析过程扩展到运行在 Google Cloud 管理的 Spark 集群上。这种方法使我们能够在合理的时间内匹配更大的数据集,但它要求我们自己进行相当多的设置和管理。

另一种方法是使用云提供商提供的实体解析 API 来为我们执行繁重的工作。Google、Amazon 和 Microsoft 都提供这些服务。

在本章中,我们将使用作为 Google 企业知识图 API 的一部分提供的实体协调服务,来解析我们在第六章和第八章中检查的 MCA 和 Companies House 数据集。我们将:

  • 将我们的标准化数据集上传到 Google 的数据仓库 BigQuery。

  • 提供我们数据模式到标准本体的映射。

  • 从控制台调用 API(我们还将使用 Python 脚本调用 API)。

  • 使用一些基本的 SQL 来处理结果。

​​为了完成本章,我们将检查该服务的性能如何。

BigQuery 简介

BigQuery 是 Google 的完全托管的、无服务器的数据仓库,支持使用 SQL 方言进行可扩展的数据查询和分析。它是一个平台即服务,支持数据查询和分析。

要开始,请从 Google Cloud 控制台中选择 BigQuery 产品。在分析下,我们选择“SQL 工作区”。

我们的第一步是从您的项目名称旁边的省略菜单中选择“创建数据集”,如图 9-1 所示。

图 9-1. BigQuery 创建数据集

在弹出窗口中,如图 9-2 所示,我们需要将数据集 ID 命名为 Chapter9,然后选择位置类型。然后,您可以选择特定的区域,或者只需接受多区域默认值。可选地,您可以添加一个在表格自动过期的天数。

一旦我们创建了一个空数据集,下一个任务是上传我们的 MCA 和 Companies House 表格。我们可以从我们在第 8 章中保存的 Google Cloud 存储存储桶中的数据上传这些表格。

图 9-2. BigQuery 创建数据集配置

选择数据集后,我们可以单击“+ 添加”,或添加数据,然后选择 Google Cloud 存储作为源(如图 9-3 所示)。然后,您可以浏览到您的 Cloud 存储存储桶并选择mari_clean.csv文件。选择 Chapter9 数据集作为目的地,并将表格命名为mari。在模式下,单击“自动检测”复选框。您可以接受其余默认设置。

图 9-3. BigQuery 创建表

对于basic_clean.csv文件,重复此过程,将其命名为basic。然后,您可以选择数据集中的表格以查看架构。选择预览将显示前几行,如图 9-4 所示。

图 9-4. BigQuery 表模式

现在我们已成功加载数据,需要告知企业知识图谱 API 如何映射我们的模式,然后运行一个协调作业。

企业知识图谱 API

Google 企业知识图谱 API 提供了一种轻量级实体解析服务,称为实体协调。该服务使用在 Google 数据上训练的 AI 模型。它使用了分层聚合聚类的并行版本。

分层聚合聚类

这是一种“自下而上”的聚类实体方法。每个实体首先位于自己的簇中,然后根据它们的相似性进行聚合。

截至撰写本文时,实体协调服务处于预览状态,并按照 Pre-GA 条款提供,详细信息请访问Google Cloud 网站

要启用 API,请从控制台导航菜单中选择人工智能下的企业 KG。从这里,您可以为您的项目点击“启用企业知识图谱 API”。

模式映射

要设置我们的实体解析作业,我们首先需要将我们的数据模式映射到 Google 实体协调 API 理解的模式上。我们通过为每个要使用的数据源创建一个映射文件来完成这一点。API 使用一种称为 YARRRML 的人类可读的简单格式语言来定义源模式与来自schema.org的目标本体之间的映射。它支持三种不同的实体类型:组织、个人和本地企业。在我们的示例中,我们将使用组织模式。

首先,我们点击 Schema Mapping,然后在组织框中选择“创建映射”。这将带我们进入一个编辑器,在这里我们可以修改并保存模板映射文件。映射文件分为一个前缀部分,告诉 API 我们将使用哪个模型和模式参考。映射部分然后列出数据集中包含的每种实体类型。对于每种实体类型,我们指定源、唯一标识实体的主键(s),然后是谓词列表(po),指定我们希望匹配的实体属性。

默认模板如下:

prefixes:
   ekg: http://cloud.google.com/ekg/0.0.1#
   schema: https://schema.org/

mappings:
   organization:
      sources:
         - [example_project:example_dataset.example_table~bigquery]        
      s: ekg:company_$(record_id)
      po:
         - [a, schema:Organization]
         - [schema:name, $(company_name_in_source)]
         - [schema:streetAddress, $(street)]
         - [schema:postalCode, $(postal_code)]
         - [schema:addressCountry, $(country)]
         - [schema:addressLocality, $(city)]
         - [schema:addressRegion, $(state)]
         - [ekg:recon.source_name, $(source_system)]
         - [ekg:recon.source_key, $(source_key)]

从 MCA 数据集的映射文件开始,按如下编辑默认模板,记得在源行中插入您的项目名称。这个文件也可以在存储库中作为Chapter9SchemaMari获得:

prefixes:
  ekg: http://cloud.google.com/ekg/0.0.1#
  schema: https://schema.org/

mappings:
  organization:
    sources:
      - [<*your_project_name*>:Chapter9.mari~bigquery]
    s: ekg:company1_$(unique_id)
    po:
      - [a, schema:Organization]
      - [schema:postalCode, $(Postcode)]
      - [schema:name, $(CompanyName)]
      - [ekg:recon.source_name, (mari)]
      - [ekg:recon.source_key, $(unique_id)]

注意这里我们正在指向先前在 Chapter9 数据集中创建的mari BigQuery 表的 API。我们使用unique_id列作为我们的主键,并将我们的Postcode字段映射到模式中的postalCode属性,将我们的CompanyName字段映射到name属性。

将编辑后的文件保存到您的 Google Storage 存储桶中,路径为handsonentityresolution目录下:

gs://<*your bucket*>/handsonentityresolution/Chapter9SchemaMari

重复此过程为英国公司注册处数据集创建一个映射文件,保存在与 Chapter9SchemaBasic 相同的位置。记得在相关行中将 basic 替换为 mari 并引用这些实体为 company2

    - [<your_bucket>:Chapter9.basic~bigquery]
   s: ekg:company2_$(unique_id)
   po:
       - [a, schema:Organization]
       - [ekg:recon.source_name, (basic)]

现在我们有了我们的数据集和映射文件,所以我们可以运行一个实体解析(或对账)作业。

对账作业

要开始一个对账作业,请在控制台导航菜单中的企业 KG 部分中选择作业,如 图 9-5 所示。

图 9-5. 开始一个对账作业

选择“运行作业”选项卡,如 图 9-6 所示。

图 9-6. 运行实体对账 API 作业

从弹出菜单中:

步骤 1: 点击“选择实体类型”

选择组织。

步骤 2: 添加 BigQuery 数据源

浏览到 BigQuery 路径,然后选择 mari 表。然后通过浏览到您的存储桶中的 handsonentityresolution 目录并选择我们之前创建的 Chapter9SchemaMari 文件来选择匹配的映射表。

点击添加另一个 BigQuery 数据源,然后重复基础表和映射文件的过程。

步骤 3: 设置 BigQuery 数据目标

浏览并选择 Chapter9 BigQuery 数据集以告知 API 写入其结果的位置。

步骤 4: 高级设置(可选)

对于最后一步,我们可以指定一个之前的结果表,这样实体对账服务就会为不同作业中的实体分配一致的 ID,如 图 9-7 所示。随着新数据的添加,这对更新现有实体记录特别有用。

图 9-7. 实体对账 API 高级设置

可以指定聚类轮数(实体解析模型的迭代次数);轮数越高,实体合并得越松散。对我们的使用情况来说,默认设置就很好。

最后,点击“完成”并开始我们的作业。假设一切顺利,我们应该会看到一个新的作业在作业历史记录下创建,如 图 9-8 所示。

图 9-8. 实体对账作业历史记录

我们可以观察作业显示状态列,以监控作业的进度,它按照 表 9-1 中显示的顺序顺序地进行显示状态,最后在完成时显示“已完成”。

表 9-1. 作业显示状态

作业显示状态 代码状态 描述
运行中 JOB_<wbr>STATE_<wbr>RUNNING 作业正在进行中。
知识提取 JOB_<wbr>STATE_<wbr>KNOWLEDGE_<wbr>EXTRACTION 企业知识图正在从 BigQuery 提取数据并创建特征。
对账预处理 JOB_<wbr>STATE_<wbr>RECON_<wbr>​PRE⁠PROCESSING 作业处于对账预处理步骤。
聚类 JOB_<wbr>STATE_<wbr>CLUSTERING 作业正在进行聚类步骤。
导出聚类 JOB_<wbr>STATE_<wbr>EXPORTING_<wbr>CLUSTERS 作业正在将输出写入 BigQuery 目标数据集。

此作业应该大约需要 1 小时 20 分钟,但在产品的此预览阶段,持续时间会有很大变化。

当作业完成后,如果我们查看 BigQuery SQL 工作空间,我们应该会看到我们的 Chapter9 数据集中的一个名为类似 clusters_15719257497877843494 的新表,如图 9-9 所示。

图 9-9。BigQuery 聚类结果表

选择 clusters_15719257497877843494 表,然后选择“预览”选项卡,我们可以查看结果。图 9-10 展示了前几行。

图 9-10。BigQuery 聚类结果预览

让我们考虑输出中的列:

  • cluster_id 给出了实体协调 API 分配给源实体的聚类的唯一引用。

  • source_name 列给出了源表的名称,在我们的情况下是 maribasic

  • source_key 列包含源表中行的 unique_id

  • confidence 分数介于 0 到 1 之间,表示记录与给定聚类相关联的强度。

  • assignment_age 列是一个内部 API 参考。

  • cloud_kg_mid 列包含 Google Cloud 企业知识图中实体的 MID 值链接,如果 API 能够解析出匹配。可以使用 Cloud 企业知识图 API 查找有关该实体的额外细节。

由于 maribasic 表中的每个实体都分配给一个聚类,因此此表的行数是源表行数的总和。在我们的情况下,这超过了 500 万行。乍一看,很难确定 API 匹配了哪些实体,因此我们需要稍微调整一下这些数据。

结果处理

有了我们的实体协调结果,我们就可以使用 BigQuery SQL 将这些原始信息处理成更容易让我们检查已解析实体的形式。

要开始,请点击“撰写新查询”,这将带我们进入一个 SQL 编辑器。您可以从Chapter9.sql文件中剪切并粘贴 SQL 模板。

首先,我们需要创建一个仅包含其 cluster_id 至少有一个 MCA 匹配的行的临时表。我们通过构建一个其行具有“mari”作为 source_name 的聚类表子集来实现这一点。然后,我们通过在匹配的 cluster_id 上使用 INNER JOIN 找到此子集的行与完整聚类表的行的交集。

确保用您的结果表的名称替换聚类表的名称,格式为 clusters_*<job reference>*

CREATE TEMP TABLE temp AS SELECT
   src.* FROM Chapter9.clusters_15719257497877843494 AS src
      INNER JOIN (SELECT cluster_id from
         Chapter9.clusters_15719257497877843494 WHERE 
             source_name = "mari") AS mari
         ON src.cluster_id = mari.cluster_id;

结果临时表现在只有 151 行。接下来,我们创建第二个临时表,这次是包含同时具有 MCA 匹配和至少一个 Companies House 匹配的聚类子集;即,我们删除了只有 MCA 匹配的聚类。

为了做到这一点,我们选择那些具有大于 1 的cluster_id,然后再次找到这个子集与第一个临时表的交集,使用匹配的cluster_id进行INNER JOIN

现在我们有一个包含只有在 Companies House 和 MCA 数据集中都找到实体的行的集群表:

CREATE TEMP TABLE match AS SELECT
   src.* FROM temp AS src
      INNER JOIN (SELECT cluster_id FROM temp GROUP BY cluster_id 
          HAVING COUNT(*) > 1) AS matches
      ON matches.cluster_id = src.cluster_id;

这个表现在有 106 行。我们已经得到了我们寻找的人口,所以我们可以创建一个持久的结果表,从源表中挑选CompanyNamePostcode,以便我们可以检查结果。

我们需要分两部分构建这张表。首先,对于指向 Companies House 数据的行,我们需要查找source_key列中的标识符,并使用它来检索相应的名称和邮政编码。然后,我们需要对指向 MCA 数据的行执行相同操作。我们使用UNION ALL语句将这两个数据集连接起来,然后按confidence首先,然后按cluster_id排序。这意味着分配到相同集群的实体在表中是相邻的,以便于查看:

CREATE TABLE Chapter9.results AS

   SELECT * FROM Chapter9.basic AS bas
   INNER JOIN (SELECT * FROM match WHERE match.source_name = "basic") AS res1
   ON res1.source_key = CAST(bas.unique_id AS STRING)

   UNION ALL

   SELECT * FROM Chapter9.mari AS mari
      INNER JOIN (SELECT * FROM match WHERE match.source_name = "mari") AS res2
      ON res2.source_key = CAST(mari.unique_id AS STRING)

ORDER BY confidence, cluster_id

这给了我们一个结果表,看起来像图 9-11 所示。

图 9-11. 处理后的结果表

在第一行中,我们可以看到 MCA 实体与CompanyName CREW AND CONCIERGE,Postcode BS31 1TP 和unique_id 18 都被分配给集群 r-03fxqun0t2rjxn。在第二行中,具有CompanyName CREW and CONCIERGE,相同的Postcodeunique_id 1182534 的 Companies House 实体也被分配到同一个集群中。

这意味着 Google 实体对账 API 已将这些记录分组到相同的集群中,即将这些行解析为同一个现实世界实体,并且置信度为 0.7。

在详细检查这些结果之前,我们将快速了解如何从 Python 调用 API 而不是从云控制台。

实体对账 Python 客户端

Google 企业知识图谱 API 还支持 Python 客户端来创建、取消和删除实体对账作业。我们可以使用 Cloud Shell 虚拟机来运行这些 Python 脚本并启动这些作业。

要激活 Google Cloud Shell,请点击控制台右上角的终端符号。这将打开一个带有命令行提示符的窗口。

包含用于调用实体对账作业的 Python 脚本已包含在仓库中。要将副本传输到您的 Cloud Shell 机器,我们可以使用:

>>>gsutil cp gs://<your_bucket>/handsonentityresolution/
   Chapter9.py .

弹出窗口将要求您授权 Cloud Shell 连接到您的存储桶。

脚本Chapter9.py如下所示。您可以使用 Cloud Shell 编辑器编辑此文件,以引用您的项目和存储桶:

#!/usr/bin/env python
# coding: utf-8

from google.cloud import enterpriseknowledgegraph as ekg

project_id = '<your_project>'
dataset_id = 'Chapter9'

import google.cloud.enterpriseknowledgegraph as ekg

client = ekg.EnterpriseKnowledgeGraphServiceClient()
parent = client.common_location_path(project=project_id, location='global')

input_config = ekg.InputConfig(
        bigquery_input_configs=[
            ekg.BigQueryInputConfig(
                bigquery_table=client.table_path(
                    project=project_id, dataset=dataset_id, table='mari'
                ),
                gcs_uri='gs://<your bucket>/
                  handsonentityresolution/Chapter9SchemaMari',
            ),
             ekg.BigQueryInputConfig(
                bigquery_table=client.table_path(
                    project=project_id, dataset=dataset_id, table='basic'
                ),
                gcs_uri='gs://<your bucket>/
                  handsonentityresolution/Chapter9SchemaBasic',
            )   
        ],
        entity_type=ekg.InputConfig.EntityType.ORGANIZATION,
    )

output_config = ekg.OutputConfig(
        bigquery_dataset=client.dataset_path(project=project_id, 
            dataset=dataset_id)
    )

entity_reconciliation_job = ekg.EntityReconciliationJob(
        input_config=input_config, output_config=output_config
)

request = ekg.CreateEntityReconciliationJobRequest(
        parent=parent, entity_reconciliation_job=entity_reconciliation_job
)

response = client.create_entity_reconciliation_job(request=request)

print(f"Job: {response.name}")

Cloud Shell 中已安装 Python,因此我们可以简单地从命令提示符运行此脚本:

>>>python Chapter9.py

要处理结果,我们可以使用我们之前检查过的 SQL 脚本。要从您的 Cloud Storage 存储桶复制这些:

>>>gsutil cp gs://<your_bucket>/handsonentityresolution/
    Chapter9.sql

然后我们使用以下 BigQuery 脚本运行:

>>>bq query --use_legacy_sql=false < Chapter9.sql

请注意,如果结果表已通过从 SQL 工作区运行此查询创建,则此命令将失败,因为表已经存在。您可以使用以下命令删除表:

>>>bq rm -f -t Chapter9.results

现在我们可以检查 API 在我们的示例上的表现。

测量性能

请参阅我们的 BigQuery 结果表预览中,我们有 106 行。匹配置信度的分布如表 9-2 所示。

表 9-2. 匹配置信度

匹配数 置信度
6 0.7
1 0.8
45 0.99
44 未找到匹配项

两个 MCA 实体与两个 Companies House 实体匹配。

回顾图 9-11,我们可以看到按置信度升序排列的前七个匹配项。您可以看到,尽管存在轻微的拼写差异或邮政编码变化,实体对账服务已能够匹配这些实体。其余的匹配项在CompanyNamePostcode上都是精确匹配,除了 INDIE PEARL 和 INDIE-PEARL 之间的连字符不匹配,但这并没有影响置信度分数。

如果我们假设独特的匹配是真正的正匹配,并且另外两个匹配是误报,则我们可以评估我们的表现如下:

T r u e p o s i t i v e m a t c h e s ( T P ) = 52

F a l s e p o s i t i v e m a t c h e s ( F P ) = 2

F a l s e n e g a t i v e m a t c h e s ( F N ) = 44

P r e c i s i o n = TP (TP+FP) = 52 (52+2) 96 %

R e c a l l = TP (TP+FN) = 52 (52+44) 54 . 2 %

所以实体对账为我们提供了极好的精确度,但相对较差的召回率。

摘要

在本章中,我们看到如何使用 Google Cloud 实体对账 API 解决我们的组织实体。我们还看到如何从云控制台和 Python 客户端配置和运行匹配作业。

使用 API 抽象我们远离了配置自己的匹配过程的复杂性。它也天然适用于非常大的数据集(数亿行)。但是,我们受到使用一组预定义模式的限制,并且没有自由调整匹配算法以优化我们用例的召回率/精确度权衡。

第十章:隐私保护记录链接

在前几章中,我们已经看到如何通过精确匹配和概率匹配技术解析实体,既使用本地计算又使用基于云的解决方案。这些匹配过程的第一步是将数据源汇集到一个平台上进行比较。当需要解析的数据源由一个共同的所有者拥有,或者可以完全共享以进行匹配,那么集中处理是最有效的方法。

然而,数据源往往可能是敏感的,隐私考虑可能阻止与另一方的无限制共享。本章考虑了如何使用隐私保护的记录链接技术,在两个独立持有数据源的各方之间执行基本的实体解析。特别是,我们将考虑私有集合交集作为识别双方已知实体的实际手段,而不会向任一方透露其完整数据集。

私有集合交集简介

私有集合交集(PSI)是一种加密技术,允许识别由两个不同方持有的重叠信息集合之间的交集,而不向任何一方透露非交集元素。

例如,如图 10-1 所示,Alice 拥有的集合 A 和 Bob 拥有的集合 B 的交集可以被确定为由元素 4 和 5 组成,而不会向 Alice 透露 Bob 对实体 6、7 或 8 的了解,或向 Bob 透露 Alice 对 1、2 或 3 的了解。

图 10-1. 私有集合交集

一旦确定了这个交集,我们可以结合 Alice 和 Bob 关于解析实体 4 和 5 的信息,以便更好地决定如何处理这些实体。这种技术通常在单一方向上应用,比如 Alice(作为客户端)和 Bob(作为服务器),Alice 了解交集元素,但 Bob 不了解 Alice 的数据集。

PSI 的示例用例

在隐私法域中的金融机构可能希望查看其客户是否与另一组织共享,而不透露其客户的身份。分享组织愿意透露他们共同拥有的个体,但不愿透露其完整的客户名单。

这是本章将要讨论的方法,其中信息集是由双方持有的实体列表,客户端试图确定服务器是否持有其集合中实体的信息,而在此过程中不会透露任何自己的实体。这听起来可能像是魔术,但请跟我一起来!

PSI 的工作原理

在服务器愿意与客户端共享其数据集的客户端/服务器设置中,客户端发现交集的最简单解决方案是让服务器向客户端发送其数据集的完整副本,然后客户端可以在私下执行匹配过程。客户端了解哪些匹配元素也由服务器持有,并且可以构建更完整的共同实体图片,而服务器则不知情。

在实践中,这种完全披露的方法通常是不可能的,要么是因为服务器数据集的大小超过了客户端设备的容量,要么是因为虽然服务器愿意透露与客户端共有的交集元素的存在和信息描述,但不愿意或不允许透露整个集合。

如果服务器无法完全与客户端共享数据,那么一个常见的解决方案通常被提议,通常称为朴素 PSI,即双方对其数据集中的每个元素应用相同的映射函数。然后服务器将其转换后的值与客户端共享,客户端可以将这些处理过的值与自己的相等值进行比较,以找到交集,然后使用匹配的客户端参考作为键查找相应的原始元素。密码哈希函数经常用于此目的。

密码哈希函数

密码哈希函数是一种哈希算法(将任意二进制字符串映射到固定大小的二进制字符串)。SHA-256 是一种常用的密码哈希函数,生成一个 256 位的值,称为摘要。

虽然高效,但基于哈希的这种方法潜在地可以被客户端利用来尝试发现完整的服务器数据集。一个可能的攻击是客户端准备一个包含原始值和转换值的综合表,将此全面集合与所有接收到的服务器值进行匹配,然后在表中查找原始值,从而重建完整的服务器数据集。当使用哈希函数执行映射时,这种预先计算的查找表被称为彩虹表。

因此,出于这个原因,我们将继续寻找更强的密码解决方案。多年来,已经采用了几种不同的密码技术来实现 PSI 解决方案。第一类算法使用公钥加密来保护交换,以便只有客户端能够解密匹配元素并发现交集。这种方法在客户端和服务器之间所需的带宽效率非常高,但计算交集的运行时间较长。

通用安全计算电路也已应用于 PSI 问题,无意识传输技术也是如此。最近,完全同态加密方案被提出,以实现近似和精确匹配。

对于本书的目的,我们将考虑由 Catherine Meadows 在 1986 年提出的原始公钥技术,使用 椭圆曲线 Diffie-Hellman(ECDH)协议。^(1) 我们不会深入探讨加密和解密过程的细节或数学。如果您想更详细地了解这个主题,我推荐阅读 Phillip J. Windley 的 Learning Digital Identity(O’Reilly)作为一个很好的入门书。

基于 ECDH 的 PSI 协议

基本的 PSI 协议工作方式如下:

  1. 客户端使用可交换的加密方案和自己的秘钥对数据元素进行加密。

  2. 客户端将它们的加密元素发送给服务器。这向服务器透露了客户端数据集中不同元素的数量,但不透露其他任何信息。

  3. 然后服务器进一步使用新的与此请求唯一的秘钥对客户端加密的值进行加密,并将这些值发送回客户端。

  4. 然后客户端利用加密方案的可交换属性,允许其解密从服务器接收的所有服务器元素,有效地去除其应用的原始加密,但保留服务器秘钥加密的元素。

  5. 服务器使用为此请求创建的相同方案和秘钥加密其数据集中的所有元素,并将加密值发送给客户端。

  6. 然后客户端可以比较在第 5 步接收到的完整的服务器加密元素与自己的集合成员,现在仅由第 4 步的服务器秘钥加密,以确定交集。

此协议显示在 Figure 10-2 中。

在其基本形式中,此协议意味着对于每个客户端查询,整个服务器数据集以加密形式发送给客户端。这些数据量可能是禁止的,无论是计算还是空间需求。但是,我们可以使用编码技术大幅减少我们需要交换的数据量,以较小的误报率为代价。我们将考虑两种技术:Bloom filters 和 Golomb-coded sets (GCSs)。提供了用于说明编码过程的简单示例 Chapter10GCSBloomExamples.ipynb

Figure 10-2. PSI 协议

布隆过滤器

Bloom filters 是一种能够非常高效地存储和确认数据元素是否存在于集合中的概率数据结构。一个空的 Bloom 过滤器是一个位数组,其位被初始化为 0。要向过滤器添加项目,需要通过多个哈希函数处理数据元素;每个哈希函数的输出映射到过滤器中的一个位位置,然后将该位置设置为 1。

要测试新数据元素是否在集合中,我们只需检查其哈希值所对应的位位置是否全部设为 1。如果是,则新元素可能已经存在于集合中。我说“可能”,因为这些位可能独立设置来表示其他值,导致假阳性。但可以确定的是,如果任何位不是设为 1,则我们的新元素不在集合中;即,没有假阴性。

假阳性的可能性取决于过滤器的长度、哈希函数的数量以及数据集中的元素数量。可以优化如下:

B l o o m f i l t e r l e n g t h ( b i t s ) = -max_elements×log 2 (fpr) 8×ln2 × 8

其中

f p r = f a l s e p o s i t i v e r a t e

m a x e l e m e n t s = max ( n u m c l i e n t i n p u t s , n u m s e r v e r _ i n p u t s )

N u m b e r h a s h f u n c t i o n s = - log 2 ( f p r )

使用布隆过滤器来编码并返回加密服务器值,而不是返回完整的原始加密值集合,使得客户端可以将这些集合中的元素应用相同的布隆编码过程来检查是否存在。

布隆过滤器示例

让我们逐步构建一个简单的布隆过滤器来说明这个过程。

假设我们逐步向长度为 32 位的布隆过滤器中使用 4 次哈希迭代添加十进制值 217、354 和 466。假设哈希迭代按照以下方式计算:

H a s h 1 = S H A 256 ( E n c r y p t e d v a l u e p r e f i x e d b y 1 ) % 32

H a s h 2 = S H A 256 ( E n c r y p t e d v a l u e p r e f i x e d b y 2 ) % 32

H a s h V a l u e = ( H a s h 1 + I t e r a t i o n n u m b e r × H a s h 2 ) % 32

然后我们逐步在表 10-1 中构建布隆过滤器。

表 10-1. 布隆过滤器示例

| 加密值 | 哈希迭代 | 哈希值(范围

0-31) | 布隆过滤器(位置 0–31,从右到左) |

--- --- --- ---
空过滤器 00000000000000000000000000000000
217 0 24 00000001000000000000000000000000
1 19 00000001000010000000000000000000
2 14 00000001000010000100000000000000
3 9 00000001000010000100001000000000
354 0 5 00000001000010000100001000100000
1 4 00000001000010000100001000110000
2 3 00000001000010000100001000111000
3 2 00000001000010000100001000111100
466 0 14 00000001000010000100001000111100
1 18 00000001000011000100001000111100
2 22 00000001010011000100001000111100
3 26 00000101010011000100001000111100
完成的过滤器 00000101010011000100001000111100

这里我们可以看到一种碰撞,即第三个值的第一个哈希迭代将位置 14 的位设置为 1,尽管它已经被第一个值的第三次迭代先前设置为 1。

类似地,如果对于新值的哈希迭代所对应的所有位位置已经设为 1,则会误以为元素已经存在于数据集中,而实际上并非如此。例如,如果我们想测试值十进制 14 是否在服务器数据集中,我们计算其哈希值如表 10-2 所示。

表 10-2. 布隆过滤器测试

| 测试值 | 哈希迭代 | 哈希值(范围

0–31) | 布隆过滤器(位置 0–31,从右到左) | 位检查 |

--- --- --- --- ---
布隆过滤器 00000101010011000100001000111100
14 0 22 00000000010000000000000000000000 True
1 2 00000000000000000000000000000100 True
2 14 00000000000000000100000000000000 True
3 26 00000100000000000000000000000000 True

从这个简单的例子中,我们错误地得出结论,即服务器数据中存在值 14,实际并非如此。显然,需要更长的布隆过滤器长度。

Golomb 编码集

Golomb 编码集(GCS),像布隆过滤器一样,是一种能够提供数据集中元素存在更高效方式的概率性数据结构。为了构建数据集的 GCS 表示,我们首先将原始数据元素哈希成一组在设定范围内的哈希值。

哈希范围计算如下:

Hashrange=max_elements fpr

与以前一样:

f p r = f a l s e p o s i t i v e r a t e

m a x e l e m e n t s = max ( n u m c l i e n t i n p u t s , n u m s e r v e r _ i n p u t s )

然后,我们按升序排序这些哈希数值并计算代表几何值范围的除数。如果选择的除数是 2 的幂,则称此变体为 Rice 编码,并可从升序列表计算如下:

G C S d i v i s o r p o w e r o f 2 = max ( 0 , r o u n d ( - l o g 2 ( - l o g 2 ( 1 . 0 - p r o b ) ) )

其中

p r o b = 1 avg

a v g = (lastelementinascendinglist+1) numberelementsinascendinglist

接下来,我们计算连续值之间的差异,移除任何值为 0 的差异,并将这些增量哈希值除以先前计算的 GCS 除数的 2 次方。这种除法产生商和余数。为了完成编码,我们使用一元编码表示商,使用二进制表示余数,并使用 0 填充到最大长度。

每个元素都以这种方式编码,并将位连接在一起形成 GCS 结构。要在结构中检查给定元素,我们通过位扫描来逐个重建每个元素,从而逐步累加我们获取的差值,以重建我们可以与测试值哈希进行比较的原始哈希值。

与布隆过滤器一样,由于哈希碰撞的可能性,存在误报的可能性,其概率取决于哈希范围的大小和要编码的元素数量。再次强调,不存在误报。

让我们考虑一个简短的例子。

GCS 示例

从相同的加密数值 217、354 和 466 以及十进制 128 的哈希范围开始。我们计算这些数值的 SHA256 哈希(作为字节),然后除以哈希范围以获得介于 0 和 127 之间的余数。这给出了在表 10-4 中显示的数值。

表 10-4. GCS 哈希值计算

加密数值 加密数值的 SHA256 哈希(十六进制) 哈希值范围 0-127
217 16badfc6202cb3f8889e0f2779b19218af4cbb736e56acadce8148aba9a7a9f8 120
354 09a1b036b82baba3177d83c27c1f7d0beacaac6de1c5fdcc9680c49f638c5fb9 57
466 826e27285307a923759de350de081d6218a04f4cff82b20c5ddaa8c60138c066 102

将减少范围哈希值按升序排序,我们有 57、102 和 120。因此,增量值为 57(57–0)、45(102–57)和 18(120–102)。

我们计算的除数幂为:

a v g = 120+1 3 40 . 33

p r o b = 1 40.33 0 . 02479

G C S d i v i s o r p o w e r o f 2 = max ( 0 , r o u n d ( - l o g 2 ( - l o g 2 ( 1 . 0 - 0 . 02479 ) ) ) = 5

使用除数参数 32(2 5),我们可以将这些值编码如表格 10-5 所示。

表格 10-5. GCS 二进制和一进制编码

| Delta hash value

范围 0–127 | 商(/32) | 余数(%32) | 余数(二进制 5 位) | 一元商(从右到左带 0 的) | GCS 编码 |

--- --- --- --- --- ---
57 1 25 11001 10 1100110
45 1 13 01101 10 0110110
18 0 18 10010 1 100101

最后到第一个,从左到右,集合编码为:10010101101101100110。

示例:使用 PSI 过程

现在我们理解了基本的 PSI 过程,让我们将其应用到识别英国 MCA 列表和 Companies House 注册中存在的公司的挑战上。如果我们将 MCA 视为客户方,Companies House 视为服务器方,那么我们可以研究如何找到那些 MCA 公司在 Companies House 注册中存在而不向 Companies House 透露 MCA 列表的内容。

请注意,此示例仅供说明目的。

环境设置

由于 PSI 过程需要大量计算资源,我们将使用 Google Cloud 暂时提供基础设施来运行这个例子。

在第六章中,我们对 MCA 和 Companies House 注册数据集进行了标准化,并将它们保存为经过标准化和清理的 CSV 文件。在第七章中,我们将这些文件上传到了 Google Cloud Storage 存储桶中。在本章中,我们将假设这些数据集由两个独立的方当中持有。

我们将把这些文件传输到一个单一的数据科学工作台实例上,在该实例上我们将运行服务器和客户端以演示交集过程。这个例子可以轻松扩展到在两台不同的机器上运行,以展示服务器和客户端角色的分离以及数据的分离。

Google Cloud 设置

要开始,我们在 Google Cloud 控制台的 AI 平台菜单中选择工作台。为了创建环境,我们选择用户管理的笔记本(而不是托管笔记本),因为这个选项将允许我们安装我们需要的包。

第一步是选择创建新的。从这里,我们可以将笔记本重命名为我们选择的名称。在环境部分,选择基本的 Python3 选项,然后点击创建。如同第七章中一样,如果您希望或接受默认设置,可以更改区域和区域设置。如果(可选)选择“IAM 和安全性”,您将注意到将授予虚拟机的根访问权限。

成本

请注意,一旦创建了新的 Workbench 环境,您将开始产生费用,无论实例是否正在运行,磁盘空间成本都会产生费用,即使实例停止时也是如此。默认情况下,Workbench 实例会创建 2 个 100 GB 的磁盘!

您有责任确保停止或删除实例,以避免产生意外费用。

创建了您的实例后,您将能够点击“打开 JupyterLab”以打开一个本地窗口,访问托管在新的 GCP Workbench 上的 JupyterLab 环境。^(2) 从这里,我们可以在“其他”下选择“终端”以打开终端窗口来配置我们的环境。

我们将要使用的 PSI 包由 OpenMined 社区发布和分发。

OpenMined

OpenMined 是一个开源社区,旨在通过降低进入私人 AI 技术的门槛来使世界更加隐私保护。他们的 PSI 存储库提供了基于 ECDH 和 Bloom 过滤器的私人集交集基数协议。

在撰写本文时,OpenMined PSI 包可以在线上获取。从该网站我们可以下载与 Google Cloud Workbench 兼容的预构建发行版(当前是运行 Debian 11 操作系统的 x86 64 位虚拟机),我们可以方便地安装(选项 1)。或者,如果您更喜欢使用不同的环境或自行构建该包,则可以选择(选项 2)。

选项 1:预构建 PSI 包

创建一个 PSI 目录并切换到该位置:

>>>mkdir psi

>>>cd psi

复制兼容 Python 发行版的链接地址,并使用 wget 进行下载。目前的链接是:

>>>wget https://files.pythonhosted.org/packages/2b/ac/
   a62c753f91139597b2baf6fb3207d29bd98a6cf01da918660c8d58a756e8/
   openmined.psi-2.0.1-cp310-cp310-manylinux_2_31_x86_64.whl

安装该软件包如下:

>>>pip install openmined.psi-2.0.1-cp310-cp310-
    manylinux_2_31_x86_64.whl

选项 2:构建 PSI 包

在终端提示符下,我们克隆 OpenMined psi 包的存储库:

>>>git clone http://github.com/openmined/psi

然后切换到 psi 目录:

>>>cd psi

要从存储库源代码构建 psi 包,我们需要安装适当版本的构建包 Bazel。使用 wget 获取 GitHub 存储库中适当的预构建 Debian 发行版软件包:

>>>wget https://github.com/bazelbuild/bazel/releases/download/
    6.0.0/bazel_6.0.0-linux-x86_64.deb

以 root 身份安装此软件包:

>>>sudo dpkg -i *.deb

接下来,我们使用 Bazel 构建 Python 发行版,即一个 wheel 文件,具备必要的依赖项。这一步可能需要几分钟:

>>>bazel build -c opt //private_set_intersection/python:wheel

一旦我们构建了 wheel 归档文件,我们可以使用 OpenMined 提供的 Python 工具将文件重命名以反映它支持的环境:

>>>python ./private_set_intersection/python/rename.py

重命名实用程序将输出重命名文件的路径和名称。我们现在需要从提供的路径安装这个新命名的包,例如:

>>>pip install ./bazel-bin/private_set_intersection/python/
   openmined.psi-2.0.1-cp310-cp310-manylinux_2_31_x86_64.whl

再次强调,此安装可能需要几分钟,但一旦完成,我们就拥有了执行样本问题数据上 PSI 所需的基本组件。

服务器安装

一旦我们安装了 psi 包,我们还需要一个基本的客户端/服务器框架来处理匹配请求。为此,我们使用 Flask 轻量级微框架,可以使用 pip 安装:

>>>pip install flask

安装完成后,我们可以从 psi 目录上级导航,以便复制我们的示例文件:

>>>cd ..

>>>gsutil cp gs://<your bucket>/<your path>/Chapter10* . 
>>>gsutil cp gs://<your bucket>/<your path>/mari_clean.csv .
>>>gsutil cp gs://<your bucket>/<your path>/basic_clean.csv .

要启动 flask 服务器并运行Chapter10Server Python 脚本,我们可以在终端选项卡提示符中使用以下命令:

>>>flask --app Chapter10Server run --host 0.0.0.0

服务器启动需要一些时间,因为它正在读取 Companies House 数据集并将实体组装成一系列连接的CompanyNamePostcode字符串。

一旦准备好处理请求,它将在命令提示符下显示以下内容:

* Serving Flask app 'Chapter10Server'
...
* Running on http://127.0.0.1:5000
PRESS CTRL+C to quit

服务器代码

让我们通过打开 Python 文件Chapter10Server.py来查看服务器代码:

import private_set_intersection.python as psi
from flask import Flask, request
from pandas import read_csv

fpr = 0.01
num_client_inputs = 100

df_m = read_csv('basic_clean.csv',keep_default_na=False)
server_items = ['ABLY RESOURCES G2 1PB','ADVANCE GLOBAL RECRUITMENT EH7 4HG']
#server_items = (df_m['CompanyName']+' '+ df_m['Postcode']).to_list()

app = Flask(__name__)

我们首先导入安装的 PSI 包,然后是我们需要的flaskpandas函数。

接下来,我们设置所需的误报率(fpr)和每个请求中将检查的客户端输入数。这些参数一起用于计算在 GCS 编码中使用的 Bloom 过滤器和哈希范围的长度。

然后,我们读取我们之前从云存储桶传输的经过清理的 Companies House 记录,指定忽略空值。然后,我们通过连接每个CompanyNamePostcode值(用空格分隔)来创建服务器项目列表。这使我们能够检查每个实体的精确名称和邮政编码匹配。

为了允许我们详细检查编码协议,我从 MCA 列表中选择了两个实体,并手动创建了它们的经过清理的名称和邮政编码字符串,作为服务器项目的另一组替代集。*要使用完整的 Companies House 数据集,只需从列表创建语句的注释标记(前导#)中删除以覆盖server_items

#server_items = (df_m['CompanyName']+' '+ df_m['Postcode']).to_list()

服务器文件的其余部分定义了一个保存服务器密钥的类,然后创建了key对象:

class psikey(object):
   def __init__(self):
      self.key = None
   def set_key(self, newkey):
      self.key = newkey
      return self.key
   def get_key(self):
      return self.key

pkey = psikey()

Flask Web 应用程序允许我们响应 GET 和 POST 请求。

服务器响应/match路径的 POST 请求时,会创建新的服务器密钥和一个psirequest对象。然后我们解析 POST 请求中的数据,使用新密钥处理(即加密)接收到的数据,然后在返回给客户端之前对这些处理后的值进行序列化。

@app.route('/match', methods=['POST'])
def match():
   s = pkey.set_key(psi.server.CreateWithNewKey(True))
   psirequest = psi.Request()
   psirequest.ParseFromString(request.data)
   return s.ProcessRequest(psirequest).SerializeToString()

处理完匹配请求后,服务器可以响应客户端对不同编码方案的 GET 请求:原始加密值、Bloom 过滤器和 GCS。在每种情况下,我们重用在匹配请求期间创建的密钥,并提供所需的误报率和每个客户端请求中的项目数,以便我们可以配置 Bloom 和 GCS 选项。

@app.route('/gcssetup', methods=['GET'])
def gcssetup():
   s = pkey.get_key()
   return s.CreateSetupMessage(fpr, num_client_inputs, server_items,
      psi.DataStructure.GCS).SerializeToString()

@app.route('/rawsetup', methods=['GET'])
def rawsetup():
   s = pkey.get_key()
   return s.CreateSetupMessage(fpr, num_client_inputs, server_items,
      psi.DataStructure.RAW).SerializeToString()

@app.route('/bloomsetup', methods=['GET'])
def bloomsetup():
   s = pkey.get_key()
   return s.CreateSetupMessage(fpr, num_client_inputs, server_items,
      psi.DataStructure.BLOOM_FILTER).SerializeToString()

客户端代码

包含客户端代码的笔记本是Chapter10Client.ipynb,开始如下:

import requests
import private_set_intersection.python as psi
from pandas import read_csv

url="http://localhost:5000/"

与服务器设置一样,我们读取经过清理的 MCA 公司详细信息,创建客户端密钥,加密,然后序列化以传输到服务器。

df_m = read_csv('mari_clean.csv')
client_items = (df_m['CompanyName']+' '+df_m['Postcode']).to_list()
c = psi.client.CreateWithNewKey(True)
psirequest = c.CreateRequest(client_items).SerializeToString()

c.CreateRequest(client_items)

在序列化之前,psirequest的前几行如下所示:

reveal_intersection: true
encrypted_elements: 
    "\002r\022JjD\303\210*\354\027\267aRId\2522\213\304\250%\005J\224\222m\354\
    207`\2136\306"
encrypted_elements: 
    "\002\005\352\245r\343n\325\277\026\026\355V\007P\260\313b\377\016\000{\336\
    343\033&\217o\210\263\255[\350"

我们将序列化的加密值作为消息内容包含在 POST 请求中,请求的路径为/match,并在头部指示我们传递的内容是一个 protobuf 结构。然后,服务器响应包含客户端加密值的服务器加密版本,并被解析为response对象:

response = requests.post(url+'match',
    headers={'Content-Type': 'application/protobuf'}, data=psirequest)
psiresponse = psi.Response()
psiresponse.ParseFromString(response.content)
psiresponse

使用原始加密服务器值

要检索原始加密的服务器值,客户端发送请求到/rawsetup URL 路径:

setupresponse = requests.get(url+'rawsetup')
rawsetup = psi.ServerSetup()
rawsetup.ParseFromString(setupresponse.content)
rawsetup

如果我们选择仅在服务器设置文件中使用两个测试条目,则可以预期设置响应中仅有两个加密元素。值将取决于服务器密钥,但结构将类似于此:

raw {
  encrypted_elements: 
      "\003>W.x+\354\310\246\302z\341\364%\255\202\354\021n\t\211\037\221\255\
      263\006\305NU\345.\243@"
  encrypted_elements: 
      "\003\304Q\373\224.\0348\025\3452\323\024\317l~\220\020\311A\257\002\
      014J0?\274$\031`N\035\277"
}

然后,我们可以计算rawsetup结构中的服务器值和psiresponse结构中的客户端值的交集:

intersection = c.GetIntersection(gcssetup, psiresponse)
#intersection = c.GetIntersection(bloomsetup, psiresponse)
#intersection = c.GetIntersection(rawsetup, psiresponse)

iset = set(intersection)
sorted(intersection)

这给我们匹配实体的列表索引,在这个简单的情况中:

[1, 2]

然后我们可以查找相应的客户端实体:

for index in sorted(intersection):
   print(client_items[index])

ABLY RESOURCES G2 1PB
ADVANCE GLOBAL RECRUITMENT EH7 4HG

成功!我们已在客户端和服务器记录之间解析了这些实体,确切匹配CompanyNamePostcode属性,而不向服务器透露客户端项目。

使用布隆过滤器编码的加密服务器值

现在让我们看看如何使用布隆过滤器对编码的服务器加密值进行编码:

setupresponse = requests.get(url+'bloomsetup')
bloomsetup = psi.ServerSetup()
bloomsetup.ParseFromString(setupresponse.content)
bloomsetup

如果我们通过/bloomsetup路径提交请求,我们会得到一个类似的输出:

bloom_filter {
  num_hash_functions: 14
  bits: "\000\000\000\000 ...\000"
}

服务器根据布隆过滤器部分中的公式计算过滤器中的位数。我们可以重新创建如下:

from math import ceil, log, log2

fpr = 0.01
num_client_inputs = 10

correctedfpr = fpr / num_client_inputs
len_server_items = 2

max_elements = max(num_client_inputs, len_server_items)
num_bits = (ceil(-max_elements * log2(correctedfpr) / log(2) /8))* 8

错误阳性率设置为每次查询 100 个客户端项目的 100 中的 1,因此总体(修正后)的 fpr 为 0.0001。对于我们非常基本的示例,max_elements也等于 100。这给我们一个布隆过滤器位长度为 1920:

num_hash_functions = ceil(-log2(correctedfpr))

这给我们 14 个哈希函数。

我们可以通过处理原始加密服务器元素来复现布隆过滤器:

from hashlib import sha256

#num_bits = len(bloomsetup.bloom_filter.bits)*8
filterlist = ['0'] * num_bits
for element in rawsetup.raw.encrypted_elements:
   element1 = str.encode('1') + element
   k = sha256(element1).hexdigest()
   h1 = int(k,16) % num_bits

   element2 = str.encode('2') + element
   k = sha256(element2).hexdigest()
   h2 = int(k,16) % num_bits

  for i in range(bloomsetup.bloom_filter.num_hash_functions):
      pos = ((h1 + i * h2) % num_bits)
      filterlist[num_bits-1-pos]='1'

filterstring = ''.join(filterlist)

然后,当以相同顺序组装并转换为字符串时,我们可以将我们的过滤器与服务器返回的过滤器比较:

bloombits = ''.join(format(byte, '08b') for byte in
   reversed(bloomsetup.bloom_filter.bits))
bloombits == filterstring

使用 GCS 编码的加密服务器值

最后,让我们看看如何使用 GCS 对编码的服务器加密值进行编码。

setupresponse = requests.get(url+'gcssetup')
gcssetup =
   psi.ServerSetup()gcssetup.ParseFromString(setupresponse.content)

如果我们通过/gcssetup路径提交请求,我们会得到一个类似的输出:

gcs {
  div: 17
  hash_range: 1000000
  bits: ")![Q\026"
}

要复现这些值,我们可以应用上述 PSI 部分中的公式:

from math import ceil, log, log2

fpr = 0.01
num_client_inputs = 100
correctedfpr = fpr/num_client_inputs

hash_range = max_elements/correctedfpr
hash_range

这给我们 1000000 的哈希范围。

与布隆过滤器类似,我们可以通过处理原始加密服务器元素来复现 GCS 结构。首先,我们将原始加密服务器值哈希到gcs_hash_range中,按升序排序,并计算差值:

from hashlib import sha256

ulist = []
for element in rawsetup.raw.encrypted_elements:
   k = sha256(element).hexdigest()
   ks = int(k,16) % gcssetup.gcs.hash_range
   ulist.append(ks)

ulist.sort()
udiff = [ulist[0]] + [ulist[n]-ulist[n-1]
   for n in range(1,len(ulist))]

现在我们可以计算 GCS 除数如下:

avg = (ulist[-1]+1)/len(ulist)
prob = 1/avg
gcsdiv = max(0,round(-log2(-log2(1.0-prob))))

这给我们一个除数为 17,然后我们可以使用它来计算商和余数,分别在一元和二元中编码这些位模式。我们将这些位模式连接在一起:

encoded = ''
for diff in udiff:
   if diff != 0:
      quot = int(diff / pow(2,gcssetup.gcs.div))
      rem = diff % pow(2,gcssetup.gcs.div)

      next = '{0:b}'.format(rem) + '1' + ('0' * quot)
      pad = next.zfill(quot+gcssetup.gcs.div+1)
      encoded = pad + encoded

最后,我们填充编码字符串,使其成为 8 的倍数,以便与返回的 GCS 比特匹配:

from math import ceil
padlength = ceil(len(encoded)/8)*8
padded = encoded.zfill(padlength)

gcsbits = ''.join(format(byte, '08b') for byte in
   reversed(gcssetup.gcs.bits))
gcsbits == padded

完整的 MCA 和 Companies House 样本示例

现在,我们已经看到了使用仅包含两个项目的微小服务器数据集进行端到端 PSI 实体匹配过程的结尾,我们准备使用完整的 Companies House 数据集。

打开Chapter10Server.py文件并取消注释:

#server_items = (df_m['CompanyName']+' '+
   df_m['Postcode']).to_list()

然后停止(Ctrl+C 或 Cmd-C)并重新启动 Flask 服务器:

>>>flask --app Chapter10Server run --host 0.0.0.0

现在我们可以重新启动客户端内核并重新运行笔记本,以获取 MCA 和 Companies House 数据的完整交集,解析CompanyNamePostcode上的实体。

我们可以请求原始、Bloom 或 GCS 响应。请允许服务器大约 10 分钟来处理并返回。我建议您跳过重现 Bloom/GCS 结构的步骤,因为这可能需要很长时间

跳转计算交集然后给出我们 45 个精确匹配项:

ADVANCE GLOBAL RECRUITMENT EH7 4HG
ADVANCED RESOURCE MANAGERS PO6 4PR
...

WORLDWIDE RECRUITMENT SOLUTIONS WA15 8AB

整理

记得停止并删除您的用户管理笔记本和任何关联磁盘,以避免持续收费!

本 PSI 示例展示了如何在两个当事方之间解析实体,即使其中一方无法与另一方共享其数据。在这个基本示例中,我们能够仅查找两个属性的同时精确匹配。

在某些情况下,精确匹配可能足够。但是,当需要近似匹配时,并且至少有一方准备分享部分匹配时,我们需要一种更复杂的方法,这超出了本书的范围。目前正在研究使用全同态加密实现隐私保护模糊匹配的实用性,这将为该技术开辟更广泛的潜在用例领域。

摘要

在本章中,我们学习了如何使用私有集合交集来解析两个当事方之间的实体,而双方都不需透露其完整数据集。我们看到了如何使用压缩数据表示来减少需要在两个当事方之间传递的数据量,尽管会引入少量误报。

我们注意到,这些技术可以轻松应用于精确匹配场景,但更高级的近似或概率匹配仍然是一个挑战,也是积极研究的课题。

^(1) Meadows, Catherine,“用于在没有连续可用第三方的情况下使用的更有效的加密匹配协议”,1986 IEEE 安全与隐私研讨会,美国加利福尼亚州奥克兰,1986 年,第 134 页,https://doi.org/10.1109/SP.1986.10022

^(2) 您可能需要在本地浏览器上允许弹出窗口。

^(3) 请参阅专利“使用全同态加密方案进行紧凑模糊私有匹配”,https://patents.google.com/patent/US20160119119

第十一章:进一步考虑

希望前几章已经为你提供了如何在数据集中解析实体的实际理解,并为你在解决过程中可能遇到的一些挑战做好了准备。

现实世界的数据杂乱无章,充满了意外,因此将其连接起来往往并不简单。但是,花费时间进行连接是非常值得的,因为当我们能够将所有的拼图片段组合在一起时,故事变得更加丰富。

在这个简短的结尾章节中,我将谈谈在构建弹性生产解决方案时值得考虑的实体解析的一些方面。我还将分享一些关于艺术和科学的未来的总结思考。

数据考虑

与任何分析过程一样,理解输入数据的背景和质量的重要性不可高估。传统应用程序可以容忍的数据怪癖或误解可能会从根本上破坏匹配过程。低质量数据可能导致超链接和低链接,有时会匹配不同的实体,这可能会产生严重后果。

在本节中,我将讨论在执行实体解析时需要考虑的最重要的与数据相关的问题。

非结构化数据

在本书中,我们主要使用结构化数据来执行匹配过程。当我们遇到半结构化数据时,我们使用非常简单的经验法则来提取所需的属性。例如,在第二章中,我们将全名字符串有些随意地拆分为FirstnameLastname,而在第六章中,我们仅从完整地址文本中提取了邮政编码。为了简单起见,我们忽略了可能丰富我们匹配过程的有价值数据。

幸运的是,在过去几年中,从非结构化文本中提取意义的最新技术已经有了显著发展。对于理解句子构造和上下文中提取实体的命名实体识别(NER)技术的进步意味着我们可以更轻松地链接到非结构化内容。

例如,现有几个 Python 库(如 usaddress,deepparse 和 libpostal)可以解析地址,提取单独的门牌号码、街道和城镇属性。这些模型的性能取决于高质量训练数据的可用性,因此在不同国家之间有所不同。

然而,即使是最复杂的 NER 也无法弥补源文本中缺少关键属性的情况。例如,新闻文章很少会为其主题提供出生日期,而金融交易通常不会包括社会安全号码。

数据质量

在我们的示例中,我们基本上接受了我们输入数据的大部分,并采取了便捷的快捷方式来准备我们的数据进行匹配。例如,作为一种捷径,我们简单地删除了包含缺失值属性的记录。我们的处理过程应能够忽略(即对该属性分配零匹配权重)而不是丢弃整个记录。对于生产解决方案,对测量和持续改进数据质量更严格的方法至关重要。数据质量越好,匹配任务就越容易。

附加的数据完整性和有效性检查(包括识别隐藏和意外字符)将提醒您可能以意想不到的方式阻碍匹配过程的问题,并且在后续诊断中具有挑战性。

时间等价性

实体解析过程依赖于匹配属性来确定记录是否指向同一现实世界的实体。然而,与实体相关联的属性可能会随时间改变。姓氏可能随婚姻状况改变;电话号码和电子邮件地址可能因个人更换服务提供商而变化;护照、驾驶执照和其他形式的身份证件会随新标识重新发行。

这听起来很明显,但实体解析的这一时间方面经常被忽视,因此我的建议是要小心处理包含来自不同时间段数据的数据集,并确保模型不会过分依赖可能会发生变化的属性。当然,在实体试图不被识别时,频繁的属性更改可能表明有意企图阻碍实体解析过程。

属性比较

在第三章中,我们探讨了一些用于近似字符串匹配的常用技术。我们考虑了编辑距离和音形等价性来确定离散名称属性之间的匹配。当我们需要评估两个具有多个单词或标记的字符串(如地址或组织名称)之间的相似性时,我们可以考虑其他技术。

集合匹配

当一个实体由一组术语标识时,我们可以使用基于集合的方法(如 Jaccard 指数^(1))来衡量每个集合中存在的标记之间的重叠程度。更复杂的方法,如 Monge-Elkan 相似度,结合了基于集合和编辑距离技术来执行比较。

最近在句子嵌入[²]方面取得的进展现在允许我们将文本字符串的语义意义转化为一个向量(多维度的量化数组)。这些向量化模型在大量开源数据库的训练中得以实现,并通过公共接口(例如 OpenAI 的嵌入 API)进行访问。这些文本字符串的语义相似性可以通过诸如余弦相似度之类的技术来评估,这种技术衡量的是向量之间的夹角。

基于向量的方法也可以应用于衡量单词之间的相似性(表示为单个字符或多字符 n-gram 的字符串),但通常不考虑这些字母的顺序,而这在匹配中可能非常重要,例如,NAB(澳大利亚银行)与 NBA(美国篮球组织)的区别。

地理编码位置匹配

与匹配组成地址的个别单词或标记不同的替代方法是将地址转换为一组地理坐标(纬度和经度)。然后,我们可以在一组直线距离容差内比较这些值,以确定它们是否指向同一位置。显然,对于在紧邻(例如共享建筑物或工业园区内)的多占用位置,这种方法可能会产生一些误报。

在撰写本文时,Google、Microsoft 和 OpenStreetMap(通过 Nominatim)提供地理编码 API,可以执行转换,但受价格和使用政策的限制。作为按需软件即服务(SaaS)提供的方法,这种方法可能不适用于大量地址比较或数据敏感且不能与第三方共享的情况。

聚合比较

正如我们所见,通常有几种不同的技术可以用来比较属性,每种技术都有其优势和劣势。在某些使用案例中,评估潜在匹配时可能有益的是使用多种方法,例如同时使用 Soundex 比较和编辑距离测量,以确定最合适的结果。

值得注意的是,如果在相同的属性上使用并行技术,则结果将不符合 Fellegi-Sunter 模型的条件独立性假设,因此在使用诸如 Splink 之类的概率工具时可能表现不佳。特别是,应该在单个比较的不同比较层次中包含不同的测量,以避免重复计数。或者,这些不同的测量可以预先聚合到一个单一的分数中,使用自定义的相似性函数。

后处理

在第十章中,我们看到了如何将成对记录分组为单个明确的集群。我们还介绍了确定用于描述我们统一实体的哪些属性值的挑战。选择逻辑以选择要推广的属性值可能是定制的,适用于您的用例,并且可能取决于数据集的相对可信度和高级性。

一旦建立了规范实体视图,就有机会重复进行成对匹配的练习,将新整合的实体视为新记录。由于新复合实体中属性的集中,之前无法匹配的额外记录现在可能会达到等价阈值。

例如,考虑表格 11-1 中显示的输入记录。根据相同的名字和出生日期,记录 1 和 2 可能被认为指的是同一个人,但是记录 3 与任何一条记录都没有足够的共性来加入那个小集群。

表格 11-1. 实体解析—输入

属性 记录 1 ** 记录 2** 记录 3
迈克尔 迈克尔 M
Shearer Shear Shearer
出生日期 4/1/1970 4/1/1970
出生地 斯托·瓦尔德 斯托·瓦尔德
手机号码 07700 900999

解析了记录 1 和 2 成为单个实体后,假设我们选择“Shearer”而不是“Shear”来表示姓。也许记录 1 是被认为比记录 2 的数据集质量更高的数据集的一部分。或者也许我们实施了一个规则来选择更完整的值。正如表格 11-2 中所示,我们将有一个更丰富的属性集来与记录 3 进行匹配。

表格 11-2. 实体解析—成对聚类

属性 集群 1 记录 1 和 2 记录 3
迈克尔 M
Shearer Shearer
出生日期 4/1/1970
出生地 斯托·瓦尔德 斯托·瓦尔德
手机号码 07700 900999

如果认为确切的姓氏匹配和等效的出生地足够作为证据,那么我们可以得出结论,记录 3 现在应该加入记录 1 和 2 的集群。

如表格 11-3 所示,我们现在已将所有三条记录解析为单个实体,并且由于我们的额外步骤,添加了一部我们否则不会与我们的实体关联的电话号码。

表格 11-3. 实体解析—实体记录解析

属性 集群 1 记录 1, 2, 和 3
迈克尔
Shearer
出生日期 4/1/1970
出生地 斯托·瓦尔德
手机号码 07700 900999

这展示了我们如何逐步建立信心,将这些记录连接成一个单一的聚类实体。这是一个“自下而上”或聚合层次聚类的例子。在这个简单的例子中,我们详尽地连接了所有三条记录,但在更大的数据集中,可能有更多的候选记录可以在后续迭代中比较和可能聚类在一起。

在第八章中,我们看到了 Google Cloud 实体协调服务如何使用这种技术。Google 服务指定了一定数量的迭代次数后终止聚类过程。显然,这种方法在处理大数据集时可能计算密集,并不能保证找到最优解。

图形表示

在属性比较和逐对匹配分类之后,实体解析过程中的最后步骤与图分析领域有显著重叠。

正如我们在第十章中看到的,聚类过程的输出可以呈现为源记录(节点)和一组匹配属性对(边)的实体图。这种表示形式可以作为更广泛的网络图的一部分,展示不同实体通过共享关系或共同属性如何连接。这种表示形式有助于允许根据其更广泛网络的背景检查(和可能折扣)匹配。

或者,如果匹配的信心很高,或者需要一个更简单的表示,实体图可以解析(或折叠)成一个单一的节点。该节点可以列出一个规范的属性集,或者保留备选属性值供进一步检查。这个策划网络或知识图提供了从不同来源汇总的给定实体的所有信息的综合视图。

您可能已经注意到,这在某种程度上是如何使得 Google 搜索今天工作的一部分。搜索结果现在呈现了来自Google 的知识图的事实信息,该图包含超过 5 亿个对象,以及关于这些不同对象的 35 亿多个事实和关系。正如我们在第八章中看到的,现在您可以使用其协调 API 将您的实体与 Google 的对象解析对比。

实时考虑因素

在本书中,我们考虑了静态数据集的基于批处理的实体解析。这种即时方法允许我们比较、匹配和聚类所有相关记录,以生成一组解析的实体。这个参考点可以在一段时间内使用,然后变得过时,需要重复这个过程。在第六章中,我们看到如何使用预训练的概率模型逐对匹配新记录与现有数据集。

如果需要一个最新的所有解析实体集合,那么随着新记录的增加逐步处理这些记录会带来一些额外的考虑。根据可用的处理时间窗口,可能很难重新聚类并根据新可用记录的内容生成新的规范实体,或将现有实体重新组织成新配置。

性能评估

当记录在它们真正不同的时候匹配程度来评估实体解析解决方案的功能性能(过链接)或者当它们指向相同的真实世界实体时保持不连接(欠链接)。基于解析数据集提出的决策和行动的性质将决定这些指标的相对优先级。例如,在高风险情况下,错过一个链接的后果可能是显著的,因此你可能希望向过链接倾斜。在更加推测性的过程中,您可能希望倾向于欠链接,以减少不必要的客户摩擦。

系统评估您的解决方案过度或欠链接的程度是具有挑战性的。在本书的早期章节中,我们有一个已知的人口群体,可以评估我们处理的过程的精确度、召回率和准确性。但实际上很少有这种情况。解析实体的需要通常是由于数据集之间缺乏共同键或已知人口而引起的,因此剥夺了评估者针对性能进行测量的地面真实性。

较小的基准数据集通常可以通过手工链接,以预测大规模的性能。然而,这些有限的数据集可能会导致对可能的真实世界结果的扭曲看法。较大的数据集更有可能包含具有相似属性的不同实体(例如相同的名称),增加误报的可能性。还必须注意确保评估过程中基准数据集的分布情况。在构建用于检查匹配过程找到正确匹配项的基准数据集中,匹配与非匹配记录的比率通常显著较高(即最大化召回率),但过高估计精确度的频率(即过高估计精度)。特别是对于更复杂的基于嵌入的方法,存在过拟合实体解析模型的风险,因为基准数据在训练数据中表示,导致泛化性能差。

评估实体解析解决方案的性能是模型开发和改进的关键部分。它需要标记数据,然后可以用来训练更复杂的模型或估算性能指标,如精确度和召回率。

在实体解析应用中,数据标记和性能评估有两种主要方法类型:

两两比较法

标记一组记录对为匹配和非匹配

基于集群的方法

识别或使用已知的完全解析的实体(集群)

两两比较法

使用两两比较法,我们可以估算精确度,即在声明匹配时我们正确的频率,只需抽样匹配的记录对并手动审查。一旦分类为真正例或假正例,我们可以如下计算精确度:

P r e c i s i o n = Truepositives (Truepositives+Falsepositives)

估算召回率更具挑战性,因为我们实质上必须重复解析过程,以识别本应声明为匹配但未被声明的记录。这可以通过选择一个松散匹配的记录块,并彻底审查此块中所有可能的记录对来更有效地估算。当然,与任何阻塞方法一样,我们面临忽略未能进入松散选定块的通配匹配的风险。

作为提醒,召回率的计算方法如下:

R e c a l l = Truepositives (Truepositives+Falsenegatives)

基于集群的方法

与两两比较法的替代方案是通过手动确定,例如通过搜索工具,来获得描述相同现实世界实体的真实集群视图。然后,我们可以将我们的两两预测与这一黄金标准进行比较,以评估我们的性能并改进我们的模型。例如,考虑 图 11-1 中所示的简单示例。

图 11-1. 基于集群的评估示例

在四个记录(A 到 D)的群体中,我们的模型已经配对了记录 A + B 和 C + D。一个真实的集群视图显示,A、B 和 C 都指向同一个实体,但是 D 是不同的。基于此,我们可以评估以下内容并计算我们的性能指标。

记录对 预测 实际 结果
A - B 匹配 匹配 真正例
A - C 不匹配 匹配 假负例
A - D 不匹配 不匹配 真负例
B - C 不匹配 匹配 假负例
B - D 不匹配 不匹配 真负例
C - D 匹配 不匹配 假正例

实体解析的评估是一个活跃研究的领域,出现了工具集来协助这一过程,并提供可操作的反馈来改进性能。^(3)

实体解析的未来

实体解析从本质上来说是达到目的的手段。通过解析实体,我们希望从多个来源汇总所有相关信息,以使我们能够得出有价值的见解,并最终做出更好的决策。

在日益数字化的世界中,我们有责任确保我们的数据记录准确和全面地反映社会。如果我们有错误的信息,或者只看到了部分画面,我们就有可能得出错误的结论和采取错误的行动。还有一项职责是尊重个人隐私,并根据情况管理敏感数据。

实体解析的艺术与科学正在发展,以平衡这些关注点。实体解析可以增强您在数据中连接点的能力,并展示更大的图景。越来越多地可以在不必要共享个人信息的情况下完成这些操作。现在可以自由获取新的机器学习算法,并采用更严格的评估和优化技术来提升其性能。

最近,大规模语言模型(LLMs)在规模和可用性方面的进展开启了一个关于如何描述和相互关联真实世界实体的广泛信息的领域。支撑这些模型的嵌入技术为匹配过程提供了丰富的背景信息。日益增长的托管实体解析服务的可用性,以及将您的实体与公共知识库关联的能力,有望使匹配过程更加简便,结果更加丰富。

希望您享受了我们一起分享的实体解析挑战之旅,并且感觉准备好在您的数据中连接这些点。谁知道您会发现什么...

^(1) 关于 Jaccard 指数的更多详细信息,请访问Wikipedia 页面

^(2) 关于如何使用 sent2vec 库的进一步详细信息,请参阅PyPI 文档

^(3) 例如,用于评估实体解析(ER)系统的开源 Python 包可以在GitHub上找到。

posted @   绝不原创的飞龙  阅读(86)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
点击右上角即可分享
微信分享提示