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:
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.
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)
[('This', 'DT'), ('is', 'VBZ'), ('the', 'DT'), ('lost', 'NN'), ('dog', 'NN'), ('I', 'PRP'), ('found', 'VBD'), ('at', 'IN'), ('the', 'DT'), ('park', 'NN')] [('The', 'DT'), ('progress', 'NN'), ('of', 'IN'), ('the', 'DT'), ('humankind', 'NN'), ('as', 'IN'), ('I', 'PRP'), ('progress', 'VBP')]
El etiquetador funciona bastante bien aunque comete errores, obviamente. Si probamos con la famosa frase de Chomksy detectamos palabras mal etiquetadas.
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())
[('Green', 'NNP'), ('colorless', 'NN'), ('ideas', 'NNS'), ('sleep', 'VBP'), ('furiously', 'RB')] [('My', 'PRP$'), ('name', 'NN'), ('is', 'VBZ'), ('Prince', 'NNP')] [('He', 'PRP'), ('was', 'VBD'), ('born', 'VBN'), ('during', 'IN'), ('the', 'DT'), ('summer', 'NN'), ('of', 'IN'), ('1988', 'CD')] [("She's", 'NNS'), ("Tony's", 'VBP'), ('sister', 'NN')] [('My', 'PRP$'), ('name', 'NN'), ('is', 'VBZ'), ('Xrtwewvdk', 'NNP')]
¿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.
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).
# imprimimos la primera oración de las noticias de Brown
print brown_sents[0] # sin anotar
print brown_tagged_sents[0] # etiquetada morfológicamente
['The', 'Fulton', 'County', 'Grand', 'Jury', 'said', 'Friday', 'an', 'investigation', 'of', "Atlanta's", 'recent', 'primary', 'election', 'produced', '``', 'no', 'evidence', "''", 'that', 'any', 'irregularities', 'took', 'place', '.'] [('The', 'AT'), ('Fulton', 'NP-TL'), ('County', 'NN-TL'), ('Grand', 'JJ-TL'), ('Jury', 'NN-TL'), ('said', 'VBD'), ('Friday', 'NR'), ('an', 'AT'), ('investigation', 'NN'), ('of', 'IN'), ("Atlanta's", 'NP$'), ('recent', 'JJ'), ('primary', 'NN'), ('election', 'NN'), ('produced', 'VBD'), ('``', '``'), ('no', 'AT'), ('evidence', 'NN'), ("''", "''"), ('that', 'CS'), ('any', 'DTI'), ('irregularities', 'NNS'), ('took', 'VBD'), ('place', 'NN'), ('.', '.')]
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.
defaultTagger = nltk.DefaultTagger('NN')
print oracion1
print defaultTagger.tag(oracion1)
print defaultTagger.tag(oracion2)
['This', 'is', 'the', 'lost', 'dog', 'I', 'found', 'at', 'the', 'park'] [('This', 'NN'), ('is', 'NN'), ('the', 'NN'), ('lost', 'NN'), ('dog', 'NN'), ('I', 'NN'), ('found', 'NN'), ('at', 'NN'), ('the', 'NN'), ('park', 'NN')] [('The', 'NN'), ('progress', 'NN'), ('of', 'NN'), ('the', 'NN'), ('humankind', 'NN'), ('as', 'NN'), ('I', 'NN'), ('progress', 'NN')]
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%:
defaultTagger.evaluate(brown_tagged_sents)
0.13089484257215028
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.
Ejercicio que surge en clase para contar el número de sustantivos contenidos en el corpus de noticias de Brown.
# creo dos contadores, y los pongo a cero
nombres = 0
total = 0
# recorro las oraciones etiquetadas del corpus de noticias de Brown
for oracion in brown_tagged_sents:
for tupla in oracion:
# en cada tupla que encuentro, añado +1 al contador de tokens totales
total = total + 1
if tupla[1] == "NN":
# si el token en cuestión es, además, un sustantivo singular (NN), añado +1 al contador de nombres
nombres = nombres + 1
# imprimimos las frecuencias
print "Tenemos", nombres, "nombres y", total, "palabras"
print nombres/float(total)
# Fíjate que, en este caso, el porcentaje de sustantivos singulares en el corpus es el mismo que la precisión
# alcanzada por nuestro etiquetador por defecto. ¿Ves por qué es así?
Tenemos 13162 nombres y 100554 palabras 0.130894842572
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
.
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)
print regexTagger.tag(u"I was taking a sunbath in Alpedrete".split())
print regexTagger.tag(u"She would have found 100 dollars in the bag".split())
print regexTagger.tag(u"DSFdfdsfsd 1852 to dgdfgould fXXXdg in XXXfdg".split())
[(u'I', 'PRP'), (u'was', 'VBD'), (u'taking', 'VBG'), (u'a', 'AT'), (u'sunbath', 'NN'), (u'in', 'IN'), (u'Alpedrete', 'NNP')] [(u'She', 'PRP'), (u'would', 'MD'), (u'have', 'VBP'), (u'found', 'NN'), (u'100', 'CD'), (u'dollars', 'NNS'), (u'in', 'IN'), (u'the', 'AT'), (u'bag', 'NN')] [(u'DSFdfdsfsd', 'NNP'), (u'1852', 'CD'), (u'to', 'TO'), (u'dgdfgould', 'MD'), (u'fXXXdg', 'NN'), (u'in', 'IN'), (u'XXXfdg', 'NNP')]
Cuando probamos a evaluarlo con un corpus de oraciones más grande, vemos que nuestra precisión sube por encima del 32%.
regexTagger.evaluate(brown_tagged_sents)
0.3260636076138194
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.
print len(brown_tagged_sents)
print (len(brown_tagged_sents) * 90) / 100
4623 4160.7
size = int(len(brown_tagged_sents) * 0.9)
corpusEntrenamiento = brown_tagged_sents[:size]
corpusTest = brown_tagged_sents[size:]
print corpusEntrenamiento[0]
print corpusTest[0]
[('The', 'AT'), ('Fulton', 'NP-TL'), ('County', 'NN-TL'), ('Grand', 'JJ-TL'), ('Jury', 'NN-TL'), ('said', 'VBD'), ('Friday', 'NR'), ('an', 'AT'), ('investigation', 'NN'), ('of', 'IN'), ("Atlanta's", 'NP$'), ('recent', 'JJ'), ('primary', 'NN'), ('election', 'NN'), ('produced', 'VBD'), ('``', '``'), ('no', 'AT'), ('evidence', 'NN'), ("''", "''"), ('that', 'CS'), ('any', 'DTI'), ('irregularities', 'NNS'), ('took', 'VBD'), ('place', 'NN'), ('.', '.')] [('But', 'CC'), ('in', 'IN'), ('all', 'ABN'), ('its', 'PP$'), ('175', 'CD'), ('years', 'NNS'), (',', ','), ('not', '*'), ('a', 'AT'), ('single', 'AP'), ('Negro', 'NP'), ('student', 'NN'), ('has', 'HVZ'), ('entered', 'VBN'), ('its', 'PP$'), ('classrooms', 'NNS'), ('.', '.')]
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
.
unigramTagger = nltk.UnigramTagger(corpusEntrenamiento)
print unigramTagger.evaluate(corpusTest)
0.813714741354
# ¿qué tal se etiquetan nuestras oraciones de ejemplo?
print unigramTagger.tag(oracion1)
print unigramTagger.tag(oracion2)
print unigramTagger.tag(oracion3)
[('This', 'DT'), ('is', 'BEZ'), ('the', 'AT'), ('lost', 'VBD'), ('dog', 'NN'), ('I', 'PPSS'), ('found', 'VBN'), ('at', 'IN'), ('the', 'AT'), ('park', 'NN')] [('The', 'AT'), ('progress', 'NN'), ('of', 'IN'), ('the', 'AT'), ('humankind', None), ('as', 'CS'), ('I', 'PPSS'), ('progress', 'NN')] [('Green', 'JJ-TL'), ('colorless', None), ('ideas', 'NNS'), ('sleep', None), ('furiously', None)]
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
.
bigramTagger = nltk.BigramTagger(corpusEntrenamiento)
trigramTagger = nltk.TrigramTagger(corpusEntrenamiento)
print bigramTagger.tag(oracion2)
print trigramTagger.tag(oracion2)
[('The', 'AT'), ('progress', None), ('of', None), ('the', None), ('humankind', None), ('as', None), ('I', None), ('progress', None)] [('The', 'AT'), ('progress', None), ('of', None), ('the', None), ('humankind', None), ('as', None), ('I', None), ('progress', None)]
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
.
print bigramTagger.evaluate(corpusTest)
print trigramTagger.evaluate(corpusTest)
0.103059902322 0.0628924548988
¿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%.
unigramTagger = nltk.UnigramTagger(corpusEntrenamiento, backoff=regexTagger)
bigramTagger = nltk.BigramTagger(corpusEntrenamiento, backoff=unigramTagger)
trigramTagger = nltk.TrigramTagger(corpusEntrenamiento, backoff=bigramTagger)
trigramTagger.evaluate(corpusTest)
0.8677364696501545
print trigramTagger.tag(oracion1)
print trigramTagger.tag(oracion2)
print trigramTagger.tag(oracion3)
[('This', 'DT'), ('is', 'BEZ'), ('the', 'AT'), ('lost', 'VBD'), ('dog', 'NN'), ('I', 'PPSS'), ('found', 'VBD'), ('at', 'IN'), ('the', 'AT'), ('park', 'NN')] [('The', 'AT'), ('progress', 'NN'), ('of', 'IN'), ('the', 'AT'), ('humankind', 'NN'), ('as', 'CS'), ('I', 'PPSS'), ('progress', 'NN')] [('Green', 'JJ-TL'), ('colorless', 'NNS'), ('ideas', 'NNS'), ('sleep', 'NN'), ('furiously', 'RB')]
print nltk.pos_tag(oracion2)
[('The', 'DT'), ('progress', 'NN'), ('of', 'IN'), ('the', 'DT'), ('humankind', 'NN'), ('as', 'IN'), ('I', 'PRP'), ('progress', 'VBP')]