Native cross-language finetuning for nirs4all¶
Status: strategy / design proposal (not yet implemented)
Scope: ecosystem-wide — nirs4all (Python), dag-ml, dag-ml-data, nirs4all-methods (libn4m), nirs4all-core, the R/WASM/MATLAB/Octave bindings, nirs4all-studio
Author: drafted from a full-ecosystem survey (see the Evidence map appendix for file:line anchors)
Question it answers: how do we get clean, native, reproducible hyperparameter finetuning into nirs4all that works identically in Python, R, MATLAB/Octave and WASM — not just Python/Optuna?
0. TL;DR — the recommended path¶
Today, all adaptive finetuning is Python-only: it lives entirely in nirs4all.optimization.optuna.OptunaManager. dag-ml already owns the deterministic half (variant generation + selection) natively in Rust, but has no sampler and no candidate-evaluation loop. nirs4all-methods already ships native black-box optimizers (GA/PSO/CARS/…) but their objective is hard-wired to PLS.
The clean, ML-logical, code-honest path is a three-seam architecture:
The search algorithm lives once, natively, in
libn4mas a dependency-free ask/tell optimizer (typed search space +n4m_optimizer_twithask()/tell()), reusing the RNG, CV/validation-plan and result-bag substrate methods already has. This is the only way to get bit-identical finetuning across R/MATLAB/Octave/WASM.dag-mlowns the control loop and every OOF/leakage/nested-CV/selection/lineage guarantee — it drivesask → schedule candidate → score → tell → select → refit. ItsTunernode,GenerationSpec,select_candidateand nested-CV plumbing already exist; it gains a sampler seam and a per-trial score-return path, not a new subsystem.The evaluator is pluggable. For a methods model (PLS-family) the objective stays fully native (no boundary crossing). For a host model (torch/TF/sklearn) the host drives the loop (
ask→ host fits & scores →tell), so no C→host objective callback is needed in the common case. Optuna becomes an optional sampler, not the engine.
Direct answers to the four questions:
# |
Question |
Answer |
|---|---|---|
Q1 |
Is finetuning customisable in dag-ml, or Python-specific? |
Today the adaptive search is Python-specific (Optuna). dag-ml already owns generation + selection + a |
Q2 |
What is needed to use finetuning from dag-ml? |
(a) a native |
Q3 |
Could R/WASM/MATLAB provide idiomatic finetuning for their nirs4all-core ports? |
Yes — each binding wraps the native optimizer thinly (R |
Q4 |
Which finetuning methods to implement in nirs4all-methods? |
In priority order: uniform random, Sobol/LHS, grid (delegate to dag-ml), ASHA/median-pruned successive-halving & Hyperband, a ternary sampler for unimodal ints ( |
1. Three meanings of “finetuning” (disambiguation first)¶
The word “finetuning” collapses three distinct capabilities in nirs4all. Keeping them separate is essential — they have different owners and different maturity.
Enumeration / grid search — deterministically enumerate a finite set of candidate pipelines (the
_or_/_grid_/_cartesian_/_zip_generator DSL, withmutex/requires/excludeconstraints) and pick the best by CV. This is variant generation + selection. In real NIRS workloads this is the dominant case: the AOM Talanta recipe is a 600-combination preprocessing grid (5 norms × 10 smoothers × 3 baselines × 4 OSC), all categorical, crossed with one model HP.Adaptive / sequential search — propose the next candidate from the scores of previous ones (TPE, CMA-ES, Bayesian/GP, successive-halving pruning). This is the Optuna intelligence. It matters for the continuous / mixed-space tail: PLS
n_components, Ridgealpha(log-uniform), DL learning rates, and epoch-budgeted DL training.Combinatorial variable / preprocessing-chain selection — search over feature subsets or operator chains (GA-PLS, PSO-PLS, CARS, VISSA, IRIV…). methods already implements these natively, but as self-contained selectors, not as a generic optimizer.
A “finetune” in a nirs4all pipeline today mixes (1) and (2): a model step carries finetune_params (adaptive, Optuna) and the surrounding pipeline may carry _or_/_grid_ generators (enumeration). The end-state must cover all three coherently.
2. État des lieux — current finetuning, per package¶
Package |
What it does for finetuning today |
Native? Portable? |
|---|---|---|
nirs4all (Python) |
The entire adaptive engine: |
Python-only. None of it crosses the C ABI. |
dag-ml |
Owns the deterministic half natively in Rust: variant |
Generation + selection are native. But no sampler, no HPO loop, no host-objective callback. The |
nirs4all-methods ( |
Ships native metaheuristic optimizers over feature-subset spaces: GA-PLS, PSO-PLS, CARS, SCARS, VISSA, IRIV, IRF, random-frog. Each takes |
Native + portable — but the objective is PLS-locked, there is no generic |
dag-ml-data |
Data contracts (schemas, axes, |
No search-space contract yet. |
Bindings (R / WASM / MATLAB / Octave / Python) |
Every binding is a pure data-in/data-out marshaller. No binding passes a host function pointer into |
The callback question is moot for the recommended design (host drives the loop; only R→C / JS→WASM repeated calls, which already work). |
nirs4all-core |
Re-exports formats/io/datasets/methods/dag_ml/dag_ml_data. Executes today only a tiny operator subset (KS split, SNV, SG, PLS). |
Finetuning is explicitly out of core’s current scope — it enters only after the engine lands upstream (methods + dag-ml) and clears the operator gate. |
nirs4all-studio |
Never reimplements the library — it serializes the editor state to/from the exact |
Consumer. Sets hard requirements: async, streamable, prunable, resumable. |
nirs4all-lab / -aom |
Real HPO recipes (AOM 600-combo preprocessing grid + model-HP tail; TabPFN search spaces; a |
Show the actual search shapes: preprocessing-heavy categorical with a small continuous/int model tail. |
The single load-bearing fact: the deterministic half (enumerate + select) is native and (nearly) cross-language; the adaptive half (sample + prune + the propose→score→propose loop) exists only in Python/Optuna. That is the whole gap.
2.1 Two honest corrections from adversarial verification¶
These sharpen the plan and correct over-optimistic framings:
methods substrate is partial reuse, not “assembly.” The CV/validation-plan is genuinely factored and the RNG engine exists — but the GA/PSO loops carry file-local copies of splitmix64 (they don’t consume the shared engine), the “PLS CV-RMSE fitness” is a copy-pasted
subset_rmseper selector shaped around a feature mask (no generic candidate/objective seam), and the population loops are bespoke overvector<uint8_t>masks with noPopulation<T>abstraction. A native optimizer reuses the primitives (folds, CV, RNG kinds, result bag, calling convention) and the proven patterns, but is real new C++ — a generic objective seam, a typed candidate representation, a generic loop, and a new ask/tell ABI. Budget it as a build, not glue.dag-ml’s generation is only transitively exposed, and even grid eval is host-side.
select_candidateis directly exposed over the C-ABI and WASM.enumerate_variantshas no dedicated FFI symbol — a host reaches the enumerated grid only bundled insidebuild_execution_plan’sExecutionPlan.variants. And dag-ml never evaluates a candidate: it hands each fully-specified variant to a host controller to fit/score, then picks the winner. So “grid finetuning already works cross-language” is true for generation + selection, but the evaluation is always host-side — which is exactly why the native-evaluator path (flavor B below) is the real portability win.
3. The recommended architecture¶
3.1 One rule, two seams¶
dag-mlowns the search control loop and all ML invariants (OOF, leakage, nested CV, selection, lineage, fingerprints). The sampler and the evaluator are the two pluggable seams. The sampler lives natively inlibn4m. The evaluator is native for methods models, host-side for host models.
This maps cleanly onto what already exists: dag-ml’s Tuner node is already a scheduled OOF-producing node with a node-local inner_cv; it just needs to (a) pull candidates from a sampler instead of enumerating them all up front, and (b) receive a per-trial score.
3.2 Three flavors, three loops¶
Flavor A — enumeration / grid (native today, minus evaluation).
compile → enumerate_variants (all N, constraint-pruned) → [host fits/scores each] → select_candidate → refit winner
No sampler. Already cross-language for generation + selection. Covers the 600-combo preprocessing grids that dominate real workloads.
Flavor B — adaptive over a NATIVE methods model (fully native, no host in the loop).
loop t:
cand = n4m_optimizer_ask(space) # sampler in libn4m
score = fit_cv(cand, inner-train folds) # native PLS/estimator, CV-RMSE
n4m_optimizer_tell(cand, score)
until budget → best → select_candidate → refit
This is the portability payoff. No boundary crossing per trial ⇒ bit-identical in Python/R/MATLAB/Octave/WASM. It is the only design that gives portable adaptive finetuning.
Flavor C — adaptive over a HOST model (torch/TF/sklearn) — sampler native, evaluation delegated.
loop t:
cand = n4m_optimizer_ask(space) # sampler still native
result = host_controller.fit(cand, inner-fold-train ids) # Python/etc. scores it
n4m_optimizer_tell(cand, result.score)
until budget → best → select_candidate → refit (host controller)
The host drives the loop and evaluates; the sampler stays native. dag-ml owns the coordination; Python keeps only what only Python can do (train a torch model in-process).
3.3 Why “host drives the loop”, not a C→host objective callback¶
The host-driven ask/tell pattern is what Optuna itself (study.ask/study.tell), scikit-optimize, Nevergrad and Ax all converged on. It is strictly better than a C→host objective callback for this codebase:
No new function-pointer ABI in the common path ⇒ it can’t violate “never throw across the boundary”, can’t clobber the thread-local error buffer by re-entering
n4m_*, and doesn’t force “callback on the calling thread only, no OpenMP”.R’s evaluator is single-threaded / non-reentrant and errors via
longjmp— a C→R objective callback threaded through a native inner loop is exactly thePROTECT/unwinding hazard. Ask/tell inverts control: R callsask, evaluates in R, callstell. Hazard gone.WASM is synchronous, single-thread, no Asyncify,
addFunctionunexported — a per-trial JS callback needs Asyncify (large cost, off). Ask/tell keeps the whole search inside one synchronous WASM call (flavor B), with progress via batched re-entry, never a per-trial callback.Python
ctypes.CDLLreleases the GIL — a re-entrant callback re-acquires it and can deadlock under OMP; ask/tell returns to Python which evaluates on its own thread.
An optional callback (n4m_optimizer_run(opt, obj_fn, user, n_trials)) can still be offered as a Python/R convenience for the core to own the iteration/pruning bookkeeping — status-returning, single-threaded, non-reentrant by contract. It is never the portable contract.
4. What to build in nirs4all-methods¶
4.1 Search-space contract (typed, C-ABI, 1:1 with the existing DSL)¶
An opaque n4m_search_space_t built with typed adders; kinds are 4-byte enums (guard-railed like every n4m enum):
typedef enum n4m_param_kind_t {
N4M_PARAM_INT=0, N4M_PARAM_FLOAT=1, N4M_PARAM_LOG_INT=2,
N4M_PARAM_LOG_FLOAT=3, N4M_PARAM_CATEGORICAL=4, N4M_PARAM_ORDINAL=5
} n4m_param_kind_t;
N4M_API n4m_status_t n4m_search_space_create(n4m_search_space_t** out);
N4M_API n4m_status_t n4m_search_space_add_int(n4m_search_space_t*, const char* name,
int64_t low, int64_t high, int64_t step, int32_t log);
N4M_API n4m_status_t n4m_search_space_add_float(n4m_search_space_t*, const char* name,
double low, double high, double step, int32_t log); /* step<=0 => continuous */
N4M_API n4m_status_t n4m_search_space_add_categorical(n4m_search_space_t*, const char* name,
const char* const* choices, int32_t n_choices);
N4M_API n4m_status_t n4m_search_space_add_ordinal(n4m_search_space_t*, const char* name,
const double* values, int32_t n_values);
N4M_API n4m_status_t n4m_search_space_set_condition(n4m_search_space_t*, const char* child,
const char* parent, const char* parent_equals);
N4M_API void n4m_search_space_destroy(n4m_search_space_t*);
DSL parity is mechanical with the Studio/Optuna vocabulary ({int, float, log_float, categorical}, list [token, low, high, step] / [token, choices] or dict {type, low, high, step, choices, log}): ["int",lo,hi,step]→add_int; ["log_float",lo,hi]→add_float(...,log=1); ["categorical",cs]→add_categorical. Nested params keep the __ flattened name verbatim (the string is opaque to the core). The model_params vs train_params split is preserved by a model. / train. name prefix, so Studio’s serializer round-trips unchanged. This requirement is non-negotiable — break the DSL vocabulary and Studio breaks.
4.2 Optimizer handle + ask/tell¶
Superseded by
FINETUNING_ROADMAP.md§2–3 (adversarial review). The enum below is the original sketch. The implementation splits sampler ⟂ pruner into two enums (n4m_sampler_kind_t×n4m_pruner_kind_t, composed inn4m_optimizer_create), dropsGRID(exhaustive enumeration is dag-ml’s parity-locked oracle, not a libn4m sampler), and movesHALVING/Hyperband/median into the pruner enum. The multi-fidelity axis is the PLSn_componentslearning curve / subsample / racing — not CV-fold fraction (which breaks the rank-preservation successive-halving assumes). See the roadmap for the authoritative surface.
typedef enum n4m_sampler_kind_t {
N4M_SAMPLER_RANDOM=0, N4M_SAMPLER_GRID=1, N4M_SAMPLER_SOBOL=2, N4M_SAMPLER_LHS=3,
N4M_SAMPLER_HALVING=4, N4M_SAMPLER_CMAES=5, N4M_SAMPLER_TPE=6, N4M_SAMPLER_GP_EI=7,
N4M_SAMPLER_GA=8, N4M_SAMPLER_PSO=9, N4M_SAMPLER_TERNARY=10
} n4m_sampler_kind_t;
N4M_API n4m_status_t n4m_optimizer_create(n4m_context_t*, const n4m_search_space_t*,
n4m_sampler_kind_t, n4m_opt_direction_t, uint64_t seed,
n4m_optimizer_t** out);
N4M_API n4m_status_t n4m_optimizer_ask(n4m_optimizer_t*, n4m_trial_t** out_trial); /* BORROWED */
N4M_API n4m_status_t n4m_trial_get_int(const n4m_trial_t*, const char* name, int64_t* out);
N4M_API n4m_status_t n4m_trial_get_float(const n4m_trial_t*, const char* name, double* out);
N4M_API n4m_status_t n4m_trial_get_category(const n4m_trial_t*, const char* name,
int32_t* out_index, const char** out_label);
N4M_API n4m_status_t n4m_optimizer_tell(n4m_optimizer_t*, int64_t trial_id, double score);
N4M_API n4m_status_t n4m_optimizer_tell_intermediate(n4m_optimizer_t*, int64_t trial_id,
int32_t step, double score, int32_t* out_should_prune);
N4M_API n4m_status_t n4m_optimizer_best(const n4m_optimizer_t*, n4m_trial_t** out_best, double* out_score);
N4M_API n4m_status_t n4m_optimizer_get_trials(const n4m_optimizer_t*, n4m_method_result_t** out);
N4M_API void n4m_optimizer_destroy(n4m_optimizer_t*);
The handle owns a seeded n4m_rng and the full trial table (it is stateful — that is what makes pruning possible). It is one-context-per-thread like every n4m handle. out_trial/out_score are core-owned scratch, only borrowed by the host; the sole host-freed object is the n4m_method_result_t trace (the sanctioned core-allocated + n4m_method_result_destroy pattern). n4m_optimizer_get_trials packs params + scores + status so Studio can stream TrialResults.
4.3 Pure-native finetune (zero host code, the portability win)¶
N4M_API n4m_status_t n4m_finetune_estimator(
n4m_context_t*, n4m_algorithm_t estimator,
const n4m_matrix_view_t* X, const n4m_matrix_view_t* Y,
const n4m_validation_plan_t* plan, const n4m_search_space_t* space,
n4m_sampler_kind_t, n4m_opt_direction_t, int32_t metric,
int32_t n_trials, uint64_t seed, n4m_method_result_t** out_result);
Internally: the ask/tell loop, feeding the sampled config into n4m_config_set_* then cross_validate_regression over plan, reading cv.metrics.rmse back into tell. This one entry point gives R/MATLAB/Octave/WASM adaptive finetuning of a native model with a single synchronous call.
4.4 Algorithm set + priorities¶
Superseded by
FINETUNING_ROADMAP.md§2–3 (Codex + adversarial review). The table below is the original sketch and is retained for the ML rationale only. The authoritative facts: (a)gridis not a libn4m sampler (dag-ml owns enumeration); (b) successive-halving/Hyperband/median are pruners (a separaten4m_pruner_kind_t), not samplers; (c) the multi-fidelity axis is the PLSn_componentslearning curve (one max-K fit per CV fold, for eligible PLS1/NIPALS paths — not free for SIMPLS/multi-target), subsample, or DL epochs — not CV-fold fraction (which breaks the rank-preservation successive-halving assumes).
# |
Algorithm |
ML rationale |
Build cost |
Kind |
|---|---|---|---|---|
1 |
Uniform random |
strong baseline; startup seeding for all others |
reuse RNG + result bag |
sampler |
2 |
Sobol / LHS |
better coverage than i.i.d. at small budgets; ideal warm-start for the 600-combo spaces |
low-new (quasi-random kernels) |
sampler |
3 |
~~Grid~~ → dag-ml |
exhaustive over finite categorical spaces |
delegate to dag-ml |
not a libn4m sampler |
4 |
Successive-halving / Hyperband / median |
prune weak configs early on the |
med-new (rung bookkeeping + |
pruner |
5 |
Ternary int search |
|
low-new |
sampler |
6 |
GA / PSO (reuse) |
combinatorial preprocessing-chains / variable selection |
reuse loops, generalize genotype |
sampler |
7 |
CMA-ES |
continuous params + model-HP tail (with independent-random fallback for categoricals) |
med-new (population pattern + |
sampler |
8 |
TPE |
Optuna default; mixed + conditional/tree spaces |
high-new (Parzen split, clean-room feasible) |
sampler |
9 |
GP-EI Bayesian |
small budgets, smooth continuous objectives |
high-new — optional / cuttable |
sampler |
Default auto policy (Optuna-analogous): pure finite categorical → grid (dag-ml); single unimodal int → ternary; combinatorial → GA/PSO; continuous-only budget ≥ ~30 → CMA-ES, else GP-EI (if built); mixed budget ≥ ~20 → TPE; first ~10 trials always Sobol/random seeding; whenever an intermediate-score axis exists (n_components/subsample/epochs) → wrap in an ASHA/median pruner (racing for fold-based stopping).
4.5 ABI implications¶
All additive symbols ⇒ ABI minor bump (2.0 → 2.1). Design the entire symbol surface in Phase 1 so later samplers are enum values, not new symbols. Regenerate cpp/abi/expected_symbols_{linux,macos,windows}.txt and add a docs/abi/changes_log.md entry in the same PR (the diff fails closed).
5. What to build in dag-ml / dag-ml-data¶
dag-ml-data: aSearchSpacedescriptor — one new declarative contract, JSON-identical acrossdag-ml↔dag-ml-data(validated by each repo’svalidate_contracts.py). It encodes the DSL exactly (int|float|log_float|categorical+ thesorted_tuple/static-scalar pass-through the Optuna DSL supports, list/dict form,model_paramsvstrain_params, and the fullmutex/requires/exclude+pick/arrange/countconstraint richness so it lowers straight intoGenerationSpecfor flavor A — single-valueset_conditionis not enough; see roadmap §3-decision-7) plus study controls (n_trials 5–500 | timeout | direction | random_state | sampler | pruner | eval_mode). Because it is declarative, any binding can build it — no callback needed to define a search.dag-ml: wire theTunernode to the loop. ReuseNestedCvSpec/build_inner_fold_setso the whole search runs inside the inner folds of the current outer-train partition (leakage refused structurally). Per trial:ask→ fit on inner-train only → validationCandidateScore→tell; each candidate gets astable_json_fingerprintkeyed on params+fold+seed (dedup/cache/lineage fall out for free). After the budget, the sameselect_candidatethe grid path uses picks the winner, which refits on full outer-train. The only structural change: the candidate stream is sampler-generated incrementally instead ofenumerate_variantsemitting all up front.dag-ml: a score-return path, not a new callback family. Reuse the existingDagMlControllerVTable{fit,predict,invoke}: theTunerissues aNodeTaskper candidate and the host returns aNodeResultcarrying the validation score (the metric field / PredictionStore row becomes the defined channel). Add only thindagml_tuner_ask_json/dagml_tell_jsonwrappers over thelibn4moptimizer handle, plus aTrialResultstream for Studio. The raw sampler callback, if any, lives inlibn4m, never in dag-ml.
Migration from the Python run_backend shim (staged, each stage shippable):
Stage 0 (done):
_or_/grid generation already lowers natively — flavor A needs no Python.Stage 1: stop demoting
_grid_/dict-form/multi-model generators (detect._FORCE_PYTHON_STEP_KEYS,_generation_kind) — route them throughGenerationSpec. Enumeration finetuning becomes fully native.Stage 2: land the
libn4moptimizer +SearchSpace; wire flavor B; dropfinetune_paramsfrom the force-Python set for native-model steps.Stage 3: wire flavor C via the vtable score-return; Optuna retained only as a selectable sampler. End state = ADR-17 (
DEFAULT_ENGINE="dag-ml").
6. Per-language idiomatic finetuning¶
Language |
Idiomatic API |
Native ecosystem |
Decision |
|---|---|---|---|
Python |
|
rich (Optuna, Ray Tune, skopt) |
both — keep Optuna for power users; |
R |
|
rich ( |
both — |
MATLAB / Octave |
|
|
native only — wrapping |
JS / WASM |
|
essentially none ( |
native only — one synchronous native call; progress via batched re-entry, no Asyncify |
# Python
search = N4MSearchCV(pipeline, search_space={"pls__n_components": ("int", 1, 30)},
n_trials=50, sampler="tpe", seed=42, cv=5) # sampler="native" ⇒ cross-lang parity
# R — native sampler, bit-identical across bindings
res <- n4m_tune(pls_n4m(n_components = tune()), X, y, sampler = "tpe", n_trials = 50, seed = 42)
% MATLAB/Octave — no bayesopt dependency
res = n4m.finetune(X, Y, struct('n_components', n4m.intspace(1,30)), ...
struct('Sampler','tpe','NumTrials',50,'Seed',42,'CV',5));
The reproducibility argument (why native, not wrap). Only a native sampler over a native RNG gives bit-identical HPO across all four languages. Wrapping each language’s own optimizer means Optuna-TPE (Python) vs ParBayesianOptimization (R) vs bayesopt (MATLAB) — different samplers, different RNGs, so even at the same seed the trial trajectories diverge and the selected “best” config differs by language. That breaks the ecosystem’s “same binary everywhere” promise exactly where it is most valuable: Studio round-trips, nirs4all-papers reproducibility kits, nirs4all-benchmarks scoring. Wrapping is an idiomatic convenience (Python/R); the native optimizer is the portable contract, and the only contract for MATLAB/Octave/WASM.
Framing correction (from verification): do not claim “native is the only way HPO is possible in these languages” — MATLAB
bayesoptand JShpjsexist. Claim the precise thing: native is the only path that is simultaneously reproducible-across-all-bindings, toolbox-free and single-source. For PLS the objective is already a native kernel, so native is the natural home, not a fallback forced by host limitations.
7. Hard engineering decisions to settle before building¶
These come out of adversarial review; the design is sound but underspecified here, and each is a real fork:
Pruning semantics must be asynchronous. A per-
tellverdict (tell_intermediate → out_should_prune) is only sound for ASHA / median / percentile pruners, not classic synchronous successive-halving (which compares a whole rung cohort). Commit explicitly to ASHA/median semantics, orN4M_SAMPLER_HALVINGsilently implements a different algorithm than the Hyperband literature it cites. Add trialbudget/rungaccessors and — for epoch-fidelity DL — anask-side “resume a suspended trial” path plus host-side partial-model caching keyed by trial id. Fold-fidelity is survivable without this; epoch-fidelity is not.Batch-ask, determinism and concurrency. “identical seed → identical ask sequence” holds only for sequential tell-order. Under parallel evaluation, completion order is nondeterministic and adaptive samplers (TPE, CMA-ES mid-generation) diverge. Decide: either scope reproducibility to sequential mode, or make within-batch tells order-invariant (generation-synchronous samplers buffer tells and update per batch). State handle thread-safety (Python GIL serializes cheaply; R/WASM have no threads). Acknowledge the structural limit: an in-process handle confines parallelism to one process, unlike Optuna’s storage-mediated multi-process studies — fine for Studio’s single backend, a gap for HPC scale-out.
Persistence/resume is new ABI surface. There is no handle-serialization precedent in
libn4m. Choose now: a versioned little-endiann4m_optimizer_save/loadblob, or replay-based resume (create(seed)+ replay the recorded ask/tell log — matches dag-ml’s lineage philosophy, but requires the deterministic tell-order from decision 2). Studio streaming itself is fine vian4m_optimizer_get_trials(add asince_idincremental form).Parity gate for stochastic samplers ≠ the PLS gate. Do not chase bit-parity with Optuna’s TPE (its behavior is dominated by undocumented, version-drifting constants —
prior_weight,n_ei_candidates, bandwidth clips, multivariate mode — plus NumPy RNG order). Use a statistical/behavioral gate: match sampling distributions and regret curves within tolerance on fixed benchmark spaces, and assert decision-level parity on canned(params, score)histories (isolates deterministic logic from RNG). Keep the existing 1e-12/1e-8 numeric gates for the deterministic PLS objective. Cross-binding parity (Python≡R≡WASM≡MATLAB at the same seed) is the tight ~1e-12 gate, via thebenchmarks/cross_bindingharness.TPE yes, GP-EI maybe never. TPE-lite from scratch is a few hundred dependency-free lines and genuinely feasible. GP-EI needs marginal-likelihood hyperparameter optimization, PSD-jitter robustness and mixed/conditional kernels — demote to “optional, possibly cut”. TPE + CMA-ES + ASHA covers the practical Optuna-replacement surface.
8. Non-goals — what stays Python-only¶
The optimizer owns SEARCH; the host owns EVALUATION. Explicitly not in libn4m:
DL objective evaluation — fitting a torch/TF model to score a trial needs the framework in-process;
libn4mnever links torch. The native sampler proposes viaask; the host trains and returns the score viatell(flavor C).SHAP / explain — stays in
nirs4allPython; not a search concern.Optuna storage backends / dashboards / RDB resume UI — the native study exposes a serializable state (or replay log); persistence and visualization are host/Studio responsibilities.
Framework-specific param nesting (
compile_/fit_/optimizer_bucketing,set_paramsmerge) — stays in Python controllers; the ABI sees only a flat typed param vector.
9. Phased roadmap¶
Phase 1 — native optimizer skeleton + pure-native objective. Ship the complete
n4m_optimizer_t+ search-space builders +ask/tell/tell_intermediate+ enums, with samplersrandom,sobol/lhs,ASHA-halving,ternary; objective = internal CV-RMSE over native PLS (n4m_finetune_estimator). No host callback. Minor ABI bump; regenerate all three symbol snapshots. Settle decisions 1–4 here.Phase 2 — dag-ml Tuner drives it cross-language + host-objective loop.
SearchSpacecontract in dag-ml-data;Tunernode runsask → controller.fit → tellinside nested CV; flavor B fully native, flavor C via the existing vtable. Extendparity-gate.yml+ the cross-binding orchestrator with the ask/tell loop.Phase 3 — CMA-ES / TPE (+ optional GP) + idiomatic wrappers. Samplers as enum values; per-language wrappers (Python
SearchCV, Rtune/mlr3, MATLAB struct, WASM promise). Optuna demoted to a selectable sampler.
10. Bottom line¶
The deterministic half of finetuning is already native in dag-ml (generation + selection); the adaptive half is Python-only (Optuna). That asymmetry is the entire problem.
The clean fix is not “port Optuna to Rust/C++ wholesale” and not “let each language wrap its own HPO”. It is a native ask/tell optimizer in
libn4m(one algorithm, every binding), orchestrated by dag-ml’s Tuner node (one set of OOF/leakage/selection guarantees), with the host driving evaluation (native for PLS, Python for DL). This is the ML-standard decomposition (ask/tell = Optuna/skopt/Nevergrad/Ax) and it respects every n4m ABI rule.It makes finetuning a dag-ml capability chosen by a string, portable and bit-reproducible in R/WASM/MATLAB/Octave, with Optuna reduced to an optional sampler and DL evaluation the only thing that stays Python — exactly the North Star boundary (“portable kernels incl. finetune move to methods; dag-ml covers coordination cross-language”).
Build honestly: methods gives you strong primitives and proven patterns but the optimizer is real new C++; settle the pruning/determinism/persistence/parity decisions in §7 before writing samplers; and treat GP-EI as optional. TPE + CMA-ES + ASHA + the reused GA/PSO/ternary is a full, defensible Optuna-replacement surface.
Appendix — evidence map (file:line)¶
nirs4all (Python HPO): nirs4all/optimization/optuna.py (OptunaManager, BinarySearchSampler, sampler/pruner/fold-strategy logic); controllers/models/base_model.py:682,711,742,752,851 (dispatch → finetune → OptunaManager → refit); pipeline/steps/parser.py:52 (finetune_params reserved); _generator/… (_or_/_grid_/_mutex_ tokens); pipeline/execution/refit/{config_extractor.py:625,662, executor.py:347,390} (best-params → refit); pipeline/dagml/{run_backend.py:117, detect.py:173,182} (finetune forced to Python).
dag-ml: crates/dag-ml-core/src/generation.rs:521,570,652 (enumerate_variants, constraints, seed derivation), :1000-1217 (parity tests); selection.rs:337,461 (select_candidate); graph.rs:33 (NodeKind::Tuner); plan.rs:200,950 (nested CV, enumerate-in-plan); fold.rs:472,519,544 (NestedCvSpec); crates/dag-ml-capi/src/lib.rs:386,1646,1688 (controller vtable; select FFI); crates/dag-ml-wasm/src/lib.rs:147,197 (select + plan-build over WASM); crates/dag-ml-py/src/in_process.rs:242,341 (per-variant fit closure, refit); dsl/tests.rs:3486 (compiles_tuner_as_external_prediction_node); docs/{CAPABILITY_MATRIX.md:24-26, COORDINATOR_SPEC.md:286, TARGET_RESPONSIBILITY_SPLIT.md}.
nirs4all-methods: cpp/include/n4m/feature_selection.h:108-228 (GA/PSO/CARS/VISSA/… native optimizers); cpp/include/n4m/n4m.h:403-411,838-905 (n4m_validation_plan_t, result bag); cpp/src/core/{ga_selection.cpp:236,259,385-655, pso_selection.cpp:28,191,334-385} (bespoke loops, copy-pasted subset_rmse/splitmix64 — the “partial reuse” finding); cpp/src/core/common/rng_engine.h:44-76 (shared RNG engine, unused by GA/PSO); cpp/src/core/{validation.hpp,cross_validation.cpp:177} (folds + CV); cpp/src/c_api/c_api_method_result.cpp (result packing). No function-pointer typedef anywhere in cpp/include/n4m/.
Consumers: nirs4all-studio/api/{pipeline_canonical_finetune.py:8-81, pipeline_canonical.py:802-1519, automl.py:85-128, execution_driver.py:253-290} (canonical DSL + streaming/pruning/resume requirements); nirs4all-core/docs/{CAPABILITIES.md, OPERATORS.md:9-23} (operator gate; finetune out of current scope); nirs4all-aom/paper/repro/{hpo_recipe_frequency.py, run_linear_hpo_paper_aom.py:134-166} (600-combo preprocessing grid + model-HP tail).
Bindings: nirs4all-methods/bindings/{python/src/n4m/_ffi.py:124, r/pls4all/src/r_dispatch.c:45,558, js/{src/ffi.ts, CMakeLists.txt:68-78}, matlab/mex/n4m_method_fit_mex.c} (pure marshallers; WASM single-thread/no-Asyncify/addFunction unexported).