实体解析实用指南-二-

79 阅读1小时+

实体解析实用指南(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:公司匹配

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

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

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

样本问题

在本章中,我们将解析由英国海事及海岸警卫局(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匹配数量匹配的唯一实体
名称和邮政编码匹配4745
仅名称匹配3731
仅邮政编码匹配11627
总匹配数20085 (去重后)
未匹配 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匹配数量唯一匹配的实体
名称和邮政编码匹配4745
仅名称匹配3731
仅邮政编码匹配31
总匹配数8773 (去重后)
未匹配 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出生年份
0MichaelShearer1970
1MichaelShearer1970
2MikeShearer1970
3MichaelShearer1971
4MichelleShearer1971
5MikeSheare1971

每个 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 万 |

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

| 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 显示了一个工作室仪表板的示例。

图 7-12. Splink 簇工作室仪表板

让我们考虑一个示例簇,参考: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匹配数量唯一匹配实体
名称和邮政编码匹配4745
仅名称匹配3731
仅邮政编码匹配10827
总匹配数19285(去重后)
未匹配 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. 匹配置信度

匹配数置信度
60.7
10.8
450.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
21702400000001000000000000000000000000
11900000001000010000000000000000000
21400000001000010000100000000000000
3900000001000010000100001000000000
3540500000001000010000100001000100000
1400000001000010000100001000110000
2300000001000010000100001000111000
3200000001000010000100001000111100
46601400000001000010000100001000111100
11800000001000011000100001000111100
22200000001010011000100001000111100
32600000101010011000100001000111100
完成的过滤器00000101010011000100001000111100

这里我们可以看到一种碰撞,即第三个值的第一个哈希迭代将位置 14 的位设置为 1,尽管它已经被第一个值的第三次迭代先前设置为 1。

类似地,如果对于新值的哈希迭代所对应的所有位位置已经设为 1,则会误以为元素已经存在于数据集中,而实际上并非如此。例如,如果我们想测试值十进制 14 是否在服务器数据集中,我们计算其哈希值如表 10-2 所示。

表 10-2. 布隆过滤器测试

| 测试值 | 哈希迭代 | 哈希值(范围

0–31) | 布隆过滤器(位置 0–31,从右到左) | 位检查 |

---------------
布隆过滤器00000101010011000100001000111100 
1402200000000010000000000000000000000True
1200000000000000000000000000000100True
21400000000000000000100000000000000True
32600000100000000000000000000000000True

从这个简单的例子中,我们错误地得出结论,即服务器数据中存在值 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
21716badfc6202cb3f8889e0f2779b19218af4cbb736e56acadce8148aba9a7a9f8120
35409a1b036b82baba3177d83c27c1f7d0beacaac6de1c5fdcc9680c49f638c5fb957
466826e27285307a923759de350de081d6218a04f4cff82b20c5ddaa8c60138c066102

将减少范围哈希值按升序排序,我们有 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 编码 |

------------------
572511001101100110
4511301101100110110
18018100101100101

最后到第一个,从左到右,集合编码为: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 页,*doi.org/10.1109/SP.…

^(2) 您可能需要在本地浏览器上允许弹出窗口。

^(3) 请参阅专利“使用全同态加密方案进行紧凑模糊私有匹配”,https://patents.google.com/patent/US20160119119