Monotonic constraints & regulated models¶
When a model must be defensible — a fair-lending scorecard, an insurance filing, an underwriting model under model-risk review — accuracy alone is not enough. ml4 gives you two complementary tools for building models you can defend: monotonic constraints (guarantee each input moves the prediction in a sensible direction) and regulated glass-box models (a single transparent estimator you can read end to end). This page covers both.
In short
constraints.monotone_constraintspins a direction (+1/-1/0) per original feature;monotone_auto = Truederives directions from partial Spearman correlation.- Only the gradient boosters honour directions. A manual pin that cannot bind stops the run;
CAT:depthwise,CAT:lossguideandGBM:arithare refused when directions are active. - Constraints apply to binary classification and regression only.
- A regulated run (
roster.regulated = True) trains one transparent model —glm,scorecard,rulefitorebm— on a train/validation policy, with opaque feature engineering forced off. - The presets
regulated_monotonic,regulated_glassboxandregulated_scorecardwire this up for you.
Part 1 — Monotonic constraints¶
A monotonic constraint pins the direction a feature may move the prediction:
+1 means the prediction is non-decreasing in the feature, -1 non-increasing,
0 unconstrained. It trades a little accuracy for a guarantee — "higher debt ratio
never lowers the predicted risk" — that a regulator or business owner can rely on.
Constraints live in the constraints section:
Manual pins¶
Keys are original input feature names (as they appear in your source dataset);
values are +1, -1, or 0. A pin binds only for a feature that survives feature
engineering under its original name — the typical numeric pass-through column.
Non-supporting algorithms (random forests, extra trees, linear, GLM, EBM, RuleFit)
ignore the map entirely.
Automatic direction derivation¶
Pinning every direction by hand is tedious. Turn on monotone_auto and ml4 derives
a direction per numeric pass-through feature at fit time. The direction is the sign
of that feature's partial Spearman correlation with the target on the training split,
i.e. with the other numeric pass-through features held fixed. That is the same
ceteris-paribus reading a booster's constraint enforces, so a feature that correlates
with a stronger driver does not inherit the driver's sign:
cfg.constraints.monotone_auto = True
cfg.constraints.monotone_auto_threshold = 0.05 # min |partial Spearman rho| to constrain
The threshold is significance-gated with a Bonferroni correction across the candidates.
Features whose |partial Spearman rho| falls below the band stay unconstrained, so a
weak or noisy signal is never pinned to a spurious direction. Near-duplicate columns
(e.g. income and a rounded copy) have no direction of their own once the other is
held fixed, so they stay unconstrained; pin one manually if needed. Manual pins
always win over the derived direction for the features they name.
On a binary target, +1 means the probability of the positive class rises with the
feature: positive_class when it is configured, else the greater label (AutoGluon's own
rule). The configured class is matched to the target's values the way the fit matches it,
by string form and then by numeric value, so positive_class = "0" names an integer 0
label. Rows without a label take no part in the derivation.
Fail-loud compliance behaviour¶
The distinction between a manual pin and an auto direction is not only precedence — it is a compliance boundary:
| Situation | Behaviour |
|---|---|
| A manual pin that cannot bind (the feature is categorical, target-encoded, dropped, or renamed) | Compliance failure — raised loudly, at plan time or at fit time. Never silently skipped. |
| An auto-derived direction that cannot bind | Warns only. |
A manual pin that can't bind stops the run
If you pin age → -1 but age was one-hot encoded or dropped by feature
selection, ml4 does not quietly proceed with an unconstrained model — that
would ship a model claiming a guarantee it does not hold. The run fails loudly
so the mismatch is fixed, not discovered in production.
Which models honour constraints¶
Only the three gradient boosters support native monotone constraints:
| Model | Support |
|---|---|
GBM:gbdt / GBM:dart / GBM:rf / GBM:extra |
Native — every LightGBM variant, extra-trees mode included (it only randomises where a split falls; probed and pinned by a test). |
XGB:gbtree / XGB:dart |
Native — both XGBoost variants. |
CAT:symmetric |
Native, CPU only. |
CAT:depthwise / CAT:lossguide |
Not supported — the run is refused. |
GBM:arith |
Not supported — the run is refused. Its generated columns carry no direction. |
GBM:arith is refused for a reason of its own: it hands the booster up to ~2,000 products and
ratios of the run's columns. A product or a ratio of a constrained feature with another one has
no direction, so the model can recover that feature's effect through it, and the fitted function would
not be monotone in it. The refusal names the spec and this reason, before any model is built.
CatBoost has two separate rules, and they are easy to conflate: a device rule (CPU only, never a refusal) and a grow-policy rule (nonsymmetric trees are refused).
Details: the two CatBoost rules
- Device.
CAT:symmetrichonours a direction on CPU only, so a monotone-CatBoost run is forced onto CPU automatically — CatBoost alone, with a one-line warning, while the rest of the roster keeps the GPU. The run is never refused for this; see GPU. - Grow policy. CatBoost refuses a direction on nonsymmetric trees outright —
Monotone constraints are unsupported for nonsymmetric trees— and that is not a GPU restriction; it holds on CPU too. So a run with active directions that selectsCAT:depthwiseorCAT:lossguideis refused before a single model is built, naming both the roster entry and the constraint (roster_build.refuse_monotone_incapable_models, called fromfit_predictor). UseCAT:symmetricfor a constrained CatBoost, or drop the variant fromroster.models. The same call refusesGBM:arith; each refused spec's reason isconfig.roster.MONOTONE_REFUSALS.
Selecting a model with no direction to take (RF, XT, LR, the neural nets) alongside a
constrained booster is fine and is not refused: it trains unconstrained and the ensemble blends
the two. The refusal is narrower — it fires only for a variant whose siblings honour directions
and which cannot carry one.
This is a narrow line, and two things it does not mean
It is not "the tree families". RF and XT are tree ensembles too. What
separates the three above is that they accept a per-feature direction, not
that they are trees.
It is not the glass-box models. The table covers the stock roster. A
regulated glm or scorecard carries directions too, as sign-constrained
coefficients — see Part 2. Regulated
models are where a mandated direction matters most, so excluding them would
invert the priority.
Selecting a non-booster alongside a constrained booster is fine: it trains unconstrained. It still gets ml4's full fold-local feature engineering and per-fold resampling — those are family-agnostic, and only the direction is not.
Binary and regression only
A monotonic direction against a multiclass target is undefined — there is no single quantity for "rises with" to point at — so constraints apply to binary classification and regression only.
Auto-derivation declines on a multiclass target. A manual pin is
refused outright: fit_predictor raises before any model is built, and so does
the booster binder (the path ml4's own hyperparameter search takes). Remove the pin,
or model the outcome as binary or regression.
The pin is not dropped with a warning. The boosters would otherwise apply one constraint vector to every per-class tree series, turning one pin into K one-vs-rest constraints nobody asked for, at least one of which must point the wrong way. A mandated direction that silently becomes a different control is worse than no control.
The regulated_monotonic preset wires this up for you — a hyperparameter search across all
three boosters (GBM:gbdt, XGB:gbtree, CAT:symmetric — each honours the constraint) whose
single winner deploys, with auto-derived directions and audit-friendly feature engineering:
from parleml_ml4.config import presets
cfg = presets.regulated_monotonic()
cfg.data.target_column = "default"
Part 2 — Regulated glass-box models¶
Sometimes a monotone booster is not transparent enough — the filing needs a model whose every decision traces back to a readable artifact. A regulated run trains exactly one transparent glass-box estimator whose native output is the deliverable an auditor reads.
The glass-box quartet¶
| Kind | Model | Audit artifact |
|---|---|---|
glm |
scikit-learn linear (logistic / ridge) | Coefficients |
scorecard |
Weight-of-evidence logistic + a coefficient→points table | The points table |
rulefit |
imodels RuleFit | Human-readable rules |
ebm |
Explainable Boosting Machine (adopted, audit-wrapped) | Per-feature shape functions |
The glm, scorecard, and rulefit variants are three faces of one custom
AutoGluon model; ebm is adopted stock, routed through an audit wrapper. In every
case ml4 extracts nothing of its own — the fitted library estimator is the
artifact (the scorecard points table is the sole ml4-owned exception).
Details: RuleFit serves sigma(z), not upstream's probability link
rulefit fits parleml_ml4.engine.models.rulefit.Ml4RuleFitClassifier, a RuleFitClassifier
subclass, rather than the stock class. Upstream builds its two probability columns as
softmax([1 − z, z]) over its own fitted logit z, which is sigma(2z − 1): the logit
doubled and shifted by −1. That is not a rescaling. It moves the 0.5 cut from z > 0 to
z > 0.5, so AutoGluon's labels (and the leaderboard score_val) would be measured at an
operating point the model was never fitted at, and every probability-scale artefact in the
Explain tab would read the distorted number. The subclass applies sigma(z). Everything that
reads the raw estimator — the airgapped export, MLI's adapter — therefore agrees with the
served probability by construction. The rules table itself is upstream's, untouched.
The linear design matrix is full rank¶
A coefficient table only means anything if each coefficient is an effect. Two structural
guards on the glm / scorecard / rulefit matrix make that true by construction rather
than by luck: a categorical that a numeric twin already encodes is dropped, and the remaining
dummy block is reference-coded.
Details: the two full-rank guards
- A categorical a numeric twin already encodes is dropped, not one-hot-encoded. When
more than one encoder code is configured (the default
["TE", "CN"]), the recipe keeps the raw column alongside its twins — each code needs it as input, and the boosters want the categorical identity. Butcity_TEandcity_CNare per-level constants, so each lies exactly in the span ofcity's own dummy block: handed all three, the linear model splits one attribute's effect acrossL + 2columns and no row of the audit is the effect ofcity=CA. The linear path therefore drops the raw column whenever a live twin represents it. (If the twin was pruned by feature selection, the raw column stays — then it is the only representation left.) - The residual dummy block is reference-coded. A full dummy block sums to 1 and is
collinear with the fitted intercept. An L2 penalty hides that by silently picking the
sum-to-zero representative, but
glm_family="lasso"/"elasticnet"and RuleFit's internal L1 have a whole set of minimisers over such a block. One level per categorical is the reference; the price, stated rather than hidden, is that a level unseen at fit time scores as the reference level.
Enabling a regulated run¶
Set regulated=True, pick a regulated_kind, and give the run a train/validation
policy — a regulated run trains one model, so the default cross_validation is
refused (use chronological_train_validation on a temporal split):
from parleml_ml4.config.roster import Ml4RosterConfig
cfg.engine.training_policy = "train_validation"
cfg.roster = Ml4RosterConfig(regulated=True, regulated_kind="glm")
Or use a preset, which also applies audit-friendly feature engineering:
from parleml_ml4.config import presets
cfg = presets.regulated_glassbox("ebm") # glm (default) / ebm / rulefit
cfg = presets.regulated_scorecard() # weight-of-evidence logistic → points
What makes a scorecard a scorecard¶
The scorecard kind is not "a GLM that also prints a table". Two things belong to the
kind itself:
It owns its feature-engineering contract. regulated_kind="scorecard" forces
target_encoding.target_encoders = ["WE"], so every categorical attribute enters the
logistic as one weight-of-evidence log-odds column instead of the default target-mean
plus frequency pair — and every continuous predictor is monotone coarse-classed into bands
and enters as the weight of evidence of its band, replacing the raw column
(coarse-classing). That is what makes a coefficient→points table a
per-attribute artifact rather than a list of engineered columns. The planner derives both
from the kind and logs them, so picking "Credit scorecard" in the roster panel on any template
gets it, not only the regulated_scorecard preset.
Its points table is a score, not a rescaling. The table carries one row per attribute
plus a leading (base score) row, so:
Points are stated in the attribute's own units — the coefficients are un-standardized before scaling, since the estimator is fitted on a standardized matrix and a "point per standard deviation of an engineered column" cannot be applied to an applicant. The scale is the standard one: 20 points to double the odds, anchored at 600 points for 50:1 good:bad.
Coarse-classing¶
Classic credit practice bins a variable before computing its weight of evidence, so no
attribute's log-odds rests on a handful of rows. regulated_kind="scorecard" does this on
both kinds of predictor, and the treatment differs because the problem does:
| Predictor | Treatment | Floor |
|---|---|---|
| Categorical | Levels too thin to estimate are pooled into one shared "rare" class; what survives is shrunk toward the prior by n / (n + 20) in log-odds space. |
20 rows per level |
| Continuous | Monotone coarse-classing into at most 8 bands: quantile pre-bins, merged until every band clears the population floor, then merged until the event rate is monotone across the bands, then merged down to the cap. One weight of evidence per band replaces the raw column. | the larger of 20 rows and 5% of the fitting slice |
Both fits are derived from the slice they are handed, so they inherit the encoder's out-of-fold lifecycle: a training row's weight of evidence comes from a fit — and a binning — that never saw it. Non-finite values (missing, ±inf) form their own band and get their own weight of evidence, the way a scorecard gives "not stated" its own attribute row.
Details: what monotone coarse-classing costs, and what is still per-column
A U-shaped predictor loses the fold. The merge is monotone by construction, so a variable whose risk rises at both extremes is merged until it is monotone and one side of the U is absorbed. That is the classic trade and it is deliberate — a band sequence a reviewer cannot read as "risk moves one way with this attribute" is not a defensible scorecard row — but if you know a predictor is U-shaped, split it into two declared features yourself.
Only declared numeric predictors are banded. A numeric column engineered by the
plan (a _DS days-since, a temporal lag) enters standardized and raw, so its points row is
a slope rather than a band. On a regulated run most of the opaque engineering is forced off
already (the forced levers), which keeps this rare rather than
invisible.
The points table has one row per attribute, not one per band. The coefficient is stated per unit of the attribute's weight of evidence; the band a given applicant falls into is what turns that into their points. A per-band expansion of the table is a reporting change on top of this, not a modelling one.
scorecard remains excluded from hyperparameter search: the bands are part of the model's
contract, not a knob to sweep.
The forced levers¶
roster.regulated trains one transparent model, so it pairs only with a
train/validation policy: a regulated config with
engine.training_policy="cross_validation" is refused, with a message naming both
fields. The run's fit shape (parleml_ml4.config.resolve_fit_shape) then derives every
AutoGluon lever from that pairing, so an incoherent regulated config (regulated +
bagging + weighted ensemble) cannot be built. A regulated run is fitted with:
| Lever | Forced value | Why |
|---|---|---|
num_bag_folds |
0 |
Single deployed model — no bagging. |
num_bag_sets |
not sent | Nothing to repeat with no bag. |
save_bag_folds |
False |
No fold children exist to persist. |
num_stack_levels |
0 |
No stacking. |
dynamic_stacking |
False |
No stacking. |
fit_weighted_ensemble |
False |
One model, not an ensemble. |
keep_only_best |
True |
Keep the single transparent model. |
time_limit |
None |
Deterministic — no wall-clock budget. |
random_state |
0 |
Deterministic — the seed never moves AutoGluon's validation carve. |
The result is one non-bagged, deterministic, transparent model with a 1:1 audit trail. Because there is no ensemble and no bagging, a regulated run reserves a holdout for its post-fit layers rather than using bagged OOF.
It also forces the opaque feature-engineering stages off, whatever template the flag was set on:
stateful_fe stage |
Why a glass-box audit cannot carry it |
|---|---|
add_tree_guided_interactions (+ tgi_importance_prune, add_tgi_feedback_round) |
A dedicated LightGBM picks the crosses and quantile bands, against the target, on the very rows the transparent model is then fitted on. |
add_cv_cat_num |
A statistic about a pair of columns, not about either one. |
add_clustering_encoding |
Reference-coded into dummies whose names are cluster ordinals. |
add_gp_features |
Mined topics out of a free-text column. |
add_text_embeddings |
Dense sentence-transformer dimensions. |
Target encoding stays on: it emits one named column per attribute (city_TE /
city_WE), which is exactly the per-attribute row a coefficient or points table needs.
This is a rewrite, not a warning, and it is announced in the run log naming the stages
it flipped. It is the one place a regulated config is corrected rather than reported:
leaving these on would actively mine machine-named columns into the one model whose promise
is that it has none. The three regulated_* presets compose the same list
(config.dependencies.OPAQUE_FE_FIELDS), so the preset and the flag cannot disagree.
Details: where the audit artifact comes out
The coefficients / shapes / rules are read off the persisted estimator by MLI. The
points table cannot be — it lives on ml4's AutoGluon wrapper, one level above the
LogisticRegression, whose coefficients are per standard deviation of an engineered
column. It therefore travels its own route: FittedPipeline.scorecard_points() on the
published contract, a scorecard.json sidecar in the saved bundle, and the product's
Explain → Glass-box tab, which renders the points table instead of the estimator's
coefficients. All three serialize through one ScorecardTable.to_dict(), so an
auditor reading the sidecar and an auditor reading the screen cannot see different
numbers.
The determinism claim is testable
execution.seed reaches the regulated model itself, so a reviewer who re-runs at a second
seed tests the seed, not an accident. What the seed does and does not buy, and how far a
reviewer can push the claim, is set out in
Reproducibility & seeds.
The foundation challenger is a no-op here
A regulated run is single-model by construction, so
with_foundation_challenger on a regulated config warns and has no effect —
there is no ensemble for a challenger to join.
Task support¶
Not every glass-box kind supports every task:
| Kind | Binary | Multiclass | Regression |
|---|---|---|---|
glm |
✓ | ✓ | ✓ |
ebm |
✓ | ✓ | ✓ |
rulefit |
✓ | — | ✓ |
scorecard |
✓ | — | — |
imodels RuleFit has no multiclass implementation, and a credit scorecard is by
definition a binary good/bad model. Selecting an unsupported (kind, task) pair is
rejected before training.
Audit-friendly feature engineering¶
A transparent estimator fed by opaque features is not actually transparent. The
audit_friendly_fe composer (applied by every regulated_* preset) disables the
opaque feature-engineering stages — the same list as the forced levers:
tree-guided interactions, CV cat×num statistics, clustering encoding, Gamma-Poisson text and
text embeddings — so the deployed model traces more directly back to its raw inputs.
Choosing between them¶
| You need… | Reach for |
|---|---|
| Gradient-boosting accuracy with defensible directions | regulated_monotonic (a tuned monotone booster) |
| A readable linear model with sign-checkable coefficients | regulated_glassbox("glm") |
| A classic credit / Basel scorecard | regulated_scorecard |
| A rules table a reviewer can read | regulated_glassbox("rulefit") |
| Per-feature shape functions | regulated_glassbox("ebm") |
Monotone constraints and glass-box are composable
A regulated GLM or scorecard can still carry monotone constraints (the linear variants pass them through), and a monotone booster can still be delivered with audit-friendly FE. The two tools stack.
Where to go next¶
- The roster and the four regulated kinds in context: Algorithms & metrics.
- The presets that wire these up: Configuration & presets.
- How a single-model run carves its holdout: Data splitting & validation.
- What the post-fit layers do: The training workflow.