Este resumen se corresponde con el capítulo 5 del NLTK Book Categorizing and Tagging Words. La lectura del capítulo es muy recomendable.
NLTK propociona varias herramientas para poder crear fácilmente etiquetadores morfológicos (part-of-speech taggers). Veamos algunos ejemplos.
Para empezar, necesitamos importar el módulo nltk
que nos da acceso a todas las funcionalidades:
In [1]:
import nltk
Como primer ejemplo, podemos utilizar la función nltk.pos_tag
para etiquetar morfológicamente una oración en inglés, siempre que la especifiquemos como una lista de palabras o tokens.
In [2]:
oracion1 = "This is the lost dog I found at the park".split()
oracion2 = "The progress of the humankind as I progress".split()
print(nltk.pos_tag(oracion1))
print(nltk.pos_tag(oracion2))
El etiquetador funciona bastante bien aunque comete errores, obviamente. Si probamos con la famosa frase de Chomksy detectamos palabras mal etiquetadas.
In [3]:
oracion3 = "Green colorless ideas sleep furiously".split()
print(nltk.pos_tag(oracion3))
print(nltk.pos_tag(["My", "name", "is", "Prince"]))
print(nltk.pos_tag('He was born during the summer of 1988'.split()))
print(nltk.pos_tag("She's Tony's sister".split()))
print(nltk.pos_tag('''My name is Xrtwewvdk'''.split()))
¿Cómo funciona este etiquetador? nltk.pos_tag
es un etiquetador morfológico basado en aprendizaje automático. A partir de miles de ejemplos de oraciones etiquetadas manualmente, el sistema ha aprendido, calculando frecuencias y generalizando cuál es la categoría gramatical más probable para cada token.
Como sabes, desde NLTK podemos acceder a corpus que ya están etiquetados. Vamos a utilizar alguno de los que ya conoces, el corpus de Brown, para entrenar nuestros propios etiquetadores.
Para ello importamos el corpus de Brown y almacenamos en un par de variables las noticias de este corpus en su versión etiquetada morfológicamente y sin etiquetar.
In [4]:
from nltk.corpus import brown
brown_sents = brown.sents(categories="news")
brown_tagged_sents = brown.tagged_sents(categories="news")
Para comparar ambas versiones, podemos imprimir la primera oración de este corpus en su versión sin etiquetas (fíjate que se trata de una lista de tokens, sin más) y en su versión etiquetada (se trata de una lista de tuplas donde el primer elemento es el token y el segundo es la etiqueta morfológica).
In [5]:
# imprimimos la primera oración de las noticias de Brown
print(brown_sents[0]) # sin anotar
print(brown_tagged_sents[0]) # etiquetada morfológicamente
NLTK nos da acceso a tipos de etiquetadores morfológicos. Veamos cómo utilizar algunos de ellos.
A la hora de enfrentarnos al etiquetado morfológico de un texto, podemos adoptar una estrategia sencilla consistente en etiquetar por defecto todas las palabras con la misma categoría gramatical. Con NLTK podemos utilizar un DefaultTagger
que etiquete todos los tokens como sustantivo. Las categoría sustantivo singular (NN
en el esquema de etiquetas de Treebank) suele ser la más frecuente. Veamos qué tal funciona.
In [6]:
defaultTagger = nltk.DefaultTagger('NN')
print(defaultTagger.tag(oracion1))
print(defaultTagger.tag(oracion2))
Obviamente no funciona bien, pero ojo, en el ejemplo anterior con oracion1
hemos etiquetado correctamente 2 de 10 tokens. Si lo evaluamos con un corpus más grande, como el conjunto de oraciones de Brown que ya tenemos, obtenemos una precisión superior al 13%:
In [7]:
defaultTagger.evaluate(brown_tagged_sents)
Out[7]:
el método .evaluate
que podemos ejecutar con cualquier etiquetador si especificamos como argumento una colección de referencia que ya esté etiquetada, nos devuelve un número: la precisión. Esta precisión se calcula como el porcentaje de tokens correctamente etiquetados por el tagger, teniendo en cuenta el corpus especificado como referencia.
Obviamente, los resultados son malos. Probemos con otras opciones más sofisticadas.
Las expresiones regulares consisten en un lenguaje formal que nos permite especificar cadenas de texto. Ya las hemos utilizado en ocasiones anteriores. Pues bien, ahora podemos probar a definir distintas categorías morfológicas a partir de patrones, al menos para fenómenos morfológicos regulares.
A continuación definimos la variable patrones
como una lista de tuplas, cuyo primer elemento se corresponde con la expresion regular que queremos capturar y el segundo elemento como la categoría gramatical. Y a partir de estas expresiones regulares creamos un RegexpTagger
.
In [8]:
patrones = [
(r'[Aa]m$', 'VBP'), # irregular forms of 'to be'
(r'[Aa]re$', 'VBP'), #
(r'[Ii]s$', 'VBZ'), #
(r'[Ww]as$', 'VBD'), #
(r'[Ww]ere$', 'VBD'), #
(r'[Bb]een$', 'VBN'), #
(r'[Hh]ave$', 'VBP'), # irregular forms of 'to be'
(r'[Hh]as$', 'VBZ'), #
(r'[Hh]ad$', 'VBD'), #
(r'^I$', 'PRP'), # personal pronouns
(r'[Yy]ou$', 'PRP'), #
(r'[Hh]e$', 'PRP'), #
(r'[Ss]he$', 'PRP'), #
(r'[Ii]t$', 'PRP'), #
(r'[Tt]hey$', 'PRP'), #
(r'[Aa]n?$', 'AT'), #
(r'[Tt]he$', 'AT'), #
(r'[Ww]h.+$', 'WP'), # wh- pronoun
(r'.*ing$', 'VBG'), # gerunds
(r'.*ed$', 'VBD'), # simple past
(r'.*es$', 'VBZ'), # 3rd singular present
(r'[Cc]an(not|n\'t)?$', 'MD'), # modals
(r'[Mm]ight$', 'MD'), #
(r'[Mm]ay$', 'MD'), #
(r'.+ould$', 'MD'), # modals: could, should, would
(r'.*ly$', 'RB'), # adverbs
(r'.*\'s$', 'NN$'), # possessive nouns
(r'.*s$', 'NNS'), # plural nouns
(r'-?[0-9]+(.[0-9]+)?$', 'CD'), # cardinal numbers
(r'^to$', 'TO'), # to
(r'^in$', 'IN'), # in prep
(r'^[A-Z]+([a-z])*$', 'NNP'), # proper nouns
(r'.*', 'NN') # nouns (default)
]
regexTagger = nltk.RegexpTagger(patrones)
In [9]:
print(regexTagger.tag("I was taking a sunbath in Alpedrete".split()))
print(regexTagger.tag("She would have found 100 dollars in the bag".split()))
print(regexTagger.tag("DSFdfdsfsd 1852 to dgdfgould fXXXdg in XXXfdg".split()))
Cuando probamos a evaluarlo con un corpus de oraciones más grande, vemos que nuestra precisión sube por encima del 32%.
In [10]:
regexTagger.evaluate(brown_tagged_sents)
Out[10]:
Antes hemos dicho que la función nltk.pos_tag
tenía un etiquetador morfológico que funcionaba con información estadística. A continuación vamos a reproducir, a pequeña escala, el proceso de entrenamiento de un etiquetador morfológico basado en aprendizaje automático.
En general, los sistemas de aprendizaje automático funcionan del siguiente modo:
Nosotros tenemos un pequeño corpus de ejemplos etiquetados: las oraciones del corpus de Brown de la categoría "noticias". Lo primero que necesitamos hacer es separar nuestros corpus de entrenamiento y test. En este caso, vamos a reservar el primer 90% de las oraciones para el entrenamiento (serán los ejemplos observados a partir de los cuales nuestro etiquetador aprenderá) y vamos a dejar el 10% restante para comprobar qué tal funciona.
In [11]:
print(len(brown_tagged_sents))
print((len(brown_tagged_sents) * 90) / 100)
In [12]:
size = int(len(brown_tagged_sents) * 0.9) # asegúrate de que conviertes esto a entero
corpusEntrenamiento = brown_tagged_sents[:size]
corpusTest = brown_tagged_sents[size:]
# como ves, estos corpus contienen oraciones diferentes
print(corpusEntrenamiento[0])
print(corpusTest[0])
A continuación vamos a crear un etiquetador basado en unigramas (secuencias de una palabra o palabras sueltas) a través de la clase nltk.UnigramTagger
, proporcionando nuestro corpusEntrenamiento
para que aprenda. Una vez entrenado, vamos a evaluar su rendimiento sobre corpusTest
.
In [13]:
unigramTagger = nltk.UnigramTagger(corpusEntrenamiento)
print(unigramTagger.evaluate(corpusTest))
In [14]:
# ¿qué tal se etiquetan nuestras oraciones de ejemplo?
print(unigramTagger.tag(oracion1))
print(unigramTagger.tag(oracion2))
print(unigramTagger.tag(oracion3))
Los etiquetadores basados en unigramas se construyen a partir del simple cálculo de una distribución de frecuencia para cada token (palabra) y asignan siempre la etiqueta morfológica más probable. En nuestro caso, esta estrategia funciona relativamente bien: el tagger supera el 81% de precisión. Sin embargo, esta aproximación presenta numerosos problemas a la hora de etiquetar palabras homógrafas (un mismo token funcionando con más de una categoría gramatical). Si probamos con nuestra oracion2
, comprobamos que la segunda aparición de progress no es etiquetada correctamente.
Intuitivamente, podemos pensar que sabríamos distinguir ambas categorías si tuviéramos en cuenta algo del contexto de aparición de las palabras: progress es un sustantivo cuando aparece después del artículo the y es verbo cuando aparece tras un pronombre personal como I. Si en lugar de calcular frecuencias de unigramas, extendiéramos los cálculos a secuencias de dos o tres palabras, podríamos tener mejores resultados. Y precisamente por eso vamos a calcular distribuciones de frecuencias condicionales: asignaremos a cada token la categoría gramatical más frecuente teniendo en cuenta la categoría gramatical de la(s) palabra(s) inmediatamente anterior(es).
Creamos un par de etiquetadores basado en bigramas (secuencias de dos palabras) o trigramas (secuencias de tres palabras) a través de las clases nltk.BigramTagger
y nltk.TrigramTagger
. Y los probamos con nuestra oracion2
.
In [15]:
bigramTagger = nltk.BigramTagger(corpusEntrenamiento)
trigramTagger = nltk.TrigramTagger(corpusEntrenamiento)
In [16]:
# funciona fatal :-(
print(bigramTagger.tag(oracion2))
print(trigramTagger.tag(oracion2))
#aquí hago trampas, le pido que analice una oración que ya ha visto durante el entrenamiento
print(bigramTagger.tag(['The', 'Fulton', 'County', 'Grand', 'Jury', 'said', 'Friday', 'an', 'investigation', 'of',
"Atlanta's", 'recent', 'primary', 'election', 'produced', '``', 'no', 'evidence', "''", 'that', 'any', 'irregularities', 'took', 'place', '.']))
Como se ve en los ejemplos, los resultados son desastrosos. La mayoría de los tokens se quedan sin etiqueta y se muestran como None
.
Si los evaluamos con nuestra colección de test, vemos que apenas superan el 10% de precisión. Peores resultados que nuestro DefaultTagger
.
In [17]:
print(bigramTagger.evaluate(corpusTest))
print(trigramTagger.evaluate(corpusTest))
¿Por qué ocurre esto? La intuición no nos engaña, y es verdad que si calculamos distribuciones de frecuencia condicionales teniendo en cuenta secuencias de palabras más largas, nuestros datos serán más finos. Sin embargo, cuando consideramos secuencias de tokens más largos nos arriesgamos a que dichas secuencias no aparezcan como tales en el corpus de entrenamiento.
En el ejemplo de oracion2
, nuestro bigramTagger
es incapaz de etiquetar la palabra progress porque no ha encontrado en el corpus de entrenamiento ni el bigrama (The, progress) ni (I, progress). Obviamente, nuestro trigramTagger
tampoco ha encontrado los trigramas (INICIO_DE_ORACIÓN
, The, progress) o (as, I, progress). Si esas secuencias no aparecen en el corpus de entrenamiento, no hay nada que aprender.
En estos casos, la solución más satisfactoria consiste en combinar de manera incremental la potencia de todos nuestros etiquetadores. Vamos a crear nuevos taggers que utilicen otros como respaldo.
Utilizaremos un tagger de trigramas que, cuando no tenga respuesta para etiquetar un determinado token, utilizará como respaldo el tagger de bigramas. A su vez, el tagger de bigramas tirará del de unigramas cuando no tenga respuesta. Por último, el de unigramas utilizará como respaldo el tagger de expresiones regulares que definimos antes. De esta manera aumentamos la precisión hasta casi el 87%.
In [18]:
unigramTagger = nltk.UnigramTagger(corpusEntrenamiento, backoff=regexTagger)
bigramTagger = nltk.BigramTagger(corpusEntrenamiento, backoff=unigramTagger)
trigramTagger = nltk.TrigramTagger(corpusEntrenamiento, backoff=bigramTagger)
print(trigramTagger.evaluate(corpusTest))
In [19]:
print(trigramTagger.tag(oracion1))
print(trigramTagger.tag(oracion2))
print(trigramTagger.tag(oracion3))
In [20]:
from nltk.corpus import brown
oracionNoAnotadas = brown.sents()
oracionAnotadas = brown.tagged_sents() # esta es la colección que usaremos para el training
# como ves, son el mismo corpus
print(oracionNoAnotadas[100])
print(oracionAnotadas[100])
In [21]:
totalOraciones = len(oracionNoAnotadas)
print("El corpus tiene", totalOraciones, "oraciones")
# en este caso, vamos a utilizar para entrenar un 75% de los datos
# y como test el 25% restante
limite = int(totalOraciones * 0.75) # ojo, esto tiene que ser un entero
print("Vamos a usar las primeras", limite, "oraciones para entrenar y las", totalOraciones-limite, "restantes como test.\n\n")
# creo el corpus de entrenamiento
corpusEntrenamiento = oracionAnotadas[:limite]
corpusTest = oracionAnotadas[limite:]
# como ves, son corpus diferentes
print(corpusEntrenamiento[0])
print(corpusTest[0])
In [22]:
# fíjate cómo importo ahora las clases para crear etiquetadores basados en ngramas y cómo los uso después
from nltk import UnigramTagger, BigramTagger, TrigramTagger
# creamos de manera incremental tres etiquetadore basados en ngramas,
# usando como respaldp etiquetadores más sencillos
unigramTagger = UnigramTagger(corpusEntrenamiento, backoff=regexTagger)
bigramTagger = BigramTagger(corpusEntrenamiento, backoff=unigramTagger)
trigramTagger = TrigramTagger(corpusEntrenamiento, backoff=bigramTagger)
In [23]:
# qué tal analiza oraciones
print(trigramTagger.tag(oracion1))
print(trigramTagger.tag(oracion2))
print(trigramTagger.tag(oracion3))
In [24]:
# vamos a ver qué precisión tiene, analizando el corpus de test
print(trigramTagger.evaluate(corpusTest))