使用pdist在Python中生成字符串距离矩阵

10
如何在Python中计算字符串的Jaro Winkler距离矩阵?
我有一个手动输入的字符串数组(名称和记录编号),我正在尝试查找列表中的重复项,包括可能存在轻微拼写差异的重复项。对于类似问题的回答建议使用Scipy的pdist函数和自定义距离函数。我尝试使用Levenshtein包中的jaro_winkler函数来实现此解决方案。但是,jaro_winkler函数需要字符串输入,而pdict函数似乎需要二维数组输入。
示例:
import numpy as np
from scipy.spatial.distance import pdist
from Levenshtein import jaro_winkler

fname = np.array(['Bob','Carl','Kristen','Calr', 'Doug']).reshape(-1,1)
dm = pdist(fname, jaro_winkler)
dm = squareform(dm)

预期输出 - 类似于这样:

          Bob  Carl   Kristen  Calr  Doug
Bob       1.0   -        -       -     -
Carl      0.0   1.0      -       -     -
Kristen   0.0   0.46    1.0      -     -
Calr      0.0   0.93    0.46    1.0    -
Doug      0.53  0.0     0.0     0.0   1.0

实际错误:

jaro_winkler expected two Strings or two Unicodes

我认为是因为jaro_winkler函数看到的是一个ndarray而不是字符串,我不知道如何在pdist函数的上下文中将函数输入转换为字符串。

有没有人有建议让这个函数可以工作?先感谢了!

4个回答

17
您需要对距离函数进行包装,就像我在下面的示例中演示的那样,使用Levensthein距离。
import numpy as np    
from Levenshtein import distance
from scipy.spatial.distance import pdist, squareform

# my list of strings
strings = ["hello","hallo","choco"]

# prepare 2 dimensional array M x N (M entries (3) with N dimensions (1)) 
transformed_strings = np.array(strings).reshape(-1,1)

# calculate condensed distance matrix by wrapping the Levenshtein distance function
distance_matrix = pdist(transformed_strings,lambda x,y: distance(x[0],y[0]))

# get square matrix
print(squareform(distance_matrix))

Output:
array([[ 0.,  1.,  4.],
       [ 1.,  0.,  4.],
       [ 4.,  4.,  0.]])

1
它正在工作,谢谢!但是,对于近16000个字符串来说,这太慢了。它只使用一个核心。是否有任何多进程解决方案可以用于Levenstein距离? - Tedo Vrbanec
1
Rick,有没有办法使用pairwise_distance而不是pdist?或者其他的多进程工具? - Tedo Vrbanec
我研究了这个问题,并找到了更快的解决方案@TedoVrbanec。请看下面我的答案。 - evces

3

TL;DR:列表推导式比pdist()快约5倍

from itertools import combinations
from leven import levenshtein
from scipy.spatial.distance import squareform

strings = ["parded", "deputed", "shopbook", "upcheer"]
distances = [levenshtein(i, j) for (i, j) in combinations(strings, 2)]
distance_matrix = squareform(distances)  # if needed

#                parded  deputed  shopbook  upcheer
#      parded         0        5         8        5
#      deputed        5        0         7        6
#      shopbook       8        7         0        8
#      upcheer        5        6         8        0

背景

我对这个问题产生了兴趣,是因为看到一个类似的问题,其中一个答案不能正常工作

首先,在这个问题中的主要问题是由于pdist()不支持字符串列表,因为它是为数字数据设计的。

Rick's answer很好地解决了这个问题,展示了一种使用Levenshtein包中的距离函数来使用pdist()的方法。然而,正如Tedo Vrbanec在评论中指出的那样,这种方法对于非常大的字符串列表来说速度很慢。应该记住,成对计算的数量按照n(n-1)/2增长,其中n是列表中字符串的数量。

在工作中另一个答案时,我发现可以使用列表推导式和itertools.combinations()来实现相同的结果。我还发现可以通过pool.starmap()使用多处理,而不是列表推导式,希望这样会更快。我进行了以下测试,以找到最快的解决方案。

方法

  • 从GitHub上一个庞大的英文单词列表(list of English words) 随机抽取了一些字符串列表。
  • 测试了五种Levenshtein距离函数的实现:leveneditdistancepylevLevenshtein以及来自Rosetta Code的一种实现。
  • 测试了三种计算成对距离的方法:@Rick的pdist()方法,我的列表推导式方法和我的pool.starmap()方法。
  • 为了检测可扩展性,使用leven的实现测试了所有三种方法,跨越四个列表长度:250、1000、4000、16000。
  • 所有测试都在具有10个CPU核心的M1 MacBook Pro上运行。

结果

enter image description here

左图显示了计算500个随机抽样单词之间成对距离的平均时间(在五个不同的单词列表上进行平均,误差条为95% CI)。每个条形图都显示了三种方法中的一种(不同颜色),与Levenshtein距离的五种实现之一(x轴)匹配。最右边的绿色条形图缺失,因为Rosetta Code的实现与starmap()不兼容。y轴是对数刻度,以强调最小值之间的差异。

无论使用哪种方法,leven的实现速度最快。虽然starmap()方法通常比列表推导式方法更快,但当两种方法都使用leven的实现时,其优势非常小。我们可能会问这种优势的大小是否取决于单词列表的长度。

在右图中,我将单词列表的长度从250个单词变化到16000个单词,使用leven的实现进行所有测试。对数对数轴上的线性趋势表明,所有三种方法都是字符串对数(n(n-1)/2)的线性。令人惊讶的是,starmap()方法几乎没有比列表推导式方法更大的优势。但是,在所有列表长度上,starmap()和列表推导式方法都比pdist()快约5倍。

结论

计算一组字符串的所有两两Levenshtein距离的最佳方法是在itertools.combinations上使用leven包的距离函数进行列表推导。选择距离函数实现是最具有影响力的因素:请注意这个排名第一的答案推荐了Rosetta Code实现,但它几乎比leven慢100倍。使用starmap()进行进程并行化似乎几乎没有优势,尽管这可能取决于系统。

scikit-learn pairwise_distances()怎么样?

最后,我看到有人建议使用sklearn.metrics.pairwise_distances()paired_distances(),但我没有运气。据我所知,这些函数需要浮点型数据。尝试将它们用于字符串或字符输入会导致:ValueError: could not convert string to float

代码

# Imports
from urllib.request import urlopen
from random import sample
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from scipy.spatial.distance import pdist, squareform
from time import time
from multiprocessing import Pool, cpu_count
from itertools import combinations

# Data
url = "https://raw.githubusercontent.com/dwyl/english-words/master/words_alpha.txt"
all_words = urlopen(url).read().splitlines()

# Implementations:
import leven
import editdistance
import pylev
import Levenshtein

# From https://rosettacode.org/wiki/Levenshtein_distance#Python:
def levenshteinDistance(str1, str2):
    m = len(str1)
    n = len(str2)
    d = [[i] for i in range(1, m + 1)]  # d matrix rows
    d.insert(0, list(range(0, n + 1)))  # d matrix columns
    for j in range(1, n + 1):
        for i in range(1, m + 1):
            if str1[i - 1] == str2[j - 1]:  # Python (string) is 0-based
                substitutionCost = 0
            else:
                substitutionCost = 1
            d[i].insert(
                j,
                min(
                    d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + substitutionCost
                ),
            )
    return d[-1][-1]


lev_implementations = [
    leven.levenshtein,
    editdistance.eval,
    pylev.wfi_levenshtein,
    Levenshtein.distance,
    levenshteinDistance,
]
lev_impl_names = {
    "levenshtein": "leven",
    "eval": "editdistance",
    "wfi_levenshtein": "pylev",
    "distance": "Levenshtein",
    "levenshteinDistance": "Rosetta",
}

# Methods of computing pairwise distances
def pdist_(strings, levenshtein):
    transformed_strings = np.array(strings).reshape(-1, 1)
    return pdist(transformed_strings, lambda x, y: levenshtein(x[0], y[0]))


def list_comp(strings, levenshtein):
    return [levenshtein(i, j) for (i, j) in combinations(strings, 2)]


def starmap(strings, levenshtein):
    return pool.starmap(levenshtein, combinations(strings, 2))

methods = [pdist_,list_comp,starmap]

# Figure 1
# Five simulations of each method x implementation pair, with 500 words
pool = Pool(processes=cpu_count())
N_sims = 5
N_words = 500
times = []
impls = []
meths = []
for simulations in range(N_sims):
    strings = [x.decode() for x in sample(all_words, N_words)]
    for method in methods:
        for levenshtein in lev_implementations:
            if (method == starmap) & (levenshtein == levenshteinDistance):
                continue
            t0 = time()
            distance_matrix = method(strings, levenshtein)
            t1 = time()
            times.append(t1 - t0)
            meths.append(method.__name__.rstrip("_"))
            impls.append(lev_impl_names[levenshtein.__name__])

df = pd.DataFrame({"Time (s)": times, "Implementation": impls, "Method": meths})

# Figure 2
# Create datasets of different sizes, 250 - 16000 words
word_counts = [250, 1000, 4000, 16000]
pool = Pool(processes=cpu_count())
N_sims = 1
times = []
meths = []
comps = []
ll = []
for simulations in range(N_sims):
    strings_multi = {}
    for N in word_counts:
        strings = [x.decode() for x in sample(all_words, N)]
        for method in methods:
            t0 = time()
            distance_matrix = method(strings, leven.levenshtein)
            t1 = time()
            times.append(t1 - t0)
            meths.append(method.__name__.rstrip("_"))
            comps.append(sum([1 for _ in combinations(strings, 2)]))
            ll.append(N)

df2 = pd.DataFrame({"Time (s)": times, "Method": meths, "Number of string pairs": comps, "List length": ll})

fig, axes = plt.subplots(1, 2, figsize=(10.5,4))

sns.barplot(x="Implementation", y="Time (s)", hue="Method", data=df, ax=axes[0])
axes[0].set_yscale('log')
axes[0].set_title('List length = %i words' % (N_words,))

sns.lineplot(x="List length", y="Time (s)", hue="Method", data=df2, marker='o', ax=axes[1])
axes[1].set_yscale('log')
axes[1].set_xscale('log')
axes[1].set_title('Implementation = leven\nList lengths = 250, 1000, 4000, 16000')

1
我尝试了你的解决方案,发现比之前使用pdist快了约10%。尽管只有10%,但已足够好,可以切换到它。 - Tedo Vrbanec
1
很有趣,我想知道为什么。是Python版本、系统细节还是数据的具体信息?感谢分享。 - evces
1
数据:Microsoft Research Paraphrase Corpus(训练子集)。Python 版本:Python 3.9.2。操作系统:Linux Debian 11。硬件:Dell G15,搭载 Intel i7(8 核心,16 线程)。 - Tedo Vrbanec
一个几乎离题的问题:在许多长度不同的文本(字符串)上使用Levenshtein距离的目的是什么?对我来说,如果用于比较两个以上的字符串,我们应该使用归一化的Levenshtein距离,不是吗? - Tedo Vrbanec
1
我错了!不小心注释掉了错误的代码行,所以作为我的代码升级,我只实现了itertools中的组合(之前我使用了自己的函数),这带来了10%的收益。现在我被迫再次处理这个问题,我注意到我没有使用multiprocessing而是pdist(错误的注释行)。我承认错误。这使速度提高了4.33倍。我之前尝试过multiprocessing,但比pdist慢。几乎相同的代码,但没有使用starmap而是使用imap和chunking。谢谢! - Tedo Vrbanec

0
这是一个简洁的解决方案,不需要使用numpy或scipy:
from Levenshtein import jaro_winkler
data = ['Bob','Carl','Kristen','Calr', 'Doug']
dm = [[ jaro_winkler(a, b) for b in data] for a in data]
print('\n'.join([''.join([f'{item:6.2f}' for item in row]) for row in dm]))

  1.00  0.00  0.00  0.00  0.53
  0.00  1.00  0.46  0.93  0.00
  0.00  0.46  1.00  0.46  0.00
  0.00  0.93  0.46  1.00  0.00
  0.53  0.00  0.00  0.00  1.00

0
对于有类似问题的人 - 我刚刚找到的一个解决方案是从pdist函数中提取相关代码,并在jaro_winkler函数输入中添加[0],以从numpy数组中调用字符串。
例如:
X = np.asarray(fname, order='c')
s = X.shape
m, n = s
dm = np.zeros((m * (m - 1)) // 2, dtype=np.double)

k = 0
for i in xrange(0, m - 1):
    for j in xrange(i + 1, m):
        dm[k] = jaro_winkler(X[i][0], X[j][0])
        k = k + 1

dms = squareform(dm)

即使这个算法可行,我仍然想了解是否存在一种“正确”的计算机科学方法来使用pdist函数。谢谢,希望对某些人有所帮助!

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接