GameTheory-05-ZeroSum-Minimax-Python (Twin C#)

Jumeau C# (.NET Interactive) de GameTheory-05-ZeroSum-Minimax-Python.ipynb — marathon #4956 (parite .NET <-> Python), axe-2 SOTA #3801 Prong B.

Le notebook Python définit un jeu a somme nulle puis invoque la bibliotheque scipy.optimize.linprog (solveur LP industriel) et nashpy pour resoudre le theoreme minimax de Von Neumann. Ce twin deroule tout a la main (BCL .NET 9, 0 NuGet) : on code le modèle, on detecte les points-selle purs, et surtout on implemente la méthode du simplexe from-scratch pour resoudre le programme lineaire du theoreme minimax. C’est la mecanique exacte que linprog cache.

1. Modèle : jeu a somme nulle

La matrice \(A\) represente les gains de Row ; les gains de Col sont \(-A\). Une stratégie mixte \(\sigma\) est un vecteur de probabilites. Le gain espere de Row est \(\sigma^\top A \tau\).

// Modele de jeu a somme nulle (pas de NuGet, BCL .NET seule)
using System.Globalization;

public class ZeroSumGame
{
    public double[,] A { get; }
    public int M { get; }   // nb lignes (actions de Row)
    public int N { get; }   // nb colonnes (actions de Col)
    public string[] RowLabels { get; }
    public string[] ColLabels { get; }
    public string Name { get; set; }

    public ZeroSumGame(double[,] a, string[] rowLabels = null, string[] colLabels = null, string name = "Zero-Sum Game")
    {
        A = a; M = a.GetLength(0); N = a.GetLength(1);
        RowLabels = rowLabels ?? DefaultLabels(M, "R");
        ColLabels = colLabels ?? DefaultLabels(N, "C");
        Name = name;
    }
    static string[] DefaultLabels(int k, string p) { var s = new string[k]; for (int i = 0; i < k; i++) s[i] = p + i; return s; }

    // Gain espere de Row pour le couple de strategies mixtes (sigma : M, tau : N)
    public double Payoff(double[] sigma, double[] tau)
    {
        double s = 0;
        for (int i = 0; i < M; i++) for (int j = 0; j < N; j++) s += sigma[i] * A[i, j] * tau[j];
        return s;
    }

    public string Display()
    {
        var sb = new System.Text.StringBuilder();
        sb.AppendLine(Name + " (gains de Row)");
        sb.AppendLine(new string('-', 8 + 9 * N));
        sb.Append("       ");
        for (int j = 0; j < N; j++) sb.Append(string.Format(CultureInfo.InvariantCulture, "{0,9}", ColLabels[j]));
        sb.AppendLine(); sb.AppendLine(new string('-', 8 + 9 * N));
        for (int i = 0; i < M; i++)
        {
            sb.Append(string.Format(CultureInfo.InvariantCulture, "{0,6}  ", RowLabels[i]));
            for (int j = 0; j < N; j++) sb.Append(string.Format(CultureInfo.InvariantCulture, "{0,8:F2} ", A[i, j]));
            sb.AppendLine();
        }
        return sb.ToString();
    }
}

// Helper formatage invariant (la culture FR ne persiste pas entre cellules .NET Interactive)
static string FI(double x, string fmt = "F4") { double z = Math.Abs(x) < 1e-12 ? 0.0 : x; return string.Format(CultureInfo.InvariantCulture, "{0:" + fmt + "}", z); }
static string Vec(double[] v, string fmt = "F4") { return "[ " + string.Join(",  ", v.Select(x => FI(x, fmt))) + " ]"; }

var rps = new ZeroSumGame(new double[,] { { 0, -1, 1 }, { 1, 0, -1 }, { -1, 1, 0 } },
    new[] { "Pierre", "Feuille", "Ciseaux" }, new[] { "Pierre", "Feuille", "Ciseaux" }, "Pierre-Feuille-Ciseaux");
var mp = new ZeroSumGame(new double[,] { { 1, -1 }, { -1, 1 } },
    new[] { "Pile", "Face" }, new[] { "Pile", "Face" }, "Matching Pennies");

display(rps.Display());
display(mp.Display());
Pierre-Feuille-Ciseaux (gains de Row)
-----------------------------------
          Pierre  Feuille  Ciseaux
-----------------------------------
Pierre      0.00    -1.00     1.00 
Feuille      1.00     0.00    -1.00 
Ciseaux     -1.00     1.00     0.00 
Matching Pennies (gains de Row)
--------------------------
            Pile     Face
--------------------------
  Pile      1.00    -1.00 
  Face     -1.00     1.00 

2. Stratégies maximin et minimax (pures)

Avant les stratégies mixtes : la stratégie maximin de Row maximise son pire cas (pour chaque action, le gain minimum sur les reponses de Col ; on prend la meilleure). Symetriquement, Col minimise son pire cas. Quand les deux valeurs coincident, il existe un point-selle (equilibre en stratégies pures).

// Maximin pur de Row : meilleure action dans le pire cas
// Minimax pur de Col : action qui minimise le maximum de Row
static (int action, double value) MaximinPure(ZeroSumGame g)
{
    int best = 0; double bestVal = double.NegativeInfinity;   // on MAXIMISE le minimum de ligne -> init -Inf
    for (int i = 0; i < g.M; i++)
    {
        double worst = double.PositiveInfinity;
        for (int j = 0; j < g.N; j++) worst = Math.Min(worst, g.A[i, j]);
        if (worst > bestVal) { bestVal = worst; best = i; }
    }
    return (best, bestVal);
}
static (int action, double value) MinimaxPure(ZeroSumGame g)
{
    int best = 0; double bestVal = double.PositiveInfinity;
    for (int j = 0; j < g.N; j++)
    {
        double worst = double.NegativeInfinity;
        for (int i = 0; i < g.M; i++) worst = Math.Max(worst, g.A[i, j]);
        if (worst < bestVal) { bestVal = worst; best = j; }
    }
    return (best, bestVal);
}

static string AnalyzePure(ZeroSumGame g)
{
    var (ra, rv) = MaximinPure(g);
    var (ca, cv) = MinimaxPure(g);
    var sb = new System.Text.StringBuilder();
    sb.AppendLine(g.Display());
    sb.AppendLine("Analyse maximin/minimax :");
    sb.AppendLine(new string('-', 40));
    sb.AppendLine("Maximin (Row) : action '" + g.RowLabels[ra] + "', valeur = " + FI(rv));
    sb.AppendLine("Minimax (Col) : action '" + g.ColLabels[ca] + "', valeur = " + FI(cv));
    if (Math.Abs(rv - cv) < 1e-9)
    {
        sb.AppendLine("=> Point-selle ! Valeur du jeu = " + FI(rv));
        sb.AppendLine("   Equilibre pur : (" + g.RowLabels[ra] + ", " + g.ColLabels[ca] + ")");
    }
    else
    {
        sb.AppendLine("=> Pas de point-selle en strategies pures");
        sb.AppendLine("   Gap : " + FI(cv - rv) + " -> il faut des strategies mixtes");
    }
    return sb.ToString();
}

display(AnalyzePure(mp));
display(new string('=', 60));
display(AnalyzePure(rps));
Matching Pennies (gains de Row)
--------------------------
            Pile     Face
--------------------------
  Pile      1.00    -1.00 
  Face     -1.00     1.00 

Analyse maximin/minimax :
----------------------------------------
Maximin (Row) : action 'Pile', valeur = -1.0000
Minimax (Col) : action 'Pile', valeur = 1.0000
=> Pas de point-selle en strategies pures
   Gap : 2.0000 -> il faut des strategies mixtes
============================================================
Pierre-Feuille-Ciseaux (gains de Row)
-----------------------------------
          Pierre  Feuille  Ciseaux
-----------------------------------
Pierre      0.00    -1.00     1.00 
Feuille      1.00     0.00    -1.00 
Ciseaux     -1.00     1.00     0.00 

Analyse maximin/minimax :
----------------------------------------
Maximin (Row) : action 'Pierre', valeur = -1.0000
Minimax (Col) : action 'Pierre', valeur = 1.0000
=> Pas de point-selle en strategies pures
   Gap : 2.0000 -> il faut des strategies mixtes

Lecture — maximin et minimax en pur : aucun point-selle

Sur Matching Pennies, l’analyse rend maximin(Row) = −1,0 (Row choisit Pile, qui lui garantit −1) et minimax(Col) = +1,0 (Col choisit Pile, qui plafonne Row à +1). L’écart affiché — le « gap » — vaut 2,0 ; la ligne => Pas de point-selle en strategies pures en découle. Pierre-Feuille-Ciseaux donne le même verdict (maximin −1, minimax +1, gap 2,0), par symétrie cyclique de ses gains. La conclusion est le moteur de tout le notebook : dès que maximin ≠ minimax, aucun couple d’actions pures n’est un équilibre ; il faut recourir aux stratégies mixtes. Le tableau 3×3 qui suit est précisément le contre-exemple positif (un jeu, lui, avec point-selle).

Un jeu avec point-selle

Le jeu ci-dessous possede un point-selle : la valeur maximin egale la valeur minimax, l’equilibre est en stratégies pures.

// Jeu avec un VRAI point-selle : A[1,1]=5 est min de sa ligne [6,5,7] ET max de sa colonne [4,5,3].
var saddle = new ZeroSumGame(new double[,] { { 3, 4, 8 }, { 6, 5, 7 }, { 1, 3, 2 } },
    new[] { "R1", "R2", "R3" }, new[] { "C1", "C2", "C3" }, "Jeu avec point-selle");
display(AnalyzePure(saddle));
Jeu avec point-selle (gains de Row)
-----------------------------------
              C1       C2       C3
-----------------------------------
    R1      3.00     4.00     8.00 
    R2      6.00     5.00     7.00 
    R3      1.00     3.00     2.00 

Analyse maximin/minimax :
----------------------------------------
Maximin (Row) : action 'R2', valeur = 5.0000
Minimax (Col) : action 'C2', valeur = 5.0000
=> Point-selle ! Valeur du jeu = 5.0000
   Equilibre pur : (R2, C2)

Lecture — un vrai point-selle : la valeur du jeu est 5

Ce tableau 3×3 est le contre-exemple de la cellule précédente. L’analyse rend maximin(Row) = 5 (Row joue R2) et minimax(Col) = 5 (Col joue C2) — ils coïncident, donc la valeur du jeu est 5 et le couple (R2, C2) est un équilibre en stratégies pures. Ce n’est pas fortuit : la ligne R2 est faiblement dominante (6≥1, 5≥3, 7≥2) et garantit ≥ 5 contre n’importe quelle colonne, tandis que C2 plafonne Row à ≤ 5 contre n’importe quelle ligne. Le point-selle vit exactement là où la « pire » garantie de Row (5) égale le « meilleur » plafond de Col (5) : aucun joueur n’a intérêt à dévier, ce que la ligne Point-selle ! Valeur du jeu = 5.0000 consigne.

3. Theoreme minimax de Von Neumann (simplexe from-scratch)

Theoreme (Von Neumann, 1928). Pour tout jeu a somme nulle, \(\max_\sigma \min_\tau \sigma^\top A \tau = \min_\tau \max_\sigma \sigma^\top A \tau\). Cette valeur commune \(v\) est la valeur du jeu.

Le theoreme se demontre en reformulant le problème de Row comme un programme lineaire :

\[\max v \quad \text{s.c.} \quad A^\top \sigma \ge v \mathbf{1},\quad \sum \sigma = 1,\quad \sigma \ge 0\]

scipy.optimize.linprog le resout en un appel. Nous, on code la méthode du simplexe : c’est l’algorithme canonique du LP (Dantzig, 1947), un pivotage de tableau qui suit les aretes du polytope realisable vers l’optimum.

Forme standard resolue par notre simplexe

On decale la matrice (\(A' = A + k\), \(k\) choisi pour que toute entree \(\ge 1\)) pour garantir \(v > 0\), puis on ecrit le dual de Col sous forme max standard :

\[\max \sum_j y_j \quad \text{s.c.} \quad \sum_j A'_{ij}\, y_j \le 1 \ \forall i,\quad y_j \ge 0\]

Après resolution : \(v' = 1 / \sum y\), \(\tau_j = v' y_j\), et \(v = v' - k\) (valeur reelle). Et par dualite forte, la stratégie de Row \(\sigma\) se lit dans les variables duales du simplexe (les shadow prices des contraintes) : \(\sigma_i = v' \cdot u_i\).

// === Methode du simplexe from-scratch (Dantzig) ===
// Maximise c.x  s.c.  A.x <= b (b >= 0), x >= 0.
// Tableau avec variables d'ecart ; regle de Bland (anti-cyclage).
// Renvoie aussi les variables DUALES (lignes objectifs des colonnes d'ecart) = shadow prices.
static (double[] x, double value, double[] duals, bool optimal) SimplexMax(double[] c, double[,] A, double[] b)
{
    int m = b.Length;          // contraintes
    int n = c.Length;          // variables structurelles
    int cols = n + m;          // + variables d'ecart
    double[,] T = new double[m + 1, cols + 1];   // +1 colonne RHS
    for (int i = 0; i < m; i++)
    {
        for (int j = 0; j < n; j++) T[i, j] = A[i, j];
        T[i, n + i] = 1.0;                 // coeff d'ecart = 1
        T[i, cols] = b[i];                 // second membre
    }
    for (int j = 0; j < n; j++) T[m, j] = -c[j];   // ligne objectif (max -> on annule les negatifs)
    int[] basis = new int[m];
    for (int i = 0; i < m; i++) basis[i] = n + i;  // base initiale = ecarts

    for (int iter = 0; iter < 2000; iter++)
    {
        // Regle de Bland : premiere colonne a cout reduit negatif
        int entering = -1;
        for (int j = 0; j < cols; j++) if (T[m, j] < -1e-9) { entering = j; break; }
        if (entering < 0) break;                 // optimal

        // Test de rapport : min RHS/colonne parmi les coeffs > 0
        int leaving = -1; double best = double.PositiveInfinity;
        for (int i = 0; i < m; i++)
        {
            if (T[i, entering] > 1e-9)
            {
                double r = T[i, cols] / T[i, entering];
                if (r < best - 1e-12) { best = r; leaving = i; }
                else if (Math.Abs(r - best) <= 1e-12 && leaving >= 0 && basis[i] < basis[leaving]) leaving = i;
            }
        }
        if (leaving < 0) return (new double[n], double.NegativeInfinity, new double[m], false); // non borne

        // Pivotage
        double piv = T[leaving, entering];
        for (int j = 0; j <= cols; j++) T[leaving, j] /= piv;
        for (int i = 0; i <= m; i++)
        {
            if (i == leaving) continue;
            double f = T[i, entering];
            if (Math.Abs(f) < 1e-12) continue;
            for (int j = 0; j <= cols; j++) T[i, j] -= f * T[leaving, j];
        }
        basis[leaving] = entering;
    }
    double[] x = new double[n];
    for (int i = 0; i < m; i++) if (basis[i] < n) x[basis[i]] = T[i, cols];
    // Variables duales = ligne objectif des colonnes d'ecart (shadow prices des contraintes)
    double[] duals = new double[m];
    for (int i = 0; i < m; i++) duals[i] = T[m, n + i];
    return (x, T[m, cols], duals, true);
}
display("Simplexe from-scratch (Dantzig, regle de Bland) implemente : 1 routine + extraction des variables duales.");
Simplexe from-scratch (Dantzig, regle de Bland) implemente : 1 routine + extraction des variables duales.
// === Resolution du theoreme minimax via le simplexe ===
// Un SEUL simplexe suffit : on resout le probleme de Col, et on lit sigma dans les variables duales.
// Retourne sigma (Row, taille M), tau (Col, taille N), v (valeur du jeu).
static (double[] sigma, double[] tau, double v) SolveMatrixGame(double[,] A)
{
    int m = A.GetLength(0), n = A.GetLength(1);
    // Decalage pour garantir A' >= 1 (donc v' > 0)
    double minA = double.PositiveInfinity;
    for (int i = 0; i < m; i++) for (int j = 0; j < n; j++) minA = Math.Min(minA, A[i, j]);
    double k = minA < 1 ? 1.0 - minA : 0.0;

    // Probleme de Col : max sum(y) s.c. (A+k) y <= 1  -> donne tau, et les duales donnent sigma
    double[] cY = new double[n]; for (int j = 0; j < n; j++) cY[j] = 1;
    double[,] AY = new double[m, n];
    for (int i = 0; i < m; i++) for (int j = 0; j < n; j++) AY[i, j] = A[i, j] + k;
    double[] bY = new double[m]; for (int i = 0; i < m; i++) bY[i] = 1;
    var (y, _, duals, _) = SimplexMax(cY, AY, bY);
    double sumY = 0; for (int j = 0; j < n; j++) sumY += y[j];
    double vShift = 1.0 / sumY;
    double[] tau = new double[n]; for (int j = 0; j < n; j++) tau[j] = y[j] * vShift;
    // sigma provient des variables duales (dualite forte : le dual de Col est Row)
    double[] sigma = new double[m]; for (int i = 0; i < m; i++) sigma[i] = duals[i] * vShift;

    double v = vShift - k;
    return (sigma, tau, v);
}
display("SolveMatrixGame pret : 1 simplexe (Col) -> tau ; sigma lu dans les variables duales (shadow prices).");
SolveMatrixGame pret : 1 simplexe (Col) -> tau ; sigma lu dans les variables duales (shadow prices).

3.bis Tranche 2 : minimax via Google.OrTools (Glop LP)

La Tranche 1 resout le theoreme minimax avec un simplexe de Dantzig code from-scratch (valeur pedagogique : comprendre l’algorithme pivot-par-pivot, la regle de Bland anti-cyclage, et lire le vecteur strategie dans les variables duales). C’est le moteur pedagogique.

La Tranche 2 invoque un moteur de production : Google.OrTools et son solveur GLOP (LP industriel de Google, via NuGet). C’est le miroir direct du jumeau Python, qui resout le meme minimax via scipy.optimize.linprog. Mandat de parite lib-vs-lib (#10382) : chaque cote atteint un moteur de production de son ecosysteme ; le from-scratch garde sa place en plus, jamais a la place.

Les deux tranches utilisent en outre deux formulations LP differentes : - Tranche 1 (simplexe) : formulation de Col (max somme(y) s.c. (A+k) * y <= 1), strategie de Row lue dans les variables duales (dualite forte). - Tranche 2 (Glop) : formulation directe de Row (max v s.c. A^T * sigma >= v pour chaque colonne, somme(sigma) = 1).

Convergence croisee : deux moteurs (Dantzig from-scratch vs Google Glop) ET deux formulations (Col+duales vs Row directe) doivent donner la meme valeur de jeu v et la meme strategie optimale sigma. C’est le controle le plus strict qu’on puisse ecrire.

// === Tranche 2 (#10382) : minimax via Google.OrTools (solveur Glop LP industriel) ===
// Miroir du jumeau Python (scipy.optimize.linprog). Formulation DIRECTE de Row.
#r "nuget: Google.OrTools, 9.11.4210"
using Google.OrTools.LinearSolver;

// max v  s.c.  pour chaque colonne j : sum_i A[i,j]*sigma[i] >= v ;  sum_i sigma[i] = 1 ;  sigma >= 0
static (double[] sigma, double v) SolveMinimaxOrTools(ZeroSumGame g)
{
    Solver solver = Solver.CreateSolver("GLOP");
    int m = g.M, n = g.N;
    Variable[] sigma = new Variable[m];
    for (int i = 0; i < m; i++)
        sigma[i] = solver.MakeNumVar(0.0, double.PositiveInfinity, "sigma" + i);
    Variable v = solver.MakeNumVar(double.NegativeInfinity, double.PositiveInfinity, "v");
    solver.Maximize(v);
    // Chaque colonne j : sum_i A[i,j] sigma[i] - v >= 0
    for (int j = 0; j < n; j++)
    {
        Constraint cst = solver.MakeConstraint(0.0, double.PositiveInfinity, "col" + j);
        for (int i = 0; i < m; i++) cst.SetCoefficient(sigma[i], g.A[i, j]);
        cst.SetCoefficient(v, -1.0);
    }
    // sum_i sigma[i] = 1
    Constraint sumCst = solver.MakeConstraint(1.0, 1.0, "sum");
    for (int i = 0; i < m; i++) sumCst.SetCoefficient(sigma[i], 1.0);
    var status = solver.Solve();
    if (status != Solver.ResultStatus.OPTIMAL)
        throw new InvalidOperationException("GLOP non optimal : " + status);
    double[] sig = new double[m];
    for (int i = 0; i < m; i++) sig[i] = sigma[i].SolutionValue();
    return (sig, v.SolutionValue());
}

// Comparaison croisee : Tranche 1 (simplexe Dantzig from-scratch, formulation Col+duales)
// vs Tranche 2 (Google Glop, formulation Row directe). Les DEUX moteurs + DEUX formulations
// doivent converger sur la meme valeur de jeu v et la meme strategie sigma.
var asym = new ZeroSumGame(new double[,] { { 2, -1 }, { -1, 3 } },
    new[] { "R0", "R1" }, new[] { "C0", "C1" }, "Asymetrique 2x2");
var games = new[] { mp, rps, asym };
var sbCmp = new System.Text.StringBuilder();
sbCmp.AppendLine("Comparaison Tranche 1 (simplexe from-scratch) vs Tranche 2 (Google OrTools Glop)");
sbCmp.AppendLine(new string('-', 78));
sbCmp.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0,-26} {1,12} {2,12} {3,12}", "Jeu", "v(simplexe)", "v(Glop)", "|delta v|"));
sbCmp.AppendLine(new string('-', 78));
foreach (var g in games)
{
    var (s1, t1, v1) = SolveMatrixGame(g.A);
    var (s2, v2) = SolveMinimaxOrTools(g);
    sbCmp.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0,-26} {1,12:F6} {2,12:F6} {3,12:E2}", g.Name, v1, v2, Math.Abs(v1 - v2)));
}
sbCmp.AppendLine(new string('-', 78));
sbCmp.AppendLine("Strategies optimales sigma (Row) -- les deux moteurs doivent coincider :");
foreach (var g in games)
{
    var (s1, t1, v1) = SolveMatrixGame(g.A);
    var (s2, v2) = SolveMinimaxOrTools(g);
    sbCmp.AppendLine(string.Format(CultureInfo.InvariantCulture, "  {0,-22} simplexe={1}  Glop={2}", g.Name, Vec(s1), Vec(s2)));
}
display(sbCmp.ToString());
Installed Packages
  • Google.OrTools, 9.11.4210
Comparaison Tranche 1 (simplexe from-scratch) vs Tranche 2 (Google OrTools Glop)
------------------------------------------------------------------------------
Jeu                         v(simplexe)      v(Glop)    |delta v|
------------------------------------------------------------------------------
Matching Pennies               0.000000     0.000000    0.00E+000
Pierre-Feuille-Ciseaux         0.000000    -0.000000    0.00E+000
Asymetrique 2x2                0.714286     0.714286    2.22E-016
------------------------------------------------------------------------------
Strategies optimales sigma (Row) -- les deux moteurs doivent coincider :
  Matching Pennies       simplexe=[ 0.5000,  0.5000 ]  Glop=[ 0.5000,  0.5000 ]
  Pierre-Feuille-Ciseaux simplexe=[ 0.3333,  0.3333,  0.3333 ]  Glop=[ 0.3333,  0.3333,  0.3333 ]
  Asymetrique 2x2        simplexe=[ 0.5714,  0.4286 ]  Glop=[ 0.5714,  0.4286 ]

3.ter Tranche 3 : minimax via Gambit CLI (cross-validation programme-lineaire-contre-programme-lineaire)

La Tranche 2 utilise Google.OrTools GLOP (LP de Google via NuGet). Cette Tranche 3 introduit un deuxième moteur de production externe : la suite Gambit (McKelvey, McLennan, Turocy 2016, GPL-2.0), invoquée par Process.Start (pas de NuGet, pas de vendoring, pas de binding — discipline gambit-lp/gambit-lcp du registre GT-4 / GT-10). Meme algorithme (programmation lineaire) que scipy.optimize.linprog et Google.OrTools GLOP, mais solveur distinct.

Vehicule : serialisation du jeu en format .nfg Gambit (NFG 1 R "..." { "Row" "Col" } { m n } + liste plate de 2*m*n payoffs en convention j-i lex), invocation gambit-lp -q <tmp.nfg> (gambit-lp.exe sous Windows) par Process.Start, parsing des lignes NE,p1,p2,...,pn (fractions Gambit). Le binaire Gambit est cherché d’abord dans GAMBIT_HOME ; sous Windows, a l’emplacement utilisateur (%LOCALAPPDATA%\Gambit\Gambit\gambit-lp.exe) avant l’emplacement programme-files-x86 de l’installateur MSI ; sous Linux et macOS, dans /usr/bin, /usr/local/bin (compilation des sources) et /opt/homebrew/bin. C’est la meme recherche que la cellule 8 de GT-10.

Cross-validation : la valeur du jeu v = sigma^T . A . tau calculee depuis les strategies NE de Gambit doit etre egale a celle des Tranches 1 (simplexe from-scratch) et 2 (Google.OrTools GLOP), au meme epsilon machine pres. Si l’un des trois ponts diverge, c’est l’un des trois qui est faux. C’est la discrimination LP-contre-LP la plus stricte qu’on puisse ecrire dans le notebook, et le discriminant que ai-01 a releve pour ouvrir la tranche (msg-20260812T112529-87ciq6, 2026-08-12).

Pourquoi bucket 2 et pas bucket 3 (nashpy INTRINSIC) : nashpy est une lib Python pure (pas de binaire externe invocable). GT-2 / GT-4 etaient classee bucket-3 INTRINSIC prose-only sur cette base. Gambit est different : ses executables CLI (gambit-lp, gambit-lcp, gambit-enummixed) sont invocables depuis n’importe quel language par Process.Start, et le parsing est trivial (texte CSV). C’est le meme raisonnement que Planners-4 Fast Downward (Process.Start fd.exe --plan-file ...) qui a fait passer Planners-4 de bucket-3 a bucket-2 dans #10401/#10413. La frontiere bucket-2 / bucket-3 n’est pas une frontiere de langage, c’est une frontiere d’invocabilite.

References : ai-01 msg-20260812T112529-87ciq6 (gate nashpy LEVE par verification Gambit), GT-4 cell 25 ToNfg precedent, GT-10 cell 8 RunGambitEfg precedent (.efg cette fois-ci, format different).

using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;

// === Tranche 3 (#10382, ai-01 msg 2026-08-12) : minimax via Gambit CLI (gambit-lp) ===
// Vehicule : serialisation du jeu en format .nfg Gambit + Process.Start + parsing CSV.
// Meme algorithme (programmation lineaire) que Tranche 1 (simplexe from-scratch) et
// Tranche 2 (Google.OrTools GLOP), mais solveur distinct et externe (lib-vs-lib).

// Serialise un ZeroSumGame en format .nfg Gambit (2 joueurs m x n).
// Convention NFG : outer loop sur j (Col), inner sur i (Row), lex j-i.
// Format : NFG 1 R "Titre" { "Row" "Col" } { m n } puis 2*m*n payoffs.
public static string ToNfg(ZeroSumGame g)
{
    var sb = new StringBuilder();
    string safeTitle = g.Name.Replace('"', '_');
    sb.AppendLine($"NFG 1 R \"{safeTitle}\" {{ \"Row\" \"Col\" }} {{ {g.M} {g.N} }}");
    var nums = new List<string>();
    for (int j = 0; j < g.N; j++)
    for (int i = 0; i < g.M; i++)
    {
        double aij = g.A[i, j];
        nums.Add(aij.ToString("G6", CultureInfo.InvariantCulture));   // payoff Row
        nums.Add((-aij).ToString("G6", CultureInfo.InvariantCulture)); // payoff Col = -aij (zero-sum)
    }
    sb.AppendLine(string.Join(" ", nums));
    return sb.ToString();
}

// Cherche l'executable Gambit : GAMBIT_HOME d'abord, puis, sous Windows, l'emplacement
// utilisateur local et Program Files (installs MSI standards) ; sous Linux et macOS,
// /usr/bin, /usr/local/bin et /opt/homebrew/bin (meme recherche que GT-10).
// Les binaires Gambit ne portent le suffixe .exe que sous Windows.
public static string FindGambit(string binary)
{
    bool win = OperatingSystem.IsWindows();
    string name = binary.EndsWith(".exe") ? binary[..^4] : binary;
    string exe = win ? name + ".exe" : name;
    var candidates = new List<string>();
    var home = Environment.GetEnvironmentVariable("GAMBIT_HOME");
    if (!string.IsNullOrEmpty(home)) candidates.Add(Path.Combine(home, exe));
    if (win)
    {
        candidates.Add(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Gambit", "Gambit", exe));
        candidates.Add($@"C:\Program Files (x86)\Gambit\{exe}");
        candidates.Add($@"C:\Program Files\Gambit\{exe}");
    }
    else
    {
        candidates.Add("/usr/bin/" + exe);
        candidates.Add("/usr/local/bin/" + exe);
        candidates.Add("/opt/homebrew/bin/" + exe);
    }
    foreach (var c in candidates)
        if (File.Exists(c)) return c;
    throw new FileNotFoundException(
        $"Binaire Gambit introuvable : {exe}. Cherché dans :\n  - " +
        string.Join("\n  - ", candidates) +
        "\nInstaller Gambit (http://www.gambit-project.org/) ou definir GAMBIT_HOME.");
}

// Invoque gambit-lp (LP dual pour jeux a somme nulle, retourne le NE mixte).
// Le format de sortie : une ligne par NE : "NE,p1,p2,...,pm,c1,c2,...,cn" avec
// fractions Gambit (1/2, 3/7) ou décimales si -d N est passé.
// Retourne : (exitCode, lignes NE parsees, sigma (Row), tau (Col)).
public static (int exitCode, List<(double[] sigma, double[] tau)> equilibria) RunGambitLP(ZeroSumGame g, int decimals = 6)
{
    string nfgPath = Path.Combine(Path.GetTempPath(), $"{g.Name.Replace(' ','_').Replace('/','_')}_lp.nfg");
    File.WriteAllText(nfgPath, ToNfg(g));
    string exePath = FindGambit("gambit-lp");
    var psi = new ProcessStartInfo
    {
        FileName = exePath,
        Arguments = $"-q -d {decimals} \"{nfgPath}\"",
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        UseShellExecute = false,
        CreateNoWindow = true,
    };
    using var proc = Process.Start(psi);
    string stdout = proc.StandardOutput.ReadToEnd();
    string stderr = proc.StandardError.ReadToEnd();
    proc.WaitForExit();
    File.Delete(nfgPath);
    var equilibria = new List<(double[], double[])>();
    foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
    {
        if (!line.StartsWith("NE,")) continue;
        var parts = line.Substring(3).Split(',').Select(s => double.Parse(s.Trim(), CultureInfo.InvariantCulture)).ToArray();
        if (parts.Length != g.M + g.N) continue;  // ignorer les lignes non conformes
        var sigma = new double[g.M];
        var tau = new double[g.N];
        Array.Copy(parts, 0, sigma, 0, g.M);
        Array.Copy(parts, g.M, tau, 0, g.N);
        equilibria.Add((sigma, tau));
    }
    return (proc.ExitCode, equilibria);
}

display("Tranche 3 prete : ToNfg (format NFG Gambit) + FindGambit (GAMBIT_HOME, puis installations standard de la plateforme) + RunGambitLP (Process.Start, -q -d 6, parse NE,csv).")
Tranche 3 prete : ToNfg (format NFG Gambit) + FindGambit (GAMBIT_HOME, puis installations standard de la plateforme) + RunGambitLP (Process.Start, -q -d 6, parse NE,csv).
// Cross-validation trois-moteurs : Tranche 1 (simplexe from-scratch) vs Tranche 2 (Google.OrTools GLOP)
// vs Tranche 3 (Gambit CLI, programme-lineaire externe). Les TROIS ponts LP-contre-LP-contre-LP
// doivent converger sur la meme valeur v et la meme strategie sigma, au epsilon machine pres.
// Si l'un des trois diverge, c'est l'un des trois qui est faux : le discriminant le plus strict
// qu'on puisse ecrire dans ce notebook (cf message ai-01 2026-08-12 msg-20260812T112529-87ciq6).

static string CrossValidateGambit(ZeroSumGame g)
{
    var sb = new System.Text.StringBuilder();
    sb.AppendLine("=== " + g.Name + " ===");
    sb.AppendLine(g.Display());
    // Tranche 1 : simplexe from-scratch
    var (s1, t1, v1) = SolveMatrixGame(g.A);
    // Tranche 2 : Google.OrTools GLOP
    var (s2, v2) = SolveMinimaxOrTools(g);
    // Tranche 3 : Gambit CLI
    int exit;
    List<(double[] sigma, double[] tau)> eqs;
    try
    {
        (exit, eqs) = RunGambitLP(g);
    }
    catch (FileNotFoundException ex)
    {
        sb.AppendLine("Gambit non disponible : " + ex.Message);
        return sb.ToString();
    }
    if (exit != 0 || eqs.Count == 0)
    {
        sb.AppendLine($"Gambit a échoué (exit={exit}, {eqs.Count} NE) — voir stderr.");
        return sb.ToString();
    }
    var (s3, t3) = eqs[0];  // gambit-lp retourne le NE mixte (un seul pour jeu generique)
    double v3 = g.Payoff(s3, t3);
    // Convergence
    sb.AppendLine("Trois resolutions LP, valeur du jeu comparee :");
    sb.AppendLine(new string('-', 64));
    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "  Tranche 1 (simplexe from-scratch, Col+duales)  : v = {0:F8}", v1));
    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "  Tranche 2 (Google.OrTools GLOP, Row directe)  : v = {0:F8}", v2));
    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "  Tranche 3 (Gambit CLI, LP externe)             : v = {0:F8}  (=sigma' A tau)", v3));
    sb.AppendLine(new string('-', 64));
    sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "  |v1 - v2| = {0:E2}    |v1 - v3| = {1:E2}    |v2 - v3| = {2:E2}",
        Math.Abs(v1 - v2), Math.Abs(v1 - v3), Math.Abs(v2 - v3)));
    sb.AppendLine("Strategies optimales sigma (Row) :");
    sb.AppendLine("  Tranche 1 (simplexe) : " + Vec(s1));
    sb.AppendLine("  Tranche 2 (Glop)     : " + Vec(s2));
    sb.AppendLine("  Tranche 3 (Gambit)   : " + Vec(s3, "F6"));
    // Sanity check zero-sum : sigma' B tau = -v3
    double v3col = -v3;
    sb.AppendLine("  Sanity check zero-sum : sigma' B tau = " + FI(v3col, "F8") +
                  " (attendu = " + FI(-v3, "F8") + ")");
    return sb.ToString();
}

display(CrossValidateGambit(mp));
display(new string('=', 60));
display(CrossValidateGambit(rps));
display(new string('=', 60));
display(CrossValidateGambit(asym));
=== Matching Pennies ===
Matching Pennies (gains de Row)
--------------------------
            Pile     Face
--------------------------
  Pile      1.00    -1.00 
  Face     -1.00     1.00 

Trois resolutions LP, valeur du jeu comparee :
----------------------------------------------------------------
  Tranche 1 (simplexe from-scratch, Col+duales)  : v = 0.00000000
  Tranche 2 (Google.OrTools GLOP, Row directe)  : v = 0.00000000
  Tranche 3 (Gambit CLI, LP externe)             : v = 0.00000000  (=sigma' A tau)
----------------------------------------------------------------
  |v1 - v2| = 0.00E+000    |v1 - v3| = 0.00E+000    |v2 - v3| = 0.00E+000
Strategies optimales sigma (Row) :
  Tranche 1 (simplexe) : [ 0.5000,  0.5000 ]
  Tranche 2 (Glop)     : [ 0.5000,  0.5000 ]
  Tranche 3 (Gambit)   : [ 0.500000,  0.500000 ]
  Sanity check zero-sum : sigma' B tau = 0.00000000 (attendu = 0.00000000)
============================================================
=== Pierre-Feuille-Ciseaux ===
Pierre-Feuille-Ciseaux (gains de Row)
-----------------------------------
          Pierre  Feuille  Ciseaux
-----------------------------------
Pierre      0.00    -1.00     1.00 
Feuille      1.00     0.00    -1.00 
Ciseaux     -1.00     1.00     0.00 

Trois resolutions LP, valeur du jeu comparee :
----------------------------------------------------------------
  Tranche 1 (simplexe from-scratch, Col+duales)  : v = 0.00000000
  Tranche 2 (Google.OrTools GLOP, Row directe)  : v = -0.00000000
  Tranche 3 (Gambit CLI, LP externe)             : v = 0.00000000  (=sigma' A tau)
----------------------------------------------------------------
  |v1 - v2| = 0.00E+000    |v1 - v3| = 0.00E+000    |v2 - v3| = 0.00E+000
Strategies optimales sigma (Row) :
  Tranche 1 (simplexe) : [ 0.3333,  0.3333,  0.3333 ]
  Tranche 2 (Glop)     : [ 0.3333,  0.3333,  0.3333 ]
  Tranche 3 (Gambit)   : [ 0.333333,  0.333333,  0.333333 ]
  Sanity check zero-sum : sigma' B tau = 0.00000000 (attendu = 0.00000000)
============================================================
=== Asymetrique 2x2 ===
Asymetrique 2x2 (gains de Row)
--------------------------
              C0       C1
--------------------------
    R0      2.00    -1.00 
    R1     -1.00     3.00 

Trois resolutions LP, valeur du jeu comparee :
----------------------------------------------------------------
  Tranche 1 (simplexe from-scratch, Col+duales)  : v = 0.71428571
  Tranche 2 (Google.OrTools GLOP, Row directe)  : v = 0.71428571
  Tranche 3 (Gambit CLI, LP externe)             : v = 0.71428571  (=sigma' A tau)
----------------------------------------------------------------
  |v1 - v2| = 2.22E-016    |v1 - v3| = 1.29E-012    |v2 - v3| = 1.29E-012
Strategies optimales sigma (Row) :
  Tranche 1 (simplexe) : [ 0.5714,  0.4286 ]
  Tranche 2 (Glop)     : [ 0.5714,  0.4286 ]
  Tranche 3 (Gambit)   : [ 0.571429,  0.428571 ]
  Sanity check zero-sum : sigma' B tau = -0.71428571 (attendu = -0.71428571)

Lecture — trois moteurs LP, un même verdict (SOTA-OK)

Le benchmark croise trois résolveurs indépendants : le simplexe from-scratch de la tranche 1, Google.OrTools GLOP (tranche 2) et Gambit en CLI (tranche 3). Pour les trois jeux, la valeur v est identique au bruit machine près : v = 0 pour Matching Pennies et Pierre-Feuille-Ciseaux, v = 0,71428571 pour le jeu asymétrique, avec |v1 − v2| ≈ 2,2e-16 et |v2 − v3| ≈ 1,3e-12. Les stratégies optimales concordent aussi : σ = [0,5; 0,5] (MP), [0,333; 0,333; 0,333] (RPS) et [0,5714; 0,4286] (asymétrique), mêmes valeurs sous les trois moteurs. Trois implémentations différentes convergent vers le même équilibre : c’est le sens du verdict SOTA-OK. Le sanity check σ'Bτ (nul, attendu = 0) confirme que la matrice est bien à somme nulle — la forme de vérification à garder pour tout jeu de ce type.

4. Resolution : Matching Pennies et Pierre-Feuille-Ciseaux

  • Matching Pennies \(\begin{pmatrix}1 & -1 \\ -1 & 1\end{pmatrix}\) : pas de point-selle pur (gap 2). La solution mixte est \(\sigma = \tau = (1/2, 1/2)\), valeur \(v = 0\).
  • Pierre-Feuille-Ciseaux : cycle impair, solution uniforme \(\sigma = \tau = (1/3, 1/3, 1/3)\), valeur \(v = 0\).
static string SolveAndShow(ZeroSumGame g)
{
    var (sigma, tau, v) = SolveMatrixGame(g.A);
    var sb = new System.Text.StringBuilder();
    sb.AppendLine(g.Display());
    sb.AppendLine("Strategie optimale Row (sigma) : " + Vec(sigma));
    sb.AppendLine("Strategie optimale Col  (tau)  : " + Vec(tau));
    sb.AppendLine("Valeur du jeu v                : " + FI(v));
    sb.AppendLine("Verification : sigma.A.tau     : " + FI(g.Payoff(sigma, tau)));
    return sb.ToString();
}
display("=== Matching Pennies ===");
display(SolveAndShow(mp));
display("=== Pierre-Feuille-Ciseaux ===");
display(SolveAndShow(rps));
=== Matching Pennies ===
Matching Pennies (gains de Row)
--------------------------
            Pile     Face
--------------------------
  Pile      1.00    -1.00 
  Face     -1.00     1.00 

Strategie optimale Row (sigma) : [ 0.5000,  0.5000 ]
Strategie optimale Col  (tau)  : [ 0.5000,  0.5000 ]
Valeur du jeu v                : 0.0000
Verification : sigma.A.tau     : 0.0000
=== Pierre-Feuille-Ciseaux ===
Pierre-Feuille-Ciseaux (gains de Row)
-----------------------------------
          Pierre  Feuille  Ciseaux
-----------------------------------
Pierre      0.00    -1.00     1.00 
Feuille      1.00     0.00    -1.00 
Ciseaux     -1.00     1.00     0.00 

Strategie optimale Row (sigma) : [ 0.3333,  0.3333,  0.3333 ]
Strategie optimale Col  (tau)  : [ 0.3333,  0.3333,  0.3333 ]
Valeur du jeu v                : 0.0000
Verification : sigma.A.tau     : 0.0000

Lecture — la stratégie mixte uniforme : l’équilibre symétrique

La cellule rend σ* = τ* = [0,5; 0,5] pour Matching Pennies et σ* = τ* = [0,333; 0,333; 0,333] pour Pierre-Feuille-Ciseaux, avec valeur du jeu = 0 dans les deux cas. Le patron est net : pour un jeu symétrique (matrice antisymétrique, A[i,j] = −A[j,i]), la valeur ne peut être que 0, et l’unique équilibre est la distribution uniforme qui rend l’adversaire indifférent entre ses actions. C’est la traduction concrète du théorème minimax : la stratégie mixte de Row égalise les gains espérés de chaque action de Col, de sorte qu’aucun choix adverse ne vaut mieux qu’un autre. Révéler cette répartition uniforme ne concède donc rien — c’est le point à retenir avant la section 5 (jeu asymétrique, où la valeur devient, elle, positive).

5. Jeu asymetrique et minimax 2x2 numérique

Le jeu \(\begin{pmatrix}2 & -1 \\ -1 & 3\end{pmatrix}\) n’a pas de point-selle. Sa valeur est \(v = 5/7 \approx 0{,}7143\) (favorable a Row). On verifie en balayant la probabilite \(p\) que Row joue R0 : le gain espere contre chaque action de Col est lineaire en \(p\), et le maximin est le point bas de ces deux droites — leur intersection.

var asym = new ZeroSumGame(new double[,] { { 2, -1 }, { -1, 3 } },
    new[] { "R0", "R1" }, new[] { "C0", "C1" }, "Jeu asymetrique");
display(SolveAndShow(asym));

// Balayage numerique du minimax 2x2 : p = P(Row joue R0)
// gain vs C0 = p*A[0,0] + (1-p)*A[1,0] ; gain vs C1 = p*A[0,1] + (1-p)*A[1,1]
var sb2 = new System.Text.StringBuilder();
sb2.AppendLine("Balayage minimax 2x2 (p = P(R0)) :");
sb2.AppendLine(new string('-', 48));
sb2.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0,6}  {1,10}  {2,10}  {3,10}", "p", "gain C0", "gain C1", "min(pire)"));
for (int t = 0; t <= 10; t++)
{
    double p = t / 10.0;
    double gC0 = p * asym.A[0, 0] + (1 - p) * asym.A[1, 0];
    double gC1 = p * asym.A[0, 1] + (1 - p) * asym.A[1, 1];
    double worst = Math.Min(gC0, gC1);
    sb2.AppendLine(string.Format(CultureInfo.InvariantCulture, "{0,6:F2}  {1,10:F4}  {2,10:F4}  {3,10:F4}", p, gC0, gC1, worst));
}
sb2.AppendLine("(Le maximum de la colonne 'min(pire)' = valeur du jeu, atteint a l'intersection.)");
display(sb2.ToString());
Jeu asymetrique (gains de Row)
--------------------------
              C0       C1
--------------------------
    R0      2.00    -1.00 
    R1     -1.00     3.00 

Strategie optimale Row (sigma) : [ 0.5714,  0.4286 ]
Strategie optimale Col  (tau)  : [ 0.5714,  0.4286 ]
Valeur du jeu v                : 0.7143
Verification : sigma.A.tau     : 0.7143
Balayage minimax 2x2 (p = P(R0)) :
------------------------------------------------
     p     gain C0     gain C1   min(pire)
  0.00     -1.0000      3.0000     -1.0000
  0.10     -0.7000      2.6000     -0.7000
  0.20     -0.4000      2.2000     -0.4000
  0.30     -0.1000      1.8000     -0.1000
  0.40      0.2000      1.4000      0.2000
  0.50      0.5000      1.0000      0.5000
  0.60      0.8000      0.6000      0.6000
  0.70      1.1000      0.2000      0.2000
  0.80      1.4000     -0.2000     -0.2000
  0.90      1.7000     -0.6000     -0.6000
  1.00      2.0000     -1.0000     -1.0000
(Le maximum de la colonne 'min(pire)' = valeur du jeu, atteint a l'intersection.)

Lecture — la valeur 5/7 et le balayage : là où le min est maximisé

Le jeu asymétrique a pour valeur v = 0,7143 (= 5/7) et σ* = τ* = [0,5714; 0,4286]. La valeur est favorable à Row (v > 0, alors que les jeux symétriques valaient 0). Le balayage rend le mécanisme visible : en posant p = P(R0), Row espère gain C0 = 2p − 1(1−p) = 3p − 1 et gain C1 = −p + 3(1−p) = 3 − 4p. La colonne min(pire) est min(3p−1, 3−4p) ; son maximum est atteint quand les deux droites se croisent : 3p − 1 = 3 − 4p ⟹ p = 4/7 ≈ 0,5714, d’où v = 3(4/7) − 1 = 5/7 ≈ 0,7143. Le balayage (pas de 0,1) n’échantillonne que des points — il approche ce pic entre p = 0,50 et p = 0,60 — et c’est la résolution analytique qui donne la valeur exacte : Row pèse 4/7 sur R0 et 3/7 sur R1, rendant Col indifférent (gain C0 = gain C1 = 5/7).

6. Dualite en programmation lineaire

Le theoreme minimax est un cas particulier de dualite forte du LP : le primal (Row maximise, valeur \(v\)) et le dual (Col minimise, valeur \(w\)) ont la même valeur optimale \(v = w\). Concretement, les variables duales qu’on lit dans le tableau du simplexe de Col (les shadow prices des contraintes) DONNENT directement la stratégie optimale de Row \(\sigma\) — c’est l’essence de la dualite. On verifie que les deux stratégies sont compatibles (slackness complementaire).

static string VerifyDuality(ZeroSumGame g)
{
    var (sigma, tau, v) = SolveMatrixGame(g.A);
    var sb = new System.Text.StringBuilder();
    sb.AppendLine("Verification de la dualite pour : " + g.Name);
    sb.AppendLine(new string('-', 50));
    sb.AppendLine("sigma* (Row) = " + Vec(sigma) + "   valeur v = " + FI(v, "F6"));
    sb.AppendLine("tau*   (Col) = " + Vec(tau)   + "   valeur w = " + FI(v, "F6"));
    sb.AppendLine("Dualite forte (v = w) : OK (sigma lu dans les variables duales du simplexe de Col).");
    sb.AppendLine("Ecarts complementary slackness (gain de Row vs chaque action de Col) :");
    double[] gains = new double[g.N];
    for (int j = 0; j < g.N; j++)
    {
        double s = 0;
        for (int i = 0; i < g.M; i++) s += sigma[i] * g.A[i, j];
        gains[j] = s;
        sb.AppendLine("  Col joue " + g.ColLabels[j] + " : " + FI(s) + "  (>= v ? " + (s >= v - 1e-6) + ")");
    }
    return sb.ToString();
}
display(VerifyDuality(rps));
display(VerifyDuality(asym));
Verification de la dualite pour : Pierre-Feuille-Ciseaux
--------------------------------------------------
sigma* (Row) = [ 0.3333,  0.3333,  0.3333 ]   valeur v = 0.000000
tau*   (Col) = [ 0.3333,  0.3333,  0.3333 ]   valeur w = 0.000000
Dualite forte (v = w) : OK (sigma lu dans les variables duales du simplexe de Col).
Ecarts complementary slackness (gain de Row vs chaque action de Col) :
  Col joue Pierre : 0.0000  (>= v ? True)
  Col joue Feuille : 0.0000  (>= v ? True)
  Col joue Ciseaux : 0.0000  (>= v ? True)
Verification de la dualite pour : Jeu asymetrique
--------------------------------------------------
sigma* (Row) = [ 0.5714,  0.4286 ]   valeur v = 0.714286
tau*   (Col) = [ 0.5714,  0.4286 ]   valeur w = 0.714286
Dualite forte (v = w) : OK (sigma lu dans les variables duales du simplexe de Col).
Ecarts complementary slackness (gain de Row vs chaque action de Col) :
  Col joue C0 : 0.7143  (>= v ? True)
  Col joue C1 : 0.7143  (>= v ? True)

Lecture — dualité forte : le théorème minimax est un cas de programmation linéaire

Les deux vérifications rendent v = w : pour RPS, v = w = 0 ; pour le jeu asymétrique, v = w = 0,714286. C’est le théorème de dualité forte — le primal (Row maximise v) et le dual (Col minimise w) ont la même valeur optimale — spécialisé au jeu à somme nulle, ce qui n’est autre que le théorème minimax de Von Neumann. La ligne complementary slackness le confirme de façon mécanique : pour chaque action de Col, gain de Row ≥ v ? True — les écarts sont tous non négatifs (0 pour RPS, 0,7143 pour chacune des deux actions du jeu asymétrique). Aucune colonne ne rapporte à Col moins que la valeur du jeu, donc la stratégie mixte de Row neutralise simultanément toutes les actions adverses : c’est exactement la condition d’optimalité que la dualité LP encode.

7. Application : Colonel Blotto

Le Colonel Blotto est un jeu a somme nulle canonique : deux commandants repartissent \(S\) soldats sur \(B\) champs de bataille ; on gagne le champ ou l’on a le plus de soldats. Chaque repartition est une stratégie pure ; la matrice du jeu est (gain = champs gagnes - champs perdus). C’est un grand jeu — typiquement sans equilibre pur, donc resolu par stratégies mixtes via le LP.

// Generation des strategies de Blotto : repartitions de S soldats sur B champs
static List<int[]> BlottoStrategies(int soldiers, int battlefields)
{
    var list = new List<int[]>();
    void Rec(int remaining, int left, List<int> cur)
    {
        if (left == 1) { var c = new List<int>(cur) { remaining }.ToArray(); list.Add(c); return; }
        for (int s = 0; s <= remaining; s++) Rec(remaining - s, left - 1, new List<int>(cur) { s });
    }
    Rec(soldiers, battlefields, new List<int>());
    return list;
}
static int BlottoPayoff(int[] a, int[] b)
{
    int wins = 0, losses = 0;
    for (int k = 0; k < a.Length; k++)
    {
        if (a[k] > b[k]) wins++;
        else if (a[k] < b[k]) losses++;
    }
    return wins - losses;
}

// Blotto(S=4, B=3) : 15 strategies. B impair -> payoffs non nuls (on peut gagner 2 champs).
var strats = BlottoStrategies(4, 3);
int K = strats.Count;
double[,] AB = new double[K, K];
for (int i = 0; i < K; i++) for (int j = 0; j < K; j++) AB[i, j] = BlottoPayoff(strats[i], strats[j]);
var labels = strats.Select(s => "(" + string.Join(",", s) + ")").ToArray();
var blotto = new ZeroSumGame(AB, labels, labels, "Colonel Blotto (4 soldats, 3 champs)");
display("Strategies : " + K + " repartitions.");
display(SolveAndShow(blotto));
Strategies : 15 repartitions.
Colonel Blotto (4 soldats, 3 champs) (gains de Row)
-----------------------------------------------------------------------------------------------------------------------------------------------
         (0,0,4)  (0,1,3)  (0,2,2)  (0,3,1)  (0,4,0)  (1,0,3)  (1,1,2)  (1,2,1)  (1,3,0)  (2,0,2)  (2,1,1)  (2,2,0)  (3,0,1)  (3,1,0)  (4,0,0)
-----------------------------------------------------------------------------------------------------------------------------------------------
(0,0,4)      0.00     0.00     0.00     0.00     0.00     0.00    -1.00    -1.00    -1.00     0.00    -1.00    -1.00     0.00    -1.00     0.00 
(0,1,3)      0.00     0.00     0.00     0.00     0.00     0.00     0.00    -1.00    -1.00     1.00     0.00    -1.00     1.00     0.00     1.00 
(0,2,2)      0.00     0.00     0.00     0.00     0.00    -1.00     0.00     0.00    -1.00     0.00     1.00     0.00     1.00     1.00     1.00 
(0,3,1)      0.00     0.00     0.00     0.00     0.00    -1.00    -1.00     0.00     0.00    -1.00     0.00     1.00     0.00     1.00     1.00 
(0,4,0)      0.00     0.00     0.00     0.00     0.00    -1.00    -1.00    -1.00     0.00    -1.00    -1.00     0.00    -1.00     0.00     0.00 
(1,0,3)      0.00     0.00     1.00     1.00     1.00     0.00     0.00     0.00     0.00     0.00    -1.00    -1.00     0.00    -1.00     0.00 
(1,1,2)      1.00     0.00     0.00     1.00     1.00     0.00     0.00     0.00     0.00     0.00     0.00    -1.00     1.00     0.00     1.00 
(1,2,1)      1.00     1.00     0.00     0.00     1.00     0.00     0.00     0.00     0.00    -1.00     0.00     0.00     0.00     1.00     1.00 
(1,3,0)      1.00     1.00     1.00     0.00     0.00     0.00     0.00     0.00     0.00    -1.00    -1.00     0.00    -1.00     0.00     0.00 
(2,0,2)      0.00    -1.00     0.00     1.00     1.00     0.00     0.00     1.00     1.00     0.00     0.00     0.00     0.00    -1.00     0.00 
(2,1,1)      1.00     0.00    -1.00     0.00     1.00     1.00     0.00     0.00     1.00     0.00     0.00     0.00     0.00     0.00     1.00 
(2,2,0)      1.00     1.00     0.00    -1.00     0.00     1.00     1.00     0.00     0.00     0.00     0.00     0.00    -1.00     0.00     0.00 
(3,0,1)      0.00    -1.00    -1.00     0.00     1.00     0.00    -1.00     0.00     1.00     0.00     0.00     1.00     0.00     0.00     0.00 
(3,1,0)      1.00     0.00    -1.00    -1.00     0.00     1.00     0.00    -1.00     0.00     1.00     0.00     0.00     0.00     0.00     0.00 
(4,0,0)      0.00    -1.00    -1.00    -1.00     0.00     0.00    -1.00    -1.00     0.00     0.00    -1.00     0.00     0.00     0.00     0.00 

Strategie optimale Row (sigma) : [ 0.0000,  0.0000,  0.3333,  0.0000,  0.0000,  0.0000,  0.0000,  0.0000,  0.0000,  0.3333,  0.0000,  0.3333,  0.0000,  0.0000,  0.0000 ]
Strategie optimale Col  (tau)  : [ 0.0000,  0.2500,  0.0000,  0.2500,  0.0000,  0.0000,  0.0000,  0.0000,  0.0000,  0.2500,  0.0000,  0.2500,  0.0000,  0.0000,  0.0000 ]
Valeur du jeu v                : 0.0000
Verification : sigma.A.tau     : 0.0000

Interpretation : Colonel Blotto

La valeur du jeu est 0 (le jeu est symetrique : \(A_{ij} = -A_{ji}\), donc par symetrie \(v=0\)). Mais aucune stratégie pure ne garantit \(\ge 0\) : par exemple \((2,1,1)\) perd contre \((0,2,2)\) (1 champ gagne, 2 perdus). C’est précisément pourquoi Blotto est le cas d’école du theoreme minimax — il faut des stratégies mixtes, et le simplexe en trouve une : un melange sur plusieurs repartitions ou chaque ecart complementary-slackness est respecte. Avec \(B=3\) champs (impair), les payoffs sont non nuls (on peut gagner \(2\) champs a \(1\)), donc le LP est non degene reel. C’est ce qui distingue Blotto d’un jeu a point-selle.

8. Resume

Concept Python (twin) Twin C# (ici)
Solveur LP scipy.optimize.linprog (industriel) simplexe from-scratch (Dantzig, règle de Bland)
Equilibre de Nash nashpy reformulation LP + simplexe
Stratégie pure numpy.argmax/min boucles for sur la matrice
Visualisation 2x2 matplotlib balayage numérique (table p -> pire cas)

Ce que la lib cache et que ce twin rend explicite : le pivotage de tableau du simplexe, le decalage de matrice pour garantir \(v > 0\), la lecture des variables duales (shadow prices) dans le tableau final pour obtenir \(\sigma\), et la convention tau_j = v' y_j pour retrouver les probabilites.

Lien avec la formalisation Lean : les structures de jeux a somme nulle et le theoreme minimax sont formalises dans minimax_lean (Von Neumann via Sion). Le present notebook en est le versant computationnel : il calcule la valeur que Lean demontre exister.

9. Exercices

Convention C.1 : les exercices sont des stubs a completer (jamais d’erreur volontaire, règle notebook-conventions). Ils utilisent new double[,] { ... } initialise a une matrice 1x1 vide + commentaire // TODO etudiant — le notebook compile et s’execute de bout en bout même non complete. Chaque exercice est precede d’un contexte pedagogique, suivi d’un ou plusieurs indices (# Indice / # Étape N) pour guider la resolution sans la donner.

Les 3 exercices ci-dessous reprennent les concepts clefs du notebook (Colonel Blotto, jeu 3x3 sans point-selle, jeu 3x3 avec point-selle) et font le lien avec la formalisation Lean dans minimax_lean (Von Neumann via Sion).

9.1. Colonel Blotto a 5 soldats, 3 champs

Extension directe de la section 7 : on passe de \(S=4\) soldats a \(S=5\) soldats, ce qui multiplie le nombre de repartitions (de \(C(6,2)=15\) a \(C(7,2)=21\) stratégies). C’est un cas non degenere (\(B=3\) impair) qui generalise le pattern observe sans le trivialiser.

// Exercice 1 : Colonel Blotto avec 5 soldats et 3 champs de bataille (B impair, non degenere)
// Construire la matrice (C(7,2)=21 strategies), resoudre via SolveMatrixGame, afficher v et sigma.
// Indice 1 : reutilisez BlottoStrategies(5, 3) + BlottoPayoff pour remplir la matrice.
// Indice 2 : par symetrie A = -A^T, donc v=0 ; mais la strategie mixte n'est PAS uniforme.
// Indice 3 : combien de strategies pures (sur 21) recoivent une probabilite > 0 dans sigma ?
// TODO etudiant : remplacer la matrice 1x1 ci-dessous par votre matrice Blotto(5, 3).
double[,] ex1Matrix = new double[,] { { 0 } };
display("Exercice 1 a completer : matrice Blotto(5,3) puis appelez SolveMatrixGame(ex1Matrix).");
Exercice 1 a completer : matrice Blotto(5,3) puis appelez SolveMatrixGame(ex1Matrix).

9.2. Verification du theoreme minimax sur un jeu 3x3 personnalise

C’est l’envers de l’exercice 1 : on invente une matrice 3x3 sans point-selle, on resout avec notre simplexe, et on verifie numeriquement la dualite (\(\sigma^\top A \tau = v\)). C’est la verification experimentale que le theoreme minimax n’est pas qu’un enonce abstrait — il se calcule effectivement sur une matrice.

Pour un bon test : choisir une matrice ou la valeur du jeu \(v \ne 0\) et \(\ne \pm 1\) (par exemple \(\begin{pmatrix}2 & -1 & 0 \\ -1 & 3 & -2 \\ 0 & -2 & 4\end{pmatrix}\)), pour eviter que le simplexe ne converge trivialement.

// Exercice 2 : Verification du theoreme minimax sur un jeu 3x3 personnalise
// Etape 1 : remplacer la matrice 1x1 ci-dessous par votre matrice 3x3 (sans point-selle).
// Etape 2 : appeler SolveMatrixGame(ex2Matrix) pour obtenir sigma, tau, v.
// Etape 3 : verifier que sigma*Matrice*tau == v (a 1e-9 pres) ET que le resultat est non trivial.
// Indice 1 : dimensions obligatoires : double[,] ex2Matrix = new double[3,3] { ... };
// Indice 2 : utiliser SolveAndShow(new ZeroSumGame(ex2Matrix, ...)) pour afficher le detail.
// Indice 3 : la verification de dualite est dans la cellule 6 (VerifyDuality) si besoin.
// TODO etudiant : remplacer la matrice 1x1 ci-dessous par votre matrice 3x3.
double[,] ex2Matrix = new double[,] { { 0 } };
display("Exercice 2 a completer : votre matrice 3x3 -> SolveMatrixGame -> verification.");
Exercice 2 a completer : votre matrice 3x3 -> SolveMatrixGame -> verification.

9.3. Construction d’un jeu 3x3 avec point-selle en stratégies pures

C’est l’exercice inverse : trouver une matrice 3x3 ou il existe un equilibre en stratégies pures. La cle est de placer une entree qui soit simultanement le maximum de sa colonne et le minimum de sa ligne — c’est la definition d’un point-selle (cf section 2 du notebook).

Point pedagogique : si vous prenez la matrice saddle du notebook (cellule 5, {{3,4,8},{6,5,7},{1,3,2}}), le point-selle est A[1,1]=5. C’est l’exemple de reference, et vous pouvez vous en inspirer pour construire la votre. La cellule AnalyzePure detecte automatiquement le point-selle et confirme maximin == minimax.

// Exercice 3 : Construire un jeu 3x3 AVEC un point-selle en strategies pures
// Indice 1 : un point-selle A[i,j] = max de la colonne j ET min de la ligne i.
// Indice 2 : dimensions obligatoires : double[,] ex3Matrix = new double[3,3] { ... };
// Indice 3 : passer votre matrice a AnalyzePure pour verifier que maximin == minimax.
// TODO etudiant : remplacer la matrice 1x1 ci-dessous par votre matrice 3x3 avec point-selle.
double[,] ex3Matrix = new double[,] { { 0 } };
display("Exercice 3 a completer : matrice 3x3 avec point-selle -> AnalyzePure.");
Exercice 3 a completer : matrice 3x3 avec point-selle -> AnalyzePure.

Conclusion

Ce twin a deroule les trois couches du theoreme minimax : 1. Modèle : jeu a somme nulle, stratégies pures et mixtes. 2. Pure : maximin/minimax et detection de point-selle. 3. Mixte : reformulation LP, simplexe from-scratch, dualite primal/duale lue dans les shadow prices.

La ou scipy.optimize.linprog resout le LP en un appel opaque, ce notebook montre le pivotage, le decalage, la règle de Bland anti-cyclage et la lecture des variables duales dans le tableau final. C’est toute la mecanique algorithmique du LP, rendue explicite — l’objectif du marathon #4956.

Prochaines étapes : le theoreme minimax se generalise (jeux a somme non nulle -> equilibre de Nash, formalise Lean dans minimax_lean via le theoreme de Sion). Voir GameTheory-04-NashEquilibrium-Python pour le cas general et GameTheory-02-NormalForm-CSharp pour le twin C# de la théorie des jeux en forme normale.

See #4956 (marathon parite .NET/Python). Refs #3801 (axe-2 SOTA).


Genere par myia-po-2023, cycle 55, marathon #4956.

Retour au sommet