Upload 6 files
Browse files- README.md +101 -6
- app.py +270 -0
- demo_cache.py +98 -0
- demo_rerun.py +56 -0
- moteur.py +170 -0
- requirements.txt +8 -0
README.md
CHANGED
|
@@ -1,11 +1,106 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Radar Avis Clients
|
| 3 |
+
emoji: 📡
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.62.0
|
| 8 |
+
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: mit
|
| 11 |
+
hardware: cpu-basic
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# 📡 Radar d'avis clients — Waaw Telecom (démo)
|
| 15 |
+
|
| 16 |
+
Application Streamlit qui ingère un CSV d'avis clients en français et répond
|
| 17 |
+
en moins d'une minute à trois questions : les clients sont-ils satisfaits ?
|
| 18 |
+
De quoi parlent-ils ? Que faut-il retenir en trois lignes ?
|
| 19 |
+
|
| 20 |
+
TP13 — Module 5 (Déploiement), Master IA.
|
| 21 |
+
|
| 22 |
+
## Capture d'écran
|
| 23 |
+
|
| 24 |
+
*(à ajouter après le premier lancement local — onglet Synthèse du tableau de bord)*
|
| 25 |
+
|
| 26 |
+
## Format du CSV attendu
|
| 27 |
+
|
| 28 |
+
| Colonne | Obligatoire | Description |
|
| 29 |
+
|---|---|---|
|
| 30 |
+
| `id` | oui | identifiant unique de l'avis |
|
| 31 |
+
| `date` | oui | date de l'avis (`AAAA-MM-JJ`) |
|
| 32 |
+
| `avis` | oui | texte de l'avis, en français |
|
| 33 |
+
| `note` | non | note client de 1 à 5, sert de vérité terrain pour évaluer le modèle |
|
| 34 |
+
|
| 35 |
+
Un jeu de démonstration de 200 avis synthétiques est fourni dans
|
| 36 |
+
`data/exemple_avis.csv` et se charge automatiquement si aucun fichier n'est
|
| 37 |
+
déposé.
|
| 38 |
+
|
| 39 |
+
## Modèles utilisés
|
| 40 |
+
|
| 41 |
+
| Tête | Modèle | Rôle |
|
| 42 |
+
|---|---|---|
|
| 43 |
+
| Sentiment | [cmarkea/distilcamembert-base-sentiment](https://huggingface.co/cmarkea/distilcamembert-base-sentiment) | classification positif / neutre / négatif + score de confiance |
|
| 44 |
+
| Thèmes | [MoritzLaurer/mDeBERTa-v3-base-mnli-xnli](https://huggingface.co/MoritzLaurer/mDeBERTa-v3-base-mnli-xnli) | zero-shot multi-étiquettes sur les thèmes choisis |
|
| 45 |
+
| Résumé | [plguillou/t5-base-fr-sum-cnndm](https://huggingface.co/plguillou/t5-base-fr-sum-cnndm) | synthèse des avis négatifs (activable, tête la plus lente) |
|
| 46 |
+
|
| 47 |
+
## Évaluation contre la vérité terrain
|
| 48 |
+
|
| 49 |
+
Quand la colonne `note` est présente, un onglet **Évaluation** calcule
|
| 50 |
+
l'accuracy et le F1-score du modèle de sentiment contre la note client
|
| 51 |
+
binarisée (≤2 négatif, ≥4 positif, 3 exclu), via la bibliothèque
|
| 52 |
+
[`evaluate`](https://huggingface.co/docs/evaluate) (`evaluate.load("accuracy")`,
|
| 53 |
+
`evaluate.load("f1")`) plutôt qu'un calcul manuel, et affiche la matrice de
|
| 54 |
+
confusion correspondante.
|
| 55 |
+
|
| 56 |
+
## Architecture du dépôt
|
| 57 |
+
|
| 58 |
+
```
|
| 59 |
+
radar-avis-clients/
|
| 60 |
+
├── app.py # point d'entrée Streamlit (interface + tableau de bord)
|
| 61 |
+
├── moteur.py # chargement (cache_resource) et analyse par lots
|
| 62 |
+
├── requirements.txt # dépendances épinglées
|
| 63 |
+
├── README.md # ce fichier (en-tête YAML du Space)
|
| 64 |
+
├── .streamlit/config.toml # thème de l'application
|
| 65 |
+
├── data/exemple_avis.csv # jeu de démonstration (200 avis)
|
| 66 |
+
├── data/generer_donnees.py # script ayant généré le jeu de démonstration
|
| 67 |
+
├── demo_rerun.py # étape 1.1 — démo du rerun Streamlit
|
| 68 |
+
└── demo_cache.py # étape 1.2 — mesure du coût du cache
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## Lancer en local
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
python -m venv .venv && source .venv/bin/activate
|
| 75 |
+
pip install -r requirements.txt
|
| 76 |
+
streamlit run app.py
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
## Coût d'exécution observé
|
| 80 |
+
|
| 81 |
+
*(à compléter après déploiement — voir rapport, questions 3, 6 et 11 : temps
|
| 82 |
+
de build, temps de premier affichage, facteur d'accélération mesuré)*
|
| 83 |
+
|
| 84 |
+
## Limites et biais
|
| 85 |
+
|
| 86 |
+
À éprouver explicitement et documenter (voir rapport) :
|
| 87 |
+
- **ironie** : « bravo pour les trois jours sans réseau, du grand art » — le
|
| 88 |
+
modèle de sentiment n'est pas entraîné pour détecter le second degré et
|
| 89 |
+
risque de classer ce type d'avis comme positif ou neutre.
|
| 90 |
+
- **français mêlé de wolof** : « réseau bi dafa bon, merci beaucoup » — le
|
| 91 |
+
modèle traite le texte comme du français bruit ; les segments en wolof sont
|
| 92 |
+
ignorés ou mal tokenisés, ce qui peut dégrader le score de confiance.
|
| 93 |
+
- **avis très courts** (« ok », « bof ») : peu de signal lexical, le modèle
|
| 94 |
+
est souvent moins confiant (score proche de 0,5) sur ces cas.
|
| 95 |
+
|
| 96 |
+
## Comparaison avec le Space Gradio (TP9)
|
| 97 |
+
|
| 98 |
+
Voir le rapport pour la comparaison argumentée sur les cinq axes : modèle
|
| 99 |
+
d'exécution, gestion de l'état, mise en page, latence perçue, facilité de
|
| 100 |
+
mise en production.
|
| 101 |
+
|
| 102 |
+
## Secrets
|
| 103 |
+
|
| 104 |
+
Si un modèle privé est utilisé, ajouter un `HF_TOKEN` dans *Settings →
|
| 105 |
+
Variables and secrets* du Space et le lire via `os.environ["HF_TOKEN"]` —
|
| 106 |
+
jamais en dur dans le code ni dans un `.streamlit/secrets.toml` versionné.
|
app.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Radar d'avis clients — TP13.
|
| 3 |
+
|
| 4 |
+
Point d'entrée Streamlit. st.set_page_config DOIT être le premier appel
|
| 5 |
+
Streamlit du script (sinon Streamlit lève une erreur).
|
| 6 |
+
"""
|
| 7 |
+
import datetime as dt
|
| 8 |
+
import io
|
| 9 |
+
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import streamlit as st
|
| 12 |
+
|
| 13 |
+
from moteur import (
|
| 14 |
+
TAILLE_LOT,
|
| 15 |
+
analyser_sentiments,
|
| 16 |
+
charger_moteur,
|
| 17 |
+
detecter_themes,
|
| 18 |
+
evaluer_contre_verite_terrain,
|
| 19 |
+
resumer_avis_negatifs,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
st.set_page_config(page_title="Radar d'avis clients", page_icon="📡", layout="wide")
|
| 23 |
+
|
| 24 |
+
COLONNES_OBLIGATOIRES = {"id", "date", "avis"}
|
| 25 |
+
CHEMIN_EXEMPLE = "data/exemple_avis.csv"
|
| 26 |
+
THEMES_DISPONIBLES = ["réseau", "facturation", "service client", "application mobile", "prix"]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# -----------------------------------------------------------------------------
|
| 30 |
+
# Ingestion et nettoyage (étape 2.1) — mise en cache_data sur le CONTENU en
|
| 31 |
+
# octets du fichier, jamais sur l'objet UploadedFile (qui n'est pas hashable
|
| 32 |
+
# de façon stable d'un rerun à l'autre : la clé de cache changerait à chaque
|
| 33 |
+
# exécution et le cache ne servirait jamais).
|
| 34 |
+
# -----------------------------------------------------------------------------
|
| 35 |
+
@st.cache_data(show_spinner=False)
|
| 36 |
+
def charger_et_nettoyer(contenu_octets: bytes) -> pd.DataFrame:
|
| 37 |
+
df = pd.read_csv(io.BytesIO(contenu_octets), encoding="utf-8")
|
| 38 |
+
colonnes_manquantes = COLONNES_OBLIGATOIRES - set(df.columns)
|
| 39 |
+
if colonnes_manquantes:
|
| 40 |
+
raise ValueError(f"Colonnes manquantes : {', '.join(sorted(colonnes_manquantes))}")
|
| 41 |
+
|
| 42 |
+
df = df.dropna(subset=["avis"])
|
| 43 |
+
df["avis"] = df["avis"].astype(str).str.strip()
|
| 44 |
+
df = df[df["avis"] != ""]
|
| 45 |
+
|
| 46 |
+
n_avant = len(df)
|
| 47 |
+
doublons = df.duplicated(subset=["avis"]).sum()
|
| 48 |
+
df = df.drop_duplicates(subset=["avis"]).reset_index(drop=True)
|
| 49 |
+
|
| 50 |
+
if "date" in df.columns:
|
| 51 |
+
df["date"] = pd.to_datetime(df["date"], errors="coerce")
|
| 52 |
+
|
| 53 |
+
df.attrs["doublons_ecartes"] = int(doublons)
|
| 54 |
+
df.attrs["lignes_avant_nettoyage"] = n_avant
|
| 55 |
+
return df
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# -----------------------------------------------------------------------------
|
| 59 |
+
# Barre latérale
|
| 60 |
+
# -----------------------------------------------------------------------------
|
| 61 |
+
st.sidebar.header("Paramètres")
|
| 62 |
+
|
| 63 |
+
fichier_depose = st.sidebar.file_uploader("Déposer un CSV d'avis", type=["csv"])
|
| 64 |
+
|
| 65 |
+
modele_sentiment_choisi = st.sidebar.selectbox(
|
| 66 |
+
"Modèle de sentiment",
|
| 67 |
+
["cmarkea/distilcamembert-base-sentiment (par défaut)", "tblard/tf-allocine"],
|
| 68 |
+
help="Le second modèle n'est câblé qu'à titre d'exemple de choix dans moteur.py.",
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
themes_selectionnes = st.sidebar.multiselect(
|
| 72 |
+
"Thèmes à détecter", THEMES_DISPONIBLES, default=THEMES_DISPONIBLES
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
inclure_resume = st.sidebar.checkbox("Inclure le résumé (tête la plus lente)", value=False)
|
| 76 |
+
|
| 77 |
+
# -----------------------------------------------------------------------------
|
| 78 |
+
# Chargement du fichier (déposé ou exemple par défaut)
|
| 79 |
+
# -----------------------------------------------------------------------------
|
| 80 |
+
if fichier_depose is not None:
|
| 81 |
+
contenu = fichier_depose.getvalue()
|
| 82 |
+
nom_source = fichier_depose.name
|
| 83 |
+
else:
|
| 84 |
+
with open(CHEMIN_EXEMPLE, "rb") as f:
|
| 85 |
+
contenu = f.read()
|
| 86 |
+
nom_source = CHEMIN_EXEMPLE
|
| 87 |
+
st.sidebar.info("Aucun fichier déposé — jeu de démonstration chargé automatiquement.")
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
df = charger_et_nettoyer(contenu)
|
| 91 |
+
except ValueError as e:
|
| 92 |
+
st.error(f"Fichier invalide : {e}")
|
| 93 |
+
st.stop()
|
| 94 |
+
except Exception as e:
|
| 95 |
+
st.error(f"Impossible de lire le fichier (encodage ou format incorrect) : {e}")
|
| 96 |
+
st.stop()
|
| 97 |
+
|
| 98 |
+
if df.attrs.get("doublons_ecartes"):
|
| 99 |
+
st.sidebar.warning(f"{df.attrs['doublons_ecartes']} doublon(s) écarté(s).")
|
| 100 |
+
|
| 101 |
+
nb_max = st.sidebar.slider("Nombre d'avis à analyser", 10, len(df), min(len(df), 100))
|
| 102 |
+
|
| 103 |
+
st.title("📡 Radar d'avis clients")
|
| 104 |
+
st.caption(f"Source : {nom_source} — {len(df)} avis valides après nettoyage")
|
| 105 |
+
st.dataframe(df.head(10), width='stretch')
|
| 106 |
+
|
| 107 |
+
lancer = st.sidebar.button("Lancer l'analyse", type="primary")
|
| 108 |
+
|
| 109 |
+
if "historique" not in st.session_state:
|
| 110 |
+
st.session_state.historique = []
|
| 111 |
+
if "resultats" not in st.session_state:
|
| 112 |
+
st.session_state.resultats = None
|
| 113 |
+
|
| 114 |
+
# -----------------------------------------------------------------------------
|
| 115 |
+
# Analyse (étape 2.2) — ne s'exécute qu'au clic sur "Lancer l'analyse", jamais
|
| 116 |
+
# à chaque rerun déclenché par un simple changement de filtre d'affichage.
|
| 117 |
+
# -----------------------------------------------------------------------------
|
| 118 |
+
if lancer:
|
| 119 |
+
sous_df = df.head(nb_max).copy()
|
| 120 |
+
moteur = charger_moteur()
|
| 121 |
+
|
| 122 |
+
barre = st.progress(0.0, text="Analyse des sentiments...")
|
| 123 |
+
sentiments, n_tronques = analyser_sentiments(
|
| 124 |
+
moteur, sous_df["avis"].tolist(), progress_callback=lambda p: barre.progress(p, text="Analyse des sentiments...")
|
| 125 |
+
)
|
| 126 |
+
sous_df["sentiment"] = [r["label"] for r in sentiments]
|
| 127 |
+
sous_df["score"] = [round(r["score"], 3) for r in sentiments]
|
| 128 |
+
|
| 129 |
+
barre2 = st.progress(0.0, text="Détection des thèmes...")
|
| 130 |
+
if themes_selectionnes:
|
| 131 |
+
themes_resultats = detecter_themes(
|
| 132 |
+
moteur, sous_df["avis"].tolist(), themes_selectionnes,
|
| 133 |
+
progress_callback=lambda p: barre2.progress(p, text="Détection des thèmes..."),
|
| 134 |
+
)
|
| 135 |
+
sous_df["themes_detectes"] = [
|
| 136 |
+
", ".join(l for l, s in zip(r["labels"], r["scores"]) if s >= 0.5) or "aucun"
|
| 137 |
+
for r in themes_resultats
|
| 138 |
+
]
|
| 139 |
+
else:
|
| 140 |
+
sous_df["themes_detectes"] = "—"
|
| 141 |
+
|
| 142 |
+
resume_negatifs = ""
|
| 143 |
+
if inclure_resume:
|
| 144 |
+
with st.spinner("Génération du résumé des avis négatifs..."):
|
| 145 |
+
negatifs = sous_df.loc[sous_df["sentiment"] == "négatif", "avis"].tolist()
|
| 146 |
+
resume_negatifs = resumer_avis_negatifs(moteur, negatifs)
|
| 147 |
+
|
| 148 |
+
st.session_state.resultats = {
|
| 149 |
+
"df": sous_df,
|
| 150 |
+
"n_tronques": n_tronques,
|
| 151 |
+
"resume_negatifs": resume_negatifs,
|
| 152 |
+
}
|
| 153 |
+
st.session_state.historique.append(
|
| 154 |
+
{
|
| 155 |
+
"horodatage": dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 156 |
+
"fichier": nom_source,
|
| 157 |
+
"n_avis": len(sous_df),
|
| 158 |
+
"part_positifs": round((sous_df["sentiment"] == "positif").mean() * 100, 1),
|
| 159 |
+
}
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# -----------------------------------------------------------------------------
|
| 163 |
+
# Tableau de bord (étape 2.3)
|
| 164 |
+
# -----------------------------------------------------------------------------
|
| 165 |
+
if st.session_state.resultats is None:
|
| 166 |
+
st.info("Configurez les paramètres dans la barre latérale puis cliquez sur « Lancer l'analyse ».")
|
| 167 |
+
st.stop()
|
| 168 |
+
|
| 169 |
+
res = st.session_state.resultats
|
| 170 |
+
res_df = res["df"]
|
| 171 |
+
|
| 172 |
+
a_verite_terrain = "note" in res_df.columns and res_df["note"].notna().any()
|
| 173 |
+
noms_onglets = ["Synthèse", "Détail", "Historique"]
|
| 174 |
+
if a_verite_terrain:
|
| 175 |
+
noms_onglets.insert(2, "Évaluation")
|
| 176 |
+
onglets = st.tabs(noms_onglets)
|
| 177 |
+
onglet_synthese, onglet_detail = onglets[0], onglets[1]
|
| 178 |
+
if a_verite_terrain:
|
| 179 |
+
onglet_evaluation, onglet_historique = onglets[2], onglets[3]
|
| 180 |
+
else:
|
| 181 |
+
onglet_historique = onglets[2]
|
| 182 |
+
|
| 183 |
+
with onglet_synthese:
|
| 184 |
+
part_positifs = (res_df["sentiment"] == "positif").mean() * 100
|
| 185 |
+
score_moyen = res_df["score"].mean()
|
| 186 |
+
tous_themes = ", ".join(res_df["themes_detectes"]).split(", ")
|
| 187 |
+
tous_themes = [t for t in tous_themes if t not in ("aucun", "—", "")]
|
| 188 |
+
theme_dominant = pd.Series(tous_themes).mode().iat[0] if tous_themes else "N/A"
|
| 189 |
+
|
| 190 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 191 |
+
c1.metric("Avis analysés", len(res_df), help="Nombre d'avis pris en compte dans cette analyse")
|
| 192 |
+
c2.metric("Part de positifs", f"{part_positifs:.1f} %", help="Proportion d'avis classés positifs")
|
| 193 |
+
c3.metric("Confiance moyenne", f"{score_moyen:.2f}", help="Score moyen du modèle de sentiment")
|
| 194 |
+
c4.metric("Thème dominant", theme_dominant, help="Thème le plus fréquemment détecté")
|
| 195 |
+
|
| 196 |
+
colg1, colg2 = st.columns(2)
|
| 197 |
+
with colg1:
|
| 198 |
+
st.subheader("Répartition des sentiments")
|
| 199 |
+
st.bar_chart(res_df["sentiment"].value_counts())
|
| 200 |
+
with colg2:
|
| 201 |
+
st.subheader("Thèmes les plus fréquents")
|
| 202 |
+
if tous_themes:
|
| 203 |
+
st.bar_chart(pd.Series(tous_themes).value_counts())
|
| 204 |
+
else:
|
| 205 |
+
st.caption("Aucun thème sélectionné.")
|
| 206 |
+
|
| 207 |
+
if "date" in res_df.columns and res_df["date"].notna().any():
|
| 208 |
+
st.subheader("Évolution hebdomadaire de la part d'avis négatifs")
|
| 209 |
+
tmp = res_df.dropna(subset=["date"]).set_index("date")
|
| 210 |
+
tmp["negatif"] = (tmp["sentiment"] == "négatif").astype(int)
|
| 211 |
+
hebdo = tmp["negatif"].resample("W").mean() * 100
|
| 212 |
+
st.line_chart(hebdo)
|
| 213 |
+
|
| 214 |
+
if res["resume_negatifs"]:
|
| 215 |
+
with st.expander("Résumé des avis négatifs"):
|
| 216 |
+
st.write(res["resume_negatifs"])
|
| 217 |
+
|
| 218 |
+
with onglet_detail:
|
| 219 |
+
st.subheader("Détail des avis (trié par négativité)")
|
| 220 |
+
filtre_sentiment = st.multiselect("Filtrer par sentiment", res_df["sentiment"].unique().tolist())
|
| 221 |
+
filtre_theme = st.text_input("Filtrer par thème (contient)")
|
| 222 |
+
|
| 223 |
+
affiche = res_df.copy()
|
| 224 |
+
# Tri par score de négativité : on force les négatifs en tête via un score signé.
|
| 225 |
+
affiche["score_negativite"] = affiche.apply(
|
| 226 |
+
lambda r: r["score"] if r["sentiment"] == "négatif" else (0.5 if r["sentiment"] == "neutre" else 1 - r["score"]), axis=1
|
| 227 |
+
)
|
| 228 |
+
affiche = affiche.sort_values("score_negativite", ascending=False)
|
| 229 |
+
|
| 230 |
+
if filtre_sentiment:
|
| 231 |
+
affiche = affiche[affiche["sentiment"].isin(filtre_sentiment)]
|
| 232 |
+
if filtre_theme:
|
| 233 |
+
affiche = affiche[affiche["themes_detectes"].str.contains(filtre_theme, case=False, na=False)]
|
| 234 |
+
|
| 235 |
+
st.dataframe(
|
| 236 |
+
affiche[["avis", "sentiment", "score", "themes_detectes"]],
|
| 237 |
+
width='stretch',
|
| 238 |
+
)
|
| 239 |
+
st.download_button(
|
| 240 |
+
"Exporter le CSV enrichi",
|
| 241 |
+
data=affiche.to_csv(index=False).encode("utf-8"),
|
| 242 |
+
file_name="avis_enrichis.csv",
|
| 243 |
+
mime="text/csv",
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
if a_verite_terrain:
|
| 247 |
+
with onglet_evaluation:
|
| 248 |
+
st.subheader("Accord avec la note client (question 7)")
|
| 249 |
+
st.caption("Binarisation : note ≤ 2 → négatif, note ≥ 4 → positif, note = 3 exclue (zone neutre ambiguë).")
|
| 250 |
+
evaluation = evaluer_contre_verite_terrain(res_df)
|
| 251 |
+
if evaluation is None:
|
| 252 |
+
st.info("Pas assez d'avis avec une note exploitable (hors note = 3) pour évaluer le modèle.")
|
| 253 |
+
else:
|
| 254 |
+
accuracy, f1, matrice = evaluation
|
| 255 |
+
c1, c2 = st.columns(2)
|
| 256 |
+
c1.metric("Accuracy", f"{accuracy:.2%}", help="Calculée avec evaluate.load('accuracy')")
|
| 257 |
+
c2.metric("F1-score", f"{f1:.2%}", help="Calculée avec evaluate.load('f1')")
|
| 258 |
+
st.subheader("Matrice de confusion")
|
| 259 |
+
st.dataframe(matrice, width="stretch")
|
| 260 |
+
st.caption(
|
| 261 |
+
"Erreurs les plus fréquentes attendues : avis ironiques (polarité lexicale positive, "
|
| 262 |
+
"sens réel négatif) et avis très courts (peu de signal, score proche de 0,5)."
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
with onglet_historique:
|
| 266 |
+
st.subheader("Historique des analyses de cette session")
|
| 267 |
+
if st.session_state.historique:
|
| 268 |
+
st.dataframe(pd.DataFrame(st.session_state.historique), width='stretch')
|
| 269 |
+
else:
|
| 270 |
+
st.caption("Aucune analyse encore enregistrée.")
|
demo_cache.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Étape 1.2 — Mesurer le coût du cache sur un pipeline de sentiment français.
|
| 3 |
+
|
| 4 |
+
Lancer avec : streamlit run demo_cache.py
|
| 5 |
+
|
| 6 |
+
Protocole de mesure (voir rapport pour les chiffres retenus) :
|
| 7 |
+
1. Choisir une version dans la barre latérale (naïve / cache_resource / cache_data).
|
| 8 |
+
2. Saisir un texte, cliquer "Analyser" 20 fois de suite (N = 20 interactions).
|
| 9 |
+
3. Relever à chaque clic : temps de chargement, temps d'inférence.
|
| 10 |
+
4. Reporter temps total, temps moyen par interaction et facteur d'accélération
|
| 11 |
+
par rapport à la version naïve dans le tableau du rapport.
|
| 12 |
+
|
| 13 |
+
Modèle utilisé : cmarkea/distilcamembert-base-sentiment (léger, adapté au CPU
|
| 14 |
+
gratuit d'un Space).
|
| 15 |
+
"""
|
| 16 |
+
import time
|
| 17 |
+
|
| 18 |
+
import streamlit as st
|
| 19 |
+
from transformers import pipeline
|
| 20 |
+
|
| 21 |
+
MODELE = "cmarkea/distilcamembert-base-sentiment"
|
| 22 |
+
|
| 23 |
+
st.set_page_config(page_title="Démo cache", layout="centered")
|
| 24 |
+
st.title("Démo — coût du cache sur un pipeline de sentiment")
|
| 25 |
+
|
| 26 |
+
version = st.radio(
|
| 27 |
+
"Version à tester",
|
| 28 |
+
["(a) naïve — sans cache", "(b) @st.cache_resource", "(c) @st.cache_resource + @st.cache_data"],
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
if "log" not in st.session_state:
|
| 32 |
+
st.session_state.log = []
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# --- (b) et (c) : le pipeline n'est chargé qu'une seule fois, partagé entre
|
| 36 |
+
# tous les utilisateurs de la session serveur -----------------------------
|
| 37 |
+
@st.cache_resource(show_spinner=False)
|
| 38 |
+
def charger_modele():
|
| 39 |
+
return pipeline("sentiment-analysis", model=MODELE, tokenizer=MODELE)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# --- (c) uniquement : le RÉSULTAT (une valeur sérialisable : dict) est mis en
|
| 43 |
+
# cache pour un même texte -> deuxième appel quasi instantané -------------
|
| 44 |
+
@st.cache_data(show_spinner=False)
|
| 45 |
+
def analyser_cache(texte: str):
|
| 46 |
+
modele = charger_modele()
|
| 47 |
+
return modele(texte)[0]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
texte = st.text_area("Avis à analyser", "Le réseau est catastrophique depuis trois jours.")
|
| 51 |
+
|
| 52 |
+
if st.button("Analyser"):
|
| 53 |
+
t0 = time.perf_counter()
|
| 54 |
+
|
| 55 |
+
if version.startswith("(a)"):
|
| 56 |
+
# (a) naïve : le pipeline est recréé (donc rechargé) à CHAQUE clic.
|
| 57 |
+
t_charge_debut = time.perf_counter()
|
| 58 |
+
modele = pipeline("sentiment-analysis", model=MODELE, tokenizer=MODELE)
|
| 59 |
+
t_chargement = time.perf_counter() - t_charge_debut
|
| 60 |
+
|
| 61 |
+
t_inf_debut = time.perf_counter()
|
| 62 |
+
resultat = modele(texte)[0]
|
| 63 |
+
t_inference = time.perf_counter() - t_inf_debut
|
| 64 |
+
|
| 65 |
+
elif version.startswith("(b)"):
|
| 66 |
+
t_charge_debut = time.perf_counter()
|
| 67 |
+
modele = charger_modele() # quasi nul après le 1er appel (mis en cache_resource)
|
| 68 |
+
t_chargement = time.perf_counter() - t_charge_debut
|
| 69 |
+
|
| 70 |
+
t_inf_debut = time.perf_counter()
|
| 71 |
+
resultat = modele(texte)[0]
|
| 72 |
+
t_inference = time.perf_counter() - t_inf_debut
|
| 73 |
+
|
| 74 |
+
else:
|
| 75 |
+
t_charge_debut = time.perf_counter()
|
| 76 |
+
charger_modele()
|
| 77 |
+
t_chargement = time.perf_counter() - t_charge_debut
|
| 78 |
+
|
| 79 |
+
t_inf_debut = time.perf_counter()
|
| 80 |
+
resultat = analyser_cache(texte) # quasi nul si texte déjà vu (cache_data)
|
| 81 |
+
t_inference = time.perf_counter() - t_inf_debut
|
| 82 |
+
|
| 83 |
+
total = time.perf_counter() - t0
|
| 84 |
+
st.session_state.log.append(
|
| 85 |
+
{
|
| 86 |
+
"version": version,
|
| 87 |
+
"chargement_s": round(t_chargement, 4),
|
| 88 |
+
"inference_s": round(t_inference, 4),
|
| 89 |
+
"total_s": round(total, 4),
|
| 90 |
+
}
|
| 91 |
+
)
|
| 92 |
+
st.write(resultat)
|
| 93 |
+
|
| 94 |
+
if st.session_state.log:
|
| 95 |
+
st.subheader("Journal des interactions")
|
| 96 |
+
st.dataframe(st.session_state.log, width="stretch")
|
| 97 |
+
if st.button("Vider le journal"):
|
| 98 |
+
st.session_state.log = []
|
demo_rerun.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Étape 1.1 — Le rerun Streamlit et pourquoi une variable ordinaire ne survit pas.
|
| 3 |
+
|
| 4 |
+
Lancer avec : streamlit run demo_rerun.py
|
| 5 |
+
|
| 6 |
+
Observez :
|
| 7 |
+
- le compteur "naïf" (variable Python ordinaire) reste bloqué à 1 après N clics,
|
| 8 |
+
car il est réinitialisé à 0 à chaque rerun, juste avant d'être incrémenté ;
|
| 9 |
+
- le compteur stocké dans st.session_state s'incrémente correctement, car
|
| 10 |
+
session_state persiste entre les reruns (il est propre à un onglet/session) ;
|
| 11 |
+
- l'horodatage en haut de page change à CHAQUE interaction, y compris une
|
| 12 |
+
simple frappe dans le champ prénom qui ne déclenche pourtant aucun clic sur
|
| 13 |
+
le bouton — preuve que Streamlit ré-exécute tout le script à chaque widget
|
| 14 |
+
qui change d'état, pas seulement au clic sur "Incrémenter".
|
| 15 |
+
"""
|
| 16 |
+
import datetime as dt
|
| 17 |
+
|
| 18 |
+
import streamlit as st
|
| 19 |
+
|
| 20 |
+
st.title("Démo — le rerun Streamlit")
|
| 21 |
+
|
| 22 |
+
# Preuve visuelle du rerun : cette ligne s'affiche à CHAQUE exécution du script.
|
| 23 |
+
st.write(f"Exécution du script à {dt.datetime.now().strftime('%H:%M:%S.%f')}")
|
| 24 |
+
|
| 25 |
+
prenom = st.text_input("Votre prénom")
|
| 26 |
+
|
| 27 |
+
st.divider()
|
| 28 |
+
st.subheader("Compteur naïf (variable Python ordinaire)")
|
| 29 |
+
|
| 30 |
+
# BUG VOLONTAIRE : cette variable est réinitialisée à 0 à chaque rerun,
|
| 31 |
+
# donc quel que soit le nombre de clics, elle affichera toujours 1.
|
| 32 |
+
compteur_naif = 0
|
| 33 |
+
if st.button("Incrémenter (naïf)"):
|
| 34 |
+
compteur_naif += 1
|
| 35 |
+
st.write(f"Valeur du compteur naïf : {compteur_naif}")
|
| 36 |
+
|
| 37 |
+
st.divider()
|
| 38 |
+
st.subheader("Compteur corrigé (st.session_state)")
|
| 39 |
+
|
| 40 |
+
# Initialisation : la clé n'est créée QUE si elle n'existe pas déjà,
|
| 41 |
+
# sinon on la remettrait à zéro à chaque rerun (même bug que ci-dessus).
|
| 42 |
+
if "compteur" not in st.session_state:
|
| 43 |
+
st.session_state.compteur = 0
|
| 44 |
+
|
| 45 |
+
col1, col2 = st.columns(2)
|
| 46 |
+
with col1:
|
| 47 |
+
if st.button("Incrémenter (session_state)"):
|
| 48 |
+
st.session_state.compteur += 1
|
| 49 |
+
with col2:
|
| 50 |
+
if st.button("Réinitialiser"):
|
| 51 |
+
st.session_state.compteur = 0
|
| 52 |
+
|
| 53 |
+
st.write(f"Valeur du compteur en session_state : {st.session_state.compteur}")
|
| 54 |
+
|
| 55 |
+
if prenom:
|
| 56 |
+
st.caption(f"Bonjour {prenom}, tape du texte ici pour déclencher un rerun sans cliquer sur aucun bouton.")
|
moteur.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Moteur d'analyse du Radar d'avis clients : trois têtes de modèle chargées
|
| 3 |
+
une seule fois (via @st.cache_resource) et appelées par lots.
|
| 4 |
+
|
| 5 |
+
Séparé de app.py pour que l'interface reste lisible et que le chargement
|
| 6 |
+
des modèles soit testable indépendamment.
|
| 7 |
+
"""
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
import streamlit as st
|
| 11 |
+
from transformers import pipeline
|
| 12 |
+
|
| 13 |
+
MODELE_SENTIMENT = "cmarkea/distilcamembert-base-sentiment"
|
| 14 |
+
MODELE_THEMES = "MoritzLaurer/mDeBERTa-v3-base-mnli-xnli"
|
| 15 |
+
MODELE_RESUME = "plguillou/t5-base-fr-sum-cnndm"
|
| 16 |
+
|
| 17 |
+
LONGUEUR_MAX_SENTIMENT = 512
|
| 18 |
+
LONGUEUR_MAX_THEMES = 256
|
| 19 |
+
TAILLE_LOT = 16
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@st.cache_resource(show_spinner="Chargement des modèles (une seule fois)...")
|
| 23 |
+
def charger_moteur():
|
| 24 |
+
"""Instancie les trois pipelines et les renvoie ensemble.
|
| 25 |
+
|
| 26 |
+
Objet non sérialisable -> @st.cache_resource (partagé, pas copié),
|
| 27 |
+
jamais @st.cache_data qui tenterait de sérialiser le modèle à chaque appel.
|
| 28 |
+
"""
|
| 29 |
+
sentiment = pipeline(
|
| 30 |
+
"sentiment-analysis",
|
| 31 |
+
model=MODELE_SENTIMENT,
|
| 32 |
+
tokenizer=MODELE_SENTIMENT,
|
| 33 |
+
truncation=True,
|
| 34 |
+
max_length=LONGUEUR_MAX_SENTIMENT,
|
| 35 |
+
)
|
| 36 |
+
themes = pipeline("zero-shot-classification", model=MODELE_THEMES)
|
| 37 |
+
resume = pipeline("summarization", model=MODELE_RESUME, tokenizer=MODELE_RESUME)
|
| 38 |
+
return {"sentiment": sentiment, "themes": themes, "resume": resume}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _compter_tokens_tronques(textes, tokenizer, longueur_max):
|
| 42 |
+
"""Renvoie le nombre de textes dont la tokenisation dépasse longueur_max
|
| 43 |
+
(donc effectivement tronqués par le pipeline)."""
|
| 44 |
+
n_tronques = 0
|
| 45 |
+
for t in textes:
|
| 46 |
+
ids = tokenizer(t, truncation=False)["input_ids"]
|
| 47 |
+
if len(ids) > longueur_max:
|
| 48 |
+
n_tronques += 1
|
| 49 |
+
return n_tronques
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def normaliser_sentiment(label_brut: str) -> str:
|
| 53 |
+
"""cmarkea/distilcamembert-base-sentiment renvoie des labels du type
|
| 54 |
+
'1 star' .. '5 stars' (notation à la Amazon), pas directement
|
| 55 |
+
positif/négatif. On les ramène à trois classes, avec le même seuil que
|
| 56 |
+
la binarisation de la question 7 (<=2 négatif, ==3 neutre, >=4 positif).
|
| 57 |
+
"""
|
| 58 |
+
n = int(label_brut.strip().split()[0])
|
| 59 |
+
if n <= 2:
|
| 60 |
+
return "négatif"
|
| 61 |
+
if n == 3:
|
| 62 |
+
return "neutre"
|
| 63 |
+
return "positif"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def analyser_sentiments(moteur, avis, progress_callback=None):
|
| 67 |
+
"""Classification de séquence par lots ; renvoie label + score de confiance."""
|
| 68 |
+
pipe = moteur["sentiment"]
|
| 69 |
+
n_tronques = _compter_tokens_tronques(avis, pipe.tokenizer, LONGUEUR_MAX_SENTIMENT)
|
| 70 |
+
|
| 71 |
+
resultats = []
|
| 72 |
+
n = len(avis)
|
| 73 |
+
for debut in range(0, n, TAILLE_LOT):
|
| 74 |
+
lot = avis[debut : debut + TAILLE_LOT]
|
| 75 |
+
sorties = pipe(lot, truncation=True, max_length=LONGUEUR_MAX_SENTIMENT, batch_size=TAILLE_LOT)
|
| 76 |
+
for s in sorties:
|
| 77 |
+
s["label_brut"] = s["label"]
|
| 78 |
+
s["label"] = normaliser_sentiment(s["label"])
|
| 79 |
+
resultats.extend(sorties)
|
| 80 |
+
if progress_callback:
|
| 81 |
+
progress_callback(min(debut + TAILLE_LOT, n) / n)
|
| 82 |
+
return resultats, n_tronques
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def detecter_themes(moteur, avis, themes_cibles, progress_callback=None):
|
| 86 |
+
"""Zero-shot multi-étiquettes : un avis peut relever de plusieurs thèmes."""
|
| 87 |
+
pipe = moteur["themes"]
|
| 88 |
+
resultats = []
|
| 89 |
+
n = len(avis)
|
| 90 |
+
for debut in range(0, n, TAILLE_LOT):
|
| 91 |
+
lot = avis[debut : debut + TAILLE_LOT]
|
| 92 |
+
sorties = pipe(
|
| 93 |
+
lot,
|
| 94 |
+
candidate_labels=themes_cibles,
|
| 95 |
+
multi_label=True,
|
| 96 |
+
truncation=True,
|
| 97 |
+
max_length=LONGUEUR_MAX_THEMES,
|
| 98 |
+
)
|
| 99 |
+
# pipeline() renvoie un seul dict (pas une liste) si len(lot) == 1
|
| 100 |
+
if isinstance(sorties, dict):
|
| 101 |
+
sorties = [sorties]
|
| 102 |
+
resultats.extend(sorties)
|
| 103 |
+
if progress_callback:
|
| 104 |
+
progress_callback(min(debut + TAILLE_LOT, n) / n)
|
| 105 |
+
return resultats
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def resumer_avis_negatifs(moteur, avis_negatifs, longueur_max_source=800):
|
| 109 |
+
"""Concatène les avis négatifs et produit un résumé en 3 lignes environ.
|
| 110 |
+
Tête la plus lente : à n'appeler que si la case "résumé" est cochée.
|
| 111 |
+
"""
|
| 112 |
+
if not avis_negatifs:
|
| 113 |
+
return "Aucun avis négatif à résumer."
|
| 114 |
+
texte = " ".join(avis_negatifs)[:longueur_max_source]
|
| 115 |
+
pipe = moteur["resume"]
|
| 116 |
+
sortie = pipe(texte, truncation=True, max_length=120, min_length=30)
|
| 117 |
+
return sortie[0]["summary_text"]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def chronometrer_sequentiel_vs_lots(moteur, avis):
|
| 121 |
+
"""Utilitaire pour la question 6 : compare un traitement avis par avis
|
| 122 |
+
(boucle Python) à un traitement par lots de TAILLE_LOT."""
|
| 123 |
+
pipe = moteur["sentiment"]
|
| 124 |
+
|
| 125 |
+
t0 = time.perf_counter()
|
| 126 |
+
for a in avis:
|
| 127 |
+
pipe(a, truncation=True, max_length=LONGUEUR_MAX_SENTIMENT)
|
| 128 |
+
t_sequentiel = time.perf_counter() - t0
|
| 129 |
+
|
| 130 |
+
t0 = time.perf_counter()
|
| 131 |
+
for debut in range(0, len(avis), TAILLE_LOT):
|
| 132 |
+
lot = avis[debut : debut + TAILLE_LOT]
|
| 133 |
+
pipe(lot, truncation=True, max_length=LONGUEUR_MAX_SENTIMENT, batch_size=TAILLE_LOT)
|
| 134 |
+
t_lots = time.perf_counter() - t0
|
| 135 |
+
|
| 136 |
+
return t_sequentiel, t_lots
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def evaluer_contre_verite_terrain(df_resultats):
|
| 140 |
+
"""Question 7 : binarise la colonne `note` (<=2 négatif, >=4 positif, 3
|
| 141 |
+
exclu) et calcule accuracy/F1 avec la bibliothèque `evaluate` (chapitre 5),
|
| 142 |
+
plutôt qu'un calcul manuel — API unifiée, cohérente avec le reste du cours.
|
| 143 |
+
|
| 144 |
+
Renvoie (accuracy, f1, matrice_confusion_dataframe) ou None si la colonne
|
| 145 |
+
`note` est absente ou si aucune ligne n'est exploitable après binarisation.
|
| 146 |
+
"""
|
| 147 |
+
import evaluate
|
| 148 |
+
import pandas as pd
|
| 149 |
+
|
| 150 |
+
if "note" not in df_resultats.columns:
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
df = df_resultats.dropna(subset=["note"]).copy()
|
| 154 |
+
df = df[df["note"] != 3] # zone neutre ambiguë exclue de l'évaluation
|
| 155 |
+
if df.empty:
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
df["reference"] = (df["note"] >= 4).astype(int) # 1 = positif, 0 = négatif
|
| 159 |
+
df["prediction"] = (df["sentiment"] == "positif").astype(int)
|
| 160 |
+
|
| 161 |
+
metric_accuracy = evaluate.load("accuracy")
|
| 162 |
+
metric_f1 = evaluate.load("f1")
|
| 163 |
+
acc = metric_accuracy.compute(predictions=df["prediction"], references=df["reference"])["accuracy"]
|
| 164 |
+
f1 = metric_f1.compute(predictions=df["prediction"], references=df["reference"])["f1"]
|
| 165 |
+
|
| 166 |
+
matrice = pd.crosstab(
|
| 167 |
+
df["reference"].map({0: "Réel négatif", 1: "Réel positif"}),
|
| 168 |
+
df["prediction"].map({0: "Prédit négatif", 1: "Prédit positif"}),
|
| 169 |
+
)
|
| 170 |
+
return round(acc, 4), round(f1, 4), matrice
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit==1.62.0
|
| 2 |
+
transformers==4.57.6
|
| 3 |
+
torch==2.7.1
|
| 4 |
+
pandas==2.2.3
|
| 5 |
+
sentencepiece==0.2.0
|
| 6 |
+
huggingface_hub==0.27.1
|
| 7 |
+
evaluate==0.4.3
|
| 8 |
+
scikit-learn==1.6.1
|