App-18b : Optimisation d’Hyperparametres - Jumeau C#

Twin C# de App-18-HyperparameterTuning (Python / scikit-learn + Optuna). Navigation : << App-17b VRP | Index | App-19 WFC >>

Objectif pedagogique

Le notebook Python App-18 optimise les hyperparametres d’un Random Forest (scikit-learn) via cinq méthodes : Grid Search, Random Search, Bayesian Optimization (Gaussian Process + Expected Improvement), Genetic Algorithm et PSO.

Ce jumeau C# reprend la même structure pedagogique en pur BCL .NET 9 (from-scratch, 0 NuGet) : - le modèle tune est un k-NN from-scratch (a la place du RandomForest sklearn) ; - l’objectif est la precision en validation croisee 5-fold sur un jeu de données synthetique déterministe ; - les cinq méthodes d’optimisation sont reimplementees from-scratch, avec en vedette la Bayesian Optimization (GP RBF + Expected Improvement) - absente de Search-11-Metaheuristics qui se concentrait sur PSO/SA/GA pour l’optimisation continue de fonctions-benchmark peu couteuses.

Pourquoi Bayesian Optimization ici ? L’optimisation d’hyperparametres est un cas canonique d’optimisation boite-noire couteuse : chaque evaluation = une CV complete (des milliers de calculs), et le budget d’evaluations est limite. C’est exactement le regime ou un surrogate probabiliste (Gaussian Process) + une fonction d’acquisition (EI/UCB) surpassent Grid/Random Search. La comparaison des courbes de convergence (section 8) le demontre.

Parite .NET / Python (Epic #4956) : les ancres déterministes (precision des configurations de reference, trajectoires de convergence) sont reproduites a l’identique dans les deux langues par construction (RNG a graine fixee, pas de derive).

1. Configuration et jeu de données

Nous generons un jeu de données de classification binaire 2D déterministe (deux amas gaussiens chevauchants, pour que la precision du k-NN depende sensiblement des hyperparametres).

Les amas sont anisotropes (ecart-types differents par axe, croises entre les deux classes) et le jeu de donnees est melange (Fisher-Yates, graine dediee) apres generation. Cette structure produit un paysage de validation croisee suffisamment rugueux pour que le choix d’optimiseur soit discriminant : sur des donnees isotropes non-melangees, les cinq methodes convergent vers la meme precision et l’interet de l’optimisation structuree disparait (EPIC #9975 grain A, Prong B). Un générateur pseudo-aleatoire LCG a graine fixee garantit la reproductibilite bit-par-bit des ancres committes.

L’espace de recherche (continu [0,1]^3, projete sur les hyperparametres reels) : - k dans [1, 50] (nombre de voisins, arrondi a l’impair le plus proche) ; - distancePower dans [1, 4] (distance de Minkowski generalisee) ; - weightBlend dans [0, 1] (melange entre vote uniforme et vote pondere par l’inverse de la distance).

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

// --- rendu (convention des jumeaux C#) ---
static void Show(object o) => o.ToString().Display();
static string FI(double x, string fmt = "F4") => x.ToString(fmt, CultureInfo.InvariantCulture);

// --- RNG deterministe (LCG a graine fixee) ---
static uint _rng = 2463534242u;
static void RngReset(uint seed) => _rng = seed;
static double U() { _rng = _rng * 1103515245u + 12345u; return ((_rng >> 8) & 0xFFFFFF) / 16777216.0; }
static double Gauss() { double u1 = Math.Max(U(), 1e-12), u2 = U(); return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2); }

// --- jeu de donnees synthetique : 2 amas gaussiens anisotropes chevauchants (classe binaire) ---
// EPIC #9975 grain A (Prong B) : centres rapproches + anisotropie croisee pour un paysage
// de CV rugueux ou les optimiseurs structurees (Bayes/GA/PSO) se distinguent de Grid/Random.
const int N = 200;
var X = new double[N][];
var y = new int[N];
RngReset(20240718u);   // graine fixee -> reproductibilite bit-par-bit
for (int i = 0; i < N; i++) {
    int cls = (i < N / 2) ? 0 : 1;
    double cx = (cls == 0) ? 2.0 : 3.0;          // centres rapproches -> chevauchement important
    double cy = (cls == 0) ? 2.0 : 3.0;
    double sx = (cls == 0) ? 1.4 : 1.0;          // anisotropie croisee entre classes
    double sy = (cls == 0) ? 1.0 : 1.4;
    X[i] = new double[] { cx + sx * Gauss(), cy + sy * Gauss() };
    y[i] = cls;
}
// Melange (Fisher-Yates) avec une graine dediee : brise l'equilibre parfait par-fold
// du decoupage i%folds (sans melange, les indices 0..99 sont tous classe 0 -> folds
// parfaitement equilibres -> paysage lisse -> tous les optimiseurs convergent).
RngReset(7777u);
for (int i = N - 1; i > 0; i--) {
    int j = (int)(U() * (i + 1)); if (j > i) j = i;
    var tmpX = X[i]; X[i] = X[j]; X[j] = tmpX;
    int tmpY = y[i]; y[i] = y[j]; y[j] = tmpY;
}

Show($"Jeu de donnees : {N} points, 2 classes, 2 features.");
Show($"Classe 0 : {y.Count(v => v == 0)} points | Classe 1 : {y.Count(v => v == 1)} points.");
Show($"Plage X1 : [{X.Select(p => p[0]).Min():F2}, {X.Select(p => p[0]).Max():F2}]  X2 : [{X.Select(p => p[1]).Min():F2}, {X.Select(p => p[1]).Max():F2}]");
Jeu de donnees : 200 points, 2 classes, 2 features.
Classe 0 : 100 points | Classe 1 : 100 points.
Plage X1 : [-2,35, 6,09]  X2 : [-1,05, 6,66]

2. Modèle cible : k-NN from-scratch et objectif (CV 5-fold)

Le k-NN est le modèle le plus simple a implementer from-scratch et suffit a définir un objectif reel (precision de classification) que les cinq optimiseurs chercheront a maximiser. On implemente : - une distance de Minkowski generalisee (|dx|^p + |dy|^p)^(1/p) ; - un vote a k voisins, blend uniforme / pondere par l’inverse de la distance ; - une validation croisee 5-fold (decoupe indexee i % folds) qui renvoie la precision moyenne - c’est notre Objective(p). Le melange prealable des donnees (section 1) fait que cette decoupe deterministe opere sur des indices deja randomises : chaque fold contient un melange des deux classes, ce qui produit la variance inter-fold necessaire a un paysage rugueux.

L’objective est expose comme un delegue Func<double[], double> capture les données : les cinq optimiseurs (tous static) le recoivent en paramètre (pattern des jumeaux C# - méthodes statiques pures, etat passe en argument, cf App-13b-TSP).

// --- k-NN from-scratch + objectif (CV 5-fold) ---
static double Minkowski(double[] a, double[] b, double p) {
    double s = 0;
    for (int i = 0; i < a.Length; i++) { double d = Math.Abs(a[i] - b[i]); s += Math.Pow(d, p); }
    return Math.Pow(s, 1.0 / p);
}

// precision 5-fold d'un k-NN parametre (X,y passes en param -> methode static pure)
static double KnnCvAccuracy(double[][] X, int[] y, int k, double distPow, double weightBlend, int folds = 5) {
    int n = X.Length;
    int correct = 0;
    for (int f = 0; f < folds; f++) {
        for (int i = 0; i < n; i++) {
            if (i % folds != f) continue;                          // point de test du fold f
            var dists = new List<(double d, int lbl)>();
            for (int j = 0; j < n; j++) { if (j % folds == f) continue; dists.Add((Minkowski(X[i], X[j], distPow), y[j])); }
            dists.Sort((a, b) => a.d.CompareTo(b.d));
            int kk = Math.Min(k, dists.Count);
            double w0 = 0, w1 = 0;
            for (int t = 0; t < kk; t++) {
                double wu = 1.0;                                    // vote uniforme
                double wd = 1.0 / (dists[t].d + 1e-9);              // vote pondere
                double w  = (1.0 - weightBlend) * wu + weightBlend * wd;
                if (dists[t].lbl == 0) w0 += w; else w1 += w;
            }
            int pred = (w1 > w0) ? 1 : 0;
            if (pred == y[i]) correct++;
        }
    }
    return (double)correct / n;
}

// objectif : projetter [0,1]^3 -> (k impair, distPow, weightBlend) puis CV.
// Delegue capturant les donnees X,y (pattern jumeaux C#).
Func<double[], double> Objective = p => {
    int k = (int)Math.Round(1 + p[0] * 48);
    k = Math.Clamp(k, 1, 50);
    if (k % 2 == 0) k = (k < 50) ? k + 1 : k - 1;                  // voisinage impair (evite les ties)
    double distPow     = 1.0 + p[1] * 3.0;                         // [1, 4]
    double weightBlend = p[2];                                     // [0, 1]
    return KnnCvAccuracy(X, y, k, distPow, weightBlend);
};

// test sanite : configurations extremes
Show($"Objectif [k~11, p=2 Euclidien, blend=0]    = {FI(Objective(new[] { 0.20, 0.33, 0.0 }))}");
Show($"Objectif [k~11, p=2 Euclidien, blend=1]    = {FI(Objective(new[] { 0.20, 0.33, 1.0 }))}");
Show($"Objectif [k~1,  p=1 Manhattan,  blend=0.5] = {FI(Objective(new[] { 0.00, 0.00, 0.5 }))}");
Objectif [k~11, p=2 Euclidien, blend=0]    = 0.7150
Objectif [k~11, p=2 Euclidien, blend=1]    = 0.7200
Objectif [k~1,  p=1 Manhattan,  blend=0.5] = 0.6600

5. Vedette - Bayesian Optimization (Gaussian Process + Expected Improvement)

Principe : modeliser la fonction-objectif couteuse par un surrogate probabiliste - un Gaussian Process a noyau RBF - qui donne, en tout point, une prediction (moyenne mu) ET une incertitude (sigma). On choisit le prochain point a evaluer en maximisant une fonction d’acquisition :

  • Expected Improvement (EI) : esperance de l’amelioration par rapport au meilleur observe. Equilibre exploitation (mu eleve) et exploration (sigma eleve).

C’est la cle de l’efficacite-echantillons : la ou Grid/Random gaspillent des evals dans des regions sans intérêt, le GP oriente le budget vers les regions prometteuses ou incertaines.

Implementation from-scratch

  • Noyau RBF : k(x,x') = sigma^2 * exp(-||x-x'||^2 / (2*l^2)).
  • Fit : resoudre (K + sigma_n^2 I) alpha = y (Gauss-Jordan, n petit <= 30).
  • Predict : mu = k_star . alpha ; sig^2 = sigma^2 - k_star . (K^{-1} k_star).
  • EI : (mu - f_best - xi) * Phi(Z) + sig * phi(Z) avec Z = (mu - f_best - xi)/sig.

Tout le GP est encapsule en fonctions locales capturant l’etat (listes gpX, gpy, hyperparams) - aucun champ static, entièrement self-contained.

// === Bayesian Optimization : Gaussian Process (RBF) + Expected Improvement ===
// helpers loi normale (purs, static)
static double phi(double z) => Math.Exp(-0.5 * z * z) / Math.Sqrt(2 * Math.PI);
static double Erf(double x) {
    double t = 1.0 / (1.0 + 0.3275911 * Math.Abs(x));
    double poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))));
    double ans = 1 - poly * Math.Exp(-x * x);
    return x >= 0 ? ans : -ans;
}
static double Phi(double z) => 0.5 * (1 + Erf(z / Math.Sqrt(2)));

// resoud Ax=b par Gauss-Jordan (n petit)
static double[] SolveLin(double[,] A, double[] b) {
    int n = b.Length;
    var M = new double[n, n + 1];
    for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) M[i, j] = A[i, j]; M[i, n] = b[i]; }
    for (int col = 0; col < n; col++) {
        int piv = col; for (int r = col + 1; r < n; r++) if (Math.Abs(M[r, col]) > Math.Abs(M[piv, col])) piv = r;
        for (int j = 0; j <= n; j++) (M[col, j], M[piv, j]) = (M[piv, j], M[col, j]);
        if (Math.Abs(M[col, col]) < 1e-12) M[col, col] += 1e-9;
        for (int r = 0; r < n; r++) if (r != col) {
            double fct = M[r, col] / M[col, col];
            for (int j = col; j <= n; j++) M[r, j] -= fct * M[col, j];
        }
    }
    var x = new double[n]; for (int i = 0; i < n; i++) x[i] = M[i, n] / M[i, i]; return x;
}

// L'optimiseur Bayesien encapsule le GP en fonctions locales (capture gpX/gpy/hyperparams) -> self-contained.
static (double[] best, double bestVal, List<double> hist) BayesOpt(Func<double[], double> obj, int nInit, int nIter, uint seed, int nCand = 400) {
    RngReset(seed);
    double gpl = 0.35, gpsig = 1.0, gpnoise = 0.005;
    var gpX = new List<double[]>(); var gpy = new List<double>();
    double[,] gpK = null; double[] gpAlpha = null;

    double Rbf(double[] a, double[] b) { double s = 0; for (int i = 0; i < a.Length; i++) { double d = a[i] - b[i]; s += d * d; } return gpsig * gpsig * Math.Exp(-s / (2.0 * gpl * gpl)); }
    void GpFit() {
        int n = gpy.Count;
        gpK = new double[n, n];
        for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) gpK[i, j] = Rbf(gpX[i], gpX[j]) + (i == j ? gpnoise : 0);
        gpAlpha = SolveLin(gpK, gpy.ToArray());
    }
    (double mu, double sig) GpPredict(double[] x) {
        int n = gpy.Count;
        var kstar = new double[n]; for (int i = 0; i < n; i++) kstar[i] = Rbf(x, gpX[i]);
        double mu = 0; for (int i = 0; i < n; i++) mu += kstar[i] * gpAlpha[i];
        var z = SolveLin(gpK, kstar);
        double dot = 0; for (int i = 0; i < n; i++) dot += kstar[i] * z[i];
        return (mu, Math.Sqrt(Math.Max(gpsig * gpsig - dot, 1e-10)));
    }
    double EI(double[] x, double fbest, double xi = 0.0) {
        var (mu, sig) = GpPredict(x);
        if (sig < 1e-6) return 0;
        double Z = (mu - fbest - xi) / sig;
        return (mu - fbest - xi) * Phi(Z) + sig * phi(Z);
    }

    double[] best = null; double bestVal = double.NegativeInfinity;
    var hist = new List<double>();
    void Eval(double[] p) { double v = obj(p); gpX.Add((double[])p.Clone()); gpy.Add(v); if (v > bestVal) { bestVal = v; best = (double[])p.Clone(); } hist.Add(bestVal); }

    for (int i = 0; i < nInit; i++) Eval(new[] { U(), U(), U() });
    for (int it = 0; it < nIter; it++) {
        GpFit();
        double[] nxt = null; double bestEI = double.NegativeInfinity;
        for (int c = 0; c < nCand; c++) { double[] cand = { U(), U(), U() }; double ei = EI(cand, bestVal); if (ei > bestEI) { bestEI = ei; nxt = cand; } }
        if (nxt == null) nxt = new[] { U(), U(), U() };
        Eval(nxt);
    }
    return (best, bestVal, hist);
}

var bayesRes = BayesOpt(Objective, nInit: 5, nIter: 25, seed: 7u);   // 30 evals au total
double[] bayesBest = bayesRes.best; double bayesVal = bayesRes.bestVal; List<double> bayesHist = bayesRes.hist;
Show($"Bayesian Optimization (5+25 = 30 evals) : best accuracy = {FI(bayesVal)}");
Show($"  -> p = [{FI(bayesBest[0], "F3")}, {FI(bayesBest[1], "F3")}, {FI(bayesBest[2], "F3")}]");
Bayesian Optimization (5+25 = 30 evals) : best accuracy = 0.7700
  -> p = [0.692, 0.617, 0.719]

Exercice 2 : Fonction d’acquisition UCB (Upper Confidence Bound)

Le BayesianOptimizer ci-dessus utilise Expected Improvement. Implementer l’acquisition UCB complementaire, plus exploration-agressive :

  • Étape 1 : UCB(x) = mu(x) + kappa * sig(x) (favorise l’incertitude pour kappa grand).
  • Étape 2 : comparer la trajectoire de convergence EI vs UCB sur le même budget.
// Exercice 2 : acquisition UCB (Upper Confidence Bound)
// Etape 1 : UCB(x) = mu(x) + kappa * sig(x)  (mu et sig via GpPredict).
// Etape 2 : comparer la trajectoire EI vs UCB sur le meme budget.
Show("Exercice 2 a completer - UCB(x, kappa) = mu(x) + kappa*sig(x).");
Exercice 2 a completer - UCB(x, kappa) = mu(x) + kappa*sig(x).

6. Algorithme Génétique (GA)

Principe : faire evoluer une population de configurations via sélection (tournoi), croisement (arithmetique), mutation (gaussienne decroissante) et elitisme. Particulierement adapte aux espaces mixtes/discrets - ici l’espace continu [0,1]^3 projete.

// === Genetic Algorithm ===
static (double[] best, double bestVal, List<double> hist) GeneticOpt(Func<double[], double> obj, int popSize, int nGen, uint seed) {
    RngReset(seed);
    var pop = new List<double[]>(); var fit = new List<double>();
    double[] best = null; double bestVal = double.NegativeInfinity;
    var hist = new List<double>();
    for (int i = 0; i < popSize; i++) {                                  // init : 1 eval / individu
        var p = new[] { U(), U(), U() }; double v = obj(p);
        pop.Add(p); fit.Add(v);
        if (v > bestVal) { bestVal = v; best = (double[])p.Clone(); }
        hist.Add(bestVal);
    }
    for (int g = 0; g < nGen; g++) {
        var newPop = new List<double[]>(); var newFit = new List<double>();
        int eIdx = 0; for (int i = 1; i < pop.Count; i++) if (fit[i] > fit[eIdx]) eIdx = i;   // elitisme top 1 (non re-evalue)
        newPop.Add((double[])pop[eIdx].Clone()); newFit.Add(fit[eIdx]);
        int T() { int a = Math.Min((int)(U() * pop.Count), pop.Count - 1), b = Math.Min((int)(U() * pop.Count), pop.Count - 1), c = Math.Min((int)(U() * pop.Count), pop.Count - 1); return (fit[a] >= fit[b] && fit[a] >= fit[c]) ? a : (fit[b] >= fit[c] ? b : c); }
        while (newPop.Count < popSize) {                                  // 1 eval / enfant
            var pa = pop[T()]; var pb = pop[T()];
            double alpha = U();
            var child = new double[3];
            for (int d = 0; d < 3; d++) { child[d] = alpha * pa[d] + (1 - alpha) * pb[d]; child[d] += 0.1 * Gauss() * Math.Max(0, 1.0 - g / (double)nGen); child[d] = Math.Clamp(child[d], 0, 1); }
            double cv = obj(child); newPop.Add(child); newFit.Add(cv);
            if (cv > bestVal) { bestVal = cv; best = (double[])child.Clone(); }
            hist.Add(bestVal);
        }
        pop = newPop; fit = newFit;
    }
    return (best, bestVal, hist);   // hist.Count = popSize + nGen*(popSize-1) = nombre d'evals reel
}

var gaRes = GeneticOpt(Objective, popSize: 15, nGen: 4, seed: 42u);   // 15 + 4*14 = 71 evals
double[] gaBest = gaRes.best; double gaVal = gaRes.bestVal; List<double> gaHist = gaRes.hist;
Show($"Genetic Algorithm (71 evals) : best accuracy = {FI(gaVal)}");
Show($"  -> p = [{FI(gaBest[0], "F3")}, {FI(gaBest[1], "F3")}, {FI(gaBest[2], "F3")}]");
Genetic Algorithm (71 evals) : best accuracy = 0.7750
  -> p = [0.684, 0.854, 0.449]

Exercice 3 : Diversite d’une population génétique

Une population trop homogene signifie que l’algorithme a converge prematurement. Implementer PopulationDiversity(pop) :

  • Étape 1 : calculer le barycentre de la population.
  • Étape 2 : sommer les distances moyennes au barycentre (mesure de dispersion).
  • Étape 3 : surveiller cette diversite au fil des generations.
// Exercice 3 : Diversite d'une population genetique
// Etape 1 : barycentre de la population.
// Etape 2 : somme des distances moyennes au barycentre (dispersion).
// Etape 3 : surveiller la diversite au fil des generations.
Show("Exercice 3 a completer - PopulationDiversity(pop).");
Exercice 3 a completer - PopulationDiversity(pop).

7. Particle Swarm Optimization (PSO)

Principe : une nuee de particules ou chacune ajuste sa trajectoire selon (a) sa propre meilleure position (cognitive) et (b) la meilleure globale (sociale), avec une inertie decroissante pour passer d’exploration a exploitation.

// === Particle Swarm Optimization ===
static (double[] best, double bestVal, List<double> hist) PsoOpt(Func<double[], double> obj, int nParticles, int nIter, uint seed) {
    RngReset(seed);
    var pos = new List<double[]>(); var vel = new List<double[]>();
    var pbest = new List<double[]>(); var pbestVal = new List<double>();
    double[] gbest = null; double gbestVal = double.NegativeInfinity;
    var hist = new List<double>();
    for (int i = 0; i < nParticles; i++) {                                // init : 1 eval / particule
        var p = new[] { U(), U(), U() }; pos.Add(p); vel.Add(new[] { 0.0, 0.0, 0.0 });
        double v = obj(p); pbest.Add((double[])p.Clone()); pbestVal.Add(v);
        if (v > gbestVal) { gbestVal = v; gbest = (double[])p.Clone(); }
        hist.Add(gbestVal);
    }
    for (int it = 0; it < nIter; it++) {
        double w = 0.9 - 0.5 * ((double)it / nIter);   // inertie decroissante
        for (int i = 0; i < nParticles; i++) {
            for (int d = 0; d < 3; d++) {
                double r1 = U(), r2 = U();
                vel[i][d] = w * vel[i][d] + 1.5 * r1 * (pbest[i][d] - pos[i][d]) + 1.5 * r2 * (gbest[d] - pos[i][d]);
                vel[i][d] = Math.Clamp(vel[i][d], -0.3, 0.3);
                pos[i][d] = Math.Clamp(pos[i][d] + vel[i][d], 0, 1);
            }
            double v = obj(pos[i]);                                       // 1 eval / particule / iteration
            if (v > pbestVal[i]) { pbestVal[i] = v; pbest[i] = (double[])pos[i].Clone(); }
            if (v > gbestVal) { gbestVal = v; gbest = (double[])pos[i].Clone(); }
            hist.Add(gbestVal);
        }
    }
    return (gbest, gbestVal, hist);   // hist.Count = nParticles + nIter*nParticles = nombre d'evals reel
}

var psoRes = PsoOpt(Objective, nParticles: 12, nIter: 5, seed: 9u);   // 12 + 12*5 = 72 evals
double[] psoBest = psoRes.best; double psoVal = psoRes.bestVal; List<double> psoHist = psoRes.hist;
Show($"PSO (72 evals) : best accuracy = {FI(psoVal)}");
Show($"  -> p = [{FI(psoBest[0], "F3")}, {FI(psoBest[1], "F3")}, {FI(psoBest[2], "F3")}]");
PSO (72 evals) : best accuracy = 0.7750
  -> p = [0.717, 0.926, 0.491]

8. Comparaison des méthodes

On compare les courbes de convergence (meilleure precision observee en fonction du nombre d’evaluations) et le tableau recapitulatif.

Lecture attendue : les trois méthodes informées (Bayes 0.7700, GA 0.7750, PSO 0.7750) se détachent des deux baselines (Grid 0.7650, Random 0.7600) - c’est l’effet cherché, rendu visible par l’anisotropie croisée des amas (cellule 1) qui brise la symétrie qui lissait le paysage. La Bayesian Optimization dépasse le plancher Grid (0.7700 vs 0.7650) avec ~4x moins d’evaluations (30 vs 125) - c’est sa propriete-cle, l’efficacite-echantillons. L’écart entre méthodes informées et baselines se creuse encore quand le cout par evaluation et la dimension augmentent - exactement le regime du tuning d’un gros modèle (RandomForest, XGBoost, reseaux de neurones), cas d’usage canonique d’Optuna/Ax/scikit-optimize.

// === Comparaison : courbes de convergence + tableau recapitulatif ===
static string Sparkline(List<double> hist, int width = 30) {
    double lo = hist.Min(), hi = hist.Max();
    var sb = new System.Text.StringBuilder();
    string bars = " ▁▂▃▄▅▆▇█";
    for (int i = 0; i < width; i++) {
        int idx = (int)((long)i * (hist.Count - 1) / Math.Max(1, width - 1));
        double frac = (hi > lo) ? (hist[idx] - lo) / (hi - lo) : 0.5;
        sb.Append(bars[(int)Math.Round(frac * 7) + 1]);
    }
    return sb.ToString();
}

var methods = new[] { ("Grid", gridHist), ("Random", randHist), ("Bayes", bayesHist), ("GA", gaHist), ("PSO", psoHist) };
Show("Courbes de convergence (meilleure precision vs evals, ASCII sparkline) :");
foreach (var (name, h) in methods)
    Show($"  {name,-7} | best={FI(h.Last())} | evals={h.Count,-3} | {Sparkline(h)}");

Show("");
Show("Tableau recapitulatif :");
Show($"  {"Methode",-8} | {"Evals",-6} | {"Best accuracy",-14} | Gap vs Grid");
foreach (var (name, h) in methods) {
    double gap = gridVal - h.Last();
    Show($"  {name,-8} | {h.Count,-6} | {FI(h.Last()),-14} | {(gap >= 0 ? "-" : "+")}{FI(Math.Abs(gap), "F4")}");
}
Show($"  (Grid = plancher de reference, best = {FI(gridVal)})");
Courbes de convergence (meilleure precision vs evals, ASCII sparkline) :
  Grid    | best=0.7650 | evals=125 | ▁▁▁▁▁▁▆▆▆▆▆▆▇▇▇███████████████
  Random  | best=0.7600 | evals=60  | ▁▁▁▁▁▅▅▅▅█████████████████████
  Bayes   | best=0.7700 | evals=30  | ▁▁▁▁▁▁▁▁▁▁▁███████████████████
  GA      | best=0.7750 | evals=71  | ▁▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇███████
  PSO     | best=0.7750 | evals=72  | ▁▃▃▅▅▅▅▅▅▅▅▇▇▇▇▇▇▇▇▇▇▇▇▇▇█████
Tableau recapitulatif :
  Methode  | Evals  | Best accuracy  | Gap vs Grid
  Grid     | 125    | 0.7650         | -0.0000
  Random   | 60     | 0.7600         | -0.0050
  Bayes    | 30     | 0.7700         | +0.0050
  GA       | 71     | 0.7750         | +0.0100
  PSO      | 72     | 0.7750         | +0.0100
  (Grid = plancher de reference, best = 0.7650)

9. Visualisation de l’espace de recherche

On visualise l’objectif (precision CV) sur une tranche 2D (k, distancePower) avec weightBlend fixe au meilleur trouve. Rendu ASCII (matplotlib non disponible nativement en .NET BCL ; convention des jumeaux C# - cf App-13b).

// === Visualisation : carte de chaleur ASCII de l'objectif (tranche k x distPow) ===
// weightBlend fixe au meilleur trouve (bayesBest), tranche 2D sur (k, distancePower)
double wbFix = bayesBest[2];
int rows = 12, cols = 30;
var gv = new double[rows, cols];
double gMin = double.PositiveInfinity, gMax = double.NegativeInfinity;
for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) {
    double pk = (double)(rows - 1 - r) / (rows - 1);          // k : haut=1, bas=50
    double pp = (double)c / (cols - 1);                        // distPow : 1 -> 4
    double v = Objective(new[] { pk, pp, wbFix });
    gv[r, c] = v; if (v < gMin) gMin = v; if (v > gMax) gMax = v;
}
string ramp = " .:-=+*#%@";
Show($"Carte de chaleur de l'objectif (weightBlend={FI(wbFix, "F2")}) : k (vertical, 1->50) x distPow (1->4)");
Show($"  echelle : {FI(gMin)} (vide) -> {FI(gMax)} (@)");
for (int r = 0; r < rows; r++) {
    var line = new System.Text.StringBuilder();
    for (int c = 0; c < cols; c++) {
        double frac = (gMax > gMin) ? (gv[r, c] - gMin) / (gMax - gMin) : 0.5;
        line.Append(ramp[(int)Math.Round(frac * 9)]);
    }
    Show(line.ToString());
}
Show("Regions @ = haute precision (objectif maximise). Les methodes adapatives (Bayes/GA/PSO) y convergent plus vite que Grid.");
Carte de chaleur de l'objectif (weightBlend=0.72) : k (vertical, 1->50) x distPow (1->4)
  echelle : 0.6500 (vide) -> 0.7600 (@)
%%@@%%%%%%%%#%%%%%#%%%%%%%%###
%@@@%%%%%%%%%%%%%%%%%%%%%%####
%@@@@@@%%%%%%%%%%%%%%%%%%%#%%%
####%%%%%%@%%%%%%%%%%%%%%####%
%%%%###%%%%%%%@@@@%%%%%%%%%%%%
###%###%%%#%%%%%%%############
#################***++++++++++
####*****+++++++++++++++++++++
#%%###%#%%%%###*****+++*++++++
##*****+*******++*********####
##***********+***************#
.. ......                     
Regions @ = haute precision (objectif maximise). Les methodes adapatives (Bayes/GA/PSO) y convergent plus vite que Grid.

9bis. Parite lib-vs-lib : ML.NET AutoML branche un moteur de production

Le jumeau Python (App-18) optimise avec sklearn (GridSearchCV / RandomSearchCV) et Optuna (Bayesian) : deux librairies de reference. Jusqu’ici, ce jumeau C# reimplementait tout from-scratch (BCL pure) — pedagogique, mais sans moteur de production. Cette tranche 2 (parite lib-vs-lib, epic #10382) branche Microsoft.ML.AutoML, le moteur HPO de production de ML.NET, sur le meme jeu de donnees X,y (cellule 2), avec une decoupe train/test deterministe : la comparaison avec les 5 methodes from-scratch est pomme-pomme.

// === Tranche 2 : bridge lib-vs-lib vers ML.NET AutoML (moteur de production) ===

#r "nuget: Microsoft.ML, 5.0.0"
#r "nuget: Microsoft.ML.AutoML, 0.23.0"
#r "nuget: Microsoft.Data.Analysis, 0.23.0"

using System;
using System.Linq;
using System.Collections.Generic;
using Microsoft.ML;
using Microsoft.ML.AutoML;
using Microsoft.ML.Data;
using Microsoft.Data.Analysis;

// RowIndex permet de tracer les lignes du split AutoML vers les indices X,y d'origine.
public class RowInfo { public int RowIndex = 0; }

var df = new DataFrame();
df["RowIndex"] = DataFrameColumn.Create("RowIndex", Enumerable.Range(0, N).ToArray());
df["X1"] = DataFrameColumn.Create("X1", X.Select(pt => (float)pt[0]).ToArray());
df["X2"] = DataFrameColumn.Create("X2", X.Select(pt => (float)pt[1]).ToArray());
df["Label"] = DataFrameColumn.Create("Label", y.Select(v => (bool)(v == 1)).ToArray());

var mlContext = new MLContext(seed: 1);
var split = mlContext.Data.TrainTestSplit(df, testFraction: 0.2, seed: 424242);
var trainIdx = mlContext.Data.CreateEnumerable<RowInfo>(split.TrainSet, reuseRowObject: false).Select(r => r.RowIndex).OrderBy(i => i).ToArray();
var testIdx  = mlContext.Data.CreateEnumerable<RowInfo>(split.TestSet,  reuseRowObject: false).Select(r => r.RowIndex).OrderBy(i => i).ToArray();
Show($"Split deterministe (TrainTestSplit, seed 424242) : {trainIdx.Length} entrainement / {testIdx.Length} test.");

// API haut niveau : CreateBinaryClassificationExperiment balaie la famille d'entraineurs
// binaires (FastTree, LightGBM, SDCA, reg. logistique, perceptron...) + leurs hyperparametres.
var colInfo = new ColumnInformation();
colInfo.IgnoredColumnNames.Add("RowIndex");
var experiment = mlContext.Auto().CreateBinaryClassificationExperiment(30);
var result = experiment.Execute(split.TrainSet, split.TestSet, colInfo);

// Re-evaluation finale deterministe sur NOTRE split de test (40 pts)
var finalModel = result.BestRun.Model;
var evalDv = finalModel.Transform(split.TestSet);
var binMetric = mlContext.BinaryClassification.Evaluate(evalDv, labelColumnName: "Label");

Show($"AutoML (30 s) : {result.RunDetails.Count()} essais, meilleur entraineur = {result.BestRun.TrainerName}");
Show($"Accuracy sur test ({testIdx.Length} pts) : {FI(binMetric.Accuracy)}   AUC : {FI(binMetric.AreaUnderRocCurve)}");
Show("Top-3 entraineurs (accuracy sur validation) :");
foreach (var r in result.RunDetails.Where(r => r.ValidationMetrics != null).OrderByDescending(r => r.ValidationMetrics.Accuracy).Take(3))
    Show($"  {r.TrainerName} : {FI(r.ValidationMetrics.Accuracy)}");
Installed Packages
  • Microsoft.Data.Analysis, 0.23.0
  • Microsoft.ML, 5.0.0
  • Microsoft.ML.AutoML, 0.23.0
Split deterministe (TrainTestSplit, seed 424242) : 165 entrainement / 35 test.
AutoML (30 s) : 405 essais, meilleur entraineur = ReplaceMissingValues=>Concatenate=>SdcaLogisticRegressionBinary
Accuracy sur test (35 pts) : 0.7714   AUC : 0.7467
Top-3 entraineurs (accuracy sur validation) :
  ReplaceMissingValues=>Concatenate=>SdcaLogisticRegressionBinary : 0.7714
  ReplaceMissingValues=>Concatenate=>SdcaLogisticRegressionBinary : 0.7714
  ReplaceMissingValues=>Concatenate=>SdcaLogisticRegressionBinary : 0.7714
// === Comparaison pomme-pomme : meilleure config from-scratch vs AutoML, MEME test split ===
static double KnnTestAccuracy(double[][] X, int[] y, int k, double distPow, double weightBlend, int[] trainIdx, int[] testIdx)
{
    int correct = 0;
    foreach (int i in testIdx)
    {
        var dists = new List<(double d, int lbl)>();
        foreach (int j in trainIdx) dists.Add((Minkowski(X[i], X[j], distPow), y[j]));
        dists.Sort((a, b) => a.d.CompareTo(b.d));
        int kk = Math.Min(k, dists.Count);
        double w0 = 0, w1 = 0;
        for (int t = 0; t < kk; t++)
        {
            double wu = 1.0, wd = 1.0 / (dists[t].d + 1e-9);
            double w = (1.0 - weightBlend) * wu + weightBlend * wd;
            if (dists[t].lbl == 0) w0 += w; else w1 += w;
        }
        if ((w1 > w0 ? 1 : 0) == y[i]) correct++;
    }
    return (double)correct / testIdx.Length;
}

// Meilleure configuration from-scratch (Grid, cellule 6) decodee en (k, distPow, blend)
int fsK = (int)Math.Round(1 + gridBest[0] * 48); fsK = Math.Clamp(fsK, 1, 50); if (fsK % 2 == 0) fsK = (fsK < 50) ? fsK + 1 : fsK - 1;
double fsDP = 1.0 + gridBest[1] * 3.0, fsWB = gridBest[2];
double fsTestAcc = KnnTestAccuracy(X, y, fsK, fsDP, fsWB, trainIdx, testIdx);
double fsCvBest = new[] { gridVal, randVal, bayesVal, gaVal, psoVal }.Max();

Show("Comparaison sur le MEME split de test (" + testIdx.Length + " pts) :");
Show($"  from-scratch k-NN (config Grid : k={fsK}, distPow={FI(fsDP,"F3")}, blend={FI(fsWB,"F3")}) = {FI(fsTestAcc)}");
Show($"  ML.NET AutoML (30 s, meilleur entraineur)                        = {FI(binMetric.Accuracy)}");
Show($"  from-scratch CV 5-fold (tout le jeu, rappel cellule 22)          = {FI(fsCvBest)}");
string winner = (binMetric.Accuracy >= fsTestAcc) ? "ML.NET AutoML" : "from-scratch k-NN";
Show($"  => {winner} devant sur ce split (ecart {FI(Math.Abs(binMetric.Accuracy - fsTestAcc), "F4")}).");
Comparaison sur le MEME split de test (35 pts) :
  from-scratch k-NN (config Grid : k=49, distPow=1.000, blend=0.000) = 0.7143
  ML.NET AutoML (30 s, meilleur entraineur)                        = 0.7714
  from-scratch CV 5-fold (tout le jeu, rappel cellule 22)          = 0.7750
  => ML.NET AutoML devant sur ce split (ecart 0.0571).

Lecture du resultat : le moteur de production prend la tete sur ce split

Avec un budget de 30 secondes (contre 60-125 evaluations pour les methodes from-scratch), ML.NET AutoML balaie une famille d’entraineurs binaires (FastTree, LightGBM, SDCA, regression logistique, perceptron) ET leurs hyperparametres : 405 essais sur le split de test (35 pts), et une accuracy 0.7714 contre 0.7143 pour la meilleure configuration k-NN from-scratch (Grid, cellule 6) — AutoML devant de 0.0571 sur le meme split. L’AUC 0.74 confirme un classement honnete, pas un sur-apprentissage du split. Deux enseignements :

  1. Parite lib-vs-lib atteinte : le jumeau C# dispose desormais du meme type de capacite que le jumeau Python (sklearn + Optuna) — un moteur de production qui cherche dans l’espace des modeles (SDCA, reg. logistique, arbres…), pas seulement des hyperparametres d’un modele fait main.
  2. Le from-scratch garde sa valeur pedagogique : implementer Grid/Bayes/GA/PSO a la main (cellules 6-20) montre le mecanisme ; AutoML le fait a l’echelle. La comparaison reste honnete : protocoles differents (CV 5-fold sur tout le jeu = 0.7750 en rappel vs split unique de 35 pts), budgets differents (evaluations vs secondes), variance de seed a garder en tete sur 35 points.

10. Recommandations et bonnes pratiques

Méthode Quand l’utiliser Cout Echantillons-efficacite
Grid Search Espace petit, dimensions connues-cles Eleve (exponentiel) Faible
Random Search Espace modere, budget limite Lineaire Moyenne
Bayesian Opt Evals couteuses, budget très limite Eleve par eval Elevee
GA Espace mixte/discret, parallelisme Modere Moyenne
PSO Espace continu, multi-modal Modere Moyenne

Lecons : 1. Bayesian Opt est le choix canon pour l’HPO moderne (Optuna, Ax, scikit-optimize reposent la-dessus). 2. Random Search est un plancher très solide - ne jamais faire pire sans raison. 3. La dimension effective du problème (combien d’hyperparametres comptent vraiment) dicte le choix.

Conclusion

Ce jumeau C# a reimplemente from-scratch les cinq méthodes d’optimisation d’hyperparametres du notebook Python App-18, en remplacant le RandomForest sklearn par un k-NN from-scratch (objectif = CV 5-fold). La Bayesian Optimization (GP RBF + Expected Improvement) est la valeur ajoutee centrale : elle dépasse le plancher Grid (0.7700 vs 0.7650) avec ~4x moins d’evaluations (30 vs 125), illustrant comment un surrogate probabiliste rend l’optimisation efficace en nombre d’evaluations - la propriete qui la rend standard pour le tuning de modèles couteux.

Sur ce problème 3D, les trois méthodes informées (Bayes/GA/PSO) dépassent les deux baselines (Grid/Random) - c’est la discrimination que l’anisotropie des amas rend visible ; l’avantage des méthodes informées se creuse quand la dimension et le cout par evaluation augmentent - d’ou leur statut de méthodes canoniques pour les gros modèles (Optuna, Ax, scikit-optimize).

Parite avec Search-11 : PSO et GA apparaissent dans les deux notebooks, mais dans des contextes différents - Search-11 optimisait des fonctions-benchmark analytiques peu couteuses (Sphere, Rastrigin) ; ici ils optimisent un objectif boite-noire couteux (CV d’un modèle), ou la Bayesian Optimization prend tout son sens.

Lien epic : #4956 (parite .NET / Python).

12. Exercices (recapitulatif)

  1. Multi-objectif : modifier l’objectif pour maximiser accuracy - lambda * complexity (penaliser les grands k).
  2. Acquisition UCB : comparer EI vs UCB (voir Exercice 2).
  3. Espace mixte : ajouter un hyperparametre categoriel (méthode de distance) et adapter le croisement GA.
Retour au sommet