实体解析实用指南(一)
原文:
zh.annas-archive.org/md5/4e35ee51118670fc815bae773646f567译者:飞龙
序言
我们都希望做出更好的决策。无论是为了更好地服务我们的客户还是保护他们的安全,我们都希望做出正确的判断并做正确的事情。为了自信地行动,我们需要了解我们正在服务的人是谁,以及他们在这个世界上的位置。虽然我们经常可以获得大量的数据,但往往这些数据并不联结,不能告诉我们面前这个个体的完整故事。
实体解析是连接数据、连接点,并看到整体图像的艺术与科学。这本书是一本实用指南,帮助您揭示更广泛的背景,并在行动之前全面了解情况。通常被视为理所当然,但在本书中您会发现,匹配数据并不总是一帆风顺——但请放心,在最后一章,您将具备充分的能力来克服这些挑战,并让您的数据集活跃起来。
谁应该读这本书
如果您是金融服务、制药或其他大型企业内的产品经理、数据分析师或数据科学家,这本书适合您。如果您正在应对数据孤岛的挑战,无法将不同数据库中的客户视图整合,或者负责合并来自不同组织或附属机构的信息,这本书也适合您。
负责打击金融犯罪和管理声誉与供应链风险的风险管理专业人士,也将受益于理解本书中提出的数据匹配挑战及其克服技术。
我为什么写这本书
实体解析的挑战无处不在——我们可能没有使用这些词,但每天这个过程一次又一次地重复。在完成本书的几周前,我的妻子要求我帮她检查名单上的姓名,因为她正在念出银行对账单上的付款人名单。名单上的人都付款了吗?这就是实体解析在实际中的应用!
编写本书的想法源于希望解释为什么检查与名单中的名称是否匹配并不像听起来那么简单,并展示目前可用于大规模解决此问题的一些惊人工具和技术。
希望通过一些现实生活的例子来引导您,使您能够自信地匹配您的数据集,以便为客户提供服务和保护。我很乐意听听您的经历,以及对本书本身的任何反馈。请随时在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 (传真)
本书有一个网页,我们在那里列出勘误、示例和任何额外信息。您可以访问这个页面: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 个名字进行去重,你实际上需要进行比较:
-
第一个名字与剩余的 99 个名字,然后
-
第二个名字与剩余的 98 个名字等等。
实际上,您需要进行 4,950 次比较。以每秒一次的速度计算,仅仅对两个短列表进行比较就需要大约 80 分钟的工作时间。对于更大的数据集,潜在的组合数量变得不切实际,即使对于性能最佳的硬件也是如此。
盲匹配?
到目前为止,我们假设我们寻求匹配的数据集对我们是完全透明的——即属性的值是 readily available 的,完整的,并且没有以任何方式被模糊或掩盖。在某些情况下,由于隐私约束或地缘政治因素阻止数据跨越国界移动,这种理想情况是不可能的。如何在看不到数据的情况下找到匹配项?这看起来像魔术,但正如我们将在第十章中看到的那样,有加密技术可以使匹配仍然发生,而不需要完全暴露要匹配的列表。
实体解析过程
为了克服上述挑战,基本的实体解析过程被分为四个连续步骤:
-
数据标准化
-
记录阻塞
-
属性比较
-
匹配分类
在匹配分类之后,可能需要进行额外的后处理步骤:
-
聚类
-
规范化
让我们依次简要描述每一个步骤。
数据标准化
在我们比较记录之前,我们需要确保我们有一致的数据结构,以便我们可以测试属性之间的等价性。我们还需要确保这些属性的格式一致。这个处理步骤通常涉及到字段的拆分,删除空值和多余字符。它通常是针对源数据集定制的。
记录阻塞
为了克服记录比较的数量不切实际高的挑战,通常会使用一种称为阻塞的过程。该过程不是将每个记录与每个其他记录进行比较,而是仅对根据某些属性之间的就绪等价性预先选择的记录对进行全面比较。这种过滤方法集中了解析过程在那些最有可能匹配的记录上。
属性比较
接下来是通过阻塞过程选择的记录对之间比较各个属性的过程。等价度可以根据属性之间的精确匹配或相似性函数来确定。该过程产生了两个记录对之间的等价度量集合。
匹配分类
基本实体解析过程的最后一步是确定个体属性之间的集体相似性是否足以声明两个记录匹配,即解析它们是否指向同一现实世界的实体。这种判断可以根据一组手动定义的规则进行,也可以基于机器学习的概率方法。
聚类
一旦我们的匹配分类完成,我们可以通过它们的匹配对将记录分组为连接的群集。将记录对包含在群集中可能是通过额外的匹配置信度阈值来确定的。未达到此阈值的记录将形成独立的群集。如果我们的匹配标准允许不同的等价标准,则我们的群集可能是不传递的;即记录 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拆分为Firstname和Lastname,以便我们可以独立匹配这些值。为了本例的目的,我们将使用一个简单的方法,选择第一个子字符串作为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,我们可以尝试实体解析过程的下一阶段。因为我们的数据集很小,我们不需要使用记录阻塞,所以我们可以直接尝试对Firstname、Lastname和Constituency进行简单的精确匹配。merge方法(类似于数据库的join)可以为我们执行这种精确匹配:
len(df_w.merge(df_t, on=['Constituency','Firstname','Lastname']))
599
我们发现 650 个中有 599 个完美匹配所有三个属性——不错!仅在Constituency和Lastname上进行匹配,我们得到 607 个完美匹配,因此显然有 8 个不匹配的Firstname条目:
len(df_w.merge(df_t, on=['Constituency','Lastname']))
607
对Firstname、Lastname和Constituency的剩余排列重复这个过程,得到了匹配计数的维恩图,如图 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
语音匹配
与编辑距离匹配的另一种选择是比较单词发音的相似性。这些语音算法大多基于英语发音,其中两个最流行的是 Metaphone 和 Match Rating Approach(MRA)。
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 算法,因为它在匹配名字时表现良好,由于其偏向于初始字符,这些字符往往更为重要。它也广泛支持我们将要使用的数据后端。
示例问题
在第二章中,我们匹配了两个名单,这些名单是英国下议院议员的成员,以探索社交媒体存在与连任之间的相关性。我们使用精确字符串匹配来建立成员的Constituency、Firstname和Lastname属性的等价性。
我们发现了 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)
然后我们可以在Firstname和Lastname属性上应用 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)
然后,我们可以选择那些Firstname和Lastname属性都大致等效于我们的潜在匹配记录。我们可以通过使用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 数据集之间每个姓名组合的一对。
在本章中,我们将多次使用每个记录对的Firstname、Lastname和Constituency字段之间的精确匹配。因此,一次计算这些匹配并将它们存储为额外的特征列更为高效:
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. 匹配与不匹配的组合
| 匹配/不匹配人口 | 选区匹配 | 第一个名字匹配 | 姓氏匹配 |
|---|---|---|---|
| 不匹配 | 否 | 否 | 否 |
| 不匹配 | 否 | 否 | 是 |
| 不匹配 | 否 | 是 | 否 |
| 不匹配 | 否 | 是 | 是 |
| 不匹配 | 是 | 否 | 否 |
| 匹配 | 是 | 否 | 是 |
| 匹配 | 是 | 是 | 否 |
| 匹配 | 是 | 是 | 是 |
现在我们将检查名字和姓氏等价性,无论是单独还是一起,能多大程度上预测一个个体记录应属于match或notmatch人群。
单属性匹配概率
让我们首先考虑单单以名字等价作为一个记录对中的两个实体是否指向同一个人的良好指标。我们将检查match和notmatch人群,并在每个子集内部建立,有多少个名字匹配和多少个不匹配。
命名约定
当我们处理这些人群的各种子集时,采用标准的命名约定是有帮助的,这样我们可以一眼看出每个记录人群是如何被选中的。当我们选择记录时,我们将选择标准添加到人群名称中,从右向左添加,例如,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人群中,名字匹配的记录对数除以在match和notmatch人群中名字匹配的记录对数:
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人群中,姓氏匹配的记录对数除以在match和notmatch人群中姓氏匹配的记录对数。
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)为match和notmatch人口的概率之和:
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 )
与m和u值相比,λ值是一个与任何特定属性都不相关的记录级值。这个值是数据集整体重复的程度的度量,并且是我们概率计算的起点。
其倒数,即两个随机选取的记录不匹配的可能性,可以写为:
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参数的。贝叶斯因子作为m和u值的组合,用于衡量我们应该给予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
一旦我们可以计算每个属性的m和u值,以及整体数据集的 λ 值,我们可以轻松地计算每对记录的概率。我们只需确定每个属性的等效性(精确或适当的近似),选择适当的贝叶斯因子,并使用前述公式将它们相乘,以计算该记录对的总体概率。
对于我们的简单示例,我们的贝叶斯因子如 Table 4-2 所示计算。
表 4-2. Firstname,Lastname 匹配因子计算
Firstname 等效性 | Lastname 等效性 | Firstname 贝叶斯因子 | Lastname 贝叶斯因子 | 组合贝叶斯因子 |
|---|---|---|---|---|
| 否 | 否 | (1−m f ) (1−u f ) | (1−m l ) (1−u l ) | (1−m f ) (1−u f ) × (1−m l ) (1−u l ) |
| 否 | 是 | (1−m f ) (1−u f ) | m l u l | (1−m f ) (1−u 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群体时如何估计我们的 λ 值以及每个属性的m和u值。我们可以使用的一种技术称为期望最大化算法(EM 算法)。
期望最大化算法
期望最大化算法使用迭代方法来逼近 λ 和 m 和 u 值。让我们看一个简化形式的示例,应用于我们的样本问题。
第一次迭代
在第一次迭代中,我们做出假设,即大多数特征列等效的记录对是匹配的:
it1_match = cross[cross['Tmatch']>=2]
it1_notmatch = cross[cross['Tmatch']<2]
len(it1_match)
637
这为我们提供了一个伪匹配人口 it1_match,共 637 条记录。除了我们在 第二章 中找到的 628 个完美匹配外,我们还有 9 个匹配,其中 Firstname 或 Lastname(但不是两者同时)不匹配,如图 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 。
因此,作为起点,两个记录匹配的可能性极低。现在让我们计算我们的 m 和 u 值,以便我们可以根据每个记录更新我们的概率。
由于我们有一个伪匹配和 notmatch 人口,因此可以直接计算我们的 m 和 u 值,作为每种人口中具有等效属性的比例。对于 名、姓 和 选区,我们使用:
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 的 m 和 u 值
| 属性 | 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)
一旦我们计算了这些值,我们可以再次迭代,根据计算出的匹配概率重新将我们的人口分成match和notmatch人口。
迭代 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 阈值。使用这些新的match和notmatch人口,我们可以修订我们的 λ 、m 和 u 值,并再次迭代,重新计算每对记录的匹配概率。
在这种情况下,我们的 λ 实际上没有太大变化:
λ 2 = 633 650×650 ≈ 0 . 0015
只有Lastname的值稍微改变,如表格 4-4 所示。
表格 4-4。迭代 2 m 和 u 值
| 属性 | 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 中使用概率图计算的值相匹配,当我们预先知道match和notmatch人口时。
总之,我们现在能够对属性等价的各种排列组合进行匹配概率估计,而无需事先了解match人口。这种概率方法既强大又可扩展,适用于具有多个属性的大型数据集。为了帮助我们更轻松地应用这些技术,我们在下一节中介绍了一个性能卓越且易于使用的开源库 Splink。
引入 Splink
Splink 是用于概率实体解析的 Python 包。Splink 实现了 Fellegi-Sunter 模型,并包含各种交互式输出,帮助用户理解模型并诊断链接问题。
Splink 支持多种后端来执行匹配计算。首先,我们将使用 DuckDB,这是一个在本地笔记本电脑上可以运行的进程内 SQL 数据库管理系统。
配置 Splink
要在我们的笔记本中导入 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更丰富的属性,因此其等价性是匹配实体的更好预测器。
预料之中,两个数据集之间的选区是唯一配对的,因此所有数值都恰好出现两次。
图 4-6. Splink 列配置
在这个简单的示例中,我们将要求 Splink 使用我们之前介绍的期望最大化算法来计算模型的所有参数。初始的True参数告诉 Splink 比较两个数据集中所有的记录而不进行阻塞(我们将在下一章看到)。我们还告诉 Splink 在每次迭代时重新计算u值,通过设置fix_u_probabilities为False。将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)
Splink 性能
EM 模型在三次迭代后收敛。Splink 生成一个交互式图表,显示相对匹配权重值的迭代进展:
em_session.match_weights_interactive_history_chart()
图 4-7. Splink 匹配权重
图 4-7 显示了 Splink 在第三次迭代后计算的最终匹配权重。首先,我们有先验(起始)匹配权重,这是两个随机选择的记录匹配的可能性的度量。如果你将鼠标悬停在匹配权重条上,你可以看到计算出的匹配权重值以及底层的m和u参数。这些计算方法如下:
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的非精确匹配权重近似为负无穷,并以不同颜色显示。这是因为没有情况下Firstname和Lastname属性匹配但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}
m和u概率与我们在本章早些时候使用期望最大化算法手动计算的那些匹配。
最后,和之前一样,我们应用一个阈值匹配概率,并选择高于阈值的记录对:
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不匹配是这些实体未达到匹配阈值的原因。
图 4-8. Splink 由于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 列。
维基百科数据
为了标准化日期增强的维基百科数据,我们将日期列转换为月份和年份的整数。如同 第二章 中所述,我们提取 Firstname 和 Lastname 属性。我们还添加了一个唯一的 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']]
让我们来看看几行(其中 Firstname 和 Lastname 已经过清理),如 图 5-1 所示。
图 5-1. 英国公司注册处具有重要控制人数据的示例行
记录阻塞和属性比较
现在我们有了一致的数据,可以配置我们的匹配过程。在我们开始之前,值得一提的是挑战的规模。我们有 650 个 MP 记录,而我们标准化后的 PSC 数据超过 1000 万条记录。如果我们考虑所有的排列组合,我们将有大约 60 亿次比较要进行。
通过在具有匹配 Month 和 Year 值的记录上执行简单的连接,我们可以看到交集的大小约为 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. 简单地根据 Lastname、Firstname、Year 和 Month 进行连接
使用 Splink 进行记录阻塞
为了减少需要考虑的记录组合数量,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
每个年份值将单独考虑,用于计算匹配概率;因此,对于不太流行的年份匹配,将比对较常见的值匹配更高地加权。
正如我们在第四章中所做的那样,我们可以使用期望最大化算法来确定m和u值,即每个属性段的匹配和不匹配概率。默认情况下,这些计算考虑应用阻塞规则之前的整体人口。
为了估计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 中,我们可以看到强烈的负先验(起始)匹配权重,以及每个属性精确匹配和在 Firstname 和 Lastname 上近似匹配的正权重:
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. Lastname 和 Firstname 的精确匹配
如果我们移除精确匹配,我们可以检查额外的近似匹配,看看我们的概率方法执行得有多好。这在 图 5-7 中展示(PSC 的名字和姓氏已经过清理):
pres[(pres['Lastname_psc']!=pres['Lastname_w']) |
(pres['Firstname_psc']!=pres['Firstname_w'])]
图 5-7. 近似匹配 — 非精确的Firstname或Lastname
检查结果,如表 5-1 所示,我们可以看到几位可能是真正的正向匹配的候选人。
表 5-1. 近似匹配 — 手动比较
match_weight | match_probability | Firstname_psc | Firstname_w | Lastname_psc | Lastname_w | company_number |
|---|---|---|---|---|---|---|
| 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人群中哪些是真正的假阴性,因此我们无法计算召回率或整体准确度指标。
总结
在本章中,我们使用概率框架内的近似匹配来识别可能对英国公司具有重大控制权的国会议员。
我们看到如何使用阻塞技术来减少我们需要评估的记录对数量,使其保持在一个实际的范围内,同时又不会太大幅度地增加我们错过重要潜在匹配的风险。
我们看到数据标准化对优化性能的重要性,以及在实体解析中获得最佳性能通常是一个迭代的过程。