Un'app per la sostenibilità: dove conviene lanciarla?¶
Analisi esplorativa del Google Play Store¶
Esame: Data Manipulation and Visualization · Master in AI e Agenti AI per il Business (start2impact University) Autore: Fabio Mencio · Data: settembre 2026, rivisto a ottobre 2026 dopo la correzione Dataset: Google Play Store Apps — Kaggle, licenza CC BY 3.0 · i dati si fermano all'8 agosto 2018
La traccia¶
Ho deciso di creare la mia prima app a tema sostenibilità, ma prima voglio capire quale settore sia il più adatto. Scarico i dati di tutto il Google Play Store e li analizzo.
La domanda guida¶
Se dovessi lanciare oggi una nuova app legata alla sostenibilità, quali caratteristiche del mercato Google Play dovrei considerare per scegliere il segmento e il modello di business più promettenti?
Cosa cambia in questa revisione¶
La correzione ha segnalato cinque problemi e un dettaglio. Li ho affrontati tutti, e due hanno cambiato i risultati.
| Osservazione | Cosa ho fatto | Dove | |
|---|---|---|---|
| 1 | Il testo cita numeri che il notebook non mostra | il notebook è salvato con tutti gli output, ogni tabella citata è stampata, e la sezione 11 ricalcola ogni numero del testo e lo confronta con quello scritto | tutto, sez. 11 |
| 2 | La somma di posizioni perde l'ampiezza delle differenze e dà pesi uguali senza dirlo | misure grezze accanto alle posizioni, un punteggio che conserva le distanze, 36 combinazioni di pesi diversi | 8.1 |
| 3 | Del classificatore misuro la precisione, non la copertura | ho letto una per una 1.000 app estratte a caso fuori dai candidati | 7.1 |
| 4 | La difficoltà di ingresso sovrascrive la classifica senza dirlo | una sola tabella con «posizione secondo i dati» e «difficoltà stimata», e la regola scritta: prevale la seconda | 9 |
| 5 | La cautela su correlazione e causalità scritta in 4.1 viene contraddetta nelle conclusioni | una regola in 4.2, applicata a ipotesi, diagnosi e strategia; ogni scelta finale dice su che tipo di prova poggia | 4.2, 6, 9 |
| — | Nomi ripetuti: categoria e installazioni coincidono? | controllato: 15 nomi appartengono ad app diverse, che ora tengo separate | 1.3 |
Cosa è cambiato nei risultati.
- Le parole chiave trovano circa un'app sostenibile su tre. Il segmento non è «lo 0,35% del catalogo»: vale fra lo 0,35% (il minimo certo) e il 2,3%, con una stima centrale dell'1,0%.
- La classifica delle categorie dipende dal metodo. Sommando le posizioni vince Food & Drink; con un punteggio che conserva le distanze vince Shopping. La scelta di Food & Drink resta, ma ora è dichiarata per quello che è: un giudizio sulla difficoltà di ingresso, che decide fra due candidate che i dati non separano.
Prima di eseguire¶
- Su Google Colab trascino i 2 file CSV nel pannello dei file a sinistra: finiscono nella cartella
/content, da cui il notebook li legge. In locale li cerca nella cartelladata/. - Servono
pandas,numpy,matplotlib,seaborn, già presenti su Colab. plotlyserve per un grafico interattivo,ydata-profilingper un riepilogo automatico: su Colab la prima cella li installa, altrove sono facoltativi e il notebook va avanti anche senza.
Indice¶
| Sezione | Contenuto |
|---|---|
| 1 | I dati: importazione e pulizia |
| 2 | Com'è fatto il mercato |
| 3 | Valori anomali |
| 4 | Correlazioni, e la regola su correlazione e causalità |
| 5 | Le categorie: concorrenza e interesse |
| 6 | Cinque ipotesi, verificate |
| 7 | Il segmento sostenibilità: precisione e copertura |
| 8 | Quale categoria scegliere, e quanto è solida la classifica |
| 9 | La strategia |
| 10 | Limiti |
| 11 | Registro dei numeri citati |
1. I dati: importazione e pulizia¶
# su Colab installo le due librerie facoltative; in locale non installo nulla
# (ydata-profiling farebbe retrocedere pandas alla versione 2)
import sys
if "google.colab" in sys.modules:
!pip install -q plotly ydata-profiling
import os
import warnings
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
warnings.filterwarnings("ignore")
%matplotlib inline
# librerie facoltative
try:
import plotly.express as px
PLOTLY = True
except ImportError:
PLOTLY = False
try:
from ydata_profiling import ProfileReport
YDATA = True
except ImportError:
YDATA = False
pd.set_option("display.max_columns", 30)
pd.set_option("display.width", 170)
# stile dei grafici, impostato una volta per tutte
sns.set_theme(style="whitegrid")
plt.rcParams.update({"figure.figsize": (9, 4), "figure.dpi": 110,
"axes.titlesize": 12, "axes.titleweight": "bold",
"axes.titlelocation": "left", "grid.linewidth": 0.5})
BLU, ARANCIONE, VERDE, GRIGIO = "#2a78d6", "#eb6834", "#1baf7a", "#9a9a96"
print("pandas", pd.__version__, "| numpy", np.__version__, "| seaborn", sns.__version__)
print("plotly:", "presente" if PLOTLY else "assente (salterò un grafico)")
print("ydata-profiling:", "presente" if YDATA else "assente (salterò un riepilogo)")
pandas 3.0.6 | numpy 2.5.3 | seaborn 0.13.2 plotly: presente ydata-profiling: assente (salterò un riepilogo)
# --- lettura dei due file CSV -------------------------------------------
# I file sono caricati a mano nel pannello dei file di Colab: stanno in /content
PERCORSO_APP = "/content/googleplaystore.csv"
PERCORSO_REC = "/content/googleplaystore_user_reviews.csv"
# se non sono in /content sto girando in locale: li cerco in data/
if not os.path.exists(PERCORSO_APP):
PERCORSO_APP = "data/googleplaystore.csv"
PERCORSO_REC = "data/googleplaystore_user_reviews.csv"
grezzo = pd.read_csv(PERCORSO_APP)
recensioni_grezze = pd.read_csv(PERCORSO_REC)
print("App :", grezzo.shape[0], "righe x", grezzo.shape[1], "colonne")
print("Recensioni :", recensioni_grezze.shape[0], "righe x", recensioni_grezze.shape[1], "colonne")
grezzo.head(3)
App : 10841 righe x 13 colonne Recensioni : 64295 righe x 5 colonne
| App | Category | Rating | Reviews | Size | Installs | Type | Price | Content Rating | Genres | Last Updated | Current Ver | Android Ver | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Photo Editor & Candy Camera & Grid & ScrapBook | ART_AND_DESIGN | 4.1 | 159 | 19M | 10,000+ | Free | 0 | Everyone | Art & Design | January 7, 2018 | 1.0.0 | 4.0.3 and up |
| 1 | Coloring book moana | ART_AND_DESIGN | 3.9 | 967 | 14M | 500,000+ | Free | 0 | Everyone | Art & Design;Pretend Play | January 15, 2018 | 2.0.0 | 4.0.3 and up |
| 2 | U Launcher Lite – FREE Live Cool Themes, Hide ... | ART_AND_DESIGN | 4.7 | 87510 | 8.7M | 5,000,000+ | Free | 0 | Everyone | Art & Design | August 1, 2018 | 1.2.4 | 4.0.3 and up |
# ---utilizzo la funzione info() per vedere che tipo di dati ha letto pandas ----------------------------------
grezzo.info()
<class 'pandas.DataFrame'> RangeIndex: 10841 entries, 0 to 10840 Data columns (total 13 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 App 10841 non-null str 1 Category 10841 non-null str 2 Rating 9367 non-null float64 3 Reviews 10841 non-null str 4 Size 10841 non-null str 5 Installs 10841 non-null str 6 Type 10840 non-null str 7 Price 10841 non-null str 8 Content Rating 10840 non-null str 9 Genres 10841 non-null str 10 Last Updated 10841 non-null str 11 Current Ver 10833 non-null str 12 Android Ver 10838 non-null str dtypes: float64(1), str(12) memory usage: 1.1 MB
Cosa ho trovato.
- Quasi tutte le colonne sono testo. L'unica numerica è
Rating.Reviews,InstallsePricecontengono numeri ma sono scritte come testo, per via del+, delle virgole e del$. Vanno convertite. Ratingha valori mancanti: 9.367 validi su 10.841.Installsnon è un numero, è una fascia:10,000+vuol dire «tra 10.000 e 49.999».
1.1 Utilizzo per una ulteriore analisi esplorativa automatica dei dati YData Profiling da https://docs.profiling.ydata.ai/¶
ydata-profiling genera da sola un report completo e una lista di anomalie. È il modo più
rapido di farsi un'idea di un dataset nuovo. Il report viene salvato in un file HTML a parte,
perché è troppo grande per stare nel notebook.
# --- riepilogo automatico con ydata-profiling ---------------------------
if YDATA:
import contextlib, io
profilo = ProfileReport(grezzo, title="Google Play Store", progress_bar=False,
duplicates={"head": 5},
correlations={"auto": {"calculate": True}})
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
profilo.to_file("report_ydata.html")
print("Report salvato in report_ydata.html\n")
print("Anomalie segnalate dallo strumento:")
for avviso in profilo.description_set.alerts:
print(" -", avviso)
else:
print("ydata-profiling non installato: salto il report e vado avanti.")
# lo stesso conteggio dei duplicati, fatto con pandas: si vede sempre
righe_ripetute = grezzo[grezzo.duplicated(keep=False)]
print("\nRighe diverse che compaiono più di una volta:", len(righe_ripetute.drop_duplicates()))
print("Copie in più da eliminare (pandas) :", grezzo.duplicated().sum())
ydata-profiling non installato: salto il report e vado avanti. Righe diverse che compaiono più di una volta: 410 Copie in più da eliminare (pandas) : 483
Cosa ho trovato. Lo strumento segnala righe duplicate, i valori mancanti di Rating e
il fatto che Type e Content Rating siano molto sbilanciate (quasi tutte le app sono
gratuite e per tutti).
Il report conta 410 righe duplicate,
ma pandas ne conta 483. La differenza sta nel modo di contare: YData Profiling conta quante righe diverse
si ripetono (410), mentre pandas conta quante copie in più ci sono (483). Uno strumento automatico come YData Profiling è utile in quanto ci dice
dove guardare possibili anomalie e lascia a noi l'approfondimento e le relative conclusioni.
1.2 Verifico i voti delle app¶
Il voto di un'app va da 1 a 5. Controllo se vale per tutte.
# --- controllo: il voto deve stare tra 1 e 5 ----------------------------
fuori_scala = grezzo[(grezzo["Rating"] > 5) | (grezzo["Rating"] < 1)] # verifico che il voto non sia maggiore di 5 o minore di 1
print("Righe con voto fuori dall'intervallo 1-5:", len(fuori_scala))
print("-------------------------------------------------------")
print(fuori_scala.T)
Righe con voto fuori dall'intervallo 1-5: 1
-------------------------------------------------------
10472
App Life Made WI-Fi Touchscreen Photo Frame
Category 1.9
Rating 19.0
Reviews 3.0M
Size 1,000+
Installs Free
Type 0
Price Everyone
Content Rating NaN
Genres February 11, 2018
Last Updated 1.0.19
Current Ver 4.0 and up
Android Ver NaN
Cosa ho trovato. La riga 10472 è sfalsata di una colonna: 1.9 è
finito in Category, 19 in Rating, 3.0M in Reviews. Probabilmente il nome dell'app
conteneva una virgola non protetta e tutti i campi sono slittati.
Non è un voto anomalo da analizzare è un errore, e lo elimino. Il punto è che 19 è un valore impossibile nel range di votazione, non estremo di cui tener conto. Un controllo in una riga di codice lo trova, mentre nelle
statistiche descrittive passerebbe per un normale outlier, falsando i valori.
1.3 Pulizia dataset¶
| Problema | Decisione | Perché |
|---|---|---|
| riga 10472 sfalsata | elimino | irrecuperabile senza inventare la categoria |
| 483 righe identiche | elimino | nessuna informazione persa |
| 698 righe con un nome già visto (523 nomi) | stesso nome e stessa fascia di installazioni = stessa app: tengo la riga con più recensioni; fascia diversa = app diverse, le tengo tutte | il controllo qui sotto |
Installs con + e virgole |
converto al minimo della fascia | non inventa nulla |
Size = «Varies with device» |
lascio vuoto | non è un valore, è un'assenza |
Rating mancante (13,6% delle righe grezze) |
non riempio | vedi il controllo dopo la pulizia |
Aggiungo anche due colonne che nel dataset non ci sono e che mi serviranno:
giorni_da_aggiornamento e recensioni_per_1000 (quante recensioni ogni mille
installazioni: misura il coinvolgimento ed è confrontabile tra app grandi e piccole).
Prima di togliere i doppioni: stesso nome vuol dire stessa app?¶
Nella prima versione tenevo, per ogni nome, la riga con più recensioni. Funziona solo se le righe con lo stesso nome sono letture della stessa app fatte in momenti diversi: in quel caso categoria e installazioni devono coincidere. Lo controllo prima di decidere.
# --- Controllo: le righe con lo stesso nome sono davvero la stessa app? ---
# 1) tolgo la riga sfalsata e le righe identiche, come farà la pulizia
base = grezzo[~((grezzo["Rating"] > 5) | (grezzo["Rating"] < 1))].drop_duplicates()
ripetute = base[base.duplicated("App", keep=False)]
# 2) per ogni nome ripetuto conto quante categorie e quante fasce di installazioni diverse ha
per_nome = ripetute.groupby("App").agg(righe=("App", "size"),
categorie=("Category", "nunique"),
fasce=("Installs", "nunique"))
esito = pd.Series("stessa categoria, stesse installazioni", index=per_nome.index)
esito[(per_nome["categorie"] > 1) & (per_nome["fasce"] == 1)] = "categoria diversa, stesse installazioni"
esito[per_nome["fasce"] > 1] = "installazioni diverse"
print(f"Righe con un nome già visto: {len(ripetute) - ripetute['App'].nunique()} "
f"(su {ripetute['App'].nunique()} nomi)\n")
display(esito.value_counts().rename("nomi").to_frame())
# 3) i casi con installazioni diverse, per vederli in faccia
diverse = ripetute[ripetute["App"].isin(esito[esito == "installazioni diverse"].index)]
display(diverse.sort_values(["App", "Reviews"])[
["App", "Category", "Installs", "Reviews", "Size", "Last Updated", "Current Ver"]].head(16))
Righe con un nome già visto: 698 (su 523 nomi)
| nomi | |
|---|---|
| stessa categoria, stesse installazioni | 432 |
| categoria diversa, stesse installazioni | 76 |
| installazioni diverse | 15 |
| App | Category | Installs | Reviews | Size | Last Updated | Current Ver | |
|---|---|---|---|---|---|---|---|
| 2513 | Blood Pressure | MEDICAL | 1,000+ | 10 | 2.4M | March 14, 2015 | 1.0 |
| 2310 | Blood Pressure | MEDICAL | 5,000,000+ | 33033 | 7.4M | July 24, 2018 | 3.27.3 |
| 1711 | Bubble Shooter | GAME | 10,000,000+ | 148895 | 46M | July 17, 2018 | 1.20.1 |
| 1657 | Bubble Shooter | GAME | 10,000,000+ | 148897 | 46M | July 17, 2018 | 1.20.1 |
| 1875 | Bubble Shooter | GAME | 10,000,000+ | 148945 | 46M | July 17, 2018 | 1.20.1 |
| 1972 | Bubble Shooter | GAME | 10,000,000+ | 148990 | 46M | July 17, 2018 | 1.20.1 |
| 3894 | Bubble Shooter | GAME | 5,000,000+ | 43576 | 50M | September 13, 2017 | 4.4 |
| 2041 | Bubble Shooter | FAMILY | 5,000,000+ | 59843 | 20M | January 9, 2018 | 2.3.3122 |
| 3242 | Calculator | TOOLS | 100,000,000+ | 40770 | Varies with device | November 21, 2017 | Varies with device |
| 477 | Calculator | DATING | 1,000+ | 57 | 6.2M | October 25, 2017 | 1.1.6 |
| 436 | Call Blocker | COMMUNICATION | 1,000,000+ | 17529 | 10M | July 26, 2018 | 5.86 |
| 206 | Call Blocker | BUSINESS | 5,000,000+ | 188841 | 3.2M | June 21, 2018 | 1.1.13 |
| 5044 | Cardiac diagnosis (heart rate, arrhythmia) | MEDICAL | 100,000+ | 4559 | 6.4M | July 27, 2018 | 117 |
| 2269 | Cardiac diagnosis (heart rate, arrhythmia) | MEDICAL | 100+ | 8 | 6.5M | July 25, 2018 | 7 |
| 9641 | Chess Free | GAME | 50,000,000+ | 1375988 | 15M | June 7, 2018 | 2.72 |
| 2156 | Chess Free | FAMILY | 5,000,000+ | 23772 | 17M | August 2, 2017 | 1.15.3028.0 |
Cosa ho trovato.
- 432 nomi su 523 hanno sempre la stessa categoria e le stesse installazioni: sono la stessa app letta più volte, e tenere la lettura con più recensioni (la più recente) è corretto.
- 76 nomi cambiano categoria ma non installazioni: è la stessa app esposta su due scaffali (quasi sempre GAME e FAMILY, o EDUCATION e FAMILY), con recensioni quasi identiche. Ne tengo una sola riga, quella con più recensioni, e la sua categoria.
- 15 nomi hanno fasce di installazioni diverse, e lì il nome inganna: «Calculator» da 100 milioni di installazioni in TOOLS e «Calculator» da 1.000 in DATING sono due app diverse, con dimensione, versione e data diverse. Con la regola vecchia ne sopravviveva una sola.
Decisione: considero la stessa app le righe con stesso nome e stessa fascia di installazioni. Le app analizzate passano da 9.659 a 9.674. La regola non è perfetta in un caso su 15 («QR Scanner & Barcode Scanner 2018», stessa versione e stessa data, è la stessa app che cambia fascia fra due letture e resta contata due volte), ma sbaglia molto meno della precedente, che fondeva 13 coppie di app diverse.
def pulisci(df):
"Pulisce il dataset e restituisce anche il registro delle righe eliminate."
registro = [("dataset grezzo", len(df))]
d = df[~((df["Rating"] > 5) | (df["Rating"] < 1))].copy() # tolgo la riga sfalsata e la registro comunque
registro.append(("meno la riga sfalsata", len(d)))
d = d.drop_duplicates() # via le righe identiche e le registro comunque
registro.append(("meno le righe duplicate", len(d)))
# stessa app = stesso nome E stessa fascia di installazioni (vedi il controllo sopra):
# della stessa app tengo la lettura con più recensioni (la più recente)
d["Reviews"] = pd.to_numeric(d["Reviews"], errors="coerce")
d = (d.sort_values("Reviews", ascending=False, kind="stable")
.drop_duplicates(subset=["App", "Installs"], keep="first").sort_index())
registro.append(("meno le letture ripetute della stessa app", len(d)))
# conversione da stringa +10000 a valore numero intero in formato int64
d["installazioni"] = (d["Installs"].str.replace(",", "", regex=False)
.str.replace("+", "", regex=False).astype("int64"))
# conversione da stringa $4.99 tolgo $ e converto in valore in numero in virgola mobile formato float
d["prezzo"] = d["Price"].str.replace("$", "", regex=False).astype(float)
# dal prezzo convertito trovo se l'app è gratis o a pagamento attraverso la condizione >0
d["tipo"] = np.where(d["prezzo"] > 0, "A pagamento", "Gratuita")
#Ripulisco i valori perchè sono formati e unità di misura diverse e porto tutto in megabyte.
#Alcuni valore sono tipo testo Varies with device e li sostituisco con NaN
dimensione = d["Size"].replace("Varies with device", np.nan)
numero = pd.to_numeric(dimensione.str.rstrip("MkG"), errors="coerce")
d["dimensione_mb"] = np.select([dimensione.str.endswith("k", na=False),
dimensione.str.endswith("G", na=False)],
[numero / 1024, numero * 1024], numero)
# converto la stringa data aggiornamento in un vero oggetto data utilizzabile
d["data_aggiornamento"] = pd.to_datetime(d["Last Updated"], format="%B %d, %Y")
# calcolo per ogni app quanti giorni sono passati dall'ultimo aggiornamento rispetto all'app più recente
d["giorni_da_aggiornamento"] = (d["data_aggiornamento"].max()
- d["data_aggiornamento"]).dt.days
# Costruisco una misura di "quanto le persone si esprimono", per confrontare le recensioni di app
#molto scaricate e quindi con molte recensioni , con app meno scaricate. Moltiplico per mille per rendere più
#leggibile il risultato a colpo d'occhio.
d["recensioni_per_1000"] = d["Reviews"] / d["installazioni"].replace(0, np.nan) * 1000
registro.append(("dataset pulito", len(d)))
return d, pd.DataFrame(registro, columns=["passaggio", "righe"])
# la funzione restituisce due valori (una tupla),dividendoli in due variabili separate:
# app per il dataframe pulito e pronto all'uso, con tutte le colonne nuove appena viste.
# registro per la tabella che riassume quante righe c'erano a ogni passaggio di pulizia,
# utile per documentare/verificare il processo.
app, registro = pulisci(grezzo)
# rinomino in italiano e tengo solo le colonne che uso
app = app.rename(columns={"App": "nome", "Category": "categoria", "Rating": "voto",
"Reviews": "recensioni", "Content Rating": "eta_minima",
"Genres": "generi"})
app = app[["nome", "categoria", "voto", "recensioni", "installazioni", "tipo", "prezzo",
"eta_minima", "generi", "dimensione_mb", "data_aggiornamento",
"giorni_da_aggiornamento", "recensioni_per_1000"]]
display(registro)
print(f"App analizzate: {len(app):,} ({len(app) / len(grezzo):.1%} delle righe di partenza)")
print(f"Voto disponibile per {app['voto'].notna().sum():,} app ({app['voto'].notna().mean():.1%})")
print("Controlli:",
"voti tra 1 e 5 OK" if app["voto"].dropna().between(1, 5).all() else "VOTI FUORI SCALA",
"| coppie nome + installazioni uniche OK"
if not app.duplicated(["nome", "installazioni"]).any() else "| DOPPIONI")
| passaggio | righe | |
|---|---|---|
| 0 | dataset grezzo | 10841 |
| 1 | meno la riga sfalsata | 10840 |
| 2 | meno le righe duplicate | 10357 |
| 3 | meno le letture ripetute della stessa app | 9674 |
| 4 | dataset pulito | 9674 |
App analizzate: 9,674 (89.2% delle righe di partenza) Voto disponibile per 8,210 app (84.9%) Controlli: voti tra 1 e 5 OK | coppie nome + installazioni uniche OK
# --- in questa parte di codice provo a verificare se le app senza voti sono un caso o è sistematico------------------
# Divido le app in due gruppi, con voto e senza voto,
# e per ciascun gruppo calcolo le relative statistiche
confronto = app.groupby(app["voto"].notna()).agg(
numero_app=("nome", "count"),
recensioni_mediane=("recensioni", "median"),
installazioni_mediane=("installazioni", "median"),
)
# Rinomino le righe per renderle leggibili
confronto.index = ["senza voto", "con voto"]
display(confronto)
#confronto che restituisce True/False per ogni riga del sottoinsieme "senza voto": True se ha meno di 10 recensioni
# poi calcolo la media di una serie di True/False
senza_voto = app[app["voto"].isna()]
print(f"App senza voto con meno di 10 recensioni: {(senza_voto['recensioni'] < 10).mean():.1%}")
# salvo il dataset pulito in un nuovo file CSV
app.to_csv("playstore_pulito.csv", index=False, encoding="utf-8-sig")
print("\nDataset pulito salvato in playstore_pulito.csv")
| numero_app | recensioni_mediane | installazioni_mediane | |
|---|---|---|---|
| senza voto | 1464 | 1.0 | 100.0 |
| con voto | 8210 | 3020.0 | 100000.0 |
App senza voto con meno di 10 recensioni: 83.5% Dataset pulito salvato in playstore_pulito.csv
Risultato i due gruppi non c'entrano nulla l'uno con l'altro: un'app senza voto ha 1 recensione mediana contro 3.020, e 100 installazioni contro 100.000. L'83,5% delle app senza voto ha meno di 10 recensioni: il voto manca perché quasi nessuno le ha recensite.
Mettere la media nella parti mancanti creerebbe un picco artificiale.
2. Com'è fatto il mercato delle app?¶
La domanda non è "quanto vale la media", ma quale misura posso usare: con i dati di mercato la media è spesso fuorviante e c'è un modo semplice per il confronto cioè con la mediana.
# --- statistiche descrittive, e il rapporto media/mediana --------------
NUMERICHE = ["voto", "recensioni", "installazioni", "dimensione_mb",
"giorni_da_aggiornamento", "recensioni_per_1000"]
display(app[NUMERICHE].describe().round(2))
# il prezzo lo lascio fuori: la sua mediana è 0 (il 92% delle app è gratuito)
riepilogo = pd.DataFrame({"media": app[NUMERICHE].mean(),
"mediana": app[NUMERICHE].median()})
riepilogo["media / mediana"] = riepilogo["media"] / riepilogo["mediana"]
riepilogo.sort_values("media / mediana", ascending=False).round(2)
| voto | recensioni | installazioni | dimensione_mb | giorni_da_aggiornamento | recensioni_per_1000 | |
|---|---|---|---|---|---|---|
| count | 8210.00 | 9674.00 | 9.674000e+03 | 8446.00 | 9674.00 | 9659.00 |
| mean | 4.17 | 216525.14 | 7.789048e+06 | 20.38 | 281.12 | 38.10 |
| std | 0.54 | 1830025.70 | 5.372864e+07 | 21.82 | 406.73 | 99.99 |
| min | 1.00 | 0.00 | 0.000000e+00 | 0.01 | 0.00 | 0.00 |
| 25% | 4.00 | 25.00 | 1.000000e+03 | 4.50 | 22.00 | 7.38 |
| 50% | 4.30 | 972.00 | 1.000000e+05 | 12.00 | 96.00 | 17.00 |
| 75% | 4.50 | 29457.75 | 1.000000e+06 | 28.00 | 366.75 | 38.16 |
| max | 5.00 | 78158306.00 | 1.000000e+09 | 100.00 | 3001.00 | 4000.00 |
| media | mediana | media / mediana | |
|---|---|---|---|
| recensioni | 216525.14 | 972.0 | 222.76 |
| installazioni | 7789047.81 | 100000.0 | 77.89 |
| giorni_da_aggiornamento | 281.12 | 96.0 | 2.93 |
| recensioni_per_1000 | 38.10 | 17.0 | 2.24 |
| dimensione_mb | 20.38 | 12.0 | 1.70 |
| voto | 4.17 | 4.3 | 0.97 |
Risultato La colonna media / mediana è la spia dell'ipotesi iniziale. Per il voto vale 0,97: media e mediana coincidono, la media si può usare. Per le recensioni vale
223, per le installazioni 78: la media è duecento volte la mediana, quindi non
descrive nessuna app reale, questo dipende dalle app Facebook e WhatsApp che trascinano in alto la media per ovvie ragioni.
Decisione valida per tutto il notebook: per recensioni e installazioni utilizzo mediana e utilizzo per le rappresentazioni una scala logaritmica per comprimere la dinamica dei grafici. Per il voto vanno bene gli strumenti consueti.
# --- Distribuzione delle tre variabili più rilevanti: voto, recensioni ed installazioni ---
# 1) preparo una figura con 3 grafici affiancati
fig, axes = plt.subplots(1, 3, figsize=(13.5, 3.4))
# 2) Voto: istogramma, con la mediana evidenziata da una linea ARANCIONE
voti = app["voto"].dropna()
axes[0].hist(voti, bins=np.arange(1, 5.11, 0.1), color=BLU, edgecolor="white", linewidth=0.5)
axes[0].axvline(voti.median(), color=ARANCIONE, linewidth=2)
axes[0].set_xlabel("voto")
axes[0].set_ylabel("numero di app")
axes[0].set_title("Il voto è compresso verso l'alto")
axes[0].text(voti.median() - 0.1, axes[0].get_ylim()[1] * 0.9, f"mediana {voti.median()}",
color=ARANCIONE, ha="right", fontweight="bold", fontsize=9)
# 3) RECENSIONI : istogramma in scala logaritmica
axes[1].hist(app.loc[app["recensioni"] > 0, "recensioni"], bins=np.logspace(0, 8, 40),
color=BLU, edgecolor="white", linewidth=0.5)
axes[1].set_xscale("log")
axes[1].set_xlabel("recensioni (scala logaritmica)")
axes[1].set_ylabel("numero di app")
axes[1].set_title("Le recensioni in scala log")
# 4) INSTALLAZIONI — barre orizzontali, una per ciascuna delle 20 fasce
fasce = app["installazioni"].value_counts().sort_index()
axes[2].barh(range(len(fasce)), fasce.values, color=BLU)
axes[2].set_yticks(range(len(fasce)), [f"{v:,}" for v in fasce.index], fontsize=7)
axes[2].set_xlabel("numero di app")
axes[2].set_title("Le installazioni a fasce")
plt.tight_layout()
plt.show()
# 5) riepilogo numerico delle tre variabili più rilevanti date come argomento al print
print(f"Voto mediana {voti.median()} | >= 4,0: {(voti >= 4).mean():.1%} | "
f">= 4,5: {(voti >= 4.5).mean():.1%} | < 3,0: {(voti < 3).mean():.1%}")
print(f"Recensioni mediana {app['recensioni'].median():,.0f} | "
f"massimo {app['recensioni'].max():,.0f}")
print(f"Installazioni mediana {app['installazioni'].median():,.0f} | "
f"fasce distinte {app['installazioni'].nunique()}")
Voto mediana 4.3 | >= 4,0: 76.7% | >= 4,5: 31.1% | < 3,0: 3.4% Recensioni mediana 972 | massimo 78,158,306 Installazioni mediana 100,000 | fasce distinte 20
Risultato. Il voto è compresso verso l'alto: il 76,7% delle app ha un voto sopra 4,0, mentre solo il 3,4% sta sotto 3,0. Questo cambia il modo di leggere un voto: 4,2 non è un buon risultato, è sotto la mediana del mercato (4,3). Per considerarsi davvero buoni bisogna arrivare al quartile superiore, che parte da 4,5.
Le installazioni, invece, non sono un numero continuo ma venti fasce fisse (es. 10.000+, 50.000+, 100.000+...). Da qui in poi, quando parlerò di "installazioni mediane" di una categoria, farò sempre riferimento a uno di questi venti valori e una differenza inferiore a 5 volte tra due categorie non è significativa, perché può dipendere semplicemente dal salto tra una fascia e l'altra.
# --- Concentrazione del mercato e modello di business (gratis o a pagamento) ---
# 1) CONCENTRAZIONE per l'1%, 10% e 50% delle app più scaricate,
# calcolo quale quota delle installazioni totali si prendono
totale = app["installazioni"].sum()
concentrazione = pd.DataFrame([
{"gruppo": f"top {q:.0%}",
"numero di app": int(len(app) * q),
"quota delle installazioni": app["installazioni"].nlargest(int(len(app) * q)).sum() / totale}
for q in (0.01, 0.10, 0.50)
])
# 2) preparo una figura con 2 grafici affiancati
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11.5, 3.2))
# 3) GRAFICO 1: barre orizzontali della concentrazione, col gruppo "top 1%" in evidenza
colori = [ARANCIONE if g == "top 1%" else BLU for g in concentrazione["gruppo"]]
ax1.barh(concentrazione["gruppo"], concentrazione["quota delle installazioni"] * 100,
color=colori)
ax1.invert_yaxis()
ax1.set_xlabel("% delle installazioni totali")
ax1.set_title("L'1% delle app si prende metà del mercato")
ax1.set_xlim(0, 118)
for i, v in enumerate(concentrazione["quota delle installazioni"]):
ax1.text(v * 100 + 1.5, i, f"{v:.1%}", va="center", fontweight="bold", fontsize=9)
# 4) GRAFICO 2: quante app sono gratuite e quante a pagamento
conteggio_tipo = app["tipo"].value_counts()
ax2.bar(conteggio_tipo.index, conteggio_tipo.values, color=[BLU, ARANCIONE], width=0.5)
ax2.set_ylabel("numero di app")
ax2.set_title("Il 92,2% del catalogo è gratuito")
ax2.set_ylim(0, conteggio_tipo.max() * 1.2)
for i, v in enumerate(conteggio_tipo.values):
ax2.text(i, v + 180, f"{v:,}\n{v / len(app):.1%}", ha="center", fontsize=9)
plt.tight_layout()
plt.show()
# 5) riepilogo testuale: prezzo delle app a pagamento
prezzi = app.loc[app["tipo"] == "A pagamento", "prezzo"]
print(f"App a pagamento: {len(prezzi):,} ({len(prezzi) / len(app):.2%}) | "
f"prezzo mediano {prezzi.median():.2f} $ | il {(prezzi <= 10).mean():.1%} costa <= 10 $")
# 6) riepilogo testuale: altre caratteristiche del catalogo (dimensione, età, aggiornamenti)
print(f"Dimensione mediana {app['dimensione_mb'].median():.0f} MB | "
f"età 'Everyone' {(app['eta_minima'] == 'Everyone').mean():.1%} | "
f"giorni dall'ultimo aggiornamento (mediana) {app['giorni_da_aggiornamento'].median():.0f}")
App a pagamento: 756 (7.81%) | prezzo mediano 2.99 $ | il 90.2% costa <= 10 $ Dimensione mediana 12 MB | età 'Everyone' 81.8% | giorni dall'ultimo aggiornamento (mediana) 96
Risultato Il mercato è dominato da pochi: l'1% delle app detiene il 49,4% delle installazioni, il 10% ne detiene l'88,1%. Lanciare un'app significa, per default statistico, finire nella metà che si divide le briciole e la domanda utile diventa: esistono categorie dove questa regola è meno brutale? (sezione 5).
Il mercato ha anche già scelto il modello: 92,2% gratuito, e tra le app a pagamento il prezzo tipico è 2,99 $. L'app mediana occupa 12 MB, è per tutti, ed è stata aggiornata 96 giorni fa.
3. I valori anomali¶
Il metodo classico è la regola dell'intervallo interquartile (IQR): è anomalo ciò che sta
oltre Q1 − 1,5 × IQR o Q3 + 1,5 × IQR. Lo applico a tutte le variabili per un risultato più intuitivo
# --- Applicazione della regola IQR ---
def conta_outlier(serie):
serie = serie.dropna()
q1, q3 = serie.quantile([0.25, 0.75])
iqr = q3 - q1
anomali = ((serie < q1 - 1.5 * iqr) | (serie > q3 + 1.5 * iqr)).sum()
return pd.Series({"n outlier": anomali, "su n valori": len(serie),
"% outlier": 100 * anomali / len(serie)})
da_controllare = {
"recensioni": app["recensioni"],
"recensioni (in log)": np.log10(app["recensioni"].clip(lower=1)),
"installazioni": app["installazioni"],
"installazioni (in log)": np.log10(app["installazioni"].clip(lower=1)),
"voto": app["voto"],
}
pd.DataFrame({n: conta_outlier(s) for n, s in da_controllare.items()}).T.round(2)
| n outlier | su n valori | % outlier | |
|---|---|---|---|
| recensioni | 1661.0 | 9674.0 | 17.17 |
| recensioni (in log) | 0.0 | 9674.0 | 0.00 |
| installazioni | 1985.0 | 9674.0 | 20.52 |
| installazioni (in log) | 0.0 | 9674.0 | 0.00 |
| voto | 492.0 | 8210.0 | 5.99 |
Risultato
Applico la regola IQR alla colonna delle recensioni, e il risultato sono 1.661 app segnalate come anomale: il 17,2% del totale. Un numero così alto può rappresentare un campanello d'allarme: se un criterio marca come "anomalo" un valore su sei, probabilmente il problema non sono i dati, ma il criterio applicato a una distribuzione per cui non era pensato.
La controprova arriva subito dopo: applico lo stesso identico criterio alla stessa colonna, ma calcolata in scala logaritmica. Il risultato cambia radicalmente: zero outlier. I dati sono rimasti gli stessi, è cambiata solo la scala su cui li misuro a conferma che il problema era nella scelta della scala, non nei dati.
Sul voto, invece, la regola IQR funziona correttamente anche in scala normale: segnala 492 app (6,0%), tutte con voto basso. Qui le anomalie sono genuine, perché il voto è una variabile che si comporta bene (in gergo statistico: è distribuita in modo più regolare, senza la forte asimmetria delle recensioni).
In conclusione: prima di cercare gli outlier bisogna scegliere la scala giusta per la variabile che stai analizzando. Lo stesso criterio può dare risultati completamente diversi e uno dei due sarà quasi sempre fuorviante.
# --- Due casi di valori anomali, spiegati uno per uno -------------------
# 1) CASO 1: app con più recensioni che installazioni (fisicamente impossibile)
impossibili = app[app["recensioni_per_1000"] > 1000]
print(f"App con più recensioni che installazioni: {len(impossibili)} (impossibile!)")
print("È un effetto della conversione: 'Installs' diceva '1+' e io l'ho reso 1.\n")
# 2) CASO 2: app che costano 100 $ o più quanto quelle 20 app distorcono la media dei prezzi
care = app[app["prezzo"] >= 100]
print(f"App che costano 100 $ o più: {len(care)}, "
f"di cui {care['nome'].str.contains('rich', case=False).sum()} con 'rich' nel nome")
display(care.nlargest(5, "prezzo")[["nome", "categoria", "prezzo", "installazioni", "voto"]])
a_pagamento = app[app["tipo"] == "A pagamento"]
print(f"Media dei prezzi con quelle 20 app: {a_pagamento['prezzo'].mean():.2f} $")
print(f"Media dei prezzi senza quelle 20: {a_pagamento.loc[a_pagamento['prezzo'] < 100, 'prezzo'].mean():.2f} $")
print(f"Mediana, in entrambi i casi: {a_pagamento['prezzo'].median():.2f} $")
App con più recensioni che installazioni: 11 (impossibile!) È un effetto della conversione: 'Installs' diceva '1+' e io l'ho reso 1. App che costano 100 $ o più: 20, di cui 15 con 'rich' nel nome
| nome | categoria | prezzo | installazioni | voto | |
|---|---|---|---|---|---|
| 4367 | I'm Rich - Trump Edition | LIFESTYLE | 400.00 | 10000 | 3.6 |
| 4197 | most expensive app (H) | FAMILY | 399.99 | 100 | 4.3 |
| 4362 | 💎 I'm rich | LIFESTYLE | 399.99 | 10000 | 3.8 |
| 5351 | I am rich | LIFESTYLE | 399.99 | 100000 | 3.8 |
| 5354 | I am Rich Plus | FAMILY | 399.99 | 10000 | 4.0 |
Media dei prezzi con quelle 20 app: 14.05 $ Media dei prezzi senza quelle 20: 4.74 $ Mediana, in entrambi i casi: 2.99 $
Risultato Le app sopra i 100 dollari sono i cloni di I am rich: app che non fanno nulla e il cui unico scopo è essere costose. Venti app su 756 spostano la media dei prezzi da 4,74 dollari a 14,05 dollari, mentre la mediana non si muove di un centesimo. È il motivo pratico per cui sui dati di mercato la mediana è più affidabile della media.
Non le elimino dal dataset, ma le escludo dalle analisi sul prezzo e lo dichiaro ogni volta. Gli altri outlier (Facebook, WhatsApp), sono il mercato, non rumore: vanno tenuti.
4. Le correlazioni¶
La correlazione dice se due variabili si muovono insieme. È un numero che va da −1 a +1:
- vicino a +1: quando una cresce, cresce anche l'altra;
- vicino a −1: quando una cresce, l'altra cala;
- vicino a 0: le due variabili non sono legate.
La calcolo in due modi diversi, perché ognuno vede cose diverse:
- il primo controlla se le due variabili crescono in proporzione, cioè se i punti del grafico stanno su una retta;
- il secondo controlla solo se crescono nella stessa direzione, anche quando la relazione disegna una curva e non una retta.
pandas li calcola entrambi con la stessa funzione .corr().
# --- Due modi di misurare quanto le variabili si muovono insieme -------
COLONNE = ["voto", "recensioni", "installazioni", "prezzo", "dimensione_mb",
"giorni_da_aggiornamento"]
ETICHETTE = ["voto", "recensioni", "installazioni", "prezzo", "dimensione", "giorni da agg."]
# i due metodi di calcolo: il primo misura relazioni "su una retta",
# il secondo misura relazioni "per posizione in classifica" (anche se curve)
METODI = ["pearson", "spearman"]
TITOLI = ["Relazioni lineari: le due variabili crescono in proporzione?",
"Relazioni per tendenza: le due variabili crescono insieme,\nanche se non in proporzione?"]
# 1) preparo una figura con 2 grafici affiancati
fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.2))
# 2) per ciascuno dei due metodi, calcolo la matrice e la disegno come heatmap
for ax, metodo, titolo in zip(axes, METODI, TITOLI):
matrice = app[COLONNE].corr(method=metodo)
sns.heatmap(matrice, mask=np.triu(np.ones_like(matrice, dtype=bool), k=1),
annot=True, fmt="+.2f", cmap="coolwarm_r", center=0, vmin=-1, vmax=1,
square=True, linewidths=2, linecolor="white", cbar=False, ax=ax,
xticklabels=ETICHETTE, yticklabels=ETICHETTE, annot_kws={"size": 9})
ax.set_title(titolo)
plt.setp(ax.get_xticklabels(), rotation=35, ha="right")
# 3) didascalia comune, per leggere i colori
fig.suptitle("Blu = crescono insieme · rosso = vanno in direzioni opposte",
fontsize=9, x=0.01, ha="left", y=1.02)
plt.tight_layout()
plt.show()
# --- Lo stesso confronto, letto in due scale diverse --------------------
# 1) calcolo quanto sono legate "recensioni" e "installazioni",
# prima sui valori originali, poi sui loro logaritmi
coefficiente_grezzo = app[["recensioni", "installazioni"]].corr().iloc[0, 1]
coefficiente_log = np.log10(app[["recensioni", "installazioni"]].clip(lower=1)).corr().iloc[0, 1]
print(f"recensioni ~ installazioni, sui valori originali : {coefficiente_grezzo:.3f}")
print(f"recensioni ~ installazioni, sui logaritmi : {coefficiente_log:.3f}")
# 2) preparo due grafici affiancati: a sinistra scala normale, a destra scala logaritmica
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 3.8))
# 3) disegno lo stesso grafico a punti due volte, cambiando solo la scala degli assi
for ax, log, titolo in [(ax1, False, f"Scala normale: indice = {coefficiente_grezzo:.2f}"),
(ax2, True, f"Scala logaritmica: indice = {coefficiente_log:.2f}")]:
ax.scatter(app["installazioni"], app["recensioni"], s=6, alpha=0.3, color=BLU,
linewidths=0)
if log:
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xlabel("installazioni")
ax.set_ylabel("recensioni")
ax.set_title(titolo)
# 4) cosa si vede nel primo grafico
ax1.text(0.3, 0.55, "i punti non stanno\nsu una retta,\nstanno su una curva",
transform=ax1.transAxes, fontsize=9)
plt.tight_layout()
plt.show()
recensioni ~ installazioni, sui valori originali : 0.625 recensioni ~ installazioni, sui logaritmi : 0.958
Risultato
Recensioni e installazioni dicono praticamente la stessa cosa. Il legame tra le due è fortissimo (quasi il massimo possibile): in pratica, sapere quante recensioni ha un'app equivale a sapere quante installazioni ha. Misurano la stessa cosa di fondo soprattutto quanto è grande il pubblico di quell'app.
Il voto non dipende da quanto un'app è scaricata. Il legame tra voto e installazioni è quasi zero, qualunque metodo uso per calcolarlo. In parole semplici: un'app molto scaricata non è né più né meno probabile che sia ben votata di una scaricata poco. Sono due cose indipendenti e quindi scorrelate.
Lo stesso identico legame può sembrare debole o forte, a seconda di come lo misuri e qui la differenza conta. Nella sezione precedente abbiamo visto che lo stesso confronto (recensioni e installazioni) dà un risultato basso se calcolato sui numeri "grezzi", e un risultato molto più alto se calcolato sui logaritmi. Il numero basso non va letto come "più prudente" o più affidabile: è semplicemente sbagliato, perché calcolato sulla scala sbagliata (dove i punti disegnano una curva, non una retta). Quando due modi diversi di misurare lo stesso legame danno risultati molto distanti tra loro, è un segnale che la relazione non è una semplice proporzione, e va guardata cambiando punto di vista utilizzando una scala diversa.
Le app aggiornate di recente hanno, in media, voti più alti e più pubblico. Più tempo è passato dall'ultimo aggiornamento, più tendono a scendere sia il voto sia il numero di recensioni e installazioni. Questo non dimostra che aggiornare spesso faccia salire il voto, potrebbe anche essere il contrario (le app di maggior successo vengono aggiornate più spesso, non il contrario).
4.1 Da Cosa dipende il voto?¶
Abbiamo appena visto che il voto non dipende da quante persone scaricano l'app. Proviamo con un'altra misura, costruita apposta: quante recensioni ogni mille installazioni riceve un'app cioè, tra tutte le persone che la usano, quante si prendono davvero la briga di scrivere qualcosa.
# --- La domanda : le app più recensite hanno anche un voto più alto? -----------------
# 1) tengo solo le app che hanno sia il voto sia il dato sul coinvolgimento,
# e le divido in 5 gruppi di uguale numerosità (quintili), dal meno al più recensito
confronto = app.dropna(subset=["voto", "recensioni_per_1000"]).copy()
confronto["gruppo"] = pd.qcut(confronto["recensioni_per_1000"], 5,
labels=["Q1 meno recensite", "Q2", "Q3", "Q4",
"Q5 più recensite"])
# 2) per ciascun gruppo calcolo: quante app contiene, il voto medio,
# e il livello di coinvolgimento tipico (valore centrale del gruppo)
per_gruppo = confronto.groupby("gruppo", observed=True).agg(
numero_app=("nome", "count"),
voto_medio=("voto", "mean"),
recensioni_per_1000=("recensioni_per_1000", "median"),
)
display(per_gruppo.round(2))
# 3) grafico a barre: un voto medio per ciascuno dei 5 gruppi
fig, ax = plt.subplots(figsize=(7.5, 3))
ax.bar(range(5), per_gruppo["voto_medio"], color=sns.color_palette("Blues", 5), width=0.6)
ax.set_xticks(range(5), [e.replace(" ", "\n", 1) for e in per_gruppo.index], fontsize=9)
ax.set_ylim(3.8, 4.55)
ax.set_ylabel("voto medio")
ax.set_title("Voto e coinvolgimento salgono insieme (legame, non causa)")
for i, v in enumerate(per_gruppo["voto_medio"]):
ax.text(i, v + 0.012, f"{v:.2f}", ha="center", fontweight="bold", fontsize=10)
plt.tight_layout()
plt.show()
# 4) un indice numerico che conferma la tendenza vista nel grafico
indice = confronto[["voto", "recensioni_per_1000"]].corr(method="spearman").iloc[0, 1]
print(f"Indice di correlazione tra voto e coinvolgimento: {indice:+.3f}")
| numero_app | voto_medio | recensioni_per_1000 | |
|---|---|---|---|
| gruppo | |||
| Q1 meno recensite | 1650 | 3.96 | 4.73 |
| Q2 | 1634 | 4.05 | 10.34 |
| Q3 | 1642 | 4.16 | 18.62 |
| Q4 | 1646 | 4.26 | 33.00 |
| Q5 più recensite | 1638 | 4.44 | 80.00 |
Indice di correlazione tra voto e coinvolgimento: +0.379
# --- Controprova con il secondo file: il tono delle recensioni scritte ---
# 1) tengo solo le recensioni che hanno il sentiment (positivo, negativo o neutro)
recensioni = recensioni_grezze.dropna(subset=["Sentiment"])
# 2) per ogni app calcolo due misure del tono delle recensioni:
# la polarità tipica (da -1 = negativa a +1 = positiva)
# e la percentuale di recensioni positive
per_app = recensioni.groupby("App").agg(
polarita_mediana=("Sentiment_Polarity", "median"),
quota_positive=("Sentiment", lambda s: (s == "Positive").mean()),
)
# 3) unisco i due file (merge) usando il nome dell'app come chiave comune,
# e tengo solo le app che hanno anche il voto
unite = app.merge(per_app, left_on="nome", right_index=True, how="inner").dropna(subset=["voto"])
# 4) controllo se il voto in numeri e il tono delle recensioni scritte vanno d'accordo
print(f"Recensioni con sentiment: {len(recensioni):,} | app coperte: {len(unite):,} "
f"({len(unite) / len(app):.1%} del dataset)")
print(f"Correlazione voto ~ tono del testo scritto: "
f"{unite[['voto', 'polarita_mediana']].corr().iloc[0, 1]:+.3f}")
print(f"Correlazione voto ~ % di recensioni positive: "
f"{unite[['voto', 'quota_positive']].corr().iloc[0, 1]:+.3f}")
Recensioni con sentiment: 37,432 | app coperte: 819 (8.5% del dataset) Correlazione voto ~ tono del testo scritto: +0.242 Correlazione voto ~ % di recensioni positive: +0.317
Risultato Scaricare un'app non basta a farla votare bene: il legame tra voto e numero di installazioni è quasi nullo. Quello che conta davvero è quante persone, tra chi la usa, decidono di lasciare una recensione. Qui il legame è molto più forte, ed è anche ordinato: passando dal gruppo di app meno recensite a quello più recensito, il voto medio sale in modo costante, da 3,96 a 4,44, un gradino alla volta, senza salti strani.
Una seconda fonte di dati, indipendente dalla prima, va nella stessa direzione: confrontando il voto numerico con il tono delle recensioni scritte (quanto sono positive o negative), i due si confermano a vicenda. Il controllo riguarda 819 app, un campione più piccolo ma utile come riprova.
Cosa non dimostra questo risultato. Non dice perché succede. Potrebbe essere che le app piacciono e per questo le persone si sentono spinte a scriverne bene, oppure che le app che riescono a far scrivere recensioni (magari perché più curate,più coinvolgenti o promemoria di lasciare recenzione durante l'utilizzo) finiscono anche per piacere di più. Da questi dati non si può distinguere la causa dall'effetto.
Cosa invece ci dice con sicurezza. Smentisce un'idea intuitiva ma sbagliata: "più persone usano un'app, meglio viene votata". E indica dove guardare per migliorare: non conta quanto è grande il pubblico, ma quanta parte di quel pubblico trova un motivo per sentirsi coinvolta e farsi sentire sia in maniera positiva che negativa.
4.2 Correlazione non è causalità: la regola che tengo fino in fondo¶
Nella prima versione scrivevo questa cautela qui, e poi la dimenticavo nelle conclusioni: usavo «il voto segue il coinvolgimento» per diagnosticare un concorrente, e chiamavo «leva» una cosa che forse è un effetto. Da qui in avanti vale una regola: quando un legame osservato entra in una decisione, accanto c'è scritto che è un legame, e quali altre spiegazioni non posso escludere.
Per ogni legame che uso, le spiegazioni alternative sono sempre tre:
| Spiegazione alternativa | Esempio in questo notebook |
|---|---|
| la causa va al contrario | non è l'aggiornamento frequente a far salire il voto: sono le app con più successo (e più utenti che segnalano problemi) a essere aggiornate di più |
| una terza variabile muove entrambe | il prezzo non «segnala qualità»: in ogni fascia di prezzo stanno tipi di app diversi (temi grafici in basso, app mediche in alto), e il tipo di app muove sia il prezzo sia le installazioni |
| il caso, o un artefatto dei dati | una categoria piccola può avere percentuali estreme per pochi elementi; le installazioni sono fasce, non numeri |
Due letture utili: Correlation and causation dell'Australian Bureau of Statistics, e Spurious correlations di Tyler Vigen, che raccoglie correlazioni fortissime e prive di senso.
Nel testo segno con ⚠ legame, non causa ogni punto in cui una decisione poggia su una correlazione.
5. Le categorie: dove c'è concorrenza e dove c'è interesse¶
La Concorrenza non è una cosa sola. Il numero di app da solo può ingannare: una categoria con pochi concorrenti può essere impossibile da attaccare se uno di loro si prende tutto. Uso quattro misure:
| Misura | Domanda a cui risponde |
|---|---|
| numero di app | quanti concorrenti ci sono? |
| quota installazioni ÷ quota app | la categoria riceve più attenzione di quanto spazio occupa? |
| quota della prima app | quanta parte del mercato si prende già il leader? |
| % di app oltre il milione | quanto è probabile arrivare a un pubblico grande? |
# --- Costruisco un profilo per ciascuna categoria dello store -----------
# 1) per ogni categoria calcolo alcuni numeri di base: quante app contiene,
# quante installazioni ha in totale, quante ne ha la sua app più scaricata,
# il voto mediano, il coinvolgimento mediano, e da quanto non si aggiorna
per_categoria = app.groupby("categoria").agg(
numero_app=("nome", "count"),
installazioni_totali=("installazioni", "sum"),
installazioni_prima_app=("installazioni", "max"),
voto_mediano=("voto", "median"),
recensioni_per_1000=("recensioni_per_1000", "median"),
giorni_da_aggiornamento=("giorni_da_aggiornamento", "median"),
)
# 2) che percentuale del catalogo totale rappresenta questa categoria,
# sia in numero di app sia in installazioni
per_categoria["quota_app"] = per_categoria["numero_app"] / per_categoria["numero_app"].sum()
per_categoria["quota_installazioni"] = (per_categoria["installazioni_totali"]
/ per_categoria["installazioni_totali"].sum())
# 3) la categoria riceve più o meno attenzione di quanto "pesa" nel catalogo?
# sopra 1 = ha più installazioni di quante gliene spetterebbero in proporzione
# sotto 1 = ne ha meno
per_categoria["domanda_su_offerta"] = (per_categoria["quota_installazioni"]
/ per_categoria["quota_app"])
# 4) quanto è dominata la categoria da una singola app di successo:
# che percentuale delle sue installazioni totali prende solo la prima app
per_categoria["quota_prima_app"] = (per_categoria["installazioni_prima_app"]
/ per_categoria["installazioni_totali"])
# 5) due indicatori in più: che percentuale di app supera il milione di
# installazioni, e che percentuale è a pagamento
per_categoria["quota_oltre_1M"] = app.groupby("categoria")["installazioni"].apply(
lambda s: (s >= 1_000_000).mean())
per_categoria["quota_a_pagamento"] = app.groupby("categoria")["tipo"].apply(
lambda s: (s == "A pagamento").mean())
# 6) riepilogo generale, e tabella delle 8 categorie con più app
print(f"{len(per_categoria)} categorie, {per_categoria['numero_app'].sum():,} app")
per_categoria.sort_values("numero_app", ascending=False)[
["numero_app", "quota_app", "quota_installazioni", "domanda_su_offerta",
"quota_prima_app", "quota_oltre_1M", "voto_mediano"]].head(8).round(3)
33 categorie, 9,674 app
| numero_app | quota_app | quota_installazioni | domanda_su_offerta | quota_prima_app | quota_oltre_1M | voto_mediano | |
|---|---|---|---|---|---|---|---|
| categoria | |||||||
| FAMILY | 1877 | 0.194 | 0.083 | 0.426 | 0.160 | 0.302 | 4.3 |
| GAME | 947 | 0.098 | 0.179 | 1.826 | 0.074 | 0.571 | 4.3 |
| TOOLS | 829 | 0.086 | 0.108 | 1.255 | 0.123 | 0.332 | 4.2 |
| BUSINESS | 420 | 0.043 | 0.009 | 0.213 | 0.143 | 0.164 | 4.2 |
| MEDICAL | 399 | 0.041 | 0.001 | 0.013 | 0.127 | 0.048 | 4.3 |
| PERSONALIZATION | 376 | 0.039 | 0.020 | 0.523 | 0.065 | 0.293 | 4.4 |
| PRODUCTIVITY | 375 | 0.039 | 0.077 | 1.985 | 0.172 | 0.395 | 4.3 |
| LIFESTYLE | 369 | 0.038 | 0.007 | 0.175 | 0.198 | 0.228 | 4.2 |
# --- Tre modi di guardare la concorrenza nelle categorie ----------------
# 1) una funzione che trasforma i nomi tecnici delle categorie
# (es. "FOOD_AND_DRINK") in nomi più semplici da leggere (es. "Food & Drink")
def nome_leggibile(categoria):
return categoria.replace("_AND_", " & ").replace("_", " ").title()
# 2) ordino le categorie dalla più affollata alla meno affollata,
# e preparo i nomi leggibili da usare nei grafici
ordinate = per_categoria.sort_values("numero_app", ascending=False)
nomi = [nome_leggibile(c) for c in ordinate.index]
righe = range(len(nomi))
# 3) preparo tre grafici affiancati e ravvicinati (wspace piccolo),
# tutti con le stesse categorie sull'asse verticale
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(14, 9), sharey=True,
gridspec_kw={"wspace": 0.06})
# 4) per seguire una categoria con lo sguardo da sinistra a destra:
# una riga sì e una no ha uno sfondo grigio chiaro, uguale nei tre grafici,
# e le righe orizzontali della griglia sono tolte per non fare confusione
for ax in (ax1, ax2, ax3):
for r in righe:
if r % 2 == 0:
ax.axhspan(r - 0.5, r + 0.5, color="#eeeeec", zorder=0)
ax.grid(axis="y", visible=False)
ax.set_ylim(len(nomi) - 0.5, -0.5) # stesso ordine dall'alto in basso
# 5) primo grafico: quanti concorrenti ci sono in ogni categoria
ax1.barh(righe, ordinate["numero_app"], height=0.65, zorder=2,
color=[ARANCIONE if c in ("FAMILY", "GAME", "TOOLS") else BLU for c in ordinate.index])
ax1.set_yticks(righe, nomi, fontsize=9.5)
ax1.set_xlim(0, ordinate["numero_app"].max() * 1.15)
ax1.set_xlabel("numero di app")
ax1.set_title("Quanti concorrenti")
for r, v in zip(righe, ordinate["numero_app"]):
ax1.text(v + 15, r, f"{v:,}".replace(",", "."), va="center", fontsize=7.5)
# 6) secondo grafico: la categoria riceve più o meno attenzione di quanto "pesa"?
# oltre la linea nera (valore 1) = più attenzione del previsto, prima = meno
ax2.barh(righe, ordinate["domanda_su_offerta"], height=0.65, zorder=2,
color=[VERDE if v >= 1 else GRIGIO for v in ordinate["domanda_su_offerta"]])
ax2.axvline(1, color="black", linewidth=1.1, zorder=3)
ax2.set_xlim(0, ordinate["domanda_su_offerta"].max() * 1.15)
ax2.set_xlabel("quota installazioni ÷ quota app")
ax2.set_title("Quanta attenzione ricevono")
for r, v in zip(righe, ordinate["domanda_su_offerta"]):
ax2.text(v + 0.05, r, f"{v:.2f}".replace(".", ","), va="center", fontsize=7.5)
# 7) terzo grafico: quanto è forte il leader di ciascuna categoria
ax3.barh(righe, ordinate["quota_prima_app"] * 100, height=0.65, zorder=2,
color=[ARANCIONE if v > 0.30 else BLU for v in ordinate["quota_prima_app"]])
ax3.set_xlim(0, ordinate["quota_prima_app"].max() * 100 * 1.15)
ax3.set_xlabel("% del mercato presa dalla prima app")
ax3.set_title("Quanto è forte il leader\n(ARANCIONE = oltre il 30%)")
for r, v in zip(righe, ordinate["quota_prima_app"]):
ax3.text(v * 100 + 0.8, r, f"{v:.0%}", va="center", fontsize=7.5)
plt.show()
# 8) elenco testuale: chi è l'app leader nelle 6 categorie più dominate da un solo prodotto
print("La prima app nelle sei categorie più concentrate:\n")
for categoria in per_categoria.nlargest(6, "quota_prima_app").index:
dentro = app[app["categoria"] == categoria]
prima = dentro.loc[dentro["installazioni"].idxmax()]
quota = prima["installazioni"] / dentro["installazioni"].sum()
print(f" {nome_leggibile(categoria):22s} {prima['nome'][:40]:40s} {quota:6.1%}")
La prima app nelle sei categorie più concentrate: Books & Reference Google Play Books 60.0% Art & Design Sketch - Draw & Paint 44.2% Health & Fitness Samsung Health 43.7% News & Magazines Google News 42.2% Beauty Beauty Camera - Selfie Camera 36.8% Travel & Local Maps - Navigate & Explore 34.5%
Risultato I tre grafici raccontano tre storie diverse, ed è il confronto tra loro a dare l'informazione utile.
Primo grafico quanti concorrenti. La categoria più affollata è FAMILY, con 1.877 app: quasi una su cinque di tutto lo store.
Secondo grafico quanta attenzione ricevono. FAMILY però raccoglie solo l'8,3% delle installazioni. Il suo indice è 0,43: riceve meno della metà del pubblico che le spetterebbe per il numero di app che ha. Tante app, poco pubblico per ciascuna. All'opposto, COMMUNICATION ha l'indice più alto (4,49), ma è un'illusione: quelle installazioni sono quasi tutte di WhatsApp, Messenger e Skype.
Terzo grafico : quanto è forte il leader. Nelle categorie più concentrate, l'app che domina è spesso un'app già presente sul telefono quando lo compri: Google Play Books ha il 60% delle installazioni della sua categoria, Samsung Health il 43,7%, Google News il 42,2%, Google Maps il 34,5%. Contro queste app non basta fare un prodotto migliore: sono già lì prima che l'utente cerchi un'alternativa.
All'estremo opposto c'è la categoria che interessa nel progetto: Food & Drink. Qui l'app più scaricata si prende solo il 4,7% del pubblico, il valore più basso di tutte le 33 categorie. Nessuno domina.
In sintesi pochi concorrenti non vuol dire mercato facile. La situazione peggiore è poche app con un leader fortissimo; la più aperta è tante app senza un leader.
# --- Dove si arriva a un pubblico grande, e dove gli utenti partecipano di più ---
# 1) ordino le categorie dalla domanda più alta alla più bassa.
# Lo stesso ordine vale per tutti e due i grafici, così ogni categoria
# sta alla stessa altezza a sinistra e a destra
ordinate = per_categoria.sort_values("quota_oltre_1M", ascending=False)
nomi = [nome_leggibile(c) for c in ordinate.index]
righe = range(len(nomi))
# categorie da evidenziare in arancione in ciascun grafico
DA_NOTARE_DOMANDA = ["FOOD_AND_DRINK", "HOUSE_AND_HOME", "EDUCATION", "SHOPPING"]
DA_NOTARE_COINVOLGIMENTO = ["GAME", "EDUCATION", "HEALTH_AND_FITNESS"]
# i due valori di riferimento dello store, disegnati come linea nera
media_oltre_1M = (app["installazioni"] >= 1e6).mean() * 100
mediana_coinvolgimento = app["recensioni_per_1000"].median()
# 2) preparo due grafici affiancati e ravvicinati, con le stesse categorie in verticale
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 9), sharey=True,
gridspec_kw={"wspace": 0.06})
# 3) una riga sì e una no con lo sfondo grigio chiaro, uguale nei due grafici,
# per seguire una categoria con lo sguardo da sinistra a destra
for ax in (ax1, ax2):
for r in righe:
if r % 2 == 0:
ax.axhspan(r - 0.5, r + 0.5, color="#eeeeec", zorder=0)
ax.grid(axis="y", visible=False)
ax.set_ylim(len(nomi) - 0.5, -0.5) # stesso ordine dall'alto in basso
# 4) primo grafico — DOMANDA: percentuale di app che supera il milione di installazioni
ax1.barh(righe, ordinate["quota_oltre_1M"] * 100, height=0.65, zorder=2,
color=[ARANCIONE if c in DA_NOTARE_DOMANDA else BLU for c in ordinate.index])
ax1.axvline(media_oltre_1M, color="black", linewidth=1.1, zorder=3)
ax1.set_yticks(righe, nomi, fontsize=9.5)
ax1.set_xlim(0, ordinate["quota_oltre_1M"].max() * 100 * 1.15)
ax1.set_xlabel("% di app che superano il milione di installazioni")
ax1.set_title(f"Domanda\n(linea = media dello store, {media_oltre_1M:.1f}%)".replace(".", ","))
for r, v in zip(righe, ordinate["quota_oltre_1M"]):
ax1.text(v * 100 + 0.8, r, f"{v:.0%}", va="center", fontsize=7.5)
# 5) secondo grafico — COINVOLGIMENTO: recensioni ogni 1.000 installazioni
ax2.barh(righe, ordinate["recensioni_per_1000"], height=0.65, zorder=2,
color=[ARANCIONE if c in DA_NOTARE_COINVOLGIMENTO else BLU for c in ordinate.index])
ax2.axvline(mediana_coinvolgimento, color="black", linewidth=1.1, zorder=3)
ax2.set_xlim(0, ordinate["recensioni_per_1000"].max() * 1.15)
ax2.set_xlabel("recensioni ogni 1.000 installazioni")
ax2.set_title(f"Coinvolgimento\n(linea = valore tipico dello store, {mediana_coinvolgimento:.0f})")
for r, v in zip(righe, ordinate["recensioni_per_1000"]):
ax2.text(v + 0.3, r, f"{v:.1f}".replace(".", ","), va="center", fontsize=7.5)
plt.show()
# --- Tutte le categorie in un solo grafico interattivo con PLOTLY ---
# il grafico si crea solo se la libreria plotly è installata
if PLOTLY:
# 1) preparo i dati: una riga per categoria, con il nome leggibile
dati = per_categoria.reset_index()
dati["nome_categoria"] = dati["categoria"].map(nome_leggibile)
# 2) grafico a bolle, dove ogni bolla è una categoria:
# - in orizzontale: quante app ci sono (più a destra = più concorrenza)
# - in verticale: quante app superano il milione (più in alto = più domanda)
# - grandezza della bolla: quanto gli utenti scrivono recensioni
# - colore: quanto è forte il leader (rosso = una sola app domina)
figura = px.scatter(
dati, x="numero_app", y="quota_oltre_1M",
size="recensioni_per_1000", color="quota_prima_app",
hover_name="nome_categoria", log_x=True, color_continuous_scale="RdYlGn_r",
hover_data={"numero_app": ":,", "quota_oltre_1M": ":.1%",
"quota_prima_app": ":.1%", "categoria": False},
labels={"numero_app": "numero di app (scala log) → più concorrenza",
"quota_oltre_1M": "% oltre il milione → più domanda",
"quota_prima_app": "quota della<br>prima app"},
title="Le 33 categorie — passa il mouse su un punto")
# 3) ritocchi grafici e visualizzazione
figura.update_traces(marker=dict(line=dict(width=1, color="white")))
figura.update_layout(width=860, height=500, template="simple_white")
figura.show()
else:
print("plotly non installato: salto il grafico interattivo.")
Risultato In tutto lo store il 35,2% delle app supera il milione di installazioni. Alcune categorie stanno molto sopra questa media: ENTERTAINMENT (83,9%, con sole 87 app), EDUCATION (57,4%, con 108 app), SHOPPING (52,5%) e FOOD & DRINK (43,8%).
Due categorie così piccole con una probabilità di successo così alta sono però un risultato sospetto: se bastasse pubblicare lì per raddoppiare le possibilità, lo farebbero tutti. Lo segno e lo verifico nella sezione 8, e la verifica cambierà la scelta finale.
Sul coinvolgimento, il valore tipico dello store è di 17 recensioni ogni 1.000 installazioni. In testa ci sono i giochi (28,5), poi EDUCATION (21,1) e HEALTH & FITNESS (20,9): categorie in cui l'utente ha un obiettivo personale e torna a usare l'app. Un'app di sostenibilità somiglia più a queste che a un gioco.
Come leggere il grafico interattivo: le bolle verdi in alto a sinistra sono le categorie candidate (molta domanda, pochi concorrenti, nessun leader dominante). Le bolle rosse vanno evitate ovunque si trovino, perché lì una sola app si prende quasi tutto.
6. Cinque ipotesi, verificate¶
Da quello che ho visto finora ricavo cinque affermazioni da mettere alla prova:
| Ipotesi | |
|---|---|
| IPOTESI 1 | Le app a pagamento hanno un voto più alto |
| IPOTESI 2 | Le app gratuite hanno molte più installazioni |
| IPOTESI 3 | Tra le app a pagamento, più costano e meno vengono installate |
| IPOTESI 4 | Le app aggiornate da poco hanno un voto più alto |
| IPOTESI 5 | Il voto cambia a seconda della categoria |
Come le verifico. Divido le app in gruppi e confronto i valori tipici (mediane) e le percentuali, aiutandomi con un grafico. È un confronto descrittivo: guardo i numeri e li metto a confronto, senza test statistici più avanzati, che sarebbero il passo successivo. I gruppi sono grandi e le differenze di solito sono nette; dove una differenza è piccola, lo dico chiaramente invece di ingigantirla.
# --- IPOTESI 1 e 2: cosa si guadagna e cosa si perde facendo pagare un'app ---
# 1) tengo solo le app che hanno un voto
con_voto = app.dropna(subset=["voto"])
# 2) IPOTESI 1 — per app gratuite e a pagamento: quante sono e che voto hanno
h1 = con_voto.groupby("tipo")["voto"].agg(numero_app="count", voto_medio="mean",
voto_mediano="median").round(3)
# 3) IPOTESI 2 — per gli stessi due gruppi: installazioni tipiche
# e percentuale di app che supera il milione
h2 = app.groupby("tipo").agg(
installazioni_mediane=("installazioni", "median"),
oltre_1_milione=("installazioni", lambda s: (s >= 1_000_000).mean()),
).round(3)
# 4) unisco le due tabelle e le mostro insieme
display(h1.join(h2))
# 5) preparo tre grafici affiancati
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(13.5, 3.2))
# 6) primo grafico (H1) — come si distribuiscono i voti nei due gruppi
sns.boxplot(data=con_voto, x="tipo", y="voto", ax=ax1, width=0.45,
palette={"Gratuita": BLU, "A pagamento": ARANCIONE})
ax1.set_title("IPOTESI 1 — il voto")
ax1.set_xlabel("")
# 7) secondo grafico (IPOTESI 2) — installazioni tipiche, in scala logaritmica
ax2.bar(h2.index, h2["installazioni_mediane"], color=[ARANCIONE, BLU], width=0.5)
ax2.set_yscale("log")
ax2.set_ylabel("installazioni mediane (log)")
ax2.set_title("IPOTESI 2 — il pubblico")
ax2.set_ylim(top=h2["installazioni_mediane"].max() * 6)
for i, v in enumerate(h2["installazioni_mediane"]):
ax2.text(i, v * 1.4, f"{v:,.0f}", ha="center", fontweight="bold")
# 8) terzo grafico (IPOTESI 2) — percentuale di app che supera il milione
ax3.bar(h2.index, h2["oltre_1_milione"] * 100, color=[ARANCIONE, BLU], width=0.5)
ax3.set_ylabel("% oltre il milione")
ax3.set_title("IPOTESI 2 — probabilità di sfondare")
ax3.set_ylim(0, 48)
for i, v in enumerate(h2["oltre_1_milione"]):
ax3.text(i, v * 100 + 1.2, f"{v:.1%}", ha="center", fontweight="bold")
plt.tight_layout()
plt.show()
| numero_app | voto_medio | voto_mediano | installazioni_mediane | oltre_1_milione | |
|---|---|---|---|---|---|
| tipo | |||||
| A pagamento | 604 | 4.260 | 4.4 | 1000.0 | 0.029 |
| Gratuita | 7606 | 4.167 | 4.3 | 100000.0 | 0.379 |
Risultato
IPOTESI 1 — CONFERMATA, ma la differenza è minima. Le app a pagamento hanno un voto tipico di 4,4 contro 4,3 delle gratuite: appena un decimo di punto, e nel grafico le due "scatole" si sovrappongono quasi del tutto. Una spiegazione possibile è che chi paga abbia già deciso prima che l'app gli serve, e quindi ne resti più soddisfatto; con questi dati non la posso verificare.
IPOTESI 2 — CONFERMATA, e la differenza è enorme. Un'app gratuita tipica ha 100.000 installazioni, una a pagamento 1.000: cento volte di meno. La probabilità di superare il milione di installazioni scende dal 37,9% al 2,9%.
Cosa posso concluderne, e cosa no. ⚠ legame, non causa. Il confronto è fra due gruppi di app diverse, non fra la stessa app gratuita e a pagamento: le app a pagamento sono più spesso app di nicchia (mediche, temi grafici, strumenti professionali), con un pubblico piccolo in partenza. Quindi non posso dire che «far pagare fa perdere il 99% del pubblico». Posso dire una cosa più modesta e comunque utile: nel 2018 quasi nessuna app a pagamento arrivava a un pubblico grande (il 2,9%), e un'app nuova che vuole un pubblico grande ha davanti a sé un mercato che è al 92% gratuito.
# --- IPOTESI 3: un prezzo più alto scoraggia le installazioni? ---
# 1) tengo solo le app a pagamento, escludendo le 20 app-scherzo sopra i 100 $
a_pagamento = app[(app["tipo"] == "A pagamento") & (app["prezzo"] < 100)].copy()
# 2) divido le app in 5 fasce di prezzo
a_pagamento["fascia_prezzo"] = pd.cut(
a_pagamento["prezzo"], bins=[0, 1.49, 2.99, 5.99, 14.99, 100],
labels=["fino a 1,49 $", "1,50–2,99 $", "3,00–5,99 $", "6,00–14,99 $", "15 $ e oltre"])
# 3) per ogni fascia calcolo: quante app ci sono, le installazioni tipiche
# e la percentuale di app che supera le 10.000 installazioni
h3 = a_pagamento.groupby("fascia_prezzo", observed=True).agg(
numero_app=("nome", "count"),
installazioni_mediane=("installazioni", "median"),
oltre_10_mila=("installazioni", lambda s: (s >= 10_000).mean()),
)
display(h3.round(3))
# 4) grafico a barre: percentuale di app oltre le 10.000 installazioni, per fascia
fig, ax = plt.subplots(figsize=(8, 3))
ax.bar(range(len(h3)), h3["oltre_10_mila"] * 100, width=0.6,
color=[ARANCIONE if f == "1,50–2,99 $" else BLU for f in h3.index])
ax.set_xticks(range(len(h3)), h3.index)
ax.set_ylabel("% oltre le 10.000\ninstallazioni")
ax.set_title("IPOTESI 3 — non è una discesa: è una gobba")
ax.set_ylim(0, 58)
for i, (v, n) in enumerate(zip(h3["oltre_10_mila"], h3["numero_app"])):
ax.text(i, v * 100 + 1.5, f"{v:.0%}\n({n} app)", ha="center", fontsize=9)
plt.tight_layout()
plt.show()
# 5) un indice che riassume se prezzo e installazioni crescono insieme oppure no
indice = a_pagamento[["prezzo", "installazioni"]].corr(method="spearman").iloc[0, 1]
print(f"Indice di correlazione tra prezzo e installazioni: {indice:+.3f}")
| numero_app | installazioni_mediane | oltre_10_mila | |
|---|---|---|---|
| fascia_prezzo | |||
| fino a 1,49 $ | 198 | 500.0 | 0.197 |
| 1,50–2,99 $ | 240 | 5000.0 | 0.471 |
| 3,00–5,99 $ | 190 | 5000.0 | 0.405 |
| 6,00–14,99 $ | 75 | 1000.0 | 0.387 |
| 15 $ e oltre | 33 | 1000.0 | 0.212 |
Indice di correlazione tra prezzo e installazioni: +0.142
# --- Controprova per l'IPOTESI 3: dentro ogni fascia di prezzo ci sono le stesse app? ---
# per ogni fascia, che percentuale delle app viene da ciascuna categoria
composizione = pd.crosstab(a_pagamento["fascia_prezzo"], a_pagamento["categoria"],
normalize="index")
# le tre categorie più presenti in ogni fascia
prime_tre = pd.DataFrame({
fascia: [f"{nome_leggibile(c) if 'nome_leggibile' in globals() else c} {q:.0%}"
for c, q in riga.nlargest(3).items()]
for fascia, riga in composizione.iterrows()
}, index=["1ª categoria", "2ª categoria", "3ª categoria"]).T
display(prime_tre)
print(f"Temi grafici (PERSONALIZATION) nella fascia fino a 1,49 $: "
f"{composizione.loc['fino a 1,49 $', 'PERSONALIZATION']:.0%} | "
f"app mediche (MEDICAL) sopra i 15 $: {composizione.loc['15 $ e oltre', 'MEDICAL']:.0%}")
| 1ª categoria | 2ª categoria | 3ª categoria | |
|---|---|---|---|
| fino a 1,49 $ | Personalization 27% | Family 20% | Game 13% |
| 1,50–2,99 $ | Family 32% | Tools 12% | Game 11% |
| 3,00–5,99 $ | Family 21% | Medical 12% | Tools 12% |
| 6,00–14,99 $ | Medical 25% | Family 24% | Game 9% |
| 15 $ e oltre | Medical 39% | Family 18% | Business 9% |
Temi grafici (PERSONALIZATION) nella fascia fino a 1,49 $: 27% | app mediche (MEDICAL) sopra i 15 $: 39%
Risultato
IPOTESI 3 : RESPINTA, ed è la più interessante delle cinque. Mi aspettavo che un prezzo più alto facesse scendere le installazioni. Invece l'indice è positivo (+0,14): in media prezzo e installazioni salgono un po' insieme. Ma il grafico mostra che non è nemmeno una crescita regolare: è una gobba.
- la fascia più economica (fino a 1,49 $) è la peggiore: solo il 19,7% delle app supera le 10.000 installazioni;
- il punto più alto è nella fascia 1,50–2,99 $, con il 47,1%;
- poi si torna a scendere, fino al 21,2% sopra i 15 $.
Perché c'è la gobba? Non lo so, e la tabella qui sopra spiega perché. ⚠ legame, non causa. Nella prima versione scrivevo che «il prezzo funziona come segnale di qualità». È una spiegazione causale, e i dati non la sostengono: in ogni fascia stanno app di tipo diverso. Fino a 1,49 $ più di un'app su quattro (27%) è un tema grafico o un pacchetto di icone; sopra i 15 $ il 39% sono app mediche per professionisti. Sono mercati diversi, con pubblici diversi: la gobba può dipendere da cosa c'è in ogni fascia, non dal prezzo in sé. Per sapere se il prezzo da solo sposta le installazioni servirebbe confrontare app dello stesso tipo a prezzi diversi, o meglio un esperimento sul prezzo della propria app.
La lezione di metodo: l'indice di correlazione riassume tutto in un solo numero e presume che la relazione vada sempre nella stessa direzione. Qui non è così: un valore piccolo e positivo nascondeva due tendenze opposte che si compensavano. Quando un'ipotesi non torna, conviene guardare la forma della relazione in un grafico, e cosa c'è dentro ogni gruppo, prima di trarre conclusioni dal solo numero.
# --- IPOTESI 4 e 5: contano gli aggiornamenti? E conta la categoria? ---
# 1) tengo solo le app con un voto e le divido in 5 fasce,
# secondo il tempo passato dall'ultimo aggiornamento
con_voto = app.dropna(subset=["voto"]).copy()
con_voto["eta_aggiornamento"] = pd.cut(
con_voto["giorni_da_aggiornamento"], bins=[-1, 30, 90, 180, 365, 10_000],
labels=["< 1 mese", "1–3 mesi", "3–6 mesi", "6–12 mesi", "oltre 1 anno"])
# 2) IPOTESI 4 — per ogni fascia: quante app ci sono e qual è il voto medio
h4 = con_voto.groupby("eta_aggiornamento", observed=True).agg(
numero_app=("nome", "count"), voto_medio=("voto", "mean"))
# 3) IPOTESI 5 — per ogni categoria con almeno 30 app votate: il voto tipico,
# con le categorie ordinate dalla migliore alla peggiore
categorie_grandi = (con_voto.groupby("categoria")["voto"].agg(["count", "median"])
.query("count >= 30").sort_values("median", ascending=False))
# 4) preparo due grafici affiancati (il secondo un po' più largo)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13.5, 4.2),
gridspec_kw={"width_ratios": [1, 1.25]})
# 5) primo grafico (IPOTESI 4) — voto medio per fascia di aggiornamento
ax1.bar(range(len(h4)), h4["voto_medio"], color=sns.color_palette("Blues_r", len(h4)),
width=0.6)
ax1.set_xticks(range(len(h4)), h4.index, rotation=20, ha="right")
ax1.set_ylim(3.9, 4.35)
ax1.set_ylabel("voto medio")
ax1.set_title("H4 — più tempo passa, più il voto scende")
for i, v in enumerate(h4["voto_medio"]):
ax1.text(i, v + 0.008, f"{v:.2f}", ha="center", fontsize=9, fontweight="bold")
# 6) secondo grafico (IPOTESI 5) — i voti di ogni categoria, con una linea arancione
# sul voto tipico di tutto lo store
sns.boxplot(data=con_voto[con_voto["categoria"].isin(categorie_grandi.index)],
y="categoria", x="voto", order=categorie_grandi.index, ax=ax2,
color=BLU, width=0.6, fliersize=1, linewidth=0.7)
ax2.axvline(app["voto"].median(), color=ARANCIONE, linewidth=1.5)
ax2.set_yticks(range(len(categorie_grandi)),
[nome_leggibile(c) for c in categorie_grandi.index], fontsize=7)
ax2.set_ylabel("")
ax2.set_xlim(1, 5.1)
ax2.set_title("IPOTESI 5 — le mediane vanno da 4,1 a 4,5")
plt.tight_layout()
plt.show()
# 7) riepilogo numerico delle due ipotesi
print(f"IPOTESI 4: voto medio con aggiornamento < 1 mese {h4['voto_medio'].iloc[0]:.2f} | "
f"oltre 1 anno {h4['voto_medio'].iloc[-1]:.2f}")
print(f"IPOTESI 5: escursione delle mediane fra {len(categorie_grandi)} categorie: "
f"{categorie_grandi['median'].max() - categorie_grandi['median'].min():.1f} punti")
IPOTESI 4: voto medio con aggiornamento < 1 mese 4.27 | oltre 1 anno 4.06 IPOTESI 5: escursione delle mediane fra 33 categorie: 0.4 punti
Risultato
IPOTESI 4 è CONFERMATA, come legame. L'andamento è regolare: passando dalle app aggiornate nell'ultimo mese a quelle ferme da più di un anno, il voto medio scende un gradino alla volta, da 4,27 a 4,06. ⚠ legame, non causa. Da questi dati non posso dire se aggiornare spesso faccia salire il voto, o se invece le app di successo vengano aggiornate più spesso perché hanno più utenti, più segnalazioni e più risorse. Nella prima versione chiamavo gli aggiornamenti «l'unica leva nelle mani di chi sviluppa»: se è il successo a generarli, non sono una leva. Quello che resta vero è più piccolo: la frequenza degli aggiornamenti è una scelta di chi sviluppa, e se funzioni lo si può scoprire solo misurandolo sulla propria app.
IPOTESI 5 è CONFERMATA, ma conta pochissimo. Il voto tipico cambia davvero da una categoria all'altra, ma tra la migliore e la peggiore ci sono solo 0,4 punti su una scala da 1 a 5, e nel grafico le "scatole" si sovrappongono quasi tutte. Quindi scegliere una categoria non serve ad avere un voto più alto: serve a farsi trovare e ad avere meno concorrenti. È un buon promemoria: una differenza può essere reale e, allo stesso tempo, troppo piccola per cambiare le decisioni.
7. Il segmento sostenibilità: si può misurare?¶
Nel dataset non c'è una colonna che dica quali app siano dedicate alla sostenibilità, provo a costruirmi questa informazione da solo. Procedo in tre passi:
- cerco delle parole chiave nel nome e nei generi delle app;
- controllo a mano i risultati, uno per uno;
- misuro quanto sbaglia il metodo.
# --- Cerco le app sostenibili con 77 parole chiave ---
# 1) l'elenco delle parole chiave, in inglese come i nomi delle app, raggruppate per tema.
# \b indica l'inizio o la fine di una parola: così "tree" (albero)
# non trova anche "street" (strada)
TERMINI = [
# energia
r"energy sav", r"save energy", r"energy efficien", r"energy monitor", r"power consum",
r"electricity", r"\bkwh\b", r"smart meter", r"solar", r"wind power", r"renewab",
# mobilità
r"\bev charg", r"charging station", r"electric car", r"electric vehicle",
r"public transport", r"\btransit\b", r"carpool", r"car pool", r"rideshar",
r"bicycl", r"\bcycling\b", r"\bbike shar",
# rifiuti e riuso
r"recycl", r"compost", r"upcycl", r"\bwaste\b", r"zero waste", r"\btrash\b", r"garbage",
r"thrift", r"second[- ]?hand", r"preloved", r"\bswap\b", r"\breuse\b", r"refill",
r"plastic", r"litter", r"clean ?up",
# alimentazione
r"\bvegan\b", r"vegetarian", r"plant[- ]based", r"\borganic\b", r"food waste",
r"leftover", r"shelf life", r"local food", r"farmers market",
# natura, clima, acqua
r"eco[- ]?friendly", r"ecolog", r"sustainab", r"\bgreen\b", r"carbon", r"\bco2\b",
r"climate", r"climat", r"environment", r"footprint", r"emission", r"pollut",
r"air quality", r"smog", r"\btree\b", r"\bforest\b", r"wildlife", r"conservation",
r"biodivers", r"\bocean\b", r"\breef\b", r"water sav", r"water consum", r"rainwater",
# etica
r"\bsdg\b", r"\besg\b", r"fair ?trade", r"ethical", r"sharing econom",
]
# 2) unisco nome e generi di ogni app in un unico testo in cui cercare
testo = app["nome"].fillna("") + " | " + app["generi"].fillna("")
# 3) tengo le app che contengono almeno una parola chiave
# ("|" significa "oppure"; case=False ignora maiuscole e minuscole)
candidati = app[testo.str.contains("|".join(TERMINI), case=False, regex=True, na=False)]
# 4) quante app ho trovato, e le prime 8 come esempio
print(f"{len(TERMINI)} parole chiave -> {len(candidati)} candidati su {len(app):,} app")
candidati[["nome", "categoria", "installazioni", "voto"]].head(8)
77 parole chiave -> 66 candidati su 9,674 app
| nome | categoria | installazioni | voto | |
|---|---|---|---|---|
| 141 | Download free book with green book | BOOKS_AND_REFERENCE | 100000 | 4.6 |
| 158 | FamilySearch Tree | BOOKS_AND_REFERENCE | 1000000 | 4.3 |
| 868 | The green alien dance | ENTERTAINMENT | 1000000 | 3.8 |
| 887 | Adult Glitter Color by Number Book - Sandbox P... | ENTERTAINMENT | 1000000 | 4.3 |
| 1185 | Frigo Magic: Easy recipe idea and anti-waste | FOOD_AND_DRINK | 500000 | 4.1 |
| 1250 | Uber Eats: Local Food Delivery | FOOD_AND_DRINK | 10000000 | 4.2 |
| 1254 | foodpanda - Local Food Delivery | FOOD_AND_DRINK | 10000000 | 4.0 |
| 1266 | Sportractive GPS Running Cycling Distance Tracker | HEALTH_AND_FITNESS | 1000000 | 4.8 |
Risultato La ricerca trova 66 candidati su 9.674 app. Ma già dai primi nomi si vede un problema: «The green alien dance», «Solar System AR», «Bike Race Free» contengono le parole giuste, ma non c'entrano nulla con la sostenibilità. La ricerca per parole chiave raccoglie molto "rumore", quindi ho letto tutti i 66 nomi e li ho classificati a mano.
Le regole che ho usato, decise prima di guardare i risultati:
- un gioco o un simulatore non è un'app sostenibile, anche se parla di biciclette o di foreste;
- temi grafici, sfondi e tastiere nemmeno;
- conta lo scopo principale dell'app, non un dettaglio (WardenCam – reuse old phones riusa vecchi telefoni, ma serve per la videosorveglianza);
- la parola può avere un altro significato (Green usato come cognome, Cleanup nel senso di pulire la memoria del telefono, Ethical Hacking);
- la consegna di cibo a domicilio non è alimentazione sostenibile;
- le app per allenarsi in bicicletta sono un caso dubbio e le escludo (Strava serve per lo sport, non per spostarsi).
La cella qui sotto contiene entrambi gli elenchi, le 34 app tenute e le 32 scartate con il motivo, e controlla che insieme diano esattamente i 66 candidati: il conteggio dei motivi non è più una tabella scritta a mano, è un output.
# --- Le 34 app che, dopo il controllo a mano, sono davvero sostenibili ---
# 1) l'elenco delle app sostenibili, ognuna con il suo tema
SOSTENIBILI = {
'Frigo Magic: Easy recipe idea and anti-waste': 'alimentazione',
'Yahoo! transit guide free timetable, operation information, transfer search': 'mobilita_pubblica',
'Transit: Real-Time Transit App': 'mobilita_pubblica',
'Mapy.cz - Cycling & Hiking offline maps': 'mobilita_attiva',
'Czech Public Transport IDOS': 'mobilita_pubblica',
'NAVITIME Bus Transit JAPAN': 'mobilita_pubblica',
'British Columbia Transit Info': 'mobilita_pubblica',
'Electricity Bill Calculator BD': 'energia',
'Remix Second Hand': 'rifiuti_riuso',
'sustainability@BU': 'informazione',
'DIY Recycled CD Wall Art': 'rifiuti_riuso',
'Offline Jízdní řády CG Transit': 'mobilita_pubblica',
'Solar CT PV System Power': 'energia',
'Recycling Cy': 'rifiuti_riuso',
'Hamburg Transit - Offline HVV DB times and plans': 'mobilita_pubblica',
'DC Metro Transit - Free': 'mobilita_pubblica',
'DC Metro Transit': 'mobilita_pubblica',
'Citymapper - Transit Navigation': 'mobilita_pubblica',
'Tamilnadu Electricity Info': 'energia',
'TN Electricity (TNEB)': 'energia',
'ES Solar': 'energia',
'¿Es Vegan?': 'alimentazione',
'CALIOPE EU: Air Quality': 'natura_clima',
'EU Brazil Green Business Forum': 'informazione',
'ChargeHub - Find EV & Tesla Charging Stations': 'mobilita_elettrica',
'NEXTCHARGE - Charging Stations': 'mobilita_elettrica',
'JuiceNet - Smart EV Charging': 'mobilita_elettrica',
'Zap-Map: EV charging points UK': 'mobilita_elettrica',
'EV Charging': 'mobilita_elettrica',
'Electric Car Charging Points: Ev charger Stations': 'mobilita_elettrica',
'ELMO EV Charging': 'mobilita_elettrica',
'Bolt - EV Charging Service': 'mobilita_elettrica',
'Power Plug EV charger': 'mobilita_elettrica',
'Carpooling FH Hagenberg': 'mobilita_pubblica',
}
# 2) le 32 app scartate, raggruppate per motivo
SCARTATE = {
"gioco o simulatore": [
"The green alien dance", "Fuzzy Seasons: Animal Forest", "Solar System AR ( ARCore )",
"Skill Tree - BL Pre Sequel", "Water Surfer Floating BMX Bicycle Rider Racing",
"Plastic Surgery Surgeon Simulator Er Doctor Games",
"Electric Car Taxi Driver: NY City Cab Taxi Games",
"Survival Forest : Survivor Home Builder"],
"la parola ha un altro significato": [
"Download free book with green book", "FamilySearch Tree", "Forest Crossing AH",
"BV Forest", "Green Build - An unofficial Travis CI client", "Ethical Hacking"],
"allenamento in bicicletta (caso dubbio, escluso)": [
"Sportractive GPS Running Cycling Distance Tracker", "Cycling - Bike Tracker",
"Bike Computer - GPS Cycling Tracker",
"Strava Training: Track Running, Cycling & Swimming",
"Map My Ride GPS Cycling Riding", "Sports Tracker Running Cycling"],
"tema grafico, sfondo o tastiera": [
"Adult Glitter Color by Number Book - Sandbox Pages",
"Colorful Glitter Neon Butterfly Keyboard Theme", "M Theme - Dark Green Icon Pack",
"A.J. Green Wallpapers 4 Fans", "Glitter Color By Number - Glitter Number Coloring"],
"pulizia della memoria del telefono": [
"RAM Cleanup Ad-Free Option", "Avast Cleanup & Boost, Phone Cleaner, Optimizer"],
"consegna di cibo a domicilio": [
"Uber Eats: Local Food Delivery", "foodpanda - Local Food Delivery"],
"meteo, suoni per rilassarsi, videosorveglianza": [
"Climatempo Lite - 15 day weather forecast", "Relax Ocean ~ Nature Sounds",
"Home Security Camera WardenCam - reuse old phones"],
}
MOTIVO = {nome: motivo for motivo, nomi in SCARTATE.items() for nome in nomi}
# 3) dal dataset prendo solo le 34 app sostenibili e aggiungo la colonna con il tema
segmento = app[app["nome"].isin(SOSTENIBILI)].copy()
segmento["tema"] = segmento["nome"].map(SOSTENIBILI)
# 4) le altre app trovate con le parole chiave sono quelle scartate:
# controllo che siano ESATTAMENTE quelle dell'elenco, e conto i motivi
scartate = candidati[~candidati["nome"].isin(SOSTENIBILI)].copy()
scartate["motivo"] = scartate["nome"].map(MOTIVO)
print("Controllo: ogni scartata ha un motivo ->",
"OK" if scartate["motivo"].notna().all() and len(scartate) == len(MOTIVO) else "NO")
display(scartate["motivo"].value_counts().rename("app scartate").to_frame())
# 5) quanto è preciso il metodo, e quanto pesa il segmento su tutto lo store
print(f"Sostenibili: {len(segmento)} | scartate: {len(scartate)} | totale: {len(candidati)}")
print(f"Precisione del metodo: {len(segmento)}/{len(candidati)} = {len(segmento) / len(candidati):.1%}")
print(f"\nIl segmento è il {len(segmento) / len(app):.2%} del catalogo "
f"ma solo il {segmento['installazioni'].sum() / app['installazioni'].sum():.2%} "
"delle installazioni.")
print(f"Installazioni mediane: {segmento['installazioni'].median():,.0f} "
f"contro {app['installazioni'].median():,.0f} del negozio.")
Controllo: ogni scartata ha un motivo -> OK
| app scartate | |
|---|---|
| motivo | |
| gioco o simulatore | 8 |
| la parola ha un altro significato | 6 |
| allenamento in bicicletta (caso dubbio, escluso) | 6 |
| tema grafico, sfondo o tastiera | 5 |
| meteo, suoni per rilassarsi, videosorveglianza | 3 |
| consegna di cibo a domicilio | 2 |
| pulizia della memoria del telefono | 2 |
Sostenibili: 34 | scartate: 32 | totale: 66 Precisione del metodo: 34/66 = 51.5% Il segmento è il 0.35% del catalogo ma solo il 0.03% delle installazioni. Installazioni mediane: 10,000 contro 100,000 del negozio.
7.1 La precisione non basta: quante app sostenibili sfuggono alle parole chiave?¶
La precisione (51,5%) dice quante delle app trovate erano davvero sostenibili. Non dice quante ne ho perse: un'app chiamata OfferUp, per comprare e vendere oggetti usati, non contiene nessuna delle 77 parole chiave e non entra nemmeno tra i candidati, e lo stesso vale per OLX. È la copertura del metodo, e senza misurarla «il segmento è lo 0,35% del catalogo» è solo un minimo.
Il modo più semplice per stimarla: estraggo a caso 1.000 app fra le 9.608 che le parole chiave non hanno trovato, e le leggo una per una con le stesse regole usate per i 66 candidati. Se nel campione trovo k app sostenibili, fuori dai candidati ce ne sono circa k/1.000 × 9.608. Con un campione di 1.000 la stima è grezza, quindi la accompagno con l'intervallo di confidenza al 95% (metodo di Wilson, che funziona bene anche quando k è piccolo).
# --- 7.1 Un campione casuale FUORI dai candidati, letto a mano ---
# 1) le app che le parole chiave NON hanno trovato
fuori = app[~app.index.isin(candidati.index)]
# 2) ne estraggo 1.000 a caso; il seme fisso (2026) rende l'estrazione ripetibile
campione = fuori.sample(n=1000, random_state=2026)
# 3) il risultato della lettura: le 1.000 righe le ho lette tutte, con le stesse regole
# dei 66 candidati. Queste sono le sostenibili, divise fra sicure e dubbie.
CAMPIONE_SOSTENIBILI = {
"NTES": "mobilita_pubblica", # orari dei treni delle ferrovie indiane
"Yandex.Transport": "mobilita_pubblica",
"DB Streckenagent": "mobilita_pubblica",
"EV Stations Hawaii": "mobilita_elettrica",
"OLX - Buy and Sell": "rifiuti_riuso", # compravendita dell'usato
"TANGEDCO Mobile App (Official)": "energia", # come 'TN Electricity (TNEB)' fra i 34
"TNEB Quick Pay Easy": "energia", # idem
}
CAMPIONE_DUBBI = {
"EV Finder": "il nome fa pensare alla ricarica elettrica, ma categoria e dati non lo confermano",
"cPro Marketplace: Buy. Sell. Rent. Date. Jobs.": "annunci di ogni tipo, l'usato è solo una parte",
"Real Estate, Car, Shopping and Others": "annunci di ogni tipo, l'usato è solo una parte",
"Horn, free country requirements": "nome tradotto male: sembra un mercato di scambio, ma non si capisce",
}
# 4) controllo che l'estrazione sia quella che ho letto (stessi dati, stessa pulizia)
mancanti = (set(CAMPIONE_SOSTENIBILI) | set(CAMPIONE_DUBBI)) - set(campione["nome"])
print("Campione:", len(campione), "app su", len(fuori), "fuori dai candidati |",
"estrazione verificata" if not mancanti else f"ATTENZIONE, campione diverso: {mancanti}")
trovate = campione[campione["nome"].isin(CAMPIONE_SOSTENIBILI) | campione["nome"].isin(CAMPIONE_DUBBI)].copy()
trovate["esito"] = np.where(trovate["nome"].isin(CAMPIONE_SOSTENIBILI), "sostenibile", "dubbia")
trovate["tema o motivo"] = trovate["nome"].map({**CAMPIONE_SOSTENIBILI, **CAMPIONE_DUBBI})
display(trovate.sort_values(["esito", "installazioni"], ascending=[False, False])[
["nome", "categoria", "installazioni", "esito", "tema o motivo"]])
Campione: 1000 app su 9608 fuori dai candidati | estrazione verificata
| nome | categoria | installazioni | esito | tema o motivo | |
|---|---|---|---|---|---|
| 2666 | OLX - Buy and Sell | SHOPPING | 50000000 | sostenibile | rifiuti_riuso |
| 3161 | NTES | TRAVEL_AND_LOCAL | 10000000 | sostenibile | mobilita_pubblica |
| 3830 | Yandex.Transport | MAPS_AND_NAVIGATION | 10000000 | sostenibile | mobilita_pubblica |
| 9182 | TANGEDCO Mobile App (Official) | BUSINESS | 500000 | sostenibile | energia |
| 8204 | DB Streckenagent | MAPS_AND_NAVIGATION | 500000 | sostenibile | mobilita_pubblica |
| 9202 | TNEB Quick Pay Easy | FINANCE | 10000 | sostenibile | energia |
| 9949 | EV Stations Hawaii | MAPS_AND_NAVIGATION | 1000 | sostenibile | mobilita_elettrica |
| 7519 | cPro Marketplace: Buy. Sell. Rent. Date. Jobs. | SHOPPING | 10000000 | dubbia | annunci di ogni tipo, l'usato è solo una parte |
| 2688 | Real Estate, Car, Shopping and Others | SHOPPING | 10000000 | dubbia | annunci di ogni tipo, l'usato è solo una parte |
| 2671 | Horn, free country requirements | SHOPPING | 1000000 | dubbia | nome tradotto male: sembra un mercato di scamb... |
| 9963 | EV Finder | TOOLS | 1000 | dubbia | il nome fa pensare alla ricarica elettrica, ma... |
# --- Dal campione a una stima: quante ne perdo, e quanto vale il segmento ---
def wilson(k, n, z=1.96):
"""Intervallo di confidenza al 95% per una proporzione k/n (metodo di Wilson)."""
p = k / n
centro = (p + z**2 / (2 * n)) / (1 + z**2 / n)
margine = z * np.sqrt(p * (1 - p) / n + z**2 / (4 * n**2)) / (1 + z**2 / n)
return centro - margine, centro + margine
n, N_fuori, N_store = len(campione), len(fuori), len(app)
k_sicure = int(campione["nome"].isin(CAMPIONE_SOSTENIBILI).sum())
k_con_dubbi = k_sicure + int(campione["nome"].isin(CAMPIONE_DUBBI).sum())
trovate_kw = len(segmento)
# tre scenari: il limite basso usa solo le sicure, il limite alto conta anche le dubbie
scenari = {
"minimo certo (solo le 34 trovate)": 0.0,
f"stima bassa ({k_sicure} su {n}, limite basso al 95%)": wilson(k_sicure, n)[0],
f"stima centrale ({k_sicure} su {n})": k_sicure / n,
f"stima alta ({k_con_dubbi} su {n}, limite alto al 95%)": wilson(k_con_dubbi, n)[1],
}
stima = pd.DataFrame({
nome: {"app sostenibili perse": p * N_fuori,
"app sostenibili in tutto": trovate_kw + p * N_fuori,
"quota del catalogo": (trovate_kw + p * N_fuori) / N_store,
"copertura delle parole chiave": trovate_kw / (trovate_kw + p * N_fuori)}
for nome, p in scenari.items()}).T
display(stima.style.format({"app sostenibili perse": "{:,.0f}", "app sostenibili in tutto": "{:,.0f}",
"quota del catalogo": "{:.2%}", "copertura delle parole chiave": "{:.0%}"}))
# la quota delle installazioni è ancora più incerta: due app del campione da 10 milioni
# pesano più di tutte le altre insieme. La riporto solo come ordine di grandezza.
inst_campione = campione.loc[campione["nome"].isin(CAMPIONE_SOSTENIBILI), "installazioni"].sum()
quota_inst_kw = segmento["installazioni"].sum() / app["installazioni"].sum()
quota_inst_stimata = quota_inst_kw + inst_campione * N_fuori / n / app["installazioni"].sum()
print(f"Quota delle installazioni: {quota_inst_kw:.2%} con le sole 34, "
f"circa {quota_inst_stimata:.1%} contando le perse (stima grezza)")
| app sostenibili perse | app sostenibili in tutto | quota del catalogo | copertura delle parole chiave | |
|---|---|---|---|---|
| minimo certo (solo le 34 trovate) | 0 | 34 | 0.35% | 100% |
| stima bassa (7 su 1000, limite basso al 95%) | 33 | 67 | 0.69% | 51% |
| stima centrale (7 su 1000) | 67 | 101 | 1.05% | 34% |
| stima alta (11 su 1000, limite alto al 95%) | 188 | 222 | 2.30% | 15% |
Quota delle installazioni: 0.03% con le sole 34, circa 0.9% contando le perse (stima grezza)
Risultato Nel campione ci sono 7 app sostenibili su 1.000, più 4 dubbie: le parole chiave vedono circa un'app sostenibile su tre.
| app sostenibili | quota del catalogo | copertura delle parole chiave | |
|---|---|---|---|
| minimo certo (le 34 trovate) | 34 | 0,35% | — |
| stima bassa | 67 | 0,69% | 51% |
| stima centrale | 101 | 1,05% | 34% |
| stima alta (con le dubbie) | 222 | 2,30% | 15% |
Quindi la frase giusta non è «il segmento è lo 0,35% del catalogo», ma: il segmento vale fra lo 0,35% e il 2,3% del catalogo, con una stima centrale intorno all'1%. Resta un segmento piccolo, ma fino a sei volte più grande di quanto dicessi.
Tre cose che il campione insegna sul metodo:
- Le app perse sono quelle che non si dichiarano «verdi». OLX compra e vende l'usato senza la parola second hand; NTES, Yandex.Transport e DB Streckenagent sono app di treni e mezzi pubblici che non scrivono transit. È lo stesso errore di Olio, che citavo fra i limiti: ora è misurato.
- Anche le installazioni erano sottostimate, e di molto. Tre app del campione hanno da 10 a 50 milioni di installazioni (OLX da sola 50 milioni): contando le perse, la quota del segmento sulle installazioni passa dallo 0,03% a un ordine di grandezza vicino all'1%. Con tre sole app grandi nel campione, è una stima molto incerta.
- Il quadro dei temi regge. Nel campione le app di treni e mezzi pubblici hanno da 500.000 a 10 milioni di installazioni, l'unica app di ricarica elettrica ne ha 1.000: lo stesso divario che si vede fra i 34.
# --- I temi sostenibili: dove c'è davvero un pubblico ---
# 1) raggruppo le 34 app per tema e per ognuno calcolo: quante app ci sono,
# le installazioni tipiche, il voto tipico e da quanto non vengono aggiornate
per_tema = segmento.groupby("tema").agg(
numero_app=("nome", "count"),
installazioni_mediane=("installazioni", "median"),
voto_mediano=("voto", "median"),
giorni_da_aggiornamento=("giorni_da_aggiornamento", "median"),
).sort_values("installazioni_mediane", ascending=False)
display(per_tema)
# 2) nomi leggibili per i grafici, con la ricarica elettrica evidenziata in arancione
etichette = [t.replace("_", " ").replace("mobilita", "mobilità") for t in per_tema.index]
colori = [ARANCIONE if t == "mobilita_elettrica" else BLU for t in per_tema.index]
righe = range(len(etichette))
giorni_tipici_store = app["giorni_da_aggiornamento"].median()
# 3) preparo due grafici affiancati e ravvicinati, con gli stessi temi in verticale
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.2), sharey=True,
gridspec_kw={"wspace": 0.06})
# 4) una riga sì e una no con lo sfondo grigio chiaro, uguale nei due grafici,
# per seguire un tema con lo sguardo da sinistra a destra
for ax in (ax1, ax2):
for r in righe:
if r % 2 == 0:
ax.axhspan(r - 0.5, r + 0.5, color="#eeeeec", zorder=0)
ax.grid(axis="y", visible=False)
ax.set_ylim(len(etichette) - 0.5, -0.5) # stesso ordine dall'alto in basso
# 5) primo grafico — installazioni tipiche per tema (in scala logaritmica),
# con accanto il valore e il numero di app del tema
ax1.barh(righe, per_tema["installazioni_mediane"], height=0.65, color=colori, zorder=2)
ax1.set_xscale("log")
ax1.set_yticks(righe, etichette, fontsize=9.5)
ax1.set_xlim(right=per_tema["installazioni_mediane"].max() * 9)
ax1.set_xticks([1_000, 10_000, 100_000, 1_000_000], ["1.000", "10.000", "100.000", "1 milione"])
ax1.set_xlabel("installazioni mediane (scala logaritmica)")
ax1.set_title("Il pubblico c'è solo dove l'app\nrisolve un problema pratico")
for r, (v, n) in zip(righe, zip(per_tema["installazioni_mediane"], per_tema["numero_app"])):
ax1.text(v * 1.3, r, f"{v:,.0f}".replace(",", ".") + f" ({n} app)",
va="center", fontsize=8)
# 6) secondo grafico — giorni passati dall'ultimo aggiornamento, per tema
# (la linea nera è il valore tipico di tutto lo store)
ax2.barh(righe, per_tema["giorni_da_aggiornamento"], height=0.65, color=colori, zorder=2)
ax2.axvline(giorni_tipici_store, color="black", linewidth=1.1, zorder=3)
ax2.set_xlim(0, per_tema["giorni_da_aggiornamento"].max() * 1.15)
ax2.set_xlabel("giorni dall'ultimo aggiornamento")
ax2.set_title("Ma non per abbandono: le app di ricarica\n"
f"sono aggiornate di recente (linea = store, {giorni_tipici_store:.0f} gg)")
for r, v in zip(righe, per_tema["giorni_da_aggiornamento"]):
# il numero va scritto dopo la barra, o dopo la linea nera se la barra è più corta,
# così non finisce sopra la linea
ax2.text(max(v, giorni_tipici_store) + 12, r, f"{v:.0f} gg", va="center", fontsize=8)
plt.show()
| numero_app | installazioni_mediane | voto_mediano | giorni_da_aggiornamento | |
|---|---|---|---|---|
| tema | ||||
| mobilita_attiva | 1 | 1000000.0 | 4.50 | 43.0 |
| mobilita_pubblica | 11 | 500000.0 | 4.40 | 85.0 |
| alimentazione | 2 | 255000.0 | 4.35 | 191.5 |
| energia | 5 | 10000.0 | 4.30 | 595.0 |
| natura_clima | 1 | 1000.0 | 3.90 | 1013.0 |
| mobilita_elettrica | 9 | 1000.0 | 3.70 | 82.0 |
| rifiuti_riuso | 3 | 1000.0 | 4.10 | 371.0 |
| informazione | 2 | 505.0 | 4.80 | 339.5 |
Risultato Qui la domanda di partenza cambia.
Il metodo a parole chiave ha una precisione del 51,5% (un candidato su due era un falso positivo) e, come visto in 7.1, una copertura intorno al 34%. Le 34 app trovate sono lo 0,35% del catalogo e raccolgono lo 0,03% delle installazioni; contando quelle perse il segmento sale all'1% circa del catalogo, con un margine di incertezza ampio. Resta un segmento piccolo, come numero di app e come pubblico.
Il dato più importante, però, è il confronto tra due temi:
| Tema | App | Installazioni tipiche | Voto tipico |
|---|---|---|---|
| trasporto pubblico | 11 | 500.000 | 4,40 |
| ricarica auto elettriche | 9 | 1.000 | 3,70 |
Quasi lo stesso numero di app, ma un pubblico cinquecento volte diverso. E il secondo grafico esclude la spiegazione più comoda: le app di ricarica elettrica non sono abbandonate. Il loro ultimo aggiornamento risale in genere a 82 giorni prima, meno del valore tipico dello store (96 giorni), ed è il più recente tra tutti i temi con più di un'app. Sono prodotti curati e aggiornati, ma senza utenti.
La mia lettura, che i dati non possono confermare perché non contengono il numero di auto elettriche in circolazione: nel 2018 quelle app servivano a pochissime persone, perché le auto elettriche erano poche. Il trasporto pubblico invece lo usano tutti, e nessuno scarica Citymapper per salvare il pianeta: lo scarica per sapere quando passa il tram. Entrambi i temi sono pratici; a fare la differenza sembra essere quante persone hanno il problema che l'app risolve, non il fatto che l'app sia «verde».
Quindi la domanda cambia. Non più «in quale nicchia verde entro?», ma:
In quale categoria conviene pubblicare un'app che risolve un problema concreto e diffuso e che, come effetto, riduce l'impatto ambientale di chi la usa?
8. Quale categoria scegliere¶
Prendo undici categorie in cui un'app di sostenibilità potrebbe stare, e le confronto con tre misure già calcolate:
- domanda — la percentuale di app che supera il milione di installazioni (più alta è, meglio è);
- concorrenti — il numero di app nella categoria (meno sono, meglio è);
- leader — la quota di pubblico presa dalla prima app (più bassa è, meglio è).
Per metterle insieme parto dal metodo più semplice: per ogni misura faccio una classifica delle categorie, poi sommo le tre posizioni. Vince chi ha il totale più basso, come in una gara a punti.
Il metodo ha due scelte nascoste, che nella prima versione non dichiaravo:
- dà lo stesso peso alle tre misure. Sommare le posizioni non vuol dire «non decidere i pesi»: vuol dire sceglierli uguali;
- butta via l'ampiezza delle differenze. Una categoria avanti di un decimo di punto guadagna una posizione intera, esattamente come una avanti di trenta punti.
Per questo la tabella mostra le misure grezze accanto alle posizioni, e in 8.1 metto alla prova la classifica cambiando metodo e pesi.
# --- Classifica delle categorie candidate, sommando le posizioni ---
# 1) le 11 categorie in cui un'app di sostenibilità potrebbe stare
CANDIDATE = ["FOOD_AND_DRINK", "HOUSE_AND_HOME", "EDUCATION", "SHOPPING",
"HEALTH_AND_FITNESS", "MAPS_AND_NAVIGATION", "LIFESTYLE", "TOOLS",
"AUTO_AND_VEHICLES", "TRAVEL_AND_LOCAL", "PRODUCTIVITY"]
candidate = per_categoria.loc[CANDIDATE].copy()
# 2) per ogni misura do una posizione in classifica (1 = la migliore):
# - domanda: più app oltre il milione = posizione migliore
# - concorrenti: meno app = posizione migliore
# - leader: prima app più debole = posizione migliore
candidate["pos_domanda"] = candidate["quota_oltre_1M"].rank(ascending=False)
candidate["pos_concorrenti"] = candidate["numero_app"].rank(ascending=True)
candidate["pos_leader"] = candidate["quota_prima_app"].rank(ascending=True)
# 3) sommo le tre posizioni: vince il totale più basso
candidate["totale"] = candidate[["pos_domanda", "pos_concorrenti", "pos_leader"]].sum(axis=1)
# 4) ordino dalla migliore alla peggiore e mostro, per ogni misura,
# il valore grezzo accanto alla posizione che gli corrisponde
classifica = candidate.sort_values("totale")
display(classifica[["quota_oltre_1M", "pos_domanda", "numero_app", "pos_concorrenti",
"quota_prima_app", "pos_leader", "totale"]]
.style.format({"quota_oltre_1M": "{:.1%}", "quota_prima_app": "{:.1%}",
"pos_domanda": "{:.0f}", "pos_concorrenti": "{:.0f}",
"pos_leader": "{:.0f}", "totale": "{:.0f}"}))
| quota_oltre_1M | pos_domanda | numero_app | pos_concorrenti | quota_prima_app | pos_leader | totale | |
|---|---|---|---|---|---|---|---|
| categoria | |||||||
| EDUCATION | 57.4% | 1 | 108 | 3 | 5.3% | 2 | 6 |
| FOOD_AND_DRINK | 43.8% | 3 | 112 | 4 | 4.7% | 1 | 8 |
| HOUSE_AND_HOME | 40.5% | 6 | 74 | 1 | 10.3% | 4 | 11 |
| SHOPPING | 52.5% | 2 | 202 | 6 | 7.1% | 3 | 11 |
| MAPS_AND_NAVIGATION | 41.2% | 5 | 131 | 5 | 19.9% | 9 | 19 |
| AUTO_AND_VEHICLES | 21.2% | 11 | 85 | 2 | 18.8% | 7 | 20 |
| TRAVEL_AND_LOCAL | 42.5% | 4 | 219 | 7 | 34.5% | 10 | 21 |
| PRODUCTIVITY | 39.5% | 7 | 375 | 10 | 17.2% | 6 | 23 |
| TOOLS | 33.2% | 9 | 829 | 11 | 12.3% | 5 | 25 |
| HEALTH_AND_FITNESS | 38.9% | 8 | 288 | 8 | 43.7% | 11 | 27 |
| LIFESTYLE | 22.8% | 10 | 369 | 9 | 19.8% | 8 | 27 |
Risultato Ai primi posti della classifica c'è EDUCATION con 6 punti, seguita da FOOD & DRINK con 8. Più indietro, a pari merito con 11 punti, ci sono HOUSE & HOME e SHOPPING.
Leggendo le misure grezze accanto alle posizioni si vede quanto la somma appiattisce. Sulla domanda SHOPPING (52,5%) è seconda e FOOD & DRINK (43,8%) è terza: una posizione di differenza per quasi nove punti percentuali. Sul leader FOOD & DRINK (4,7%) è prima ed EDUCATION (5,3%) seconda: una posizione intera per mezzo punto. E il margine fra le prime due, 6 contro 8, sono due posizioni su undici: poco, per un metodo così grossolano.
8.1 Quanto è solida questa classifica?¶
La metto alla prova in due modi.
- Un punteggio che conserva le distanze. Per ogni misura porto la peggiore delle undici categorie a 0 e la migliore a 1, in proporzione: una categoria avanti di un decimo prende un decimo, non una posizione intera. Poi faccio la media dei tre punteggi.
- Pesi diversi. Provo tutte le 36 combinazioni di pesi in cui ogni misura pesa almeno il 10%, a passi del 10% (per esempio 60% domanda, 20% concorrenti, 20% leader), con entrambi i metodi, e conto quante volte vince ciascuna categoria.
# --- 8.1 La classifica cambiando metodo e pesi ---
def scala_0_1(serie, meglio_alto):
"""Porta una misura fra 0 (la peggiore) e 1 (la migliore), conservando le distanze."""
s = (serie - serie.min()) / (serie.max() - serie.min())
return s if meglio_alto else 1 - s
def misure(tabella):
"""Per un gruppo di categorie: posizioni e punteggi 0-1 delle tre misure."""
posizioni = pd.DataFrame({"domanda": tabella["quota_oltre_1M"].rank(ascending=False),
"concorrenti": tabella["numero_app"].rank(),
"leader": tabella["quota_prima_app"].rank()})
punteggi = pd.DataFrame({"domanda": scala_0_1(tabella["quota_oltre_1M"], True),
"concorrenti": scala_0_1(tabella["numero_app"], False),
"leader": scala_0_1(tabella["quota_prima_app"], False)})
return posizioni, punteggi
# 1) tutte le combinazioni di pesi: ogni misura almeno 10%, a passi del 10%
PESI = [(a / 10, b / 10, (10 - a - b) / 10) for a in range(1, 9) for b in range(1, 10 - a)]
def vittorie(tabella):
"""Quante volte, su 36 combinazioni di pesi, ogni categoria arriva prima."""
posizioni, punteggi = misure(tabella)
conto = {"con le posizioni": pd.Series(0.0, index=tabella.index),
"con il punteggio 0-1": pd.Series(0.0, index=tabella.index)}
for w in PESI:
totale = (posizioni * w).sum(axis=1) # più basso = meglio
primi = totale[totale == totale.min()].index
conto["con le posizioni"][primi] += 1 / len(primi) # i pari merito si dividono la vittoria
conto["con il punteggio 0-1"][(punteggi * w).sum(axis=1).idxmax()] += 1
return pd.DataFrame(conto)
# 2) la tabella completa: misure grezze, totale delle posizioni, punteggio 0-1, vittorie
posizioni, punteggi = misure(candidate)
solidita = candidate[["quota_oltre_1M", "numero_app", "quota_prima_app", "totale"]].copy()
solidita["punteggio 0-1 (pesi uguali)"] = punteggi.mean(axis=1)
solidita = solidita.join(vittorie(candidate).add_prefix("vittorie su 36 "))
solidita = solidita.sort_values("totale")
display(solidita.style.format({"quota_oltre_1M": "{:.1%}", "quota_prima_app": "{:.1%}",
"totale": "{:.0f}", "punteggio 0-1 (pesi uguali)": "{:.3f}",
"vittorie su 36 con le posizioni": "{:.1f}",
"vittorie su 36 con il punteggio 0-1": "{:.0f}"}))
| quota_oltre_1M | numero_app | quota_prima_app | totale | punteggio 0-1 (pesi uguali) | vittorie su 36 con le posizioni | vittorie su 36 con il punteggio 0-1 | |
|---|---|---|---|---|---|---|---|
| categoria | |||||||
| EDUCATION | 57.4% | 108 | 5.3% | 6 | 0.980 | 27.5 | 36 |
| FOOD_AND_DRINK | 43.8% | 112 | 4.7% | 8 | 0.858 | 4.5 | 0 |
| HOUSE_AND_HOME | 40.5% | 74 | 10.3% | 11 | 0.797 | 4.0 | 0 |
| SHOPPING | 52.5% | 202 | 7.1% | 11 | 0.877 | 0.0 | 0 |
| MAPS_AND_NAVIGATION | 41.2% | 131 | 19.9% | 19 | 0.696 | 0.0 | 0 |
| AUTO_AND_VEHICLES | 21.2% | 85 | 18.8% | 20 | 0.541 | 0.0 | 0 |
| TRAVEL_AND_LOCAL | 42.5% | 219 | 34.5% | 21 | 0.544 | 0.0 | 0 |
| PRODUCTIVITY | 39.5% | 375 | 17.2% | 23 | 0.595 | 0.0 | 0 |
| TOOLS | 33.2% | 829 | 12.3% | 25 | 0.379 | 0.0 | 0 |
| HEALTH_AND_FITNESS | 38.9% | 288 | 43.7% | 27 | 0.402 | 0.0 | 0 |
| LIFESTYLE | 22.8% | 369 | 19.8% | 27 | 0.422 | 0.0 | 0 |
Risultato Finché EDUCATION è in gara, vince quasi sempre: con le posizioni in 27,5 combinazioni di pesi su 36, con il punteggio 0-1 in tutte e 36. Il punteggio, però, cambia l'ordine dietro di lei: SHOPPING (0,877) passa davanti a FOOD & DRINK (0,858), perché la somma di posizioni nascondeva i nove punti di domanda in più di SHOPPING.
Nella sezione 5, però, avevo notato che i numeri di EDUCATION erano un po' troppo belli per essere veri. Prima di sceglierla voglio capire se c'è qualcosa che mi sfugge.
8.2 Un controllo che cambia tutto¶
Nel dataset ci sono due colonne che a prima vista sembrano uguali:
categoriaindica lo "scaffale" dello store su cui l'app viene mostrata;generiindica di che tipo di app si tratta.
Mi aspetterei che coincidano: un'app educativa dovrebbe stare nello scaffale EDUCATION. Per verificarlo conto quante app hanno un certo genere e quante di queste si trovano davvero nella categoria con lo stesso nome.
# --- Categoria e genere: dicono davvero la stessa cosa? ---
# 1) un'app può avere più generi separati da ";" (es. "Education;Pretend Play"):
# explode() crea una riga per ogni genere, così posso contarli uno per uno
per_genere = app.assign(genere=app["generi"].str.split(";")).explode("genere")
per_genere["genere"] = per_genere["genere"].str.strip()
# 2) per alcune categorie confronto quante app stanno nella CATEGORIA
# e quante hanno quel GENERE: se il rapporto è 1, le due colonne coincidono
confronti = [("Education", "EDUCATION"), ("Entertainment", "ENTERTAINMENT"),
("Food & Drink", "FOOD_AND_DRINK"), ("House & Home", "HOUSE_AND_HOME"),
("Shopping", "SHOPPING"), ("Maps & Navigation", "MAPS_AND_NAVIGATION")]
display(pd.DataFrame([
{"ambito": genere,
"app nella CATEGORIA": (app["categoria"] == categoria).sum(),
"app con quel GENERE": per_genere.loc[per_genere["genere"] == genere, "nome"].nunique(),
"rapporto": per_genere.loc[per_genere["genere"] == genere, "nome"].nunique()
/ (app["categoria"] == categoria).sum()}
for genere, categoria in confronti]).round(2))
# 3) le app con genere "Education": in quale categoria sono state messe?
educative = per_genere[per_genere["genere"] == "Education"]
dove = educative.groupby("categoria").agg(
numero_app=("nome", "nunique"),
installazioni_mediane=("installazioni", "median"),
quota_oltre_1M=("installazioni", lambda s: (s >= 1_000_000).mean()),
).sort_values("numero_app", ascending=False)
display(dove.round(3))
# 4) grafico: installazioni tipiche delle app educative,
# a seconda dello "scaffale" (categoria) su cui sono esposte
fig, ax = plt.subplots(figsize=(7.5, 3))
valori = [app.loc[app["categoria"] == "EDUCATION", "installazioni"].median(),
educative.loc[educative["categoria"] == "FAMILY", "installazioni"].median()]
n_cat_edu = (app["categoria"] == "EDUCATION").sum()
n_fam_edu = educative.loc[educative["categoria"] == "FAMILY", "nome"].nunique()
ax.bar([f"Categoria EDUCATION\n({n_cat_edu} app)", f"Genere Education,\nscaffale FAMILY ({n_fam_edu} app)"],
valori, color=[ARANCIONE, BLU], width=0.45)
ax.set_yscale("log")
ax.set_ylabel("installazioni mediane (log)")
ax.set_title("Lo stesso genere, due collocamenti:\ndipende dallo scaffale su cui il negozio lo mette")
for i, v in enumerate(valori):
ax.text(i, v * 1.4, f"{v:,.0f}", ha="center", fontweight="bold", fontsize=11)
ax.set_ylim(top=max(valori) * 4)
plt.tight_layout()
plt.show()
print(f"Rapporto fra i due collocamenti: {valori[0] / valori[1]:.0f} volte")
| ambito | app nella CATEGORIA | app con quel GENERE | rapporto | |
|---|---|---|---|---|
| 0 | Education | 108 | 642 | 5.94 |
| 1 | Entertainment | 87 | 592 | 6.80 |
| 2 | Food & Drink | 112 | 112 | 1.00 |
| 3 | House & Home | 74 | 74 | 1.00 |
| 4 | Shopping | 202 | 202 | 1.00 |
| 5 | Maps & Navigation | 131 | 131 | 1.00 |
| numero_app | installazioni_mediane | quota_oltre_1M | |
|---|---|---|---|
| categoria | |||
| FAMILY | 526 | 5000.0 | 0.126 |
| EDUCATION | 108 | 1000000.0 | 0.574 |
| PARENTING | 7 | 100000.0 | 0.429 |
| GAME | 1 | 100000.0 | 0.000 |
| TOOLS | 1 | 10000000.0 | 1.000 |
Rapporto fra i due collocamenti: 200 volte
# --- Classifica finale, senza la categoria distorta (EDUCATION) ---
# 1) tolgo EDUCATION e ricalcolo TUTTO sulle dieci rimaste: posizioni, punteggi 0-1, vittorie
dieci = candidate.drop(index="EDUCATION")
posizioni, punteggi = misure(dieci)
classifica_finale = dieci.copy()
classifica_finale["totale"] = posizioni.sum(axis=1)
classifica_finale["punteggio 0-1"] = punteggi.mean(axis=1)
classifica_finale = classifica_finale.join(vittorie(dieci).add_prefix("vittorie su 36 "))
classifica_finale = classifica_finale.sort_values("totale")
classifica_finale.insert(0, "posizione", range(1, len(classifica_finale) + 1))
# 2) mostro la classifica finale con gli indicatori principali
display(classifica_finale[["posizione", "numero_app", "quota_oltre_1M", "quota_prima_app",
"recensioni_per_1000", "quota_a_pagamento", "giorni_da_aggiornamento",
"totale", "punteggio 0-1", "vittorie su 36 con le posizioni",
"vittorie su 36 con il punteggio 0-1"]]
.style.format({"quota_oltre_1M": "{:.1%}", "quota_prima_app": "{:.1%}",
"recensioni_per_1000": "{:.1f}", "quota_a_pagamento": "{:.1%}",
"giorni_da_aggiornamento": "{:.0f}", "totale": "{:.0f}",
"punteggio 0-1": "{:.3f}", "vittorie su 36 con le posizioni": "{:.1f}",
"vittorie su 36 con il punteggio 0-1": "{:.0f}"}))
# 3) le vittorie delle prime categorie, in un grafico
prime = classifica_finale.head(4)
fig, ax = plt.subplots(figsize=(8, 3))
y = np.arange(len(prime))
ax.barh(y - 0.18, prime["vittorie su 36 con le posizioni"], height=0.34, color=BLU,
label="sommando le posizioni")
ax.barh(y + 0.18, prime["vittorie su 36 con il punteggio 0-1"], height=0.34, color=ARANCIONE,
label="con il punteggio 0-1")
ax.set_yticks(y, [nome_leggibile(c) for c in prime.index])
ax.invert_yaxis()
ax.set_xlim(0, 36)
ax.set_xlabel("combinazioni di pesi vinte, su 36")
ax.set_title("Senza EDUCATION, il vincitore dipende dal metodo")
ax.legend(loc="lower right", fontsize=8, frameon=False)
plt.tight_layout()
plt.show()
| posizione | numero_app | quota_oltre_1M | quota_prima_app | recensioni_per_1000 | quota_a_pagamento | giorni_da_aggiornamento | totale | punteggio 0-1 | vittorie su 36 con le posizioni | vittorie su 36 con il punteggio 0-1 | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| categoria | |||||||||||
| FOOD_AND_DRINK | 1 | 112 | 43.8% | 4.7% | 13.6 | 1.8% | 36 | 6 | 0.890 | 25.5 | 12 |
| SHOPPING | 2 | 202 | 52.5% | 7.1% | 17.2 | 1.0% | 22 | 8 | 0.923 | 4.5 | 22 |
| HOUSE_AND_HOME | 3 | 74 | 40.5% | 10.3% | 10.2 | 0.0% | 27 | 9 | 0.825 | 6.0 | 2 |
| MAPS_AND_NAVIGATION | 4 | 131 | 41.2% | 19.9% | 15.0 | 3.8% | 54 | 16 | 0.725 | 0.0 | 0 |
| AUTO_AND_VEHICLES | 5 | 85 | 21.2% | 18.8% | 14.0 | 3.5% | 36 | 18 | 0.541 | 0.0 | 0 |
| TRAVEL_AND_LOCAL | 6 | 219 | 42.5% | 34.5% | 11.6 | 5.5% | 56 | 18 | 0.574 | 0.0 | 0 |
| PRODUCTIVITY | 7 | 375 | 39.5% | 17.2% | 15.2 | 7.5% | 101 | 20 | 0.621 | 0.0 | 0 |
| TOOLS | 8 | 829 | 33.2% | 12.3% | 13.4 | 9.4% | 151 | 22 | 0.396 | 0.0 | 0 |
| LIFESTYLE | 9 | 369 | 22.8% | 19.8% | 12.8 | 5.1% | 139 | 24 | 0.424 | 0.0 | 0 |
| HEALTH_AND_FITNESS | 10 | 288 | 38.9% | 43.7% | 20.9 | 5.2% | 38 | 24 | 0.427 | 0.0 | 0 |
Risultato Questo controllo cambia la scelta finale.
Ci sono 642 app di tipo educativo (genere «Education»), ma solo 108 stanno nella categoria EDUCATION. Le altre 526 sono nella categoria FAMILY. E i due gruppi vanno in modo molto diverso:
| app | installazioni tipiche | oltre il milione | |
|---|---|---|---|
| nella categoria EDUCATION | 108 | 1.000.000 | 57,4% |
| educative, ma nella categoria FAMILY | 526 | 5.000 | 12,6% |
Sono app dello stesso tipo, ma le prime hanno duecento volte le installazioni delle seconde.
Il motivo non è il mercato, è il modo in cui Google organizza lo store: le app educative per bambini vanno in FAMILY. Nella categoria EDUCATION restano poche app, quasi tutte già famose. Quindi non è una nicchia libera dove è facile emergere: è uno scaffale dove arrivano solo le app che ce l'hanno già fatta. Io stavo guardando quei numeri e li scambiavo per un'opportunità.
Lo stesso succede con ENTERTAINMENT: 592 app hanno quel genere, ma solo 87 stanno nella categoria. Le due categorie che nella sezione 5 sembravano le più promettenti sono proprio queste due. Il dubbio che avevo era giusto, ed escludo EDUCATION.
Senza EDUCATION, però, i dati non danno un vincitore netto. Ricalcolo tutto sulle dieci categorie rimaste. Sommando le posizioni vince FOOD & DRINK (6 punti contro gli 8 di SHOPPING e i 9 di HOUSE & HOME, prima in 25,5 combinazioni di pesi su 36). Con il punteggio che conserva le distanze vince SHOPPING (0,923 contro 0,890, prima in 22 combinazioni su 36 contro le 12 di FOOD & DRINK). Le due categorie si scambiano il primo posto a seconda di come si misura, e HOUSE & HOME resta terza in entrambi i casi. Per scegliere fra loro serve un'informazione che il dataset non contiene: la sezione 9 la aggiunge, e lo dichiara.
9. La strategia: dove conviene lanciare l'app?¶
Torno alla domanda da cui sono partito: un'app per la sostenibilità, dove conviene lanciarla?
I numeri della sezione 8 dicono com'è fatta una categoria, ma non quanto è difficile costruirci sopra un'app. Aggiungo quindi una mia valutazione della difficoltà di ingresso. Nel dataset questa informazione non c'è: è un mio giudizio, e la tabella qui sotto la tiene in una colonna separata da quello che dicono i dati.
La regola, scritta prima di guardare l'esito: quando le due colonne non concordano, prevale la difficoltà stimata. Un'app che non so costruire, o che richiede un'organizzazione che non ho, non diventa una buona scelta perché la sua categoria ha numeri migliori.
# --- Dati e giudizio, nella stessa tabella ---
# 1) il mio giudizio sulla difficoltà di ingresso: NON viene dal dataset
GIUDIZIO = {
"FOOD_AND_DRINK": ("bassa",
"un'app contro lo spreco di cibo funziona con il solo telefono: scadenze, dispensa, "
"ricette con quello che c'è in frigo"),
"SHOPPING": ("alta",
"un mercato dell'usato funziona solo con tanti venditori e compratori insieme; "
"i concorrenti sono piattaforme enormi (nel campione di 7.1 OLX ha 50 milioni di installazioni)"),
"HOUSE_AND_HOME": ("alta",
"le app più scaricate sono annunci immobiliari; misurare i consumi di casa "
"richiede dati o apparecchi che non ho"),
"MAPS_AND_NAVIGATION": ("alta",
"un'app di mobilità sostenibile richiede gli orari e le reti dei trasporti, "
"città per città"),
}
# 2) le prime quattro della classifica finale, con le due colonne affiancate
decisione = classifica_finale.loc[list(GIUDIZIO), ["posizione", "punteggio 0-1",
"vittorie su 36 con le posizioni",
"vittorie su 36 con il punteggio 0-1"]].copy()
decisione.columns = ["posizione secondo i dati (somma)", "punteggio 0-1",
"vittorie (posizioni)", "vittorie (punteggio)"]
decisione["difficoltà stimata (mio giudizio)"] = [GIUDIZIO[c][0] for c in decisione.index]
decisione["perché"] = [GIUDIZIO[c][1] for c in decisione.index]
decisione["esito"] = np.where(decisione["difficoltà stimata (mio giudizio)"] == "bassa",
"scelta", "scartata")
decisione.index = [nome_leggibile(c) for c in decisione.index]
with pd.option_context("display.max_colwidth", 110):
display(decisione.style.format({"punteggio 0-1": "{:.3f}", "vittorie (posizioni)": "{:.1f}",
"vittorie (punteggio)": "{:.0f}"}))
| posizione secondo i dati (somma) | punteggio 0-1 | vittorie (posizioni) | vittorie (punteggio) | difficoltà stimata (mio giudizio) | perché | esito | |
|---|---|---|---|---|---|---|---|
| Food & Drink | 1 | 0.890 | 25.5 | 12 | bassa | un'app contro lo spreco di cibo funziona con il solo telefono: scadenze, dispensa, ricette con quello che c'è in frigo | scelta |
| Shopping | 2 | 0.923 | 4.5 | 22 | alta | un mercato dell'usato funziona solo con tanti venditori e compratori insieme; i concorrenti sono piattaforme enormi (nel campione di 7.1 OLX ha 50 milioni di installazioni) | scartata |
| House & Home | 3 | 0.825 | 6.0 | 2 | alta | le app più scaricate sono annunci immobiliari; misurare i consumi di casa richiede dati o apparecchi che non ho | scartata |
| Maps & Navigation | 4 | 0.725 | 0.0 | 0 | alta | un'app di mobilità sostenibile richiede gli orari e le reti dei trasporti, città per città | scartata |
Risultato Nelle prime quattro colonne SHOPPING e FOOD & DRINK si contendono il primo posto (sezione 8.2); la quinta le separa. La scelta di FOOD & DRINK viene dal giudizio sulla difficoltà, non dalla classifica, ed è giusto dirlo così: se un giorno avessi la struttura per un mercato dell'usato, i dati non mi impedirebbero di scegliere SHOPPING.
La risposta è quindi Food & Drink, con un'app che aiuta a non sprecare il cibo in casa.
E c'è un ultimo argomento: ho letto i nomi di tutte le 112 app della categoria, ed esiste una sola app contro lo spreco di cibo, Frigo Magic, con mezzo milione di installazioni. Le più vicine sono app di ricette con lista della spesa, che non nascono per questo scopo. Le persone installano questo tipo di app, ma quasi nessuno la offre.
# --- Il concorrente diretto: com'è messo rispetto alla sua categoria ---
# 1) prendo le app di Food & Drink e cerco Frigo Magic, l'unica app anti-spreco
food = app[app["categoria"] == "FOOD_AND_DRINK"]
frigo = food[food["nome"].str.contains("Frigo", case=False)]
display(frigo[["nome", "installazioni", "voto", "recensioni_per_1000",
"dimensione_mb", "giorni_da_aggiornamento", "tipo"]])
# 2) confronto Frigo Magic con il valore tipico (mediana) della categoria
# e con il quartile migliore (il livello raggiunto dal 25% delle app più forti)
riferimenti = pd.DataFrame({
"Frigo Magic": [frigo["installazioni"].iloc[0], frigo["voto"].iloc[0],
frigo["recensioni_per_1000"].iloc[0], frigo["dimensione_mb"].iloc[0]],
"mediana Food & Drink": [food["installazioni"].median(), food["voto"].median(),
food["recensioni_per_1000"].median(),
food["dimensione_mb"].median()],
"quartile migliore": [food["installazioni"].quantile(0.75), food["voto"].quantile(0.75),
food["recensioni_per_1000"].quantile(0.75),
food["dimensione_mb"].quantile(0.75)],
}, index=["installazioni", "voto", "recensioni per 1.000 inst.", "dimensione (MB)"])
display(riferimenti.round(2))
# 3) quante app della categoria hanno un voto alto, e quante sono a pagamento
print(f"App di Food & Drink con voto >= 4,5: {(food['voto'] >= 4.5).sum()} su {len(food)} "
f"({(food['voto'] >= 4.5).mean():.0%}) | app a pagamento: "
f"{(food['tipo'] == 'A pagamento').mean():.1%}")
votate = food["voto"].dropna()
print(f"App votate con un voto più alto di Frigo Magic: {(votate > frigo['voto'].iloc[0]).sum()} "
f"su {len(votate)} ({(votate > frigo['voto'].iloc[0]).mean():.0%})")
| nome | installazioni | voto | recensioni_per_1000 | dimensione_mb | giorni_da_aggiornamento | tipo | |
|---|---|---|---|---|---|---|---|
| 1185 | Frigo Magic: Easy recipe idea and anti-waste | 500000 | 4.1 | 4.946 | 14.0 | 11 | Gratuita |
| Frigo Magic | mediana Food & Drink | quartile migliore | |
|---|---|---|---|
| installazioni | 500000.00 | 300000.00 | 1000000.00 |
| voto | 4.10 | 4.30 | 4.50 |
| recensioni per 1.000 inst. | 4.95 | 13.57 | 30.22 |
| dimensione (MB) | 14.00 | 17.00 | 27.00 |
App di Food & Drink con voto >= 4,5: 37 su 112 (33%) | app a pagamento: 1.8% App votate con un voto più alto di Frigo Magic: 58 su 94 (62%)
Risultato Frigo Magic è l'unica app contro lo spreco di cibo della categoria, e il suo profilo descrive un mercato che esiste, ma che nessuno sta servendo bene:
- 500.000 installazioni: la domanda c'è, non bisogna inventarla. È il rischio più grande, ed è già escluso.
- voto 4,1, sotto il valore tipico della categoria (4,3): quasi due terzi delle app votate di Food & Drink (58 su 94) hanno un voto più alto, e 37 su 112 arrivano almeno a 4,5.
- 4,95 recensioni ogni 1.000 installazioni, contro le 13,6 della categoria: un terzo del coinvolgimento normale. Mezzo milione di persone l'ha installata, ma quasi nessuno ha avuto voglia di scriverne.
Cosa ne deduco, e con quale cautela. ⚠ legame, non causa. Nella prima versione scrivevo che, siccome «il voto segue il coinvolgimento», questi due numeri erano il segno di un'app che si installa e si dimentica, e che la debolezza «si batte con un prodotto fatto meglio». È proprio il salto che la sezione 4.2 vieta: in 4.1 ho visto che voto e coinvolgimento salgono insieme, non che uno causi l'altro. Quello che i dati dicono di Frigo Magic è più modesto: è sotto la categoria su entrambe le misure. Che sia un'app dimenticata dopo l'installazione è un'ipotesi: compatibile con i numeri, ma ce ne sono altre (un pubblico che scrive poco per abitudine, recensioni chieste di rado). Per questo il piano non la dà per vera: la mette alla prova nei primi sei mesi (9.2).
9.1 La proposta¶
Nome di lavoro: Dal frigo non si butta niente · categoria: FOOD_AND_DRINK · età minima: Everyone (adatta a tutti)
Il messaggio principale: «Smetti di buttare la spesa», cioè soldi e fatica risparmiati. Non «Riduci la tua impronta di carbonio». È una mia scelta, e la dichiaro come tale: il dataset non contiene i messaggi delle app e non può dire quale funzioni meglio. È coerente con la lettura della sezione 7, dove le app con più pubblico sono quelle che risolvono un problema che hanno in tanti: lo spreco di cibo riguarda chiunque faccia la spesa, non solo chi è sensibile all'ambiente.
Il beneficio ambientale resta dentro l'app e diventa qualcosa da mostrare all'utente, per esempio «questo mese non hai buttato 4,2 kg di cibo», ma come premio, non come argomento di vendita. È anche un modo per provare ad aumentare il coinvolgimento, che è proprio la misura su cui Frigo Magic è più debole.
Ogni scelta dice da quale dato viene e che tipo di prova ha dietro:
| Scelta | Valore | Da quale dato viene | Tipo di prova |
|---|---|---|---|
| categoria | FOOD_AND_DRINK |
leader al 4,7% (il più debole dello store), 112 app, 43,8% oltre il milione | dati + giudizio sulla difficoltà (sez. 9) |
| modello | gratuita + abbonamento facoltativo | H2: le app gratuite hanno 100 volte le installazioni; in questa categoria solo l'1,8% è a pagamento | ⚠ confronto fra app diverse, non effetto del prezzo |
| prezzo abbonamento | 2,99 $ al mese | H3: la fascia 1,50–2,99 $ ha più app oltre le 10.000 installazioni | ⚠ legame; le fasce contengono app diverse: è un punto di partenza da provare, non un prezzo dimostrato |
| voto da raggiungere | almeno 4,5 | è il livello del 25% delle app migliori della categoria; Frigo Magic è a 4,1 | descrittivo: un obiettivo, non una previsione |
| coinvolgimento | almeno 20 recensioni ogni 1.000 installazioni | è il livello di Education e Health; Frigo Magic è a 4,95 | descrittivo: un obiettivo |
| dimensione | massimo 17 MB | valore tipico della categoria | descrittivo; non è dimostrato che un'app più leggera vada meglio |
| aggiornamenti | almeno uno al mese | nella categoria l'ultimo aggiornamento risale in genere a 36 giorni prima | ⚠ H4 è un legame: lo tengo come buona pratica e ne misuro l'effetto |
| obiettivo a 18 mesi | 500.000 installazioni | come Frigo Magic; il valore tipico della categoria è 300.000 | descrittivo; la scadenza è un mio giudizio (il dataset non ha date di lancio) |
9.2 Quando fermarsi¶
Un piano serio dice anche quando ammettere che non sta funzionando. Siccome diverse scelte poggiano su legami e non su cause, gli indicatori servono anche a verificare quei legami sulla mia app.
| Indicatore | Obiettivo | Se dopo 6 mesi non ci siamo |
|---|---|---|
| voto | almeno 4,5 | sotto 4,2 l'app non piace: va riprogettata, non pubblicizzata |
| recensioni ogni 1.000 installazioni | almeno 20 | sotto 10 l'ipotesi «app installata e dimenticata» vale anche per me |
| installazioni | 500.000 in 18 mesi | sotto 50.000 dopo 6 mesi, la categoria o il messaggio sono sbagliati |
| utenti che si abbonano | almeno il 2% | sotto l'1% serve un altro modo per guadagnare, o un altro prezzo |
In che ordine lavorare: prima il voto e il coinvolgimento, poi le installazioni, l'abbonamento per ultimo. È un ordine che scelgo io, non una legge dei dati: punta sulla misura dove l'unico concorrente è più debole. Se l'ipotesi su Frigo Magic fosse sbagliata, me ne accorgerei dal secondo indicatore, e cambierei ordine.
10. Limiti¶
Ogni analisi ha dei limiti. Ecco quelli di questa, cioè le cose che i dati non mi permettono di dire.
I dati sono del 2018. È il limite più importante, perché il mercato nel frattempo è cambiato. Per esempio, nella sezione 7 ho visto che le app per ricaricare le auto elettriche non avevano utenti. Oggi quasi certamente non è più così, perché le auto elettriche sono molte di più. Quel risultato vale quindi solo per il 2018. Quello che resta valido è il modo in cui ho ragionato.
Le installazioni non sono numeri precisi. Il dataset non dice "12.345 installazioni", ma solo fasce come "oltre 10.000". Le fasce sono venti in tutto. Per questo, se due categorie differiscono di meno di 5 volte, non posso dire con sicurezza quale sia davvero più scaricata.
Il segmento sostenibilità è stimato, non contato. Le parole chiave ne trovano circa una su tre (sezione 7.1). La stima viene da 1.000 app lette a mano: l'intervallo è ampio (dallo 0,35% al 2,3% del catalogo) e la classificazione resta un giudizio, con alcuni casi dubbi dichiarati.
La classifica delle categorie dipende dal metodo (sezione 8.1): senza EDUCATION, FOOD & DRINK e SHOPPING si scambiano il primo posto. La scelta finale poggia su un giudizio dichiarato.
Correlazioni, non cause (sezione 4.2). Voto e coinvolgimento, voto e aggiornamenti, prezzo e installazioni si muovono insieme, ma questi dati non dicono perché. Le scelte della strategia che ci poggiano sono segnate con ⚠ e diventano cose da misurare sulla propria app.
Non ho dati economici. Non so quanto guadagna un'app, quanto costa farla conoscere, quanto tempo le persone la usano o se la riaprono dopo una settimana. Le mie conclusioni sul modello di business si basano solo su installazioni e voto, perché sono gli unici dati disponibili.
Vedo solo le app che ce l'hanno fatta. Le app tolte dallo store perché non funzionavano non sono nel dataset. Per questo le possibilità di successo che ho calcolato sono più alte di quelle reali. Inoltre il voto esiste solo per le app che qualcuno ha usato, quindi le medie dei voti valgono solo per quelle.
Non ho fatto test statistici sulle differenze fra gruppi. Ho confrontato gruppi di app guardando i numeri; l'unico intervallo di confidenza è quello della stima del segmento. Per sapere se una differenza piccola potrebbe essere dovuta al caso servirebbe un test statistico, che sarebbe il passo successivo di questo lavoro.
La lezione più utile di questa analisi¶
Nel 2018 nove sviluppatori avevano già avuto la mia stessa idea: un'app per ricaricare le auto elettriche. Le loro app avevano in genere mille installazioni e i voti più bassi tra le app sostenibili, anche se venivano aggiornate spesso. La mia lettura, che il dataset non può confermare, è che fosse il momento sbagliato: le auto elettriche erano ancora poche.
Un'analisi di mercato serve a scegliere il settore giusto, ma può anche far nascere la domanda giusta sul momento. Tante app e pochi utenti è un segnale da indagare: può voler dire che il mercato non è pronto, o che le app non sono buone, e i dati da soli non distinguono le due cose. Con lo spreco di cibo il segnale è opposto: c'è una sola app, e mezzo milione di persone l'ha già installata.
E una lezione di metodo, che devo alla correzione: un numero vale quanto la prova che lo accompagna. Per questo la sezione 11 ricalcola ogni numero citato nel testo.
11. Registro dei numeri citati¶
Ogni numero che il testo usa per argomentare è ricalcolato qui dagli stessi dati, e confrontato con il valore scritto. Se un giorno cambiassero i dati o il codice, la colonna «coincide» lo direbbe subito.
# --- 11. Ogni numero del testo, ricalcolato e confrontato con quello scritto ---
def it(x, decimali=0, percentuale=False):
"""Formatta come nel testo: punto per le migliaia, virgola per i decimali."""
if percentuale:
x = x * 100
s = f"{x:,.{decimali}f}".replace(",", "§").replace(".", ",").replace("§", ".")
return s + ("%" if percentuale else "")
v = app["voto"].dropna()
gratis, pagam = app[app["tipo"] == "Gratuita"], app[app["tipo"] == "A pagamento"]
ap_no_scherzi = pagam[pagam["prezzo"] < 100]
fasce_prezzo = pd.cut(ap_no_scherzi["prezzo"], bins=[0, 1.49, 2.99, 5.99, 14.99, 100])
oltre_10k = ap_no_scherzi.groupby(fasce_prezzo, observed=True)["installazioni"].apply(lambda s: (s >= 1e4).mean())
pc = per_categoria
sv = app[app["voto"].isna()]
tema = segmento.groupby("tema").agg(n=("nome", "count"), inst=("installazioni", "median"),
voto=("voto", "median"), giorni=("giorni_da_aggiornamento", "median"))
coinv = app.dropna(subset=["voto", "recensioni_per_1000"])
quintili = coinv.groupby(pd.qcut(coinv["recensioni_per_1000"], 5, labels=False))["voto"].mean()
aggiorn = app.dropna(subset=["voto"]).groupby(pd.cut(app["giorni_da_aggiornamento"],
bins=[-1, 30, 90, 180, 365, 10_000]),
observed=True)["voto"].mean()
votate_food = food["voto"].dropna()
cf = classifica_finale
NUMERI = [
("1", "righe del file grezzo", "10.841", it(len(grezzo))),
("1", "voti validi nel file grezzo", "9.367", it(grezzo["Rating"].notna().sum())),
("1.1", "righe identiche da eliminare", "483", it(grezzo.duplicated().sum())),
("1.1", "righe diverse ripetute (conteggio di ydata)", "410", it(len(grezzo[grezzo.duplicated(keep=False)].drop_duplicates()))),
("1.3", "nomi ripetuti", "523", it(ripetute["App"].nunique())),
("1.3", "righe con un nome già visto", "698", it(len(ripetute) - ripetute["App"].nunique())),
("1.3", "nomi con stessa categoria e installazioni", "432", it((esito == "stessa categoria, stesse installazioni").sum())),
("1.3", "nomi con categoria diversa", "76", it((esito == "categoria diversa, stesse installazioni").sum())),
("1.3", "nomi con installazioni diverse", "15", it((esito == "installazioni diverse").sum())),
("1.3", "voto mancante nelle righe grezze", "13,6%", it(grezzo["Rating"].isna().mean(), 1, True)),
("1.3", "app analizzate", "9.674", it(len(app))),
("1.3", "recensioni mediane: senza voto / con voto", "1 / 3.020", f"{it(sv['recensioni'].median())} / {it(app.loc[app['voto'].notna(), 'recensioni'].median())}"),
("1.3", "installazioni mediane: senza voto / con voto", "100 / 100.000", f"{it(sv['installazioni'].median())} / {it(app.loc[app['voto'].notna(), 'installazioni'].median())}"),
("1.3", "app senza voto con meno di 10 recensioni", "83,5%", it((sv["recensioni"] < 10).mean(), 1, True)),
("2", "media / mediana: recensioni", "223", it(app["recensioni"].mean() / app["recensioni"].median())),
("2", "media / mediana: installazioni", "78", it(app["installazioni"].mean() / app["installazioni"].median())),
("2", "app con voto sopra 4,0", "76,7%", it((v >= 4).mean(), 1, True)),
("2", "app con voto sotto 3,0", "3,4%", it((v < 3).mean(), 1, True)),
("2", "voto mediano", "4,3", it(v.median(), 1)),
("2", "inizio del quartile superiore del voto", "4,5", it(v.quantile(0.75), 1)),
("2", "installazioni del top 1% delle app", "49,4%", it(app["installazioni"].nlargest(int(len(app) * 0.01)).sum() / app["installazioni"].sum(), 1, True)),
("2", "installazioni del top 10% delle app", "88,1%", it(app["installazioni"].nlargest(int(len(app) * 0.10)).sum() / app["installazioni"].sum(), 1, True)),
("2", "app gratuite", "92,2%", it(len(gratis) / len(app), 1, True)),
("2", "prezzo mediano delle app a pagamento", "2,99", it(pagam["prezzo"].median(), 2)),
("2", "dimensione mediana (MB)", "12", it(app["dimensione_mb"].median())),
("2", "giorni dall'ultimo aggiornamento (mediana)", "96", it(app["giorni_da_aggiornamento"].median())),
("3", "outlier IQR sulle recensioni", "1.661", it(conta_outlier(app["recensioni"])["n outlier"])),
("3", "outlier IQR sulle recensioni, in %", "17,2%", it(conta_outlier(app["recensioni"])["% outlier"] / 100, 1, True)),
("3", "outlier IQR sul voto", "492", it(conta_outlier(app["voto"])["n outlier"])),
("3", "app a pagamento", "756", it(len(pagam))),
("3", "media dei prezzi con / senza le 20 app da 100 $", "14,05 / 4,74", f"{it(pagam['prezzo'].mean(), 2)} / {it(ap_no_scherzi['prezzo'].mean(), 2)}"),
("4.1", "voto medio: quintile meno / più recensito", "3,96 / 4,44", f"{it(quintili.iloc[0], 2)} / {it(quintili.iloc[-1], 2)}"),
("4.1", "app coperte dal file delle recensioni", "819", it(len(unite))),
("5", "app in FAMILY", "1.877", it(pc.loc["FAMILY", "numero_app"])),
("5", "quota installazioni di FAMILY", "8,3%", it(pc.loc["FAMILY", "quota_installazioni"], 1, True)),
("5", "indice domanda/offerta di FAMILY", "0,43", it(pc.loc["FAMILY", "domanda_su_offerta"], 2)),
("5", "indice domanda/offerta di COMMUNICATION", "4,49", it(pc.loc["COMMUNICATION", "domanda_su_offerta"], 2)),
("5", "quota della prima app in BOOKS (Google Play Books)", "60,0%", it(pc.loc["BOOKS_AND_REFERENCE", "quota_prima_app"], 1, True)),
("5", "quota della prima app in HEALTH (Samsung Health)", "43,7%", it(pc.loc["HEALTH_AND_FITNESS", "quota_prima_app"], 1, True)),
("5", "quota della prima app in FOOD & DRINK", "4,7%", it(pc.loc["FOOD_AND_DRINK", "quota_prima_app"], 1, True)),
("5", "app oltre il milione, tutto lo store", "35,2%", it((app["installazioni"] >= 1e6).mean(), 1, True)),
("5", "app oltre il milione: ENTERTAINMENT / EDUCATION", "83,9% / 57,4%", f"{it(pc.loc['ENTERTAINMENT', 'quota_oltre_1M'], 1, True)} / {it(pc.loc['EDUCATION', 'quota_oltre_1M'], 1, True)}"),
("5", "app oltre il milione: SHOPPING / FOOD & DRINK", "52,5% / 43,8%", f"{it(pc.loc['SHOPPING', 'quota_oltre_1M'], 1, True)} / {it(pc.loc['FOOD_AND_DRINK', 'quota_oltre_1M'], 1, True)}"),
("5", "coinvolgimento tipico: store / GAME / EDUCATION / HEALTH", "17 / 28,5 / 21,1 / 20,9", f"{it(app['recensioni_per_1000'].median())} / {it(pc.loc['GAME', 'recensioni_per_1000'], 1)} / {it(pc.loc['EDUCATION', 'recensioni_per_1000'], 1)} / {it(pc.loc['HEALTH_AND_FITNESS', 'recensioni_per_1000'], 1)}"),
("6", "voto mediano: a pagamento / gratuite", "4,4 / 4,3", f"{it(pagam['voto'].median(), 1)} / {it(gratis['voto'].median(), 1)}"),
("6", "installazioni mediane: gratuite / a pagamento", "100.000 / 1.000", f"{it(gratis['installazioni'].median())} / {it(pagam['installazioni'].median())}"),
("6", "oltre il milione: gratuite / a pagamento", "37,9% / 2,9%", f"{it((gratis['installazioni'] >= 1e6).mean(), 1, True)} / {it((pagam['installazioni'] >= 1e6).mean(), 1, True)}"),
("6", "indice prezzo ~ installazioni", "+0,14", "+" + it(ap_no_scherzi[["prezzo", "installazioni"]].corr(method="spearman").iloc[0, 1], 2)),
("6", "oltre 10.000: fino a 1,49 $ / 1,50-2,99 $ / 15 $ e oltre", "19,7% / 47,1% / 21,2%", " / ".join(it(oltre_10k.iloc[i], 1, True) for i in (0, 1, -1))),
("6", "temi grafici fino a 1,49 $ / app mediche sopra 15 $", "27% / 39%", f"{it(composizione.loc['fino a 1,49 $', 'PERSONALIZATION'], 0, True)} / {it(composizione.loc['15 $ e oltre', 'MEDICAL'], 0, True)}"),
("6", "voto medio: aggiornata da meno di un mese / oltre un anno", "4,27 / 4,06", f"{it(aggiorn.iloc[0], 2)} / {it(aggiorn.iloc[-1], 2)}"),
("6", "escursione delle mediane del voto fra categorie", "0,4", it(categorie_grandi["median"].max() - categorie_grandi["median"].min(), 1)),
("7", "candidati trovati dalle parole chiave", "66", it(len(candidati))),
("7", "app sostenibili fra i candidati / scartate", "34 / 32", f"{it(len(segmento))} / {it(len(scartate))}"),
("7", "precisione delle parole chiave", "51,5%", it(len(segmento) / len(candidati), 1, True)),
("7", "segmento: quota del catalogo (solo le 34)", "0,35%", it(len(segmento) / len(app), 2, True)),
("7", "segmento: quota delle installazioni (solo le 34)", "0,03%", it(segmento["installazioni"].sum() / app["installazioni"].sum(), 2, True)),
("7", "trasporto pubblico: app / installazioni / voto", "11 / 500.000 / 4,40", f"{it(tema.loc['mobilita_pubblica', 'n'])} / {it(tema.loc['mobilita_pubblica', 'inst'])} / {it(tema.loc['mobilita_pubblica', 'voto'], 2)}"),
("7", "ricarica elettrica: app / installazioni / voto / giorni", "9 / 1.000 / 3,70 / 82", f"{it(tema.loc['mobilita_elettrica', 'n'])} / {it(tema.loc['mobilita_elettrica', 'inst'])} / {it(tema.loc['mobilita_elettrica', 'voto'], 2)} / {it(tema.loc['mobilita_elettrica', 'giorni'])}"),
("7.1", "app fuori dai candidati", "9.608", it(len(fuori))),
("7.1", "sostenibili nel campione: sicure / con le dubbie", "7 / 11", f"{k_sicure} / {k_con_dubbi}"),
("7.1", "app sostenibili in tutto: bassa / centrale / alta", "67 / 101 / 222", " / ".join(it(x) for x in stima["app sostenibili in tutto"].iloc[1:])),
("7.1", "quota del catalogo: bassa / centrale / alta", "0,69% / 1,05% / 2,30%", " / ".join(it(x, 2, True) for x in stima["quota del catalogo"].iloc[1:])),
("7.1", "copertura: bassa / centrale / alta", "51% / 34% / 15%", " / ".join(it(x, 0, True) for x in stima["copertura delle parole chiave"].iloc[1:])),
("7.1", "quota installazioni contando le perse (ordine di grandezza)", "0,9%", it(quota_inst_stimata, 1, True)),
("8", "totale EDUCATION / FOOD & DRINK / HOUSE / SHOPPING", "6 / 8 / 11 / 11", " / ".join(it(classifica.loc[c, "totale"]) for c in ["EDUCATION", "FOOD_AND_DRINK", "HOUSE_AND_HOME", "SHOPPING"])),
("8.1", "vittorie di EDUCATION: posizioni / punteggio", "27,5 / 36", f"{it(solidita.loc['EDUCATION', 'vittorie su 36 con le posizioni'], 1)} / {it(solidita.loc['EDUCATION', 'vittorie su 36 con il punteggio 0-1'])}"),
("8.1", "punteggio 0-1: SHOPPING / FOOD & DRINK (con EDUCATION)", "0,877 / 0,858", f"{it(solidita.loc['SHOPPING', 'punteggio 0-1 (pesi uguali)'], 3)} / {it(solidita.loc['FOOD_AND_DRINK', 'punteggio 0-1 (pesi uguali)'], 3)}"),
("8.2", "app di genere Education / nella categoria EDUCATION / in FAMILY", "642 / 108 / 526", f"{it(per_genere.loc[per_genere['genere'] == 'Education', 'nome'].nunique())} / {it(n_cat_edu)} / {it(n_fam_edu)}"),
("8.2", "app di genere Entertainment / nella categoria", "592 / 87", f"{it(per_genere.loc[per_genere['genere'] == 'Entertainment', 'nome'].nunique())} / {it((app['categoria'] == 'ENTERTAINMENT').sum())}"),
("8.2", "senza EDUCATION, vittorie FOOD & DRINK: posizioni / punteggio", "25,5 / 12", f"{it(cf.loc['FOOD_AND_DRINK', 'vittorie su 36 con le posizioni'], 1)} / {it(cf.loc['FOOD_AND_DRINK', 'vittorie su 36 con il punteggio 0-1'])}"),
("8.2", "senza EDUCATION, vittorie SHOPPING col punteggio", "22", it(cf.loc["SHOPPING", "vittorie su 36 con il punteggio 0-1"])),
("8.2", "senza EDUCATION, totale FOOD & DRINK / SHOPPING / HOUSE", "6 / 8 / 9", " / ".join(it(cf.loc[c, "totale"]) for c in ["FOOD_AND_DRINK", "SHOPPING", "HOUSE_AND_HOME"])),
("8.2", "senza EDUCATION, punteggio 0-1 SHOPPING / FOOD & DRINK", "0,923 / 0,890", f"{it(cf.loc['SHOPPING', 'punteggio 0-1'], 3)} / {it(cf.loc['FOOD_AND_DRINK', 'punteggio 0-1'], 3)}"),
("9", "app in FOOD & DRINK / in SHOPPING / in HOUSE & HOME", "112 / 202 / 74", " / ".join(it(pc.loc[c, "numero_app"]) for c in ["FOOD_AND_DRINK", "SHOPPING", "HOUSE_AND_HOME"])),
("9", "FOOD & DRINK: a pagamento / giorni dall'aggiornamento", "1,8% / 36", f"{it(pc.loc['FOOD_AND_DRINK', 'quota_a_pagamento'], 1, True)} / {it(pc.loc['FOOD_AND_DRINK', 'giorni_da_aggiornamento'])}"),
("9", "Frigo Magic: installazioni / voto / recensioni per 1.000", "500.000 / 4,1 / 4,95", f"{it(frigo['installazioni'].iloc[0])} / {it(frigo['voto'].iloc[0], 1)} / {it(frigo['recensioni_per_1000'].iloc[0], 2)}"),
("9", "FOOD & DRINK: voto / coinvolgimento / installazioni / MB tipici", "4,3 / 13,6 / 300.000 / 17", f"{it(food['voto'].median(), 1)} / {it(food['recensioni_per_1000'].median(), 1)} / {it(food['installazioni'].median())} / {it(food['dimensione_mb'].median())}"),
("9", "FOOD & DRINK: quartile migliore del voto", "4,5", it(food["voto"].quantile(0.75), 1)),
("9", "app con voto più alto di Frigo Magic / app votate", "58 / 94", f"{it((votate_food > frigo['voto'].iloc[0]).sum())} / {it(len(votate_food))}"),
("9", "app di FOOD & DRINK con voto almeno 4,5", "37", it((food["voto"] >= 4.5).sum())),
]
registro_numeri = pd.DataFrame(NUMERI, columns=["sezione", "numero citato", "scritto nel testo", "ricalcolato"])
registro_numeri["coincide"] = np.where(registro_numeri["scritto nel testo"] == registro_numeri["ricalcolato"], "sì", "NO")
with pd.option_context("display.max_rows", 200, "display.max_colwidth", 70):
display(registro_numeri)
print(f"{(registro_numeri['coincide'] == 'sì').sum()} numeri su {len(registro_numeri)} coincidono con il testo")
| sezione | numero citato | scritto nel testo | ricalcolato | coincide | |
|---|---|---|---|---|---|
| 0 | 1 | righe del file grezzo | 10.841 | 10.841 | sì |
| 1 | 1 | voti validi nel file grezzo | 9.367 | 9.367 | sì |
| 2 | 1.1 | righe identiche da eliminare | 483 | 483 | sì |
| 3 | 1.1 | righe diverse ripetute (conteggio di ydata) | 410 | 410 | sì |
| 4 | 1.3 | nomi ripetuti | 523 | 523 | sì |
| 5 | 1.3 | righe con un nome già visto | 698 | 698 | sì |
| 6 | 1.3 | nomi con stessa categoria e installazioni | 432 | 432 | sì |
| 7 | 1.3 | nomi con categoria diversa | 76 | 76 | sì |
| 8 | 1.3 | nomi con installazioni diverse | 15 | 15 | sì |
| 9 | 1.3 | voto mancante nelle righe grezze | 13,6% | 13,6% | sì |
| 10 | 1.3 | app analizzate | 9.674 | 9.674 | sì |
| 11 | 1.3 | recensioni mediane: senza voto / con voto | 1 / 3.020 | 1 / 3.020 | sì |
| 12 | 1.3 | installazioni mediane: senza voto / con voto | 100 / 100.000 | 100 / 100.000 | sì |
| 13 | 1.3 | app senza voto con meno di 10 recensioni | 83,5% | 83,5% | sì |
| 14 | 2 | media / mediana: recensioni | 223 | 223 | sì |
| 15 | 2 | media / mediana: installazioni | 78 | 78 | sì |
| 16 | 2 | app con voto sopra 4,0 | 76,7% | 76,7% | sì |
| 17 | 2 | app con voto sotto 3,0 | 3,4% | 3,4% | sì |
| 18 | 2 | voto mediano | 4,3 | 4,3 | sì |
| 19 | 2 | inizio del quartile superiore del voto | 4,5 | 4,5 | sì |
| 20 | 2 | installazioni del top 1% delle app | 49,4% | 49,4% | sì |
| 21 | 2 | installazioni del top 10% delle app | 88,1% | 88,1% | sì |
| 22 | 2 | app gratuite | 92,2% | 92,2% | sì |
| 23 | 2 | prezzo mediano delle app a pagamento | 2,99 | 2,99 | sì |
| 24 | 2 | dimensione mediana (MB) | 12 | 12 | sì |
| 25 | 2 | giorni dall'ultimo aggiornamento (mediana) | 96 | 96 | sì |
| 26 | 3 | outlier IQR sulle recensioni | 1.661 | 1.661 | sì |
| 27 | 3 | outlier IQR sulle recensioni, in % | 17,2% | 17,2% | sì |
| 28 | 3 | outlier IQR sul voto | 492 | 492 | sì |
| 29 | 3 | app a pagamento | 756 | 756 | sì |
| 30 | 3 | media dei prezzi con / senza le 20 app da 100 $ | 14,05 / 4,74 | 14,05 / 4,74 | sì |
| 31 | 4.1 | voto medio: quintile meno / più recensito | 3,96 / 4,44 | 3,96 / 4,44 | sì |
| 32 | 4.1 | app coperte dal file delle recensioni | 819 | 819 | sì |
| 33 | 5 | app in FAMILY | 1.877 | 1.877 | sì |
| 34 | 5 | quota installazioni di FAMILY | 8,3% | 8,3% | sì |
| 35 | 5 | indice domanda/offerta di FAMILY | 0,43 | 0,43 | sì |
| 36 | 5 | indice domanda/offerta di COMMUNICATION | 4,49 | 4,49 | sì |
| 37 | 5 | quota della prima app in BOOKS (Google Play Books) | 60,0% | 60,0% | sì |
| 38 | 5 | quota della prima app in HEALTH (Samsung Health) | 43,7% | 43,7% | sì |
| 39 | 5 | quota della prima app in FOOD & DRINK | 4,7% | 4,7% | sì |
| 40 | 5 | app oltre il milione, tutto lo store | 35,2% | 35,2% | sì |
| 41 | 5 | app oltre il milione: ENTERTAINMENT / EDUCATION | 83,9% / 57,4% | 83,9% / 57,4% | sì |
| 42 | 5 | app oltre il milione: SHOPPING / FOOD & DRINK | 52,5% / 43,8% | 52,5% / 43,8% | sì |
| 43 | 5 | coinvolgimento tipico: store / GAME / EDUCATION / HEALTH | 17 / 28,5 / 21,1 / 20,9 | 17 / 28,5 / 21,1 / 20,9 | sì |
| 44 | 6 | voto mediano: a pagamento / gratuite | 4,4 / 4,3 | 4,4 / 4,3 | sì |
| 45 | 6 | installazioni mediane: gratuite / a pagamento | 100.000 / 1.000 | 100.000 / 1.000 | sì |
| 46 | 6 | oltre il milione: gratuite / a pagamento | 37,9% / 2,9% | 37,9% / 2,9% | sì |
| 47 | 6 | indice prezzo ~ installazioni | +0,14 | +0,14 | sì |
| 48 | 6 | oltre 10.000: fino a 1,49 $ / 1,50-2,99 $ / 15 $ e oltre | 19,7% / 47,1% / 21,2% | 19,7% / 47,1% / 21,2% | sì |
| 49 | 6 | temi grafici fino a 1,49 $ / app mediche sopra 15 $ | 27% / 39% | 27% / 39% | sì |
| 50 | 6 | voto medio: aggiornata da meno di un mese / oltre un anno | 4,27 / 4,06 | 4,27 / 4,06 | sì |
| 51 | 6 | escursione delle mediane del voto fra categorie | 0,4 | 0,4 | sì |
| 52 | 7 | candidati trovati dalle parole chiave | 66 | 66 | sì |
| 53 | 7 | app sostenibili fra i candidati / scartate | 34 / 32 | 34 / 32 | sì |
| 54 | 7 | precisione delle parole chiave | 51,5% | 51,5% | sì |
| 55 | 7 | segmento: quota del catalogo (solo le 34) | 0,35% | 0,35% | sì |
| 56 | 7 | segmento: quota delle installazioni (solo le 34) | 0,03% | 0,03% | sì |
| 57 | 7 | trasporto pubblico: app / installazioni / voto | 11 / 500.000 / 4,40 | 11 / 500.000 / 4,40 | sì |
| 58 | 7 | ricarica elettrica: app / installazioni / voto / giorni | 9 / 1.000 / 3,70 / 82 | 9 / 1.000 / 3,70 / 82 | sì |
| 59 | 7.1 | app fuori dai candidati | 9.608 | 9.608 | sì |
| 60 | 7.1 | sostenibili nel campione: sicure / con le dubbie | 7 / 11 | 7 / 11 | sì |
| 61 | 7.1 | app sostenibili in tutto: bassa / centrale / alta | 67 / 101 / 222 | 67 / 101 / 222 | sì |
| 62 | 7.1 | quota del catalogo: bassa / centrale / alta | 0,69% / 1,05% / 2,30% | 0,69% / 1,05% / 2,30% | sì |
| 63 | 7.1 | copertura: bassa / centrale / alta | 51% / 34% / 15% | 51% / 34% / 15% | sì |
| 64 | 7.1 | quota installazioni contando le perse (ordine di grandezza) | 0,9% | 0,9% | sì |
| 65 | 8 | totale EDUCATION / FOOD & DRINK / HOUSE / SHOPPING | 6 / 8 / 11 / 11 | 6 / 8 / 11 / 11 | sì |
| 66 | 8.1 | vittorie di EDUCATION: posizioni / punteggio | 27,5 / 36 | 27,5 / 36 | sì |
| 67 | 8.1 | punteggio 0-1: SHOPPING / FOOD & DRINK (con EDUCATION) | 0,877 / 0,858 | 0,877 / 0,858 | sì |
| 68 | 8.2 | app di genere Education / nella categoria EDUCATION / in FAMILY | 642 / 108 / 526 | 642 / 108 / 526 | sì |
| 69 | 8.2 | app di genere Entertainment / nella categoria | 592 / 87 | 592 / 87 | sì |
| 70 | 8.2 | senza EDUCATION, vittorie FOOD & DRINK: posizioni / punteggio | 25,5 / 12 | 25,5 / 12 | sì |
| 71 | 8.2 | senza EDUCATION, vittorie SHOPPING col punteggio | 22 | 22 | sì |
| 72 | 8.2 | senza EDUCATION, totale FOOD & DRINK / SHOPPING / HOUSE | 6 / 8 / 9 | 6 / 8 / 9 | sì |
| 73 | 8.2 | senza EDUCATION, punteggio 0-1 SHOPPING / FOOD & DRINK | 0,923 / 0,890 | 0,923 / 0,890 | sì |
| 74 | 9 | app in FOOD & DRINK / in SHOPPING / in HOUSE & HOME | 112 / 202 / 74 | 112 / 202 / 74 | sì |
| 75 | 9 | FOOD & DRINK: a pagamento / giorni dall'aggiornamento | 1,8% / 36 | 1,8% / 36 | sì |
| 76 | 9 | Frigo Magic: installazioni / voto / recensioni per 1.000 | 500.000 / 4,1 / 4,95 | 500.000 / 4,1 / 4,95 | sì |
| 77 | 9 | FOOD & DRINK: voto / coinvolgimento / installazioni / MB tipici | 4,3 / 13,6 / 300.000 / 17 | 4,3 / 13,6 / 300.000 / 17 | sì |
| 78 | 9 | FOOD & DRINK: quartile migliore del voto | 4,5 | 4,5 | sì |
| 79 | 9 | app con voto più alto di Frigo Magic / app votate | 58 / 94 | 58 / 94 | sì |
| 80 | 9 | app di FOOD & DRINK con voto almeno 4,5 | 37 | 37 | sì |
81 numeri su 81 coincidono con il testo
Appendice come ripetere l'analisi¶
Un riepilogo di tutto quello che serve per rifare l'analisi e ottenere gli stessi risultati.
# --- Riepilogo per chi vuole rifare l'analisi: ambiente, dati e scelte fatte ---
# 1) versioni delle librerie usate
print("AMBIENTE")
print(f" pandas {pd.__version__} | numpy {np.__version__} | seaborn {sns.__version__}")
print(f" plotly: {'presente' if PLOTLY else 'assente'} | "
f"ydata-profiling: {'presente' if YDATA else 'assente'}")
# 2) da dove vengono i dati
print("\nDATI")
print(" origine kaggle.com/datasets/lava18/google-play-store-apps (licenza CC BY 3.0)")
print(f" i dati arrivano al {app['data_aggiornamento'].max():%d/%m/%Y}")
# 3) quante righe sono rimaste dopo ogni passaggio di pulizia
print("\nPULIZIA")
for _, riga in registro.iterrows():
print(f" {riga['passaggio']:42s} {riga['righe']:>6,}")
# 4) le scelte fatte durante l'analisi che influenzano i risultati
print("\nSCELTE CHE INFLUENZANO I RISULTATI")
print(" - app ripetute: stessa app = stesso nome e stessa fascia di installazioni;")
print(" della stessa app tengo la lettura con più recensioni (la più recente)")
print(" - Installs: convertite al MINIMO della fascia ('10,000+' -> 10000)")
print(" - voto mancante: NON riempito")
print(" - Size 'Varies with device': lasciato vuoto")
print(" - 20 app sopra 100 $ ('I am rich'): escluse dalle analisi sul prezzo")
print(" - segmento sostenibilità: 77 parole chiave + controllo a mano dei 66 candidati")
print(" - copertura: 1.000 app estratte a caso fuori dai candidati, lette a mano")
print(" - classifica delle categorie: pesi uguali, verificata con 36 combinazioni di pesi")
# 5) un solo passaggio casuale, con il seme fisso: i risultati non cambiano fra un'esecuzione e l'altra
print("\nUN SOLO PASSAGGIO CASUALE: l'estrazione del campione in 7.1, con seme fisso 2026.")
print("Ogni esecuzione sugli stessi file produce gli stessi risultati.")
AMBIENTE
pandas 3.0.6 | numpy 2.5.3 | seaborn 0.13.2
plotly: presente | ydata-profiling: assente
DATI
origine kaggle.com/datasets/lava18/google-play-store-apps (licenza CC BY 3.0)
i dati arrivano al 08/08/2018
PULIZIA
dataset grezzo 10,841
meno la riga sfalsata 10,840
meno le righe duplicate 10,357
meno le letture ripetute della stessa app 9,674
dataset pulito 9,674
SCELTE CHE INFLUENZANO I RISULTATI
- app ripetute: stessa app = stesso nome e stessa fascia di installazioni;
della stessa app tengo la lettura con più recensioni (la più recente)
- Installs: convertite al MINIMO della fascia ('10,000+' -> 10000)
- voto mancante: NON riempito
- Size 'Varies with device': lasciato vuoto
- 20 app sopra 100 $ ('I am rich'): escluse dalle analisi sul prezzo
- segmento sostenibilità: 77 parole chiave + controllo a mano dei 66 candidati
- copertura: 1.000 app estratte a caso fuori dai candidati, lette a mano
- classifica delle categorie: pesi uguali, verificata con 36 combinazioni di pesi
UN SOLO PASSAGGIO CASUALE: l'estrazione del campione in 7.1, con seme fisso 2026.
Ogni esecuzione sugli stessi file produce gli stessi risultati.
Note finali: strumenti di intelligenza artificiale utilizzati¶
Per l'impostazione delle analisi e la revisione del codice mi sono avvalso di Claude (Anthropic), modelli Opus 5, Sonnet 5 e Opus 5.5, utilizzato all'interno di Google Antigravity e, per la revisione di ottobre 2026, di Claude Code. Le 1.000 app del campione della sezione 7.1 sono state lette una per una con Claude, applicando le regole scritte in sezione 7; un secondo passaggio con un modello locale (Qwen 3.8 via Ollama) non ha aggiunto casi nuovi, ed è risultato molto più prudente (ne ha riconosciuti solo due).
Le scelte di analisi, la verifica dei risultati e le conclusioni sono mie. Ogni numero riportato nel testo è ricalcolato nella sezione 11, che lo confronta con il valore scritto.