Person holding a compass in s forest

Búsquedas difusas: Metaphone

Siguiendo con el tema de búsquedas difusas, este posteo trata de un algoritmo fonético que es bastante útil: Metaphone. Esta puede considerarse una versión avanzada del SOUNDEX. Metaphone como algoritmo tiene la ventaja de ser más robusto y está mejor adaptado al uso en múltiples idiomas. Esto es muy importante en el mundo globalizado de hoy.

Por otra parte, Metaphone introduce una mayor complejidad y algo de pérdida en desempeño. La versión original del algoritmo fue escrita en 1990 por Lawrence Philips y fue mejorada por él mismo en el año 2000. Esta versión es la llamada “Double Metaphone” que es la versión más popular del algoritmo. Sobre la función SOUNDEX hice un posteo reciente: Búsquedas Difusas: Función SOUNDEX.

Hace unos 3 o 4 años, me encontré con una versión del algoritmo escrita en SQL por Phil Factor, esta versión está disponible en GitHub en SQLMetaphone. El posteo original en inglés: String Comparisons in SQL: The Metaphone Algorithm. El código de la función tal y como esta escrito en el GitHub es:

IF  OBJECT_ID('dbo.Metaphone','FN') IS NOT NULL --drop any existing metaphone function
   DROP FUNCTION dbo.Metaphone
go
CREATE FUNCTION dbo.Metaphone
/**
summary:   >
The Metaphone  phonetic algorithm was devised by Lawrence Philips in 1990.
It reduces words to their basic sounds, but produces a more accurate encoding,
than Soundex for matching words that sound similar. 
Metaphone is a built-in operator in a number of systems such as PHP but there
seemed to be no available SQL Version until I wrote this. It is merely
a reverse engineering of the original published algorithm but tweaked to ensure
that it gave the same result as the PHP version.
Author: Phil Factor
Revision: 1.0
date: 21 Jan 2017
example: >
	Select dbo.Metaphone ('opportunities')
	--OPRTNTS
Parameters: 
	-- @String (a word -all punctuation will be stripped out)
  A string representing the Metaphone equivalent of the word. 
**/  
(
	@String VARCHAR(30)
)
RETURNS VARCHAR(10)
AS
BEGIN
DECLARE  @New BIT, @ii INT, @Metaphone VARCHAR(28), @Len INT, @where INT;
DECLARE @This CHAR, @Next CHAR, @Following CHAR, @Previous CHAR, @silent BIT;

SELECT @String = UPPER(LTRIM(COALESCE(@String, ''))); --trim and upper case
SELECT @where= PATINDEX ('%[^A-Z]%',@String COLLATE Latin1_General_CI_AI ) 
WHILE  @where>0 --strip out all non-alphabetic characters!
	BEGIN
	SELECT @String=STUFF(@string,@where,1,'')
	SELECT @where=PATINDEX ('%[^A-Z]%',@String COLLATE Latin1_General_CI_AI ) 
    END
IF(LEN(@String) < 2) RETURN  @String

--do the start of string stuff first.
--If the word begins with 'KN', 'GN', 'PN', 'AE', 'WR', drop the first letter.
-- "Aebersold", "Gnagy", "Knuth", "Pniewski", "Wright"
IF SUBSTRING(@String, 1, 2) IN ( 'KN', 'GN', 'PN', 'AE', 'WR' )
  SELECT @String = STUFF(@String, 1, 1, '');
-- Beginning of word: "x" change to "s" as in "Deng Xiaopeng"
IF SUBSTRING(@String, 1, 1) = 'X'
  SELECT @String = STUFF(@String, 1, 1, 'S');
-- Beginning of word: "wh-" change to "w" as in "Whatsoever"
IF @String LIKE 'WH%'
  SELECT @String = STUFF(@String, 1, 1, 'W');
-- Set up for While loop 
SELECT @Len = LEN(@String), @Metaphone = '', -- Initialize the main variable 
  @New = 1, -- this variable only used next 10 lines!!! 
  @ii = 1; --Position counter
--
WHILE((LEN(@Metaphone) <= 8) AND (@ii <= @Len))
  BEGIN --SET up the 'pointers' for this loop-around }
  SELECT @Previous =
    CASE WHEN @ii > 1 THEN SUBSTRING(@String, @ii - 1, 1) ELSE '' END,
    -- originally a nul terminated string }
    @This = SUBSTRING(@String, @ii, 1),
    @Next =
      CASE WHEN @ii < @Len THEN SUBSTRING(@String, @ii + 1, 1) ELSE '' END,
    @Following =
      CASE WHEN((@ii + 1) < @Len) THEN SUBSTRING(@String, @ii + 2, 1) ELSE
                                                                        '' END
   -- 'CC' inside word 
  --SELECT @Previous,@this,@Next,@Following,@New,@ii,@Len,@Metaphone
  /* Drop duplicate adjacent letters, except for C.*/
  IF @This=@Previous AND @This<> 'C' 
	BEGIN
	--we do nothing 
	SELECT @New=0
    END
  /*Drop all vowels unless it is the beginning.*/
  ELSE IF @This IN ( 'A', 'E', 'I', 'O', 'U' )
    BEGIN
    IF @ii = 1 --vowel at the beginning
      SELECT @Metaphone = @This;
    /* B -> B unless at the end of word after "m", as in "dumb", "Comb" */
    END;
  ELSE IF @This = 'B' AND NOT ((@ii = @Len) AND (@Previous = 'M'))
         BEGIN
         SELECT @Metaphone = @Metaphone + 'B';
         END;
         -- -mb is silent 
 /*'C' transforms to 'X' if followed by 'IA' or 'H' (unless in latter case, it is part of '-SCH-',
 in which case it transforms to 'K'). 'C' transforms to 'S' if followed by 'I', 'E', or 'Y'. 
 Otherwise, 'C' transforms to 'K'.*/
  ELSE IF @This = 'C'
         BEGIN -- -sce, i, y = silent 
         IF NOT (@Previous= 'S') AND (@Next IN ( 'H', 'E', 'I', 'Y' )) --front vowel set 
           BEGIN
			   IF(@Next = 'I') AND (@Following = 'A')
				 SELECT @Metaphone = @Metaphone + 'X'; -- -cia- 
			   ELSE IF(@Next IN ( 'E', 'I', 'Y' ))
				 SELECT @Metaphone = @Metaphone + 'S'; -- -ce, i, y = 'S' }
			   ELSE IF(@Next = 'H') AND (@Previous = 'S')
				 SELECT @Metaphone = @Metaphone + 'K'; -- -sch- = 'K' }
			   ELSE IF(@Next = 'H')
				 BEGIN
				   IF(@ii = 1) AND ((@ii + 2) <= @Len) 
					 AND NOT(@Following IN ( 'A', 'E', 'I', 'O', 'U' ))
					   SELECT @Metaphone = @Metaphone + 'K';
				   ELSE
					 SELECT @Metaphone = @Metaphone + 'X';
				   END
           End  
		 ELSE 
           SELECT @Metaphone = @Metaphone +CASE WHEN @Previous= 'S' THEN '' else 'K' end;
         	   -- Else silent 
         END; -- Case C }
  /*'D' transforms to 'J' if followed by 'GE', 'GY', or 'GI'. Otherwise, 'D' 
  transforms to 'T'.*/
  ELSE IF @This = 'D'
         BEGIN
         SELECT @Metaphone = @Metaphone
           + CASE WHEN(@Next = 'G') AND (@Following IN ( 'E', 'I', 'Y' )) --front vowel set 
                 THEN 'J' ELSE 'T' END;
         END;
  ELSE IF @This = 'G'
         /*Drop 'G' if followed by 'H' and 'H' is not at the end or before a vowel. Drop 'G' 
 if followed by 'N' or 'NED' and is at the end.
 'G' transforms to 'J' if before 'I', 'E', or 'Y', and it is not in 'GG'. 
 Otherwise, 'G' transforms to 'K'.*/
    BEGIN
  SELECT @silent = 
    CASE WHEN (@Next = 'H') AND (@Following IN ('A','E','I','O','U'))
	AND (@ii > 1) AND (((@ii+1) = @Len) OR ((@Next = 'n') AND
    (@Following = 'E') AND SUBSTRING(@String,@ii+3,1) = 'D') AND ((@ii+3) = @Len)) 
-- Terminal -gned 
  AND (@Previous = 'i') AND (@Next = 'n')
  THEN 1 
 -- if not start and near -end or -gned.) 
  WHEN (@ii > 1) AND (@Previous = 'D')-- gnuw
    AND (@Next IN ('E','I','Y')) --front vowel set 
  THEN 1 -- -dge, i, y 
  ELSE 0 END
  IF NOT (@silent=1)
    SELECT @Metaphone = @Metaphone 
	+ CASE WHEN (@Next IN ('E','I','Y')) --front vowel set 
      THEN  'J' ELSE  'K' END
  END
  /*Drop 'H' if after vowel and not before a vowel.
  or the second char of  "-ch-", "-sh-", "-ph-", "-th-", "-gh-"*/

  ELSE IF @This = 'H'
    BEGIN
    IF NOT ( (@ii= @Len) OR (@Previous IN  ( 'C', 'S', 'T', 'G' ))) 
	   AND (@Next IN ( 'A', 'E', 'I', 'O', 'U' ) )
     SELECT @Metaphone = @Metaphone + 'H';
         -- else silent (vowel follows) }
    END;
  ELSE IF @This IN --some get no substitution
       ( 'F', 'J', 'L', 'M', 'N', 'R' )
     BEGIN
     SELECT @Metaphone = @Metaphone + @This;
     END;
  /*'CK' transforms to 'K'.*/
  ELSE IF @This = 'K'
     BEGIN
     IF(@Previous <> 'C')
       SELECT @Metaphone = @Metaphone + 'K';
     END;
  /*'PH' transforms to 'F'.*/
  ELSE IF @This = 'P'
    BEGIN
      IF(@Next = 'H') SELECT @Metaphone = @Metaphone + 'F', @ii = @ii + 1;
      -- Skip the 'H' 
      ELSE
        SELECT @Metaphone = @Metaphone + 'P';
      END;
  /*'Q' transforms to 'K'.*/
  ELSE IF @This = 'Q'
    BEGIN
      SELECT @Metaphone = @Metaphone + 'K';
    END;
  /*'S' transforms to 'X' if followed by 'H', 'IO', or 'IA'.*/
  ELSE IF @This = 'S'
    BEGIN
    SELECT @Metaphone = @Metaphone + 
	  CASE 
		WHEN(@Next = 'H')
		 OR( (@ii> 1) AND (@Next = 'i') 
		  AND (@Following IN ( 'O', 'A' ) )
		  ) 
		THEN 'X' ELSE 'S' END;
     END;
  /*'T' transforms to 'X' if followed by 'IA' or 'IO'. 'TH' transforms 
to '0'. Drop 'T' if followed by 'CH'.*/
  ELSE IF @This = 'T'
    BEGIN
    SELECT @Metaphone = @Metaphone
      + CASE 
	    WHEN(@ii = 1) AND (@Next = 'H') AND (@Following = 'O') 
	       THEN 'T' -- Initial Tho- }
        WHEN(@ii > 1) AND (@Next = 'i') 
		     AND (@Following IN ( 'O', 'A' )) 
		  THEN 'X'
        WHEN(@Next = 'H') THEN '0'
        WHEN NOT((@Next = 'C') AND (@Following = 'H')) 
		  THEN 'T'
        ELSE '' END;
         -- -tch = silent }
    END;
  /*'V' transforms to 'F'.*/
  ELSE IF @This = 'V'
    BEGIN
    SELECT @Metaphone = @Metaphone + 'F';
    END;
  /*'WH' transforms to 'W' if at the beginning. Drop 'W' if not followed by a vowel.*/
  /*Drop 'Y' if not followed by a vowel.*/
  ELSE IF @This IN ( 'W', 'Y' )
    BEGIN
    IF @Next IN ( 'A', 'E', 'I', 'O', 'U' )
      SELECT @Metaphone = @Metaphone + @This;
     --else silent 
     /*'X' transforms to 'S' if at the beginning. Otherwise, 'X' transforms to 'KS'.*/
    END;
  ELSE IF @This = 'X'
    BEGIN
      SELECT @Metaphone = @Metaphone + 'KS';
    END;
  /*'Z' transforms to 'S'.*/
  ELSE IF @This = 'Z'
     BEGIN
       SELECT @Metaphone = @Metaphone + 'S';
     END;
  ELSE
  RETURN 'error with '''+ @This+ '''';
  -- end
  SELECT @ii = @ii + 1;
  END; -- While 
return @Metaphone 
END
go

La ventaja de esta función es que sus resultados son mucho más precisos y el costo de desempeño es bajo, siempre y cuando se use SQL 2019. No puedo recomendar que se use esta función en versiones anteriores de SQL, ya que esta implementada como una función escalar lo produce muchísimos problemas de rendimiento en versiones de SQL 2017 para abajo.

Por si acaso, y para enfatizar el punto anterior, las funciones escalares son asesinas del desempeño de servidores. Microsoft mismo lo dice que en su documentación en línea. Puntualmente da 4 razones por lo que tienen mal desempeño: Ejecución en serie, ejecución interpretada, falta de costos e invocación iterativa. Puedes leer el artículo en inglés: Scalar UDF Inlining. Como testimonio de práctica profesional, te cuento que, en una consultoría reciente, tres funciones escalares eran las responsables del 90% de consumo de CPU del servidor, y reescribir estas funciones a funciones in-line, incremento el desempeño de forma significativa: consultas que tomaban 10 minutos se ejecutaban en menos de 5 segundos. En SQL 2019 se eliminan estos problemas de rendimiento de las funciones escalares.

Siguiendo con Metaphone, si aplicamos la función al padrón electoral de Costa Rica (3.4 Millones de filas aproximadamente), la siguiente consulta genera el siguiente gráfico:

SELECT Nombre, dbo.Metaphone(Nombre)  AS MKey
   , COUNT(*) AS NCount
   , SUM(COUNT(*)) 
       OVER(PARTITION BY dbo.Metaphone(Nombre)  ORDER BY COUNT(*)
       ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
       )  AS MCount
FROM dbo.Padron
GROUP BY Nombre
HAVING COUNT(*)>1000
ORDER BY 4 DESC, 2,3 DESC,1;

Si se compara con el Soundex, vemos que Metapone es mucho más específico y genera como caso más popular el MRTLSNJLS que representa la llave de Maria de los Ángeles seguida del código MRLN que representa a los nombres: Maria Elena, Marlene, Marilyn y Marlon. Soundex tenía como llave más popular la M600 con 139,028 filas que agrupaban a todas las Maria, Mario, Mayra, etc.

Al igual que en el caso anterior, el uso principal de esta función es crear índices para optimizar búsquedas difusas, y rara vez es analítico.

Una respuesta a «Búsquedas difusas: Metaphone»

  1. […] semana me he encontrado en el blog de Javier Loría, un artículo a echar un ojo, sobre un algoritmo fonético […]

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *