Partager la publication "Créer des corpus IRaMuTeQ à partir de Wikipédia avec Python : wiki2iramuteq"
Dans cet article nous verrons comment constituer des corpus de textes à partir de Wikipédia pour les analyser avec le logiciel libre IRaMuTeQ. Nous utiliserons pour cela deux petits scripts Python, wiki2iramuteq.py et filtrer_corpus.py, disponibles sur notre dépôt GitHub : https://github.com/Anakeyn/wiki2iramuteq.
Rappelons qu’IRaMuTeQ (Interface de R pour les Analyses Multidimensionnelles de Textes et de Questionnaires) est un logiciel de textométrie développé par Pierre Ratinaud. La textométrie est l’une des branches de l’analyse de données textuelles, avec la lexicométrie et la logométrie. IRaMuTeQ est très utilisé en sciences humaines et sociales, notamment pour sa classification de Reinert, qui regroupe les segments de texte selon leur vocabulaire, et pour ses analyses de similitudes. Cette méthode a été proposée par Max Reinert, créateur du logiciel Alceste : « Une méthode de classification descendante hiérarchique : application à l’analyse lexicale par contexte », Les cahiers de l’analyse des données, 1983.
La difficulté, en analyse de données textuelles, n’est souvent pas l’analyse elle-même mais la constitution du corpus. Copier-coller des dizaines d’articles à la main est long, peu reproductible et source d’erreurs. Wikipédia offre au contraire un corpus immense, libre (licence CC BY-SA), daté et versionné, disponible dans plus de 300 langues.
Cet article est le premier d’une série de trois :
- constituer des corpus IRaMuTeQ à partir de Wikipédia, en français et en breton (le présent article) ;
- créer un dictionnaire breton pour IRaMuTeQ à partir des ressources du projet Apertium ;
- comparer les sujets traités par les Wikipédia bretonne et française.
Notez que nos exemples portent sur le français et le breton, et non pas sur l’anglais comme souvent. Le breton demande d’ailleurs quelques précautions particulières que nous verrons en chemin.
Si vous ne souhaitez pas utiliser de console, pas d’inquiétude : le dépôt contient aussi des corpus prêts à l’emploi, que l’on peut ouvrir directement dans IRaMuTeQ.
Le format de corpus IRaMuTeQ
Un corpus IRaMuTeQ est un simple fichier texte, de préférence encodé en UTF-8. Chaque texte du corpus commence par une ligne étoilée : quatre étoiles, puis une liste de variables étoilées de la forme *variable_modalité.
Par exemple, pour l’article « Logiciel libre » :
**** *art_Logiciel_libre *id_Logiciel_libre *cat_recherche *prof_0 *terme_logiciel_libre *equiv_br_oui *lang_fr Le logiciel libre est un logiciel dont l'utilisation, l'étude, la modification…
Ces variables sont très utiles : IRaMuTeQ permet ensuite de croiser les résultats d’une analyse avec elles. Par exemple, on pourra voir si une classe de Reinert est surtout composée d’articles qui n’existent pas dans l’autre Wikipédia.
Quelques contraintes sont à respecter :
- le caractère
*est réservé aux lignes étoilées : il ne doit pas apparaître dans les textes ; - les modalités ne doivent pas contenir d’espaces ; nous les limitons de plus aux caractères ASCII pour éviter toute surprise ;
- les lignes vides sont inutiles et sont supprimées.
Voici les variables que produit notre script :
| Variable | Signification |
|---|---|
*art_ |
titre de l’article, transformé en modalité |
*id_ |
identifiant stable, commun aux deux langues dans le mode alignement |
*cat_ |
catégorie Wikipédia d’origine (ou « recherche ») |
*prof_ |
profondeur de la sous-catégorie (0 = catégorie de départ) |
*terme_ |
terme de recherche le plus fréquent dans l’article |
*equiv_xx_oui/non |
l’article existe-t-il dans la Wikipédia de langue xx ? |
*lang_ |
langue de l’article |
De quoi aurons-nous besoin ?
IRaMuTeQ
IRaMuTeQ est un logiciel libre (GPL) qui s’installe sous Windows, macOS et Linux. Vous le trouverez sur sa page officielle : https://pratinaud.gitpages.huma-num.fr/iramuteq-website/.
Attention : IRaMuTeQ s’appuie sur le logiciel R pour ses calculs statistiques (son nom l’indique : Interface de R…). Il faut donc installer R avant IRaMuTeQ. R est lui aussi un logiciel libre, disponible sur le site du CRAN (Comprehensive R Archive Network) : https://cran.r-project.org/.
- Windows : https://cran.r-project.org/bin/windows/base/
- macOS : https://cran.r-project.org/bin/macosx/
- Linux : R s’installe en général avec le gestionnaire de paquets de votre distribution (paquet
r-basesous Debian et Ubuntu) ; voir https://cran.r-project.org/bin/linux/
Reportez-vous ensuite à la page d’installation d’IRaMuTeQ pour votre système : elle précise les éventuels paquets R complémentaires. Pour cet article, nous avons utilisé R 4.3.3.
Python, sans aucune bibliothèque
Contrairement à nos articles précédents, nous n’utiliserons ni Anaconda, ni Pandas, ni bibliothèque à installer. Les scripts n’utilisent que la bibliothèque standard de Python (version 3.8 ou plus récente) : urllib pour interroger Wikipédia, json, csv, re pour les expressions régulières, argparse pour les options en ligne de commande.
Ce choix est volontaire : les étudiants qui ne sont pas en filière informatique ne sont pas toujours à l’aise avec l’installation de bibliothèques. Moins il y a de dépendances, moins il y a de problèmes.
- Sous Linux et macOS, Python est déjà présent.
- Sous Windows, installez Python depuis python.org en cochant bien la case Add Python to PATH. Dans les commandes de cet article, remplacez
python3parpython, et écrivez chaque commande sur une seule ligne : le\de fin de ligne ne fonctionne pas dans la console Windows.
Le dépôt GitHub
Récupérez le dépôt avec le bouton vert Code → Download ZIP sur https://github.com/Anakeyn/wiki2iramuteq, décompressez-le, puis ouvrez un terminal dans ce dossier. Il contient :
wiki2iramuteq.py: le script qui interroge Wikipédia et produit les corpus ;filtrer_corpus.py: le script qui refiltre un corpus déjà téléchargé ;- le dossier
corpus/: des corpus prêts à l’emploi.
Sans console : les corpus prêts à l’emploi
Si vous voulez seulement analyser les corpus, aucune commande n’est nécessaire. Sur GitHub, cliquez sur le fichier .txt voulu, puis sur le bouton de téléchargement (Download raw file).
Dans IRaMuTeQ : Fichier → Ouvrir un corpus, encodage utf-8, langue français.
Pour les corpus bretons, IRaMuTeQ ne propose pas encore de dictionnaire breton : ce sera l’objet de notre deuxième article. En attendant, ouvrez-les avec la langue « français » et décochez la lemmatisation dans les paramètres de chaque analyse. Les analyses portent alors sur les formes du texte telles qu’elles apparaissent.
Code source de wiki2iramuteq.py
Vous pourrez soit récupérer le code source par morceaux dans les extraits suivants, soit en entier sur GitHub : https://github.com/Anakeyn/wiki2iramuteq.
Le principe général est le suivant :
- sélectionner les articles, selon l’un des quatre modes : catégorie, recherche, articles parallèles ou alignement ;
- récupérer le texte brut de chaque article via l’API officielle de Wikipédia ;
- nettoyer ce texte en retirant les sections annexes (références, liens externes…) ;
- écrire le corpus au format IRaMuTeQ, ainsi qu’un fichier CSV de métadonnées.
Chargement des bibliothèques et paramètres
Toutes les bibliothèques font partie de Python : rien à installer.
Notez la constante USER_AGENT. Wikimédia demande que les outils s’identifient, avec un nom et un moyen de contact (courriel ou site web). Sans cela, les requêtes sont rapidement limitées par le serveur. Le contact est renseigné par l’option --contact.
import argparse
import csv
import json
import re
import sys
import time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
from datetime import date
from pathlib import Path
# Wikimedia exige un User-Agent identifiant l'outil ET un moyen de contact
# (courriel ou site) ; sans cela les requêtes sont vite limitées (HTTP 429).
# Le contact est complété par l'option --contact.
USER_AGENT = "wiki2iramuteq/1.7 (corpus pour IRaMuTeQ ; contact : {contact}) python-urllib"
CONFIG = {"user_agent": USER_AGENT.format(contact="non renseigné"),
"pause": 1.0, "dernier_appel": 0.0}
# Préfixe de l'espace de noms « Catégorie » selon la langue
PREFIXES_CATEGORIE = {"fr": "Catégorie", "br": "Rummad", "en": "Category"}
Le dictionnaire PREFIXES_CATEGORIE rappelle que les espaces de noms de Wikipédia sont traduits : une catégorie s’appelle « Catégorie » en français, « Rummad » en breton et « Category » en anglais.
Interroger l’API de Wikipédia
Toutes les requêtes passent par une seule fonction, api(). Elle interroge l’API MediaWiki de la langue voulue (par exemple https://br.wikipedia.org/w/api.php) et renvoie la réponse JSON.
Cette fonction est aussi chargée d’être polie avec les serveurs de Wikimédia :
- elle attend au moins une seconde entre deux requêtes ;
- le paramètre
maxlagdemande au serveur de refuser la requête s’il est surchargé ; on attend alors 10 secondes ; - en cas d’erreur HTTP 429 (trop de requêtes) ou 503, elle lit l’en-tête
Retry-After, attend le délai demandé, puis double la pause entre les requêtes suivantes (jusqu’à 5 secondes) ; - en cas d’erreur réseau, elle réessaie avec une attente croissante : 1, 2, 4, 8 secondes…
def api(lang, **params):
"""Appel GET à l'API MediaWiki : requêtes espacées, respect des limites du serveur."""
params.update({"format": "json", "formatversion": "2", "maxlag": "5"})
url = f"https://{lang}.wikipedia.org/w/api.php?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"User-Agent": CONFIG["user_agent"],
"Accept-Encoding": "identity"})
for essai in range(8):
# pause minimale entre deux requêtes, quelle que soit la fonction appelante
ecart = time.monotonic() - CONFIG["dernier_appel"]
if ecart < CONFIG["pause"]:
time.sleep(CONFIG["pause"] - ecart)
CONFIG["dernier_appel"] = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=30) as r:
data = json.load(r)
if "error" in data:
if data["error"].get("code") == "maxlag":
time.sleep(10)
continue
raise RuntimeError(data["error"])
return data
except urllib.error.HTTPError as e:
if e.code in (429, 503):
# le serveur indique souvent combien de secondes attendre
try:
attente = int(e.headers.get("Retry-After", ""))
except ValueError:
attente = 30 * (essai + 1)
attente = max(attente, 10)
CONFIG["pause"] = min(CONFIG["pause"] * 2, 5.0) # on ralentit pour la suite
print(f" (Wikipédia demande de ralentir, HTTP {e.code} — pause de {attente} s, "
f"puis {CONFIG['pause']:.0f} s entre requêtes)", file=sys.stderr)
time.sleep(attente)
else:
print(f" (erreur HTTP {e.code} — nouvel essai dans {2 ** essai} s)", file=sys.stderr)
time.sleep(2 ** essai)
except (OSError, ValueError) as e:
attente = 2 ** essai
print(f" (erreur réseau : {e} — nouvel essai dans {attente} s)", file=sys.stderr)
time.sleep(attente)
raise RuntimeError(f"Échec de l'appel API après plusieurs essais : {url}")
Parcourir une catégorie et ses sous-catégories
Sur Wikipédia, les catégories forment une arborescence : la catégorie « Humanités numériques » contient des articles, mais aussi des sous-catégories, qui contiennent à leur tour des articles et des sous-catégories…
La fonction membres() liste le contenu d’une catégorie. L’API ne renvoie que 500 résultats à la fois : on boucle donc tant que la réponse contient un champ continue.
La fonction parcourir() effectue un parcours en largeur de l’arborescence, jusqu’à la profondeur demandée (option --profondeur, 2 par défaut). L’ensemble vues évite de tourner en rond, car un graphe de catégories peut contenir des boucles. Pour chaque article, on garde la catégorie et la profondeur où il a été trouvé en premier.
def titre_categorie(lang, nom):
prefixe = PREFIXES_CATEGORIE.get(lang, "Category")
nom = nom.strip()
for p in set(PREFIXES_CATEGORIE.values()):
if nom.startswith(p + ":"):
nom = nom.split(":", 1)[1]
return f"{prefixe}:{nom}"
def membres(lang, cat, cmtype):
"""Liste les membres (pages de l'espace principal ou sous-catégories) d'une catégorie."""
resultats, suite = [], {}
while True:
params = dict(action="query", list="categorymembers", cmtitle=cat,
cmtype=cmtype, cmlimit="500", **suite)
if cmtype == "page":
params["cmnamespace"] = "0"
data = api(lang, **params)
resultats += data["query"]["categorymembers"]
if "continue" in data:
suite = data["continue"]
else:
return resultats
def parcourir(lang, cat_racine, profondeur_max):
"""Parcours en largeur des catégories. Renvoie {titre: (catégorie, profondeur)}."""
articles, vues = {}, {cat_racine}
file = [(cat_racine, 0)]
while file:
cat, prof = file.pop(0)
print(f" [{prof}] {cat}")
for m in membres(lang, cat, "page"):
articles.setdefault(m["title"], (cat.split(":", 1)[1], prof))
if prof < profondeur_max:
for sc in membres(lang, cat, "subcat"):
if sc["title"] not in vues:
vues.add(sc["title"])
file.append((sc["title"], prof + 1))
return articles
Attention : plus la profondeur est grande, plus on s’éloigne du sujet de départ. Au-delà de 2 ou 3 niveaux, on récupère souvent des articles qui n’ont plus grand-chose à voir avec la catégorie initiale.
Récupérer le texte brut d’un article
Pour obtenir le texte d’un article, nous utilisons l’extension TextExtracts de l’API (prop=extracts). Avec explaintext=1, elle renvoie le texte brut, débarrassé du code wiki, des infobox, des tableaux et des références. Avec exsectionformat=wiki, les titres de sections sont conservés sous la forme == Titre ==, ce qui nous permettra de repérer les sections annexes.
On récupère aussi le numéro de révision (lastrevid) : il permet de retrouver exactement la version de l’article utilisée, ce qui est essentiel pour la reproductibilité d’une recherche.
def texte_article(lang, titre):
"""Texte brut d'un article (extension TextExtracts), avec titres de sections."""
data = api(lang, action="query", prop="extracts|info", titles=titre,
explaintext="1", exsectionformat="wiki", redirects="1")
page = data["query"]["pages"][0]
return page.get("extract", "") or "", page.get("lastrevid"), page.get("title", titre)
Nettoyage : supprimer les sections annexes
Un article Wikipédia se termine presque toujours par des sections annexes : « Notes et références », « Voir aussi », « Liens externes », « Bibliographie »… Ces sections contiennent surtout des titres d’ouvrages, des noms d’auteurs et des URL. Si on les garde, elles polluent l’analyse : IRaMuTeQ risque par exemple de créer une classe entière autour du vocabulaire bibliographique.
Nous avons donc dressé une liste de titres de sections à supprimer, en français, en breton et en anglais :
# Titres de sections annexes à supprimer (comparaison en minuscules, début de titre)
SECTIONS_ANNEXES = [
# français
"notes et références", "notes", "références", "voir aussi",
"articles connexes", "liens externes", "bibliographie", "sources",
"annexes", "pour approfondir", "webographie",
# breton
"notennoù", "daveennoù", "daveoù", "notennoù ha daveennoù", "gwelet ivez",
"da welet ivez", "pennadoù kar", "liammoù", "liamm diavaez", "levrlennadur",
"levrioù", "mammennoù", "stagadennoù", "lec'hiennoù", "lec'hienn",
"evit gouzout hiroc'h", "evit gouzout muioc'h", "dielloù",
# anglais
"references", "see also", "external links", "further reading",
]
La fonction nettoyer() lit le texte ligne par ligne. Une expression régulière repère les titres de sections (== Titre ==, === Sous-titre ===…) ; le nombre de signes = indique le niveau du titre.
Lorsqu’on rencontre un titre de section annexe, on mémorise son niveau dans niveau_coupe et on ignore toutes les lignes suivantes. On ne reprend qu’au prochain titre de même niveau ou de niveau supérieur. Ainsi, une sous-section de « Voir aussi » est elle aussi supprimée, mais une section normale qui suit est conservée.
Ensuite, on applique les contraintes du format IRaMuTeQ : suppression des étoiles, des espaces insécables et des lignes vides.
RE_TITRE = re.compile(r"^(=+)\s*(.*?)\s*\1\s*$")
def nettoyer(texte):
"""Supprime les sections annexes et les titres, normalise les espaces."""
lignes_gardees, niveau_coupe = [], None
for ligne in texte.splitlines():
m = RE_TITRE.match(ligne)
if m:
niveau, intitule = len(m.group(1)), m.group(2).lower().strip()
if niveau_coupe is not None and niveau <= niveau_coupe:
niveau_coupe = None
if niveau_coupe is None and any(intitule.startswith(s) for s in SECTIONS_ANNEXES):
niveau_coupe = niveau
continue # on ne garde pas les titres de sections eux-mêmes
if niveau_coupe is None:
lignes_gardees.append(ligne)
texte = "\n".join(lignes_gardees)
texte = texte.replace("*", " ") # « * » est réservé par IRaMuTeQ
texte = texte.replace("\u00a0", " ").replace("\u202f", " ")
texte = re.sub(r"[ \t]+", " ", texte)
texte = re.sub(r"\n\s*\n+", "\n", texte) # pas de lignes vides
return texte.strip()
Notez que les titres de sections eux-mêmes ne sont pas conservés : ce sont des fragments très courts, qui n’apportent pas grand-chose à une analyse textométrique.
Fabriquer des modalités valides
Les modalités des variables étoilées ne doivent contenir ni espaces ni caractères spéciaux. La fonction modalite() décompose les caractères accentués (normalisation NFKD), ne garde que leur partie ASCII, puis remplace tout le reste par des soulignés.
Ainsi « Humanités numériques » devient Humanites_numeriques, et « Treiñ e brezhoneg » devient Trein_e_brezhoneg.
def modalite(s, longueur=60):
"""Transforme une chaîne en modalité de variable IRaMuTeQ (ASCII, sans espace)."""
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
s = re.sub(r"[^A-Za-z0-9]+", "_", s).strip("_")
return (s or "inconnu")[:longueur]
Le mode recherche : des termes plutôt qu’une catégorie
Les catégories sont pratiques, mais elles dépendent du travail de classement des contributeurs. Sur une petite Wikipédia comme la Wikipédia en breton, une catégorie peut être très incomplète, voire ne pas exister.
Le mode recherche part donc de termes. La fonction rechercher() utilise la recherche plein texte de Wikipédia (list=search). Un terme composé de plusieurs mots est mis entre guillemets pour être cherché comme une expression. L’option --plafond limite le nombre de résultats par terme, les plus pertinents d’abord : c’est indispensable pour une grande Wikipédia comme la Wikipédia en français.
def rechercher(lang, termes, plafond=2000):
"""Recherche plein texte (espace principal). Renvoie {titre: [termes trouvés]}."""
trouves = {}
for terme in termes:
requete = f'"{terme}"' if " " in terme else terme
n, suite = 0, {}
while n < plafond:
data = api(lang, action="query", list="search", srsearch=requete,
srnamespace="0", srlimit="500", srwhat="text", srinfo="totalhits",
srprop="", **suite)
q = data.get("query", {})
for r in q.get("search", []):
trouves.setdefault(r["title"], []).append(terme)
n += 1
if "continue" in data:
suite = data["continue"]
else:
break
total = q.get("searchinfo", {}).get("totalhits", n)
print(f" « {terme} » : {total} articles ({n} récupérés)")
return trouves
Un article qui cite une seule fois le mot « ordinateur » ne parle pas forcément d’informatique. On compte donc les occurrences des termes dans le texte nettoyé, et on ne garde que les articles qui atteignent un minimum (--min-occurrences, 2 par défaut).
Le comptage se fait en début de mot et sans tenir compte des majuscules : (?<!\w) signifie « non précédé d’une lettre ». Ainsi urzhiataer (ordinateur) compte aussi le pluriel urzhiataerioù, mais pas un mot où il apparaîtrait au milieu.
def compter(texte, termes):
"""Occurrences de chaque terme (début de mot, insensible à la casse)."""
return {t: len(re.findall(r"(?<!\w)" + re.escape(t), texte, flags=re.IGNORECASE))
for t in termes}
Attention aux mutations en breton ! En breton, la consonne initiale d’un mot change selon le mot qui précède : kenrouedad (réseau) devient par exemple ar genrouedad. La recherche de Wikipédia ne gère pas ces mutations : il faut donc donner les variantes vous-même, par exemple kenrouedad,genrouedad.
Les liens interlangues : l’article existe-t-il dans l’autre Wikipédia ?
Chaque article de Wikipédia peut être relié à ses équivalents dans les autres langues : ce sont les liens interlangues (prop=langlinks). C’est grâce à eux que nous pouvons comparer deux Wikipédia.
La fonction equivalents() interroge l’API par lots de 50 titres (le maximum autorisé), ce qui divise d’autant le nombre de requêtes. Elle gère aussi les titres normalisés ou redirigés par Wikipédia, pour toujours retomber sur le titre de départ.
def equivalents(lang_source, titres, lang_cible):
"""Pour une liste de titres (lang_source), renvoie {titre: titre_dans_lang_cible}.
Interroge les liens interlangues par lots de 50 titres."""
resultat = {}
for i in range(0, len(titres), 50):
lot = titres[i:i + 50]
suite = {}
while True:
data = api(lang_source, action="query", prop="langlinks", lllang=lang_cible,
lllimit="max", titles="|".join(lot), redirects="1", **suite)
q = data.get("query", {})
# titres normalisés ou redirigés : on remonte au titre d'origine
origine = {t: t for t in lot}
for cle in ("normalized", "redirects"):
for n in q.get(cle, []):
for t, o in list(origine.items()):
if t == n["from"]:
origine[n["to"]] = o
for page in q.get("pages", []):
for ll in page.get("langlinks", []):
resultat[origine.get(page["title"], page["title"])] = ll["title"]
if "continue" in data:
suite = data["continue"]
else:
break
return resultat
def marquer_equivalents(lang, entrees, lang_autre):
"""Ajoute à chaque entrée la variable *equiv_<lang_autre>_oui/non."""
print(f"Recherche des équivalents en « {lang_autre} »…")
corresp = equivalents(lang, [e["titre"] for e in entrees], lang_autre)
for e in entrees:
e["extra"] = e.get("extra", "") + \
f" *equiv_{lang_autre}_{'oui' if e['titre'] in corresp else 'non'}"
print(f"{len(corresp)} articles sur {len(entrees)} existent aussi en « {lang_autre} ».")
La fonction marquer_equivalents() (option --marquer) s’en sert pour ajouter la variable *equiv_fr_oui ou *equiv_fr_non à chaque article. Dans IRaMuTeQ, une classe de Reinert composée surtout d’articles *equiv_fr_non désignera des sujets propres à la Wikipédia bretonne.
Le même principe sert à retrouver une catégorie dans une autre langue (option --depuis), avec la petite fonction categorie_equivalente() : on demande par exemple l’équivalent anglais de la catégorie française « Humanités numériques ».
def categorie_equivalente(lang_source, categorie, lang_cible):
"""Retrouve la catégorie équivalente dans une autre langue (liens interlangues)."""
data = api(lang_source, action="query", prop="langlinks",
titles=titre_categorie(lang_source, categorie), lllang=lang_cible)
pages = data["query"]["pages"]
for p in pages:
for ll in p.get("langlinks", []):
return ll["title"]
return None
Écrire le corpus et ses métadonnées
La fonction ecrire_corpus() est le cœur du programme. Pour chaque article sélectionné, elle :
- récupère le texte brut et le numéro de révision ;
- écarte les doublons : deux titres différents peuvent rediriger vers le même article ;
- nettoie le texte et compte ses mots ;
- décide si l’article est retenu : assez long (
--min-mots, 50 mots par défaut) et, en mode recherche, avec assez d’occurrences des termes ; - calcule le terme dominant de l’article, pour la variable
*terme_; - écrit une ligne dans le fichier CSV de métadonnées, que l’article soit retenu ou non ;
- si l’article est retenu, écrit la ligne étoilée puis le texte dans le corpus.
def ecrire_corpus(lang, entrees, base, sortie, min_mots, filtre=None, termes=None, min_occ=0):
"""Écrit le corpus IRaMuTeQ et son CSV de métadonnées.
entrees : liste de dict {id, titre, categorie, prof}. Renvoie l'ensemble des id retenus."""
f_corpus = sortie / f"{base}.txt"
f_meta = sortie / f"{base}_metadonnees.csv"
retenus, titres_ecrits = set(), set()
with open(f_corpus, "w", encoding="utf-8") as fc, \
open(f_meta, "w", encoding="utf-8", newline="") as fm:
meta = csv.writer(fm)
meta.writerow(["id", "titre", "url", "categorie", "profondeur", "revision", "mots",
"occurrences", "retenu"])
for i, e in enumerate(entrees, 1):
brut, revid, titre_reel = texte_article(lang, e["titre"])
if titre_reel in titres_ecrits: # redirection vers un article déjà écrit
print(f" {i:4}/{len(entrees)} = [{lang}] {e['titre']} → {titre_reel} (doublon écarté)")
continue
texte = nettoyer(brut)
nb_mots = len(texte.split())
retenu = (e["id"] in filtre) if filtre is not None else nb_mots >= min_mots
variable_terme, occ = "", ""
if termes:
comptes = compter(texte, termes)
occ = sum(comptes.values())
retenu = retenu and occ >= min_occ
dominant = max(comptes, key=comptes.get)
variable_terme = f" *terme_{modalite(dominant, 30)}"
url = f"https://{lang}.wikipedia.org/wiki/" + urllib.parse.quote(titre_reel.replace(" ", "_"))
meta.writerow([e["id"], titre_reel, url, e["categorie"], e["prof"], revid, nb_mots,
occ, int(retenu)])
print(f" {i:4}/{len(entrees)} {'✓' if retenu else '–'} [{lang}] {titre_reel} ({nb_mots} mots{f', {occ} occ.' if termes else ''})")
if retenu:
titres_ecrits.add(titre_reel)
fc.write(f"**** *art_{modalite(titre_reel)} *id_{e['id']} "
f"*cat_{modalite(e['categorie'], 40)} *prof_{e['prof']}{variable_terme}{e.get('extra', '')} "
f"*lang_{lang}\n"
f"{texte}\n\n")
retenus.add(e["id"])
print(f"\n{len(retenus)} articles écrits dans {f_corpus}")
print(f"Métadonnées : {f_meta}")
return retenus
Le fichier CSV de métadonnées est important à plusieurs titres :
- il garde l’URL et le numéro de révision de chaque article : on peut retrouver la version exacte utilisée et son historique ;
- il garde aussi les articles non retenus, avec leur nombre de mots et d’occurrences : on peut ainsi refiltrer le corpus plus tard sans rien retélécharger (nous le verrons avec
filtrer_corpus.py) ; - il sert de point de départ au mode alignement.
Voici à quoi ressemblent ses premières lignes pour le corpus « Humanités numériques » :
id,titre,url,categorie,profondeur,revision,mots,retenu Alliance_of_Digital_Humanities_Organizations,Alliance of Digital Humanities Organizations,https://fr.wikipedia.org/wiki/Alliance_of_Digital_Humanities_Organizations,Humanités numériques,0,239688033,855,1 Analyse_de_donnees_textuelles,Analyse de données textuelles,https://fr.wikipedia.org/wiki/Analyse_de_donn%C3%A9es_textuelles,Humanités numériques,0,229963737,240,1
Le mode alignement : deux corpus strictement parallèles
Pour comparer deux Wikipédia, l’idéal est de disposer des mêmes articles dans les deux langues. C’est le rôle du mode alignement (option --aligner).
Il part du fichier CSV de métadonnées d’un corpus déjà produit, par exemple le corpus breton. Il retrouve la version française de chaque article retenu grâce aux liens interlangues, puis il écrit deux corpus :
- les versions françaises des articles ;
- le corpus breton d’origine, restreint aux articles qui ont un équivalent français.
Les deux corpus partagent la même variable *id_ : on peut donc rapprocher chaque article breton de sa version française.
# ----- Mode alignement --------------------------------------------------
if args.aligner:
with open(Path(args.aligner).expanduser(), encoding="utf-8") as f:
lignes = [l for l in csv.DictReader(f) if l.get("retenu") == "1"]
if not lignes:
sys.exit("Aucun article retenu dans ce CSV.")
src = urllib.parse.urlparse(lignes[0]["url"]).netloc.split(".")[0]
print(f"{len(lignes)} articles retenus en « {src} ». Recherche des versions en « {args.lang} »…")
corresp = equivalents(src, [l["titre"] for l in lignes], args.lang)
print(f"{len(corresp)} ont une version en « {args.lang} » "
f"({len(lignes) - len(corresp)} sans équivalent).")
entrees, deja = [], set()
for l in lignes:
cible = corresp.get(l["titre"])
if cible and cible not in deja:
deja.add(cible)
entrees.append(dict(id=l["id"], titre=cible, categorie=l["categorie"],
prof=l["profondeur"]))
if len(entrees) < len(corresp):
print(f"({len(corresp) - len(entrees)} doublon(s) écarté(s) : plusieurs articles "
f"pointent vers le même article en « {args.lang} »)")
base = Path(args.aligner).name.replace("_metadonnees.csv", "")
base = base.replace(f"wikipedia_{src}_", f"wikipedia_{args.lang}_aligne_sur_{src}_", 1)
termes = [t.strip() for t in args.recherche.split(",")] if args.recherche else None
ids = ecrire_corpus(args.lang, entrees, base, sortie, 0, termes=termes, min_occ=0)
# corpus source restreint aux mêmes articles, pour une comparaison stricte
f_src = Path(args.aligner).expanduser().with_name(
Path(args.aligner).name.replace("_metadonnees.csv", ".txt"))
if f_src.exists():
blocs = re.split(r"(?m)^(?=\*\*\*\* )", f_src.read_text(encoding="utf-8"))
garder = [b for b in blocs
if (m := re.search(r"\*id_(\S+)", b.split("\n", 1)[0])) and m.group(1) in ids]
f_out = sortie / f_src.name.replace(".txt", f"_aligne_sur_{args.lang}.txt")
f_out.write_text("".join(garder), encoding="utf-8")
print(f"Corpus « {src} » restreint aux mêmes {len(garder)} articles : {f_out}")
print(f"Source : Wikipédia ({args.lang}), CC BY-SA 4.0, extraction du {date.today():%d/%m/%Y}.")
return
Remarquez l’expression régulière ^(?=\*\*\*\* ) : elle découpe le corpus juste avant chaque ligne étoilée, sans consommer les étoiles. Chaque bloc obtenu contient donc un article complet, ligne étoilée comprise.
Le programme principal main() lit les options avec argparse, puis lance le mode demandé. Les modes catégorie, recherche et articles parallèles suivent le même schéma que le mode alignement : sélection des articles, marquage éventuel des équivalents, puis appel à ecrire_corpus(). Vous les trouverez dans le code complet sur GitHub.
Voici les principales options :
| Option | Rôle | Défaut |
|---|---|---|
--lang |
langue du corpus à produire (fr, br, en…) |
fr |
--sortie |
dossier de sortie | obligatoire |
--contact |
courriel ou site web transmis à Wikimédia | — |
--categorie |
mode catégorie : nom de la catégorie, sans préfixe | — |
--profondeur |
niveaux de sous-catégories parcourus | 2 |
--depuis |
langue dans laquelle --categorie est donnée |
— |
--recherche |
mode recherche : termes séparés par des virgules | — |
--min-occurrences |
occurrences minimales des termes | 2 |
--plafond |
résultats maximum par terme | 2000 |
--marquer |
ajoute *equiv_LANGUE_oui/non |
— |
--aligner |
mode alignement : CSV de métadonnées d’un corpus existant | — |
--paralleles |
mode articles parallèles : langue de départ | — |
--min-mots |
écarte les articles plus courts | 50 |
--pause |
secondes minimum entre deux requêtes | 1 |
Refiltrer un corpus par densité : filtrer_corpus.py
Le problème des articles longs
Un nombre minimal d’occurrences pénalise les articles courts et favorise les articles longs. Deux mentions du mot « ordinateur » dans un article de 200 mots, c’est probablement le sujet de l’article. Deux mentions dans un article de 5 000 mots sur un pays ou une personnalité, c’est anecdotique.
Nous avons donc ajouté un second critère : la densité, c’est-à-dire le nombre d’occurrences des termes pour 1 000 mots (en ‰) :
densité = 1000 × occurrences / nombre de mots
Avec 2 occurrences, un article de 200 mots a une densité de 10 ‰, alors qu’un article de 5 000 mots n’a qu’une densité de 0,4 ‰.
Le script filtrer_corpus.py applique ce critère à un corpus déjà téléchargé. Il ne retélécharge rien : il travaille uniquement à partir des colonnes « mots » et « occurrences » du fichier CSV de métadonnées.
Code source
Les deux fonctions de base calculent la densité d’un article et décident s’il passe les seuils :
import argparse
import csv
import re
import sys
from pathlib import Path
def densite(ligne):
mots = int(ligne["mots"] or 0)
occ = int(ligne["occurrences"] or 0)
return 1000 * occ / mots if mots else 0.0
def garde(ligne, min_occ, min_dens):
return (ligne["retenu"] == "1" and int(ligne["occurrences"] or 0) >= min_occ
and densite(ligne) >= min_dens)
Étape 1 : l’aperçu
Avant de choisir un seuil, il est utile de voir son effet. L’option --apercu affiche le nombre d’articles gardés pour plusieurs combinaisons de seuils, puis les 15 articles les moins denses, qui sont les premiers candidats à l’exclusion. Rien n’est écrit sur le disque.
if args.apercu:
print(f"{len(retenus)} articles retenus actuellement.\n")
print("occ. min | densité min (‰ mots) → articles gardés")
for occ in (2, 3, 4):
ligne = " ".join(f"{d:>3} → {sum(garde(l, occ, d) for l in lignes):>4}"
for d in (0, 2, 5, 10))
print(f" {occ} | {ligne}")
print("\nArticles les moins denses (candidats à l'exclusion) :")
for l in sorted(retenus, key=densite)[:15]:
print(f" {densite(l):6.1f} ‰ {l['occurrences']:>3} occ. / {l['mots']:>6} mots {l['titre']}")
return
python3 filtrer_corpus.py corpus/num_br/wikipedia_br_numerique_metadonnees.csv --apercu
Étape 2 : la révision manuelle
Un seuil automatique n’est jamais parfait. Le script permet donc une révision manuelle, dans les deux sens :
--exclureécarte des articles qui passent les seuils mais sont hors sujet ;--garderconserve des articles qui ne passent pas les seuils mais sont bien dans le sujet.
Les titres doivent être exacts. Pour les retrouver, l’option --chercher affiche les titres contenant un mot, avec leurs occurrences, leur nombre de mots et leur densité :
python3 filtrer_corpus.py corpus/num_br/wikipedia_br_numerique_metadonnees.csv --chercher Atlas
Puis on lance le filtrage effectif :
python3 filtrer_corpus.py corpus/num_br/wikipedia_br_numerique_metadonnees.csv --min-densite 2 \
--exclure "Portugal;Shrek" --garder "Treiñ e brezhoneg"
Le cœur du filtrage tient en quelques lignes. Un article est gardé s’il n’est pas exclu à la main et s’il est forcé à la main ou passe les seuils. Le corpus est ensuite découpé article par article, avec la même expression régulière que dans le mode alignement :
exclus = {t.strip().lower() for t in args.exclure.split(";") if t.strip()}
forces = {t.strip().lower() for t in args.garder.split(";") if t.strip()}
connus = {l["titre"].lower() for l in retenus}
for nom, ens in (("exclure", exclus), ("garder", forces)):
if ens - connus:
print(f"Attention, titres à {nom} introuvables : " + " ; ".join(sorted(ens - connus))
+ " (utilisez --chercher MOT pour trouver le titre exact)")
ids = {l["id"] for l in retenus
if l["titre"].lower() not in exclus
and (l["titre"].lower() in forces or garde(l, args.min_occurrences, args.min_densite))}
ecartes = [l["titre"] for l in retenus if l["id"] not in ids]
f_txt = f_csv.with_name(f_csv.name.replace("_metadonnees.csv", ".txt"))
if not f_txt.exists():
sys.exit(f"Corpus introuvable : {f_txt}")
blocs = re.split(r"(?m)^(?=\*\*\*\* )", f_txt.read_text(encoding="utf-8"))
gardes = [b for b in blocs
if (m := re.search(r"\*id_(\S+)", b.split("\n", 1)[0])) and m.group(1) in ids]
Notez l’avertissement si un titre à exclure ou à garder est introuvable : une faute de frappe passerait sinon inaperçue.
Garder la trace des choix
Le script produit trois fichiers :
…_filtre.txt: le corpus filtré, à ouvrir dans IRaMuTeQ ;…_filtre_metadonnees.csv: les métadonnées mises à jour, utilisables ensuite avec--aligner;…_filtre_criteres.txt: la trace des seuils, exclusions et conservations appliqués.
Ce dernier fichier est important pour la rigueur scientifique : les choix manuels font partie de la méthode et doivent pouvoir être décrits et discutés. Voici par exemple celui du corpus breton :
Source : wikipedia_br_numerique_metadonnees.csv Occurrences minimales : 2 Densité minimale : 2.0 ‰ Exclusions manuelles : Portugal;Rummad kimiek;Tekla Iconium;Niels Henrik Abel;Esparder gravitadur;Niveradur arabek;Matematik;Liu Cixin;Garmenig Ihuellou;George Byron;Kaier;Starship Troopers;Shrek;Atlas (kartennaoueg);Zaman (kazetenn) Conservations forcées : Treiñ e brezhoneg;Listennad kodoù-statud HTTP;Agence Bretagne Presse;Holloueziadur;Kazetennerezh Articles gardés : 156 sur 190
On y voit bien l’intérêt de la révision : des articles sur le Portugal, Lord Byron ou le film Shrek contenaient assez de termes liés au numérique pour passer les seuils, sans pour autant traiter du sujet.
Les corpus du dépôt
Pour illustrer notre propos, nous avons constitué cinq corpus, extraits le 20 octobre 2026. Ils sont dans le dossier corpus/ du dépôt :
| Corpus | Fichier à ouvrir dans IRaMuTeQ | Articles |
|---|---|---|
| Humanités numériques (fr) | corpus/hn_fr/wikipedia_fr_humanites_numeriques.txt |
45 |
| Le numérique (fr) | corpus/num_fr/wikipedia_fr_numerique_filtre.txt |
1 232 |
| Le numérique (br) | corpus/num_br/wikipedia_br_numerique_filtre.txt |
156 |
| Paires fr/br, version française | corpus/num_fr_aligne/wikipedia_fr_aligne_sur_br_numerique_filtre.txt |
137 |
| Paires fr/br, version bretonne | corpus/num_fr_aligne/wikipedia_br_numerique_filtre_aligne_sur_fr.txt |
137 |
Humanités numériques en français : le mode catégorie
Le premier corpus part simplement de la catégorie « Humanités numériques » de la Wikipédia en français, avec deux niveaux de sous-catégories :
python3 wiki2iramuteq.py --lang fr --categorie "Humanités numériques" --profondeur 2 \
--contact "votre@courriel" --sortie ./corpus/hn_fr
On obtient 45 articles. C’est un petit corpus, mais bien centré sur son sujet : il servira de base à notre étude IRaMuTeQ des humanités numériques.
Pourquoi « le numérique » et pas « les humanités numériques » en breton ?
Nous voulions au départ constituer le même corpus en breton. Mais la catégorie n’existe pas dans la Wikipédia en breton, et le mode articles parallèles ne trouve aucun article de la catégorie française qui ait une version bretonne.
Nous avons donc élargi le thème au numérique en général et utilisé le mode recherche, avec les termes bretons stlenneg (informatique), urzhiataer (ordinateur), kenrouedad / genrouedad (réseau, avec sa mutation), niverel (numérique) et meziant frank / meziantoù frank (logiciel libre / logiciels libres). L’option --marquer fr indique pour chaque article s’il existe aussi en français :
python3 wiki2iramuteq.py --lang br \
--recherche "stlenneg,urzhiataer,kenrouedad,genrouedad,niverel,meziant frank,meziantoù frank" \
--marquer fr --nom numerique --contact "votre@courriel" --sortie ./corpus/num_br
La recherche retient 190 articles. Après filtrage par densité (2 ‰) et révision manuelle, il en reste 156, dont 18 n’ont pas d’équivalent dans la Wikipédia en français. Ce sont ces articles qui nous intéresseront particulièrement dans notre troisième article.
Le numérique en français
Le corpus français équivalent utilise des termes français, et l’option --plafond 500 pour limiter le volume : la Wikipédia en français est beaucoup plus grande. On demande aussi au moins 5 occurrences par article :
python3 wiki2iramuteq.py --lang fr \
--recherche "informatique,ordinateur,internet,numérique,logiciel libre" \
--marquer br --plafond 500 --min-occurrences 5 --nom numerique \
--contact "votre@courriel" --sortie ./corpus/num_fr
Sur 1 266 articles retenus, le filtrage en garde 1 232. Ici, aucune exclusion manuelle n’a été nécessaire, mais 19 articles ont été conservés à la main malgré une densité trop faible, comme « Apprentissage profond », « Android » ou « Règlement général sur la protection des données ».
Les paires français / breton : le mode alignement
Enfin, nous avons aligné le corpus breton filtré sur la Wikipédia en français :
python3 wiki2iramuteq.py --lang fr \
--aligner ./corpus/num_br/wikipedia_br_numerique_filtre_metadonnees.csv \
--recherche "informatique,ordinateur,internet,numérique,logiciel libre" \
--contact "votre@courriel" --sortie ./corpus/num_fr_aligne
Sur les 156 articles bretons, 138 ont un équivalent français. Deux d’entre eux pointent vers le même article français : un doublon est écarté, et on obtient 137 paires. Ici, l’option --recherche ne filtre rien : elle ajoute seulement la variable *terme_ et le décompte des occurrences.
Nous disposons ainsi de deux corpus strictement parallèles, qui permettront de comparer comment un même sujet est traité dans les deux langues.
Wikipédia évoluant en permanence, un nouveau tirage avec ces commandes donnera des résultats proches, mais pas identiques. C’est pourquoi nous publions les corpus eux-mêmes, avec les numéros de révision de chaque article.
Conclusion
Avec deux scripts Python sans dépendance, nous avons vu comment transformer Wikipédia en corpus IRaMuTeQ documentés et reproductibles : sélection des articles par catégorie, par termes ou par alignement entre deux langues, nettoyage des sections annexes, variables étoilées, métadonnées et trace des choix manuels.
Dans notre prochain article, nous verrons comment créer un dictionnaire breton pour IRaMuTeQ à partir des ressources du projet libre Apertium. Il permettra enfin de lemmatiser les corpus bretons. Le troisième article comparera ensuite les sujets qui intéressent les contributeurs des Wikipédia bretonne et française.
Licences et citation
- Code (
*.py) : licence GPL-3.0-or-later. - Corpus : textes issus de Wikipédia, sous licence CC BY-SA 4.0. Les auteurs sont les contributeurs et contributrices de Wikipédia. Toute réutilisation doit citer la source et rester sous la même licence.
Pour citer ce travail : Pierre Rouarch (Anakeyn), wiki2iramuteq : corpus IRaMuTeQ à partir de Wikipédia, 2026, https://github.com/Anakeyn/wiki2iramuteq.
Il ne vous reste plus qu’à ouvrir les corpus dans IRaMuTeQ pour vous faire votre propre opinion.
A bientôt,
Pierre