vike
Tutorials

Fit a model and read what it learned

Run a study that fits a gradient-boosted classifier through the pinned trainer, then turn the saved model into a probability series and a feature-importance table.

Vike Trader splits machine learning down one line: fitting drives a child process, inference is pure Rust. crates/vike-ml/src/cli.rs argues the first half — a measured bakeoff disqualified every Rust binding because they drop categorical_feature at dataset construction silently and then record it in the saved model's parameter block anyway, an artifact that lies to an auditor — and driving the upstream binary costs no crate, no FFI, no unsafe, and nothing in the supply-chain audit surface. crates/vike-ml/src/infer.rs and importance.rs are the second half: pure functions over the model text, working identically everywhere.

That split decides where you can stand. The tool provisioning skips the LightGBM binary on every non-Linux host, because it is a Linux x86-64 executable and fetching it onto Windows or macOS "installs a file nothing there can execute" — you can name it explicitly to stage a project folder for a Linux box. So: fit where the binary runs, read the model anywhere.

Fit it

vike-study is the study runner that can fit. It is feature-gated (study-cli) and lives beside the function it calls rather than in the CLI, which is deliberately kept free of the DataFusion dependency this needs.

vike-study --study NAME --recipe FILE --from WHEN --to WHEN --store DIR
           [--lightgbm PATH] [--scratch DIR] [--runs-root DIR]

Three of those flags carry a decision:

  • --lightgbm PATH — the pinned trainer. Omitted, the study runs with no learner, and a fitting study refuses by name rather than silently producing nothing. Before this binary existed nothing in the workspace could fit a compiled study at all: the Studio's ML surface is the inference half only, so it supplies no learner and a study needing a classifier per fold answered NoLearner on every invocation.
  • --scratch DIR — per-fit scratch, defaulting under the project folder, never the system temp directory.
  • --recipe FILE — the study's own TOML, which may carry a [learner] table.

That table exists because a study's non-searched LightGBM parameters are a property of the study — the keys its published numbers were measured at — while the learner type knows nothing about any study. In the cohort study those five keys had no home a host could reach: they lived in a test-only module, so any real run would have used LightGBM's defaults and quietly fitted a different model than the one every recorded number came from. An absent table means the trainer's own defaults, which is the right answer for a caller with no opinion and the wrong one to assume in silence — so the run prints which of the two it used.

The pins, and what they do not catch

Two constants decide whether a model file and this crate are talking about the same thing: MODEL_VERSION, the text's own version= line, and PINNED_LIGHTGBM_TAG, the upstream release the binary was built from, asserted at startup by LightGbmCli::new.

The binary cannot name itself

lightgbm --version does not exist — upstream's main.cpp has no version handling, and the flag just produces an unknown-parameter warning and a non-zero exit. So the check reads the tag= line out of the PROVENANCE file the build recipe writes beside the binary. A binary with no PROVENANCE beside it — the ordinary state of one built by hand — is a refusal naming both paths and the recipe to fix it, never a panic. The honest weakness, stated in the module rather than implied: that file is self-reported by our own recipe and only its tag= line is ever read.

The deeper residual is worth carrying before you trust a version check: version=v4 does not change when fields are added within v4, and the parser tolerates unknown key=value lines silently — it must, since LightGBM already writes several the walker has no use for. A new per-node array with routing meaning would be read as noise, dropped, and every prediction would be quietly wrong while the version check stayed green. The equality gate that trains and predicts through both implementations, not the version check, is the real proof.

Packing the data

TrainData::new(x, y, n_cols) validates the shape once, here, rather than letting a child process discover it and report it as a parse error forty lines into a file. x is row-major n_rows × n_cols f64; y is one f32 label per row, because f32 is LightGBM's own label width. with_categorical declares categorical columns and refuses an index outside a row.

The list-parameter trap, and the type that closes it

GbdtParams spells LightGBM's own parameter names, and each field is rendered as one key=value line. LightGBM splits list parameters on commas but does not strip brackets: categorical_feature=0,3 names two columns, while categorical_feature=[0,3] — the shape anyone reaches for who has used the Python API — names nothing. The column would then be binned as numerical, nothing would error, and the saved model's parameters: echo would still claim it was categorical. The defence is a type rather than a comment: the field is an Option<String>, so a list cannot be written, and categorical_feature_csv is how a list of indices becomes one. Indices are 0-based over feature columns; the label column is not counted.

The five searched axes travel separately as a GridPoint: num_leaves, max_depth, min_data_in_leaf, learning_rate, feature_fraction. DEFAULT_POINT is 15 / 4 / 20 / 0.05 / 0.7 — conservative on every axis relative to LightGBM's own 31 / -1 / 20 / 0.1 / 1.0 — and its doc is emphatic that it is a fallback, not a recommendation: a caller that reaches it has learned nothing about its data.

Read what came out

crates/vike-studio-core/src/ml.rs's ScoringModel is the whole reading surface. from_path or from_text parses the walked model and its importance table in one load, and refuses as one — a text whose two halves disagree is not half-loaded. Then:

  • score_rows(flat_x, n_features) turns a row-major matrix into the probability series, checking the stride once for the whole matrix and refusing a length that is not a whole number of rows.
  • score_row(row) is the checked single-row door. It refuses a row narrower than the model, because the walker substitutes NaN for a column the row does not carry, routes that through the tree, and returns a probability that looks like a probability. A wider row is fine; its trailing columns are ignored.
  • importance_ranked() returns FeatureWeight rows — index, optional name, gain, splits — ordered descending by gain with ties broken by ascending index, compared with total_cmp so a NaN out of a doctored text sorts to one end instead of corrupting the sort.

Every feature, including the ones no tree split on

importance_ranked deliberately keeps the zero rows. "This input mattered not at all" is an answer, and it is the half of the report that tells you what to stop computing.

Names are the last thing to check. The training CSV this crate writes is headerless, so LightGBM auto-generates Column_0, Column_1, … and index-to-name mapping belongs to whoever packed the matrix. A model naming no column is therefore accepted and every weight's name is None; one naming some is refused, because that is the shifted-table shape, and a misaligned importance table attributes the model's behaviour to the wrong feature — worse than no table at all.

Next: models for the learner seam, training and inference for each half, feature importance for the parse, and model pins for the bump procedure.

On this page