Spaces:
Sleeping
Sleeping
| 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)") | |
| 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.") | |