Maîtriser les expressions régulières

Download nbviewer Onyxia
Binder Open In Colab githubdev

Introduction

Python offre énormément de fonctionalités très pratiques pour la manipulation de données textuelles. C’est l’une des raisons de son succès dans la communauté du traitement automatisé du langage (NLP, voir partie dédiée).

Dans les chapitres précédents, nous avons parfois été amenés à chercher des éléments textuels basiques. Cela était possible avec la méthode str.find du package Pandas qui constitue une version vectorisée de la méthode find de base. Nous avons d’ailleurs pu utiliser cette dernière directement, notamment lorsqu’on a fait du webscraping.

Cependant, cette fonction de recherche trouve rapidement ses limites. Par exemple, si on désire trouver à la fois les occurrences d’un terme au singulier et au pluriel, il sera nécessaire d’utiliser au moins deux fois la méthode find. Pour des verbes conjugués, cela devient encore plus complexe, en particulier si ceux-ci changent de forme selon le sujet.

Pour des expressions compliquées, il est conseillé d’utiliser les expressions régulières, ou “regex”. C’est une fonctionnalité qu’on retrouve dans beaucoup de langages. C’est une forme de grammaire qui permet de rechercher des expressions.

Une partie du contenu de cette partie est une adaptation de la documentation collaborative sur R nommée utilitR à laquelle j’ai participé. Ce chapitre reprend aussi du contenu du livre R for Data Science qui présente un chapitre très pédagogique sur les regex.

Nous allons utiliser le package re pour illustrer nos exemples d’expressions régulières. Il s’agit du package de référence, qui est utilisé, en arrière-plan, par Pandas pour vectoriser les recherches textuelles.

import re
import pandas as pd

Principe

Les expressions régulières sont un outil permettant de décrire un ensemble de chaînes de caractères possibles selon une syntaxe précise, et donc de définir un motif (ou pattern). Les expressions régulières servent par exemple lorsqu’on veut extraire une partie d’une chaîne de caractères, ou remplacer une partie d’une chaîne de caractères. Une expression régulière prend la forme d’une chaîne de caractères, qui peut contenir à la fois des éléments littéraux et des caractères spéciaux qui ont un sens logique.

Par exemple, "ch.+n" est une expression régulière qui décrit le motif suivant: la chaîne littérale ch, suivi de n’importe quelle chaîne d’au moins un caractère (.+), suivie de la lettre n. Dans la chaîne "J'ai un chien.", la sous-chaîne "chien" correspond à ce motif. De même pour "chapeau ron" dans "J'ai un chapeau rond". En revanche, dans la chaîne "La soupe est chaude.", aucune sous-chaîne ne correpsond à ce motif (car aucun n n’apparaît après le ch).

Pour s’en convaincre, nous pouvons déjà regarder les deux premiers cas:

pattern = "ch.+n"
print(re.search(pattern, "J'ai un chien."))
print(re.search(pattern, "J'ai un chapeau rond."))
<re.Match object; span=(8, 13), match='chien'>
<re.Match object; span=(8, 19), match='chapeau ron'>

Cependant, dans le dernier cas, nous ne trouvons pas le pattern recherché:

print(re.search(pattern, "La soupe est chaude."))
None

La regex précédente comportait deux types de caractères:

  • les caractères littéraux: lettres et nombres qui sont reconnus de manière littérale
  • les méta-caractères: symboles qui ont un sens particulier dans les regex.

Les principaux méta-caractères sont ., +, *, [, ], ^ et $ mais il en existe beaucoup d’autres. Parmi cet ensemble, on utilise principalement les quantifieurs (., +, *…), les classes de caractères (ensemble qui sont délimités par [ et ]) ou les ancres (^, $…)

Dans l’exemple précédent, nous retrouvions deux quantifieurs accolés .+. Le premier (.) signifie n’importe quel caractère1. Le deuxième (+) signifie “répète le pattern précédent”. Dans notre cas, la combinaison .+ permet ainsi de répéter n’importe quel caractère avant de trouver un n. Le nombre de fois est indeterminé: cela peut ne pas être pas nécessaire d’intercaler des caractères avant le n ou cela peut être nécessaire d’en intercepter plusieurs:

print(re.search(pattern, "J'ai un chino"))
print(re.search(pattern, "J'ai un chiot très mignon."))
<re.Match object; span=(8, 12), match='chin'>
<re.Match object; span=(8, 25), match='chiot très mignon'>

Classes de caractères

Lors d’une recherche, on s’intéresse aux caractères et souvent aux classes de caractères : on cherche un chiffre, une lettre, un caractère dans un ensemble précis ou un caractère qui n’appartient pas à un ensemble précis. Certains ensembles sont prédéfinis, d’autres doivent être définis à l’aide de crochets.

Pour définir un ensemble de caractères, il faut écrire cet ensemble entre crochets. Par exemple, [0123456789] désigne un chiffre. Comme c’est une séquence de caractères consécutifs, on peut résumer cette écriture en [0-9].

Par exemple, si on désire trouver tous les pattern qui commencent par un c suivi d’un h puis d’une voyelle (a, e, i, o, u), on peut essayer cette expression régulière.

re.findall("[c][h][aeiou]", "chat, chien, veau, vache, chèvre")
['cha', 'chi', 'che']

Il serait plus pratique d’utiliser Pandas dans ce cas pour isoler les lignes qui répondent à la condition logique (en ajoutant les accents qui ne sont pas compris sinon):

import pandas as pd
txt = pd.Series("chat, chien, veau, vache, chèvre".split(", "))
txt.str.match("ch[aeéèiou]")
0     True
1     True
2    False
3    False
4     True
dtype: bool

Cependant, l’usage ci-dessus des classes de caractères n’est pas le plus fréquent. On privilégie celles-ci pour identifier des pattern complexe plutôt qu’une suite de caractères littéraux. Les tableaux d’aide mémoire illustrent une partie des classes de caractères les plus fréquentes ([:digit:] ou \d…)

Quantifieurs

Nous avons rencontré les quantifieurs avec notre première expression régulière. Ceux-ci contrôlent le nombre de fois qu’un pattern est rencontré.

Les plus fréquents sont:

  • ? : 0 ou 1 match ;
  • + : 1 ou plus de matches ;
  • * : 0 or more matches.

Par exemple, colou?r permettra de matcher à la fois l’écriture américaine et anglaise

re.findall("colou?r", "Did you write color or colour?")
['color', 'colour']

Ces quantifiers peuvent bien-sûr être associés à d’autres types de caractères, notamment les classes de caractères. Cela peut être extrèmement pratique. Par exemple, \d+ permettra de capturer un ou plusieurs chiffres, \s? permettra d’ajouter en option un espace, [\w]{6,8} un mot entre six et huit lettres qu’on écrira…

Il est aussi possible de définir le nombre de répétitions avec {}:

  • {n} matche exactement n fois ;
  • {n,} matche au moins n fois ;
  • {n,m} matche entre n et m fois.

Cependant, la répétition des termes ne s’applique par défaut qu’au dernier caractère précédent le quantifier. On peut s’en convaincre avec l’exemple ci-dessus:

print(re.match("toc{4}","toctoctoctoc"))
None

Pour pallier ce problème, il existe les parenthèses. Le principe est le même qu’avec les règles numériques: les parenthèses permettent d’introduire une hiérarchie. Pour reprendre l’exemple précédent, on obtient bien le résultat attendu grâce aux parenthèses:

print(re.match("(toc){4}","toctoctoctoc"))
print(re.match("(toc){5}","toctoctoctoc"))
print(re.match("(toc){2,4}","toctoctoctoc"))
<re.Match object; span=(0, 12), match='toctoctoctoc'>
None
<re.Match object; span=(0, 12), match='toctoctoctoc'>

Aide-mémoire

Le tableau ci-dessous peut servir d’aide-mémoire sur les regex:

Expression régulière Signification
"^" Début de la chaîne de caractères
"$" Fin de la chaîne de caractères
"\\." Un point
"." N’importe quel caractère
".+" N’importe quelle suite de caractères non vide
".*" N’importe quelle suite de caractères, éventuellement vi
"[:alnum:]" Un caractère alphanumérique
"[:alpha:]" Une lettre
"[:digit:]" Un chiffre
"[:lower:]" Une lettre minuscule
"[:punct:]" Un signe de ponctuation
"[:space:]" un espace
"[:upper:]" Une lettre majuscule
"[[:alnum:]]+" Une suite d’au moins un caractère alphanumérique
"[[:alpha:]]+" Une suite d’au moins une lettre
"[[:digit:]]+" Une suite d’au moins un chiffre
"[[:lower:]]+" Une suite d’au moins une lettre minuscule
"[[:punct:]]+" Une suite d’au moins un signe de ponctuation
"[[:space:]]+" Une suite d’au moins un espace
"[[:upper:]]+" Une suite d’au moins une lettre majuscule
"[[:alnum:]]*" Une suite de caractères alphanumériques, éventuellement vide
"[[:alpha:]]*" Une suite de lettres, éventuellement vide
"[[:digit:]]*" Une suite de chiffres, éventuellement vide
"[[:lower:]]*" Une suite de lettres minuscules, éventuellement vide
"[[:upper:]]*" Une suite de lettres majuscules, éventuellement vide
"[[:punct:]]*" Une suite de signes de ponctuation, éventuellement vide
"[^[:alpha:]]+" Une suite d’au moins un caractère autre qu’une lettre
"[^[:digit:]]+" Une suite d’au moins un caractère autre qu’un chiffre
"|" L’une des expressions x ou y est présente
[abyz] Un seul des caractères spécifiés
[abyz]+ Un ou plusieurs des caractères spécifiés (éventuellement répétés)
[^abyz] Aucun des caractères spécifiés n’est présent

Certaines classes de caractères bénéficient d’une syntaxe plus légère car elles sont très fréquentes. Parmi-celles:

Expression régulière Signification
\d N’importe quel chiffre
\D N’importe quel caractère qui n’est pas un caractère
\s N’importe quel espace (espace, tabulation, retour à la ligne)
\S N’importe quel caractère qui n’est pas un espace
\w N’importe quel type de mot (lettres et nombres)
\W N’importe quel ensemble qui n’est pas un mot (lettres et nombres)

Dans l’exercice suivant, vous allez pouvoir mettre en pratique les exemples précédents sur une regex un peu plus complète. Cet exercice ne nécessite pas la connaissance des subtilités du package re, vous n’aurez besoin que de re.findall.

Cet exercice utilisera la chaine de caractère suivante:

s = """date 0 : 14/9/2000
date 1 : 20/04/1971     date 2 : 14/09/1913     date 3 : 2/3/1978
date 4 : 1/7/1986     date 5 : 7/3/47     date 6 : 15/10/1914
date 7 : 08/03/1941     date 8 : 8/1/1980     date 9 : 30/6/1976"""
s
'date 0 : 14/9/2000\ndate 1 : 20/04/1971     date 2 : 14/09/1913     date 3 : 2/3/1978\ndate 4 : 1/7/1986     date 5 : 7/3/47     date 6 : 15/10/1914\ndate 7 : 08/03/1941     date 8 : 8/1/1980     date 9 : 30/6/1976'

A l’issue de la question 1, vous devriez avoir ce résultat :

['14/',
 '9/',
 '20/',
 '04/',
 '14/',
 '09/',
 '2/',
 '3/',
 '1/',
 '7/',
 '7/',
 '3/',
 '15/',
 '10/',
 '08/',
 '03/',
 '8/',
 '1/',
 '30/',
 '6/']

A l’issue de la question 2, vous devriez avoir ce résultat, qui commence à prendre forme:

['14/9',
 '20/04',
 '14/09',
 '2/3',
 '1/7',
 '7/3',
 '15/10',
 '08/03',
 '8/1',
 '30/6']

A l’issue de la question 3, on parvient bien à extraire les dates :

['14/9/2000',
 '20/04/1971',
 '14/09/1913',
 '2/3/1978',
 '1/7/1986',
 '7/3/47',
 '15/10/1914',
 '08/03/1941',
 '8/1/1980',
 '30/6/1976']

Si tout va bien, à la question 5, votre regex devrait fonctionner:

['14/9/2000',
 '20/04/1971',
 '14/09/1913',
 '2/3/1978',
 '1/7/1986',
 '7/3/47',
 '15/10/1914',
 '08/03/1941',
 '8/1/1980',
 '30/6/1976',
 '1998/07/12']

Principales fonctions de re

Voici un tableau récapitulatif des principales fonctions du package re suivi d’exemples.

Nous avons principalement utilisé jusqu’à présent re.findall qui est l’une des fonctions les plus pratiques du package. re.sub et re.search sont également bien pratiques. Les autres sont moins vitales mais peuvent dans des cas précis être utiles.

Fonction Objectif
re.match(<regex>, s) Trouver et renvoyer le premier match de l’expression régulière <regex> à partir du début du string s
re.search(<regex>, s) Trouver et renvoyer le premier match de l’expression régulière <regex> quelle que soit sa position dans le string s
re.finditer(<regex>, s) Trouver et renvoyer un itérateur stockant tous les matches de l’expression régulière <regex> quelle que soit leur(s) position(s) dans le string s. En général, on effectue ensuite une boucle sur cet itérateur
re.findall(<regex>, s) Trouver et renvoyer tous les matches de l’expression régulière <regex> quelle que soit leur(s) position(s) dans le string s sous forme de liste
re.sub(<regex>, new_text, s) Trouver et remplacer tous les matches de l’expression régulière <regex> quelle que soit leur(s) position(s) dans le string s

Pour illustrer ces fonctions, voici quelques exemples:

Exemple de re.match 👇

re.match ne peut servir qu’à capturer un pattern en début de string. Son utilité est donc limitée. Capturons néanmoins toto :

re.match("(to){2}", "toto à la plage")
<re.Match object; span=(0, 4), match='toto'>
Exemple de re.search 👇

re.search est plus puissant que re.match, on peut capturer des termes quelle que soit leur position dans un string. Par exemple, pour capturer age:

re.search("age", "toto a l'age d'aller à la plage")
<re.Match object; span=(9, 12), match='age'>

Et pour capturer exclusivement “age” en fin de string:

re.search("age$", "toto a l'age d'aller à la plage")
<re.Match object; span=(28, 31), match='age'>
Exemple de re.finditer 👇

re.finditer est, à mon avis, moins pratique que re.findall. Son utilité principale par rapport à re.findall est de capturer la position dans un champ textuel:

s = "toto a l'age d'aller à la plage"
for match in re.finditer("age", s):
    start = match.start()
    end = match.end()
    print(f'String match "{s[start:end]}" at {start}:{end}')
String match "age" at 9:12
String match "age" at 28:31
Exemple de re.sub 👇

re.sub permet de capturer et remplacer des expressions. Par exemple, remplaçons “age” par “âge”. Mais attention, il ne faut pas le faire lorsque le motif est présent dans “plage”. On va donc mettre une condition négative: capturer “age” seulement s’il n’est pas en fin de string (ce qui se traduit en regex par ?!$)

re.sub("age(?!$)", "âge", "toto a l'age d'aller à la plage")
"toto a l'âge d'aller à la plage"

Généralisation avec Pandas

Les méthodes de Pandas sont des extensions de celles de re qui évitent de faire une boucle pour regarder, ligne à ligne, une regex. En pratique, lorsqu’on traite des DataFrames, on utilise plutôt l’API Pandas que re. Les codes de la forme df.apply(lambda x: re.<fonction>(<regex>,x), axis = 1) sont à bannir car très peu efficaces.

Les noms changent parfois légèrement par rapport à leur équivalent re.

Méthode Description
str.count() Compter le nombre d’occurrences du pattern dans chaque ligne
str.replace() Remplacer le pattern par une autre valeur. Version vectorisée de re.sub()
str.contains() Tester si le pattern apparaît, ligne à ligne. Version vectorisée de re.search()
str.extract() Extraire les groupes qui répondent à un pattern et les renvoyer dans une colonne
str.findall() Trouver et renvoyer toutes les occurrences d’un pattern. Si une ligne comporte plusieurs échos, une liste est renvoyée. Version vectorisée de re.findall()

A ces fonctions, s’ajoutent les méthodes str.split() et str.rsplit() qui sont bien pratiques.

Exemple de str.count 👇

On peut compter le nombre de fois qu’un pattern apparaît avec str.count

df = pd.DataFrame({"a": ["toto", "titi"]})
df['a'].str.count("to")
0    2
1    0
Name: a, dtype: int64
Exemple de str.replace 👇

Remplaçons le motif “ti” en fin de phrase

df = pd.DataFrame({"a": ["toto", "titi"]})
df['a'].str.replace("ti$", " punch")
/tmp/ipykernel_1294/3231151913.py:2: FutureWarning:

The default value of regex will change from True to False in a future version.

0        toto
1    ti punch
Name: a, dtype: object
Exemple de str.contains 👇

Vérifions les cas où notre ligne termine par “ti”:

df = pd.DataFrame({"a": ["toto", "titi"]})
df['a'].str.contains("ti$")
0    False
1     True
Name: a, dtype: bool
Exemple de str.findall 👇
df = pd.DataFrame({"a": ["toto", "titi"]})
df['a'].str.findall("to")
0    [to, to]
1          []
Name: a, dtype: object

Pour en savoir plus

Exercices supplémentaires

Extraction d’adresses email

Il s’agit d’un usage classique des regex

text_emails = 'Hello from toto@gmail.com to titi.grominet@yahoo.com about the meeting @2PM'
['toto@gmail.com', 'titi.grominet@yahoo.com']

Extraire des années depuis un DataFrame Pandas

L’objectif général de l’exercice est de nettoyer des colonnes d’un DataFrame en utilisant des expressions régulières.

Voici par exemple le problème qu’on demande de détecter à la question 3 :

Date of Publication Title
13 1839, 38-54 De Aardbol. Magazijn van hedendaagsche land- e...
14 1897 Cronache Savonesi dal 1500 al 1570 ... Accresc...
15 1865 See-Saw; a novel ... Edited [or rather, writte...
16 1860-63 Géodésie d'une partie de la Haute Éthiopie,...
17 1873 [With eleven maps.]
18 1866 [Historia geográfica, civil y politica de la ...
19 1899 The Crisis of the Revolution, being the story ...
181

Grâce à notre regex (question 5), on obtient ainsi un DataFrame plus conforme à nos attentes

Date of Publication year
0 1879 [1878] 1879
7 NaN NaN
13 1839, 38-54 1839
16 1860-63 1860
23 1847, 48 [1846-48] 1847
... ... ...
8278 1883, [1884] 1883
8279 1898-1912 1898
8283 1831, 32 1831
8284 [1806]-22 1806
8286 1834-43 1834

1759 rows × 2 columns

Quant aux nouveaux NaN, il s’agit de lignes qui ne contenaient pas de chaînes de caractères qui ressemblaient à des années:

Date of Publication year
1081 112. G. & W. B. Whittaker NaN
7391 17 vols. University Press NaN

Enfin, on obtient l’histogramme suivant des dates de publications:

<AxesSubplot: ylabel='Frequency'>

  1. N’importe quel caractère à part le retour à la ligne (\n). Ceci est à garder en tête, j’ai déjà perdu des heures à chercher pourquoi mon . ne capturait pas ce que je voulais qui s’étalait sur plusieurs lignes… ↩︎

Previous
Next