尝试优化模糊匹配

17
我有250万个产品名称,想要将它们分组,即找到名称相似的产品。例如,我可能有三种产品:
- Heinz烤豆400g; - Hz Bkd Beans 400g; - Heinz Beans 400g。
实际上它们是同一种产品,可以合并在一起。
我的计划是使用Jaro-Winkler距离的实现来查找匹配项。该过程如下:
- 在内存中制作所有产品名称的大列表; - 选择列表中的第一个产品; - 将其与列表中其后的每个产品进行比较,并计算“Jaro分数”; - 报告任何具有高匹配度(例如0.95f或更高)的产品; - 移动到下一个产品。
因此,这样做在优化方面有一些好处,它只单向匹配每个产品,节省了一半的处理时间。

我编写了这个程序并进行了测试。它完美地工作,并找到了数十个匹配项需要调查。

将一个产品与其他2,500,000个产品进行比较并计算“Jaro分数”大约需要20秒钟。假设我的计算是正确的,这意味着完成处理需要最长一年的时间。

显然这不是实际可行的。

我的同事们已经检查过代码,并成功地提高了“Jaro分数”计算部分的速度20%。他们使该过程成为多线程,并且这使得它稍微快了一些。我们还删除了一些信息片段,将其缩减为仅包含产品名称和唯一标识符的列表;这似乎对处理时间没有任何影响。

尽管有这些改进,我们仍认为这需要几个月的时间来处理,而我们需要它在几小时内完成(或者最多几天)。

我不想详细讨论,因为我认为这并不完全相关,但我将产品详细信息加载到列表中:

private class Product
{
    public int MemberId;
    public string MemberName;
    public int ProductId;
    public string ProductCode;
    public string ProductName;
}
private class ProductList : List<Product> { }
private readonly ProductList _pl = new ProductList();

然后我使用以下内容来处理每个产品:
{Outer loop...
var match = _pl[matchCount];

for (int count = 1; count < _pl.Count; count++)
{
    var search = _pl[count];
    //Don't match products with themselves (redundant in a one-tailed match)
    if (search.MemberId == match.MemberId && search.ProductId == match.ProductId)
        continue;
    float jaro = Jaro.GetJaro(search.ProductName, match.ProductName);

    //We only log matches that pass the criteria
    if (jaro > target)
    {
        //Load the details into the grid
        var row = new string[7];
        row[0] = search.MemberName;
        row[1] = search.ProductCode;
        row[2] = search.ProductName;
        row[3] = match.MemberName;
        row[4] = match.ProductCode;
        row[5] = match.ProductName;
        row[6] = (jaro*100).ToString("#,##0.0000");
        JaroGrid.Rows.Add(row);
    }
}

我认为在这个问题的目的上,我们可以假设Jaro.GetJaro方法是一个“黑盒子”,也就是说,它的工作方式并不重要,因为这部分代码已经尽可能地进行了优化,我无法想象如何改进它。
是否有更好的方法来模糊匹配这个产品列表呢?
我想知道是否有一种“聪明”的方法来预处理列表,以便在匹配过程的开始时获得大多数匹配项。例如,如果比较所有产品需要3个月,但只需要3天来比较“可能”的产品,那么我们可以接受这种情况。
好的,有两个常见的事情出现了。首先,是的,我确实利用了单尾匹配过程。真正的代码是:
for (int count = matchCount + 1; count < _pl.Count; count++)

我后悔发布修改版;我试图简化一下(这是个坏主意)。
其次,很多人想看Jaro代码,那么这里就是(它相当长,它原本不是我的 - 我甚至可能在这里找到它?)。顺便说一句,我喜欢在指示出不匹配的情况下,在完成之前退出的想法。我现在会开始研究它!
using System;
using System.Text;

namespace EPICFuzzyMatching
{
    public static class Jaro
    {
        private static string CleanString(string clean)
        {
            clean = clean.ToUpper();
            return clean;
        }

        //Gets the similarity of the two strings using Jaro distance
        //param string1 the first input string
        //param string2 the second input string
        //return a value between 0-1 of the similarity
        public static float GetJaro(String string1, String string2)
        {
            //Clean the strings, we do some tricks here to help matching
            string1 = CleanString(string1);
            string2 = CleanString(string2);

            //Get half the length of the string rounded up - (this is the distance used for acceptable transpositions)
            int halflen = ((Math.Min(string1.Length, string2.Length)) / 2) + ((Math.Min(string1.Length, string2.Length)) % 2);

            //Get common characters
            String common1 = GetCommonCharacters(string1, string2, halflen);
            String common2 = GetCommonCharacters(string2, string1, halflen);

            //Check for zero in common
            if (common1.Length == 0 || common2.Length == 0)
                return 0.0f;

            //Check for same length common strings returning 0.0f is not the same
            if (common1.Length != common2.Length)
                return 0.0f;

            //Get the number of transpositions
            int transpositions = 0;
            int n = common1.Length;
            for (int i = 0; i < n; i++)
            {
                if (common1[i] != common2[i])
                    transpositions++;
            }
            transpositions /= 2;

            //Calculate jaro metric
            return (common1.Length / ((float)string1.Length) + common2.Length / ((float)string2.Length) + (common1.Length - transpositions) / ((float)common1.Length)) / 3.0f;
        }

        //Returns a string buffer of characters from string1 within string2 if they are of a given
        //distance seperation from the position in string1.
        //param string1
        //param string2
        //param distanceSep
        //return a string buffer of characters from string1 within string2 if they are of a given
        //distance seperation from the position in string1
        private static String GetCommonCharacters(String string1, String string2, int distanceSep)
        {
            //Create a return buffer of characters
            var returnCommons = new StringBuilder(string1.Length);

            //Create a copy of string2 for processing
            var copy = new StringBuilder(string2);

            //Iterate over string1
            int n = string1.Length;
            int m = string2.Length;
            for (int i = 0; i < n; i++)
            {
                char ch = string1[i];

                //Set boolean for quick loop exit if found
                bool foundIt = false;

                //Compare char with range of characters to either side
                for (int j = Math.Max(0, i - distanceSep); !foundIt && j < Math.Min(i + distanceSep, m); j++)
                {
                    //Check if found
                    if (copy[j] == ch)
                    {
                        foundIt = true;
                        //Append character found
                        returnCommons.Append(ch);
                        //Alter copied string2 for processing
                        copy[j] = (char)0;
                    }
                }
            }
            return returnCommons.ToString();
        }
    }
}

鉴于这个问题仍然有一些浏览量,我想快速更新一下发生了什么:

  • 我真希望我最初发布的代码实际上是我正在使用的代码,因为人们仍然告诉我要减少一半迭代次数(显然没有阅读超过第一段或更多);
  • 我采纳了这里提出的一些建议,以及其他人在SO之外提出的一些建议,并将运行时间缩短到约70小时;
  • 主要改进是预处理数据,只考虑与其相关联的销售数量相当高的项目。不是很好,但工作量大大减小;
  • 我的笔记本电脑过热,所以我在冰箱里放了一个周末来运行大部分工作。通过这样做,我学到了冰箱不是笔记本电脑的好环境(太潮湿),我的笔记本电脑在大约一周后就死了;
  • 最终结果是我达到了我想做的事情,也许不如我希望的全面,但总体上我认为它是成功的;
  • 为什么我没有接受答案?嗯,实际上以下答案都没有完全解决我的初始问题,尽管它们大多有所帮助(在我首次发布此问题几年后提出的一些答案当然没有帮助),但我觉得挑选一个作为“答案”是不公平的。

1
我认为你需要添加几个启发式算法。你更了解匹配通常的样子,但根据你的例子,一个可能是按每个产品的第一个字母分组,然后仅在同一字母组内进行比较。这样每个产品只需要与总数的1/26进行比较(假设分布均匀)。 - mao47
3
你能否修改GetJaro方法,使其在得分明显过低时提前返回? - Rob
3
从“GetJaro”代码中,我已经看到你应该进行“预清洗”你的“ProductName”,而不是每次都这样做。通过2,500,000个项目进行一次单独的遍历(或在构建列表时对它们的名称执行ToUpper,或存储CleanedProductName)这必须是一个巨大的提升。编辑:我的意思是,即使最小化所需循环的数量,那也得像6,250,002,500,000个ToUpper调用一样,还有所有字符串字符迭代、字符串创建和垃圾收集。 - Chris Sinclair
2
在进行任何比较之前,您能否对输入数据进行“规范化”处理?这样做的时间复杂度为O(n)而不是O(n^2)。您可能会发现这样做会使一些项变成string.Equal,这样检查起来更快。我不知道在您的领域中什么样的规范形式,但它可能涉及使用ToUpper(),纠正拼写错误和替换缩写,删除“and”等。 - Rob
1
为了提供更多反馈,这里列举了我们尝试过的一些方法以及所得到的性能提升。预处理产品名称可以提高10-15%的性能,放弃Common2可以提高1%的性能。到目前为止最大的改进是构建代码的发布版本而不是在Visual Studio中运行它 - 这可以提高50%的性能!! - Richard Hansell
显示剩余16条评论
3个回答

3
首先看起来“外层循环”也在遍历_pl,因为您有一个matchCount,然后从中取出一个match
如果我正确的话,那么您的循环计数器count应该从matchCount开始,这样您就不会再次测试a和b,然后再次测试b和a。它将消除您需要在循环顶部检查项目本身的需求,并将您的迭代次数减半。
编辑,另一个想法
有些人说你应该预处理匹配字符串,这样你就不会重复执行像ToUpper这样的操作。如果您这样做,这里有一些其他(可能较小)的事情可以做。
搜索匹配字符串以查找双字母。如果找到任何一个,请从匹配字符串中删除它们,但标记表示您已经这样做了(存储字母重复的索引列表)。在GetCommonCharacters中,当与该字母的单个剩余实例进行比较时,只需将循环结束条件加1即可。随后的比较也需要调整缺少的字母。具体来说,使您的循环从i - distanceSepi + distanceSep + 1(当然保持最小和最大检查)。
假设您的string1包含“ee”,distanceSep为1。您只需进行4次比较,而不是6次,请节省33%。当distanceSep更大时,节省的时间更多。如果是2,则从10下降至6,可节省40%。
一个例子,如果那很困惑。 string1具有“ee”,而string2仅具有“abcd”,因此它不匹配。distanceSep为1。不必再比较“e / a”,“e / b”,“e / c”...然后比较“e / b”,“e / c”,“e / d”,请删除string1中的第二个'e',并仅将该'e'与四个字母进行比较。

是的,在我提到“将其与列表中之后的每个产品进行比较并计算“Jaro分数””时已经涵盖了这一点。这确实将处理量减少了一半。 - Richard Hansell
@RichardHansell:从您发布的代码来看,似乎并不是这样。 - Chris Sinclair
1
@RichardHansell,你发布的代码没有使用这个优化。如果你将内部循环改为for(int count = matchCount + 1; ...,你就不会重复检查项目,也可以摆脱检查相同项目的if语句。 - mao47
是的,对此我表示歉意。我试图“简化”代码,但结果证明这是一个非常糟糕的想法。我已经更新了问题以使其更清晰。 - Richard Hansell

3

在我看来,你应该肯定地发布GetJaro()代码,因为它是你的程序中需要时间的部分。

它涉及字符串操作并被执行数百万次。如果StackOverflow用户看到更多可以改进的地方,这将比删除列表处理的微秒更大程度地提高整体时间。

简而言之:优化需要时间的代码,而不是其周围的循环。

编辑:我必须在答案中加上这个。不要使用浮点数,而是使用整型。它们比FPU快得多。 此外,我建议规范化输入,例如使用ToUpper()或其他方法使所有项在外观上都“类似”。


同时,不要使用浮点变量和通用的“var”类型,因为整数和强类型变量更易于处理(因此更快)。 - damaltor
3
我认为 var 在编译时就会被解析成对应的类型,而不是在执行时。参见:https://dev59.com/hHRC5IYBdhLWcg3wROtQ - mao47
我不确定这一点,因为理论上以后可能会将var容器用于其他类型。不过你说的也有道理。 - damaltor
3
“var”只代表与所赋值表达式结果相关的数据类型。例如,“var i = 0;”永远是一个“int”,始终如一。“var d = 0.0;”永远是一个“double”。在编译时,它们与“int i = 0;”和“double d = 0.0;”是_等价的。 - Chris Sinclair

1
基本问题在于你正在比较每一对记录。这意味着你必须进行的比较次数为0.5 * N * (N-1),或O(N^2)。
你需要找到一种方法来减少这个数量。有几种方法可以做到这一点,但最简单的方法是称为“阻塞”。基本上,你将你的数据分成具有某些共同点的记录组,例如“共同单词”或“前三个字符”。然后只在一个块内比较记录。
在最坏的情况下,复杂度仍然是O(N^2)。在最好的情况下,它是O(N)。在实践中,通常可以通过阻塞将比较次数减少超过99.9%。 dedupe python library 实现了许多阻塞技术,文档提供了对一般方法的良好概述

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