Classification de commentaires Webtoon (CamemBERT)

Author

Romain Orioli

Published

January 26, 2026

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.

Note

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é.