Devoir de programmation Java :
Les algorithmes génétiques et problèmes d'optimisations.

Introduction

L'objectif de ce projet était de nous apprendre les bases de la programmation en langage Java et par la même occasion de nous apporter un élément supplémentaire de culture informatique : les problèmes NP complets et une solution possible de résolution par les algorithmes génétiques.

Premièrement, nous avons dû implémenter un moteur générique de calcul pour ce type de problème : gestion de la population, de la viabilité d'un individu (fonction d'un score) et gestion des cycles de vie.

Dans un second temps, nous avons utilisé ce moteur afin de trouver des solutions acceptables aux célèbres problèmes du voyageur de commerce et du sac à dos.

Nous présenterons tout d'abord plus en détail le projet. Nous aborderons ensuite l'architecture du programme et la répartition du travail. Puis, nous détaillerons la façon dont nous avons implémenté et testé notre programme. Enfin, nous concluerons par une réflexion générale sur ce projet.

I Présentation du projet

1) À quoi servent les algorithmes génétiques

Les algorithmes génétiques, inspirés de la théorie de Darwin, permettent de tenter de résoudre des problèmes dont la solution exacte, si elle est connue, est trop longue à calculer : ce sont les problèmes NP complets (on ne connait pas de meilleur algorithme que d'essayer toutes les solutions possibles et de garder la meilleure). Voici quelques exemples d'application : problème du voyageur de commerce, du sac à dos, du dilemme du prisonnier, etc.

2) Comment fonctionnent les algorithmes génétiques ?

On crée au hasard une population initiale de solutions potentielles qui s'avèrent donc plus ou moins bonnes.

La population de solutions est alors soumise à une imitation de l'évolution des espèces : reproduction, mutation et sélection. Les individus se reproduisent entre eux et génèrent ainsi d'autres individus. Puis, parmi les individus, certains, choisis aléatoirement, subissent des mutations. Une mutation peut être vue comme un changement aléatoire d'une caractéristique de l'individu. Finalement s'applique une sélection naturelle, la sélection par le milieu d'évolution. Les individus les moins adaptés au milieu disparaissent. On réitère ce procédé un certain nombre de fois et en favorisant la survie des plus aptes (les solutions les plus correctes), on provoque l'apparition de meilleurs individus (Rappel : cette remarque n'est évidemment pas absolue puisque les algorithmes génétiques sont basés sur le hasard. Toutefois, les études empiriques sur le sujet montrent qu'on trouve tout de même des solutions acceptables au bout d'un nombre d'itérations suffisant).

Ce procédé a donc pour effet que les générations successives soient de plus en plus adaptées à la résolution du problème au fur et à mesure des itérations.

Ce principe n'est pas très difficile à implémenter. En effet, la difficulté réside plutôt dans le choix des paramètres de la reproduction,de la mutation, de la sélection, du nombre d'itérations, etc..

Schéma d'illustration :
Principe de l'algorithme génétique

Ce mécanisme surprenant a été rigoureusement validé mathématiquement dans les années 70. Il est ainsi prouvé qu'on arrive finalement à la meilleure solution possible. Bien sûr, ce finalement représente une durée qui dépend beaucoup de la qualité de la mise en oeuvre logicielle.

3) Le problème du voyageur de commerce

Le problème du voyageur de commerce est l'un des exemples les plus connus d'application de l'algorithme génétique. L'énoncé du problème est le suivant : un voyageur de commerce doit pour son travail traverser un ensemble de N villes. Il doit toutes les traverser une fois et une seule. Le problème est de trouver le ou les chemins les plus courts qui vérifient cette contrainte.

4) Le problème du sac à dos

Un autre problème de la même classe est connu sous le nom de problème du sac à dos (knapsack problem). On dispose d'un sac à dos de contenance maximale (volume) N et d'un ensemble P d'éléments qu'on souhaite ranger dans le sac, chacun de ces éléments prenant un certain volume. Selon les contraintes citées, on souhaite remplir le sac à dos au maximum.

II Répartition du travail

1) Méthodes de travail

Outils utilisés :

Nous avons décidé dès le début du projet d'utiliser CVS. Un préliminaire du travail a donc été de s'initier à cet outil qui nous a permis :

On peut critiquer l'intérêt d'utiliser CVS pour un groupe de 2 personnes seulement, mais outre l'intérêt, même seul, de conserver un historique du développement, l'objectif était aussi d'apprendre à le maîtriser pour les projets futurs où le nombre important de personnes rendra son utilisation pratiquement indispensable.

Nous avons aussi choisi d'utiliser l'EDI java Eclipse pour ses multiples aides au développement :

Démarche :

Nous avons passé un certain temps à bien comprendre le sujet du projet et à analyser les interfaces fournies avant de commencer à programmer. Puis, nous avons réfléchi aux différentes classes à implémenter pour traiter les différentes fonctionnalités du programme. Enfin, nous avons aussi réfléchi à l'implémentation des méthodes clés du projet (Individu.croisement() et Individu.mutation()) de sorte à avoir à chaque fois la meilleure complexité possible.

2) Répartition des tâches

Les groupes étaient tous des binômes, nous nous sommes partagé le travail de façon équitable :

III Architecture du programme

1) Présentation générale

Pour réaliser notre programme, nous avons réparti notre code source sur plusieurs paquetages :

IV Implémentation de notre programme

1) Moteur générique d'algorithme génétique

Cette partie a été la plus simple à programmer. En effet, le seul rôle de cette partie est de faire se croiser ou muter des individus choisis de façon aléatoire et d'en sélectionner une partie à chaque itération (être donc capable de les comparer). Les méthodes plus complexes sont évidemment plutôt celles situées au niveau Individu.

Nous allons toutefois donner pour les classes de ce paquetage les points sur lesquels nous avons tenté d'optimiser le code.

2) Application au problème du voyageur de commerce

La première partie ayant été implémentée, nous l'avons utilisée pour résoudre notre problème de parcours des villes.

Les villes sont numérotées de 0 à N-1. L'ensemble des villes et des routes les reliant est représenté par une matrice de N lignes par N colonnes contenant des entiers. Dans la matrice, sur la ligne i et à la colonne j, l'entier est la distance pour aller de la ville i à la ville j. L'absence de route entre deux villes est indiquée par le nombre de villes X la distance maximum d'une route reliant une ville à une autre directement. Cette valeur permet de savoir immédiatement si une route contient un chemin contient une transition invalide et est donc particulièrement utile pour les tests.

Nous avons donc écrit une classe GraphImpl implémentant l'interface Graph. Cette interface tente de standardiser les méthodes qui sont indispensables pour un graphe. De plus, si on désire créer une classe représentant un graphe avec des listes de suivants, cette méthode aura l'avantage de permettre d'interchanger de façon transparente les deux implémentations (si on n'a bien sûr pas ajouté dans une implémentation des méthodes qui soient inexistantes dans l'interface).

Pour pouvoir utiliser cette classe pour résoudre notre problème de parcours des villes avec l'algorithme génétique générique que nous avons implémenté dans la première partie, nous avons considéré qu'un chemin est un individu. Ainsi, nous avons écrit la classe Path implémentant l'interface Individu et représentant les chemins. Elle implémente principalement les méthodes de croisement et de mutation des individus.

2.1) Croisement

Après comparaison de différentes méthodes de croisement, nous avons opté pour celle du croisement en deux points. Etant donné deux parcours, on en construit un autre selon le principe suivant :

  1. On choisit aléatoirement deux points de découpe firstCurIndex et secondCutIndex en vérifiant firstCurIndex<secondCutIndex :
    sélection des deux points de découpe du croisement de deux chemins
  2. Pour favoriser la création de bon individus, on s'arrange pour que le chemin créé le soit avec le plus grand nombre de sommets issus de son meilleur parent. Ce principe a donné lieu au tableau suivant :
    Score père ≥ Score mère Score père < Score mère
    Intervalle ≥ moitié du nombre de sommets Échange Père/Mère Rien à faire
    Intervalle < moitié du nombre de sommets Rien à faire Échange Père/Mère
  3. On prend la partie du père qui se situe à l'extérieur de ces index :
    ([0;firstCutIndex[U]secondCutIndex;N[)
    et la partie de la mère se situant à l'intérieur :
    ([firstCutIndex;secondCutIndex])  :
    Début du croisement : création de    l'enfant avec une partie de la mère et du père.
  4. À l'extérieur des points de découpe (partie issue du père), on remplace les villes qui sont déjà placées entre les points de découpe (partie issue de la mère) par -1 :
    Suppression des sommets utilisés plusieurs fois
  5. On recense les villes qui n'apparaissent pas et on les ajoute dans les emplacements contenant -1 (par ordre croissant) :
    Ajout des sommets encore non utilisés

2.2) Mutation :

Il s'agit de modifier un des éléments constitutifs de la solution, ici c'est une ville. Quand un chemin doit être muté, on choisit aléatoirement deux index de villes, on intervertit les sommets correspondants. Cette méthode possède évidemment une complexité en O(1).

Schéma d'illustration :
Mutation d'un Path :  Échange de deux sommets

3) Résolution du problème du sac à dos

La première classe que nous avons implémentée est ReadUserInput qui permet la saisie et le stockage des éléments de P dans un tableau (tabEltP) et du volume du sac à dos dans une variable (backPackVolume). La seconde classe créée est Fill qui contient principalement un tableau (tabIndexEltP) des indices des éléments de P dans le tableau tabEltP et une variable indexQ qui permet d'obtenir les éléments de Q.

Nous considérons qu'une organisation d'indices des éléments de P est un individu. C'est donc pour cette raison que la classe Fill implémente l'interface Individu. Cette dernière déclare 3 fonctions dont les fonctions de croisement et de mutation. La méthode de croisement de Fill est très proche de celle implémentée pour résoudre le problème du voyageur de commerce. Quant à la méthode de mutation, elle s'en inspire aussi beaucoup.

3.1) Fonctionnement général du programme résolvant le problème du sac à dos :

Pour une plus grande rapidité du programme, nous réalisons plusieurs tests avant de lancer ou pas l'algorithme génétique. Ceux-ci sont destinés à vérifier que le calcul ne va pas être lancé alors qu'il existe une solution évidente et simple. Ils sont décrits par le schéma suivant : Schéma descriptif des conditions de lancement du programme

3.2) Application de l'algorithme génétique :

  1. Saisie des éléments de P : 2 4 28 6 1
    Stockage de ces éléments dans le tableau tabEltP :
    Tableau des éléments de P
  2. Saisie du volume du sac à dos : 36
    Stockage de ce volume dans la variable backPackVolume.
  3. Stockage aléatoire dans tabIndexEltP d'une organisation d'indices des éléments de P dans tabEltP :
    Exemples   d'organisations du tableau d'éléments P

Une organisation d'indices des éléments de P dans tabEltP dans tabIndexEltP est un individu. Pour générer ces différentes organisations aléatoirement on utilise la méthode PopulationImpl.shuffle() décrite précédemment.

Exemple d'une reproduction

La méthode utilisée est similaire à celle du voyageur de commerce :
Illustration de l'algorithme utilisé pour le croisement de deux sac à dos

Une fois l'individu obtenu, pour obtenir la liste des éléments de Q, on se sert de tabEltP, de tabIndexEltP et d'une variable nommée indexQ.

La reproduction précédente donne le tableau tabIndexEltP qui contient les indices 3 1 4 0 2 :

La variable indexQ, initialisée à 0, s'incrémente jusqu'à dépasser la valeur de backPackVolume (36). Soit 28+2+173>36, donc indexQ = 3. Au final, on décrémente indexQ de 1 (car l'indice de la 1ère case d'un tableau est toujours 0), donc elle vaut 2.

Exemple d'une mutation :

Il suffit de permuter un nombre dans tabIndexEltP qui a un indice inférieur à indexQ et un autre qui a un indice supérieur ou égal à indexQ :
Algorithme de mutation d'un sac à dos

Après chaque mutation, la variable indexQ est recalculée. Dans ce cas, elle vaut encore 2. Après une telle reproduction et une telle mutation, l'ensemble Q obtenu contient les éléments 28 1. S'afficheront donc à l'écran ces éléments.

V Fonctionnalités ajoutées

1) Gestion des éventuels paramètres

L'utilisateur pourra, s'il le souhaite, saisir les paramètres suivants  (cf :Mode d'emploi):

Une aide (-help) est fournie en cas de besoin.

Si l'utilisateur ne saisit aucun paramètre, le programme est lancé avec des paramètres par défaut.

Pour gérer les paramètres, nous avons implémenté la classe abstraite Parameters qui se trouve dans le paquetage Algogen. Nous l'avons fait hériter de deux classes :

2) Problème du voyageur de commerce : implémentation de la méthode genDotfile()

Au préalable, il vous faut le logiciel springgraph. Pour l'installer sous Debian GNU/Linux : apt-get install springgraph.

La méthode genDotfile() génère une image donnant une représentation graphique simple (sans les valuations) d'une matrice des villes créée. Elle retourne une chaîne de caractères.

Il y a deux possibilités pour générer une telle représentation :

Illustration :

Exemple de matrice d'un graphe
0 1 2 3
0 90 10 90 12
1 5 90 10 90
2 17 90 90 90
3 90 90 90 90

Représentation graphique du graphe ci-dessus générée avec springgraph :
Image du graphe

VI Test de notre programme

1) Voyageur de commerce

Pour tester la partie de notre programme qui concerne le voyageur de commerce, nous avons saisi la matrice de configuration des villes et des routes (entrée en dur dans le constructeur sans paramètre) fournie dans l'énoncé du projet. Sur cette matrice de configuration, la meilleure solution a pour coût 30. Lors de nos tests, il nous est effectivement arrivé de parvenir à trouver ce résultat (et évidemment rien d'inférieur).

Ce résultat nous a donc permis de savoir que notre algorithme était (ou en tout paraissait) correct. Cela a donc été la méthode de validation principale pour cette partie.

2) Sac à dos

Pour savoir si nos résultats étaient fiables, nous avons fait appel à un autre binôme. Avec ce binôme, nous avons lancé le programme avec un même ensemble P et un même entier naturel n. Nous avons le plus souvent trouvé des résultats indentiques ou très proches..

3) Gestion des bugs

Eclipse possède un debugger intégré. Cette option, ainsi que la méthode d'affichage System.out.println(..), nous ont permis de gagner du temps pour repérer plus rapidement ce qui n'allait pas dans le programme.

4) Optimisation du programme

Profiling : Xprof

Pour toutes les parties du projet, nous avons utilisé l'option Xprof de la machine virtuelle java, le profiler intégré (similaire à gprof par exemple). Nous avons ainsi pu analyser les parties à améliorer et éviter de passer inutilement du temps par exemple dans une méthode qui représenterait 0.1% du temps.

Exemple (extrait) d'affichage avec l'option Xprof :

Flat profile of 9.24 secs (270 total ticks): main
    Compiled + native   Method                        
 14.9%    18  +    22    fr.umlv.boubakermathus.commerce.Path.croisement
 13.4%     9  +    27    fr.umlv.boubakermathus.commerce.Path.mutation
 11.6%    21  +    10    java.util.Arrays.mergeSort
 10.4%    28  +     0    fr.umlv.boubakermathus.commerce.GraphImpl.pathCost
  7.1%    19  +     0    fr.umlv.boubakermathus.algogen.IndividuComparator.compare
  6.3%    17  +     0    fr.umlv.boubakermathus.commerce.GraphImpl.getDirectCost
  4.9%    13  +     0    fr.umlv.boubakermathus.commerce.Path.fonctionne
  4.1%    11  +     0    fr.umlv.boubakermathus.algogen.ScoreImpl.compareTo
  3.0%     8  +     0    java.util.Random.setSeed
  3.0%     8  +     0    java.util.Random.next
  3.0%     8  +     0    java.util.ArrayList.RangeCheck
  2.2%     6  +     0    java.util.ArrayList.set
  1.9%     5  +     0    java.util.Collections.sort
  1.9%     5  +     0    fr.umlv.boubakermathus.algogen.PopulationImpl.reproduction
  1.5%     4  +     0    java.util.ArrayList.get
  1.5%     4  +     0    java.util.Random.nextInt
  0.7%     2  +     0    java.util.Random.nextDouble
  0.7%     2  +     0    java.util.AbstractList$Itr.next
  0.7%     2  +     0    java.util.AbstractList$ListItr.set
  0.7%     1  +     1    fr.umlv.boubakermathus.algogen.PopulationImpl.mutation
  0.4%     1  +     0    java.util.ArrayList.ensureCapacity
 94.0%   192  +    60    Total compiled

Ici, on remarque évidemment que les méthodes dans lesquelles on passe le plus de temps sont croisement() et mutation(), ce qui est un résultat attendu. Avant de faire une amélioration significative, nous voyions apparaître la méthode compare() en troisième position avec environ 13 à 14% du temps total. C'est grâce à cela que nous avons défini un objet IndividuComparator déclaré public static final dans PopulationImpl que nous avons passé à la méthode Collections.sort() (dans la méthode PopulationImpl.sortPopulation()) au lieu de new IndividuComparator() comme précédemment.

5) Choix des paramètres par défaut

Nous avons fait de multiples essais en faisant varier différents paramètres pour observer l'impact de chacun de ces paramètres sur la "qualité" de la solution trouvée et le temps de calcul nécessaire.

Gnuplot

Afin de déterminer ces paramètres, nous avons voulu voir l'évolution des courbes de progression des scores de la population.

Pour ce faire, nous avons décidé d'utiliser l'outil de génération de graphiques gnuplot. Or il nous fallait tout d'abord générer les données à fournir à celui-ci. Il nous a très vite semblé évident qu'il serait impossible de changer manuellement les coefficients de reproduction, de mutation et le nombre d'itérations pour faire tous les différents tests. Nous avons donc écrit la méthode Algogen.testValues() qui permet de générer automatiquement tous ces fichiers selon les différents intervalles et les pas (steps) spécifiés.

Après comparaison des courbes, nous avons donc décidé de fixer les paramètres suivants pour le voyageur de commerce :

Pour le sac à dos, nous avons choisi des paramètres identiques à ceux précédemment :

Voir les graphiques et les commentaires

6) Exemples d'exécution

Voir les exemples d'exécution

VII Réflexion générale sur le projet

1) Apport de la programmation Orienté Objet pour ce type de projet

1.1)Définition de la programmation Orienté Objet

JAVA est un Langage Orienté Objet (LOO). L' Orienté Objet signifie manipuler uniquement des objets c'est-à-dire des ensembles groupés de variables et de méthodes associées à des entités intégrant naturellement ces variables et ces méthodes.

1.2) Avantages de l'OO

L' OO présente plusieurs avantages : facilité d'organisation de classes, réutilisation, méthode plus intuitive, facilité de répartition du travail et de correction, projet plus facile à gérer. L'intérêt principal de l'OO réside dans le fait que l'on ne décrit plus par le code des actions à réaliser de façon linéaire mais par des ensembles cohérents appelés objets.

L' OO est facilement concevable car il décrit des entités comme il en existe dans le monde réel. Ainsi, l'objet ScoreImpl qui implémente la méthode ScoreImpl.compareTo() est facilement concevable et peut être utilisé, par exemple, dans un jeu de combat où on fera interagir plusieurs objets ScoreImpl. Les modèles à objets ont été créés pour modéliser le monde réel. "Dans un modèle à objets, toute entité du monde réel est un objet, et réciproquement, tout objet représente une entité du monde réel".

L' OO permet en quelque sorte de factoriser le code en ensembles logiques. Du point de vue de la programmation, l' OO nous a permis d'écrire du code facilement lisible de taille minimale et à la correction aisée.

Les informations concernant un domaine étant centralisées en objets, il est facile de sécuriser le programme en interdisant ou en autorisant l'accès à ces objets aux autres parties du programme.

2) Pourquoi l'algorithme génétique est-il une pratique intéressante ?

L'algorithme présente une approche un peu brutale, nécessitant une grande puissance de calcul (mais pour cela les ordinateurs contemporains sont largement suffisants), mais présentant l'immense avantage pratique de fournir des solutions pas trop éloignées de l'optimal, même si l'on ne connaît pas de méthode de résolution. Par exemple, pour le problème du voyageur de commerce, l'approche naïve est de calculer tous les itinéraires, puis de choisir le plus court chemin reliant un certain nombre de villes données. Un calcul rapide indique cependant que pour plus de quelques dizaines de villes, le nombre de possibilités comporte des centaines de zéros ! La méthode naïve est donc inutilisable pour des problèmes réalistes. La branche de la technique qui cherche à résoudre ce genre de problème porte le nom de «recherche opérationnelle». Contrairement à la recherche opérationnelle, l'algorithme génétique n'exige aucune connaissance de la manière dont résoudre le problème; il est seulement nécessaire de pouvoir évaluer la qualité d'une solution. Il est également léger à mettre en oeuvre (le moteur est commun, il y a moins de programmation spécifique au problème à faire).

Le seul reproche qu'on puisse faire aux algorithmes génétiques est qu'au vu de la part de hasard à la base du principe, on ne peut pas garantir que la ou les solutions trouvées soient les meilleures. On peut donc itérer indéfiniment jusqu'à estimer que la solution est acceptable, mais définir cet acceptable peut se réveler délicat.

Le tableau suivant récapitule les différences entre une approche de recherche opérationnelle et la résolution du même problème par l'algorithme génétique :

Recherche Opérationnelle (approche ad hoc, analytique, spécifique) Approche génétique
Rapidité Selon solution, parfois bonne Bonne à très bonne
Performance Selon solution Bonne
Compréhension du problème Nécessaire Non nécessaire
Travail humain Très variable : de quelques jours à quelques thèses Faible : Quelques jours ou semaines
Applicabilité Faible : La plupart des problèmes intéressants n'ont pas d'expression mathématique exploitable, ou sont non calculables, ou "NP-complets" (trop de possibilités). Générale
Etapes intermédiaires? ne sont pas des solutions (il faut attendre la fin des calculs) sont des solutions (le processus peut être interrompu à tout moment)

Plus le problème est complexe, plus l'algorithme génétique montre sa supériorité.

Conclusion

Ce projet JAVA est l'aboutissement d'un trimestre d'enseignement de ce langage. La réalisation de ce projet fut, pour nous, une réelle source d'enrichissement. Il nous a permis d'approfondir nos connaissances concernant ce langage (classes, interfaces, javadoc ...) et la programmation Orienté Objet. Sur le plan relationnel, il a nous a permis de travailler en groupe, ce qui est important pour notre future vie professionnelle. Tout le travail demandé a été réalisé avec succès et nous nous en réjouissons.

Nous tenons de plus à remercier les professeurs pour ce sujet si intéressant et important pour notre culture générale informatique. En effet, plutôt que de nous avoir demandé de réaliser un projet uniquement destiné à nous apprendre le langage, nous avons aussi pu découvrir un des domaines de recherche informatique les plus mystérieux et passionnant.

Connaître des techniques de résolution de problèmes NP complets tels que les algorithmes génétiques ou le recuit simulé par exemple sont en effet des éléments non négligeables pour un informaticien.

Liens utiles

Page valide XHTML 1.0 Strict.