Kernel : .NET (C#) — pont PythonNet vers mealpy dans la même exécution
Introduction
Quatre paires mesurées, quatre histoires : PSO dominé par mealpy (MGS-22), DE quasi-ex-aequo en qualité avec vitesse inversée (MGS-23), SA doublé qualité-vitesse par MGS (MGS-24), WOA ex-aequo entre variantes MGS avec mealpy en retrait (MGS-25). La paire Equilibrium Optimizer est d’une autre nature, pour une raison qui tient en une phrase lue dans le code source lui-même :
l’EO de MGS est un port déclaré de l’EO de mealpy : la docstring de EquilibriumOptimizer.cs dit « implemented directly from the mealpy version » (PR giacomelli/GeneticSharp#87). On ne compare pas deux implémentations indépendantes d’un papier, mais un port .NET et son original Python — l’écart mesuré est l’écart de traduction ;
zéro paramètre propre des deux côtés : les quatre constantes de l’article Faramarzi et al.
— V = 1, a1 = 2, a2 = 1, GP = 0,5 — sont codées en dur des deux côtés (« Fixed parameter proposed by authors » côté mealpy, littéraux du constructeur côté MGS). Le confond paramétrique est exactement nul, davantage encore que le WOA de MGS-25 ;
même architecture du pool d’équilibre : 4 meilleures solutions + leur centroïde, tirage aléatoire par individu, même équation de mise à jour (Eq. 16 de l’article) mot pour mot.
Restent comme différences mesurables : le moteur qui porte les équations (composé génétique MGS contre update vectoriel NumPy) et la sélection (greedy par individu côté mealpy, réinsertion du composé côté MGS). Le croisement a deux colonnes : MGS EquilibriumOptimizer et mealpy OriginalEO. Même substrat, même budget, mêmes graines.
1. Le protocole apparié, pré-enregistré — hérité de MGS-22, non renégocié
Le protocole est celui de MGS-22 (#12302), repris intégralement pour que les paires de l’Epic soient comparables entre elles :
même substrat : grille Easy[0] de Sudoku_Easy51.txt, représentation R1 (continu + arrondi), fonction de coût = conflits totaux d’une grille pleine ;
budget d’évaluations égalisé — mesuré, pas supposé : EO est population-based des deux côtés, structure identique au DE/WOA (population 50 × 160 générations côté MGS, epochs côté mealpy ; l’epoch mealpy coûte 51 évaluations — 50 candidats + le centroïde du pool — d’où un epoch ajusté (156 contre 160 : le centroïde du pool coûte une évaluation par epoch côté mealpy)) ;
4 graines nommées {0, 1, 7, 42}, médiane + min/max, jamais un run isolé ;
contre-vérification croisée du coût : le vainqueur mealpy est décodé et coûté côté C# — sans elle on compare deux fonctions de coût, pas deux moteurs ;
ms/éval séparé du temps total ;
graines passées explicitement aux deux moteurs : solve(prob, seed=N) côté mealpy (le paramètre du constructeur est silencieusement ignoré en 3.x), ResetSeed(N) côté MGS ;
déterminisme vérifié (répétition, conflits identiques exigés) avant de publier le moindre chiffre.
Paramètres par défaut de chaque bibliothèque, mesurés et déclarés (c’est le protocole MGS-22 : on compare les bibliothèques telles que leurs auteurs les livrent) :
moteur
équations
constantes
pool d’équilibre
MGS EquilibriumOptimizer
Faramarzi et al. 2020, Eq. 9-16 (port de mealpy, PR GeneticSharp#87)
Les deux moteurs partagent donc les équations, les constantes ET la structure du pool : la différence mesurée sera l’effet d’implémentation pur — port C# composé contre original Python NumPy. C’est la question de l’exercice 2 qui pousse plus loin (taille de population à budget constant, donc pool tiré d’une population plus large).
// === MGS-26 : socle commun — DLLs MGS, grille de référence, fonction de coût ===// Même socle que MGS-22/23/24/25 : la représentation R1 (continu + arrondi) est le substrat du bench.#r "../../MetaGeneticSharp/src/MetaGeneticSharp.Domain/bin/Debug/net9.0/GeneticSharp.Infrastructure.Framework.dll"#r "../../MetaGeneticSharp/src/MetaGeneticSharp.Domain/bin/Debug/net9.0/MetaGeneticSharp.Infrastructure.dll"#r "../../MetaGeneticSharp/src/MetaGeneticSharp.Domain/bin/Debug/net9.0/MetaGeneticSharp.Domain.dll"using MetaGeneticSharp;using GeneticSharp;using System.Diagnostics;// Grille facile Easy[0] de Sudoku_Easy51.txt — la MÊME que MGS-21/22/23/24/25.publicstaticstring PuzzleLine26 ="902005403100063025508407060026309001057010290090670530240530600705200304080041950";publicstaticint[,]ParsePuzzle26(){var g =newint[9,9];for(int i =0; i <81; i++) g[i /9, i %9]= PuzzleLine26[i]-'0';return g;}// Fonction de coût du bench : conflits totaux (lignes + colonnes + blocs) sur grille PLEINE.// Renvoie 0 ssi résolu. Le côté Python réimplémente exactement ce comptage.publicstaticintCountConflicts26(int[,] g){int conflicts =0;for(int i =0; i <9; i++){var row =new HashSet<int>();var col =new HashSet<int>();var blk =new HashSet<int>();for(int j =0; j <9; j++){if(!row.Add(g[i, j])) conflicts++;if(!col.Add(g[j, i])) conflicts++;int br =3*(i /3)+ j /3, bc =3*(i %3)+ j %3;if(!blk.Add(g[br, bc])) conflicts++;}}return conflicts;}publicstaticintCountEmpty26(int[,] p){int n =0;foreach(var v in p)if(v ==0) n++;return n;}publicstatic List<(int r,int c)>EmptyCells26(int[,] p){var l =new List<(int,int)>();for(int r =0; r <9; r++)for(int c =0; c <9; c++)if(p[r, c]==0) l.Add((r, c));return l;}// Décodage R1 : arrondi + clamp vers 1..9 sur les cellules vides, ordre de lecture.publicstaticint[,]DecodeR1_26(double[] genes){var Puzzle =ParsePuzzle26();var empties =EmptyCells26(Puzzle);var g =(int[,])Puzzle.Clone();for(int k =0; k < empties.Count; k++) g[empties[k].r, empties[k].c]= Math.Max(1, Math.Min(9,(int)Math.Round(genes[k])));return g;}var Puzzle26 =ParsePuzzle26();Console.WriteLine($"Grille de référence : {CountEmpty26(Puzzle26)} cellules vides, "+ $"{81 - CountEmpty26(Puzzle26)} indices fixes, {EmptyCells26(Puzzle26).Count} gènes R1.");
The below script needs to be able to find the current output cell; this is an easy method to get it.
Grille de référence : 36 cellules vides, 45 indices fixes, 36 gènes R1.
Lecture. Le socle est posé, identique à MGS-22/23/24/25 au nom près — c’est voulu : la comparabilité de l’Epic #12373 tient à ce que chaque paire courre sur exactement le même substrat. 36 cellules vides = 36 gènes continus dans [1, 10), la fonction de coût compte les doublons ligne/colonne/bloc d’une grille pleine et vaut 0 ssi résolue.
// === Moteur MGS : chromosome R1, fitness instrumentée, composé EquilibriumOptimizer ===// EO de Faramarzi et al. (2020) en composé géométrique MGS — port déclaré de mealpy// (PR giacomelli/GeneticSharp#87) : pool = 4 meilleurs + centroïde (crossover géométrique// à 4 parents), update Eq. 16 par crossover linéaire custom, constantes a1=2, a2=1, GP=0,5, V=1.publicclass SudokuR1Chromosome26 : ChromosomeBase{privateconstdouble LO =1.0, HI =10.0;publicSudokuR1Chromosome26():base(EmptyCells26(ParsePuzzle26()).Count){CreateGenes();}publicoverride Gene GenerateGene(int index)=>newGene(RandomizationProvider.Current.GetDouble(LO, HI));publicoverride IChromosome CreateNew()=>newSudokuR1Chromosome26();publicdouble[]ToGenes(){var v =newdouble[Length];for(int i =0; i < Length; i++) v[i]=(double)GetGene(i).Value;return v;}publicint[,]ToGrid()=>DecodeR1_26(ToGenes());}// Fitness instrumentée : chaque évaluation est comptée — le budget se mesure, il ne se suppose pas.publicclass SudokuR1Fitness26 : IFitness{publicstaticint Evals;publicdoubleEvaluate(IChromosome chromosome){ Evals++;return-CountConflicts26(((SudokuR1Chromosome26)chromosome).ToGrid());}}publicstaticclass Mgs26Host{publicstatic IMetaHeuristic BuildEo(int maxGens,int popSize)=> MetaHeuristicsService.CreateMetaHeuristicByName("EquilibriumOptimizer", maxGens, popSize);publicstatic(int conflicts,int evals,double ms,double[] genes)RunEo(int seed,int popSize,int maxGens){// Seeding AVANT création de population : le RNG est consommé par CreateNew()// de chaque individu initial (leçon #12071 / MGS-21). FastRandomRandomization.ResetSeed(seed);var compound =BuildEo(maxGens, popSize);var adam =newSudokuR1Chromosome26();var pop =newMetaPopulation(popSize, popSize, adam);var ga =newMetaGeneticAlgorithm( pop,newSudokuR1Fitness26(),newEliteSelection(),newUniformCrossover(0.5f),newUniformMutation(true), compound); ga.Termination=newGenerationNumberTermination(maxGens); SudokuR1Fitness26.Evals=0;var sw = Stopwatch.StartNew(); ga.Start(); sw.Stop();var best =(SudokuR1Chromosome26)ga.BestChromosome;return(CountConflicts26(best.ToGrid()), SudokuR1Fitness26.Evals, sw.Elapsed.TotalMilliseconds, best.ToGenes());}}// Échauffement JIT (course jetée), puis course témoin graine 7.var warmupMgs = Mgs26Host.RunEo(123,50,10);var demoEo = Mgs26Host.RunEo(7,50,160);Console.WriteLine($"MGS EO (graine 7, témoin) : {demoEo.Item1} conflits, "+ $"{demoEo.Item2} évaluations, {demoEo.Item3:F0} ms.");
Lecture. Le composé MGS est branché sur le même harnais que les paires précédentes : chromosome R1, fitness comptée, seeding avant création de population. La course témoin graine 7 donne les premiers chiffres — 43 conflits pour 8 000 évaluations — l’échauffement JIT la précède pour que la course mesurée ne paie pas la compilation. Noter le compte d’évaluations, mesuré et non supposé : 160 générations × 50 candidats = 8 000 exactement — le centroïde du pool sert de donneur de gènes côté MGS sans être coûté (l’asymétrie symétrique côté mealpy, qui l’évalue, sera documentée au croisement).
// === Le pont PythonNet : mealpy dans le même kernel, la même exécution ===// Recette validée MGS-22 (#12356) : pythonnet 3.1.0, DLL résolue par probe// (PYTHONNET_PYDLL d'abord, sinon installs standards par OS — aucun chemin machine en dur).// NB : pythonnet 3.1.0 exige CPython >= 3.13 (symbole PyThreadState_GetUnchecked absent// de python311.dll) — le probe résout la version la plus HAUTE disponible, et prend la// DLL au nom le plus long (le stub ABI "python3.dll" ne forwarde pas ce symbole).#r "nuget: pythonnet,3.1.0"using Python.Runtime;staticstringResolvePythonDll26(){var env = Environment.GetEnvironmentVariable("PYTHONNET_PYDLL");if(!string.IsNullOrEmpty(env)&& System.IO.File.Exists(env))return env;if(OperatingSystem.IsWindows()){// Installs CPython.org standards d'abord (les plus récentes portent les packages récents// comme mealpy), ensuite le scan LOCALAPPDATA — un Python périmé qui n'a pas mealpy// ne doit pas masquer une install plus récente (pb 2026-08-25 : Python310 2023 devant Python313).foreach(var c innew[]{ @"C:\Python313\python313.dll", @"C:\Python312\python312.dll"})if(System.IO.File.Exists(c))return c;var local = Environment.GetEnvironmentVariable("LOCALAPPDATA");if(!string.IsNullOrEmpty(local)){var pyDir = System.IO.Path.Combine(local,"Programs","Python");if(System.IO.Directory.Exists(pyDir)){var dirs = System.IO.Directory.GetDirectories(pyDir,"Python3*").OrderByDescending(d => System.IO.Path.GetFileName(d).Replace("Python","")).ToList();foreach(var d in dirs){var hit = System.IO.Directory.GetFiles(d,"python3*.dll");if(hit.Length==0)continue;// python3.dll (stub ABI stable, 10 chars) ne forwarde PAS// PyThreadState_GetUnchecked : preferer la DLL versionnee la// plus longue (ex. python313.dll), comme la branche miniconda.return hit.OrderByDescending(h => System.IO.Path.GetFileName(h).Length).First();}}}var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);foreach(var root innew[]{ System.IO.Path.Combine(home,"miniconda3"), System.IO.Path.Combine(home,"anaconda3"), @"C:\ProgramData\miniconda3", @"C:\ProgramData\anaconda3"}){if(!System.IO.Directory.Exists(root))continue;var hits = System.IO.Directory.GetFiles(root,"python3*.dll");var pick ="";foreach(var h in hits)if(System.IO.Path.GetFileName(h).Length>11) pick = h;if(pick ==""&& hits.Length>0) pick = hits[0];if(pick !=""){var dirs =new[]{ root, System.IO.Path.Combine(root,"Library","mingw-w64","bin"), System.IO.Path.Combine(root,"Library","bin"), System.IO.Path.Combine(root,"Scripts")};var path = Environment.GetEnvironmentVariable("PATH")??"";var toAdd ="";foreach(var d in dirs)if(System.IO.Directory.Exists(d)&&!path.Contains(d +";")) toAdd += d +";";if(toAdd !="") Environment.SetEnvironmentVariable("PATH", toAdd + path);return pick;}}}else{var libs =new[]{"/usr/lib/x86_64-linux-gnu","/usr/lib","/usr/local/lib","/opt/homebrew/lib"};foreach(var dir in libs)if(System.IO.Directory.Exists(dir)){var hit = System.IO.Directory.GetFiles(dir,"libpython3.*");foreach(var h in hit)if(h.EndsWith(".so")|| h.EndsWith(".dylib"))return h;}}thrownew System.IO.FileNotFoundException("DLL Python introuvable : definir PYTHONNET_PYDLL ou installer Python 3.10+ (mealpy requis).");}Runtime.PythonDLL=ResolvePythonDll26();PythonEngine.Initialize();// Le problème Python : decode + coût réimplémentés à l'identique, compteur d'évals,// solveur mealpy OriginalEO avec seed EXPLICITE en solve() (API 3.x — le seed du// constructeur est ignoré) et journal muet (log_to='nothing').publicstatic PyModule S26;using(Py.GIL()){ S26 = Py.CreateScope(); S26.Set("puzzle_line26", PuzzleLine26); S26.Exec(@"import sysimport mealpyfrom mealpy.physics_based.EO import OriginalEOfrom mealpy import Problem, FloatVarimport json as _jsonpuzzle =[int(ch)for ch in puzzle_line26]empties =[(i // 9, i % 9) for i in range(81) if puzzle[i] == 0]def decode(vec): g =[puzzle[r *9:(r +1)*9]for r inrange(9)]for k inrange(len(empties)): r, c = empties[k] v =int(round(float(vec[k]))) g[r][c]=max(1,min(9, v))return gdef cost(g): conflicts =0for i inrange(9): units =([g[i][j]for j inrange(9)],[g[j][i]for j inrange(9)],[g[3*(i // 3) + j // 3][3 * (i % 3) + j % 3] for j in range(9)])for unit in units: seen =set()for v in unit:if v in seen: conflicts +=1 seen.add(v)return conflictsdef cost_of_vector(vec):returncost(decode(vec))PY_EVALS =[0]classSudokuProblem(Problem): def __init__(self, bounds=None, minmax='min',**kwargs):super().__init__(bounds, minmax, log_to='nothing',**kwargs) def obj_func(self, x): PY_EVALS[0]+=1returnfloat(cost(decode(x)))def run_mealpy_eo(seed, pop_size, epoch): import time PY_EVALS[0]=0 prob =SudokuProblem(bounds=FloatVar(lb=(1.0,)*len(empties), ub=(10.0,)*len(empties), name='genes'), minmax='min') model =OriginalEO(epoch=epoch, pop_size=pop_size) t0 = time.perf_counter() g_best = model.solve(prob, seed=seed) dt =(time.perf_counter()- t0)*1000.0 sol = _json.dumps([float(v)for v in g_best.solution])returncost(decode(g_best.solution)), PY_EVALS[0], dt, soldef bench_mealpy_eo(seeds_json, pop_size, epoch, reps=3):out=[]for sd in _json.loads(seeds_json): runs =[run_mealpy_eo(sd, pop_size, epoch)for _ inrange(reps)] cs =[r[0]for r in runs] es =[r[1]for r in runs] ts =sorted(r[2]for r in runs) med = ts[len(ts)// 2] if len(ts) % 2 == 1 else (ts[len(ts) // 2 - 1] + ts[len(ts) // 2]) / 2.0out.append({'seed': sd, 'conflicts': cs[0], 'all_same':len(set(cs))==1, 'evals': es[0], 'ms': med, 'sol': runs[0][3]})return _json.dumps(out)def time_python_evals(vecs_json, reps=5): import time vecs = _json.loads(vecs_json) ts =[]for _ inrange(reps): t0 = time.perf_counter()for v in vecs:cost_of_vector(v) ts.append((time.perf_counter()- t0)*1000.0) ts.sort()return ts[len(ts)// 2]# Defaults mealpy OriginalEO(3.x): mesures, pas doc — aucun parametre propre,# V=1, a1=2, a2=1, GP=0.5 sont codes en dur par les auteurs('Fixed parameter# proposed by authors'), verifie a la source dans __init__/evolve._m =OriginalEO(epoch=10, pop_size=5)__defaults__ = 'mealpy OriginalEO defaults: aucun parametre propre(epoch, pop_size seuls); V=1, a1=2, a2=1, GP=0.5 fixes par les auteurs'__mealpy_ver__ = 'mealpy ' + mealpy.__version__+ ' sur Python ' + sys.version.split()[0]"); Console.WriteLine($"Pont PythonNet actif : {S26.Get<string>("__mealpy_ver__")}"); Console.WriteLine($"Parametres defaut mealpy : {S26.Get<string>("__defaults__")}");}// --- Sanity check : la fonction de coût est-elle la MÊME des deux côtés ? ---// 3 vecteurs témoins DÉTERMINISTES (LCG écrit à la main), décodés et costés des deux côtés.publicstaticdouble[]LcgVector26(int seed,int n){uint state =(uint)seed;var v =newdouble[n];for(int i =0; i < n; i++){ state = state *1664525u+1013904223u; v[i]=1.0+(state /4294967296.0)*9.0;// uniforme dans [1, 10)}return v;}var witnessVectors =new[]{LcgVector26(1,36),LcgVector26(2,36),LcgVector26(3,36)};using(Py.GIL()){ S26.Set("__witness_json__", System.Text.Json.JsonSerializer.Serialize(witnessVectors.Select(v => v.ToList()).ToList())); S26.Exec(@"__py_costs__ = _json.dumps([cost_of_vector(v) for v in _json.loads(__witness_json__)])");var pyCosts = System.Text.Json.JsonSerializer.Deserialize<List<int>>(S26.Get<string>("__py_costs__"));var csCosts = witnessVectors.Select(v =>CountConflicts26(DecodeR1_26(v))).ToList();bool identical = pyCosts.SequenceEqual(csCosts); Console.WriteLine($"Sanity check cout : C# {string.Join(",", csCosts)} | Python {string.Join(",", pyCosts)} "+ $"-> {(identical ? "IDENTIQUE" : "DIFFERENT")}");}
Lecture. Le pont PythonNet est actif et la sanity check porte tout le bench : les trois vecteurs témoins LCG, décodés et coûtés indépendamment des deux côtés, donnent exactement les mêmes conflits (67, 71, 60 des deux côtés — IDENTIQUE). Sans cette égalité prouvée, une différence mesurée entre moteurs pourrait n’être qu’une différence entre les deux fonctions de coût. L’EO mealpy n’expose aucun paramètre propre — V = 1, a1 = 2, a2 = 1, GP = 0,5 sont codés en dur « as proposed by authors », vérifié à la source : ce sont exactement les littéraux du constructeur MGS.
2. Le croisement — 2 moteurs × 4 graines à budget égal
Population 50, 160 générations (MGS) / epochs (mealpy), graines {0, 1, 7, 42}, trois répétitions par graine côté MGS pour la médiane de temps (amendement anti-pic GC), déterminisme exigé partout. L’epoch mealpy coûte 51 évaluations (50 candidats + le centroïde du pool, évalué) contre 50 côté MGS (mesuré : 8 000 pour 160 générations) — d’où epoch = 156 côté mealpy (50 + 156×51 = 8 006 ≈ 8 000) pour tenir le budget du protocole : les compteurs des deux côtés font foi, pas les paramètres nominaux.
// === LE BENCH : 2 moteurs x 4 graines {0,1,7,42} — MGS EO, mealpy OriginalEO,// population 50, budgets mesurés par les compteurs. ===publicclass BenchRow26{publicint seed {get;set;}publicint conflicts {get;set;}publicbool all_same {get;set;}publicint evals {get;set;}publicdouble ms {get;set;}publicstring sol {get;set;}}int[] Seeds26 ={0,1,7,42};// --- Côté MGS (C#) : 3 répétitions par graine, ms = médiane ---var mgsRows =new List<(int seed,int conflicts,int evals,double ms,bool allSame)>();foreach(var sd in Seeds26){var runs3 =new List<(int c,int e,double t)>();for(int rep =0; rep <3; rep++){var r = Mgs26Host.RunEo(sd,50,160); runs3.Add((r.Item1, r.Item2, r.Item3));}var times = runs3.Select(x => x.t).OrderBy(t => t).ToList();double med = times[1]; mgsRows.Add((sd, runs3[0].c, runs3[0].e, med, runs3.All(x => x.c== runs3[0].c)));}// --- Côté mealpy (Python, boucle unique dans le scope) ---string mealpyJson;using(Py.GIL()){ S26.Set("__seeds_json__", System.Text.Json.JsonSerializer.Serialize(Seeds26.ToList())); S26.Exec(@"__bench_json__ = bench_mealpy_eo(__seeds_json__, 50, 156)"); mealpyJson = S26.Get<string>("__bench_json__");}var mealpyRows = System.Text.Json.JsonSerializer.Deserialize<List<BenchRow26>>(mealpyJson);// --- Table ---staticdoubleMedian26(List<int> xs){var s = xs.OrderBy(x => x).ToList();return(s.Count%2==1)? s[s.Count/2]:(s[s.Count/2-1]+ s[s.Count/2])/2.0;}Console.WriteLine($"{"moteur",-13} {"graine",6} {"conflits",9} {"evals",7} {"ms",8} {"ms/eval",8}");foreach(var r in mgsRows) Console.WriteLine($"{"MGS EO",-13} {r.seed,6} {r.conflicts,9} {r.evals,7} {r.ms,8:F0} {r.ms / r.evals,8:F3}");foreach(var r in mealpyRows) Console.WriteLine($"{"mealpy",-13} {r.seed,6} {r.conflicts,9} {r.evals,7} {r.ms,8:F0} {r.ms / r.evals,8:F3}");var mgsC = mgsRows.Select(r => r.conflicts).ToList();var mpC = mealpyRows.Select(r => r.conflicts).ToList();double mgsMsEval = mgsRows.Average(r => r.ms/ r.evals);double mpMsEval = mealpyRows.Average(r => r.ms/ r.evals);Console.WriteLine();Console.WriteLine($"MGS EO : médiane conflits {Median26(mgsC):F1} (min {mgsC.Min()}, max {mgsC.Max()}), ms/éval moyen {mgsMsEval:F3}");Console.WriteLine($"mealpy : médiane conflits {Median26(mpC):F1} (min {mpC.Min()}, max {mpC.Max()}), ms/éval moyen {mpMsEval:F3}");Console.WriteLine($"Rapport ms/éval mealpy/MGS : {mpMsEval / mgsMsEval:F2}x");int detAll = mgsRows.Count(r => r.allSame)+ mealpyRows.Count(r => r.all_same);Console.WriteLine($"Déterminisme : conflits identiques sur les 3 répétitions pour {detAll}/8 paires graine-moteur.");
moteur graine conflits evals ms ms/eval
MGS EO 0 46 8000 540 0,067
MGS EO 1 41 8000 531 0,066
MGS EO 7 43 8000 428 0,054
MGS EO 42 40 8000 332 0,042
mealpy 0 21 8006 589 0,074
mealpy 1 24 8006 650 0,081
mealpy 7 26 8006 607 0,076
mealpy 42 31 8006 604 0,075
MGS EO : médiane conflits 42,0 (min 40, max 46), ms/éval moyen 0,057
mealpy : médiane conflits 25,0 (min 21, max 31), ms/éval moyen 0,077
Rapport ms/éval mealpy/MGS : 1,34x
Déterminisme : conflits identiques sur les 3 répétitions pour 8/8 paires graine-moteur.
// === Coût par évaluation : la fitness seule, hors moteur, 500 vecteurs identiques ===// Les vecteurs sont générés côté C# (LCG, graines 42..541) et passés en JSON au Python :// les DEUX côtés chronomètrent decode+coût sur exactement les mêmes 500 points.int K26 =500;var benchVecs =new List<double[]>();for(int i =0; i < K26; i++) benchVecs.Add(LcgVector26(42+ i,36));var csTimes =new List<double>();for(int rep =0; rep <5; rep++){var swRep = Stopwatch.StartNew();foreach(var v in benchVecs)CountConflicts26(DecodeR1_26(v)); swRep.Stop(); csTimes.Add(swRep.Elapsed.TotalMilliseconds);}csTimes.Sort();double csMs = csTimes[2];// médiane de 5 (amendement §2)double pyMs;using(Py.GIL()){ S26.Set("__vecs_json__", System.Text.Json.JsonSerializer.Serialize(benchVecs.Select(v => v.ToList()).ToList())); S26.Exec(@"__py_ms__ = time_python_evals(__vecs_json__)"); pyMs = S26.Get<double>("__py_ms__");}Console.WriteLine($"Fitness seule, {K26} vecteurs identiques (médiane de 5 répétitions par côté) :");Console.WriteLine($" C# : {csMs:F1} ms total -> {csMs / K26:F3} ms/éval");Console.WriteLine($" Python : {pyMs:F1} ms total -> {pyMs / K26:F3} ms/éval");Console.WriteLine($" rapport Python/C# : {pyMs / csMs:F2}x");
Fitness seule, 500 vecteurs identiques (médiane de 5 répétitions par côté) :
C# : 2,8 ms total -> 0,006 ms/éval
Python : 15,8 ms total -> 0,032 ms/éval
rapport Python/C# : 5,71x
Lecture du croisement. La paire la plus symétrique sur le papier donne l’écart le plus asymétrique de l’Epic :
qualité : mealpy écrase — médiane 25,0 [21-31] contre 42,0 [40-46], étendues disjointes (le pire mealpy, 31 conflits, reste à 9 unités du meilleur MGS, 40). C’est le plus grand écart qualité de l’Epic toutes paires confondues, et la deuxième victoire mealpy après le PSO — mais le PSO gagnait à l’étroite sur des étendues chevauchées ; ici il n’y a pas de chevauchement du tout ;
l’écart ne peut venir que de la traduction : mêmes équations (Eq. 9-16 de Faramarzi et al.), mêmes constantes codées en dur (V=1, a1=2, a2=1, GP=0,5), même pool (4 meilleurs + centroïde), même substrat prouvé identique (sanity check + contre-vérification croisée). Deux différences d’implémentation restent, toutes deux mesurables : (a) mealpy applique une sélection greedy par individu — chaque candidat ne remplace sa position que s’il l’améliore, un élitisme local index par index que le composé MGS remplace par sa réinsertion de population ; (b) mealpy évalue le centroïde du pool (51e évaluation par epoch) : son pool contient un point réel, le pool MGS un donneur de gènes jamais coûté — le compteur le prouve (8 000 = 160×50 exactement côté MGS, 50 + 156×51 = 8 006 côté mealpy) ;
vitesse : MGS garde la main, de peu — 1,34× moins cher par évaluation (0,057 contre 0,077 ms ; run original : 1,21×). Après le coude-à-coude du PSO (0,8×-1,3× selon la machine), c’est le plus faible écart moteur en faveur de MGS (WOA 1,78×, DE 2,37×, SA 3,26× — re-exécutions #13407) : la mécanique du composé — match steps par individu, crossover custom par génération, centroïde recalculé — pèse plus lourd que l’update vectoriel NumPy des paires précédentes ;
le protocole est tenu : budget mesuré 8 000 contre 8 006 (l’epoch mealpy est ajusté à 156 pour l’asymétrie du centroïde), déterminisme 8/8, contre-vérification croisée du coût IDENTIQUE.
Lecture du coût par évaluation. La fitness C# isolée reste 5,71× plus rapide (0,006 contre 0,032 ms/éval ; run original : 7,75× — l’amplitude du ratio fitness est sensible à la machine, l’ordre ne l’est pas) — mais l’écart moteur fond à 1,34× : comme sur le PSO de MGS-22, la mécanique du composé engloutit l’avantage de langage. Sur cette paire, la traduction géométrique paie un surcoût structurel ET rend une qualité très inférieure.
Verdict de la paire. Le port perd contre son original — nettement. Cinq paires mesurées, cinq verdicts : PSO mealpy devant à l’étroite, DE quasi-ex-aequo, SA doublé par MGS, WOA ex-aequo avec mealpy en retrait, EO mealpy écrasant. La leçon spécifique de la paire : « même algorithme » ne se transfère pas gratuitement entre paradigmes d’implémentation. Un composé génétique n’est pas un NumPy en C# : réinsertion de population contre greedy index par index, centroïde-costé contre centroïde-donneur — des choix de traduction apparemment mineurs font ici 17 conflits d’écart médian à budget égal, davantage que n’importe quel confond paramétrique neutralisé dans les paires précédentes.
La boucle corrective #13778 — la moitié de l’écart tient à une ligne de sélection
La lecture du croisement nommait deux différences de traduction mesurables. La boucle corrective les instruit une à la fois — une hypothèse par expérience, graines et budget appariés.
Hypothèse (a) — la sélection. mealpy applique un greedy par individu (get_better_agent dans OriginalEO.evolve : le candidat ne remplace sa position que s’il l’améliore). Le port remplace ce verdict local par une réinsertion de population (FitnessBasedElitistReinsertion : les 50 meilleurs parmi les 100 parents et enfants). Un enfant qui améliore son parent sans battre le 50ᵉ global est rejeté côté MGS, accepté côté mealpy.
Instrument (meilleur, pire et dispersion inter-individus par checkpoint) : la population du port colle en un clone — dispersion 2,55 à la génération 1, autour de 0,05 à la génération 40, exactement 0 ensuite ; meilleur et pire se confondent dès la génération 40-80. Le moteur est mort à mi-budget : à budget ×4, le meilleur ne bouge plus d’une unité de la génération 160 à la 640 (37/37/37/37 sur la graine 0). La troncation globale sérialise la population : entre égaux, tout remplacement est un vote majoritaire, et la diversité ne peut que décroître.
Le correctif candidat existe déjà dans le framework : FitnessBasedPairwiseReinsertion, la réinsertion canonique du composé FBI — comparaison index par index, exactement le greedy mealpy. Il se branche en opt-in par CustomReinsertion, sans toucher au sous-module : la voie historique reste le défaut.
// === BOUCLE CORRECTIVE #13778 (hypothese a) : reinsertion pairwise en opt-in, appariement strict ===// Baseline = voie historique (service -> FitnessBasedElitistReinsertion, troncation 50/100).// Correctif = CustomReinsertion = FitnessBasedPairwiseReinsertion (comparaison index par index,// le greedy mealpy get_better_agent). Le cablage repete le case EquilibriumOptimizer// du service (converter double-identite compris) -- rien d'autre ne change.publicstaticclass EoCorrective26{privatestaticreadonly TypedGeometricConverter NoEmbedding =BuildNoEmbedding();// Converter double-identite : replique exacte du defaut du service (genes = doubles bruts).privatestatic TypedGeometricConverter BuildNoEmbedding(){var conv =new GeometricConverter<double>{ IsOrdered =false, DoubleToGeneConverter =(geneIndex, geomValue)=> geomValue, GeneToDoubleConverter =(genIndex, geneValue)=> geneValue};var typed =newTypedGeometricConverter(); typed.SetTypedConverter(conv);return typed;}publicstaticdoubleMedian(List<int> v){var s = v.OrderBy(x => x).ToList();return s.Count%2==1? s[s.Count/2]:(s[s.Count/2-1]+ s[s.Count/2])/2.0;}publicstatic(int conflicts,int evals,string trace)RunWith(bool pairwise,int seed,int popSize,int maxGens){var log =new List<string>();var cpSet =new HashSet<int>(new[]{1, maxGens /4, maxGens /2,3* maxGens /4, maxGens }); FastRandomRandomization.ResetSeed(seed);var compound = pairwise?newEquilibriumOptimizer(){ MaxGenerations = maxGens, GeometricConverter = NoEmbedding, NoMutation =true, CustomReinsertion =newFitnessBasedPairwiseReinsertion()}.Build(): MetaHeuristicsService.CreateMetaHeuristicByName("EquilibriumOptimizer", maxGens, popSize);var adam =newSudokuR1Chromosome26();var pop =newMetaPopulation(popSize, popSize, adam);var ga =newMetaGeneticAlgorithm(pop,newSudokuR1Fitness26(),newEliteSelection(),newUniformCrossover(0.5f),newUniformMutation(true), compound); ga.Termination=newGenerationNumberTermination(maxGens); ga.GenerationRan+=(s, e)=>{int g = pop.GenerationsNumber;if(!cpSet.Contains(g))return;var chrs = pop.CurrentGeneration.Chromosomes.Cast<SudokuR1Chromosome26>().ToList();int n = chrs.Count, L = chrs[0].Length;double spread =0.0;for(int j =0; j < L; j++){double mean =0.0;for(int i =0; i < n; i++) mean +=(double)chrs[i].GetGene(j).Value; mean /= n;double varc =0.0;for(int i =0; i < n; i++){double d =(double)chrs[i].GetGene(j).Value- mean; varc += d * d;} spread += Math.Sqrt(varc / n);} spread /= L;int best =CountConflicts26(((SudokuR1Chromosome26)ga.BestChromosome).ToGrid()); log.Add($"g{g}: best {best}, dispersion {spread:F2}");}; SudokuR1Fitness26.Evals=0; ga.Start();var bestChr =(SudokuR1Chromosome26)ga.BestChromosome;return(CountConflicts26(bestChr.ToGrid()), SudokuR1Fitness26.Evals,string.Join(" | ", log));}}Console.WriteLine($"{"graine",-7} {"élitiste global",-15} {"pairwise(greedy)",-17} évaluations");var baseV =new List<int>();var pairV =new List<int>();foreach(var sd innew[]{0,1,7,42}){var b = EoCorrective26.RunWith(false, sd,50,160);var p = EoCorrective26.RunWith(true, sd,50,160); baseV.Add(b.conflicts); pairV.Add(p.conflicts); Console.WriteLine($"{sd,-7} {b.conflicts,-15} {p.conflicts,-17} {b.evals} / {p.evals}");}Console.WriteLine($"médianes : élite globale {EoCorrective26.Median(baseV):F1} contre pairwise {EoCorrective26.Median(pairV):F1} conflits");Console.WriteLine("dispersion graine 0, élite globale : "+ EoCorrective26.RunWith(false,0,50,160).trace);Console.WriteLine("dispersion graine 0, pairwise : "+ EoCorrective26.RunWith(true,0,50,160).trace);
graine élitiste global pairwise (greedy) évaluations
0 46 33 8000 / 8000
1 41 34 8000 / 8000
7 43 31 8000 / 8000
42 40 32 8000 / 8000
médianes : élite globale 42,0 contre pairwise 32,5 conflits
dispersion graine 0, élite globale : g1: best 56, dispersion 2,55 | g40: best 47, dispersion 0,05 | g80: best 47, dispersion 0,00 | g120: best 47, dispersion 0,00 | g160: best 46, dispersion 0,03
dispersion graine 0, pairwise : g1: best 56, dispersion 2,55 | g40: best 42, dispersion 2,57 | g80: best 33, dispersion 0,27 | g120: best 33, dispersion 0,00 | g160: best 33, dispersion 0,00
Lecture du correctif. Le greedy par individu referme plus de la moitié de l’écart, à budget strictement égal (8 000 évaluations des deux côtés, mêmes graines) :
G160, 4 graines
élitiste global (voie historique)
pairwise (greedy mealpy)
mealpy
médiane
42,0
32,5
25,0
étendue
[40, 46]
[31, 34]
[21, 31]
La dispersion raconte le mécanisme : à la génération 40, la population pairwise est encore diverse (dispersion 1,10 à 2,57, contre 0,00 à 0,10 en baseline) — les cinquante lignées explorent, le pool d’équilibre reste quatre points distincts. Le clone arrive quand même vers la génération 120 — l’Eq. 16 attire tout le monde vers le pool — mais après avoir exploité la diversité. À budget ×4, la médiane passe de 38,0 à 35,5 ; chaque régime ne fige qu’une graine sur quatre après la génération 160, la différence tient au plateau atteint, pas à une longévité du moteur.
Verdict de la tranche : IMPROVES (partiel). Le port gagne 9,5 conflits médian sans aucune évaluation supplémentaire ; l’écart à mealpy passe de 17,0 à 7,5. L’écart résiduel reste ouvert : l’hypothèse (b) — le centroïde coûté et membre du pool chez mealpy (8 006 évaluations par epoch), simple donneur de gènes jamais coûté dans le port (8 000) — attend sa propre tranche. La leçon de la paire se précise : « même algorithme » ne se transfère pas gratuitement entre paradigmes d’implémentation, et sur cette paire, plus de la moitié du prix de la traduction tenait à une ligne de sélection.
Exercice 1 : budget ×4 — l’écart du port survit-il à un budget accru ?
MGS-22/23/24/25 posaient la même question. Un écart qui se referme à budget accru dit « le moteur distillé converge plus lentement mais atteint le même plateau » ; un écart stable dit « plateau différent ». Ici la question est la plus tranchée de l’Epic : même équations, mêmes constantes — un écart de plateau ne peut venir que de la traduction (sélection, ordre des tirages), pas des mathématiques.
À compléter (décommentez dans la cellule suivante) :
1. Relancez les deux côtés à budget x4 (MGS : 640 générations ; mealpy : 624 epochs).
2. Comparez les médianes obtenues à celles du croisement.
3. Verdict : l'ordre MGS/mealpy est-il stable à budget accru ?
// EXERCICE 1 : budget x4 — MGS pop 50, 640 générations ; mealpy 624 epochs (4 x 156).// Décommentez et exécutez :// foreach (var sd in new[] {0, 1, 7, 42})// {// var r = Mgs26Host.RunEo(sd, 50, 640);// Console.WriteLine($"MGS EO x4 (graine {sd}) : {r.Item1} conflits ({r.Item3:F0} ms, {r.Item2} evals).");// }// using (Py.GIL())// {// S26.Set("__seeds_json__", System.Text.Json.JsonSerializer.Serialize(new[] {0, 1, 7, 42}.ToList()));// S26.Exec(@"__bench_x4_json__ = bench_mealpy_eo(__seeds_json__, 50, 624)");// Console.WriteLine(S26.Get<string>("__bench_x4_json__"));// }Console.WriteLine("Exercice a completer (decommentez le bloc ci-dessus).");
Exercice a completer (decommentez le bloc ci-dessus).
Exercice 2 : taille de population à budget constant — un pool d’équilibre plus large aide-t-il ?
Le pool d’équilibre (4 meilleures + centroïde) est toujours de taille 5, mais il est tiré de la population courante : une population double offre au pool des candidats meilleurs, au même budget d’évaluations total. RunEo prend population et générations libres — comparez pop 25 × 320 générations, pop 50 × 160 (le croisement) et pop 100 × 80 : même budget, pools tirés de populations de profondeur différente.
// EXERCICE 2 : pop 25 x 320 vs pop 50 x 160 vs pop 100 x 80 (même budget), 4 graines.// Décommentez et exécutez :// foreach (var (ps, gens) in new[] {(25, 320), (50, 160), (100, 80)})// {// var line = new List<string>();// foreach (var sd in new[] {0, 1, 7, 42})// {// var r = Mgs26Host.RunEo(sd, ps, gens);// line.Add($"graine {sd}: {r.Item1} conflits ({r.Item2} evals)");// }// Console.WriteLine($"pop {ps,3} x {gens,3} gens : {string.Join(" | ", line)}");// }Console.WriteLine("Exercice a completer (decommentez le bloc ci-dessus).");
Exercice a completer (decommentez le bloc ci-dessus).
Exercice 3 : profiler la fitness — où va la milliseconde ?
La cellule du coût par évaluation compare déjà le total decode+coût. Pour localiser la différence, séparez les deux étapes côté Python (décoder une fois, coûter N fois) et comparez au profil C# équivalent.
// EXERCICE 3 : profil decode vs cost, 500 vecteurs, deux côtés.// Décommentez et exécutez (adapté de MGS-23/24/25 exercice 3) :// var decSw = Stopwatch.StartNew();// foreach (var v in benchVecs) DecodeR1_26(v);// decSw.Stop();// Console.WriteLine($"C# decode seul : {decSw.Elapsed.TotalMilliseconds / K26:F3} ms/vec " +// $"(reste = coût : {(csMs - decSw.Elapsed.TotalMilliseconds) / K26:F3} ms/vec)");// using (Py.GIL())// {// S26.Set("__vecs_json__", System.Text.Json.JsonSerializer.Serialize(benchVecs.Select(v => v.ToList()).ToList()));// S26.Exec(@"import time// _vecs = _json.loads(__vecs_json__)// _t0 = time.perf_counter()// _grids = [decode(v) for v in _vecs]// _t1 = time.perf_counter()// for g in _grids: cost(g)// _t2 = time.perf_counter()// print(f'Python decode seul : {(_t1-_t0)*1000.0/len(_vecs):.3f} ms/vec, coût : {(_t2-_t1)*1000.0/len(_vecs):.3f} ms/vec')");// }Console.WriteLine("Exercice a completer (decommentez le bloc ci-dessus).");
Exercice a completer (decommentez le bloc ci-dessus).