fix(gp): a numeric- or date-valued category is spellable (improvise-zv8t)
`fit_measure` handed `preprocess` an `Overrides::default()`, so it never said which header columns are document categories — although the very next line told `Features::of`. `preprocess` then inferred Numeric -> Date -> Categorical from the values, so a `Year` category with items 2024/2025 became a numeric column and a category of ISO dates became a date column, and both were refused as encoding artefacts with `Year IS 2024` spellable all along. `fit_measure` now builds its `Overrides` from the same `header[..category_columns]` slice it already hands `Features::of`, forcing each to `Forced::Categorical`, so the one place the document's own answer was known is now the only place either caller reads it from. `Forced::Categorical` already existed; no formula-language change was needed. Three things follow, and the last is a cost. The date decline in gp.md §11 now means what it says. A `DatePart` could arrive over a *category* column by exactly this accident, so that decline was narrower than it read; it now covers a genuine date measure and nothing else. The `Artefact` arms stay and stay correct. `crate::dataset` and `fitted_formula` take the caller's `Overrides`, so a caller that does not force can still produce a `Numeric` role over a category column. The arm is no longer reachable from `fit_measure`, which is the only production entry point — it is not dead code, and deleting it would derive the classification from one caller's habits. Forcing widens the encoding, deliberately: a two-item `Year` is now two one-hots rather than one numeric feature, and a two-hundred-item one is refused by `MAX_FEATURES` where before it was refused one stage later as an artefact. Both refuse, so nothing that fitted before stops fitting; what changes is which guard speaks, and the width guard's message names the repair. `encoded_width` had to learn the same slice for that to hold, or it counted such a column as one feature and handed the refusal to the exact check *after* materialising the matrix — the side of the cost the preflight exists to stay on (§14). Evidence. The two spelling tests needed no fault injected: on the unfixed tree `fit_measure` refuses with the two real messages the ticket named. Each fixture gives the model one regular category, no other feature column, and a target that category alone determines — because the refusal is conditioned on `used_features`, so a fit whose champion happened to be a bare leaf would be accepted and prove nothing. Asserting `formula.raw` contains `Year IS` rather than merely `is_ok()` closes that in both directions. The preflight test did need one: dropping the `col >= category_columns` clause makes it fail by name, with the *exact* check speaking where the preflight should. The prose this corrects was itself the ticket's subject. improvise-wd6n's §11 called `Artefact::NumericCategory` a permanent refusal that "no vocabulary closes", which is false and is what filed improvise-zv8t. §11 and the gp test-evidence entry are both updated to say it is closed rather than open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
+41
-14
@@ -373,20 +373,47 @@ Two of the other three are declined permanently: a one-hot over a measure and
|
||||
the `(missing)` pseudo-item name no coordinate *by construction*, so no
|
||||
vocabulary closes them. They are refusals rather than gaps.
|
||||
|
||||
**`NumericCategory` is not, and this paragraph said it was** — which is the
|
||||
more useful half of improvise-wd6n, because prose that calls something a
|
||||
permanent refusal is what stops anyone filing the ticket. `fit_measure` hands
|
||||
`preprocess` an `Overrides::default()`, so it never says which header columns
|
||||
are document categories, although the very next line tells `Features::of`.
|
||||
`preprocess` then infers `Numeric` → `Date` → `Categorical` from the cell
|
||||
values, so a `Year` category with items `2024`/`2025` becomes a *numeric column*
|
||||
and a category of ISO dates becomes a *date column* — and both are refused,
|
||||
`Year IS 2024` being exactly spellable all along. That needs no
|
||||
formula-language change whatever: `Forced::Categorical` already exists and is
|
||||
the knob. Filed as **improvise-zv8t**. The same accident is why a `DatePart`
|
||||
can arrive over a category column, so the date family's decline above is
|
||||
narrower than it looks: it covers a genuine date *measure*, not a category
|
||||
misread as one.
|
||||
**`NumericCategory` was not, and improvise-zv8t has closed it.** That it was
|
||||
*called* one is the more useful half of improvise-wd6n: prose declaring
|
||||
something a permanent refusal is what stops anyone filing the ticket, and
|
||||
nothing but a reader could have caught it — the suite was green either way.
|
||||
|
||||
The accident was one argument. `fit_measure` handed `preprocess` an
|
||||
`Overrides::default()`, so it never said which header columns are document
|
||||
categories, **although the very next line told `Features::of`**. `preprocess`
|
||||
then inferred `Numeric` → `Date` → `Categorical` from the cell values, so a
|
||||
`Year` category with items `2024`/`2025` became a *numeric column* and a
|
||||
category of ISO dates became a *date column* — and both were then refused as
|
||||
encoding artefacts with `Year IS 2024` spellable all along. `fit_measure` now
|
||||
builds its `Overrides` from the same `header[..category_columns]` slice it
|
||||
already hands `Features::of`, forcing each to `Forced::Categorical`, so the one
|
||||
place the document's own answer was known is now the only place either caller
|
||||
reads it from.
|
||||
|
||||
Three things follow, and the last is a cost rather than a win.
|
||||
|
||||
**The date decline above now means what it says.** A `DatePart` could arrive
|
||||
over a *category* column by exactly this accident, so §11's date family was
|
||||
narrower than it read. It now covers a genuine date **measure** and nothing
|
||||
else, which is the scope the decline was always argued for.
|
||||
|
||||
**The `Artefact` arms stay, and stay correct.** `crate::dataset` and
|
||||
`fitted_formula` take the caller's `Overrides`, so a caller that does not force
|
||||
can still produce a `Numeric` role over a category column. The arm is no longer
|
||||
reachable from `fit_measure`, which is the only production entry point; it is
|
||||
not unreachable code, and deleting it would be deriving the classification from
|
||||
one caller's habits instead of from the roles.
|
||||
|
||||
**Forcing widens the encoding, deliberately.** A `Year` category with two items
|
||||
was one numeric feature and is now two one-hots; with two hundred items it is
|
||||
two hundred, and `MAX_FEATURES` refuses it — where before it encoded as a single
|
||||
feature and was refused one stage later as an artefact. Both refuse, so no
|
||||
document that fitted before stops fitting; what changes is *which* guard speaks,
|
||||
and the width guard's message names the repair (mark the category a Label). The
|
||||
preflight `encoded_width` had to learn the same slice for that to be true, or it
|
||||
counted such a column as one feature and handed the refusal to the exact check
|
||||
after materialising the matrix — which is the side of the cost the preflight
|
||||
exists to stay on (§14).
|
||||
|
||||
**And the refusal's own words must not name a column kind the arm does not
|
||||
know.** `FeatureRole::Missing` is *not* conditioned on `is_category` — unlike
|
||||
|
||||
@@ -606,10 +606,68 @@ The fixture reaches three families from one `preprocess` call (a text measure
|
||||
column, a measure with a hole in it, a column of ISO dates), which is why the
|
||||
module is named for what it reaches rather than for "each family". The
|
||||
remaining two are not reachable from it and are not asserted here.
|
||||
`AbsentCoordinate` is a permanent refusal; `NumericCategory` is **not**, and
|
||||
`AbsentCoordinate` is a permanent refusal; `NumericCategory` was **not**, and
|
||||
the first draft of this entry and of `gp.md` §11 both called it one — review
|
||||
found that `fit_measure` simply never tells `preprocess` which columns are
|
||||
categories, so it is a filed gap (improvise-zv8t) rather than a refusal. Worth
|
||||
recording as the failure mode: the branch that *invented* a classification also
|
||||
wrote the prose declaring one of its arms unfixable, and nothing but a reader
|
||||
could have caught that.
|
||||
categories. improvise-zv8t has since closed it (below). Worth recording as the
|
||||
failure mode: the branch that *invented* a classification also wrote the prose
|
||||
declaring one of its arms unfixable, and nothing but a reader could have caught
|
||||
that — the suite was green on either reading.
|
||||
|
||||
### fit::tests::a_category_whose_items_are_numbers_is_still_spelled_as_a_coordinate
|
||||
### fit::tests::a_category_whose_items_are_dates_is_still_spelled_as_a_coordinate
|
||||
|
||||
improvise-zv8t. `fit_measure` handed `preprocess` an `Overrides::default()`, so
|
||||
it never said which header columns are document categories — although the very
|
||||
next line told `Features::of`. `preprocess` infers `Numeric` → `Date` →
|
||||
`Categorical` from the values, so a **`Year` category with items `2024`/`2025`**
|
||||
became a numeric column and a **category of ISO dates** became a date column,
|
||||
and both were refused as encoding artefacts with `Year IS 2024` spellable all
|
||||
along. User-visible: a fit over such a document could not be stored at all.
|
||||
|
||||
No fault had to be injected, because the bug was live. On the unfixed tree both
|
||||
tests fail inside `fit_measure` with the real refusals, which are the two
|
||||
arms the ticket named and are worth quoting:
|
||||
|
||||
- `["Year"] names a category whose items all parse as numbers, so a reference
|
||||
to it would resolve to a `_Measure` item of the same name`
|
||||
- `["Year.epoch_day", "Year.year"] is a part of a date, and date arithmetic is
|
||||
something improvise does not have anywhere`
|
||||
|
||||
The second is why `gp.md` §11's date decline was narrower than it read.
|
||||
|
||||
**Why each fixture is discriminating.** `year_document` gives the model **one**
|
||||
regular category and **no other feature column**, and makes the target a
|
||||
function of that category alone — so a tree that does not split on `Year` is a
|
||||
poor fit and the GA does not settle for one. That matters because the refusal is
|
||||
conditioned on `used_features`: a fit whose champion happened to be a bare leaf
|
||||
would be *accepted* on the unfixed tree and prove nothing. Asserting
|
||||
`formula.raw.contains("Year IS")` rather than merely `is_ok()` closes that hole
|
||||
in both directions — on the unfixed tree the text could only have been
|
||||
`Expr::Ref("Year")`, never `Year IS`. The numeric test then adds the property
|
||||
the refusal exists to protect: the formula goes in through `add_formula` and
|
||||
every record evaluates to a `Number`, not `Error("ref:…")` (improvise-8xhy).
|
||||
|
||||
### fit::tests::the_preflight_counts_a_numeric_category_at_its_cardinality
|
||||
|
||||
The other half of improvise-zv8t, and the one that is a *cost* rather than a
|
||||
fix. Forcing a category categorical widens it from one numeric feature to one
|
||||
per distinct value, so `encoded_width` — the cheap preflight that exists to
|
||||
refuse a too-wide table **before** `preprocess` materialises it (§14) — had to
|
||||
learn the same `header[..category_columns]` slice, or it would under-count
|
||||
exactly the column this ticket is about.
|
||||
|
||||
Fault injected: the `col >= category_columns` clause dropped from `encoded_width`,
|
||||
leaving the pre-ticket inference. The test fails by name, and the failure message
|
||||
is the evidence — it shows the **exact** check speaking where the preflight
|
||||
should:
|
||||
|
||||
> the PREFLIGHT must refuse it, before anything is materialised: fitting "Score"
|
||||
> encoded 70 features, over the 64 this host will search.
|
||||
|
||||
A test asserting only `expect_err` would have passed under that injection, since
|
||||
both guards refuse; the discriminator is *which message*, because the two guards
|
||||
differ in what they have already paid for. The fixture also asserts its own
|
||||
precondition — that `Year` really is the header's first column — so a
|
||||
`feature_table` change that reorders the header fails here rather than silently
|
||||
slicing the wrong names.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! One fit, end to end: a [`View`]'s records in, an improvise [`Formula`] and the
|
||||
//! evidence for believing it out. It never prints — `context/code/gp.md` §14.
|
||||
|
||||
use gp_tree::data::{Overrides, Target, preprocess};
|
||||
use gp_tree::data::{Forced, Overrides, Target, preprocess};
|
||||
use gp_tree::fit::{
|
||||
FitConfig, RegressionQuality, cv_regress, denormalize, fit, importance_ranking, mse,
|
||||
permutation_importance, regression_quality, standardize,
|
||||
@@ -15,7 +15,7 @@ use crate::dataset::{FeatureTable, feature_table};
|
||||
|
||||
/// A cheap upper bound on how wide `preprocess` would expand this table, computed
|
||||
/// without expanding it. **Advisory, not authoritative** — `context/code/gp.md` §14.
|
||||
fn encoded_width(table: &FeatureTable) -> usize {
|
||||
fn encoded_width(table: &FeatureTable, category_columns: usize) -> usize {
|
||||
(0..table.header.len().saturating_sub(1))
|
||||
.map(|col| {
|
||||
let cells: Vec<&str> = table
|
||||
@@ -30,7 +30,12 @@ fn encoded_width(table: &FeatureTable) -> usize {
|
||||
.filter(|c| !gp_tree::data::is_missing(c))
|
||||
.collect();
|
||||
let has_missing = present.len() < cells.len();
|
||||
match !present.is_empty() && present.iter().all(|c| c.parse::<f64>().is_ok()) {
|
||||
// A document category is forced categorical below, so the preflight must
|
||||
// count it the same way or it under-counts exactly the improvise-zv8t shape.
|
||||
let numeric = col >= category_columns
|
||||
&& !present.is_empty()
|
||||
&& present.iter().all(|c| c.parse::<f64>().is_ok());
|
||||
match numeric {
|
||||
// A numeric column is one feature, plus the `.missing` flag.
|
||||
true => 1 + usize::from(has_missing),
|
||||
// Everything else: one column per distinct value.
|
||||
@@ -44,6 +49,18 @@ fn encoded_width(table: &FeatureTable) -> usize {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Force every document category to be encoded as one, rather than inferred from its
|
||||
/// values. The argument for asking the document is `context/code/gp.md` §11.
|
||||
fn as_categories(header: &[String], category_columns: usize) -> Overrides {
|
||||
Overrides {
|
||||
force: header[..category_columns]
|
||||
.iter()
|
||||
.map(|name| (name.clone(), Forced::Categorical))
|
||||
.collect(),
|
||||
..Overrides::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The same text with every CR and LF turned into a space, so a name a document may
|
||||
/// legally hold cannot split a one-line status bar. Public: the refusals need it too.
|
||||
pub fn one_line(text: String) -> String {
|
||||
@@ -216,7 +233,8 @@ pub fn fit_measure(
|
||||
table.rows.len(),
|
||||
request.max_records,
|
||||
);
|
||||
let width = encoded_width(&table);
|
||||
let category_columns = model.regular_category_names().len();
|
||||
let width = encoded_width(&table, category_columns);
|
||||
anyhow::ensure!(
|
||||
width <= MAX_FEATURES,
|
||||
"fitting {target:?} would encode {width} features from {} records, which is too wide \
|
||||
@@ -225,9 +243,12 @@ pub fn fit_measure(
|
||||
table.rows.len(),
|
||||
);
|
||||
|
||||
let category_columns = model.regular_category_names().len();
|
||||
let header = table.header.clone();
|
||||
let mut data = preprocess(&table.header, &table.rows, &Overrides::default());
|
||||
let mut data = preprocess(
|
||||
&table.header,
|
||||
&table.rows,
|
||||
&as_categories(&header, category_columns),
|
||||
);
|
||||
data.warnings.splice(0..0, table.warnings);
|
||||
no_ambiguous_column_names(&header)?;
|
||||
let features = crate::Features::of(&data, &header[..category_columns]);
|
||||
@@ -501,6 +522,132 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bug improvise-zv8t: `fit_measure` handed `preprocess` an `Overrides::default()`,
|
||||
/// so a numeric-valued category was inferred numeric. Evidence: `context/test-evidence/gp.md`.
|
||||
#[test]
|
||||
fn a_category_whose_items_are_numbers_is_still_spelled_as_a_coordinate() {
|
||||
let (mut model, view) = numeric_category_document();
|
||||
let outcome =
|
||||
fit_measure(&model, &view, "Score", FIT_INTO, &request()).expect("the fit lands");
|
||||
assert!(
|
||||
outcome.formula.raw.contains("Year IS"),
|
||||
"the only predictor is a category; the fit spelled it {:?}",
|
||||
outcome.formula.raw
|
||||
);
|
||||
// …and the spelling really resolves: every cell a number, not `Error("ref:…")`.
|
||||
model.add_formula(outcome.formula.clone());
|
||||
model.recompute_formulas(&[]);
|
||||
for i in 0..12u32 {
|
||||
match model.evaluate(&year_cell(&format!("r{i}"), &outcome.formula.target)) {
|
||||
Some(CellValue::Number(_)) => {}
|
||||
other => panic!("record r{i} came back as {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same accident let a category of ISO dates arrive as a `DatePart`, so
|
||||
/// `gp.md` §11’s date decline covers a date *measure*, not a misread category.
|
||||
#[test]
|
||||
fn a_category_whose_items_are_dates_is_still_spelled_as_a_coordinate() {
|
||||
let (model, view) = date_category_document();
|
||||
let outcome =
|
||||
fit_measure(&model, &view, "Score", FIT_INTO, &request()).expect("the fit lands");
|
||||
assert!(
|
||||
outcome.formula.raw.contains("Year IS"),
|
||||
"a category of ISO dates must stay a coordinate: {:?}",
|
||||
outcome.formula.raw
|
||||
);
|
||||
}
|
||||
|
||||
/// The preflight must count a category the way `preprocess` will now be told to
|
||||
/// encode it, or it under-counts exactly the column improvise-zv8t is about.
|
||||
#[test]
|
||||
fn the_preflight_counts_a_numeric_category_at_its_cardinality() {
|
||||
let (model, view) = year_document(&WIDE_YEARS);
|
||||
let table = feature_table(&model, &view, "Score").expect("the table builds");
|
||||
assert_eq!(
|
||||
table.header.first().map(String::as_str),
|
||||
Some("Year"),
|
||||
"the fixture must really put the category first, or the slice is wrong"
|
||||
);
|
||||
let err = fit_measure(&model, &view, "Score", FIT_INTO, &request())
|
||||
.expect_err("a category with a value per record is too wide");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("would encode"),
|
||||
"the PREFLIGHT must refuse it, before anything is materialised: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// One distinct numeric `Year` per record, well past `MAX_FEATURES`.
|
||||
const WIDE_YEARS: [&str; 70] = [
|
||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16",
|
||||
"17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31",
|
||||
"32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46",
|
||||
"47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61",
|
||||
"62", "63", "64", "65", "66", "67", "68", "69", "70",
|
||||
];
|
||||
|
||||
/// One **Regular** category `Year`, no other feature column, and a target the
|
||||
/// category alone determines. `items` are the coordinate values it carries.
|
||||
fn numeric_category_document() -> (Model, View) {
|
||||
year_document(&["2024", "2025"])
|
||||
}
|
||||
|
||||
fn date_category_document() -> (Model, View) {
|
||||
year_document(&["2024-01-01", "2025-01-01"])
|
||||
}
|
||||
|
||||
fn year_document(items: &[&str]) -> (Model, View) {
|
||||
let mut model = Model::new("sales");
|
||||
model.add_category("Year").expect("under the limit");
|
||||
model.add_category(TAG).expect("under the limit");
|
||||
for item in items {
|
||||
model
|
||||
.category_mut("Year")
|
||||
.expect("just added")
|
||||
.add_item(*item);
|
||||
}
|
||||
model.category_mut(TAG).expect("just added").kind = CategoryKind::Label;
|
||||
let records = items.len().max(12) as u32;
|
||||
for i in 0..records {
|
||||
model
|
||||
.category_mut(TAG)
|
||||
.expect("just added")
|
||||
.add_item(format!("r{i}"));
|
||||
}
|
||||
model
|
||||
.category_mut(MEASURE)
|
||||
.expect("_Measure always exists")
|
||||
.add_item("Score");
|
||||
for i in 0..records {
|
||||
let year = items[(i as usize) % items.len()];
|
||||
model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Year".to_string(), year.to_string()),
|
||||
(TAG.to_string(), format!("r{i}")),
|
||||
(MEASURE.to_string(), "Score".to_string()),
|
||||
]),
|
||||
CellValue::Number(if i % 2 == 0 { 1000.0 } else { 4000.0 }),
|
||||
);
|
||||
}
|
||||
let mut view = View::new("Default");
|
||||
for cat in model.category_names() {
|
||||
view.on_category_added(cat);
|
||||
}
|
||||
view.set_axis("Year", Axis::Row);
|
||||
view.set_axis(MEASURE, Axis::Page);
|
||||
(model, view)
|
||||
}
|
||||
|
||||
fn year_cell(tag: &str, measure: &str) -> CellKey {
|
||||
CellKey::new(vec![
|
||||
("Year".to_string(), "2024".to_string()),
|
||||
(TAG.to_string(), tag.to_string()),
|
||||
(MEASURE.to_string(), measure.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
Reference in New Issue
Block a user