Forward checking

Dans cette série, vous allez programmer de nouveaux algorithmes de résolution de systèmes de contraintes, plus sophistiqués que ceux de la leçon précédente :

  • Les heuristiques du Variable Ordering et du Dynamic Variable Ordering ;
  • Programmer l’heuristique du Forward Checking.

Vous les testerez ensuite sur le jeu du Sudoku.

Modules squelettes

Commencez par copier les modules suivants, qui implémentent partiellement le code de l’exercice. Le module moteur_psc_heuristique fournit le squelette du programme que nous allons développer. Les modules exemple_forward_checking.py et exemple_sudoku.py sont là pour vous permettre de tester votre programme.

forward_checking.zip

Module .../moteur_psc_heuristique/variable_avec_label.py :

from moteur_psc.variable import Variable

class VariableAvecLabel(Variable):
    """ Modélisation d'une variable munie d'un label dans un système de\
        contraintes.
    """

    def __init__(self, nom, domaine, val=None):
        """
            :param str nom: nom de la variable.
            :param list domaine: le domaine de définition de la variable.
            :param val: valeur de départ.
        """

        Variable.__init__(self, nom, domaine, val)

        # Le label est initialement égal au domaine.
        self.label = domaine[:]

    def __repr__(self):
        return '{} = {}, domaine: {}, label: {}'.format(self.nom,
                                                        self.val,
                                                        self.domaine,
                                                        self.label)

Module .../moteur_psc_heuristique/contrainte_avec_propagation.py :

from moteur_psc.contrainte import ContrainteBinaire

class ContrainteAvecPropagation(ContrainteBinaire):
    """ Contrainte imposant une restriction sur deux variables avec la méthode\
        ``propage``.

        Exemple: ``x > y``.
    """

    def __init__(self, var1, var2, op):
        """ Exemples d'op: ``lambda x,y: x != y``, ``lambda x,y: x < y``.

            :param var1: première variable concernée par la contrainte.
            :param var2: deuxième variable concernée par la contrainte.
            :param op: fonction ou expression lambda permettant de vérifier la\
            contrainte.
        """

        ContrainteBinaire.__init__(self, var1, var2, op)

    def reviser(self):
        """ Algorithme de révision des domaines.

            Pour chaque variable, vérifie chaque valeur du domaine. Supprime\
            les valeurs qui empêchent la contrainte d'être satisfaite dans le\
            domaine.

            :return: ``True`` si un des domaines a été modifié.
        """

        # Nous appliquons d'abord la méthode reviser() de la classe-mère pour
        # réviser les domaines de chaque variable.
        domaines_modifies = ContrainteBinaire.reviser(self)

        # Puis, s'il y a lieu, nous nous assurons que les labels sont toujours
        # identiques aux domaines.
        if domaines_modifies:
            for var in self.variables:
                var.label = var.domaine[:]

        return domaines_modifies


    def propage(self, var):
        """ Propage l'assignation d'une variable au label de la seconde.

            :param var: la variable fixée.
            :return: ``True`` si la contrainte est toujours satisfaisable avec\
            la valeur fixée.
        """

        print('à compléter')

Module .../psc_heuristique/moteur_psc_heuristique.py :

from moteur_psc.psc import PSC

class PSCHeuristique(PSC):

    def __init__(self, variables, contraintes):
        """
            :param list variables: les variables du problème.
            :param list contraintes: les contraintes du problème.
        """

        PSC.__init__(self, variables, contraintes)

        self.reinitialise()

    def reinitialise(self):
        """ Réinitialise les attributs du problème.

            Après l'appel à cette méthode, les labels des variables sont à\
            nouveau égaux aux domaines complets, et les variables ont une\
            valeurs indéfinie (égale à ``None``).
        """

        self.initialise_labels()
        self.solutions = []
        self.iterations = 0

    def initialise_labels(self):
        """ Initialise les labels pour les rendre identiques aux domaines. """

        for var in self.variables:
            var.label = var.domaine[:]

    def consistance_noeuds(self):
        """ Applique la consistance des noeuds sur les contraintes unaires du\
            problème.

            L'algorithme consiste à enlever des domaines de définition toutes\
            les valeurs qui violent les contraintes unaires.
        """

        # Nous appelons d'abord la méthode de la classe-mère PSC pour réduire
        # les domaines.
        PSC.consistance_noeuds(self)

        # Puis, nous nous assurons que les labels sont identiques aux domaines.
        self.initialise_labels()

    def variable_ordering(self):
        """ Trie les variables par ordre croissant de taille de domaine."""

        print('à compléter')

    def dynamic_variable_ordering(self, k):
        """ Place en position ``k`` la variable non instanciée dotée du label le\
            plus restreint.

            :param k: profondeur actuelle de la recherche.
        """

        print('à compléter')

    def propagation_consistante(self, k):
        """ Propage la valeur de la variable actuelle sur les variables\
            suivantes.

            Pour chaque contrainte portant sur la variable courante et sur une\
            ou plusieurs des variables non encore instanciée, appelle la methode\
            ``propage`` de la contrainte pour réduire le label de la deuxième\
            variable.

            :param k: profondeur actuelle de la recherche.
            :return: ``True`` si la valeur de la dernière variable instanciée\
            n'empêche pas l'instanciation des variables suivantes.
        """

        print('à compléter')

    def forward_checking(self, k=0, une_seule_solution=False):
        """ Algorithme du Forward Checking.

            Le Forward Checking essaie de limiter les retours en arrière en\
            restreignant les domaines des variables non instanciées\
            (par application de la consistance des arcs à chaque itération).

            :param une_seule_solution: retourne après avoir trouvé la première\
            solution.
            :param k: la profondeur actuelle de la recherhe.
        """

        print('à compléter')

Module .../sudoku.py :

from moteur_psc_heuristique.variable_avec_label import VariableAvecLabel
from moteur_psc.contrainte import ContrainteUnaire
from moteur_psc_heuristique.contrainte_avec_propagation import ContrainteAvecPropagation
from moteur_psc_heuristique.psc_heuristique import PSCHeuristique

class Sudoku:
    """ Représentation et résolution d'une grille de Sudoku."""

    def __init__(self, grille, taille=9, sous_taille=3):
        """
            :param taille: taille de la grille de Sudoku.
            :param sous_taille: taille des sous-grilles du problème.
        """

        if taille % sous_taille != 0:
            raise ValueError('Taille et sous-taille de grille incompatibles.')
        self.taille = taille
        self.sous_taille = sous_taille

        # Génère une variable par case.
        self.variables = [VariableAvecLabel('{}{}'.format(i, j),
                                            list(range(1, self.taille + 1)))
            for i in range(self.taille) for j in range(self.taille)]

        # Initialise les cases dont les valeurs sont connues.
        for i in range(self.taille):
            for j in range(self.taille):
                # Si la case est instanciée avec une valeur valide,
                # assigne la variable et restreint son domaine à la valeur initiale.
                if isinstance(grille[i][j], int):
                    if not (1 <= grille[i][j] <= self.taille):
                        raise ValueError('Valeur invalide dans la grille de départ.')
                    var = self.variables[i * self.taille + j]
                    var.val = grille[i][j]
                    var.domaine = [grille[i][j]]
                    var.label = [grille[i][j]]

        self.contraintes = []
        self.genere_contraintes()

    def __genere_contraintes_sous_grille(self, x, y):
        """ Génère les contraintes de la sous-grille dont le coin est situé\
            en (x,y).
        """

        # Parcours de la sous-grille.
        for i in range(x, x + self.sous_taille):
            for j in range(y, y + self.sous_taille):
                # Pour chaque case qui n'est ni dans la même ligne i ni dans la même colonne j,
                # on ajoute une contrainte. (les autres cases sont couvertes par les contraintes
                # de lignes et de colonnes.)
                for k in range(x, x + self.sous_taille):
                    for l in range(y, y + self.sous_taille):
                        if i != k and j != l:
                            self.contraintes.append(
                                ContrainteAvecPropagation(self.variables[i * self.taille + j],
                                                          self.variables[k * self.taille + l],
                                                          lambda x,y: x != y)
                            )

    def genere_contraintes(self):
        """ Génère toutes les contraintes d'une grille de Sudoku. """

        self.contraintes = []

        for i in range(0, self.taille):
            for j in range(0, self.taille):
                # Contraintes sur les case d'une ligne.
                for k in range(j + 1, self.taille):
                    self.contraintes.append(
                        ContrainteAvecPropagation(self.variables[i * self.taille + j],
                                                  self.variables[i * self.taille + k],
                                                lambda x,y: x != y)
                    )
                # Contraintes sur les cases d'une colonne.
                for k in range(j + 1, self.taille):
                    self.contraintes.append(
                        ContrainteAvecPropagation(self.variables[j * self.taille + i],
                                                  self.variables[k * self.taille + i],
                                                  lambda x,y: x != y)
                    )

        # Contrainte sur les cases d'une sous-grille.
        # Le troisième argument de range permet de régler l'incrément.
        # Ex.: range(0, 5, 2) génère la séquence 0, 3.
        #      range(10, 5, -1) génère la séquence 10, 9, 8, 7, 6.
        # '//' est l'opérateur de division entière.
        for i in range(0, self.taille, self.taille // self.sous_taille):
            for j in range(0, self.taille, self.taille // self.sous_taille):
                self.__genere_contraintes_sous_grille(i, j)

    def resoudre(self, methode):
        """ Lance l'algorithme de résolution choisi sur la grille.

            :param methode: méthode de résolution: ``'forward_checking'`` ou\
            ``'backtracking'``.
        """

        psc = PSCHeuristique(self.variables, self.contraintes)
        psc.consistance_noeuds()
        psc.consistance_arcs()

        if methode == 'forward_checking':
            psc.forward_checking(une_seule_solution=True)
        elif methode == 'backtracking':
            psc.variable_ordering()
            psc.backtracking(une_seule_solution=True)
        else:
            raise ValueError('Méthode inconnue: ' + str(methode))
        print('Méthode: ' + methode)
        print('Recherche terminée en {} itérations'.format(psc.iterations))
        for i in range(self.taille):
            for j in range(self.taille):
                nom = '{}{}'.format(i, j)
                self.variables[i * self.taille + j].val = psc.solutions[0][nom]

    def __repr__(self):
        """ Convertit les variables du problème en grille à afficher."""

        def val(e):
            """ Méthode locale pour la mise en forme des valeurs absentes."""

            if e is None:
                return '-'
            else:
                return e

        ret = ''
        for i in range(self.taille):
            for j in range(self.taille):
                ret += '{} '.format(val(self.variables[i * self.taille + j].val))
            ret += '\n'

        return ret

Module .../exemple_forward_checking.py :

from moteur_psc_heuristique.variable_avec_label import VariableAvecLabel
from moteur_psc.contrainte import ContrainteUnaire
from moteur_psc_heuristique.contrainte_avec_propagation import ContrainteAvecPropagation
from moteur_psc_heuristique.psc_heuristique import PSCHeuristique

variables = [
             VariableAvecLabel('a', [2, 3]),
             VariableAvecLabel('b', list(range(12))),
             VariableAvecLabel('c', list(range(3))),
             VariableAvecLabel('d', list(range(3))),
             VariableAvecLabel('e', list(range(12))),
             ]

contraintes = [
    ContrainteUnaire(variables[1], lambda x: x < 4),
    ContrainteAvecPropagation(variables[0], variables[1], lambda x, y: x != y),
    ContrainteAvecPropagation(variables[1], variables[2], lambda x, y: x != y),
    ContrainteAvecPropagation(variables[1], variables[3], lambda x, y: x != y),
    ContrainteAvecPropagation(variables[1], variables[4], lambda x, y: x != y),
    ContrainteAvecPropagation(variables[2], variables[3], lambda x, y: x != y),
    ContrainteAvecPropagation(variables[2], variables[4], lambda x, y: x != y),
    ContrainteAvecPropagation(variables[3], variables[4], lambda x, y: x != y),
    ContrainteAvecPropagation(variables[4], variables[0], lambda x, y: x < y),
]

psc = PSCHeuristique(variables, contraintes)

psc.consistance_noeuds()
psc.consistance_arcs()
psc.variable_ordering()

psc.backtracking()

print('Backtracking avec variable ordering: ')
psc.affiche_solutions()

psc.reinitialise()
psc.forward_checking()

print('Forward checking: ')
psc.affiche_solutions()

Module .../exemple_sudoku.py :

from sys import argv, exit
from moteur_psc_heuristique.variable_avec_label import VariableAvecLabel
from moteur_psc.contrainte import ContrainteUnaire
from moteur_psc_heuristique.contrainte_avec_propagation import ContrainteAvecPropagation
from moteur_psc_heuristique.psc_heuristique import PSCHeuristique
from sudoku import Sudoku

grilleA = [
    [ 9 ,'-','-','-','-','-','-','-', 2],
    [ 3 ,'-', 7 , 1 ,'-','-', 4 ,'-', 8],
    ['-', 1 ,'-','-', 5 , 4 ,'-', 6 ,'-'],
    ['-','-', 1 ,'-','-','-','-', 7 ,'-'],
    ['-','-', 4 ,'-','-','-', 9 ,'-','-'],
    ['-', 2 ,'-','-','-','-', 8 ,'-','-'],
    ['-', 8 ,'-', 3 , 2 ,'-','-', 4 ,'-'],
    [ 7 ,'-', 3 ,'-','-', 6 , 2 ,'-', 1],
    [ 4 ,'-','-','-','-','-','-','-', 5]
]

grilleB = [
    ['-','-','-','-','-','-','-','-','-'],
    ['-','-', 7 , 8 , 3 ,'-', 9 ,'-','-'],
    ['-','-', 5 ,'-','-', 2 , 6 , 4 ,'-'],
    ['-','-', 2 , 6 ,'-','-','-', 7 ,'-'],
    ['-', 4 ,'-','-','-','-','-', 8 ,'-'],
    ['-', 6 ,'-','-','-', 3 , 2 ,'-','-'],
    ['-', 2 , 8 , 4 ,'-','-', 5 ,'-','-'],
    ['-','-','-','-', 9 , 6 , 1 ,'-','-'],
    ['-','-','-','-','-','-','-','-','-']
]

if len(argv) < 3:
    print('On attend deux arguments: grille (A ou B) ' +\
          'et méthode (forward_checking ou backtracking)')
    exit(1)

if argv[1].lower() == 'a':
    sudoku = Sudoku(grilleA)
elif argv[1].lower() == 'b':
    sudoku = Sudoku(grilleB)
else:
    print('Le premier argument doit être A ou B')
    exit(1)

if argv[2] in ('backtracking', 'forward_checking'):
    methode = argv[2]
else:
    print('Le second argument doit être forward_checking ou backtracking')
    sys.exit(1)

print('Grille ' + argv[1])
print(sudoku)

sudoku.resoudre(methode)

print(sudoku)

Comme les méthodes que vous allez programmer dans cet exercice sont une extension du code des chapitres précédents, il est plus commode de les implémenter dans des classes-filles, qui héritent des classes que nous avons déjà développées. Nous vous fournissons ainsi la classe VariableAvecLabel, qui étend la classe Variable, la classe ContrainteAvecPropagation, qui étend la classe ContrainteBinaire, et la classe PSCHeuristique, qui hérite de la classe PSC.

Exercice 1 : Variable ordering

Dans le cas du Backtracking, comme vous avez pu le constater, les variables sont instanciées les unes après les autres dans l’ordre où elles apparaissent dans self.variables. L’heuristique du Variable Ordering consiste à trier ces variables de façon à instancier d’abord celles dont le domaine est le plus restreint. L’idée est de commencer par les variables les plus restrictives, car ce sont celles-ci qui ont le plus de chance d’aboutir à une instanciation inconsistante, et donc à un backtrack.

Le premier exercice consiste à implémenter cet algorithme dans la classe PSCHeuristique. Pour trier les variables, vous pouvez appeler la méthode sort() de la liste self.variables en passant comme paramètre key une fonction lambda qui retourne la taille du domaine. Vous pouvez vous inspirer de la documentation disponible ici .

Vous pouvez ensuite tester votre algorithme sur le fichier exemple_forward_checking.py en vérifiant s’il améliore la performance du Backtracking.

Exercice 2 : Algorithme du forward checking

Des heuristiques peuvent aussi être employées afin d’améliorer la recherche par rapport au Backtracking. Vous allez ainsi programmer l’heuristique connue sous le nom de Forward Checking. Elle a pour but d’éviter à l’avance des instanciations inconsistantes en appliquant le critère de la consistance des arcs pendant la recherche. Pour cela, il faut ajouter à chaque variable un attribut self.label, qui sera initialement égal au domaine de celle-ci. Le forward checking met alors à jour ces labels en appliquant la règle suivante : à chaque instanciation d’une variable x_{k}, on retire toutes les valeurs inconsistantes avec x_{k} des labels des variables qui ne sont pas encore instanciées.

Dans la méthode forward_checking de la classe PSCHeuristique, programmez donc l’algorithme ci-dessous :

solutions <- []
variables <- [v1, v2, ..., vn]

ForwardChecking(k, une_seule_solution)
1.  IF k >= n THEN
2.      IF NOT une_seule_solution THEN
3.          ajouter la solution actuelle à solutions
4.      ELSE
5.          RETURN solutions = [solution actuelle]
6.      END IF
7.  ELSE
8.      v <- variables[k]
9.      sauvegarde_labels <- labels des variables vk, ..., vn
10.     FOR EACH valeur de label d de la variable v DO
11.         assigner la valeur d à la variable v
12.         réduire le label de v à la seule valeur d
13.         propager v=d aux labels des variables suivantes
14.         IF v=d est consistant THEN
15.             reste <- ForwardChecking(k+1, une_seule_solution)
16.             IF reste != échec THEN
17.                 RETURN reste
18.             END IF
19.         END IF
20.         labels des variables <- sauvegarde_labels
21.     END FOR
22. END IF
23. RETURN échec
END ForwardChecking

La méthode forward_checking prend deux paramètres :

  • k : la profondeur courante (commence à 0) ;
  • une_seule_solution : si True, retourne la première solution trouvée, sinon retourne toutes les solutions.

Les solutions seront stockées dans la variable de classe self.solutions. Chaque solution sera représentée par un dictionnaire qui, à un nom de variable donné, associera la valeur de cette variable.

Les étapes 13 et 14 de l’algorithme ci-dessus sont à implémenter à l’aide de la fonction propagation_consistante de la classe PSCHeuristique, que vous devez compléter. Pour chaque contrainte portant sur la variable courante et sur au moins une deuxième variable non encore instanciée, cette fonction doit appeler la méthode propage de la contrainte pour tenter de réduire le label de la deuxième variable.

Vous devez aussi implémenter la méthode propage de la classe ContrainteAvecPropagation. Pour chaque valeur possible de la deuxième variable, propage vérifiera si cette valeur est consistante avec la contrainte et la retirera du label de la variable si ce n’est pas le cas. Les deux méthodes propagation_consistante et propage devront retourner True si les contraintes peuvent être satisfaites, et False si au moins une des variables non encore instanciées n’a plus aucune valeur possible dans son label après propagation.

Lorsque vous avez terminé, vous pouvez tester votre implémentation du forward checking sur le fichier exemple_forward_checking.py en vérifiant s’il améliore la performance du Backtracking.

Exercice 3 : Dynamic variable ordering

L’heuristique du variable ordering ne trie la liste des variables qu’une seule fois, avant la recherche. Le Dynamic Variable Ordering est une heuristique encore plus efficace, qui trie la liste des variables par ordre croissant de la taille du label à chaque étape de la recherche. L’idée est donc de retrier la liste des variables à chaque étape k, mais seulement à partir de la position k (car les variables précédentes ont déjà été instanciées).

Implémentez cette heuristique dans la classe PSCHeuristique, et appelez-la dans l’algorithme du forward checking entre les lignes 7 et 8 du pseudocode ci-dessus. Notez que vous n’avez pas besoin de trier toute la liste. En supposant que vous êtes à l’étape k, il est plus efficace de chercher la variable possédant le plus petit label à partir de la position k et de l’échanger avec la variable d’indice k. Pour échanger les valeurs de deux variables a et b en Python, vous pouvez utiliser la syntaxe a, b = b, a.

Testez finalement à nouveau votre algorithme sur le fichier exemple_forward_checking.py, et comparez les résultats.

Exercice 4 : Sudoku

Afin de comprendre ces algorithmes plus en détail, testez-les sur le fichier exemple_sudoku.py, qui contient deux grilles de Sudoku. Essayez la grille A, puis la grille B. Que constatez-vous ?

python3 exemple_sudoku.py A forward_checking
python3 exemple_sudoku.py B forward_checking

Essayez maintenant d’utiliser le Backtracking. Que constatez-vous ?

python3 exemple_sudoku.py A backtracking
python3 exemple_sudoku.py B backtracking