【问题标题】:Trying to optimise fuzzy matching尝试优化模糊匹配
【发布时间】:2013-09-25 19:40:27
【问题描述】:

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

  • 亨氏焗豆 400g;
  • 赫兹黑豆 400g;
  • 亨氏豆 400 克。

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

我的计划是使用Jaro–Winkler distance 的实现来查找匹配项。流程如下:

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

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

我对此进行了编码并进行了测试。它运行良好,找到了几十个匹配项进行调查。

将 1 个产品与 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/26(假设均匀分布)
  • 当分数太低时,您能否获得修改后的 GetJaro 方法以提前返回?
  • search.MemberId == match.MemberId &amp;&amp; search.ProductId == match.ProductId 行是怎么回事?您的主列表中是否有多个条目具有相同的MemberIdProductId,它们是不同的 对象实例并且不满足引用检查(Object.ReferenceEquals(match, search))?
  • GetJaro 代码中,我已经可以看出您应该“预先清理”您的ProductName,而不是每次都这样做。单次浏览您的 2,500,000 个项目(或者在您构建列表时,在他们的名字上添加 ToUpper,或存储 CleanedProductName)这一定会大受欢迎。编辑:我的意思是,即使最小化所需的循环数量,也必须像 6,250,002,500,000 ToUpper 调用以及所有字符串字符迭代、字符串创建和垃圾收集。
  • 在做任何比较之前你能Normalize你的输入数据吗?这样做是O(n) 而不是O(n^2)。您可能会发现这样做会使某些项目 string.Equal,这样检查起来会更快。我不知道您的域中的 Normal Form 是什么样的,但它可能涉及使用 ToUpper()、更正拼写错误和替换缩写、删除“and”等。

标签: c# algorithm duplicates grouping fuzzy-logic


【解决方案1】:

恕我直言,您绝对应该发布 GetJaro() 代码,因为它是您程序中需要时间的部分。

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

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

编辑:我必须将其放入答案中。不要使用浮点数,而是使用整数类型。它们的速度要快得多,因为它们不需要 FPU。 我也建议对输入进行规范化,如 ToUpper() 或其他使所有项目在外观上“相似”的东西。

【讨论】:

  • 另外:不要使用浮点变量和通用的“var”类型,因为整数和强类型变量更容易处理(因此更快)。
  • 我相信var 等价于相应的类型,因为它是在编译时而不是在执行时解析的。见:stackoverflow.com/questions/356846/…
  • 我不确定,因为理论上可以稍后将 var 容器用于其他类型。不过你可能是对的。
  • @damaltor: var 只会表示分配给该变量的表达式所产生的类型。例如,var i = 0; 将始终是 int始终var d = 0.0; 将永远是 double。它们在编译时等价于 int i = 0;double d = 0.0;
【解决方案2】:

首先,“外循环”似乎也在循环_pl,因为您有一个matchCount,然后从中取出一个match

如果我在这方面是正确的,那么您的循环计数器 count 应该从 matchCount 开始,这样您就不会测试 a 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 与所有四个字母进行比较。

【讨论】:

  • 是的,这在我所说的问题中有所体现:“将其与列表中的每个产品进行比较并计算“Jaro 分数”;”。这确实减少了一半的处理。
  • @RichardHansell:尽管从您发布的代码来看,它看起来不像。
  • @RichardHansell,您发布的代码未使用此优化。如果将内部循环更改为for(int count = matchCount + 1; ...,则不会重复检查项目,也可以去掉if 语句来检查相同的项目。
  • 是的,为此道歉,我试图“简化”代码,但结果证明这是一个非常糟糕的主意。我已经更新了问题以使这一点更清楚。
【解决方案3】:

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

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

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

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

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2019-06-11
    • 1970-01-01
    • 2012-07-14
    • 2019-05-17
    • 2016-02-11
    • 2021-10-17
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多