Seguimos con la traducción de algunos capítulos del libro The Programming Historian, de William J. Turkel, Adam Crymble y Alan MacEachern.
Ahora vamos con el capítulo 5.

Calculando frecuencias

Medidas usuales para textos

En la sección anterior, escribimos un programa en Python al que llamamos html-a-lista-1.py . Dicho programa sirve para bajar una página web a la que le quita el formateo HTML y los metadata, y luego nos devuelve una lista de “palabras”, como esta:

 ['Dictionary', 'of', 'Canadian', 'Biography', 'DOLLARD', 'DES', 
'ORMEAUX', '(called', 'Daulat', 'in', 'his', 'death', 'certificate', 
'and', 'Daulac', 'by', 'some', 'historians),', 'ADAM,', 'soldier,',
'\x93garrison', 'commander', 'of', 'the', 'fort', 'of', 
'Ville-Marie', '[Montreal]\x94;', 'b.', '1635,', 'killed', 'by', 
'the', 'Iroquois', 'at', 'the', 'Long', 'Sault', 'in', 
'May 1660.', '\xa0\xa0\xa0\xa0\xa0', 'Nothing', 'is', 'known', 
'of', 'Dollard\x92s', 'activities', 'prior', 'to', 'his', 'arrival', 
'in', 'Canada', 'except', 'that', '\x93he', 'had', 'held', 'some', 
'commands', 'in', 'the', 'armies', 'of', 'France.\x94', 'Having', 
'come', 'to', 'Montreal', 'as', 'a', 'volunteer,', 'very', 
'probably', 'in', '1658,', 'he', 'continued', 'his', 'military', 
'career', 'there.', 'In', '1659', 'and', '1660', 'he', 'was', 
'described', 'as', 'an', '\x93officer\x94', 'or', '\x93garrison', 
'commander', 'of', 'the', 'fort', 'of', 'Ville-Marie,\x94', 'a', 
'title', 'that', 'he', 'shared', 'with', 'Pierre', 'Picot\xe9', 
'de', 'Belestre.', 'We', 'do', 'not', 'however', 'know', 'what', 
'his', 'particular', 'responsibility', 'was.']

Debido a que ya sabemos leer, esa habilidad del programa, por sí misma, no es muy importante. Pero podemos usar el texto para hacer cosas que usualmente no son posibles sin un software especializado. Vamos a empezar calculando las frecuencias de palabras y de otras unidades lingüísticas.

Limpiando la lista

Está claro que nuestra lista necesitará un poco de limpieza antes de que podamos usarla para contar frecuencias. Antes que nada, no queremos saber las frecuencias atadas a mayúsculas o minúsculas: “Dollard” y “DOLLARD” deberían ser contadas como la misma palabra. Normalmente las palabras son clasificadas en minúsculas cuando contamos frecuencias, por lo que está bien utilizar el método de cadena lower.

print('Hola MUNDO'.lower())
-> hola mundo

Hay distintas marcas de puntuación que nos cambiarían el conteo de frecuencia si las incluiríamos en él. Queremos que “soldado:” sea contabilizada como “soldado” y “[Montreal]” como “Montreal”. Mirando en la impresión en el panel de salida del Komodo, encontramos también “&nbsp ;” que es el código HTML para el ampersand. Utilizando otro método de cadena, podemos reemplazar ese código con un espacio en blanco, de la siguiente manera:

print('hola mundo')
-> hola mundo
 
print('hola mundo'.replace(' ',' '))
-> hola mundo

Hay también un grupo de caracteres acentuados franceses que son representados con cadenas Unicode como “\xe9” (que indica “é”). Aprenderemos más acerca de trabajar con caracteres Unicode más tarde; por ahora vamos a dejarlos como están.
En este punto, podríamos mirar en otras entradas DCB [de ese diccionario se extrajo la página web del ejemplo] y en un rango más amplio de potenciales fuentes de documentos para asegurarnos que no haya otros caracteres especiales que nos traigan problemas más adelante. Podríamos también anticipar situaciones donde no queremos quitar la puntuación (por ejemplo, el signo que distingue un monto de dinero como en “$1629” de un año, o el que distingue el sentido de “1629-40” del otro en “1629 40”). Esto es lo que hace que los programadores profesionales cobren: tratar de pensar todo lo que puede estar mal por adelantado.

Vamos a encarar otra aproximación. Nuestro principal objetivo es desarrollar técnicas que un historiador o una historiadora puedan utilizar durante su investigación. Esto significa que siempre vamos a preferir la solución correcta que pueda desarrollarse rápidamente. Más que dedicar tiempo a hacer nuestro programa robusto frente a excepciones, simplemente vamos a descartar todo aquello que no sea una letra acentuada o sin acentuar o un número arábigo. La programación es normalmente un proceso de refinamiento por etapas. Comenzamos con un problema y con una solución parcial, y entonces procedemos a refinar nuestra solución hasta hacer algo que funcione mejor.

Nuestro primer uso de expresiones regulares.
A los efectos de eliminar caracteres especiales, vamos a hacer uso de un poderoso mecanismo llamado expresiones regulares. Las expresiones regulares son facilitadas por muchos lenguajes de programación de distintas maneras. Para hacer lo que queremos ahora, tenemos que importar la biblioteca de expresiones regulares de Python y compilar un patrón que encuentre cualquier cosa que no sea un carácter alfanumérico. Copie la siguiente función y péguela en el módulo dh.py.

# Dada una cadena de texto, remueva todos los caracteres alfanuméricos
# (utilizando la definición Unicode de alfanumérico).
 
def stripNonAlphaNum(texto):
    import re
    return re.compile(r'\W+', re.UNICODE).split(texto)

La expresión regular en el código de más arriba es \W+. La \W es la forma abreviada para significar la clase de caracteres alfanuméricos. En las expresiones regulares de Python, el signo más (+) encuentra una o más copias de un caracter determinado. El re.UNICODE le dice al intérprete que queremos incluir caracteres de otros idiomas en nuestra definición de alfanumérico, además de las series que van de la A a la Z, de la a a la z, y del / al 9 [N.T: el primer set de caracteres ASCII era etnocéntrico y no incluía signos como las vocales acentuadas o la cedilla; en el texto original las series enumeradas al final de la última oración son del inglés; y todo lo que queda fuera, parte de “los otros idiomas del mundo”]. Las expresiones regulares deben ser compiladas antes de ser usadas, que es lo que el resto de las órdenes del programa hace. No se preocupe por entender el resto de la compilación por ahora.
Al refinar nuestro programa html-a-lista, podemos dejarlo así:

# html-a-lista-2.py
 
import urllib2
import dh
 
url = 'http://niche.uwo.ca/programming-historian/dcb/dcb-34298.html'
 
response = urllib2.urlopen(url)
html = response.read()
texto = dh.stripTags(html).replace(' ', ' ')
wordlist = dh.stripNonAlphaNum(texto.lower())
print wordlist[0:500]

Cuando ejecute el programa y mire el panel de salida del Komodo, podrá ver que ha hecho un mejor trabajo. Como esperábamos, dejó los caracteres acentuados en forma de códigos (así, palabras como “Picoté” aparecen como “picot\xe9”). Dividió los términos con guiones, como “Ville-Marie” en dos palabras, y cambió el posesivo “s” en una palabra separada, dejando fuera el apóstrofo. Esta es una buena aproximación a lo que queríamos hacer, así que podemos avanzar a contar frecuencias antes de intentar hacer este programa mejor. (Si usted trabaja con fuentes en más de un idioma necesitará aprender más acerca del estándar Unicode y acerca del soporte de Python para ese estándar.

Diccionarios de Python

Tanto las cadenas como las listas están secuencialmente ordenadas, lo que quiere decir que podemos acceder a sus contenidos utilizando un index, un número que comienza con 0. Si tiene una lista que contiene cadenas, puede usar un par de índices para acceder, primero, a una cadena en particular, y luego a un caracter específico en esa cadena. Estudie el siguiente ejemplo:

s = 'hola mundo'
print s[0]
-> h
 
print s[1]
-> o
 
m = ['hola', 'mundo']
print m[0]
-> hola
 
print m[1]
-> mundo
 
print m[0][1]
-> o
 
print m[1][0]
-> m

Para llevar la cuenta de las frecuencias, necesitaremos otro tipo de objeto en Python, un diccionario. El diccionario es una colección desordenada de objetos. Esto significa que no podemos usar un índice para recuperar un elemento del mismo. Podemos hacerlo, de todos modos, utilizando una clave (key). Estudie el siguiente ejemplo:

d = {'mundo': 1, 'hola': 0}
print d['hola']
-> 0
 
print d['mundo']
-> 1
 
print d.keys()
-> ['mundo', 'hola']

Fíjese que usamos llaves para definir un diccionario pero corchetes para acceder a las cosas que hay en él. Las operaciones con claves devuelven una lista de claves que están definidas en el diccionario.

Conteo de frecuencia de palabras

Ahora vamos a contar la frecuencia de cada palabra en nuestra lista. Ya vimos que esto es fácil procesar una lista utilizando un bucle for. Pruebe guardando y ejecutando este ejemplo:

# contar-lista-items-1.py
 
wordstring = 'quien mal anda mal acaba '
wordstring += 'dime con quien andas y te dire quien eres'
 
wordlist = wordstring.split()
 
wordfreq = []
for word in wordlist:
    wordfreq.append(wordlist.count(word))
 
print "Cadena\n" + wordstring +"\n"
print "Lista\n" + str(wordlist) + "\n"
print "Frecuencia\n" + str(wordfreq) + "\n"
print "Pares\n" + str(zip(wordlist, wordfreq))

Obtendrá algo como esto:

Cadena
quien mal anda mal acaba dime con quien andas y te dire quien eres
Lista
['quien', 'mal', 'anda', 'mal', 'acaba', 'dime', 'con', 'quien', 'andas', 'y', 'te', 'dire', 'quien', 'eres']
Frecuencia
[3, 2, 1, 2, 1, 1, 1, 3, 1, 1, 1, 1, 3, 1]
Pares
[('quien', 3), ('mal', 2), ('anda', 1), ('mal', 2), ('acaba', 1), ('dime', 1), ('con', 1), ('quien', 3), ('andas', 1), ('y', 1), ('te', 1), ('dire', 1), ('quien', 3), ('eres', 1)]

En ese programa, empezamos con una cadena y la dividimos en una lista, como ya lo habíamos hecho antes. Entonces recorrimos cada palabra de esa lista y contamos las veces que esa palabra aparece en toda la lista. Luego agregamos el resultado a una lista de frecuencias de palabras. Usando el comando zip podemos comparar la primera palabra de la lista de palabras con el primer número de la lista de frecuencias, la segunda palabra con la segunda frecuencia y así sucesivamente. Finalizamos obteniendo una lista de pares (palabras y frecuencias). La declaración str convierte cualquier objeto a una cadena, para poder imprimirlo.
Python incluye una herramienta muy conveniente llamada lista comprensiva (list comprehension), que podemos usar para hacer lo mismo que con el bucle loop pero más económicamente.

# contar-lista-items-2.py
wordstring = 'quien mal anda mal acaba '
wordstring += 'dime con quien andas y te dire quien eres'
wordlist = wordstring.split()
wordfreq = [wordlist.count(w) for w in wordlist]
print "Cadena\n" + wordstring +"\n"
print "Lista\n" + str(wordlist) + "\n"
print "Frecuencias\n" + str(wordfreq) + "\n"
print "Pares\n" + str(zip(wordlist, wordfreq))

En este punto tenemos una lista de pares, donde cada par contiene una palabra y su frecuencia. Note que esta lista podría ser redundante. Si el artículo “el” se repitiera 500 veces, la lista contendría quinientas copias de ese par (‘el’, 500). La lista está, además, ordenada por el orden de las palabras en el texto inicial. Podemos resolver ambos problemas convirtiéndola en un diccionario. Entonces todo lo que tenemos que hacer es imprimir el diccionario en orden, desde los ítems más frecuentes a los menos frecuentes.

De HTML a un diccionario de pares palabra-frecuencia.

A partir de lo que tenemos hasta ahora, queremos una función que pueda convertir una lista de palabras en un diccionario de pares de palabra-frecuencia. La única declaración nueva que necesitamos conocer es dict, que hacer un diccionario a partir de una lista de pares. Agregue el siguiente código al módulo dh.py.

# Dada una lista de palabras, devuelva un diccionario de
# pares de palabra-frecuencia.
 
def wordListToFreqDict(wordlist):
    wordfreq = [wordlist.count(p) for p in wordlist]
    return dict(zip(wordlist,wordfreq))

Vamos a querer además un función que pueda ordenar un diccionario de pares de palabra-frecuencia por frecuencias, de modo descendiente. Copie también esto al módulo dh.py:

# Ordenar un diccionario de pares de palabra-frecuencia por
# frecuencias de modo descendiente
 
def sortFreqDict(freqdict):
    aux = [(freqdict[key], key) for key in freqdict]
    aux.sort()
    aux.reverse()
    return aux

Podemos ahora escribir un programa que tome una URL y devuelva pares de palabras-frecuencia para la página web, ordenados de acuerdo a la frecuencia de cada palabra, desde la que más tiene a la que menos tiene. Copie el siguiente programa en el Komodo, guarde como html-to-freq.py y ejecútelo. Estudie cuidadosamente el programa y lo que se imprime en el panel de salida, antes de continuar.

# html-a-freq.py
 
import urllib2
import dh
 
url = 'http://niche.uwo.ca/programming-historian/dcb/dcb-34298.html'
 
response = urllib2.urlopen(url)
html = response.read()
text = dh.stripTags(html).replace(' ', ' ')
wordlist = dh.stripNonAlphaNum(text.lower())
dictionary = dh.wordListToFreqDict(wordlist)
sorteddict = dh.sortFreqDict(dictionary)
for s in sorteddict: print str(s)

Removiendo las stop words

Cuando miramos la salida de nuestro programa html-a-freq.py, vemos que las mayores frecuencias son para palabras como “the”, “of”, “and”.

(647, 'the')
(310, 'of')
(273, 'to')
(202, 'and')
(171, 'in')
(134, 'a')
(118, 'that')
(91, 'dollard')
(78, 'was')
(78, 'their')
(75, 'were')
(72, 'they')
(71, 'his')

Esas palabras son las más usuales en cualquier texto en inglés, y no nos dicen mucho sobre la biografía de Dollard. En general, estamos más interesados en encontrar las palabras que nos permitan distinguir este texto de otros con diferentes temas. Por lo que vamos a filtrar, a quitar, las palabras más comunes. Las palabras que son ignoradas, como esas, se conocen como stop words. Vamos a usar una lista, adaptada de un post de unos cientistas de Glasgow. Copie lo siguiente en el comienzo de la biblioteca dh.py.

stopwords = ['a', 'about', 'above', 'across', 'after', 'afterwards']
stopwords += ['again', 'against', 'all', 'almost', 'alone', 'along']
stopwords += ['already', 'also', 'although', 'always', 'am', 'among']
stopwords += ['amongst', 'amoungst', 'amount', 'an', 'and', 'another']
stopwords += ['any', 'anyhow', 'anyone', 'anything', 'anyway', 'anywhere']
stopwords += ['are', 'around', 'as', 'at', 'back', 'be', 'became']
stopwords += ['because', 'become', 'becomes', 'becoming', 'been']
stopwords += ['before', 'beforehand', 'behind', 'being', 'below']
stopwords += ['beside', 'besides', 'between', 'beyond', 'bill', 'both']
stopwords += ['bottom', 'but', 'by', 'call', 'can', 'cannot', 'cant']
stopwords += ['co', 'computer', 'con', 'could', 'couldnt', 'cry', 'de']
stopwords += ['describe', 'detail', 'did', 'do', 'done', 'down', 'due']
stopwords += ['during', 'each', 'eg', 'eight', 'either', 'eleven', 'else']
stopwords += ['elsewhere', 'empty', 'enough', 'etc', 'even', 'ever']
stopwords += ['every', 'everyone', 'everything', 'everywhere', 'except']
stopwords += ['few', 'fifteen', 'fifty', 'fill', 'find', 'fire', 'first']
stopwords += ['five', 'for', 'former', 'formerly', 'forty', 'found']
stopwords += ['four', 'from', 'front', 'full', 'further', 'get', 'give']
stopwords += ['go', 'had', 'has', 'hasnt', 'have', 'he', 'hence', 'her']
stopwords += ['here', 'hereafter', 'hereby', 'herein', 'hereupon', 'hers']
stopwords += ['herself', 'him', 'himself', 'his', 'how', 'however']
stopwords += ['hundred', 'i', 'ie', 'if', 'in', 'inc', 'indeed']
stopwords += ['interest', 'into', 'is', 'it', 'its', 'itself', 'keep']
stopwords += ['last', 'latter', 'latterly', 'least', 'less', 'ltd', 'made']
stopwords += ['many', 'may', 'me', 'meanwhile', 'might', 'mill', 'mine']
stopwords += ['more', 'moreover', 'most', 'mostly', 'move', 'much']
stopwords += ['must', 'my', 'myself', 'name', 'namely', 'neither', 'never']
stopwords += ['nevertheless', 'next', 'nine', 'no', 'nobody', 'none']
stopwords += ['noone', 'nor', 'not', 'nothing', 'now', 'nowhere', 'of']
stopwords += ['off', 'often', 'on','once', 'one', 'only', 'onto', 'or']
stopwords += ['other', 'others', 'otherwise', 'our', 'ours', 'ourselves']
stopwords += ['out', 'over', 'own', 'part', 'per', 'perhaps', 'please']
stopwords += ['put', 'rather', 're', 's', 'same', 'see', 'seem', 'seemed']
stopwords += ['seeming', 'seems', 'serious', 'several', 'she', 'should']
stopwords += ['show', 'side', 'since', 'sincere', 'six', 'sixty', 'so']
stopwords += ['some', 'somehow', 'someone', 'something', 'sometime']
stopwords += ['sometimes', 'somewhere', 'still', 'such', 'system', 'take']
stopwords += ['ten', 'than', 'that', 'the', 'their', 'them', 'themselves']
stopwords += ['then', 'thence', 'there', 'thereafter', 'thereby']
stopwords += ['therefore', 'therein', 'thereupon', 'these', 'they']
stopwords += ['thick', 'thin', 'third', 'this', 'those', 'though', 'three']
stopwords += ['three', 'through', 'throughout', 'thru', 'thus', 'to']
stopwords += ['together', 'too', 'top', 'toward', 'towards', 'twelve']
stopwords += ['twenty', 'two', 'un', 'under', 'until', 'up', 'upon']
stopwords += ['us', 'very', 'via', 'was', 'we', 'well', 'were', 'what']
stopwords += ['whatever', 'when', 'whence', 'whenever', 'where']
stopwords += ['whereafter', 'whereas', 'whereby', 'wherein', 'whereupon']
stopwords += ['wherever', 'whether', 'which', 'while', 'whither', 'who']
stopwords += ['whoever', 'whole', 'whom', 'whose', 'why', 'will', 'with']
stopwords += ['within', 'without', 'would', 'yet', 'you', 'your']
stopwords += ['yours', 'yourself', 'yourselves']

Ahora que pusimos esas palabras en una lista es fácil utilizarlas. Copie esta función también en el módulo dh.py.

# Dada una lista de palabras remueva cualquiera 
# que figure en la lista de stop words.
 
def removeStopwords(wordlist, stopwords):
    return [w for w in wordlist if w not in stopwords]
[Para ampliar este tema ver la solapa discussion en el wiki]

Poniendo todo junto

Ahora tenemos todo lo que necesitamos para determinar las frecuencias de palabras en páginas web. Copie lo siguiente en el Komodo, guárdelo como html-a-freq-2.py y ejecútelo.

# html-to-freq-2.py
 
import urllib2
import dh
 
url = 'http://niche.uwo.ca/programming-historian/dcb/dcb-34298.html'
 
response = urllib2.urlopen(url)
html = response.read()
text = dh.stripTags(html).replace(' ', ' ')
fullwordlist = dh.stripNonAlphaNum(text.lower())
wordlist = dh.removeStopwords(fullwordlist, dh.stopwords)
dictionary = dh.wordListToFreqDict(wordlist)
sorteddict = dh.sortFreqDict(dictionary)
for s in sorteddict: print str(s)

Si todo va bien, la salida debería mostrar algo así:

(91, 'dollard')
(64, 'iroquois')
(33, 'long')
(27, 'sault')
(24, 'enemy')
(24, '1660′)
(20, 'time')
(20, 'seventeen')
(20, 'french')
(19, 'new')
(19, 'montreal')
(19, 'army')
(18, 'hurons')
(18, 'fort')
(17, 'france')
(15, 'men')
(14, 'marie')
(14, 'companions')