Rule extraction¶
mli mines human-readable IF-THEN rules directly from a fitted tree
ensemble. Each rule is a conjunction of feature <op> value antecedents with
quality metrics (coverage, confidence, recall, lift, TP / FP counts and the
Wilson lower bound of its precision) — measured on the rows it was mined on
(Train) and, given held-out rows, on rows neither the model nor the rule
search saw (Test), with a verdict on whether the rule holds up there.
The rules are label rules: patterns in the labels, found through the model's trees. Their conditions come from the model's splits, but every number — precision, recall, lift — is measured against the true labels, not the model's predictions.
In short
- Turn rules on with
ProfilingConfig(rules=RulesConfig()), or callextract_ruleson a fitted ensemble and a binary target. - The default runs two engines:
positive_miner(independent, lift-gated rules) andsequential(a small set that covers the positives).all_classis opt-in and binary only. - Train numbers are optimistic. Pass
X_holdout/y_holdoutto get Test numbers and a verdict per rule:holds_up,does_not_hold_uportoo_few_rows. - Categorical splits read as the original labels, and missing values follow the model's own routing.
- Rules need a binary target; multiclass models are mined one class at a time.
Agent access
Rule cards are exposed to AI agents as the MCP tool get_rules (model
and business kinds), and the published *__rules / *__rules_fired SQL
tables answer per-row questions — see the ParleML MCP docs site
(parleml_mcp/docs/).
Two ways in¶
As part of profiling — set ProfilingConfig(rules=RulesConfig(...)) and
read result.rules_:
from parleml_mli import ProfilingConfig, RulesConfig, fit_profiler
config = ProfilingConfig(rules=RulesConfig())
result = fit_profiler(
model=clf, X=X_train, y=y_train,
target_mask=(y_train == 1), config=config,
X_holdout=X_test, y_holdout=y_test, # rows the model never saw → Test numbers
)
summary = result.rules_.default_engine_summary # a RuleSetSummary
for rule in summary.rules:
print(rule.text, rule.holds_up)
# Every ruleset, keyed by the engine that mined it (positive_miner and
# sequential after engine="all").
for engine, engine_summary in result.rules_.engine_summaries().items():
print(engine, engine_summary.n_rules)
X_holdout / y_holdout are the rows the rules are tested on; they feed
nothing else. The model-evaluation splits are X_test / y_test (and
X_valid / y_valid) — pass the same rows to both when you want both.
Or lazily on an existing result:
from parleml_mli import AcceptanceGates, MinerConfig, RulesConfig
from parleml_mli.result import build_rules_result
result.rules_ = build_rules_result(
result,
RulesConfig(
engine="positive_miner",
miner=MinerConfig(acceptance=AcceptanceGates(min_lift=2.0)),
),
X=X_train,
X_holdout=X_test, y_holdout=y_test,
)
Standalone — call extract_rules on any
fitted ensemble + binary y:
from parleml_mli import extract_rules
from parleml_mli.config import RulesConfig
summary = extract_rules(
model=clf,
X=X_train,
y=(y_train == 1).to_numpy(), # binary target
config=RulesConfig(engine="positive_miner"),
X_holdout=X_test, y_holdout=(y_test == 1).to_numpy(), # optional: Test numbers
)
print(summary.to_dataframe()) # tidy pandas view of every rule
X must hold the columns the model was fitted on, in the order it was
fitted on: rules name a split's column by its position in the model. When
the model recorded its column names (a model fitted on a DataFrame),
extract_rules raises ValueError on any difference in names or order.
Engines¶
Pick the engine with RulesConfig(engine=...) — both when profiling and on
standalone extract_rules:
| Engine | Class | Use when |
|---|---|---|
"all" (default) |
— | Runs "positive_miner" and "sequential", each ruleset kept in result.rules_.by_engine. Standalone extract_rules mines one engine and reads "all" as "positive_miner". |
"positive_miner" |
PositiveRuleMiner |
You want independently fireable, lift-gated rules with sibling alternatives — the general-purpose business-rule view. |
"sequential" |
PositiveSequentialRuleMiner |
You want a minimal operational set that greedily covers the positive class with as few rules as possible. |
"all_class" |
AllClassRuleClassifier |
You want a rule set that behaves like a classifier across all classes (cascade mode with metric / conf_threshold / cov_threshold). Slow on large ensembles, so it runs only when named; binary models only. |
A tuple of distinct engine names runs each of them, stored like "all" —
RulesConfig(engine=("positive_miner", "sequential", "all_class")) adds
the all-class list to the default pair — and RulesConfig.engines resolves
any spec to its engine tuple. RulesConfig refuses an unknown engine (or a
tuple with an unknown or repeated one) with a ValueError when it is
built, and standalone extract_rules refuses a tuple. In a fan-out, an
engine that fails on the model is recorded in
result.rules_.engines_skipped while the others still mine.
MinerConfig holds the positive/sequential miner knobs (via
RulesConfig(miner=MinerConfig(...))). It is composed from cohesive
sub-sections rather than a flat field pile — expansion=ExpansionGates(...),
wilson=WilsonConfig(...), acceptance=AcceptanceGates(...),
dedup=DedupConfig(...) and business_value=BusinessValueConfig(...) — plus a
few miner-wide top-level knobs (max_rules, max_combination_iterations,
catboost_combination_handling, …). The whole dedup section is ignored by
the sequential engine. wilson is the run's confidence for every engine:
the Wilson bounds on the mining and the held-out rows, and the holds-up
verdict. RulesConfig(max_trees=100) caps the boosting rounds (base
estimators for forests, AdaBoost and gradient boosting) every engine reads
from the model, counting only those the model predicts with; None mines
the whole ensemble. Standalone
extract_rules takes the same
RulesConfig(miner=MinerConfig(...)). On the positive_miner /
sequential branches the cascade-mode params (metric, conf_threshold,
cov_threshold, all_class=AllClassConfig(...)) are ignored.
Search budget and multiclass workers¶
Combining leaves across trees grows the candidate pool with every tree, so
each search (one engine, one class) is bounded:
MinerConfig(max_candidates=20_000) stops combining once the pool holds that
many candidate rules (deterministic), and MinerConfig(time_budget_s=600) is a
wall-clock backstop (machine-dependent). A stopped search keeps every tree's
own leaves plus the combinations of the earliest trees, mines its best rules
from them and says why on RuleSetSummary.budget_stop; None disables either
cap. A multiclass model's classes are mined in up to
RulesConfig(class_workers=4) worker processes (capped at the class count;
1 mines them in turn in this process) with the same result for any worker
count. result.rules_.class_status lists, per class × engine, the rules mined,
a failure's reason (never empty) and a budget stop.
Anatomy of a rule¶
rule = summary.rules[0]
rule.antecedents # tuple[Antecedent, ...] — the IF clauses
rule.text # '(amount > 500) AND (CITY in {"Accra", "Lagos"}) -> [1]'
rule.lift # Train: how much more likely the positive class is when fired
rule.coverage # Train: fraction of rows the rule fires on
rule.confidence # Train: P(positive | rule fires) on the mining rows
rule.metrics_holdout # the same numbers on held-out rows (Test), or None
rule.holds_up # "holds_up" | "does_not_hold_up" | "too_few_rows" | None
Each Antecedent is a feature <op> value
(or feature <op> value_set) clause. Operator covers the numeric comparisons
plus set membership (IN / NOT_IN) for native categoricals.
LightGBM splits zero against non-zero at ±1e-35, so a yes/no column (stored
as 0/1) splits at 1e-35. The text writes such a bound at 0 with the operator
that fires on the same rows — (auto_renew <= 0), (x < 0) for x <= -1e-35,
(x >= 0) for x > -1e-35 — while Antecedent.value keeps the model's split.
Train and Test numbers¶
A rule's flat numbers (coverage, confidence, recall, lift, tp, fp,
precision_lower) are its Train numbers, measured on the rows it was mined
on. They describe those rows and are optimistic by construction: the search
kept the rules that looked best there, so a Train precision — or its Wilson
lower bound — is no estimate for new rows. On pure-noise labels mined rules
still look good on Train.
Given held-out rows — rows the rule search never saw, and for a model trained
on other rows the model neither (an imported model's training rows are unknown,
so its Test rows are a held-back half of the reference sample) — every rule also gets Test numbers, measured by the same scorer
(parleml_mli.rules.scoring.score_rules) on those rows: rule.metrics_holdout
(precision, recall, lift, support, coverage, tp, fp, the Wilson interval of the
precision, covered and total rows). The whole list gets metrics_holdout (the
classification numbers of its prediction) and cumulative_holdout (its curve
after each rule, in the train pick order, so the Train and Test curves compare
rule by rule). fit_profiler, build_rules_result, extract_rules and
extract_rules_ovr all take X_holdout / y_holdout — same columns as X,
labels like y — and holdout_basis (parleml_mli.HoldoutBasis), which records where
the rows come from and is stamped on every rule set (as a "none" basis when
there are no held-out rows). A held-out label the rules were never mined on
counts against every rule; a row without a label is dropped with a warning.
Holds up? Each rule gets a verdict on its Test numbers, rule.holds_up:
| Verdict | When |
|---|---|
"too_few_rows" |
It covers fewer than RulesConfig(holdout=RulesHoldoutConfig(min_test_rows=20)) held-out rows — too few to say. |
"holds_up" |
The Wilson lower bound of its test precision exceeds the test base rate of its class: its test lift is credibly above 1. |
"does_not_hold_up" |
Otherwise. |
The floor counts covered rows, not positives, so a rule that covers many test rows and catches no positive gets a verdict instead of hiding as "too few".
The confidence is the run's MinerConfig.wilson (one-sided, alpha=0.05 by
default) — the same one mining and the Train bounds use. With one-sided bounds,
precision_lower and precision_upper are each a one-sided 1 − alpha
limit, so together they span a 1 − 2·alpha interval (90 % at the default).
The verdict always uses the interval's lower bound, even with
use_lower_bound=False (which makes the mining and reported bounds the raw
precision).
result.rules_.config records min_test_rows and the wilson settings. The
verdict is None without held-out rows and for an all-class list's default
rule.
The rules of an ordered list — the sequential and all-class engines, read first
rule wins — also carry cascade numbers on both sets (rule.cascade,
rule.cascade_holdout): precision and recall on the rows the rules above them
leave.
A summary built without held-out rows can be measured later, in place:
summary.measure_holdout(X_test, y_test_indicator, wilson=WilsonConfig(),
min_test_rows=20), where the labels are those the rules predict — the
{0, 1} indicator for a binary summary.
Native categorical rules¶
When the model was trained with native categorical features, set-membership splits decode back to the original category labels:
CatBoost decoding covers all three split families (OneHotFeature exact,
single-element OnlineCtr, and multi-element Combination / OnlineCtr).
Long category sets are collapsed at render time per
CardinalityCaps
— the underlying value_set is never truncated.
Details: the values each model family decodes to
Each family decodes a split to the values its model reads:
- LightGBM — the booster's
pandas_categorical, paired with the mining frame'spd.Categoricalcolumns in frame order as LightGBM pairs them at predict time; an integer column declared throughcategorical_featuredecodes to its raw values. - HistGradientBoosting — the categories the model's input holds: the
labels of a
pd.Categoricalcolumn, the raw values of a numeric one (never bin positions). - CatBoost — the mining frame's own values: CatBoost stores categories as text, so a column of integer codes (AutoGluon's encoding) decodes back to its codes.
- XGBoost — the mining frame's labels when its column holds the table the booster stored at fit time (XGBoost stores labels that are not pure ASCII truncated), else the booster's own category table.
XGBoost's tree diagrams and the surrogate / error-tree node tables decode the same way.
Missing values¶
Every condition routes a missing value the way the model does. On the model's missing branch, a
numeric antecedent also fires on missing values and renders as (age <= 40 or missing).
Details: missing-value routing by model family
Rules are tested on rows the mining never saw, so every condition routes a
missing value the way the model does — whether or not the mining rows held
one. On the branch the model sends missing values down, a numeric
antecedent also fires on missing values and renders as
(age <= 40 or missing) (Antecedent.missing_fires, "missing_fires" in
every numeric antecedent's to_dict() payload). The direction is read from
each split: XGBoost's missing branch; LightGBM's default_left (with
missing_type=None LightGBM reads a missing value as 0); the
scikit-learn / HistGradientBoosting missing_go_to_left (AdaBoost and
GradientBoosting refuse missing values, and their rules follow the trees'
direction so every row still fires one rule per tree); CatBoost's
nan_value_treatment. A categorical antecedent carries missing values as a
NaN member of its set.
An XGBoost model trained with a finite missing value (for example
missing=0) reads that value as missing too: its numeric antecedents carry
Antecedent.missing_value, never compare it, and render it as
(income > 5; 0 counts as missing).
A LightGBM model trained with zero_as_missing=True is refused with a
ValueError: its splits send zeros down the missing branch, which no rule
can express. Retrain it with zero_as_missing=False to mine rules.
The positive and sequential miners mine data with missing values for any
model. AllClassRuleClassifier fits and predicts rows with missing values
when its base ensemble accepts them — XGBoost, LightGBM, CatBoost, or a
scikit-learn estimator whose tags declare allow_nan (RandomForest,
ExtraTrees, decision trees, Bagging over trees) — or when the frame has
native categorical columns; over its default GradientBoostingClassifier it
rejects a numeric matrix with missing values.
Unseen categories¶
A held-out row can carry a category the model never saw. Each condition sends it down the branch
the model would take. A condition that can only be read approximately carries a (±marker) badge.
Details: unseen categories and approximate conditions
A held-out row can carry a category the model never saw. An IN antecedent
fires on its set only and a NOT_IN antecedent on everything else, so a set
is never stored as its complement: each split's pair takes the form that
sends an unseen category down the model's own branch. LightGBM, and XGBoost
on raw integer codes, send it outside the split's list; HistGradientBoosting
sends it down the split's missing-value branch; CatBoost one-hot splits send
it out of the set, and CatBoost CTR splits route it by the prior-only CTR
value, as CatBoost does. An XGBoost model with a category table refuses such a
row; its rules route it like a missing value. A missing category goes with the
unseen ones where the model gives it no branch of its own. CatBoost reads only
the categories the mining rows contain; one the model saw that the mining
sample lacks is treated like an unseen category.
A condition read approximately says how in Antecedent.approximation,
rendered as a (±marker) badge: ctr-counter / ctr-feature-freq /
ctr-borders (a CatBoost CTR split), unseen-as-missing (an XGBoost
categorical split routes an unseen category like a missing value),
combination-merged (a CatBoost tree split on combined categorical columns
was merged away), raw-cat-indices / raw-cat-int-hash (the labels were not
recoverable). A multiclass CatBoost model that splits on combined categorical
columns has no rules: it raises RulesUnavailableError, which one-vs-rest
extraction records in engines_skipped (retrain with max_ctr_complexity=1).
Display caps¶
Antecedent.to_text(caps=CardinalityCaps(inline_limit=15, top_k=5)) controls how
many category labels render inline before collapsing to {a, b, c, ... (+N more)}.
This affects rendering only — semantic evaluation always uses the full set.
Caveats¶
- Binary only. Rule miners assume a positive/negative dichotomy. For
regression, binarize via
target_maskfirst (see the Profiling guide). - Classifiers. LightGBM, XGBoost and HistGradientBoosting regressors are not supported; CatBoost's regressor and ranker are (their trees' regions are mined against the binary target).
- Multiclass models are mined one class at a time (one-vs-rest, see
extract_rules_ovr), and every class's rules are measured on the Train and Test rows in the class-vs-rest view; the all-class engine is binary-only.
Next¶
- Global explanations: effect curves, importance, fairness and the error tree.
- Decision support: the rules a single row fires.