Analyse lexicale

Il y a une solide différence entre l'analyse lexicale des langages formels, par ex. les langages de programmation, et cette couche dans le domaine des langues naturelles. On commence par l'identification des mots (entités lexicales) avec les caractères de ponctuation [opérateurs, parenthèses, mots-clés, etc.] mais dans une langue naturelle on a des phénomènes de morphologie, la séparation de la racine et les suffixes, la normalisation des mots : le pluriel, les formes verbales, la scission des formes composites, etc. La morphologie "interpole" entre le lexique et la syntaxe.

Nous allons commencer par la séparation du texte et l'identification des entités lexicales : la tokenisation (ou segmentation). C'est une procédure si fréquente, que plusieurs outils ont été élaborés spécialement pour ça.

Tokenisation et expressions régulières

Un texte est normalement bruité. Les séquences d'espaces ont le même sens qu'un espace, ou (pas toujours) une fin de ligne. Mais les mots sont séparés autrement : "l'amour" ou "I've" en sont des exemples. Pour l'identification d'une adresse mail, par ex. "prenom.nom@site.sub.domaine" il faut encore d'autres conventions. Les langages de programmation (Python en particulier) disposent de procédures standard, prédéfinies, mais aussi de paquetages de support spécifique. En Python surtout les modules :
string et re (regexps, expressions régulières [en français aussi : expr. rationnelles]).

Les procédures standard sont implémentées plutôt comme méthodes que comme fonctions.


Le débruitage et la tokenisation des textes humains peuvent être plus compliqués que l'on n'y pense, et pas seulement à sause des séparateurs comme les apostrophes ou les tirets (dans des mots composites). La ponctuation peut être ou ne pas être importante ! Si nous voulons construire une base documentaire (un dictionnaire, un document indexé par des mots cherchables), alors les virgules, points, interrogations, etc. ne jouent aucun rôle.

Mais si on veut faire une analyse fréquentielle, la distribution des n-grammes au niveau des mots, afin de pouvoir faire des phrases synthétiques, les marqueurs de fin de phrase (au moins le point), et les séparateurs de segments dans les phrases : les virgules – deviennent utiles, constituent des "mots". Le rôle des apostrophes peut aussi être ambigu (guillemet, séparateur avec l'élision : "l'élision", ou lien dans des formes composées : "aujourd'hui". Bref, l'analyse lexicale, encore à la phase typographique, loin des raisonnements structurels, pose déjà quelques problèmes, et son automatisation n'est pas si facile.

Nous serons modestes, parlerons des techniques de tokenisation de base, surtout à l'aide des expressions régulières qui décrivent des classes des mots respectant quelques contraintes. Ce que vous apprenez ici, vous servira également dans le cours de compilation en L3, et dans plusieurs projets de programmation.

Regexps, définition et usage de base

Une expression régulière (ou rationnelle) ou un motif régulier est une chaîne qui décrit et permet la reconnaissance des classes de chaînes textuelles. Le plus simple est une chaîne simple, par ex. Belle marquise, qui représente la chaîne Belle marquise et rien d'autre. [Dans le programme, parfois dans le texte nous mettrons ces chaînes entre guillements afin d'éviter des ambiguïtés, mais les délimiteurs n'en font pas partie].

Afin qu'un motif puisse représenter une classe (plus d'un élément), on place dedans quelques métacaractères : caractères spéciaux, qui symbolisent d'autres choses que les caractères eux-mêmes. Parfois ces métacaractères sont des "séquences d'échappement", par ex. \s ou \t, avec quelques lettres ayant un sens spécial seulement si préfixées par le backslash \ . Voici quelques métacaractères et séquences :

Avant de continuer, attention ! Le "langage" formel des patterns réguliers peut être un peu différent des patterns figurant dans les programmes dans des langages de programmation ! (Python, Javascript, PHP ...). Quelques autres regexps.

Il nous faudra parler de l'usage des regexps dans les programmes. Python demande l'importation du module re. Ensuite il offre un certain nombre de procédures et de méthodes permettant de chercher et séparer les sous-chaînes conformes, et de les remplacer par d'autres. Les procédures seront préfixés par re. Les motifs peuvent figurer comme les chaînes avec les métacaractères, mais ils peuvent aussi être compilés vers des structures binaires, qui pilotent un module interne : un "automate de reconnaissance", qui est beaucoup plus efficace qu'un comparateur de chaînes. La procédure re.compile(pat, flags=0) retourne le motif compilé, disposant de plusieurs méthodes plus riches qu'une simple comparaison de chaînes.

Voici quelques utilités de base. Nos descriptions ne peuvent être complètes.

Les méthodes des motifs compilés, et les groupes

Le résultat rex de re.compile(...) est un objet d'une classe spéciale, disposant de quelques méthodes. En particulier rex.search(chn[, pos[, endpos]]) (aussi : rex.match(...)) généralisent la procédure décrite ci-dessus.

Nous avons aussi rex.findall(...), rex.split(chn, maxsplit=0) qui scinde la chaîne en utilisant le motif comme séparateur, etc.


Les parenthèses dans [la source d'] un motif : "xxx(yyy)zzz(www)uuu" ont plus d'utilité que de grouper les sous-expressions répétées ou alternatives.

Chaque groupe "...(...) ..." stocke l'appariement correspondant comme un "groupe" au sens technique, qui peut ensuite être enquêté et réutilisé. Par exemple, si la chaîne est tst=" avant #contenu# après", et le motif :
dl = re.compile("#(\w+)#"), l'appariement complet trouve "#contenu#". Mais après l'exécution de mtch=dl.search(tst), on vérifie : mtch.group(1)'contenu'. On peut choisir d'autre groupe si existant, et ..groups() repertorie tous les groupes. Le motif : "([*|#+])(\w+)\\1" trouvera le texte délimité comme mtch.group(2). Le premier choisit le délimiteur, qui doit se répéter. On peut trouver donc #contenu#, *contenu*, etc. Bien sûr, mtch est un match-object.

La nécessité de dédoubler le backslash est pénible. Dans le motif il n'est pas un caractère d'échappement, mais un symbole. Si on veut éviter cela, on utilise les chaînes "crues" (raw strings) : r"([*|#+])(\w+)\1".

La réutilisation des groupes dans les motifs permet d'analyser l'information contextuelle. D'autres mécanismes seront décrits et testés en TP. Mais nous avons vu que .group(n), groups() ce sont des méthodes spécifiques des objets appariements. .group(0) identifie la totalité de la chaîne reconnue.

Plusieurs autres méthodes existent, on ne peut pas les décrire ici, mais quelques unes doivent être mentionnées.

mtch.start(groupe) retourne l'indice du début de la chaîne appariée avec le groupe portant ce numéro. On a aussi .end, .span, .pos et .endpos : voir la doc.

Dans quelques circonstances nous voulons grouper les éléments dans une regexp, mais sans vouloir "attraper" le groupe. Ceci en Python est codé par " ... (?: ....) ...". Si on remplace ":" par d'autres caractères, on construit des extensions spécifiques, qui ne seront pas décrites.

Exemple : écrivons un motif permettant de reconnaître des nombres réels, comme 1.2, -3e-12, +0.9e10, etc., dont la spécification est la suivante :

nombre="[+-]?\d+((\.\d+)?[eE][+-]?\d+|\.\d+)"

L'exposant peut être présent avec ou sans la partie fractionnelle, mais si cette variante échoue, la partie fractionnelle est obligatoirement présente. La solution n'est pas optimale, car le point et les chiffres qui suivent peuvent être scannés deux fois ! Il faut savoir optimiser / factoriser de telles expressions. Pire, la solution n'est pas suffisamment sélective, et accepte des textes mauvais. Si :

tst="A 1.2 -6.0e+11 et 12. ou .2e5 :-21E-05 +123.87E+32 fin"
nn=re.compile(nombre)
mtc=re.search(nn,tst)
p=nn.finditer(tst)
for k in p: print(k.group(0))
nous aurons :
1.2
-6.0e+11
2e5
-21E-05
+123.87E+32
Le fragment ".2e5" sans le point décimal a été accepté. Afin de l'éliminer, il faut refuser un nombre (séquence de chiffres) qui commence par le point. Essayez de résoudre ce problème. Souvent un pattern spécial : "(?=...)" s'avère utile. C'est un groupe qui s'apparie avec "...", mais ne consomme aucun caractère, c'est pour vérifier que le motif se trouve dans le texte, avant de passer au scanning, et pouvoir avant prendre quelques décisions par le programme (par ex., concernant la construction des regexps utiles ultérieurement).

En cours de langages et compilation, on apprend que les expressions régulières sont équivalents aux automates finis, des structures formelles possédant un état interne, et consommant des données d'un flot d'entrée. Leur utilité dépasse des questions linguistiques, donc nous n'allons pas en parler.
(Au moins en cours. En TP on verra...)

Cependant c'est un domaine très utile et important. Les regexps compilés en Python sont de tels automates (ou les graphes – objets Python qui pilotent les automates de reconnaissance.)

La plupart d'éditeurs de texte modernes disposent d'utilités capables d'effectuer les recherches et les remplacements basés sur les regexps. Cependant, c'est à l'utilisateur de les définir, selon ses besoins. Il est dans votre intérêt vous entraîner.

Expressions régulières et grammaires

Une grande partie de la section consacrée à l'analyse syntaxique décrit le concept et l'usage de grammaires génératrices de Chomsky, nous y reviendrons. Cependant, il est utile de savoir que la représentation régulière, genre (a|b)+ n'est pas la seule possible. La forme a* signifie : rien, une occurrence de a, deux : aa, ensuite aaa, aaaa etc. Nous pouvons définir une telle expression, par ex. (a|b)* par une forme récursive :
aoub --> [a] | [b].
star --> [] | star, aoub. % ou : --> [] | aoub,star.
La convention est que les symboles dans des crochets signifient eux-mêmes, ce sont des constantes. Les autres sont des variables. La virgule est la concaténation, la barre : alternative.

à présent attention !!

Ceci n'est pas une forme uniquement pour vos beaux yeux. C'est un programme exécutable, un code DCG (Definite Clause Grammar) en langage Prolog [Progs/dcg0.pl]. (Implémentation SWI-Prolog. Un jour vous en aurez besoin).

Les formes DCG ici servent – uniquement pour simplicité – à engendrer les séquences plutôt qu'à les reconnaître, mais en général DCG sont utilisées assez fréquemment pour la recherche / reconnaissance. Exercice, prouver que la forme ci-dessous engendre le langage $a^n b^n$. Est-ce une expression régulière?...

s --> [a], [b].
s --> [a], s, [b].

On déclenche la génération par : phrase(star,R). Ou phrase(s,Qchose)., etc., et on obtient la réponse. Mais Prolog permet également de lui fournir la réponse (hypothétique, peut-être partielle), et il confirmera sa validité ou non.
phrase(s,[a,a,a,X,b|Rst]).

  % Réponses du système
X = b,
Rst = [b] ;
X = a,
Rst = [b, b, b] ;
false.  % Plus rien...

Si on lance phrase(GramClause,chaîne,Var)., la Variable prolog Var est instanciée à la chaîne restante, après la reconnaissance, et Prolog l'affiche ou utilise, permettant d'autres manipulations.

Le reste de l'introduction, ainsi que quelques exercices, se trouvent sur la page des TP du 12.10. Mais le sujet sera repris en cours plus tard !