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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,41 @@ inside each artifact (`schema_version`).

## [Unreleased]

### Added
- `compile_selected` ([#75](https://github.com/orgoca/CompileML/issues/75)):
the target and the configuration are chosen on data the report never sees.
Rows are split into Fit, Select and Report; the ceiling (the teacher) is
tuned once, frozen and refit K times for cross-fitted soft targets; every
(α, trees, depth, monotone) configuration is compiled on Fit and scored on
Select as an integer artifact; the simplest configuration statistically
tied with the best wins, preferring α = 1 as a governance prior; the floor
gates; the winner is refit on Fit ∪ Select; and `result.report()` reads
Report once, writing the figures into a hash-covered provenance block. Below
a configurable event count in Select, nested cross-validation takes over.
Whether the whitebox learns from the ceiling's probabilities or from the
labels is now a selected parameter, not an assumption.
- `compileml.batch.score_batch`: the runtime's score path over a matrix, in
NumPy, asserted bit-identical to `decide()` on every row — 200,000 rows in
under a second. Selection ranks compiled artifacts with it; deployments
still use the runtime or an export.
- Validation check 11, selection hygiene: reads the provenance block and
lists broken protocol — soft targets not cross-fitted, partitions that
overlap, Report read more than once. Advisory unless
`require_selection_hygiene=True`, following check 10.
- `train_whitebox(backend="hist")` selects the histogram backend explicitly.
It was measured at 1–2 s where the classic backend took 18 s at 30k rows and
293 s at 300k; `compile_selected` uses it throughout. The default is
unchanged, since changing it would change every artifact built with the
defaults.
- `extract_trees` passes an `ExtractedModel` through, so a boosted fit can be
extracted once and compiled from prefixes of its trees: the first k trees of
a fit are the k-tree model, verified for both backends.

### Changed
- `sweep_whitebox` warns when `alpha_grid` is not passed — its default of
pure distillation is the assumption real data falsified — and when `X_val`
is used both to choose and to report. The defaults switch in 1.0.

## [0.8.0] - 2026-09-13

What compilation cost where decisions are made — and what to do about it.
Expand Down
13 changes: 12 additions & 1 deletion src/compileml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,15 @@
# `compileml_version` recorded inside every artifact can never disagree.
__version__ = "0.8.0"

__all__ = ["decide", "load_artifact", "verify_artifact", "__version__"]
__all__ = ["compile_selected", "decide", "load_artifact", "verify_artifact", "__version__"]


def __getattr__(name):
# compile_selected is learning-side (NumPy, scikit-learn). Importing it
# lazily keeps `import compileml` free of those dependencies, so the
# runtime story - copy compileml/runtime anywhere - stays true of the root.
if name == "compile_selected":
from compileml.select import compile_selected

return compile_selected
raise AttributeError(f"module 'compileml' has no attribute {name!r}")
122 changes: 122 additions & 0 deletions src/compileml/batch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Score many rows through an artifact at once, with NumPy.

The runtime scores one row at a time in the standard library, which is what
lets it run anywhere. Selection scores forty compiled candidates on hundreds
of thousands of rows, where a Python loop costs minutes per candidate. This
module does the same arithmetic vectorised — the same integer sums, the same
float64 comparison, the same rounding — and the test suite asserts it agrees
with :func:`compileml.runtime.decide` on every row.

Learning side: it reads an artifact and never makes a decision anyone acts
on. Deployments use the runtime or an export.
"""

from __future__ import annotations

import numpy as np

from compileml.runtime.calibrate import PD_SCALE
from compileml.runtime.io import ArtifactError
from compileml.runtime.score import LEAF


def _div_rha_nonneg(num: np.ndarray, den) -> np.ndarray:
"""Spec §2.2 for non-negative numerators, in int64."""
den = np.asarray(den, dtype=np.int64)
return (2 * num + den) // (2 * den)


def _prepare(artifact: dict, X) -> np.ndarray:
"""The runtime's ``_prepare_row`` over a matrix (spec §4, §8)."""
names = artifact["features"]["names"]
X_arr = np.array(X, dtype=np.float64, copy=True)
if X_arr.ndim != 2 or X_arr.shape[1] != len(names):
raise ArtifactError(f"expected {len(names)} feature columns, got shape {X_arr.shape}")
missing = np.isnan(X_arr)
if missing.any():
if artifact["features"].get("missing_policy", "baseline") == "reject":
rows = int(missing.any(axis=1).sum())
raise ArtifactError(f"{rows} row(s) have missing values and missing_policy is 'reject'")
baseline = np.asarray(artifact["features"]["baseline"], dtype=np.float64)
X_arr = np.where(missing, baseline[None, :], X_arr)
if artifact["model"].get("input_precision", "float64") == "float32":
X_arr = X_arr.astype(np.float32).astype(np.float64)
return X_arr


def raw_micro_batch(model: dict, X: np.ndarray) -> np.ndarray:
"""``score_micro`` for every row: integer sums of the leaves each row reaches."""
n = X.shape[0]
acc = np.full(n, int(model["base_micro"]), dtype=np.int64)
rows = np.arange(n)
for tree in model["trees"]:
feature = np.asarray(tree["feature"], dtype=np.int64)
threshold = np.asarray(tree["threshold"], dtype=np.float64)
left = np.asarray(tree["left"], dtype=np.int64)
right = np.asarray(tree["right"], dtype=np.int64)
value = np.asarray(tree["value_micro"], dtype=np.int64)
node = np.zeros(n, dtype=np.int64)
active = feature[node] != LEAF
while active.any():
idx = rows[active]
at = node[idx]
go_left = X[idx, feature[at]] <= threshold[at]
node[idx] = np.where(go_left, left[at], right[at])
active = feature[node] != LEAF
acc += value[node]
return acc


def calibrate_ppm_batch(
latent_micro: np.ndarray, calibration: dict | None, micro_scale: int
) -> np.ndarray:
"""``calibrate_ppm`` for every row (spec §6)."""
if not calibration:
return _div_rha_nonneg(latent_micro * PD_SCALE, micro_scale)
f = np.asarray(calibration["f_micro"], dtype=np.int64)
pd = np.asarray(calibration["pd_ppm"], dtype=np.int64)
k = len(f)
out = np.empty_like(latent_micro)
low = latent_micro <= f[0]
high = latent_micro >= f[k - 1]
out[low] = pd[0]
out[high] = pd[k - 1]
mid = ~(low | high)
if mid.any():
lm = latent_micro[mid]
hi = np.searchsorted(f, lm, side="right")
lo = hi - 1
if calibration.get("mode", "linear_int") == "step":
out[mid] = pd[lo]
else:
num = (pd[hi] - pd[lo]) * (lm - f[lo])
out[mid] = pd[lo] + _div_rha_nonneg(num, f[hi] - f[lo])
return out


def score_batch(artifact: dict, X) -> dict[str, np.ndarray]:
"""Score a matrix of rows: the runtime's score path, one array per output.

Returns ``raw_micro``, ``latent_micro``, ``latent_int``, ``band_idx`` and
``pd_ppm`` as int64 arrays, plus ``pd`` as float64. Every integer equals
what ``decide(artifact, row, explain=False)`` returns for that row.
"""
model = artifact["model"]
micro_scale = int(model["micro_scale"])
ratio = micro_scale // int(artifact["scale"])
X_arr = _prepare(artifact, X)

raw = raw_micro_batch(model, X_arr)
latent_micro = np.clip(raw, 0, micro_scale)
latent_int = _div_rha_nonneg(latent_micro, ratio)
edges = np.asarray(artifact["bands"]["edges_int"], dtype=np.int64)[1:-1]
band_idx = np.searchsorted(edges, latent_int, side="right")
pd_ppm = calibrate_ppm_batch(latent_micro, artifact.get("calibration"), micro_scale)
return {
"raw_micro": raw,
"latent_micro": latent_micro,
"latent_int": latent_int,
"band_idx": band_idx,
"pd_ppm": pd_ppm,
"pd": pd_ppm / PD_SCALE,
}
8 changes: 7 additions & 1 deletion src/compileml/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ def cmd_validate(args) -> int:
X_val=X_val,
y_val=y_val,
require_full_reason_coverage=args.require_reasons,
require_selection_hygiene=args.require_selection_hygiene,
)
print(json.dumps(report, indent=2))
return 0 if report["all_pass"] else 1
Expand Down Expand Up @@ -275,7 +276,12 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--out", help="output file (default: stdout)")
p.set_defaults(func=cmd_scorecard)

p = sub.add_parser("validate", help="run the 10-check validation framework")
p = sub.add_parser("validate", help="run the validation framework")
p.add_argument(
"--require-selection-hygiene",
action="store_true",
help="fail check 11 when the provenance block records a broken selection protocol",
)
p.add_argument("artifact")
p.add_argument("--csv", help="validation CSV (features + outcome)")
p.add_argument("--y-col", help="outcome column name in --csv")
Expand Down
15 changes: 14 additions & 1 deletion src/compileml/compile/distill.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def train_whitebox(
monotone_constraints=None,
teacher_latent=None,
sample_weight=None,
backend: str | None = None,
):
"""Fit a whitebox GBM to a target.

Expand All @@ -54,6 +55,14 @@ def train_whitebox(
the backend to ``HistGradientBoostingRegressor``; ``None`` (or all
zeros) keeps the classic ``GradientBoostingRegressor``.

``backend`` overrides that choice: ``"hist"`` for
``HistGradientBoostingRegressor`` and ``"gbr"`` for the classic
``GradientBoostingRegressor``. The histogram backend is the one to use
at scale — it bins features and runs multithreaded, and was measured at
1–2 s where the classic backend took 18 s at 30k rows and 293 s at 300k
— but the default stays ``None`` (classic unless constrained) because
changing it would change every artifact built with the defaults.

``sample_weight`` — one non-negative weight per row — tells the fit where
fidelity matters. A whitebox has a fixed budget and, unweighted, spends
it where most of the squared error is: the largest segment and the busiest
Expand Down Expand Up @@ -97,7 +106,11 @@ def train_whitebox(
raise ValueError("sample_weight must be finite, non-negative, and not all zero")
feature_names = list(X.columns) if hasattr(X, "columns") else None
cst = normalize_constraints(monotone_constraints, X_arr.shape[1], feature_names=feature_names)
if cst is not None:
if backend not in (None, "hist", "gbr"):
raise ValueError("backend must be None, 'hist' or 'gbr'")
if backend == "gbr" and cst is not None:
raise ValueError("monotone constraints need the 'hist' backend")
if cst is not None or backend == "hist":
model = HistGradientBoostingRegressor(
max_iter=n_estimators,
max_depth=max_depth,
Expand Down
10 changes: 9 additions & 1 deletion src/compileml/compile/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,15 @@ def _extract_lightgbm(model) -> ExtractedModel:


def extract_trees(model) -> ExtractedModel:
"""Extract a fitted model into float tree arrays; dispatches on family."""
"""Extract a fitted model into float tree arrays; dispatches on family.

An :class:`ExtractedModel` passes through unchanged. That is what lets a
caller extract a boosted model once and build artifacts from prefixes of
its trees — the first ``k`` trees of a boosted fit *are* the ``k``-tree
model — without refitting.
"""
if isinstance(model, ExtractedModel):
return model
if _is_sklearn_hist(model):
extracted = _extract_sklearn_hist(model)
validate_extraction(extracted, model)
Expand Down
Loading
Loading