Implémentation d’un AFD en Python

Bienvenue dans ce deuxième article de la série sur les automates. Aujourd’hui, nous allons nous intéresser particulièrement aux bases de notre implémentation en Python.

Avant de commencer

Le choix de Python me semble adéquat puisqu’il permettra d’illustrer simplement les différents algorithmes liés aux automates dans les prochains chapitres, tout en profitant de interpréteur pour faciliter les manipulation.

Cette implémentation se concentrera sur l’objectif d’avoir des outils de manipulation d’automates simples. La question des performances sera parfois abordées, mais il ne s’agit pas de l’aspect principal de cet article.

De la même façon, si on peut discuter longtemps des différentes approches pour proposer une API de manipulation d’automates, il s’agira avant tout d’avoir un code simple à utiliser. Notamment, le support des AFND et des AFND-ε pour les chapitres suivants ne sera pas considéré dans cet article (même si l’implémentation que je vous propose ne demandera pas beaucoup de modifications lorsque nous en serons arrivés là).

Cet article se divise en cinq parties :

  • Modélisation d’un automate
  • Implémentation de cette solution
  • Implémentation de l’exécution d’un mot donné.
  • Comment sauvegarder/charger un automate, de manière ne pas avoir à reconstruire un automate lorsqu’on relance l’interpréteur.
  • Comment visualiser notre automate.

Modélisation d’un automate

La première question qu’il nous faut nous poser est la manière dont on va représenter un automate.

Plusieurs solutions peuvent être envisagées, chacune proposant leurs propres avantages d’un point de vue design de logiciel. Nous allons ici en regarder quelques unes rapidement mais il ne s’agit pas de présenter toutes les solutions possibles.

Pour modéliser un automate, il va falloir trouver le moyen de représenter l’alphabet, les états, les différentes transitions ainsi que l’état initial et les états accepteurs.

Dans un premier temps, je vous propose de regrouper tous ces éléments au sein d’un même classe, DFA, qui correspondra à un automate fini déterministe. Si on imagine assez facilement que l’alphabet puisse être remplacé par une chaîne de caractère contenant tous les symboles, la question des états et des transitions est plus complexe.

Nous allons mettre de côté les approches orientées graphes, telles que les matrices d’adjacences, d’incidences etc. Celles-ci ont des avantages certains d’optimisation mais il ne s’agit pas ici d’un cours de théorie des graphes et je vais donc partir du principe que c’est un domaine inconnu du lecteur.

Une approche possible est de créer les classes suivantes :

  • Transition : contenant l’état d’origine, l’état de destination et le symbole associé.
  • State : qui contiendra une liste de transitions, et éventuellement un nom.

Cette méthode est assez facile à conceptualiser mais est un peu lourde. L’autre solution que je vous propose d’utiliser ici, est de ne pas créer de classe supplémentaire, ce qui nous permettra d’avoir une seule classe  DFA à développer et qui nous fera globalement moins de code à écrire.

En effet, la question de l’identification des états est importante pour pouvoir définir d’où part et où arrive une transition (et également pour la manipulation d’un automate en général). Si on décide de stocker nos états dans une liste dans la classe DFA, on peut décider de les identifier par indice dans cette liste. On peut aussi utiliser des adresses (difficile en python), ou un nom etc.

Imposer un nom unique pour les états permet de représenter les états par une liste de chaîne de caractères et m’a semblé ici la solution la plus simple à mettre en place. Les états seront donc de simples strings et nous ajouterons donc dans notre classe  DFA une liste de strings supplémentaire correspondant aux états finaux et une chaîne de caractère qui sera l’état initial.

La dernière question est donc la façon dont on va représenter les transitions de l’automate. Une transition associe deux états et un symbole. Une solution parmi d’autres, mais qui à l’avantage de proposer une bonne syntaxe d’utilisation (et d’être utile pour les automates non-deterministe par la suite) est d’utiliser un dictionnaire associant l’état d’origine de la transition (son nom) à une paire (symbole, état-cible). Cette méthode nous permettrait de trouver l’état de destination pour un symbole et un état d’origine donné de cette manière :

L’état initial étant unique, il nous suffit d’avoir un attribut correspondant au nom de l’état initial. De la même manière un attribut contiendra le nom des états finaux :

L’implémentation

Pour résumer le design que nous avons choisi, un automate est représenté par la classe  DFA, qui va contenir :

  • Un alphabet (représenté par une chaîne de caractères).
  • Une liste d’états, qui seront représentés par leur nom.
  • Le nom de l’état initial.
  • Une liste d’états finaux, encore une fois leurs noms.
  • Les transitions, représentés par un dictionnaire associant une chaîne de caractère (le nom de l’état d’origine), avec une paire symbole / état-cible.

Ci-suit donc la classe  DFA et son constructeur :

Vous constaterez que j’ai fait ici le choix de laisser tous les attributs en public. Le Python ne dispose pas d’une portée privée stricte mais l’usage est de préfixer dans ce cas la variable par un underscore (la technique de name mangling avec le double underscore est également possible), mais il aurait pu être intéressant de proposer une veritable encapsulation. Cela aurait cependant alourdi l’interface et on peut très bien s’en passer ici.

L’état initial est ainsi par défaut à None, et peut être directement modifié après la création de l’automate.

Nous allons maintenant nous intéresser aux deux parties un peu plus complexes : l’ajout d’un état et l’ajout de transition. L’ajout d’état est plus simple puisqu’il nous faut simplement nous assurer que le nom n’est pas déjà utilisé :

L’ajout de transition demande quant à lui de s’assurer que les états et le symbole sont valides puis de vérifier que la transition n’existe pas encore pour l’état d’origine et le symbole donné. Nous allons donc également introduire les fonctions valid_symbol et dst_state qui nous simplifieront la tâche :

Nous avons maintenant pratiquement tout ce qu’il nous faut pour les algorithmes qui vont suivre. Nous allons aussi surcharger __str__  de manière à avoir possibilité d’afficher nos automates :

Je vous propose donc de reprendre l’automate du premier chapitre reconnaissant les mots contenant au moins une fois le symbole a sur l’alphabet “ab” :

Et d’utiliser notre module pour créer l’automate équivalent :

Et voilà ! On peut aussi s’amuser à tester les autres fonctions :

Avant de clore l’implémentation des fonctions de ce fichier, je vous propose tout de même d’ajouter la méthode clone, qui permettra de faire une copie (complète) de l’automate :

Une possibilité d’amélioration serait de fournir des méthodes pour la suppression d’états, de transitions ou de symboles par exemple.  On verra pour la suite, ce n’est pas indispensable pour le moment.

Exécution d’un automate

Nous allons maintenant nous centrer sur l’exécution de l’automate sur un mot donné. Je vous propose de mettre cette fonction dans le fichier algorithms.py :

L’algorithme d’éxécution est relativement simple. On démarre à l’état initial, et pour chaque symbole du mot, on se rend à l’état correspondant à la transition pour ce symbole et l’état courant. Si on rencontre un symbole pour lequel il n’existe pas de transition, le mot est refusé.

Si à l’inverse on arrive à la fin du mot, il nous suffit de regarder si le dernier état atteint est accepteur. Si c’est le cas le mot est accepté, sinon il est refusé. Voici donc une implémentation de cette algorithme :

De manière à rendre ça plus visuel, je vous propose d’ajouter des messages d’erreurs et des informations sur l’état de l’exécution. Pour ce faire, on va aussi ajouter un booléen verbose qui déterminera le niveau de détail qu’on souhaite sur l’exécution :

Encore une fois, testons tout ça avec un exemple, en reprenant cette fois l’automate reconnaissant les mots contenant la séquence aaba sur l’alphabet Σ = {a, b} :

Et nous pouvons maintenant tester des séquences :

Charger et sauvegarder un automate

Vous l’aurez surement remarqué, le mode interactif est très pratique pour manipuler nos automates et faire des tests, mais devoir recréer l’automate à chaque fois est fastidieux. Il nous faut un moyen de sauvegarder et de charger nos automates pour nous simplifier le travail.

La méthode la plus simple que j’ai trouvé pour ne pas avoir à avoir à définir un format spécifique et à le parser, c’est de simplement enregistrer les lignes de code nécessaires à la recréation de l’automate, puis d’exécuter le fichier contenant ce code et récupérer l’automate correspondant. Ci-suit la fonction de sauvegarde, qu’on mettra dans le fichier util.py :

On se contente donc de recréer les commandes qu’on utilise, voyons à quoi ça ressemble avec l’automate précédent :

Pour la lecture il nous faut passer par les fonctions compile et execexec permet de passer nos variables locales, ce qui va faire que la variable locale a contenant l’automate sera accessible :

Et voilà, nous pouvons maintenant sauvegarder et lire nos automates :

Affichage graphique de l’automate

Une dernière question pour cette article : peut-on obtenir une représentation graphique de notre automate ?

L’affichage des graphes de façon correcte est un sujet complexe et c’est très largement hors de la portée de ce cours. En revanche, il existe des outils qui peuvent nous permettre d’y arriver.

Le format DOT est un format texte de représentation de graphes. Il existe des programmes qui génèrent une représentation graphique des fichiers DOT.

Le fichier est relativement simple, il indique le mot-clef digraph, puisque les automates sont des graphes orientés (entendez par là que les transitions ne vont que dans un sens), suivi du nom de l’automate :

rankdir permet de spécifier la direction du graphe. Ici,  LR signifie simplement LeftRight, donc de gauche à droite. C’est en général l’orientation la plus naturelle.

Suit ensuite la liste des états, que nous ferons précéder par Q_ de manière à nous assurer que le nom de l’état soit valide pour le format DOT même lorsque c’est un nombre. Le label correspond au nom qui sera affiché :

Pour représenter les états finaux, on va utiliser  node [shape=doublecircle]; et  node [shape=circle]; qui permettent de définir l’aspect des états ( circle est la valeur par défaut).

Pour l’état initial, on va créer un état supplémentaire dont partira la flèche initial, et lui donner une apparence de point. Voici donc le fichier DOT complet correspondant à notre automate aaba :

Un fichier dot peut ensuite être simple transformé en image à l’aide de divers outils, notamment Graphviz. Cette collection d’outils est installée par défaut sur beaucoup de distributions Linux, sinon essayez de trouver le paquet correspondant ( apt install graphviz). Pour créer une image PNG à partir du fichier DOT :  dot -Tpng *aaba*.dot -o *aaba*.png. Ci-suit l’image résultant de cette commande :

Le résultat est tout à fait convainquant, je vous propose donc d’ajouter ces fonctionnalités d’exportation en DOT, en PNG et en PDF directement dans le fichier util.py, en utilisant le module subprocess pour exécuter les appels à dot :

Voici un exemple d’utilisation :

Voilà donc qui conclut cet article sur l’implémentation de nos automates finis déterministes. A cette adresse, vous trouverez la version complète du projet à l’issu de cet article, qui contient quelques automates d’exemple.

Je vous dis donc à une prochaine fois pour un nouvel article !

%d bloggers like this: