Bellabeat Case Study — How Can a Wellness Company Play It Smart?¶
Google Data Analytics Capstone · Junior Data Analyst, Bellabeat Marketing Analytics Team¶
Autore: Luca Mulazzi · Data: giugno 2026
Questo case study segue il processo di analisi dati in 6 fasi — Ask · Prepare · Process · Analyze · Share · Act — per analizzare l'uso di smart device fitness e tradurlo in raccomandazioni di marketing per Bellabeat, azienda tech di prodotti per il benessere femminile.
1. Ask¶
Contesto di business¶
Urška Sršen (cofondatrice e CCO di Bellabeat) ritiene che analizzare i dati dei fitness tracker possa aprire opportunità di crescita. Il compito è analizzare come i consumatori usano smart device non-Bellabeat e applicare gli insight a un prodotto Bellabeat.
Business task¶
Identificare i trend di utilizzo degli smart device fitness e tradurli in raccomandazioni concrete per la strategia di marketing di un prodotto Bellabeat.
Domande guida¶
- Quali sono i trend nell'uso degli smart device?
- Come si applicano questi trend ai clienti Bellabeat?
- Come possono influenzare la strategia di marketing di Bellabeat?
Stakeholder¶
- Urška Sršen — cofondatrice & Chief Creative Officer (decision maker)
- Sando Mur — cofondatore, team esecutivo
- Team Marketing Analytics — destinatari operativi dell'analisi
Deliverable¶
Riepilogo del business task · descrizione delle fonti · documentazione della pulizia · analisi · visualizzazioni · top 3 raccomandazioni.
2. Prepare¶
Fonte dati¶
FitBit Fitness Tracker Data (Kaggle, CC0 Public Domain, via Mobius). 30+ utenti FitBit che hanno acconsentito a condividere dati a livello di minuto su attività, frequenza cardiaca e sonno, dal 12 aprile al 12 maggio 2016 (~31 giorni). I dati sono organizzati in 18 file CSV in formato long e wide.
Valutazione di credibilità — ROCCC¶
| Criterio | Valutazione | Note |
|---|---|---|
| Reliable | ⚠️ Bassa | Solo ~30 utenti, campione piccolo e non casuale |
| Original | ⚠️ Media | Dati di terze parti raccolti via Amazon Mechanical Turk |
| Comprehensive | ⚠️ Media | Manca la demografia (sesso, età): critico per un brand femminile |
| Current | ❌ Bassa | Dati del 2016, oltre 8 anni fa |
| Cited | ✅ | Fonte documentata (Kaggle/Mobius) |
Limiti principali (da tenere a mente nelle raccomandazioni)¶
- Campione ridotto: 33 utenti per l'attività, 24 per il sonno, solo 8 per il peso.
- Nessun dato demografico → non possiamo verificare che il campione rappresenti donne (target Bellabeat).
- Finestra di 1 mese e dati datati (2016).
- Il file peso ha la colonna Fat quasi sempre vuota (65/67) → inutilizzabile.
➡️ Per un'analisi reale andrebbe integrato un dataset più recente, demografico e con campione più ampio.
# --- Import delle librerie ---
import pandas as pd # pandas: caricamento e manipolazione dei dati tabellari (DataFrame)
import numpy as np # numpy: supporto numerico (usato indirettamente da pandas/seaborn)
import matplotlib.pyplot as plt # matplotlib: motore di base per disegnare i grafici
import seaborn as sns # seaborn: grafici statistici di alto livello sopra matplotlib
# --- Impostazioni grafiche coerenti con il brand Bellabeat ---
sns.set_theme(style="whitegrid") # tema con griglia chiara di sfondo per tutti i grafici
BELLA = ["#2A9D8F", "#E76F51", "#264653", "#E9C46A", "#8AB17D"] # palette colori (teal, corallo, blu scuro, oro, verde)
sns.set_palette(BELLA) # imposta la palette come predefinita per seaborn
plt.rcParams["figure.figsize"] = (10, 5) # dimensione predefinita di ogni figura (larghezza, altezza in pollici)
plt.rcParams["axes.titlesize"] = 13 # dimensione del font dei titoli degli assi
plt.rcParams["axes.titleweight"] = "bold" # titoli in grassetto
# --- Caricamento dei file CSV in DataFrame ---
DATA = "Data/Fitabase Data 4.12.16-5.12.16/" # cartella che contiene i CSV (percorso relativo al notebook)
activity = pd.read_csv(DATA + "dailyActivity_merged.csv") # attività giornaliera: passi, distanze, minuti, calorie
sleep = pd.read_csv(DATA + "sleepDay_merged.csv") # sonno giornaliero: minuti dormiti / a letto
weight = pd.read_csv(DATA + "weightLogInfo_merged.csv") # registro peso: peso, BMI, grasso (campione minimo)
hourly = pd.read_csv(DATA + "hourlySteps_merged.csv") # passi aggregati ora per ora
# .shape restituisce la coppia (numero righe, numero colonne) di ogni DataFrame
print("activity:", activity.shape, "| sleep:", sleep.shape,
"| weight:", weight.shape, "| hourly:", hourly.shape)
activity: (940, 15) | sleep: (413, 5) | weight: (67, 8) | hourly: (22099, 3)
# --- Quanti utenti distinti ci sono in ciascun file? ---
print("== UTENTI UNICI ==")
for name, df in [("activity", activity), ("sleep", sleep), ("weight", weight), ("hourly", hourly)]:
# df['Id'] = colonna identificativo utente; .nunique() conta i valori distinti
print(f"{name:9s}: {df['Id'].nunique()} utenti") # f-string: {name:9s} allinea il nome a 9 caratteri
# --- Ci sono righe completamente duplicate? ---
print("\n== DUPLICATI ==")
for name, df in [("activity", activity), ("sleep", sleep), ("weight", weight)]:
# .duplicated() marca True le righe ripetute; .sum() le conta (True=1)
print(f"{name:9s}: {df.duplicated().sum()} righe duplicate")
# --- Valori mancanti nel file attività ---
print("\n== VALORI MANCANTI (activity) ==")
# .isna() crea una maschera True dove il valore è mancante; .sum() conta i mancanti per colonna
# [lambda s: s > 0] mostra solo le colonne che hanno almeno un mancante
print(activity.isna().sum()[lambda s: s > 0] if activity.isna().sum().sum() else "Nessuno")
# Quanti valori mancano nella colonna Fat del file peso (per confermarne l'inutilizzabilità)
print("\nFat mancanti in weight:", weight['Fat'].isna().sum(), "su", len(weight))
== UTENTI UNICI == activity : 33 utenti sleep : 24 utenti weight : 8 utenti hourly : 33 utenti == DUPLICATI == activity : 0 righe duplicate sleep : 3 righe duplicate weight : 0 righe duplicate == VALORI MANCANTI (activity) == Nessuno Fat mancanti in weight: 65 su 67
3.2 Pulizia e trasformazione¶
# 1) Rimuovo le righe completamente duplicate nel file sonno (3 righe identiche trovate sopra)
sleep = sleep.drop_duplicates() # restituisce il DataFrame senza duplicati, riassegnato a 'sleep'
# 2) Converto le date da testo a oggetti datetime, specificando il formato esatto (niente ambiguità mese/giorno)
activity['ActivityDate'] = pd.to_datetime(activity['ActivityDate'], format='%m/%d/%Y') # es. "4/12/2016"
sleep['SleepDay'] = pd.to_datetime(sleep['SleepDay'], format='%m/%d/%Y %I:%M:%S %p') # es. "4/12/2016 12:00:00 AM"
hourly['ActivityHour'] = pd.to_datetime(hourly['ActivityHour'], format='%m/%d/%Y %I:%M:%S %p') # data + ora
# 3) Creo colonne derivate che serviranno all'analisi
activity['Weekday'] = activity['ActivityDate'].dt.day_name() # nome del giorno della settimana (Monday, ...)
activity['ActiveMinutes'] = (activity['VeryActiveMinutes'] # somma dei minuti attivi a tutte le intensità
+ activity['FairlyActiveMinutes']
+ activity['LightlyActiveMinutes'])
activity['SedentaryHours'] = activity['SedentaryMinutes'] / 60 # minuti sedentari convertiti in ore
hourly['Hour'] = hourly['ActivityHour'].dt.hour # estrae solo l'ora (0–23) dal timestamp
sleep['HoursAsleep'] = sleep['TotalMinutesAsleep'] / 60 # minuti di sonno convertiti in ore
sleep['TimeInBedAwake'] = sleep['TotalTimeInBed'] - sleep['TotalMinutesAsleep'] # tempo passato a letto ma sveglio
# 4) Identifico i giorni in cui il device NON è stato indossato: 1440 min = 24h tutte sedentarie => probabile non-utilizzo
activity['NonWear'] = activity['SedentaryMinutes'] == 1440 # colonna booleana True/False
print("Giorni con device non indossato (sedentary=1440):", activity['NonWear'].sum()) # conta i True
print("Giorni con 0 passi:", (activity['TotalSteps'] == 0).sum()) # giorni a zero passi
# Creo una copia 'pulita' senza i giorni di non-utilizzo, per non distorcere le medie di attività
activity_valid = activity[~activity['NonWear']].copy() # '~' inverte la maschera: tiene solo i giorni indossati
print("Righe activity totali:", len(activity), "| valide (device indossato):", len(activity_valid))
Giorni con device non indossato (sedentary=1440): 79 Giorni con 0 passi: 77 Righe activity totali: 940 | valide (device indossato): 861
Documentazione della pulizia
- ✅ Rimosse 3 righe duplicate dal file
sleepDay. - ✅ Convertite le date da stringa a
datetime(formato esplicito → niente ambiguità). - ✅ Aggiunte colonne derivate:
Weekday,ActiveMinutes,SedentaryHours,Hour,HoursAsleep,TimeInBedAwake. - ✅ Segnalati e isolati i giorni di non-utilizzo (sedentarietà = 1440 min) per non distorcere le medie di attività.
- ⚠️ File
weightmantenuto solo come riferimento qualitativo (8 utenti) e colonnaFatignorata.
# .describe() calcola count, media, deviazione std, min, quartili e max per le colonne numeriche scelte
summary = activity_valid[['TotalSteps','TotalDistance','ActiveMinutes',
'SedentaryMinutes','Calories']].describe().round(1) # .round(1) = 1 cifra decimale
display(summary) # display() mostra la tabella formattata nel notebook (meglio di print per i DataFrame)
# Medie principali, arrotondate, per leggere i valori tipici dell'utente
print("Media passi/giorno :", round(activity_valid['TotalSteps'].mean())) # .mean() = media aritmetica
print("Media calorie/giorno :", round(activity_valid['Calories'].mean()))
print("Media ore sedentarie :", round(activity_valid['SedentaryHours'].mean(), 1))
print("Media ore di sonno :", round(sleep['HoursAsleep'].mean(), 2))
print("Media min. svegli a letto:", round(sleep['TimeInBedAwake'].mean(), 1))
| TotalSteps | TotalDistance | ActiveMinutes | SedentaryMinutes | Calories | |
|---|---|---|---|---|---|
| count | 861.0 | 861.0 | 861.0 | 861.0 | 861.0 |
| mean | 8280.3 | 5.9 | 248.4 | 950.0 | 2350.6 |
| std | 4783.2 | 3.7 | 104.9 | 280.9 | 713.9 |
| min | 0.0 | 0.0 | 0.0 | 0.0 | 52.0 |
| 25% | 4832.0 | 3.3 | 184.0 | 720.0 | 1852.0 |
| 50% | 7990.0 | 5.6 | 258.0 | 1019.0 | 2207.0 |
| 75% | 11085.0 | 7.9 | 323.0 | 1187.0 | 2828.0 |
| max | 36019.0 | 28.0 | 552.0 | 1439.0 | 4900.0 |
Media passi/giorno : 8280 Media calorie/giorno : 2351 Media ore sedentarie : 15.8 Media ore di sonno : 6.99 Media min. svegli a letto: 39.3
Osservazioni
- ~8.300 passi/giorno (giorni di effettivo utilizzo): sotto i 10.000 raccomandati dall'OMS/CDC.
- ~15,8 ore sedentarie/giorno: gran parte della giornata è inattiva.
- ~7 ore di sonno: al limite inferiore delle 7–9h raccomandate; in media ~40 min svegli a letto.
4.2 Relazioni tra variabili¶
# Unisco attività e sonno per gli stessi utente+giorno, così da confrontarli sulla stessa riga
merged = activity_valid.merge(sleep, # .merge() = join in stile SQL
left_on=['Id','ActivityDate'], # chiavi nel DataFrame di sinistra (activity)
right_on=['Id','SleepDay'], # chiavi nel DataFrame di destra (sleep)
how='inner') # 'inner' = tiene solo i giorni presenti in entrambi
print("Righe activity+sleep:", len(merged), "| utenti:", merged['Id'].nunique())
# .corr() calcola il coefficiente di correlazione di Pearson (da -1 a +1) tra due colonne
print("\nCorrelazioni:")
print(" Passi ↔ Calorie :", round(activity_valid['TotalSteps'].corr(activity_valid['Calories']), 3))
print(" Sedentarietà ↔ Sonno :", round(merged['SedentaryMinutes'].corr(merged['TotalMinutesAsleep']), 3))
print(" Min. attivi ↔ Calorie:", round(activity_valid['ActiveMinutes'].corr(activity_valid['Calories']), 3))
Righe activity+sleep: 410 | utenti: 24 Correlazioni: Passi ↔ Calorie : 0.569 Sedentarietà ↔ Sonno : -0.601 Min. attivi ↔ Calorie: 0.446
4.3 Quando e quanto le persone usano il device¶
# --- Frequenza d'uso: in quanti giorni distinti ogni utente ha indossato il device ---
usage_days = activity[~activity['NonWear']].groupby('Id')['ActivityDate'].nunique() # per utente, conta i giorni indossati
def segment(d): # funzione che classifica un utente in base ai giorni d'uso
if d >= 21: return "Uso alto (21–31 gg)" # device indossato quasi sempre
if d >= 11: return "Uso moderato (11–20 gg)" # uso intermedio
return "Uso basso (1–10 gg)" # uso saltuario
seg_counts = usage_days.apply(segment).value_counts() # applica la funzione a ogni utente e conta per categoria
print("Segmentazione utenti per frequenza d'uso:\n", seg_counts.to_string())
# --- Quanti utenti tracciano davvero ciascuna metrica? ---
print("\nUtenti che tracciano attività:", activity['Id'].nunique(),
"| sonno:", sleep['Id'].nunique(),
"| peso:", weight['Id'].nunique())
# --- Pattern orario: media dei passi per ciascuna ora del giorno ---
hourly_avg = hourly.groupby('Hour')['StepTotal'].mean() # raggruppa per ora e fa la media dei passi
# --- Pattern settimanale: media dei passi per giorno della settimana, riordinata Lun->Dom ---
order = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday']
weekday_avg = activity_valid.groupby('Weekday')['TotalSteps'].mean().reindex(order) # .reindex() forza l'ordine dei giorni
print("\nOre di picco (passi medi):", hourly_avg.sort_values(ascending=False).head(3).round(0).to_dict())
Segmentazione utenti per frequenza d'uso:
ActivityDate
Uso alto (21–31 gg) 25
Uso moderato (11–20 gg) 7
Uso basso (1–10 gg) 1
Utenti che tracciano attività: 33 | sonno: 24 | peso: 8
Ore di picco (passi medi): {18: 599.0, 19: 583.0, 17: 550.0}
Key findings
- Attività bassa, sedentarietà alta — l'utente medio cammina ~8,3k passi e sta seduto ~15,8h/giorno: enorme spazio per messaggi che spingono al movimento.
- Più movimento → più calorie (r ≈ 0,59): feedback motivazionale concreto da comunicare.
- Sedentarietà ↔ sonno negativi (r ≈ −0,60): chi sta più fermo dorme meno → leva per legare attività e qualità del sonno.
- Il sonno è sotto-tracciato: solo 24/33 utenti loggano il sonno e il peso quasi nessuno → funzioni poco usate da valorizzare/automatizzare.
- Picchi d'uso 12–14 e 17–19, sabato il giorno più attivo → finestre ottimali per notifiche e campagne.
- Chi adotta resta fedele: la maggioranza indossa il device 21–31 giorni → l'engagement va alimentato, non conquistato da zero.
5. Share — Visualizzazioni¶
# Grafico 1: relazione tra passi e calorie, con retta di regressione
fig, ax = plt.subplots() # crea figura (fig) e area di disegno (ax)
sns.regplot(data=activity_valid, x='TotalSteps', y='Calories', # scatter + retta di tendenza
scatter_kws={'alpha':0.35, 's':25, 'color':BELLA[0]}, # punti semitrasparenti (alpha), dimensione s, colore
line_kws={'color':BELLA[1], 'lw':2}, ax=ax) # retta corallo, spessore lw=2
ax.set_title("Più passi → più calorie bruciate (r ≈ 0,59)") # titolo del grafico
ax.set_xlabel("Passi giornalieri"); ax.set_ylabel("Calorie") # etichette degli assi X e Y
plt.tight_layout(); plt.show() # ottimizza i margini e mostra il grafico
# Grafico 2: passi medi per ora del giorno (barre), evidenziando le 3 ore di picco
fig, ax = plt.subplots()
hourly_avg.plot(kind='bar', color=BELLA[0], ax=ax) # grafico a barre dei passi medi per ora
for h in hourly_avg.sort_values(ascending=False).head(3).index: # per le 3 ore con più passi...
ax.patches[h].set_color(BELLA[1]) # ...coloro la barra corrispondente in corallo
ax.set_title("Passi medi per ora del giorno — picchi a pranzo (12–14) e sera (17–19)")
ax.set_xlabel("Ora"); ax.set_ylabel("Passi medi")
plt.tight_layout(); plt.show()
# Grafico 3: passi medi per giorno della settimana con linea della media
fig, ax = plt.subplots()
weekday_avg.plot(kind='bar', color=BELLA[4], ax=ax) # barre verdi: passi medi per giorno
ax.axhline(weekday_avg.mean(), color=BELLA[1], ls='--', label='Media settimanale') # linea orizzontale tratteggiata
ax.set_title("Passi medi per giorno della settimana")
ax.set_xlabel(""); ax.set_ylabel("Passi medi"); ax.legend() # legenda per la linea della media
plt.xticks(rotation=45, ha='right'); plt.tight_layout(); plt.show() # ruota le etichette dei giorni di 45°
# Grafico 4: composizione media della giornata per livello di attività (torta)
parts = activity_valid[['VeryActiveMinutes','FairlyActiveMinutes',
'LightlyActiveMinutes','SedentaryMinutes']].mean() # media dei minuti per ciascun livello
labels = ['Molto attivo','Moderato','Leggero','Sedentario'] # etichette degli spicchi
fig, ax = plt.subplots(figsize=(7,7)) # figura quadrata per la torta
ax.pie(parts, labels=labels, autopct='%1.1f%%', startangle=90, # autopct mostra la percentuale su ogni spicchio
colors=[BELLA[1],BELLA[3],BELLA[4],BELLA[2]], # un colore per spicchio
wedgeprops={'edgecolor':'white'}) # bordo bianco tra gli spicchi
ax.set_title("Come passa la giornata l'utente medio\n(79% del tempo tracciato è sedentario)")
plt.tight_layout(); plt.show()
# Grafico 5: relazione tra ore sedentarie e ore di sonno (correlazione negativa)
fig, ax = plt.subplots()
sns.regplot(data=merged, x='SedentaryHours', y='HoursAsleep', # scatter + retta sul dataset unito
scatter_kws={'alpha':0.35,'s':25,'color':BELLA[2]},
line_kws={'color':BELLA[1],'lw':2}, ax=ax)
ax.set_title("Più ore sedentarie → meno ore di sonno (r ≈ −0,60)")
ax.set_xlabel("Ore sedentarie / giorno"); ax.set_ylabel("Ore di sonno")
plt.tight_layout(); plt.show()
# Grafico 6: numero di utenti per fascia di frequenza d'uso (barre orizzontali)
fig, ax = plt.subplots(figsize=(8,5))
seg_counts.reindex(["Uso alto (21–31 gg)","Uso moderato (11–20 gg)","Uso basso (1–10 gg)"]).plot( # ordine fisso delle fasce
kind='barh', color=[BELLA[0],BELLA[3],BELLA[1]], ax=ax) # barh = barre orizzontali, un colore per fascia
ax.set_title("Frequenza d'uso del device — la maggioranza lo indossa quasi ogni giorno")
ax.set_xlabel("Numero di utenti")
plt.tight_layout(); plt.show()
6. Act — Conclusioni e raccomandazioni¶
Prodotto scelto: l'app Bellabeat (con focus sul tracker Leaf/Time)¶
L'app è il punto di contatto centrale che connette tutti i dispositivi: gli insight su attività, sonno e sedentarietà si traducono direttamente in funzionalità e messaggi dell'app.
Risposte alle domande di business¶
- Trend: utenti poco attivi (~7,6k passi), molto sedentari (~16h), sonno al limite (~7h); uso fedele ma sonno/peso poco tracciati; picchi di movimento a pranzo e sera.
- Applicazione a Bellabeat: gli stessi bisogni (muoversi di più, dormire meglio, ridurre la sedentarietà) sono il cuore del valore Leaf/Time + app.
- Strategia marketing: vedi le 3 raccomandazioni qui sotto.
🎯 Top 3 raccomandazioni¶
1. Campagna "anti-sedentarietà" con promemoria intelligenti. Il dato più forte è 16h sedentarie/giorno. Posizionare Bellabeat come l'app che ti fa muovere: notifiche di movimento mirate nelle finestre di picco (12–14 e 17–19) e sfide a obiettivi realistici (es. +1.000 passi/giorno invece dei 10k astratti).
2. Spingere il tracking del sonno come funzione di punta. Solo 24/33 utenti tracciano il sonno, eppure il dato lega sedentarietà e riposo (r ≈ −0,60). Comunicare il binomio "muoviti di giorno, dormi meglio di notte", con report settimanali del sonno e suggerimenti personalizzati — leva ideale anche per la membership premium.
3. Marketing data-driven nei momenti giusti + gamification dell'engagement. Inviare campagne e contenuti negli orari/giorni di maggior uso (sera, weekend, sabato), e usare il feedback "passi → calorie" come meccanica di gamification (badge, streak) per trasformare l'alta fedeltà d'uso in retention e upsell verso la membership.
Prossimi passi e limiti¶
- Validare gli insight su un dataset più ampio, recente e con demografia femminile (il campione attuale è 33 utenti del 2016, senza genere/età).
- A/B test delle notifiche negli orari di picco prima del rollout.
- Integrare dati di hydration (Spring) e ciclo mestruale per insight specifici sul target Bellabeat.