MGS-30 : Scatter Search MGS contre son ombre — la diversité du RefSet en question, et le jumeau mealpy
Paire 9/9 de l’EPIC #12373 (MGS vs mealpy). Le composé MGS ScatterSearch transcrit le template à cinq méthodes de Glover (1998) sur la grammaire géométrique du moteur : la population joue le Reference Set, la combinaison convexe (lambda * x_a + (1 - lambda) * x_b, un lambda ~ U(0,1) tiré une fois par enfant) joue le path-relinking, et la réinsertion garde b1 slots par qualité + le reste par diversité max-min (ScatterSearchReinsertion, QualityFraction = 0.7 par défaut).
mealpy 3.0.2 ne porte aucun scatter search — mesuré dans ce notebook même : le scan exhaustif des optimiseurs du paquet rend 0 occurrence. Le jumeau est donc construit comme un subclass Optimizer (point d’extension natif mealpy) pinnant la même formule, exactement comme la paire 7 (MGS-28, Bare Bones PSO) l’a fait pour son jumeau absent.
Le banc croise trois bras pour démêler ce que l’écart brut mélange :
Bras
Moteur
Sémantique
1
MGS ScatterSearch
complet — b1 qualité + b2 diversité (qf = 0,7)
2
MGS ScatterSearch
ablaté — qf = 1,0, b2 désactivé, élitesse pure
3
mealpy OriginalScatterSearch (subclass)
jumeau Glover pinné, même qf = 0,7
Bras 1 contre bras 2 (même engine) isole la contribution de l’axe diversité ; bras 1 contre bras 3 (formules pinnées identiques) isole l’écart de noyau (engine C# vs harnais mealpy).
Position dans la série. Huit paires ont précédé celle-ci ; aucune n’a ablaté l’axe diversité. Les comparaisons MGS contre mealpy portaient jusqu’ici sur des formules distinctes implantées de part et d’autre — l’écart mesuré y mélangeait noyau et sémantique. Cette paire 9/9 retourne le problème : les formules sont pinnées identiques dans les trois bras, et l’ablation de QualityFraction interroge une hypothèse qu’aucune paire précédente ne pouvait isoler — sur ce puzzle, à ce budget, l’investissement de 30 % du Reference Set dans la diversité max-min paie-t-il ? C’est la recommandation de Glover portée au banc : le template à cinq méthodes prescrit la diversité comme police d’assurance contre la stagnation prématurée ; le banc la met à l’épreuve.
Plan de lecture. Le socle pose la grille et le coût ; le moteur instancie les deux bras MGS (complet et ablaté) ; le pont PythonNet fait entrer mealpy dans le même kernel ; la cellule formules prouve l’absence de scatter search dans mealpy 3.0.2 et pinnise les formules des trois bras ; la course témoin calibre ; le banc croisé mesure ; la cellule coût/éval isole la fitness du moteur ; la lecture du croisement confronte les trois questions aux mesures ; trois exercices prolongent.
// === MGS-30 : socle commun — DLLs MGS, grille de référence, fonction de coût ===// Même socle que MGS-22/23/28 : 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/25/27/28.publicstaticstring PuzzleLine30 ="902005403100063025508407060026309001057010290090670530240530600705200304080041950";publicstaticint[,]ParsePuzzle30(){var g =newint[9,9];for(int i =0; i <81; i++) g[i /9, i %9]= PuzzleLine30[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.publicstaticintCountConflicts30(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;}publicstaticintCountEmpty30(int[,] p){int n =0;foreach(var v in p)if(v ==0) n++;return n;}publicstatic List<(int r,int c)>EmptyCells30(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_30(double[] genes){var Puzzle =ParsePuzzle30();var empties =EmptyCells30(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 Puzzle30 =ParsePuzzle30();Console.WriteLine($"Grille de référence : {CountEmpty30(Puzzle30)} cellules vides, "+ $"{81 - CountEmpty30(Puzzle30)} indices fixes, {EmptyCells30(Puzzle30).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-28 au nom près — c’est voulu : la comparabilité entre paires exige le même substrat (grille, représentation R1, fonction de coût). 51 gènes continus dans [1, 10], arrondis + bornés vers 1..9 au décodage ; le coût compte les conflits de lignes, colonnes et blocs, 0 si et seulement si la grille est résolue.
Deux choix de ce socle portent toute la paire. D’abord la représentation R1 : les métaheuristiques de ce banc (combinaison convexe comprise) travaillent dans le continu, alors que le Sudoku est discret — le décodage continu → arrondi → borné est le pont obligé, et il est identique des deux côtés du bench : un vecteur donné produira la même grille et le même coût côté C# et côté Python (la cellule pont le vérifiera sur témoins). Ensuite la fonction de coût comme unique source de vérité : elle seule définit « mieux », pour les trois bras comme pour les exercices — aucune métrique annexe (diversité, distance) n’entre dans le classement final ; ces métriques ne servent qu’à construire les candidats.
// === Moteur MGS : chromosome R1, fitness instrumentée, composé ScatterSearch (2 variantes) ===// Bras 1 : ScatterSearch complet via le catalogue (QualityFraction = 0,7 par défaut).// Bras 2 : ScatterSearch ABLATÉ construit directement (QualityFraction = 1,0) — le catalogue// n'expose pas QualityFraction, la construction directe duplique son converter identité.publicclass SudokuR1Chromosome30 : ChromosomeBase{privateconstdouble LO =1.0, HI =10.0;publicSudokuR1Chromosome30():base(EmptyCells30(ParsePuzzle30()).Count){CreateGenes();}publicoverride Gene GenerateGene(int index)=>newGene(RandomizationProvider.Current.GetDouble(LO, HI));publicoverride IChromosome CreateNew()=>newSudokuR1Chromosome30();publicdouble[]ToGenes(){var v =newdouble[Length];for(int i =0; i < Length; i++) v[i]=(double)GetGene(i).Value;return v;}publicint[,]ToGrid()=>DecodeR1_30(ToGenes());}// Fitness instrumentée : chaque évaluation est comptée — le budget se mesure, il ne se suppose pas.publicclass SudokuR1Fitness30 : IFitness{publicstaticint Evals;publicdoubleEvaluate(IChromosome chromosome){ Evals++;return-CountConflicts30(((SudokuR1Chromosome30)chromosome).ToGrid());}}publicstaticclass Mgs30Host{// Trajectoire best-so-far : conflits du meilleur APRÈS chaque génération (event GenerationRan).publicstatic List<int>RunTrajectory(int seed,int popSize,int maxGens,double qualityFraction,outint conflicts,outint evals,outdouble ms,outdouble[] genes){// Seeding AVANT création de population : le RNG est consommé par CreateNew()// de chaque individu initial (leçon #12071 / MGS-21). FastRandomRandomization.ResetSeed(seed); IMetaHeuristic compound;if(qualityFraction <0.0){// Bras 1 : chemin catalogue — QualityFraction par défaut (0,7). compound = MetaHeuristicsService.CreateMetaHeuristicByName("ScatterSearch", maxGens, popSize);}else{// Bras 2 : construction directe pour poser QualityFraction = 1,0 (b2 désactivé).// Miroir exact du bloc converter-identité de MetaHeuristicsService (double-identity).var noEmbeddingConverter =new GeometricConverter<double>{ IsOrdered =false, DoubleToGeneConverter =(geneIndex, geomValue)=> geomValue, GeneToDoubleConverter =(genIndex, geneValue)=> geneValue};var typed =newTypedGeometricConverter(); typed.SetTypedConverter(noEmbeddingConverter);var ss =newScatterSearch(){ MaxGenerations = maxGens, GeometricConverter = typed, NoMutation =true, QualityFraction = qualityFraction}; compound = ss.Build();}var adam =newSudokuR1Chromosome30();var pop =newMetaPopulation(popSize, popSize, adam);var ga =newMetaGeneticAlgorithm( pop,newSudokuR1Fitness30(),newEliteSelection(),newUniformCrossover(0.5f),newUniformMutation(true), compound); ga.Termination=newGenerationNumberTermination(maxGens);var traj =new List<int>(maxGens +1); ga.GenerationRan+=(s, e)=> traj.Add(CountConflicts30(((SudokuR1Chromosome30)ga.BestChromosome).ToGrid())); SudokuR1Fitness30.Evals=0;var sw = Stopwatch.StartNew(); ga.Start(); sw.Stop();var best =(SudokuR1Chromosome30)ga.BestChromosome; conflicts =CountConflicts30(best.ToGrid()); evals = SudokuR1Fitness30.Evals; ms = sw.Elapsed.TotalMilliseconds; genes = best.ToGenes();return traj;}// qualityFraction < 0 => bras 1 (catalogue, 0,7) ; sinon bras 2 à la fraction donnée.publicstatic(int conflicts,int evals,double ms,double[] genes,int[] cp)RunScatter(int seed,int popSize,int maxGens,double qualityFraction =-1.0){var traj =RunTrajectory(seed, popSize, maxGens, qualityFraction,outvar c,outvar e,outvar t,outvar g);var q =new[]{ traj[maxGens /4-1], traj[maxGens /2-1], traj[3* maxGens /4-1], traj[maxGens -1]};return(c, e, t, g, q);}}// Échauffement JIT (course jetée), puis les deux courses témoins graine 7.var warmupMgs = Mgs30Host.RunScatter(123,50,10);var demoFull = Mgs30Host.RunScatter(7,50,160);// bras 1 : complet (qf = 0,7)var demoAbl = Mgs30Host.RunScatter(7,50,160,1.0);// bras 2 : ablaté (qf = 1,0)Console.WriteLine($"MGS ScatterSearch complet (graine 7, témoin) : {demoFull.Item1} conflits, {demoFull.Item2} évaluations, "+ $"{demoFull.Item3:F0} ms, checkpoints 25/50/75/100 % = {string.Join("/", demoFull.Item5)}.");Console.WriteLine($"MGS ScatterSearch ablaté (graine 7, témoin) : {demoAbl.Item1} conflits, {demoAbl.Item2} évaluations, "+ $"{demoAbl.Item3:F0} ms, checkpoints 25/50/75/100 % = {string.Join("/", demoAbl.Item5)}.");
Lecture. Le composé est invoqué par son nom de catalogue ("ScatterSearch") pour le bras complet ; le bras ablaté construit le composé directement parce que le catalogue n’expose pas QualityFraction — le converter-identité du service est dupliqué à l’identique pour que la seule différence entre les deux bras soit la fraction. À QualityFraction = 1,0 la réinsertion garde tous les slots par qualité : le RefSet s’effondre sur l’élitisme pur, c’est la limite FitnessBasedElitistReinsertion documentée dans ScatterSearchReinsertion.cs — « pure quality collapses the reference set onto the best point and starves the combination operator of distant mates ». Les témoins graine 7 donnent le premier signal de cette famine.
Lu depuis Glover (1998), le template à cinq méthodes se projette ainsi sur la grammaire du moteur : la génération diversifiée initiale est la population de départ ; l’amélioration locale est différée (axe mémétique hors composé, comme le note la cellule formules) ; le Reference Set est la population réinsérée à chaque génération ; la combinaison est la corde convexe lambda * x_a + (1 - lambda) * x_b ; la réinsertion referme la boucle. À population 50 et QualityFraction = 0,7, le partage concret est 35 slots par qualité, 15 par diversité max-min : à chaque génération, le RefSet retient les 35 meilleurs puis complète par les 15 plus éloignés (au sens RMS gène à gène) des déjà retenus. L’ablaté (qf = 1,0) garde 50 slots par qualité et 0 par diversité : il retire exactement ces 15 places — l’écart mesuré entre bras 1 et bras 2 sera donc attribuable à cette seule politique de réinsertion.
// === 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).#r "nuget: pythonnet,3.1.0"using Python.Runtime;staticstringResolvePythonDll30(){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))foreach(var d in System.IO.Directory.GetDirectories(pyDir,"Python3*")){var hit = System.IO.Directory.GetFiles(d,"python3*.dll");if(hit.Length>0)return hit[0];}}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=ResolvePythonDll30();PythonEngine.Initialize();// Le problème Python : decode + coût réimplémentés à l'identique, compteur d'évals,// et le JUMEAU SCATTER SEARCH : subclass Optimizer (point d'extension natif mealpy) pinnant// le template de Glover — mealpy 3.0.2 n'en porte AUCUN (preuve mesurée cellule formules).publicstatic PyModule S30;using(Py.GIL()){ S30 = Py.CreateScope(); S30.Set("puzzle_line30", PuzzleLine30); S30.Exec(@"import sysimport numpy as npimport mealpyfrom mealpy import Problem, FloatVar, Optimizerimport json as _jsonpuzzle =[int(ch)for ch in puzzle_line30]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)))classOriginalScatterSearch(Optimizer): # Scatter Search — template Glover 1998/ Laguna & Marti 2003 — jumeau mealpy du composé MGS. # Formules pinnées(celles du composé MGS ScatterSearch.cs): # COMBINAISON : x_new = lam * x_a +(1- lam)* x_b, lam ~U(0,1) tiré UNE fois par enfant, # x_a = position courante, x_b = membre aléatoire du RefSet SANS soi-même # (RandomIncludesCurrent =false côté MGS, remap MatchPicker). # REFSET : pool parents+enfants ; b1 =max(1,round(N*qf)) slots par qualité, # le reste par diversité max-min(distance RMS gène à gène). # AMÉLIORATION :différée(axe mémétique hors template — Glover : optionnelle). def __init__(self, epoch=10000, pop_size=100, quality_fraction=0.7,**kwargs):super().__init__(**kwargs) self.epoch= self.validator.check_int('epoch', epoch,[1,100000]) self.pop_size= self.validator.check_int('pop_size', pop_size,[2,10000]) self.quality_fraction=float(quality_fraction) self.checkpoints={} def generate_agent(self, solution=None): agent = self.generate_empty_agent(solution) agent.target= self.get_target(agent.solution)return agent def evolve(self, epoch): # Checkpoint APRÈS l'epoch m : enregistré à l'entrée de l'epoch m+1(g_best à jour).if epoch -1in(self.epoch// 4, self.epoch // 2, 3 * self.epoch // 4): self.checkpoints[epoch -1]=float(self.g_best.target.fitness) # 1) Subset generation + combination : un enfant par membre, mate aléatoire != soi. offspring =[]for idx inrange(self.pop_size): j =int(self.generator.integers(0, self.pop_size-1))if j == idx: j = self.pop_size-1 lam =float(self.generator.uniform()) pos_new = self.correct_solution(lam * self.pop[idx].solution+(1.0- lam)* self.pop[j].solution) agent = self.generate_empty_agent(pos_new) agent.target= self.get_target(pos_new) offspring.append(agent) # 2) Reference-set update : b1 par qualité(minmax='min' => coût croissant), reste max-min. # Distance RMS vectorisée(mêmes valeurs que la boucle scalaire, zéro tirage RNG ajouté). candidates =sorted(list(self.pop)+ offspring, key=lambda a: a.target.fitness) target =min(self.pop_size,len(candidates)) b1 =max(1,min(target,int(round(target * self.quality_fraction))))ref=[a for a in candidates[:b1]] remaining = candidates[b1:] ref_mat = np.array([a.solutionfor a inref])whilelen(ref)< target and remaining: rem_mat = np.array([a.solutionfor a in remaining]) dmin = np.sqrt(np.mean((rem_mat[:, None,:]- ref_mat[None,:,:])**2, axis=2)).min(axis=1) best_i =int(np.argmax(dmin))ref.append(remaining.pop(best_i)) ref_mat = np.vstack([ref_mat,ref[-1].solution]) self.pop=refdef run_mealpy_ss(seed, pop_size, epoch, quality_fraction=0.7): 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 =OriginalScatterSearch(epoch=epoch, pop_size=pop_size, quality_fraction=quality_fraction) t0 = time.perf_counter() g_best = model.solve(prob, seed=seed) dt =(time.perf_counter()- t0)*1000.0 cps =[int(model.checkpoints[epoch // 4]), int(model.checkpoints[epoch // 2]),int(model.checkpoints[3* epoch // 4]), int(g_best.target.fitness)] sol = _json.dumps([float(v)for v in g_best.solution])returncost(decode(g_best.solution)), PY_EVALS[0], dt, sol, cpsdef bench_mealpy_ss(seeds_json, pop_size, epoch, reps=3, quality_fraction=0.7):out=[]for sd in _json.loads(seeds_json): runs =[run_mealpy_ss(sd, pop_size, epoch, quality_fraction)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, 'cp': runs[0][4], '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]__mealpy_ver__ = 'mealpy ' + mealpy.__version__+ ' sur Python ' + sys.version.split()[0]"); Console.WriteLine($"Pont PythonNet actif : {S30.Get<string>("__mealpy_ver__")}");}// --- 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[]LcgVector30(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[]{LcgVector30(1,51),LcgVector30(2,51),LcgVector30(3,51)};using(Py.GIL()){ S30.Set("__witness_json__", System.Text.Json.JsonSerializer.Serialize(witnessVectors.Select(v => v.ToList()).ToList())); S30.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>>(S30.Get<string>("__py_costs__"));var csCosts = witnessVectors.Select(v =>CountConflicts30(DecodeR1_30(v))).ToList();bool identical = pyCosts.SequenceEqual(csCosts); Console.WriteLine($"Sanity check cout : C# {string.Join(",", csCosts)} | Python {string.Join(",", pyCosts)} "+ $"-> {(identical ? "IDENTIQUE" : "DIFFERENT")}");}
Installed Packages
pythonnet, 3.1.0
Pont PythonNet actif : mealpy 3.0.2 sur Python 3.13.13
Sanity check cout : C# 67,71,60 | Python 67,71,60 -> IDENTIQUE
Lecture. Le pont est actif et la sanity check porte tout le bench : la fonction de coût C# et sa réimplémentation Python rendent les mêmes valeurs sur trois vecteurs témoins déterministes. Toute différence mesurée ensuite vient des moteurs, pas de la fonction de coût. Le subclass OriginalScatterSearch pousse le template Glover dans le harnais mealpy : génération d’un enfant par membre (combinaison convexe, mate aléatoire sans soi-même — RandomIncludesCurrent = false côté MGS), puis mise à jour du Reference Set b1-qualité / b2-diversité sur le pool parents+enfants — la sémantique de ScatterSearchReinsertion.
La sanity check mérite son statut de précondition du banc entier : elle fixe trois vecteurs témoins déterministes et exige que la fonction C# et sa réimplémentation Python rendent le même conflit. Si elle échouait, aucun écart ultérieur ne serait attribuable — un décalage de décodage ou de comptage se glisserait dans chaque mesure. C’est parce qu’elle passe que la suite peut lire « écart de moteur » dans une différence de conflits. Le subclass mealpy, lui, écrit les formules du bras 1 dans le harnais numpy/Agent du paquet : generate_agent/evolve portent le tirage lambda, le mate sans soi-même et la réinsertion b1/b2 — RNG numpy en lieu et place du RNG du moteur, même sémantique déclarée.
// === CELLULE FORMULES : la preuve d'absence, puis les jumeaux AVANT toute mesure ===// Objectif : prouver que mealpy 3.0.2 ne porte AUCUN scatter search (le subclass se justifie),// puis pinner les formules des trois bras pour que l'écart mesuré s'interprète (axe vs noyau).using(Py.GIL()){// 1) Le scan exhaustif : chaque optimiseur mealpy, famille par famille, cherché en 'scatter'. S30.Exec(@"import inspect, pkgutil__ss_scan__ =[]__n_opt__ =0for _pkg in pkgutil.iter_modules(mealpy.__path__):if not _pkg.ispkg:continue _fam = 'mealpy.' + _pkg.namefor _sub in pkgutil.iter_modules(__import__(_fam, fromlist=['']).__path__): _mod =__import__(f'{_fam}.{_sub.name}', fromlist=[''])for _cn, _obj in inspect.getmembers(_mod, inspect.isclass):ifissubclass(_obj, Optimizer) and _obj.__module__== _mod.__name__: __n_opt__ +=1if 'scatter' in _cn.lower(): __ss_scan__.append(_cn)__ss_verdict__ = f'{__n_opt__} optimisateurs mealpy scannes, scatter search trouves : ' +(str(__ss_scan__)if __ss_scan__ else 'AUCUN')"); Console.WriteLine(S30.Get<string>("__ss_verdict__"));}Console.WriteLine(@"Formules pinnées des trois bras : MGS ScatterSearch COMPLET(in-source, ScatterSearch.cs+ ScatterSearchReinsertion.cs): combinaison : x_new = lam * x_i +(1- lam)* x_j, lam ~U(0,1) par enfant(store, partage par tous les gènes du meme enfant), x_j membre aleatoire SANS soi RefSet : pool parents+enfants, b1 =round(N*0.7) par qualite, reste par diversite max-min(distance RMS gene a gene) amelioration :differee(axe memetique hors compound — Glover : optionnelle) MGS ScatterSearch ABATE(bras 2): identique, QualityFraction =1.0=> b2 =0 slot diversite, l'elitisme pur(limite FitnessBasedElitistReinsertion) mealpy OriginalScatterSearch(bras 3, subclass): formules du bras 1 pinnées dans le harnaismealpy(numpy RNG, generate_agent/evolve) — qf =0.7, distance RMS identique.TRANCHE : le composé porte DEUX axes distincts — (1) l'AXE DIVERSITÉ(b2 max-min vs elitismepur),(2) le NOYAU(engine C# vs harnais mealpy). L'écart brut des paires précédentes lesmélange ; le bench croise donc TROIS bras -> bras 1 MGS complet vs bras 2 MGS ablaté : meme engine => ÉCART D'AXE(contribution de b2) bras 1 MGS complet vs bras 3 mealpy-jumeau : formules pinnées identiques => ÉCART NOYAU.");
213 optimisateurs mealpy scannes, scatter search trouves : AUCUN
Formules pinnées des trois bras :
MGS ScatterSearch COMPLET (in-source, ScatterSearch.cs + ScatterSearchReinsertion.cs) :
combinaison : x_new = lam * x_i + (1 - lam) * x_j, lam ~ U(0,1) par enfant (store,
partage par tous les gènes du meme enfant), x_j membre aleatoire SANS soi
RefSet : pool parents+enfants, b1 = round(N*0.7) par qualite, reste par diversite
max-min (distance RMS gene a gene)
amelioration : differee (axe memetique hors compound — Glover : optionnelle)
MGS ScatterSearch ABATE (bras 2) : identique, QualityFraction = 1.0
=> b2 = 0 slot diversite, l'elitisme pur (limite FitnessBasedElitistReinsertion)
mealpy OriginalScatterSearch (bras 3, subclass) : formules du bras 1 pinnées dans le harnais
mealpy (numpy RNG, generate_agent/evolve) — qf = 0.7, distance RMS identique.
TRANCHE : le composé porte DEUX axes distincts — (1) l'AXE DIVERSITÉ (b2 max-min vs elitisme
pur), (2) le NOYAU (engine C# vs harnais mealpy). L'écart brut des paires précédentes les
mélange ; le bench croise donc TROIS bras ->
bras 1 MGS complet vs bras 2 MGS ablaté : meme engine => ÉCART D'AXE (contribution de b2)
bras 1 MGS complet vs bras 3 mealpy-jumeau : formules pinnées identiques => ÉCART NOYAU.
Lecture. La preuve d’absence est mesurée dans l’exécution même : aucun des optimisateurs mealpy 3.0.2 ne porte « scatter » dans son nom — le subclass n’est donc pas un choix de commodité, c’est la seule voie pour un jumeau. Les formules pinnées fixent l’interprétation : l’écart bras 1 ↔︎ bras 2 est attributable à l’axe diversité (même engine), l’écart bras 1 ↔︎ bras 3 au noyau (mêmes formules). Sans cette tranche, un écart brut ne dirait pas lequel des deux axes le porte.
Noter que l’absence est mesurée dans l’exécution même (scan des 213 optimisateurs, 0 occurrence) : le notebook porte sa propre preuve, et une évolution future de mealpy qui ajouterait un scatter search rendrait la cellule fausse à la prochaine exécution — le signal viendrait du notebook, pas d’une note de version. C’est plus fort qu’une absence documentée en prose, qui dérive silencieusement.
// === 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 K30 =500;var benchVecs =new List<double[]>();for(int i =0; i < K30; i++) benchVecs.Add(LcgVector30(42+ i,51));var csTimes =new List<double>();for(int rep =0; rep <5; rep++){var swRep = Stopwatch.StartNew();foreach(var v in benchVecs)CountConflicts30(DecodeR1_30(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()){ S30.Set("__vecs_json__", System.Text.Json.JsonSerializer.Serialize(benchVecs.Select(v => v.ToList()).ToList())); S30.Exec(@"__py_ms__ = time_python_evals(__vecs_json__)"); pyMs = S30.Get<double>("__py_ms__");}Console.WriteLine($"Fitness seule, {K30} vecteurs identiques (médiane de 5 répétitions par côté) :");Console.WriteLine($" C# : {csMs:F1} ms total -> {csMs / K30:F3} ms/éval");Console.WriteLine($" Python : {pyMs:F1} ms total -> {pyMs / K30: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# : 3,5 ms total -> 0,007 ms/éval
Python : 18,4 ms total -> 0,037 ms/éval
rapport Python/C# : 5,25x
Lecture du croisement. Trois lectures, une par question posée à la cellule formules — chacune confrontée aux mesures du banc.
L’axe diversité (complet vs ablaté, même engine) — attendu : si le b2 max-min travaille, le complet devance l’ablaté ou le rattrape tard ; la forme par graine dit quand la diversité paie. Mesuré : médianes 54,0 contre 52,0 — l’ablaté devant, et la forme n’est pas du bruit : écart nul sur les graines 0 et 1 (les deux bras finissent identiques), +4 persistant pour l’ablaté sur la graine 7, −2 seulement pour le complet sur la graine 42. Verdict : sur ce puzzle à ce budget, les 15 slots de diversité max-min ne paient pas — l’ablaté les égalise ou les devance sur 3 graines sur 4. Les checkpoints plats du complet (54/54/54/54 dès 25 % du budget) montrent le mécanisme : un RefSet diversifié entretient des mates éloignés dont la corde convexe produit des enfants décodant loin de toute solution, sans amélioration locale pour les raffiner (amélioration différée, hors composé).
Le noyau (complet vs jumeau mealpy, formules pinnées) — attendu : à formules égales, l’écart restant mesure l’engine. Mesuré : médianes 54,0 contre 51,5 — le jumeau mealpy devant sur 3 graines sur 4 (écarts persistants de 1 à 8 conflits ; seule la graine 42 inverse, −7 pour MGS). La colonne ms/éval (0,113 contre 0,123, ratio 0,92×) écarte l’explication par le coût : la fitness pure est 5,25× plus rapide côté C# (0,007 contre 0,037 ms/éval) — l’écart de qualité vient de l’ordre des opérations et des tirages propres à chaque harnais, pas d’un avantage de calcul. À formules déclarées identiques, deux moteurs divergent quand même : le pinning fixe les équations, pas la trajectoire.
Le déterminisme — mesuré : 12/12 bras-graines stables sur 3 répétitions (seeding explicite ResetSeed / solve(seed=...)). Les lectures 1 et 2 portent sur du signal, pas du bruit.
Le classement final — jumeau mealpy (51,5) < ablaté (52,0) < complet (54,0) — est inverse de l’intuition « plus de diversité, meilleur scatter search » : c’est précisément ce que le troisième bras rendait visible.
Exercice 1 : à budget ×4, l’écart d’axe se referme-t-il ou s’installe-t-il ?
Le banc courant est calibré (pop 50, 160 générations). Refais tourner les deux bras MGS à budget ×4 (160 → 640 générations, même population, mêmes 4 graines, 1 répétition suffit ici). Deux issues opposées : si l’ablaté referme l’écart, la diversité b2 n’était qu’une accélération de phase précoce ; s’il plafonne, l’élitisme pur verrouille le RefSet et le verrou s’aggrave avec le temps. À compléter : resultats64 rempli, verdict écrit.
Ce que « se referme » ou « s’installe » signifie pour l’hypothèse diversité : si l’écart complet-ablaté s’annule à budget ×4, la diversité ne faisait que ralentir la convergence précoce (le complet rattrape une fois la phase d’exploitation atteinte) ; s’il persiste ou s’agrandit, la politique de réinsertion dégrade la recherche en profondeur sur ce puzzle (mates éloignés → enfants hors bassin, sans amélioration locale pour les récupérer). Le témoin graine 7 (54 contre 50 à budget ×1, checkpoints plats du complet) donne la prédiction à battre. Compte tenu du témoin (~1 s pour 160 générations côté complet), attends-toi à ~4 s par graine et par bras — environ une demi-minute pour la cellule complète.
// EXERCICE 1 : budget x4 (pop 50, 640 generations), 4 graines, les deux bras MGS.// TODO etudiant — remplis resultats64 puis ecris ton verdict (refermeture, installation, mixte ?).// Indice : Mgs30Host.RunScatter(seed, 50, 640) = bras complet ; RunScatter(seed, 50, 640, 1.0) = ablate (qf = 1).// Etape 1 : pour chaque graine de Seeds30, lancer les deux courses a 640 generations.// Etape 2 : afficher graine, conflits complet/ablate et le delta.// Etape 3 : comparer aux deltas du banc (160 generations) et conclure.var resultats64 =new List<(int seed,int complet,int ablate)>();// ... courses et affichage a ecrire ...Console.WriteLine("Exercice a completer : verdict budget x4 — refermeture, installation, ou mixte ?");
Exercice a completer : verdict budget x4 — refermeture, installation, ou mixte ?
Exercice 2 : où vit la contribution diversité — balayage de QualityFraction
L’ablation du banc est un point extrême (qf = 1,0). Balaye la fraction côté MGS sur {0,5 · 0,85 · 1,0} (4 graines, 160 générations) et confronte au bras complet (qf = 0,7) du banc : la contribution b2 est-elle monotone, présente un optimum intérieur, ou plate ? Attention au sens de lecture : qf baisse = plus de slots diversité (b1 = round(N·qf) qualité, le reste diversité). À compléter : sweepRows rempli, verdict écrit.
Les trois lectures possibles du balayage : monotone croissant en qf (plus d’élitisme = mieux → le puzzle est un problème d’exploitation, la diversité n’y sert à rien) ; optimum intérieur autour de 0,85 (un peu de diversité aide, trop dessert — le régime que Glover recommande en général) ; plat (indifférence : le RefSet est assez petit pour que la corde convexe domine la politique de réinsertion). Le banc courant ne mesure que les extrêmes 0,7 et 1,0 — le balayage ajoute le point 0,5, côté sous-investi.
// EXERCICE 2 : balayage QualityFraction {0.5, 0.85, 1.0} cote MGS, 4 graines, 160 generations.// TODO etudiant — complete sweepRows et ecris le verdict (monotone / optimum interieur / plat).// Indice : Mgs30Host.RunScatter(sd, 50, 160, qf) accepte la fraction en 4e argument.// Indice : qf baisse = plus de slots diversite (b1 = round(N*qf) qualite, le reste diversite).// Etape 1 : double boucle qf x Seeds30, une course par combinaison.// Etape 2 : mediane des conflits par qf (Median30 est disponible).// Etape 3 : confronter au bras complet du banc (qf = 0.7) et conclure.var sweepRows =new List<(double qf,int seed,int conflicts)>();// ... balayage et affichage a ecrire ...Console.WriteLine("Exercice a completer : verdict balayage — monotone, optimum interieur, ou plat ?");
Exercice a completer : verdict balayage — monotone, optimum interieur, ou plat ?
Exercice 3 : profiler la fitness — où va la milliseconde ?
L’écart ms/éval entre bras peut venir du decode (R1 → grille), du comptage de conflits, ou du moteur lui-même. La cellule coût/éval mesure decode+coût ensemble ; sépare-les : chronomètre 500 vecteurs en deux passes (decode seul avec coût jeté, coût seul sur grilles pré-décodées), côté C# et côté Python. À compléter : profilRows rempli, verdict écrit.
Le prior à confronter : la cellule coût/éval mesure 5,25× en faveur du C# sur la fitness complète (decode + coût), mais le rapport ms/éval du banc n’est que de 0,92×. Si le profil confirme que decode et coût sont massivement plus rapides côté C#, alors le temps restant du bench vit dans le moteur (allocation, collections, RNG), pas dans la fonction — et la question devient : où exactement ?
// EXERCICE 3 : profil decode vs cost, 500 vecteurs, deux cotes.// TODO etudiant — complete profilRows (4 lignes : C#-decode, C#-cost, Py-decode, Py-cost).// Indice : Stopwatch cote C#, time.perf_counter cote Python via Py.GIL().// Indice : benchVecs = 500 vecteurs temoins ; K30 normalise en ms par evaluation.// Etape 1 : mesurer le decode seul (C# puis Python) sur benchVecs.// Etape 2 : mesurer le cout seul (CountConflicts30 / conflicts Python) sur les grilles decodees.// Etape 3 : ranger ms/eval dans profilRows et commenter le rapport decode/cout.var profilRows =new List<(string cote,string passe,double msParEval)>();// ... mesures a ecrire ...Console.WriteLine("Exercice a completer : profil decode vs cost — ou va la milliseconde ?");
Exercice a completer : profil decode vs cost — ou va la milliseconde ?
Conclusion de la paire 9/9
Trois énoncés sortent du banc, tous mesurés : (1) la diversité max-min ne paie pas sur ce puzzle à ce budget — l’ablaté (qf = 1,0) égalise ou devance le complet sur 3 graines sur 4, médianes 52,0 contre 54,0 ; (2) à formules pinnées identiques, le jumeau mealpy devance l’engine C# (51,5 contre 54,0) alors que sa fitness pure est 5,25× plus lente — l’écart de noyau est un écart de trajectoire, pas de coût ; (3) le déterminisme est intégral (12/12), les deux premiers énoncés portent sur du signal.
Ce que cette paire ne dit pas : Scatter Search en général n’est pas condamné — un puzzle Sudoku 51 à 8000 évaluations, une amélioration locale différée et une combinaison convexe unique sont trois choix spécifiques ; un RefSet plus petit, une amélioration engagée ou un path-relinking plus riche pourraient renverser le verdict. La valeur de la paire est ailleurs : elle montre qu’une prescription de template (« garder 30 % de diversité », Glover) est une hypothèse testable, pas une évidence — et que le banc à trois bras la teste sans mélanger noyau et sémantique. La synthèse croisée des neuf paires (coût systématique contre qualité spécifique) vit dans MGS-31.