Haciendo un Dashboard con Streamlit en Python

articulos tutoriales

Muchas veces, después de hacer un cálculo, queremos mostrar de forma sencilla algún resultado de tal forma que cualquiera lo pueda visualizar. A veces, el resultado no es algo sencillo y queremos proporcionar herramientas para que otros puedan toquetear los datos y que vean actualizaciones de forma interactiva.

Opciones

Para hacer lo anterior tenemos varias opciones dentro del mundo Python:

La que te voy a enseñar hoy se llama streamlit y consiste en una forma muy sencilla de crear un prototipo de forma rápida y con fácil integración de muchos componentes del stack científico de Python.

Primeros pasos

Considero que tienes conda instalado por lo que abrimos un terminal (bash, cmd,..., dependiendo del sistema operativo en el que te encuentres) y escribimos lo siguiente para crear un entorno virtual conda con nombre streamlit:

conda create -n streamlit -y

Una vez creado el entorno virtual lo puedes inicializar usando el siguiente comando:

conda activate streamlit

Con el entorno activo puedes instalar streamlit usando pip (en este caso, streamlit no está en ningún canal de conda):

pip install streamlit

Y deberías estar lista para empezar a trabajar...

¿Qué es streamlit?

Es una biblioteca que hace fácil crear aplicaciones web para mostrar resultados de tus análisis de datos.

Esto está muy bien pero con este tipo de herramientas pasa que nos tenemos que adaptar nosotros a su forma de hacer las cosas. Si su forma de hacer las cosas se ajusta a nuestras necesidades será perfecto. Si no es así hará que salirse de la norma nos ponga las cosas muy complicadas o las haga imposible. Sabiendo esto de partida veamos las posibilidades.

¿Cómo lo hacemos?

La idea es escribir un script con código Python y ejecutarlo desde la línea de comandos (con el entorno streamlit acttivado) usando:

streamlit run mi_programa.py [-- otros posibles argumentos]

En cuanto se ejecute lo anterior se creará un servidor local que mostrará lo que haya decidido mostrar mediante el código incluido en el script mi_programa.py.

Para mostrar cosas en pantalla en la web, streamlit me proporciona una interfaz amplia (y que se sigue ampliando rápidamente). Algunas de las cosas que podemos usar en nuestro script Python (en los ejemplos siguientes supón que he importado streamlit de la siguiente forma import streamlit as st:

Texto:

  • st.title("Esto sería un título"): Principalmente usado para el título de la webapp. En realidad inserta una etiqueta H1 en HTML.
  • st.header("Esto sería un cabecero"): Esto es un header como una etiqueta H2 en HTML.
  • st.subheader("Esto sería un cabecero menor"): Esto es un header como una etiqueta H3 en HTML.
  • st.text("Esto sería texto"): Inserta texto con letras de ancho constante (como las que se ven en un IDE o en una consola).
  • st.latex("y = x^2"): Permite mostrar latex en pantalla.
  • st.code("a = 1", language="python"): Este es una forma especial del siguiente comando que se ha creado por conveniencia y que permite mostrar código en pantalla con su resaltado gracias a la keyword language que nos permite indicar como queremos resaltar el código.
  • st.markdown("Esto es **texto** usando *Markdown*"): Inserta texto que permite formatearlo como si fuera Markdown.
  • st.write(...): Este comando funciona de comodín y permite escribir muchas cosas y dependiendo de lo que usamos funcionará de una forma u otra. Por ejemplo, podemos usar texto y funcionará mostrando texto. Pero podemos usar un DataFrame de pandas y lo mostrará usando el método especial del objeto _repr_html_. Podemos usar una figura de matplotlib, de altair, de plotly, de bokeh,...

Las dos últimas son un poco las opciones estrella puesto que disponen de una opción, unsafe_allow_html, que permite pasar código HTML (y CSS y JS) y proporciona mucha más libertad. Pero esto lo quieren eliminar por temas de seguiridad ya que inyectar según qué código puede ser peligroso. De la documentación:

That said, we strongly advise against it. It is hard to write secure HTML, so by using this argument you may be compromising your users’ security.

Puedes ver cómo lo está usando/abusando la gente visitando este hilo en su foro de discusión: https://discuss.streamlit.io/t/are-you-using-html-in-markdown-tell-us-why/96

Con lo que ya he mostrado vamos a hacer nuestro Hola Mundo inicial. Guardamos el siguiente código en un fichero que se llame app01.py en la carpeta que quieras:

import streamlit as st

st.title('Título')
st.header("Cabecero")
st.subheader("SubCabecero")
st.text("Esto es texto")
st.latex("y = x^2")
st.code("if a == 1:\n    print(a)", language="python")
st.code("var a = 1;", language="javascript")
st.markdown("Esto es **texto** usando *Markdown*")

Desde la carpeta donde he guardado el fichero app01.py y con el entorno streamlit activado escribo en la línea de comandos:

streamlit run app01.py

Y, si no hay errores, mostrará en pantalla algo como:

  You can now view your Streamlit app in your browser.

  Local URL: http://localhost:8501
  Network URL: http://172.172.172.172:8501

Y se abrirá una pestaña en el navegador (si no se abre copia http://localhost:8501 y pégalo en la barra de direcciones en una nueva pestaña de tu navegador). Si todo funciona bien veré algo como lo siguiente:

En la parte principal se ve todo lo que hemos escrito en el programa Python. Abajo nos inyecta un pie de página con 'Made with Streamlit' y, además, añade un menú que muestro desplegado en la figura anterior.

Entrando en la parte interesante

Lo anterior está muy bien y muestra un poco la mecánica de cómo mostrar cosas en pantalla en tu aplicación web pero de momento no he mostrado nada muy interesante. Ahora voy a hacer algo más interesante deteniéndome mucho menos para que este hilo no se haga eterno. Si queréis más detalle visitad la documentación.

Lo que voy a hacer va a ser convertir lo que hice en este otro artículo en algo más interactivo para que cualquiera se pueda descargar un gráfico para la localización que considere.

Antes de continuar hemos de instalar una serie de cosas en el entorno streamlit para que el ejemplo funcione:

conda install -c conda-forge numpy pandas matplotlib xarray netcdf4 seaborn

En una primera versión solo voy a añadir un par de sliders que permitan elegir la latitud y la longitud y un botón para que calcule la gráfica. Escribo el código y lo explico brevemente. Lo guardaré en un fichero llamado app02.py:

from typing import Union


import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import streamlit as st
import xarray as xr
import pandas as pd
import seaborn as sns
import numpy as np

# Esto se explicó en:
# https://pybonacci.org/2020/01/20/pintando-las-bandas-del-calentamiento-warming-stripes-con-seaborn-y-matplotlib-en-python/
def read_noaa(filename: str) -> xr.Dataset:
    """Read the netCDF file downloaded using `download_noaa`.

    Parameters
    ----------
    filename : str
        The name of the file to read as `xarray.Dataset`

    Returns
    -------
    `xarray.Dataset`
    """
    return xr.open_dataset(filename)

# Esto se explicó en:
# https://pybonacci.org/2020/01/20/pintando-las-bandas-del-calentamiento-warming-stripes-con-seaborn-y-matplotlib-en-python/
def get_noaa_timeseries(
        xarr: xr.Dataset, 
        lon: Union[int, float], 
        lat: Union[int, float]
    ) -> xr.Dataset:
    """Get the annual temperature anomaly time series from NOAA data.

    Parameters
    ----------
    xarr : xr.Dataset
        `xarray.Dataset` containing the monthly anomalies.
    lon : Union[int, float]
        Longitude in decimal degrees. It will return the closest timeseries
        to this location.
    lat : Union[int, float]
        Latitude in decimal degrees. It will return the closest timeseries
        to this location.

    Returns
    -------
    `xarray.Dataset`.
    """
    data = xarr.sel(lon=lon, lat=lat, z=0, method='nearest')
    df = data.to_dataframe()['anom']
    ts = df * df.index.days_in_month
    ts = (     
        ts.groupby(pd.Grouper(freq='Y')).mean()      
        /      
        ts.groupby(pd.Grouper(freq='Y')).count() 
    )
    ts.name = "Anomalía de Temperatura"
    return ts[ts.index.year < 2020] # <- Modificado solo para usar años completos

# Esto se explicó en:
# https://pybonacci.org/2020/01/20/pintando-las-bandas-del-calentamiento-warming-stripes-con-seaborn-y-matplotlib-en-python/
def plot_noaa(
        xarr: xr.Dataset,
        lon: Union[int, float],
        lat: Union[int, float]
    ) -> None:
    lon = float(lon)
    lat = float(lat)
    ts = get_noaa_timeseries(xarr, lon, lat)
    # warming stripes adapted from https://towardsdatascience.com/climate-heatmaps-made-easy-6ec5be0be6ff
    fig, ax = plt.subplots(figsize=(12, 5))
    sns.heatmap(
        data=ts.values[np.newaxis,:],
        ax=ax,
        cmap='RdBu_r',
        cbar=False,
        vmin=ts.min(),
        vmax=ts.max(),
        center=0.,
        xticklabels=False, 
        yticklabels=False,
    )
    fig.tight_layout()
    #fig.savefig("warming_stripes.png")
    return fig, ts # <- Modificado para no guardar la imagen y devolver fig y ts


# Código para mostrar en la aplicación Web
# Esta es la parte que explicaré con un poco más de detalle.
xarr = read_noaa("NOAA_V5_air_temperature_anomaly.nc")

st.title('#ShowYourStripes')
lon = st.slider(
    label="Longitud",
    min_value=0.0,
    max_value=360.0,
    value=0.0,
    step=0.5,
)
lat = st.slider(
    label="Latitud",
    min_value=-90.0,
    max_value=90.0,
    value=40.0,
    step=0.5,
)
if st.button("Pinta"):
    st.markdown(f"Longitud: {lon:.1f}")
    st.markdown(f"Latitud:  {lat:.1f}")
    fig, ts = plot_noaa(xarr, lon, lat)
    st.pyplot(fig)
    st.table(ts)
else:
    st.markdown(
        "Selecciona la localización usando los controles Y pulsa en "
        "el botón 'Pinta'."
    )

Para que el código anterior funcione debes descargar el fichero con los datos de temperatura en el mismo sitio donde se encuentre el fichero app02.py y se debe llamar NOAA_V5_air_temperature_anomaly.nc.

Como he comentado, me centro en esta parte:

xarr = read_noaa("NOAA_V5_air_temperature_anomaly.nc")

st.title('#ShowYourStripes')
lon = st.slider(
    label="Longitud",
    min_value=0.0,
    max_value=360.0,
    value=0.0,
    step=0.5,
)
lat = st.slider(
    label="Latitud",
    min_value=-90.0,
    max_value=90.0,
    value=40.0,
    step=0.5,
)
if st.button("Pinta"):
    st.markdown(f"Longitud: {lon:.1f}")
    st.markdown(f"Latitud:  {lat:.1f}")
    fig, ts = plot_noaa(xarr, lon, lat)
    st.pyplot(fig)
    st.table(ts)
else:
    st.markdown(
        "Selecciona la localización usando los controles Y pulsa en "
        "el botón 'Pinta'."
    )
  • xarr = read_noaa("NOAA_V5_air_temperature_anomaly.nc"): Leemos los datos en formato NetCDF.
  • st.title('#ShowYourStripes'): Explicado más arriba.
  • lon = st.slider(...): Añade un control para deslizar entre un rango y con un paso y una posición inicial. Lo mismo se hace con lat. Esto me permite obtener información del usuario y guardarlo en las variables lon y lat que usaré más tarde.
  • if st.button(...): ... else: ...: En este caso añadimos un botón que cuando pulsemos hará lo que aparece en la condición y cuando actualicemos cualquier control mostrará lo del else.
  • st.pyplot(fig): Dentro de la condición uso st.pyplot que me permite mostrar instancias de Figure (matplotlib).
  • st.table(ts): Me permite mostrar un DataFrame o una Series de pandas como una tabla HTML. Puedo hacer algo muy similar usando st.dataframe(ts). La ventaja del segundo es que, por ejemplo, podemos ordenar por los valores de una columna o estilizar la tabla. De momento uso st.table para simplificar.

Para ver la aplicación en acción debemos ejecutar desde una línea de comandos, localizada en la misma carpeta donde hemos guardardo app02.py y los datos y con el entorno streamlit esté activado, lo siguiente:

streamlit run app02.py

Y deberás ver algo parecido a:

Hay muchos más controles útiles que podría usar, checkboxes^, menús drop-down,... Dependiendo de la aplicación y la necesidad tendrá sentido usar unos u otros.

Podemos iterar sobre esta idea. Ahora, para seguir viendo más cosas que podemos hacer con streamlit, voy a añadir un mapa que indique la serie que he seleccionado y voy a meter los controles en el lateral para que la navegación sea más cómoda.

Para mostrar un mapa y que sea algo más interactivo voy a usar folium. Primero habrá que instalarlo en el entorno streamlit. Lo puedes hacer usando:

conda install -c conda-forge folium

Instalo también un componente que permite usar folium en streamlit sin problemas:

pip install streamlit-folium

Escribo primero el código que guardaré en el fichero app03.py y luego comento las partes nuevas:

from typing import Union
from pathlib import Path
import json

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import streamlit as st
from streamlit_folium import folium_static
import xarray as xr
import pandas as pd
import seaborn as sns
import numpy as np
import folium

# Esto se explicó en:
# https://pybonacci.org/2020/01/20/pintando-las-bandas-del-calentamiento-warming-stripes-con-seaborn-y-matplotlib-en-python/
def read_noaa(filename: str) -> xr.Dataset:
    """Read the netCDF file downloaded using `download_noaa`.

    Parameters
    ----------
    filename : str
        The name of the file to read as `xarray.Dataset`

    Returns
    -------
    `xarray.Dataset`
    """
    return xr.open_dataset(filename)

# Esto se explicó en:
# https://pybonacci.org/2020/01/20/pintando-las-bandas-del-calentamiento-warming-stripes-con-seaborn-y-matplotlib-en-python/
def get_noaa_timeseries(
        xarr: xr.Dataset, 
        lon: Union[int, float], 
        lat: Union[int, float]
    ) -> xr.Dataset:
    """Get the annual temperature anomaly time series from NOAA data.

    Parameters
    ----------
    xarr : xr.Dataset
        `xarray.Dataset` containing the monthly anomalies.
    lon : Union[int, float]
        Longitude in decimal degrees. It will return the closest timeseries
        to this location.
    lat : Union[int, float]
        Latitude in decimal degrees. It will return the closest timeseries
        to this location.

    Returns
    -------
    `xarray.Dataset`.
    """
    data = xarr.sel(lon=lon, lat=lat, z=0, method='nearest')
    df = data.to_dataframe()['anom']
    ts = df * df.index.days_in_month
    ts = (     
        ts.groupby(pd.Grouper(freq='Y')).mean()      
        /      
        ts.groupby(pd.Grouper(freq='Y')).count() 
    )
    ts.name = "Anomalía de Temperatura"
    return ts[ts.index.year < 2020] # <- Modificado solo para usar años completos

# Esto se explicó en:
# https://pybonacci.org/2020/01/20/pintando-las-bandas-del-calentamiento-warming-stripes-con-seaborn-y-matplotlib-en-python/
def plot_noaa(
        xarr: xr.Dataset,
        lon: Union[int, float],
        lat: Union[int, float]
    ) -> None:
    lon = float(lon)
    lat = float(lat)
    ts = get_noaa_timeseries(xarr, lon, lat)
    # warming stripes adapted from https://towardsdatascience.com/climate-heatmaps-made-easy-6ec5be0be6ff
    fig, ax = plt.subplots(figsize=(12, 5))
    sns.heatmap(
        data=ts.values[np.newaxis,:],
        ax=ax,
        cmap='RdBu_r',
        cbar=False,
        vmin=ts.min(),
        vmax=ts.max(),
        center=0.,
        xticklabels=False, 
        yticklabels=False,
    )
    fig.tight_layout()
    #fig.savefig("warming_stripes.png")
    return fig, ts # <- Modificado para no guardar la imagen y devolver fig y ts

def create_geojson_grid(
    xnodes: Union[list, tuple, np.array], 
    ynodes: Union[list, tuple, np.array], 
    filename: str) -> None:
    """Function that creates polygons in a regular grid and creates
    a geojson file.

    Parameters
    ----------
    xnodes : array-like
        1D array like (`list`, `tuple`, `numpy.array`,...) with the
        longitude coordinates in decimal values.
    ynodes : array-like
        1D array like (`list`, `tuple`, `numpy.array`,...) with the
        latitude coordinates in decimal values.
    filename : str
        Output filename for the geojson file.

    Returns
    -------
    None.

    """
    with open(filename, 'w') as f:
        gj = '{"type":"FeatureCollection","features":['
        for x1, x2 in zip(xnodes[:-1], xnodes[1:]):
            x1 = int(x1)
            x2 = int(x2)
            xnode_half_width = x2 - x1
            for y1, y2 in zip(ynodes[:-1], ynodes[1:]):
                y1 = int(y1)
                y2 = int(y2)
                xnode_half_width = x2 - x1
                text = f"from ({x1},{y1}) to ({x2},{y2})"
                coords = [[[x1, y1], [x2, y1], [x2, y2], [x1, y2]]]
                pol = {
                    "type": "Feature",
                    "id": f"{text}",
                    "properties": {
                        "name": f"{text}",
                        "lon_center": x1 + xnode_half_width,
                        "lat_center": y1 + xnode_half_width,
                    },
                    "geometry":{"type":"Polygon","coordinates": coords}
                }
                gj += json.dumps(pol)
                gj += ','
        gj = gj[:-1] + ']}'
        f.write(gj)

def create_map(
        lon: Union[int, float],
        lat: Union[int, float],
        tile: str = "OpenStreetMap",
    ) -> None:
    mapa = folium.Map(location=(lat, lon), tiles=tile, zoom_start=5)
    filename = Path(".", "noaagrid.geojson")
    if not filename.is_file():
        xnodes = np.arange(-180, 185, 5)
        ynodes = np.arange(-90, 95, 5)
        create_geojson_grid(xnodes, ynodes, filename)
    folium.GeoJson(str(filename), name="grid").add_to(mapa)
    tooltip = "Posición elegida por el usuario"
    folium.Marker(
        [lat, lon],
        popup=f"<i>Lon: {lon:.1f}, Lat: {lat:.1f}</i>", 
        tooltip=tooltip
    ).add_to(mapa)
    return mapa


# Código para mostrar en la aplicación Web
# Esta es la parte que explicaré con un poco más de detalle.
xarr = read_noaa("NOAA_V5_air_temperature_anomaly.nc")

st.title('#ShowYourStripes')
lon = st.sidebar.slider(
    label="Longitud",
    min_value=0.0,
    max_value=360.0,
    value=2.5,
    step=0.5,
)
lat = st.sidebar.slider(
    label="Latitud",
    min_value=-90.0,
    max_value=90.0,
    value=39.5,
    step=0.5,
)
tile = st.sidebar.selectbox(
    label="Mapa base",
    options=["OpenStreetMap", "Stamen Toner", "Stamen Terrain",
             "Stamen Watercolor", "CartoDB positron", "CartoDB dark_matter"],
    index=0,
)
if st.sidebar.button("Pinta"):
    st.markdown(f"Longitud: {lon:.1f}")
    st.markdown(f"Latitud:  {lat:.1f}")
    fig, ts = plot_noaa(xarr, lon, lat)
    st.pyplot(fig)
    folium_static(create_map(lon, lat, tile=tile))
    st.table(ts)
else:
    st.markdown(
        "Selecciona la localización usando los controles Y pulsa en "
        "el botón 'Pinta'."
    )

Entre las novedades de la versión 3 de nuestra aplicación he incluido:

  • create_geojson_grid: Esta es una función que crea un fichero GeoJson con polígonos de 5x5 grados alrededor de cada nodo donde tenemos datos de temperatura. Este GeoJson lo uso luego en folium.
  • create_map: Esta función me ayuda a crear el mapa que quiero mostrar en pantalla. Crea un mapa, lee el GeoJson creado con la función anterior y añade, además, un marcador en la localización que ha elegido el usuario.

En la parte de los controles he añadido:

  • tile = st.sidebar.selectbox(...): Esto añade un menú drop-down en la barra lateral de controles. Me permite elegit los tiles que usará el mapa de folium.

En la parte de mostrar resultados (el contenedor principal) he añadido:

  • st.markdown(mapa._repr_html_(), unsafe_allow_html=True): De esta forma puedo representar el código HTML del mapa de folium y que se muestre sin problemas. PERO RECUERDA QUE LA OPCIÓN unsafe_allow_html LA QUIEREN ELIMINAR. Supongo que añadirán alguna alternativa pero habrá que ver cómo evoluciona.

Aquí puedes ver cómo quedaría:

Resumen

Esto ha sido solo un ejemplo real de uso de streamlit para crear un cuadro de mando (dashboard) de unos datos reales. Como ves, parece bastante sencillo de usar. Todo es código Python y la parte de la aplicación es muy pequeña, el resto del código es el análisis normal que se haría para obtener el gráfico.

Cosas que me han gustado:

  • Prácticamente no interfiere en el código normal que escribimos. Con un par de funciones y algo de código para crear la capa visual tenemos una aplicación funcional.
  • La capa visual (streamlit) me parece que está muy bien pensada con opciones que con poco permiten hacer mucho.
  • El desarrollo está muy activo y están preguntando a los usuarios lo cual me parece positivo.
  • Permite pasar de la idea a una aplicación usable en muy poco tiempo.

Cosas que no me han gustado:

  • Está muy verde aun y el servidor se 'rompe' cada dos por tres.
  • Es muy lento. Existe una opción para cachear cosas pero no creo que sea muy útil en muchos casos.
  • Si te sales del guión será difícil hacer algo muy personalizado y te tocará ir a contracorriente.
  • El desarrollo está muy enfocado a crear un producto/empresa por lo que no sé si acabará siendo positivo o negativo y genera cierta incertidumbre para adoptarlo. Otros modelos como el de plotly parace que han funcionado bien por lo que esto no es necesariamente negativo pero sí que es negativa la incertidumbre.
  • Si se adopta de forma masiva veríamos mcuhas webs muy parecidas. Similar a la bootstrap-ificación de la web en general. Eso resta diversidad visual, de enfoques, de soluciones,...

¿Has usado alguna de las otras opciones para crear un cuadro de mando? Puedes comentar si te gusta la idea detrás de Panel, cómo se hacen las cosas con Voilà, la facilidad de uso de Dash,... O, incluso mejor, nos puedes escribir un artículo contándonos tus impresiones. Estaría genial para poder comparar.

Y eso es todo por hoy.

Saludos.

P.D.: El código y los datos los podéis descargar de este repositorio. He creado también un binder para ejecutar la app pero falla algo con tk y no tengo ni idea de cómo resolverlo. Si alguien sabe cómo resolverlo le agradecería que me echase un cable. Puedes ver cómo funciona usando MyBinder. Aquí te explico cómo hacerlo tú mismo para que puedas compartir tus creaciones con otros.