Imbalanced data & outliers¶
Skewed targets — fraud, churn, rare-event detection — are the common case, not the exception.
In short
- On a plain run you often need nothing else: pick a PR-aware
eval_metric(average_precision,f1,beta) and let the tuned decision threshold work. - There is no automatic class weighting by default (
engine.sample_weight = None)."balance_weight"turns it on; a column name gives per-row business weights. - Per-fold resampling (
balancing.enabled) is off by default. When on, it reaches its ratio by deleting majority rows, so read theRebalance:log line. - With
hpo.provider="ml4_optuna"the imbalance treatment becomes one searched choice per model, scored on your metric. - Outlier removal (
outliers.method) is a separate opt-in step on training rows only.
ml4 treats imbalance in three stages, each acting on a different part of the fit:
| Stage | Acts on | Where | Gate |
|---|---|---|---|
| 1 — resampling | the row set | per bag fold, inside the model | balancing.enabled (off by default; when on, guarded by the class ratio) |
| 2 — imbalance-aware feature selection | the feature set | once, at plan time | always on |
| 3 — the searched imbalance strategy | the objective | per roster model, in the tuner | hpo.provider="ml4_optuna" |
The numbering is by depth, not by clock: stage 2 runs first, before AutoGluon sees the data at all.
Underneath all three sits the default path, which is what you get on a plain run.
The default path — the tuned threshold and your metric¶
You often need nothing else. ml4's post-fit decision layer tunes the threshold from the
run's eval_metric: a single binary threshold, or per-class multiclass thresholds under a
hard-label metric such as f1_macro. That alone recovers most of the achievable
minority-class recall, without touching the training distribution at all.
0.5 is never an operating point ml4 assumes (a sweep may legitimately land on it).
Multiclass differs under a probability metric. With logloss — the multiclass default —
per-class bars have no decision rule to win on, so none are fitted and predict serves
plain argmax on the served probabilities, the Bayes rule for them. If a rare class
needs its own bar, name a label metric such as f1_macro. The bars are then fitted and
kept unless they score below argmax under that metric.
The threshold follows your metric's family, not the metric itself
There are three sweeps, and every metric maps onto one of them:
- the F-β family (
beta/beta_macro/beta_weighted) tunes F-β atbeta_threshold(a top-levelMl4Configfield, not anengineone); accuracyandrecall_weightedtune exact accuracy;- everything else — including
roc_auc,logloss,average_precisionand the macro/weighted precision & recall variants — tunes F1, becausepredictstill owes you labels even when the metric never asks for any.
So a binary run graded on roc_auc ships an F1-optimal operating point; a multiclass run
under a probability metric fits no bars and serves argmax (above). measures.json records
which sweep ran as threshold_objective, next to the cutoffs themselves, so the
substitution is visible rather than implied.
There is no automatic class weighting on the default path
engine.sample_weight defaults to None, so a classification run trains on the class
prior of your data. cfg.engine.sample_weight = "balance_weight" turns class weighting on
in one line, and stage 3 can measure the whole axis for you on your data.
Details: why class weighting is not the default
Two measurements argue against weighting every run:
- It is a per-dataset bet, not a constant. Over 12 seeds × 5-fold OOF on three
imbalanced fixtures (
ml4/scripts/race_weight_vs_resample.py), weighting roughly doubles raw Brier on every fixture, while its ranking effect changes sign between them (−0.014 to +0.008 OOF AUC). - It would disarm stage 3. The searched
nonebranch is scored by fitting the fold-local model directly with no weight vector, then deployed inside AutoGluon. A default global weight vector would hand every deployed model the weights anyway, sononewould be measured unweighted and deployed weighted, andbalanced_sample_weightwould collapse onto it entirely.
Two roster families ignore engine.sample_weight entirely
engine.sample_weight is a TabularPredictor constructor kwarg, handed to every model,
so it reads as a property of the run. For NN_TORCH and FASTAI it is not: AutoGluon
passes them the vector and both drop it. ml4 warns rather than refuses, naming the
offending members, and the roster panel badges them. The remedy that reaches every model
is stage 1, which resamples the fold's rows.
Details: what weight-blind members mean for a run
AutoGluon logs the dropped weights below ml4's default verbosity, so nothing reaches the
operator on its own. The opt-in foundation challenger is weight-blind for the same reason
(none of the four wrappers references sample_weight).
This applies to a weight column exactly as much as to "balance_weight".
Nothing is wrong per model: each is honestly fit and honestly scored, and selection stays fair because the OOF metric is unweighted for everyone. What the run silently becomes is heterogeneous: some members corrected the prior and some did not, and the greedy ensemble blends the two.
balancing.enabled resamples the fold's rows, so the class balance every model sees
really changes. The single source for the list of weight-blind families is
roster.WEIGHT_BLIND_FAMILIES.
The most impactful lever for imbalance is therefore your metric, not a sampler:
from parleml_ml4 import Ml4Config, Tasks
from parleml_ml4.contracts.metrics import EvalMetric
cfg = Ml4Config()
cfg.ml_task = Tasks.BINARY_CLASSIFICATION
cfg.data.target_column = "is_fraud"
cfg.eval_metric = EvalMetric.average_precision # or f1, or beta
average_precision— area under the precision–recall curve; the strongest default when positives are very rare.f1— balanced precision / recall.betawithcfg.beta_threshold— an F-β that weights recall over precision (or the reverse) explicitly.
ml4 builds a real AutoGluon Scorer for the F-β family and user-defined metrics, so
training and model selection optimise the metric you asked for, and the baseline
honesty gate scores against the same one. See
Algorithms & metrics.
Weighted training moves the raw probabilities
Once you turn weighting on, class weighting — with or without resampling — roughly
doubles raw Brier versus an unweighted fit: the model is calibrated to a reweighted
world, not yours. ml4's post-hoc calibration repairs it (calibration, on by
default and gated on an out-of-fold bake-off — see
Probability calibration). If you set
calibration.apply_at_predict_time = False, expect distorted probabilities and judge
the run on ranking metrics (average_precision, AUC) rather than Brier or log-loss.
With the search on, balance_weight is refused — a weight column is not¶
When you select hpo.provider="ml4_optuna", the class-weighting field under Engine stays
available. One of its values conflicts with the search; the other two do not.
engine.sample_weight |
With hpo.provider="ml4_optuna" |
|---|---|
None (default) |
The search owns the imbalance decision outright. |
"balance_weight" |
Refused. It is a TabularPredictor constructor kwarg, fixed for the whole run and applied to every model, so a per-model search cannot vary it — the searched axis collapses to one arm and measures nothing. |
| a column name | Legal. A weight column says how much each row matters, not how much each class does. It is a business quantity, and the search still chooses the class-prior treatment on top of it. |
The template editor greys out exactly balance_weight, with the reason attached, generated
from the same rule table the engine validates against.
The picker does not offer auto_weight at all. AutoGluon never implemented it (its
constructor silently rewrites the value to None), so ml4 refuses it rather than let a run
believe it is weighted when it is not.
Your own per-row weights¶
engine.sample_weight also accepts a column name, and that is a different thing from
class weighting: it says how much each row matters (exposure, revenue, sampling
probability), not how much each class does. The column must be numeric, non-null and
non-negative; AutoGluon validates all three.
One rule beyond that, and ml4 refuses the run rather than half-honouring it: the column must be present in the training frame and declared as neither a categorical nor a numeric feature.
Details: why a weight column cannot also be a feature, and other refused values
AutoGluon lifts the weight column out of the frame before any feature engineering runs and logs that it is "used as sample weights instead of predictive features". A column declared as a feature as well becomes a phantom predictor. It consumes a feature-selection slot (and can evict a genuine correlated feature), it is mined into interaction pairs that can only materialise as all-NaN, and it is planned into the GLOBAL recipe, which then indexes a column AutoGluon has already removed.
Two more values are refused up front rather than deep inside a fit:
"auto_weight": AutoGluon accepts it in its validator and then raisesNotImplementedErrorfrom its learner, because the strategy was never implemented.weight_evaluation = Truewith no weighting set: AutoGluon reads that flag only when a weight vector exists.
Passing a weight column from a host application¶
A weight column occupies a role no other column has: in the frame, but not a feature. An application that derives ml4's config by classifying columns — feature versus reserved — has no bucket for it. Concretely, the caller must:
- keep the column in the DataFrame handed to
fit; - leave it out of
data.cat_featuresanddata.num_features; - leave it out of whatever set the host uses to drop non-modelling columns — the name is read out of the frame by AutoGluon at fit time, so it must survive that drop;
- set
engine.sample_weightto the column name.
The library entry point satisfies all four by construction, since you build the frame and the config together. A UI that only offers "feature" and "ignore" cannot.
Details: the two failures, and how to tell them apart
Both of the buckets a host does have are wrong, and the failure is loud in each direction. The two messages are how you tell which one you hit:
| What the host did | What happens | Message |
|---|---|---|
Bucketed the column as a cat_features / num_features entry, because it is not the target, a key or an ignored column |
ml4's config translator refuses the fit | ValueError from engine.kwargs, naming the column and the exact fix (remove it from the feature list) |
| Marked the column ignored so it would not be modelled, and dropped it from the frame | AutoGluon cannot find the weight column it was told to extract | AutoGluon's missing-column error at fit time |
A weight column weights a regression fit too
"balance_weight" alone is classification-only. A column name is forwarded to the
estimator on regression as well, so LGBMRegressor / Ridge / CatBoostRegressor all
receive it.
Details: why a weight column reaches regression
AutoGluon's set_predefined_weights returns the frame untouched unless the problem type is
binary or multiclass. A column name is extracted from the frame unconditionally
(extract_column, before the problem type is consulted). Read "no effect on regression"
as a statement about the predefined strategy only. The two symmetric mistakes are an
analyst with exposure weights who leaves the field unset on a regression problem and
silently loses the weighting they needed, and one who sets it and never reconciles the
weighted fit with the unweighted RMSE the report shows.
Only the model is weighted — every layer around it is not
A weight vector reaches AutoGluon's estimators, and (with
engine.weight_evaluation = True) AutoGluon's own validation score, ensemble blend
and leaderboard. It reaches nothing ml4 owns. Probability calibration, the
binary / multiclass decision thresholds, the constant-baseline gate, the conformal
residuals behind prediction intervals and every number in measures.json's test
block are all computed unweighted. measures.json → weighting states this on every run.
Details: what the unweighted layers mean, and the weighting block
A weighted run ships a model selected under the weighted metric with a
threshold tuned, a calibrator fit and a baseline gate scored under the unweighted
one. None of those layers takes a weights argument. On regression the gate compares
against an unweighted training mean, which is the wrong constant for a weighted
objective. With baseline.fail_on_no_signal set, that comparison can abort a run that
is fine on its own terms.
Every run — weighted or not — carries a weighting block in measures.json:
"weighting": {
"sample_weight": "exposure",
"weighted_training": true,
"weight_evaluation": false,
"unweighted_layers": ["calibration", "decision_thresholds",
"baseline_gate", "reported_scores"]
}
sample_weight is read off the fitted predictor, not off the config, so it is
what actually trained. unweighted_layers is task-shaped and empty on an unweighted
run, where there is no divergence to disclose.
Stage 1 — guarded per-fold resampling¶
Class rebalancing is off by default: a skewed target is handled by the tuned decision threshold
and the post-hoc calibration. Resampling moves the training prior (at 1% positives the defaults
delete about 90% of the majority rows). It helps ranking only on small, weak-signal data where
row-counting regularisers such as LightGBM's min_data_in_leaf bind. When enabled it does nothing
unless the smallest class falls below the configured fraction of the largest:
cfg.balancing.enabled = True # opt in (default False)
cfg.balancing.max_oversample_ratio = 1.5 # cap any class's inflation at 1.5× original
cfg.balancing.min_acceptable_ratio = 0.15 # floor: smallest class ≥ 15% of the largest
cfg.balancing.oversampler = "randomoversampler"
cfg.balancing.undersampler = "randomundersampler"
Beyond two classes it is a levelling rule, not a floor¶
Both knobs are named in binary language, and on a binary target they behave exactly as named. On
a multiclass target the resampler derives one ceiling from the rarest class alone and cuts
every class above it to that count. Raising min_acceptable_ratio toward 1.0 makes the run
more aggressive, not gentler.
Details: the multiclass ceiling, worked through
The ceiling is
Two folds of the same 3-class problem, at the shipped defaults (1.5 / 0.15):
| fold (before) | ceiling | after | what happened |
|---|---|---|---|
| 100 / 50 / 10 | 100 | 100 / 50 / 15 | ceiling lands on the head count; nobody is cut |
| 100 / 50 / 5 | 46 | 46 / 46 / 7 | head more than halved, and level with the middle class |
Dropping the rarest class from 10 rows to 5 — a change in a class that is not the head and
not the middle — more than halves the head class. It also flattens a genuine 2:1
head-to-middle prior that was never imbalanced in the first place. The arithmetic is the
only reading of two binary knobs that generalises to K > 2. On a multiclass target, read
min_acceptable_ratio as "how much class-prior distortion I accept across the whole
roster of classes", not as a floor under one of them. Raising it lowers the ceiling and
deepens the trimming.
To leave the priors untouched, keep cfg.balancing.enabled off (the default) and reach for
engine.sample_weight (the default path)
instead. Whatever the run does, the per-class before/after counts are logged, so the effect is
never a guess:
Rebalance: 155 -> 99 training rows in this fold (64% kept); per class A: 100 -> 46, B: 50 -> 46,
C: 5 -> 7. 58 row(s) were DELETED: with minority inflation capped at max_oversample_ratio=1.5x,
trimming the larger classes is the only way to reach min_acceptable_ratio=0.15.
The line is logged at WARNING (not INFO) whenever fewer than half the fold's rows survive.
Random sampling duplicates/removes complete rows and is categorical-safe. The sampler dropdowns also expose SMOTENC plus numeric-only SMOTE/ADASYN/Borderline/K-means/SVM and NearMiss/Cluster-Centroids methods.
Resampling runs inside the model's _fit: only the training partition in train/validation
mode, and independently on each fold's training side in cross-validation mode. Fold-local encoders
fit on the original training rows before outlier removal → oversample → undersample; synthetic
methods operate on that already transformed training matrix. It applies to the training slice
only, so out-of-fold predictions stay honest. This is a merge-blocking leak-safety invariant
(OOF row count unchanged, noise stays at chance).
max_oversample_ratio is a multiplier, not a floor
It is a cap on minority inflation expressed as a multiple of the minority class's
original size. A value below 1.0 shrinks the minority class rather than growing
it, the opposite of what the knob's name suggests. BalancingConfig.__post_init__
rejects that outright, along with a min_acceptable_ratio outside (0, 1].
The ratio is met by deleting majority rows, and it has no floor
Read the two knobs together. Once the max_oversample_ratio cap binds, trimming the larger
classes is the only arithmetic left. At the shipped 1.5 / 0.15, a binary fold's training
slice becomes 11.5 × n_minority rows, independent of how much majority data exists.
At 1% positives 11.5% of the rows survive; at 0.1%, 1.1%. Raise max_oversample_ratio or
lower min_acceptable_ratio to keep more of the majority.
Details: the deletion arithmetic and the measured effect
min_acceptable_ratio is a floor relative to the largest class, and
max_oversample_ratio caps how far the smallest class can be inflated. Do the
substitution and the majority count cancels out:
At 1.5 / 0.15 that is 10 × n_minority for every class. On ml4's own rare fixture
(1,648 rows, 2.9% positives) that is 1648 → 552: +24 minority rows against −1,120
majority rows.
This is the only way to honour what you asked for, and ml4's 12-seed paired race measured
it as a gain in exactly that regime (+0.0205 OOF AUC over weights alone at
min_data_in_leaf=20). But it is not what "oversample the minority" sounds like, and there
is deliberately no absolute row floor: clamping would quietly break the ratio you
configured. So the run reports it instead — per fold, per class, at WARNING once less
than half the slice survives:
Rebalance: 1648 -> 552 training rows in this fold (33% kept); per class
0: 1600 -> 480, 1: 48 -> 72. 1120 row(s) were DELETED: with minority inflation
capped at max_oversample_ratio=1.5x, trimming the larger classes is the only way
to reach min_acceptable_ratio=0.15.
Read it before trusting the preset and time budget you picked: both were sized for the full frame, and the row-counting regularisers bite against the smaller count.
Stage 1 composes with the weighting — it does not replace or double-correct it
Both act on the same quantity — how much each class counts — so the rebalance is
mass-preserving: each class leaves it with exactly the total sample-weight mass it
entered with. The weighting decides how much a class counts; the resampling decides
only how many rows carry that mass. balancing.enabled leaves engine.sample_weight
alone.
Details: what resampling adds on top of weights, and when ml4 warns
Your weight column keeps the class ratio you asked for instead of being multiplied by the oversampling factor.
ml4 warns about the combination only when engine.sample_weight = "balance_weight",
balancing.enabled = True and calibration.apply_at_predict_time = False all hold: then
the class-weighted raw probabilities serve uncorrected. Measured over 12 seeds × 5-fold
OOF on three imbalanced fixtures (ml4/scripts/race_weight_vs_resample.py), keeping the
weighting next to resampling is calibration-neutral once the post-hoc calibrator has run,
and worth up to +0.007 OOF AUC where the row-count regularisers bind.
What resampling adds on top of weights is exactly that row count: it changes the
row-counting regularisers (LightGBM's min_data_in_leaf and friends), which a
weight cannot do. Its weight-side twin min_sum_hessian_in_leaf sees the same mass
either way. The effect runs in both directions: extra minority rows relax them, and the
majority rows the ratio deletes tighten them against a much smaller frame (see the danger
box above — on a heavily skewed target deletion is the dominant effect, not inflation).
The gain shows up only when those regularisers actually bind, which is why the knob is
gated on the class ratio rather than resampling every run.
Why resampling is leak-safe here
Fold-local encoders are fit once on the true training distribution. Random methods reuse
those leak-free rows; synthetic methods generate rows only in the resulting transformed
training space. Validation, calibration, OOF and test rows are transformed normally and
never passed to a sampler. It applies to every
stock family (the boosters, RF, XT, linear and both neural nets), because all of them
are wrapped in the same fold-local model. The regulated / glass-box models are left
untouched — see the roster warning for what that
means for a roster.regulated run. It is a no-op for regression.
The sampler safety matrix¶
Categorical safety. A sampler that computes distances between rows needs a metric
space. Every neighbour-based sampler — the SMOTE family, the cleaning under-samplers, the
hybrids — interpolates or measures in numeric space and breaks on categorical columns.
Only three samplers in the catalogue are categorical-safe: randomoversampler and
randomundersampler (they duplicate or drop whole rows, so they never have to compute
anything), and smotenc (built for mixed frames — it takes the modal category rather than
interpolating it).
| Sampler | Categorical-safe | ml4 status |
|---|---|---|
randomoversampler |
✅ | Accepted — the default oversampler |
randomundersampler |
✅ | Accepted — the default undersampler |
smotenc |
✅ | Accepted — categorical-aware synthetic rows |
smote, adasyn, borderlinesmote, kmeanssmote, svmsmote |
❌ numeric-only | Accepted when no categorical columns remain |
nearmiss, clustercentroids |
❌ numeric-only | Accepted when no categorical columns remain |
The template editor detects active categorical model features (after ignored and reserved columns are removed) and disables incompatible choices. Enable Use numeric attributes only to remove remaining non-numeric columns after numeric encodings are created; the same columns are then omitted at validation and inference. Unsupported method names are rejected during fit setup rather than silently substituted.
Stage 2 — imbalance-aware feature selection¶
This stage is always on and needs no configuration. It decides what the later stages get to work with.
ml4's plan-time instrumental models — the feature-selection rankers and the tree-guided
interaction miner — are themselves fit with balanced inverse-frequency sample weights
(minority/majority ratio n_majority / n_minority, keyed on class frequency rather than
on the label 1, so it is coding-agnostic). Both rankers are weighted, not only one.
Without this, a ranker fit on a 2%-positive frame ranks features by how well they explain the majority, and a genuinely minority-predictive column can be pruned before AutoGluon ever sees it. No amount of downstream reweighting can undo that error.
There is deliberately no weight floor here
A max(50, …)-style override would apply a ~50× minority weight to near-balanced data
and distort every data-driven structure decision — feature selection, correlation
pruning, interaction-pair ranking — on the default path, for every run. The
instrumental weighting is the plain balanced ratio and nothing else. The data-adaptive
ceiling that does exist belongs to stage 3, where it is searched rather than assumed.
Stage 3 — the searched imbalance strategy¶
When hyperparameter search is on, the imbalance treatment stops being a constant you guessed once and becomes one searched categorical per family, scored on the run's metric like every other hyperparameter.
Why one axis and not several knobs¶
Sample weights, a native class weight and resampling all correct the same quantity: the class prior. Stacking two of them corrects it twice, which overshoots into the minority class and wrecks the probability scale on the way. Independent knobs would put that mistake one trial away, and a search will find it if it happens to score well on the single inner slice. Modelling them as alternatives makes double correction impossible by construction. Exactly one mechanism is active per trial.
Resampling is the one of the three the search does not own: it is fixed for the run by the
stage-1 ResampleSpec and reaches the fold-local models whole, so a per-trial search cannot
vary it. It is still exclusive against the two branches below; config.dependencies enforces
that exclusion, not the axis.
| Branch | What it does | Tasks |
|---|---|---|
none |
Train on the distribution as it is. Kept in the candidate set so the search can decide the treatment is not worth it — which on well-separated data it sometimes is. | all |
balanced_sample_weight |
Per-fold inverse-frequency row weights, computed inside the fold-local model. | classification |
native_class_weight |
The family's own knob — see the table below. | where the family has one |
On regression the whole axis collapses to ("none",) and is not sampled at all.
Every branch re-weights a class prior, so offering the choice would burn a search
dimension on a decision that cannot change the fit.
The native knobs, per family¶
The strategy is searched per roster model — every booster variant runs its own study — but
the knob is a property of the library. So this table is keyed by family, and every variant of
a family searches the same one (roster.family_of is the projection).
| Family | Knob | Searched values | Tasks |
|---|---|---|---|
GBM |
scale_pos_weight |
[min(1, n_neg/n_pos), max(50, n_neg/n_pos)], log |
binary only |
XGB |
scale_pos_weight |
same | binary only |
CAT |
auto_class_weights |
Balanced | SqrtBalanced | None |
binary and multiclass |
RF, XT |
class_weight |
balanced_subsample | None |
binary and multiclass |
LR, glm, scorecard |
class_weight |
balanced | None |
binary and multiclass |
Details: four load-bearing details in that table
- Both ends of the
scale_pos_weightrange are data-adaptive, and they are mirror images:max(50, n_neg/n_pos)andmin(1, n_neg/n_pos). A hard ceiling of 50 puts the textbook optimum out of reach on genuinely heavy imbalance (at 99:1 the balancing value is 99). A purely ratio-based ceiling would collapse to a useless range on near-balanced data; taking the larger keeps both searchable. The floor mirrors that. A constant floor of1.0would assume the positive class is the rare one. It need not be:converted,approvedandon_timeare ordinary majority-positive targets. On those the balancing value isn_neg/n_pos < 1, and a constant floor would make it unreachable, so every drawable value would push further in the direction that already dominates. Together the two bounds guarantee the interval contains both landmarks that matter:1.0(no correction) and the balancing ratio. The scale is log, because the interval spans decades under heavy imbalance and1.0sits inside it rather than at an end. n_pos/n_negare the counts of internal class 1 / class 0, not of the minority and the majority.scale_pos_weightscales class 1, and class 1 is whichever label yourpositive_classnames. The tuner hands it to AutoGluon's label cleaner so the search and the deployed fit code the labels identically (see the positive class is part of the label space). Reading the two counts off the frequency ranks instead would hard-code "positive = minority" and detach the searched range from the class the parameter actually weights.scale_pos_weightis binary-only. LightGBM and XGBoost ignore it under multiclass softmax. So on a multiclass run those two families are not offerednative_class_weightat all.balanced_sample_weightcovers K > 2 for them, and CatBoost'sauto_class_weightscovers it natively. No branch quietly does nothing.balanced_subsample, notbalanced, for the forests. It recomputes the weights per bootstrap sample, which is the correct variant for a bagged learner; plainbalanceduses the whole training set's frequencies for every tree.
engine.sample_weight is a different locus — leave it off while the search owns the axis
engine.sample_weight is a TabularPredictor constructor argument: one global
decision for the whole run, which a per-model search cannot vary. The searched
balanced_sample_weight is computed per fold, inside the model. They are different
loci, and only one of them is a decision the search can make. Leaving
engine.sample_weight at its None default is what lets the search measure the decision
instead of inheriting it.
Details: how the tuner handles a run-level weight
Two things follow, and both are enforced rather than left to the reader:
- Every trial is fitted under the deployed weighting. The tuner builds its models
outside
TabularPredictor, so it reproduces AutoGluon's weight vector itself: thebalance_weightformulan / (count_c · k), or your weight column normalised the way AutoGluon normalises it, with that column lifted out of the features exactly as AutoGluon lifts it. Otherwise the trials would optimise a loss the winner never trains on: not only the imbalance branch but every capacity knob. XGBoost'smin_child_weight— a sum of hessians — bindsn_neg/n_postimes harder once minority rows carry weight. - Branches that cannot change the fit are removed from the axis. With
engine.sample_weight="balance_weight"the class prior is already corrected, so the axis collapses to("none",)and the run says so in the log. With a weight column,balanced_sample_weightis dropped (onesample_weightslot per fit, andMl4RecipeFoldLocalModel._fitrefuses to overwrite AutoGluon's vector), whilenative_class_weightstays, because a business weight is not a class-prior correction.
Outlier removal (orthogonal, opt-in)¶
An independent step with its own gate on cfg.outliers: drop noisy training rows with an
unsupervised detector before the rebalance.
cfg.outliers.method = "iforest" # None (default) | "iforest" | "ee" | "lof"
cfg.outliers.threshold = 0.05 # contamination fraction, in (0, 0.5)
cfg.outliers.columns = "original_numeric" # or "all_numeric"
cfg.outliers.min_samples = 100
| Knob | Meaning |
|---|---|
method |
The detector — iforest (IsolationForest, most general), ee (EllipticEnvelope, Gaussian-ish features), lof (LocalOutlierFactor, local density, slowest). None disables the step. |
threshold |
Expected outlier fraction (sklearn contamination). Keep < 0.5. |
columns |
original_numeric (default, safer — only user-declared num_features) or all_numeric (every post-encoding numeric, including target-encoded columns; more aggressive). |
min_samples |
Floor below which the detector is skipped rather than fit on a handful of rows. |
Behaviour is implicit from ml_task:
- Classification → the detector is fit per class, so the minority class is never
deleted on aggregate. Classes below
min_samplesare skipped entirely, which protects tiny minority classes by construction. - Regression → a single-pass fit on the whole training set, skipped below
min_samplesrows.
Outlier removal touches training data only — validation and test frames are left
intact — and never passes raw categorical columns to the detector. Its gate
(outliers.method is not None) is independent of balancing.enabled, but not of the
roster: see below.
A regulated roster ignores this whole page's Stage 1 and outlier removal
Per-fold rebalancing and outlier removal run inside the eight stock families' fit, and
the regulated roster (glm, rulefit, scorecard, ebm) deliberately never carries
them: a transparent model trains on the distribution the auditor was shown. With
roster.regulated = True every balancing knob and all four outliers knobs are inert,
on every task. The template editor still renders both sections, because its only
visibility gate is the ML task. ml4 logs this at config validation; it does not switch
the knobs off, so moving back to a stock roster restores your choice.
The remaining lever on a regulated run is engine.sample_weight, which does reach
glm / scorecard / ebm. RuleFit takes neither: imodels' implementation has no
sample_weight parameter, so a regulated RuleFit run on an imbalanced target gets its
imbalance handling from the tuned decision threshold alone.
What to reach for, when¶
| Situation | Do this |
|---|---|
| First run on a skewed target | Nothing. Set a PR-aware eval_metric and let the tuned decision threshold work. |
| Recall still too low, positives rare (≲ 3 %) and the data small | Try engine.sample_weight = "balance_weight", or opt into stage 1 with balancing.enabled = True — measured on a 2.9 %-positive fixture, resampling is worth +0.027 OOF AUC over weighting alone. They are not either/or (they compose), but weighting is the one that costs raw probability accuracy. |
| Recall still too low, classes already well separated | Leave balancing.enabled off (the default). On a strongly-separable fixture resampling cost 0.006 AUC (p = 0.02) — there is no leaf the extra rows unlock. Invest in features and the threshold instead. |
| You have the budget and want the decision measured rather than guessed | Select hpo.provider="ml4_optuna" and leave engine.sample_weight at its None default, so stage 3 searches the axis on your data and your metric. |
| Multiclass with a rare class | The searched axis covers it: balanced_sample_weight for LightGBM/XGBoost, auto_class_weights for CatBoost, class_weight for the forests and linear models. scale_pos_weight does not apply. For per-class decision bars, name a label metric such as f1_macro; under the default logloss, predict is plain argmax. |
| A few extreme rows dominate the boosters | Add outliers.method = "iforest". |
| You specifically need SMOTE | Select a SMOTE-family method; use SMOTENC for mixed data or enable numeric-only attributes. |
See also¶
- Hyperparameter search — stage 3's host, and what else it searches.
- Algorithms & metrics — the custom scorer and metric families.
- Training workflow — where resampling and outlier removal sit in the pipeline.
- Every
balancing/outliers/hpoknob.