vike
Research

Models

The learner seam in vike-ml — two traits that make a trainer substitutable, the capture contract that keeps one fit one fit, and the one learner that drives a real LightGBM binary.

crates/vike-ml/src/learner.rs is the learner seam: fit a model, read one probability per row. Two traits, and the header says they are the crate's only ones. Learner takes a TrainData, a GridPoint and a seed and hands back a ProbaModel; ProbaModel turns one feature row into one probability. Everything a real implementation needs beyond that — a driven child process, a scratch directory, a parameter bag, a binned-dataset cache — is behind the trait, and every caller that only wants "fit, then predict" is written against these two methods.

The seam was written in the one study that first needed a model, as that study's own view of a learner; that reason expired when the crate pinned its format and its binary, leaving a general abstraction reachable by nothing else. What it buys is substitution: code that fits models can be tested on a machine with no LightGBM binary — every Windows box and every CI runner — against crates/vike-ml/src/test_support.rs's ScriptedLearner, which answers a scripted probability, counts its fits and can be told to fail. That double deliberately answers no fit_identity and no model text, because a double that answers Some is a double that can be wrong, and a fabricated identity or text would let every consumer's refusal pass vacuously.

What the seam promises

Learner::fit returns Result<_, String> rather than the crate's error enum on purpose: a caller scoring a grid must treat one rejected point as an ordinary event and keep the other candidates competing, and the whole value of a rejection is its sentence — MlError::Backend already carries the trainer's stderr tail. Both traits carry Sync because a grid search fits and predicts from several threads at once.

fit_identity is the key half of a cross-run fit cache: a content digest of everything a fit would consume, defaulting to None. Two calls may share an identity only when their fits are bit-identical by construction, because a wrong hit silently corrupts results — so None means "cannot prove it", and a cache must then never serve or store for that learner, which is the safe direction. An implementation digests the actual bytes (rendered configs, the data as the trainer reads it, the binary's provenance), never a description such as a path or a fold id. The fit cache is the consumer.

fit_captured is fit plus whatever Capture asks be kept: the fitted model's per-feature importance, its own serialised text, or both. It is one method with a request parameter rather than a second name because a caller asking for both would otherwise pay the fit twice — identical trees, twice the wall clock — on the fit nothing caches. The flags are answered independently, so a text-only request cannot be failed by an importance parse it never wanted. The default answers None to every artifact, and the asymmetry between the absences belongs to the caller: an empty importance table positively misinforms ("no feature mattered"), so a caller should refuse rather than write one, while an absent model text costs only the export.

Capture::text is the trainer's own bytes

The text a learner hands back is its own serialised output, never a re-serialisation of the parsed model: this crate parses LightGBM's save_model text and deliberately has no writer, since a writer would be a second spelling of the format to keep in step with the trainer's.

ProbaModel for GbdtModel is a one-line delegation to the walker — 1 / (1 + exp(-sigmoid * raw)) for objective=binary — and its tests pin that the impl does not sanitise: a NaN raw score reaches the caller as a NaN probability that a ranking caller scores worst (clamping to 0.5 would let a failed fit rank like a mediocre model and win), while an infinite raw score saturates to exactly 1 or 0. Inference is the walker.

The point a search moves

crates/vike-ml/src/search.rs's GridPoint is the five searched axes as one Copy, const-constructible struct — num_leaves, max_depth, min_data_in_leaf, learning_rate, feature_fraction — with the non-searched parameters deliberately absent, because a base copy of a searched axis is dead state whose only effect would be to give a dropped mapping a plausible value to fail silently with. ParamPoint is the free-form name/value bag, and GbdtParams::apply refuses a name no parameter matches, so a swept knob that was never connected is an error rather than a winner that means nothing. DEFAULT_POINT (15 / 4 / 20 / 0.05 / 0.7) is the fallback for a search with no winner, and its doc says it is not a recommendation.

The one real learner

crates/vike-ml/src/gbdt_learner.rs opens with its claim: the one Learner that drives a real trainer; everything else implementing the seam is a double. It was a study's own adapter, promoted under two conditions visible in GbdtLearner::new(binary, scratch, base): the base parameter bag is a constructor parameter, so no caller's regularisation is compiled into the library (GbdtParams::default is LightGBM's own defaults), and the bin cache's capacity is a fixed DEFAULT_BIN_CAPACITY of 64 with with_bin_capacity as the override rather than a function of a thread pool, so promotion added no dependency. Capacity is a performance knob only — an evicted entry is re-binned, so no value of it can change a model.

The header answers four questions: the non-searched parameters live in the caller's base bag, applied by params_for, and a base must not restate a searched axis; a categorical column is declared as a comma-separated string of feature indices, refused before anything spawns if one is out of range; fit and the model are Sync, and each fit gets its own scratch directory because two concurrent fits sharing one config path would train each other's parameters; the error is a String. Two divergences from the original oracle are stated up front: num_threads = 1, because the pinned binary is built without OpenMP and thread count changes the float reduction order, so fits are parallel across processes rather than inside one; and a full 243-point grid rather than 20 sampled trials, which belongs to the caller's search — this file fits one point.

fit checks the shape, finalises the parameters, takes a pre-binned dataset from the fold-keyed BinCache — keyed on the data's content plus the two inputs binning bakes, min_data_in_leaf (the one searched axis consumed at dataset construction) and the categorical declaration, with a OnceLock per key so concurrent first fits bin once — trains into a private directory, parses the model text, and removes the directory on both outcomes. A fold's 20 search trials therefore write and parse the ~8 MB training CSV at most three times instead of twenty, and the models are bit-identical to per-fit CSV construction, proven by a matrix smoke against the real binary. Its fit_identity enumerates every input — the CSV bytes as streamed, the two rendered configs with path lines normalised, the trainer's PROVENANCE digest, the model text-format version — under a tag frozen at vike-research fit-identity v1, a cache key rather than a citation.

The handover is gated rather than trusted: a crate-private Trainer seam lets a recording double assert the trainer is handed the parameters the fit just built, because substituting the default bag used to leave every unit test green while every grid point trained identically and the search still named a winner. What no in-process test can gate — that LightGBM honours what it is handed — is the equality gate's job, in Model pins. Training is the process underneath, and a study is what moves the point — the crate enumerates no grid of its own, so a caller walks its own five-axis space.

On this page