VII. Annotations

Auteur·rice

Guillaume Guex

1 Introduction

Dans ce TP, nous allons voir comment tokeniser, lemmatiser et annoter automatiquement un corpus grâce à la librairie udpipe (https://bnosac.github.io/udpipe/en/index.html). Cette dernière utilise des modèles de langage, construits dans le cadre du projet Universal Dependencies (https://universaldependencies.org/), afin d’effectuer automatiquement toutes ces opérations. Nous allons également voir succinctement comment gérer le format XML avec R.

Il est important de préciser que les “modèles de langage” utilisés par udpipe diffèrent fondamentalement des grands modèles actuels basés sur l’architecture Transformer (comme GPT, Claude ou Qwen). udpipe repose sur des algorithmes d’apprentissage automatique plus légers et traditionnels. Si cette approche peut sembler datée face à l’état de l’art actuel en intelligence artificielle, elle n’en demeure pas moins un standard incontournable pour l’analyse syntaxique. Elle présente en effet des avantages majeurs pour ce type de traitement : une grande rapidité d’exécution, la possibilité de fonctionner localement sur une machine standard sans équipement spécifique (GPU), et une excellente transparence dans les règles grammaticales appliquées.

Nous aurons besoin des librairies suivantes :

library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.2.1     ✔ readr     2.2.0
✔ forcats   1.0.1     ✔ stringr   1.6.0
✔ ggplot2   4.0.3     ✔ tibble    3.3.1
✔ lubridate 1.9.5     ✔ tidyr     1.3.2
✔ purrr     1.2.2     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(readtext)
library(quanteda)
Package version: 4.4
Unicode version: 14.0
ICU version: 71.1
Parallel computing: disabled
See https://quanteda.io for tutorials and examples.

Attachement du package : 'quanteda'

L'objet suivant est masqué depuis 'package:readtext':

    texts
library(udpipe)
library(xml2)
library(xmlconvert)

1.1 Le corpus

Le chargement du corpus dans R est fait, comme la fois précédente, à l’aide de la fonction readtext() du package readtext :

texts_df = readtext("data/manifesto/*.txt")

On vérifie que le chargement s’est bien déroulé :

texts_df
readtext object consisting of 9 documents and 0 docvars.
# A data frame: 9 × 2
  doc_id                             text               
  <chr>                              <chr>              
1 en_marche_2017.txt                 "\"Emmanuel M\"..."
2 europe_ecologie_les_verts_2017.txt "\"Il y a urg\"..."
3 france_insoumise_2017.txt          "\"L'AVENIR E\"..."
4 front_national_2017.txt            "\"Au nom du \"..."
5 mouvement_democratique_2017.txt    "\"La France \"..."
6 parti_communiste_francais_2017.txt "\"Le peuple \"..."
# ℹ 3 more rows

Pour rappel, ce format de chargement (en dataframe) nous permet de créer les objets corpus et tokens de quanteda. Ici, nous les utiliserons d’une autre manière avec udpipe.

2 Annotation du corpus et statistiques

2.1 Annotation

Pour utiliser udpipe, on doit d’abord télécharger un modèle de langage pré-entraîné spécifique à la langue que l’on étudie. Ces modèles ont été entraînés sur les corpus du projet Universal Dependencies (dont la liste complète est consultable ici : https://universaldependencies.org/). Dans R, le téléchargement du modèle se fait directement via une fonction dédiée du package, udpipe_download_model() (commande à décommenter pour télécharger le modèle lors de la première utilisation) :

# udpipe_download_model(language="french-gsd", model_dir="data/models")

Une fois téléchargé, on peut charger le modèle dans une variable via la fonction udpipe_load_model():

udpipe_model = udpipe_load_model("data/models/french-gsd-ud-2.5-191206.udpipe")

Pour effectuer la tokenisation, la lemmatisation et l’annotation du corpus, on utilise la fonction udpipe_annotate(). On doit donner les textes à annoter dans l’argument x de la fonction, et les noms des textes dans l’argument doc_id (l’option trace=T permet d’afficher une barre de progression) :

udpipe_res = udpipe_annotate(udpipe_model, x=texts_df$text, doc_id=texts_df$doc_id, trace=T)
2026-10-06 16:17:17.26369 Annotating text fragment 1/9
2026-10-06 16:17:19.498695 Annotating text fragment 2/9
2026-10-06 16:17:20.743451 Annotating text fragment 3/9
2026-10-06 16:17:30.14129 Annotating text fragment 4/9
2026-10-06 16:17:32.279878 Annotating text fragment 5/9
2026-10-06 16:17:35.855047 Annotating text fragment 6/9
2026-10-06 16:17:36.05138 Annotating text fragment 7/9
2026-10-06 16:17:41.201162 Annotating text fragment 8/9
2026-10-06 16:17:41.76756 Annotating text fragment 9/9
Note

Cette fonction peut parfois prendre beaucoup de temps. Pour en gagner, il est possible de désactiver le parsing, avec l’option parser="none".

On peut obtenir le résultat sous la forme de tibble avec as_tibble() et as.data.frame() :

corpus_token_df = as_tibble(as.data.frame(udpipe_res))
corpus_token_df
# A tibble: 79,110 × 14
   doc_id     paragraph_id sentence_id sentence token_id token lemma upos  xpos 
   <chr>             <int>       <int> <chr>    <chr>    <chr> <chr> <chr> <chr>
 1 en_marche…            1           1 Emmanue… 1        Emma… Emma… PROPN <NA> 
 2 en_marche…            1           1 Emmanue… 2        Macr… Macr… PROPN <NA> 
 3 en_marche…            1           1 Emmanue… 3        prés… prés… NOUN  <NA> 
 4 en_marche…            1           1 Emmanue… 4        .     .     PUNCT <NA> 
 5 en_marche…            1           2 “Retrou… 1        “     “     PUNCT <NA> 
 6 en_marche…            1           2 “Retrou… 2        Retr… retr… VERB  <NA> 
 7 en_marche…            1           2 “Retrou… 3        notre son   DET   <NA> 
 8 en_marche…            1           2 “Retrou… 4        espr… espr… NOUN  <NA> 
 9 en_marche…            1           2 “Retrou… 5        de    de    ADP   <NA> 
10 en_marche…            1           2 “Retrou… 6        conq… conq… NOUN  <NA> 
# ℹ 79,100 more rows
# ℹ 5 more variables: feats <chr>, head_token_id <chr>, dep_rel <chr>,
#   deps <chr>, misc <chr>

Que l’on peut alors sauvegarder (on retire les colonnes vides) :

corpus_token_df %>%
  select(-c(sentence, xpos, deps)) %>%
  write_tsv("data/manifesto_annotated.tsv")

2.2 Comprendre le tableau d’annotation

Si vous observez corpus_token_df, vous verrez que chaque ligne correspond désormais à un seul mot (ou ponctuation). Les colonnes les plus importantes pour nos futures analyses sont :

  • token : Le mot exact tel qu’il apparaît dans le texte original (ex: “chevaux”, “allons”).

  • lemma : Le mot ramené à sa forme canonique. “chevaux” devient “cheval”, “allons” devient “aller”. C’est indispensable pour compter la fréquence réelle d’un concept et cela nous sera très utile pour faire des analyses de type bag-of-words.

  • upos (Universal Part of Speech) : La classe grammaticale universelle du mot. Par exemple : NOUN (Nom), ADJ (Adjectif), VERB (Verbe), PUNCT (Ponctuation).

2.3 Nombre d’annotations, de tokens, de types et de lemmes

Ce jeu de données peut être utilisé pour extraire des statistiques, par exemple, le nombre et la proportion des annotations morpho-syntaxiques par document :

corpus_token_df %>%
  group_by(doc_id) %>%
  count(upos) %>%
  mutate(prop = n/sum(n)*100)
# A tibble: 144 × 4
# Groups:   doc_id [9]
   doc_id             upos      n   prop
   <chr>              <chr> <int>  <dbl>
 1 en_marche_2017.txt ADJ     432  5.71 
 2 en_marche_2017.txt ADP    1049 13.9  
 3 en_marche_2017.txt ADV     329  4.34 
 4 en_marche_2017.txt AUX     160  2.11 
 5 en_marche_2017.txt CCONJ   209  2.76 
 6 en_marche_2017.txt DET    1015 13.4  
 7 en_marche_2017.txt NOUN   1396 18.4  
 8 en_marche_2017.txt NUM      73  0.964
 9 en_marche_2017.txt PRON    449  5.93 
10 en_marche_2017.txt PROPN   537  7.09 
# ℹ 134 more rows

Même résultat avec un graphique :

ggplot(corpus_token_df) +
  geom_bar(aes(x=doc_id, fill=upos), position = "fill") +
  coord_flip()

On peut obtenir les statistiques sur le nombre de tokens, types et lemmes à partir de cette table.

Tokens par document :

corpus_token_df %>%
  group_by(doc_id) %>%
  summarise(n_tokens=n())
# A tibble: 9 × 2
  doc_id                             n_tokens
  <chr>                                 <int>
1 en_marche_2017.txt                     7572
2 europe_ecologie_les_verts_2017.txt     4391
3 france_insoumise_2017.txt             24774
4 front_national_2017.txt                6757
5 mouvement_democratique_2017.txt       11689
6 parti_communiste_francais_2017.txt      638
7 parti_radical_de_gauche_2017.txt      15609
8 parti_socialiste_2017.txt              1739
9 udi_republicains_2017.txt              5941

Tokens pour le corpus :

corpus_token_df %>%
  summarise(n_tokens=n())
# A tibble: 1 × 1
  n_tokens
     <int>
1    79110

Types par document :

corpus_token_df %>%
  group_by(doc_id) %>%
  count(str_to_lower(token)) %>%
  summarise(n_types=n())
# A tibble: 9 × 2
  doc_id                             n_types
  <chr>                                <int>
1 en_marche_2017.txt                    1763
2 europe_ecologie_les_verts_2017.txt    1278
3 france_insoumise_2017.txt             4200
4 front_national_2017.txt               1760
5 mouvement_democratique_2017.txt       2495
6 parti_communiste_francais_2017.txt     276
7 parti_radical_de_gauche_2017.txt      3100
8 parti_socialiste_2017.txt              612
9 udi_republicains_2017.txt             1486

Types pour le corpus :

corpus_token_df %>%
  count(str_to_lower(token)) %>%
  summarise(n_types=n())
# A tibble: 1 × 1
  n_types
    <int>
1    8291

Lemmes par document :

corpus_token_df %>%
  group_by(doc_id) %>%
  count(lemma) %>%
  summarise(n_lemmes=n())
# A tibble: 9 × 2
  doc_id                             n_lemmes
  <chr>                                 <int>
1 en_marche_2017.txt                     1464
2 europe_ecologie_les_verts_2017.txt     1111
3 france_insoumise_2017.txt              3372
4 front_national_2017.txt                1522
5 mouvement_democratique_2017.txt        2020
6 parti_communiste_francais_2017.txt      243
7 parti_radical_de_gauche_2017.txt       2531
8 parti_socialiste_2017.txt               552
9 udi_republicains_2017.txt              1267

Lemmes pour le corpus :

corpus_token_df %>%
  count(lemma) %>%
  summarise(n_lemmes=n())
# A tibble: 1 × 1
  n_lemmes
     <int>
1     6312

2.4 De udpipe à quanteda

Pour obtenir des résultats similaires (sans le nombre de types), on peut aussi recréer un vecteur de documents lemmatisés pour ensuite utiliser quanteda.

On recolle les lemmes par document, puis on extrait la colonne dans un vecteur :

lemmatized_texts = corpus_token_df %>%
  mutate(lemma=ifelse(is.na(lemma), token, lemma)) %>%
  group_by(doc_id) %>%
  summarise(text=str_c(lemma, collapse=" ")) %>%
  .$text

On nomme le vecteur :

names(lemmatized_texts) = texts_df$doc_id

Création du corpus :

my_corpus = corpus(lemmatized_texts)
summary(my_corpus)
Corpus consisting of 9 documents, showing 9 documents:

                               Text Types Tokens Sentences
                 en_marche_2017.txt  1467   7936        46
 europe_ecologie_les_verts_2017.txt  1113   4569        10
          france_insoumise_2017.txt  3351  26054       129
            front_national_2017.txt  1522   7064        40
    mouvement_democratique_2017.txt  2021  11959        43
 parti_communiste_francais_2017.txt   247    664         7
   parti_radical_de_gauche_2017.txt  2533  16477        68
          parti_socialiste_2017.txt   550   1801         5
          udi_republicains_2017.txt  1274   6205        21

3 XML

3.1 Chargement

Le format XML (https://fr.wikipedia.org/wiki/Extensible_Markup_Language) est très utilisé pour sauvegarder des documents annotés. Avec certaines librairies R, en particulier xml2 et xmlconvert, il est possible d’extraire des informations d’un document XML, ou alors de sauvegarder un tableau de données en XML. Cette tâche n’est cependant pas facile à automatiser, car chaque document XML possède sa propre hiérarchie et ses balises personnalisées.

Pour charger un document XML en R, on utilise la fonction xml_to_df() du package xmlconvert (voir l’aide pour toutes les options). Ici, nous chargeons un document trouvé sur https://www.frantext.fr/ (cette logique devrait donc fonctionner pour tous les documents trouvés sur ce site) :

frantext_df = xml_to_df("data/xml/C432.xml", 
                          records.tags="x:wf", 
                          fields="attributes")
as_tibble(frantext_df)
# A tibble: 3,275 × 3
   word      pos   lemma     
   <chr>     <chr> <chr>     
 1 I         X     I         
 2 Le        DET   le        
 3 système   NC    système   
 4 de        P     de        
 5 la        DET   le        
 6 grammaire NC    grammaire 
 7 On        NP    On        
 8 a         V     avoir     
 9 travaillé VPP   travailler
10 de        P     de        
# ℹ 3,265 more rows

Il est possible de capturer une plus grande part de la structure de ce document (récupérer les chapitres, paragraphes, phrases, etc.) mais cette procédure peut être complexe et nous ne la verrons pas ici.

Si nécessaire, le texte brut peut être récupéré avec :

# La fonction suivante va malheurseuement ajouter des espaces avant la ponctuation
frantext_texte = str_c(frantext_df$word, collapse=" ")
str_sub(frantext_texte, 1, 100)
[1] "I Le système de la grammaire On a travaillé de manière bien différente en grammaire ( = théorie de l"

et peut ensuite être tokenisé, lemmatisé, annoté avec, p.ex., udpipe.

3.2 Sauvegarde

Le processus de sauvegarde est également assez spécifique au jeu de données que nous possédons et de la structure que nous désirons créer dans le document XML. Il s’agira de créer un arbre hiérarchique, avec comme “feuilles” les lignes de notre jeu de données. La création de cette hiérarchie est faite avec les fonctions disponibles dans les packages xml2 et xmlconvert :

  • xml_new_root(): permet de créer un noeud vide.
  • xml_add_child(): permet de coller un noeud enfant à un noeud parent.
  • df_to_xml(): permet de convertir un jeu de données et sous-arbre XML
    (chaque ligne est un noeud, qui possède autant d’enfants que de variables).

Nous allons ici sauvegarder le document “parti_socialiste_2017.txt”, annoté en format XML, en gardant la hiérarchie des phrases.

On crée le noeud <document> pour notre document :

doc_xml = xml_new_root("document")

On ajoute le nom du document dans un noeud <nom>, que l’on met comme enfant de <document> :

doc_xml %>% 
  xml_add_child("nom", "parti_socialiste_2017.txt")

On restreint notre jeu de données au document parti_socialiste_2017.txt :

doc_df = corpus_token_df %>% 
  filter(doc_id=="parti_socialiste_2017.txt") 

On extrait les identifiants des phrases du document :

sent_ids = unique(doc_df$sentence_id)

On boucle sur les identifiants des phrases :

for(sent_id in sent_ids){
  
  # On restreint le jeu de données à la phrase, on sélectionne les colonnes
  # pertinentes, puis on transforme ce sous-tableau en arbre XML :
  sent_xml = doc_df %>%
    filter(sentence_id==sent_id) %>%
    select(token, lemma, upos) %>%
    df_to_xml(root.node="sentence", record.tag="word")
  
  # On supprime l'attribut "encoding" du noeud `<sentence>` (automatique avec
  # `df_to_xml()`)
  xml_set_attr(sent_xml, "encoding", NULL)
  
  # On ajoute le sous-arbre `<sentence>` au noeud <document>
  doc_xml %>% xml_add_child(sent_xml)
  
}

Observons le résultat :

doc_xml
{xml_document}
<document>
 [1] <nom>parti_socialiste_2017.txt</nom>
 [2] <sentence>\n  <word>\n    <token>Un</token>\n    <lemma>un</lemma>\n     ...
 [3] <sentence>\n  <word>\n    <token>Une</token>\n    <lemma>un</lemma>\n    ...
 [4] <sentence>\n  <word>\n    <token>Plan</token>\n    <lemma>Plan</lemma>\n ...
 [5] <sentence>\n  <word>\n    <token>Fonds</token>\n    <lemma>fonds</lemma> ...
 [6] <sentence>\n  <word>\n    <token>2</token>\n    <lemma>2</lemma>\n    <u ...
 [7] <sentence>\n  <word>\n    <token>3</token>\n    <lemma>3</lemma>\n    <u ...
 [8] <sentence>\n  <word>\n    <token>4</token>\n    <lemma>4</lemma>\n    <u ...
 [9] <sentence>\n  <word>\n    <token>5</token>\n    <lemma>5</lemma>\n    <u ...
[10] <sentence>\n  <word>\n    <token>prévention</token>\n    <lemma>préventi ...
[11] <sentence>\n  <word>\n    <token>6</token>\n    <lemma>6</lemma>\n    <u ...
[12] <sentence>\n  <word>\n    <token>7</token>\n    <lemma>7</lemma>\n    <u ...
[13] <sentence>\n  <word>\n    <token>15 000</token>\n    <lemma>15 000</lemm ...
[14] <sentence>\n  <word>\n    <token>8</token>\n    <lemma>8</lemma>\n    <u ...
[15] <sentence>\n  <word>\n    <token>9</token>\n    <lemma>9</lemma>\n    <u ...
[16] <sentence>\n  <word>\n    <token>10</token>\n    <lemma>10</lemma>\n     ...
[17] <sentence>\n  <word>\n    <token>11</token>\n    <lemma>11</lemma>\n     ...
[18] <sentence>\n  <word>\n    <token>12</token>\n    <lemma>12</lemma>\n     ...
[19] <sentence>\n  <word>\n    <token>plus</token>\n    <lemma>plus</lemma>\n ...
[20] <sentence>\n  <word>\n    <token>13</token>\n    <lemma>13</lemma>\n     ...
...

On sauvegarde l’arbre XML dans un fichier .xml :

write_xml(doc_xml, "data/parti_socialiste_2017.xml")