feat(fit): fit-measure — a fitted tree as an ordinary improvise formula

improvise-szrt / improvise-vx3. `improvise-gp` was a bridge with nothing
crossing it; this is the crossing.

`improvise_gp::fit::fit_measure` is the sequence between the two halves that
already existed: `dataset` (a View's records as a feature matrix) and
`fitted_formula` (a champion as formula text the document can resolve). It
lives in `improvise-gp` rather than in the command because none of it is
terminal-specific — a browser fitting the same measure must run exactly these
steps in exactly this order or it gets a different tree from the same seed.

`command/cmd/fit.rs` is the user-facing surface: `fit-measure <target>
[<measure-name>] [<seed>]`, returning `SetStatus` + `MarkDirty` + `AddFormula`.
After it there is nothing GP-specific in the document — the rule is a
`## Formulas` line, recomputed by the fixed-point evaluator, editable in the
formula panel, drillable, saved with no grammar change.

**Where the fit runs, and why.** In `execute`, on the command side. A fit is
decision logic — a pure function of (Model, View, target, seed, FitConfig)
that mutates nothing — so it belongs where decision logic belongs; putting an
expensive stochastic search inside `Effect::apply` would mix it into the one
place the app mutates. That buys purity, not asynchrony, so vx3's AC 4 is met
on its "bounded" arm: `FitRequest::default` is a smaller search than the CLI's,
**measured** at 6ms / 12ms / 105ms over 12 / 200 / 2,000 records in release.

**Three things the sequence had to get right**, each of which fails quietly:

* The fit is standardized and the formula must not be. Written down untouched,
  a champion predicts standard deviations — a well-formed formula evaluating to
  ±5 where the user's data is in the thousands, and nothing downstream can see
  it. `denormalize` folds the affine map back into the leaves first.
* The rule is stored as a *new* measure (`<target> (fit)`), never as the target
  itself: gotcha 7 makes a formula target and an explicit item one item, so the
  cells the fit was learned from would stop being visible.
* `Quality` is an enum. `cv_regress` pools nothing below two folds and scoring
  an empty pool gives R² exactly 0 — "nobody checked" reading as "this rule is
  worth nothing", which is the loudest thing a fit can say.

**A bug this found, fixed with its own failing test first.** `_Measure` is a
filtering category, and `Workbook::new`'s default view puts it on `Axis::Page`
— so `dataset::filter_coords` pinned it to one measure and the feature matrix
collapsed. On a document whose target is `_Measure`'s first item every feature
column came back blank; otherwise the table was empty and the error blamed the
data ("no visible record has a Revenue value"). `_Measure` is the coordinate
the columns are spread across, so paging it is not "which records am I looking
at", it is "throw away every column". Every existing test in that module built
its view with `View::new` + `on_category_added`, under which an underscore
category lands on `Axis::None` — the fixture was building the one world the
code worked in. The two new regression tests compare row *contents*, because
asserting the header and the row count passes with the bug present.

Evidence, and the faults injected to earn it. Dropping the `denormalize` call
fails `the_fitted_formula_predicts_in_the_documents_own_units` and nothing
else. Both `_Measure`-on-Page tests were watched failing before the one-line
fix. Every new test asserts against a real document — never a helper that
fabricates a `_Measure` item per feature name, which is what let improvise-8xhy
hide. 1216 passing, up 27 from 1189; suite still well under 2s.

Also fixes the refusal message, which a `rustfmt` pass over its multi-line
literal had left with runs of ten spaces mid-sentence — invisible in the source
and to every test that only asked whether a substring was present, and visible
to precisely the user it exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-19 22:45:23 -07:00
parent 9c6de57542
commit e46429ecfc
8 changed files with 982 additions and 4 deletions
Generated
+1
View File
@@ -862,6 +862,7 @@ dependencies = [
"anyhow",
"improvise-core",
"improvise-formula",
"improvise-gp",
"improvise-io",
"improvise-protocol",
"serde",
+1
View File
@@ -11,6 +11,7 @@ improvise-core = { path = "../improvise-core" }
improvise-protocol = { path = "../improvise-protocol" }
improvise-formula = { path = "../improvise-formula" }
improvise-io = { path = "../improvise-io" }
improvise-gp = { path = "../improvise-gp" }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -0,0 +1,291 @@
//! `fit-measure`: evolve a decision tree over what the active view shows and
//! store it as an ordinary improvise formula.
//!
//! After this command there is nothing GP-specific left in the document — the
//! rule is a `## Formulas` line like any other, recomputed by the fixed-point
//! evaluator, editable in the formula panel, drillable, and saved with no new
//! grammar (improvise-21d's enabling fact, and improvise-7na's answer).
//!
//! # The fit runs inside `execute`, and that is the architecture rather than a
//! shortcut
//!
//! A `Cmd` is pure over `&CmdContext` and returns effects; an `Effect` is the
//! only mutation site. A fit is **decision logic** — a pure function of
//! `(Model, View, target, seed, FitConfig)` that mutates nothing — so it
//! belongs on the command side, and what the effects carry is its *result*.
//! Putting the search inside an `Effect::apply` would have mixed an expensive
//! stochastic computation into the one place the app mutates, which is the
//! split's whole point.
//!
//! What this does **not** buy is asynchrony. The command runs on the thread
//! that draws the next frame, so a fit is bounded rather than cancellable:
//! [`FitRequest::default`] is a deliberately smaller search than the CLI's
//! (improvise-vx3's AC 4 asks for "cancellable **or** bounded"). Moving it off
//! the main loop is a change to `draw.rs`'s event loop, not to this file, and
//! nothing here forecloses it — the fit is already a pure function that could
//! be handed to a worker and have its `FitOutcome` posted back as the same
//! effects.
//!
//! # What a refusal is for
//!
//! `improvise_gp::fit_measure` fails rather than storing something misleading
//! — most sharply when the champion splits on an encoding artefact like
//! `Region=North`, which is not an item of anyone's document, so every cell of
//! the stored measure would be `Error("ref:…")` (improvise-8xhy, repo-map
//! gotcha 37d). This command is where a user actually meets that restriction,
//! so the refusal goes to the status line **whole**: it names the features that
//! could not be resolved, because "the fit failed" is not something a user can
//! act on and "it split on Region, which is a category rather than a measure"
//! is.
use improvise_gp::fit::{FitRequest, fit_measure, fitted_measure_name};
use crate::command::cmd::core::{Cmd, CmdContext};
use crate::ui::effect::{self, Effect};
/// Fit `target` from the other measures and categories the view shows, storing
/// the rule as the measure `into`.
#[derive(Debug)]
pub struct FitMeasure {
pub target: String,
pub into: String,
pub request: FitRequest,
}
impl FitMeasure {
/// A nameless instance for `register_pure`, which reads a command's name
/// off a prototype rather than off a string literal (gotcha 15).
pub fn prototype() -> Self {
Self {
target: String::new(),
into: String::new(),
request: FitRequest::default(),
}
}
/// `fit-measure <target> [<measure-name>] [<seed>]`.
///
/// The seed is an argument rather than a clock read for the reason the CLI
/// fixes its own default: two fits of the same document should agree, and a
/// user who wants a different search asks for one. It is reported back on
/// the status line so the fit that landed can be asked for again.
pub fn parse(args: &[String]) -> Result<Box<dyn Cmd>, String> {
let [target, rest @ ..] = args else {
return Err("fit-measure requires a target measure".into());
};
let (into, seed) = match rest {
[] => (fitted_measure_name(target), None),
[into] => (into.clone(), None),
[into, seed] => (
into.clone(),
Some(
seed.parse::<u64>()
.map_err(|e| format!("fit-measure: {seed:?} is not a seed: {e}"))?,
),
),
_ => {
return Err(format!(
"fit-measure takes 1-3 arguments, got {}",
args.len()
));
}
};
Ok(Box::new(FitMeasure {
target: target.clone(),
into,
request: FitRequest {
seed: seed.unwrap_or(FitRequest::default().seed),
..FitRequest::default()
},
}))
}
}
impl Cmd for FitMeasure {
fn name(&self) -> &'static str {
"fit-measure"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
match fit_measure(ctx.model, ctx.view, &self.target, &self.into, &self.request) {
// The report first and `AddFormula` last, so a formula the emitter
// spelled but the parser will not take leaves *its* message on
// screen rather than this command's optimistic one (gotcha 20).
Ok(outcome) => vec![
effect::set_status(outcome.status_line()),
Box::new(effect::MarkDirty),
Box::new(effect::AddFormula {
raw: outcome.formula.raw.clone(),
target_category: outcome.formula.target_category.clone(),
}),
],
Err(e) => vec![effect::set_status(format!("fit-measure: {e}"))],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command::cmd::test_helpers::*;
use crate::model::category::CategoryKind;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
const MEASURE: &str = "_Measure";
const REGION: &str = "Region";
const TAG: &str = "Tag";
/// A document a tree can learn exactly — `Score` steps at `Cost` 50 — in
/// the units a real document uses, and with `Region` single-valued so its
/// one-hot has the degenerate range `(1, 1)` that `simplify` prunes. The
/// champion therefore cannot reach an unresolvable feature and the success
/// path is deterministic rather than seed-dependent; `Tag` is a `Label`
/// category, which `dataset` keeps out of the features while still making
/// the twelve records distinct. See `improvise_gp::fit`'s own fixture.
fn workbook() -> crate::workbook::Workbook {
let mut wb = crate::workbook::Workbook::new("sales");
wb.add_category(REGION).expect("first category");
wb.add_item(REGION, "North").expect("Region exists");
wb.add_category(TAG).expect("second category");
wb.model.category_mut(TAG).expect("just added").kind = CategoryKind::Label;
for i in 0..12u32 {
wb.add_item(TAG, format!("r{i}")).expect("Tag exists");
}
let measure = wb
.model
.category_mut(MEASURE)
.expect("_Measure always exists");
for item in ["Cost", "Score"] {
measure.add_item(item);
}
for i in 0..12u32 {
let cost = 10.0 * f64::from(i);
wb.model.set_cell(cell(i, "Cost"), CellValue::Number(cost));
wb.model.set_cell(
cell(i, "Score"),
CellValue::Number(if cost > 50.0 { 4000.0 } else { 1000.0 }),
);
}
wb.active_view_mut().set_axis(REGION, Axis::Row);
wb
}
fn cell(i: u32, measure: &str) -> CellKey {
CellKey::new(vec![
(REGION.to_string(), "North".to_string()),
(TAG.to_string(), format!("r{i}")),
(MEASURE.to_string(), measure.to_string()),
])
}
fn cmd(args: &[&str]) -> Box<dyn Cmd> {
FitMeasure::parse(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
.expect("the arguments parse")
}
/// Run `fit-measure` the way the app does: build the read-only context
/// from a workbook, execute, and apply the effects to a real `App`.
fn run(args: &[&str]) -> crate::ui::app::App {
let wb = workbook();
let layout = make_layout(&wb);
let registry = make_registry();
let effects = cmd(args).execute(&make_ctx(&wb, &layout, &registry));
let mut app = crate::ui::app::App::new(workbook(), None);
app.apply_effects(effects);
app
}
/// The end-to-end claim of improvise-vx3, asserted against a real
/// document: a command fits, the formula lands through the ordinary
/// `add_formula` path, and the new measure evaluates to numbers.
#[test]
fn the_command_installs_a_formula_the_document_evaluates() {
let app = run(&["Score"]);
let model = &app.model_state.workbook.model;
assert!(
model.formulas().iter().any(|f| f.target == "Score (fit)"),
"no fitted formula was stored: {:?} / {:?}",
model.formulas().iter().map(|f| &f.raw).collect::<Vec<_>>(),
app.view_state.status_msg,
);
for i in 0..12u32 {
match model.evaluate(&cell(i, "Score (fit)")) {
Some(CellValue::Number(_)) => {}
other => panic!("record r{i} came back as {other:?}"),
}
}
}
/// Adding a rule is a change to the document, so the command marks it
/// dirty — otherwise the fit is lost on the next quit without a prompt.
#[test]
fn a_fit_marks_the_document_dirty() {
assert!(
!crate::ui::app::App::new(workbook(), None).model_state.dirty,
"the fixture starts clean"
);
assert!(run(&["Score"]).model_state.dirty);
}
/// improvise-vx3 is emphatic that the rule alone is not the deliverable:
/// a tidy tree that generalizes no better than the mean is worse than
/// useless. So the status line carries the held-out comparison, what the
/// rule leans on, and the seed that reproduces it.
#[test]
fn the_status_line_reports_quality_against_the_baseline() {
let app = run(&["Score"]);
let status = &app.view_state.status_msg;
for expected in ["R\u{b2}", "baseline", "leans on", "seed"] {
assert!(
status.contains(expected),
"{expected:?} missing from {status:?}"
);
}
}
/// A refusal reaches the user *whole*. "fit failed" is not actionable;
/// naming the measure that does not exist is (improvise-8xhy's argument,
/// arriving at the place a user meets it).
#[test]
fn a_refusal_names_what_went_wrong_and_stores_nothing() {
let wb = workbook();
let layout = make_layout(&wb);
let registry = make_registry();
let effects = cmd(&["Margin"]).execute(&make_ctx(&wb, &layout, &registry));
assert_eq!(effects.len(), 1, "a refused fit must emit no model change");
let mut app = crate::ui::app::App::new(workbook(), None);
app.apply_effects(effects);
assert!(
app.view_state
.status_msg
.contains("not a measure holding data"),
"{:?}",
app.view_state.status_msg
);
assert!(app.model_state.workbook.model.formulas().is_empty());
}
/// gotcha 37b: the seed is half of what reproduces a fit, so it is an
/// argument — and a different one really does search differently.
#[test]
fn the_seed_argument_changes_the_search() {
let wb = workbook();
let layout = make_layout(&wb);
let registry = make_registry();
let ctx = make_ctx(&wb, &layout, &registry);
assert_ne!(
effects_debug(&cmd(&["Score", "A", "1"]).execute(&ctx)),
effects_debug(&cmd(&["Score", "A", "99"]).execute(&ctx)),
"two seeds produced the identical batch of effects"
);
}
#[test]
fn a_target_is_required_and_a_bad_seed_is_named() {
assert!(FitMeasure::parse(&[]).is_err());
let err = FitMeasure::parse(&["Score".into(), "A".into(), "later".into()])
.expect_err("\"later\" is not a seed");
assert!(err.contains("is not a seed"), "{err}");
}
}
@@ -2,6 +2,7 @@ pub mod cell;
pub mod commit;
pub mod core;
pub mod effect_cmds;
pub mod fit;
pub mod grid;
pub mod mode;
pub mod navigation;
@@ -15,6 +15,7 @@ use super::cell::*;
use super::commit::*;
use super::core::*;
use super::effect_cmds::*;
use super::fit::FitMeasure;
use super::grid::*;
use super::mode::*;
use super::navigation::*;
@@ -54,6 +55,7 @@ pub fn default_registry() -> CmdRegistry {
},
);
r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse);
r.register_pure(&FitMeasure::prototype(), FitMeasure::parse);
r.register_pure(&RemoveFormulaCmd(vec![]), RemoveFormulaCmd::parse);
r.register_pure(&CreateViewCmd(vec![]), CreateViewCmd::parse);
r.register_pure(&DeleteViewCmd(vec![]), DeleteViewCmd::parse);
+61 -3
View File
@@ -113,12 +113,23 @@ fn cell_text(value: &CellValue) -> String {
}
}
/// The coordinates every visible record shares, exactly as `GridLayout::new`
/// derives them: one selection per filtering category, defaulting to its first
/// item when the view names none.
/// The coordinates every visible record shares — one selection per filtering
/// category, defaulting to its first item when the view names none, exactly as
/// `GridLayout::new` derives them, **except** that `_Measure` never
/// constrains.
///
/// That exception is not a special case, it is the difference between a grid
/// and a feature matrix. `_Measure` is the coordinate the *columns* are spread
/// across, so pinning it to one item is not "which records am I looking at" but
/// "throw away every column except this one" — and `Workbook::new`'s default
/// view puts it on `Axis::Page`, so that was the behaviour on a freshly opened
/// document. Either the target's own column survived and every feature came
/// back blank, or, on a document whose target is not `_Measure`'s first item,
/// the table was empty and the error blamed the data.
fn filter_coords(model: &Model, view: &View) -> Vec<(String, String)> {
view.filtering_cats()
.into_iter()
.filter(|cat| !CategoryKind::of_name(cat).is_measure())
.filter_map(|cat| {
let selected = view
.page_selection(cat)
@@ -395,6 +406,53 @@ mod tests {
);
}
/// Regression (improvise-vx3): **`_Measure` is a filtering category and
/// must not filter a fit.** `Workbook::new`'s default view puts it on
/// `Axis::Page`, so `filtering_cats()` named it, `filter_coords` pinned it
/// to the first measure, and every "record" then carried exactly one
/// measure cell — never the target. The whole table came back as "no
/// visible record has a Revenue value to learn from" on a document plainly
/// full of them, which reads as a data problem rather than as an axis one.
///
/// The rule that fixes it is not a special case: a fit's target *and* its
/// features are measures, spread across the `_Measure` coordinate, so
/// paging to one is not "which records am I looking at" but "throw away
/// every column". Page ∪ Filter still scopes the rows in every other
/// category, which is what the test below this one pins.
///
/// Every test in this module used `View::new` + `on_category_added`, under
/// which a leading-underscore category lands on `Axis::None` — so the
/// fixture was quietly building the one world the code worked in, and no
/// test here could see the default a real `Workbook` hands out.
#[test]
fn a_measure_on_the_page_axis_does_not_filter_away_the_training_rows() {
let (model, mut view) = fixture();
let unfiltered = table(&model, &view);
view.set_axis(MEASURE_CATEGORY, Axis::Page);
let paged = feature_table(&model, &view, "Revenue")
.expect("paging the measure axis is not a reason to have no data");
// The *contents*, not the shape. Pinning only the header and the row
// count passes on this fixture even with the bug present: `_Measure`'s
// first item is `Revenue`, so every row still has a target and only
// the feature columns come back blank — the shape survives while the
// data does not, which is exactly the silent failure to guard against.
assert_eq!(paged, unfiltered);
}
/// The other half, and the sharper one: which measure `filter_coords`
/// would have pinned to is the *first* item, so on a document whose target
/// is not first the table came back empty and the error blamed the data —
/// "no visible record has a Revenue value to learn from", on a document
/// plainly full of them.
#[test]
fn a_paged_measure_that_is_not_the_target_does_not_empty_the_table() {
let (model, mut view) = fixture();
let unfiltered = table(&model, &view);
view.set_axis(MEASURE_CATEGORY, Axis::Page);
view.set_page_selection(MEASURE_CATEGORY, "Cost");
assert_eq!(table(&model, &view), unfiltered);
}
/// gotcha 27: what is *visible* is Page ∪ Filter. Scoping by
/// `categories_on(Axis::Page)` would fit against rows a drill has
/// already excluded, and say nothing about it.
+616
View File
@@ -0,0 +1,616 @@
//! One fit, end to end: a [`View`]'s records in, an improvise [`Formula`] and
//! the evidence for believing it out.
//!
//! [`dataset`] already turns what a view shows into the feature matrix a search
//! consumes, and [`crate::fitted_formula`] already turns a champion into
//! formula text the document can store. This module is the sequence between
//! them, and it exists here rather than in a command because none of it is
//! terminal-specific: a browser fitting the same measure over the same document
//! must run exactly these steps in exactly this order, or it gets a different
//! tree from the same seed.
//!
//! **Nothing here prints** (repo-map gotcha 37a). The quality numbers, the
//! importance ranking and `preprocess`'s dropped columns all come back as data
//! on [`FitOutcome`], and deciding they become a status line, a panel or a
//! stderr note is the host's business. [`FitOutcome::status_line`] is a
//! *rendering* of that data offered so a second front end does not compose its
//! own — the same argument that put `flatten_cat_tree` in the shared layer —
//! and it is a returned `String`, never a `println!`.
//!
//! # Three things the sequence has to get right
//!
//! **A fit is standardized and the formula must not be.** The crate's leaf
//! scores live in a ±5 range, so a target in the thousands cannot be reached by
//! leaf mutation and [`gp_tree::fit::standardize`] is not optional. Its
//! consequence is that the champion predicts *standard deviations*: `fit_csv`
//! answers that by printing `≈ score × std + mean` beside the tree, which a
//! document has nowhere to put. So the affine map is folded back into the
//! leaves with [`gp_tree::fit::denormalize`] before a single character of
//! formula text is spelled. Omitting it emits a perfectly well-formed formula
//! whose every cell is out by an affine map — wrong in the quiet way, since
//! small numbers in the right shape look like an answer.
//!
//! **The seed alone does not reproduce a fit** (gotcha 37b), so
//! [`FitOutcome`] carries the [`FitConfig`] beside it. Every stochastic step
//! draws from one generator in a fixed order — cross-validate, then fit, then
//! explain — so the pair really is sufficient.
//!
//! **Cross-validating nothing is not the same as explaining nothing.**
//! [`gp_tree::fit::cv_regress`] pools no pairs below two folds, and scoring an
//! empty pool gives an R² of exactly zero — which reads as "this rule is worth
//! nothing" when what happened is that nobody checked. [`Quality`] is an enum
//! so the two cannot be confused, and a host that wants the numbers has to name
//! the case where there are none.
use gp_tree::data::{Overrides, Target};
use gp_tree::fit::{
FitConfig, RegressionQuality, cv_regress, denormalize, fit, importance_ranking, mse,
permutation_importance, regression_quality, standardize,
};
use gp_tree::{Spec, SplitMix64};
use improvise_core::model::Model;
use improvise_core::view::View;
use improvise_formula::Formula;
use crate::dataset::dataset;
/// What to ask of a fit. Everything a run's outcome depends on, so that
/// `(FitRequest, document)` reproduces it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FitRequest {
/// The seed for every stochastic step.
pub seed: u64,
/// The search's knobs — necessary beside the seed, not implied by it.
pub config: FitConfig,
/// Cross-validation folds. Below two, no held-out evidence is produced and
/// [`Quality::Unmeasured`] says so.
pub cv_folds: usize,
/// Shuffles per feature when measuring permutation importance.
pub importance_repeats: usize,
}
impl Default for FitRequest {
/// Interactive defaults: bounded hard enough to run inside a keystroke.
///
/// `FitConfig::default()` is the CLI's budget — 80 members over 600
/// generations — which is the right answer for a batch tool and the wrong
/// one for a synchronous command, because a `Cmd` here runs on the thread
/// that draws the next frame. These numbers are a deliberate quality/
/// latency trade, not a tuned optimum; a caller wanting the CLI's search
/// passes `FitConfig::default()`.
///
/// **Measured**, release build, on a document of the shape this module's
/// tests use: **6ms** at 12 records, **12ms** at 200, **105ms** at 2,000 —
/// the whole sequence, five-fold CV included. So it is a pause rather than
/// a freeze at any document size in this repo's own benchmarks (18,000
/// cells), and roughly linear in records beyond that. There is no timing
/// *test*: a wall-clock assertion is flaky on shared CI and would fail for
/// a reason that is not this code's. Re-measure after changing these.
fn default() -> Self {
Self {
seed: 0x0F17_C0FF_EE00_D00D,
config: FitConfig {
pop: 40,
generations: 120,
..FitConfig::default()
},
cv_folds: 5,
importance_repeats: 3,
}
}
}
/// Held-out evidence, or the statement that there is none.
///
/// The two are different things to tell a user and an `Option` would have made
/// them one: `Quality::Measured` carrying an all-zero [`RegressionQuality`]
/// means "this tree is no better than the mean", which is the loudest thing a
/// fit can say, and it must not be reachable by simply not having asked.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Quality {
/// Pooled `k`-fold cross-validation against the predict-the-mean baseline.
Measured { folds: usize, of: RegressionQuality },
/// Fewer rows than folds asked for: nothing was held out, so nothing is
/// claimed.
Unmeasured { rows: usize },
}
/// A fitted rule and the evidence for it.
#[derive(Clone, Debug)]
pub struct FitOutcome {
/// The champion as an improvise formula, already parsed and already
/// checked against the document it is to be stored in.
pub formula: Formula,
/// How the rule did on data it was not fitted to.
pub quality: Quality,
/// `(feature name, percentage share)`, most important first, features the
/// rule does not use omitted.
pub importance: Vec<(String, f32)>,
/// Records and columns the selection dropped — `preprocess`'s notices and
/// the feature table's, in that order.
pub warnings: Vec<String>,
/// The seed this fit ran under, and…
pub seed: u64,
/// …the knobs it ran under. Both, because neither alone reproduces it.
pub config: FitConfig,
}
impl FitOutcome {
/// One line naming the rule's worth, what it leans on, and anything the
/// selection dropped — in that order, because a user who reads only the
/// first clause has read the part that decides whether to believe it.
pub fn status_line(&self) -> String {
let quality = match self.quality {
Quality::Measured { folds, of } => format!(
"R\u{b2} {:.2}, RMSE {:.3} vs baseline {:.3} ({folds}-fold CV)",
of.r2,
of.rmse,
of.baseline_mse.sqrt(),
),
Quality::Unmeasured { rows } => {
format!("no held-out check \u{2014} only {rows} record(s)")
}
};
let top: Vec<String> = self
.importance
.iter()
.take(3)
.map(|(name, share)| format!("{name} {share:.0}%"))
.collect();
let leans = if top.is_empty() {
"a constant \u{2014} no feature matters".to_string()
} else {
format!("leans on {}", top.join(", "))
};
let tail = if self.warnings.is_empty() {
String::new()
} else {
format!(" \u{2014} {}", self.warnings.join("; "))
};
format!(
"{} = fitted tree: {quality}; {leans}; seed {:#x}{tail}",
self.formula.target, self.seed
)
}
}
/// The measure a fit of `target` is stored under, unless a caller names
/// another: the fit is a *second, derived* column beside the data it explains.
///
/// Storing it under `target` itself would make the formula shadow the very
/// cells it was fitted to — gotcha 7's `_Measure` items are explicit items ∪
/// formula targets, so the two would be one item with a computed value, and
/// the user's typed data would stop being visible. A fit is a rule *about* the
/// data, never a replacement for it.
pub fn fitted_measure_name(target: &str) -> String {
format!("{target} (fit)")
}
/// Fit `target` from everything else the `view` shows, storing the rule as the
/// measure `into`, and hand back the rule with the evidence for it.
///
/// Fails, rather than storing something misleading, when: the selection cannot
/// produce a table at all (see [`crate::feature_table`]); `into` is a measure
/// that already holds data; the target is categorical, which a single
/// regression tree does not express; or the champion splits on a feature the
/// document cannot resolve (improvise-8xhy, and the message names them).
pub fn fit_measure(
model: &Model,
view: &View,
target: &str,
into: &str,
request: &FitRequest,
) -> anyhow::Result<FitOutcome> {
anyhow::ensure!(
into != target,
"a fit of {target:?} cannot be stored as {target:?} — the formula would shadow the \
cells it was fitted to. Name a different measure"
);
anyhow::ensure!(
!model.measure_item_names().iter().any(|m| m == into),
"{into:?} is already a measure; a formula on it would replace what is there. \
Name a measure that does not exist yet"
);
let data = dataset(model, view, target, &Overrides::default())?;
let rows = regression_rows(data.target, target)?;
let names: Vec<&str> = data.feature_names.iter().map(String::as_str).collect();
let spec = Spec {
ranges: &data.ranges,
names: &names,
};
let mut rng = SplitMix64::new(request.seed);
// Held-out evidence first, and from the same generator, so the sequence
// reproduces as a whole rather than step by step.
let folds = request.cv_folds.min(rows.len());
let quality = match folds >= 2 {
true => Quality::Measured {
folds,
of: regression_quality(&cv_regress(&rows, &spec, &request.config, folds, &mut rng)),
},
false => Quality::Unmeasured { rows: rows.len() },
};
// Then the rule itself, over every row. Standardized to fit, de-normalized
// to be written down — see this module's header.
let (normalized, mean, std) = standardize(&rows);
let champion = denormalize(
&fit(&normalized, &spec, &request.config, &mut rng),
mean,
std,
);
let importance = permutation_importance(
|shuffled| mse(&champion, shuffled),
&rows,
names.len(),
request.importance_repeats,
&mut rng,
);
Ok(FitOutcome {
formula: crate::fitted_formula(
&champion,
&spec,
into,
improvise_core::model::category::MEASURE_CATEGORY,
model,
)?,
quality,
importance: importance_ranking(&importance)
.into_iter()
.map(|(j, share)| (data.feature_names[j].clone(), share))
.collect(),
warnings: data.warnings,
seed: request.seed,
config: request.config,
})
}
/// The regression rows, or a refusal naming why a categorical target is not a
/// formula.
///
/// `preprocess` infers classification the moment a target cell does not parse
/// as a number, and a classification fit is one tree *per class* plus an
/// `argmax` — three things improvise formulas have none of. Refusing here is
/// the same choice `fitted_formula` makes for an unresolvable feature: fail
/// where the formula would have been written, rather than store something that
/// looks like an answer.
fn regression_rows(
target_data: Target,
target: &str,
) -> anyhow::Result<Vec<gp_tree::fit::Row<f32>>> {
match target_data {
Target::Regress(rows) => Ok(rows),
Target::Classify { labels, .. } => anyhow::bail!(
"{target:?} holds text, so a fit over it is a classification into {labels:?} — \
that is one tree per class chosen by argmax, which improvise formulas cannot \
express. Fit a numeric measure instead"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use improvise_core::model::category::CategoryKind;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::view::Axis;
const MEASURE: &str = "_Measure";
const REGION: &str = "Region";
const TAG: &str = "Tag";
const FIT_INTO: &str = "Score (fit)";
/// A document with one regular category, one feature measure and one
/// target measure standing in a rule a tree can express exactly: `Score`
/// steps at `Cost` 50, and the numbers are in the **thousands** so a fit
/// that forgets to de-normalize is visibly wrong rather than subtly so.
///
/// `Region` carries a single item on purpose. `preprocess` one-hots a
/// categorical column, and a one-hot that is constant across every record
/// has the degenerate range `(1, 1)`, which `simplify` prunes — so the
/// champion *cannot* split on an unresolvable feature and the success path
/// is deterministic rather than "whatever this seed happened to pick".
/// `Tag` is what makes the twelve records distinct, and it is a `Label`
/// category so `dataset` excludes it from the features (it would otherwise
/// one-hot into one column per row: a perfect memoriser).
fn document() -> (Model, View) {
let mut model = Model::new("sales");
for cat in [REGION, TAG] {
model.add_category(cat).expect("under the category limit");
}
model
.category_mut(REGION)
.expect("just added")
.add_item("North");
model.category_mut(TAG).expect("just added").kind = CategoryKind::Label;
for i in 0..12u32 {
model
.category_mut(TAG)
.expect("just added")
.add_item(format!("r{i}"));
}
let measure = model.category_mut(MEASURE).expect("_Measure always exists");
for item in ["Cost", "Score"] {
measure.add_item(item);
}
for i in 0..12u32 {
let cost = 10.0 * f64::from(i);
model.set_cell(cell(&format!("r{i}"), "Cost"), CellValue::Number(cost));
model.set_cell(
cell(&format!("r{i}"), "Score"),
CellValue::Number(if cost > 50.0 { 4000.0 } else { 1000.0 }),
);
}
let mut view = View::new("Default");
for cat in model.category_names() {
view.on_category_added(cat);
}
view.set_axis(REGION, Axis::Row);
(model, view)
}
fn cell(tag: &str, measure: &str) -> CellKey {
CellKey::new(vec![
(REGION.to_string(), "North".to_string()),
(TAG.to_string(), tag.to_string()),
(MEASURE.to_string(), measure.to_string()),
])
}
fn request() -> FitRequest {
FitRequest {
config: FitConfig {
pop: 24,
generations: 40,
..FitRequest::default().config
},
..FitRequest::default()
}
}
/// The whole point, asserted against a real document: the fit's formula
/// goes in through the ordinary `add_formula` path and every cell of the
/// new measure evaluates to a number — not to `Error("ref:…")`, which is
/// what improvise-8xhy was.
#[test]
fn a_fitted_formula_evaluates_in_the_document_it_was_fitted_on() {
let (mut model, view) = document();
let outcome =
fit_measure(&model, &view, "Score", FIT_INTO, &request()).expect("the fit lands");
model.add_formula(outcome.formula.clone());
model.recompute_formulas(&[]);
for i in 0..12u32 {
match model.evaluate(&cell(&format!("r{i}"), &outcome.formula.target)) {
Some(CellValue::Number(_)) => {}
other => panic!("record r{i} came back as {other:?}"),
}
}
}
/// The de-normalization regression, and the reason it needs a target in
/// the thousands. `fit` runs against a standardized target, so a champion
/// written down untouched predicts standard deviations: a well-formed
/// formula, parsing cleanly, evaluating to ±5 where the user's data is
/// 1000–4000. Nothing downstream can see that — it is a number in a cell.
///
/// Injecting the fault (dropping the `denormalize` call in `fit_measure`)
/// fails this and only this test.
#[test]
fn the_fitted_formula_predicts_in_the_documents_own_units() {
let (mut model, view) = document();
let outcome =
fit_measure(&model, &view, "Score", FIT_INTO, &request()).expect("the fit lands");
model.add_formula(outcome.formula.clone());
model.recompute_formulas(&[]);
for i in 0..12u32 {
let Some(CellValue::Number(n)) = model.evaluate(&cell(&format!("r{i}"), FIT_INTO))
else {
continue;
};
assert!(
(500.0..=5000.0).contains(&n),
"record r{i} predicted {n}, which is standardized units, not the \
document's 1000–4000"
);
}
// …and the same claim read off the formula text, so the assertion does
// not depend on the target item having been resolvable above.
let numbers = leaf_numbers(&outcome.formula.expr);
assert!(!numbers.is_empty(), "the fit emitted no leaf at all");
assert!(
numbers.iter().any(|n| *n > 500.0),
"every leaf is {numbers:?} — standardized units, not the document's 1000–4000"
);
}
/// Every `Number` literal in the emitted expression, leaves and thresholds
/// alike. A threshold here is a `Cost` (0–110) and a leaf is a `Score`
/// (1000–4000), so "some literal exceeds 500" can only be a leaf.
fn leaf_numbers(expr: &improvise_formula::Expr) -> Vec<f64> {
use improvise_formula::Expr;
match expr {
Expr::Number(n) => vec![*n],
Expr::If(c, t, e) => [c, t, e].iter().flat_map(|e| leaf_numbers(e)).collect(),
Expr::BinOp(_, l, r) => [l, r].iter().flat_map(|e| leaf_numbers(e)).collect(),
Expr::UnaryMinus(e) => leaf_numbers(e),
Expr::Ref(_) | Expr::Agg(..) => Vec::new(),
}
}
/// gotcha 37b: the seed alone does not reproduce a fit, so the outcome
/// carries the config beside it — and the pair really is sufficient.
#[test]
fn the_same_seed_and_config_reproduce_the_same_rule() {
let (model, view) = document();
let a = fit_measure(&model, &view, "Score", FIT_INTO, &request()).expect("fits");
let b = fit_measure(&model, &view, "Score", FIT_INTO, &request()).expect("fits");
assert_eq!(a.formula.raw, b.formula.raw);
assert_eq!(a.seed, b.seed);
assert_eq!(a.config, b.config);
}
/// And the other half: a different *config* under the same seed searches
/// differently, which is why recording the seed alone would not do.
#[test]
fn the_same_seed_under_a_different_config_is_a_different_search() {
let (model, view) = document();
let base = request();
let wider = FitRequest {
config: FitConfig {
generations: base.config.generations * 3,
..base.config
},
..base
};
assert_eq!(base.seed, wider.seed);
assert_ne!(
fit_measure(&model, &view, "Score", FIT_INTO, &base)
.expect("fits")
.formula
.raw,
fit_measure(&model, &view, "Score", FIT_INTO, &wider)
.expect("fits")
.formula
.raw,
"the same seed gave the same rule under different knobs, so recording \
the seed alone would have been enough after all"
);
}
/// A fit is a rule *about* the data, never a replacement for it. Stored
/// under its own target the formula would shadow the very cells it was
/// fitted to (gotcha 7: `_Measure` items are explicit items ∪ formula
/// targets, so the two would be one item), and the user's typed numbers
/// would simply stop being visible.
#[test]
fn a_fit_stored_under_its_own_target_is_refused() {
let (model, view) = document();
let err =
fit_measure(&model, &view, "Score", "Score", &request()).expect_err("self-shadowing");
assert!(err.to_string().contains("shadow"), "{err}");
}
/// The same hazard one step removed: any existing measure would be
/// replaced by the rule, so the name has to be a new one.
#[test]
fn a_fit_stored_over_an_existing_measure_is_refused() {
let (model, view) = document();
let err =
fit_measure(&model, &view, "Score", "Cost", &request()).expect_err("Cost holds data");
assert!(err.to_string().contains("already a measure"), "{err}");
}
/// improvise-8xhy reaching a real user, and the reason improvise-4zl7
/// exists: with a categorical feature carrying the signal, the champion
/// splits on a one-hot — `Region=North`, an encoding artefact no document
/// has an item for — and the whole fit is refused *by name* rather than
/// written in as a measure whose every cell is `Error("ref:…")`.
///
/// Note what this costs in practice: which features a champion picks is a
/// property of the search, so on a document with several categories the
/// same command refuses or succeeds depending on the seed.
#[test]
fn a_fit_that_needs_a_category_is_refused_naming_the_feature() {
let (mut model, view) = document();
// Region is the only thing left that can explain Score.
model
.category_mut(REGION)
.expect("Region exists")
.add_item("South");
for i in 0..12u32 {
model.clear_cell(&cell(&format!("r{i}"), "Cost"));
}
for (i, region) in (0..12u32).map(|i| (i, if i < 6 { "North" } else { "South" })) {
model.set_cell(
CellKey::new(vec![
(REGION.to_string(), region.to_string()),
(TAG.to_string(), format!("r{i}")),
(MEASURE.to_string(), "Score".to_string()),
]),
CellValue::Number(if region == "North" { 1000.0 } else { 4000.0 }),
);
}
let err = fit_measure(&model, &view, "Score", FIT_INTO, &request())
.expect_err("the champion can only split on a one-hot");
let msg = err.to_string();
assert!(
msg.contains("Region=North") || msg.contains("Region=South"),
"{msg}"
);
assert!(
msg.contains("not an item") || msg.contains("not items"),
"{msg}"
);
// The refusal is read off a one-line status bar, so it has to *be* one
// line. A `rustfmt` pass over the multi-line literal that spells it had
// left runs of ten spaces mid-sentence — invisible in the source and in
// every test that only asked whether a substring was present, and
// visible to precisely the user this message exists for.
assert!(!msg.contains('\n'), "{msg:?}");
assert!(!msg.contains(" "), "{msg:?}");
}
/// A text-valued target is a *classification*, which is k trees and an
/// argmax — not something an improvise formula can hold. Refused by name,
/// at the point the formula would have been written.
#[test]
fn a_text_target_is_refused_as_a_classification() {
let (mut model, view) = document();
for i in 0..12u32 {
model.set_cell(
cell(&format!("r{i}"), "Score"),
CellValue::Text(if i % 2 == 0 { "up" } else { "down" }.into()),
);
}
let err =
fit_measure(&model, &view, "Score", FIT_INTO, &request()).expect_err("text target");
assert!(err.to_string().contains("classification"), "{err}");
}
/// Cross-validating nothing is not "explained nothing". With fewer records
/// than folds asked for, the outcome says so rather than reporting an R²
/// of zero that a user would read as a verdict on the rule.
#[test]
fn too_few_records_to_cross_validate_says_so_rather_than_scoring_zero() {
let (mut model, view) = document();
for i in 1..12u32 {
for measure in ["Cost", "Score"] {
model.clear_cell(&cell(&format!("r{i}"), measure));
}
}
let outcome = fit_measure(&model, &view, "Score", FIT_INTO, &request()).expect("fits");
assert!(
matches!(outcome.quality, Quality::Unmeasured { rows: 1 }),
"{:?}",
outcome.quality
);
assert!(
outcome.status_line().contains("no held-out check"),
"{}",
outcome.status_line()
);
}
/// The status line is one line and leads with the verdict, because a user
/// who reads only its first clause has read the part that decides whether
/// to believe the rule at all.
#[test]
fn the_status_line_leads_with_the_verdict_and_names_the_seed() {
let (model, view) = document();
let outcome = fit_measure(&model, &view, "Score", FIT_INTO, &request()).expect("fits");
let line = outcome.status_line();
assert!(!line.contains('\n'), "{line}");
assert!(
line.starts_with(&format!("{FIT_INTO} = fitted tree: R")),
"{line}"
);
assert!(line.contains("baseline"), "{line}");
assert!(
line.contains(&format!("{:#x}", outcome.seed)),
"the seed is what reproduces the fit: {line}"
);
}
}
+9 -1
View File
@@ -84,8 +84,10 @@
//! it looks and what it would and would not cover.
pub mod dataset;
pub mod fit;
pub use dataset::{FeatureTable, dataset, feature_table};
pub use fit::{FitOutcome, FitRequest, Quality, fit_measure};
use gp_tree::{Spec, Tree};
use improvise_core::model::Model;
@@ -153,7 +155,13 @@ pub fn fitted_formula(
let unresolvable = unresolvable_features(tree, spec, model);
anyhow::ensure!(
unresolvable.is_empty(),
"the fitted tree splits on {unresolvable:?}, which {} not {} of this document — a formula refers to its inputs by name, so every cell of {target:?} would be a reference error. Encoding artefacts (`Cat=Item` one-hots, `col.missing` flags, date parts) cannot be written as a formula: fit over numeric measures for now",
// One line, because it is read off a status bar. A `rustfmt` pass over
// the previous multi-line literal left runs of ten spaces mid-sentence
// in the message a user actually sees.
"the fitted tree splits on {unresolvable:?}, which {} not {} of this document \
— a formula refers to its inputs by name, so every cell of {target:?} would be \
a reference error. Encoding artefacts (`Cat=Item` one-hots, `col.missing` flags, \
date parts) cannot be written as a formula: fit over numeric measures for now",
if unresolvable.len() == 1 { "is" } else { "are" },
if unresolvable.len() == 1 {
"an item"