A la fin de ce notebook, vous saurez : 1. Formuler la resolution de Sudoku comme un problème d’optimisation 2. Définir une fonction d’energie comptant les violations de contraintes 3. Construire un voisinage par echange de cellules 4. Implementer un solveur par recuit simule avec la bibliotheque simanneal 5. Analyser les forces et limites du recuit simule pour le Sudoku
Ce notebook implemente un solveur de Sudoku utilisant le recuit simule (Simulated Annealing) en Python. C’est l’equivalent Python du notebook C# Sudoku-04-SimulatedAnnealing-CSharp.ipynb.
Introduction
Le recuit simule (Simulated Annealing, SA) est une métaheuristique inspiree du processus metallurgique de recuit : un metal est chauffe puis refroidi lentement pour atteindre un etat cristallin optimal.
Principe
Etat : Une grille entierement remplie (potentiellement avec des erreurs)
Energie : Mesure le nombre de violations de contraintes
Temperature : Contrôle l’acceptation de mouvements degradants
Refroidissement : Reduit progressivement la temperature
Critere d’acceptation de Metropolis
\[P(\text{accepter}) = \begin{cases} 1 & \text{si } \Delta E \leq 0 \\ e^{-\Delta E / T} & \text{si } \Delta E > 0 \end{cases}\]
ou \(\Delta E = E(\text{voisin}) - E(\text{courant})\) et \(T\) est la temperature courante.
Installation
pip install simanneal numpy matplotlib
# Importsimport numpy as npimport timeimport randomimport mathfrom typing import List, Tuple, Optional# Note: La bibliotheque simanneal est optionnelle# Ce notebook utilise principalement l'implementation manuelletry:from simanneal import Annealer SIMANNEAL_AVAILABLE =Trueprint(f"simanneal importe avec succes")exceptImportError: SIMANNEAL_AVAILABLE =Falseprint("simanneal non installe (optionnel). Utilisation de l'implementation manuelle.")
simanneal importe avec succes
Configuration du chemin vers les fichiers de puzzles.
# Configuration du chemin vers les puzzlesimport osfrom pathlib import Path# Resolution robuste du repertoire du notebook (CWD peut differer sous Papermill)_here = Path().resolve()for _candidate in [_here, _here /"MyIA.AI.Notebooks"/"Sudoku", _here.parent /"Sudoku"]:if (_candidate /"Puzzles").exists(): _here = _candidatebreakNOTEBOOK_DIR = _herePUZZLES_DIR = NOTEBOOK_DIR /"Puzzles"if PUZZLES_DIR.exists():print(f"Dossier Puzzles: {PUZZLES_DIR.name}")else:print(f"ATTENTION: Dossier Puzzles non trouve a {PUZZLES_DIR.name}") PUZZLES_DIR = Path(os.getcwd()) /"Puzzles"
Dossier Puzzles: Puzzles
1. Classe SudokuGrid
Representation d’une grille de Sudoku avec méthodes pour calculer l’energie et generer des voisins.
class SudokuGrid:"""Representation d'une grille de Sudoku 9x9."""def__init__(self, grid: Optional[np.ndarray] =None):if grid isNone:self.cells = np.zeros((9, 9), dtype=int)else:self.cells = grid.copy()@classmethoddef from_string(cls, s: str) ->'SudokuGrid': s = s.replace('.', '0').replace(' ', '').replace('\n', '')iflen(s) !=81:raiseValueError(f"La chaine doit avoir 81 caracteres") grid = cls() grid.cells = np.array([int(c) for c in s], dtype=int).reshape(9, 9)return griddef clone(self) ->'SudokuGrid':return SudokuGrid(self.cells.copy())def to_string(self) ->str:return''.join(str(self.cells[r, c]) for r inrange(9) for c inrange(9))def__str__(self) ->str: lines = []for r inrange(9):if r >0and r %3==0: lines.append('-'*21) row_str =''for c inrange(9):if c >0and c %3==0: row_str +='| ' val =self.cells[r, c] row_str += (str(val) if val !=0else'.') +' ' lines.append(row_str)return'\n'.join(lines)def load_puzzles(filepath: str, max_puzzles: int=None) -> List[str]: puzzles = []withopen(filepath, 'r') as f:for line in f: line = line.strip()iflen(line) >=81: puzzles.append(line[:81])if max_puzzles andlen(puzzles) >= max_puzzles:breakreturn puzzles# Charger les puzzleseasy_puzzles = load_puzzles(str(PUZZLES_DIR /'Sudoku_Easy51.txt'), max_puzzles=5)print(f"Puzzles charges: {len(easy_puzzles)}")test_grid = SudokuGrid.from_string(easy_puzzles[0])print("\nGrille de test:")print(test_grid)
Lecture du corpus : 5 puzzles, la grille témoin en tête
5 puzzles chargés, et la grille de test affichée est la grille témoin de la série — comptez : 9,2,5,4,3 en première ligne, 45 indices au total, le même puzzle que Sudoku-06 (AIMA/MAC) et Sudoku-11 (Choco). L’uniformité du témoin est précieuse : quand ce notebook mesurera son solveur, la comparaison avec les autres approches de la série portera sur des entrées identiques. Le point . marque une cellule libre — 36 déductions à faire ici, mais pour le recuit simulé ce ne sont pas des déductions : c’est une configuration initiale à perturber.
2. Fonction d’Energie
L’energie compte le nombre de violations de contraintes dans les colonnes et les blocs 3x3. Les lignes sont garanties valides par construction (permutations).
Exercice : Calculer l’energie d’une grille partiellement remplie
Objectif Implementez une variante de compute_energy qui calcule le nombre de conflits uniquement pour les lignes, colonnes et blocs non complets.
Indice Parcourir chaque ligne/colonne/bloc et compter les doublons parmi les valeurs non nulles.
# EXERCICE : Calculer l'energie d'une grille partiellement rempliedef compute_partial_energy(grid: np.ndarray) ->int:# TODO: Compter les conflits (doublons) uniquement dans les# lignes/colonnes/blocs qui contiennent des valeurs non nulles result =0# TODO etudiantreturn resultprint("Exercice a completer")
Exercice a completer
def compute_energy(grid: np.ndarray) ->int:"""Calcule l'energie d'une grille (nombre de doublons colonnes + blocs).""" energy =0# Doublons dans les colonnesfor col inrange(9): values = grid[:, col] counts = np.bincount(values[values >0], minlength=10) energy +=int(np.sum(np.maximum(counts[1:] -1, 0))) # doublons uniquement (clamp: valeur absente = 0)# Doublons dans les blocs 3x3for box_row inrange(3):for box_col inrange(3): block = grid[box_row*3:(box_row+1)*3, box_col*3:(box_col+1)*3].flatten() counts = np.bincount(block[block >0], minlength=10) energy +=int(np.sum(np.maximum(counts[1:] -1, 0))) # doublons uniquement (clamp: valeur absente = 0)return energyprint("Fonction compute_energy definie.")print(f"Energie de la grille de test: {compute_energy(test_grid.cells)}")
Fonction compute_energy definie.
Energie de la grille de test: 0
Interpretation : Fonction d’energie
Aspect
Valeur
Signification
Energie = 0
Solution valide
Aucun doublon colonnes/blocs
Energie > 0
Grille invalide
Nombre total de violations
L’objectif est de reduire l’energie a 0 par echanges successifs.
Fonction d’energie : on compte, pour chaque colonne et chaque bloc 3x3, le nombre de doublons (counts[v] - 1 pour les valeurs presentes, 0 pour les valeurs absentes via np.maximum(..., 0)). Les lignes sont valides par construction (permutations de 1-9), donc seules les colonnes et les blocs peuvent porter des violations. L’energie de la grille de test (partiellement remplie) vaut 0 ci-dessus car les cellules fixees par le puzzle ne créent pas encore de conflit ; en revanche, une fois la grille completee par permutations (cellule suivante), l’energie devient positive et le recuit doit la minimiser.
3. Initialisation de la Grille
Chaque ligne est initialisee comme une permutation de 1-9, en respectant les cellules fixes.
def initialize_grid(puzzle: SudokuGrid, rng: random.Random) -> Tuple[np.ndarray, np.ndarray]:"""Initialise une grille avec permutations par ligne.""" grid = puzzle.cells.copy() is_fixed = puzzle.cells >0for row inrange(9):# Identifier les valeurs fixes et manquantes fixed_values =set(grid[row, grid[row] >0]) missing_values =list(set(range(1, 10)) - fixed_values) empty_cols = [c for c inrange(9) if grid[row, c] ==0]# Melanger les valeurs manquantes rng.shuffle(missing_values)# Remplir les cellules videsfor i, col inenumerate(empty_cols): grid[row, col] = missing_values[i]return grid, is_fixedrng = random.Random(42)init_grid, is_fixed = initialize_grid(test_grid, rng)print("Grille initialisee (chaque ligne est une permutation de 1-9):")print(SudokuGrid(init_grid))print(f"\nEnergie initiale: {compute_energy(init_grid)}")print(f"Objectif: energie = 0")
Lecture de l’initialisation : des lignes parfaites pour concentrer le défaut
La grille initiale est construite en complétant chaque ligne par une permutation de 1-9 (les indices fixés en place, les trous remplis). Conséquence immédiate : chaque ligne contient 1-9 exactement une fois — l’énergie des lignes est nulle par construction, et tout le conflit se concentre dans les colonnes et les blocs. D’où le 29 de l’énergie initiale : comptez la première colonne de la sortie — 9, 1, 5, 4, 3, 1, 2, 7, 2 : deux 1 et deux 2, quatre conflits rien qu’ici. Cette initialisation n’est pas neutre : en réduisant l’espace de recherche aux permutations de lignes (au lieu de toutes les grilles), elle rend le voisinage « échange dans une ligne » fermé — un échange ne peut jamais casser une ligne, seulement en déplacer les conflits.
Notez enfin le choix pédagogique de l’énergie 29 : elle n’est pas nulle, mais elle est petite et lisible — on peut la décomposer à la main (4 conflits dans la seule colonne 1, le reste en colonnes 3-9 et blocs). C’est cette lisibilité qui permet de suivre l’algorithme pas à pas dans les sections suivantes, là où une initialisation aléatoire produirait une énergie de l’ordre de 150 impossible à décortiquer.
Exercice : Generer un voisin par echange de bloc
Objectif Implementez un opérateur de voisinage qui echange deux valeurs non fixees dans le même bloc 3x3 (au lieu de la même ligne).
Indice Choisissez un bloc aleatoire, puis deux cellules non fixees dans ce bloc, et echangez leurs valeurs.
# EXERCICE : Generer un voisin par echange de blocdef generate_block_neighbor(grid: np.ndarray, is_fixed: np.ndarray, rng: random.Random) -> Tuple[np.ndarray, Tuple]:# TODO: Echangez deux valeurs non fixees dans un meme bloc 3x3# Retournez (nouvelle_grille, position_echangee) result =None# TODO etudiantreturn resultprint("Exercice a completer")
Exercice a completer
4. Voisinage par Echange
Le voisinage est défini par l’echange de deux cellules non fixes dans une même ligne. Cela preserve la propriete de permutation de la ligne.
def generate_neighbor(grid: np.ndarray, is_fixed: np.ndarray, rng: random.Random) -> Tuple[int, int, int]:"""Genere un voisin par echange de deux cellules non fixes dans une ligne. Returns: (row, col1, col2): Ligne et colonnes echangees """# Choisir une ligne avec au moins 2 cellules non fixeswhileTrue: row = rng.randint(0, 8) free_cols = [c for c inrange(9) ifnot is_fixed[row, c]]iflen(free_cols) >=2:break# Choisir deux cellules distinctes col1, col2 = rng.sample(free_cols, 2)# Effectuer l'echange grid[row, col1], grid[row, col2] = grid[row, col2], grid[row, col1]return row, col1, col2def undo_swap(grid: np.ndarray, row: int, col1: int, col2: int):"""Annule un echange.""" grid[row, col1], grid[row, col2] = grid[row, col2], grid[row, col1]# Demonstrationtest_init = init_grid.copy()energy_before = compute_energy(test_init)swap_row, swap_col1, swap_col2 = generate_neighbor(test_init, is_fixed, rng)energy_after = compute_energy(test_init)print(f"Echange effectue : ligne {swap_row}, colonnes {swap_col1} <-> {swap_col2}")print(f"Energie avant : {energy_before}, Energie apres : {energy_after}, Delta : {energy_after - energy_before}")
Echange effectue : ligne 2, colonnes 4 <-> 8
Energie avant : 29, Energie apres : 30, Delta : 1
Lecture de l’échange : un pas de marche aléatoire, delta +1
Le mouvement élémentaire du recuit est affiché : échanger les colonnes 4 et 8 dans la ligne 2 — deux cellules non fixes de la même ligne. L’énergie passe de 29 à 30, delta +1 : le mouvement dégrade. Et c’est précisément ce que le recuit simulé accepte de faire, avec une probabilité qui décroît avec la température — accepter occasionnellement un recul est ce qui distingue le recuit d’une gloutonne pure et ce qui lui permet de sortir des minimums locaux. La conception du voisinage (échange intra-ligne) garantit qu’aucun pas ne peut produire une ligne invalide : l’espace exploré est exactement l’ensemble des grilles à lignes-permutations, l’énergie ne mesure que colonnes et blocs.
La règle d’acceptation qui décide de ce recul est celle de Metropolis : accepter un delta positif avec probabilité exp(-delta/T) — forte au début (T élevée, exploration), quasi nulle à la fin (T bas, exploitation). Le delta +1 mesuré ici sera donc accepté en début de refroidissement et refusé en fin de course, selon le moment où le tirage a lieu.
5. Solveur par Recuit Simule avec simanneal (Optionnel)
La bibliotheque simanneal fournit un cadre pour implementer facilement le recuit simule. Cette section est optionnelle - si simanneal n’est pas installe, passez a la section 7.
if SIMANNEAL_AVAILABLE:class SudokuAnnealer(Annealer):"""Solveur de Sudoku par recuit simule utilisant simanneal."""def__init__(self, puzzle: SudokuGrid):# Etat initial : grille avec permutations par ligne rng = random.Random(42)self.puzzle_cells = puzzle.cells.copy()self.is_fixed = puzzle.cells >0 initial_state, _ = initialize_grid(puzzle, rng)# Convertir en tuple pour simanneal (etat doit etre hashable)self.state_shape = initial_state.shapesuper().__init__(initial_state=tuple(initial_state.flatten()))def move(self):"""Genere un voisin par echange.""" state = np.array(self.state).reshape(self.state_shape)# Choisir une ligne avec au moins 2 cellules non fixes rows_with_free = [r for r inrange(9) if np.sum(~self.is_fixed[r, :]) >=2]ifnot rows_with_free:return row = random.choice(rows_with_free) free_cols = [c for c inrange(9) ifnotself.is_fixed[row, c]] col1, col2 = random.sample(free_cols, 2)# Effectuer l'echange state[row, col1], state[row, col2] = state[row, col2], state[row, col1]self.state =tuple(state.flatten())def energy(self):"""Calcule l'energie de l'etat actuel.""" state = np.array(self.state).reshape(self.state_shape)return compute_energy(state)print("Classe SudokuAnnealer definie.")else:print("simanneal non disponible - passez a la section 7 pour l'implementation manuelle.")
Classe SudokuAnnealer definie.
6. Test sur un Puzzle Facile
print("=== Test : Puzzle Facile ===")puzzle = SudokuGrid.from_string(easy_puzzles[0])print("Puzzle original:")print(puzzle)print(f"Cellules vides: {np.sum(puzzle.cells ==0)}")if SIMANNEAL_AVAILABLE:# Creer et executer le recuit simule avec simanneal annealer = SudokuAnnealer(puzzle) annealer.Tmax =1.0# Temperature initiale annealer.Tmin =0.001# Temperature minimale annealer.steps =50000# Nombre d'iterations annealer.updates =100# Afficher tous les 100 pas start = time.time() state, e = annealer.anneal() elapsed = time.time() - start result = SudokuGrid(np.array(state).reshape(9, 9)) final_energy = compute_energy(result.cells)print(f"\nSolution trouvee en {elapsed:.2f}s (avec simanneal):")print(result)print(f"\nEnergie finale: {final_energy}")print(f"Solution valide: {final_energy ==0}")else:print("simanneal non disponible - voir section 7 pour l'implementation manuelle")
Le recuit simule trouve la solution pour ce puzzle facile
Dynamique
Energie 29 -> 0 : le solveur explore reellement le paysage
Non-determinisme
Chaque exécution peut donner un résultat différent
Point cle : contrairement au backtracking, le recuit simule n’est pas garanti de trouver la solution.
Lecture de la trace : l’energie part de 29 (la grille initialisee par permutations viole les contraintes colonnes/blocs), chute a 14 des T=0.93 (Accept 30.6%, Improve 12.4%), y stagne a T=0.87, puis descend par paliers — 8 a T=0.81, 5 a T=0.76 — jusqu’a 2 vers T=0.71. Le run touche 0 des T=0.58, ne s’y tient pas : il remonte a 2 a T=0.54 avant de retomber a 0 a T=0.50 et d’y rester jusqu’a Tmin. Les colonnes Accept (taux de mouvements acceptes, y compris degradants) et Improve (taux de mouvements ameliorants) sont non triviales au debut (maximum mesure : Accept 30.6%, Improve 12.4% a T=0.93) puis s’effondrent quand T devient petit : c’est la signature d’un vrai recuit – exploration a haute temperature, exploitation a basse temperature. La solution finale Energie finale: 0 est ici veritablement valide (fonction d’energie correcte).
7. Implementation Manuelle du Recuit Simule
Pour mieux comprendre l’algorithme, implementons-le sans utiliser simanneal.
Lecture du test manuel : 0,21 s, et la solution canonique retrouvée
L’implémentation manuelle (sans bibliothèque) résout le puzzle témoin en 0,21 s. Vérifiez la solution affichée : 9 6 2 | 1 8 5 | 4 7 3 en première ligne — c’est exactement la solution canonique du témoin partagé, celle que MAC (Sudoku-06) et Choco (Sudoku-11) produisent sur la même grille. Trois familles d’algorithmes — recherche exhaustive avec inférence, solveur industriel par contraintes, métaheuristique stochastique — convergent vers la même grille : sur un problème à solution unique, la cohérence inter-méthodes est un contrôle de validité gratuit.
8. Benchmark sur Plusieurs Puzzles
Exercice : Comparer les schemas de refroidissement
Objectif Comparez le schema de refroidissement lineaire et exponentiel sur les mêmes puzzles.
Étape 1 Implementez un schema de refroidissement exponentiel: T = T_init * alpha^step Étape 2 Lancez les benchmarks et comparez les taux de succes.
# EXERCICE : Comparer les schemas de refroidissementdef exponential_cooling(T_init: float, T_min: float, alpha: float, steps: int) -> List[float]:# TODO: Generez une liste de temperatures selon le schema exponentiel# T_i = T_init * alpha^i, arretez quand T < T_min result = [] # TODO etudiantreturn resultprint("Exercice a completer")
Lecture : les 3 puzzles faciles sont resolus (3/3), mais le temps de resolution croit fortement avec le nombre de cellules vides (mesure en direct par la cellule benchmark_sa (Section 8) — le recuit simule est stochastique, les valeurs exactes varient d’un run a l’autre). Le recuit simule finit par trouver la solution sur ces instances, mais reste considerablement plus lent que le backtracking (qui resout ces mêmes puzzles en millisecondes, voir la comparaison ci-dessous).
Fourchettes indicatives (au-dela du benchmark ci-dessus) : Easy 60-100% de succes en quelques secondes ; Medium 10-50% en quelques dizaines de secondes ; Hard < 10% de succes et considerablement plus lent. Ces ordres de grandeur dependent fortement des paramètres (T0, alpha, itérations) et de la fonction de voisinage.
Points clés : 1. Le recuit simule ne garantit pas de trouver la solution 2. Les performances dependent fortement du reglage des paramètres 3. Pour les puzzles difficiles, les solveurs CSP (OR-Tools, Z3) restent plus fiables
9. Comparaison avec Backtracking
# Simple backtracking pour comparaisonclass SimpleBacktracking:"""Simple backtracking pour comparaison."""def__init__(self):self.call_count =0def solve(self, puzzle: SudokuGrid) ->bool: grid = puzzle.cells.copy()self.call_count =0returnself._backtrack(grid)def _backtrack(self, grid: np.ndarray) ->bool:self.call_count +=1# Trouver case videfor r inrange(9):for c inrange(9):if grid[r, c] ==0:for num inrange(1, 10):ifself._is_valid(grid, r, c, num): grid[r, c] = numifself._backtrack(grid):returnTrue grid[r, c] =0returnFalsereturnTruedef _is_valid(self, grid: np.ndarray, row: int, col: int, num: int) ->bool:# Vérifier ligneif num in grid[row, :]:returnFalse# Vérifier colonneif num in grid[:, col]:returnFalse# Vérifier bloc br, bc =3* (row //3), 3* (col //3)if num in grid[br:br+3, bc:bc+3]:returnFalsereturnTrue# Durees du run courant, capturees pour l'exercice de la derniere cellule :T_BT_MS_MESURE: List[float] = []T_SA_MS_MESURE: List[float] = []# Comparaisonprint("=== Comparaison : Backtracking vs Recuit Simule ===")for i, puzzle_str inenumerate(easy_puzzles[:3]):print(f"\nPuzzle {i+1}:")# Backtracking grid = SudokuGrid.from_string(puzzle_str) bt = SimpleBacktracking() start = time.time() bt.solve(grid) t_bt = time.time() - startprint(f" Backtracking: {bt.call_count} appels, {t_bt*1000:.1f} ms") T_BT_MS_MESURE.append(t_bt *1000)# Recuit simule grid = SudokuGrid.from_string(puzzle_str) solver = SimulatedAnnealingSolver() start = time.time() result, solved = solver.solve(grid, max_restarts=5) t_sa = time.time() - start status ="OK"if solved else"Echec"print(f" Recuit Simule: {status}, {t_sa*1000:.0f} ms") T_SA_MS_MESURE.append(t_sa *1000)
=== Comparaison : Backtracking vs Recuit Simule ===
Puzzle 1:
Backtracking: 49 appels, 5.1 ms
Recuit Simule: OK, 369 ms
Puzzle 2:
Backtracking: 201 appels, 17.0 ms
Recuit Simule: OK, 31286 ms
Puzzle 3:
Backtracking: 295 appels, 33.0 ms
Recuit Simule: OK, 628478 ms
Lecture de la comparaison : pourquoi le recuit perd de quatre ordres de grandeur
Le tableau est sans appel, lisez-le puzzle par puzzle. Puzzle 1 : backtracking 49 appels / 5,1 ms, recuit 369 ms — déjà 72× plus lent. Puzzle 2 : 201 appels / 17,0 ms contre 31 286 ms — 1 840×. Puzzle 3 : 295 appels / 33,0 ms contre 628 478 ms, soit plus de dix minutes — un facteur ~19 000. Et pendant ce temps le backtracking n’a jamais dépassé 295 appels récursifs.
La leçon n’est pas « le recuit est mauvais » mais le recuit est le mauvais moteur pour ce problème. Le Sudoku est un problème à contraintes dures, fortement structuré, où l’inférence logique (propagation, arc-consistance) élimine des continents entiers de l’espace de recherche avant même de compter un pas. Le recuit, lui, n’exploite aucune structure : il perturbe, mesure, accepte ou refuse — adapté aux paysages continus ou aux problèmes sans inférence exploitable, désarmé ici. C’est le critère Prong B en acte : choisir le moteur SOTA approprié à la structure du problème, et savoir lire dans un benchmark quand il ne l’est pas.
Le contraste puzzle 2 / puzzle 3 est lui-même instructif : 31 s contre 628 s — un facteur 20 entre deux instances du même problème pour le recuit, alors que le backtracking passe de 17,0 à 33,0 ms (facteur 2). Une métaheuristique stochastique a une variance d’exécution énorme selon le paysage local : une grille dont les minimums locaux sont profonds la retient des dizaines de milliers d’itérations. L’exercice 1 (reheat — réchauffage) attaque exactement ce défaut : ré-augmenter la température quand la recherche stagne pour sortir du piège. Aucune de ces rustines ne changera l’ordre de grandeur : sur un problème où l’inférence logique est disponible, le bon moteur reste la recherche avec propagation.
10. Exercices
Exercice 1 : Rechauffement (Reheating)
Le recuit simule peut rester bloque dans des optima locaux. Le rechauffement consiste a remonter la temperature pour relancer l’exploration.
Objectif : Etendre SimulatedAnnealingSolver pour ajouter un mécanisme de rechauffement.
Indices : 1. Suivez l’energie a chaque palier de temperature 2. Si l’energie ne diminue pas pendant stagnation_threshold paliers consecutifs, remontez la temperature 3. Le facteur reheat_factor (ex: 0.5) determine a quel niveau remonter : T = T0 * reheat_factor 4. Après rechauffement, continuez le refroidissement normal
Verification : Testez sur un puzzle du corpus Sudoku_top95.txt, plus difficile qu’Easy51 (load_puzzles(str(PUZZLES_DIR / 'Sudoku_top95.txt'), max_puzzles=1)), avec et sans rechauffement. Le rechauffement devrait augmenter le taux de succes.
class SimulatedAnnealingWithReheatSolver(SimulatedAnnealingSolver):"""Solveur avec rechauffement pour echapper aux optima locaux."""def__init__(self, T0: float=1.0, alpha: float=0.999, iterations_per_temp: int=100, Tmin: float=0.001, stagnation_threshold: int=50, reheat_factor: float=0.5):super().__init__(T0, alpha, iterations_per_temp, Tmin)# Exercice: Initialiser les parametres de rechauffement# self.stagnation_threshold = stagnation_threshold# self.reheat_factor = reheat_factorpassdef solve(self, puzzle: 'SudokuGrid', max_restarts: int=5):# Exercice: Copier le code de SimulatedAnnealingSolver.solve() et ajouter# la detection de stagnation et le rechauffement :# stagnation_count = 0# energy_before_palier = current_energy# ... (boucle interne) ...# if current_energy >= energy_before_palier:# stagnation_count += 1# else:# stagnation_count = 0# if stagnation_count >= self.stagnation_threshold:# T = self.T0 * self.reheat_factor # Rechauffement# stagnation_count = 0# else:# T *= self.alphapass# Test de votre implementationpuzzle = SudokuGrid.from_string(easy_puzzles[0])# solver = SimulatedAnnealingWithReheatSolver(stagnation_threshold=50, reheat_factor=0.5)# result, solved = solver.solve(puzzle)# print(f"Resolu: {solved}")print("TODO: Implementez SimulatedAnnealingWithReheatSolver")
Le taux de refroidissement optimal depend du puzzle. Un contrôle adaptatif peut ajuster alpha dynamiquement selon le comportement du solveur.
Objectif : Adapter alpha en fonction du taux d’acceptation des mouvements.
Indices : 1. A chaque palier, calculez le taux d’acceptation : accepted_moves / total_moves 2. Si le taux est eleve (> 0.8), refroidir plus vite : alpha = min(0.9999, alpha * 1.0001) 3. Si le taux est faible (< 0.2), refroidir plus lentement : alpha = max(0.95, alpha * 0.9999) 4. alpha reste dans [0.95, 0.9999]
Verification : Comparez alpha constant vs adaptatif sur 10 puzzles du corpus Sudoku_top95.txt (load_puzzles(str(PUZZLES_DIR / 'Sudoku_top95.txt'), max_puzzles=10)). Mesurez le taux de succes moyen.
class AdaptiveSimulatedAnnealingSolver(SimulatedAnnealingSolver):"""Solveur avec taux de refroidissement adaptatif."""def solve(self, puzzle: 'SudokuGrid', max_restarts: int=5): rng = random.Random(42) best_grid =None best_energy =float('inf')for restart inrange(max_restarts): grid, is_fixed = initialize_grid(puzzle, rng) current_energy = compute_energy(grid) T =self.T0 alpha =self.alpha # alpha variable maintenantwhile T >self.Tmin and current_energy >0: accepted =0 total =0for _ inrange(self.iterations_per_temp):# Exercice: Generer voisin et appliquer critere de Metropolis# row, col1, col2 = generate_neighbor(grid, is_fixed, rng)# new_energy = compute_energy(grid)# delta_E = new_energy - current_energy# if delta_E <= 0 or rng.random() < math.exp(-delta_E / T):# accepted += 1# current_energy = new_energy# else:# undo_swap(grid, row, col1, col2) total +=1# Exercice: Adapter alpha selon le taux d'acceptation# acceptance_rate = accepted / total if total > 0 else 0# if acceptance_rate > 0.8:# alpha = min(0.9999, alpha * 1.0001)# elif acceptance_rate < 0.2:# alpha = max(0.95, alpha * 0.9999) T *= alphapassreturn best_grid, best_energy ==0# Test de votre implementationpuzzle = SudokuGrid.from_string(easy_puzzles[0])solver = AdaptiveSimulatedAnnealingSolver()# result, solved = solver.solve(puzzle)# print(f"Resolu: {solved}")print("TODO: Implementez AdaptiveSimulatedAnnealingSolver")
Le voisinage par echange intra-ligne est local. Des mouvements plus globaux peuvent aider a echapper aux optima locaux.
Objectif : Implementer un voisinage etendu avec des echanges inter-lignes.
Indices : 1. Echange intra-ligne (standard) : echanger deux cellules non fixes dans la même ligne 2. Echange inter-lignes (etendu) : echanger une cellule non fixe en ligne row1 avec une cellule non fixe en ligne row1 + 1 3. Avec probabilite use_extended, utiliser l’echange inter-lignes; sinon l’echange standard 4. Retourner (move_type, params) pour pouvoir annuler le mouvement
Verification : Testez différentes valeurs de use_extended (0.1, 0.2, 0.5) sur des puzzles du corpus Sudoku_top95.txt.
def generate_extended_neighbor(grid: np.ndarray, is_fixed: np.ndarray, rng: random.Random, use_extended: float=0.2):""" Genere un voisin avec probabilite use_extended d'utiliser un mouvement inter-lignes. Retourne (move_type, params) pour pouvoir annuler le mouvement. """if rng.random() < use_extended:# Exercice: Mouvement etendu - echange entre lignes adjacentes# Indice 1 : row1 = rng.randint(0, 7); row2 = row1 + 1# Indice 2 : Choisir col1 non fixe dans row1, col2 non fixe dans row2# Indice 3 : Effectuer l'echange : grid[row1, col1] <-> grid[row2, col2]# Indice 4 : Retourner ("inter_line", (row1, col1, row2, col2))passelse:# Exercice: Mouvement standard - echange intra-ligne# Indice : Utiliser generate_neighbor() et retourner ("intra_line", (row, col1, col2))passdef undo_extended_move(grid: np.ndarray, move_type: str, params: tuple):"""Annule un mouvement etendu."""# Exercice: Implementer selon move_type# if move_type == "intra_line":# row, col1, col2 = params# undo_swap(grid, row, col1, col2)# elif move_type == "inter_line":# row1, col1, row2, col2 = params# grid[row1, col1], grid[row2, col2] = grid[row2, col2], grid[row1, col1]pass# Test de votre implementationprint("TODO: Implementez generate_extended_neighbor et undo_extended_move")# grid_test, is_fixed_test = initialize_grid(SudokuGrid.from_string(easy_puzzles[0]), random.Random(42))# move_type, params = generate_extended_neighbor(grid_test, is_fixed_test, random.Random(42), use_extended=1.0)# print(f"Mouvement: {move_type}, params: {params}")
TODO: Implementez generate_extended_neighbor et undo_extended_move
Resume
Concepts cles
Concept
Description
Recuit simule
Métaheuristique d’optimisation inspiree de la metallurgie
Fonction d’energie
Nombre de doublons colonnes + blocs
Voisinage
Echange de deux cellules non fixes dans une même ligne
Acceptation de Metropolis
Accepter les degradations avec probabilite \(e^{-\Delta E / T}\)
Refroidissement
Reduction progressive de \(T\) (programme exponentiel)
Ce notebook a applique le recuit simule (Simulated Annealing) a la resolution de Sudoku.
Formulation SA pour le Sudoku
Élément
Choix
Etat
Grille 9x9 avec permutations par ligne
Energie
Doublons colonnes + blocs 3x3 (lignes valides par construction)
Voisinage
Echange de 2 cellules non-fixees dans la même ligne
Refroidissement
T *= alpha (0.999), T0=1.0, Tmin=0.001
Résultats du benchmark
Méthode
Puzzles
Temps observe
SA manuel
3/3 resolus
mesurable en direct (Section 8, benchmark_sa ; croit avec le nombre de vides)
Backtracking
3/3 resolus
49-295 appels (déterministe ; duree en direct Section 9, SimpleBacktracking)
simanneal (50000 steps)
1/1 resolu
mesurable en direct (Section 6, test simanneal)
Lecture comparee
Le backtracking resout ces puzzles faciles en quelques millisecondes ; le recuit simule y parvient aussi mais est considerablement plus lent (la duree exacte sur 51 cellules vides se mesure en direct via la cellule benchmark_sa, Section 8 — SA stochastique). La valeur du recuit simule n’est pas dans la vitesse sur ces instances faciles, mais dans sa capacite a explorer un paysage d’energie (acceptation de mouvements degradants a haute temperature, exploitation a basse temperature) – une approche transferable a des problemes d’optimisation ou aucun solveur déterministe n’est disponible.
Le recuit simule offre une approche non-déterministe sans garantie de completude, adaptee aux problemes d’optimisation combinatoire. Pour le Sudoku, les solveurs CSP (OR-Tools CP-SAT, Z3 SMT) restent plus fiables sur les instances difficiles.
11. Comparaison quantitative SA vs Backtracking (Prong B #3801)
Le benchmark précédent (Section 8, cellule benchmark_sa) a mesure le recuit simule (SA) sur 3 puzzles faciles (~36-51 cellules vides) avec un temps culminant mesurable en direct dans sa sortie sur le puzzle 3 (51 cellules). Pour positionner SA face au solveur exact (Backtracking) sur les mêmes instances, voici une comparaison quantitative avec les chiffres reels publies dans les cellules voisines (Section 8 benchmark_sa pour SA, Section 9 SimpleBacktracking pour BT).
Ecart de plusieurs ordres de grandeur entre SA et Backtracking sur les mêmes instances. Le ratio explose avec la difficulte (cellules vides) : de ~72x (36 vides) a ~19 045x (51 vides) sur le run mesure – re-mesurez-le en direct via les cellules benchmark_sa (Section 8) et SimpleBacktracking (Section 9).
Garantie d’exactitude : Backtracking est complet (trouve une solution si elle existe), SA est non garanti (les 3 OK ci-dessus dependent du seed et du time budget).
Cout runtime : SA devient lent (ordre de la minute) sur le puzzle 3 (51 vides), Backtracking reste en millisecondes. Pour la production, Backtracking (ou DLX) est systematiquement preferable.
Valeur pedagogique du SA : illustre une famille de méthodes (recherche locale + stochasticite) complémentaire des solveurs exacts. Le SA brille sur des problemes ou l’espace de recherche est trop grand pour l’exhaustif et ou une solution “proche de l’optimal” suffit (TSP, planning, scheduling). Pour le Sudoku, ce n’est pas le cas : backtracking + danse-links (DLX) dominent.
Note methodologique : les durees absolues sont mesurees en direct par les cellules benchmark (benchmark_sa en Section 8 pour SA, SimpleBacktracking en Section 9 pour BT ; outputs reels, ec != null, 0 erreur) – re-executez-les pour les valeurs courantes. SA etant stochastique, deux runs différent : la Section 8 a mesure 0.43 s / 29.97 s / 648.37 s, la Section 9 0.37 s / 31.29 s / 628.48 s – c’est la variance attendue du recuit, pas une erreur. Les constantes T_SA_MS / T_BT_MS de l’exercice (Section 11) sont derivees de la mesure de la Section 9 : elles suivent ce run au lieu d’etre recopiees, et ne peuvent donc plus citer des durees absentes des sorties. Seuls les compteurs d’appels Backtracking (49/201/295) sont déterministes. Le recuit simule est execute avec T0=1.0, alpha=0.999, iterations_per_temp=100, Tmin=0.001, max_restarts=10 (appel de benchmark_sa, Section 8).
Exercice : Estimer le ratio SA / Backtracking (Prong B reflexif)
Contexte
Le tableau ci-dessus compare le recuit simule (heuristique stochastique) au Backtracking (solveur exact) sur 3 puzzles Easy du même fichier. Pour faire toucher du doigt le ratio de cout et sa non-linearite avec la difficulte, on veut le chiffrer — a partir des durees mesurees en direct par la cellule SimpleBacktracking (Section 9), qui chronometre les deux moteurs sur les memes instances. La cellule suivante les expose en constantes (T_SA_MS, T_BT_MS) derivees de ce run : elles ne sont plus recopiees a la main.
Enonce
A partir des constantes T_SA_MS / T_BT_MS, derivees des durees mesurees en direct par la cellule SimpleBacktracking (Section 9) :
Calculer le ratio temps_SA / temps_BT pour chacun des 3 puzzles.
Observer la non-linearite : le ratio explose-t-il lineairement avec le nombre de cellules vides, ou exponentiellement ?
Conclure sur la position du SA dans la hiérarchie : efficace / equivalentes / nettement inferieures / sans commune mesure, sur le cas Sudoku.
Si ratio_puzzle_3 / ratio_puzzle_1 > 10, on a une croissance super-lineaire -> méthode inadaptee au scaling
Suggestion : conclude_prong_b() retourne une catégorie parmi "lineaire", "polynomial", "exponentiel", "autre"
Solution (a completer par l’etudiant)
ratio_puzzle_1 = None # TODO etudiant
ratio_puzzle_2 = None # TODO etudiant
ratio_puzzle_3 = None # TODO etudiant
conclusion = None # TODO etudiant
# Exercice : Estimer le ratio SA / Backtracking (Prong B reflexif)# Cf. cellule markdown precedente pour l'enonce et les indications.# Durees du run courant, capturees par la comparaison mesuree en Section 9# (cellule SimpleBacktracking). Elles derivent de la sortie au lieu d'etre# recopiees a la main : recopier un run rendait ces constantes perimees des# la re-execution suivante, et l'exercice citait alors des durees absentes# de toute sortie.T_SA_MS =tuple(round(v) for v in T_SA_MS_MESURE) # recuit simule (Section 9)T_BT_MS =tuple(round(v, 1) for v in T_BT_MS_MESURE) # backtracking (Section 9)VIDES = (36, 49, 51) # Cellules vides par puzzle (Section 9)def compute_ratio_sa_bt(idx_puzzle: int) ->float:"""Calcule le ratio temps_SA / temps_BT pour le puzzle d'index donne. Returns: ratio (float) ou None si pas complete. """returnNone# TODO etudiantdef conclude_prong_b(ratios: tuple) ->str:"""Conclut sur la croissance du ratio SA/BT avec la difficulte. Returns: "lineaire", "polynomial", "exponentiel", ou "autre". """returnNone# TODO etudiant# Affichage pedagogique (fonctionne meme si l'etudiant n'a pas complete)r1 = compute_ratio_sa_bt(0)r2 = compute_ratio_sa_bt(1)r3 = compute_ratio_sa_bt(2)conclusion = conclude_prong_b((r1, r2, r3)) ifall(r isnotNonefor r in (r1, r2, r3)) elseNoneprint("=== Estimation du ratio SA / Backtracking (Prong B #3801) ===")print()print(f"{'Puzzle':<10}{'Vides':>6}{'BT (ms)':>10}{'SA (ms)':>12}{'Ratio SA/BT':>14}")print("-"*56)for i, (vides, t_bt, t_sa) inenumerate(zip(VIDES, T_BT_MS, T_SA_MS)): ratio = (r1, r2, r3)[i] ratio_str =f"{ratio:.1f}x"ifisinstance(ratio, (int, float)) elsestr(ratio)print(f"Puzzle {i+1:<3}{vides:>6d}{t_bt:>10.1f}{t_sa:>12.1f}{ratio_str:>14}")print()print(f"Croissance ratio P3/P1 : N/A (a completer)")print(f"Conclusion Prong B : {conclusion}")print()if r1 isNone:print(">>> Exercice a completer : implementer compute_ratio_sa_bt() et conclude_prong_b()")
=== Estimation du ratio SA / Backtracking (Prong B #3801) ===
Puzzle Vides BT (ms) SA (ms) Ratio SA/BT
--------------------------------------------------------
Puzzle 1 36 5.1 369.0 None
Puzzle 2 49 17.0 31286.0 None
Puzzle 3 51 33.0 628478.0 None
Croissance ratio P3/P1 : N/A (a completer)
Conclusion Prong B : None
>>> Exercice a completer : implementer compute_ratio_sa_bt() et conclude_prong_b()
References citees dans la section 11 (chiffres verifies)
MyIA.AI.Notebooks/Sudoku/Sudoku-04-SimulatedAnnealing-Python.ipynb (Section 8, cellule benchmark_sa — benchmark SA sur 3 Easy)
MyIA.AI.Notebooks/Sudoku/Sudoku-04-SimulatedAnnealing-Python.ipynb (Section 9, cellule SimpleBacktracking — comparaison BT vs SA sur 3 Easy)
Liens transverses (autres solveurs Sudoku pour Prong B)
Note pedagogique : ces ratios (SA/BT ~72x a ~19 045x sur le run mesure en Section 9) demontrent que le recuit simule est inadapte au Sudoku en production, malgre sa richesse théorique. Cf. tableau comparatif dans la Section 7 de Sudoku-03-Genetic-Python (ratio GA/OR-Tools ~700x sur Easy).