前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >在几秒钟内将数千个类似的电子表格文本单元分组

在几秒钟内将数千个类似的电子表格文本单元分组

作者头像
代码医生工作室
发布2019-07-22 10:23:21
1.8K0
发布2019-07-22 10:23:21
举报
文章被收录于专栏:相约机器人相约机器人

作者 | Luke Whyte

来源 | Medium

编辑 | 代码医生团队

这是一个常见的电子表格或数据库问题:

代码语言:javascript
复制
+-----+-------------------+
| row |     fullname      |
+-----+-------------------+
|   1 | John F. Doe       |
|   2 | Esquivel, Mara    |
|   3 | Doe, John F       |
|   4 | Whyte, Luke       |
|   5 | Doe, John Francis |
+-----+-------------------+

第1,3和5行可能指的是拼写和格式略有偏差的同一个人。在小型数据集中,可以手动清洁细胞。但是在庞大的数据集中呢?如何梳理成千上万的文本条目并将类似的实体分组?

理想情况下,有一种简单的方法来添加第三列,如下所示:

代码语言:javascript
复制
+-----+-------------------+---------------+
| row |     fullname      |  name_groups  |
+-----+-------------------+---------------+
|   1 | John F. Doe       | Doe John F    |
|   2 | Esquivel, Mara    | Esquivel Mara |
|   3 | Doe, John F       | Doe John F    |
|   4 | Whyte, Luke       | Whyte Luke    |
|   5 | Doe, John Francis | Doe John F    |
+-----+-------------------+---------------+

好吧,那就是要做的事情。

TLDR:为此构建了一个工具。可以在此处安装Python模块。但是如果想了解这个工具背后的概念请继续阅读。

https://github.com/lukewhyte/textpack

将讨论的主题:

  1. 使用TF-IDF和N-Grams构建文档术语矩阵
  2. 使用余弦相似度计算字符串之间的接近度
  3. 使用哈希表将发现转换为电子表格中的“组”列

在本教程中,将使用美国劳工部工资盗窃调查的这个数据集。它包含了从1984年到2018年由于最低工资或加班违规而对雇主进行的每次DOL调查。

https://data.world/lukewhyte/us-dept-of-labor-wage-theft-investigations

数据包括一legal_name列,列出了被调查公司的名称。但是,输入格式变化很大:

代码语言:javascript
复制
+-----+----------------------+
| row |      legal_name      |
+-----+----------------------+
|   1 | Wal-mart Inc         |
|   2 | Walmart Stores Inc.  |
|   3 | Wal-mart stores Inc  |
|   4 | Wal-Mart stores Inc. |
+-----+----------------------+

将对条目进行规范化和分组,legal_name然后使用组进行快速分析。

第一步:使用TF-IDF和N-Grams构建文档术语矩阵

在这里面临的最大挑战是,专栏中的每个条目都需要与其他条目进行比较。因此,一张400,000行的纸张需要400,000²的计算。

如果可以使用矩阵乘法进行同步计算会更快,可以使用文档术语矩阵,TF-IDF和N-Grams。

定义这些术语:

文件术语矩阵

文档术语矩阵本质上是Bag of Words(BOW)概念的延伸,喜欢这个概念,因为它听起来就像是一个蒙面男子会在芝麻街偷窃的东西。

BOW涉及计算字符串中单词的频率。所以鉴于这句话:

“Rhode Island is neither a road nor is it an island. Discuss.”

可以生成这样的BOW表示:

代码语言:javascript
复制
+---------+-------+
|  term   | count |
+---------+-------+
| rhode   |     1 |
| island  |     2 |
| is      |     2 |
| neither |     1 |
| a       |     1 |
| road    |     1 |
| nor     |     1 |
| it      |     1 |
| an      |     1 |
| discuss |     1 |
+---------+-------+

文档术语矩阵(DTM)将BOW扩展为多个字符串(或者在命名中,“多个文档”)。想象一下,有以下三个字符串:

  1. “Even my brother has needs”
  2. “My brother needs a lift”
  3. “Bro, do you even lift?”

DTM可能如下所示:

每个条目的值通过计算每个单词在每个字符串中出现的次数来确定。

上述方法的问题在于,诸如“the”,“is”和“if”之类的微不足道的词语往往比重要词语更频繁地出现,这可能会扭曲分析。

因此可以为它们分配TF-IDF分数,而不是计算单词,该分数评估每个单词对DTM的重要性。

TF-IDF

为了计算TF-IDF分数,将术语在单个文档中出现的次数(术语频率或TF)乘以术语对整个语料库的重要性(逆文档频率或IDF) - 单词出现的文档越多在这个词中,人们认为这个词在区分文件方面的价值就越低。

重要的是,对于文档术语矩阵中的每个单词,如果用TF-IDF分数替换单词计数,可以在检查字符串相似性时更有效地权衡单词。

N元

最后将解决这个问题:

Burger King是两个字。BurgerKing应该是两个单词,但计算机会将其视为一个单词。因此,当计算文档术语矩阵时,这些术语将不匹配。

N-gram是一种将字符串分成较小块的方法,其中块N大小。所以如果设置N到3得到:

['Bur','urg','rge','ger','er','r K','Ki','Kin','ing']

和:

['Bur','urg','rge','ger','erK','rKi','Kin','ing']

它比原始字符串重叠得多。

因此当构建文档术语矩阵时,计算N-Grams的TF-IDF分数而不是单词。

最后一些代码:

以下是使用N-Grams构建文档术语矩阵作为列标题和值的TF-IDF分数的代码:

代码语言:javascript
复制
import re
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
 
# Import your data to a Pandas.DataFrame
df = pd.read_csv('./dol-data.csv')
 
# Grab the column you'd like to group, filter out duplicate values
# and make sure the values are Unicode
vals = df['legal_name'].unique().astype('U')
 
 
# Write a function for cleaning strings and returning an array of ngrams
def ngrams_analyzer(string):
    string = re.sub(r'[,-./]', r'', string)
    ngrams = zip(*[string[i:] for i in range(5)])  # N-Gram length is 5
    return [''.join(ngram) for ngram in ngrams]
 
# Construct your vectorizer for building the TF-IDF matrix
vectorizer = TfidfVectorizer(analyzer=ngrams_analyzer)
 
# Build the matrix!!!
tfidf_matrix = vectorizer.fit_transform(vals)

在第6行,将CSV转换为Pandas DataFrame。

第10行从legal_name数据集的列中提取唯一值,并将它们放在一维NumPy数组中。

在第14行,编写了用于构建5个字符N-Grams的函数。使用正则表达式过滤掉一些字符。

第20行传递ngrams_analyzer给将用于构建矩阵的TF-IDF矢量化器。

最后在第23行,构建了文档术语矩阵。

稀疏与密集矩阵以及如何使计算机崩溃

上述代码的结果tfidf_matrix是压缩稀疏行(CSR)矩阵。

出于目的,要知道任何大多数零值的矩阵都是稀疏矩阵。这与大多数非零值的密集矩阵不同。

N-Grams矩阵有237,573行和389,905列。前10行和列如下所示:

这很稀疏。没有理由将所有这些零存储在内存中。如果这样做,就有可能耗尽RAM并触发一个MemoryError。

输入CSR矩阵,该矩阵仅存储矩阵的非零值和对其原始位置的引用。

重要的是CSR格式可以节省内存,同时仍允许快速行访问和矩阵乘法。

步骤二:使用余弦相似度计算字符串之间的接近度

余弦相似度是0和1之间的度量,用于确定类似字符串的长度,而不管它们的长度如何。

它测量多维空间中字符串之间角度的余弦。该值越接近1(余弦为0°),字符串相似度越高。

采取以下三个字符串:

  1. I love dogs
  2. I love love love love love love love dogs
  3. I hate hate hate cats

并将它们放在文档术语矩阵中:

然后在多维空间上绘制此矩阵,其中每个维度对应于我们的四个术语之一。这可能看起来像这样:

如果看看点之间的距离,“I love dogs”和“I hate cats”实际上比“I love dogs”和“I love … love dogs”更接近彼此。

然而,如果看一下点线之间的角度 -余弦距离 - 可以看到“I love dogs”和“I love … love dogs”之间的角度远小于“I love dogs”之间的角度和“I hate cats”。

因此字符串1和字符串2之间的余弦相似性将比字符串1和字符串3之间的余弦相似性更高(更接近1)。

这是一个更深入的解释。

在Python中计算余弦相似度

可以使用scikit-learn来计算余弦相似度。这将返回具有余弦相似度值的成对矩阵,如:

然后将通过相似性阈值(例如0.75或0.8)过滤此矩阵,以便对认为代表相同实体的字符串进行分组。

但是如果使用由ING Bank的数据科学家构建的这个模块,可以在构建矩阵时按照相似性阈值进行过滤。该方法比scikit-learn更快,并返回内存密集度较低的CSR矩阵供使用。

https://github.com/ing-bank/sparse_dot_topn

所以在脚本中添加以下内容:

代码语言:javascript
复制
# Import IGN's awesome_cossim_topn module
from sparse_dot_topn import awesome_cossim_topn
 
# The arguments for awesome_cossim_topn are as follows:
### 1. Our TF-IDF matrix
### 2. Our TF-IDF matrix transposed (allowing us to build a pairwise cosine matrix)
### 3. A top_n filter, which allows us to filter the number of matches returned, which isn't useful for our purposes
### 4. This is our similarity threshold. Only values over 0.8 will be returned
cosine_matrix = awesome_cossim_topn(
  tf_idf_matrix,
  tf_idf_matrix.transpose(),
  vals.size,
  0.8
)

现在有一个CSR矩阵,表示所有字符串之间的余弦相似性。是时候把它带回家了。

第三步:构建一个哈希表,将发现转换为电子表格中的“组”列

现在要构建一个Python字典,其中包含legal_name列中每个唯一字符串的键。

最快的方法是将CSR矩阵转换为坐标(COO)矩阵。COO矩阵是稀疏矩阵的另一种表示。

例如如果有这个稀疏矩阵:

代码语言:javascript
复制
+ ------------ +
| 0,0,0,4 |
| 0,1,0,0 |
| 0,0,0,0 |
| 3,0,0,7 |
+ ------------ +

将其转换为COO矩阵,它会成为一个对象,具有三个属性- ,,row -分别包含以下三个数组,:coldata

  1. [0, 1, 3, 3]:每个非零值的行索引(0索引)
  2. [3, 1, 0, 3]:每个非零值的列索引(0索引)
  3. [4, 1, 3, 7]:来自矩阵的非零值

因此可以说值4(存储在matrix.data[0])的坐标是(0,3)(存储在(matrix.row[0],matrix.col[0])中。

构建COO矩阵并使用它来填充字典:

代码语言:javascript
复制
# Build a coordinate matrix from a cosine matrix
coo_matrix = cosine_matrix.tocoo()
 
# Instaniate our lookup hash table
group_lookup = {}
 
 
def find_group(row, col):
    # If either the row or the col string have already been given
    # a group, return that group. Otherwise return none
    if row in group_lookup:
        return group_lookup[row]
    elif col in group_lookup:
        return group_lookup[col]
    else:
        return None
 
 
def add_vals_to_lookup(group, row, col):
    # Once we know the group name, set it as the value
    # for both strings in the group_lookup
    group_lookup[row] = group
    group_lookup[col] = group
 
 
def add_pair_to_lookup(row, col):
    # in this function we'll add both the row and the col to the lookup
    group = find_group(row, col)  # first, see if one has already been added
    if group is not None:
        # if we already know the group, make sure both row and col are in lookup
        add_vals_to_lookup(group, row, col)
    else:
        # if we get here, we need to add a new group.
        # The name is arbitrary, so just make it the row
        add_vals_to_lookup(row, row, col)
 
# for each row and column in coo_matrix
# if they're not the same string add them to the group lookup
for row, col in zip(coo_matrix.row, coo_matrix.col):
    if row != col:
        # Note that what is passed to add_pair_to_lookup is the string at each index
        # (eg: the names in the legal_name column) not the indices themselves
        add_pair_to_lookup(vals[row], vals[col])

在第2行,将余弦矩阵转换为坐标矩阵。

在第39-43行,遍历坐标矩阵,为非零值拉出行和列索引 - 记住它们都具有超过0.8的余弦相似性 - 然后将它们转换为它们的字符串值。

为了澄清,通过一个简单的示例进一步解开第39-43行。再次,取这个余弦矩阵:

如果使用awesome_cossim_topn阈值设置为0.8 构建它,然后将其转换为COO矩阵,可以像这样表示:

代码语言:javascript
复制
  (row, col) | data  
 ------------|------
  (0,0)      |    1
  (0,2)      | 0.84
  (1,1)      |    1
  (2,0)      | 0.84
  (2,2)      |    1

vals等于['Walmart', 'Target', 'Wal-mart stores']。

因此在循环内,首先(row, col)对通过的row != col条件是(0, 2),再通到add_pair_to_lookup作为(vals[0], vals[2)或('Walmart', 'Wal-mart stores')。

继续这个例子,在所有的字符串通过之后add_pair_to_lookup,最终得到:

代码语言:javascript
复制
>>> group_lookup
{
    'Walmart': 'Walmart',
    'Wal-mart stores': 'Walmart'
}

没有类似于'Target'的字符串,因此没有分配组。

矢量化Panda

最后,可以在Pandas中使用矢量化功能,将每个legal_name值映射到GroupDataFrame中的新列并导出新的CSV。

由于Pandas函数可以同时对整个数组进行操作 - 而不是依次对各个值进行操作 - 因此这个过程非常快:

代码语言:javascript
复制
df['Group'] = df['legal_name'].map(group_lookup).fillna(df['legal_name'])
 
df.to_csv('./dol-data-grouped.csv')

该fillna方法允许在没有密钥时替换该legal_name值。Groupgroup_lookup

把它们放在一起:

代码语言:javascript
复制
import re
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sparse_dot_topn import awesome_cossim_topn
 
# Import your data to a Pandas.DataFrame
df = pd.read_csv('./dol-data.csv')
 
# Instaniate our lookup hash table
group_lookup = {}
 
 
# Write a function for cleaning strings and returning an array of ngrams
def ngrams_analyzer(string):
    string = re.sub(r'[,-./]', r'', string)
    ngrams = zip(*[string[i:] for i in range(5)])  # N-Gram length is 5
    return [''.join(ngram) for ngram in ngrams]
 
 
def find_group(row, col):
    # If either the row or the col string have already been given
    # a group, return that group. Otherwise return none
    if row in group_lookup:
        return group_lookup[row]
    elif col in group_lookup:
        return group_lookup[col]
    else:
        return None
 
 
def add_vals_to_lookup(group, row, col):
    # Once we know the group name, set it as the value
    # for both strings in the group_lookup
    group_lookup[row] = group
    group_lookup[col] = group
 
 
def add_pair_to_lookup(row, col):
    # in this function we'll add both the row and the col to the lookup
    group = find_group(row, col)  # first, see if one has already been added
    if group is not None:
        # if we already know the group, make sure both row and col are in lookup
        add_vals_to_lookup(group, row, col)
    else:
        # if we get here, we need to add a new group.
        # The name is arbitrary, so just make it the row
        add_vals_to_lookup(row, row, col)
 
 
# Construct your vectorizer for building the TF-IDF matrix
vectorizer = TfidfVectorizer(analyzer=ngrams_analyzer)
 
# Grab the column you'd like to group, filter out duplicate values
# and make sure the values are Unicode
vals = df['legal_name'].unique().astype('U')
 
# Build the matrix!!!
tfidf_matrix = vectorizer.fit_transform(vals)
 
cosine_matrix = awesome_cossim_topn(tf_idf_matrix, tf_idf_matrix.transpose(), vals.size, 0.8)
 
# Build a coordinate matrix
coo_matrix = cosine_matrix.tocoo()
 
# for each row and column in coo_matrix
# if they're not the same string add them to the group lookup
for row, col in zip(coo_matrix.row, coo_matrix.col):
    if row != col:
        add_pair_to_lookup(vals[row], vals[col])
 
df['Group'] = df['legal_name'].map(group_lookup).fillna(df['legal_name'])
 
df.to_csv('./dol-data-grouped.csv')

剩下要做的就是将这些数据放入数据透视表中,看看哪些雇主欠(d)雇员的工资最多。

剧透警报:这是沃尔玛。183项调查导致他们同意支付近4100万美元的拖欠工资。

最后一点

如果希望按两列或更多列而不是一列进行分组,则可以创建一个临时列,以便在DataFrame中对每个列连接成单个字符串的条目进行分组:

代码语言:javascript
复制
columns_to_group = ['legal_name', 'address']
df['grouper'] = df[
   columns_to_group.pop(0)
].astype(str).str.cat(
   df[columns_to_group].astype(str)
)

然后你会设置vals为:

代码语言:javascript
复制
vals = df['grouper'].unique().astype('U')

并且,在最后导出时,删除该列:

代码语言:javascript
复制
df.drop(columns=['grouper']).to_csv('./dol-data-grouped.csv')

再次创建了一个Python模块来完成所有这些工作。

https://github.com/lukewhyte/textpack

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-07-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 相约机器人 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档