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
Exécuter deux implémentations du même algorithme (TPE) sur le même objectif et comparer qualité trouvée et temps de recherche.
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).
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 timeimport numpy as npimport matplotlib.pyplot as pltfrom IPython.display import display, Markdowndef 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 inrange(k): vote[y_train[idx[i]]] += w[i]returnint(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 inrange(n_folds): test_idx = np.concatenate([folds0[f], folds1[f]]) train_idx = np.concatenate([ np.concatenate([folds0[j] for j inrange(n_folds) if j != f]), np.concatenate([folds1[j] for j inrange(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 inenumerate(X_te)) accs.append(correct /len(y_te))returnfloat(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 _ inrange(10): cv_accuracy(X, y, 5, 2.0, 0.5)obj_ms = (time.perf_counter() - t0) /10*1000display(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 optunaimport rustunafrom optuna.samplers import TPESampler as OptunaTPEfrom rustuna.samplers import TPESampler as RustunaTPEoptuna.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 indir(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] ifisinstance(v, (list, tuple)) elsefloat(v))return out
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.valuesbtv = bt[0] ifisinstance(bt, (list, tuple)) elsefloat(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 :
Random Search from-scratch (numpy pur, repris d’App-18b §4) — le plancher ;
Optuna TPESampler — la référence SOTA installée ;
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 _ inrange(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, paramsreturn best_v, best_p, histrows = []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}"ifisinstance(vv, float) elsef"{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**")
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 carreeDIST = 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.0for _ inrange(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, Falsereturn-(length + DIST[cur, 0]) # direction maximize, comme les regimes A/Bc_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 sysfrom pathlib import Pathdef _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).parentfor 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 candraiseFileNotFoundError("tuning_harness.py introuvable depuis "+str(Path.cwd()))ifstr(_hybrid_dir()) notin 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 2mk_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 2000d_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 =0for 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 inzip(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.returnNone# 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).returnNone# 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.