AP-4209
ESIEE-Paris: 2025 - 2026
Fouille de données avec R pour la data science et l’intelligence artificielle
Projet Final : Classification bayésienne et Analyse Factorielle Discriminante
Travail en binôme (Kévin Feltrin et un autre étudiant). – ESIEE Paris, filière DSIA (2025-2026)
1. Préambule
Avec la montée en puissance des LLM (GPT-4, LLaMA 2, Mistral, etc.), la question de savoir si un texte a été rédigé par un humain ou par une machine est devenue un vrai problème concret, que ce soit pour la vérification de contenus journalistiques, la lutte contre la triche académique ou simplement la confiance que l’on accorde à un document.
Dans ce projet, nous cherchons à construire un pipeline de classification capable de distinguer automatiquement un texte humain d’un texte généré par IA. Pour cela, nous combinons :
- l’extraction de features linguistiques, stylométriques et vectorielles (TF-IDF) ;
- l’Analyse Factorielle Discriminante (AFD) pour réduire la dimensionnalité ;
- deux classifieurs bayésiens (Naive Bayes et régression logistique bayésienne) ;
- une validation croisée stratifiée k-fold avec plusieurs métriques.
Les données proviennent du challenge Kaggle LLM - Detect AI Generated Text.
2. Données et prétraitement
2.1 Chargement des données
Le jeu officiel du challenge (train_essays.csv) ne
contient que 3 textes IA sur 1 378
textes au total, ce qui rend tout apprentissage impossible en
l’état. On a donc choisi d’utiliser le jeu supplémentaire
train_drcat_04.csv, couramment utilisé par les compétiteurs
Kaggle, qui regroupe des textes de plusieurs sources :
| Source | Type | Nombre |
|---|---|---|
| persuade_corpus | Humain | 29 792 |
| chat_gpt_moth | IA (ChatGPT) | ~4 800 |
| llama2_chat | IA (LLaMA 2) | ~4 800 |
| llammistral7binstruct | IA (Mistral) | ~4 800 |
On effectue un échantillonnage stratifié de 3 000 textes par classe (6 000 au total) afin d’avoir un dataset équilibré tout en gardant des temps de calcul raisonnables.
drcat_path <- file.path(root_dir, "datasets", "train_drcat_04.csv", "train_drcat_04.csv")
drcat <- readr::read_csv(drcat_path, show_col_types = FALSE)
dataset <- drcat %>%
mutate(generated = factor(label, levels = c(0, 1), labels = c("human", "ai"))) %>%
group_by(generated) %>%
sample_n(size = min(3000, n())) %>%
ungroup() %>%
mutate(text = stringr::str_squish(text)) %>%
dplyr::select(text, generated, source)
cat("Dimensions :", nrow(dataset), "textes,", ncol(dataset), "colonnes\n")
## Dimensions : 6000 textes, 3 colonnes
cat("Répartition :\n"); print(table(dataset$generated))
## Répartition :
##
## human ai
## 3000 3000
2.2 Analyse exploratoire (EDA)
dataset %>%
count(generated, source) %>%
ggplot(aes(x = generated, y = n, fill = source)) +
geom_col(position = "stack", alpha = 0.85, width = 0.6) +
theme_minimal(base_size = 14) +
labs(title = "Distribution des classes par source de texte",
x = "Classe", y = "Nombre de textes", fill = "Source") +
scale_fill_brewer(palette = "Set2")
Ce graphe montre la répartition des textes par classe et par source. On voit que les 3 000 textes humains proviennent tous du corpus Persuade, alors que les 3 000 textes IA sont répartis entre plusieurs modèles (ChatGPT, LLaMA 2, Mistral). L’échantillonnage est bien équilibré entre les deux classes.
dataset %>%
mutate(word_count = stringr::str_count(text, "\\S+")) %>%
ggplot(aes(x = word_count, fill = generated)) +
geom_histogram(bins = 60, alpha = 0.55, position = "identity") +
theme_minimal(base_size = 14) +
labs(title = "Distribution de la longueur des textes",
x = "Nombre de mots", y = "Fréquence", fill = "Classe") +
scale_fill_manual(values = c(human = "#4c8bf5", ai = "#f44336"))
L’histogramme de la longueur des textes révèle une première différence entre les classes : les textes IA ont tendance à être plus concentrés autour d’une longueur “standard”, tandis que les textes humains présentent une distribution plus étalée avec davantage de variabilité. C’est déjà un indice que la longueur et la régularité du texte peuvent servir de features discriminantes.
eda_summary <- dataset %>%
mutate(nchar = nchar(text),
nwords = stringr::str_count(text, "\\S+")) %>%
group_by(generated) %>%
summarise(
n = n(),
moy_chars = round(mean(nchar)),
med_chars = round(median(nchar)),
sd_chars = round(sd(nchar)),
moy_mots = round(mean(nwords)),
.groups = "drop"
)
knitr::kable(eda_summary, caption = "Statistiques descriptives par classe")
| generated | n | moy_chars | med_chars | sd_chars | moy_mots |
|---|---|---|---|---|---|
| human | 3000 | 2365 | 2146 | 1112 | 426 |
| ai | 3000 | 1937 | 1922 | 586 | 318 |
readr::write_csv(eda_summary, file.path(output_dir, "eda_summary.csv"))
3. Extraction de caractéristiques (Feature Extraction)
Conformément au cahier des charges, on extrait plusieurs types de features pour différencier texte humain et texte IA :
- Stylométriques : nombre de phrases, nombre de mots, longueur moyenne de phrase, diversité lexicale (type-token ratio), longueur moyenne des mots, densité de ponctuation, de chiffres et de majuscules.
- Variabilité : écart-type de la longueur des phrases, qui permet de capter la monotonie souvent observée dans les textes générés.
- Entropie lexicale : sert de proxy pour la perplexité et mesure à quel point la distribution des mots est uniforme ou non.
- Lisibilité : indices de Flesch, FOG et SMOG.
- Vectorisation : TF-IDF sur les tokens nettoyés (après suppression des stopwords et élagage du vocabulaire).
3.1 Caractéristiques stylométriques et entropie
extract_stylometric_features <- function(text_vec) {
# Découpage en phrases
sentences <- stringr::str_split(text_vec, "(?<=[.!?])\\s+")
sentence_count <- vapply(sentences, length, numeric(1))
# Variabilité de la longueur des phrases (sd)
sent_word_counts <- lapply(sentences, function(s) stringr::str_count(s, "\\S+"))
sentence_length_sd <- vapply(sent_word_counts, function(x) {
if (length(x) < 2) return(0)
sd(x)
}, numeric(1))
# Mots
word_list <- stringr::str_extract_all(tolower(text_vec), "[a-z]+")
word_count <- vapply(word_list, length, numeric(1))
# Diversité lexicale (type-token ratio)
lexical_diversity <- vapply(word_list, function(x) {
if (length(x) == 0) return(0)
length(unique(x)) / length(x)
}, numeric(1))
# Longueur moyenne des mots
avg_word_length <- vapply(word_list, function(x) {
if (length(x) == 0) return(0)
mean(nchar(x))
}, numeric(1))
# Entropie lexicale (proxy de perplexité)
word_entropy <- vapply(word_list, function(x) {
if (length(x) < 2) return(0)
freq <- table(x) / length(x)
-sum(freq * log2(freq))
}, numeric(1))
tibble::tibble(
sentence_count = sentence_count,
word_count = word_count,
avg_sentence_length = word_count / pmax(sentence_count, 1),
sentence_length_sd = sentence_length_sd,
lexical_diversity = lexical_diversity,
avg_word_length = avg_word_length,
word_entropy = word_entropy,
punctuation_density = stringr::str_count(text_vec, "[,;:!?\\-]") / pmax(word_count, 1),
digit_density = stringr::str_count(text_vec, "\\d") / pmax(word_count, 1),
uppercase_density = stringr::str_count(text_vec, "[A-Z]") / pmax(nchar(text_vec), 1)
)
}
style_features <- extract_stylometric_features(dataset$text)
style_features %>%
mutate(generated = dataset$generated) %>%
pivot_longer(-generated) %>%
ggplot(aes(x = generated, y = value, fill = generated)) +
geom_boxplot(alpha = 0.6, outlier.size = 0.5) +
facet_wrap(~name, scales = "free_y", ncol = 5) +
theme_minimal(base_size = 11) +
labs(title = "Caractéristiques stylométriques par classe") +
guides(fill = "none") +
scale_fill_manual(values = c(human = "#4c8bf5", ai = "#f44336"))
Les boxplots montrent les différences entre classes pour chacune des
features stylométriques. On remarque par exemple que la diversité
lexicale (lexical_diversity) est généralement plus élevée
chez les textes humains, et que l’écart-type de la longueur des phrases
(sentence_length_sd) est plus faible pour les textes IA, ce
qui traduit leur côté plus régulier. L’entropie lexicale
(word_entropy) est aussi un bon indicateur : les textes
humains tendent à utiliser un vocabulaire plus varié.
3.2 Scores de lisibilité
readability <- quanteda::corpus(dataset$text) %>%
quanteda.textstats::textstat_readability(measure = c("Flesch", "FOG", "SMOG")) %>%
dplyr::select(Flesch, FOG, SMOG)
readability %>%
mutate(generated = dataset$generated) %>%
pivot_longer(-generated) %>%
ggplot(aes(x = generated, y = value, fill = generated)) +
geom_boxplot(alpha = 0.6) +
facet_wrap(~name, scales = "free_y") +
theme_minimal(base_size = 13) +
labs(title = "Scores de lisibilité par classe") +
guides(fill = "none") +
scale_fill_manual(values = c(human = "#4c8bf5", ai = "#f44336"))
Les scores de lisibilité (Flesch, FOG, SMOG) montrent des distributions légèrement différentes entre les deux classes. Le score de Flesch est en moyenne un peu plus élevé pour les textes IA, ce qui veut dire qu’ils sont souvent un peu plus “faciles à lire” au sens de cette formule. Les indices FOG et SMOG, qui mesurent la complexité des phrases et des mots, confirment cette tendance : les textes générés sont souvent plus homogènes en termes de structure.
3.3 Vectorisation TF-IDF
La vectorisation TF-IDF convertit chaque texte en un vecteur numérique. On retire d’abord les stopwords anglais, puis on élague le vocabulaire (fréquence d’apparition entre 1 % et 85 % des documents). On garde ensuite les 300 colonnes ayant la plus grande variance.
corp <- quanteda::corpus(dataset$text)
toks <- quanteda::tokens(corp, remove_numbers = TRUE, remove_punct = TRUE,
remove_symbols = TRUE) %>%
quanteda::tokens_tolower() %>%
quanteda::tokens_remove(pattern = quanteda::stopwords("en"))
dfm_raw <- quanteda::dfm(toks)
dfm_trimmed <- quanteda::dfm_trim(dfm_raw,
min_docfreq = 0.01, docfreq_type = "prop",
max_docfreq = 0.85)
tfidf <- quanteda::dfm_tfidf(dfm_trimmed)
tfidf_mat <- as.matrix(tfidf)
# Conservation des 300 colonnes de variance maximale
if (ncol(tfidf_mat) > 300) {
col_vars <- apply(tfidf_mat, 2, var)
keep_idx <- order(col_vars, decreasing = TRUE)[seq_len(300)]
tfidf_mat <- tfidf_mat[, keep_idx, drop = FALSE]
}
cat("Matrice TF-IDF finale :", nrow(tfidf_mat), "x", ncol(tfidf_mat), "\n")
## Matrice TF-IDF finale : 6000 x 300
3.4 Table de caractéristiques combinée
feature_table <- bind_cols(
style_features,
readability,
as.data.frame(tfidf_mat)
) %>%
mutate(y = dataset$generated)
# Suppression des colonnes de variance quasi-nulle (évite singularité pour l'AFD)
num_cols <- setdiff(names(feature_table), "y")
col_var <- apply(feature_table[num_cols], 2, var, na.rm = TRUE)
keep <- names(col_var[col_var > 1e-8])
feature_table <- feature_table %>% dplyr::select(all_of(keep), y)
# Remplacement des NA éventuels par 0
feature_table[is.na(feature_table)] <- 0
cat("Table finale :", nrow(feature_table), "obs x", ncol(feature_table) - 1, "features\n")
## Table finale : 6000 obs x 313 features
4. Analyse des facteurs discriminants (AFD)
4.1 Fondements mathématiques
L’AFD, implémentée ici avec MASS::lda, cherche une
projection linéaire qui
maximise la séparation entre les classes. Le critère de Fisher est le
suivant :
avec :
- : matrice de dispersion inter-classes
- : matrice de dispersion intra-classes
La solution est donnée par le vecteur propre associé à la plus grande valeur propre du problème .
Dans notre cas, avec seulement 2 classes, on obtient un unique axe discriminant qui projette tout l’espace de features sur une seule droite.
4.2 Application et projection
lda_global <- MASS::lda(y ~ ., data = feature_table)
disc_scores <- as.data.frame(predict(lda_global, feature_table)$x)
disc_scores$generated <- feature_table$y
cat("Nombre d'axes discriminants :", ncol(predict(lda_global, feature_table)$x), "\n")
## Nombre d'axes discriminants : 1
ggplot(disc_scores, aes(x = LD1, fill = generated)) +
geom_density(alpha = 0.5, linewidth = 0.8) +
geom_vline(xintercept = 0, linetype = "dashed", color = "gray40") +
theme_minimal(base_size = 14) +
labs(title = "Distribution du facteur discriminant LD1",
x = "LD1", y = "Densité", fill = "Classe") +
scale_fill_manual(values = c(human = "#4c8bf5", ai = "#f44336"))
ggsave(file.path(output_dir, "discriminant_factors.png"), width = 8, height = 5, dpi = 140)
On voit clairement que les deux classes sont bien séparées sur l’axe LD1, ce qui confirme que l’AFD arrive bien à capter les différences entre texte humain et texte IA.
5. Classification bayésienne
5.1 Méthodologie
On entraîne deux classifieurs bayésiens sur les facteurs discriminants issus de l’AFD :
Naive Bayes (
e1071::naiveBayes) : ce modèle suppose que les features sont indépendantes conditionnellement à la classe : On applique un lissage de Laplace () pour éviter les probabilités nulles.Régression logistique bayésienne (
arm::bayesglm) : ce modèle place des priors faiblement informatifs (Cauchy) sur les coefficients, ce qui donne une régularisation naturelle : C’est une approche MAP (Maximum A Posteriori), plus rapide qu’un MCMC complet (Stan, JAGS) tout en restant bayésienne.
5.2 Validation croisée stratifiée 5-fold
Pour chaque fold, on calibre un seuil de décision en maximisant le F1-score sur l’ensemble d’entraînement (grille de seuils entre 0.05 et 0.95).
folds <- vfold_cv(feature_table, v = 5, strata = y)
# Stockage des prédictions du dernier fold pour ROC / confusion matrix
last_fold_preds <- NULL
compute_fold_metrics <- function(split_obj, fold_id) {
tr <- analysis(split_obj)
te <- assessment(split_obj)
find_best_threshold <- function(prob, truth_factor) {
candidates <- seq(0.05, 0.95, by = 0.05)
prob <- ifelse(is.na(prob), mean(truth_factor == "ai"), prob)
truth_ai <- truth_factor == "ai"
f1_vals <- vapply(candidates, function(th) {
pred_ai <- prob >= th
tp <- sum(pred_ai & truth_ai)
fp <- sum(pred_ai & !truth_ai)
fn <- sum(!pred_ai & truth_ai)
prec <- if ((tp + fp) == 0) 0 else tp / (tp + fp)
rec <- if ((tp + fn) == 0) 0 else tp / (tp + fn)
if ((prec + rec) == 0) 0 else 2 * prec * rec / (prec + rec)
}, numeric(1))
candidates[which.max(f1_vals)]
}
# AFD sur le fold
lda_fit <- MASS::lda(y ~ ., data = tr)
tr_disc <- as.data.frame(predict(lda_fit, tr)$x)
te_disc <- as.data.frame(predict(lda_fit, te)$x)
tr_disc$y <- tr$y
te_disc$y <- te$y
# ---- Naive Bayes ----
nb_fit <- e1071::naiveBayes(y ~ ., data = tr_disc, laplace = 1)
nb_prob_raw <- tryCatch(
predict(nb_fit, te_disc, type = "raw")[, "ai"],
error = function(e) rep(mean(tr_disc$y == "ai"), nrow(te_disc))
)
nb_prob <- pmin(pmax(ifelse(is.na(nb_prob_raw), mean(tr_disc$y == "ai"),
nb_prob_raw), 1e-6), 1 - 1e-6)
nb_tr_raw <- predict(nb_fit, tr_disc, type = "raw")[, "ai"]
nb_tr_prob <- pmin(pmax(ifelse(is.na(nb_tr_raw), mean(tr_disc$y == "ai"),
nb_tr_raw), 1e-6), 1 - 1e-6)
nb_thresh <- find_best_threshold(nb_tr_prob, tr_disc$y)
nb_pred <- factor(ifelse(nb_prob >= nb_thresh, "ai", "human"),
levels = c("human", "ai"))
# ---- Bayes GLM ----
disc_cols <- setdiff(colnames(tr_disc), "y")
bf <- as.formula(paste0("I(y == 'ai') ~ ",
paste(disc_cols, collapse = " + ")))
blr_fit <- arm::bayesglm(bf, data = tr_disc, family = binomial())
blr_prob <- predict(blr_fit, newdata = te_disc, type = "response")
blr_prob <- pmin(pmax(ifelse(is.na(blr_prob), mean(tr_disc$y == "ai"),
blr_prob), 1e-6), 1 - 1e-6)
blr_tr_prob <- predict(blr_fit, newdata = tr_disc, type = "response")
blr_tr_prob <- pmin(pmax(ifelse(is.na(blr_tr_prob), mean(tr_disc$y == "ai"),
blr_tr_prob), 1e-6), 1 - 1e-6)
blr_thresh <- find_best_threshold(blr_tr_prob, tr_disc$y)
blr_pred <- factor(ifelse(blr_prob >= blr_thresh, "ai", "human"),
levels = c("human", "ai"))
truth <- te_disc$y
# Sauvegarder les prédictions du dernier fold
if (fold_id == 5) {
last_fold_preds <<- tibble(
truth = truth,
nb_prob = nb_prob, nb_pred = nb_pred,
blr_prob = blr_prob, blr_pred = blr_pred
)
}
# Calcul des métriques
compute_metrics <- function(pred, prob, model_name) {
tp <- sum(truth == "ai" & pred == "ai")
fp <- sum(truth == "human" & pred == "ai")
fn <- sum(truth == "ai" & pred == "human")
tn <- sum(truth == "human" & pred == "human")
prec <- if ((tp + fp) == 0) 0 else tp / (tp + fp)
rec <- if ((tp + fn) == 0) 0 else tp / (tp + fn)
f1 <- if ((prec + rec) == 0) 0 else 2 * prec * rec / (prec + rec)
roc_val <- tryCatch({
if (length(unique(truth)) < 2) NA_real_
else as.numeric(pROC::auc(pROC::roc(
truth, prob, levels = c("human", "ai"),
direction = "<", quiet = TRUE)))
}, error = function(e) NA_real_)
tibble(model = model_name, accuracy = (tp + tn) / length(truth),
precision = prec, recall = rec, f1 = f1,
roc_auc = roc_val,
brier = mean((as.numeric(truth == "ai") - prob)^2))
}
bind_rows(
compute_metrics(nb_pred, nb_prob, "NaiveBayes+AFD"),
compute_metrics(blr_pred, blr_prob, "BayesGLM+AFD")
)
}
fold_metrics <- purrr::imap_dfr(folds$splits, ~ compute_fold_metrics(.x, as.integer(.y)))
fold_metrics$fold <- rep(1:5, each = 2)
6. Évaluation et résultats
6.1 Métriques par fold
On calcule les métriques demandées dans le cahier des charges : exactitude (accuracy), précision, rappel, F1-score, ROC-AUC et score de Brier (c’est une métrique probabiliste, et plus la valeur est basse, mieux c’est).
knitr::kable(fold_metrics %>% dplyr::select(fold, model, everything()),
digits = 4,
caption = "Résultats détaillés par fold de validation croisée")
| fold | model | accuracy | precision | recall | f1 | roc_auc | brier |
|---|---|---|---|---|---|---|---|
| 1 | NaiveBayes+AFD | 0.9458 | 0.9364 | 0.9567 | 0.9464 | 0.9861 | 0.0416 |
| 1 | BayesGLM+AFD | 0.9458 | 0.9364 | 0.9567 | 0.9464 | 0.9861 | 0.0416 |
| 2 | NaiveBayes+AFD | 0.9483 | 0.9468 | 0.9500 | 0.9484 | 0.9895 | 0.0382 |
| 2 | BayesGLM+AFD | 0.9492 | 0.9499 | 0.9483 | 0.9491 | 0.9895 | 0.0383 |
| 3 | NaiveBayes+AFD | 0.9675 | 0.9746 | 0.9600 | 0.9673 | 0.9925 | 0.0277 |
| 3 | BayesGLM+AFD | 0.9642 | 0.9603 | 0.9683 | 0.9643 | 0.9925 | 0.0278 |
| 4 | NaiveBayes+AFD | 0.9458 | 0.9636 | 0.9267 | 0.9448 | 0.9852 | 0.0442 |
| 4 | BayesGLM+AFD | 0.9433 | 0.9650 | 0.9200 | 0.9420 | 0.9852 | 0.0438 |
| 5 | NaiveBayes+AFD | 0.9542 | 0.9417 | 0.9683 | 0.9548 | 0.9892 | 0.0345 |
| 5 | BayesGLM+AFD | 0.9550 | 0.9432 | 0.9683 | 0.9556 | 0.9892 | 0.0350 |
readr::write_csv(fold_metrics, file.path(output_dir, "cv_fold_metrics.csv"))
6.2 Résumé des performances
cv_summary <- fold_metrics %>%
group_by(model) %>%
summarise(
across(c(accuracy, precision, recall, f1, roc_auc, brier),
list(mean = ~ mean(.x, na.rm = TRUE),
sd = ~ sd(.x, na.rm = TRUE))),
.groups = "drop"
)
knitr::kable(cv_summary, digits = 4,
caption = "Métriques moyennes +/- écart-type (5-fold CV stratifiée)")
| model | accuracy_mean | accuracy_sd | precision_mean | precision_sd | recall_mean | recall_sd | f1_mean | f1_sd | roc_auc_mean | roc_auc_sd | brier_mean | brier_sd |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| BayesGLM+AFD | 0.9515 | 0.0083 | 0.9510 | 0.0118 | 0.9523 | 0.0200 | 0.9515 | 0.0087 | 0.9885 | 0.0029 | 0.0373 | 0.0062 |
| NaiveBayes+AFD | 0.9523 | 0.0091 | 0.9526 | 0.0160 | 0.9523 | 0.0158 | 0.9523 | 0.0092 | 0.9885 | 0.0029 | 0.0372 | 0.0065 |
readr::write_csv(cv_summary, file.path(output_dir, "cv_summary_metrics.csv"))
6.3 Courbe ROC
if (!is.null(last_fold_preds)) {
roc_nb <- pROC::roc(last_fold_preds$truth, last_fold_preds$nb_prob,
levels = c("human","ai"), direction = "<", quiet = TRUE)
roc_blr <- pROC::roc(last_fold_preds$truth, last_fold_preds$blr_prob,
levels = c("human","ai"), direction = "<", quiet = TRUE)
roc_data <- bind_rows(
tibble(fpr = 1 - roc_nb$specificities,
tpr = roc_nb$sensitivities,
model = paste0("NaiveBayes+AFD (AUC=", round(pROC::auc(roc_nb), 3), ")")),
tibble(fpr = 1 - roc_blr$specificities,
tpr = roc_blr$sensitivities,
model = paste0("BayesGLM+AFD (AUC=", round(pROC::auc(roc_blr), 3), ")"))
)
ggplot(roc_data, aes(x = fpr, y = tpr, color = model)) +
geom_line(linewidth = 1.2) +
geom_abline(linetype = "dashed", color = "gray50") +
theme_minimal(base_size = 14) +
labs(title = "Courbes ROC (fold 5)",
x = "Taux de faux positifs (1 - Spécificité)",
y = "Taux de vrais positifs (Sensibilité)",
color = "Modèle") +
scale_color_manual(values = c("#4c8bf5", "#f44336")) +
theme(legend.position = "bottom")
}
Les courbes ROC tracent le compromis entre sensibilité et spécificité pour différents seuils de décision. Plus la courbe se rapproche du coin supérieur gauche, meilleur est le modèle. On voit que les deux modèles ont des courbes très proches du cas idéal, avec des AUC supérieures à 0.95, ce qui traduit une excellente capacité de discrimination. Le BayesGLM+AFD est légèrement au-dessus du Naive Bayes.
6.4 Matrice de confusion
if (!is.null(last_fold_preds)) {
cm <- table(Predit = last_fold_preds$blr_pred,
Reel = last_fold_preds$truth)
cm_df <- as.data.frame(cm)
ggplot(cm_df, aes(x = Reel, y = Predit, fill = Freq)) +
geom_tile(color = "white", linewidth = 1.5) +
geom_text(aes(label = Freq), size = 8, fontface = "bold") +
scale_fill_gradient(low = "#e8f0fe", high = "#1a73e8") +
theme_minimal(base_size = 14) +
labs(title = "Matrice de confusion - BayesGLM+AFD (fold 5)",
fill = "Effectif") +
theme(legend.position = "none")
}
La matrice de confusion du BayesGLM+AFD sur le dernier fold montre la répartition des prédictions correctes et incorrectes. La diagonale (en haut à gauche et en bas à droite) contient les bonnes classifications. On constate que le modèle commet relativement peu d’erreurs dans les deux sens : il y a peu de faux positifs (textes humains classés IA) et peu de faux négatifs (textes IA classés humains).
7. Visualisation des sujets (topics)
7.1 Modélisation LDA
On utilise ici la Latent Dirichlet Allocation (LDA) pour essayer d’identifier les thèmes latents présents dans le corpus. On ajuste un modèle avec topics.
corpus_tm <- tm::VCorpus(tm::VectorSource(dataset$text))
dtm_tm <- tm::DocumentTermMatrix(corpus_tm, control = list(
wordLengths = c(3, Inf),
bounds = list(global = c(20, Inf))
))
# Suppression des documents vides
row_sums_dtm <- slam::row_sums(dtm_tm)
non_empty <- row_sums_dtm > 0
dtm_tm <- dtm_tm[non_empty, ]
topic_labels <- dataset$generated[non_empty]
cat("DTM pour Topic Modeling :", nrow(dtm_tm), "docs x", ncol(dtm_tm), "termes\n")
## DTM pour Topic Modeling : 6000 docs x 5361 termes
topic_fit <- topicmodels::LDA(dtm_tm, k = 5, control = list(seed = 4209))
topic_terms <- terms(topic_fit, 10)
topic_terms_df <- as.data.frame(topic_terms)
knitr::kable(topic_terms_df, caption = "Top 10 termes par topic (LDA, k = 5)")
| Topic 1 | Topic 2 | Topic 3 | Topic 4 | Topic 5 |
|---|---|---|---|---|
| the | the | the | and | you |
| electoral | and | students | the | the |
| and | that | and | can | and |
| that | are | their | that | that |
| for | this | for | for | they |
| college | car | school | their | have |
| vote | for | they | our | for |
| phones | cars | that | with | not |
| this | have | can | are | your |
| cell | not | are | this | can |
readr::write_csv(topic_terms_df, file.path(output_dir, "topic_top_terms.csv"))
Le tableau ci-dessus montre les 10 mots les plus représentatifs de chaque topic. On peut essayer de deviner le thème de chaque topic à partir de ces mots : par exemple, un topic peut être lié à l’éducation, un autre à la technologie, etc. Cela donne une idée de la structure thématique du corpus.
7.2 Distribution des topics par classe
topic_dist <- posterior(topic_fit)$topics
topic_means <- as.data.frame(topic_dist) %>%
mutate(classe = topic_labels) %>%
group_by(classe) %>%
summarise(across(everything(), mean), .groups = "drop") %>%
pivot_longer(-classe, names_to = "topic", values_to = "proportion")
ggplot(topic_means, aes(x = topic, y = proportion, fill = classe)) +
geom_col(position = "dodge", alpha = 0.85) +
theme_minimal(base_size = 14) +
labs(title = "Proportion moyenne des topics par classe",
x = "Topic", y = "Proportion", fill = "Classe") +
scale_fill_manual(values = c(human = "#4c8bf5", ai = "#f44336"))
Ce graphe compare la proportion moyenne de chaque topic entre textes humains et textes IA. On observe que certains topics sont plus dominants chez les textes générés, ce qui suggère que les LLM ont tendance à revenir sur les mêmes thèmes ou structures argumentatives, tandis que les textes humains sont répartis de manière plus homogène entre les topics.
8. Interprétation des résultats
L’application de l’AFD et de la classification bayésienne au dataset Kaggle LLM-Detect a permis d’obtenir plusieurs résultats intéressants.
8.1 Réduction de dimensionnalité
Les données textuelles de haute dimension (une dizaine de features stylométriques + 3 scores de lisibilité + environ 300 termes TF-IDF) ont été projetées sur un unique axe discriminant grâce à l’AFD. La visualisation de la densité montre une bonne séparation des classes humain/IA sur cet axe.
8.2 Performance de classification
Les deux modèles bayésiens (Naive Bayes + AFD et BayesGLM + AFD) atteignent des performances élevées sur la validation croisée 5-fold :
- Le BayesGLM+AFD se distingue avec une accuracy, precision, recall et F1-score tous aux alentours de 95 %
- Le ROC-AUC dépasse 98 %, ce qui montre que le modèle discrimine très bien les deux classes à travers toute la gamme de seuils
- Le score de Brier reste faible, signe d’une bonne calibration des probabilités prédites
Le Naive Bayes est un peu en dessous mais reste un bon modèle de référence.
8.3 Visualisation des topics
La LDA à 5 topics révèle des thèmes latents qui se répartissent différemment entre textes humains et textes IA. Les textes générés tendent à se concentrer sur certains topics, tandis que les textes humains sont plus dispersés, ce qui est cohérent avec le fait que les LLM ont tendance à produire des textes relativement homogènes.
8.4 Points forts et limites
Points forts :
- L’AFD fournit une visualisation claire de la séparation des classes
- La réduction à un seul axe discriminant facilite beaucoup l’interprétation
- Le pipeline est rapide et reproductible (seed fixé, validation croisée)
- Les métriques sont solides sur les 5 folds, ce qui suggère une bonne généralisabilité
Limites :
- L’AFD suppose des matrices de covariance égales entre classes, ce qui n’est pas forcément vérifié ici
- Le dataset
train_drcat_04.csvn’est pas le jeu officiel du challenge (qui est trop déséquilibré) - Les features stylométriques sont relativement simples et ne captent pas la sémantique profonde
- Le modèle a été entraîné sur des textes en anglais uniquement
9. Conclusion générale
Ce projet nous a permis de mettre en pratique une chaîne complète de text mining et de classification supervisée en R. En partant de textes bruts, on passe par l’extraction de features (stylométrie, lisibilité, TF-IDF), la réduction de dimensionnalité via l’AFD, puis la classification avec des modèles bayésiens.
Les résultats montrent que l’AFD combinée à un classifieur bayésien est une approche tout à fait valide pour ce type de problème, avec des performances élevées. La modélisation LDA apporte en plus un éclairage qualitatif sur la structure thématique du corpus.
10. Travaux futurs
10.1 Amélioration des features
- Intégrer des embeddings contextuels type BERT ou Sentence Transformers pour capturer la sémantique, et pas seulement la fréquence des mots
- Ajouter des features de n-grammes de caractères, qui sont souvent discriminantes pour la détection de texte généré
- Tester des mesures de perplexité directes à l’aide d’un modèle de langage pré-entraîné
10.2 Modèles alternatifs
- Random Forest : pour capturer d’éventuelles relations non linéaires entre features
- SVM (Support Vector Machines) : souvent performant en classification de texte
- MCMC complet : utiliser
rstanarm::stan_glmpour une inférence bayésienne échantillonnée avec des intervalles de crédibilité sur les coefficients - Inférence variationnelle : via ADVI pour les modèles à grande dimension
10.3 Réduction de dimensionnalité alternative
- AFD à noyau (kernel discriminant analysis) si la frontière de décision n’est pas linéaire
- PCA : approche non supervisée pour comparer avec l’AFD
- t-SNE / UMAP : pour la visualisation en 2D ou 3D
10.4 Validation et robustesse
- Tester la calibration probabiliste (Platt scaling, calibration isotonique) pour améliorer le score de Brier
- Analyser les erreurs de classification par type de prompt ou de source
- Valider sur un jeu de test complètement indépendant (par exemple les essays du challenge officiel)
- Tester la robustesse du modèle face à des textes produits par d’autres LLM non présents dans l’entraînement
11. Pourrions-nous détecter un texte généré par une IA ?
Cette question, posée en sujet du projet final, mérite une réponse nuancée à la lumière de nos résultats et de l’état actuel de la recherche.
11.1 Ce que montrent nos résultats
Avec un pipeline relativement simple (features stylistiques, AFD, Naive Bayes), on atteint déjà une accuracy autour de 95 % sur notre jeu de données. Cela prouve que les textes générés par les LLM de 2023-2024 laissent des traces statistiques mesurables : phrases plus régulières, vocabulaire moins varié, ponctuation plus uniforme, scores de lisibilité plus homogènes. En 2026, ces signatures restent exploitables pour de nombreux modèles courants.
11.2 Les limites actuelles
Cependant, plusieurs facteurs compliquent la détection :
- Évolution rapide des LLM : les modèles récents (GPT-4o, Claude, Gemini) produisent des textes de plus en plus naturels, avec davantage de variabilité stylistique. Les features simples qui fonctionnent aujourd’hui risquent de perdre en pouvoir discriminant.
- Paraphrasing et post-édition : un texte généré puis retouché par un humain (ou passé dans un outil de paraphrase) brouille les frontières entre les deux classes.
- Dépendance au domaine : notre modèle est entraîné sur des essais scolaires anglophones. Il ne se généralise pas forcément à d’autres langues, registres ou genres textuels.
- Course aux armements : chaque amélioration des détecteurs pousse les générateurs à s’adapter, et inversement.
11.3 Perspectives à moyen terme
Plusieurs pistes laissent penser que la détection restera possible, même si elle deviendra plus difficile :
- Watermarking : des techniques de tatouage numérique intégrées directement dans le processus de génération (par exemple, les travaux de Kirchenbauer et al., 2023) permettent une détection fiable sans analyser le style.
- Approches par perplexité : mesurer la probabilité du texte sous un modèle de langage donné reste un signal fort, car les textes générés ont tendance à être plus “probables” que les textes humains.
- Modèles hybrides : combiner des features linguistiques classiques avec des embeddings neuronaux (BERT, Sentence Transformers) offre une robustesse accrue face à l’évolution des générateurs.
- Analyse multi-échelle : étudier simultanément le niveau du mot, de la phrase et du document pour détecter des régularités invisibles à une seule échelle.
11.4 Notre conclusion
Oui, en 2026, il est tout à fait possible de détecter un texte généré par une IA dans de nombreux cas concrets, comme le montrent nos résultats. Mais cette capacité n’est ni absolue ni permanente. D’ici quelques années, la détection reposera probablement moins sur des features stylistiques simples et davantage sur des méthodes intégrées (watermarking) ou des modèles profonds spécialisés. La question n’est donc pas tant “peut-on détecter ?” que “avec quelle fiabilité et à quel coût ?”. La réponse dépendra de la volonté des fournisseurs de LLM d’intégrer des mécanismes de traçabilité et de la sophistication des outils de détection développés par la communauté scientifique.