Streamlit: come migliorare l’esperienza utente di una web app

Con Streamlit è possibile creare dashboard interattive in pochissimo tempo. L'interfaccia utente deve però essere intuitiva, semplice da usare ed efficace. In questo tutorial scopriremo come migliorare una web app con poche e semplici trucchi.

Share

Reading time: 9 minutes

Nel tutorial Streamlit: costruire una Web App in pochi minuti, abbiamo visto come creare un cruscotto interattivo per la visualizzazione e il filtraggio di dati in pochi minuti con Streamlit. Ricordiamo che Streamlit è un framework gratuito, open-source, interamente in python, che consente di costruire rapidamente cruscotti interattivi e web apps senza alcuna esperienza/competenza di sviluppo web front-end. Introdotto per la prima volta nel 2019, ha rapidamente guadagnato popolarità tra i professionisti e gli appassionati di data science.

L’app che avevamo costruito nel tutorial precedente era molto semplice ma illustrava le potenzialità di Streamlit. In questo articolo, costruiremo un’app di analisi del mercato immobiliare simile a quella precedente ma con qualche funzionalità e dato in più. Discuteremo anche nel dettaglio alcuni concetti e caratteristiche che possono migliorare notevolmente l’usabilità dell’app:

  • Usare st.sidebar per organizzare i widget di input per un’interfaccia più pulita
  • Usare st.forms e st.form_submit_button per raggruppare i widget di input e inviare i dati usando un solo pulsante
  • Aggiungere tooltip ai widget di input per fornire informazioni utili agli utenti
  • Usare st.expander per inserire un contenitore che mostra ulteriori informazioni solo quando viene espanso
  • Usare st.image per aggiungere il logo del proprio sito/brand

Datasets

Useremo le fonti di dato già presentate nel tutorial Streamlit: costruire una Web App in pochi minuti ma non useremo gli stessi dati. Collegatevi, quindi, a Redfin e scorrete fino alla sezione How it works. Scaricate i dati a livello di County. Dal momento che creeremo una mappa interattiva nell’applicazione, abbiamo anche bisogno di ottenere il file geojson che definisce i confini geografici di ogni contea. Dal sito public.opendatasoft.com, andate nella sezione ‘Geographic File Formats’ e scaricare il file geojson per i confini delle contee degli Stati Uniti.

Purtroppo, il dataset Redfin non contiene il codice FIPS dello stato, che è un identificatore unico per ogni stato e che useremo in seguito come chiave per collegarsi al file geojson. Pertanto, abbiamo bisogno di scaricare un terzo file che ha sia i nomi degli stati che i codici FIPS. Potete scarica qui il file county_fips. I dati sono stati estratti dal sito nrcs.usda.gov.

Preparare i dati

Poiché l’obiettivo di questo tutorial è di esplorare e imparare varie caratteristiche di Streamlit basandosi sull’app che abbiamo creato nell’articolo Streamlit: costruire una Web App in pochi minuti, vi riportiamo il codice da inserire all’interno del file my_app_v2.py. Abbiamo riportato alcuni commenti per illustrare le operazioni principali che vengono eseguite. Per ulteriori informazioni potete rileggere il precedente tutorial.

# Import Python Libraries
import pandas as pd
import folium #to install folium using Anaconda: conda install -c conda-forge folium
import geopandas as gpd #to install geopandas, run this code in the conda terminal: conda install geopandas
from folium.features import GeoJsonTooltip
import streamlit as st #You can follow the instructions in the beginner tutorial to install Streamlit if you don't have it
from streamlit_folium import folium_static

@st.cache
def read_csv(path):
    return pd.read_csv(path, compression='gzip', sep='\t', quotechar='"')

housing_price_df=read_csv('county_market_tracker.tsv000.gz') #Replace ... with your file path
housing_price_df=housing_price_df[(housing_price_df['period_begin']>='2020-10-01') & (housing_price_df['period_begin']<='2021-10-01')] #only look at past 12 months' data
county_fips=pd.read_csv('county_fips.csv', sep='\t')
county_fips['region']=county_fips["Name"] + ' County, '+ county_fips["State"] #Create a new column called 'region' which is the concatenation of county name and state. This column will be used in the next step to join housing_price_df with county_fips

housing_price_df= housing_price_df.merge(county_fips, on="region", how="left") 
housing_price_df['FIPS'] = housing_price_df['FIPS'].astype(str).replace('\.0', '', regex=True)
housing_price_df["county_fips"] = housing_price_df["FIPS"].str.zfill(5)

@st.cache
def read_file(path):
    return gpd.read_file(path)

#Read the geojson file
gdf = read_file('georef-united-states-of-america-county.geojson')

#Merge the housing market data and geojson file into one dataframe
df_final = gdf.merge(housing_price_df, left_on="coty_code", right_on="county_fips", how="outer") #join housing_price_df with gdf to get the geometries column from geojson file
df_final= df_final[~df_final['period_begin'].isna()]  
df_final = df_final[~df_final['geometry'].isna()]
df_final=df_final[['period_begin','period_end', 'region','parent_metro_region','state_code',"property_type",'median_sale_price','median_sale_price_yoy','homes_sold','homes_sold_yoy','new_listings',
                   'new_listings_yoy','median_dom','avg_sale_to_list',"county_fips",'geometry']]
df_final.rename({'median_sale_price': 'Median Sales Price',
                 'median_sale_price_yoy': 'Median Sales Price (YoY)',
                 'homes_sold':'Homes Sold',
                 'homes_sold_yoy':'Homes Sold (YoY)',
                 'new_listings':'New Listings',
                 'new_listings_yoy':'New Listings (YoY)',
                 'median_dom':'Median Days-on-Market',
                 'avg_sale_to_list':'Avg Sales-to-Listing Price Ratio'}, 
                 axis=1, inplace=True) 

#st.write(df_final.head())   

Ricordatevi di impostare nei Settings l’opzione Run on save così da vedere immediatamente le modifiche apportate al codice. Inoltre, se volete vedere, per questioni di debug, la tabella dei dati importati decommentate l’istruzione st.write.

Aggiungere una barra laterale

Per prima cosa aggiungeremo una barra laterale all’app che contiene un messaggio di benvenuto “Welcome Streamlitters”. La funzione della barra laterale è ottima per organizzare tutti i tuoi widget di input interattivi in un’unica sezione ed è espandibile/collassabile per permettere agli utenti di concentrarsi sul contenuto.

#Adding a sidebar to the app
st.sidebar.title("Welcome Streamlitters!") 

Il risultato che otterremo è il seguente.

Aggiungere filtri/ widget di input

A differenza dell’app del precedente tutorail, vogliamo aggiungere alcuni filtri per interagire con i dati nella barra laterale. I filtri vengono implementati mediante diversi widget tra cui st.selectbox, st.radio, st.slider ecc. Per aggiungere i widget alla barra laterale e non nell’interfaccia principale, è sufficiente usare i widget associati alla sidebar come st.sidebar.selectbox, st.sidebar.radio, ecc.

Per aiutare l’utente nell’utilizzo di questi filtri, aggiungeremo un tooltip ad ogni widget semplicemente assegnando al parametro help un testo.

#Add filters/input widgets with tooltips
st.sidebar.markdown("Select Filters:") 
period_list=df_final["period_begin"].unique().tolist()
period_list.sort(reverse=True)
year_month = st.sidebar.selectbox("Snapshot Month", period_list, index=0, help='Choose by which time period you want to look at the metrics. The default is always the most recent month.')

prop_type = st.sidebar.selectbox(
            "View by Property Type", ['All Residential', 'Single Family Residential', 'Townhouse','Condo/Co-op','Single Units Only','Multi-Family (2-4 Unit)'] , index=0, help='select by which property type you want to look at the metrics. The default is all residential types.')

metrics = st.sidebar.selectbox("Select Housing Metrics", ["Median Sales Price","Median Sales Price (YoY)", "Homes Sold",'Homes Sold (YoY)','New Listings','New Listings (YoY)','Median Days-on-Market','Avg Sales-to-Listing Price Ratio'], index=0, help='You can view the map by different housing market metrics such as median sales price, homes sold, etc.')

state_list=df_final["state_code"].unique().tolist()
state_list.sort(reverse=False)
state_list.insert(0,"All States")
state = st.sidebar.selectbox("Select State", state_list,index=0, help='select to either view the map for all the states or zoom into one state')

homes_sold=st.sidebar.slider("Sold >= X Number of Homes", min_value=1, max_value=500, value=10,help='Drag the slider to select counties that sold at least x number of homes in the snapshot month. By defaut we are showing counties that sold at least 10 homes in the snapshot month.') 

Il risultato grafico sarà il seguente.

Ora è necessario fornire le selezioni dell’utente al frame dei dati al fine di filtrarli.

# Pass the user input to the data frame
df_final=df_final[df_final["period_begin"]==year_month] #only show rows with period_begin equal to whatever selected by user as the time period
df_final=df_final[df_final["property_type"]==prop_type] #only show rows with property type equal to user's selection
df_final=df_final[df_final["Homes Sold"]>=homes_sold] #only show rows with at least X number of homes sold based on user's selection

#Define a function so that if user select 'all states' we'll show data for all the states. Otherwise only show data for whatever state selected by user
def state_filter (state):
   if state=='All States':
       df=df_final
   else: 
       df=df_final[df_final["state_code"]==state]
   return df
df_final=state_filter(state)  

#Quickly check whether the slicing and dicing of the dataframe works properly based on user's input
st.write(df_final) 

Ricordatevi di commentare la funzione st.write all’inizio per vedere solo i dati filtrati. Il risultato che otterrete sarà simile a quello riportato di seguito.

Questa soluzione è ottima dal punto di vista grafico ma non ottimale a livello di performance. Infatti, ogni volta che l’utente interagisce con un widget o un filtro, l’applicazione viene eseguita nuovamente e aggiorna i dati di conseguenza. Ciò potrebbe non essere critico se si hanno solo un paio di filtri o il dataframe non è molto grande. Tuttavia, immaginate quando avete un complesso modello di apprendimento automatico con molti widget di input e dati di grandi dimensioni. In questo caso la riesecuzione del codice ad ogni interazione dell’utente potrebbe portare ad un rallentamento dell’app.

Come è possibile risolvere questo problema? Fortunatamente Streamlit ha introdotto una coppia di comandi chiamati st.form e st.form_submit_button per affrontare specificamente questo problema. È possibile utilizzare questi comandi per raggruppare i widget di input e inviare i loro valori con il clic di un pulsante, che innescherà solo una singola ripetizione dell’intera app.

Form dei widget

Vediamo come possiamo modificare il nostro codice per usare i form e il pulsante di invio per raggruppare i widget. I form possono essere dichiarati usando l’istruzione with e possono includere più di un widget.

Il codice seguente crea un form che contiene 5 widget e un pulsante di invio chiamato ‘Apply Filters’. In questo modo, gli utenti possono interagire con i widget quanto vogliono senza causare una nuova esecuzione del codice. Per applicare i filtri ed aggiornare l’applicazione, l’utente dovrà cliccare sul pulsante di invio del form.

Sostituite il codice relativo ai widgets con il seguente. Ricordatevi di non cancellare l parte relativa al passaggio dei valori dei filtri al dataframe.

#Add filters/input widgets with tooltips
st.sidebar.markdown("Select Filters:") 
#Use forms and submit button to batch input widgets
with st.sidebar.form(key='columns_in_form'):
    period_list=df_final["period_begin"].unique().tolist()
    period_list.sort(reverse=True)
    year_month = st.selectbox("Snapshot Month", period_list, index=0, help='Choose by which time period you want to look at the metrics. The default is always the most recent month.')

    prop_type = st.selectbox(
                "View by Property Type", ['All Residential', 'Single Family Residential', 'Townhouse','Condo/Co-op','Single Units Only','Multi-Family (2-4 Unit)'] , index=0, help='select by which property type you want to look at the metrics. The default is all residential types.')

    metrics = st.selectbox("Select Housing Metrics", ["Median Sales Price","Median Sales Price (YoY)", "Homes Sold",'Homes Sold (YoY)','New Listings','New Listings (YoY)','Median Days-on-Market','Avg Sales-to-Listing Price Ratio'], index=0, help='You can view the map by different housing market metrics such as median sales price, homes sold, etc.')
    
    state_list=df_final["state_code"].unique().tolist()
    state_list.sort(reverse=False)
    state_list.insert(0,"All States")
    state = st.selectbox("Select State", state_list,index=0, help='select to either view the map for all the states or zoom into one state')
    
    homes_sold=st.slider("Sold >= X Number of Homes", min_value=1, max_value=500, value=10,help='Drag the slider to select counties that sold at least x number of homes in the snapshot month. By defaut we are showing counties that sold at least 10 homes in the snapshot month.')

    submitted = st.form_submit_button('Apply Filters')
 

L’interfaccia risulterà leggermente diversa rispetto a prima.

Popolare l’interfaccia principale

Al momento l’interfaccia principale contiene solo la tabella dei dati eventualmente filtrati. Iniziamo ad aggiungere un titolo, un logo, un espansore e infine la mappa che visualizza le metriche del mercato immobiliare a livello di contea.

Aggiungere un titolo e il logo

st.columns è un comando molto utile che permette di inserire contenitori disposti come colonne affiancate. Lo useremo, quindi, per creare due colonne in modo da poter aggiungere il titolo e il logo affiancati.

#Add a title and company logo
from PIL import Image
image = Image.open('.../Insights_Bees_logo.png')

col1, col2 = st.columns( [0.8, 0.2])
with col1:
    st.title("U.S. Real Estate Insights")   
with col2:
    st.image(image,  width=150) 

Ricordatevi di commentare la visualizzazione del dataframe. Diversamente il contenitore appena inserito verrà visualizzato immediatamente dopo la tabella.

Aggiungere un espansore sotto il titolo

Possiamo anche aggiungere un espansore sotto il titolo per fornire informazioni sull’app. L’espansore può essere espanso o compresso per fornire più dettagli risparmiando spazio nell’app, che è una bella caratteristica da sfruttare.

#Add an expander to the app 
with st.expander("About the App"):
     st.write("""
         This app is created using Redfin Data Center's open data (https://www.redfin.com/news/data-center/) to visualize various housing market metrics across the U.S. states at county level. Areas that are white on the map are the counties that don't have data available. Select the filters on the sidebar and your insights are just a couple clicks away. Hover over the map to view more details.
     """) 

Aggiungere la mappa

Ora inseriamo la mappa nell’interfaccia principale dell’app. E’ sufficiente inserire il codice seguente per creare una mappa usando Folium. Questa mappa visualizzerà i dati del mercato immobiliare statunitense in base al mese, al tipo di proprietà e alle metriche del mercato immobiliare (ad esempio, prezzo mediano di vendita, case vendute, rapporto vendite/listini, ecc.)

#Create a choropleth map
col1, col2 = st.columns( [0.7, 0.3])
with col1:
    us_map = folium.Map(location=[40, -96], zoom_start=4,tiles=None)
    folium.TileLayer('CartoDB positron',name="Light Map",control=False).add_to(us_map)
    custom_scale = (df_final[metrics].quantile((0,0.6,0.7,0.8,0.9, 1))).tolist()

    folium.Choropleth(
            geo_data='.../georef-united-states-of-america-county.geojson',
            data=df_final,
            columns=['county_fips', metrics],  #Here we tell folium to get the county fips and plot the user-selected housing market metric for each county
            key_on='feature.properties.coty_code', #Here we grab the geometries/county boundaries from the geojson file using the key 'coty_code' which is the same as county fips
            threshold_scale=custom_scale, #use the custom scale we created for legend
            fill_color='YlGn',
            nan_fill_color="White", #Use white color if there is no data available for the county
            fill_opacity=0.7,
            line_opacity=0.2,
            legend_name='Measures',
            highlight=True,
            line_color='black').geojson.add_to(us_map) #by using .geojson.add_to() instead of .add_to() we are able to hide the legend. The reason why we want to hide the legend here is because the legend scale numbers are overlapping


    #Add Customized Tooltips to the map
    feature = folium.features.GeoJson(
                    data=df_final,
                    name='North Carolina',
                    smooth_factor=2,
                    style_function=lambda x: {'color':'black','fillColor':'transparent','weight':0.5},
                    tooltip=folium.features.GeoJsonTooltip(
                        fields=['period_begin',
                                'period_end',
                                'region',
                                'parent_metro_region',
                                'state_code',
                                "Median Sales Price",
                                "Median Sales Price (YoY)", 
                                "Homes Sold",'Homes Sold (YoY)',
                                'New Listings','New Listings (YoY)',
                                'Median Days-on-Market',
                                'Avg Sales-to-Listing Price Ratio'],
                        aliases=["Period Begin:",
                                    'Period End:',
                                    'County:',
                                    'Metro Area:',
                                    'State:',
                                    "Median Sales Price:",
                                "Median Sales Price (YoY):", 
                                "Homes Sold:",
                                'Homes Sold (YoY):',
                                'New Listings:',
                                'New Listings (YoY):',
                                'Median Days-on-Market:',
                                'Avg Sales-to-Listing Price Ratio:'], 
                        localize=True,
                        sticky=False,
                        labels=True,
                        style="""
                            background-color: #F0EFEF;
                            border: 2px solid black;
                            border-radius: 3px;
                            box-shadow: 3px;
                        """,
                        max_width=800,),
                            highlight_function=lambda x: {'weight':3,'fillColor':'grey'},
                        ).add_to(us_map)                    
        
    folium_static(us_map)

with col2:
    markdown_metrics = '<span style="color:black">**Metric**: '+metrics + '</span>'
    st.markdown(markdown_metrics, unsafe_allow_html=True)  #Overlay a text to the map which indicates which metric is shown on the choropleth map 

Nel codice qui sopra, le linee da 4 a 20 inizializzano una mappa degli Stati Uniti vuota e impostano la posizione centrale predefinita della mappa a (40, -96). La mappa viene creata in base all’input dell’utente fornito mediante il widget metrics, il quale determina l’ombreggiatura di ogni contea. Le linee da 24 a 65 aggiungono tooltip personalizzati alla mappa. La linea 67 aggiunge la mappa all’interfaccia principale.

Infine, è stato utilizzato il layout st.columns per sovrapporre del testo alla mappa. Questo testo appare nell’angolo in alto a destra della mappa e indica quale metrica è rappresentata dalla mappa, a seconda della selezione da parte dell’utente relativamente al filtro ‘Select Housing Metrics’. Questo si ottiene usando le righe 2, 3, 69 e 70.

Una piccola cosa da notare è che nella linea 20 del codice viene usato .geojson.add_to(us_map) invece di .add_to(us_map). Questo è un piccolo trucco che permette di nascondere la leggenda nella mappa. Se si utlizza .add_to(us_map), la legenda appare nella mappa ma i numeri della scala si sovrappongono, quindi ho rimosso la legenda dalla mappa.

Con queste ultime modifiche l’applicazione è pronta! A differenza di quella precedente, è molto più sofisticata e facile da usare. E può essere un punto di partenza avanzato per la costruzione di nuove app.

Tutto il codice è disponibile nel repository github. Se volete usare l’app potete collegarvi a Streamlit Cloud.

Letture consigliate

More To Explore

Intelligenza artificiale

Gradio: applicazioni web in python per AI [parte2]

Gradio è una libraria python che ci permette di creare applicazioni web in modo veloce e intuitivo per i nostri modelli di machine learning e AI. Le nostre applicazioni richiedono sempre un’interazione con l’utente e una personalizzazione del layout. Scopriamo, mediante degli esempi, come migliorare le nostre applicazioni.

Intelligenza artificiale

Gradio: applicazioni web in python per AI [parte1]

Scrivere applicazioni web per i nostri modelli di machine learning e/o di intelligenza artificiale può richiedere molto tempo e competenze che non sono in nostro possesso. Per snellire e velocizzare questo compito ci viene in aiuto Gradio, una libreria Python pensata per creare applicazioni web con poche righe di codice. Scopriamo le sue funzionalità base con alcuni esempi.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

Design with MongoDB

Design with MongoDB!!!

Buy the new book that will help you to use MongoDB correctly for your applications. Available now on Amazon!