Me presentaron este problema hace aproximadamente un año cuando se trataba de buscar información ingresada por el usuario sobre una plataforma petrolera en una base de datos de información diversa. El objetivo era hacer algún tipo de búsqueda de cadena difusa que pudiera identificar la entrada de la base de datos con los elementos más comunes.
Parte de la investigación implicó la implementación del algoritmo de distancia de Levenshtein , que determina cuántos cambios se deben realizar en una cadena o frase para convertirla en otra cadena o frase.
La implementación que se me ocurrió fue relativamente simple e implicó una comparación ponderada de la longitud de las dos frases, el número de cambios entre cada frase y si cada palabra se podía encontrar en la entrada de destino.
El artículo está en un sitio privado, así que haré todo lo posible para agregar los contenidos relevantes aquí:
Fuzzy String Matching es el proceso de realizar una estimación similar a la humana de la similitud de dos palabras o frases. En muchos casos, implica identificar palabras o frases que son más similares entre sí. Este artículo describe una solución interna al problema de coincidencia de cadenas difusas y su utilidad para resolver una variedad de problemas que pueden permitirnos automatizar tareas que anteriormente requerían una tediosa participación del usuario.
Introducción
La necesidad de hacer una coincidencia difusa de cadenas surgió originalmente mientras se desarrollaba la herramienta Validator del Golfo de México. Lo que existía era una base de datos de plataformas y plataformas petroleras conocidas del golfo de México, y las personas que compran seguros nos darían información mal escrita sobre sus activos y tuvimos que hacerla coincidir con la base de datos de plataformas conocidas. Cuando se proporcionó muy poca información, lo mejor que pudimos hacer es confiar en que un asegurador "reconozca" al que se refería y solicite la información adecuada. Aquí es donde esta solución automatizada es útil.
Pasé un día investigando métodos de coincidencia de cadenas difusas, y finalmente me topé con el muy útil algoritmo de distancia de Levenshtein en Wikipedia.
Implementación
Después de leer sobre la teoría detrás de esto, implementé y encontré formas de optimizarlo. Así es como se ve mi código en VBA:
'Calculate the Levenshtein Distance between two strings (the number of insertions,
'deletions, and substitutions needed to transform the first string into the second)
Public Function LevenshteinDistance(ByRef S1 As String, ByVal S2 As String) As Long
Dim L1 As Long, L2 As Long, D() As Long 'Length of input strings and distance matrix
Dim i As Long, j As Long, cost As Long 'loop counters and cost of substitution for current letter
Dim cI As Long, cD As Long, cS As Long 'cost of next Insertion, Deletion and Substitution
L1 = Len(S1): L2 = Len(S2)
ReDim D(0 To L1, 0 To L2)
For i = 0 To L1: D(i, 0) = i: Next i
For j = 0 To L2: D(0, j) = j: Next j
For j = 1 To L2
For i = 1 To L1
cost = Abs(StrComp(Mid$(S1, i, 1), Mid$(S2, j, 1), vbTextCompare))
cI = D(i - 1, j) + 1
cD = D(i, j - 1) + 1
cS = D(i - 1, j - 1) + cost
If cI <= cD Then 'Insertion or Substitution
If cI <= cS Then D(i, j) = cI Else D(i, j) = cS
Else 'Deletion or Substitution
If cD <= cS Then D(i, j) = cD Else D(i, j) = cS
End If
Next i
Next j
LevenshteinDistance = D(L1, L2)
End Function
Simple, rápido y una métrica muy útil. Usando esto, creé dos métricas separadas para evaluar la similitud de dos cadenas. Una que llamo "valuePhrase" y otra que llamo "valueWords". valuePhrase es solo la distancia de Levenshtein entre las dos frases, y valueWords divide la cadena en palabras individuales, en función de delimitadores como espacios, guiones y cualquier otra cosa que desee, y compara cada palabra entre sí, resumiendo la más corta Distancia de Levenshtein que conecta dos palabras cualquiera. Esencialmente, mide si la información en una 'frase' está realmente contenida en otra, solo como una permutación de palabras. Pasé unos días como proyecto paralelo para encontrar la forma más eficiente posible de dividir una cadena basada en delimitadores.
Función valueWords, valuePhrase y Split:
Public Function valuePhrase#(ByRef S1$, ByRef S2$)
valuePhrase = LevenshteinDistance(S1, S2)
End Function
Public Function valueWords#(ByRef S1$, ByRef S2$)
Dim wordsS1$(), wordsS2$()
wordsS1 = SplitMultiDelims(S1, " _-")
wordsS2 = SplitMultiDelims(S2, " _-")
Dim word1%, word2%, thisD#, wordbest#
Dim wordsTotal#
For word1 = LBound(wordsS1) To UBound(wordsS1)
wordbest = Len(S2)
For word2 = LBound(wordsS2) To UBound(wordsS2)
thisD = LevenshteinDistance(wordsS1(word1), wordsS2(word2))
If thisD < wordbest Then wordbest = thisD
If thisD = 0 Then GoTo foundbest
Next word2
foundbest:
wordsTotal = wordsTotal + wordbest
Next word1
valueWords = wordsTotal
End Function
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
' SplitMultiDelims
' This function splits Text into an array of substrings, each substring
' delimited by any character in DelimChars. Only a single character
' may be a delimiter between two substrings, but DelimChars may
' contain any number of delimiter characters. It returns a single element
' array containing all of text if DelimChars is empty, or a 1 or greater
' element array if the Text is successfully split into substrings.
' If IgnoreConsecutiveDelimiters is true, empty array elements will not occur.
' If Limit greater than 0, the function will only split Text into 'Limit'
' array elements or less. The last element will contain the rest of Text.
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
Function SplitMultiDelims(ByRef Text As String, ByRef DelimChars As String, _
Optional ByVal IgnoreConsecutiveDelimiters As Boolean = False, _
Optional ByVal Limit As Long = -1) As String()
Dim ElemStart As Long, N As Long, M As Long, Elements As Long
Dim lDelims As Long, lText As Long
Dim Arr() As String
lText = Len(Text)
lDelims = Len(DelimChars)
If lDelims = 0 Or lText = 0 Or Limit = 1 Then
ReDim Arr(0 To 0)
Arr(0) = Text
SplitMultiDelims = Arr
Exit Function
End If
ReDim Arr(0 To IIf(Limit = -1, lText - 1, Limit))
Elements = 0: ElemStart = 1
For N = 1 To lText
If InStr(DelimChars, Mid(Text, N, 1)) Then
Arr(Elements) = Mid(Text, ElemStart, N - ElemStart)
If IgnoreConsecutiveDelimiters Then
If Len(Arr(Elements)) > 0 Then Elements = Elements + 1
Else
Elements = Elements + 1
End If
ElemStart = N + 1
If Elements + 1 = Limit Then Exit For
End If
Next N
'Get the last token terminated by the end of the string into the array
If ElemStart <= lText Then Arr(Elements) = Mid(Text, ElemStart)
'Since the end of string counts as the terminating delimiter, if the last character
'was also a delimiter, we treat the two as consecutive, and so ignore the last elemnent
If IgnoreConsecutiveDelimiters Then If Len(Arr(Elements)) = 0 Then Elements = Elements - 1
ReDim Preserve Arr(0 To Elements) 'Chop off unused array elements
SplitMultiDelims = Arr
End Function
Medidas de similitud
Utilizando estas dos métricas, y una tercera que simplemente calcula la distancia entre dos cadenas, tengo una serie de variables que puedo ejecutar un algoritmo de optimización para lograr el mayor número de coincidencias. La coincidencia de cadenas difusas es, en sí misma, una ciencia difusa, por lo que al crear métricas linealmente independientes para medir la similitud de cadenas y al tener un conjunto conocido de cadenas que deseamos unir entre sí, podemos encontrar los parámetros que, para nuestros estilos específicos de cadenas, da los mejores resultados de partidos difusos.
Inicialmente, el objetivo de la métrica era tener un valor de búsqueda bajo para una coincidencia exacta y aumentar los valores de búsqueda para medidas cada vez más permutadas. En un caso poco práctico, esto fue bastante fácil de definir usando un conjunto de permutaciones bien definidas, y diseñando la fórmula final de modo que tuvieran resultados de valores de búsqueda crecientes según lo deseado.
En la captura de pantalla anterior, modifiqué mi heurística para encontrar algo que sentí que se adaptaba bien a mi diferencia percibida entre el término de búsqueda y el resultado. La heurística que utilicé Value Phrase
en la hoja de cálculo anterior fue =valuePhrase(A2,B2)-0.8*ABS(LEN(B2)-LEN(A2))
. Estaba reduciendo efectivamente la penalización de la distancia de Levenstein en un 80% de la diferencia en la longitud de las dos "frases". De esta manera, las "frases" que tienen la misma longitud sufren la penalización completa, pero las "frases" que contienen "información adicional" (más larga) pero aparte de eso aún comparten los mismos caracteres sufren una penalización reducida. Usé elValue Words
función tal como está, y luego mi SearchVal
heurística final se definió como=MIN(D2,E2)*0.8+MAX(D2,E2)*0.2
- un promedio ponderado. Cualquiera de los dos puntajes más bajos obtuvo una ponderación del 80% y el 20% del puntaje más alto. Esto fue solo una heurística que se adaptaba a mi caso de uso para obtener una buena tasa de coincidencia. Estos pesos son algo que uno podría ajustar para obtener la mejor tasa de coincidencia con sus datos de prueba.
Como puede ver, las dos últimas métricas, que son métricas de coincidencia de cadenas difusas, ya tienen una tendencia natural a dar puntajes bajos a las cadenas que deben coincidir (en la diagonal). Esto es muy bueno.
Aplicación
Para permitir la optimización de la coincidencia difusa, ponderé cada métrica. Como tal, cada aplicación de coincidencia de cadena difusa puede ponderar los parámetros de manera diferente. La fórmula que define el puntaje final es simplemente una combinación de las métricas y sus pesos:
value = Min(phraseWeight*phraseValue, wordsWeight*wordsValue)*minWeight
+ Max(phraseWeight*phraseValue, wordsWeight*wordsValue)*maxWeight
+ lengthWeight*lengthValue
Usando un algoritmo de optimización (la red neuronal es mejor aquí porque es un problema discreto y multidimensional), el objetivo ahora es maximizar el número de coincidencias. Creé una función que detecta el número de coincidencias correctas de cada conjunto entre sí, como se puede ver en esta captura de pantalla final. Una columna o fila obtiene un punto si se asigna el puntaje más bajo a la cadena que debía coincidir, y se otorgan puntos parciales si hay un empate para el puntaje más bajo, y la coincidencia correcta se encuentra entre las cadenas empatadas. Entonces lo optimicé. Puede ver que una celda verde es la columna que mejor coincide con la fila actual, y un cuadrado azul alrededor de la celda es la fila que mejor coincide con la columna actual. El puntaje en la esquina inferior es aproximadamente el número de coincidencias exitosas y esto es lo que le decimos a nuestro problema de optimización para maximizar.
El algoritmo fue un éxito maravilloso, y los parámetros de la solución dicen mucho sobre este tipo de problema. Notará que el puntaje optimizado fue 44, y el mejor puntaje posible es 48. Las 5 columnas al final son señuelos y no tienen ninguna coincidencia con los valores de las filas. Cuantos más señuelos haya, más difícil será encontrar la mejor combinación.
En este caso de coincidencia particular, la longitud de las cadenas es irrelevante, porque esperamos abreviaturas que representen palabras más largas, por lo que el peso óptimo para la longitud es -0.3, lo que significa que no penalizamos cadenas que varían en longitud. Reducimos la puntuación en anticipación de estas abreviaturas, dando más espacio para que las coincidencias parciales de palabras reemplacen las coincidencias que no son palabras que simplemente requieren menos sustituciones porque la cadena es más corta.
El peso de la palabra es 1.0, mientras que el peso de la frase es solo 0.5, lo que significa que penalizamos las palabras enteras que faltan en una cadena y valoramos más que la frase entera esté intacta. Esto es útil porque muchas de estas cadenas tienen una palabra en común (el peligro) donde lo que realmente importa es si la combinación (región y peligro) se mantiene o no.
Finalmente, el peso mínimo se optimiza en 10 y el peso máximo en 1. Lo que esto significa es que si la mejor de las dos puntuaciones (frase de valor y palabras de valor) no es muy buena, la coincidencia se penaliza enormemente, pero no No penaliza en gran medida el peor de los dos puntajes. Esencialmente, esto pone énfasis en exigir que valueWord o valuePhrase tengan una buena puntuación, pero no ambas. Una especie de mentalidad de "tomar lo que podemos obtener".
Es realmente fascinante lo que dice el valor optimizado de estos 5 pesos sobre el tipo de coincidencia de cadena difusa que tiene lugar. Para casos prácticos completamente diferentes de coincidencia de cadenas difusas, estos parámetros son muy diferentes. Lo he usado para 3 aplicaciones separadas hasta ahora.
Si bien no se utilizó en la optimización final, se estableció una hoja de evaluación comparativa que hace coincidir las columnas entre sí para obtener todos los resultados perfectos en la diagonal, y permite al usuario cambiar los parámetros para controlar la velocidad a la que los puntajes difieren de 0 y observar similitudes innatas entre las frases de búsqueda ( que en teoría podría usarse para compensar los falsos positivos en los resultados)
Aplicaciones adicionales
Esta solución tiene potencial para usarse en cualquier lugar donde el usuario desee que un sistema informático identifique una cadena en un conjunto de cadenas donde no hay una coincidencia perfecta. (Como una coincidencia aproximada vlookup para cadenas).
Entonces, lo que debe tomar de esto es que probablemente quiera usar una combinación de heurística de alto nivel (encontrar palabras de una frase en la otra frase, longitud de ambas frases, etc.) junto con la implementación del algoritmo de distancia de Levenshtein. Debido a que decidir cuál es la "mejor" coincidencia es una determinación heurística (difusa): tendrá que encontrar un conjunto de ponderaciones para cualquier métrica que se le ocurra para determinar la similitud.
Con el conjunto apropiado de heurísticas y pesos, tendrá su programa de comparación tomando rápidamente las decisiones que habría tomado.