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:
- Panel: https://panel.holoviz.org/
- Dash: https://plot.ly/dash/
- Voilá: https://github.com/voila-dashboards/voila
- Flask (de forma más tradicional)
- ...
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 keywordlanguageque 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 unDataFramedepandasy lo mostrará usando el método especial del objeto_repr_html_. Podemos usar una figura dematplotlib, dealtair, deplotly, debokeh,...
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 conlat. Esto me permite obtener información del usuario y guardarlo en las variableslonylatque 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 delelse.st.pyplot(fig): Dentro de la condición usost.pyplotque me permite mostrar instancias deFigure(matplotlib).st.table(ts): Me permite mostrar unDataFrameo unaSeriesdepandascomo una tabla HTML. Puedo hacer algo muy similar usandost.dataframe(ts). La ventaja del segundo es que, por ejemplo, podemos ordenar por los valores de una columna o estilizar la tabla. De momento usost.tablepara 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 enfolium.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 defolium.
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 defoliumy que se muestre sin problemas. PERO RECUERDA QUE LA OPCIÓNunsafe_allow_htmlLA 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.
Pybonacci