Boîte à Outils 3

Extraction des patrons morphosyntaxiques
et relations de dépendances

Image

ETAPES :
1) Extraire les patrons morphosyntaxiques grâce à l'étiquetage réalisé par TreeTagger dans la BAO2
2) Extraire les relations de dépendances grâce à l'étiquetage par UDPipe dans la BAO3

EN ENTREE :
Les sortie de la BAO2 : le fichier TreeTagger pour les patrons, le fichier UDPipe pour les relations.

EN SORTIE :
Pour les patrons morphosyntaxiques : les patrons "ADJ NOM", "NOM ADJ", "NOM PRP NOM PRP", "VER DET NOM", "NUM NOM", "VER PRP" ont été testés sur la rubrique "A la une" (3208) en Python. Pour les relations de dépendances : la relation "obj" a été testée en Perl. Cependant, les versions du script dans les deux langages ont été réalisés (avec quelques différences mineures).
Deux exemples de sortie avec les patron "ADJ NOM" et "VER-DET-NOM sont consultables ci-dessous. Il est néanmoins important de noter que n'importe quelle sortie avec une autre rubrique, un autre patron ou une autre relation peut être réalisée grâce à ces scripts et les boites à outils précédentes. De plus, ces extractions de patrons et de relation sont également réalisables avec des feuilles de style XSL et des requêtes XQuery (voir Exercice 14 du site Document Structuré).

Langage Nom de fichier Téléchargement
Python python-extract_patron-ADJ-NOM-3208.txt extraction_ADJ-NOM-3208.txt
Python python-extract_patron-VER-DET-NOM-3208.txt extraction_VER-DET-NOM-3208.txt

SCRIPTS :
Vous pouvez directement télécharger les scripts commentés de la BAO3 ci-dessous.

Langage Ligne de commande Téléchargement
Perl perl BAO3-patrons.pl fichier_treetagger.xml patron BAO3-patrons.pl
Python python3 BAO3-patrons.py fichier_treetagger.xml patron(s)_separateur_tiret BAO3-patrons.py
Perl perl BAO3-relations.pl fichier_udpipe.xml relation BAO3-relations.pl
Python python3 BAO3-relations.py fichier_udpipe.xml relation BAO3-relations.py

PERL : Patrons morpho-syntaxiques

Etape 1/3 : Etape préliminaire
On s'assure que les éléments du patron passés en argument sont dans notre liste de possibilités, on récupère la rubrique et on crée également une chaine de caractères contenant le patron complet (ex: ADJ-NOM) pour le nommage des fichiers.

#!/usr/bin/perl
#-----------------------------------------------------------
# Ligne de commande : perl BAO3-patrons.pl fichier_treetagger.xml patron(s)_separateur_tiret
# Attention au chemin ! Le fichier résultat de treetagger fichier_treetagger.xml se trouve dans le dossier BAO2
#---------------------------------------------------------
# On précise l'encodage de sortie en UTF-8
use utf8;
binmode STDOUT,":utf8";
#---------------------------------------------------------
# On prend le premier argument en tant que fichier à traiter
# Le reste des arguments (correspondant au patron) est stocké dans une liste
my $fichieratraiter=shift @ARGV;
my @PATRON=@ARGV;
#---------------------------------------------------------
my @PATRON_TT = ("ABR", "ADJ", "ADV", "DET", "DET:ART", "DET:POS", "INT", "KON", "NAM", "NOM", "NUM", 
"PRO", "PRO:DEM", "PRO:IND", "PRO:PER", "PRO:POS", "PRO:REL", "PRP", "PRP:det", "PUN", "PUN:cit", "SENT", 
"SYM", "VER", "VER:cond", "VER:futu", "VER:impe", "VER:impf", "VER:infi", "VER:pper", "VER:ppre", "VER:pres", 
"VER:simp", "VER:subi", "VER:subp");
# On s'assure que les patrons font bien partis de la liste
# Et on affiche la liste si besoin
for $pos (@PATRON) {
	if ( !grep( /^$pos$/, @PATRON_TT ) ) {
		print "Le ou les patrons n'ont pas été reconnu(s)\n";
		print "Voir la liste des POS possibles ? \nTapez 'oui', ou n'importe quelle touche pour sortir ";
		# <STDIN> permet de récupérer la réponse de l'utilisateur dans le terminal
		my $voir_liste = <STDIN>;
		# chomp permet d'enlever la ligne de réponse de l'utilisateur
		chomp $voir_liste;
		if ($voir_liste eq "oui") {
			print "@PATRON_TT\n";
			exit;
		}
		exit;	
	}
}
#---------------------------------------------------------
# On récupère la rubrique et le patron complet qui nous servira aux noms des fichiers de sortie
# Rq : la longueur du patron '$#PATRON' n'est pas égal à longueur du patron donnée par 'scalar @PATRON' 
# En réalité, $#PATRON = scalar @PATRON -1, ici $#PATRON est utilisé car notre indice pour le foreach commence à 0
my $rubrique = "";
if ($fichier=~/.*?-(\d+)\.xml/){
	$rubrique = $1;
}
$patron="";
$i=0;
foreach (@PATRON) {
	if ($i != $#PATRON){
		$patron.="$PATRON[$i]-";
	}
	else {
		$patron.="$PATRON[$i]";
	}
	$i++;
}
#print $patron;

Etape 2/3 : La recherche des patrons
La stratégie adoptée a été de lire le fichier ligne par ligne, en enlevant la ligne lue de la liste au fur et à mesure. Le match des éléments du patrons et la capture des formes sont réalisés grâce aux expressions régulières déjà intégrés dans Perl.

# On ouvre le fichier à traiter et on stocke le contenu dans une liste
open my $input, "<:encoding(utf-8)", $fichieratraiter;
my @CONTENU=<$input>;
close($input);
# On lit la liste @CONTENU en la vidant : tant qu'il y a encore du contenu, on continue la lecture
# une fois que la liste sera vide, cela signifiera que tout le contenu aura été lu

while (my $ligne=shift @CONTENU){
	my $chaine="";
	my $indice=1;
	# si le premier élément/POS du patron ($PATRON[0]) a été matché
	if ($ligne=~/<element><data type="type">$PATRON[0]<\/data><data type="lemma">[^<]+?<\/data><data type="string">([^<]+?)<\/data><\/element>/) {
		# On incrémente la chaine avec la forme que l'on a trouvé et on utilise une variable pour voir la longueur du patron qu'on va pouvoir atteindre
		$chaine.=$1;
		my $longueur=1;
		# et il faut que je continue la lecture pour voir si la suite matche également avec le reste du patron
		while ($CONTENU[$indice-1]=~/<element><data type="type">$PATRON[$indice]<\/data><data type="lemma">[^<]+?<\/data><data type="string">([^<]+?)<\/data><\/element>/){
			$indice++;
			$longueur++;
			$chaine.=" ".$1;
		}
		# Si tout le patron complet a été matché, alors on garde la chaine
		if (scalar @PATRON == $longueur) {
			#print "$chaine\n";
			$dic_Patron{$chaine}++;
			$nbTerme++;
		}
	}
}

Etape 3/3 : Ecriture des éléments trouvés
Les séquences trouvées avec le patron soumis sont écrites dans un fichier contenant dans son nom, la rubrique et le patron correspondants. Ces données sont triées par ordre décroissant grâce au dictionnaire créé en amont, qui stockait les séquences du patron trouvées tout en les comptant.


# On ouvre et écrit dans notre fichier de résultat

open my $resultat,">:encoding(UTF-8)","perl-extract_patron-$rubrique-$patron.txt";

# On écrit le nombre de chaines trouvés selon le patron demandé
print $resultat "$nbTerme éléments trouvés\n";

# On trie notre dictionnaire selon les fréquences
# Pour chaque chaine du dictionnaire, on écrire la clé suivie de son nombre d'occurrences dans le texte
foreach my $patron (sort {$dic_Patron{$b} <=> $dic_Patron{$a} } keys %dic_Patron) {
	print $resultat "$dic_Patron{$patron}\t$patron\n";
}
close($resultat);
move("perl-extract_patron-$rubrique-$patron.txt", "BAO3") or die "Erreur $!";
#---------------------------------------------------------

PYTHON : Patrons morpho-syntaxiques

Note : 4 méthodes différentes ont été réalisées, seule la 3ème sera commentée ici

Différence majeure avec le script Perl 1/3 : méthodes chronométrés
L'étape préliminaire du script est la même qu'en perl (patron valide, chaine contenant le nom du patron séparé par des tirets pour le nommage du fichier...), à la seule différence que les bibliothèques doivent être importées. La bibliothèque "time" notamment permet d'évaluer le temps d'exécution de chaque méthode (voir plus de commentaires dans le script à télécharger, avec notamment une zone de test qui permet de voir les différences de temps entre les méthodes)

#!/usr/bin/python3
#-----------------------------------------------------------
# Ligne de commande : python3 BAO3-patrons.py fichier_treetagger.xml patron(s)_separateur_tiret
# Attention au chemin ! Le fichier résultat de treetagger fichier_treetagger.xml se trouve dans le dossier BAO2
###################################################################################
# Importation des bibliothèques dont on a besoin
from typing import List
import re
import sys
from pathlib import Path
import shutil, os
from os.path import exists
import time
start_time = time.time()   
....
# Comme en perl, on teste si le patron est valide
patron_TT = ["ABR", "ADJ", "ADV", "DET", "DET:ART", "DET:POS", "INT", "KON", "NAM", "NOM", "NUM", "PRO", "PRO:DEM", "PRO:IND", 
"PRO:PER", "PRO:POS", "PRO:REL", "PRP", "PRP:det", "PUN", "PUN:cit", "SENT", "SYM", "VER", "VER:cond", "VER:futu", "VER:impe", 
"VER:impf", "VER:infi", "VER:pper", "VER:ppre", "VER:pres", "VER:simp", "VER:subi", "VER:subp"];
def TestPatron(patron): 
    for tag in patron:
        if tag not in patron_TT:
            return False
    return True
#---------------------------------------------------------
# Et on récupère un patron avec des tags séparés de tirets pour le fichier de sortie
def Patron_avec_tirets(patron):
    chaine = ""
    # Si le patron est valide
    if TestPatron(patron):
        # On incrémente la chaine avec les tags du patron jusqu'au dernier
        for i in range(len(patron)-1):
            chaine += patron[i] + "-"
        # Le dernier est ajouté sans tiret    
        chaine += patron[-1]
        return chaine
    return "erreur"

Différence majeure avec le script Perl 2/3 : utilisation d'un buffer
On utilise une fenêtre glissante (buffer) pour lire le fichier par bloc de lignes (à la taille du patron) permettant ainsi d'avoir une meilleure rapidité d'exécution (et donc d'être moins coûteux). On récupère dans le buffer la forme et le tag (=POS) du bloc de lignes et on le compare au patron. S'il est identique (ou presque, par ex si on a un patron avec VER et comme tag VER:cond) alors on garde la séquence. Puis on passe à la prochaine ligne, et ainsi de suite jusqu'à atteindre la fin du document, on s'arrête dès que la longueur du patron dépasse celle des lignes restantes du document (le patron ne pourra plus être trouvé car il n'y a plus assez de lignes).

# 3ème méthode on s'appuie sur la méthode du buffer de la méthode précédente
def extract3(corpus_file: str, patron: List[str]) :
#chemin du fichier corpus_file
    # Comme la 2ème méthode, on initialise notre buffer
    # Mais cette fois-ci avec deux emplacements "---" pour chaque tag du patron
    buf = [("---", "---")] * len(patron)
    # On ouvre notre corpus
    with open(corpus_file) as corpus : 
        # Pour chaque ligne du corpus
        for line in corpus :
            # On enlève le premier couple de notre liste
            buf.pop(0)
            # On crée notre match qui va capturer le tag et la forme présents dans la ligne
            match = re.match(f'<element><data type="type">([^<]+?)</data><data type="lemma">[^<]+?</data><data type="string">([^<]+?)</data></element>', line)
            # Si on a bien matché la ligne contenant le tag et la forme
            # On ajoute le couple dans le buffer et on incrémente la chaine avec la forme
            if match :
                tag = match.group(1)
                forme = match.group(2)
                buf.append((tag,forme))
            # Si on a pas pu matcher la ligne
            # Cela signifie que l'on est soit en début soit en fin de corpus, soit que l'on change de phrase
            # Dans ces cas là, il faut réinitialiser le buffer à zéro (avant d'entamer la prochaine phrase notamment)
            else :
                buf = [("---", "---")] * len(patron)
                 # break
            # On se base sur le même modèle que précédemment avec une légère variation
            chaine = ""
            ok = True
            for i, gat in enumerate(patron) :
                # Ici, si le premier élément du couple (tag, forme) est égal au tag correspondant du patron demandé
                # Attention, on veut également matcher les tags comme VER qui se trouvent sous la forme VER:cond
                # d'où le match et non le "=" strict
                if re.match(gat, buf[i][0]):
                    chaine += buf[i][1] + " "
                else:
                    ok = False
            if ok :
                global f
                f.write(chaine + "\n")    

Différence majeure avec le script Perl 3/3 : liste de patrons en argument
Afin d'automatiser encore plus les patrons passés en argument, une fonction a été crée pour découper les patrons (délimiteur/séparateur : le tiret). Pour cela, une liste de listes a été crée permettant dans la fonction principale main() d'appeller la fonction d'extraction sur chaque patron de la grande liste.

# Fonction afin d'automatiser les patrons demandés
# On va créer une grande liste de patrons contenant des patrons (qui sont eux-mêmes des listes contenant les tags)
def DecoupPatrons(liste_patrons: List[str]):
    grande_liste=[]
    petite_liste=[]
    for e in liste_patrons:
        # On ajoute la petite liste dès qu'on trouve un séparateur et on la vide
        if e == "-":
            grande_liste.append(petite_liste)
            petite_liste=[]
            continue
        else :
           petite_liste.append(e)
    # On n'oublie pas le dernier patron qui lui n'a pas de séparateur à la fin
    # avant de return la liste de liste (grande_liste) crée 
    grande_liste.append(petite_liste)
    return grande_liste
#---------------------------------------------------------
# Programme principal                
if __name__== "__main__":
    # On stocke les arguments passés au programme comme suit :
    # Le premier argument sera notre corpus, d'où on récupère la rubrique comme pour perl
    # Tout le reste des arguments sera notre patron
    corpus_file = sys.argv[1]
    p_rubrique = re.search("-(\d+).xml", corpus_file)
    rubrique = p_rubrique.group(1)
    patrons = sys.argv[2:]
	###################################
    # Zone de test, à commenter/décommenter pour tester les différentes méthodes pour comparer le temps d'exécution
	...
	###################################
    for patron in DecoupPatrons(patrons):
        if Patron_avec_tirets(patron) != "erreur" :
            fichier = f"python-extract_patron-{rubrique}-{Patron_avec_tirets(patron)}.txt"
            # Si le fichier a déjà été créé, on le supprime afin de laisser place au nouveau
            if os.path.exists(f"./BAO3/{fichier}"):
                os.remove(f"./BAO3/{fichier}")
            with open(fichier, "w", encoding="utf-8") as f:
                extract3(corpus_file, patron)
            shutil.move(fichier, "BAO3")
        else : 
            print("Attention, il y a eu au moins une erreur dans les patrons donnés. Seuls les patrons corrects ont été réalisés.")

PERL : Relations de dépendances

Etape 1/3 : Etape préliminaire
L'étape préliminaire s'occupe de prendre les arguments passés au programme et de récupérer la rubrique traitée. On initialise également notre dictionnaire de comptage des relations et on découpe notre texte selon la balise </p>.

#!/usr/bin/perl
#----------------------------------------------------------------------------------
# Ligne de commande : perl BAO3-relations.pl fichier_udpipe.xml relation
# Attention au chemin ! Le fichier résultat de udpipe fichier_udpipe.xml se trouve dans le dossier BAO2
# En entrée : sortie UDPIPE formatée en XML + une relation syntaxique
# En sortie la liste triée des couples (gouverneur, dépendant) en relation
#----------------------------------------------------------------------------------
use strict;
use utf8;
binmode STDOUT, ':utf8';
#-------------------------------------------------------------------------------------
my $fichier="$ARGV[0]";
my $rubrique = "";
if ($fichier=~/.*?-(\d+)\.udpipe\.xml$/){
	$rubrique = $1;
}
my $relation="$ARGV[1]";
my %dicoRelation=();
# on découpe le texte par phrase (liste d'items annotés et potentiellement dépendants)
$/="</p>"; 	

Etape 2/3 : La recherche des éléments en relation de dépendance
Le dépendant de la relation aura dans sa ligne, la relation demandée (ex: obj) et l'identifiant de son gouverneur, dont on doit aller chercher sa forme, soit avant, soit après cet élément. Le script ici permet donc de comparer l'identifiant du dépendant trouvé (contenant la relation étudiée) avec celui de son gouverneur, afin d'aller chercher sa forme selon son emplacement.

open my $IN ,"<:encoding(utf8)",$fichier;
while (my $phrase=<$IN>) {
	# on traite chaque "paragraphe" (balises <p>) en le découpant par "items"
	my @LIGNES=split(/\n/,$phrase);
	for (my $i=0;$i<=$#LIGNES;$i++) {
		# si la ligne lue contient la relation, on ira chercher le dépendant puis le gouverneur
		if ($LIGNES[$i]=~/<item><a>([^<]+)<\/a><a>([^<]+)<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>([^<]+)<\/a><a>[^<]*$relation[^<]*<\/a><a>[^<]+<\/a><a>[^<]+<\/a><\/item>/i) {
			my $posDep=$1;
			my $posGouv=$3;
			my $formeDep=$2;
			# soit le gouverneur est avant le dépendant, soit après
			# on fait donc les deux cas de figure :
			# si le gouverneur est avant le dépendant, on parcourt les lignes de 0 à l'endroit où on a trouvé le dépendant (exclu)
			if ($posGouv < $posDep) {
				for (my $k=0;$k<$i;$k++) {
					# On cherche la ligne du gouverneur selon son identifiant et on capture sa forme
					if ($LIGNES[$k]=~/<item><a>$posGouv<\/a><a>([^<]+)<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><\/item>/) {
						my $formeGouv=$1;
						# On rajoute dans notre dictionnaire le couple gouverneur et dépendant trouvé
						$dicoRelation{"$formeGouv $formeDep"}++;
					}
				}
			}
			# si le gouverneur est après le dépendant alors on commence à la ligne suivante jusqu'à la dernière ligne de la phrase
			else {
				for (my $k=$i+1;$k<=$#LIGNES;$k++) {
					if ($LIGNES[$k]=~/<item><a>$posGouv<\/a><a>([^<]+)<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><a>[^<]+<\/a><\/item>/) {
						my $formeGouv=$1;
						# On rajoute dans notre dictionnaire le couple gouverneur et dépendant trouvé
						$dicoRelation{"$formeGouv $formeDep"}++;
					}
				}
			}
		}
	}
}
close ($IN);

Etape 3/3 : Ecriture des éléments trouvés
De la même manière que pour les patrons, on écrit les séquences trouvées avec la relation demandée dans un fichier contenant dans son nom, la rubrique et la relation correspondantes. Ces données sont également triées par ordre décroissant grâce au dictionnaire, d'après le même principe.

# On ouvre et écrit dans notre fichier de résultat avant de le déplacer
open my $resultat,">:encoding(UTF-8)","perl-extract_relation-$rubrique-$relation.txt";
# On imprime la liste des couples gouverneurs et dépendants et selon leur fréquence, triée par ordre décroissant
foreach my $relation (sort {$dicoRelation{$b}<=>$dicoRelation{$a}} (keys %dicoRelation)) {
	print $resultat "$relation\t$dicoRelation{$relation}\n";
}
close($resultat);
move("perl-extract_relation-$rubrique-$relation.txt", "BAO3") or die "Erreur $!";
#---------------------------------------------------------

PYTHON : Relations de dépendances

Note : Ce script formate les données d'une façon particulière car ce dernier va servir à la BAO4 qui n'aura que quelques différences mineures avec le script ci-dessous.

Différence majeure avec le script Perl 1/3 : Fonction de nettoyage
Comme énoncé plus haut, une fonction de nettoyage a été rajoutée pour la préparation de la BAO4 car celui-ci aura besoin de données les plus uniformes possibles.

# Fonction de nettoyage
def clean(s: str) :
    s=re.sub("[^\w]", "", s)
    s=re.sub("’","'", s)
    s=re.sub(",","",s)
    s=re.sub("@","", s)
    s=re.sub("ᵉ","", s)
    s=re.sub("°","", s)
    return s

Différence majeure avec le script Perl 2/3 : utilisation d'un buffer, set des couples trouvés...
On utilise un buffer qui stocke tous les identifiants et formes de chaque token de la phrase, avant de se vider pour accueillir la prochaine. Chaque couple est ajouté à un set, qui par défaut, ne conservera que des couples uniques. Une syntaxe intéressante peut être relevée pour Python, qui n'est pas réalisable en Perl :
fields = re.findall("<a>([^<]+)</a>", line)
idx, word, lemma, tag, _, _, head, rel, _, _ = fields
En Python, cette syntaxe qui (a l'avantage d'être compacte) est bien valide : fields contenant exactement 8 éléments, chaque élément sera mis dans sa variable correspondante.

def Couple(f, relation):
    # On initialise les variables dont on va avoir besoin
    # Un buffer sous forme de dictionnaire pour stocker les identifiants et lemme de chaque token (réinitialisé à chaque phrase)
    sent_buf = {} 
    # Un buffer sous forme de liste pour stocker des tuples contenant le lemme du dépendant et l'identifiant de son gouverneur
    obj_buf = []
    # Trois sets pour avoir une liste sans doublons des couples, gouverneurs et dépendants
    couples = set()
    gouvernors = set()
    deps = set()
    # Pour chaque ligne
    for line in Path(f).read_text().split("\n"):
        # Lorsqu'on tombe sur un item, on cherche tous les éléments avec balises <a>
        # On en trouve normalement 8 qu'on met dans les différentes variables
        # Ceux qui ne nous sont pas utiles sont stockés dans la variable "_"
        if line.startswith("<item>"):
            fields = re.findall("<a>([^<]+)</a>", line)
            idx, word, lemma, tag, _, _, head, rel, _, _ = fields
            lemma = clean(lemma)
            # On stocke chaque lemme dans le dictionnaire sent_buf avec comme clé son identifiant
            sent_buf[idx] = lemma
            # Si la relation est obj, on ajoute le lemme et le gouverneur sous forme de tuple dans la liste obj_buf
            if rel == relation:
                obj_buf.append((lemma, head))
        # Si on arrive à la fin d'une phrase (signalé avec la balise fermante "</p>")
        if line == "</p>":
            # Pour chaque lemme et gouverneur dans la liste obj_buf
            for dep_lemma, head in obj_buf:
                # print(f'{sent_buf[head]}, "--[{relation}]-->", {dep_lemma}')
                # On ajoute le lemme du gouverneur et du dépendant en tant que couple
                # Le lemme du gouverneur ayant été trouvé grâce au numéro "head" (identifiant du gouverneur)
                # qui sert de clé pour trouver le lemme associé dans le dictionnaire sent_buf
                couples.add((f"{sent_buf[head]}", f"{dep_lemma}"))
                # On ajoute le gouverneur dans le set des gouverneurs
                # On ajoute le dépendant dans le set des dépendants
                gouvernors.add(sent_buf[head])
                deps.add(dep_lemma)
            # On vide nos buffers pour la phrase suivante
            obj_buf = []
            sent_buf = {}
    return couples

Différence majeure avec le script Perl 3/3 : affichage des couples
On affiche dans le terminal les couples trouvés, sans doublons. (Rq : On aurait pu très bien les écrire dans un fichier comme en Perl).

# Programme principal                
if __name__== "__main__":
    fichier = sys.argv[1]
    relation = sys.argv[2]
    print(Couple(fichier,relation))
#-------------------------------------------------------------------------