Search-03d (C#) — Weighted A* : recherche à sous-optimalité bornée

Partie 3 — Recherche avancée. Ce notebook est le jumeau .NET du notebook Search-03d — Weighted A* (Python). Il implémente en C# le même algorithme — Weighted A* de Pohl (1970) — sur le même banc d’essai (cheminement sur un terrain pondéré), pour montrer la transposition en C# d’un best-first search paramétré par un poids \(W\) sur l’heuristique.

Prérequis : avoir vu les fondations en Partie 1 — Search-3 (Informed) (A*, heuristiques admissibles).

Note sur les sorties : le générateur aléatoire Random(42) du runtime .NET n’étant pas identique au Mersenne Twister de Python, le terrain généré diffère de celui du jumeau Python — donc les coûts et nombres de nœuds exacts diffèrent. Les propriétés pédagogiques (A* optimal, Greedy rapide mais sous-optimal, Weighted A* interpolle avec garantie \(\text{coût} \leq W \cdot \text{optimal}\)) sont préservées sur tout terrain non-dégénéré.

2. Le banc d’essai : cheminement sur un terrain pondéré

Pour mettre Weighted A* en valeur, il faut un problème où A* explore beaucoup (où l’optimalité coûte de nombreux nœuds) et où le compromis vitesse/qualité est net : un terrain pondéré (routes rapides, herbe lente, marécages quasi-impraticables, murs). Sur un graphe à coût uniforme, A* dégénère en BFS et Weighted A* n’apporte rien (cf la leçon « problème dégénéré ») — il faut des coûts contrastés pour que l’heuristique et l’inflation par \(W\) soient visibles dans la sortie.

// --- Terrain pondéré : chaque case a un coût de traversée ---
// 1 = route (rapide), 3 = herbe, 8 = marécage, '#' = mur infranchissable.
// Coûts CONTRASTÉS -> A* va explorer, Greedy va se tromper : terrain non-dégénéré.
using System.Collections.Generic;

// Coût de traversée par type de case (null = mur).
var TERRAIN_COST = new Dictionary<char, int?> {
    ['.'] = 1, ['g'] = 3, ['s'] = 8, ['#'] = null   // s = swamp, g = grass
};

const int ROWS = 25, COLS = 25;
(int, int) start = (0, 0), goal = (ROWS - 1, COLS - 1);

// Grille : (row, col) -> type de case. RNG .NET (différent du Mersenne Twister Python).
Dictionary<(int, int), char> MakeGrid(int rows, int cols, int seed)
{
    var rng = new Random(seed);
    var grid = new Dictionary<(int, int), char>();
    for (int r = 0; r < rows; r++)
        for (int c = 0; c < cols; c++)
        {
            double x = rng.NextDouble();
            if (x < 0.15) grid[(r, c)] = '#';        // mur (15%)
            else if (x < 0.45) grid[(r, c)] = 'g';   // herbe (30%)
            else if (x < 0.60) grid[(r, c)] = 's';   // marécage (15%)
            else grid[(r, c)] = '.';                 // route (40%)
        }
    (int, int) start = (0, 0), goal = (rows - 1, cols - 1);
    grid[start] = '.'; grid[goal] = '.';             // départ + arrivée franchissables
    return grid;
}

// Heuristique : Manhattan (admissible : coût min = 1, distance >= Manhattan*1).
int Manhattan((int r, int c) a, (int r, int c) b) => Math.Abs(a.r - b.r) + Math.Abs(a.c - b.c);

// Vérifie la connectivité start->goal par BFS (évite un terrain cloisonné par les murs).
bool Connected(Dictionary<(int, int), char> g, (int, int) s, (int, int) goal)
{
    var seen = new HashSet<(int, int)> { s };
    var queue = new Queue<(int, int)>();
    queue.Enqueue(s);
    while (queue.Count > 0)
    {
        var cur = queue.Dequeue();
        if (cur.Equals(goal)) return true;
        foreach (var n in Neighbors(g, cur))
            if (seen.Add(n)) queue.Enqueue(n);
    }
    return false;
}

// Comme le RNG .NET (≠ Mersenne Twister Python) peut cloisonner le départ, on cherche
// le premier seed tel que start rejoigne goal. Pédagogie inchangée ; seed différé du jumeau Python.
Dictionary<(int, int), char> MakeConnectedGrid(int rows, int cols, (int, int) s, (int, int) g, out int seedUsed)
{
    int seed = 42;
    while (true)
    {
        var grid = MakeGrid(rows, cols, seed);
        if (Connected(grid, s, g)) { seedUsed = seed; return grid; }
        seed++;
    }
}

var grid = MakeConnectedGrid(ROWS, COLS, start, goal, out int actualSeed);

// Coût d'un pas vers une case voisine (null = mur / hors-boundaires).
int? StepCost(Dictionary<(int, int), char> g, (int, int) cur, (int, int) nxt)
{
    return g.TryGetValue(nxt, out char t) ? TERRAIN_COST[t] : null;
}

// Voisins 4-connexes franchissables.
List<(int, int)> Neighbors(Dictionary<(int, int), char> g, (int r, int c) s)
{
    var out_ = new List<(int, int)>();
    foreach (var (dr, dc) in new[] { (-1, 0), (1, 0), (0, -1), (0, 1) })
    {
        var n = (s.r + dr, s.c + dc);
        if (n.Item1 >= 0 && n.Item1 < ROWS && n.Item2 >= 0 && n.Item2 < COLS
            && StepCost(g, s, n).HasValue)
            out_.Add(n);
    }
    return out_;
}

int nWalls = 0;
foreach (var v in grid.Values) if (v == '#') nWalls++;
Console.WriteLine($"Grille {ROWS}x{COLS} : {nWalls} murs ({100.0*nWalls/(ROWS*COLS):F0}%), départ {start}, but {goal} (seed .NET = {actualSeed}).");
Console.WriteLine("Coûts contrastés (route=1, herbe=3, marécage=8) -> A* va explorer, Greedy va se tromper : terrain non-dégénéré.");
Grille 25x25 : 91 murs (15%), départ (0, 0), but (24, 24) (seed .NET = 43).
Coûts contrastés (route=1, herbe=3, marécage=8) -> A* va explorer, Greedy va se tromper : terrain non-dégénéré.

3. A*, Greedy, et Weighted A* : un seul algorithme paramétré

Les algorithmes se ramènent à un même best-first search sur \(f(n) = g(n) + W \cdot h(n)\) : le poids \(W\) de l’heuristique est le seul paramètre.

  • \(W = 1\) → A* (optimal, admissible) ;
  • \(W > 1\) → Weighted A* (sous-optimalité bornée par \(W\)) ;
  • \(W \to \infty\) → Greedy (l’heuristique domine, \(g\) devient négligeable).

On implémente donc une fonction unique BestFirst(grid, start, goal, W) paramétrée par \(W\), puis un TrueGreedy séparé (qui ignore \(g\) dans la file de priorité, pôle non borné).

// Best-first search sur f(n) = g(n) + W*h(n). W=1 -> A* ; W>1 -> Weighted A*.
// Renvoie (chemin, coût, nœuds développés). Re-push sur meilleur g (pas de decrease-key explicite).
(List<(int, int)> Path, int Cost, int Nodes) BestFirst(
    Dictionary<(int, int), char> g, (int, int) start, (int, int) goal, double W,
    Func<(int, int), (int, int), int> h)
{
    var gCost = new Dictionary<(int, int), int> { [start] = 0 };
    var parent = new Dictionary<(int, int), (int, int)?> { [start] = null };
    // PriorityQueue<élément, priorité> (.NET 6+) — min-heap sur la priorité f.
    var open = new PriorityQueue<(int, int), double>();
    open.Enqueue(start, W * h(start, goal));
    int developed = 0;
    while (open.Count > 0)
    {
        var cur = open.Dequeue();
        developed++;
        if (cur.Equals(goal))
        {
            var path = new List<(int, int)>();
            (int, int)? p = goal;
            while (p is not null) { path.Add(p.Value); p = parent[p.Value]; }
            path.Reverse();
            return (path, gCost[goal], developed);
        }
        foreach (var nxt in Neighbors(g, cur))
        {
            int nc = gCost[cur] + StepCost(g, cur, nxt)!.Value;
            if (!gCost.ContainsKey(nxt) || nc < gCost[nxt])
            {
                gCost[nxt] = nc;
                parent[nxt] = cur;
                open.Enqueue(nxt, nc + W * h(nxt, goal));
            }
        }
    }
    return (new List<(int, int)>(), int.MaxValue, developed);   // pas de chemin
}

// Vrai Greedy best-first (f = h uniquement, g ignoré dans la file) : le pôle NON BORNÉ.
// On garde g pour reconstruire le chemin et mesurer son coût réel.
(List<(int, int)> Path, int Cost, int Nodes) TrueGreedy(
    Dictionary<(int, int), char> g, (int, int) start, (int, int) goal,
    Func<(int, int), (int, int), int> h)
{
    var gCost = new Dictionary<(int, int), int> { [start] = 0 };
    var parent = new Dictionary<(int, int), (int, int)?> { [start] = null };
    var open = new PriorityQueue<(int, int), double>();
    open.Enqueue(start, h(start, goal));
    int developed = 0;
    var seen = new HashSet<(int, int)>();
    while (open.Count > 0)
    {
        var cur = open.Dequeue();
        developed++;
        if (!seen.Add(cur)) continue;
        if (cur.Equals(goal))
        {
            var path = new List<(int, int)>();
            (int, int)? p = goal;
            while (p is not null) { path.Add(p.Value); p = parent[p.Value]; }
            path.Reverse();
            return (path, gCost[goal], developed);
        }
        foreach (var nxt in Neighbors(g, cur))
        {
            int nc = gCost[cur] + StepCost(g, cur, nxt)!.Value;
            if (!gCost.ContainsKey(nxt) || nc < gCost[nxt])
            {
                gCost[nxt] = nc;
                parent[nxt] = cur;
                open.Enqueue(nxt, h(nxt, goal));   // f = h seulement
            }
        }
    }
    return (new List<(int, int)>(), int.MaxValue, developed);
}

// Repères : A* (W=1) = référence optimale ; Greedy (f=h) = pôle rapide non borné.
var (astarPath, astarCost, astarNodes) = BestFirst(grid, start, goal, W: 1.0, Manhattan);
var (greedyPath, greedyCost, greedyNodes) = TrueGreedy(grid, start, goal, Manhattan);
Console.WriteLine($"A*     (W=1, optimal)   : coût = {astarCost,3} | nœuds développés = {astarNodes}");
Console.WriteLine($"Greedy (f=h, non borné) : coût = {greedyCost,3} | nœuds développés = {greedyNodes}  (ratio = {greedyCost/(double)astarCost:F2}x optimal, AUCUNE borne)");
int optimal = astarCost;   // A* admissible -> référence d'optimalité
Console.WriteLine($"\nRéférence d'optimalité (A* admissible) : {optimal}.");
A*     (W=1, optimal)   : coût =  79 | nœuds développés = 497
Greedy (f=h, non borné) : coût = 152 | nœuds développés = 55  (ratio = 1,92x optimal, AUCUNE borne)

Référence d'optimalité (A* admissible) : 79.

4. Le balayage de \(W\) : la frontière coût/nœuds

Faisons varier \(W\) de 1 (A* optimal) à de grandes valeurs (proche Greedy). À chaque \(W\), on mesure le coût de la solution et le nombre de nœuds développés. La promesse de Weighted A* est double, et on la vérifie honnêtement :

  1. Vitesse — à mesure que \(W\) croît, le nombre de nœuds chute.
  2. Garantie — le coût de la solution reste \(\leq W \cdot \text{optimal}\) (Pohl 1970).
// Balayage de W >= 1 (le régime Weighted A*) : A* (W=1) -> de plus en plus "confiant".
double[] weights = { 1.0, 1.5, 2.0, 3.0, 5.0, 8.0, 12.0 };
Console.WriteLine(string.Format("{0,5} | {1,5} | {2,9} | {3,7} | {4,6} | {5,6} | {6}",
    "W", "coût", "ratio/opt", "borne W", "nœuds", "% A*", "garantie?"));
Console.WriteLine(new string('-', 66));
foreach (double W in weights)
{
    var (path, cost, nodes) = BestFirst(grid, start, goal, W, Manhattan);
    double ratio = cost / (double)optimal;
    double pct = 100.0 * nodes / astarNodes;
    string garantit = cost <= W * optimal + 1e-9 ? "OUI" : "NON";
    Console.WriteLine(string.Format("{0,5:F1} | {1,5} | {2,9:F3} | {3,7:F2} | {4,6} | {5,5:F1}% | {6}",
        W, cost, ratio, W, nodes, pct, garantit));
}
    W |  coût | ratio/opt | borne W |  nœuds |   % A* | garantie?
------------------------------------------------------------------
  1,0 |    79 |     1,000 |    1,00 |    497 | 100,0% | OUI
  1,5 |    79 |     1,000 |    1,50 |    377 |  75,9% | OUI
  2,0 |    79 |     1,000 |    2,00 |    124 |  24,9% | OUI
  3,0 |    91 |     1,152 |    3,00 |     82 |  16,5% | OUI
  5,0 |    91 |     1,152 |    5,00 |     53 |  10,7% | OUI
  8,0 |    91 |     1,152 |    8,00 |     53 |  10,7% | OUI
 12,0 |    96 |     1,215 |   12,00 |     52 |  10,5% | OUI
// Visualisation ASCII : le chemin trouvé par A* (W=1) vs Weighted A* (W=3).
string Render(Dictionary<(int, int), char> g, List<(int, int)> path)
{
    var sym = new Dictionary<char, char> { ['.'] = '.', ['g'] = ',', ['s'] = '~', ['#'] = '#' };
    var cellsMap = new Dictionary<(int, int), char>();
    foreach (var kv in g) cellsMap[kv.Key] = sym[kv.Value];
    foreach (var s in path) cellsMap[s] = '@';        // le chemin
    cellsMap[start] = 'S'; cellsMap[goal] = 'G';
    var lines = new List<string>();
    for (int r = 0; r < ROWS; r++)
    {
        var sb = new System.Text.StringBuilder();
        for (int c = 0; c < COLS; c++) sb.Append(cellsMap[(r, c)]);
        lines.Add(sb.ToString());
    }
    return string.Join("\n", lines);
}

var (wa3Path, _, _) = BestFirst(grid, start, goal, W: 3.0, Manhattan);
Console.WriteLine("Légende : S départ · G but · @ chemin · . route · , herbe · ~ marécage · # mur\n");
Console.WriteLine("=== A* (W=1, optimal) ===");
Console.WriteLine(Render(grid, astarPath));
Console.WriteLine("\n=== Weighted A* (W=3) ===");
Console.WriteLine(Render(grid, wa3Path));
Légende : S départ · G but · @ chemin · . route · , herbe · ~ marécage · # mur

=== A* (W=1, optimal) ===
S,...#,#,#.#,.#.....~,,#.
@.,,.#.~#.....,~..#~..#,.
@#.,..,..,,.~~#,,#,~,,.~,
@@#~,,..~,..,,.~..,#.###,
,@@..,,,..##,~,~.#,#,#.,#
,,@,..~###,.#.,.~~,.,~,#.
.#@.##...,.~###~#,~...,#,
.~@~..#.....#..,,,~,,,~,,
,,@,##...,~,#,~..,#,..,~,
,~@@.,#,,,,,,..#.,.,.#...
~#,@,~,,..~#...~#.#,,,.,,
.~~@~,..#~,.~#.,,..,.....
.~.@,#,,~,.~.,,.,.,..~~,.
,..@@@@@@~....,,,#...,,..
~#,##,.~@@,,~#,#...~#~,.~
~~.~,.,,#@@,.,,.,.,#...,.
.~.,~..~~~@@#.,.,,,,..~,.
.......,.,~@.~@@@,.~.,,,#
.,...##,~.,@@@@#@@,.,,.,#
.~...~,..#~.,.,~,@@~~#~#,
..#,.,,,.#.,,.,###@,#,,.,
..,.....~.~,,..#~~@.,#.~~
~#..,~~###~,.,,.~.@@@.,,#
~..,#.#.,#.,.,,.#,.#@@,,.
~,.....~.....#,,~~#.~@@@G

=== Weighted A* (W=3) ===
S,...#,#,#.#,.#.....~,,#.
@@@,.#.~#.....,~..#~..#,.
,#@@@@,..,,.~~#,,#,~,,.~,
..#~,@@@~@@@@@@~..,#.###,
,....,,@@@##,~@~.#,#,#.,#
,,.,..~###,.#.@@~~,.,~,#.
.#..##...,.~###@#,~...,#,
.~~~..#.....#..@,,~,,,~,,
,,.,##...,~,#,~@@,#,..,~,
,~...,#,,,,,,..#@@.,.#...
~#,.,~,,..~#...~#@#,,,.,,
.~~,~,..#~,.~#.,,@.,.....
.~..,#,,~,.~.,,.,@@..~~,.
,...,...,~....,,,#@..,,..
~#,##,.~.,,,~#,#..@~#~,.~
~~.~,.,,#..,.,,.,.@#...,.
.~.,~..~~~.,#.,.,,@,..~,.
.......,.,~..~.,.,@~.,,,#
.,...##,~.,....#.,@.,,.,#
.~...~,..#~.,.,~,,@~~#~#,
..#,.,,,.#.,,.,###@,#,,.,
..,.....~.~,,..#~~@.,#.~~
~#..,~~###~,.,,.~.@@@.,,#
~..,#.#.,#.,.,,.#,.#@@,,.
~,.....~.....#,,~~#.~@@@G

Lecture du résultat

Le balayage encadre le compromis structurel de Weighted A* par ses deux pôles, puis l’interpolation bornée entre les deux :

  • Greedy (f=h) — pôle rapide, NON borné. Peu de nœuds, mais un coût qui peut dériver arbitrairement (ratio \(> 1\) sans garantie).
  • A* (W=1) — pôle optimal, cher. Coût optimal mais le plus de nœuds développés.
  • Weighted A* (\(W > 1\)) — interpolation bornée. À mesure que \(W\) croît, le nombre de nœuds chute, tandis que le coût reste \(\leq W \cdot \text{optimal}\) (colonne « garantie » = OUI tant que l’heuristique reste admissible).

C’est tout l’intérêt : échanger une garantie d’optimalité contre un gain de vitesse, de façon paramétrable.

5. Pourquoi ça marche : la garantie de sous-optimalité bornée

La garantie \(\text{coût} \leq W \cdot \text{optimal}\) repose sur une idée simple. En gonflant l’heuristique par \(W\), on rend la recherche plus confiante dans l’estimation \(h\) : elle « fonce » vers le but en s’autorisant à développer moins de nœuds latéraux. Le prix de cette confiance est qu’on peut manquer le chemin optimal — mais le facteur d’inflation \(W\) borne exactement de combien on peut dévier.

Formellement (Pohl 1970) : si \(h\) est admissible (\(h \leq h^*\)), alors la solution retournée par Weighted A* a un coût \(\leq W \cdot C^*\), où \(C^*\) est le coût optimal.

6. Ponts avec le reste de la série

Direction Lien Relation
↔︎ Search-03b Search-03b — Pattern Databases Autre réponse à « l’optimum est dur » : renforcer l’heuristique (rester optimal) vs ici relâcher l’optimalité
↔︎ Search-03c Search-03c — LDS LDS borne les écarts à l’heuristique (rester optimal) ; Weighted A* relâche l’optimalité — même constat, stratégies opposées
↔︎ Search-03c (C#) Search-03c — LDS (C#) Jumeau .NET de LDS — même triptyque en C#
← Fondations Search-3 — Informed A*, heuristiques admissibles — Weighted A* en est la généralisation pondérée
↔︎ Python Search-03d — Weighted A* (Python) Même algorithme en Python (jumeau)

Référence : Pohl, I. (1970). Heuristic Search Viewed as Path Finding in a Graph. Artificial Intelligence 1(3-4). Origine de Weighted A* et de la garantie \(W\)-bornée.

7. Exercices

Exercice 1 : courbe de Pareto coût/nœuds

Tracez (ou affichez en ASCII) la frontière de Pareto : en abscisse le nombre de nœuds développés, en ordonnée le ratio coût/optimal, un point par valeur de \(W \in [1, 12]\). Identifiez le « genou » (knee) — la valeur de \(W\) au-delà de laquelle gagner encore en vitesse coûte cher en qualité. - Indice : le genou est le point d’inflexion : avant, on gagne beaucoup de nœuds pour peu de qualité perdue ; après, le ratio coûte/optimal grimpe vite. Reprenez le balayage avec un pas plus fin (ex. [1, 1.2, 1.5, 2, 2.5, 3, 4, 6, 9, 12]).

// Exercice 1 à compléter
// Conseil : reprenez le balayage ci-dessus (weights plus fin), collectez (nodes, ratio)
// pour chaque W, et repérez le "genou" de la frontière de Pareto.
Console.WriteLine("Exercice 1 à compléter");
Exercice 1 à compléter

Exercice 2 : sensibilité à l’heuristique (admissible vs inadmissible)

Refaitez le balayage de \(W\) avec une heuristique inadmissible (par ex. Manhattan sans tenir compte du coût minimal : \(h\) sur-estimé d’un facteur 2 dès le départ). Vérifiez que la garantie \(\text{coût} \leq W \cdot \text{optimal}\) est alors violée. - Indice : la garantie suppose \(h\) admissible. Avec h_bad = 2 * Manhattan, le « W effectif » devient \(W \cdot 2\), et la borne théorique n’est plus tenue.

// Exercice 2 à compléter
// Conseil : définissez HBad(s) = 2*Manhattan(s, goal) (inadmissible), relancez le balayage,
// et cherchez un W où cost > W*optimal (garantie violée).
Console.WriteLine("Exercice 2 à compléter");
Exercice 2 à compléter

Exercice 3 : Anytime Repairing A* (ARA*)

Implémentez une version anytime : démarrez avec un grand \(W_0\) (solution rapide), puis décrémentez \(W\) par petits pas en réutilisant la liste OPEN déjà construite, jusqu’à \(W=1\) (solution optimale). À chaque étape, affichez le coût courant et le temps écoulé — l’utilisateur peut s’arrêter quand il est satisfait. - Indice : l’idée de Likhachev et al. (2003) est que la OPEN d’un \(W\) donné contient déjà la quasi-totalité du travail pour le \(W\) suivant. Bouclez \(W = W_0, W_0 - \delta, \dots, 1\).

// Exercice 3 à compléter
// Conseil : bouclez W = W0, W0-step, ..., 1. À chaque W, re-triez l'OPEN existante par la
// nouvelle clé f=g+W*h (ou réinsérez), développez jusqu'au but, affichez (W, coût, t).
Console.WriteLine("Exercice 3 à compléter");
Exercice 3 à compléter

Conclusion

Weighted A* referme le triptyque de la Partie 3. Face à un problème où A* optimal explore trop de nœuds, on dispose de trois leviers : renforcer l’heuristique (Search-03b, PDB), borner les écarts à l’heuristique (Search-03c, LDS) — qui préservent l’optimalité — ou, sujet de ce notebook, relâcher l’optimalité de façon contrôlée (Weighted A*) : accepter une solution au plus \(W\) fois l’optimal pour explorer bien moins de nœuds.

Comparaison C# vs Python (jumeau)

Le port C# met en évidence : - le PriorityQueue<TElement, TPriority> natif .NET 6+ vs le module heapq de Python (min-heap dans les deux cas, sémantique identique) ; - les tuples value types (int, int) comme clés de Dictionary (égalité/hachage automatiques via ValueTuple) vs les tuples Python ; - le typage statique des signatures (Func<(int,int),(int,int),int> pour l’heuristique) vs la simplesse dynamique de Python.

L’algorithmique, elle, est identique — Weighted A* est language-agnostic. Voir le jumeau Python pour la version dynamique (et des chiffres exacts sur un terrain Mersenne-Twister).

Référence : Pohl, I. (1970). Heuristic Search Viewed as Path Finding in a Graph. Artificial Intelligence 1(3-4).

Retour au sommet