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

## [Unreleased]

### Added
- Monotone feature constraints ([#8](https://github.com/orgoca/CompileML/issues/8)):
`train_whitebox(monotone_constraints=...)` switches the whitebox backend to
`HistGradientBoostingRegressor` (extraction parity tested; the
unconstrained `GradientBoostingRegressor` path is byte-identical to
before). `build_artifact(monotone_constraints=...)` verifies the declared
directions against the compiled integer trees — refusing to build on any
violation, independent of the trainer — and records them as
`model.monotone_constraints` (hash-covered, spec §3.1). Validation gains
check 9, re-verifying the declaration from the artifact alone; at depth
≤ 2, `scorecard_monotone_report` certifies the aggregate direction on the
printed scorecard tables. `sweep_whitebox(monotone_constraints=...)`
measures the monotonicity premium.

## [0.1.1] - 2026-08-16

First complete release. Supersedes 0.1.0, whose wheel carried a stale
Expand Down
3 changes: 1 addition & 2 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,12 +9,11 @@ The [open issues](https://github.com/orgoca/CompileML/issues) are written to be
picked up: each one states the problem, why it matters in a regulated lending
context, a proposed approach, acceptance criteria, and the files involved.

Three of them are, in my view, what stands between this being interesting
Two of them are, in my view, what stands between this being interesting
infrastructure and something a risk function could actually adopt:

| | |
|---|---|
| [#8](https://github.com/orgoca/CompileML/issues/8) | **Monotone feature constraints.** Today the distilled whitebox cannot enforce them, so a compiled scorecard may show a bin where more delinquency scores *better*. That is a scorecard a committee rejects on sight. |
| [#9](https://github.com/orgoca/CompileML/issues/9) | **Stability monitoring (PSI/CSI/drift).** The validation framework checks an artifact at a point in time; model risk management is about what happens next. |
| [#10](https://github.com/orgoca/CompileML/issues/10) | **Fair lending.** Disparate impact testing, plus disparity decomposition over the exact attributions — something the reconciliation identity makes possible here in a way it is not elsewhere. |

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -305,7 +305,8 @@ It checks:
5. bad-rate monotonicity;
6. band-ladder churn;
7. explanation stability;
8. reason-code coverage.
8. reason-code coverage;
9. declared monotone directions, re-verified against the shipped trees.

These checks run against the compiled artifact through the same runtime used for production decisions. There is no separate notebook implementation allowed to become "almost the same" over time.

Expand Down
22 changes: 21 additions & 1 deletion docs/ARTIFACT_SPEC.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,7 +96,8 @@ model, not of the float model it came from.
"right": [4, 3, -1, -1, 6, -1, -1],
"value_micro": [0, 0, -18342, 21077, 0, -4410, 33590]
}
]
],
"monotone_constraints": [1, 0, -1, ...] // OPTIONAL (§3.1)
},
"calibration": { // OPTIONAL (null allowed)
"mode": "linear_int", // or "step"
Expand DownExpand Up@@ -138,6 +139,22 @@ model, not of the float model it came from.
payload. Interior nodes have `value_micro == 0`. Thresholds are float64 and
MUST round-trip exactly through JSON (shortest-repr serialization).

### 3.1 Monotone constraints (optional)

`model.monotone_constraints`, when present, is a list of length
`n_features` over `{-1, 0, +1}`: the declared direction of the compiled
score in each feature (+1 non-decreasing, −1 non-increasing, 0
unconstrained). The field is covered by the hash (§9) like everything
else.

The declaration is a *verified property of the shipped trees*, not a
training-time promise: builders MUST NOT emit the field unless the
quantized ensemble satisfies it (CompileML re-verifies tree-by-tree at
build and refuses otherwise), and validators re-verify it from the
artifact alone — validation check 9. Runtimes ignore the field; it
changes no decision, only what can be claimed about them. Absent field
means no directions are declared.

## 4. Scoring (normative)

Input: `x`, an array of float64 of length `n_features`, ordered by
Expand DownExpand Up@@ -376,6 +393,9 @@ identified by its hash, and the hash is the unit of governance.
---

*Changelog*
- **v2, additive** — optional `model.monotone_constraints` (§3.1): declared,
build-verified monotone directions. Absent field means unconstrained;
`schema_version` unchanged.
- **v2** — integer-quantized leaves, integer calibration, half-micro exact
attribution with largest-remainder display rounding, missing-value policy,
hash-verified loads. Supersedes a pre-release float-scoring layout that
Expand Down
14 changes: 14 additions & 0 deletions docs/faq.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,20 @@ or `compileml scorecard decision.json --format csv`. The printed tables
re-sum to every production decision bit-for-bit; a validator can reproduce
scores in a spreadsheet.

### Can I force a direction — "more delinquency must never score better"?

Yes. Pass `monotone_constraints` (per-feature −1/0/+1) to `train_whitebox`
and the whitebox is trained with scikit-learn's histogram GBM, which enforces
directions during growth. Pass the same declaration to `build_artifact` and
it is *re-verified against the compiled integer trees* — the build refuses on
any violation, whatever trainer produced the model — then recorded in the
artifact under the hash. Validation check 9 repeats the verification from the
artifact alone, and at depth ≤ 2 the printed scorecard certifies the
aggregate direction (`scorecard_monotone_report`). Constraints cost fidelity
wherever the teacher genuinely wiggles; [measure the
premium](howto/tuning.md#the-monotonicity-premium-measured) instead of
guessing it.

### Why not just use SHAP?

TreeSHAP is exact for trees and a fine analysis tool — the differences are
Expand Down
51 changes: 51 additions & 0 deletions docs/howto/tuning.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,6 +169,57 @@ spreadsheet.
Above depth 2, `build_scorecard` raises instead of approximating — the same
boundary as exact attribution, for the same reason.

## Enforcing monotone directions

A compiled scorecard with a bin where more delinquency scores *better* is a
scorecard a committee rejects on sight — even when the wiggle is statistically
justified. Declare the directions and the whitebox is trained with
scikit-learn's `HistGradientBoostingRegressor`, which enforces them during
tree growth:

```python
model, metrics = train_whitebox(
X, teacher_latent,
monotone_constraints={"UTIL": +1, "TENURE": -1}, # or a [-1, 0, +1, ...] list
)
artifact = build_artifact(
model, feature_names, baseline, edges,
monotone_constraints={"UTIL": +1, "TENURE": -1},
...,
)
```

Name-keyed dicts work at `train_whitebox` when `X` is a DataFrame; with bare
arrays, key by index. Without constraints, nothing changes — the classic
`GradientBoostingRegressor` path is untouched.

The declaration at `build_artifact` is not a training-time promise passed
along: the builder re-verifies the *quantized integer trees* against it,
tree by tree, and refuses to emit the artifact on any violation — whatever
trainer produced the model. The verified signs are recorded in the artifact
(`model.monotone_constraints`, hash-covered), validation check 9 re-verifies
them from the artifact alone, and at depth ≤ 2 the scorecard's own tables
certify the aggregate direction (`scorecard_monotone_report`) — a check a
validator can repeat in a spreadsheet.

### The monotonicity premium, measured

Constraints cost fidelity wherever the teacher genuinely wiggles, and the
two backends also regularize differently (histogram binning, leaf-size
defaults), so do not guess the cost — measure it:

```python
rows_free = sweep_whitebox(X, latent, y, X_val=Xv, y_val=yv, teacher_latent_val=lv)
rows_mono = sweep_whitebox(X, latent, y, X_val=Xv, y_val=yv, teacher_latent_val=lv,
monotone_constraints={"UTIL": +1, "TENURE": -1})
```

Diff the `gini_retention_pct` column at your chosen configuration. If the
premium is small, the teacher's wiggle was noise and the constraint bought
committee-credibility for free; if it is large, the teacher has learned a
genuinely non-monotone pattern, and *that* is worth investigating before any
constraint is imposed.

## Defaults, for the impatient

`train_whitebox(n_estimators=30, max_depth=2)` and `n_bands=10` are sane
Expand Down
6 changes: 4 additions & 2 deletions docs/howto/validate.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@ Or gate a pipeline on the CLI's exit code:
compileml validate decision.json --csv holdout.csv --y-col DEFAULT --require-reasons
```

## The eight checks
## The nine checks

| # | Check | What it proves | Needs |
|---|---|---|---|
Expand All@@ -36,9 +36,11 @@ compileml validate decision.json --csv holdout.csv --y-col DEFAULT --require-rea
| 6 | churn baseline | bootstrap ladder stability, measured with fixed-point edges | X + latent_train |
| 7 | explainability stability | top-k reason sets stable under small input perturbation, using the runtime's explainer | X |
| 8 | reason coverage | dictionary coverage of feature names; optional hard gate | nothing |
| 9 | monotone constraints | declared directions re-verified against the shipped integer trees ([spec §3.1](../ARTIFACT_SPEC.md)) | nothing |

Checks lacking inputs **skip** (reported as skipped, not passed silently);
check 1 and check 8 always run.
checks 1 and 8 always run, and check 9 runs whenever the artifact declares
constraints — it needs no data because the trees themselves are the evidence.

## Evidence, not verdicts

Expand Down
6 changes: 6 additions & 0 deletions docs/reference/api.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,12 @@

::: compileml.compile.quantize_model

::: compileml.compile.normalize_constraints

::: compileml.compile.verify_monotone_constraints

::: compileml.compile.scorecard_monotone_report

## Bands

::: compileml.bands.quantile_bands
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,7 +50,7 @@ handled per the artifact's missing policy.
compileml validate decision.json --csv holdout.csv --y-col DEFAULT --require-reasons
```

Runs the [eight-check framework](../howto/validate.md); prints the full
Runs the [nine-check framework](../howto/validate.md); prints the full
evidence report as JSON; exits non-zero if any check fails — suitable as a CI
deployment gate.

Expand Down
25 changes: 25 additions & 0 deletions src/compileml/artifact/build.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
from compileml import __version__
from compileml.artifact.calibration import fit_isotonic_table
from compileml.compile.extract import extract_trees, score_float
from compileml.compile.monotone import normalize_constraints, verify_monotone_constraints
from compileml.compile.quantize import (
max_depth,
quantization_error_bound,
Expand DownExpand Up@@ -52,6 +53,7 @@ def build_artifact(
display_names: dict | None = None,
feature_meta: list | None = None,
missing_policy: str = "baseline",
monotone_constraints=None,
metadata: dict | None = None,
scale: int = 1000,
micro_scale: int = 1_000_000,
Expand DownExpand Up@@ -82,6 +84,13 @@ def build_artifact(
for consumer-facing notices. Coverage below 100% warns and is
recorded in metadata (spec §7.6).
missing_policy: "baseline" (impute at decision time) or "reject".
monotone_constraints: Declared directions per feature: a sequence of
-1/0/+1 in feature order, or a dict keyed by feature name or
index. The compiled integer trees are *verified* against the
declaration (spec §3.1) — any violation raises, whatever
trainer produced the model — and the signs are recorded in the
artifact under ``model.monotone_constraints``, covered by the
hash. Validation check 9 re-verifies on the artifact alone.
X_sample: Optional sample rows; enables the measured quantization
report and the latent-range check.

Expand DownExpand Up@@ -118,6 +127,22 @@ def build_artifact(
for tree in extracted.trees:
tree["threshold"] = [round(t, int(threshold_decimals)) for t in tree["threshold"]]
model_int = quantize_model(extracted, micro_scale=micro_scale)

# --- monotone constraints: verify against the shipped trees, then record --
cst = normalize_constraints(monotone_constraints, len(names), feature_names=names)
if cst is not None:
report = verify_monotone_constraints(model_int, cst)
if not report["ok"]:
constrained = [names[i] for i, sign in enumerate(cst) if sign]
raise ValueError(
f"monotone constraint violated by the compiled trees: "
f"{report['n_violations']} violation(s) across {constrained}. "
"First examples: "
f"{report['violations'][:3]}. Retrain with "
"train_whitebox(..., monotone_constraints=...) to enforce them."
)
model_int["monotone_constraints"] = cst

depth = max_depth(model_int)
if depth > 2:
warnings.warn(
Expand Down
3 changes: 2 additions & 1 deletion src/compileml/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ def cmd_inspect(args) -> int:
},
"calibration_mode": (artifact.get("calibration") or {}).get("mode"),
"missing_policy": artifact["features"].get("missing_policy"),
"monotone_constraints": artifact["model"].get("monotone_constraints"),
"reason_coverage": meta.get("reason_coverage"),
"model_family": meta.get("model_family"),
"compileml_version": meta.get("compileml_version"),
Expand DownExpand Up@@ -252,7 +253,7 @@ 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 8-check validation framework")
p = sub.add_parser("validate", help="run the 9-check validation framework")
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
8 changes: 8 additions & 0 deletions src/compileml/compile/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,16 +11,24 @@
score_float,
validate_extraction,
)
from compileml.compile.monotone import (
normalize_constraints,
scorecard_monotone_report,
verify_monotone_constraints,
)
from compileml.compile.quantize import max_depth, quantization_error_bound, quantize_model, rha

__all__ = [
"ExtractedModel",
"extract_trees",
"max_depth",
"normalize_constraints",
"quantization_error_bound",
"quantize_model",
"rha",
"score_float",
"scorecard_monotone_report",
"train_whitebox",
"validate_extraction",
"verify_monotone_constraints",
]
46 changes: 37 additions & 9 deletions src/compileml/compile/distill.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,11 @@
to reproduce the teacher's latent. Depth ≤ 2 keeps the artifact's
attribution *exact* (spec §7.3) — going deeper trades exactness for
fidelity and is warned about loudly.

With ``monotone_constraints`` the whitebox is trained with
``HistGradientBoostingRegressor`` (the only sklearn GBM that enforces
``monotonic_cst`` during growth); without them the classic
``GradientBoostingRegressor`` path is untouched, byte for byte.
"""

from __future__ import annotations
Expand All@@ -13,7 +18,9 @@

import numpy as np
from scipy.stats import pearsonr, spearmanr
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.ensemble import GradientBoostingRegressor, HistGradientBoostingRegressor

from compileml.compile.monotone import normalize_constraints


def train_whitebox(
Expand All@@ -25,11 +32,18 @@ def train_whitebox(
learning_rate: float = 0.2,
random_state: int = 42,
loss: str = "squared_error",
) -> tuple[GradientBoostingRegressor, dict]:
monotone_constraints=None,
):
"""Fit a whitebox GBM to a teacher's latent scores.

Returns (model, metrics) where metrics quantifies distillation fidelity
on the training data (pearson, spearman, mae, rmse, prediction range).

``monotone_constraints`` takes a per-feature sequence of -1/0/+1 or a
dict keyed by feature index (or by name, when ``X`` is a DataFrame
carrying column names). Any nonzero sign switches
the backend to ``HistGradientBoostingRegressor``; ``None`` (or all
zeros) keeps the classic ``GradientBoostingRegressor``.
"""
if max_depth > 2:
warnings.warn(
Expand All@@ -40,13 +54,27 @@ def train_whitebox(

X_arr = np.asarray(X, dtype=float)
y = np.asarray(teacher_latent, dtype=float).reshape(-1)
model = GradientBoostingRegressor(
n_estimators=n_estimators,
max_depth=max_depth,
learning_rate=learning_rate,
random_state=random_state,
loss=loss,
)
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:
model = HistGradientBoostingRegressor(
max_iter=n_estimators,
max_depth=max_depth,
learning_rate=learning_rate,
monotonic_cst=cst,
early_stopping=False,
max_leaf_nodes=None,
random_state=random_state,
loss=loss,
)
else:
model = GradientBoostingRegressor(
n_estimators=n_estimators,
max_depth=max_depth,
learning_rate=learning_rate,
random_state=random_state,
loss=loss,
)
model.fit(X_arr, y)

y_hat = np.clip(model.predict(X_arr), 0.0, 1.0)
Expand Down
Loading
Loading