Spaces:
Paused
Paused
File size: 11,102 Bytes
c29dbfe | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | """
Radar d'avis clients — TP13.
Point d'entrée Streamlit. st.set_page_config DOIT être le premier appel
Streamlit du script (sinon Streamlit lève une erreur).
"""
import datetime as dt
import io
import pandas as pd
import streamlit as st
from moteur import (
TAILLE_LOT,
analyser_sentiments,
charger_moteur,
detecter_themes,
evaluer_contre_verite_terrain,
resumer_avis_negatifs,
)
st.set_page_config(page_title="Radar d'avis clients", page_icon="📡", layout="wide")
COLONNES_OBLIGATOIRES = {"id", "date", "avis"}
CHEMIN_EXEMPLE = "exemple_avis.csv"
THEMES_DISPONIBLES = ["réseau", "facturation", "service client", "application mobile", "prix"]
# -----------------------------------------------------------------------------
# Ingestion et nettoyage (étape 2.1) — mise en cache_data sur le CONTENU en
# octets du fichier, jamais sur l'objet UploadedFile (qui n'est pas hashable
# de façon stable d'un rerun à l'autre : la clé de cache changerait à chaque
# exécution et le cache ne servirait jamais).
# -----------------------------------------------------------------------------
@st.cache_data(show_spinner=False)
def charger_et_nettoyer(contenu_octets: bytes) -> pd.DataFrame:
df = pd.read_csv(io.BytesIO(contenu_octets), encoding="utf-8")
colonnes_manquantes = COLONNES_OBLIGATOIRES - set(df.columns)
if colonnes_manquantes:
raise ValueError(f"Colonnes manquantes : {', '.join(sorted(colonnes_manquantes))}")
df = df.dropna(subset=["avis"])
df["avis"] = df["avis"].astype(str).str.strip()
df = df[df["avis"] != ""]
n_avant = len(df)
doublons = df.duplicated(subset=["avis"]).sum()
df = df.drop_duplicates(subset=["avis"]).reset_index(drop=True)
if "date" in df.columns:
df["date"] = pd.to_datetime(df["date"], errors="coerce")
df.attrs["doublons_ecartes"] = int(doublons)
df.attrs["lignes_avant_nettoyage"] = n_avant
return df
# -----------------------------------------------------------------------------
# Barre latérale
# -----------------------------------------------------------------------------
st.sidebar.header("Paramètres")
fichier_depose = st.sidebar.file_uploader("Déposer un CSV d'avis", type=["csv"])
modele_sentiment_choisi = st.sidebar.selectbox(
"Modèle de sentiment",
["cmarkea/distilcamembert-base-sentiment (par défaut)", "tblard/tf-allocine"],
help="Le second modèle n'est câblé qu'à titre d'exemple de choix dans moteur.py.",
)
themes_selectionnes = st.sidebar.multiselect(
"Thèmes à détecter", THEMES_DISPONIBLES, default=THEMES_DISPONIBLES
)
inclure_resume = st.sidebar.checkbox("Inclure le résumé (tête la plus lente)", value=False)
# -----------------------------------------------------------------------------
# Chargement du fichier (déposé ou exemple par défaut)
# -----------------------------------------------------------------------------
if fichier_depose is not None:
contenu = fichier_depose.getvalue()
nom_source = fichier_depose.name
else:
with open(CHEMIN_EXEMPLE, "rb") as f:
contenu = f.read()
nom_source = CHEMIN_EXEMPLE
st.sidebar.info("Aucun fichier déposé — jeu de démonstration chargé automatiquement.")
try:
df = charger_et_nettoyer(contenu)
except ValueError as e:
st.error(f"Fichier invalide : {e}")
st.stop()
except Exception as e:
st.error(f"Impossible de lire le fichier (encodage ou format incorrect) : {e}")
st.stop()
if df.attrs.get("doublons_ecartes"):
st.sidebar.warning(f"{df.attrs['doublons_ecartes']} doublon(s) écarté(s).")
nb_max = st.sidebar.slider("Nombre d'avis à analyser", 10, len(df), min(len(df), 100))
st.title("📡 Radar d'avis clients")
st.caption(f"Source : {nom_source} — {len(df)} avis valides après nettoyage")
st.dataframe(df.head(10), width='stretch')
lancer = st.sidebar.button("Lancer l'analyse", type="primary")
if "historique" not in st.session_state:
st.session_state.historique = []
if "resultats" not in st.session_state:
st.session_state.resultats = None
# -----------------------------------------------------------------------------
# Analyse (étape 2.2) — ne s'exécute qu'au clic sur "Lancer l'analyse", jamais
# à chaque rerun déclenché par un simple changement de filtre d'affichage.
# -----------------------------------------------------------------------------
if lancer:
sous_df = df.head(nb_max).copy()
moteur = charger_moteur()
barre = st.progress(0.0, text="Analyse des sentiments...")
sentiments, n_tronques = analyser_sentiments(
moteur, sous_df["avis"].tolist(), progress_callback=lambda p: barre.progress(p, text="Analyse des sentiments...")
)
sous_df["sentiment"] = [r["label"] for r in sentiments]
sous_df["score"] = [round(r["score"], 3) for r in sentiments]
barre2 = st.progress(0.0, text="Détection des thèmes...")
if themes_selectionnes:
themes_resultats = detecter_themes(
moteur, sous_df["avis"].tolist(), themes_selectionnes,
progress_callback=lambda p: barre2.progress(p, text="Détection des thèmes..."),
)
sous_df["themes_detectes"] = [
", ".join(l for l, s in zip(r["labels"], r["scores"]) if s >= 0.5) or "aucun"
for r in themes_resultats
]
else:
sous_df["themes_detectes"] = "—"
resume_negatifs = ""
if inclure_resume:
with st.spinner("Génération du résumé des avis négatifs..."):
negatifs = sous_df.loc[sous_df["sentiment"] == "négatif", "avis"].tolist()
resume_negatifs = resumer_avis_negatifs(moteur, negatifs)
st.session_state.resultats = {
"df": sous_df,
"n_tronques": n_tronques,
"resume_negatifs": resume_negatifs,
}
st.session_state.historique.append(
{
"horodatage": dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"fichier": nom_source,
"n_avis": len(sous_df),
"part_positifs": round((sous_df["sentiment"] == "positif").mean() * 100, 1),
}
)
# -----------------------------------------------------------------------------
# Tableau de bord (étape 2.3)
# -----------------------------------------------------------------------------
if st.session_state.resultats is None:
st.info("Configurez les paramètres dans la barre latérale puis cliquez sur « Lancer l'analyse ».")
st.stop()
res = st.session_state.resultats
res_df = res["df"]
a_verite_terrain = "note" in res_df.columns and res_df["note"].notna().any()
noms_onglets = ["Synthèse", "Détail", "Historique"]
if a_verite_terrain:
noms_onglets.insert(2, "Évaluation")
onglets = st.tabs(noms_onglets)
onglet_synthese, onglet_detail = onglets[0], onglets[1]
if a_verite_terrain:
onglet_evaluation, onglet_historique = onglets[2], onglets[3]
else:
onglet_historique = onglets[2]
with onglet_synthese:
part_positifs = (res_df["sentiment"] == "positif").mean() * 100
score_moyen = res_df["score"].mean()
tous_themes = ", ".join(res_df["themes_detectes"]).split(", ")
tous_themes = [t for t in tous_themes if t not in ("aucun", "—", "")]
theme_dominant = pd.Series(tous_themes).mode().iat[0] if tous_themes else "N/A"
c1, c2, c3, c4 = st.columns(4)
c1.metric("Avis analysés", len(res_df), help="Nombre d'avis pris en compte dans cette analyse")
c2.metric("Part de positifs", f"{part_positifs:.1f} %", help="Proportion d'avis classés positifs")
c3.metric("Confiance moyenne", f"{score_moyen:.2f}", help="Score moyen du modèle de sentiment")
c4.metric("Thème dominant", theme_dominant, help="Thème le plus fréquemment détecté")
colg1, colg2 = st.columns(2)
with colg1:
st.subheader("Répartition des sentiments")
st.bar_chart(res_df["sentiment"].value_counts())
with colg2:
st.subheader("Thèmes les plus fréquents")
if tous_themes:
st.bar_chart(pd.Series(tous_themes).value_counts())
else:
st.caption("Aucun thème sélectionné.")
if "date" in res_df.columns and res_df["date"].notna().any():
st.subheader("Évolution hebdomadaire de la part d'avis négatifs")
tmp = res_df.dropna(subset=["date"]).set_index("date")
tmp["negatif"] = (tmp["sentiment"] == "négatif").astype(int)
hebdo = tmp["negatif"].resample("W").mean() * 100
st.line_chart(hebdo)
if res["resume_negatifs"]:
with st.expander("Résumé des avis négatifs"):
st.write(res["resume_negatifs"])
with onglet_detail:
st.subheader("Détail des avis (trié par négativité)")
filtre_sentiment = st.multiselect("Filtrer par sentiment", res_df["sentiment"].unique().tolist())
filtre_theme = st.text_input("Filtrer par thème (contient)")
affiche = res_df.copy()
# Tri par score de négativité : on force les négatifs en tête via un score signé.
affiche["score_negativite"] = affiche.apply(
lambda r: r["score"] if r["sentiment"] == "négatif" else (0.5 if r["sentiment"] == "neutre" else 1 - r["score"]), axis=1
)
affiche = affiche.sort_values("score_negativite", ascending=False)
if filtre_sentiment:
affiche = affiche[affiche["sentiment"].isin(filtre_sentiment)]
if filtre_theme:
affiche = affiche[affiche["themes_detectes"].str.contains(filtre_theme, case=False, na=False)]
st.dataframe(
affiche[["avis", "sentiment", "score", "themes_detectes"]],
width='stretch',
)
st.download_button(
"Exporter le CSV enrichi",
data=affiche.to_csv(index=False).encode("utf-8"),
file_name="avis_enrichis.csv",
mime="text/csv",
)
if a_verite_terrain:
with onglet_evaluation:
st.subheader("Accord avec la note client (question 7)")
st.caption("Binarisation : note ≤ 2 → négatif, note ≥ 4 → positif, note = 3 exclue (zone neutre ambiguë).")
evaluation = evaluer_contre_verite_terrain(res_df)
if evaluation is None:
st.info("Pas assez d'avis avec une note exploitable (hors note = 3) pour évaluer le modèle.")
else:
accuracy, f1, matrice = evaluation
c1, c2 = st.columns(2)
c1.metric("Accuracy", f"{accuracy:.2%}", help="Calculée avec evaluate.load('accuracy')")
c2.metric("F1-score", f"{f1:.2%}", help="Calculée avec evaluate.load('f1')")
st.subheader("Matrice de confusion")
st.dataframe(matrice, width="stretch")
st.caption(
"Erreurs les plus fréquentes attendues : avis ironiques (polarité lexicale positive, "
"sens réel négatif) et avis très courts (peu de signal, score proche de 0,5)."
)
with onglet_historique:
st.subheader("Historique des analyses de cette session")
if st.session_state.historique:
st.dataframe(pd.DataFrame(st.session_state.historique), width='stretch')
else:
st.caption("Aucune analyse encore enregistrée.")
|