Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **`fit` accepts an optional leading model name**, mirroring `predict`:
`fit('churn', f1, ..., fN, label)` registers the trained student as `churn` and
returns its id, so the train and serve calls read in parallel with
`predict('churn', f1, ..., fN)`. The name can still be given as
`'{"register":"churn"}'` instead; supplying it both ways raises
`PREDICT_ERR_OPTIONS`. Existing `fit(f1, ..., fN, label [, options])` calls are
unchanged (a leading argument is only a name when it is TEXT, and features are
numeric).

### Fixed

- **Forest prediction accumulates the learning-rate product in double**, matching
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ skills-capable agent, or read them as the condensed operator's manual.
| `forecast(ts, value, horizon [, options])` | Where is this metric going? | an aggregate over your rows: forecast steps with prediction intervals, one JSON document per group |
| `detect_anomalies(ts, value [, options])` | Which points are abnormal? | an aggregate over your rows: per-point anomaly probability and interval, one JSON document per group |
| `backtest(ts, value, horizon [, options])` | How accurate is the model here? | an aggregate over your rows: per-fold MAE, RMSE, MASE, sMAPE and coverage from rolling-origin evaluation |
| `fit(f1, ..., fN, label [, options])` | Train a model on labeled rows | an aggregate over your rows: a native tabular student, registered by id or returned as a blob |
| `fit([name,] f1, ..., fN, label [, options])` | Train a model on labeled rows | an aggregate over your rows: a native tabular student, registered under a leading id argument or a `register` option, or returned as a blob |
| `predict(model, f1, ..., fN [, options])` | Classify or regress a row | a scalar: one prediction per row from a fitted student |
| `distill_predict(train_query [, options])` | Compress a slow teacher into a fast student | a tiny native model (decision tree, gradient-boosted forest, or MLP), registered and served by `predict` |
| `distill_forecast(train_query [, options])` | Compress a forecast foundation model into a fast student | a native DLinear/TiDE forecast net (a linear skip plus a small residual), registered and served by `forecast` |
Expand Down
109 changes: 83 additions & 26 deletions predict-tabular.c
Original file line number Diff line number Diff line change
Expand Up @@ -855,12 +855,17 @@ static sqlite3_module predictModule = {

/* ======================================================================
* fit() aggregate + predict() scalar: the guessable tabular pair.
* fit(f1, ..., fN, label [, options]) trains a native student over the rows of
* the statement (label is the last positional argument, no target option) and
* returns the plain registered id text when options request registration
* ({"register":"my-id"}), otherwise its serialized model blob. The optional
* trailing options is a TEXT JSON object; a trailing '{...}' is therefore always
* read as options, so a class label must not itself be a JSON-object string.
* fit([name,] f1, ..., fN, label [, options]) trains a native student over the
* rows of the statement (features are numeric and positional, the label is the
* last positional argument, no target option). An optional leading TEXT argument
* names and registers the model, mirroring predict(model, ...); the same name may
* instead be given as {"register":"my-id"}, but supplying it both ways is an
* error. With a name fit() returns the registered id text; with neither it
* returns the serialized model blob. The optional trailing options is a TEXT JSON
* object; once a feature and a label precede it (argc - has_name >= 3) a trailing
* '{...}' is read as options, so a class label at that arity must not itself be a
* JSON-object string. At the lowest arity (a single feature and the label, as in
* fit(x, '{}')) the trailing object is the label.
* predict(model, f1, ..., fN [, options]) serves that student per row, features
* positional, deserialized once and cached on the model argument.
* ====================================================================== */
Expand Down Expand Up @@ -912,6 +917,7 @@ static const char *const FIT_OPTION_KEYS[] = {"kind", "student_kind", "task",
typedef struct {
int configured;
int has_opts;
int has_name; /* a leading TEXT model-name argument was present */
char *opts_raw; /* trailing options text of the first row (owned), or NULL */
int nfeat;
int classify;
Expand Down Expand Up @@ -952,18 +958,46 @@ static void fit_step(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
}
if (c->err)
return;
/* Determine this row's trailing options: the last argument when it is TEXT
* beginning with '{'. Presence and content must be constant across the group,
* or the feature/label split and the model config would depend on the order
* SQLite happens to visit rows in. Consequence of this convention: the trailing
* '{...}' is ALWAYS the options object, so a class label must not be a JSON
* object string (it would be consumed as options). A malformed options object
* fails loudly at predict0_options_parse; the only quiet case is a label that
* is itself a well-formed, valid-key options object, which is why the label
/* An optional leading model name: a TEXT first argument. Features are numeric,
* so a text argv[0] is unambiguously the register name — the guessable mirror of
* predict(model, ...). Like the options object it must be constant across the
* group, and it is reconciled against {"register":...} below (supplying the name
* both ways is an error, not a silent precedence). Detected before the trailing
* options so the options arity can discount it. */
int has_name = (argc >= 1 && sqlite3_value_type(argv[0]) == SQLITE_TEXT);
const char *name_txt = NULL;
if (has_name) {
name_txt = (const char *)sqlite3_value_text(argv[0]);
if (!name_txt) { /* TEXT value but NULL text pointer means an allocation failed */
c->err = SQLITE_NOMEM;
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/* The name flows through mprintf("%s") and strcmp below, so an embedded NUL
* would silently truncate it to a different id than the caller passed. Reject
* it rather than register/return the wrong name. */
if (memchr(name_txt, '\0', (size_t)sqlite3_value_bytes(argv[0]))) {
c->err = SQLITE_ERROR;
c->errmsg = sqlite3_mprintf("%s: model name must not contain a NUL byte",
PREDICT_ERR_OPTIONS);
return;
}
}

/* This row's trailing options: the last argument when it is TEXT beginning with
* '{'. It is options only when a feature and a label still precede it after the
* optional name (argc - has_name >= 3); with fewer arguments the trailing '{...}'
* is the label, exactly as the name-less form treats it, so both forms stay
* consistent. Presence and content must be constant across the group, or the
* feature/label split and the model config would depend on the order SQLite
* happens to visit rows in. Consequence: a trailing '{...}' with a feature and
* label present is ALWAYS the options object, so a class label must not be a
* JSON-object string (it would be consumed as options). A malformed options
* object fails loudly at predict0_options_parse; the only quiet case is a label
* that is itself a well-formed, valid-key options object, which is why the label
* contract is documented rather than guessed. */
int has_opts = 0;
const char *opts_txt = NULL;
if (argc >= 3 && sqlite3_value_type(argv[argc - 1]) == SQLITE_TEXT) {
if (argc - has_name >= 3 && sqlite3_value_type(argv[argc - 1]) == SQLITE_TEXT) {
const char *t = (const char *)sqlite3_value_text(argv[argc - 1]);
if (t && t[0] == '{') {
has_opts = 1;
Expand All @@ -972,10 +1006,10 @@ static void fit_step(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
}

if (!c->configured) {
int nfeat = argc - 1 - has_opts;
int nfeat = argc - has_name - 1 - has_opts;
if (nfeat < 1) {
c->err = SQLITE_ERROR;
c->errmsg = sqlite3_mprintf("%s: fit(feature, ..., label [, options])"
c->errmsg = sqlite3_mprintf("%s: fit([name,] feature, ..., label [, options])"
" needs at least one feature and a label",
PREDICT_ERR_SCHEMA);
return;
Expand All @@ -998,6 +1032,16 @@ static void fit_step(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
return;
}
}
/* The model name is given at most once: a leading argument OR {"register":...},
* never both — a conflicting pair is a mistake, not a precedence to resolve. */
if (has_name && o.reg) {
c->err = SQLITE_ERROR;
c->errmsg = sqlite3_mprintf(
"%s: model name given twice (leading argument and \"register\" option)",
PREDICT_ERR_OPTIONS);
fit_opts_free(&o);
return;
}
int classify = 1;
if (o.task) {
if (strcmp(o.task, "regress") == 0)
Expand All @@ -1014,10 +1058,20 @@ static void fit_step(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
c->classify = classify;
c->kind = o.kind; /* transfer ownership */
o.kind = NULL;
c->reg_id = o.reg;
o.reg = NULL;
if (has_name) {
c->reg_id = sqlite3_mprintf("%s", name_txt);
if (!c->reg_id) {
c->err = SQLITE_NOMEM;
fit_opts_free(&o);
return;
}
} else {
c->reg_id = o.reg; /* transfer ownership */
o.reg = NULL;
}
fit_opts_free(&o);
c->has_opts = has_opts;
c->has_name = has_name;
if (has_opts) {
c->opts_raw = sqlite3_mprintf("%s", opts_txt);
if (!c->opts_raw) {
Expand All @@ -1026,11 +1080,13 @@ static void fit_step(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
}
}
c->configured = 1;
} else if (has_opts != c->has_opts ||
(has_opts && (!c->opts_raw || strcmp(opts_txt, c->opts_raw) != 0))) {
} else if (has_opts != c->has_opts || has_name != c->has_name ||
(has_opts && (!c->opts_raw || strcmp(opts_txt, c->opts_raw) != 0)) ||
(has_name && (!c->reg_id || strcmp(name_txt, c->reg_id) != 0))) {
c->err = SQLITE_ERROR;
c->errmsg = sqlite3_mprintf("%s: options must be constant within a group",
PREDICT_ERR_OPTIONS);
c->errmsg = sqlite3_mprintf(
"%s: fit options and model name must be constant within a group",
PREDICT_ERR_OPTIONS);
return;
}

Expand Down Expand Up @@ -1072,14 +1128,15 @@ static void fit_step(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
}
f32 *row = &c->X[(size_t)c->n * c->nfeat];
for (int i = 0; i < c->nfeat; i++) {
int t = sqlite3_value_type(argv[i]);
/* features start after the optional leading name (argv[0]) */
int t = sqlite3_value_type(argv[has_name + i]);
if (t != SQLITE_INTEGER && t != SQLITE_FLOAT) {
c->err = SQLITE_ERROR;
c->errmsg = sqlite3_mprintf("%s: fit feature %d must be numeric",
PREDICT_ERR_SCHEMA, i + 1);
return;
}
f64 fv = sqlite3_value_double(argv[i]);
f64 fv = sqlite3_value_double(argv[has_name + i]);
if (!isfinite(fv)) { /* 1e999 -> +Inf as SQLITE_FLOAT; NaN poisons splits */
c->err = SQLITE_ERROR;
c->errmsg = sqlite3_mprintf("%s: fit feature %d must be finite",
Expand All @@ -1088,7 +1145,7 @@ static void fit_step(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
}
row[i] = (f32)fv;
}
sqlite3_value *lv = argv[c->nfeat];
sqlite3_value *lv = argv[has_name + c->nfeat];
if (c->classify) {
/* A NULL class label is not a real class; fail loudly rather than train a
* silent empty-string class. A NULL text under memory pressure is NOMEM. */
Expand Down
96 changes: 96 additions & 0 deletions tests/test_fit.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,102 @@ def test_fit_register_then_scalar_predict(db):
assert preds == ["1", "1", "0", "0"]


def test_fit_leading_name_rhymes_with_predict(db):
"""The guessable mirror: a leading TEXT model name registers the model, so
fit('id', f..., label) reads parallel to predict('id', f...). Same churn
signal, no options object needed."""
_seed(db)
mid = db.execute(
"SELECT fit('churn-lead', tenure, spend, churned) FROM h").fetchone()[0]
assert mid == "churn-lead"
preds = [r[1] for r in db.execute(
"SELECT id, predict('churn-lead', tenure, spend) FROM a ORDER BY id")]
assert preds == ["1", "1", "0", "0"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def test_fit_leading_name_composes_with_options(db):
"""A leading name and a trailing options object coexist: the name registers,
the options still select the student kind."""
_seed(db)
mid = db.execute(
"SELECT fit('lead-tree', tenure, spend, churned, '{\"kind\":\"tree\"}')"
" FROM h").fetchone()[0]
assert mid == "lead-tree"
assert db.execute("SELECT predict('lead-tree', 2, 20)").fetchone()[0] == "1"


def test_fit_leading_name_equals_options_register(db):
"""The two ways to name a model are equivalent: a model registered by a
leading name predicts identically to one registered via {"register":...} on
the same rows (same features, same training, deterministic)."""
_seed(db)
db.execute("SELECT fit('lead-eq', tenure, spend, churned) FROM h").fetchone()
db.execute("SELECT fit(tenure, spend, churned, '{\"register\":\"opt-eq\"}')"
" FROM h").fetchone()
lead = [r[0] for r in db.execute(
"SELECT predict('lead-eq', tenure, spend) FROM a ORDER BY id")]
opt = [r[0] for r in db.execute(
"SELECT predict('opt-eq', tenure, spend) FROM a ORDER BY id")]
assert lead == opt


def test_fit_model_name_given_twice_fails_loud(db):
"""A leading name and a {"register":...} option name the model twice. That is
a mistake, not a precedence to resolve silently: fail loudly
(PREDICT_ERR_OPTIONS)."""
_seed(db)
with pytest.raises(sqlite3.OperationalError) as e:
db.execute("SELECT fit('twice-a', tenure, spend, churned,"
" '{\"register\":\"twice-b\"}') FROM h").fetchall()
assert "PREDICT_ERR_OPTIONS" in str(e.value)


def test_fit_leading_name_still_needs_features_and_label(db):
"""A leading name does not substitute for data: fit('id', label) has a name
and a label but no features, and must fail loudly (PREDICT_ERR_SCHEMA)."""
_seed(db)
with pytest.raises(sqlite3.OperationalError) as e:
db.execute("SELECT fit('nofeat', churned) FROM h").fetchall()
assert "PREDICT_ERR_SCHEMA" in str(e.value)


def test_fit_leading_name_varying_within_group_fails_loud(db):
"""The leading name, like the options object, must be constant within an
aggregate group. A first TEXT argument that evaluates to different names
across rows fails loudly (PREDICT_ERR_OPTIONS) rather than silently binding
to whichever row SQLite happened to visit first."""
_seed(db)
with pytest.raises(sqlite3.OperationalError) as e:
db.execute(
"SELECT fit(CASE WHEN tenure < 10 THEN 'lo' ELSE 'hi' END,"
" tenure, spend, churned) FROM h").fetchall()
assert "PREDICT_ERR_OPTIONS" in str(e.value)


def test_fit_leading_name_rejects_embedded_nul(db):
"""A model name with an embedded NUL would be truncated by the C-string path,
registering and returning a different id than the caller supplied. Reject it
loudly (PREDICT_ERR_OPTIONS) instead of silently binding to the prefix."""
_seed(db)
with pytest.raises(sqlite3.OperationalError) as e:
db.execute("SELECT fit(char(97, 0, 98), tenure, spend, churned)"
" FROM h").fetchall()
assert "PREDICT_ERR_OPTIONS" in str(e.value)


def test_fit_leading_name_json_object_still_options(db):
"""The leading name discounts the options arity, so a trailing JSON-object
argument is consumed as options with or without a name: fit('id', f1, f2, obj)
fails loudly (PREDICT_ERR_OPTIONS) just like fit(f1, f2, obj), rather than one
form treating the object as a class label and the other as options."""
db.execute("CREATE TABLE jl2(f1 REAL, f2 REAL, lbl TEXT)")
db.executemany("INSERT INTO jl2 VALUES (?, ?, ?)",
[(i * 1.0, (i % 3) * 1.0, '{"category":"A"}') for i in range(20)])
with pytest.raises(sqlite3.OperationalError) as e:
db.execute("SELECT fit('jlmodel', f1, f2, lbl) FROM jl2").fetchall()
assert "PREDICT_ERR_OPTIONS" in str(e.value)


def test_fit_blob_served_via_cte(db):
"""No registration: fit() returns a model blob, served in one statement."""
_seed(db)
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/guides/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ e.g. `'{"confidence_level":0.9}'`.
| `forecast(ts, value, horizon [, options])` | Where is this metric going? | one JSON document per group: future rows with prediction intervals and a status |
| `detect_anomalies(ts, value [, options])` | Which points are abnormal? | one JSON document per group: anomaly-scored rows with expected value and probability |
| `backtest(ts, value, horizon [, options])` | How accurate is the model here? | one JSON document per group: per-fold MAE / RMSE / MASE / sMAPE and coverage |
| `fit(f1, ..., fN, label [, options])` | Train a model on labeled rows | a registered model id, or a model blob |
| `fit([name,] f1, ..., fN, label [, options])` | Train a model on labeled rows | a registered model id, or a model blob |
| `predict(model, f1, ..., fN [, options])` | Classify or regress a row | a prediction, or a `{prediction, confidence}` document with `proba` |
| `distill_predict(train_query [, options])` | Compress a teacher into a fast student | a registered native tabular model |
| `distill_forecast(train_query [, options])` | Compress a forecast model into a student | a registered native forecast model |
Expand Down
33 changes: 22 additions & 11 deletions website/src/content/docs/reference/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,30 @@ SELECT avg(mae) FROM backtest_rows(
(SELECT backtest(ts, value, 6, '{"folds":20}') FROM readings));
```

### `fit(f1, ..., fN, label [, options])`

Trains a native tabular student over your rows. The features are the leading
positional arguments and the **label is the last argument** (there is no
`target` option). The optional trailing `options` is a TEXT JSON object, so a
trailing `{...}` argument is always read as options: a class **label must not be
a JSON-object string** (it would be consumed as options). Returns the model:
with `'{"register":"churn-v1"}'` it registers into `_predict_models` and returns
the id, otherwise it returns a model blob you can pass to `predict`. Options:
`kind` (`gbt` default, or `tree`), `task` (`classify`/`regress`, inferred from
the label), `register`.
### `fit([name,] f1, ..., fN, label [, options])`

Trains a native tabular student over your rows. The features are positional and
the **label is the last argument before the optional `options`** (there is no
`target` option). An
optional **leading TEXT argument names and registers the model**, mirroring
Comment thread
coderabbitai[bot] marked this conversation as resolved.
`predict(model, ...)` so the train and serve calls read in parallel:
`fit('churn', f..., label)` then `predict('churn', f...)`. The same name can
instead be given as `'{"register":"churn"}'`, but supplying it both ways raises
`PREDICT_ERR_OPTIONS`. With a name, `fit` registers into `_predict_models` and
returns the id; with neither it returns a model blob you can pass to `predict`.
The optional trailing `options` is a TEXT JSON object, so a trailing `{...}`
argument is always read as options: a class **label must not be a JSON-object
string** (it would be consumed as options). Within an aggregate group the leading
name and the `options` object must be constant, or the call raises
`PREDICT_ERR_OPTIONS`. Options: `kind` (`gbt` default, or `tree`), `task`
(`classify` default, or `regress` for a numeric target), `register`.

```sql
-- name the model with a leading id, and fit/predict read in parallel:
SELECT fit('churn-v1', tenure, spend, churned) FROM history;
SELECT id, predict('churn-v1', tenure, spend) AS churn FROM active;

-- or name it in options, alongside the student kind:
SELECT fit(tenure, spend, churned, '{"kind":"gbt","register":"churn-v1"}') FROM history;
```

Expand Down
Loading