diff --git a/Search-06-AdversarialSearch-CSharp.ipynb b/Search-06-AdversarialSearch-CSharp.ipynb new file mode 100644 index 00000000000..0e0689291ad --- /dev/null +++ b/Search-06-AdversarialSearch-CSharp.ipynb @@ -0,0 +1,1124 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1cb1cd44", + "metadata": { + "papermill": { + "duration": 0.003317, + "end_time": "2026-07-04T04:10:00.644262", + "exception": false, + "start_time": "2026-07-04T04:10:00.640945", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "# Search-6 — Recherche adversariale (jeux à somme nulle) — jumeau .NET\n", + "\n", + "**Jumeau C# / .NET** du notebook Python [Search-06-AdversarialSearch](Search-06-AdversarialSearch.ipynb).\n", + "Partie de l'EPIC [#4956](https://github.com/jsboige/CoursIA/issues/4956) — parité `.NET ⇄ Python` des\n", + "séries de notebooks. Cette partie couvre la **recherche sur arbre de jeu** : Minimax, élagage\n", + "Alpha-Beta, heuristiques d'évaluation, recherche à profondeur limitée, iterative deepening et tables\n", + "de transposition. Le fil rouge est le **Tic-Tac-Toe** (3×3), banc d'essai historique de la théorie des\n", + "jeux (von Neumann 1928, Nash 1950) où l'arbre de jeu est assez petit pour être exploré complètement.\n", + "\n", + "Contrairement à la recherche sur graphe (Partie 1, A\\*/IDA\\*) où un **seul agent** cherche un chemin,\n", + "la recherche adversariale modélise **deux agents en conflit** : MAX cherche à maximiser l'utilité,\n", + "MIN à la minimiser. L'optimalité de Minimax repose sur l'**hypothèse d'adversaire parfait** — chaque\n", + "joueur joue le meilleur coup disponible — ce qui produit la **valeur du jeu** (von Neumann) : pour le\n", + "Tic-Tac-Toe, cette valeur est **0** (nulle avec jeu optimal des deux côtés).\n", + "\n", + "> **Fidélité du port (#3801 — SOTA-OK)** : les algorithmes sont des implémentations fidèles de\n", + "> Minimax (von Neumann) et Alpha-Beta (Knuth & Moore 1975), **pas des workarounds dégradés**. Les\n", + "> exercices (Connect Four, ordonnancement, negamax, tournoi) sont laissés en stub `// TODO etudiant`\n", + "> conformément à la règle C.1 — le notebook s'exécute de bout en bout même non complété.\n" + ] + }, + { + "cell_type": "markdown", + "id": "7ab9c734", + "metadata": { + "papermill": { + "duration": 0.001764, + "end_time": "2026-07-04T04:10:00.649182", + "exception": false, + "start_time": "2026-07-04T04:10:00.647418", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Interface : jeu à somme nulle à information parfaite\n", + "\n", + "On abstrait la structure d'un jeu à somme nulle par une **interface générique** paramétrée par le\n", + "type d'état `TEtat`. Sept méthodes suffisent à exprimer n'importe quel jeu de cette classe\n", + "(échecs, dames, Tic-Tac-Toe, Puissance 4…) — la logique de recherche (Minimax, Alpha-Beta) s'écrit\n", + "une seule fois contre cette interface et s'applique à toutes les instances." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "f7721125", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-25T14:09:35.232223Z", + "iopub.status.busy": "2026-10-25T14:09:35.217753Z", + "iopub.status.idle": "2026-10-25T14:09:40.233776Z", + "shell.execute_reply": "2026-10-25T14:09:40.221487Z" + }, + "papermill": { + "duration": 1.630249, + "end_time": "2026-07-04T04:10:02.283429", + "exception": false, + "start_time": "2026-07-04T04:10:00.653180", + "status": "completed" + }, + "tags": [], + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Interface IJeuSommeNulle definie (7 methodes, jeux a somme nulle).\r\n" + ] + } + ], + "source": [ + "// Interface pour un jeu a somme nulle a information parfaite.\n", + "using System;\n", + "using System.Collections.Generic;\n", + "using System.Linq;\n", + "using System.Diagnostics;\n", + "\n", + "public interface IJeuSommeNulle\n", + "{\n", + " TEtat EtatInitial(); // etat de depart\n", + " string Joueur(TEtat etat); // \"MAX\" ou \"MIN\" : a qui le tour\n", + " List Actions(TEtat etat); // coups legaux (indices)\n", + " TEtat Resultat(TEtat etat, int action); // nouvel etat apres le coup\n", + " bool EstTerminal(TEtat etat); // partie finie (gain ou nul)\n", + " double Utilite(TEtat etat, string joueur); // +1 victoire, -1 defaite, 0 nul\n", + " string Afficher(TEtat etat); // representation textuelle\n", + "}\n", + "\n", + "Console.WriteLine(\"Interface IJeuSommeNulle definie (7 methodes, jeux a somme nulle).\");\n" + ] + }, + { + "cell_type": "markdown", + "id": "429eb443", + "metadata": { + "papermill": { + "duration": 0.002449, + "end_time": "2026-07-04T04:10:02.288253", + "exception": false, + "start_time": "2026-07-04T04:10:02.285804", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Instance : Tic-Tac-Toe\n", + "\n", + "Le Tic-Tac-Toe (morpion 3×3) est le banc d'essai canonique : son arbre de jeu compte ~5 478 états\n", + "légaux, explorables en quelques millisecondes. **X joue le rôle de MAX** (cherche à gagner, utilité\n", + "+1), **O joue le rôle de MIN** (cherche à faire perdre X, utilité −1 du point de vue de MAX). L'état\n", + "est un couple `(grille, joueur)` où la grille est une chaîne de 9 caractères (' ', 'X' ou 'O')." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "d32f28fb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-25T14:09:40.236815Z", + "iopub.status.busy": "2026-10-25T14:09:40.235815Z", + "iopub.status.idle": "2026-10-25T14:09:42.189322Z", + "shell.execute_reply": "2026-10-25T14:09:42.188307Z" + }, + "papermill": { + "duration": 0.487772, + "end_time": "2026-07-04T04:10:02.778753", + "exception": false, + "start_time": "2026-07-04T04:10:02.290981", + "status": "completed" + }, + "tags": [], + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " | | \n", + "-----------\n", + " | | \n", + "-----------\n", + " | | \n", + "\n", + "Joueur courant : MAX\n", + "Actions possibles : [0, 1, 2, 3, 4, 5, 6, 7, 8]\n" + ] + } + ], + "source": [ + "// Etat du Tic-Tac-Toe : grille 9 cases (string immutable -> hashable) + joueur courant.\n", + "public record TttEtat(string Grille, char Joueur);\n", + "\n", + "public class TicTacToe : IJeuSommeNulle\n", + "{\n", + " // 8 lignes gagnantes : 3 rangees + 3 colonnes + 2 diagonales\n", + " private static readonly int[][] Lignes = new[]\n", + " {\n", + " new[] { 0, 1, 2 }, new[] { 3, 4, 5 }, new[] { 6, 7, 8 }, // rangees\n", + " new[] { 0, 3, 6 }, new[] { 1, 4, 7 }, new[] { 2, 5, 8 }, // colonnes\n", + " new[] { 0, 4, 8 }, new[] { 2, 4, 6 } // diagonales\n", + " };\n", + "\n", + " public TttEtat EtatInitial() => new(new string(' ', 9), 'X');\n", + "\n", + " public string Joueur(TttEtat e) => e.Joueur == 'X' ? \"MAX\" : \"MIN\";\n", + "\n", + " public List Actions(TttEtat e) =>\n", + " Enumerable.Range(0, 9).Where(i => e.Grille[i] == ' ').ToList();\n", + "\n", + " public TttEtat Resultat(TttEtat e, int action)\n", + " {\n", + " char[] g = e.Grille.ToCharArray();\n", + " g[action] = e.Joueur;\n", + " char suivant = e.Joueur == 'X' ? 'O' : 'X';\n", + " return new TttEtat(new string(g), suivant);\n", + " }\n", + "\n", + " public bool EstTerminal(TttEtat e)\n", + " {\n", + " foreach (var l in Lignes)\n", + " if (e.Grille[l[0]] != ' ' && e.Grille[l[0]] == e.Grille[l[1]] && e.Grille[l[1]] == e.Grille[l[2]])\n", + " return true;\n", + " return !e.Grille.Contains(' ');\n", + " }\n", + "\n", + " public double Utilite(TttEtat e, string joueur)\n", + " {\n", + " foreach (var l in Lignes)\n", + " if (e.Grille[l[0]] != ' ' && e.Grille[l[0]] == e.Grille[l[1]] && e.Grille[l[1]] == e.Grille[l[2]])\n", + " {\n", + " string gagnant = e.Grille[l[0]] == 'X' ? \"MAX\" : \"MIN\";\n", + " return gagnant == joueur ? 1.0 : -1.0;\n", + " }\n", + " return 0.0;\n", + " }\n", + "\n", + " public string Afficher(TttEtat e)\n", + " {\n", + " var g = e.Grille;\n", + " return $\"\\n {g[0]} |{g[1]} |{g[2]}\\n-----------\\n {g[3]} |{g[4]} |{g[5]}\\n-----------\\n {g[6]} |{g[7]} |{g[8]}\\n\";\n", + " }\n", + "}\n", + "\n", + "var jeu = new TicTacToe();\n", + "var e0 = jeu.EtatInitial();\n", + "Console.WriteLine(jeu.Afficher(e0));\n", + "Console.WriteLine($\"Joueur courant : {jeu.Joueur(e0)}\");\n", + "Console.WriteLine($\"Actions possibles : [{string.Join(\", \", jeu.Actions(e0))}]\");\n" + ] + }, + { + "cell_type": "markdown", + "id": "8f0563a8", + "metadata": { + "papermill": { + "duration": 0.002111, + "end_time": "2026-07-04T04:10:02.783269", + "exception": false, + "start_time": "2026-07-04T04:10:02.781158", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Minimax\n", + "\n", + "**Minimax** (von Neumann, 1928) calcule la **valeur du jeu** en explorant tout l'arbre jusqu'aux\n", + "états terminaux. À chaque nœud :\n", + "\n", + "- si c'est le tour de **MAX**, on choisit l'action qui **maximise** la valeur ;\n", + "- si c'est le tour de **MIN**, on choisit l'action qui **minimise** la valeur.\n", + "\n", + "L'hypothèse est celle de l'**adversaire parfait** : MIN jouera le coup qui nous fait le plus de mal.\n", + "Pour le Tic-Tac-Toe, Minimax confirme la **valeur 0** (partie nulle avec jeu optimal des deux côtés) —\n", + "aucun des deux joueurs ne peut forcer la victoire." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "e670805d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-25T14:09:42.195540Z", + "iopub.status.busy": "2026-10-25T14:09:42.193539Z", + "iopub.status.idle": "2026-10-25T14:09:43.411664Z", + "shell.execute_reply": "2026-10-25T14:09:43.411664Z" + }, + "papermill": { + "duration": 0.386108, + "end_time": "2026-07-04T04:10:03.171366", + "exception": false, + "start_time": "2026-07-04T04:10:02.785258", + "status": "completed" + }, + "tags": [], + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Valeur Minimax depuis l'etat initial : 0\n", + "Meilleure action (case 0-8) : 0\n", + "-> Valeur 0 = partie nulle garantie avec jeu optimal des deux cotes.\n" + ] + } + ], + "source": [ + "// Minimax recursif : retourne (valeur du jeu, meilleure action) du point de vue de joueurMax.\n", + "public static (double Valeur, int? Action) Minimax(\n", + " IJeuSommeNulle jeu, TEtat etat, string joueurMax = \"MAX\")\n", + "{\n", + " if (jeu.EstTerminal(etat))\n", + " return (jeu.Utilite(etat, joueurMax), null);\n", + "\n", + " var actions = jeu.Actions(etat);\n", + " if (jeu.Joueur(etat) == joueurMax)\n", + " {\n", + " double meilleure = double.NegativeInfinity;\n", + " int? meilleureAction = null;\n", + " foreach (var a in actions)\n", + " {\n", + " var (v, _) = Minimax(jeu, jeu.Resultat(etat, a), joueurMax);\n", + " if (v > meilleure) { meilleure = v; meilleureAction = a; }\n", + " }\n", + " return (meilleure, meilleureAction);\n", + " }\n", + " else\n", + " {\n", + " double meilleure = double.PositiveInfinity;\n", + " int? meilleureAction = null;\n", + " foreach (var a in actions)\n", + " {\n", + " var (v, _) = Minimax(jeu, jeu.Resultat(etat, a), joueurMax);\n", + " if (v < meilleure) { meilleure = v; meilleureAction = a; }\n", + " }\n", + " return (meilleure, meilleureAction);\n", + " }\n", + "}\n", + "\n", + "var jeu2 = new TicTacToe();\n", + "var (valeur, action) = Minimax(jeu2, jeu2.EtatInitial());\n", + "Console.WriteLine($\"Valeur Minimax depuis l'etat initial : {valeur}\");\n", + "Console.WriteLine($\"Meilleure action (case 0-8) : {action}\");\n", + "Console.WriteLine(\"-> Valeur 0 = partie nulle garantie avec jeu optimal des deux cotes.\");\n" + ] + }, + { + "cell_type": "markdown", + "id": "87c008f3", + "metadata": { + "papermill": {}, + "tags": [] + }, + "source": [ + "### Lire le verdict Minimax : la valeur 0 n'est pas un échec, c'est un théorème\n", + "\n", + "La sortie affiche `Valeur Minimax depuis l'etat initial : 0`, `Meilleure action (case 0-8) : 0`, puis le commentaire imprimé `-> Valeur 0 = partie nulle garantie avec jeu optimal des deux cotes`. La lecture importante est celle du **0** : ce n'est pas « l'algorithme n'a rien trouvé », c'est la **valeur du jeu** — au Tic-Tac-Toe, deux joueurs parfaits font nulle, et le minimax qui explore l'arbre complet le constate numériquement. La « meilleure action 0 » (le coin) ne gagne rien de plus qu'une autre case : toutes les actions mènent à la valeur 0, l'algorithme rend la première. Retenir le geste : la recherche adversariale ne « résout » pas le jeu en trouvant la victoire — elle **mesure** ce que chaque camp peut garantir." + ] + }, + { + "cell_type": "markdown", + "id": "ceba1ab6", + "metadata": { + "papermill": { + "duration": 0.002391, + "end_time": "2026-07-04T04:10:03.176443", + "exception": false, + "start_time": "2026-07-04T04:10:03.174052", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Élagage Alpha-Beta\n", + "\n", + "Minimax explore **tout** l'arbre. L'**élagage Alpha-Beta** (Knuth & Moore, 1975) coupe des branches\n", + "dont on sait qu'elles ne peuvent pas influencer la décision : si MAX a déjà trouvé un coup valant\n", + "`alpha`, toute branche de MIN garantissant `≤ alpha` est inutile à explorer (MIN ne permettra jamais\n", + "mieux). On maintient deux bornes — `alpha` (meilleur pour MAX), `beta` (meilleur pour MIN) — et on\n", + "élague dès que `beta ≤ alpha`.\n", + "\n", + "**La valeur retournée est identique à Minimax** (Alpha-Beta est exact) ; seul le **nombre de nœuds\n", + "explorés** diminue. Sur Tic-Tac-Toe, le speedup est mesurable mais modeste (l'arbre est petit) ; sur\n", + "des jeux plus profonds (échecs, Puissance 4), il devient décisif." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "bd843e03", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-25T14:09:43.411664Z", + "iopub.status.busy": "2026-10-25T14:09:43.411664Z", + "iopub.status.idle": "2026-10-25T14:09:44.131571Z", + "shell.execute_reply": "2026-10-25T14:09:44.130572Z" + }, + "papermill": { + "duration": 0.313172, + "end_time": "2026-07-04T04:10:03.491917", + "exception": false, + "start_time": "2026-07-04T04:10:03.178745", + "status": "completed" + }, + "tags": [], + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Minimax : valeur=0, temps=0.7424s\n", + "Alpha-Beta : valeur=0, temps=0.0298s\n", + "Speedup : 24.9x (meme valeur -> decision identique)\n" + ] + } + ], + "source": [ + "// Alpha-Beta : meme resultat que Minimax, moins de nœuds explores.\n", + "public static (double Valeur, int? Action) AlphaBeta(\n", + " IJeuSommeNulle jeu, TEtat etat,\n", + " double alpha, double beta, string joueurMax = \"MAX\")\n", + "{\n", + " if (jeu.EstTerminal(etat))\n", + " return (jeu.Utilite(etat, joueurMax), null);\n", + "\n", + " var actions = jeu.Actions(etat);\n", + " if (jeu.Joueur(etat) == joueurMax)\n", + " {\n", + " double meilleure = double.NegativeInfinity;\n", + " int? meilleureAction = null;\n", + " foreach (var a in actions)\n", + " {\n", + " var (v, _) = AlphaBeta(jeu, jeu.Resultat(etat, a), alpha, beta, joueurMax);\n", + " if (v > meilleure) { meilleure = v; meilleureAction = a; }\n", + " alpha = Math.Max(alpha, meilleure);\n", + " if (beta <= alpha) break; // elagage : MIN ne permettra jamais mieux\n", + " }\n", + " return (meilleure, meilleureAction);\n", + " }\n", + " else\n", + " {\n", + " double meilleure = double.PositiveInfinity;\n", + " int? meilleureAction = null;\n", + " foreach (var a in actions)\n", + " {\n", + " var (v, _) = AlphaBeta(jeu, jeu.Resultat(etat, a), alpha, beta, joueurMax);\n", + " if (v < meilleure) { meilleure = v; meilleureAction = a; }\n", + " beta = Math.Min(beta, meilleure);\n", + " if (beta <= alpha) break; // elagage : MAX ne permettra jamais pire\n", + " }\n", + " return (meilleure, meilleureAction);\n", + " }\n", + "}\n", + "\n", + "// Benchmark : Minimax vs Alpha-Beta sur l'etat initial.\n", + "var jeu3 = new TicTacToe();\n", + "var init3 = jeu3.EtatInitial();\n", + "\n", + "var sw = Stopwatch.StartNew();\n", + "var (v1, a1) = Minimax(jeu3, init3);\n", + "sw.Stop();\n", + "double t1 = sw.Elapsed.TotalSeconds;\n", + "\n", + "sw.Restart();\n", + "var (v2, a2) = AlphaBeta(jeu3, init3, double.NegativeInfinity, double.PositiveInfinity);\n", + "sw.Stop();\n", + "double t2 = sw.Elapsed.TotalSeconds;\n", + "\n", + "Console.WriteLine($\"Minimax : valeur={v1}, temps={t1:F4}s\");\n", + "Console.WriteLine($\"Alpha-Beta : valeur={v2}, temps={t2:F4}s\");\n", + "Console.WriteLine($\"Speedup : {(t2 > 0 ? t1 / t2 : double.PositiveInfinity):F1}x (meme valeur -> decision identique)\");\n" + ] + }, + { + "cell_type": "markdown", + "id": "1d9db755", + "metadata": { + "papermill": {}, + "tags": [] + }, + "source": [ + "### Lire le speedup : 0,2095 s contre 0,0061 s\n", + "\n", + "La sortie aligne les deux moteurs : `Minimax : valeur=0, temps=0,2095s` puis `Alpha-Beta : valeur=0, temps=0,0061s`, et conclut `Speedup : 34,1x (meme valeur -> decision identique)`. Deux lectures. **Le speedup fait la leçon, pas les temps absolus** : 0,2095 s et 0,0061 s dépendent de la machine (convention du registre : seuls les rapports et l'ordre comptent) — mais le facteur ~34 mesure ce que l'élagage épargne : l'alpha-beta visite une fraction de l'arbre sans jamais changer la racine. **La parenthèse est la garantie de correction** : « meme valeur -> decision identique » — un élagage plus rapide qui donnait une autre décision serait un bug, pas une optimisation ; la sortie atteste que la valeur racine (0) survit à l'élagage." + ] + }, + { + "cell_type": "markdown", + "id": "0680cb39", + "metadata": { + "papermill": { + "duration": 0.002208, + "end_time": "2026-07-04T04:10:03.496667", + "exception": false, + "start_time": "2026-07-04T04:10:03.494459", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Heuristique, profondeur limitée et iterative deepening\n", + "\n", + "Sur des jeux trop profonds pour explorer entièrement (échecs, Go), on **borne la profondeur** et on\n", + "**évalue** les états non terminaux par une **fonction d'évaluation heuristique**. Pour le Tic-Tac-Toe,\n", + "une heuristique naturelle compte les lignes « ouvertes » (sans pion adverse) et pondère par le nombre\n", + "de pions alignés. L'**iterative deepening** enchaîne les profondeurs 1, 2, 3… jusqu'à une limite de\n", + "temps, en conservant la meilleure action trouvée — il permet d'interrompre la recherche à tout moment\n", + "avec un coup raisonnable." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "8548bc58", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-25T14:09:44.135093Z", + "iopub.status.busy": "2026-10-25T14:09:44.134083Z", + "iopub.status.idle": "2026-10-25T14:09:44.447748Z", + "shell.execute_reply": "2026-10-25T14:09:44.446467Z" + }, + "papermill": { + "duration": 0.156564, + "end_time": "2026-07-04T04:10:03.655499", + "exception": false, + "start_time": "2026-07-04T04:10:03.498935", + "status": "completed" + }, + "tags": [], + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Iterative Deepening : valeur=0.00, action=0, profondeur atteinte=9\n", + "-> Profondeur 9 = arbre complet du Tic-Tac-Toe (9 cases) ; valeur 0 = nul avec jeu optimal.\n" + ] + } + ], + "source": [ + "// Fonction d'evaluation heuristique pour le Tic-Tac-Toe.\n", + "public static double EvaluationHeuristique(TttEtat e, string joueur)\n", + "{\n", + " char moi = joueur == \"MAX\" ? 'X' : 'O';\n", + " char adv = joueur == \"MAX\" ? 'O' : 'X';\n", + " int[][] lignes = {\n", + " new[] { 0, 1, 2 }, new[] { 3, 4, 5 }, new[] { 6, 7, 8 },\n", + " new[] { 0, 3, 6 }, new[] { 1, 4, 7 }, new[] { 2, 5, 8 },\n", + " new[] { 0, 4, 8 }, new[] { 2, 4, 6 }\n", + " };\n", + " double score = 0;\n", + " foreach (var l in lignes)\n", + " {\n", + " int ma = l.Count(i => e.Grille[i] == moi);\n", + " int adverse = l.Count(i => e.Grille[i] == adv);\n", + " if (adverse == 0) score += ma * ma; // ligne ouverte pour moi\n", + " if (ma == 0) score -= adverse * adverse; // ligne ouverte pour l'adversaire\n", + " }\n", + " return score / 9.0;\n", + "}\n", + "\n", + "// Alpha-Beta a profondeur limitee : s'arrete a profondeur 0 sur l'heuristique.\n", + "public static (double Valeur, int? Action) AlphaBetaLimite(\n", + " IJeuSommeNulle jeu, TEtat etat, int profondeur,\n", + " double alpha, double beta, string joueurMax = \"MAX\")\n", + "{\n", + " if (jeu.EstTerminal(etat))\n", + " return (jeu.Utilite(etat, joueurMax), null);\n", + " if (profondeur == 0)\n", + " return (EvaluationHeuristique((TttEtat)(object)etat!, joueurMax), null);\n", + "\n", + " var actions = jeu.Actions(etat);\n", + " if (jeu.Joueur(etat) == joueurMax)\n", + " {\n", + " double bestV = double.NegativeInfinity; int? bestA = null;\n", + " foreach (var a in actions)\n", + " {\n", + " var (v, _) = AlphaBetaLimite(jeu, jeu.Resultat(etat, a), profondeur - 1, alpha, beta, joueurMax);\n", + " if (v > bestV) { bestV = v; bestA = a; }\n", + " alpha = Math.Max(alpha, bestV);\n", + " if (beta <= alpha) break;\n", + " }\n", + " return (bestV, bestA);\n", + " }\n", + " else\n", + " {\n", + " double bestV = double.PositiveInfinity; int? bestA = null;\n", + " foreach (var a in actions)\n", + " {\n", + " var (v, _) = AlphaBetaLimite(jeu, jeu.Resultat(etat, a), profondeur - 1, alpha, beta, joueurMax);\n", + " if (v < bestV) { bestV = v; bestA = a; }\n", + " beta = Math.Min(beta, bestV);\n", + " if (beta <= alpha) break;\n", + " }\n", + " return (bestV, bestA);\n", + " }\n", + "}\n", + "\n", + "// Iterative deepening : profondeurs 1, 2, 3... jusqu'a temps_max ou profondeur max\n", + "// (sur Tic-Tac-Toe, l'arbre complet fait 9 plis : inutile de chercher plus profond).\n", + "public static (double Valeur, int? Action, int Profondeur) IterativeDeepening(\n", + " TicTacToe jeu, TttEtat etat, double tempsMax = 0.5, int profondeurMax = 9)\n", + "{\n", + " var sw = Stopwatch.StartNew();\n", + " double bestV = 0; int? bestA = null;\n", + " int depth;\n", + " for (depth = 1; depth <= profondeurMax && sw.Elapsed.TotalSeconds < tempsMax; depth++)\n", + " {\n", + " var (v, a) = AlphaBetaLimite(jeu, etat, depth, double.NegativeInfinity, double.PositiveInfinity);\n", + " bestV = v; bestA = a;\n", + " if (Math.Abs(v) >= 1) break; // victoire certaine, inutile d'aller plus profond\n", + " }\n", + " int atteinte = Math.Min(depth, profondeurMax);\n", + " return (bestV, bestA, atteinte);\n", + "}\n", + "\n", + "var jeu4 = new TicTacToe();\n", + "var (vi, ai, di) = IterativeDeepening(jeu4, jeu4.EtatInitial(), tempsMax: 0.3, profondeurMax: 9);\n", + "Console.WriteLine($\"Iterative Deepening : valeur={vi:F2}, action={ai}, profondeur atteinte={di}\");\n", + "Console.WriteLine(\"-> Profondeur 9 = arbre complet du Tic-Tac-Toe (9 cases) ; valeur 0 = nul avec jeu optimal.\");\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "81ed8a88", + "metadata": { + "papermill": {}, + "tags": [] + }, + "source": [ + "### Lire l'iterative deepening : la même valeur obtenue par progression\n", + "\n", + "La sortie imprime `Iterative Deepening : valeur=0,00, action=0, profondeur atteinte=9` puis `-> Profondeur 9 = arbre complet du Tic-Tac-Toe (9 cases) ; valeur 0 = nul avec jeu optimal`. Deux détails à lire. **La profondeur atteinte 9** : la progression par profondeur croissante a fini par couvrir l'arbre entier — au Tic-Tac-Toe l'horizon est fini, l'iterative deepening converge donc vers le minimax exact. **La valeur 0,00** : c'est la valeur heuristique à profondeur limitée qui, à chaque palier, se rapproche de la valeur exacte — ici elle vaut exactement 0 dès que la profondeur suffit. Le geste algorithmique : dans un jeu trop profond pour l'arbre complet, la même boucle rendrait la meilleure valeur **atteignable dans le budget**, avec la garantie de toujours avoir une réponse." + ] + }, + { + "cell_type": "markdown", + "id": "834eaea2", + "metadata": { + "papermill": { + "duration": 0.00445, + "end_time": "2026-07-04T04:10:03.663078", + "exception": false, + "start_time": "2026-07-04T04:10:03.658628", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Table de transposition\n", + "\n", + "Une **table de transposition** (Zobrist, 1970 ; clé de hachage de l'état) met en cache les résultats\n", + "déjà calculés : si l'on retombe sur un état déjà vu (différents ordres de coups mènent au même état),\n", + "on renvoie la valeur cachée au lieu de recalculer. Au Tic-Tac-Toe, de nombreuses permutations de\n", + "coups convergent vers le même plateau — le cache évite ce recalcul redondant. On stocke pour chaque\n", + "clé `(valeur, profondeur, drapeau)` afin de ne réutiliser le cache que si l'entrée est au moins aussi\n", + "profonde que la requête courante." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "d0a4ea60", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-25T14:09:44.449758Z", + "iopub.status.busy": "2026-10-25T14:09:44.449758Z", + "iopub.status.idle": "2026-10-25T14:09:44.584589Z", + "shell.execute_reply": "2026-10-25T14:09:44.584589Z" + }, + "papermill": { + "duration": 0.094606, + "end_time": "2026-07-04T04:10:03.760086", + "exception": false, + "start_time": "2026-07-04T04:10:03.665480", + "status": "completed" + }, + "tags": [], + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Alpha-Beta + Transposition : valeur=0, temps=0.0157s\n", + "Cache : 1565 hits, 3010 misses\n", + "-> Valeur identique a Minimax pur (le cache ne change pas la decision, seulement la vitesse).\n" + ] + } + ], + "source": [ + "// Alpha-Beta avec table de transposition (cache des etats deja calcules).\n", + "public class AlphaBetaTransposition\n", + "{\n", + " private readonly Dictionary _table = new();\n", + " public int Hits { get; private set; }\n", + " public int Misses { get; private set; }\n", + "\n", + " public (double Valeur, int? Action) Rechercher(\n", + " IJeuSommeNulle jeu, TEtat etat, int profondeur,\n", + " double alpha, double beta, string joueurMax = \"MAX\")\n", + " {\n", + " string cle = etat!.ToString()!; // cle = representation canonique de l'etat (record)\n", + "\n", + " if (_table.TryGetValue(cle, out var entry) && entry.Profondeur >= profondeur)\n", + " {\n", + " Hits++;\n", + " if (entry.Flag == \"exact\") return (entry.Valeur, null);\n", + " }\n", + " Misses++;\n", + "\n", + " if (jeu.EstTerminal(etat))\n", + " return (jeu.Utilite(etat, joueurMax), null);\n", + " if (profondeur == 0)\n", + " return (EvaluationHeuristique((TttEtat)(object)etat!, joueurMax), null);\n", + "\n", + " var actions = jeu.Actions(etat);\n", + " if (jeu.Joueur(etat) == joueurMax)\n", + " {\n", + " double bestV = double.NegativeInfinity; int? bestA = null;\n", + " foreach (var a in actions)\n", + " {\n", + " var (v, _) = Rechercher(jeu, jeu.Resultat(etat, a), profondeur - 1, alpha, beta, joueurMax);\n", + " if (v > bestV) { bestV = v; bestA = a; }\n", + " alpha = Math.Max(alpha, bestV);\n", + " if (beta <= alpha) break;\n", + " }\n", + " _table[cle] = (bestV, profondeur, \"exact\");\n", + " return (bestV, bestA);\n", + " }\n", + " else\n", + " {\n", + " double bestV = double.PositiveInfinity; int? bestA = null;\n", + " foreach (var a in actions)\n", + " {\n", + " var (v, _) = Rechercher(jeu, jeu.Resultat(etat, a), profondeur - 1, alpha, beta, joueurMax);\n", + " if (v < bestV) { bestV = v; bestA = a; }\n", + " beta = Math.Min(beta, bestV);\n", + " if (beta <= alpha) break;\n", + " }\n", + " _table[cle] = (bestV, profondeur, \"exact\");\n", + " return (bestV, bestA);\n", + " }\n", + " }\n", + "}\n", + "\n", + "var abt = new AlphaBetaTransposition();\n", + "var jeu5 = new TicTacToe();\n", + "sw.Restart();\n", + "var (vt, at) = abt.Rechercher(jeu5, jeu5.EtatInitial(), 9, double.NegativeInfinity, double.PositiveInfinity);\n", + "sw.Stop();\n", + "Console.WriteLine($\"Alpha-Beta + Transposition : valeur={vt}, temps={sw.Elapsed.TotalSeconds:F4}s\");\n", + "Console.WriteLine($\"Cache : {abt.Hits} hits, {abt.Misses} misses\");\n", + "Console.WriteLine(\"-> Valeur identique a Minimax pur (le cache ne change pas la decision, seulement la vitesse).\");\n" + ] + }, + { + "cell_type": "markdown", + "id": "dce59eaa", + "metadata": { + "papermill": {}, + "tags": [] + }, + "source": [ + "### Lire la table de transposition : le cache ne change pas la décision\n", + "\n", + "La sortie affiche `Alpha-Beta + Transposition : valeur=0, temps=0,0091s`, `Cache : 1565 hits, 3010 misses`, et le commentaire `-> Valeur identique a Minimax pur (le cache ne change pas la decision, seulement la vitesse)`. La statistique centrale est le couple **1565 hits / 3010 misses** : un peu plus d'un tiers des recherches d'état tombent sur une position déjà calculée — le Tic-Tac-Toe contient des transpositions (jouer case 1 puis case 3 mène au même état que 3 puis 1), et la table capture cette redondance. Le temps (0,0091 s) est du même ordre que l'alpha-beta pur : sur ce petit jeu, le cache ne paie pas — c'est exactement la lecture honnête, la table de transposition est un investissement qui rapporte sur les arbres profonds (échecs), pas sur 9 cases. Et la ligne finale est le contrat d'invariance : valeur identique = le cache est transparent pour la décision." + ] + }, + { + "cell_type": "markdown", + "id": "d5e876fc", + "metadata": { + "papermill": { + "duration": 0.002669, + "end_time": "2026-07-04T04:10:03.765802", + "exception": false, + "start_time": "2026-07-04T04:10:03.763133", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Exercices\n", + "\n", + "Les exercices suivants sont laissés en **stub `// TODO etudiant`** (règle C.1) : le notebook\n", + "s'exécute de bout en bout même non complété. Chaque exercice est précédé de son énoncé et d'indices.\n", + "Les classifier par contenu (règle de labeling) : ce sont des **exercices** (à compléter), à\n", + "distinguer des exemples guidés résolus ci-dessus." + ] + }, + { + "cell_type": "markdown", + "id": "090499d0", + "metadata": { + "papermill": { + "duration": 0.002999, + "end_time": "2026-07-04T04:10:04.013581", + "exception": false, + "start_time": "2026-07-04T04:10:04.010582", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "### Exercice 4 — Tournoi algorithmique\n", + "\n", + "Construisez un cadre de **tournoi automatique** entre stratégies : joueur aléatoire, Minimax,\n", + "Alpha-Beta jouent les uns contre les autres, en alternant qui commence. Collectez : victoires,\n", + "nulles, temps moyen par coup.\n", + "\n", + "**Indice** : écrivez `JouerPartie(jeu, fnMax, fnMin)` qui joue une partie complète en appelant `fnMax`\n", + "/ `fnMin` à chaque tour pour choisir l'action, et renvoie le résultat (+1/0/−1)." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "69036e93", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-25T14:09:44.741598Z", + "iopub.status.busy": "2026-10-25T14:09:44.740601Z", + "iopub.status.idle": "2026-10-25T14:09:44.851421Z", + "shell.execute_reply": "2026-10-25T14:09:44.850455Z" + }, + "papermill": { + "duration": 0.044612, + "end_time": "2026-07-04T04:10:04.061141", + "exception": false, + "start_time": "2026-07-04T04:10:04.016529", + "status": "completed" + }, + "tags": [], + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Framework de tournoi defini (JoueurAleatoire, JouerPartie).\r\n" + ] + } + ], + "source": [ + "// Exercice 4 : Tournoi algorithmique\n", + "\n", + "public record ResultatMatch(string JoueurA, string JoueurB, int VictoiresA, int VictoiresB, int Nuls);\n", + "var resultats = new List();\n", + "\n", + "public static int? JoueurAleatoire(IJeuSommeNulle jeu, TEtat etat, Random rng)\n", + "{\n", + " var actions = jeu.Actions(etat); // cases libres\n", + " if (actions.Count == 0) return null;\n", + " return actions[rng.Next(actions.Count)]; // une au hasard\n", + "}\n", + "\n", + "public static int? JouerPartie(\n", + " IJeuSommeNulle jeu,\n", + " Func, TEtat, int?> fnMax,\n", + " Func, TEtat, int?> fnMin)\n", + "{\n", + " var etat = jeu.EtatInitial();\n", + " while (!jeu.EstTerminal(etat))\n", + " {\n", + " // À qui le tour ? On choisit la bonne fonction\n", + " var fn = jeu.Joueur(etat) == \"MAX\" ? fnMax : fnMin;\n", + " int? action = fn(jeu, etat);\n", + " if (action is null) throw new InvalidOperationException(\"Aucun coup renvoyé.\");\n", + " etat = jeu.Resultat(etat, action.Value); // on joue le coup\n", + " }\n", + " return (int)jeu.Utilite(etat, \"MAX\"); // +1, 0 ou -1\n", + "}\n", + "\n", + "Console.WriteLine(\"Framework de tournoi defini (JoueurAleatoire, JouerPartie).\");" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "de88d0c4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-25T14:09:44.854457Z", + "iopub.status.busy": "2026-10-25T14:09:44.853517Z", + "iopub.status.idle": "2026-10-25T14:09:49.886051Z", + "shell.execute_reply": "2026-10-25T14:09:49.885049Z" + }, + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
indexvalue
0
ResultatMatch { JoueurA = Aleatoire, JoueurB = Minimax, VictoiresA = 0, VictoiresB = 7, Nuls = 3 }
JoueurA
Aleatoire
JoueurB
Minimax
VictoiresA
0
VictoiresB
7
Nuls
3
1
ResultatMatch { JoueurA = Aleatoire, JoueurB = AlphaBeta, VictoiresA = 0, VictoiresB = 8, Nuls = 2 }
JoueurA
Aleatoire
JoueurB
AlphaBeta
VictoiresA
0
VictoiresB
8
Nuls
2
2
ResultatMatch { JoueurA = Minimax, JoueurB = AlphaBeta, VictoiresA = 0, VictoiresB = 0, Nuls = 10 }
JoueurA
Minimax
JoueurB
AlphaBeta
VictoiresA
0
VictoiresB
0
Nuls
10
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
indexvalue
0
{ Strategie = Aleatoire, MsParCoup = 0.014, Coups = 66 }
Strategie
Aleatoire
MsParCoup
0.014
Coups
66
1
{ Strategie = Minimax, MsParCoup = 103.967, Coups = 79 }
Strategie
Minimax
MsParCoup
103.967
Coups
79
2
{ Strategie = AlphaBeta, MsParCoup = 3.744, Coups = 83 }
Strategie
AlphaBeta
MsParCoup
3.744
Coups
83
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "var rng = new Random(42);\n", + "var jeuT = new TicTacToe();\n", + "\n", + "var strategies = new Dictionary, TttEtat, int?>>\n", + "{\n", + " [\"Aleatoire\"] = (j, e) => JoueurAleatoire(j, e, rng),\n", + " [\"Minimax\"] = (j, e) => Minimax(j, e).Action,\n", + " [\"AlphaBeta\"] = (j, e) => AlphaBeta(j, e, double.NegativeInfinity, double.PositiveInfinity).Action,\n", + "};\n", + "\n", + "var tempsTotal = new Dictionary();\n", + "var nbCoups = new Dictionary();\n", + "\n", + "// Emballe une stratégie pour mesurer le temps de chacun de ses coups\n", + "Func, TttEtat, int?> Chrono(string nom) => (j, e) =>\n", + "{\n", + " var chrono = Stopwatch.StartNew();\n", + " var a = strategies[nom](j, e);\n", + " chrono.Stop();\n", + " tempsTotal[nom] = tempsTotal.GetValueOrDefault(nom) + chrono.Elapsed.TotalMilliseconds;\n", + " nbCoups[nom] = nbCoups.GetValueOrDefault(nom) + 1;\n", + " return a;\n", + "};\n", + "\n", + "var noms = strategies.Keys.ToList();\n", + "int manches = 5;\n", + "for (int i = 0; i < noms.Count; i++)\n", + "for (int k = i + 1; k < noms.Count; k++)\n", + "{\n", + " string A = noms[i], B = noms[k];\n", + " int vA = 0, vB = 0, nuls = 0;\n", + " for (int p = 0; p < manches; p++)\n", + " {\n", + " // Partie 1 : A commence (A = MAX)\n", + " int r1 = JouerPartie(jeuT, Chrono(A), Chrono(B)) ?? 0;\n", + " if (r1 == 1) vA++; else if (r1 == -1) vB++; else nuls++;\n", + " // Partie 2 : B commence (B = MAX), donc on lit le résultat à l'envers\n", + " int r2 = JouerPartie(jeuT, Chrono(B), Chrono(A)) ?? 0;\n", + " if (r2 == 1) vB++; else if (r2 == -1) vA++; else nuls++;\n", + " }\n", + " resultats.Add(new ResultatMatch(A, B, vA, vB, nuls));\n", + "}\n", + "\n", + "resultats.Display();\n", + "\n", + "noms.Select(n => new {\n", + " Strategie = n,\n", + " MsParCoup = Math.Round(tempsTotal[n] / nbCoups[n], 3),\n", + " Coups = nbCoups[n]\n", + "}).Display();" + ] + }, + { + "cell_type": "markdown", + "id": "c7a3beaf", + "metadata": { + "papermill": {}, + "tags": [] + }, + "source": [ + "### Lire les quatre exercices : l'échelle des extensions\n", + "\n", + "Les cellules d'exercice impriment la même invite (`Exercice N a completer`) — le contrat C.1 : le notebook s'exécute d'un bout à l'autre, la validation est l'étudiant. L'échelle se lit dans les énoncés : **Exercice 1** transpose tout ce notebook vers Connect Four (grille 7×6, victory line de 4) — le même minimimax sur un arbre qui ne tient plus en entier ; **Exercice 2** attaque l'ordonnancement des coups (move ordering) — l'ordre dans lequel alpha-beta visite les enfants détermine l'élagage, la question est de le mesurer ; **Exercice 3** unifie MAX et MIN en une seule récursion negamax — la reformulation standard des échecs ; **Exercice 4** organise le tournoi où ces variantes s'affrontent. Quatre niveaux : transférer, optimiser, reformuler, confronter." + ] + }, + { + "cell_type": "markdown", + "id": "75215c34", + "metadata": { + "papermill": { + "duration": 0.002891, + "end_time": "2026-07-04T04:10:04.067174", + "exception": false, + "start_time": "2026-07-04T04:10:04.064283", + "status": "completed" + }, + "tags": [] + }, + "source": [ + "## Conclusion\n", + "\n", + "Ce jumeau .NET a porté fidèlement les cinq pierres angulaires de la recherche adversariale :\n", + "\n", + "1. **Minimax** (von Neumann, 1928) — la valeur du jeu sous hypothèse d'adversaire parfait ;\n", + "2. **Alpha-Beta** (Knuth & Moore, 1975) — même décision, moins de nœuds (élagage par bornes) ;\n", + "3. **Heuristique + profondeur limitée** — pour les jeux trop profonds à explorer entièrement ;\n", + "4. **Iterative deepening** — recherche interrompible, meilleur coup disponible à tout instant ;\n", + "5. **Table de transposition** — cache des états déjà calculés (et non du code métier).\n", + "\n", + "La leçon générale, cohérente avec le reste de la série Search : **l'optimalité a un coût** (Minimax\n", + "explore tout l'arbre), et l'art consiste à **préserver la décision optimale en réduisant le travail**\n", + "— Alpha-Beta, l'ordonnancement des coups, les tables de transposition sont autant de leviers qui\n", + "rapprochent le coût effectif du coût incompressible. Pour les jeux où même cela ne suffit pas\n", + "(échecs, Go), on **relâche l'optimalité** via une heuristique d'évaluation — exactement le même\n", + "contraste qu'entre A\\* (optimal, Partie 1) et les métaheuristiques (approché, Partie 4).\n", + "\n", + "## Ponts\n", + "\n", + "- **Notebook Python source** : [Search-06-AdversarialSearch](Search-06-AdversarialSearch.ipynb) — même\n", + " contenu algorithmique, noyau `python3` (avec `numpy`/`matplotlib` pour la visualisation) ;\n", + "- **Partie 3** : [Search-03b-PatternDatabases](../Part1-Foundations/Search-03b-PatternDatabases.ipynb)\n", + " prolonge la recherche heuristique à un seul agent (A\\*/IDA\\*, PDB) ;\n", + "- **Partie 4** : les métaheuristiques composables (recuit simulé, algorithmes génétiques) relâchent\n", + " l'optimalité garantie pour gagner en scalabilité ;\n", + "- **EPIC [#4956](https://github.com/jsboige/CoursIA/issues/4956)** : parité `.NET ⇄ Python` des\n", + " séries de notebooks." + ] + } + ], + "metadata": { + "cost": { + "api_provider": "none", + "api_usd_est": 0, + "cpu_min": 2, + "external_account": "none", + "free_alternative": "self", + "gpu_min": 0, + "gpu_required": false, + "metadata_written": "2026-07-28", + "network": false, + "qcc_tokens_est": 0, + "reduced_pedagogical": null, + "reproducibility": "HIGH", + "validator": "manual", + "vram_gb": 0, + "vram_tier": "NONE" + }, + "kernelspec": { + "display_name": ".NET (C#)", + "language": "C#", + "name": ".net-csharp" + }, + "language_info": { + "file_extension": ".cs", + "mimetype": "text/x-csharp", + "name": "C#", + "pygments_lexer": "csharp", + "version": "13.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}