Trouver des entités nommées dans un corpus cicéronien : documentation et résultats
J’ai eu récemment l’occasion de me livrer à un petit travail de lecture distante sur un corpus philosophique antique. Il m’a paru intéressant de proposer dans un billet une description complète de ma démarche et de mes résultats.
Ce travail n’est pas présentable sous forme d’article scientifique, et probablement ne mérite pas une publication – j’expliquerai notamment que les données que j’utilise et les visualisations que j’obtiens posent des problèmes non encore résolus. Mais je me dis que mes scripts, qui ne sont ni élégants ni originaux mais efficaces, peuvent peut-être aider certaines collègues, et que c’est une façon informelle d’ouvrir à un public élargi une façon de travailler et d’explorer des données scientifiques.
La question que je me suis posée est très simple : dans quelle mesure est-il aujourd’hui possible de récupérer et visualiser facilement, et de la façon la plus automatisée possible, des entités nommées dans un corpus philosophique antique ?
Mon terrain de jeu ici est le corpus cicéronien, qui a plusieurs avantages : il est assez vaste, il contient de très nombreuses entités nommées (lieux, dates, noms) et il est disponible facilement. J’ai choisi de tenter de relever l’ensemble des noms de philosophes cités par Cicéron dans quatre textes appartenant au corpus philosophique de Cicéron – dans lequel les philosophes cités sont très abondants : le De Finibus, le De Natura Deorum, les Tusculanes, le De Officiis. L’expérience pourrait être étendue à d’autres textes (notamment les Académiques, le De Fato, le De Legibus ou la République). Mon objectif est double : à la fois collecter ces noms et comparer d’un texte à l’autre les fréquences d’apparition des mêmes noms.
Malgré leur imperfection, j’ai choisi de déposer les données traitées et travaillées sur Zenodo, pour que ma démarche puisse être testée du point de vue de la reproductibilité des résultats.
J’ai travaillé mes données avec des scripts rédigés en Python, à partir de plusieurs bibliothèques connues et pour certaines anciennes : notamment LatinCy, Levenshtein, pandas. Pour ma démonstration, j’ai volontairement réduit au minimum le nettoyage manuel des données – puisque mon objectif était sur le plan méthodologique de voir ce qui était automatisable et quels résultats étaient obtenus de cette façon. Il y a donc dans ces données du bruit et des erreurs, et j’évalue la marge de bruit à 10 à 15% – évaluation faite à partir de petits sondages ponctuels, donc tout cela reste assez empirique.
Etape 1 : collecter les entités nommées et les transformer en lemmes
On appelle entités nommées des expressions ou chaînes de caractère renvoyant à des réalités identifiables uniques – traditionnellement des noms de personnes, de lieux ou des périodes historiques. Le principe de l’entité nommée est qu’elle renvoie à un objet du monde non ambigu mais susceptible d’adopter des formulations, des graphies, etc, différentes. L’état de l’art concernant l’étiquetage et l’extraction d’entités nommées dans les corpus classiques a énormément progressé ces dernières années, et de nombreuses ressources et méthodologies ont été développées pour indexer dans de grands volumes de textes latins et grecs des noms de lieux ou des noms de personnages.
Pour procéder à une extraction si possible automatique de telles entités, plusieurs étapes préalables sont nécessaires :
— l’établissement de la liste elle-même des « entités » et de l’ensemble de leurs formes possibles ;
— la préparation d’un segment significatif du corpus avec un étiquetage contrôlé (donc manuel) systématique ;
— l’entraînement d’un modèle de reconnaissance à partir de ce corpus préparé, pour passer en revue le reste du corpus.
Plusieurs modèles sont actuellement utilisables pour les corpus classiques (grec et latin). J’ai choisi d’utiliser celui proposé par la bibliothèque LatinCy, qui est une extension de SpaCy, pour sa simplicité d’utilisation. L’honnêteté m’oblige à admettre que je n’ai jamais réussi à obtenir de résultats sur mon corpus avec CLTK dans la mesure où le module permettant le repérage des entités nommées semble avoir subi de multiples modifications récentes et pour tout dire assez mal documentées.
En revanche, j’ai utilisé le dépôt de CLTK pour me procurer les textes qui m’intéressent en format .txt ; ces textes sont eux-mêmes tirés de la Library of Latin Texts et ils ont été nettoyés de façon à les rendre immédiatement utilisables pour des traitements computationnels.
Les corpus grecs et latins posent un problème supplémentaire dans la mesure où il s’agit de langues dites à flexions ; un même nom propre peut se trouver écrit de différentes façons, suivant son cas, c’est à dire sa fonctione grammaticale dans la phrase. LatinCy va également me permettre de lemmatiser puis tokeniser ces noms propres, de façon à obtenir une liste sous forme de chaînes de caractères uniformes – ce qui me permettra ensuite facilement, à l’étape suivante, de comparer cette liste avec un thesaurus de noms de philosophes.
Voici donc le premier script qui, à partir de LatinCy, me permet pour chacun de mes textes d’extraire les entités nommées et de les présenter sous la forme d’une liste de lemmes.
import spacy from spacy import displacy nlp = spacy.load('la_core_web_lg') import pandas as pd from pprint import pprint nlp = spacy.load('la_core_web_lg') #initie LatinCy with open('tusc.txt', 'r') as f: #ouvre le document (ici texte des Tusculanes) #récupéré sur le dépôt CLTK from LLT text = f.read() doc = nlp(text) data = [] for ent in doc.ents: data.append([ent.text, ent.label_]) #récupère toutes les entités nommées df = pd.DataFrame(data, columns=['ent', 'type']) df.to_csv('tusc.csv') #crée un premier tableur contenant #toutes les entités nommées du texte des Tusculanes ent_text = '' for el in df['ent'] : ent_text+=f'{el} ' #récupère tout le contenu de la colonne 'ent' #du premier tableur sous forme de string ent_nlp = nlp(ent_text) data_lemma = [] for token in ent_nlp: data_lemma.append([token.text, token.lemma_, token.ent_type_,]) #lemmatise les entités nommées df_lemma = pd.DataFrame(data_lemma, columns=['text', 'lemma', 'type']) df_lemma.to_csv('tusc_ent_lemma.csv') #crée un second tableur avec la liste #lemmatisée des entités nommées |
tableau = pd.read_csv('tusc_ent_lemma.csv') colonnes = ['lemma', 'type'] tableau = tableau[colonnes] filtre = tableau['type']!= 'LOC' echantillon = tableau[filtre] #on supprime ici les entités autres #que des noms de personnes (à savoir, les lieux) nom_ciceron = '' for el in echantillon['lemma'] : nom_ciceron+=f'{el} ' noms_cicerondoc = nlp(nom_ciceron) data = [] for token in noms_cicerondoc: data.append(token.text)#le contenu de la colonne lemma est devenu #une liste de tokens qui sont des chaînes de caractère |
Etape 2 : mapping avec le thesaurus de philosophes de la base de données CIRIS
Pour que nos deux listes soient comparables il faut transformer chaque item de chaque liste en chaîne de caractère. Les données de la base CIRIS ayant une présentation un peu différente de celles extraites de notre corpus, il faut également faire un autre pré-traitement directement dans le .csv en scindant la colonne contenant les noms et leurs épithètes pour ne conserver que le nom. Cela nous fait perdre une information qui permettrait peut-être une désambiguation en cas d’homonymie, mais cela va augmenter la fiabilité mécanique de notre mapping donc c’est un mal pour un bien.
tableau2 = pd.read_csv('philosophes.csv') #penser à scinder la colonne dénomination #pour séparer le nom de l'épithète colonnes = ['denomination', 'siecle_fin'] tableau2 = tableau2[colonnes] dates = ['-7', '-6', '-5', '-4', '-3', '-2', '-1', '1'] tableau2[tableau2.siecle_fin.isin(dates)] tableau2_sansdates = tableau2.drop("siecle_fin", axis=1) tableau2_sansdates.drop_duplicates(keep = 'first', inplace=True) nom_philosophe = ' ' for el in tableau2_sansdates['denomination'] : nom_philosophe+=f'{el} ' noms_philosophedoc = nlp(nom_philosophe) dataphil = [] for token in noms_philosophedoc: dataphil.append(token.text)#on a à présent aussi sous forme de liste de #chaînes de caractères les dénominations extraites de CIRIS |
Il nous reste à effectuer le mapping à proprement parler : comparer nos deux listes de tokens sous forme de chaînes de caractères. Nous utilisons pour cela la bibliothèque Levenshtein qui permet de calculer une distance entre deux tokens. Nous récupérons à la sortie sous forme de dataframe le tableau contenant les noms de philosophes communs entre nos entités nommées récupérées dans notre corpus, et les noms de la base CIRIS.
from Levenshtein import distance def lev_merging(a, b): merged = [] for el in data: for elo in dataphil: if distance(el, elo) < 2: merged.append(elo) return merged cic_phil = lev_merging(data, dataphil) #la variable data correspond aux tokens #extraits de chaque texte, l'opération doit donc être effectuée 4 fois df = pd.DataFrame(cic_phil, columns=['nom']) #on a maintenant une liste sous forme de df #des noms de philosophes identifiés dans le texte de Cicéron sur lequel on a fait tourner le script |
– il est possible que des noms de philosophes ne soient pas présents dans la base CIRIS. Une méthode complète impliquerait d’abord de vérifier la complétude en comparant avec des instruments plus exhaustifs, comme le Répertoire des sources philosophiques antiques ou le Dictionnaire des philosophes antiques. Mais le risque serait dans ce cas de créer beaucoup plus d’homonymes que ceux déjà présents nécessairement.
– il est impossible avec cette méthode de conserver le contexte de citation d’un philosophe. On ne peut pas utiliser d’éléments contextuels pour désambigüer des homonymes ou pour éclaircir des graphies divergentes pour certains noms.
– il est encore plus impossible de faire état de personnages qui ne seraient pas nommés directement par Cicéron, mais appelés par des surnoms ou des allusions.
Il faut donc garder à l’esprit que les résultats qui vont être proposés à partir de ces données sont incomplets.
Etape 3 : calcul de fréquence d’apparition de chaque nom dans chaque texte
Nous allons à présent à partir de notre liste de noms compter l’ensemble des occurrences de chaque nom dans chacun des textes de notre corpus. C’est un script extrêmement simple et pandas va ensuite nous permettre de transformer cette liste d’occurrences en dataframe, ce qui va permettre ensuite de créer des visualisations en graphe.
On fait cette opération pour chacun de nos 4 textes. Chaque dataframe obtenu est exporté en csv – ce que je conseille d’ailleurs de faire à chaque étape de chaque script, cela permet un contrôle rapide des résultats obtenus et on voit bien s’il y a des aberrations ou pas.
frequences_noms = [] for nom in cic_phil: frequences_noms.append(cic_phil.count(nom)) occurrences = list(zip(cic_phil, frequences_noms)) df = pd.DataFrame(occurrences, columns = ['nom', 'frequence_tusc']) filtre2 = df['nom']!= ' ' #cette ligne me permet de nettoyer mon dataframe #en éliminant les noms qui n'ont aucune occurrence df_propre = df[filtre2] df_propre.drop_duplicates(keep = 'first', inplace=True)#dans certains cas le mapping a créé des #doublons dans les données, cette ligne les élimine df_propre.to_csv('occurrences_noms_tusc.csv') #j'obtiens un tableur qui contient la liste # des occurrences pour un texte, je refait l'opération pour chacun |
tableau1 = pd.read_csv('occurrences_noms_tusc.csv') tableau2 = pd.read_csv('occurrences_noms_fin.csv') tableau3 = pd.read_csv('occurrences_noms_nd.csv') tableau4 = pd.read_csv('occurrences_noms_off.csv') tableau1['nom'] = tableau1['nom'].astype(str) tableau2['nom'] = tableau2['nom'].astype(str) tableau3['nom'] = tableau3['nom'].astype(str) tableau4['nom'] = tableau4['nom'].astype(str) tableauA = pd.merge(tableau1, tableau2, on='nom', how='left') tableauA = tableauA.drop('Unnamed: 0_x', axis=1) tableauA = tableauA.drop('Unnamed: 0_y', axis=1) tableauB = pd.merge(tableau3, tableauA, on='nom', how='right') tableauB = tableauB.drop('Unnamed: 0', axis=1) tableauC = pd.merge(tableauB, tableau4, on='nom', how='left') tableauC = tableauC.drop('Unnamed: 0', axis=1) tableauC['frequence_tusc'] = tableauC['frequence_tusc'].astype(float) #cette ligne permet de régler un problème #qui arrive parfois au moment de la fusion des tableurs : #dans le tableau final les occurrences doivent bien être des nombres et non des strings tableauC.to_csv('occurrences_noms_cic_phil_corpus.csv') #jeu de données complet sous forme de tableur #contenant les occurrences respectives de chaque nom dans chaque texte |
Visualisation en graphe
Je vais utiliser Gephi pour créer un graphe (disponible en format .svg sur Zenodo) me permettant de visualiser à partir de mon jeu de données le poids réciproque des noms dans chaque texte.
Mes nœuds seront à la fois les noms de philosophes et les quatre textes, pour pouvoir créer un lien entre chaque nom et chaque texte. Mes arêtes seront les fréquences de citation entre un nom et un texte.
Une fois chargés dans Gephi, mes deux jeux de données (nœuds et arêtes) doivent être encore un peu travaillés. Pour obtenir mon graphe, j’ai d’abord fait tourner un algorithme d’expansion afin d’empêcher les chevauchements de nœuds (ce qui donne un graphe très grand, mais vue la masse de données c’est un peu inévitable). J’ai également demandé l’affichage du label pour les différents nœuds. Les options statistiques, notamment celle de degré pondéré me permettent de moduler la taille des flèches en fonction du nombre d’occurrences. Enfin, pour marquer par des couleurs les occurrences les plus importantes, j’ai utilisé la détection de communauté. Ainsi, mes noeuds auteurs prennent la couleur du texte dans lequel ils apparaissent le plus.

Pour accéder à une version PDF en meilleure résolution de ce graphe, cliquer ici.
L’ensemble de ce travail m’a pris quelques jours. Il n’est pas révolutionnaire, n’utilise que des technologies assez simples et propose une visualisation que j’estime juste modulo la marge d’erreur signalée plus haut dans les données d’entrée. Il y a probablement des manières plus simples, plus directes ou plus élégantes d’obtenir ce genre de visualisation, mais ma méthode, pour ce qu’elle vaut, a dorénavant l’avantage d’être documentée.
OpenEdition vous propose de citer ce billet de la manière suivante :
Julie Giovacchini (20 janvier 2025). Trouver des entités nommées dans un corpus cicéronien : documentation et résultats. L'information philologique. Consulté le 11 février 2025 à l’adresse https://doi.org/10.58079/133y5