1. ¿Cómo funciona un corrector ortográfico?¶
- Se crea un vocabulario:
A partir de un corpus se fija un vocabulario
- Identifica la palabra incorrecta $w$:
Identificamos que una palabra es incorrecta, es decir, no se encuentra dentro de nuestro vocabulario.
- Se filtran los posibles candidatos:
Usualmente, se calculan las palabras a 1, 2 o 3 de distancia en términos del número mínimos de pasos de edición (deletes, replaces, inserts) que se necesitan para transformar una palabra a otra. Se filtran los posibles candidatos de manera que se encuentren dentro de nuestro vocabulario.
- Se calcula el más probable en funcion del corpus
Se encuentra la corrección $c$, de todos los candidatos, de forma que maximize la probabilidad de que $c$ sea la palabra correcta. La probabilidad se estima a partir de un «modelo probabilístico» que nos dará los criterios específicos para hacer los cálculos correspondientes.
2. Elementos de un modelo probabilístico generativo¶
La parte fundamental de un corrector ortográfico es dada una palabra errónea $w$ econtrar la mejor corrección $c$ dentro de un conjunto de cantidatos:
$$c_{mejor~corrección} = argmax_{c \in candidatos} P(c|w)$$
Por ejemplo, $P(\mbox{Dulcinea}|\mbox{Dulsinea})$ se puede interpretar como las veces en que $\mbox{Dulcinea}$ es la corrección del error $\mbox{Dulsinea}$. De la misma manera $P(\mbox{Dulcine}|\mbox{Dulsinea})$ las veces que $\mbox{Dulcine}$ es la corrección del error $\mbox{Dulsinea}$. Estas probabilidades son dependientes del corpus de apoyo para estimar estas probabilidades.
El punto de vista generativo plantea estimar esta probabilidad a partir del teorema de Bayes (ver https://www.youtube.com/watch?v=HZGCoVF3YvM):
$$P(c|w) = P(c) \dfrac{P(w|c)}{P(w)}.$$
Tenemos en esta formulación los siguientes elementos:
$P(c|w)$ (p. posterior) la probabilidad del candidato $c$ dado el error $w$: probabilidad de que la hipótesis $c$ es correcta dada la evidencia $w$.
$P(c)$ (prior) la probabilidad previa del candidato $c$ antes de que se observe cualquier error $w$: probabilidad de la hipótesis $c$ antes de cualquier evidencia $w$.
$P(w|c)$ (verosimilitud) la probabilidad de la palabra $w$ dado el candidato $c$: probabilidad de ver la evidencia $w$ si la hipótesis $c$ es cierta.
$P(w)$ (evidencia): probabilidad de que la evidencia es cierta.
Como $P(w)$ es independiente de cualquier candidato $c$, podemos finalmente considerar el siguiente problema de optimización:
$$c_{mejor~corrección} = argmax_{c \in candidatos} P(c) P(w|c)$$
Visto de esta manera, en la ecuación aparecen 4 elementos escenciales de un modelo de autocorrector:
- Selección de mecanismo: $argmax$
Se elige al candidato con mayor probabilidad.
- Modelo de candidato: conjunto $candidatos$
Nos dice qué correcciones candidatos $c$ considerar.
- Modelo de lenguaje: $P(c)$
La probabilidad de que $c$ aparezca en el idioma español.
- Modelo de error: $P(w|c)$
La probabilidad de que $w$ se escriba en un texto cuando el autor quiso decir $c$.
Por ejemplo, P(Dulsine|Dulcinea
se puede interpretar como de las veces que se escribió Dulsinea
dentro de las veces que se quiso escribir Dulcinea}
. En este contextoP(Dulce|Dulcinea)
debería ser mucho menor que P(Dulsinea|Dulcinea
, es decir Dulce
es un error menos común que Dulsinea
como errores de Dulcinea
.
3. Metodología para la creación de un autocorrector¶
3.1. Obtención del corpus¶
Para este taller vamos a entrenar nuestro corrector ortográfico con El Quijote. El corpus se ha descargado de https://fegalaz.usc.es/~gamallo/aulas/lingcomputacional/corpus/quijote-es.txt.
La idea es estimar probabilidad de una palabra, $P(w)$ (modelo de lenguaje), contando el número de veces que aparece cada palabra en el archivo quijote-es.txt. Sin embargo este es un ejemplo de juguete ya que una estimación más precisa debe considerar al menos $1\times 10^9$ de palabras distintas.
Ademas, el texto es muy específico a un momento histórico y geográfico, así que no sería una buen corpus para un corrector actual. Por otro lado, ¡no tenemos corpus para estimar $P(w|c)$! pues tenemos necesitaríamos datos sobre la frecuencia con las que un habitante del reino de Castilla y León del siglo XVII comete errores ortográficos y cuáles son estos. Ese tipo de conjuntos es difícil encontrarlos incluso en el español actual. Veremos más adelantes como sortear este problema. En inglés existe por ejemplo el Birkbeck spelling error corpus https://www.dcs.bbk.ac.uk/~roger/corpora.html. Ver también https://en.wikipedia.org/wiki/Wikipedia:Lists_of_common_misspellings/For_machines.
En el repositorio del taller, los archivos con los documentos que se utilizarán ya vienen en el mismo directorio que esta libreta. Si estás usando Colaboratory de Google entonces tienes que descargar los archivos en el contenedor que te ofrece la plataforma. Para eso ejecuta la celda siguiente:
!curl -O https://raw.githubusercontent.com/mcd-unison/corrector-ortografico/main/quijote.txt
!curl -O https://raw.githubusercontent.com/mcd-unison/corrector-ortografico/main/viage_parmaso.txt
!curl -O https://raw.githubusercontent.com/mcd-unison/corrector-ortografico/main/cien-años-de-soledad.txt
3.2. Creación de vocabulario y análisis exploratorio¶
Primero se va a construir una función words
para tokenizar cualquier texto, esta tokenización se hace de la manera más sencilla posible considerando la expresión regular '\w+' (ver https://docs.python.org/3/library/re.html para más sobre expresiones regulares). Una vez tokenizado nuestro texto quijote.txt
se crea, no solo un el vocabulario, si no un diccionario de frecuencias:
import re
from collections import Counter
def words(text): return re.findall(r'\w+', text.lower())
WORDS = Counter(words(open('quijote.txt', encoding='latin-1').read()))
Nuesto diccionario de frecuencias contiene el vocabulario y también la frecuencia de cada tipo, lo que nos servirá más adelante para estimar la probabildad de una palabra. Un token es una instancia de una secuencia de caracteres en algún documento en particular que se agrupan como una unidad semántica útil para el procesamiento. Un tipo es la clase de todos los tokens diferentes.
WORDS.most_common(30) ## los 30 tipos más frecuentes
La longitud del corpus (N
) es suma de frecuencias de tokens de nuestro diccionario de frecuencias y la cardinalidad del vocabulario (V
) es la cardinalidad del vocabulario de tipos, la cual coincide con la del propio diccionario:
N = sum(WORDS.values())
V = len(WORDS)
print('Longitud de El Quijote (N): ' +
str(N) + '\n' +
'Tamaño del vocabulario del El Quijote (V): ' +
str(V))
Longitud de El Quijote (N): 376516 Tamaño del vocabulario del El Quijote (V): 22602
3.2.1. Análisis de datos¶
Aquí un pequeño análisis de la distribución de los valores del diccionario de frecuencias. Corre y analiza los resultados de los histogramas que se presentan.
import plotly.express as px
def histo_palabras(dicionario, inferior, superior, title_str):
temp = dict(
[(key, val) for key, val in dicionario.items()
if inferior <= val <= superior])
x = list(temp.values())
fig = px.histogram(x, text_auto=True, title=title_str)
fig.update_layout(showlegend=False)
return fig
title_str = 'Histograma de frecuencias de palabras en El Quijote que aparecen de 1 a 10 veces'
histo_palabras(WORDS, 1, 10, title_str).show()
title_str = 'Histograma de frecuencias de palabras en El Quijote que aparecen de 10 a 200 veces'
histo_palabras(WORDS, 10, 200, title_str).show()
title_str = 'Histograma de frecuencias de palabras en El Quijote que aparecen de 200 a 2500 veces'
histo_palabras(WORDS, 200, 2500, title_str).show()
title_str = 'Histograma de frecuencias de palabras en El Quijote que aparecen de 8000 a 21000 veces'
histo_palabras(WORDS, 8000, 21000, title_str).show()
Hápax y riqueza léxica
Según el diccionario Oxford hápax es hápax una palabra o expresión que solo se encuentra documentada una vez en una lengua, un autor o un texto e.g. «la edición crítica de los autores clásicos permite en muchas ocasiones aclarar si determinadas palabras son realmente hápax o se trata de erratas de los copistas».
WORDS_hapax = dict([(key, val) for key, val in WORDS.items() if val == 1])
WORDS_hapax
¿Qué proporción de hápax hay en El Quijote?
tasa_hápax = len(WORDS_hapax)/V
print('Tasa de hápax: ' + str(tasa_hápax) + ' 🤨')
Tasa de hápax: 0.486859569949562 🤨
Índice de riqueza léxica:
$$ H = 100 \frac{\log (\mbox{tamaño corpus})}{1-\mbox{tasa_hápax}}. $$
Para nuestro corpus:
import numpy as np
H = 100 * np.log(N)/(1-tasa_hápax)
print('Índice de riqueza léxica de (todo) El Quijote: ' + str(H.round(2)))
Índice de riqueza léxica de (todo) El Quijote: 2501.99
Close up
import pandas as pd
skip_min = WORDS['dulcinea']
skip_max = WORDS['quijote']
WORDS_reducido = dict(
[(key, val) for key, val in WORDS.items()
if skip_min <= val <= skip_max])
vocabulario_reducido = pd.DataFrame(
WORDS_reducido.items(),
columns = ['palabra','frecuencia']
).sort_values(by='frecuencia')
fig = px.bar(
vocabulario_reducido,
x='palabra', y='frecuencia', color = 'frecuencia')
fig.show()
Repite este ejercicio pero con Cien años de soledad (se anexa el archivo cien-años-de-soledad.txt
) ¿Cuál de los dos textos tiene mayor riquiza léxica?
La siguiente función known recibe una lista de tokens y regresa un conjunto de ____
def known(words):
"Subconjunto de palabras que aparecen en el diccionario WORDS."
return set(w for w in words if w in WORDS)
- ¿Qué se concluye de los histogramas presentados antes de la reducción?
- Encuentra el índice de riqueza léxica con el vocabulario reducido con
skip_min = WORDS['dulcinea']
yskip_max = WORDS['quijote']
.
3.3. Creación de candidatos dado una palabra 𝑤¶
Considera la función edits1
.
def edits1(word):
"Palabras a una distancia _____ de edición de `word`."
letters = 'abcdefghijklmñnopqrstuvwxyzáéíóú'
splits = [(word[:i], word[i:]) for i in range(len(word) + 1)]
deletes = [L + R[1:] for L, R in splits if R]
replaces = [L + c + R[1:] for L, R in splits if R for c in letters]
inserts = [L + c + R for L, R in splits for c in letters]
return set (deletes + replaces + inserts)
La siguiente función
edits1
recibe una palabra $w$ y regresa un conjunto de palabras a distancia ____ de número de ediciones deletes, replaces, inserts. Completa también el comentario en el código.Haz el bosquejo de corrida en papel de
edits1('dulcinea')
.Al considerar textos en español, nuestro conjunto de letras ("letters") posee 32 símbolos. Así, con una cadena de caracteres de longitud $n$, podemos obtener:
- ____ cadenas al eliminar un símbolo de la cadena original
- ____ cadenas al realizar reemplazos
- ____ cadenas al insertar símbolos
Esto es, edits1
tiene $63n +32$ elementos.
Considere ahora la función
def edits2(word):
"Palabras a una distancia _____ de edición de `word`."
return set(e2 for e1 in edits1(word) for e2 in edits1(e1))
Ejemplo de uso
'ayer' in edits2('aller')
La siguiente función
edits2
recibe una palabra y regresa un conjunto de palabras a distancia ____ de número de ediciones deletes, replaces, inserts. Completa el comentario en el código.¿Cuántos elementos tiene
edits2(word)
si la palabra de entrada tiene longitud $n$?Diseña la función
edits3(word)
¿qué inconveniente tiene esta función?
Hasta aquí hemos creado una manera de generar posibles candidatos de corrección, pero claramente hay que pasar por el filtro known
que creamos con nuestro diccionario de frecuencias:
def candidates_version_preliminar(word):
"Posibles canditados a ser la corrección de `word`."
return known(edits2(word))
Ejemplos
candidates_version_preliminar('af')
candidates_version_preliminar('alf')
Mas ejemplos:
candidates_version_preliminar('dulcinea')
candidates_version_preliminar('dulsinea')
candidates_version_preliminar('zancho')
¿Y que pasará con un neologísmo?
candidates_version_preliminar('internet')
3.4. Estimación del mejor candidato¶
Recordar nuestra estrategia original era resolver:
$$ c_{mejor~corrección} = argmax_{c \in candidatos} P(c) P(w|c) $$
Estrategia para calcular $P(c)$ (modelo de lenguaje)¶
Considera el siguiente código:
V = len(WORDS)
N = sum(WORDS.values())
La siguiente funciónP
estima la probabilidad de una función en el lenguaje. Notar que esta estimación está determinada completamente por la información recibida en el corpus.
def P(word):
"Probabilidad de `word`"
return WORDS[word] / N
Ejemplos:
[P('el'), P('quijote'), P('no'), P('usaba'), P('internet')]
[0.021449287679673638, 0.005726184279021343, 0.016665958418765735, 1.0623718513954255e-05, 0.0]
Usualmente los cálculos se hacen con la log $P$ por cuestiones numéricas. Veremos como se ve esta distribución:
def logP(word):
"Log-probabilidad de `word`"
return np.log(P(word))
def distribution(diccionario):
"Distribucción de probabilidad de los valores de `diccionario`"
return list(P(w) for w in diccionario)
def distributionlog(diccionario):
"Distribucción de log-probabilidad de los valores de `diccionario`"
return list(logP(w) for w in diccionario)
fig = px.violin(
distribution(WORDS),
title='Distribución de probabilidad modelo de lenguaje')
fig.show()
fig = px.violin(
distributionlog(WORDS),
title='Distribución de log-probabilidad modelo de lenguaje')
fig.show()
Justifica porqué:
$$ argmax_{c \in candidatos} P(c) P(w|c) = argmax_{c \in candidatos} \log P(c) \log P(w|c). $$
Por otro lado, también se suelen utilizar suavizados para compensar la falta de información que siempre se va a tener aún cuando nuestro corpus sea inmenso o bien para hacer el sistema más robusto. Ejemplos en el modelo de lenguajes de n-gramas https://books.google.com/ngrams/.
Un ejemplo sencillo es el suavizado de Laplace el cual depende de un parámetro $k$:
$$ P_{Lapace}(w,k)= \frac{count(w)+k}{N+kV}. $$
def P_Laplace(word,k):
"Probabilidad de `k`-Laplace de la palabra `word`"
return (WORDS[word] + k) / (N + k * V)
def distribution_Laplace(diccionario,k):
"Distribucción de probabilidad `k`-Laplace de los valores de `diccionario`"
return list(P_Laplace(w,k) for w in diccionario)
- Grafica la distribución de probabilidad de nuestro lenguaje respecto a diferentes estimaciones variando el parámetro $k$. Describe tus observaciones.
- Demuestra que para cualquier $k$, la función $P_{Laplace}$ es efectivamente una distribución de probabilidad, es decir:
$$ \sum_{w\in Vocabulary} P_{Lapace}(w,k)=1 $$
Estrategia para «calcular» $P(w|c)$ (modelo de error)¶
Como hemos establecido anteriormente no tenemos un corpus de errores frecuentes en el Reino de Castilla León del siglo XVII. Sin datos podemos construir un buen modelo de error ortográfico, sin embargo es posible construir un «modelo defectuoso» a partir de la premisa de que las palabras conocidas de distancia de edición 1 son mucho más probables que las palabras conocidas de distancia de edición 2, y mucho más probables que una de distancia de edición 3 y así sucesivamente. Además lo más probable de todo es que la palabra que una palabra conocida con distancia de edición 0.
Entonces la idea es que en lugar de calcular $P(w|c)$ se genere la lista de candidatos en orden de prioridad, dada $w$:
- La palabra original, si es conocida; de lo contrario
- La lista de palabras conocidas a una distancia de edición, si las hay; de lo contrario
- La lista de palabras conocidas a una distancia de edición de dos, si las hay; de lo contrario
- La palabra original, aunque no se conoce.
Entonces no necesitamos multiplicar por un factor $P(w|c)$, porque cada candidato con la prioridad elegida tendrá la misma probabilidad (según nuestro modelo defectuoso). Lo anterior lo podemos lograr con el siguiente código:
def candidates(word):
return (known([word]) or known(edits1(word)) or known(edits2(word)) or [word])
Ejemplos:
candidates('af')
{'a', 'ad', 'ah', 'al', 'ay'}
candidates('alf')
{'al', 'ala', 'alá'}
Más ejemplos:
candidates('dulcinea')
{'dulcinea'}
candidates('dulsinea')
{'dulcinea'}
candidates('zancho')
{'ancho', 'gancho', 'rancho', 'sancho'}
Y con neologísmos:
candidates('internet')
['internet']
Notar que word $\in$ known(edits1(word)) $\subset$ known(edits2(word)) :
'internet' in known(edits1('internet'))
False
known(edits1('sancho')).issubset(known(edits1('sancho')))
True
Estimación final¶
def correction(word):
"Error más probable de `word`."
return max(candidates(word), key=logP)
correction('af')
'a'
correction('alf')
'al'
candidates('internet')
['internet']
correction('zancho')
'sancho'
correction('pusiéredes')
'pudiéredes'
3.5. Evaluación del modelo¶
La siguiente prueba recibe como entrada una secuencia de entradas de la forma (right
, wrong
), ejecuta la funcion correction
sobre cada error wrong
y regresa la proporción de aciertos y fallas, y la cantidad de tiempo de ejecución.
def spelltest(tests, verbose=False):
"Ejecuta la corrección a la entrada `wrong` de en todos los pares (`right`, `wrong`); reporta resultados."
import time
start = time.perf_counter()
good, unknown = 0, 0
n = len(tests)
for right, wrong in tests:
w = correction(wrong)
good += (w == right)
if w!= right:
unknown += (right not in WORDS)
if verbose:
print(f'correction({wrong}) => {w} ({WORDS[w]}); expected {right} ({WORDS[right]})')
dt = time.perf_counter() - start
print(f"{good/n:.0%} de {n} correciones ({unknown/n:.0%} desconocidos) a {n/dt:.0f} palabras por segundo")
El siguiente código genera la lista de (right
, wrong
) dados dos textos tokenizados de entrada:
def genera_pares(original,dictado):
"Regresa lista de pares (`right`, `wrong`)"
test = []
for i in range(len(original)):
if original[i] != dictado[i]:
test.append((original[i],dictado[i]))
return test
Para ejecutar esta función necesitamos un conjunto de prueba, ¡el cual no tenemos! Así que fabricaremos uno. Vamos a descargar las Viage al Parnaso también de Cervantes (tomado de https://www.gutenberg.org/cache/epub/16110/pg16110.txt) (archivo viage_parmaso.txt
). Se tomarán las primeras palabras de este texto:
texto_prueba = re.sub(
r'[^\w+]|_', ' ',
open('viage_parmaso.txt', encoding='utf-8').read().lower()
)
Copia y pega un pedazo de algunas 15 palabras al azar y pégalo dentro de las comillas y después copia de manera rápida el texto que tienes arriba. Verifica el performance del corrector. Repite el procedimiento con varias veces y escribe tus conclusiones. Aquí unos ejemplos:
texto = 'peregrinas veras si en ello adviertes y reparas que es una este bagel de las mas dinas de admiracion'.split()
texto_dictado= 'peregrinas verás si en ello adviertees y reparasd que es una este bagel de las smas dinas de admiracion'.split()
test = genera_pares(texto,texto_dictado)
test
[('veras', 'verás'), ('adviertes', 'adviertees'), ('reparas', 'reparasd'), ('mas', 'smas')]
spelltest(test)
75% de 4 correciones (0% desconocidos) a 4513 palabras por segundo
correction('ambrosía')
'ambrosio'
4. Trabajo futuro¶
- Nuestro corrector tiene problemas en lidiar con palabras desconocidas, una manera de arreglarlo
es permitir que el resultado de la corrección sea una palabra que no hemos visto. Por ejemplo,
si la entrada es
correctoniand
, una buena corrección sería cambiar las
final por unao
, aunquecorrectoniand
no esté en nuestro diccionario. Podríamos lograr esto con un modelo de lenguaje basado en componentes de palabras: quizás en sílabas o sufijos, pero es más fácil basarlo en secuencias de caracteres: secuencias comunes de 2, 3 y 4 letras.
- El modelo de error $P(w|c)$ hasta ahora ha sido trivial: cuanto menor es la distancia de edición, menor es el error. Esto causa algunos problemas, como lo muestran los ejemplos siguientes. Primero, algunos casos en los que la corrección devuelve una palabra en la distancia de edición 1 cuando debería devolver una en la distancia de edición 2. Claramente, podríamos usar un mejor modelo del costo de las ediciones. Podríamos usar nuestra intuición para asignar costos más bajos por duplicar letras y cambiar una vocal por otra vocal (en comparación con un cambio de letra arbitrario), pero parece mejor recopilar datos: obtener un corpus de errores ortográficos y contar la probabilidad de que ocurran.
- Una maner más eficaz es cambiar la interfaz a corrección para ver más contexto. Hasta ahora, la corrección sólo se centra en una palabra a la vez. Para construir un modelo que analice varias palabras a la vez, necesitaremos muchos datos. Google ha publicado una base de datos con recuentos de palabras para todas las secuencias de hasta cinco palabras, recopiladas de un corpus de un billón de palabras (anteriormente dimos la liga para ejemplificar el suavizado), sin embargo, aunque existe una parte con $n$-gramas en español, todavía es muy pobre. Un corrector ortográfico que obtenga una precisión del 90% necesitará utilizar el contexto de las palabras circundantes para tomar una decisión y se requiere de acceso a datos (del orden de al menos $1x10^{12}$) y poder de cómputo y almacenamiento para gestionar y procesar la información.
📖¶
Norvig, How to Write a Spelling Corrector
https://norvig.com/spell-correct.html
.Jurafsky and Martin, Speech and Language Processing
https://home.cs.colorado.edu/~martin/slp.html