试图优化模糊匹配

时间:2013-09-20 12:06:21

标签: c# algorithm grouping big-o fuzzy-logic

我有2,500,000个产品名称,我想尝试将它们组合在一起,即找到名称相似的产品。例如,我可以有三种产品:

  • Heinz Baked Beans 400g;
  • Hz Bkd Beans 400g;
  • Heinz Beans 400g。

实际上是同一产品,可以合并在一起。

我的计划是使用Jaro Winkler的距离算法来实现匹配。该过程如下:

  • 列出内存中所有产品名称的大清单;
  • 选择列表中的第一个产品;
  • 将其与列表中的每个产品进行比较并计算出“Jaro Score”;
  • 报告任何具有高匹配(例如0.95f或更高)的产品;
  • 转到下一个产品。

所以这有一些优化,因为它只是单向匹配每个产品,节省了一半的处理时间。

我对此进行了编码并对其进行了测试。它运作良好,并发现了数十个匹配的调查。

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

显然这不切实际。

我让同事们查看代码,他们设法将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();
        }
    }
}

3 个答案:

答案 0 :(得分:3)

恕我直言,你一定要发布GetJaro()代码,因为它是你需要时间的程序的一部分。

它涉及字符串操作并执行数百万次。如果StackOverflow用户看到更多的改进只会消除一小部分计算时间,那么这将比删除微秒的列表处理带来更多的整体时间改进。

tl; dr:优化需要时间的代码,而不是围绕它的循环。

编辑:我必须把它放到答案中。不要使用浮点数,而是使用整数类型。它们更快,因为它们不需要FPU。 此外,我建议规范化输入,如ToUpper()或其他东西,使所有项目的外观“相似”。

答案 1 :(得分:2)

首先看起来“外圈”也会循环_pl,因为你有一个matchCount,然后从中取出match

如果我是正确的,那么你的循环计数器count应该从matchCount开始,这样你就不会测试vs b,然后再测试b vs a。它将消除您检查项目本身位于循环顶部的需要,并将迭代次数减少一半。

编辑,另一个想法

有些人说你应该预先处理你的匹配字符串,这样你就不会重复ToUpper之类的操作了。如果你这样做,那么你可以做其他事情(可能很小)。

在匹配字符串中搜索双字母。如果找到任何内容,请从匹配字符串中删除它们,但标记您已执行此操作(存储字母加倍的索引列表)。在GetCommonCharacters内部,在与该字母的单个剩余实例进行比较时,只需在循环结束条件中加1。随后的比较也需要调整丢失的字母。具体来说,让你的循环从i - distanceSep转到i + distanceSep + 1(当然要保持最小和最大检查)。

假设您的string1包含“ee”,distanceSep为1.而不是6次比较,您节省了4%,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与所有四个字母进行比较。

答案 2 :(得分:0)

根本问题在于您要比较每对记录。这意味着您必须进行的比较次数为0.5 * N *(N-1)或O(N ^ 2)。

您需要找到一种方法来减少这种情况。有几种方法可以做到这一点,但最简单的事情就是称为&#34;阻塞。&#34;基本上,您将数据分成已经具有某些的记录组,例如common wordfirst three characters。然后你只比较一个区块内的记录。

在最坏的情况下,复杂性仍为O(N ^ 2)。在最好的情况下,它是O(N)。在实践中不会看到最坏或最好的情况。通常,阻止可以将比较次数减少99.9%以上。

dedupe python library实现了许多阻止技术和documentation gives a good overview of the general approach