Quantcast
Channel: El vol de l'home ocell » Projectes
Viewing all articles
Browse latest Browse all 10

Correcció automàtica en l’Oracle: Trie

$
0
0

Continuem amb la segona alternativa que vaig fer servir per a permetre la correcció automàtica, o si voleu cerca aproximada.

Ja us vaig comentar en l’anterior entrada els problemes que presenta la cerca dicotòmica amb cadenes ordenades per ordre lexicogràfic per a buscar alternatives a una cerca que conté algun error. Semblava doncs que l’ordre lexicogràfic no era suficient per a establir la relació que jo necessitava entre els noms de les persones, així que la segona idea que vaig tenir és la d’utilitzar la famosa distància de Levenshtein.

Sols comentar que quan estava escrivint aquest post em vaig adonar que tenia un greu error en el meu algorisme, de fet vaig haver de canviar-lo. Això em va servir per a entendre bé aquest nou algorisme que explique ací i millorar alguns aspectes en la implementació del meu programa. Eixe és el motiu pel qual m’ha dut quasi una setmana publicar aquesta entrada.

Avisar-vos també que aquest post és molt més llarg i pesat que l’anterior, això sí, molt més interessant també. Els algorismes que ací descric no són cosa meua i poden haver errors en la descripció de l’algorisme o la seva complexitat. Evidentment, aquest blog no tracta de ser una font de referència i no deuríeu de confiar excessivament en els detalls que s’expliquen ja que poden ser imprecisos o inclús erronis. Si us interessa el tema i voleu informació en la que pugueu confiar més, sols heu de cercar els termes en articles de la Wikipedia o els algorismes en publicacions de Google Scholar.

Fet aquest avís, comencem.

Distància de Damerau-Levenshtein

La distància de Levenshtein mesura la distància d’edició entre dues cadenes de text en termes d’insercions, esborrats i substitucions. Això és, el nombre mínim d’aquestes operacions que són necessàries per transformar una cadena A en una altra cadena B. Si volem comptar també les transposicions com a operacions, la mètrica s’anomena distància de Damerau-Levenshtein. L’algorisme utilitzat per a computar aquesta mètrica és un clàssic de qualsevol curs de programació dinàmica.

Intentaré explicar l’algorisme de manera breu. Imaginem que volem calcular la distància d’una cadena X a una cadena Y. Llavors és construeix una matriu D de |X|+1 files i |Y|+1 columnes. L’element D_{ij} indica quina és la distància de la subcadena x_1 \ldots x_i a la subcadena y_1 \ldots y_j. La fila 0 i la columna 0 representen les distàncies de la cadena \epsilon a y_1 \ldots y_j i de la cadena x_1 \ldots x_i a \epsilon respectivament (\epsilon és la cadena buida, aquella que no conté cap símbol).

Doncs bé, la fórmula per a calcular cada element de la taula és la següent.

Ací teniu un exemple de com quedaria matriu per a les cadenes hola i poal.

  \begin{array}{|c|c|c|c|c|c|}  \hline D & \epsilon & p & o & a & l \\  \hline \epsilon & 0 & 1 & 2 & 3 & 4 \\  \hline h & 1 & 1 & 2 & 3 & 4\\  \hline o & 2 & 2 & 1 & 2 & 3\\  \hline l & 3 & 3 & 2 & 2 & 2\\  \hline a & 4 & 4 & 3 & 2 & 2\\  \hline  \end{array}

Si mirem l’element D_{4,4} de la taula, veiem que la distància de Damerau-Levenshtein entre les dues cadenes és 2 (transformacion: hola->pola->poal). Fixeu-vos que és menor que la distància de Levenshtein (que no admet transposicions) i que seria 3.

La gràcia d’aquest algorisme és que pot anar construint-se fila a fila i columna a columna, de manera que el cost temporal de l’algorisme per a dues cadenes de longituds m i n és O(m \cdot n), a l’igual que el cost espacial.

El problema d’aquesta mètrica és el cost: no és lineal. No era una alternativa adequada simplement computar per a cada cerca la seva distància de Damerau-Levenshtein amb els noms de les persones i quedar-me amb aquelles amb un mínim error. Suposant que tenim n persones i la longitud dels seus noms és aproximadament l, el cost temporal d’aquesta cerca seria O(n \cdot l^2), un cost massa alt si el comparem amb el de la cerca dicotòmica (O(l \cdot \log_2 n)).

Millora d’Ukkonen

El primer que hem de tenir clar és que no necessitem saber la distància exacta entre dues paraules. Imaginem que un usuari s’equivoca escrivint la paraula que busca i escriu ocel. Què serà més probable, que l’usuari estigués buscant una paraula que està a una distància 1 (p.ex: ocell) o que estigués buscant una paraula a una distància 5 (p.ex: tomaca)? Realment, sols estem interessats en saber si la distància entre dues cadenes X i Y és menor o igual que un cert llindar k.

Si estem calculant la distància entre dues cadenes i tenim una fila per a la qual, cap element és menor o igual que k, llavors cap element de les següents files serà menor o igual que k.

A més, siga C_i = j, l’última columna j per a la fila i tal que D_{i,j} \leq k, llavors quan construïm la fila i sols haurem de fer-ho fins la columna C_{i-1}+1, ja que els elements de les columnes posteriors tindran un valor D_{i,j} > k.

Ací teniu un exemple de com quedaria la matriu per a les cadenes hola i poal, amb un valor de k igual a 1.

  \begin{array}{|c||c|c|c|c|c|c|}  \hline C & D & \epsilon & p & o & a & l \\  \hline 1 & \epsilon & 0 & 1 &  &  &  \\  \hline 1 & h & 1 & 1 & 2 &  & \\  \hline 2 & o & 2 & 2 & 1 &  & \\  \hline 0 & l & 3 & 3 & 2 & 2 & \\  \hline 0 & a &  &  &  &  & \\  \hline  \end{array}

Si consultem el valor de l’element D_{i,j} aquest sols és vàlid si C_i > 0. Si no és així, sabem que D_{i,j} > k.

Doncs bé, tenint en compte aquesta millora, el cost temporal de l’algorisme es redueix a O(k^2), segons diu la publicació d’on vaig treure l’algorisme.

Així i tot, no resulta interessant aplicar aquest algorisme sobre tots els noms ja que el cost estaria en O(n \times k^2). Per a k xicotetes és molt menor que O(n \times l^2) de la idea original, però que encara es pot millorar.

Necessitava tenir els noms de les persones representats d’alguna manera per a estalviar-me treball reduir aquest cost. I ací és on entren en joc els tries.

Trie

Un trie no és més que un arbre de prefixes. El node arrel representa el prefixe de la cadena buida (o \epsilon). Cada aresta de l’arbre s’etiqueta amb un símbol de l’alfabet \Sigma, de manera que el node representa el prefix format per la concatenació de tots els símbols en les arestes des de l’arrel fins a l’esmentat node.

Vegem-ho més clar en un exemple. Suposem que tenim els noms de Alan, Andrew, Leslie, Johan i John. El trie que representaria tots aquests noms és el següent.

Com vegeu, les fulles de l’arbre corresponen al nom complet d’alguna de les persones: allan, john, johan, andrew i leslie (recorreguent l’arbre en amplada).

Un dels avantatges d’utilitzar un arbre de prefixes és que el cost de buscar una persona no depèn del nombre de persones. Amb una cadena de longitud l, el cost de la cerca és O(l). Simplement hem de descendir de nivell en nivell en l’arbre per a cada lletra del nom.

Compressió de camins

El problema dels tries, com ja podreu deduir de la imatge anterior és l’espai que ocupen en memòria. Suposant de nou que totes les cadenes tenen la mateixa longitud l, en el pitjor dels casos, cap de les n cadenes codificades en l’arbre tindrà un prefixe comú i en aquest cas el nombre de nodes és O(n \cdot l) (encara que això sols passar quan n \leq \vert \Sigma \vert).

A primera vista sembla que no és gran cosa, ja que té el mateix cost temporal que mantenir els noms en un vector. El problema és que necessitem emmagatzemar per a cada node quins són els seus descendents.

Si volem mantenir el cost de cerca O(l), necessitem determinar en cost O(1) per quina branca em de descendir quan busquem una cadena. La resposta en aquest cas és evident: Mantenir un vector de punters amb tants elements com símbols té l’alfabet amb el que treballem. Si hi ha algun descendent en el node per al símbol i, el punter p_i apuntarà a aquest descendent. Mentre que si p_i és nul, sabem que no hi ha cap descendent del node actual per al símbol i. D’aquesta manera, el cost espacial de mantenir l’arbre de prefixos és O( l \cdot n \cdot \vert \Sigma \vert ).

El cost ja no és tan atractiu com semblava. Suposem que el nostre alfabet té 26 caràcters (de la a a la z), que tenim 3 milions de persones i la longitud mitjana dels noms és de 30 caràcters. Suposant que els punters són de 4 bytes, com serien en un processador de 32 bits, mantenir aquesta estructura podria necessitar al voltant de 9GB!

Evidentment, en la realitat el nombre de nodes és molt menor ja que els noms sí que tenen prefixos en comú així i tot en la realitat no només tenim 26 caràcters en l’alfabet: els noms de les persones de IMDB inclouen també números, espais, símbols d’exclamació, caràcters accentuats i caràcters d’altres alfabets que no són el llatí (ciríl·lic o grec, per exemple).

Com solucionem aquest problema? En primer lloc, tractant de reduir el nombre de nodes i això pot fer-se fàcilment utilitzant compressió de camins en l’arbre. Què és la compressió de camins? Fixeu-se en el primer arbre que us he presentat. Veureu que molts dels nodes sols tenen un descendent. No seria possible unir en un únic node tots els nodes que tenen un únic descendent? Això és la compressió de camins i aquest trie s’anomena Radix Tree. Veieu com quedaria l’arbre anterior amb els camins comprimits.

Reduint l’espai dels nodes

Una altra millora que pot fer-se per a reduir l’espai és intentar reduir el nombre de punters que s’emmagatzemen en cada node. Havíem dit que si volem mantenir el cost de cerca en O(l) necessitem un vector de \vert \Sigma \vert punters. Però no podem reduir aquest nombre? La resposta és sí, però a costa d’incrementar el cost de cerca.

Una possible millora és emmagatzemar els punters en un arbre de cerca (en el meu cas he utilitzat el map de la STL de C++). Així, en cada node sols s’emmagatzema un punter per a cada descendent i no per a cada símbol de l’alfabet (per tant, no tenim punters nuls que ocupen memòria). Si en un node no hi ha cap punter per al símbol que busquem, llavors no hi ha descendent per a aquest símbol. D’aquesta manera el nombre de punters en cada node es redueix considerablement, especialment a mesura que es descendeix en l’arbre, que és també quan més nodes hi ha. Penseu que és probable que tinguem noms començant en totes les lletres (el node arrel tindrà tants descendents com símbols té l’alfabet), en canvi és poc probable que tinguem molts noms amb el mateix prefix i amb l’última lletra diferent (els nodes de nivells inferiors tindran pocs descendents).

El problema és que ara el cost de cercar una cadena en el trie és O(l \cdot \log_2 p), on p és el nombre de punters en els nodes (p \leq \vert \Sigma \vert, normalment prou menor).

Amb totes aquestes millores, el meu programa ocupa ara mateixa 3686MB en memòria, que és bastant menys dels 9GB que comentàvem al començament. Hi ha que tenir en compte a més, que aquests 3686MB no són només del trie sinó també de mantenir els títols de les pel·lícules, el graf que relaciona pel·lícules i actors i tota la informació corresponent a cada persona i pel·lícula.

Comentaré també que em vaig endur una desagradable sorpresa al passar el programa d’una màquina de 32 bits a una de 64 bits: els punters van passar d’ocupar 4 bytes a ocupar-ne 8 i clar, emmagatzemar el mateix arbre necessitava el doble de memòria!

Cerca aproximada

Hem parlat molt de com emmagatzemar eficientment el trie, però encara no hem solucionat el problema que havíem plantejat: fer una cerca aproximada donada una cadena X. Ací és on unim l’algorisme de Damerau-Levenshtein explicat abans i els tries.

Hi ha dos coses importants a tenir en compte a l’hora de calcular la distància de Damerau-Levenshtein sobre distintes cadenes:

  1. Al calcular la distància entre la cadena X i la cadena YZ, les |Y|+1 primeres files de la matriu de Damerau-Levenshtein seran iguals a les del càlcul de la distància entre la cadena X i la cadena Y.
  2. Si per al càlcul de la distància entre una cadena X i una cadena Y, totes les columnes de la fila |Y| tenen un valor major que k, cap cadena amb el prefix Y estarà a una distància menor que k de X.

El trie permet estalviar temps ja què la matriu de Damerau-Levenshtein va calculant-se iterativament quan descendim en l’arbre, pel que havíem comentat en la propietat (1). A més, tenint en compte la propietat (2), si per a un node la distància a la cadena buscada és major que un cert llindar k llavors sabem que cap dels descendents d’aquest node tindrà un llindar menor, de manera que no hem d’explorar els descendents.

Algunes consideracions sobre les operacions que apareixen en el pseudocodi. L’operació update(D, C, x) el que fa és actualitzar la matriu D afegint |x| files i computant el valor dels nous elements i el vector C com he explicat en la millora de Ukkonen.

L’operació dist(D', C') el que fa es tornar el valor de l’element D'_{|v| , |w| } tenint en compte que potser aquest element no ha arribat a calcular-se en l’operació update. Aquest valor és la distància que hi ha entre les cadenes v i w si aquesta distància és menor o igual que k, o k+1 en cas contrari.

La gràcia d’aquest algorisme és que té una complexitat temporal de O(k \times {\vert \Sigma \vert}^k), segons l’article que us citava abans. Això suposant que l’arbre de prefixos és complet i que cada node emmagatzema un vector de punters als possibles descendents (és a dir, sense tenir en compte les millores per a reduir espai).

Fixeu-vos que l’algorisme no depèn del nombre de cadenes en el trie! Això significa que podem trobar els noms que més s’aproximen a una cerca feta per l’usuari sense importar quants noms tenim emmagatzemats. Una considerable millora front al cost O(n \times k^2), això sí, sempre que k siga menuda, perquè el cost creix de manera exponencial amb k!

El gràfic que es mostra ací baix és el temps mitjà de resposta per part de l’oracle quan es busca el nom d’un actor que no existeix en la base de dades. El valor de k que utilitze és \min(3, \left\lfloor \frac{|w|}{4} \right\rfloor). Si usés un valor fixe per a k hi hauria molts suggeriments per a noms curts que realment no tenen massa sentit. Anem al cas extrem: imaginem que un usuari busca el nom a, té sentit suggerir-li els noms b, c, d, etc? La distància a tots ells és 1, però és que la longitud de la cadena era 1! Això significaria que l’usuari ha errat en el 100% de la cadena! Jo permet a l’usuari que s’equivoqui fins a un 25% de la longitud de la cadena. Però si la cadena cercada fos molt llarga el valor k seria molt gran, per això pose un límit al número d’errors a 3.

Dir que l’interval de confiança que es mostra és per a un valor crític del 95%. Crec que el gràfic no mereix més explicacions. El temps de resposta mitjà és de 0.208 segons amb totes les millores comentades. També apareix el temps de resposta utilitzant el càlcul de la distància de Damerau-Levenshtein sense la millora de Ukkonen (0.301 segons).

Encara millor?

Des del primer moment vaig tenir com a referència a Google i el seu “Did you mean…?“. I tenia ben clar que els de Google no podien utilitzar un trie gegantí perquè el vocabulari amb el que treballen és enorme: imagineu totes les paraules diferents que poden haver en la WWW i el que suposaria construir un trie com el descrit!

Google utilitza un enfocament basat en aprenentatge automàtic i processament del llenguatge natural. I aquesta va ser la ruta que em vaig proposar seguir i que us explicaré en el pròxim post.

Mentrestant els tries han funcionat prou bé: són ràpids i sobretot obtenen els resultats que l’usuari espera. I poden ser una alternativa senzilla en situacions amb un vocabulari i un alfabet més reduït. Per exemple, per a construir un corrector automàtic en un editor de textos.


Viewing all articles
Browse latest Browse all 10

Latest Images