Nos proponemos hacer un programa funcional que cuente la frecuencia de las ocurrencias de palabras en un texto. El tipo de la función es el siguiente:
frecuencia :: [Palabra] -> [(Palabra,Int)]
Donde Palabra
es un sinónimo de tipo, en este caso de un String
, y sirve para mejorar la legibilidad del código. Para crear este sinónimo, tenemos que escribir:
type Palabra = String
Y esperamos este tipo de comportamiento.
Main> frecuencia ["hola","que","hola"] [("hola",2),("que",1)]
Esta función resulta de gran utilidad para análisis estadístico de textos, como el que se utiliza para comprimir archivos, dar importancia a las palabras en las búsquedas en colecciones de documentos (o internet), detectar palabras significativas en documentos, etc.
Para hacer más fácil el uso de esta función tomamos de Haskell The Craft of Functional Programming por Simon Thompson, la definción de una función que parte una cadena en palabras, para no tener que meter a mano palabra por palabra, con el formato de una lista de Strings, que sería bastante pesado.
Veamos el código de “arriba para abajo” top-down, empezando desde la definición principal y terminando en las funciones básicas.
-- Declaracion de tipos type Palabra = String partePalabras :: String -> [Palabra] partePalabras st = partePalabras' (tiraEspacio st) partePalabras' :: String -> [Palabra] partePalabras' [] = [] partePalabras' st = (tomaPalabra st) : partePalabras' (tiraEspacio (tiraPalabra st)) tomaPalabra :: String -> String tomaPalabra [] = [] tomaPalabra (x:xs) | elemento x espacio = [] | otherwise = x : tomaPalabra xs tiraPalabra :: String -> String tiraPalabra [] = [] tiraPalabra (x:xs) | elemento x espacio = x:xs | otherwise = tiraPalabra xs tiraEspacio :: String -> String tiraEspacio [] = [] tiraEspacio (x:xs) | elemento x espacio = tiraEspacio xs | otherwise = x:xs elemento :: Eq a => a -> [a] -> Bool -- Definirlo! espacio :: [Char] espacio = ['\r','\n','\t',' ']
Casos de Test: elemento [] [ [1,2],[],[3,4] ]
, elemento “sandia” [“pera”,“melon”,“uva”]
.
Casos de Test:
tiraEspacio " Despues de tres dias" = "Despues de tres dias" tomaPalabra "Despues de tres dias" = "Despues" tiraPalabra "Despues de tres dias" = " de tres dias" tiraEspacio " de tres dias" = "de tres dias" tomaPalabra "de tres dias" = "de" tiraPalabra "de tres dias" = " tres dias" ...
Para probar ejemplos más largos podemos agregar una función que dado el nombre de un archivo, devuelve un String
con ese archivo (tomado de EasyIO.hs del curso Introductory Functional Programming for Physics Students )
Este código debe estar al inicio del script.
-- extensión para poder leer archivos en Strings import Hugs.IOExts(unsafePerformIO) leeArchivo :: String -> String leeArchivo nombre = unsafePerformIO $ readFile nombre
Para obtener textos largos e interesantes tenemos el Proyecto Gutemberg-Español donde hay muchos libros que ya no tienen derecho de copia.
Por ejemplo están Cuentos de Amor de Locura y de Muerte de Horacio Quiroga y El Gaucho Martín Fierro de José Hernandez.
Hay que tener cuidado con la codificación pues no todas funcionan.
Por ejemplo UTF8
es una codificación posible. Bajamos el archivo Martín Fierro en UTF8, lo guardamos con el nombre 14765-utf8.txt
y lo usamos para probar partePalabras.
Main> take 100 (partePalabras (leeArchivo "14765-utf8.txt")) ["The","Project","Gutenberg","EBook","of","El","Gaucho","Mart\237n","Fierro, ...
o de una forma equivalente utilizando el operador .
de composición de funciones, a fin de evitar un poco de paréntesis y mejorar la lectura del código.
((take 100).partePalabras.leeArchivo) "14765-utf8.txt" ["The","Project","Gutenberg","EBook","of","El","Gaucho","Mart\237n","Fierro, ...
La función más importante tiene el siguiente tipo:
frecuencia :: [Palabra] -> [(Palabra,Int)]
donde el resultado es una lista de pares (Palabra,Int)
que representa la cantidad de veces que una palabra está en el texto.
Para definir esta función utilizaremos la idea de acumulación. Básicamente si podemos definir una función:
agregaOcurrencia :: Palabra -> [(Palabra,Int)] -> [(Palabra,Int)]
que procesa una palabra, sumando 1 al entero cada vez que se encuentra una ocurrencia de la palabra. La definición de frecuencia es simplemente acumular toda la lista con esta función.
Casos de test: agregaOcurrencia “hola” [(“hola”,1)]
, agregaOcurrencia “que” [(“hola”,1)]
.
Caso de test: frecuencia (partePalabras “Cantando me he de morir Cantando me han de enterrar”) = [(“enterrar”,1),(“de”,2),(“han”,1),(“me”,2),(“Cantando”,2),(“morir”,1),(“he”,1)]
El esquema presentado hasta ahora (frecuencia.partePalabras.leeArchivo
) resulta poco útil, debido a que resulta esencial, conocer cuales son, por ejemplo, las 10 palabras más frecuentes. Veamos la utilidad en distintos contextos:
La lista que devuelve frecuencia está ordenada según aparecen las palabras. Podríamos intentar ordenarlas de acuerdo a la frecuencia y luego tomar las 10 primeras para ver con que nos encontramos.
En el Problemario del taller, se planteó la función ordenaIns, y en en la Wiki con las soluciones tiene el siguiente código insertaOrd, ordenaIns, que al día de hoy es:
{-Lord Hobborg-} insertaOrd :: Int -> [Int] -> [Int] insertaOrd y (x:xs) | y>=x = x:insertaOrd y xs | y<x = y:(x:xs) insertaOrd y _ = [y] {-Lord Hobborg-} ordenaIns :: [Int] -> [Int] ordenaIns (x:xs) = insertaOrd x (ordenaIns xs) ordenaIns _ = []
Casos de test: insertaOrdFrec (“algo”,10) []
, insertaOrdFrec (“algo”,10) [(“nada”,1)]
, insertaOrdFrec (“algo”,10) [(“mucho”,100)]
.
(frecuencia.(take 1000).partePalabras.leeArchivo) “14765-utf8.txt”
para obtener en forma creciente la frecuencia de las primeras 1000 palabras de “El Gaucho Martín Fierro”.
El cálculo de la frecuencia resulta ineficiente. Por cada palabra tenemos que recorrer toda la lista de frecuencias [(Palabra,Int)]
a fin de incluir la nueva ocurrencia.
De hecho para las aproximadamente 15000 palabras que contiene “El Gaucho Martín Fierro”, este proceso toma su tiempo.
Una posible mejora sería mantener ordenada por palabras la lista de frecuencias, a fin de cortar la búsqueda apenas llegamos a una palabra mayor. El código es el siguiente:
-- Ordenada por palabras agregaOcurrenciaOrd :: Palabra -> [(Palabra,Int)] -> [(Palabra,Int)] agregaOcurrenciaOrd palabra [] = [(palabra,1)] agregaOcurrenciaOrd palabra ((p,n):xs) | p==palabra = (p,n+1) : xs | p<palabra = (p,n) : agregaOcurrenciaOrd palabra xs | p>palabra = (palabra,1) : (p,n) : xs -- Optimizada? frecuencia' :: [Palabra] -> [(Palabra,Int)] frecuencia' xs = foldr agregaOcurrenciaOrd [] xs
Activamos la llave que muestra la cantidad de reducciones con:
Main> :set +s
Y comprobamos cual de las dos versiones resulta más rápida (menor cantidad de reducciones).
Main> (frecuencia.(take 100).partePalabras.leeArchivo) "14765-utf8.txt" [("Palacio.",1),("Cecowski",1),("Mariano",1), ... ] (77558 reductions, 131876 cells) Main> (frecuencia'.(take 100).partePalabras.leeArchivo) "14765-utf8.txt" [("#14765]",1),("***",2),("2005",1),("23,",1), ... ] (105556 reductions, 232333 cells)
D'oh!
Al parecer la idea no es buena, y se pone peor a medida que aumentamos la cantidad de palabras que tomamos en el take
.
Podemos intentar cambiar de manera salvaje la estructura de datos, es decir cambiar la lista de pares por otra estructura que mejore el tiempo de búsqueda de una palabra.
Una de las estructuras de datos más comunes que mejora de manera dramática el tiempo de búsqueda son los Árboles Binarios de Búsqueda o ABB.
Esta estructura de datos es ligeramente más complicada que la lista, cada nodo tiene:
donde el dato que está en la raíz es mayor que todos los del subárbol izquierdo y menor que todos los del subárbol derecho.
Luego con esta forma de estructurar los datos, realizar una búsqueda es sencillo:
Para definir estos árboles haremos uso de la definición de tipos de datos en Haskell data
.
data ArbolFrec = Nodo (Palabra,Int) ArbolFrec ArbolFrec | Nulo agregaOcurrenciaArbol :: Palabra -> ArbolFrec -> ArbolFrec agregaOcurrenciaArbol palabra Nulo = Nodo (palabra,1) Nulo Nulo agregaOcurrenciaArbol palabra (Nodo par@(p,n) izq der) | p==palabra = Nodo (p,n+1) izq der | p<palabra = Nodo par (agregaOcurrenciaArbol palabra izq) der | p>palabra = Nodo par izq (agregaOcurrenciaArbol palabra der) frecuencia'' :: [Palabra] -> ArbolFrec frecuencia'' xs = foldr agregaOcurrenciaArbol Nulo xs
Lo probamos para ver el resultado en las 100 primeras palabras y obtenemos:
Main> (frecuencia''.(take 100).partePalabras.leeArchivo) "14765-utf8.txt" ERROR - Cannot find "show" function for: *** Expression : (frecuencia'' . take 100 . partePalabras . leeArchivo) "14765-utf8.txt" *** Of type : ArbolFrec
El problema se soluciona desactivando una llave de Hugs
Main> :set -u Main> (frecuencia''.(take 100).partePalabras.leeArchivo) "14765-utf8.txt" ArbolFrec_Nodo ("Palacio.",1) (ArbolFrec_Nodo ("by",3) (ArbolFrec_Nodo ("formatted",1) (ArbolFrec_Nodo ("set",1) (ArbolFrec_Nodo ("www.gutenberg.net",1) ArbolFrec_Nulo (ArbolFrec_Nodo ("this",1) (ArbolFrec_Nodo ("with",2) ...
… y esto no se entiende nada! Entonces definimos una función que “aplana” el árbol en una lista, de forma tal que lo podamos leer y también ordenarlo.
aplanaArbol :: ArbolFrec -> [(Palabra,Int)] aplanaArbol Nulo = [] aplanaArbol (Nodo par izq der) = par : (aplanaArbol izq ++ aplanaArbol der)
Ahora si veamos como se comporta la versión ABB del problema:
Main> (aplanaArbol.frecuencia''.(take 100).partePalabras.leeArchivo) "14765-utf8.txt" [("Palacio.",1),("by",3),("formatted",1),("set",1),("www.gutenberg.net",1), ... (58403 reductions, 104322 cells)
Woohoo!
La siguiente tabla resume las reducciones en de las 3 versiones y para 3 cantidades de palabras:
algoritmo\palabras | 100 | 1000 | 10000 |
---|---|---|---|
frecuencia' | 96K | 2.99M | 188M |
frecuencia | 68K | 1.61M | 61M |
frecuencia'' | 58K | 0.66M | 8.42M |
Notar que la mejora de frecuencia' ' es mucho más que lineal, para 100 palabras es 1.17 veces más rápido que el standard frecuencia, para 1000 es 2.43 veces más rápido y finalmente para 10000 palabras es 7.24 veces más rápido.