Como hacer un mapa muy bonito de España en ggplot2

(Este post apareció originalmente en mi blog).

Hace unas semanas leí un artículo en el cual Timo Grossenbacher mostraba como consiguió hacer, en mi opinión, uno de los mapas más bonitos que he visto nunca. Timo empleó la que es, en mi opinión, una de las librerias más expresivas y bellas que hay para hacer gráficos, ggplot2. La versión original de ggplot2 es para R, pero existe una versión de python no exhaustiva gracias a la buena gente de Yhat.

Asi que por supuesto, tenía que replicarlo.

Antes que nada, aquí está el mapa.

Mapilla

El código empleado para hacer el mapa lo podeis descargar en github. He compartido varias versiones del mapa para que se pueda observar como los diferentes cambios en las escalas afectan a la visualización.

Código.

Para empezar, importamos las librerías necesarias:

 
setwd("/DIRECTORIO_DE_TRABAJO/")

if (!require(rgdal)) {
install.packages("rgdal", repos = "http://cran.us.r-project.org")
require(rgdal)
}

if (!require(rgeos)) {
install.packages("rgeos", repos = "http://cran.us.r-project.org")
require(rgeos)
}
if (!require(rgdal)) {
install.packages("rgdal", repos = "http://cran.us.r-project.org")
require(rgdal)
}
if (!require(raster)) {
install.packages("raster", repos = "http://cran.us.r-project.org")
require(raster)
}
if(!require(ggplot2)) {
install.packages("ggplot2", repos="http://cloud.r-project.org")
require(ggplot2)
}
if(!require(viridis)) {
install.packages("viridis", repos="http://cloud.r-project.org")
require(viridis)
}
if(!require(dplyr)) {
install.packages("dplyr", repos = "https://cloud.r-project.org/")
require(dplyr)
}
if(!require(gtable)) {
install.packages("gtable", repos = "https://cloud.r-project.org/")
require(gtable)
}
if(!require(grid)) {
install.packages("grid", repos = "https://cloud.r-project.org/")
require(grid)
}
if(!require(tidyr)) {
install.packages("tidyr", repos = "https://cloud.r-project.org/")
require(tidyr)
}
}
 

El siguiente paso es importar los datos. Tras mucho buscar, encontré un archivo shapefile con los municipios españoles en ArcGis, sin ninguna atribución que pudiera encontrar.

Para obtener los datos del censo español, hice uso de la *fantástica* herramienta de extracción de datos proporcionada por el Instituto Nacional de Estadística. La herramienta es una pesadilla en términos de usabilidad, así que si queréis simplemente hacer el mapa he compartido los datos en el repositorio.

 
data_spain data_spain$municipality_code data_spain$People data_spain$Average.age

#Cargamos el shapefile y lo convertimos en un dataframe
municipalities_spain map_data_fortified_spain % mutate(id = as.numeric(id))

#Ahora unimos los datos del censo con los datos geométricos usando municipality_code como clave
map_data_spain % left_join(data_spain, by = c("id" = "municipality_code")) %>% fill(Average.age)
rm(data_spain)
rm(map_data_fortified_spain)
rm(municipalities_spain)
 

Finalmente, el código para hacer el mapa en sí. Hay muchísima lógica en dicho código orientada a hacer el mapa más bonito, os recomiendo mirar el artículo original para ver la evolución de los parámetros del gráfico, en particular todo lo relativo a la escala de colores.

 
# Aquí hacemos que los saltos de la escala de edades sean más bonitos e informativos visualmente

# encontramos los extremos del rango de edad
minVal maxVal # calculamos los valores de las etiquetas de los rangos de edad
labels brks # Redondeamos los extremos del rango de edad
for(idx in 1:length(brks)){
labels }

labels # definimos una nueva variable con los datos de la escala de edad
map_data_spain$brks breaks = brks,
include.lowest = TRUE,
labels = labels)

brks_scale labels_scale

theme_map theme_minimal() +
theme(
text = element_text(family = "Ubuntu Regular", color = "#22211d"),
axis.line = element_blank(),
axis.text.x = element_blank(),
axis.text.y = element_blank(),
axis.ticks = element_blank(),
axis.title.x = element_blank(),
axis.title.y = element_blank(),
# panel.grid.minor = element_line(color = "#ebebe5", size = 0.2),
panel.grid.major = element_line(color = "#ebebe5", size = 0.2),
panel.grid.minor = element_blank(),
plot.background = element_rect(fill = "#f5f5f2", color = NA),
panel.background = element_rect(fill = "#f5f5f2", color = NA),
legend.background = element_rect(fill = "#f5f5f2", color = NA),
panel.border = element_blank(),
...
)
}

#Esta función simplemente extiende los extremos de la escala de edad para llegar al mínimo y el máximo
extendLegendWithExtremes p_grob legend legend_grobs # grab the first key of legend
legend_first_key legend_first_key$widths # modify its width and x properties to make it longer
legend_first_key$grobs[[1]]$width legend_first_key$grobs[[1]]$x

# último valor de la leyenda
legend_last_key legend_last_key$widths

legend_last_key$grobs[[1]]$width legend_last_key$grobs[[1]]$x

# cambiamos también la posición de la última etiqueta para que no se superponga a la anterior
legend_last_label legend_last_label$grobs[[1]]$x

# Insertamos el nuevo color de la leyenda en la leyenda combinada
legend_grobs$grobs[legend_grobs$layout$name == "key-3-1-1"][[1]] <-
legend_first_key$grobs[[1]]
legend_grobs$grobs[legend_grobs$layout$name == "key-3-6-1"][[1]] <-
legend_last_key$grobs[[1]]
legend_grobs$grobs[legend_grobs$layout$name == "label-5-6"][[1]] <-
legend_last_label$grobs[[1]]

# Ahora lo mismo para el valor mínimo de la leyenda
new_first_label new_first_label$label new_first_label$x new_first_label$hjust

legend_grobs new_first_label,
t = 6,
l = 2,
name = "label-5-0",
clip = "off")
legend$grobs[[1]]$grobs[1][[1]] p_grob$grobs[p_grob$layout$name == "guide-box"][[1]]

# se usa esta función para dibujar la escala
grid.newpage()
grid.draw(p_grob)
}

p geom_polygon(data = map_data_spain, aes(fill = brks,
x = long,
y = lat,
group = group)) +
# municipality outline
geom_path(data = map_data_spain, aes(x = long,
y = lat,
group = group),
color = "white", size = 0.1) +
coord_equal() +
theme_map() +
theme(
legend.position = c(0.7, 0.03),
legend.text.align = 0,
legend.background = element_rect(fill = alpha('white', 0.0)),
legend.text = element_text(size = 14, hjust = 0, color = "#4e4d47"),
legend.title = element_text(size = 20),
plot.title = element_text(size = 28, hjust = 0.8, color = "#4e4d47"),
plot.subtitle = element_text(size = 20, hjust = 0.8, face = "italic", color = "#4e4d47"),
plot.caption = element_text(size = 14, hjust = 0.95, color = "#4e4d47"),
plot.margin = unit(c(.5,.5,.2,.5), "cm"),
panel.border = element_blank()
) +
labs(x = NULL,
y = NULL,
title = "Spain's regional demographics",
subtitle = "Average age in Spanish municipalities, 2011",
caption = "Author: Manuel Garrido (@manugarri) Original Idea: Timo Grossenbacher (@grssnbchr), Geometries: ArcGis Data: INE, 2011;") +
scale_fill_manual(
values = rev(magma(8, alpha = 0.8)[2:7]),
breaks = rev(brks_scale),
name = "Average age",
drop = FALSE,
labels = labels_scale,
guide = guide_legend(
direction = "horizontal",
keyheight = unit(2, units = "mm"),
keywidth = unit(70/length(labels), units = "mm"),
title.position = 'top',
title.hjust = 0.5,
label.hjust = 1,
nrow = 1,
byrow = T,
reverse = T,
label.position = "bottom"
)
)
extendLegendWithExtremes(p)
 

Este código está diseñado muy cuidadosamente para exportar una imagen con un ancho de 2400 píxeles.

Dado que las islas Canarias estan muy alejadas de la península, una práctica común es desplazar las islas más cerca de España, esto lo he hecho en Gimp.

Notas.

- Yo sabía que España tenía un problema poblacional, pero ¡madre mía! El noroeste del pais parece un gran asilo. El mapa original de Suiza tenia una escala de edad en el rango 40-52 años, pero he tenido que expandirlo a 40-56 debido al envejecimiento poblacional español.

- Una vez más, me he dado cuenta de lo mal que está el movimiento Open Data en España:

  • La herramienta de extracción de datos del INE parece que se hizo en los años 90 (tanto en usabilidad como en velocidad).
  • La información del censo de España se actualiza, ¡cada 10 años!. Esto significa que este mapa está usando la información más actualizada que existe de la población española, y es de 2011. Estos datos deberían actualizarse más frecuentemente en un mundo en el que todo cambia más rápido.
  • Si vais al mapa del artículo original, vereis que su mapa tiene una capa de topografía muy bonita encima de los municipios. Timo usó una imagen ráster a escala 1:1.000.000 con información topográfica proporcionada por la oficina federal de topografía suiza.
    Yo me tiré un dia entero buscando algo similar para España, y según parece el Centro Nacional de Información Geográfica sólo proporciona mapas ráster en la escala de 25 y 50 metros lo que obliga a descargarte cientos de archivos de imagen y unirlos luego. De verdad que yo no tenía ganas de hacer eso para hacer un mapa a escala tan pequeña. Al final, me hice mi propia imágen topográfica raster tomando imágenes de internet que procesé en Gimp. No incluyo el relieve en el mapa por que, al contrario que con Suiza (donde no hay municipios en la región de los Alpes), en España mostrar el relieve ocultaría la información de los municipios por debajo.

- Aunque tengo que decir que la cantidad de esfuerzo y dedicación que ha llevado realizar este mapa es impresionante (una de las principales razones por las cuales yo quería replicarlo), creo que debería haber una manera mejor de hacer gráficas customizadas en ggplot2. El código actual solo funciona con una resolución específica en mente, y se necesitan un montón de pruebas hasta llegar a encontrar los tamaños de fuentes que hacen que todo encaje. Idealmente ggplot2 usaría un sistema de referencia variable (algo como los em en css) que cambiara en funcion del tamaño del mapa.

Eso es todo por hoy, muchas gracias por vuestra atención 🙂

Como mejorar tu script fácilmente

Esta entrada apareció originalmente en inglés en mi blog.

Nos ha pasado a todos. Ese momento en el que descubres que sabes suficiente sobre un lenguage de programacion que quieres ponerlo en práctica y construir "algo", lo que sea.
Una de las mejores cosas de la comunidad de Python es no sólo su habilidad para construir cosas increíbles, sino también para compartirlas con todo el mundo, mejorando la comunidad en el proceso.

Sin embargo, llevo un tiempo fijándome en un patrón que se repite en algunos de estos proyectos. Seguro que has visto alguno así. Hablo de esos proyectos con 2 ó 3 componentes, donde el README tiene una pequeña descripción del proyecto, quizás un par de lineas explicando como ejecutar el proyecto, y frases del tipo, "Seguramente añadiré X o Y si tengo tiempo".

El caso es que muchos de estos proyectos son realmente interesantes, y tienen algún tipo de componentes que me gustaría usar sin tener que implementarlos yo mismo.

Te voy a mostrar 3 formas distintas de implementar uno de estos proyectos, cada una de ellas mejor (desde mi punto de vista) que la anterior:

Supongamos que queremos construir un script genial, donde la funcionalidad principal será que, dado un número entero por el usuario, realizará un calculo simple en base a ese entero, y devolverá el resultado.

Implementación 1

 
#!/usr/bin/env python

"""
Super awesome script
Asks the user for a number:
 - If the number is less or equal to 100, it returns the 1st tetration of the number (power of itself)
 - else, it returns the number squared
"""

__version__ = '0.1'

if __name__ == '__main__':

    while 1:
        user_number = input('Choose a number:\n') #raw_input() in python2
        if user_number.isdigit():
            user_number = int(user_number)
            break
        else:
            print('{} is not a valid number'.format(user_number))

    if user_number > 100:
        print(user_number**2)
    else:
        print(user_number**user_number)

Ésta suele ser la implementación de alquien que lleva poco tiempo en python. Funciona, pregunta al usuario por el input, realiza la operación, e imprime en pantalla el resultado.

Veo dos problemas en esta implementación:

1. No hay ningún tipo de separación entre la lógica de la interacción del usuario y la lógica del cálculo. Todo esta incluido en el mismo macro bloque. Pese a ser funcional, esta implementación hace que sea díficil el modificar o expandir este script (para hacerlo tendrías que leerte todo el código).

2. Estamos gestionando toda la validación por nuestra cuenta. Python tiene formas de hacer esto para que tú no te tengas que molestar en hacerlo :).

Para la siguiente implementación, usaremos el módulo mas simple de la libreria standard para trabajar con inputs del usuario, .

Implementación 2

 
#!/usr/bin/env python

"""
Super awesome script
Asks the user for a number:
 - If the number is less or equal to 100, it returns it to the power of itself
 - else, it returns the number squared
"""

import argparse

__version__ = '0.2'


if __name__ == '__main__':

    parser = argparse.ArgumentParser()
    parser.add_argument('--number', required=True, type=int,
                        help='number to perform calculation')
    values = parser.parse_args()
    user_number = values.number
    if user_number > 100:
        print(user_number**2)
    else:
        print(user_number**user_number)

En esta implementación hemos eliminado el problema #2 de la implementación anterior. En esta ocasión usamos argparse, de esta forma dejamos que la libreria estándar se encargue de la validación del input. Esta implementación no funciona a menos que el input sea válido.

Todavía tenemos el problema #1, la separación entre la lógica del input y la lógica primaria (la función de calculo).

En la siguiente implementación vemos como podemos arreglar esto.

Implementación 3

 
#!/usr/bin/env python

"""
Super awesome script
Asks the user for a number:
 - If the number is less or equal to 100, it returns it to the power of itself
 - else, it returns the number squared
"""

import argparse

__version__ = '0.3'



def calculation(number):
    """Performs awesome calculation"""
    if number > 100:
        return number**2
    else:
        return number**number

if __name__ == '__main__':

    parser = argparse.ArgumentParser()
    parser.add_argument('--number', required=True, type=int,
                        help='number to perform calculation')
    values = parser.parse_args()
    user_number = values.number
    calculation_result = calculation(user_number)
    print(calculation_result)

En esta implementación, hemos hecho dos cosas:

1. Hemos puesto la carga de la validación en un módulo bien mantenida como es argparse.
2. Hemos separado la lógica del input del usuario de la lógica del input de cálculo.

Éste último cambio tiene tres ventajas sobre #1 y #2.

- Ventaja 1: En primer lugar, si nos damos cuenta que por algún motivo queremos modificar el 100 por un 200, ahora podemos fácilmente modificar eso, sin tener que modificar ni leer todo el código. Siempre y cuando la función calculation siga teniendo los mismos inputs y outputs, el resto de código seguirá funcionando sin problemas.

- Ventaja 2: Otro efecto, y para mi el más significativo, es que si ahora yo leo este script que otra persona ha escrito, y me gusta tanto que quiero añadirlo a un proyecto mio, ¡ahora puedo importarlo sin problemas!.

En las implementacines #1 y #2, la única manera de usar el script era haciendo:

python calculation_script.py --number INTEGER

Ahora, en la implementación #3, tenemos una manera mucho mas útil de usar la lógica mas importante (la del cálculo). Si yo tengo otro script en el que quiero usar la funcion de cálculo, puedo usarla de la forma:

 
from calculation_script import calculation

number = 10
calculation_result = calculation(number)

¿Increíble, no? Simplemente haciendo una pequeña modificación a la estructura del proyecto, ahora cualquier persona se puede beneficiar del mismo.

- Ventaja 3: Supongamos que este simple proyecto empieza a crecer, más desarrolladores se interesan y empiezan a colaborar. El código empieza a crecer y alguien comenta que tendría sentido empezar a trabajar en el suite de testing. (si no sabes lo que es el testing, te recomiendo este artículo.)

Con la implementación #3, testear la funcionalidad de calculation es super fácil (gracias a /u/choffee en reddit por el apunte):

 
import pytest
from calculation_script import calculation

class TestCalculation:
    """Calculation function does funky things to number
    More above 100 than below
    """
    def test_zero():
        x = 0
        assert calculation(x) == 0

    def test_border():
        x = 100
        assert calculation(x) == 10000

    def test_one():
        x = 1
        assert calculation(x) == 1

Piensa en ello la próxima vez, no cuesta nada y hace que tu script sea mejor 🙂

Breve introducción a los Sistemas de Recomendación

En este pequeño tutorial, vamos a hablar sobre Sistemas de Recomendación.

Es posible que no sepas que son, sin embargo interactúas constantemente con ellos en Internet.

amazon

Cada vez que Amazon te sugiere productos relacionados...

netflix

O cuando Netflix o Youtube te recomiendan contenido que te puede interesar...

La finalidad de un sistema de recommendación es predecir la valoración que un usuario va a hacer de un ítem que todavía no ha evaluado.

Esta valoración se genera al analizar una de dos cosas, o las características de cada item, o las valoraciones de cada usuario a cada item, y se usa para recomendar contenido personalizado a los usuarios.

Hay dos tipos principales de sistemas de recomendación:

  • Filtrado de Contenido. Las recomendaciones están basadas en las características de cada item.
  • Filtrado Colaborativo. Las recomendaciones están basadas en las valoraciones existentes de los usuarios.

En este tutorial vamos a trabajar con el dataset de MovieLens. Este dataset contiene valoraciones de películas sacadas de la página web MovieLens (https://movielens.org/).

El dataset consiste en múltiples archivos, pero los que vamos a usar en este artículo son movies.dat y ratings.dat.

Primero nos descargamos el dataset:

 
wget http://files.grouplens.org/datasets/movielens/ml-1m.zip
unzip ml-1m.zip
cd ml-1m/

Filtrado de Contenido

Aquí están las primeras líneas del archivo movies.dat. El archivo tiene el formato:

movie_id::movie_title::movie genre(s)

 
head movies.dat

1::Toy Story (1995)::Animation|Children's|Comedy
2::Jumanji (1995)::Adventure|Children's|Fantasy
3::Grumpier Old Men (1995)::Comedy|Romance
4::Waiting to Exhale (1995)::Comedy|Drama
5::Father of the Bride Part II (1995)::Comedy
6::Heat (1995)::Action|Crime|Thriller
7::Sabrina (1995)::Comedy|Romance
8::Tom and Huck (1995)::Adventure|Children's
9::Sudden Death (1995)::Action
10::GoldenEye (1995)::Action|Adventure|Thriller

Los géneros de cada película están separados por un pipe |.

Cargamos el archivo movies.dat:

 
import pandas as pd
import numpy as np
movies_df = pd.read_table('movies.dat', header=None, sep='::', names=['movie_id', 'movie_title', 'movie_genre'])

movies_df.head()

Out[]:

movie_id movie_title movie_genre
0 1 Toy Story (1995) Animation|Children's|Comedy
1 2 Jumanji (1995) Adventure|Children's|Fantasy
2 3 Grumpier Old Men (1995) Comedy|Romance
3 4 Waiting to Exhale (1995) Comedy|Drama

Para poder usar la columna movie_genre, tenemos que convertirla en un grupo de campos llamados dummy_variables.

Esta función convierte una variable categórica (por ejemplo, el genéro de la película puede ser Animation, Comedy, Romance...), en múltiples columnas (una columna para Animation, una columna para Comedy, etc).

Para cada película, éstas columnas dummy tendrán un valor de 0 excepto para aquellos géneros que tenga la película.

 
# we convert the movie genres to a set of dummy variables 
movies_df = pd.concat([movies_df, movies_df.movie_genre.str.get_dummies(sep='|')], axis=1)
movies_df.head()

Out[]:

movie_id movie_title movie_genre Action Adventure Animation Children's Comedy Crime Documentary ...
0 1 Toy Story (1995) Animation|Children's|Comedy 0 0 1 1 1 0 0 ...
1 2 Jumanji (1995) Adventure|Children's|Fantasy 0 1 0 1 0 0 0 ...
2 3 Grumpier Old Men (1995) Comedy|Romance 0 0 0 0 1 0 0 ...
3 4 Waiting to Exhale (1995) Comedy|Drama 0 0 0 0 1 0 0 ...
4 5 Father of the Bride Part II (1995) Comedy 0 0 0 0 1 0 0 ...

Por ejemplo, la película con una id de 1, Toy Story, pertenece a los géneros Animation, Children's y Comedy, y por lo tanto las columnas Animation, Children's y Comedy tienen un valor de 1 para Toy Story.

 
movie_categories = movies_df.columns[3:]
movies_df.loc[0]

Out[]:

movie_id                                 1
movie_title               Toy Story (1995)
movie_genre    Animation|Children&apos;s|Comedy
Action                                   0
Adventure                                0
Animation                                1
Children&apos;s                          1
Comedy                                   1
Crime                                    0
Documentary                              0
Drama                                    0
Fantasy                                  0
Film-Noir                                0
Horror                                   0
Musical                                  0
Mystery                                  0
Romance                                  0
Sci-Fi                                   0
Thriller                                 0
War                                      0
Western                                  0
Name: 0, dtype: object

El filtrado de contenidos es una manera bastante simple de construir un sistema de recomendación. En este método, los items (en éste ejemplo las películas), se asocian a un grupo de características (en este caso los géneros cinematográficos).

Para recomendar items a un usuario, primero dicho usuario tiene que especificar sus preferencias en cuanto a las características.

En el ejemplo de Movielens, el usuario tiene que especificar qué generos le gustan y cuánto le gustan.

Por el momento tenemos todas las columnas categorizadas por géneros.

Vamos a crear un usuario de ejemplo, con unos gustos cinematográficos enfocados a películas de acción, aventura y ficción:

 
from collections import OrderedDict

user_preferences = OrderedDict(zip(movie_categories, []))

user_preferences['Action'] = 5
user_preferences['Adventure'] = 5
user_preferences['Animation'] = 1
user_preferences["Children's"] = 1
user_preferences["Comedy"] = 3
user_preferences['Crime'] = 2
user_preferences['Documentary'] = 1
user_preferences['Drama'] = 1
user_preferences['Fantasy'] = 5
user_preferences['Film-Noir'] = 1
user_preferences['Horror'] = 2
user_preferences['Musical'] = 1
user_preferences['Mystery'] = 3
user_preferences['Romance'] = 1
user_preferences['Sci-Fi'] = 5
user_preferences['War'] = 3
user_preferences['Thriller'] = 2
user_preferences['Western'] =1

Ahora que tenemos las preferencias del usuario, para calcular la puntuación que dicho usuario daría a cada película sólo tenemos que hacer el producto vectorial de las preferencias del usuario con las características de cada película.

 
#En producción usaríamos np.dot, en vez de escribir esta función, la pongo como ejemplo.
def dot_product(vector_1, vector_2):
    return sum([ i*j for i,j in zip(vector_1, vector_2)])

def get_movie_score(movie_features, user_preferences):
    return dot_product(movie_features, user_preferences)

Ahora podemos computar la puntuación de la película Toy Story, una película de animación infantil, para el usuario del ejemplo.

 
toy_story_features = movies_df.loc[0][movie_categories]
toy_story_features
>Action         0
Adventure      0
Animation      1
Children&apos;s     1
Comedy         1
Crime          0
Documentary    0
Drama          0
Fantasy        0
Film-Noir      0
Horror         0
Musical        0
Mystery        0
Romance        0
Sci-Fi         0
Thriller       0
War            0
Western        0
Name: 0, dtype: object
 
toy_story_user_predicted_score = dot_product(toy_story_features, user_preferences.values())
toy_story_user_predicted_score

Out[]:

5

Para este usuario, Toy Story tiene una puntuación de 5. Lo cual no significa mucho por sí mismo, sólo si comparamos dicha puntuación con la puntuación de las otras películas.

Por ejemplo,, calculamos la puntuación de Die Hard (La Jungla de Cristal), una película de acción.

 
movies_df[movies_df.movie_title.str.contains('Die Hard')]
movie_id movie_title movie_genre Action Adventure Animation Children's Comedy Crime Documentary ...
163 165 Die Hard: With a Vengeance (1995) Action|Thriller 1 0 0 0 0 0 0 ...
1023 1036 Die Hard (1988) Action|Thriller 1 0 0 0 0 0 0 ...
1349 1370 Die Hard 2 (1990) Action|Thriller 1 0 0 0 0 0 0 ...
 
die_hard_id = 1036
die_hard_features = movies_df[movies_df.movie_id==die_hard_id][movie_categories]
die_hard_features.T 

Out[]:

1023
Action 1
Adventure 0
Animation 0
Children's 0
Comedy 0
Crime 0
Documentary 0
Drama 0
Fantasy 0
Film-Noir 0
Horror 0
Musical 0
Mystery 0
Romance 0
Sci-Fi 0
Thriller 1
War 0
Western 0
  • Nota, 1023 es el índice interno del dataframe, no el índice de la película Die Hard en Movielens*
 
die_hard_user_predicted_score = dot_product(die_hard_features.values[0], user_preferences.values())
die_hard_user_predicted_score

Out[]:

8

Vemos que Die Hard tiene una puntuación de 8 y Toy Story de 5, asi que Die Hard sería recomendada al usuario antes que Toy Story. Lo cual tiene sentido teniendo en cuenta que a nuestro usuario de ejemplo le encantan las películas de acción.

Una vez sabemos como calcular la puntuación para una película, recomendar nuevas películas es tan fácil como calcular las puntuaciones de cada película, y luego escoger aquellas con una puntuación más alta.

 
def get_movie_recommendations(user_preferences, n_recommendations):
    #we add a column to the movies_df dataset with the calculated score for each movie for the given user
    movies_df['score'] = movies_df[movie_categories].apply(get_movie_score, 
                                                           args=([user_preferences.values()]), axis=1)
    return movies_df.sort_values(by=['score'], ascending=False)['movie_title'][:n_recommendations]
    
get_movie_recommendations(user_preferences, 10)    

Out[]:

2253                                       Soldier (1998)
257             Star Wars: Episode IV - A New Hope (1977)
2036                                          Tron (1982)
1197                              Army of Darkness (1993)
2559     Star Wars: Episode I - The Phantom Menace (1999)
1985                      Honey, I Shrunk the Kids (1989)
1192    Star Wars: Episode VI - Return of the Jedi (1983)
1111                                    Abyss, The (1989)
1848                                    Armageddon (1998)
2847                                  Total Recall (1990)
Name: movie_title, dtype: object</pre>

Asi que vemos que el sistema de recomendación recomienda películas de acción y de ciencia ficción.

El filtrado de contenidos hace que recomendar nuevas películas a un usuario sea muy fácil, ya que los usuarios simplemente tienen que indicar sus preferencias una vez. Sin embargo, este sistema tiene algunos problemas:

  • Hay que categorizar cada item nuevo manualmente en funcion a las características existentes.
  • Las recomendaciones son limitadas, ya que por ejemplo, los items existentes no se pueden clasificar en función de una nueva categoría.

Hemos visto que el filtrado de contenidos es quizás una manera demasiado simple de hacer recomendaciones, lo que nos lleva a...

Filtrado Colaborativo

El filtrado colaborativo es otro método distinto de predecir puntuaciones de usuarios a items. Sin embrago, en este método usamos las puntuaciones existentes de usuarios a items para predecir los items que no han sido valorados por el usuario al que queremos recomendar.

Para ello asumimos que las recomendaciones que le hagamos a un usuario serán mejores si las basamos en usuarios con gustos similares.

Para este ejemplo usaremos el archivo ratings.dat, que tiene el siguiente formato:

user_id::movie_id::rating::timestamp

head ratings.dat

1::1193::5::978300760
1::661::3::978302109
1::914::3::978301968
1::3408::4::978300275
1::2355::5::978824291
1::1197::3::978302268
1::1287::5::978302039
1::2804::5::978300719
1::594::4::978302268
1::919::4::978301368

El dataset de Movielens contiene un archivo con más de un millón de valoraciones de películas hechas por usuarios.

 
ratings_df = pd.read_table('ratings.dat', header=None, sep='::', names=['user_id', 'movie_id', 'rating', 'timestamp'])

#Borramos al fecha en la que el rating fue creado.
del ratings_df['timestamp']

#reemplazamos la id de la película por su titulo para tener una mayor claridad
ratings_df = pd.merge(ratings_df, movies_df, on='movie_id')[['user_id', 'movie_title', 'movie_id','rating']]

ratings_df.head()

Out[]:

user_id movie_title movie_id rating
0 1 One Flew Over the Cuckoo's Nest (1975) 1193 5
1 2 One Flew Over the Cuckoo's Nest (1975) 1193 5
2 12 One Flew Over the Cuckoo's Nest (1975) 1193 4
3 15 One Flew Over the Cuckoo's Nest (1975) 1193 4
4 17 One Flew Over the Cuckoo's Nest (1975) 1193 5

De momento tenemos una matriz de usuarios y películas, vamos a convertir ratings_df a una matriz con un usuario por fila y una película por columna.

 
ratings_mtx_df = ratings_df.pivot_table(values='rating', index='user_id', columns='movie_title')
ratings_mtx_df.fillna(0, inplace=True)

movie_index = ratings_mtx_df.columns

ratings_mtx_df.head()

Out[]:

movie_title $1,000,000 Duck (1971) 'Night Mother (1986) 'Til There Was You (1997) ...
user_id
1 0 0 0 ...
2 0 0 0 ...
3 0 5 0 ...
4 0 0 1 ...
5 0 0 0 ...

Nos queda una matriz de 6064 usuarios y 3706 películas.

Para computar la similaridad entre películas, una manera de hacerlo es calcular la correlación entre ellas en función de la puntuación que dan los usuarios.

Una manera fácil de calcular la similaridad en python es usando la función numpy.corrcoef, que calcula el coeficiente de correlación de Pearson(PMCC)](https://es.wikipedia.org/wiki/Coeficiente_de_correlaci%C3%B3n_de_Pearson) entre cada pareja de items.

El PMCC tiene un valor entre -1 y 1 que mide cuán relacionadas están un par de variables cuantitativas.

La matriz de correlación es una matriz de tamaño m x m, donde el elemento Mij representa la correlación entre el item i y el item j.

 
corr_matrix = np.corrcoef(ratings_mtx_df.T)
corr_matrix.shape

Out[]:

(3706, 3706)

*Nota: Usamos la matriz traspuesta de ratings_mtx_df para que la función np.corrcoef nos devuelva la correlación entre películas. En caso de no hacerlo nos devolvería la correlación entre usuarios.

Una vez tenemos la matriz, si queremos encontrar películas similares a una concreta, solo tenemos que encontrar las películas con una correlación alta con ésta.

 
favoured_movie_title = 'Toy Story (1995)'

favoured_movie_index = list(movie_index).index(favoured_movie_title)

P = corr_matrix[favoured_movie_index]

#solo devolvemos las películas con la mayor correlación con Toy Story
list(movie_index[(P>0.4) & (P<1.0)])

Out[]:

['Aladdin (1992)',
 "Bug's Life, A (1998)",
 'Groundhog Day (1993)',
 'Lion King, The (1994)',
 'Toy Story 2 (1999)']

Vemos que los resultados son bastante buenos.

Ahora, si queremos recomendar películas a un usuario, solo tenemos que conseguir la lista de películas que dicho usuario ha visto. Ahora, con dicha lista, podemos sumar las correlaciones de dichas películas con todas las demás y devolver las películas con una mayor correlación total.

 
def get_movie_similarity(movie_title):
    '''Devuelve el vector de correlación para una película'''
    movie_idx = list(movie_index).index(movie_title)
    return corr_matrix[movie_idx]

def get_movie_recommendations(user_movies):
    '''Dado un grupo de películas, devolver las mas similares'''
    movie_similarities = np.zeros(corr_matrix.shape[0])
    for movie_id in user_movies:
        movie_similarities = movie_similarities + get_movie_similarity(movie_id)
    similarities_df = pd.DataFrame({
        'movie_title': movie_index,
        'sum_similarity': movie_similarities
        })
    similarities_df = similarities_df[~(similarities_df.movie_title.isin(user_movies))]
    similarities_df = similarities_df.sort_values(by=['sum_similarity'], ascending=False)
    return similarities_df

Por ejemplo, vamos a seleccionar un usuario con preferencia por las películas infantiles y algunas películas de acción.

 
sample_user = 21
ratings_df[ratings_df.user_id==sample_user].sort_values(by=['rating'], ascending=False)

Out[]:

user_id movie_title movie_id rating
583304 21 Titan A.E. (2000) 3745 5
707307 21 Princess Mononoke, The (Mononoke Hime) (1997) 3000 5
70742 21 Star Wars: Episode VI - Return of the Jedi (1983) 1210 5
239644 21 South Park: Bigger, Longer and Uncut (1999) 2700 5
487530 21 Mad Max Beyond Thunderdome (1985) 3704 4
707652 21 Little Nemo: Adventures in Slumberland (1992) 2800 4
708015 21 Stop! Or My Mom Will Shoot (1992) 3268 3
706889 21 Brady Bunch Movie, The (1995) 585 3
623947 21 Iron Giant, The (1999) 2761 3
619784 21 Wild Wild West (1999) 2701 3
4211 21 Bug's Life, A (1998) 2355 3
368056 21 Akira (1988) 1274 3
226126 21 Who Framed Roger Rabbit? (1988) 2987 3
41633 21 Toy Story (1995) 1 3
34978 21 Aladdin (1992) 588 3
33432 21 Antz (1998) 2294 3
18917 21 Bambi (1942) 2018 1
612215 21 Devil's Advocate, The (1997) 1645 1
617656 21 Prince of Egypt, The (1998) 2394 1
440983 21 Pinocchio (1940) 596 1
707674 21 Messenger: The Story of Joan of Arc, The (1999) 3053 1
708194 21 House Party 2 (1991) 3774 1

Ahora podemos proporcionar nuevas recomendaciones para dicho usuario teniendo en cuenta las películas que ha visto como input.

 
sample_user_movies = ratings_df[ratings_df.user_id==sample_user].movie_title.tolist()
recommendations = get_movie_recommendations(sample_user_movies)

#Obtenemos las 20 películas con mejor puntuación
recommendations.movie_title.head(20)

Out[]:

1939                     Lion King, The (1994)
324                Beauty and the Beast (1991)
1948                Little Mermaid, The (1989)
3055    Snow White and the Seven Dwarfs (1937)
647                     Charlotte&apos;s Web (1973)
679                          Cinderella (1950)
1002                              Dumbo (1941)
301                              Batman (1989)
3250            Sword in the Stone, The (1963)
303                      Batman Returns (1992)
2252                              Mulan (1998)
2924                Secret of NIMH, The (1982)
2808                         Robin Hood (1973)
3026                    Sleeping Beauty (1959)
1781                   Jungle Book, The (1967)
260         Back to the Future Part III (1990)
259          Back to the Future Part II (1989)
2558                          Peter Pan (1953)
2347             NeverEnding Story, The (1984)
97                  Alice in Wonderland (1951)
Name: movie_title, dtype: object

Vemos que el sistema recomienda mayoritariamente películas infantiles y algunas películas de acción.

El ejemplo que he puesto sobre filtrado colaborativo es un ejemplo muy simple, y no tiene en cuenta las valoraciones que cada usuario ha hecho a cada película (solo si las ha visto).

Una manera más eficaz de hacer filtrado colaborativo es vía Descomposición en valores singulares (SVD). Es un tópico que da para otro artículo pero este artículo lo explica con bastante claridad.

El filtrado colaborativo se usa con frecuencia en la actualidad. Es capaz de recomendar nuevos items sin tener que clasificarlos manualmente en función de sus características. Además, es capaz de proporcionar recomendaciones basadas en características ocultas que no serían obvias a primera vista (por ejemplo, combinaciones de géneros o de actores).
Sin embargo, el filtrado colaborativo tiene un problema importante, y es que no puede recomendar items a un usuario a menos que dicho usuario haya valorado items, este problema se llama problema de Arranque en frío.

Una manera de solucionar ésto es usar un sistema híbrido de filtrado de contenido + filtrado colaborativo, usando el filtrado de contenidos para nuevos usuarios y filtrado colaborativo para usuarios de los que se tiene suficiente información.

Lista de lecturas

Aquí hay una lista de lecturas sobre sisetmas de recomendación (en inglés)

Como hacer Análisis de Sentimiento en español

Este post es una continuación de un articulo previo donde explico como obtener y dibujar en un mapa un mapa de calor de miles de tweets enviados desde mi ciudad

Puedes encontrar el código que he usado en github.

Tambien he subido el archivo de tweets obtenido en el articulo anterior para que puedas seguir este tutorial sin tener que descargarte los tweets.

En este post, me enfocaré en como hacer análisis de sentimiento (Sentiment Analysis) en español.

Hacer Sentiment Analysis en inglés es muy fácil. Hay múltiples paquetes que vienen con modelos preparados para calcular el sentimiento o polaridad de un nuevo texto (ejemplos incluyen TextBlob o word2vec).

Sin embargo, no tengo constancia de un modelo preparado en español, así que en este post vamos a hacer nuestro propio modelo predictivo :).

Para eso, lo primero que necesitamos es un dataset previamente categorizado. En mi caso utilicé el corpus de TASS.

TASS es un Taller de Análisis de Sentimiento en español organizado cada año por la Sociedad Española del Procesado del Lenguaje Natural(SEPLN).

Hay que pedir permiso para usar el corpus, por tanto no puedo compartirlo aquí. Para conseguirlo, solo hay que ponerse en contacto con los organizadores del TASS (hay un email de contacto en su pagina).

Los archivos del corpus están en formato XML y contienen miles de tweets en español con su sentimiento (polaridad). Algunos de estos archivos están enfocados en un tópico, por ejemplo política o TV.

La estructura de los archivos es similar a esta:

<?xml version="1.0" encoding="UTF-8"?>  
<tweets>  
 <tweet>
  <tweetid>142378325086715906</tweetid>
  <user>jesusmarana</user>
  <content><![CDATA[Portada 'Público', viernes. Fabra al banquillo por 'orden' del Supremo; Wikileaks 'retrata' a 160 empresas espías. http://t.co/YtpRU0fd]]></content>
  <date>2011-12-02T00:03:32</date>
  <lang>es</lang>
  <sentiments>
   <polarity><value>N</value></polarity>
  </sentiments>
  <topics>
   <topic>política</topic>
  </topics>
 </tweet>
 <tweet>

los campos que nos interesan para cada tweet son el campo content , que tiene el contenido del tweet, y el campo sentiment.polarity.value, que incluye la polaridad del tweet.

Hay que fijarse en que diversos archivos tienen diferentes esquemas dependiendo de que edición del TASS sean.

Después de procesar y unir todos los archivos, tenemos un archivo con unos 48,000 tweets con una polaridad asociada. Dicha polaridad esta codificada como una variable ordinal que contiene uno de los siguientes valores: N+ (muy negativo), N (negativo), NEU (Neutral), P (Positivo), P+ (muy positivo).

El objetivo de este problema de Machine Learning es predecir el sentimiento de los tweets incluidos en el archivo que creamos en el post anterior usando el corpus de TASS como training data (datos para entrenar al modelo predictivo).

Sin embargo, antes de hacer eso, tenemos que hacer un paso más.

Si analizamos el dataset, nos damos cuenta de que hay tweets en múltiples idiomas, y por lo tanto no podemos predecir la polaridad de tweets que no estén escritos en español mediante el corpus de TASS

Lo que significa que tenemos que asignar el lenguaje a cada tweet, y entonces filtrar sólo aquellos que son en español.

Detección de Lenguaje

Para asignar el lenguaje de cada tweet, he usado 3 paquetes diferentes, langdetect, langid y Textblob, y solo mantuve los tweets en los que al menos un paquete decidiera que el tweet era en español.

 
import langid
from langdetect import detect
import textblob

def langid_safe(tweet):
    try:
        return langid.classify(tweet)[0]
    except Exception as e:
        pass
        
def langdetect_safe(tweet):
    try:
        return detect(tweet)
    except Exception as e:
        pass

def textblob_safe(tweet):
    try:
        return textblob.TextBlob(tweet).detect_language()
    except Exception as e:
        pass   
        
#Este paso tarda mucho tiempo
tweets['lang_langid'] = tweets.tweet.apply(langid_safe)
tweets['lang_langdetect'] = tweets.tweet.apply(langdetect_safe)
tweets['lang_textblob'] = tweets.tweet.apply(textblob_safe)

tweets.to_csv('tweets_parsed2.csv', encoding='utf-8')

tweets = tweets.query("lang_langdetect == 'es' or lang_langid == 'es' or lang_langtextblob=='es' ")

Tras filtar los archivos por lenguaje nos queda un archivo de 77,550 tweets en español.

Como he mencionado más arriba, el corpus contiene múltiples niveles de polaridad. No obstante, hay diferencias entre diferentes archivos, por ejemplo algunos archivos sólo tienen los niveles Positivo, Negativo y Neutral

Por lo tanto para poder trabajar con todos los archivos conjuntamente, vamos a convertir la polaridad en una variable dicotómica (binaria) con los valores (Positivo=1, Negativo=0).

Procesamiento de texto

Para poder analyzar los tweets, tenemos que extraer y estructurar la información contenida en el texto. Para ello, usaremos la clase sklearn.feature_extraction.CountVectorizer.

CountVectorizer convierte la columna de texto en una matriz en la que cada palabra es una columna cuyo valor es el número de veces que dicha palabra aparece en cada tweet.

Por ejemplo, si tenemos el tweet:

Machine Learning is very cool

CountVectorizer lo convierte en:

tweet machine learning is very cool
0 Machine Learning is very cool 1 1 1 1 1
1 Machine Learning is cool 1 1 1 0 1

De esta forma podemos trabajar con estos vectores en vez de con texto plano.

Modificaremos nuestro CountVectorizer para que aplique los siguientes pasos a cada tweet:

  1. Tokenizar, este paso convierte una cadena de texto en una lista de palabras (tokens). Usaremos un tokenizador modificado que no solo tokeniza (mediante el uso de nltk.word_tokenize), sino que también remueve signos de puntuación. Como estamos tratando con tweets en español, es importante incluir ¿ y ¡ en la lista de signos a eliminar.
  2. Convertir todas las palabras en minúsculas.
  3. Remover stopwords. Se llama stopwords a las palabras que son muy frecuentes pero que no aportan gran valor sintáctico. Ejemplos de stopwords serían de, por, con ...
  4. Stemming. Stemming es el proceso por el cual transformamos cada palabra en su raiz. Por ejemplo las palabras maravilloso, maravilla o maravillarse comparten la misma raíz y se consideran la misma palabra tras el stemming.

Este es el código para procesar el texto:

 
#Tienes que descargarte las stopwords primero via nltk.download()
import nltk
from nltk.corpus import stopwords
from nltk import word_tokenize
from nltk.data import load
from nltk.stem import SnowballStemmer
from string import punctuation
from sklearn.feature_extraction.text import CountVectorizer       


spanish_stopwords = stopwords.words('spanish')

stemmer = SnowballStemmer('spanish')

non_words = list(punctuation)
non_words.extend(['¿', '¡'])
non_words.extend(map(str,range(10)))

stemmer = SnowballStemmer('spanish')
def stem_tokens(tokens, stemmer):
    stemmed = []
    for item in tokens:
        stemmed.append(stemmer.stem(item))
    return stemmed

def tokenize(text):
    text = ''.join([c for c in text if c not in non_words])
    tokens =  word_tokenize(text)

    # stem
    try:
        stems = stem_tokens(tokens, stemmer)
    except Exception as e:
        print(e)
        print(text)
        stems = ['']
    return stems
    
vectorizer = CountVectorizer(
                analyzer = 'word',
                tokenizer = tokenize,
                lowercase = True,
                stop_words = spanish_stopwords)

Evaluación del modelo

En este apartado es donde probamos múltiples algoritmos y medimos su eficacia. Herramientas como SciKit-Learn Laboratory (SKLL) ayudan mucho en este proceso.

Un aspecto importante a considerar es elegir una métrica apropiada para evaluar cada modelo. Como este problema es un problema de clasificación binaria (predecir si un tweet es positivo =1 o negativo=0), una buena métrica es el Area bajo la Curva ROC, que tiene en cuenta tanto los Falsos positivos (es decir, tweets negativos que fueron clasificados como positivos) como los Falsos Negativos (es decir, los tweets postivos que fueron clasificados como negativos)

Tras evaluar varios modelos, el algoritmo SVM (en particular su implementación para casos de clasificación LinearSVC fué el que produjo valores mejores de AUC (area under the curve).

Una vez hemos seleccionado nuestro Vectorizer y nuestro clasificador, hacemos una búsqueda en rejilla GridSearchCV. para encontrar los mejores parámetros para nuestros modelos.

GridSearchCV itera sobre los modelos especificados con el rango de parámetros indicado y nos devuelve aquel modelo cuyos parámetros proporcionan los mejores resultados.

Este es el código que hace la búsqueda:

 

#Definimos el vectorizer de nuevo y creamos un pipeline de vectorizer -> classificador
vectorizer = CountVectorizer(
                analyzer = 'word',
                tokenizer = tokenize,
                lowercase = True,
                stop_words = spanish_stopwords)

#LinearSVC() es el clasificador

pipeline = Pipeline([
    ('vect', vectorizer),
    ('cls', LinearSVC()),
])


# Aqui definimos el espacio de parámetros a explorar
parameters = {
    'vect__max_df': (0.5, 1.9),
    'vect__min_df': (10, 20,50),
    'vect__max_features': (500, 1000),
    'vect__ngram_range': ((1, 1), (1, 2)),  # unigramas or bigramas
    'cls__C': (0.2, 0.5, 0.7),
    'cls__loss': ('hinge', 'squared_hinge'),
    'cls__max_iter': (500, 1000)
}


grid_search = GridSearchCV(pipeline, parameters, n_jobs=-1 , scoring='roc_auc')
grid_search.fit(tweets_corpus.content, tweets_corpus.polarity_bin)

Este paso tarda bastante tiempo, pero al terminar nos devolverá el conjunto de parámetros (o como se les llama también, hiperparámetros) que producen el mejor AUC. En este caso, el mejor AUC fue de 0.92, que es un resultado aceptable (con más tiempo, intentaríamos subir ese AUC hasta los 0.97 o aproximado, pero al fin y al cabo, este es un experimento).

Ahora ya tenemos nuestros modelos con los mejores parámetros, asi que solo falta entrenar el modelo en el corpus de TASS y predecir la polaridad del archivo de tweets que descargamos.

Finalmente, guardaremos en un archivo la latitud, longitud y polaridad de cada tweet.

 

from sklearn.svm import LinearSVC
from sklearn.pipeline import Pipeline

#Creamos un Pipeline con los parámetros mejores
pipeline = Pipeline([
    ('vect', CountVectorizer(
            analyzer = 'word',
            tokenizer = tokenize,
            lowercase = True,
            stop_words = spanish_stopwords,
            min_df = 50,
            max_df = 1.9,
            ngram_range=(1, 1),
            max_features=1000
            )),
    ('cls', LinearSVC(C=.2, loss='squared_hinge',max_iter=1000,multi_class='ovr',
             random_state=None,
             penalty='l2',
             tol=0.0001
             )),
])

#ajustamos el modelo at corpus de TASS
pipeline.fit(tweets_corpus.content, tweets_corpus.polarity_bin)
#now we predict on the new tweets dataset
tweets['polarity'] = pipeline.predict(tweets.tweet)

Cuando tenemos el archivo de latitudes y longitudes con su polaridad, seguimos los mismos pasos que seguimos en el tutorial previo y obtenemos el siguiente mapa de calor donde se pueden observar los sitios con polaridad más negativa y más positiva:

murcia sentiment heatmap

¿Que os parece?

Dibujando 100k tweets de mi ciudad

[Esta entrada apareció originalmente en inglés en mi blog].

Hace tiempo que he querido jugar con la API de Twitter. El pasado verano se me ocurrió que podría ser interesante dibujar un mapa de mi ciudad (Murcia, España, bella ciudad con comida increible) mostrando un heatmap de tweets.

La idea es que dibujando esos tweets podría encontrar patrones interesantes de mi ciudad. Por ejemplo:

  • ¿Desde qué áreas la gente tuitea más?
  • ¿Qué horas del día son las más activas?
  • ¿Cuales son los lugares más felices/tristes?
  • ¿Hay alguna comunidad tuitera local extranjera?

Con esas ideas en la cabeza empecé la investigación. Primero, necesitaba una librería para interactuar con la API de Twitter. Después de probar la extensa cantidad de wrappers disponibles me decidí por Tweepy. Posee una interfaz simple y agradable de usar y está bien mantenida.

(INCISO, todo el código y los datos que se usan en esta entrada está disponible en Github).

De cara a obtener los tweets de mi ciudad en tiempo real decidí ajustarme al API Streaming de Twitter. Este es el código sencillo que usé:

 
import json
from tweepy import Stream
from tweepy import OAuthHandler
from tweepy.streaming import StreamListener

ckey = CONSUMER_KEY
csecret = CONSUMER_SECRET
atoken = APP_TOKEN
asecret = APP_SECRET

murcia = [-1.157420, 37.951741, -1.081202, 38.029126] #Venid a verla, es una ciudad muy bonita!

file =  open('tweets.txt', 'a')

class listener(StreamListener):

    def on_data(self, data):
        # La API de Twitter devuelve datos en formato JSON, asi que hay que decodificarlos.
        try:
            decoded = json.loads(data)
        except Exception as e:
            print e #no queremos que el script se pare
        return True

        if decoded.get('geo') is not None:
            location = decoded.get('geo').get('coordinates')
        else:
            location = '[,]'
        text = decoded['text'].replace('\n',' ')
        user = '@' + decoded.get('user').get('screen_name')
        created = decoded.get('created_at')
        tweet = '%s|%s|%s|s\n' % (user,location,created,text)

        file.write(tweet)
        print tweet
        return True

    def on_error(self, status):
        print status

if __name__ == '__main__':
print 'Empezando...'

auth = OAuthHandler(ckey, csecret)
auth.set_access_token(atoken, asecret)
twitterStream = Stream(auth, listener())
twitterStream.filter(locations=murcia)
 

El script solo requiere las claves y secretos de la API de Twitter además de un par de puntos (latitudes y longitudes de los puntos). La API de Twitter solo devolverá tweets cuyas lat/lon estén dentro de los límites definidos (al menos, en teoria).

INCISO: Si no quieres tener que descargarte los tweets puedes bajarte el archivo en Github

Dejé este script ejecutándose durante meses en una de mis instancias en Digital Ocean. Obtuve alrededor de 600K tweets. De esos 600K, alrededor de 1/6 estaban geolocalizados. Por tanto, me quedé con 100K tweets para hacer el gráfico.

Una vez que tenemos los datos de Twitter parseados, solo tuve que buscar una buena librería para dibujar heatmaps. La mejor que encontré, tanto por su simplicidad (solo un fichero)como por su nivel de ajustes, fue Heatmap.py.

Puedes echar un vistazo en Github para ver como usé heatmap. Aquí puedes varios de los mapas que dibujé:


Bonito, ¿verdad?

En la próxima entrada os mostraré como aplicar análisis de sentimientos al conjunto de datos para encontrar los lugares de la ciudad más alegres/tristes.