Resumen NLTK: Acceso a corpus de texto y recursos léxicos

Este resumen se corresponde con el capítulo 2 del NLTK Book Accessing Text Corpora and Lexical Resources. La lectura del capítulo es muy recomendable.

Corpus no anotados: el Proyecto Gutenberg

NLTK no da acceso directo a varias colecciones de textos. Para empezar, vamos a juguetear un poco con los libros del Proyecto Gutenberg, un repositorio público de libros libres y/o sin derechos de copyright en vigor. Antes de nada, necesitamos importar el módulo gutenberg que está en la librería nltk.corpus.


In [ ]:
from __future__ import print_function
from __future__ import division

from nltk.corpus import gutenberg

Podemos listar el catálogo de libros del Proyecto Gutenberg disponibles desde NLTK a través del método nltk.corpus.gutenberg.fileids


In [ ]:
print(gutenberg.fileids())

Para cargar alguno de estos libros en variables y poder manipularlos directamente, podemos utilizar varios métodos.

  • gutenberg.raw recupera el texto como una única cadena de caracteres.
  • gutenberg.words recupera el texto tokenizado en palabras. El método devuelve una lista palabras.
  • gutenberg.sents recupera el texto segmentado por oraciones. El método devuelve una lista de oraciones. Cada oración es a su vez una lista de palabras.
  • gutenberg.paras recupera el texto segmentado por párrafos. El método devuelve una lista de párrafos. Cada párrafo es una lista de oraciones, cada oración es a su vez una lista de palabras.

In [ ]:
# cargo la vesión 'cruda' de un par de libros. Como son libros del Proyecto Gutenberg, se trata de ficheros en texto plano
alice = gutenberg.raw('carroll-alice.txt')
print(alice[:200]) # imprimo los primeros 200 caracteres del libro de Alicia

bible = gutenberg.raw('bible-kjv.txt')
print(bible[:200]) # imprimo los primeros 200 caracteres de la Biblia

In [ ]:
# segmentamos el texto en palabras teniendo en cuenta los espacios 
bible_tokens = bible.split()
# cargamos la versión de la Biblia segmentado en palabras
bible_words = gutenberg.words('bible-kjv.txt')

# no da el mismo número de tokens
print(len(bible_tokens), len(bible_words))

print(bible_tokens[:100])
print('\n', '-' * 75, '\n')
print(bible_words[:100])

In [ ]:
# cargo la versión de Alicia segmentada en palabras
alice_words = gutenberg.words('carroll-alice.txt')
print(alice_words[:20]) # imprimo las primeros 20 palabras
print(len(alice_words))

In [ ]:
# cargo la versión de Alicia segmentada en oraciones
alice_sents = gutenberg.sents('carroll-alice.txt')
print('Alice tiene', len(alice_sents), 'oraciones')
print(alice_sents[2:5]) # imprimo la tercera, cuarta y quinta oración

In [ ]:
# cargo la versión de Alicia segmentada en párrafos
alice_paras = gutenberg.paras('carroll-alice.txt')
print('Alice tiene', len(alice_paras), 'párrafos')

# imprimo los cinco primeros
for para in alice_paras[:5]:
    print(para)
    print('\n', '-' * 75, '\n')

Fíjate en que cada método devuelve una estructura de datos diferente: desde una única cadena a listas de listas anidadas. Para que tengas claro las dimensiones de cada uno, podemos imprimir el número de caracteres, palabras, oraciones y párrafos del libro.


In [ ]:
print(len(alice), 'caracteres') 
print(len(alice_words), 'palabras')
print(len(alice_sents), 'oraciones')
print(len(alice_paras), 'párrafos')

Vamos a imprimir algunas estadísticas para todos los libros del Proyecto Gutenberg disponibles. Para cada libro, impriremos por pantalla el promedio de caracteres por palabra, el promedio de palabras por oración y el promedio de oraciones por párrafo.


In [ ]:
# para cada libro que está disponible en el objeto gutenberg
for libro in gutenberg.fileids():
    caracteres = len(gutenberg.raw(libro))
    palabras = len(gutenberg.words(libro))
    oraciones = len(gutenberg.sents(libro))
    parrafos = len(gutenberg.paras(libro))
    print(libro[:-4], '\t', round(caracteres/palabras, 2), '\t', round(palabras/oraciones, 2), '\t', round(oraciones/parrafos, 2))

El módulo nltk.corpus permite acceder a otras colecciones de textos en otras lenguas (lista completa aquí). Vamos a probar con un corpus de noticias en castellano llamado cess_esp que incluye anotación morfo-sintáctica.


In [ ]:
from nltk.corpus import cess_esp

# la versión en crudo de este corpus contiene información morfosintáctica con un formato que todavía no conocemos.
# en este caso, pasamos directamente a trabajar con los textos segmentados

# cargo el primer documento del corpus segmentado en palabras
palabras = cess_esp.words(cess_esp.fileids()[0])
print(palabras[:50])

print('\n', '-' * 75, '\n')

# y segmentado en oraciones
oraciones = cess_esp.sents(cess_esp.fileids()[0])
print(oraciones[:5])

In [ ]:
print(" ".join(palabras[:100]))

De manera similar a como hemos hecho sacando estadísticas de las obras disponibles en el corpus gutenberg, vamos a calcular la longitud promedio de palabras y el número de palabras promedio por oración, para los diez primeros documentos de este corpus cess_esp.

Fíjate en la estructura de este ejemplo: contiene bucles anidados.


In [ ]:
# para cada documento que está entre los 10 primeros del corpus
for documento in cess_esp.fileids()[:10]:    
    # carga el texto segmentado en palabras
    palabras = cess_esp.words(documento)
    # y en oraciones
    oraciones = cess_esp.sents(documento)
    
    # pon el contador de caracteres a 0
    caracteres = 0    
    # para cada palabra dentro de la lista de palabras del documento
    for palabra in palabras:
        # ve sumando al contador el número de caracteres que tiene la palabra en cuestión
        caracteres = caracteres + len(palabra)
    
    # cuando hayas terminado, divide la longitud total del texto entre el número de palabras
    longitud_promedio = caracteres / len(palabras)
    
    # imprime el nombre del documento, la longitud de la palabra y el número de palabras por oración
    print(documento[:-4], '\t', round(longitud_promedio, 2), '\t', round(len(palabras)/len(oraciones), 2))

Los libros del Proyecto Gutenberg constituyen el tipo de corpus más sencillo: no está anotado (no incluye ningún tipo de información lingüística) ni categorizado.

Corpus categorizados y anotados: el Corpus de Brown

El Corpus de Brown fue el primer gran corpus orientado a tareas de PLN. Desarrollado en la Universidad de Brown, contiene más de un millón de palabras provenientes de 500 fuentes. La principal catacterística de este corpus es que sus textos están categorizados por género.


In [ ]:
from nltk.corpus import brown

Como en los libros del Proyecto Gutenberg, aquí también podemos imprimir los nombres de los ficheros. En este caso son poco significativos, nos nos dicen nada del contenido.


In [ ]:
# Brown está formado por 500 documentos
print(len(brown.fileids()))
# imprimimos solos los 10 primeros
print(brown.fileids()[:10])

El corpus de Brown está categorizado por géneros

Una de las principales diferencias con otros corpus vistos anteriormente es que el de Brown está categorizado: los textos están agrupados según su género o temática. Y en este caso, los nombres de las categorías sí nos permiten intuir el contenido de los textos.


In [ ]:
print(brown.categories())

De manera similar a los libros del Proyecto Gutenberg, podemos acceder a los textos de este corpus a través de los métodos brown.raw, brown.words, brown.sents y brown.paras. Además, podemos acceder a una categoría de textos concretas si lo especificamos como argumento.


In [ ]:
news_words = brown.words(categories='news')
scifi_sents = brown.sents(categories='science_fiction')

In [ ]:
print(news_words[:50])
print('\n', '-' * 75, '\n')
print(scifi_sents[:3])

Vamos a sacar provecho de la categorización de los textos de este corpus. Para ello, vamos a calcular la frecuencia de distribución de distintos verbos modales para cada categoría. Para ello vamos a calcular una distribución de frecuencia condicional que calcule la frecuencia de cada palabra para cada categoría.

No te preocupes si no entiendes la sintaxis para crear tablas de frecuencias condicionales a través del objeto ConditionalFreqDist. Créeme, ese objeto calcula frecuencias de palabras atendiendo a la categoría en la que aparecen y crea una especie de diccionario de diccionarios.


In [ ]:
from nltk import ConditionalFreqDist
modals = 'can could would should must may might'.split()
modals_cfd = ConditionalFreqDist(
                          (category, word) 
                          for category in brown.categories() 
                          for word in brown.words(categories=category)
                        )

# la sintaxis anterior contiene varios bucles anidados y es equivalente a:
# for category in brown.categories():
#     for word in brown.words(categories=category):
#         ConditionalFreqDist(category, word)

Una vez tenemos calculada la frecuencia de distribución condicional, podemos pintar los valores fácilmente en forma de tabla a través del método .tabulate, especificando como condiciones cada una de las categorías, y como muestras los verbos modales del inglés que hemos definido.


In [ ]:
modals_cfd.tabulate(conditions=brown.categories(), samples=modals)

print('\n', '-' * 75, '\n')

# imprimo solo algunos verbos modales para la categoría fiction
modals_cfd.tabulate(conditions=['fiction'], samples=['can', 'should', 'would'])

Las cifras que hemos mostrado en las tablas anteriores se refieren a las frecuencias absolutas de cada verbo modal en cada categoría. Realizar comparaciones así no es acertado, porque es posible que cada categoría tenga un número de documentos (y de palabras) diferente.

Vamos a comprobar si esto es cierto. ¿Está equilibrada la colección o tenemos algunos géneros sobrerrepresentados?


In [ ]:
for categoria in brown.categories():
    print(categoria, len(brown.words(categories=categoria)))

Como vemos, el número de palabras no está equilibado. Tenemos muchos más datos en las categorías belles_lettres y learned que en science_fiction o humor, por ejemplo.

Calculemos a continuación la frecuencia relativa de estos verbos modales, atendiendo al género. Para ello, necesitamos dividir la frecuencia absoluta de cada modal entre el número de palabras total de cada categoría.


In [ ]:
for categoria in brown.categories():
    # ¿cuántas palabras tenemos en cada categoría?
    longitud = len(brown.words(categories=categoria))
    print('\n', categoria)
    print('\n', '-' * 75, '\n')
    for palabra in modals:
        print(palabra, '->', modals_cfd[categoria][palabra]/longitud)

Vamos a repetir la operación de cálculo de frecuencias relativas reasignando estos valores en el propio objeto modals_cfd, con el objetivo de utilizar el método tabulate para poder impirmir la tabla con los valors relativos.


In [ ]:
# lo primero, realizo una copia de mi distribución de frecuencias 
import copy
modals_cfd_rel = copy.deepcopy(modals_cfd)

In [ ]:
# sustituyo los conteos de la tabla por sus frecuencias relativas (ojo, en tantos por 10.000)
for categoria in brown.categories():
    longitud = len(brown.words(categories=categoria))
    for palabra in modals:
        modals_cfd_rel[categoria][palabra] = (modals_cfd[categoria][palabra]/longitud)*10000

In [ ]:
# imprimo la tabla        
modals_cfd_rel.tabulate(conditions=brown.categories(), samples=modals)

Brown es también un corpus anotado con información morfológica

El corpus de Brown no solo está categorizado, también está anotado con información morfológica. Para acceder a la versión anotada del corpus, podemos utilizar los métodos: brown.tagged_words, brown.tagged_sents y brown.tagged_ paras


In [ ]:
scifi_tagged_words = brown.tagged_words(categories='science_fiction')
print(scifi_tagged_words[:20])

Fíjate que cuando accedemos a la versión etiquetada del corpus, no obtenemos una simple lista de palabras sino una lista de tuplas, donde el primer elemento es la palabra en cuestión u el segundo es la etiqueta que indica la categoría gramatical de la palabra.

Este conjunto etiquetas se ha convertido en casi un estándar para el inglés y se utilizan habitualmente para anotar cualquier recurso lingüístico en esa lengua.

Vamos a crear una nueva frecuencia de distribución condicional para calcular la frecuencia de aparición de las etiquetas, teniendo en cuenta la categoría.


In [ ]:
tags_cfd = ConditionalFreqDist(
                                (category, item[1])
                                for category in brown.categories()
                                for item in brown.tagged_words(categories=category)
                              )

Y ahora vamos a imprimir la tabla de frecuencias para cada categoría y para algunas de las etiquetas morfológicas: sustantivos en singular NN, verbos en presente VB, verbos en pasado simple VBD, participios pasados VBN, adjetivos JJ, preposiciones IN, y artículos AT.

Recuerda: estas cifras no son directamente comparables entre categorías ya que éstas no están equilibradas. Hay categorías con más textos que otras.


In [ ]:
tags_cfd.tabulate(conditions=brown.categories(), 
                  samples='NN VB VBD VBN JJ IN AT'.split())

Pequeño ejercicio: buscando adjetivos

Creamos una lista de adjetivos (etiquetados como JJ) que aparezcan en la colección de textos categorizados como hobbies, e imprimimos por pantalla los 50 primeros que encontramos.


In [ ]:
# escribe tu código aquí

Pequeño ejercicio: buscando palabras largas

Recorre cada categoría del corpus de Brown buscando adjetivos, e imprime solo aquellos que tengan una longitud de al menos 15 caracteres y que no sean palabras compuestas escritas con guiones ortográficos.


In [ ]:
# escribe tu código aquí

Recursos Léxicos: WordNet

Wordnet es una red semántica para el inglés. En esencia, es similar a un diccionario pero está organizado por synsets (conjunto de palabras sinónimas) y no por lemas.

Podemos acceder a WordNet a través de NLTK, de manera similar a como accedemos desde el interfaz web:


In [ ]:
from nltk.corpus import wordnet as wn

Para consultar los synsets en los que aparece una determinada palabra, podemos utilizar el método .synsets como se muestra en el ejemplo. Como resultado obtenemos una lista con todos los synsets en los que aparece la palabra.


In [ ]:
# buscamos los synsets en los que aparece la palabra sword
print(wn.synsets('sword'))

# y buscamos car
print(wn.synsets('car'))

En este caso, la palabra sword solo aparece en un synset, lo que implica que solo tiene un sentido. Además, sabemos que es un sustantivo, porque el nombre de synset está etiquetado como n.

Por su parte, la palabra car es polisémica y aparece en cinco sentidos, toso ellos sustantivos.

Si guardo el synset en cuestión en una variable (fíjate que me quedo con el primer elemento de la lista que me devuelve el método wn.synsets), podemos acceder a distintos métodos:


In [ ]:
sword = wn.synsets('sword')[0]
print(sword.lemma_names()) # imprime los lemas del synset => sinónimos
print(sword.definition()) # imprime la definición del synset

# hacemos lo mismo con car
car = wn.synsets('car')
cable_car = car[-1]
print(cable_car.lemma_names(), cable_car.definition())
print(car[-1].lemma_names(), car[-1].definition()) # esta línea es equivalente a la anterior. ¿Ves por qué?

# imprimo las oraciones de ejemplo
print(cable_car.examples())

Si escribes sword. y pulsas el tabulador podrás visualizar todos los métodos accesibles desde un objeto synset. Son muchos: si tienes interés en alguno que no se menciona en este resumen, pregúntame o consulta el libro de NLTK.

Entre las cosas que sí nos interesan está el poder acceder a relaciones como hiponimia, meronimia, etc. Por ejemplo, para acceder a todos los hipónimos de sword con el sentido de espada, es decir, a todos los tipos de espada y a sus definiciones.


In [ ]:
print(sword.hyponyms())

for element in sword.hyponyms():
    print(element.lemma_names())
    print(element.definition())

En lugar de bajar hasta los elementos más específicos, podemos navegar en la jerarquía de sentidos hasta los synsets más generales. Por ejemplo, podemos acceder a los hiperónimos inmediatos de un synset a través del método .hypernyms():


In [ ]:
for element in sword.hypernyms():
    print(element.lemma_names())
    print(element.definition())

Fíjate que con este método sólo subimos un nivel hacia el synset más general. En este caso, comprobamos que sword es un tipo de weapon o arm. Si, por el contrario, lo que nos interesa es acceder a todos los hiperónimos de sword, navegando hasta el elemento raíz de la jerarquía de WordNet (que siempre es entity), podemos utilizar el método .hypernym_paths():


In [ ]:
for path in sword.hypernym_paths():
    for element in path:
        print(element.lemma_names())
        print(element.definition())

Fíjate que .hypernym_paths() me devuelve una lista de caminos posibles desde el synset en cuestión hasta el elemento entity. Por eso itero sobre los elementos path que me devuelve .hypernym_paths(). En el ejemplo de sword, da la casualidad de que solo hay un camino posible. Cada path es una lista de synsets, e itero sobre ellos. Por eso utilizo un bucle dentro de otro.

Para acceder a los merónimos, es decir, a las partes o elementos constitutivos de sword, podemos utilizar el método .part_meronyms(), como se muestra a continuación.


In [ ]:
for element in sword.part_meronyms():
    print(element.lemma_names())
    print(element.definition())

De manera similar, podemos acceder a los holónimos de un synset, es decir, a los elementos de los que espada forma parte, a través del método .part_holonyms(). El synset que estamos utilizando no tiene definidos holónimos, así que el ejemplo devuelve una lista vacía.


In [ ]:
print(sword.part_holonyms())

Busquemos ahora algún ejemplo que tenga otros tipos de merónimos.


In [ ]:
# en cuántos synsets aparece la palabra water?
water = wn.synsets('water')
for synset in water:
    print(synset.lemma_names())

# me quedo con el primero
agua = water[0]
# que tiene unos cuantos merónimos de sustancia
print(agua.substance_meronyms())

# ídem para air
air = wn.synsets('air')
for synset in air:
    print(synset.lemma_names(), synset.definition())

aire = air[0]
print(aire.substance_meronyms())

Hay varios métodos para acceder a distintos tipos de merónimos y holónimos, aunque no siempre están definidas estas relaciones. Cuando no están definidas, los métodos no dan error, simplemente devuelven listas vacías.

Los nombres de estos métodos tratan de ser autoexplicativos: por un lado, tenemos .part_holonyms(), .member_holonyms(), .substance_holonyms(), y por otro, .part_meronyms(), .member_meronyms(), .substance_meronyms().

Pequeño ejercicio

  • Busca los sentidos en los que aparece la palabra bike.
  • Identifica el que hace referencia a bicicleta: vehículo de dos ruedas a pedales
  • Imprime los merónimos, esto es, las partes que conforman una bicicleta

In [ ]: