Feature importance
Gain and split count per feature, reconstructed from the trainer's own model text in a separate pass inference never reads — the refusals that keep the table aligned with the caller's columns, and the one scorer the crate ships.
crates/vike-ml/src/importance.rs answers a question about the fit rather than about a
prediction: which feature columns did the trees actually split on, and how much gain did each one
contribute. Its header explains why the parser lives in vike-ml — everything it knows is a fact
about this crate. It parses the text train tells LightGBM to write and parse reads back, it
was verified against the binary pins pins, its column convention is TrainData::write_csv's,
and what it reconstructs is precisely split_gain, one of the fields
crates/vike-ml/src/model.rs states it parses past and drops because inference has no use for
it. A consumer that owned this parser would be documenting the crate's format from the outside and
would need re-verifying every time either side moved; it was written that way once, in a research
crate since dissolved, and this is that file having come home.
The header is emphatic that inference reads none of this.
importance_from_model_text is a separate fold over the same text, and nothing about it changes
what the walker keeps.
The format, verified rather than remembered
The header records the verification: trained with the pinned v4.7.0 binary on a CSV in the exact
shape write_csv produces — label first, no header — the model text carries:
- a header line
max_feature_idx=<N-1>, where the label column is not counted, andfeature_names=Column_0 Column_1 …— auto-generated, because the training CSV this crate writes has no header. The model file therefore cannot name a caller's columns; index-to-name mapping belongs to whoever packed the matrix, and a length equality againstmax_feature_idx + 1is the only honest check available; - per
Tree=<k>section,split_feature=(one feature index per internal node,num_leaves - 1of them) andsplit_gain=(the same count, pairwise: entryjof both describes the same split); - for a constant tree —
num_leaves=1, LightGBM'sAsConstantTreewhen a fit could not split — both keys with an empty value, not an absent line. Hand-written minimal models omit the lines entirely; both spellings mean zero splits and both are accepted; end of trees, after which per-tree lines never appear. Anything after it — the parameter echo LightGBM writes there — is ignored.
The result
FitImportance is plain data: n_features, the feature count the model declares
(max_feature_idx + 1), and two vectors indexed by feature column position — gain, the total
split gain per feature with 0.0 for one no tree ever split on, and splits, how many splits
used each feature with 0 for one never used. The three lengths agree by contract. The field doc
on n_features says what the caller does with it: compare it against the matrix it packed and
refuse on mismatch, because a misaligned index-to-name mapping silently attributes importance to
the wrong feature. The explicit zeros are the "which columns were never used" half of the report.
Why it refuses instead of shrugging
The header's argument: a misaligned importance table is worse than none, because it attributes
the model's behaviour to the wrong named feature and poisons every downstream pruning decision. So
each of the following is an Err naming the tree, never a row silently skipped:
- a
split_feature=/split_gain=pair whose counts disagree; - a
split_featureindex at or past the declared width — the test for it is called the misattribution guard, since growing the vector would let a model trained on a wider matrix than the caller packed produce a plausible table whose every row is shifted; - an unparsable entry in either array;
- a
split_feature=orsplit_gain=line without its partner, which the message calls a doctored or truncated tree section; - either key appearing twice in one tree;
- a
Tree=section arriving beforemax_feature_idx=, or a text with nomax_feature_idx=at all.
The error is a String rather than MlError, and the header says why: every message names a
tree, and neither candidate variant frames that — Parse wants a line number this fold
deliberately does not track, and Shape renders a pairing fault as a row/feature count. The whole
value of these refusals is the sentence.
Structural completeness is the other parser's job
A text that never said end of trees still folds its last tree here. parse_model_text, which
runs first on every real fit, is the authority on whether a model file is complete; this parser's
own job ends at correct pairing.
How a study asks for it
crates/vike-ml/src/learner.rs's fit_captured doc records how the request reaches a fit. The
importance table and the model text are both artifacts of one fit, and Learner::fit_captured
(crates/vike-ml/src/learner.rs's Capture and CapturedFit) carries the request as a parameter
rather than as a second method, so a caller asking for both never pays for the fit twice. The two
artifacts are independent by construction — a text-only request runs no importance parse and
cannot be failed by one — and what each absence costs is the caller's decision, not the crate's.
The shared test double, ScriptedLearner, reports a scripted FitImportance only when a
Capture actually asks for it, the way a real learner answers.
Train a model is the tutorial that reads the table out of a
study.
The one scorer: binary_logloss
crates/vike-analytics/src/metrics.rs holds the crate's scores over predictions — pure functions, no
model and no training involved. It ships one: binary_logloss(labels, probs), the mean binary
cross-entropy -mean(y ln p + (1 - y) ln(1 - p)), lower is better. Its doc pins three edges. Each
probability is clamped to [LOGLOSS_EPS, 1 - LOGLOSS_EPS] with LOGLOSS_EPS = 1e-15, the value
LightGBM's own metric uses, because without it a confidently wrong prediction scores +inf and a
search would rank two catastrophic candidates as equal. An empty input returns NaN rather than
0.0, because zero is the best possible score and an empty validation fold would otherwise win a
grid search outright. A length mismatch scores over the shorter overlap: the signature is
infallible, and truncating to the aligned prefix never fabricates a label or a probability.
Two arithmetic choices are deliberate and opposite to the walker's. The sum is
vike_model::py_sum, the workspace's Neumaier-compensated fold, and this is the only place in the
crate that uses it — hundreds of small logs in arrival order are exactly what compensation exists
for — while the tree walk does a plain fold because it must reproduce LightGBM's accumulation bit
for bit. And both logarithms are libm::log, not f64::ln: the doc records that the platform
libms disagree in the last bit across most of this function's domain, and a log-loss is a number
two grid points are ranked by, so a last-bit difference is a different winner at a tie.
Nothing in the workspace calls it yet
The doc says so plainly: binary_logloss is latent public-API surface. No caller's fixtures move
when the arithmetic underneath it changes, and the first caller will be measuring the libm
answer from its first run.
The fit cache
The cross-run cache over the learner seam — a validation score content-addressed by everything the fit consumed, why a wrong hit is worse than no cache, and what the key deliberately leaves to the caller.
Model pins
The two constants that decide whether a model file and the crate agree — what each proves, what neither proves, the equality gate that is the real bump proof, and the category map that is a wire format with no version of its own.