adriens
fix: pre-compute KDE server-side with scipy to fix blank after density chart
a18c5af
Raw
History Blame Contribute Delete
12.8 kB
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.")