Classification de commentaires Webtoon (CamemBERT)
1. Libraries
#| label: libraries
#| eval: false
#| message: false
#| warning: false
import os
import random
import time
import re
import numpy as np
import pandas as pd
import torch
from tqdm import tqdm
from sentence_transformers import SentenceTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
from sklearn.metrics import precision_recall_fscore_support
from datasets import Dataset
from transformers import (
AutoTokenizer,
AutoModelForSequenceClassification,
TrainingArguments,
Trainer,
DataCollatorWithPadding)2. Préparation des données
2.1 Chargement et tirage aléatoire
#| label: chargement_données
#| eval: false
#dossier de travail
rep = r"\\ad.univ-lille.fr\Personnels\Homedir1\4273\Documents\cours25_26\Master2 ENSP\textometrie\dossier_exam"
comm = pd.read_csv(
os.path.join(rep, "tc.csv"),
encoding="utf-8"
)
phrases = comm["comment"]
# Tirage aléatoire de 1 000 commentaires
random.seed(454)
indices_train = random.sample(range(len(phrases)), 1000)
indices_test = list(set(range(len(phrases))) - set(indices_train))
#constitution des jeux train/test
phrases_train = phrases.iloc[indices_train].reset_index(drop=True)#1000 commentaires
phrases_test = phrases.iloc[indices_test].reset_index(drop=True) #reste du corpus
phrases_train_df = pd.DataFrame({"comment": phrases_train})
phrases_test_df = pd.DataFrame({"comment": phrases_test})
#export des fichiers à annoter/conserver
phrases_train_df.to_csv(os.path.join(rep, "train.csv"), index=False, encoding="utf-8")
phrases_test_df.to_csv(os.path.join(rep, "test.csv"), index=False, encoding="utf-8")2.2 Réimportation du train annoté
#| label: reimport_annotations
#| eval: false
df_final = pd.read_csv(
os.path.join(rep, "train_annote.csv"),
sep=";",
encoding="utf-8"
)
# Normalisation des noms de colonnes
df_final = df_final.rename(columns={
"communauté": "communaute",
"Histoire ": "histoire",
"Histoire": "histoire",
})
# Types (si Excel a exporté en texte)
df_final["communaute"] = df_final["communaute"].astype(int)
df_final["histoire"] = df_final["histoire"].astype(int)
df_final["avis"] = df_final["avis"].astype(int)
# Export propre (en UTF-8 pour sauvegarde "au cas où")
df_final.to_csv(
os.path.join(rep, "train_annote_propre_utf8.csv"),
index=False,
encoding="utf-8"
)
# Vérification
print(df_final.head(5))
print(df_final["comment"].head(20))2.3 Nettoyage possible (non utilisé ici)
Avant de procéder à l’entraînement nous aurions pu nettoyer un peu les commentaires. Nous aurions pu également créer un dictionnaire ad hoc pour les abréviations, les mots mal orthographiés etc. On peut par exemple proposer ceci (mais cela n’aurait bien entendu pas été suffisant).
#| label: nettoyage
#| eval: false
def nettoyer(txt: str) -> str:
if not isinstance(txt, str):
return ""
txt = txt.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
txt = re.sub(r"@\w+", "@USER", txt)
txt = re.sub(r"http\S+|www\.\S+", "URL", txt)
txt = re.sub(r"\s+", " ", txt).strip()
return txt
df_final["comment_net"] = df_final["comment"].apply(nettoyer)3. Entraînement des modèles
3.1 Label “Communauté”
3.1.1 Classifieur Simple (SentenceTransformer + LogReg)
#| label: communaute_-logreg_classifieur_simple
#| eval: false
train_df, test_df = train_test_split(
df_final,
test_size=0.2,
random_state=454,
stratify=df_final["communaute"]
)
print("Taux communaute=1 - train:", train_df["communaute"].mean(), "test:", test_df["communaute"].mean())
# X = textes, y = label ( "communaute" ici )
X_text = df_final["comment"].astype(str).tolist()
y = df_final["communaute"].astype(int).tolist()
# Split 20/80 sur les 1000 commentaires annotés
X_tr_text, X_val_text, y_tr, y_val = train_test_split(
X_text, y, test_size=0.2, random_state=454, stratify=y) # stratify=y permet de s'assurer qu'on ait les mêmes proportions de "communauté" dans les deux jeux.
# Embeddings (transformation texte -> vecteur). On choisit un embedder multilingue (FR/EN/emojis)
model_embedder = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
X_tr = model_embedder.encode(X_tr_text, show_progress_bar=True)
X_val = model_embedder.encode(X_val_text, show_progress_bar=True)
#Classifieur
clf = LogisticRegression(max_iter=2000, class_weight="balanced") #on met class-weight="balanced" car sans cette option les résultats étaient peu satisfaisants Comme il y a un gros déséquilibre de classe (15%/85%) cela peut améliorer le modèle
clf.fit(X_tr, y_tr)
#évaluation
y_val_pred = clf.predict(X_val)
print("Accuracy validation:", accuracy_score(y_val, y_val_pred))
print(classification_report(y_val, y_val_pred, digits=3))
Bonnes performances globales, mais une faible capacité à détecter la catégorie communauté, en raison du déséquilibre des classes et de la nature non systématique (contextuelle) de cette catégorie. recall=0.55, f1-score=0.386.
Ce n’est pas satisfaisant. On va essayer le fine-tuning avec CamemBERT
3.1.2 Fine-Tuning (CamemBERT)
Documentation : CamemBERT (Transformers)
A) Split train/test
#| label: split
#| eval: false
train_df, test_df = train_test_split(
df_final,
test_size=0.2,
random_state=454,
stratify=df_final["communaute"]
)
print("Train:", train_df.shape, "Test:", test_df.shape)
print("Taux commu=1 train:", train_df["communaute"].mean(), "test:", test_df["communaute"].mean())B) Préparer les datasets
#| label: datasets
#| eval: false
modele = "camembert-base"
tokenizer = AutoTokenizer.from_pretrained(modele) #transforme le texte en tokens compréhensibles pour le modèle
collator = DataCollatorWithPadding(tokenizer=tokenizer) #met les commentaires par paquets (batches) et ajoute automatiquement du "remplissage" aux phrases courtes pour qu'elles aient toutes la même longueur lors du calcul
#transformation en dataset avec une colonne "label" comme attendu
train_ds = Dataset.from_pandas(train_df[["comment", "communaute"]].rename(columns={"communaute": "labels"}))
test_ds = Dataset.from_pandas(test_df[["comment", "communaute"]].rename(columns={"communaute": "labels"}))
#transformation des commentaires textuels en représentations numériques compréhensibles par le modèle (avec troncature des textes trop longs).
def tokenize(batch):
return tokenizer(batch["comment"], truncation=True, max_length=128)
train_ds = train_ds.map(tokenize, batched=True)
test_ds = test_ds.map(tokenize, batched=True) # .map Applique la fonction tokenize à tous les commentaires du dataset,par groupes, et ajoute le résultat au dataset (résultats en 2 colonnes "input_ids" et "attention_mask").
# On garde seulement ce qui sert au modèle
cols_ok = {"input_ids", "attention_mask", "labels"}
train_ds_commu = train_ds.remove_columns([c for c in train_ds.column_names if c not in cols_ok])
test_ds_commu = test_ds.remove_columns([c for c in test_ds.column_names if c not in cols_ok])C) Modèle CamemBERT binaire
#| label: CamemBERT_metrics
#| eval: false
#on prend le modèle pré_entraîné CamemBERT qu'on traite en binaire (num_labels=2)
model_commu = AutoModelForSequenceClassification.from_pretrained(modele, num_labels=2)
#on définit une fonction qui permet d'évaluer la qualité du modèle à chaque epoch
def compute_metrics_commu(eval_pred):
logits, labels = eval_pred
# logits : scores bruts du modèle pour chaque classe (non normalisés, avant calcul des probabilités)
# labels : classes réelles (0/1) issues de l’annotation manuelle
preds = np.argmax(logits, axis=-1) #pour chaque commentaire, on prend la classe dont le score est le plus élevé.
prec, rec, f1, _ = precision_recall_fscore_support(labels, preds, average="binary", zero_division=0) #on calcule les indicateurs clefs du modèle avec la classe 1 comme référence en comparant les vraies classes (labels) et les classes prédites (preds)
acc = accuracy_score(labels, preds) #accuracy : proportion de résultats corrects
return {"accuracy": acc, "precision": prec, "recall": rec, "f1": f1}D) Entraînement
#| eval: false
from transformers import logging
logging.set_verbosity_info() #pour afficher les informations pendant que le modèle tourne. Utile quand on fait vraiment tourner le modèle car cela peut être très long.
#| label: CamemBERT_train
#| eval: false
args_commu = TrainingArguments(
output_dir=os.path.join(rep, "ft_camembert_communaute"), # Dossier de sauvegarde des checkpoints
evaluation_strategy="epoch", # On évalue à la fin de chaque epoch
save_strategy="epoch", # Sauvegarde des checkpoints
load_best_model_at_end=True, # On recharge le meilleur checkpoint à la fin
metric_for_best_model="f1", # Le meilleur modèle est celui qui maximise f1 (compromis entre précision et recall)
greater_is_better=True, # On veut f1 le plus grand possible
num_train_epochs=5, # 5 passages sur les données d'entraînement
learning_rate=2e-5, # valeur standard d'ajustement du modèle à chaque passage
per_device_train_batch_size=8, #pendant l'entraînement on traite les textes par paquets de 8
per_device_eval_batch_size=16, #pendant l'évaluation on traite les textes par paquets de 16
logging_strategy="steps",
logging_steps=10, #affiche des infos toutes les 10 étapes
disable_tqdm=True,#eviter de bugger à cause de la barre de progression
report_to="none",#n'envoie pas de log à des outils externes.
)
trainer_commu = Trainer(
model=model_commu,
args=args_commu,
train_dataset=train_ds_commu,
eval_dataset=test_ds_commu,
compute_metrics=compute_metrics_commu )#calcule les métriques qu'on a définies (précision recall et f1 à chaque évaluation)
trainer_commu.train() #lance l'apprentissage en sauvegardant et évaluant selon trainingArguments
print("\nEVAL:", trainer_commu.evaluate()) #calcule les métriques sur le test avec le best model rechargéParmi les 1 000 commentaires annotés manuellement, 80 % (n = 800) ont été utilisés pour l’apprentissage du modèle, tandis que les 20 % restants (n = 200) ont constitué un jeu de validation strictement indépendant. Ce jeu de validation a servi uniquement à l’évaluation des performances à la fin de chaque epoch et au choix du meilleur modèle, sans jamais être utilisé pour l’entraînement.
L’entraînement a été mené sur cinq epochs, mais le modèle final retenu correspond à celui obtenant le meilleur F1 sur le jeu de validation, atteinte au quatrième epoch (F1 = 0,61).
Pour la catégorie communauté, ce modèle présente une précision de 0,63, ce qui signifie qu’environ 63 % des commentaires identifiés comme communautaires sont effectivement des commentaires relevant de cette catégorie, les autres correspondant à des faux positifs.
Le rappel de 0,59 indique que le modèle parvient à identifier un peu moins de six commentaires communautaires sur dix présents dans les données, les commentaires communautaires non détectés constituant des faux négatifs.
Ce compromis entre précision et rappel reflète un arbitrage assumé entre la limitation des faux positifs et la capacité à repérer une catégorie rare et pragmatiquement complexe. Le F1 de 0,61 peut paraître faible mais elle est une performance satisfaisante compte tenu du caractère minoritaire et contextuel de la pratique. Ce F1 aurait certainement été meilleure si nous avions nettoyé les données au préalable et utilisé un dictionnaire ad hoc.
3.2 Label “Avis”
Remarque : Pour les deux labels suivants (Avis et histoire) seul le code brut est inscrit. Ce sont exactement les mêmes modèles que le précédent.
Le seul changement, en dehors des références, est l’introduction d’une régularisation weight_decay pour limiter le surapprentissage. Cette option n’a pas été retenue pour la catégorie communauté, plus rare et pragmatiquement instable, afin de ne pas pénaliser excessivement le signal minoritaire.
#| label: camembert_avis
#| eval: false
# from sklearn.metrics import precision_recall_fscore_support
train_df, test_df = train_test_split(
df_final,
test_size=0.2,
random_state=454,
stratify=df_final["avis"]
)
modele_base = "camembert-base"
tokenizer = AutoTokenizer.from_pretrained(modele_base)
collator = DataCollatorWithPadding(tokenizer=tokenizer)
train_ds = Dataset.from_pandas(train_df[["comment", "avis"]].rename(columns={"avis": "labels"}))
test_ds = Dataset.from_pandas(test_df[["comment", "avis"]].rename(columns={"avis": "labels"}))
def tokenize(batch):
return tokenizer(batch["comment"], truncation=True, max_length=128)
train_ds = train_ds.map(tokenize, batched=True)
test_ds = test_ds.map(tokenize, batched=True)
keep_cols = {"input_ids", "attention_mask", "labels"}
train_ds_avis = train_ds.remove_columns([c for c in train_ds.column_names if c not in keep_cols])
test_ds_avis = test_ds.remove_columns([c for c in test_ds.column_names if c not in keep_cols])
model_avis = AutoModelForSequenceClassification.from_pretrained(modele_base, num_labels=2)
def compute_metrics_avis(eval_pred):
logits, labels = eval_pred
preds = np.argmax(logits, axis=-1)
prec, rec, f1, _ = precision_recall_fscore_support(labels, preds, average="binary", zero_division=0)
acc = accuracy_score(labels, preds)
return {"accuracy": acc, "precision": prec, "recall": rec, "f1": f1}
args_avis = TrainingArguments(
output_dir=os.path.join(rep, "ft_camembert_avis"),
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="f1",
greater_is_better=True,
num_train_epochs=5,
learning_rate=2e-5,
per_device_train_batch_size=8,
per_device_eval_batch_size=16,
weight_decay=0.01,
logging_strategy="steps",
logging_steps=10,
disable_tqdm=True,
report_to="none",
)
trainer_avis = Trainer(
model=model_avis,
args=args_avis,
train_dataset=train_ds_avis,
eval_dataset=test_ds_avis,
compute_metrics=compute_,
)
trainer_avis.train()
print("\nEVAL AVIS:", trainer_avis.evaluate())
EVAL AVIS: {‘eval_loss’: 0.1594199687242508, ‘eval_accuracy’: 0.96, ‘eval_precision’: 0.8387096774193549, ‘eval_recall’: 0.896551724137931, ‘eval_f1’: 0.8666666666666667, ‘eval_runtime’: 15.4348, ‘eval_samples_per_second’: 12.958, ‘eval_steps_per_second’: 0.842, ‘epoch’: 5.0}
Le modèle CamemBERT a également été fine-tuné pour la classification des commentaires relevant de la catégorie avis, à partir du même corpus (800/200)
L’entraînement a été conduit sur cinq epochs, le modèle final correspondant à celui maximisant le F1 sur le jeu de validation. Ce modèle atteint un F1 de 0,87, avec une précision de 0,84 et un rappel de 0,90 pour la catégorie avis.
Ces résultats indiquent que le modèle parvient à identifier la grande majorité des commentaires exprimant un avis, tout en limitant efficacement les faux positifs, c’est-à-dire les commentaires classés à tort comme relevant d’un jugement évaluatif. Le rappel élevé traduit une faible proportion de faux négatifs, les commentaires d’avis étant rarement ignorés par le modèle.
Les performances nettement supérieures observées pour cette catégorie, en comparaison avec la catégorie communauté, s’expliquent par le caractère plus lexicalement marqué des commentaires d’avis, qui mobilisent des jugements explicites, positifs ou négatifs, sur la qualité du webtoon, de son scénario ou de son esthétique.
3.3 Label “Histoire”
#| label: histoire-camembert
#| eval: false
# from sklearn.metrics import precision_recall_fscore_support
train_df, test_df = train_test_split(
df_final,
test_size=0.2,
random_state=454,
stratify=df_final["histoire"]
)
modele_base = "camembert-base"
tokenizer = AutoTokenizer.from_pretrained(modele_base)
collator = DataCollatorWithPadding(tokenizer=tokenizer)
train_ds = Dataset.from_pandas(train_df[["comment", "histoire"]].rename(columns={"histoire": "labels"}))
test_ds = Dataset.from_pandas(test_df[["comment", "histoire"]].rename(columns={"histoire": "labels"}))
def tokenize(batch):
return tokenizer(batch["comment"], truncation=True, max_length=128)
train_ds = train_ds.map(tokenize, batched=True)
test_ds = test_ds.map(tokenize, batched=True)
keep_cols = {"input_ids", "attention_mask", "labels"}
train_ds_hist = train_ds.remove_columns([c for c in train_ds.column_names if c not in keep_cols])
test_ds_hist = test_ds.remove_columns([c for c in test_ds.column_names if c not in keep_cols])
model_hist = AutoModelForSequenceClassification.from_pretrained(modele_base, num_labels=2)
def compute_metrics_hist(eval_pred):
logits, labels = eval_pred
preds = np.argmax(logits, axis=-1)
prec, rec, f1, _ = precision_recall_fscore_support(labels, preds, average="binary", zero_division=0)
acc = accuracy_score(labels, preds)
return {"accuracy": acc, "precision": prec, "recall": rec, "f1": f1}
args_hist = TrainingArguments(
output_dir=os.path.join(rep, "ft_camembert_histoire"),
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="f1",
greater_is_better=True,
num_train_epochs=5,
learning_rate=2e-5,
per_device_train_batch_size=8,
per_device_eval_batch_size=16,
weight_decay=0.01,
logging_strategy="steps",
logging_steps=10,
disable_tqdm=True,
report_to="none",
)
trainer_hist = Trainer(
model=model_hist,
args=args_hist,
train_dataset=train_ds_hist,
eval_dataset=test_ds_hist,
compute_metrics=compute_metrics_hist,
)
trainer_hist.train()
print("\nEVAL HISTOIRE:", trainer_hist.evaluate())EVAL HISTOIRE: {‘eval_loss’: 0.3707737326622009, ‘eval_accuracy’: 0.895, ‘eval_precision’: 0.893491124260355, ‘eval_recall’: 0.9805194805194806, ‘eval_f1’: 0.934984520123839, ‘eval_runtime’: 13.6311, ‘eval_samples_per_second’: 14.672, ‘eval_steps_per_second’: 0.954, ‘epoch’: 5.0}
Le modèle CamemBERT a également été fine-tuné pour la classification des commentaires relevant de la catégorie histoire, à partir du même corpus de 800 commentaires annotés manuellement, avec une évaluation réalisée sur un jeu de validation distinct de 200 commentaires.
L’entraînement a été conduit sur cinq epochs, le modèle final correspondant à celui maximisant le F-1 sur le jeu de validation. Ce modèle atteint un F-1 de 0,93, avec une précision de 0,89 et un rappel de 0,98 pour la catégorie histoire.
Le rappel très élevé indique que la quasi-totalité des commentaires effectivement centrés sur la narration sont correctement identifiés par le modèle, les faux négatifs étant très rares. Autrement dit, les commentaires portant sur l’histoire et ses personnages sont presque systématiquement reconnus comme tels.
La précision élevée traduit également une proportion limitée de faux positifs, c’est-à-dire de commentaires classés à tort comme relevant de l’histoire. L’examen qualitatif (voir point suivant) de ces faux positifs montre toutefois que nombre d’entre eux entretiennent un lien indirect avec le récit, par exemple via des références implicites à des situations ou à des personnages, ce qui contribue à expliquer leur assimilation par le modèle à la catégorie histoire.
Ces performances très élevées confirment que la catégorie histoire repose sur des indices lexicaux et référentiels relativement stables, permettant une opérationnalisation automatique particulièrement efficace, en contraste avec la catégorie communauté, plus interactionnelle, et proche des performances observées pour le label avis.
3.4 Étude des erreurs de classification (faux positifs et faux négatifs)
Cette étude doit nous permettre de comprendre pourquoi le modèle n’a pas correctement catégorisé certains commentaires. Cela nous permettra d’améliorer le modèle (par le nettoyage, l’utilisation d’un dictionnaire ou un affinement de l’annotation manuelle) si nous réitérons cette étude.
Les faux positifs (0→1) et faux négatifs (1→0) sont extraits sur le jeu de validation (n=200), puis triés par niveau de confiance du modèle (probabilité p1 de la classe 1). On présente les 10 cas les plus extrêmes afin d’identifier les régularités des confusions.
3.4.1 Erreurs de classification : Communauté
#| label: erreurs_communauté
#| eval: false
#calcul des softmax (les proba calculées à partir des scores bruts)
pred = trainer_commu.predict(test_ds_commu)
logits = pred.predictions
x = logits - logits.max(axis=1, keepdims=True) #logits.max donne le plus grand des 2 scores. Donc on "normalise"" les scores pour appliquer l'exponentielle sur des valeurs pas trop grandes.
proba_1 = np.exp(x)[:, 1] / np.exp(x).sum(axis=1) #ici on calcule le softmax : probabilité classe1 = exp(score1normalisé)/(somme des exp(scores normalisés))
y_true = test_df["communaute"].to_numpy()
y_pred = np.argmax(logits, axis=1)
err = test_df.copy()
err["y_true"] = y_true
err["y_pred"] = y_pred
err["p1"] = proba_1
fp = err[(err.y_true == 0) & (err.y_pred == 1)].sort_values("p1", ascending=False).head(10)
fn = err[(err.y_true == 1) & (err.y_pred == 0)].sort_values("p1", ascending=True).head(10)
print(fp[["comment", "p1"]].to_string(index=False))
print(fn[["comment", "p1"]].to_string(index=False))
| Commentaire (faux positif – communauté) | Probabilité p₁ |
|---|---|
| je ne comprends pas l’âge s’il ont ?? | 0.995 |
| parler nous de votre rencontre !!!! | 0.995 |
| NANI ! Tu vas rester en faite, moi j’ai tjrs pas vu de scène croustillantes ! | 0.994 |
| “utilise moi comme tu veux” à ta place j’utiliserai une plume pour écrire mais c’est mon avis 😂💜 | 0.994 |
| Le jury a été généreux, il leur on mis un point alors que pour leur patrons, il on eu aucune pitié 😂 | 0.993 |
| moi je croyais que tout le dans ce webtoon avait un surnom de bouffe… | 0.993 |
| A ba voilà 🤩 enfin un webtoon avec mon nom 😆 | 0.993 |
| On avait Adorablement chien… je vous présente maintenant Adorablement renard !! 🤣🤣😭 | 0.991 |
| On peut jouer avec le cœur des gens comme ça ? | 0.943 |
| J’aime trop ce nouveau webtoon… et aussi nos trois héros ! ❤️ | 0.832 |
==> ces faux positifs ne proviennent pas d’une erreur lors de l’annotation mais bien d’une capacité limitée du modèle à séparer adresse à la communauté et adresse aux personnages des webtoons. Le modèle confond “forme interactionnelle” (question, apostrophe, impératif) avec “communauté au sens strict” (interaction avec lecteurs / auteur / commentaires/ référence à la plateforme). C’est, malgré le travail de cadrage effectué, un problème de définition : où commence “communauté” ? à “nous” ? à l’interpellation ? à la mention explicite des autres lecteurs ?
| Commentaire (faux négatif – communauté) | Probabilité p₁ |
|---|---|
| Oups. T’es mal mon p’tit gars… (PS VOUS AVEZ VU CES COULEURS BORDEL KECÉBOOOO) | 0.0029 |
| @IRMA, Ha oui c’est un homme aussi ? 🤣🤣🤣 C’était violent | 0.0029 |
| On est d’accord que Dum-dum est trop cute ?? ✨ | 0.0030 |
| slm cv | 0.0030 |
| ÇA SE FAIT PAS !!!!! C’EST TROP CRUEL DE NOUS LAISSER COMME ÇA !!!!!! 😡😡😡😡😡😡😡😭😭😭😭😭😭😭 | 0.0031 |
| j’attends devant mon tel moi | 0.0031 |
| 😂😂😂, Piment me tuera toujours. Ne t’inquiètes pas Chocolat… j’ai découvert ton webtoon récemment et c’est une pépite 💜💜💜 | 0.0032 |
| BONJOUR! 🥰 | 0.0047 |
| criquitez mais mettez vous a sa place un peu ! faites vous brisez vos rêves… | 0.0054 |
| non mais c pas possible le créateur il joue a quoi en fessant sa la suite 😭😭😭😭 | 0.0059 |
==>les faux négatifs confirment que le modèle a du mal à détecter les adresse à la communauté (salutations, interpellation du créateur/auteur/dessinateur/question explicite posée aux autres webtooners). Les formes très courtes ou très “chat” sont sous-apprises. Conclusion: Dans l’idéal il faudrait ajouter dans le train quelques (20–50) commentaires “salutations / @ / questions brèves” correctement labellisés, puis refaire un fine-tuning.
3.4.2 Erreurs de classification : Avis
#| label: erreurs_avis
#| eval: false
pred_avis = trainer_avis.predict(test_ds_avis)
logits_avis = pred_avis.predictions
x = logits - logits.max(axis=1, keepdims=True)
ex = np.exp(x)
proba_1 = ex[:, 1] / ex.sum(axis=1)
y_true = test_df["avis"].to_numpy() #attention, suppose que test_ds et test_df sont dans le même ordre, ce qui est le cas ici.
y_pred = np.argmax(logits, axis=1)
err = test_df.copy()
err["y_true"] = y_true
err["y_pred"] = y_pred
err["p1"] = proba_1
fp = err[(err.y_true == 0) & (err.y_pred == 1)].sort_values("p1", ascending=False).head(10)
fn = err[(err.y_true == 1) & (err.y_pred == 0)].sort_values("p1", ascending=True).head(10)
print(fp[["comment", "p1"]].to_string(index=False))
print(fn[["comment", "p1"]].to_string(index=False))| Commentaire (faux positif – avis) | Probabilité p₁ |
|---|---|
| Ça promet un bon triangle amoureux classique avec les chassés croisé de sentiment… classique, prévisible mais qu’on aime tellement !! 😍 | 0.972 |
| super Court… | 0.971 |
| Vivement Que Ce Webtoon Sort 😍😍 | 0.970 |
| J’ai kiffé les dernières répliques 😭❤️ | 0.964 |
| Commentaire (faux négatif – avis) | Probabilité p₁ |
|---|---|
| wesh, les épisodes des séries d’aujourd’hui que des bangers 🙏🏾 ça, couple breaker, my raison to die… | 0.0095 |
| Franchement elle est vraiment chelou, c’est pas parce que ton patron est beau que tu peux lui pardonner d’être un connard… c’est n’importe quoi cette intrigue. | 0.0097 |
| franchement l’autre prof mme Yun elle m’énerve au plus au point. sinon l’histoire est très intéressante et j’espère qu’il finiront ensemble le prof qui a peur des chiens et Mme Han ☺️☺️ | 0.141 |
| ça sent la romaaaance ❤️ (Merci cher webtooneurs et webtonneuses, c’est mon deuxième n’est cooom je vous aiiiiime) | 0.947 |
Ces confusions soulignent la porosité entre expression affective, anticipation et jugement évaluatif, malgré une performance globale élevée du modèle pour cette catégorie. Elle souligne également une instabilité de l’annotation puisque 2 faux négatifs auraient dû en effet être codés 0 (“Franchement elle est chelou….” et “ça sent la romance….”.
3.4.3 Erreurs de classification : Histoire
#| label: erreurs_histoire
#| eval: false
pred_hist = trainer_hist.predict(test_ds_hist)
logits_hist = pred_hist.predictions
x = logits - logits.max(axis=1, keepdims=True)
ex = np.exp(x)
proba_1 = ex[:, 1] / ex.sum(axis=1)
y_true = test_df["histoire"].to_numpy()
y_pred = np.argmax(logits, axis=1)
err = test_df.copy()
err["y_true"] = y_true
err["y_pred"] = y_pred
err["p1"] = proba_1
fp = err[(err.y_true == 0) & (err.y_pred == 1)].sort_values("p1", ascending=False).head(10)
fn = err[(err.y_true == 1) & (err.y_pred == 0)].sort_values("p1", ascending=True).head(10)
print(fp[["comment", "p1"]].to_string(index=False))
print(fn[["comment", "p1"]].to_string(index=False))| Commentaire (faux positif – histoire) | Probabilité p₁ |
|---|---|
| madame la daronne comment ça mon reuf ? 😂🤔 | 0.971 |
| C quand que la suite arrive il avait dit 2 à 4 mois je crois ! 😭😭😭 | 0.971 |
| Alors, moi, ma mère elle éteint le Wi-Fi. Mais apparemment on a pas les mêmes daronnes 😳😨 | 0.971 |
| Mdr la fille lit de la fantasy romantique et elle a tous les codes du royaume ! 😂 bravoooo | 0.971 |
| Pourquoi vous soupçonnez ce mec ? J’y comprends rien. | 0.971 |
| oh mon dieu imaginez vous êtes dans le corps d’un idole que vous kiffez grave… like si tu es une ✨️moharmystay✨️ | 0.971 |
| on joue au Twister tu vois pas là 👹 | 0.970 |
| Je crois que la véritable histoire d’Ulysse et ce webtoon sont deux webtoons où je rigole le plus ;) | 0.970 |
| Ce n’est qu’un détail mais… le dessinateur a « sali » la converse de Ga-Eun… ce sont ces petites choses que j’apprécie… | 0.970 |
| t’as de la chance de pouvoir bronzer facilement ! | 0.969 |
| Commentaire (faux négatif – histoire) | Probabilité p₁ |
|---|---|
| Je suis tombée dessus par hasard et je kiff déjà. Pressée de voir leur évolution ❤️❤️😁😁 | 0.044 |
| A ba voilà 🤩 enfin un webtoon avec mon nom 😆 | 0.044 |
| tjr trop chou | 0.045 |
Pour la catégorie histoire, les faux positifs correspondent majoritairement à des commentaires très proches du récit, parfois interrogatifs, mais qui ne décrivent pas directement une scène, une action ou un événement narratif précis. Il s’agit également de commentaires dont il est difficile de comprendre le sujet ou qui ont été l’objet d’une erreur d’annotation (“Pourquoi vous soupçonnez ce mec ? J’y comprends rien.”).
Les quelques faux négatifs sont des commentaires assez courts, fortement affectif et dont la référence à la narration est difficilement perceptible par le modèle. Par exemple, il est difficile de détecter que “leur évolution” fait référence aux personnages.
4. Application des modèles
#| label: application_modeles
#| eval: false
#reprise des 3 best-model
ckpt_commu = os.path.join(rep, "ft_camembert_communaute", "checkpoint-400")
ckpt_avis = os.path.join(rep, "ft_camembert_avis", "checkpoint-500")
ckpt_hist = os.path.join(rep, "ft_camembert_histoire", "checkpoint-500")
tokenizer = AutoTokenizer.from_pretrained("camembert-base") #on charge le dictionnaire
#on charge les "poids" définis par les modèles
model_commu = AutoModelForSequenceClassification.from_pretrained(ckpt_commu)
model_avis = AutoModelForSequenceClassification.from_pretrained(ckpt_avis)
model_hist = AutoModelForSequenceClassification.from_pretrained(ckpt_hist)
#on va maintenant ajouter les prédictions à notre base de données de départ (la prédiction et sa probabilité)
device = torch.device("cpu") #en l'absence de GPU type NVIDIA
model_commu.to(device).eval() #on place le modèle sur le CPU et on passe en mode prédiction (.eval())
model_avis.to(device).eval()
model_hist.to(device).eval()
#on va appliquer ces modèles aux commentaires étudiés
textes = comm["comment"].astype(str).tolist() #liste des 286505 commentaires
#création d'une fonction qu'on pourra appliquer aux 3 modèles (communauté, avis et histoire)
#La fonction predire_modele_progress va:predire_modele_progress(...) fait trois choses :
#-Prendre une liste de textes (textes) et un modèle binaire (0/1).
#-Calculer pour chaque texte une probabilité d’être dans la classe 1 (proba_1).
#-Transformer cette probabilité en prédiction 0/1 via un seuil de 0,5 (pred_01).
def predire_modele_progress(model, textes, nom, batch_size=32, max_length=96, print_every_batches=200): #Retourne (pred_01, proba_1) avec barre de progression + print périodique
model.eval()
proba_1 = np.zeros(len(textes), dtype=np.float32) #creation du tableau vide pour stocker les proba
t0 = time.time() #pour calculer le temps de calcul
nb_batches = (len(textes) + batch_size - 1) // batch_size #calcul du nb de batches au total
with torch.no_grad(): #désactive le calcul des gradiens pour gagner en rapidité
for b in tqdm(range(nb_batches), desc=f"Prediction {nom}", leave=True): #boucle sur les batches
i0 = b * batch_size
i1 = min(i0 + batch_size, len(textes)) #au cas où il reste moins de textes que le batch_size (32)
batch = textes[i0:i1]
#i0:indice du premier commentaire du batch
#i1 : indice du dernier commentaire du batch
enc = tokenizer(
batch,
truncation=True,
padding=True,
max_length=max_length,
return_tensors="pt" #retourne des tenseurs (tableau de nombres) de format PyTorch
)
enc = {k: v.to(device) for k, v in enc.items()} #déplace les tenseurs sur le CPU
logits = model(**enc).logits #applique le modèle sur le batch. L'utilisation de ** signifie qu'on demande de déplier le dictionnaire enc en arguments nommés. model(**enc)=model(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"]))
p1 = torch.softmax(logits, dim=1)[:, 1].cpu().numpy() #on prend la proba de la classe 1, on s'assure que c'est sur le cpu et on le transforme en tableau numpy
proba_1[i0:i1] = p1 #stocke les proba du batch dans le tableau
if (b + 1) % print_every_batches == 0: #à la fin du batch on affiche le message d'avancement
elapsed = time.time() - t0 #temps écoulé
done = i1 #nombre de textes traités
speed = done / elapsed if elapsed > 0 else 0 #vitesse
remaining = (len(textes) - done) / speed if speed > 0 else float("inf") #temps restant
print(f"[{nom}] {done}/{len(textes)} textes | {speed:.1f} txt/s | reste ~ {remaining/60:.1f} min")
pred_01 = (proba_1 >= 0.5).astype(np.int8) #convertit les proba en prédictions (np.int8 permet de transformer les True/False en 1/0. C'est économe en mémoire)
return pred_01, proba_1
#on applique cette fonction aux 3 labels (on sauvegarde en csv à la fin d'un modèle "au cas où")
comm["pred_communaute"], comm["proba_communaute"] = predire_modele_progress(model_commu, textes, "communaute")
comm.to_csv(os.path.join(rep, "tc_pred_step1_communaute.csv"), index=False, encoding="utf-8")
comm["pred_avis"], comm["proba_avis"] = predire_modele_progress(model_avis, textes, "avis")
comm.to_csv(os.path.join(rep, "tc_pred_step2_avis.csv"), index=False, encoding="utf-8")
comm["pred_histoire"], comm["proba_histoire"] = predire_modele_progress(model_hist, textes, "histoire")
comm.to_csv(os.path.join(rep, "tc_predictions_3labels_utf8.csv"), index=False, encoding="utf-8")
# Verification des proportions
comm[["pred_communaute","pred_avis","pred_histoire"]].mean()
df_final[["communaute","avis","histoire"]].mean() #pour comparer avec les 1000 commentaires annotés "à la main".comm[[“pred_communaute”,“pred_avis”,“pred_histoire”]].mean()
pred_communaute 0.132092 pred_avis 0.161411 pred_histoire 0.788119 dtype: float64
df[[“communaute”,“avis”,“histoire”]].mean()
communaute 0.144 avis 0.146 histoire 0.772 dtype: float64
=> les proportions sont assez proches. Les écarts sont cohérents avec les analyses menées en section 3.4. On note notamment une sur-classification dans la catégorie avis (sur-captation de l’enthousiasme) et une légère sous-classification dans la catégorie communauté.