Skip to content

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_constraints pins a direction (+1 / -1 / 0) per original feature; monotone_auto = True derives directions from partial Spearman correlation.
  • Only the gradient boosters honour directions. A manual pin that cannot bind stops the run; CAT:depthwise, CAT:lossguide and GBM:arith are 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, rulefit or ebm — on a train/validation policy, with opaque feature engineering forced off.
  • The presets regulated_monotonic, regulated_glassbox and regulated_scorecard wire 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:

cfg.constraints.monotone_constraints = {"debt_ratio": 1, "age": -1}

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:symmetric honours 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 selects CAT:depthwise or CAT:lossguide is refused before a single model is built, naming both the roster entry and the constraint (roster_build.refuse_monotone_incapable_models, called from fit_predictor). Use CAT:symmetric for a constrained CatBoost, or drop the variant from roster.models. The same call refuses GBM:arith; each refused spec's reason is config.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. But city_TE and city_CN are per-level constants, so each lies exactly in the span of city's own dummy block: handed all three, the linear model splits one attribute's effect across L + 2 columns and no row of the audit is the effect of city=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:

score(row) = base + Σ points_j · attribute_j
           = 600 + (20 / ln 2) · ln(odds_good / 50)

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.

from parleml_ml4.config import presets

cfg = presets.audit_friendly_fe(cfg)     # apply to any config

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

Next: Algorithms & metrics · Configuration & presets