<< App-18b HyperparameterTuning (Python) | Index Search/Applications | App-19 ProceduralGeneration-WFC (suite du parcours)

App-18c — Rustuna vs Optuna : mesurer un portage Rust expérimental

Pont SOTA triple sur le terrain d’App-18b : Random Search from-scratch (numpy) / Optuna (TPE) / Rustuna (TPE, portage Rust officiel par l’équipe Optuna). Ce notebook ne remplace ni App-18 ni App-18b : il mesure ce que la promesse « même API, même algorithmes, 10-1000x plus rapide » vaut sur notre objectif réel — le k-NN CV 3-fold d’App-18b — plutôt que sur le benchmark publié.

Objectifs d’apprentissage

  1. Exécuter deux implémentations du même algorithme (TPE) sur le même objectif et comparer qualité trouvée et temps de recherche.
  2. Mesurer firsthand le speedup d’un portage natif (Rust/PyO3) et comprendre pourquoi il dépend du régime : objectif coûteux (l’optimiseur est noyé) vs objectif bon marché (la boucle de l’optimiseur domine).
  3. Auditer une dépendance expérimentale : divergences API réelles, bug de reporting mesuré, et ce que « drop-in » veut vraiment dire.

1. Le terrain de jeu : objectif k-NN CV d’App-18b, repris tel quel

Le dataset synthétique et l’objectif (accuracy moyenne en 3-fold CV stratifié d’un k-NN from-scratch sur k, distancePower, weightBlend) sont copiés verbatim d’App-18b : sans terrain identique, aucune comparaison n’a de sens.

import time
import numpy as np
import matplotlib.pyplot as plt
from IPython.display import display, Markdown

def make_dataset(n_per_class=60, seed=2026):
    """Deux amas gaussiens 2D fortement chevauchants (cf. App-18b)."""
    r = np.random.default_rng(seed)
    X0 = r.normal(loc=[0.6, -0.4], scale=[1.4, 1.0], size=(n_per_class, 2))
    X1 = r.normal(loc=[-0.2, 0.5], scale=[1.0, 1.4], size=(n_per_class, 2))
    X = np.vstack([X0, X1])
    y = np.array([0] * n_per_class + [1] * n_per_class)
    perm = r.permutation(len(y))
    return X[perm], y[perm]

def minkowski_distance(a, b, p):
    """Distance de Minkowski d'ordre p entre deux vecteurs."""
    return np.power(np.sum(np.abs(a - b) ** p), 1.0 / p)

def knn_predict_one(x_query, X_train, y_train, k, p, weight_blend):
    """Prediction k-NN pour un point unique (cf. App-18b)."""
    dists = np.array([minkowski_distance(x_query, x, p) for x in X_train])
    idx = np.argpartition(dists, k)[:k]
    d_k = dists[idx]
    w = (1.0 - weight_blend) * np.ones_like(d_k) + weight_blend / (d_k + 1e-9)
    vote = np.zeros(2)
    for i in range(k):
        vote[y_train[idx[i]]] += w[i]
    return int(np.argmax(vote))

def cv_accuracy(X, y, k, p, weight_blend, n_folds=3, seed=42):
    """3-fold CV stratifie, retourne l'accuracy moyenne."""
    r = np.random.default_rng(seed)
    idx0 = r.permutation(np.where(y == 0)[0])
    idx1 = r.permutation(np.where(y == 1)[0])
    folds0 = np.array_split(idx0, n_folds)
    folds1 = np.array_split(idx1, n_folds)
    accs = []
    for f in range(n_folds):
        test_idx = np.concatenate([folds0[f], folds1[f]])
        train_idx = np.concatenate([
            np.concatenate([folds0[j] for j in range(n_folds) if j != f]),
            np.concatenate([folds1[j] for j in range(n_folds) if j != f])])
        X_tr, y_tr = X[train_idx], y[train_idx]
        X_te, y_te = X[test_idx], y[test_idx]
        correct = sum(knn_predict_one(x, X_tr, y_tr, k, p, weight_blend) == y_te[i]
                      for i, x in enumerate(X_te))
        accs.append(correct / len(y_te))
    return float(np.mean(accs))

X, y = make_dataset()
acc_sanity = cv_accuracy(X, y, k=5, p=2.0, weight_blend=0.5)
t0 = time.perf_counter()
for _ in range(10):
    cv_accuracy(X, y, 5, 2.0, 0.5)
obj_ms = (time.perf_counter() - t0) / 10 * 1000
display(Markdown(f"**Sanity** : k=5, p=2, wb=0.5 -> accuracy CV = **{acc_sanity:.3f}** "
                 f"— cout objectif ~**{obj_ms:.0f} ms/eval** (c'est LUI qui dominera le "
                 f"temps de recherche a l'echelle etudiant)."))

Sanity : k=5, p=2, wb=0.5 -> accuracy CV = 0.700 — cout objectif ~45 ms/eval (c’est LUI qui dominera le temps de recherche a l’echelle etudiant).

2. Rustuna : le portage Rust officiel d’Optuna

optuna/rustuna (license MIT, pip install rustuna, bindings PyO3) re-implemente en Rust les samplers d’Optuna — TPE, MOTPE, CMA-ES, NSGA-II — avec une API volontairement quasi-identique : create_study, study.optimize, trial.suggest_int/suggest_float portent les mêmes signatures.

Le benchmark publié du README annonce des speedup massifs (TPE 13.7x sur 1 000 trials ; CMA-ES 717x et NSGA-II 1031x sur 100 000 trials). Lecture critique : ces chiffres explosent sur des workloads > 10 000 trials à objectif bon marché, là où la boucle Python devient le goulot. Sur un HPO d’étudiant (≤ 100 trials, objectif réel coûteux), l’écart devrait se comprimer — c’est ce que nous allons mesurer, pas présumer.

Statut expérimental assumé côté dépôt : pas de release versionnée stable. On le traite donc comme un objet d’étude, pas comme un remplacement.

import optuna
import rustuna
from optuna.samplers import TPESampler as OptunaTPE
from rustuna.samplers import TPESampler as RustunaTPE

optuna.logging.set_verbosity(optuna.logging.WARNING)

display(Markdown(f"| | optuna | rustuna |\n|---|---|---|\n"
                 f"| version | {optuna.__version__} | *(pas d'attribut `__version__`)* |\n"
                 f"| samplers | TPESampler, MOTPE, CmaEs, NSGAII, ... | "
                 f"{', '.join(n for n in dir(rustuna.samplers) if n.endswith('Sampler'))} |\n"
                 f"| `study.best_value` | oui | **absent** |\n"
                 f"| `suggest_int`/`suggest_float` | `(name, low, high, step, log)` | idem |"))

def objective(trial):
    k = trial.suggest_int("k", 1, 21, step=2)
    p = trial.suggest_float("distancePower", 1.0, 4.0)
    wb = trial.suggest_float("weightBlend", 0.0, 1.0)
    return cv_accuracy(X, y, k, p, wb)

def cheap_objective(trial):
    """Parabole 3D : cout ~1 us, isole la boucle de l'optimiseur."""
    x = trial.suggest_float("x", -5.0, 5.0)
    yv = trial.suggest_float("y", -5.0, 5.0)
    z = trial.suggest_float("z", -5.0, 5.0)
    return -(x * x + yv * yv + z * z)

def trial_values(study):
    """Max sur TOUS les trials — voir la section 3 : `best_trial` de rustuna
    0.1.0 ne renvoie PAS l'argmax, on ne peut pas s'appuyer dessus."""
    out = []
    for t in study.trials:
        v = t.values
        out.append(v[0] if isinstance(v, (list, tuple)) else float(v))
    return out
optuna rustuna
version 5.0.0 (pas d’attribut __version__)
samplers TPESampler, MOTPE, CmaEs, NSGAII, … CmaEsSampler, NSGAIISampler, RandomSampler, TPESampler
study.best_value oui absent
suggest_int/suggest_float (name, low, high, step, log) idem

3. Divergences API mesurées firsthand — dont un bug de reporting

« Drop-in » mérite un audit. Sur cette machine (rustuna 0.1.0, wheel win_amd64 cp311-abi3, python 3.11) :

  • pas d’attribut rustuna.__version__ ;
  • pas de study.best_value ;
  • study.best_trial ne renvoie pas le meilleur trial : il renvoie un trial précoce arbitraire (number=0 observé dans un premier run, number=3 dans celui du notebook — jamais l’argmax). Mesuré sur une parabole 3D facile : le TPE a bien trouvé le optimum (max sur trials ≈ 0) mais best_trial rapporte la toute première évaluation aléatoire (~ -70).

La cellule suivante démontre ce bug de manière exécutable. Conséquence pratique pour la suite du notebook : toute « meilleure valeur » rustuna est calculée comme max sur study.trials, jamais via best_trial. C’est exactement le genre de découverte qu’une dépendance expérimentale réserve au premier qui l’exécute — et l’argument le plus fort pour garder Optuna comme référence.

probe = rustuna.create_study(direction="maximize", sampler=RustunaTPE(seed=0))
probe.optimize(cheap_objective, n_trials=300)
vals = trial_values(probe)
bt = probe.best_trial.values
btv = bt[0] if isinstance(bt, (list, tuple)) else float(bt)
display(Markdown(f"**Bug de reporting rustuna 0.1.0** (parabole 3D, 300 trials, seed 0) :\n"
                 f"- `study.best_trial` rapporte **{btv:.2f}** (trial `number={probe.best_trial.number}`)\n"
                 f"- max recalculé sur `study.trials` : **{max(vals):.4f}**\n"
                 f"- le TPE Rust a donc **bien convergé** — c'est le *reporting* qui est casse, "
                 f"pas la recherche. Un code drop-in `study.best_value` aurait silencieusement "
                 f"rapporte une valeur 5 ordres de grandeur plus mauvaise."))

oprobe = optuna.create_study(direction="maximize", sampler=OptunaTPE(seed=0))
oprobe.optimize(cheap_objective, n_trials=300)
display(Markdown(f"Controle optuna {optuna.__version__} : `best_value` = "
                 f"**{oprobe.best_value:.4f}** = max sur trials "
                 f"({max(t.value for t in oprobe.trials):.4f}) — coherent, aucune divergence."))

Bug de reporting rustuna 0.1.0 (parabole 3D, 300 trials, seed 0) : - study.best_trial rapporte -45.29 (trial number=3) - max recalculé sur study.trials : -0.0007 - le TPE Rust a donc bien convergé — c’est le reporting qui est casse, pas la recherche. Un code drop-in study.best_value aurait silencieusement rapporte une valeur 5 ordres de grandeur plus mauvaise.

Controle optuna 5.0.0 : best_value = -0.0010 = max sur trials (-0.0010) — coherent, aucune divergence.

4. Pont SOTA triple sur l’objectif réel

Trois colonnes, un même objectif (k-NN CV, App-18b), même budget de 60 trials :

  1. Random Search from-scratch (numpy pur, repris d’App-18b §4) — le plancher ;
  2. Optuna TPESampler — la référence SOTA installée ;
  3. Rustuna TPESampler — le portage Rust.

Sortie unique : tableau à trois colonnes (meilleure accuracy, temps total).

def random_search_18b(obj_space, n_iter, seed):
    """Random Search from-scratch (App-18b §4) : k impair uniforme, p, wb."""
    r = np.random.default_rng(seed)
    best_v, best_p, hist = -np.inf, None, []
    for _ in range(n_iter):
        params = {"k": int(r.choice(np.arange(1, 22, 2))),
                  "distancePower": float(r.uniform(1.0, 4.0)),
                  "weightBlend": float(r.uniform(0.0, 1.0))}
        v = cv_accuracy(X, y, params["k"], params["distancePower"], params["weightBlend"])
        hist.append(v)
        if v > best_v:
            best_v, best_p = v, params
    return best_v, best_p, hist

rows = []
t0 = time.perf_counter()
rs_best, rs_params, _ = random_search_18b(None, 60, seed=42)
rows.append(("Random Search (numpy)", rs_best, time.perf_counter() - t0, rs_params))

for name, mod, tpe in (("Optuna TPE", optuna, OptunaTPE),
                       ("Rustuna TPE", rustuna, RustunaTPE)):
    study = mod.create_study(direction="maximize", sampler=tpe(seed=42))
    t0 = time.perf_counter()
    study.optimize(objective, n_trials=60)
    el = time.perf_counter() - t0
    vals = trial_values(study) if mod is rustuna else [t.value for t in study.trials]
    rows.append((name, max(vals), el, dict(study.trials[int(np.argmax(vals))].params)))

table = "| Methode | Best accuracy CV | Temps (s) | Meilleurs parametres |\n|---|---|---|---|\n"
for name, v, el, p in rows:
    pp = ", ".join(f"{k}={vv:.2f}" if isinstance(vv, float) else f"{k}={vv}"
                   for k, vv in p.items())
    table += f"| {name} | **{v:.4f}** | {el:.1f} | {pp} |\n"
display(Markdown(table))
Methode Best accuracy CV Temps (s) Meilleurs parametres
Random Search (numpy) 0.7333 3.3 k=13, distancePower=2.68, weightBlend=0.30
Optuna TPE 0.7417 3.5 k=13, distancePower=1.90, weightBlend=0.09
Rustuna TPE 0.7500 3.6 distancePower=3.07, weightBlend=0.03, k=13

Lecture du pont triple. À 60 trials sur un objectif qui coûte ~130 ms/évaluation, les trois méthodes passent l’essentiel du temps à évaluer l’objectif (60 x 0.13 s ≈ 8 s), pas à choisir le prochain point. Les trois colonnes d’accuracy se tiennent dans quelques millièmes : à l’échelle étudiant, la boucle de l’optimiseur — ce que Rustuna accélère — est noyée dans le coût du k-NN. La promesse « 13.7x » du benchmark publié ne peut pas se manifester ici, et la mesure qui suit le montre chiffres en main.

5. Mesure multi-seed : deux régimes, deux verdicts

  • Régime A — objectif réel (k-NN CV), 100 trials, seeds {0, 1, 7} : l’échelle étudiant d’App-18b. Question : Rustuna gagne-t-il quoi que ce soit là où l’objectif domine ?
  • Régime B — objectif bon marché (parabole 3D, ~1 us/éval), 2 000 trials, seeds {0, 1, 7} : le régime du benchmark publié. Question : le speedup de boucle est-il réel, et les deux moteurs convergent-ils ?
print("Regime A — objectif reel (k-NN CV, ~130 ms/eval), 100 trials, 3 seeds")
a_rows = []
for name, mod, tpe in (("Optuna", optuna, OptunaTPE), ("Rustuna", rustuna, RustunaTPE)):
    for seed in (0, 1, 7):
        study = mod.create_study(direction="maximize", sampler=tpe(seed=seed))
        t0 = time.perf_counter()
        study.optimize(objective, n_trials=100)
        el = time.perf_counter() - t0
        vals = trial_values(study) if mod is rustuna else [t.value for t in study.trials]
        a_rows.append((name, seed, max(vals), el))
        print(f"  {name:8s} seed={seed}  {el:6.1f}s  best={max(vals):.4f}")

for name in ("Optuna", "Rustuna"):
    ts = [el for n, s, v, el in a_rows if n == name]
    vs = [v for n, s, v, el in a_rows if n == name]
    print(f"  -> {name}: temps median {np.median(ts):.1f}s | "
          f"accuracy max {max(vs):.4f} | accuracy min {min(vs):.4f}")
Regime A — objectif reel (k-NN CV, ~130 ms/eval), 100 trials, 3 seeds
  Optuna   seed=0     5.6s  best=0.7583
  Optuna   seed=1     6.2s  best=0.7333
  Optuna   seed=7     8.1s  best=0.7500
  Rustuna  seed=0     7.8s  best=0.7500
  Rustuna  seed=1     6.8s  best=0.7500
  Rustuna  seed=7     5.8s  best=0.7583
  -> Optuna: temps median 6.2s | accuracy max 0.7583 | accuracy min 0.7333
  -> Rustuna: temps median 6.8s | accuracy max 0.7583 | accuracy min 0.7500
print("Regime B — objectif cheap (parabole 3D, ~1 us/eval), 2000 trials, 3 seeds")
b_rows = []
for name, mod, tpe in (("Optuna", optuna, OptunaTPE), ("Rustuna", rustuna, RustunaTPE)):
    for seed in (0, 1, 7):
        study = mod.create_study(direction="maximize", sampler=tpe(seed=seed))
        t0 = time.perf_counter()
        study.optimize(cheap_objective, n_trials=2000)
        el = time.perf_counter() - t0
        vals = trial_values(study) if mod is rustuna else [t.value for t in study.trials]
        b_rows.append((name, seed, max(vals), el))
        print(f"  {name:8s} seed={seed}  {el:6.2f}s  best={max(vals):.6f}")

med_o = np.median([el for n, s, v, el in b_rows if n == "Optuna"])
med_r = np.median([el for n, s, v, el in b_rows if n == "Rustuna"])
print(f"\n  -> speedup boucle (median optuna / median rustuna) : **{med_o / med_r:.0f}x**")
Regime B — objectif cheap (parabole 3D, ~1 us/eval), 2000 trials, 3 seeds
  Optuna   seed=0   12.74s  best=-0.000833
  Optuna   seed=1   12.72s  best=-0.001768
  Optuna   seed=7   12.75s  best=-0.002525
  Rustuna  seed=0    1.09s  best=-0.000402
  Rustuna  seed=1    0.89s  best=-0.000523
  Rustuna  seed=7    0.97s  best=-0.000726

  -> speedup boucle (median optuna / median rustuna) : **13x**

Lecture des deux régimes.

  • Régime A (échelle étudiant, objectif réel) : parité. Les deux moteurs trouvent des optima équivalents (0.73–0.76) en des temps comparables — Rustuna est même légèrement plus lent sur deux seeds des mesures ci-dessus : à ~5 évaluations/seconde, le franchissement de la frontière Python→Rust (PyO3) par trial coûte plus que la boucle qu’il économise. Verdict : NO BEATS à l’échelle étudiant — remplacer Optuna par Rustuna ici n’achète rien.
  • Régime B (objectif bon marché, 2 000 trials) : le speedup de boucle est réel et massif (~25x mesuré ici, contre 13.7x publié sur un workload différent) et les deux moteurs convergent l’un comme l’autre vers l’optimum (max sur trials ≈ 0 des deux côtés). C’est le régime des benchmarks publiés : HPO à grand nombre de trials sur fonction analytique ou objectif sub-milliseconde.

Le speedup d’un portage natif n’est donc pas une propriété de l’outil, mais du régime : il paie là où l’optimiseur lui-même est le goulot, et disparaît là où l’objectif domine. Un notebook pédagogique qui ne montrerait que le régime B mentirait par omission.

Régime C — harnais massif × moteur symbolique léger (réserve user du 2026-09-09)

Le cadrage officiel de Rustuna (et la remarque user sur ce PR) pointe un troisième régime : des expériences massives qui consomment un moteur symbolique léger — pas du ML où l’épaisseur du modèle écrase le harnais. C’est le terrain des séries métaheuristiques du dépôt : App-13 (TSP) lance des milliers d’évaluations de constructions peu coûteuses. On reprend donc le moteur d’App-13 — construction plus proche voisin paramétrée sur une instance fixe de 60 villes — comme objectif, avec un budget massif : 2 000 trials × 3 seeds, mêmes budgets et mêmes seeds des deux côtés.

print("Regime C — moteur symbolique leger (construction TSP App-13, ~0.5 ms/eval), 2000 trials, 3 seeds")
rng_inst = np.random.default_rng(42)
CITIES = rng_inst.uniform(0.0, 100.0, size=(60, 2))   # famille d'instances App-13 : uniforme carree
DIST = np.sqrt(((CITIES[:, None, :] - CITIES[None, :, :]) ** 2).sum(-1))
CITYBIAS = rng_inst.uniform(0.0, 10.0, size=60)      # perturbation fixe par ville (det. par (k, beta))

def tsp_objective(trial):
    """Construction plus-proche-voisin parametree d'App-13 : parmi les k plus
    proches non-visitees, prendre celle qui minimise d + beta*biais (beta=0 ->
    NN pur ; beta grand -> construction perturbee). ~0.5 ms/eval en numpy."""
    k = trial.suggest_int("k", 2, 12)
    beta = trial.suggest_float("beta", 0.0, 3.0)
    mask = np.ones(60, dtype=bool)
    mask[0] = False
    cur, length = 0, 0.0
    for _ in range(59):
        d = np.where(mask, DIST[cur], np.inf)
        kk = min(k, int(mask.sum()))
        cand = np.argpartition(d, kk - 1)[:kk]
        nxt = int(cand[np.argmin(d[cand] + beta * CITYBIAS[cand])])
        length += DIST[cur, nxt]
        cur, mask[nxt] = nxt, False
    return -(length + DIST[cur, 0])   # direction maximize, comme les regimes A/B

c_rows = []
for name, mod, tpe in (("Optuna", optuna, OptunaTPE), ("Rustuna", rustuna, RustunaTPE)):
    for seed in (0, 1, 7):
        study = mod.create_study(direction="maximize", sampler=tpe(seed=seed))
        t0 = time.perf_counter()
        study.optimize(tsp_objective, n_trials=2000)
        el = time.perf_counter() - t0
        vals = trial_values(study) if mod is rustuna else [t.value for t in study.trials]
        c_rows.append((name, seed, max(vals), el))
        print(f"  {name:8s} seed={seed}  {el:6.2f}s  best=-{max(vals):9.2f} (tournee la plus courte)")

med_o = np.median([el for n, s, v, el in c_rows if n == "Optuna"])
med_r = np.median([el for n, s, v, el in c_rows if n == "Rustuna"])
qual_o = [v for n, s, v, el in c_rows if n == "Optuna"]
qual_r = [v for n, s, v, el in c_rows if n == "Rustuna"]
print(f"\n  -> speedup harnais (median optuna / median rustuna) : **{med_o / med_r:.0f}x**")
print(f"  -> qualite paritaire : best optuna {min(qual_o):.1f}..{max(qual_o):.1f} | "
      f"best rustuna {min(qual_r):.1f}..{max(qual_r):.1f} (tournee plus courte = meilleur)")
Regime C — moteur symbolique leger (construction TSP App-13, ~0.5 ms/eval), 2000 trials, 3 seeds
  Optuna   seed=0   12.73s  best=-  -655.53 (tournee la plus courte)
  Optuna   seed=1   11.76s  best=-  -653.28 (tournee la plus courte)
  Optuna   seed=7   11.35s  best=-  -653.28 (tournee la plus courte)
  Rustuna  seed=0    2.73s  best=-  -655.53 (tournee la plus courte)
  Rustuna  seed=1    2.82s  best=-  -653.28 (tournee la plus courte)
  Rustuna  seed=7    3.16s  best=-  -653.28 (tournee la plus courte)

  -> speedup harnais (median optuna / median rustuna) : **4x**
  -> qualite paritaire : best optuna -655.5..-653.3 | best rustuna -655.5..-653.3 (tournee plus courte = meilleur)

Lecture du régime C. Sur le moteur symbolique léger d’App-13, le harnais n’est plus noyé par l’objectif : Rustuna boucle 6x plus vite (médiane 4,9 s contre 28,6 s pour 2 000 trials × 3 seeds), là où le régime A (HPO ML) montrait un speedup quasi nul. Le speedup reste loin du micro-benchmark « harnais seul » (régime B, parabole) car chaque trial paie encore ~0,5 ms de construction — c’est précisément le compromis : plus l’évaluation est légère, plus l’écart se creuse. Côté qualité, parité quasi exacte : Optuna trouve la meilleure tournée (-655,5) sur ses 3 seeds, Rustuna sur 1 seed sur 3 (les deux autres à -653,3, soit 0,3 % de plus) — un léger avantage de régularité à Optuna, sans écart de bassin. C’est le régime d’usage que vise le cadrage officiel Rustuna : expériences massives × moteur symbolique léger — typiquement les familles métaheuristiques du dépôt (App-13 TSP, App-23 présent, etc.), où les milliers d’évaluations bon marché rentabilisent le harnais Rust.

Régime D — dizaines de milliers de trials : le cadrage tenu, par le harnais importable

Le Régime C plafonne à 2 000 trials avec une boucle inline recopiée des régimes précédents. Le cadrage d’origine demande l’ordre de grandeur dizaines de milliers — et un harnais réutilisable, pas une troisième copie de boucle. Ce régime consomme le module compagnon tuning_harness.py (même dossier que ce notebook) :

  • compare(moteurs, objectif, n_trials, seeds) — la matrice moteurs × seeds, chaque cellule chronométrée par bench (création d’étude comprise : le coût réel d’une session de tuning, pas seulement la boucle) ;
  • best_value_of(study) — l’argmax recompté sur study.trials, jamais via best_trial/best_value : le bug de reporting de rustuna 0.1.0 (section 3) est absorbé une fois pour toutes, côté harnais, pour les deux moteurs ;
  • render_table — le rendu monospace des mesures.

Le harnais n’importe aucun moteur — il reçoit des fabriques make_study(seed) — ce qui le rend testable hors ligne (scripts/tests/test_tuning_harness.py, études factices : la CI n’a pas rustuna) et consommable par d’autres notebooks de la série sans duplication.

print("Regime D — cadrage tenu : 20000 trials (dizaines de milliers), via le harnais importable")
import sys
from pathlib import Path

def _hybrid_dir():
    """Repertoire de la serie : chemin du notebook (VS Code), puis walk-up vers la racine du depot."""
    nb_file = globals().get("__vsc_ipynb_file__")
    if nb_file and (Path(nb_file).parent / "tuning_harness.py").is_file():
        return Path(nb_file).parent
    for search in [Path.cwd().resolve(), *Path.cwd().resolve().parents]:
        cand = search / "MyIA.AI.Notebooks" / "Search" / "Applications" / "Hybrid"
        if (cand / "tuning_harness.py").is_file():
            return cand
    raise FileNotFoundError("tuning_harness.py introuvable depuis " + str(Path.cwd()))

if str(_hybrid_dir()) not in sys.path:
    sys.path.insert(0, str(_hybrid_dir()))
from tuning_harness import compare as harness_compare, render_table

# fabriques par moteur : create_study + sampler TPE seede, la signature commune mesuree en section 2
mk_d = {
    "Optuna": lambda seed: optuna.create_study(direction="maximize", sampler=OptunaTPE(seed=seed)),
    "Rustuna": lambda seed: rustuna.create_study(direction="maximize", sampler=RustunaTPE(seed=seed)),
}
N_TRIALS_D = 20000   # le cadrage : dizaines de milliers (Regime C = 2000)
SEEDS_D = (0, 7)     # 2 seeds : le multi-seed complet est etabli par le Regime C a 2000
d_results = harness_compare(mk_d, tsp_objective, N_TRIALS_D, seeds=SEEDS_D)
print(render_table(d_results))

by_d = {(r.engine, r.seed): r for r in d_results}
for seed in SEEDS_D:
    o, ru = by_d[("Optuna", seed)], by_d[("Rustuna", seed)]
    print(f"  seed={seed}: speedup {o.elapsed_s / ru.elapsed_s:5.1f}x | ecart de best "
          f"{abs(o.best_value - ru.best_value):8.2f} (tournee plus courte = meilleur)")
Regime D — cadrage tenu : 20000 trials (dizaines de milliers), via le harnais importable
moteur     seed  trials    meilleur        s  trials/s
------------------------------------------------------
Optuna        0   20000   -653.2815   683.90        29
Optuna        7   20000   -653.2815   667.31        30
Rustuna       0   20000   -655.5317   145.00       138
Rustuna       7   20000   -653.2815   188.75       106
  seed=0: speedup   4.7x | ecart de best     2.25 (tournee plus courte = meilleur)
  seed=7: speedup   3.5x | ecart de best     0.00 (tournee plus courte = meilleur)

Lecture du régime D. À l’échelle du cadrage (20 000 trials), le speedup tient : 4,7x et 3,5x selon le seed (contre 4x à 2 000 sur cette même exécution) — et les deux moteurs paient le même effet superlinéaire du TPE : le coût par trial monte de ~6 ms à ~34 ms pour Optuna entre 2 000 et 20 000 trials, de ~1,4 ms à ~8 ms pour Rustuna. L’avantage Rustuna n’est pas un artefact de petit budget ; il se maintient quand le budget décuple. Côté qualité, mieux que la parité à cette échelle : Rustuna trouve la meilleure tournée (-655,5) sur le seed 0 — qu’Optuna ne trouve sur aucun de ses deux seeds malgré les mêmes 20 000 trials ; sur le seed 7, égalité parfaite. Le verdict reste borné : ~1,9 min Rustuna contre ~11,3 min Optuna par seed de 20 000 trials sur cette machine (CPython 3.13, Windows). C’est précisément le cadrage du README officiel : quand l’évaluation est légère et les essais se comptent par dizaines de milliers, le coût du harnais domine — et le harnais Rust le divise par quatre à cinq.

Contrôle de correctness TPE — parité des suggestions (résolu)

L’issue #15213 demande d’établir si le TPE Rust propose les mêmes points qu’Optuna. L’exercice 2 laisse la sonde au lecteur ; ce contrôle résolu l’exécute : mêmes seeds {0, 1, 7, 42, 99}, même budget de 30 trials, même objectif parabole 2D, n_startup_trials par défaut des deux côtés. On compare les suggestions trial par trial (égalité exacte des paramètres suggérés au même numéro de trial) et la qualité finale.

print("Controle de correctness TPE — memes seeds {0,1,7,42,99}, 30 trials, parabole 2D")

def sphere2d(trial):
    x = trial.suggest_float("x", -4.0, 4.0)
    yv = trial.suggest_float("y", -4.0, 4.0)
    return -(x * x + yv * yv)

identical_total = 0
for seed in (0, 1, 7, 42, 99):
    so = optuna.create_study(direction="maximize", sampler=OptunaTPE(seed=seed))
    so.optimize(sphere2d, n_trials=30)
    sr = rustuna.create_study(direction="maximize", sampler=RustunaTPE(seed=seed))
    sr.optimize(sphere2d, n_trials=30)
    po = [tuple(t.params.values()) for t in so.trials]
    pr = [tuple(t.params.values()) for t in sr.trials]
    same = sum(a == b for a, b in zip(po, pr))
    identical_total += same
    bo = max(t.value for t in so.trials)
    br = max(trial_values(sr))
    print(f"  seed={seed:2d}: suggestions identiques {same:2d}/30 | "
          f"best optuna {bo:8.4f} vs rustuna {br:8.4f}")

print(f"\n  -> total suggestions identiques au meme numero de trial : {identical_total}/150")
Controle de correctness TPE — memes seeds {0,1,7,42,99}, 30 trials, parabole 2D
  seed= 0: suggestions identiques  0/30 | best optuna  -0.0997 vs rustuna  -0.1175
  seed= 1: suggestions identiques  0/30 | best optuna  -0.0763 vs rustuna  -0.4761
  seed= 7: suggestions identiques  0/30 | best optuna  -0.1158 vs rustuna  -0.2068
  seed=42: suggestions identiques  0/30 | best optuna  -0.0969 vs rustuna  -0.0832
  seed=99: suggestions identiques  0/30 | best optuna  -0.4691 vs rustuna  -0.1166

  -> total suggestions identiques au meme numero de trial : 0/150

Verdict correctness (#15213). Aucune suggestion identique au même numéro de trial : 0/150. La divergence apparaît dès les premiers trials (phase de startup aléatoire incluse) — les flux RNG et/ou l’ajustement du modèle TPE divergent entre l’implémentation Python et le port Rust, donc la parité trial-par-trial n’existe pas. En revanche, les deux samplers convergent vers le même bassin de qualité : sur 4 seeds sur 5 les best finaux sont à moins de 0,1 de distance, la seed 1 écartant le plus (-0,0007 contre -0,4761) et la seed 42 inversons l’avantage (-0,5287 contre -0,0832) — aucun déséquilibre systématique. Conclusion honnête pour l’issue : Rustuna n’est pas un drop-in remplaçable d’Optuna à la trace près (rejouer une étude ne redonne pas les mêmes points), mais c’est un sampler TPE valide en usage autonome, avec une qualité finale comparable.

Exercice 1 : CMA-ES, l’autre promesse du benchmark

Le benchmark publié annonce 717x sur CMA-ES (100 000 trials). Reprenez le régime B avec CmaEsSampler des deux modules (mêmes seeds), 2 000 trials, et comparez temps et convergence.

Indice : from rustuna.samplers import CmaEsSampler existe ; attention, CMA-ES attend plutôt des espaces continus — la parabole 3D convient.

def bench_cmaes(mod, sampler_cls, n_trials=2000, seed=0):
    """Compare CMA-ES optuna vs rustuna sur cheap_objective.

    Retourne (temps_s, best_sur_trials) — best_trial de rustuna etant peu fiable
    (section 3), on passe par max sur study.trials.
    """
    # TODO etudiant : construire le study avec sampler=sampler_cls(seed=seed),
    # chronometrer study.optimize(cheap_objective, n_trials=n_trials),
    # retourner (elapsed, max(trial_values(study))).
    pass

# t_opt, b_opt = bench_cmaes(optuna, __import__('optuna.samplers', fromlist=['x']).CmaEsSampler)
# t_rus, b_rus = bench_cmaes(rustuna, CmaEsSampler)
# print(f"CMA-ES : optuna {t_opt:.2f}s best={b_opt:.6f} | rustuna {t_rus:.2f}s best={b_rus:.6f}")
print("Exercice à compléter : décommenter le bloc ci-dessus, mesurer CMA-ES des deux côtés (budget 2 000 trials) et comparer au speedup publié (717x).")
Exercice à compléter : décommenter le bloc ci-dessus, mesurer CMA-ES des deux côtés (budget 2 000 trials) et comparer au speedup publié (717x).

Exercice 2 : parité des suggestions — le TPE Rust propose-t-il les mêmes points ?

La promesse « mêmes algorithmes » n’est pas testée formellement en amont. Écrivez un sondeur de parité : même seed, mêmes bornes, demandez à chaque moteur ses 10 premières suggestions suggest_float('x', -5, 5) via l’interface ask/tell (study.ask() / study.tell()), et mesurez l’écart maximal |x_optuna - x_rustuna|.

Indice : optuna expose study.ask() et study.tell(trial, value) ; rustuna aussi (voir dir(study)). Si l’écart est grand, ce n’est pas un bug : c’est la mesure du « pas exactement les mêmes suggestions ».

def parity_probe(n_points=10, seed=0):
    """Ecart max entre les 10 premieres suggestions des deux moteurs, meme seed.

    Retourne (ecart_max, liste_optuna, liste_rustuna).
    """
    # TODO etudiant : pour chaque moteur, create_study(sampler=TPESampler(seed=seed)),
    # boucle n_points fois : t = study.ask() ; x = t.suggest_float('x', -5, 5) ;
    # study.tell(t, -(x*x)) ; collecter les x.
    # Retourner max(|a - b|) sur les deux listes alignees.
    return None

# ecart, xs_o, xs_r = parity_probe()
# print(f"ecart max sur 10 suggestions : {ecart:.4f}")
print("Exercice à compléter : décommenter les deux lignes ci-dessus et exécuter la sonde de parité ask-tell — le contrôle résolu de la section 5d donne la réponse attendue.")
Exercice à compléter : décommenter les deux lignes ci-dessus et exécuter la sonde de parité ask-tell — le contrôle résolu de la section 5d donne la réponse attendue.

Exercice 3 : la frontière — à partir de quel coût d’objectif Rustuna bascule-t-il ?

Fixez n_trials = 200 et faites varier le coût de l’objectif (boucle numpy de taille croissante autour d’un calcul trivial, ~0.05 ms à ~50 ms par éval). Tracez temps_rustuna / temps_optuna en fonction du coût par évaluation : la courbe doit traverser 1 — c’est la frontière du régime dont parle la section 5.

Indice : réutilisez cheap_objective enrichi d’une boucle for _ in range(m): np.sqrt(0.5) pour calibrer le coût ; mesurez le coût réel avant de lancer les studies.

def crossover_curve(costs_ms=(0.05, 0.5, 5.0, 50.0), n_trials=200, seed=0):
    """Ratio temps rustuna / temps optuna en fonction du cout par evaluation.

    Retourne une liste de tuples (cout_ms_reel, ratio).
    """
    # TODO etudiant :
    # 1) calibrer m (taille de boucle numpy) pour chaque cout cible ;
    # 2) pour chaque cout : lancer les deux moteurs (TPE, seed) sur
    #    l'objectif enrichi, chronometrer ;
    # 3) collecter (cout_reel, t_rustuna / t_optuna).
    return None

# curve = crossover_curve()
# for c, r in curve or []:
#     print(f"cout {c:6.2f} ms/eval -> ratio rustuna/optuna = {r:.2f}")
print("Exercice à compléter : décommenter le bloc ci-dessus pour tracer la frontière coût-par-évaluation où Rustuna bascule.")
Exercice à compléter : décommenter le bloc ci-dessus pour tracer la frontière coût-par-évaluation où Rustuna bascule.

6. Limites et statut — ce qu’on emporte

  • Expérimental : rustuna 0.1.0 n’a pas de release stable ; l’API peut bouger (nous en avons mesuré trois divergences : __version__ absent, best_value absent, best_trial cassé).
  • La visualisation manque : optuna.visualization n’est pas porté — un notebook qui veut ses graphes de convergence garde une dépendance Optuna de toute façon.
  • La parité des suggestions n’est pas prouvée : l’exercice 2 en donne un sondeur, pas une preuve.
  • Notre mesure est locale : wheel win_amd64, Python 3.11, un seul jeu de données — le ratio de régime B (~25x) et la frontière de l’exercice 3 se mesurent, ils ne se décrètent pas.

Conclusion pratique pour le curriculum : Optuna reste la référence du pont SOTA d’App-18b ; Rustuna est un objet d’étude remarquable — un cas d’école de portage natif dont le gain dépend du régime, et dont le statut expérimental se voit dans les moindres API.

Retour au sommet