La théorie des jeux a information complète (échecs, poker vu par Dieu) se résout par minimax ou induction a rebours. Mais le poker est a information imparfaite : chaque joueur connait sa carte cachée, pas celle de l’adversaire. L’algorithme de référence pour ces jeux est CFR (Counterfactual Regret Minimization, Zinkevich et al. 2007) — c’est lui qui a permis de résoudre le Heads-Up Limit Texas Hold’em (Bowling et al. 2015). La stratégie moyenne produite par CFR converge vers un équilibre de Nash.
Plan pédagogique
Kuhn Poker — le plus petit jeu de poker réaliste (3 cartes, 2 actions)
Regret et Regret Matching — minimiser le regret cumule (demo Pierre-Feuille-Ciseaux)
CFR Vanilla — récursion contrefactuelle sur l’arbre de jeu
CFR+ — variante avec regrets ecretes (Tammelin 2014)
Convergence — la stratégie moyenne converge vers Nash
Exercices
Parite #4956 : la version Python déroule un solveur CFR from-scratch (§3-4) ET le compare a OpenSpiel (librarie Google DeepMind, boite noire). Ce twin C# (BCL .NET 9, 0 NuGet) traduit le solveur from-scratch (le coeur pédagogique — récursion contrefactuelle, regret matching, accumulation de stratégie moyenne) ; la comparaison OpenSpiel (Python-only, pas d’equivalent C# du même niveau) devient une note documentee honnete (RECOVERABLE-MACHINE) : le from-scratch CFR solver EST la substance.
1. Kuhn Poker : notre jeu de référence
Le Kuhn Poker (Kuhn 1950) est le plus petit poker a information imparfaite encore non-trivial. Trois cartes (Jack=0, Queen=1, King=2), deux joueurs, ante de 1. Chaque joueur reçoit une carte, le joueur 1 ouvre (passe ou mise), etc. Les historiques terminaux sont pp (check-check : abattage), pbp (check-bet-fold), pbb (check-bet-call : abattage), bp (bet-fold), bb (bet-call : abattage).
Un information set (infoset) = carte du joueur + historique visible. Deux situations dans le même infoset sont indistinguables pour le joueur (c’est la cle de l’information imparfaite).
using System.Linq;using System.Text;using System.Collections.Generic;staticvoidShow(string s){ s.Display();}// Kuhn Poker : 3 cartes (J=0, Q=1, K=2), 2 actions (Pass=0, Bet=1).publicstaticclass Kuhn{publicconstint PASS =0;publicconstint BET =1;publicconstint NUM_ACTIONS =2;publicstaticreadonlystring[] CardName ={"J","Q","K"};publicstaticboolIsTerminal(string history)=> history =="pp"|| history =="pbp"|| history =="pbb"|| history =="bp"|| history =="bb";// Payoff du joueur courant a un etat terminal. cards = [card0, card1].publicstaticdoubleGetPayoff(string history,int[] cards){bool p1Higher = cards[0]> cards[1];return history switch{"pp"=> p1Higher ?1:-1,// check-check : abattage (pot=2, net +-1)"pbp"=>-1,// check-bet-fold : J1 perd l'ante"pbb"=> p1Higher ?2:-2,// check-bet-call : abattage (pot=4, net +-2)"bp"=>1,// bet-fold : J1 gagne l'ante"bb"=> p1Higher ?2:-2,// bet-call : abattage (pot=4, net +-2) _ =>0};}publicstaticstringGetInfoSet(string history,int card)=> CardName[card]+ history;publicstaticintGetCurrentPlayer(string history)=> history.Length%2;// 0 = J1, 1 = J2 (alternance)}// Tests du modele de jeu.$"Tests Kuhn Poker :".Display();$"'pp' terminal ? {Kuhn.IsTerminal("pp")} | 'p' terminal ? {Kuhn.IsTerminal("p")}".Display();$"Payoff 'bb' avec K vs J : {Kuhn.GetPayoff("bb", new[]{ 2, 0 })} (attendu +2)".Display();$"Payoff 'bp' (bet-fold) : {Kuhn.GetPayoff("bp", new[]{ 0, 2 })} (attendu +1)".Display();$"Payoff 'pp' avec Q vs J : {Kuhn.GetPayoff("pp", new[]{ 1, 0 })} (attendu +1)".Display();$"Infoset J2 avec Q apres 'p' : {Kuhn.GetInfoSet("p", 1)} (attendu 'Qp')".Display();
The below script needs to be able to find the current output cell; this is an easy method to get it.
Tests Kuhn Poker :
'pp' terminal ? True | 'p' terminal ? False
Payoff 'bb' avec K vs J : 2 (attendu +2)
Payoff 'bp' (bet-fold) : 1 (attendu +1)
Payoff 'pp' avec Q vs J : 1 (attendu +1)
Infoset J2 avec Q apres 'p' : Qp (attendu 'Qp')
Lecture chiffree — le banc de tests du moteur Kuhn. Cinq lignes verifient chacune une piece du moteur avant tout entrainement : 'pp' terminal ? True mais 'p' terminal ? False — un pass de J1 laisse le coup vivre, deux passes closent l’abattage ; Payoff 'bb' avec K vs J : 2 (attendu +2) — mise contre mise, le King ramasse les deux mises ; Payoff 'bp' (bet-fold) : 1 (attendu +1) — le fold ne cede qu’une mise ; Payoff 'pp' avec Q vs J : 1 (attendu +1) — l’abattage au check vaut une mise ; et Infoset J2 avec Q apres 'p' : Qp — la etiquette d’information se construit carte + historique. Chaque attendu affirme une regle du jeu : la cellule suivante peut batir le regret matching sur ce socle, sachant que les payoffs et les infosets du solveur sont exacts.
2. Regret et Regret Matching
Le regret d’avoir joue l’action \(a\) au lieu de la stratégie \(\sigma\) est la différence d’utilité : \(R(a) = u(a) - \sum_b \sigma(b) u(b)\). On accumule ces regrets au fil des itérations, et la stratégie courante est proportionnelle aux regrets positifs cumules :
(quand tous les regrets sont <= 0, stratégie uniforme.) Théorème : le regret matching sur un jeu a somme nulle converge vers l’équilibre de Nash.
#nullable enable// Regret Matching pour un agent (N actions).publicclass RegretMatcher{publicint NumActions;publicdouble[] RegretSum;publicdouble[] StrategySum;publicRegretMatcher(int numActions){ NumActions = numActions; RegretSum =newdouble[numActions]; StrategySum =newdouble[numActions];}// Strategie courante : proportionnelle aux regrets positifs cumules.publicdouble[]GetStrategy(){var s =newdouble[NumActions];double norm =0;for(int a =0; a < NumActions; a++){ s[a]= Math.Max(0, RegretSum[a]); norm += s[a];}if(norm >0)for(int a =0; a < NumActions; a++) s[a]/= norm;elsefor(int a =0; a < NumActions; a++) s[a]=1.0/ NumActions;// uniformereturn s;}// Strategie moyenne (converge vers Nash en jeu a somme nulle).publicdouble[]GetAverageStrategy(){var s =newdouble[NumActions];double norm = StrategySum.Sum();if(norm >0)for(int a =0; a < NumActions; a++) s[a]= StrategySum[a]/ norm;elsefor(int a =0; a < NumActions; a++) s[a]=1.0/ NumActions;return s;}// Mise a jour : actionUtilities[a] = utilite observee de l'action a.publicvoidUpdate(double[] actionUtilities,double reachProb =1.0){var strategy =GetStrategy();double expected =0;for(int a =0; a < NumActions; a++) expected += strategy[a]* actionUtilities[a];for(int a =0; a < NumActions; a++) RegretSum[a]+= actionUtilities[a]- expected;// regret = u(a) - u(sigma)for(int a =0; a < NumActions; a++) StrategySum[a]+= reachProb * strategy[a];}}// Demonstration : regret matching sur Pierre-Feuille-Ciseaux, face a un adversaire qui joue toujours Pierre.var rps =newRegretMatcher(3);// 0=Pierre, 1=Feuille, 2=Ciseauxvar utilVsRock =newdouble[]{0.0,1.0,-1.0};// Pierre=0, Feuille=+1, Ciseaux=-1for(int t =0; t <1000; t++) rps.Update(utilVsRock);var avg = rps.GetAverageStrategy();$"Regret Matching vs adversaire 'toujours Pierre' (1000 iter) :".Display();$" Pierre={avg[0]:F3} Feuille={avg[1]:F3} Ciseaux={avg[2]:F3}".Display();$" -> Converge vers Feuille (meilleure reponse pure a 'toujours Pierre'). Attendu (0.000, 1.000, 0.000).".Display();
Regret Matching vs adversaire 'toujours Pierre' (1000 iter) :
Pierre=0,000 Feuille=0,999 Ciseaux=0,000
-> Converge vers Feuille (meilleure reponse pure a 'toujours Pierre'). Attendu (0.000, 1.000, 0.000).
Lecture chiffree — le regret matching trouve la meilleure reponse pure. Face a un adversaire fige sur ‘toujours Pierre’ pendant 1000 iterations, la strategie apprise tombe a Pierre=0,000 Feuille=0,999 Ciseaux=0,000, contre l’attendu (0.000, 1.000, 0.000). Deux choses dans ces trois nombres : la masse se concentre entierement sur Feuille, la seule action a regret positif contre un adversaire qui ne joue que Pierre ; et le 0,999 — pas tout a fait 1,000 — rappelle que le regret matching ne produit jamais une pure exacte en temps fini : les regrets s’accumulent, la strategie s’approche sans toucher la borne. C’est le meme mecanisme que CFR va boucler contrefactuellement sur les 12 infosets du Kuhn Poker.
3. CFR Vanilla : la récursion contrefactuelle
CFR applique le regret matching a chaque information set de l’arbre de jeu, en parcourant récursivement toutes les attributions de cartes. L’idee cle est la reach probability contrefactuelle : on met a jour le regret d’un infoset seulement avec la probabilité d’atteindre ce noeud sans tenir compte du joueur courant (puisqu’on veut évaluer ce qui se passerait si CE joueur jouait differemment, toute chose égale par ailleurs).
cfr(history, cards, reach_probs):
si terminal : retourner le payoff
player = joueur courant ; infoset = carte[player] + history
strategy = regret_matching(infoset)
pour chaque action a :
child_util = cfr(history + a, cards, reach * strategy[a] pour player)
node_util += strategy[a] * child_util
pour chaque action a :
regret = child_util[player] - node_util[player]
regret_sum[infoset][a] += reach_probs[opponent] * regret # contrefactuel
strategy_sum[infoset] += reach_probs[player] * strategy
retourner node_util
Théorème (Zinkevich 2007) : la stratégie moyenne issue de strategy_sum converge vers un epsilon-équilibre de Nash quand le nombre d’itérations croit.
#nullable enable// Solveur CFR vanilla pour Kuhn Poker. Stocke regret_sum et strategy_sum par infoset.publicclass CFRSolver{public Dictionary<string,double[]> RegretSum =new();public Dictionary<string,double[]> StrategySum =new();publicint Iterations =0;protectedvirtualdouble[]GetRegret(string infoset){if(!RegretSum.ContainsKey(infoset)) RegretSum[infoset]=newdouble[Kuhn.NUM_ACTIONS];return RegretSum[infoset];}publicdouble[]GetStrategy(string infoset){var regrets =GetRegret(infoset);var s =newdouble[Kuhn.NUM_ACTIONS];double norm =0;for(int a =0; a < Kuhn.NUM_ACTIONS; a++){ s[a]= Math.Max(0, regrets[a]); norm += s[a];}if(norm >0)for(int a =0; a < Kuhn.NUM_ACTIONS; a++) s[a]/= norm;elsefor(int a =0; a < Kuhn.NUM_ACTIONS; a++) s[a]=1.0/ Kuhn.NUM_ACTIONS;return s;}publicdouble[]GetAverageStrategy(string infoset){if(!StrategySum.ContainsKey(infoset))return Enumerable.Repeat(1.0/ Kuhn.NUM_ACTIONS, Kuhn.NUM_ACTIONS).ToArray();var ss = StrategySum[infoset];double norm = ss.Sum();var s =newdouble[Kuhn.NUM_ACTIONS];if(norm >0)for(int a =0; a < Kuhn.NUM_ACTIONS; a++) s[a]= ss[a]/ norm;elsefor(int a =0; a < Kuhn.NUM_ACTIONS; a++) s[a]=1.0/ Kuhn.NUM_ACTIONS;return s;}// Recursion CFR principale. Retourne [util_J0, util_J1].publicvirtualdouble[]Cfr(string history,int[] cards,double[] reach){if(Kuhn.IsTerminal(history)){double p = Kuhn.GetPayoff(history, cards);// payoff de J1returnnewdouble[]{ p,-p };}int player = Kuhn.GetCurrentPlayer(history);int opponent =1- player;string infoset = Kuhn.GetInfoSet(history, cards[player]);var strategy =GetStrategy(infoset);var childUtil =newdouble[Kuhn.NUM_ACTIONS][];double[] nodeUtil ={0,0};for(int a =0; a < Kuhn.NUM_ACTIONS; a++){char ac = a == Kuhn.PASS?'p':'b';var newReach =(double[])reach.Clone(); newReach[player]*= strategy[a]; childUtil[a]=Cfr(history + ac, cards, newReach); nodeUtil[0]+= strategy[a]* childUtil[a][0]; nodeUtil[1]+= strategy[a]* childUtil[a][1];}// Mise a jour contrefactuelle : regret pondere par reach de l'adversaire.double cfReach = reach[opponent];var rs =GetRegret(infoset);for(int a =0; a < Kuhn.NUM_ACTIONS; a++){double regret = childUtil[a][player]- nodeUtil[player]; rs[a]+= cfReach * regret;}// Accumulation de la strategie (ponderee par reach du joueur courant).if(!StrategySum.ContainsKey(infoset)) StrategySum[infoset]=newdouble[Kuhn.NUM_ACTIONS];var ss = StrategySum[infoset];for(int a =0; a < Kuhn.NUM_ACTIONS; a++) ss[a]+= reach[player]* strategy[a];return nodeUtil;}// Toutes les distributions de cartes (J1, J2) avec 3 cartes distinctes.staticreadonlyint[][] CardPermutations ={new[]{0,1},new[]{0,2},new[]{1,0},new[]{1,2},new[]{2,0},new[]{2,1}};publicvirtualdoubleTrain(int iterations){double totalAvg =0;for(int i =0; i < iterations; i++){double totalUtil =0;foreach(var cards in CardPermutations) totalUtil +=Cfr("", cards,newdouble[]{1.0,1.0})[0]; totalAvg += totalUtil / CardPermutations.Length; Iterations++;}return totalAvg / iterations;}}"Solveur CFR vanilla (Kuhn Poker) pret.".Display();
Solveur CFR vanilla (Kuhn Poker) pret.
3.1 Entrainement et valeur du jeu
On entraine CFR sur un grand nombre d’itérations. La valeur du jeu (utilité moyenne par coup pour J1) converge vers la valeur de Nash : Kuhn (1950) a demontre que cette valeur vaut \(-1/18 \approx -0{,}0556\) pour le joueur 1 (leger desavantage du premier joueur).
var solver =newCFRSolver();int N =20000;double gameValue = solver.Train(N);$"Entrainement CFR vanilla sur Kuhn Poker ({N} iterations).".Display();$"Valeur du jeu pour J1 = {gameValue:F4} (valeur de Nash theorique = {-1.0/18.0:F4})".Display();$"Iterations executees : {solver.Iterations}".Display();
Entrainement CFR vanilla sur Kuhn Poker (20000 iterations).
Valeur du jeu pour J1 = -0,0565 (valeur de Nash theorique = -0,0556)
Iterations executees : 20000
Lecture chiffree — la valeur approchee a 9 dix-milliemes. Apres 20000 iterations, la sortie mesure Valeur du jeu pour J1 = -0,0565 contre la reference valeur de Nash theorique = -0,0556 (\(-1/18\)). L’ecart vaut \(|{-0{,}0565} - (-0{,}0556)| = 0{,}0009\) : CFR n’atteint pas Nash, il l’approche — a moins d’un millieme d’utilite par coup. Deux lectures utiles de ce seul nombre : le signe negatif confirme le leger desavantage du premier joueur pose par Kuhn (1950) ; et la taille de l’ecart servira d’etalon a la section suivante, ou CFR+ sera mesure sur le meme criterium.
3.2 Stratégies apprises (stratégie moyenne)
La stratégie moyenne converge vers un équilibre de Nash. Pour Kuhn Poker, on s’attend notamment a : le joueur avec le King mise presque toujours ; avec Jack face a une mise, il se couche (King bat Jack a l’abattage) ; les melanges (bluffs avec Jack, value bets avec King) emergent dans les infosets intermediaires.
// Afficher la strategie moyenne de chaque infoset (triee par carte puis historique).var sb =newStringBuilder();sb.AppendLine("Strategies Nash apprises par CFR (format : [Pass, Bet]) :");sb.AppendLine($"{"Infoset",-8} {"Pass",-8} {"Bet",-8} interpretation");sb.AppendLine(newstring('-',52));// Toutes les infosets non-terminales : carte dans {J,Q,K}, historique dans {"", "p", "b", "pb"}.string[] histories ={"","p","pb","b"};foreach(var card innew[]{0,1,2})foreach(var h in histories){// ne pas lister les situations impossibles/terminales.if(Kuhn.IsTerminal(h))continue;// "pb" n'est atteignable que pour J1 (J1 a passe, J2 a mise) ; "b" que pour J2 (J1 a mise).// On garde tout pour simplicite — la strategie d'un infoset jamais atteint reste uniforme.string infoset = Kuhn.GetInfoSet(h, card);var strat = solver.GetAverageStrategy(infoset);string readout = infoset switch{"K"=>"King : value bet","Jpb"=>"Jack face a mise : fold (King bat Jack)", _ =>""}; sb.AppendLine($"{infoset,-8} {strat[0],-8:F3} {strat[1],-8:F3} {readout}");}Show(sb.ToString());// Verifier deux resultats canoniques.double[] kStrat = solver.GetAverageStrategy("K");double[] jpbStrat = solver.GetAverageStrategy("Jpb");$"Verification : King mise (bet) avec proba ~ {kStrat[1]:F3} (Nash : King value-bet, proba >= 1/3 ; Kuhn 1950 donne une famille d'equilibres).".Display();$"Verification : Jack face a mise se couche (pass) avec proba ~ {jpbStrat[0]:F3} (Nash : Jack fold face a bet, attendu ~ 1.0).".Display();
Strategies Nash apprises par CFR (format : [Pass, Bet]) :
Infoset Pass Bet interpretation
----------------------------------------------------
J 0,779 0,221
Jp 0,667 0,333
Jpb 1,000 0,000 Jack face a mise : fold (King bat Jack)
Jb 1,000 0,000
Q 1,000 0,000
Qp 1,000 0,000
Qpb 0,439 0,561
Qb 0,659 0,341
K 0,339 0,661 King : value bet
Kp 0,000 1,000
Kpb 0,000 1,000
Kb 0,000 1,000
Verification : King mise (bet) avec proba ~ 0,661 (Nash : King value-bet, proba >= 1/3 ; Kuhn 1950 donne une famille d'equilibres).
Verification : Jack face a mise se couche (pass) avec proba ~ 1,000 (Nash : Jack fold face a bet, attendu ~ 1.0).
Lecture chiffree — la structure de la table des 12 infosets. Trois blocs se lisent carte par carte. Les trois lignes du King sont identiques : Kp, Kpb et Kb affichent toutes 0,000 1,000 — le King mise dans chaque position, la value bet n’a pas d’exception. La Dame a l’oppose ne mise jamais en premier : Q et Qp a 1,000 0,000, et ses mixtes n’apparaissent qu’apres mise adverse (Qpb 0,439 0,561, Qb 0,659 0,341). Le Jack joue les deux tableaux : Jpb 1,000 0,000 (le fold face a mise, signe deja dans la sortie), mais J 0,779 0,221 — un bluf environ une fois sur cinq — et surtout Jp 0,667 0,333, pile deux tiers / un tiers. Les lignes pures et les mixtes se repartissent exactement comme la famille d’equilibres de Kuhn 1950 le predit : strategies pures aux extremites (King mise, Jack passe face a mise), mixtures au milieu, ou le bluff du Jack equilibre la value bet du King.
4. Variante CFR+ (Tammelin 2014)
CFR+ accelere la convergence avec deux modifications : 1. Regrets ecretes : on maintient regret_sum[a] = max(0, regret_sum[a] + cf_reach * regret) (les regrets negatifs sont immediatement remis a zero). 2. Accumulation ponderee : la stratégie est accumulee avec un poids croissant w = t+1 (itérations recentes privilegiees).
CFR+ a ete l’algorithme cle de la resolution du Heads-Up Limit Texas Hold’em (Bowling 2015).
#nullable enable// CFR+ : variant avec regrets ecretes (Tammelin 2014).publicclass CFRPlusSolver : CFRSolver{protectedoverridedouble[]GetRegret(string infoset){if(!RegretSum.ContainsKey(infoset)) RegretSum[infoset]=newdouble[Kuhn.NUM_ACTIONS];return RegretSum[infoset];}publicoverridedouble[]Cfr(string history,int[] cards,double[] reach){if(Kuhn.IsTerminal(history)){double p = Kuhn.GetPayoff(history, cards);returnnewdouble[]{ p,-p };}int player = Kuhn.GetCurrentPlayer(history);int opponent =1- player;string infoset = Kuhn.GetInfoSet(history, cards[player]);var strategy =GetStrategy(infoset);var childUtil =newdouble[Kuhn.NUM_ACTIONS][];double[] nodeUtil ={0,0};for(int a =0; a < Kuhn.NUM_ACTIONS; a++){char ac = a == Kuhn.PASS?'p':'b';var newReach =(double[])reach.Clone(); newReach[player]*= strategy[a]; childUtil[a]=Cfr(history + ac, cards, newReach); nodeUtil[0]+= strategy[a]* childUtil[a][0]; nodeUtil[1]+= strategy[a]* childUtil[a][1];}// CFR+ : (1) regrets ecretes a chaque mise a jour.double cfReach = reach[opponent];var rs =GetRegret(infoset);for(int a =0; a < Kuhn.NUM_ACTIONS; a++){double regret = childUtil[a][player]- nodeUtil[player]; rs[a]= Math.Max(0, rs[a]+ cfReach * regret);}// CFR+ : (2) strategie accumulee avec poids croissant w = iterations+1.if(!StrategySum.ContainsKey(infoset)) StrategySum[infoset]=newdouble[Kuhn.NUM_ACTIONS];double w = Iterations +1;var ss = StrategySum[infoset];for(int a =0; a < Kuhn.NUM_ACTIONS; a++) ss[a]+= w * reach[player]* strategy[a];return nodeUtil;}}var solverPlus =newCFRPlusSolver();double gvPlus = solverPlus.Train(N);$"Entrainement CFR+ sur Kuhn Poker ({N} iterations).".Display();$"Valeur du jeu (CFR+) pour J1 = {gvPlus:F4} (Nash = {-1.0/18.0:F4})".Display();$"Convergence CFR vs CFR+ : |valeur - (-1/18)| vanilla={Math.Abs(gameValue - (-1.0/18.0)):F4} CFR+={Math.Abs(gvPlus - (-1.0/18.0)):F4} (CFR+ converge generalement plus vite)".Display();
Entrainement CFR+ sur Kuhn Poker (20000 iterations).
Valeur du jeu (CFR+) pour J1 = -0,0572 (Nash = -0,0556)
Convergence CFR vs CFR+ : |valeur - (-1/18)| vanilla=0,0009 CFR+=0,0016 (CFR+ converge generalement plus vite)
Lecture chiffree — ce run inverse l’attendu de CFR+. La derniere ligne de la sortie mesure les deux ecarts a Nash : vanilla=0,0009 CFR+=0,0016. Sur CE run de 20000 iterations, le vanilla est deux fois plus proche de \(-1/18\) que CFR+ (-0,0565 contre -0,0572), alors que la parenthese de la sortie meme rappelle que CFR+ converge generalement plus vite. Aucune contradiction : la superiorite de CFR+ est un enonce asymptotique et en moyenne, pas une garantie run par run — ici un seul tirage par variante, sans moyenne sur seeds ni variance affichee. La lecon methodologique vaut pour toutes les cellules de ce notebook : une comparaison d’algorithmes sur un run unique est une indication, jamais un verdict — c’est exactement le multi-seed que les exercices de evaluation exigent ailleurs dans le depot.
5. Visualisation de la convergence
On re-entraine CFR pour quelques paliers d’itérations et on trace la valeur du jeu : elle doit converger vers \(-1/18\). Courbe ASCII (axe horizontal = itérations en echelle log, axe vertical = valeur pour J1).
// Courbe ASCII : valeur du jeu vs iterations (CFR vanilla).int[] checkpoints ={10,30,100,300,1000,3000,10000,30000};var pts =new List<(int iter,double val)>();foreach(var cp in checkpoints){var s =newCFRSolver();double v = s.Train(cp); pts.Add((cp, v));}double vmin = pts.Min(p => p.val), vmax = pts.Max(p => p.val);double nash =-1.0/18.0;// elargir un peu l'echelle pour inclure la ligne de Nash.vmin = Math.Min(vmin, nash)-0.01; vmax = Math.Max(vmax, nash)+0.01;int H =14, W =50;var canvas =newchar[H, W];for(int r =0; r < H; r++)for(int c =0; c < W; c++) canvas[r, c]=' ';// ligne de Nash (pointille).int nashCol =-1;for(int c =0; c < W; c++){double frac =(double)c /(W -1);int iter =(int)Math.Round(checkpoints[0]+ frac *(checkpoints[^1]- checkpoints[0]));// place la colonne la plus proche de Nash sur l'axe vertical}// determiner la ligne correspondant a 'nash' sur l'axe verticalintRowFor(double v)=> H -1-(int)Math.Round((v - vmin)/(vmax - vmin)*(H -1));int nashRow =RowFor(nash);for(int c =0; c < W; c++)if(c %2==0) canvas[nashRow, c]='-';// points CFR.for(int i =0; i < pts.Count; i++){int c =(int)Math.Round((double)i /(pts.Count-1)*(W -1));int r =RowFor(pts[i].val);if(r >=0&& r < H && c >=0&& c < W) canvas[r, c]='*';}var sb2 =newStringBuilder();sb2.AppendLine($"Convergence CFR : valeur du jeu (J1) vs iterations (ligne '- -' = Nash = {nash:F4})");for(int r =0; r < H; r++){var line =newStringBuilder();for(int c =0; c < W; c++) line.Append(canvas[r, c]);double v = vmax -(double)r /(H -1)*(vmax - vmin); sb2.AppendLine($"{v,7:F3} |{line}");}sb2.AppendLine($" +{new string('-', W)}");sb2.AppendLine($" iter: {checkpoints[0]} ... {checkpoints[^1]} (log-scale approx)");Show(sb2.ToString());$"Convergence constatee : la valeur du jeu se rapproche de la ligne de Nash ({nash:F4}) a mesure que les iterations augmentent.".Display();
Convergence constatee : la valeur du jeu se rapproche de la ligne de Nash (-0,0556) a mesure que les iterations augmentent.
Lecture chiffree — oscillation puis plateau sur la ligne de Nash. Les huit paliers (10, 30, 100 … 30000 iterations) dessinent trois regimes. Le depart est trop optimiste : iter 10 vaut -0,016, bien au-dessus de Nash. Le creux suit : iter 30 plonge a -0,091, plus pessimiste que la cible. Le retour : iter 100 remonte a -0,064, puis les cinq derniers paliers — 300 a 30000 — tiennent tous la ligne -0,057, celle ou le tracé place les tirets de Nash. La convergence n’est pas monotone : la valeur traverse la cible en oscillant avant de s’y coller, et la ligne finale -0,057 contre -0,0556 attendu rappelle qu’a convergence affichee il reste l’ecart constant de ~0,0009 mesure plus haut.
6. Exercices
Convention C.1 : les stubs s’executent sans erreur (jamais throw). Remplir le corps, re-exécuter, verifier.
Exercice 1 — Leduc Poker (generalisation a 2 tours)
Implementer un solveur CFR pour Leduc Poker (6 cartes : 3 rangs x 2 couleurs, 2 tours de mise). C’est le deuxième benchmark canonique du poker a information imparfaite après Kuhn.
Indice : etendre le modèle de jeu (historique plus long, plus d’actions legales), garder la récursion CFR identique.
#nullable enable// Exercice 1 : Leduc Poker (2 tours, 6 cartes). Generalisation de CFR.// TODO etudiant : modeliser Leduc + lancer CFR.staticdoubleSolveLeduc(){// Indice : nouvelle classe Leduc avec IsTerminal/GetPayoff/GetInfoSet etendus,// reutiliser CFRSolver en parametrant le jeu.return0.0;// TODO etudiant}"Exercice a completer".Display();
Exercice a completer
Exercice 2 — Exploitabilite (best-response)
Coder le calcul de l’exploitabilite d’une stratégie : \(\mathrm{expl}(\sigma) = \frac{1}{2}(\mathrm{BR}_1(\sigma_2) + \mathrm{BR}_2(\sigma_1)) - v(\mathrm{Nash})\), ou \(\mathrm{BR}\) est la meilleure reponse. Une stratégie proche de Nash a une exploitabilite proche de 0.
Indice : calculer la meilleure reponse d’un joueur face a la stratégie fixee de l’autre, par programmation dynamique sur l’arbre de jeu.
#nullable enable// Exercice 2 : exploitabilite d'une strategie CFR.// TODO etudiant : best-response de chaque joueur, puis moyenne.staticdoubleExploitability(CFRSolver solver){// Indice : BR de J1 face a strategy(J2), BR de J2 face a strategy(J1), moyenne - valeur Nash.return0.0;// TODO etudiant}"Exercice a completer".Display();
Exercice a completer
Exercice 3 — External Sampling MCCFR
Les variantes Monte Carlo CFR echantillonnent les actions plutot que de tout parcourir. Implementer External Sampling : on echantillonne les actions de l’adversaire et du hasard, on explore exhaustivement celles du joueur courant.
Indice : dans la récursion, tirer une seule action pour l’adversaire (au lieu de sommer), garder l’exploration complète pour le joueur courant.
#nullable enable// Exercice 3 : External Sampling MCCFR.// TODO etudiant : recursion avec echantillonnage des actions adverses.staticdoubleExternalSamplingCfr(){// Indice : Random.NextDouble pour tirer l'action adverse selon sa strategie courante.return0.0;// TODO etudiant}"Exercice a completer".Display();
Exercice a completer
Conclusion
Ce que vous avez appris
Information imparfaite — contrairement aux jeux d’échecs, le joueur ne connait pas tout l’etat ; les information sets regroupent les situations indistinguables.
Regret Matching — stratégie proportionnelle aux regrets positifs cumules ; converge vers Nash en jeu a somme nulle (demo Pierre-Feuille-Ciseaux).
CFR (Zinkevich 2007) — regret matching applique a chaque infoset via une récursion contrefactuelle (reach probability de l’adversaire). La stratégie moyenne converge vers un epsilon-Nash.
CFR+ (Tammelin 2014) — variant a regrets ecretes qui a permis de résoudre le Heads-Up Limit Texas Hold’em (Bowling 2015).
Valeur du jeu — Kuhn Poker : \(-1/18\) pour J1 (leger desavantage du premier joueur), retrouve par CFR.
Pont avec la version Python
La version Python (GameTheory-13-ImperfectInfo-CFR-Python.ipynb) déroule le même solveur CFR from-scratch (§3-4) et le compare a OpenSpiel (Google DeepMind). Ce twin C# traduit le coeur from-scratch (BCL .NET 9, 0 NuGet) — les internes (récursion contrefactuelle, reach probabilities, accumulation de stratégie moyenne) sont visibles. La comparaison OpenSpiel (Python-only) est desormais RECOVERABLE-LOCAL (reclassification #10459) : la taxonomie bucket-3 (#10382) avait omis l’axe PythonNet (.NET -> Python.Runtime -> pyspiel -> OpenSpiel C++, meme axe que SemanticKernel-09 et que le moteur QuantConnect/Lean). Le pont est démontré et mesuré en annexe §7 (CFR + exploitabilité executés depuis C#) – pythonnet (NuGet) + open_spiel (pip) s’installent sur la machine du worker (regle F). Le from-scratch C# reste la contrepartie legitime (niveau semantic) ; le pont est en plus, jamais a la place (mandat #10382). La ou le Python delegue a OpenSpiel : la ou le Python delegue a OpenSpiel (cellules 28-33 : openspiel_cfr, exploitability, benchmark Leduc), le C# rend visible la mecanique de la recursion contrefactuelle et du regret matching que la lib encapsule.
Parite #4956
Twin de parite legitime (Prong B, niveau semantic) : les deux langages deroulent le solveur CFR from-scratch. La ou Python s’appuie sur OpenSpiel pour la comparaison SOTA (pont PythonNet desormais branche depuis C#, cf. annexe §7 et verdict RECOVERABLE-LOCAL ci-dessus), le C# rend visible la mecanique de la récursion contrefactuelle. Le notebook GameTheory-09-BackwardInduction-Python couvre l’induction a rebours en information complète (point de contraste).
Marathon #4956 (parite .NET <-> Python).
7. Annexe : Pont .NET vers OpenSpiel CFR via PythonNet (#10459)
La conclusion ci-dessus requalifie le verdict SOTA d’OpenSpiel d’INTRINSIC en RECOVERABLE-LOCAL : un pont direct existe, il est installable et mesurable. Cette annexe démontre le branchement effectif sur le coeur meme du sujet de ce notebook – le solveur CFR et l’exploitabilité – la parité lib-vs-lib visée par le mandat #10382, axe PythonNet omis de la taxonomie bucket-3 et corrigé par #10459.
Chaîne d’interopérabilité : C# (.NET 9) -> pythonnet (Python.Runtime) -> pyspiel -> OpenSpiel C++. C’est le même axe que le notebook SemanticKernel-09 (CLR interop Python<->.NET), que l’architecture du moteur de backtesting QuantConnect/Lean, et que l’annexe S10 de GameTheory-17-MultiAgent-RL-CSharp. Aucun port .NET natif n’est requis : pythonnet s’installe par NuGet, open_spiel par pip (règle F).
La cellule ci-dessous invoque le vrai solveur CFR d’OpenSpiel (pyspiel.CFRSolver) sur Kuhn poker depuis C#, itère 100 fois, puis mesure l’exploitabilité de la stratégie moyenne (pyspiel.exploitability) – le même concept que l’Exercice 2 et que la convergence démontrée section 5. La sortie committée est la preuve que le pont est vivant.
Rejouabilité : la cellule pointe vers CPython via la variable d’environnement PYTHONNET_PYDLL (défaut sous Windows : C:\Python313\python313.dll, le CPython 3.13 du worker où open_spiel est pip-installé). Sans cette variable ni ce fichier, elle interroge le premier interpréteur (python3, puis python) qui importe pyspiel, et en reprend la bibliothèque et le sys.path : sous Linux et macOS, pip install open_spiel suffit, environnement virtuel compris. PYTHONNET_PYDLL reste le moyen de désigner un autre CPython.
#r "nuget: pythonnet,3.0.5"using System;using Python.Runtime;// Pont .NET -> CPython -> pyspiel -> OpenSpiel C++ (annexe CFR, #10459).// pythonnet (NuGet) embarque Python.Runtime ; PYTHONNET_PYDLL (env) pointe vers// le CPython 3.13 ou open_spiel est installe (pip install open_spiel), defaut du worker.// Repli portable (Linux, macOS, ou Windows sans le CPython ci-dessus) : le premier// interpreteur (python3, puis python) qui importe le module fournit sa bibliotheque// partagee, son prefixe et son sys.path, environnement virtuel compris.staticvoidInitPythonFromInterpreter(string module){conststring probe ="import importlib, json, os, sys, sysconfig\n"+"importlib.import_module(sys.argv[1])\n"+"v = sysconfig.get_config_var; lib = v('LIBDIR') or ''; mm = sys.version_info[:2]\n"+"c = [os.path.join(lib, n) for n in (v('INSTSONAME'), v('LDLIBRARY')) if n]\n"+"c += [os.path.join(d, 'libpython%d.%d.dylib' % mm) for d in (lib, os.path.join(sys.base_prefix, 'lib'))]\n"+"c.append(os.path.join(sys.base_prefix, 'python%d%d.dll' % mm))\n"+"dll = next((p for p in c if os.path.isfile(p)), '')\n"+"print(json.dumps({'dll': dll, 'home': sys.base_prefix, 'path': [p for p in sys.path if p]}))\n";foreach(var exe innew[]{"python3","python"}){try{var psi =new System.Diagnostics.ProcessStartInfo(exe){ RedirectStandardOutput =true, RedirectStandardError =true, UseShellExecute =false};foreach(var arg innew[]{"-c", probe, module }) psi.ArgumentList.Add(arg);usingvar p = System.Diagnostics.Process.Start(psi);var stderr = p.StandardError.ReadToEndAsync();string json = p.StandardOutput.ReadToEnd(); p.WaitForExit();if(p.ExitCode!=0)continue;var info = System.Text.Json.JsonDocument.Parse(json).RootElement;string dll = info.GetProperty("dll").GetString();if(string.IsNullOrEmpty(dll))continue; Runtime.PythonDLL= dll; PythonEngine.PythonHome= info.GetProperty("home").GetString(); PythonEngine.Initialize();using(Py.GIL()){var path = info.GetProperty("path").EnumerateArray().Select(e =>(PyObject)newPyString(e.GetString())).ToArray(); Py.Import("sys").SetAttr("path",newPyList(path));}return;}catch(System.ComponentModel.Win32Exception){}// interpreteur absent du PATH}thrownew System.IO.FileNotFoundException( $"Aucun CPython n'importe {module} : l'installer (pip install), ou definir PYTHONNET_PYDLL.");}var pyDll = Environment.GetEnvironmentVariable("PYTHONNET_PYDLL")?? @"C:\Python313\python313.dll";if(System.IO.File.Exists(pyDll)){ Runtime.PythonDLL= pyDll; PythonEngine.Initialize();}elseInitPythonFromInterpreter("pyspiel");Console.WriteLine($"Runtime Python : {PythonEngine.Version}");// Invoque le vrai solveur CFR d'OpenSpiel (Zinkevich 2007) -- le meme algorithme que le from-scratch section 3.using(Py.GIL()){ dynamic pyspiel = Py.Import("pyspiel"); dynamic game = pyspiel.load_game("kuhn_poker"); dynamic cfr = pyspiel.CFRSolver(game);for(int i =0; i <100; i++) cfr.evaluate_and_update_policy(); dynamic policy = cfr.average_policy();// Exploitabilite de la strategie moyenne (best-response) -- le concept de l'Exercice 2.double expl =(double)pyspiel.exploitability(game, policy); Console.WriteLine($"CFR(kuhn_poker, 100 iters) : exploitabilite = {expl.ToString("F6", System.Globalization.CultureInfo.InvariantCulture)} (converge vers 0 = Nash)");}Console.WriteLine("PONT .NET -> OpenSpiel CFR : OK");
Installed Packages
pythonnet, 3.0.5
Runtime Python : 3.11.15 (main, Mar 3 2026, 09:26:23) [GCC 13.3.0]
CFR(kuhn_poker, 100 iters) : exploitabilite = 0.008226 (converge vers 0 = Nash)
PONT .NET -> OpenSpiel CFR : OK