vike
Research

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, and feature_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 against max_feature_idx + 1 is the only honest check available;
  • per Tree=<k> section, split_feature= (one feature index per internal node, num_leaves - 1 of them) and split_gain= (the same count, pairwise: entry j of both describes the same split);
  • for a constant tree — num_leaves=1, LightGBM's AsConstantTree when 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_feature index 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= or split_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 before max_feature_idx=, or a text with no max_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.

On this page