import streamlit as st import pandas as pd import numpy as np import altair as alt import plotly.express as px from datasets import load_dataset from scipy.stats import gaussian_kde st.set_page_config( page_title="Mobitag Explorer", page_icon="📱", layout="wide", ) st.title("📱 Mobitag — Activité SMS Nouvelle-Calédonie") st.caption("Dataset : [opt-nc/mobitag](https://huggingface.co/datasets/opt-nc/mobitag) · Heure locale NC (UTC+11)") @st.cache_data(show_spinner="Chargement du dataset…") def load_data(): ds = load_dataset("opt-nc/mobitag") df = pd.concat([ds["train"].to_pandas(), ds["test"].to_pandas()]) df["date"] = pd.to_datetime(df["date"]) df["date_local"] = df["date"] + pd.Timedelta(hours=11) return df df = load_data() day_fr = {"Monday":"Lundi","Tuesday":"Mardi","Wednesday":"Mercredi", "Thursday":"Jeudi","Friday":"Vendredi","Saturday":"Samedi","Sunday":"Dimanche"} day_order_fr = ["Lundi","Mardi","Mercredi","Jeudi","Vendredi","Samedi","Dimanche"] month_names = {1:"Jan",2:"Fév",3:"Mar",4:"Avr",5:"Mai",6:"Jun", 7:"Jul",8:"Aoû",9:"Sep",10:"Oct",11:"Nov",12:"Déc"} # --- Sidebar filtres --- st.sidebar.header("Filtres") years = sorted(df["year"].unique()) selected_years = st.sidebar.multiselect("Année", years, default=years) df_f = df[df["year"].isin(selected_years)] st.sidebar.markdown(f"**{len(df_f):,} événements** sélectionnés") # --- KPIs --- col1, col2, col3, col4 = st.columns(4) col1.metric("Total SMS", f"{len(df_f):,}") col2.metric("Longueur moyenne", f"{df_f['message_length'].mean():.0f} car.") col3.metric("Longueur médiane", f"{df_f['message_length'].median():.0f} car.") col4.metric("Période couverte", f"{df_f['year_month'].min()} → {df_f['year_month'].max()}") st.divider() # --- Volume journalier --- st.subheader("Volume journalier de SMS") daily_vol = df_f.groupby(df_f["date_local"].dt.date).size().reset_index(name="count") daily_vol.columns = ["date", "count"] daily_vol["date"] = pd.to_datetime(daily_vol["date"]) chart_daily = alt.Chart(daily_vol).mark_line(strokeWidth=1, opacity=0.8, color="#1f77b4").encode( x=alt.X("date:T", title="Date"), y=alt.Y("count:Q", title="SMS / jour"), tooltip=[alt.Tooltip("date:T", title="Date", format="%d/%m/%Y"), alt.Tooltip("count:Q", title="SMS")], ).properties(height=280) st.altair_chart(chart_daily, use_container_width=True) st.divider() # --- Volume mensuel --- st.subheader("Volume mensuel de SMS") monthly = df_f.groupby("year_month").size().reset_index(name="count") chart = alt.Chart(monthly).mark_bar().encode( x=alt.X("year_month:O", title="Mois", axis=alt.Axis(labelAngle=-45)), y=alt.Y("count:Q", title="Nombre de SMS"), tooltip=["year_month", "count"], ).properties(height=300) st.altair_chart(chart, use_container_width=True) st.divider() # --- Longueur moyenne des messages dans le temps --- st.subheader("Longueur moyenne des messages par mois") avg_length = df_f.groupby("year_month")["message_length"].mean().reset_index(name="avg") avg_length["avg"] = avg_length["avg"].round(1) chart_avg = alt.Chart(avg_length).mark_line(point=True, color="#2ca02c", strokeWidth=2).encode( x=alt.X("year_month:O", title="Mois", axis=alt.Axis(labelAngle=-45)), y=alt.Y("avg:Q", title="Longueur moyenne (car.)", scale=alt.Scale(zero=False)), tooltip=["year_month", alt.Tooltip("avg:Q", title="Longueur moy.", format=".1f")], ).properties(height=280) st.altair_chart(chart_avg, use_container_width=True) st.divider() # --- Comparaison année par année (volume par mois) --- st.subheader("Comparaison année par année — volume mensuel") # Détecte l'année incomplète (dernier mois < décembre) max_year = int(df["year"].max()) max_month_of_last_year = int(df[df["year"] == max_year]["month"].max()) if max_month_of_last_year < 12: st.info(f"ℹ️ **{max_year} est une année incomplète** — données disponibles jusqu'en {month_names[max_month_of_last_year]} {max_year} seulement.") yoy = df_f.groupby(["year", "month"]).size().reset_index(name="count") yoy["year"] = yoy["year"].astype(str) yoy["mois"] = yoy["month"].map(month_names) chart_yoy = alt.Chart(yoy).mark_bar().encode( x=alt.X("month:O", title="Mois", axis=alt.Axis(labelExpr= "['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'][datum.value-1]" )), xOffset=alt.XOffset("year:N"), y=alt.Y("count:Q", title="Nombre de SMS"), color=alt.Color("year:N", title="Année", scale=alt.Scale(scheme="tableau10")), tooltip=["year", "mois", "count"], ).properties(height=320) st.altair_chart(chart_yoy, use_container_width=True) st.divider() # --- Distribution horaire & jour de semaine --- col_l, col_r = st.columns(2) with col_l: st.subheader("Activité par heure (local NC)") hourly = df_f.groupby("hour").agg(count=("message_length","count"), avg_len=("message_length","mean")).reset_index() hourly["avg_len"] = hourly["avg_len"].round(1) chart_h = alt.Chart(hourly).mark_bar(color="#1f77b4").encode( x=alt.X("hour:O", title="Heure"), y=alt.Y("count:Q", title="Nombre de SMS"), tooltip=["hour", "count", alt.Tooltip("avg_len:Q", title="Longueur moy.", format=".1f")], ).properties(height=200) base = alt.Chart(hourly).encode( x=alt.X("hour:Q", title="Heure"), y=alt.Y("avg_len:Q", title="Longueur moy. (car.)", scale=alt.Scale(zero=False)), ) area = base.mark_area(color="#2ca02c", opacity=0.15) line = base.mark_line(color="#2ca02c", strokeWidth=2) trend = base.transform_loess("hour", "avg_len", bandwidth=0.4).mark_line( color="#d62728", strokeWidth=2, strokeDash=[5, 3] ) chart_len_h = (area + line + trend).properties(height=160) st.altair_chart(chart_h & chart_len_h, use_container_width=True) with col_r: st.subheader("Activité par jour de semaine") daily = df_f.groupby("day_name").size().reset_index(name="count") daily["jour"] = daily["day_name"].map(day_fr) chart_d = alt.Chart(daily).mark_bar(color="#ff7f0e").encode( x=alt.X("jour:O", title="Jour", sort=day_order_fr), y=alt.Y("count:Q", title="Nombre de SMS"), tooltip=["jour", "count"], ).properties(height=280) st.altair_chart(chart_d, use_container_width=True) st.divider() # --- Distribution longueur des messages --- st.subheader("Distribution de la longueur des messages") hist = alt.Chart(df_f.sample(min(50_000, len(df_f)))).mark_bar(opacity=0.7).encode( x=alt.X("message_length:Q", bin=alt.Bin(maxbins=40), title="Longueur (caractères)"), y=alt.Y("count():Q", title="Fréquence"), tooltip=["count()"], ).properties(height=280) st.altair_chart(hist, use_container_width=True) st.divider() # --- Sunburst jour × heure --- st.subheader("Sunburst : distribution jour de semaine / heure") sb_data = df_f.groupby(["day_name", "hour"]).size().reset_index(name="count") sb_data["jour"] = pd.Categorical(sb_data["day_name"].map(day_fr), categories=day_order_fr, ordered=True) sb_data["heure"] = sb_data["hour"].astype(str).str.zfill(2) + "h" sb_data = sb_data.sort_values(["jour", "hour"]) fig = px.sunburst( sb_data, path=["jour", "heure"], values="count", color="count", color_continuous_scale="Blues", ) fig.update_layout(margin=dict(t=10, b=10, l=10, r=10), height=550) st.plotly_chart(fig, use_container_width=True) st.divider() # --- Heatmap heure × jour --- st.subheader("Heatmap : heure × jour de semaine") heatmap_data = df_f.groupby(["day_name", "hour"]).size().reset_index(name="count") heatmap_data["jour"] = heatmap_data["day_name"].map(day_fr) heatmap = alt.Chart(heatmap_data).mark_rect().encode( x=alt.X("hour:O", title="Heure (local NC)"), y=alt.Y("jour:O", title="Jour", sort=day_order_fr), color=alt.Color("count:Q", scale=alt.Scale(scheme="blues"), title="SMS"), tooltip=["jour", "hour", "count"], ).properties(height=250) st.altair_chart(heatmap, use_container_width=True) st.divider() # --- Détermination du seuil optimal (Jenks + KMeans) --- st.subheader("📐 Détermination du seuil optimal — Jenks & KMeans") st.caption("Les deux méthodes convergent à **99 caractères** : frontière naturelle entre deux populations distinctes.") SPLIT = 99 CENTER_SHORT, CENTER_LONG = 61.5, 137.0 col_a, col_b = st.columns(2) col_a.metric("Centroïde messages courts", f"{CENTER_SHORT:.0f} chars", "cluster 1") col_b.metric("Centroïde messages longs", f"{CENTER_LONG:.0f} chars", "cluster 2") lengths = df["message_length"] # Double density plot — KDE pré-calculée côté serveur (scipy) x_range = np.linspace(1, 164, 300) kde_short = gaussian_kde(lengths[lengths <= SPLIT].values, bw_method=0.15) kde_long = gaussian_kde(lengths[lengths > SPLIT].values, bw_method=0.15) kde_df = pd.DataFrame({ "length": np.concatenate([x_range, x_range]), "density": np.concatenate([kde_short(x_range), kde_long(x_range)]), "cluster": ["Non authentifié (≤ 99)"] * 300 + ["Authentifié (> 99)"] * 300, }) color_scale = alt.Scale( domain=["Non authentifié (≤ 99)", "Authentifié (> 99)"], range=["#1f77b4", "#d62728"] ) area_density = alt.Chart(kde_df).mark_area(opacity=0.35).encode( x=alt.X("length:Q", title="Longueur (caractères)"), y=alt.Y("density:Q", title="Densité", stack=None), color=alt.Color("cluster:N", scale=color_scale, title="Cluster"), ) line_density = alt.Chart(kde_df).mark_line(strokeWidth=2).encode( x="length:Q", y=alt.Y("density:Q", stack=None), color=alt.Color("cluster:N", scale=color_scale, legend=None), ) centroid_lines = alt.Chart( pd.DataFrame({"x": [CENTER_SHORT, CENTER_LONG], "cluster": ["Non authentifié (≤ 99)", "Authentifié (> 99)"]}) ).mark_rule(strokeDash=[5, 3], strokeWidth=1.5).encode( x="x:Q", color=alt.Color("cluster:N", scale=color_scale, legend=None), ) split_rule = alt.Chart(pd.DataFrame({"x": [SPLIT]})).mark_rule( color="black", strokeWidth=2 ).encode(x="x:Q") density_chart = (area_density + line_density + centroid_lines + split_rule).properties(height=320) st.altair_chart(density_chart, use_container_width=True) st.caption("**Zone bleue** = cluster non authentifié (centroïde 62 chars) · **zone rouge** = cluster authentifié (centroïde 137 chars) · **ligne noire** = seuil à 99 chars · le chevauchement montre la frontière naturelle entre les deux populations") st.divider() # --- Analyse tarifaire freemium --- st.subheader("💰 Analyse tarifaire — Split non authentifié / authentifié") st.caption("Modèle : tier non authentifié (≤ seuil) monétisé par pub · tier authentifié (> seuil) sans pub") AVG_MSGS_PER_MONTH = 63_100 # moyenne historique du dataset col_sl1, col_sl2 = st.columns(2) with col_sl1: threshold = st.slider("Seuil non authentifié / authentifié (caractères)", min_value=50, max_value=160, value=99, step=1) with col_sl2: XPF_PER_AD = st.slider("Revenu par affichage pub (XPF)", min_value=0.1, max_value=1.0, value=1.0, step=0.1, format="%.1f XPF") n_free = int((lengths <= threshold).sum()) n_paid = int((lengths > threshold).sum()) pct_free = n_free / len(lengths) * 100 pct_paid = n_paid / len(lengths) * 100 msgs_free_monthly = AVG_MSGS_PER_MONTH * pct_free / 100 revenue_monthly = msgs_free_monthly * XPF_PER_AD revenue_annual = revenue_monthly * 12 c1, c2, c3, c4 = st.columns(4) c1.metric("Messages non authentifiés (pub)", f"{pct_free:.1f}%", f"{msgs_free_monthly:,.0f} msgs/mois") c2.metric("Messages authentifiés", f"{pct_paid:.1f}%", f"{AVG_MSGS_PER_MONTH - msgs_free_monthly:,.0f} msgs/mois") c3.metric("💰 Gain pub / mois", f"{revenue_monthly:,.0f} XPF", f"≈ {revenue_monthly/119.33:.0f} €") c4.metric("💰 Gain pub / an", f"{revenue_annual:,.0f} XPF", f"≈ {revenue_annual/119.33:.0f} €") # Courbe CDF avec seuil cdf_data = pd.DataFrame({ "length": range(1, 165), "pct_free": [(lengths <= t).sum() / len(lengths) * 100 for t in range(1, 165)], }) rule = alt.Chart(pd.DataFrame({"x": [threshold]})).mark_rule( color="red", strokeWidth=2, strokeDash=[6, 3] ).encode(x="x:Q") cdf_line = alt.Chart(cdf_data).mark_line(color="#1f77b4", strokeWidth=2).encode( x=alt.X("length:Q", title="Longueur message (caractères)"), y=alt.Y("pct_free:Q", title="% messages non authentifiés (CDF)", scale=alt.Scale(domain=[0, 100])), tooltip=["length", alt.Tooltip("pct_free:Q", format=".1f", title="% non authentifié")], ) st.altair_chart(cdf_line + rule, use_container_width=True) st.caption(f"La ligne rouge marque le seuil à **{threshold} caractères** — {pct_free:.1f}% des messages sont non authentifiés, {pct_paid:.1f}% sont authentifiés.")