matplotlib heatmap calendar.

Aunque la expresión, no hay que reiventar al rueda, tiene todo el sentido del mundo, también es cierto que si te dedicas a proveer de ruedas a los demás. no viene mal tener una idea clara de como se fabrican.

Nuestro objetivo de hoy, es desarrollar una función que reciba una lista de valores diarios, de algún hecho concreto y, construya un calendario clásico donde se represente las ocurrencias de cada día usando una escala de colores.

Aunque ya existen librerías como July que nos permitirían hacerlo, vamos a explicar desde cero como conseguir un resultado como el siguiente, usando python y matplotlib.

Para no alargarnos demasiado, en esta entrega, nos limitaremos a la salida de la izquierda. Y si ésta despierta interés, mostraré las otras en alguna publicación posterior.

Como hemos comentado, necesitaremos matplotlib, así que, para no interferir con lo que tengamos instalado en nuestro equipo, crearemos un entorno virtual.

mkdir heatmap_calendar
cd heatmap_calendar
python3 -m venv venv
source venv/bin/activate
pip3 install matplotlib

Abrimos nuestro editor favorito, pero antes de empezar, un pequeño recordatorio. Cualquier imagen, en realidad es una matiz de puntos (pixels), cada uno de los cuales acostumbra a estar representado por 3 valores (entre el 0 y el 255), que representan la cantidad de rojo, verde y azul que lo componen y que determinan su color (por eso se dice que es una imagen RGB que se corresponde con las siglas en inglés de los colores mencionados).

Un pixel rojo sería (255, 0, 0), uno verde (0, 255, 0) y, un punto azul se representaría con (0, 0, 255).

Una vez visto esto, en teoría, podríamos definir una imagen con un código parecedo al siguiente:

rojo  = (255, 0, 0)
verde = (0, 255, 0)
azul  = (0, 0, 255)
valores = [
    [rojo,  verde, azul], 
    [verde, azul,  rojo], 
    [azul,  rojo,  verde], 
]

Esto podría representar una imagen de 3x3 pixels rojos, verdes y azules, colocados en diferente posición.

matplotlib dispone de la función imshow que nos permite mostrar una imagen. Vamos a escribir algo de código y ver que pasa:

import matplotlib.pyplot as plt

rojo  = (255, 0, 0)
verde = (0, 255, 0)
azul  = (0, 0, 255)

valores = [
    [rojo,  verde, azul], 
    [verde, azul,  rojo], 
    [azul,  rojo,  verde], 
]

# Creamos el lienzo sobre el que dibujar las gráficas
fig, ax = plt.subplots()

# Mostramos la imagen que hemos definido
ax.imshow(valores)
plt.show()

Voilà !!! tenemos lo que queríamos, una imagen de 3x3 pixels (bastante gordos, por cierto).

Ya disponemos de una forma fácil de dibujar una cuadrícula con idferentes colores (que es una de las cosas que necesitaremos para construir nuestro calendario con mapa de colores).

Hasta el momento, hemos trabajado con una imagen RGB de color, en el que el color de cada pixel (cuadradito) está constituido de una lista de 3 valores, que indican la proporción de rojo, verde y azul que lo componen (todos los colores se pueden conseguir sumando estos tres en su proporción adecuada).

Pero antes de que las imágenes a color fueran una realidad, las imágenes eran en blanco y negro y se representaban con una escala de grises, por lo que para cada punto, en lugar de tres, sólo era necesario un valor (también entre 0 y 255), para indicar la intensidad de gris ha representar.

Vamos a modificar nuestro código, para que viaje atrás en el tiempo, y vamos a representar cada uno de nuestros cuadraditos con un único valor.

import matplotlib.pyplot as plt

valores = [
    [0, 175, 255], 
    [175, 255, 0], 
    [255, 0, 175], 
]

fig, ax = plt.subplots()

# Indicamos a la función que la imagen es de escala de grises en lugar RGB (cmap='gray')
ax.imshow(valores, cmap='gray')
plt.show()

Si en lugar de 3x3, la ampliamos a 7x6, podríamos representar con colores los valores de un mes (7 para los días de la semana y 6 para el máximo de semanas que pueden formar parte de un mismo mes).

Pero seguimos teniendo un par de problemas. Estéticamente, una imagen de grises acostumbra a no ser demasiado agradable y, cada cuadrado sólo permite representar valores entre 0 y 255 (que són los permitidos para una imagen).

Afortunadamente, imshow dispone del parámetro cmap que nos permite especificar una paleta de colores diferentes y, los parámetros vmin y vmax que, como probalemente ya habrás deducido, nos permiten especificar el rango de valores a representar.

import matplotlib.pyplot as plt

valores = [
    [10, 20, 30, 40, 50, 60, 70], 
    [20, 30, 40, 50, 60, 70, 10], 
    [30, 40, 50, 60, 70, 10, 20], 
    [40, 50, 60, 70, 10, 20, 30], 
    [50, 60, 70, 10, 20, 30, 40], 
    [60, 70, 10, 20, 30, 40, 50], 
]

fig, ax = plt.subplots()

# Especificamos paleta de colores a usar y rango de valores a representar.
ax.imshow(valores, cmap='YlGn', vmin=0, vmax=100)
plt.show()
import matplotlib.pyplot as plt

# Lista de etiquetas para representar los días de la semana.
dias_semana = ['L', 'M', 'M', 'J', 'V', 'S', 'D']
valores = [
    [10, 20, 30, 40, 50, 60, 70], 
    [20, 30, 40, 50, 60, 70, 10], 
    [30, 40, 50, 60, 70, 10, 20], 
    [40, 50, 60, 70, 10, 20, 30], 
    [50, 60, 70, 10, 20, 30, 40], 
    [60, 70, 10, 20, 30, 40, 50], 
]

fig, ax = plt.subplots()

ax.imshow(valores, cmap='YlGn', vmin=0, vmax=100)
# Colocamos el título
ax.set_title("Enero\n")

# Borramos el contenido de las etiquetas del eje Y 
ax.set_yticklabels([])

# Indicamos las posiciones posiciones del ejs X con las que secorresponden las etiquetas.
ax.set_xticks([0, 1, 2, 3, 4, 5, 6])

# Especificamos las etiquetas para las posiciones especificadas anteriormente.
ax.set_xticklabels(dias_semana)
plt.show()

Ya estamos más cerca, pero tenemos que mover las etiquetas de los días a la parte superior y, elimicar el marco de alrededor que quedará un poco raro cuando desaparezcan los cuadrados sobrantes de inicio y final de mes (la mayoría de las veces, los meses no empiezan en lunes y acaban en domingo).

import matplotlib.pyplot as plt

dias_semana = ['L', 'M', 'M', 'J', 'V', 'S', 'D']
valores = [
    [10, 20, 30, 40, 50, 60, 70], 
    [20, 30, 40, 50, 60, 70, 10], 
    [30, 40, 50, 60, 70, 10, 20], 
    [40, 50, 60, 70, 10, 20, 30], 
    [50, 60, 70, 10, 20, 30, 40], 
    [60, 70, 10, 20, 30, 40, 50], 
]

fig, ax = plt.subplots()

ax.imshow(valores, cmap='YlGn', vmin=0, vmax=100)
ax.set_title("Enero\n")
ax.set_yticklabels([])
ax.set_xticks([0, 1, 2, 3, 4, 5, 6])
ax.set_xticklabels(dias_semana)

# Colocamos las etiquetas del eje X en la parte superior
ax.xaxis.tick_top()

# Ocultamo cada uno de lso dados que circundan el gráfico
for lado in ['left', 'right', 'bottom', 'top']:
    ax.spines[lado].set_visible(False)

plt.show()

Ahora, ya sólo nos falta eliminar las casillas sobrantes y deshacernos de las marquitas que han quedado en los ejes.

import numpy as np
import matplotlib.pyplot as plt

# Colocaremos el valor especial de Not a Number a las cassila que sobran y 
# conseguiremos que maptlotlib no las muestre
NA = np.nan

dias_semana = ['L', 'M', 'M', 'J', 'V', 'S', 'D']
valores = [
    [NA, NA, NA, NA, NA, 60, 70], 
    [20, 30, 40, 50, 60, 70, 10], 
    [30, 40, 50, 60, 70, 10, 20], 
    [40, 50, 60, 70, 10, 20, 30], 
    [50, 60, 70, 10, 20, 30, 40], 
    [60, NA, NA, NA, NA, NA, NA], 
]

fig, ax = plt.subplots()

ax.imshow(valores, cmap='YlGn', vmin=0, vmax=100)
ax.set_title("Enero\n")
ax.set_yticklabels([])
ax.set_xticks([0, 1, 2, 3, 4, 5, 6])
ax.set_xticklabels(dias_semana)
ax.xaxis.tick_top()

# Reducimos la longitud de las marcas a 0 para que no sean visibles
ax.tick_params(axis=u'both', which=u'both', length=0)
for lado in ['left', 'right', 'bottom', 'top']:
    ax.spines[lado].set_visible(False)

plt.show()

Y para acabar, añadimos un pequeño detalle estético.

import numpy as np
import matplotlib.pyplot as plt

NA = np.nan

dias_semana = ['L', 'M', 'M', 'J', 'V', 'S', 'D']
valores = [
    [NA, NA, NA, NA, NA, 60, 70], 
    [20, 30, 40, 50, 60, 70, 10], 
    [30, 40, 50, 60, 70, 10, 20], 
    [40, 50, 60, 70, 10, 20, 30], 
    [50, 60, 70, 10, 20, 30, 40], 
    [60, NA, NA, NA, NA, NA, NA], 
]

fig, ax = plt.subplots()

ax.imshow(valores, cmap='YlGn', vmin=0, vmax=100)
ax.set_title("Enero\n")
ax.set_yticklabels([])
ax.set_xticks([0, 1, 2, 3, 4, 5, 6])
ax.set_xticklabels(dias_semana)
ax.xaxis.tick_top()

ax.tick_params(axis=u'both', which=u'both', length=0)
for lado in ['left', 'right', 'bottom', 'top']:
    ax.spines[lado].set_visible(False)

# Indicamos las posiciones donde dibujaremos la rejilla    
ax.set_xticks([-0,5, 0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5], minor=True)
ax.set_yticks([-0,5, 0.5, 1.5, 2.5, 3.5, 4.5, 5,5], minor=True)
# Dibujamos la rejilla de color blanco para que actue como separador.
ax.grid(which='minor', color='w', linestyle='-', linewidth=2)

plt.show()

Y ya lo tenemos. Hemos conseguido representar en un calendario de un mes, una serie de valores utilizando un código de colores.

Ya hemos explicado lo que podría resultar menos evidente. Lo que queda, es repetir el proceso para los 12 meses y repartir nuestra lista de valores para que encaje, de forma adecuada, con el mes correspondiente.

import calendar
import numpy as np
import matplotlib.pyplot as plt

def heatmap_calendar(year, valores):

    dias_semana = ['L', 'M', 'M', 'J', 'V', 'S', 'D']
    nombres_meses = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']
    maximo_semanas_mes = 6
    meses = 12
    columnas = 3
    filas = 4

    # Creamos un gráfico para cada més del año
    fig, ax = plt.subplots(filas, columnas, figsize=(3.5*columnas, 3.5*filas))

    # Para facilitar la iteración convertimos la tabla 2D en una lista 1D
    axs = np.array(ax).reshape(-1)

    numero_valores = len(valores)
    maximo = max(valores)
    puntero_dia_actual = 0

    # Repetimos el proceso para cada mes
    for mes in range(meses):
        # Averiguamos que día de la semana empieza el mes y cuantos días tiene
        dia_semana, dias_mes = calendar.monthrange(year, mes + 1)

        # Creamos un array nullo como plantilla para ir colocando los valores del mes
        plantilla_mes = np.empty((maximo_semanas_mes, len(dias_semana)))
        plantilla_mes[:] = np.nan

        # Mostramos nombre del mes
        axs[mes].set_title(nombres_meses[mes]+'\n', fontsize=12)

        # Colocamos etiqueta de días de la semana.
        axs[mes].set_xticks(np.arange(len(dias_semana)))
        axs[mes].set_xticklabels(dias_semana, fontsize=10, fontweight='bold', color='#555555')
        axs[mes].set_yticklabels([])
        axs[mes].xaxis.tick_top()

        # Ocultamos las marcas de los ejes
        axs[mes].tick_params(axis=u'both', which=u'both', length=0)  # remove tick marks

        # Dibujamos rejilla blanca para separar casillas
        axs[mes].set_xticks(np.arange(-.5, len(dias_semana), 1), minor=True)
        axs[mes].set_yticks(np.arange(-.5, maximo_semanas_mes, 1), minor=True)
        axs[mes].grid(which='minor', color='w', linestyle='-', linewidth=2)

        # Ocultamos cada uno de los lados del marco que rodea la cuadrícula.
        for lado in ['left', 'right', 'bottom', 'top']:
            axs[mes].spines[lado].set_visible(False)

        # Empezamos a rellenar cada mes desde la primera fila y desde el día de la
        # semana en la que empieza el mes        
        fila = 0
        columna = dia_semana

        # Copiamos tantos valores de nuestra lista como días tiene el mes
        for n in range(dias_mes):
            if puntero_dia_actual < numero_valores:
                plantilla_mes[fila][columna] = valores[puntero_dia_actual]

            # Conservamos cual es el próximo valor de nuestra lista a procesar
            puntero_dia_actual += 1

            # Controlamos cuando se produce cambio de semana
            if columna == 6:
                columna = 0
                fila +=1
            else:
                columna += 1

        # Mostramos los valores del mes        
        axs[mes].imshow(plantilla_mes, cmap='YlGn', vmin=1, vmax=maximo)

    # Colocamos el año del calendario y reajustamos el conjunto para dejas
    # un pequeño margen alrededor        
    fig.suptitle("\n" + str(year), fontsize=16)
    plt.subplots_adjust(left=0.04, right=0.96, top=0.88, bottom=0.04)

    # La visualización online es correcta en el caso de jupyter notebook, pero hay solapamientos
    # incorrectos si lo vemos directamente con el visor de matplotlib. Salvamos en un documento PDF
    # para tener la salida correcta
    plt.savefig('heatmap_calendar.pdf')
    plt.show()

if __name__ == '__main__':
    # Generamos valores aleatorios para probar la función
    valores = np.random.randint(500, size=365)
    heatmap_calendar(2021, valores=valores)

La programación, a veces, tiene bastantes similitudes con la magia. Lo que ves parece imposible, hasta que alguien te explica el truco y piensas, "pues vaya tontería".

Hemos mostrado la versión más básica del truco, pero seguro que se te ocurren multitud de variantes para enrriquecerlo.

15