Salehhhhh commited on
Commit
592ac2d
·
verified ·
1 Parent(s): bbbaaf2

Upload 6 files

Browse files
Files changed (6) hide show
  1. README.md +101 -6
  2. app.py +270 -0
  3. demo_cache.py +98 -0
  4. demo_rerun.py +56 -0
  5. moteur.py +170 -0
  6. requirements.txt +8 -0
README.md CHANGED
@@ -1,11 +1,106 @@
1
  ---
2
- title: TP13
3
- emoji: 😻
4
- colorFrom: pink
5
- colorTo: pink
6
- sdk: static
 
 
7
  pinned: false
8
  license: mit
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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