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¶

  1. Quali sono i trend nell'uso degli smart device?
  2. Come si applicano questi trend ai clienti Bellabeat?
  3. 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.

In [1]:
# --- 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)

3. Process¶

Strumenti scelti: Python (pandas, matplotlib, seaborn) in Jupyter — riproducibile, con codice e pulizia documentati passo passo, ideale per un portfolio.

3.1 Ispezione iniziale¶

In [2]:
# --- 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¶

In [3]:
# 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 weight mantenuto solo come riferimento qualitativo (8 utenti) e colonna Fat ignorata.

4. Analyze¶

4.1 Statistiche descrittive¶

In [4]:
# .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¶

In [5]:
# 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¶

In [6]:
# --- 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

  1. 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.
  2. Più movimento → più calorie (r ≈ 0,59): feedback motivazionale concreto da comunicare.
  3. Sedentarietà ↔ sonno negativi (r ≈ −0,60): chi sta più fermo dorme meno → leva per legare attività e qualità del sonno.
  4. Il sonno è sotto-tracciato: solo 24/33 utenti loggano il sonno e il peso quasi nessuno → funzioni poco usate da valorizzare/automatizzare.
  5. Picchi d'uso 12–14 e 17–19, sabato il giorno più attivo → finestre ottimali per notifiche e campagne.
  6. Chi adotta resta fedele: la maggioranza indossa il device 21–31 giorni → l'engagement va alimentato, non conquistato da zero.

5. Share — Visualizzazioni¶

In [7]:
# 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
No description has been provided for this image
In [8]:
# 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()
No description has been provided for this image
In [9]:
# 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°
No description has been provided for this image
In [10]:
# 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()
No description has been provided for this image
In [11]:
# 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()
No description has been provided for this image
In [12]:
# 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()
No description has been provided for this image

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.