No description
  • Jupyter Notebook 100%
Find a file
Mathéo GUILBERT d8b5bfde50 a
2025-11-07 11:23:16 +01:00
images : Finalization of the classification section 2025-11-03 23:19:49 +01:00
.gitignore 🎉 init project for french pd books analysis 2025-10-23 15:02:10 +02:00
README.md 📝 : update README.md 2025-11-06 11:09:50 +01:00
work.ipynb a 2025-11-07 11:23:16 +01:00
work.slurm : slurm configuration for morgoth 2025-11-07 11:21:54 +01:00
work_generation.ipynb 📝 : update README.md 2025-11-06 11:09:50 +01:00

Projet IA5 — Étude : classification des textes par époque

Mathéo GUILBERT


1. Contexte et problématique

L'objectif principal de cette étude est d'évaluer si l'on peut reconnaître, de façon fiable et robuste, l'époque d'un texte uniquement à partir de son vocabulaire et de sa sémantique.

Je cherche à répondre à la question : "Est-il possible de reconnaître l'époque d'un texte grâce à ses mots et à sa construction ?".

Dans un second temps, j'explore la faisabilité de "traduire" un texte d'une époque à une autre.

2. Démarche

Pour répondre à cette problématique, ma démarche a été la suivante :

  1. Exploration et Prétraitement : Analyse du jeu de données, nettoyage des textes et création d'étiquettes de "périodes" (par tranches de 10 et 50 ans).

  2. Premier essai : Une première expérimentation utilisant TF-IDF avec SGDClassifier.

  3. Second essai : Une seconde approche utilisant des embeddings de phrases pour capturer la sémantique du texte.

  4. Analyse et Comparaison : Comparaison des performances des deux modèles.

3. Les données et Prétraitement

3.1. Source

Le jeu de données utilisé est PleIAs/French-PD-Books (voir sur Hugging Face). Il contient 289 000 livres environ, avec, pour chaque ouvrage, les données suivantes :

  • file_id : id du fichier
  • ocr :
  • title : titre du livre
  • date : date de publication, peut être une année simple ou un intervalle
  • author : nom de l'auteur et ses dates de naissance et décès
  • page_count : nombre de pages du livre
  • word_count : nombre de mots du livre
  • character_count : nombre de personnages mentionnés dans le livre (fictifs ou réels)
  • complete_text : texte entier du livre

A noter : les textes proviennent de scans OCR. Ils contiennent donc beaucoup de bruit : éléments de pagination (numéros de page, entêtes, pieds de page), sauts de ligne et retours à la ligne, coupures de mots au passage de ligne, caractères d'échappement, et parfois des erreurs d'OCR. Une étape de prétraitement robuste est donc indispensable avant toute modélisation.

Les dates

Les dates ne sont pas toutes homogènes, on retrouve les formats suivants :

  • une année : 1860
  • un intervalle d'années : 1929-1931
  • intervalles d'années avec valeur manquante : 1876-???? ou ????-1876

La taille du jeu de données

Le jeu de données est très volumineux. On voit sur le graphique ci-dessous une forte concentration de textes sur certaines périodes.

Répartition des textes dans le temps

Compte tenu du temps de prétraitement et d'entraînement, j'ai effectué mes expérimentations sur un sous-ensemble de 5000 textes.

3.2. Prétraitement des textes

Deux fonctions de nettoyage ont été testées. La première (clean_text_old) était une tentative de nettoyage en profondeur à base de regex.

# Ancienne version
def clean_text(example):
    text = example["complete_text"]
    date = example.get("date", None)

    # Si la date contient un "-", on essaie d'extraire l'année connue (format "1234-????" ou "????-1234") ou moyenne des deux années
    if "-" in str(date) and date is not None:
        parts = str(date).split("-")
        if (parts[1].isdigit() and len(parts[1]) == 4) and parts[0] == "????":
            date = str(parts[1])
        else:
            date = str(parts[0])

    # Retirer les numéros de page
    text = re.sub(r"[—\-]\s*\d+\s*[—\-]", " ", text)
    
    # Corriger les apostrophes et guillemets échappés
    text = text.replace("\\'", "'")
    text = text.replace("\\\"", "\"")
    text = text.replace("\\n", " ")
    text = text.replace("\\r", " ")
    text = text.replace("\\t", " ")
    
    # Corriger les mots coupés (pattern plus précis)
    text = re.sub(r'([a-zàâäæçéèêëïîôùûüœ])\s+([a-zàâäæçéèêëïîôùûüœ]{2,})', 
                  r'\1\2', text)
    
    # Corriger les cas avec plusieurs espaces
    text = re.sub(r'([a-zàâäæçéèêëïîôùûüœ])\s{2,}([a-zàâäæçéèêëïîôùûüœ])', 
                  r'\1\2', text)
    
    # Normaliser les espaces multiples
    text = re.sub(r"\s+", " ", text)
    
    # Nettoyer les caractères spéciaux
    text = re.sub(r"[^\w\s\.,;:\?!'\-\"«»À-ÖØ-öø-ÿœŒ]", " ", text)
    
    # Re-normaliser après nettoyage
    text = re.sub(r"\s+", " ", text)
    
    # Corriger la ponctuation
    text = re.sub(r"\s+([,.\?!;:])", r"\1", text)
    text = re.sub(r"([,.\?!;:])\s*([,.\?!;:])", r"\1\2", text)
    
    text = text.strip()
    return {"text": text, "date": str(date)}

Ici, les traitements appliqués sont :

  • suppression des sauts de ligne et des retours à la ligne, remplacement par des espaces,
  • élimination des caractères non alphabétiques (en conservant les lettres accentuées françaises),
  • suppression partielle des numéros de page

La suppression des numéros de pages est une étape délicate car leur mise en forme dépend de l'ouvrage et de l'éditeur le plus souvent. J'ai quand même repéré une écriture récurrente : -- [NUMERO DE PAGE] --. J'ai supprimé ces cas-là.

Cette fonction s'est avérée complexe et pas nécessairement plus performante.

Pour l'approche par embeddings, j'ai opté pour une version simplifiée, se concentrant sur la minuscule, la suppression de la ponctuation et des stopwords.

def clean_text(example):
    """
        Nettoie le texte d'entrée
    """
    text = example["complete_text"]
    date = example.get("date", None)

    # Nettoyage de la date
    if "-" in str(date) and date is not None:
        parts = str(date).split("-")
        if (parts[1].isdigit() and len(parts[1]) == 4) and parts[0] == "????"
            date = str(parts[1])
        else:
            date = str(parts[0])

    # Nettoyage de texte
    text = (text.replace("\\\\n", " ")
                .replace("\\\\r", " ")
                .replace("\\\\t", " "))
    
    text = text.lower()

    text = re.sub(r"[^a-zàâäæçéèêëïîôùûüœ\\s]", " ", text)
    
    text = re.sub(r"\\s+", " ", text).strip()

    words = text.split()
    filtered_words = [word for word in words if word not in french_stopwords]
    text = " ".join(filtered_words)

    return {"text": text, "date": str(date)}

Les stopwords utilisés ont été générés par Gemini après lui avoir donné les nuages de mots qui viendront plus tard dans le rapport :

french_stopwords = set([
    'a', 'ai', 'aie', 'aient', 'aies', 'ait', 'alors', 'as', 'au', 'aucun', 'aura', 'aurai', 'auraient', 'aurais', 'aurait', 'auras', 'aurez', 'auriez', 'aurions', 'aurons', 'auront', 'aussi', 'autre', 'aux', 'avaient', 'avais', 'avait', 'avant', 'avec', 'avez', 'aviez', 'avions', 'avoir', 'avons', 'ayant', 'ayez', 'ayons',
    'bon',
    'c', 'ce', 'ceci', 'cela', 'ces', 'cet', 'cette', 'chaque', 'comme', 'comment',
    'd', 'dans', 'de', 'des', 'deux', 'donc', 'dont', 'du',
    'elle', 'en', 'encore', 'es', 'est', 'et', 'etaient', 'etais', 'etait', 'etant', 'ete', 'etes', 'etiez', 'etions', 'etre', 'eu', 'eue', 'eues', 'eurent', 'eus', 'eusse', 'eussent', 'eusses', 'eussiez', 'eussions', 'eut', 'eux', 'eûmes', 'eût', 'eûtes',
    'fait', 'fais', 'faisaient', 'faisais', 'faisait', 'faisant', 'faire', 'faites', 'fasse', 'fassent', 'fasses', 'fassiez', 'fassions', 'faut', 'fi', 'font', 'force', 'furent', 'fus', 'fusse', 'fussent', 'fusses', 'fussiez', 'fussions', 'fut', 'fûmes', 'fût', 'fûtes',
    'hors',
    'i', 'ici', 'il', 'ils',
    'j', 'je',
    'l', 'la', 'le', 'les', 'leur', 'leurs', 'lui',
    'm', 'ma', 'mais', 'me', 'mes', 'moi', 'mon',
    'n', 'ne', 'ni', 'nos', 'notre', 'nous',
    'on', 'ont', 'ou', 'où',
    'par', 'pas', 'pendant', 'peu', 'peut', 'peux', 'plus', 'point', 'pour', 'pourquoi',
    'qu', 'quand', 'que', 'quel', 'quelle', 'quelles', 'quels', 'qui',
    's', 'sa', 'sans', 'se', 'sera', 'serai', 'seraient', 'serais', 'serait', 'seras', 'serez', 'seriez', 'serions', 'serons', 'seront', 'ses', 'soi', 'soient', 'sois', 'soit', 'sommes', 'son', 'sont', 'soyez', 'soyons', 'suis', 'sur',
    't', 'ta', 'te', 'tes', 'toi', 'ton', 'tous', 'tout', 'tu', 'un', 'une',
    'va', 'vers', 'voici', 'voilà', 'vos', 'votre', 'vous',
    'y', 'à'
])

3.3. Création des labels

Pour la classification, j'ai groupé les textes par périodes de 10 ans et 50 ans.

def create_period_label(example, period_length=50):
     """
          Crée une étiquette de période basée sur l'année de publication.
     """
     try:
          year = int(example['date'])
          start_year = (year // period_length) * period_length
          end_year = start_year + period_length - 1

          return {"period": f"{start_year}-{end_year}"}
     except (ValueError, TypeError):
          return {"period": None}
     
dataset_with_labels = cleaned_ds.map(create_period_label)

4. Modèle 1 : Approche "Bag-of-Words" (TF-IDF)

Nous utilisons TfidfVectorizer de Scikit-learn. Pour des raisons de performance et de gestion de la mémoire (RAM), des optimisations ont été nécessaires :

  • ngram_range=(1, 1) : Seuls les mots uniques (unigrammes) sont utilisés, ignorant les paires de mots (bigrammes) qui consommaient trop de mémoire.
  • max_features=5000 : Le vocabulaire est limité aux 5000 mots les plus fréquents.
  • min_df=5 : Un mot doit apparaître dans au moins 5 documents pour être conservé.
  • dtype=np.float32 : La matrice est stockée en 32 bits pour diviser par deux son poids en RAM.

Le modèle est ensuite entraîné avec un SGDClassifier, un classifieur linéaire efficace pour le texte.

# Initialiser le vectoriseur TF-IDF
tfidf_vectorizer = TfidfVectorizer(
    max_features=N_FEATURES_TFIDF,
    ngram_range=(1, 1),
    min_df=MIN_DF_TFIDF,
    max_df=MAX_DF_TFIDF,
    dtype=np.float32 
)

# Adapter (fit) le vectoriseur et transformer les ensembles
X_train_tfidf = tfidf_vectorizer.fit_transform(train_texts)
X_test_tfidf = tfidf_vectorizer.transform(test_texts)

# Entraîner le classifieur
model_tfidf_sgd = train_sgd_with_loss_tracking(
    X_train_tfidf, X_test_tfidf,
    train_labels, test_labels,
    "TF-IDF",
    n_epochs=50
)

5. Modèle 2 : Approche Sémantique (Embeddings)

La seconde approche vise à capturer non seulement le vocabulaire, mais aussi le sens et la structure des phrases. Pour cela, j'utilise des embeddings de phrases.

5.1. Optimisation pour les textes longs

Les modèles Transformers (comme CamemBERT) ont une limite de tokens. Les textes étant beaucoup plus longs, on les échantillonne : pour chaque texte, on prend des passages des textes aléatoires, pour un total d'environ 400 mots.

De plus, pour éviter un crash de RAM lors de la génération des embeddings pour des milliers de textes, la fonction create_embeddings_batch est utilisée pour traiter les textes par lots.

5.2. Implémentation

Le modèle choisi est dangvantuan/sentence-camembert-base (voir sur Hugging Face), spécialisé pour le français et la génération d'embeddings de phrases.

embedding_model = SentenceTransformer('dangvantuan/sentence-camembert-base')

# Échantillonner les textes
train_texts_sampled = [sample_text_by_segment(text, n_words=100) for text in train_texts]
test_texts_sampled = [sample_text_by_segment(text, n_words=100) for text in test_texts]

# Création des Embeddings par lots
X_train_embedding = create_embeddings_batch(train_texts_sampled, embedding_model, batch_size=BATCH_SIZE)
X_test_embedding = create_embeddings_batch(test_texts_sampled, embedding_model, batch_size=BATCH_SIZE)

# Entraînement
model_embedding_sgd_loss = train_with_loss_tracking(
    X_train_embedding, X_test_embedding,
    train_labels, test_labels,
    "Embeddings (CamemBERT)",
    n_epochs=50
)

6. Comparaison et évaluation

Pour comparer l'efficacité des deux approches (TF-IDF vs Embeddings), j'ai essayé plusieurs méthodes d'évaluation.

6.1. Analyse des Courbes de Perte

  • Objectif : Détecter le surapprentissage (overfitting).
  • Interprétation : Un modèle idéal montre les deux courbes (entraînement et test) qui descendent et se stabilisent. Si la perte d'entraînement continue de baisser alors que la perte de test remonte, le modèle est en surapprentissage. Ici les modèles sont plutôt stables.

Courbe du suivi de la perte (TF-IDF)

Courbe du suivi de la perte (Embeddings)

6.2. Évaluation Visuelle (Réel vs. Prédiction)

Les graphes montrent la courbe de périodes réelles et celle de la prédiction des modèles, sur l'ensemble des données de test.

  • Interprétation : Un modèle parfait aurait tous ses points sur la ligne. Cette courbe permet de voir si un modèle a tendance à se tromper d'une seule période (ex: prédire "1700-1749" au lieu de "1750-1799") ou s'il fait des erreurs plus grossières.

Graph des courbes période réelle / prédiction pour les deux modèles

6.3. Tests de Prédiction Aléatoire et Accuracy

J'ai également écrit une méthode qui permet de tester sur un texte aléatoire.

def predict_random_text(data_source_df, vectorizer, model_tfidf, model_embedding):
    """
    Prend un texte aléatoire depuis le DataFrame source (df_labeled),
    le nettoie (si nécessaire, mais il l'est déjà), le vectorise (TF-IDF),
    crée son embedding (après échantillonnage), et affiche les prédictions
    des deux modèles.
    """
    
    # Récupérer un échantillon aléatoire
    random_sample_df = data_source_df.sample(1)
    
    # Extraire le texte et le vrai label
    # .iloc[0] pour obtenir les valeurs de la première ligne
    text_to_predict = random_sample_df['text'].iloc[0]
    true_label = random_sample_df['period'].iloc[0]

    print(f"Texte : {text_to_predict[:250]}")
    print(f"Période réelle : {true_label}")

    # --- Prédiction TF-IDF ---
    text_tfidf = vectorizer.transform([text_to_predict])
    prediction_tfidf = model_tfidf.predict(text_tfidf)[0]
    print(f"TF-IDF : {prediction_tfidf}")

    # --- Prédiction Embeddings ---
    text_sampled_emb = sample_text_by_segment(text_to_predict, n_words=100)
    try:
        embedding_model
    except NameError:
        print("Rechargement du modèle d'embedding pour la prédiction...")
        embedding_model = SentenceTransformer('dangvantuan/sentence-camembert-base')
    
    text_embedding = embedding_model.encode([text_sampled_emb], show_progress_bar=False)
    
    prediction_embedding = model_embedding.predict(text_embedding)[0]
    print(f"Embeddings : {prediction_embedding}s")

7. Conclusion sur la classification

Il est possible de classer des textes par période en se basant uniquement sur leur contenu. Les deux approches, TF-IDF et Embeddings, sont pertinentes, je pense que la méthode par embeddings doit être plus efficace sur beaucoup plus de données, ne serait-ce que sur le temps de traitement, qui reste moins long que TF-IDF.

Je ne suis pas allé plus loin sur la classification, j'aurais aimé approfondir. En passant par exemple par une étape de recherche de la durée de période optimale via clusters.

Traduction d'un texte d'une époque en une autre

Pour cette partie, je n'ai pas entraîné de modèle, mais j'ai utilisé un LLM pré-entrainé.

J'ai utilisé le modèle Mistral-7B-Instruct voir sur HuggingFace.

J'ai donné un prompt au modèle en lui donnant le rôle d'un expert en littérature française. Je lui fournis le texte, l'époque de départ et l'époque cible, en lui demandant de ne modifier que le style (vocabulaire, grammaire, syntaxe) tout en préservant le sens.

def traduire_style_epoque(texte_original: str, epoque_originale: str, epoque_cible: str) -> str:
    
    # Un "prompt" système pour guider le modèle.
    # Le format [INST]...[/INST] est spécifique à Mistral Instruct.
    prompt = f"""[INST] Tu es un expert en histoire de la littérature française et un écrivain.
    Ta tâche est de réécrire le texte suivant, qui a été écrit dans le style de l'époque "{epoque_originale}", pour qu'il corresponde parfaitement au style (vocabulaire, grammaire, syntaxe) de l'époque "{epoque_cible}".
    Le sens, les idées et le contenu du texte original doivent être préservés au maximum. Ne fais que modifier le style.

    TEXTE ORIGINAL ({epoque_originale}):
    {texte_original}

    RÉÉCRITURE ({epoque_cible}):
    [/INST]"""
    
    reponse = llm(
        prompt, 
        temperature=0.7, 
        max_new_tokens=1024, 
        top_k=40
    )
    
    return reponse

J'ai effectué deux tests pour voir le comportement du modèle :

Test 1 : Moderne (2024) vers Ancien (1670)

J'ai donné au modèle un texte très moderne et informel :

Original (2024) : "Salut les gars, J'ai checké le nouveau projet sur le drive, c'est vraiment un truc de ouf. Mon boss m'a dit que le client est super content et que le lancement est prévu pour ce week-end. On va pouvoir décompresser, c'est trop cool."
Traduction (1670) : "Salut mes amis,
J'ai vérifié le dernier projet sur mon bureau à la nuit, et je suis très content que cela soit bien arrivé.
Mon patron m'a informé que le client est extrêmement satisfait et que le lancement est prévu pour ce weekend. 
On peut décomprimer, c'est trop magnifique."

Le modèle a bien compris la tâche et a tenté de "traduire" les concepts :

  • "Salut les gars" -> "Salut mes amis"
  • "truc de ouf" -> "bien arrivé" (ici, il a un peu extrapolé)
  • "Mon boss" -> "Mon patron"
  • "super content" -> "extrêmement satisfait"

Il a produit un hybride stylistique, avec des tournures correctes ("m'a informé que...") mais aussi des expressions étranges comme "sur mon bureau à la nuit" ou "c'est trop magnifique", qui mélange le "trop" moderne avec un vocabulaire plus ancien.

Test 2 : Ancien (1720) vers Moderne (2024)

J'ai ensuite tenté l'exercice inverse, avec un texte très formel du 18ème siècle, en demandant une traduction en "email professionnel de 2024".

Original (1720) : "Monseigneur, Je me fais l'honneur de vous adresser la présente missive afin de requérir votre bienveillance. Les affaires de votre domaine prospèrent, mais la météo capricieuse menace les récoltes. Je crains fort que la disette ne frappe nos manants si le ciel ne nous est point plus clément. Votre très humble et dévoué serviteur."

Le résultat a été très surprenant, le modèle a parfaitement adopté le style d'un email professionnel... mais en anglais.

Traduction (2024) : "Dear Sir,

I trust this message finds you well and in good spirits. I am reaching out to kindly request your assistance with a matter of utmost concern for our community.

As you are aware, the agricultural sector has been thriving under your leadership. However, recent weather conditions have posed an unprecedented challenge - excessive rainfall followed by sudden droughts have disrupted crop growth and yield. This unpredictable climate jeopardizes not only the livelihoods of our farmers but also food security for future generations.

I urge you to consider taking proactive measures to mitigate the impact of these extreme weather events on agricultural productivity. It would be greatly appreciated if we could discuss potential solutions at your earliest convenience. 

Thank you in advance for considering this matter and for your continued commitment towards the welfare of our region.

Best regards,
[Your Name]"

Cette courte exploration montre que la "traduction" stylistique est possible, elle est complexe et nécessiterait un fine-tuning plus poussé pour être fiable.