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 :

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")

Figure 1 du rapport

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"))

Figure 2 du rapport

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")
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 :

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"))

Figure 3 du rapport

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"))

Figure 4 du rapport

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 𝐰\mathbf{w} qui maximise la séparation entre les classes. Le critère de Fisher est le suivant :

J(𝐰)=𝐰T𝐒B𝐰𝐰T𝐒W𝐰J(\mathbf{w}) = \frac{\mathbf{w}^T \, \mathbf{S}_B \, \mathbf{w}}{\mathbf{w}^T \, \mathbf{S}_W \, \mathbf{w}}

avec :

  • 𝐒B=∑k=1Knk(𝛍k−𝛍)(𝛍k−𝛍)T\mathbf{S}_B = \displaystyle\sum_{k=1}^{K} n_k \,(\boldsymbol{\mu}_k - \boldsymbol{\mu})(\boldsymbol{\mu}_k - \boldsymbol{\mu})^T : matrice de dispersion inter-classes
  • 𝐒W=∑k=1K∑𝐱i∈Ck(𝐱i−𝛍k)(𝐱i−𝛍k)T\mathbf{S}_W = \displaystyle\sum_{k=1}^{K} \sum_{\mathbf{x}_i \in C_k} (\mathbf{x}_i - \boldsymbol{\mu}_k)(\mathbf{x}_i - \boldsymbol{\mu}_k)^T : matrice de dispersion intra-classes

La solution est donnée par le vecteur propre associé à la plus grande valeur propre du problème 𝐒W−1𝐒B𝐰=λ𝐰\mathbf{S}_W^{-1}\mathbf{S}_B\,\mathbf{w} = \lambda\,\mathbf{w}.

Dans notre cas, avec seulement 2 classes, on obtient un unique axe discriminant LD1LD1 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"))

Figure 5 du rapport

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 :

  1. Naive Bayes (e1071::naiveBayes) : ce modèle suppose que les features sont indépendantes conditionnellement à la classe : P(Ck∣𝐱)∝P(Ck)∏j=1pP(xj∣Ck)P(C_k \mid \mathbf{x}) \propto P(C_k) \prod_{j=1}^{p} P(x_j \mid C_k) On applique un lissage de Laplace (α=1\alpha = 1) pour éviter les probabilités nulles.

  2. 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 : βj∼Cauchy(0,2.5)\beta_j \sim \text{Cauchy}(0, \, 2.5) 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")
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)")
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")
}

Figure 6 du rapport

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")
}

Figure 7 du rapport

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 k=5k = 5 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)")
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"))

Figure 8 du rapport

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 LD1LD1 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.csv n’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_glm pour 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.