Skip to content

Configuration parameters

Auto-generated

This page is generated from the live config schema (parleml_ml4.config.schema) — the same introspection that drives the product's Expert Settings panel — so it always matches the version you have installed. Every parameter, type, default, and description below is read straight from the code. Regenerate with python scripts/gen_config_reference.py.

Set any nested field through its section attribute, e.g. config.hpo.provider = "ml4_optuna". See Configuration & presets guide for the object model and the presets that pre-fill these values.

Supervised Ml4Config

Top-level ml4 training configuration. Only the problem-definition fields stay on Ml4Config itself — ml_task, eval_metric, beta_threshold, positive_class and the user_defined_metric_* pair — because they describe the problem and are read at every layer. Everything else lives in a composed section.

Problem definition

Parameter Type Default Description
ml_task one of: binary_classification, multiclass_classification, regression BINARY_CLASSIFICATION The problem type, and the one field the rest of the config is read against. It is DECLARED, never inferred from the label: it becomes AutoGluon's problem_type verbatim (AG_PROBLEM_TYPE), picks the default eval metric when eval_metric is None, and decides which sections the fit path consults at all — calibration / balancing are classification-only and intervals / target_transform regression-only, so those knobs are inert on the other task rather than wrong. Because it is declared rather than inferred, preflight_check reconciles it against the label before any work is done and refuses four cases (TYPE-6): a single-class classification target, a non-numeric regression target, BINARY declared on a K>2 label — AutoGluon's label cleaner would assert after the whole feature-engineering pass had been paid for — and MULTICLASS declared on a 2-class label, which trains the identical binary model but swaps in a decision layer neither sweep tuned and a report with no threshold, no Brier score and no positive class. So declaring binary on a 3-class column is a config error, not a different model.
eval_metric one of: auc, logloss, f1, beta, f1_macro, f1_weighted, beta_macro, beta_weighted, precision_macro, precision_weighted, recall_macro, recall_weighted, average_precision, accuracy, rmse, mse, mae, mape, r2, spearman, pearson, user_defined_metric (optional) None None → resolved to the task-appropriate default at translate time (logloss for both classification tasks, rmse for regression); an explicit metric is validated against the task and rejected on mismatch. The classification default is deliberately threshold-free (MET-3): AutoGluon selects, early-stops and weights its ensemble against this metric, and a label metric such as f1 makes it do so at a fixed 0.5 / argmax cut that ml4 then replaces with a tuned operating point — so the model selected was not the model served. Naming f1 explicitly is still supported and now means what it says: select at the default cut.
beta_threshold number 0.5 The β of F-β — how much weight the metric puts on recall relative to precision (β > 1 favours recall, β < 1 favours precision, β = 1 is plain F1). Despite the name it is NOT a threshold: nothing is ever compared against it (E-23). It is read whenever eval_metric is one of the F-β family (beta / beta_macro / beta_weighted), where it is passed straight through to sklearn.metrics.fbeta_score(beta=…), and by the delivery layer, which tunes the binary decision threshold to maximise F-β at this β. The misleading name survives because it is a public wire field (backend schemas, the generated web client, and every persisted run config), so a rename would break configs already on disk.
positive_class string (optional) None Binary classification only: the target value to treat as the positive class (encoded to label 1). None keeps AutoGluon's default order; ml4 asserts predictor.positive_class == positive_class after fit.
user_defined_metric_ref string (optional) None eval_metric = user_defined_metric only: importable reference to the custom metric callable — "package.module:qualname" (or a plain dotted path). The callable must accept (y_true, y_predicted, sample_weight=None) and return a scalar.
user_defined_metric_direction string (optional) None Optimisation direction for the user-defined metric: "minimize" or "maximize". Ignored unless user_defined_metric_ref is set.

execution — ExecutionConfig

Execution knobs: parallelism, randomness, GPU.

Parameter Type Default Description
n_jobs integer -1 Default parallelism level forwarded to every sklearn-style estimator (n_jobs on RandomForest, the FS rankers, CV splitters, …). -1 means use all available CPUs (sklearn convention); set a positive integer to cap parallelism for shared hosts. Booster libraries (LightGBM / XGBoost / CatBoost) read this too but their internal thread pools may still oversubscribe — pin OMP_NUM_THREADS if that matters.
seed integer 42 Random seed forwarded to every randomness source the framework controls: the split resolver, the Optuna sampler, the instrumental feature-selection rankers, per-fold resampling, the baseline gate's chance floor, temporal lag dropout — and each trained model, written into whichever hyperparameter that family calls its seed (LightGBM seed, CatBoost random_seed, the sklearn families random_state) plus the bagged wrapper's own fold shuffling. Change it and the models really do change: that is what makes a seed-variation stability study — "is this model's edge real, or is it fold noise?" — answerable, and what makes a determinism check on a regulated run mean something. Libraries that accept no seed for some internal stage (CatBoost's threading, GPU kernel reduction order) can still introduce nondeterminism at the last decimal.
device one of: cpu, cuda, auto cpu GPU acceleration for the tree boosters (LightGBM / XGBoost / CatBoost). Availability is probed per library (engine/gpu_probe.py) — a build flag for XGBoost, a device count for CatBoost, and a one-iteration trial fit for LightGBM, whose GPU support is decided at build time and exposes no introspection API short of trying it. - "cpu": no GPU code path is exercised at all (no probe, no CUDA context). - "auto": use the GPU where it is usable; falls back to CPU quietly, logged once per library. - "cuda": an ASSERTION, not a preference. A library that cannot take the device aborts the run with an install hint. A four-hour fit that silently drops to CPU is a worse failure mode than a clean abort at second zero, and the two used to be indistinguishable: "cuda" was an alias for "auto" and neither reached a model.

data — DataConfig

Data-related configuration.

Parameter Type Default Description
target_column string (optional) None The target label column name.
key1 string (optional) None Primary identification key (BAN / Customer ID / device id). Not a feature — it names the ENTITY a row belongs to, which is a structural role: the FE planner rescues it from instrumental feature selection (_protected_columns), the Mode-1 temporal encoders group their look-backs by it whenever temporal.entity_columns is unset, and the entity-cardinality encoder fans out <VALUE>_NUNIQUE_PER_<KEY> counts from it. Leave unset and those per-entity features have nothing to group by and are silently skipped.
key2 string (optional) None A second identification key, for data with two grains (customer and account, household and device). It is not a fallback for key1: both are used together — the temporal encoders group by the pair, and the entity-cardinality encoder generates the key↔key directions as well ("accounts per customer" and "customers per account" are different signals).
cat_features list of string [] The declared CATEGORICAL feature columns — the starting pool the whole plan is built from. They are what instrumental feature selection ranks and prunes, what the fold-local target encoders encode, what interaction mining pairs up, and what the entity-cardinality fan-out counts per key. A column absent from both this list and num_features (and from every *_domain role below) takes no part in the run at all.
num_features list of string [] The declared NUMERIC feature columns. Same role as cat_features on the numeric side, plus three reads that are numeric-only: the outlier detector's default original_numeric column policy scopes itself to exactly this list (so engineered / target-encoded columns cannot themselves be judged outliers), the temporal encoders lag every column here when temporal.lag_features is unset, and the clustering encoder discretises them. Declaring a column numeric is also a coercion instruction — junk cells are to_numeric(errors="coerce")d, not rejected.
names_domains list of list of string [] Special domains parsed by the stateless encoder family. names_domains: each entry is either a [first_name_col, last_name_col] pair or a single-column [name_col] entry (analysed as both first name and surname). See names_domain.normalize_tcols.
time_domain list of string [] Datetime columns. Each one gets a time step emitting the calendar parts the stateless section leaves enabled (<c>_YEAR / _MONTH / _DAY / _WDAY / _HOUR / _WKEND, plus _HOLIDAY / _BUSINESS_HOUR when switched on) and a <c>_DS_<anchor> elapsed-days column against every other anchor in base_time_columns. The first entry doubles as the Mode-1 temporal encoders' event-time axis when temporal.event_time_column is unset, so a temporal run with neither set logs a warning and emits no look-backs.
device_info_domain list of string [] Columns holding a STRUCTURED device blob (a JSON object with browser / OS / manufacturer / user-agent / ip fields), not a raw User-Agent string — that is useragent_domain. Keys resolve case-insensitively, and the nested user-agent and IP fields are parsed inline by the sibling parsers into <c>_UA_* / <c>_IPADDRESS_* features. The extracted <c>_IPADDRESS also feeds the cross-source IP consistency flags.
address_domain list of string [] Free-form street-address columns, decomposed into their parts (_STREET / _CITY / _STATE / _ZIP) plus a PO-Box flag and a commercial-vs- residential heuristic. Locale-sensitive: parsing follows stateless.country and the full CRF split needs the optional usaddress package, without which only the regex-based flags survive. The extracted parts are what the cross-source _MATCH flags compare a declared city_domain / state_domain / street_domain column against.
email_domain list of string [] Email-address columns → domain / sub-domain / local-part-prefix categoricals plus risk flags. When names_domains is ALSO set, this encoder additionally matches the local part against the dataset's own first / last names (the name-prefix sets are fitted on the training rows only, so the match is leak-free); with no name columns those features are omitted rather than emitted as all-zero.
ip_domain list of string [] IP-address columns → geo / ASN enrichment plus structural flags (_IS_IPV6 / _IS_PRIVATE / _IS_BOGON). The geo and ASN halves need bring-your-own MaxMind GeoLite2 databases (MaxMind's EULA forbids shipping them) and degrade to empty without them, while the structural flags always emit. The derived <c>_ZIP is fed into the zip step and <c>_CITY / <c>_STATE_ABBREV into the cross-source _MATCH flags.
url_domain list of string [] URL / referrer columns → host, TLD and path structure plus _HAS_PORT / _HAS_QUERY / _IS_SHORTENER / _IS_IP_LITERAL. Malformed URLs are caught per value, so one junk referrer costs that row its host split instead of failing the fit and every serve batch afterwards.
zip_domain list of string [] Postal-code columns. Unlike the other domains these are encoded by ONE step over ALL of them together, because most of the value is PAIRWISE: equality, shared 3-character prefix and geographic distance between every pair of codes on the row. Any <ip>_ZIP extracted from ip_domain joins the same step, so even a single declared zip column gets pairwise features when an IP column is present. Prefix / place / state lookups follow stateless.country.
city_domain list of string [] A raw city column — a CONSISTENCY role, not an encoder input. ml4 has no city encoder; naming a column here only enters it into the cross-source city bucket, so it can be compared against the city extracted from an address or IP column as a <A>_<B>_MATCH flag. On its own (no address_domain / ip_domain to compare with) it produces no features at all — a bucket needs at least two members.
state_domain list of string [] A raw state / region column. Same consistency-only role as city_domain: it is matched against the state parsed out of an address column or resolved from an IP column, and yields nothing without one of those.
street_domain list of string [] A raw street column. Same consistency-only role again — compared against the street parsed out of an address_domain column, and inert without one.
useragent_domain list of string [] Raw User-Agent strings → UserAgentDomain (browser/OS/device family + version + is_mobile/is_bot/… flags). Distinct from device_info_domain, which parses a structured device JSON blob, not a raw UA string.
phone_domain list of string [] Identity / payment numbers → checksum + classification features. phone uses the optional phonenumbers lib (validity / region / line-type / carrier); creditcard / iban / vat are pure-Python checksum/format validators (Luhn / mod-97 / structural).
creditcard_domain list of string [] Payment-card (PAN) columns → _CC_VALID (passes the Luhn checksum), _CC_SCHEME (VISA / MASTERCARD / AMEX / …, from the IIN prefix, and only for a value whose length is plausible for a card) and _CC_LEN. The digits themselves never become a feature — what the model sees is whether the number is well-formed and which network issued it.
iban_domain list of string [] IBAN columns → _IBAN_VALID (ISO 13616 mod-97 checksum), _IBAN_COUNTRY (the 2-letter prefix, only when the value is IBAN-structured) and _IBAN_LEN.
vat_domain list of string [] VAT-number columns → _VAT_VALID, _VAT_COUNTRY and _VAT_LEN. Note what "valid" means here: a STRUCTURAL check (recognised country prefix, number part of the right length and charset), not a per-country check-digit verification, so a well-formed invention passes.
dirty_domain list of string [] High-cardinality, inconsistently-spelled CATEGORY columns — "NETFLIX Inc", "netflix", "Ntflix" as three levels of one concept. Where they actually go is decided by two switches in other sections, not here: Gamma-Poisson topic factors when stateful_fe.add_gp_features (the default), otherwise MinHash signatures when stateless.add_minhash_features, otherwise nothing — the column falls back to AutoGluon's native categorical handling. Under Gamma-Poisson the raw column additionally becomes a fold-local target-encoding target, its "reputation twin".
mt_domain list of string [] Multi-token columns: several values packed into one cell ("fraud,chargeback,dispute"). Each column becomes per-token target-mean aggregates, so this encoder is target-aware and is re-fit inside every bag fold rather than once up front. Tokens split on comma unless the column has a token_delimiters entry, and a multiclass target emits one slot per class. Use wt_domain instead when the tokens carry counts, or text_domain for prose.
wt_domain list of string [] Weighted multi-token / packed-count fields (e.g. "Netflix(2), McAfee(1)"). Parsed to a per-token count matrix and compressed with NMF into additive, non-negative latent factors. Unsupervised + target-agnostic (unlike mt_domain); count-aware and non-negative (unlike text_domain).
text_domain list of string [] free-form text → TF-IDF/SVD
embedding_domain list of string [] Free-form text routed to NEURAL EMBEDDINGS (sentence-transformers / LiteLLM) instead of TF-IDF. A per-field encoder choice: a column lands here when the user assigns it the embedding domain.
base_time_columns list of string [] Reference/anchor datetime columns for recency ("days-since") features — the prediction moment. Each anchor makes every other time_domain column emit a <col>_DS_<anchor> feature (whole days between them). An anchor is not a feature itself: it emits no calendar parts even when it is also in time_domain (its _YEAR indexes the period, which a temporal test never shares) — derive a column for the moment's seasonality when it is wanted. Set per-dataset from the dataset default or per-experiment column-role override. Without at least one anchor the datetime columns contribute only calendar parts, not relative recency — which is usually the strongest temporal signal.
token_delimiters object {} Per-column input token delimiter for the tokenised-text encoders (mt_domain → multi-token, wt_domain → weighted multi-token). Maps column name → delimiter (a name such as "comma" / "whitespace" / "semicolon" / "pipe" / "tab", or a literal char). Columns absent from the map default to comma — the historical behaviour. Lets a space-delimited tag column ("rear_end single_vehicle") be tokenised correctly instead of being treated as one opaque token.

engine — Ml4EngineConfig

AutoGluon execution: budget, ensembling, finalization, and row weighting.

Parameter Type Default Description
training_policy one of: cross_validation, train_validation, chronological_train_validation cross_validation Template-owned validation shape. Chronological runs receive their dataset-specific time column and boundaries at launch.
time_limit integer (optional) None Nominal search and model-selection time budget, in seconds; shared between native HPO and AutoGluon's fit. This is not a hard end-to-end deadline: model completion can overrun, and refit_full, preparation, evaluation and explanation add time. None = no limit (regulated fits are unbounded).
num_bag_folds integer (optional) 5 Cross-validation folds the bagged ensemble is built over when training_policy is cross_validation: at least 2, and None means 5. The train/validation policies and a regulated roster do not bag, so it does not apply to them; on a grouped split the fold count is split_policy.n_folds instead. Edited via the template's "Training policy" control.
num_bag_sets integer (optional) 1 How many times the k-fold bagging is repeated (more repeats = steadier estimates, more compute). At least 1; one pass is the reproducible default. A grouped split bags its single whole-group partition once, whatever this says.
num_stack_levels integer (optional) 1 Number of stacking layers built on top of the base models of a bagged run. None means 1; 0 disables stacking. There is no stack layer when the run does not bag, when split_policy.allow_multilayer_stacking is off, or when the roster resolves to a single model (a stacker over one model can only refit it on its own predictions).
dynamic_stacking boolean or string auto Automatically detect and avoid stacked-ensemble overfitting. The safeguard is a full extra probe fit of the whole pipeline, so switching it on roughly doubles the training cost. "auto" (the default) lets the engine decide, and it declines in two cases: a roster that resolves to a single model — there is no stack layer worth policing, since one base model can only be refit on its own out-of-fold column — and a grouped split. Set it to True to run the probe on a grouped split: it then holds out one whole bag fold, so it never splits a group, and it needs at least three folds. There is never a probe when there is no stack layer to police.
ensemble_advantage_margin number 0.005 How much better the blended ensemble must be than the best single model to be worth shipping, as a fraction of that model's score (0.005 = 0.5% better). Below it, the single model ships instead: it predicts faster, it explains cleanly, and it can be compiled for fast scoring. 0 turns the check off and always ships whichever the engine scored highest, however narrow the win. 0.5% is roughly the run-to-run noise of a cross-validated score, so a smaller "win" than that is not reliably a win at all — and a blend that only ties still costs every prediction the full price of running all of its members.
split_policy SplitPolicy — How rows are assigned to folds / holdout. Owned by the launch wizard's data-splitting step, so it is not shown in this form.
validation_holdout_frac number 0.1 Share of the training frame reserved as the VALIDATION holdout. The post-fit layers — probability calibration, the decision threshold, the constant-baseline gate and regression prediction intervals — are fit on this slice, which the deployed model never trains on. It is reserved on the default settings: a holdout is skipped only when the run both bags AND serves the bagged ensemble (refit_full off), because only then do out-of-fold predictions cover every row of the model that actually ships. With refit_full on (the default) the served model is retrained on all the rows the bagged children were validated against, so its OOF predictions are not out-of-sample for it and this slice is what keeps the post-fit layers honest — at a real cost of 10% of the training rows. Distinct from the launch wizard's "Reserved test set", which carves the outer TEST slice (split_policy.outer_test_frac) before training even starts. Carved under the same split_policy rule, so a grouped or temporal run keeps its isolation guarantee.
label_count_threshold integer 1 Multiclass only. The fewest rows a class needs, counted over the whole training frame, to be modelled at all (at least 1). A class below it is excluded before anything is fitted, together with its rows: the model can never predict it, and the run names it in a warning. The default of 1 keeps every class, whatever the metric: a class with a single training row on a bagged run is copied to a second row so every fold can see it, and its rows are then left out of the rows the post-fit layers are fitted on (a bagged run over a grouped split cannot copy a row and is refused instead). Raise it to exclude a long tail deliberately — a class with a handful of rows costs a column in every per-class encoder and scores as noise in the per-class report.
refit_full boolean True After the search, retrain the winning model on every row it was handed — which is the training frame minus the validation holdout, not the whole dataset: the post-fit layers have to be fitted on rows the served model never saw, and this refit is what makes it a single model that saw nearly all of them. Needed to serve or explain a single final model. Leave on. Turning it off is the "serve the bagged ensemble" choice — it also removes the holdout carve, because the bag's out-of-fold predictions then cover every row (see validation_holdout_frac).
set_best_to_refit_full boolean True Serve the retrained-on-all-data model as the final model. Dependent on refit_full: with the refit off there is no retrained-on-all-data model to serve, so the fit shape resolves this to False regardless of what is set here. It is not validated against its parent, because refit_full=False on its own is the normal way to say "serve the bagged ensemble" and should not need a second keystroke.
save_bag_folds boolean True Keep the per-fold models. Required for out-of-fold predictions and for retraining on all data — leave on.
sample_weight string (optional) None How much each row counts toward the training loss. One setting, TWO unrelated jobs. EMPTY (the default, and right for most runs): every row counts the same. A skewed target is handled by the decision threshold, which is always tuned and is the layer that actually decides labels; weighting is a per-dataset bet that costs raw probability accuracy and helps ranking on some datasets and hurts it on others, so ml4 does not take it for you. "balance_weight" = CLASS weighting: every class gets equal total weight, derived from the label distribution automatically. Classification only. Use it when the rare class matters more than its frequency suggests. It cannot be combined with the hyperparameter search — the search exists to MEASURE this decision per dataset and this value fixes it for the whole run, so the pairing is refused. A COLUMN NAME = ROW weighting, which is not an imbalance feature at all: the column holds one number per row and that number IS the row's relative importance. A row with 12 counts twice a row with 6 because that is the ratio you put in the column — nothing infers it. Typical uses are exposure (a policy held 12 months vs 6), revenue, sampling probability, recency. It applies to EVERY task, regression included. Linear-in-exposure is the standard choice for frequency/count models; for noisy or heavy-tailed weights consider storing sqrt(x) or clipping the top percentile, since one row with a huge weight can dominate a fold. The column must be numeric, non-null and non-negative, must be PRESENT in the training frame, and must NOT also be declared as a categorical/numeric feature — AutoGluon lifts it out before any feature engineering, so declaring it twice makes it a phantom predictor and ml4 refuses. WHICH TASKS IT REACHES: it is "balance_weight" alone that is classification-only — AutoGluon's set_predefined_weights returns the frame untouched unless the problem type is binary or multiclass. A WEIGHT COLUMN applies to every task, REGRESSION INCLUDED: AutoGluon extracts it by name whatever the problem type and bundles it back into the training frame, so every model that reads a weight vector fits on it. WHAT STAYS UNWEIGHTED whichever value is set: NN_TORCH and FASTAI drop the vector (roster.WEIGHT_BLIND_FAMILIES — the fit warns when one is on the roster, and balancing.enabled is the remedy that reaches them), the opt-in foundation challenger drops it too (roster.include_foundation_challenger), and every ml4-owned post-fit layer — calibration, the decision threshold, the constant-baseline gate, the conformal residuals and every reported metric — scores rows equally, with no parameter to take weights. weight_evaluation weights AutoGluon's own validation metric only; it does not reach any of them. On a stock-family roster it composes with per-fold rebalancing (balancing.enabled): the weighting decides how much each class counts, the resampling only how many rows carry it. On a regulated (glass-box) roster there is no rebalancing to compose with — the transparent models do not resample — so this is the only imbalance lever there, and RuleFit does not even take it. Weighted training shifts the raw probabilities, which the default post-hoc calibration corrects.
weight_evaluation boolean False Also apply the sample weights when scoring the validation metric (not only during training). Requires sample_weight: with no weights there is nothing to weight a metric by, so the pairing is refused rather than rendered as a tick-box that changes nothing.
verbosity integer 2 How much detail the training engine writes to the log (0 = quiet … 4 = debug).

split_policy — SplitPolicy

Parameter Type Default Description
strategy one of: iid_stratified, grouped, temporal_holdout iid_stratified How rows are assigned to folds — and, because the outer-test carve follows it too, the single field that decides what "held out" means for this run. It also fixes which key column below is REQUIRED (GROUPED → group_column and TEMPORAL_HOLDOUT → time_column); a missing one raises at construction. The default splits rows at random with the class balance preserved, which is only honest when rows are independent: pick GROUPED when several rows describe the same customer / device, or TEMPORAL_HOLDOUT when the model will be asked to predict the future. TEMPORAL_HOLDOUT pairs with engine.training_policy="chronological_train_validation" and with no other policy: ml4 does not bag a temporal split, so the roster is fitted on the past, the best model is selected on a later validation slice, and the post-fit layers are fit on a reserved future slice rather than on out-of-fold predictions.
group_column string (optional) None req GROUPED — isolation key (BAN/customer/household/device)
time_column string (optional) None req TEMPORAL_HOLDOUT (event-time axis)
n_folds integer 5
embargo string (optional) None TEMPORAL_HOLDOUT: purge train rows within this window of a validation boundary, e.g. "7D". Applies to the INNER CV fold boundaries only — it does NOT trim the outer test boundary, which is carved by fraction or outer_test_cutoff before the folds exist. Use outer_test_cutoff with a gap of your own if the outer boundary needs one.
outer_test_policy one of: random, temporal, none random Whether to reserve an outer TEST slice at all — rows carved off before training starts and never seen by the fit, the folds, or the post-fit layers. Read as on/off: NONE carves nothing, and the other two do not choose the carve METHOD, which always follows strategy (grouped → whole groups, temporal → the chronologically latest rows, else stratified). TEMPORAL is therefore a declaration that is checked rather than obeyed — it is rejected on a non-temporal strategy, where it would otherwise read as a promise of an out-of-time test set while carving at random. RANDOM is named for the default IID case and means "carve by the strategy's rule", not "carve at random regardless of strategy" — under GROUPED it still carves whole groups.
outer_test_frac number 0.2 Size of that outer test slice as a fraction of all rows. 0 is an alternative way to say "no outer test". Ignored when outer_test_cutoff names an explicit date to cut on. On a temporal carve the realised fraction can come out smaller than requested: rows sharing a timestamp are never split across the boundary, so the cut lands on the nearest tie-free rank (and a materially smaller carve is logged as a warning rather than passed off as the requested one). Distinct from engine.validation_holdout_frac, which reserves the VALIDATION slice that the post-fit layers are fit on.
validation_cutoff string (optional) None TEMPORAL train/validation boundary
outer_test_cutoff string (optional) None TEMPORAL outer cut (ISO date)
allow_multilayer_stacking boolean True Allow a stacking layer over the base models of a bagged run. Off trains no stack layer whatever num_stack_levels says; a run that does not bag never stacks either.
seed integer 0 Seed for the split's own randomness — the shuffle behind the stratified / grouped K-fold and behind the random outer-test carve. This is the SPLIT's seed, not the run's: execution.seed (default 42) seeds the models, the sampler and the resamplers, while this one is what makes the partition itself reproducible. Hold it fixed to compare two configs on identical folds; vary it to ask whether a score survives a different partition.

hpo — HPOConfig

ML4 hyperparameter-search settings.

Parameter Type Default Description
provider one of: none, ml4_optuna, autogluon none One owner for hyperparameter selection. "none" fits effective defaults; "ml4_optuna" searches ml4's rich conditional spaces; "autogluon" uses AutoGluon's native spaces and validation geometry.
num_trials integer 60 Candidate combinations evaluated per family. A training time limit may stop the study early but does not silently resize this budget.
patience_trials integer (optional) 20 Stop a family's search once this many completed trials in a row fail to improve its best score (None runs the whole budget). A study also stops as soon as a trial reaches the metric's best possible value (an AUC of 1, a log-loss of 0), which no later trial can beat — and the run warns, because a perfect inner-validation score usually means a rule-like label or a leak, not a perfect model.
cv_folds integer 1 Inner folds used to score each candidate. One uses a single inner validation split; larger values reduce objective noise at proportional fit cost.
space_tier one of: full, capacity_capped full Template-owned search-space width. Latency-budgeted presets may cap model capacity so trials are not spent on models the inference limit rejects.

roster — Ml4RosterConfig

Which models to train.

Parameter Type Default Description
models list of one of: GBM:gbdt, GBM:dart, GBM:rf, GBM:extra, GBM:arith, CAT:symmetric, CAT:depthwise, CAT:lossguide, XGB:gbtree, XGB:dart, RF, XT, LR, NN_TORCH, FASTAI ['GBM:gbdt'] Which models to train. Entries are model specs: the gradient boosters carry a variant ("GBM:dart" is LightGBM in DART mode; "GBM:extra" adds AutoGluon's extra-trees settings and "GBM:arith" an experimental arithmetic feature search, both best beside "GBM:gbdt"), everything else is named bare ("RF"). The default is explicit so configuration surfaces show the model that will really run instead of rendering an empty selection backed by a hidden fallback. The fit path still accepts an empty list for legacy configs and resolves it to DEFAULT_MODELS. It is NOT an AutoGluon portfolio: ml4 always decides the roster itself (see the note on Ml4EngineConfig about the removed use_portfolio knob), so breadth has to be asked for explicitly. Each entry is one more trained model, and one more Optuna study when the search is on.
include_foundation_challenger boolean False Also train the tabular foundation model as a challenger. It is never the default strongest model. Asked for, it is not optional: a run that turns this on exists to set the foundation model beside the stock roster, so a run where it does not train FAILS, naming why — its package is not installed (refused before anything trains), AutoGluon estimates it needs more memory than is free (the error names both numbers), the time budget runs out before it, its own size limits exclude the table, or it raises. A latency ceiling (deployment.max_inference_seconds_per_row) can keep it out of the ensemble; it is still trained and on the leaderboard. It is WEIGHT-BLIND: AutoGluon's TabICL wrapper never reads sample_weight, so with engine.sample_weight set the challenger trains on the raw class prior while the rest of the roster trains on the weighted one, and the ensemble blends the two. The fit logs a warning naming it — see WEIGHT_BLIND_FAMILIES for the stock families in the same position and for the in-product remedy (balancing.enabled, which resamples rows and reaches everyone).
foundation_model string (optional) None Which foundation model to use when the challenger is enabled. None = the default, TabICL v2 — currently the only model offered; any other name is refused.
regulated boolean False Regulated (glass-box) run: train a single transparent, auditable model instead of an ensemble. Set the kind below when enabled.
regulated_kind one of: glm, rulefit, scorecard, ebm (optional) None Which transparent model to use for a regulated run: "glm", "rulefit", "scorecard", or "ebm". Required when "regulated" is on.

constraints — ConstraintsConfig

Monotonicity constraints for the gradient boosters that support them natively (LightGBM, XGBoost) and — CPU-only — CatBoost.

Parameter Type Default Description
monotone_constraints object {} Optional per-feature monotonicity constraints. Keys are original input feature names (as they appear in the source dataset); values are +1 (prediction non-decreasing in the feature), -1 (non-increasing), or 0 (unconstrained — same as omitting). Constraints only bind for features that survive feature engineering under their original name (numeric pass-through columns — the typical fair-lending / insurance case, e.g. {"debt_ratio": 1, "age": -1}). A manual pin that cannot bind — on a categorical / target-encoded / dropped / renamed column — is a compliance failure and is raised loudly (at plan time by the planner, or at fit time by the fold-local binder), never silently skipped. Non-supporting algorithms (RF, ET, Linear, GLM, EBM, RuleFit, DT) ignore the map entirely. Empty by default — no behavioural change when unset.
monotone_auto boolean False When True, the supporting tree boosters derive a monotonic direction per numeric pass-through feature automatically at fit time — the sign of each feature's partial Spearman association with the target on the train split, other numeric pass-through features held fixed, gated by a Bonferroni-corrected significance floor — instead of requiring the analyst to pin every direction by hand. Near-duplicate columns have no direction of their own and stay unconstrained. Manual monotone_constraints always win over the derived direction for the features they name. Only meaningful for binary / regression targets (a direction against a multiclass target is undefined and the derivation no-ops). The regulated_monotonic preset turns this on.
monotone_auto_threshold number 0.05 Minimum \|partial Spearman rho\| for an auto-derived feature to be constrained. Features below this band stay unconstrained so weak / noisy signals are not pinned to a spurious direction. Ignored unless monotone_auto.

baseline — BaselineConfig

Post-fit honesty gate: the deployed model must beat the best skill-free predictor.

Parameter Type Default Description
enable_constant_baseline boolean True When True, runs the post-fit baseline honesty gate. The floor is searched, not a fixed rule: every skill-free predictor is scored and the strongest one wins — constants (mean / median for regression, each observed class label for a hard-label metric, the class prior for a probability metric) and chance-level draws from the target's own distribution (stratified / uniform for a hard-label metric, a shuffled target for regression, drawn from a stream derived from execution.seed so the floor is reproducible without replaying the run's own draws). The winner is reported as BaselineReport.strategy, and the run warns when the pipeline does not beat it by baseline_warning_relative_margin. The candidates are derived in memory and never persisted, so the gate is cheap and rarely worth disabling outside of fast smoke tests. One honest limit: on pearson / spearman the shuffled draw is the only candidate scorable at all (every constant has zero variance), and a single shuffle is as noisy an estimate of zero correlation as a zero-skill model is — measured over 10 seeds, that gate catches a pure-noise strongest model about half the time. Weak, but a live gate where constants alone give none.
baseline_warning_relative_margin number 0.01 Direction-aware relative margin, expressed as a fraction of the baseline's own error — not of its score. With err = optimum - score in AutoGluon's higher-is-better space the gate reports (base_err - strongest_err) / base_err, so 0.01 means the model must remove 1% of the error the baseline leaves behind. Raise it for noisier targets where a sub-percent improvement is not meaningful.
fail_on_no_signal boolean False When True, a failed baseline gate aborts the run instead of only warning. Raises NoViableModelError — a hard "no viable model" stop for pipelines that must not ship a signal-free model. Only an active failure raises (no_signal / worse_than_constant), and only when the verdict is conclusive: a low_confidence verdict (fewer rows than the gate's own floor, or a win the paired bootstrap cannot confirm) still ships, still logs and still reaches measures.json as a failure, but does NOT abort — such a verdict demonstrates nothing either way, and killing a good model over a statistic the interval layers had already declined to compute for lack of data would be the gate contradicting its own siblings. A skipped gate never raises either, because the metric could not be scored on the fitting set (AUC on a single-class one), no skill-free candidate was scorable at all, or the baseline is already perfect and there is no error left to remove. A degenerate target skips the gate on every metric: with a constant target a correlation metric can score nothing at all, and an error metric's constant floor is already exact. Off by default.

imputation — ImputationConfig

Imputation knobs — regulated (glass-box) runs only.

Parameter Type Default Description
replace_num_inf boolean True Master switch for the pre-imputation ±inf replacement step. When True, every ±inf in a numeric column is replaced with replace_num_inf_value before the imputer runs; when False they become NaN and the numeric strategy (or the median backstop) imputes them, so a non-finite value never reaches the estimator either way. In practice this rarely fires: CleanEncode already sweeps ±inf → NaN frame-wide in the global chain, ahead of every model, so by the time LinearMatrixPrep runs there is usually no infinity left to map. What it still covers is an infinity created after that sweep — a ratio or log feature the fold-local chain produced.
replace_num_inf_value integer or number 0 Sentinel written in place of ±inf when replace_num_inf is on. Read this as a LINEAR-MODEL choice, not a tree one (CLEAN-5). The comment here used to recommend 0 as "robust for tree models" and suggest a large magnitude "if the downstream model is linear" — but this whole section is regulated-only (see the class docstring), so the tree case it recommended for is the one case that never reads it. The only consumer is the glass-box linear matrix, where the choice is genuinely load-bearing: 0 lands in the middle of a standardised column, which is the least distinguishable place to put a value that was not measured, and a large magnitude instead dominates the fitted coefficient. Pick a value that cannot collide with a legitimate domain value in that column, and prefer replace_num_inf = False (impute it as missing) when no such value exists. A substitution is never silent: LinearMatrixPrep logs a WARNING at fit naming the columns and the number of cells it forged.
numeric_imputation integer or number or one of: mean, median, mode, knn (optional) median Numeric imputation strategy for SUPERVISED, regulated (glass-box) runs — this is ImputationConfig.numeric_imputation. It is NOT the unsupervised knob: clustering and anomaly detection read UnsupervisedConfig.numeric_imputation, which accepts "knn" (its default, from a bounded donor sample) / "median" / "mean" / "zero", rejects "mode" / a scalar / None outright, leaves the <col>__missing flag to UnsupervisedConfig.missing_indicators, and decides where in the pipeline the fill happens relative to scaling. The two fields share a name and nothing else; the reference table lists them separately, so check which section you are in before copying a value across. Either a symbolic strategy name ("mean" / "median" / "mode" / "knn"), a scalar fill value (int / float), or None to disable imputation entirely. Consumed by the linear glass-box path only (LinearMatrixPrep); the tree models never read it (AutoGluon fills NaN natively). "median" is the distribution-robust default for the linear estimators that do read it; use a scalar for a constant fill or "knn" for a multivariate estimate. "knn" IS DIFFERENT IN KIND from the others: every other strategy freezes one number per column, while sklearn's KNNImputer keeps the WHOLE training feature matrix inside the fitted object (_fit_X) and rescans it for each scored row. Two consequences, both invisible from the config: cost is superlinear and unbounded (measured on a 10-column frame: 0.5 s at 500 rows, 21 s at 8k, 97 s at 20k, at fit and at every serve batch), and the exported artefact carries raw training rows (1.8 MB vs 0.0008 MB for "median" at 20k×10 — a scorecard handed to a customer would ship the training data with it). ImputeEncode therefore caps it at a FIXED 50 000 training rows: above that it fits "median" instead and the run raises a warning in Notifications saying so, because on a bigger table "knn" simply MEANS "median" — set the strategy you actually want. The ceiling bounds cost only; it is no privacy control, since a knn artefact embeds raw training rows at every size (~19 MB at 50 000×50), and it is not a knob, so nothing here can raise it. Prefer "median" unless the multivariate estimate is demonstrably worth both costs on a table small enough to keep them. WHATEVER YOU PICK, THE FACT THAT THE VALUE WAS MISSING IS NOT PRESERVED (CLEAN-6). No shipped run emits a <col>_MISSING 0/1 indicator: ImputeEncode can compute one from the pre-imputation NaN mask, but no planner emits an impute step, so the only production entry point is LinearMatrixPrep, which constructs the imputer without it — and would drop the flag anyway, since it selects exactly the numeric output columns. The flag is reachable only from a hand-authored impute recipe step. So on the glass-box path an imputed cell is indistinguishable from a genuine observation at the fill value, and "missing" cannot carry its own coefficient / points row. Two consequences worth pricing in before choosing a strategy: informative missingness (a field left blank because of the outcome) is erased rather than modelled, and a central fill ("mean" / "median") pulls those rows to the middle of the distribution, which is the least visible place to put a value that was never measured. Where missingness is itself a signal, encode it upstream as its own column rather than expecting this section to. Numerics only: AutoGluon's categorical handling adds a missing LEVEL, so a missing category survives as a category on every roster.
custom_num_imputations object {} Per-column overrides for the numeric imputer, keyed by strategy name (or scalar fill value) → list of column names. Takes precedence over numeric_imputation for any column that appears in any of the lists. Example: {"median": ["AMOUNT"], "mode": ["TIER"]}.
cat_imputation one of: mode, nan (optional) nan Categorical imputation strategy. "mode" (most frequent) is the textbook default. "nan" keeps the literal NaN — useful with CatBoost / LightGBM which handle missing categories natively. Any other string is treated as a constant fill value; None skips.
custom_cat_imputations object {} Per-column overrides for the categorical imputer, keyed by strategy name (or constant fill string) → list of column names. Takes precedence over cat_imputation for any column that appears in any of the lists. Example: {"mode": ["CITY"], "unknown": ["UNVERIFIED"]}.

balancing — BalancingConfig

Resample only the active training slice; validation, calibration, OOF and test stay untouched.

Parameter Type Default Description
enabled boolean False Master switch for per-fold class rebalancing. Off by default: a skewed target is handled by the tuned decision threshold and the post-hoc calibration. When on and the smallest class is below min_acceptable_ratio of the largest, each fold keeps at most max_oversample_ratio / min_acceptable_ratio majority rows per minority row (10 at the defaults) — at 1% positives that deletes about 90% of the majority class and lifts the training prior to about 13%, so the raw probabilities rise with it. Measured, that helps ranking only on small, weak-signal data where row-counting regularisers such as LightGBM's min_data_in_leaf bind, and costs average precision on larger data: turn it on per dataset. Above the floor it is a strict no-op; regression and regulated (glass-box) rosters never resample; the run log states per class what moved. It composes with class weighting (engine.sample_weight), since the rebalance preserves each class's weight mass. Outlier removal has its own gate (outliers.method), independent of this.
max_oversample_ratio number 1.5 Cap on how far an under-represented class may be inflated, as a multiple of its original size (1.5 = up to 1.5x); excessive oversampling introduces artefacts. Must be at least 1.0: it multiplies the class's row count, so anything below 1 discards rows instead.
min_acceptable_ratio number 0.15 Floor on the smallest class as a fraction of the largest (0.15 = at least 15%). When the oversample cap alone cannot reach it, the largest class is undersampled the rest of the way. 1.0 means "fully balanced". BEYOND TWO CLASSES THIS IS A LEVELLING RULE, NOT A FLOOR. Both knobs read as binary — "the minority", "the majority" — but the resampler derives ONE ceiling from the RAREST class alone (new_max ≈ rarest × max_oversample_ratio / min_acceptable_ratio, clamped to the largest class) and cuts every class above it to exactly that count. So the rarest class governs what happens to classes it has nothing to do with. At the shipped defaults a 100/50/10 fold comes out 100/50/15 — the ceiling lands on 100, nobody is cut — but move the rare class from 10 to 5 and the ceiling drops to 46, so the same fold comes out 46/46/7: the head class is more than halved and lands level with the middle class it was legitimately twice the size of. A genuine 2:1 prior that was never imbalanced is flattened by a decision taken entirely on a third class. This is deliberate — one ceiling is the only reading of two binary knobs that generalises to K > 2 — but on a multiclass target read min_acceptable_ratio as "how much class-prior distortion I will accept across the whole roster of classes", not as a floor under one of them. Raising it toward 1.0 does not make the run gentler; it makes the ceiling lower and the trimming deeper. To leave the priors alone entirely, set enabled = False and use engine.sample_weight instead. The per-class before/after counts are logged on every run that resamples, so what actually happened is never a guess.
oversampler one of: randomoversampler, smote, smotenc, adasyn, borderlinesmote, kmeanssmote, svmsmote randomoversampler How minority rows are added. Random duplication and SMOTENC are categorical-safe; SMOTE/ADASYN/Borderline/KMeans/SVM variants require numeric transformed attributes.
undersampler one of: randomundersampler, nearmiss, clustercentroids randomundersampler How majority rows are reduced. Random undersampling is categorical-safe; neighbour and centroid methods require numeric transformed attributes.
drop_categoricals_after_encoding boolean False Remove non-numeric columns after fold-local encoding for numeric-only samplers. The same columns are removed from validation and inference for train/serve parity.

outliers — OutlierRemovalConfig

Pre-rebalance outlier removal on the training set.

Parameter Type Default Description
method one of: iforest, ee, lof (optional) None Unsupervised outlier detector. None (default) disables outlier removal entirely. "iforest" (IsolationForest) is the most general-purpose; "ee" (EllipticEnvelope) assumes Gaussian-ish features; "lof" (LocalOutlierFactor) catches local density anomalies but is the slowest.
threshold number 0.05 Expected outlier fraction (contamination). Higher values drop more training rows — keep < 0.5 to avoid removing legitimate minority signal. Tracks sklearn's contamination parameter semantics.
columns one of: original_numeric, all_numeric original_numeric Column scope for the outlier detector. original_numeric (default, safer): only DataConfig.num_features columns the user explicitly declared as numeric — ignores TE / CV-encoded columns whose distribution may itself encode the minority signal. all_numeric: every numeric column after stateful encoding (slightly more aggressive, slightly higher minority-class deletion risk).
min_samples integer 100 Minimum rows in a (sub-)set before an outlier detector is fitted at all. Below this the step is skipped rather than fitting a degenerate IsolationForest / EE / LOF on a handful of rows. For classification this is the per-class floor, so tiny minority classes are protected by construction; for regression it gates the single whole-set fit.

stateless — StatelessFEConfig

How domain-tagged original attributes are expanded into smart features.

Parameter Type Default Description
country string us Country drives country-specific data (names statistics, zip-locations, ip-locations, etc.).
dayofweek boolean True Calendar parsing flags. Each one adds a single derived column with the corresponding numeric / boolean part of the parsed timestamp. On by default so the standard calendar set is emitted (_YEAR/_MONTH/_DAY/_WDAY/_HOUR/_WKEND); switch individual parts off to trim the post-FE feature count. _WDAY int [0, 6] (Mon=0).
is_weekend boolean True _WKEND boolean — Sat/Sun flag.
hour boolean True _HOUR int [0, 23].
day boolean True _DAY day-of-month int [1, 31].
month boolean True _MONTH int [1, 12].
year boolean True _YEAR full year int — careful with non-stationarity.
is_holiday boolean False Per-country national-holiday flag (_HOLIDAY). Needs the optional holidays PyPI package (calendar resolved from this section's country); off by default so a run without holidays installed emits the plain calendar set only. Enable to add it — but note the output is declared unconditionally once enabled: with the package missing or the country unknown the column is still emitted, all-False, with a warning.
business_hour boolean False Composite "was this during working hours" flag (_BUSINESS_HOUR): 1 when the timestamp is Mon-Fri, 09:00-16:59, AND not a public holiday. It is not a rename of is_holiday + hour: those give a model three raw numbers to combine itself, whereas this is the single conjunction, which is what a linear or scorecard model needs stated explicitly. Because of the holiday term it carries the same optional-holidays dependency — without the package the flag degrades to plain weekday-office-hours rather than failing. Off by default; the cyclical encoder deliberately leaves it alone, treating it as a flag rather than a calendar part.
days_since_null_replacement integer 999 Sentinel value emitted by days_since_* columns when either endpoint is UNUSABLE — null, or a non-null cell that does not parse as a date (the days-since pair is coerced, not validated, so one junk cell cannot abort a fit). Both cases get this one value, so "no elapsed time is computable" reaches the model as a single encoding rather than splitting into a sentinel and a native NaN. Be aware that the default 999 is an IN-RANGE value, not an out-of-range marker: it is byte-identical to a genuine "999 days before the anchor", so on any history longer than ~2.7 years "unknown" and that one real recency cannot be told apart — and the other common choice, -1, is exactly what an event one day after the anchor legitimately produces. A booster can still isolate the sentinel with two splits, but a linear or scorecard term (and any partial-dependence plot) reads it as a moderate recency. Set a value provably outside your own range in both directions (e.g. -1000000) when "unknown" has to stay separable. It must be an INT: the emitted column is int64, and None is not a supported value — it would make the column object-dtype (None, not NaN, so the "one encoding" property is lost) and TimeFeatures.from_step raises TypeError replaying such a recipe.
add_minhash_features boolean True MinHash features for the dirty domain when Gamma-Poisson is disabled (stateful_fe.add_gp_features False). On by default so dirty columns still get a numeric encoding; set False to leave them to AutoGluon's native categorical handling.
names_demographic_proxies boolean False Emit the two name-derived demographic proxies: <name>_F_P_FEMALE (the probability that the first name is female) and <name>_L_TOP_COUNTRY (the surname's most likely country of origin). Off by default: they stand in for sex and ethnic origin, which anti-discrimination law protects (ECOA / Reg B in US credit, the EU gender directive in insurance pricing, the EU AI Act for high-risk credit scoring; ethnic origin is special-category data under GDPR Art. 9). Turn on only where feeding them to a model is lawful and intended. This setting covers those two columns only. Name IDENTITY — the name columns themselves (pseudonymised) and their popularity ranks <name>_F_RANK / <name>_L_RANK — still reaches a stock-roster model with it off, and a model can learn a per-name rate from it, which proxies sex, ethnic origin and age much as the raw name does. Where that is unlawful, declare no name columns or train a regulated roster: a regulated run (roster.regulated) rejects this setting and keeps name identity out of the model as well.
zip_region_features boolean True Emit where each postal code is: <zip>_PREF (the 3-character prefix), <zip>_PLACE and <zip>_STATE. On by default: region is plain signal for many problems (house prices, demand). Geography can stand in for race and ethnicity (redlining: ECOA / Reg B, the US Fair Housing Act), so turn it off where a model must not use region. The pair features between two zip columns — <a>_<b>_DIST (distance), <a>_<b>_MATCH and <a>_<b>_PREF_MATCH — stay either way: they say how two addresses relate (billing far from shipping), not where a person lives. A regulated run (roster.regulated) never receives the region features, whatever this says.

target_encoding — TargetEncodingConfig

Target encoding for categorical attributes (the category_encoders family).

Parameter Type Default Description
target_encoders list of one of: TE, WE, JS, ME, GM, LO, CN ['TE', 'CN'] Classification target encoders applied to ALL categorical columns regardless of cardinality — including binary categoricals, whose target-mean encoding is equivalent to a 0/1 indicator. One list, no cardinality split. Default ["TE", "CN"] pairs the target-mean encoder (cross-fitted within each training slice) with a CountEncoder for raw frequency signal. Add more encoders to widen the feature space at linear cost; empty list disables classification target encoding. Every family — linear included — consumes these numeric twins directly; there is no separate one-hot representation. MULTICLASS is encoded ONE-VS-REST: each target-dependent code emits one column per class, <col>_<CODE>_<slot>, fit against the 0/1 indicator y == class_k. So a 3-class run with the default list yields <col>_TE_0/1/2 plus a single <col>_CN — the count is label-independent and is not expanded. Budget for it on wide tables: the target-dependent codes cost n_classes × as many columns as on a binary target.
m_estimate_prior_count number 20.0 Additive pseudo-count for the ME m-estimate encoder — the number of "virtual prior rows" every category is credited with. MEstimateEncoder estimates a category of fold-train size n as (Σy + prior·m)/(n + m), so the category keeps self-weight n/(n+m) and its half-weight point is exactly n == m. Larger ⇒ stronger shrinkage toward the global mean. Reaches ME alone, on whichever list selected it (classification or regression); every other code ignores it. It is the only category_encoders SMOOTHING default ml4 overrides (CN's normalize is the other override, and that one is a units choice, not a statistical one), and the override is the point: the library's m=1.0 leaves a two-row category carrying a third of its own label and a singleton half of it — the weakest smoothing in the family, reached by an analyst who picked "ME" precisely for additive smoothing STRONGER than TE's. 20.0 is TargetEncoder's own min_samples_leaf default, the category size at which TE's sigmoid crosses ½, so ME and TE shrink comparably and choosing between them is about the SHAPE of the shrinkage (additive vs sigmoid) rather than an unstated 20× difference in its strength (TE-2).
enable_regression_target_encoding boolean True Master switch for regression-task target encoding. Off ⇒ skip the entire regression-TE block (the classification path is unaffected). On by default since regression TE is well-validated and cheap.
regression_target_encoders list of one of: TE, WE, JS, ME, GM, LO, CN ['TE', 'CN'] Subset of TargetEncoder codes used when the task is regression. ["TE", "CN"] is the safe default. Regression runs the same category_encoders wrappers as classification (there is no separate regression encoder class or CV strategy). WE (Weight-of-Evidence) is binary-classification only and is skipped.

target_transform — TargetTransformConfig

Automatic regression-target transforms and feature reshaping for linear glass-box models.

Parameter Type Default Description
enable_target_transform_tuning boolean True For regression, compare the unmodified target with log, square-root, and Yeo-Johnson transformations. Use a transformation only when it improves validation results. Predictions are always converted back to the target's original units. This setting does not apply to classification.
target_transform_tuner_n_estimators integer 100 Maximum number of trees used to compare target transformations. This is a temporary evaluation model, not the model delivered by the experiment. Higher values may make the comparison more reliable, but take longer.
target_transform_tuner_early_stopping_rounds integer 10 Stop evaluating a target transformation after this many trees without improvement. Lower values finish sooner; higher values give noisy data more time to improve.
target_transform_tuner_min_samples integer 200 Minimum number of training rows required to compare transformations with cross-validation. With fewer rows, the system uses target skewness to decide whether to apply Yeo-Johnson instead.
target_transform_tuner_cv_folds integer 3 Number of cross-validation folds used to compare target transformations. More folds give a steadier comparison but increase training time. Use 1 only when speed matters more than a reliable choice.
enable_x_power_transform boolean False Reshape skewed numeric features with Yeo-Johnson before fitting linear glass-box models such as GLM and scorecard. This can improve accuracy, but makes coefficients less direct to interpret. Tree models ignore it.
enable_x_cyclical_transform boolean True Represent hour, weekday, month, and day as repeating cycles for linear glass-box models. For example, this makes 23:00 close to 00:00 instead of treating them as opposite ends of a number range. Tree models ignore it.

stateful_fe — StatefulFEConfig

Stateful feature-engineering knobs.

Parameter Type Default Description
add_clustering_encoding boolean True numeric-to-nominal clustering encoder.
clustering_max_k integer 15 Upper bound on k for the clustering encoder. The encoder sweeps k ∈ [2, clustering_max_k] and keeps the k with the best silhouette / elbow heuristic.
clustering_binning_method one of: consensus, kmeans consensus Algorithm used to discretise numerics for the clustering encoder. "consensus" agrees on bins across multiple methods (more stable); "kmeans" is faster but more sensitive to outliers.
mt_base_alpha number 5.0 smoothing prior.
mt_min_token_freq integer 1 Document-frequency floor for the per-token target means: a token seen in fewer than this many rows of the fitting slice is dropped from the table, so rows carrying it fall back to the prior instead of to an estimate built from one or two labels. MultiTokenEncode.min_token_freq existed and was read by from_step, but no config field fed it and RecipePlanner._multi_token_steps never wrote it — a dead knob in the same class as wt_n_top_tokens / compress_verbose above, except that this one gates a statistical estimate rather than a log line. It is wired rather than deleted (the treatment) because a frequency floor is the one lever that acts on the rare-token tail: smoothing cannot substitute for it. base_alpha shrinks a singleton token's mean toward the prior but the estimate stays strictly monotone in that row's own label, so no value of mt_base_alpha removes the rare-token noise — it only rescales it. Default 1 keeps every token, matching the shipped behaviour: the fold-local locus plus the inner out-of-fold cross-fit (oof_folds) already make the reported numbers honest, so raising the floor is a bias/variance choice for the analyst, not a leakage fix, and silently changing it would move every existing model's features.
wt_components integer 10 NMF latent factors per encoded column.
wt_cutoff integer 1 Document-frequency floor: tokens appearing in fewer than this many rows collapse into a single OTHER bucket before factorisation.
wt_normalization one of: l1, l2 (optional) None Optional row normalisation of the count matrix before NMF. None keeps raw counts; "l1" / "l2" make rows scale-comparable.
wt_max_iter integer 200 NMF solver iterations.
add_gp_features boolean True True → Gamma-Poisson; False → MinHash.
n_components integer 30 latent categories.
ngram_range list of any [2, 4] Substring length range for BOTH dirty-domain encoders — Gamma-Poisson (add_gp_features) and MinHash (add_gp_features=False + stateless.add_minhash_features). Both are skrub encoders over character n-grams by default (see gp_analyzer), so (2, 4) means 2-, 3- and 4-CHARACTER substrings — skrub's own default and the configuration of the method it implements (Cerda & Varoquaux 2019). Raise the upper bound for longer tokens; lowering the floor to 1 under a character analyzer is rejected in __post_init__. 1 is refused rather than merely discouraged (GAP5-3) because sklearn's char analyzer emits one token per character at min_n == 1, so a length-L string contributes L order-invariant single-character counts against only L-1 bigrams: roughly half the count mass the Gamma-Poisson factorisation sees becomes an alphabet-sized letter-frequency block, and the 3-/4-grams that carry token identity and typo tolerance disappear. It fails no test and raises no error — it just makes the topics worse. ml4 shipped (1, 2) here with the word-analyzer rationale "unigrams + bigrams — good baseline … widen (1, 3) for typo-rich data"; that reasoning belongs to text_ngram_range below, whose analyzer really is word-level. It also genuinely reaches MinHash now: the planner used to construct DirtyEncode with n_components alone, so the MinHash branch silently kept its own (2, 4) and the same dirty column got a different substring basis depending on the add_gp_features toggle.
gp_analyzer one of: word, char, char_wb char Sklearn analyzer for Gamma-Poisson tokenisation. "char" is the default and handles typos well; "word" is cleaner but brittle for dirty data; "char_wb" enforces word boundaries.
gp_hashing boolean False Use hashing-trick CountVectorizer (collision-tolerant, no fitted vocabulary, lower memory). Disable for full-vocabulary mode when you need exact reproducibility of token IDs.
gp_max_iter integer 20 EM-style iterations for the Gamma-Poisson factorisation. Higher ⇒ more stable latent categories at linear extra cost.
gp_sorting boolean True Normalise a comma-separated cell before vectorising: split on commas, join intra-token spaces with "_", sort, re-join — so "b two, a" and "a, b two" become the same input string and land on the same topics. A cell with no comma is untouched, so on a column that holds no comma lists this is a silent no-op. It sorts TOKENS INSIDE A CELL. It does NOT sort the latent components, and nothing in ml4 or in skrub does (GAP5-2 — this comment used to say "sort latent components by frequency … stabilises feature names across runs", which promised a stability the pipeline never had). The emitted names <col>_GP_0…n are positional and so are always stable, but which topic sits at index i is fixed by the GapEncoder's k-means++ initialisation: a retrain on changed data can re-assign every index. Anything keyed by column name across retrains — stored importances, SHAP baselines, a name-keyed drift monitor — is then comparing different topics and will not say so.
text_max_features integer 10000 Vocabulary cap for the TF-IDF stage (top-N most frequent tokens). Maps to sklearn TfidfVectorizer(max_features=...). Keeps the SVD input matrix from blowing up on tail-heavy corpora.
text_ngram_range list of any [1, 2] n-gram range for the free-text TF-IDF encoder. (1, 2) (unigram + bigram) is the standard text-classification baseline.
text_min_df integer 2 Drop terms appearing in fewer than this many documents. Filters out singleton typos / IDs without losing signal.
text_sublinear_tf boolean True Apply sublinear TF scaling (1 + log(tf)). Standard text-IR smoothing; mitigates dominance by very high-frequency tokens.
text_analyzer one of: word, char_wb word Sklearn analyzer for the TF-IDF encoder. "word" is the default; "char_wb" (character n-grams with word boundaries) handles noisy / multilingual text.
text_stop_words string or list of string (optional) None Stop words for the TF-IDF encoder. None keeps all tokens; "english" uses the sklearn list; pass an explicit list for domain-specific stop words (URLs, IDs, etc.).
text_svd_components integer 50 TruncatedSVD.
text_add_linear boolean True CV-stacked linear (one feature per text column; one-per-class for multiclass + "one_per_class").
text_linear_k_fold integer 5 K-fold for the CV-stacked linear classifier on top of the TF-IDF matrix (out-of-fold predictions become the "text linear" feature). Higher ⇒ smoother OOF predictions, linear extra cost.
text_linear_C number 1.0 Inverse regularisation strength for the stacked linear classifier (sklearn convention — smaller ⇒ stronger regularisation). 1.0 is the standard text-classification baseline.
text_linear_max_iter integer 1000 Iteration cap for the linear solver. Most text problems converge well below this; bump only if you see ConvergenceWarning.
text_linear_class_weight object or string (optional) balanced Class-weight policy for the stacked linear classifier. "balanced" (default) reweighs inversely to class frequency — important for skewed text targets. None keeps unweighted; pass a dict for explicit per-class weights.
text_linear_multiclass one of: max_proba, one_per_class max_proba Multiclass output mode. "max_proba" emits a single feature (argmax probability) — compact. "one_per_class" emits one feature per class — richer signal, K× more columns.
add_text_embeddings boolean False Master switch. When True, the free-text columns are encoded with neural sentence embeddings (reduced via SVD/PCA) instead of / in addition to the TF-IDF encoder above. Heavier and slower, but captures semantics TF-IDF misses. Applies to data.text_domain columns; data.embedding_domain columns are embedded whatever this says.
text_embedding_mode one of: augment, replace augment Consulted only when add_text_embeddings is on; the columns are data.text_domain. "augment" (default) runs BOTH TF-IDF+SVD and sentence embeddings on those columns (more features, more cost); "replace" emits embeddings only (no TF-IDF for them).
text_embedding TextEmbeddingConfig — Where the vectors come from: a local sentence-transformers model (the default; needs the parleml-ml4[embeddings] extra, and the fit pins the model's revision), or an embedding-provider connection, which sends the text to that provider at fit and at every scoring call and stops scoring if the provider changes the model. The same config the clustering text preprocessor takes.
text_embedding_reducer one of: svd, pca, none svd Deterministic, tree-friendly reduction of the high-dim embedding. "svd"/"pca" reduce to text_embedding_components dims; "none" passes the raw embedding through (wide — use with care).
text_embedding_components integer 50 Reduced embedding width per text column.
text_embedding_cache_dir string (optional) None DATASET-scoped embedding cache directory, resolved by the backend runner (None ⇒ no cache). Not a user knob and not part of the recipe identity — a pure runtime perf optimization, mirroring clustering's text_embedding_cache_dir.
add_tree_guided_interactions boolean True Master switch for tree-guided interactions. TWO STAGES, AND ONLY THE SECOND ONE MEASURES ANYTHING (TGI-1). Stage 1 fits the small dedicated LightGBM below and ranks column PAIRS; stage 2 (tgi_importance_prune) materialises the candidate columns, fits a second cheap booster over [base features + interactions] and thins by the per-column gain of the ACTUAL column. The stage-1 rank is a PROXY, not an interaction measurement, and the shape of the proxy matters when reading tgi_top_k_pairs / tgi_min_pair_score: the miner pairs every split feature in a tree with every other one (itertools.combinations over the tree's split features), so two independently useful columns sitting in disjoint branches score exactly like a genuine conditional dependence. Scoring each pair by the min of its two best split gains narrows it — a high-gain "hub" column can no longer inflate all of its pairings — but nothing at this stage looks at f(A, B) at all. Treat the stage-1 knobs as a CANDIDATE BUDGET (how many pairs to materialise and test), never as a significance threshold. Consequence: with tgi_importance_prune = False the run ships whatever the proxy ranked, unmeasured. Leave the prune on unless its extra plan-time LightGBM fit is the binding cost — it is what turns a guess into a measurement.
tgi_mining_depth integer 5 Mining hyperparameters (small dedicated LightGBM).
tgi_mining_rounds integer 50 Boosting rounds for the TGI mining LightGBM. Small budget — the tree is a pair detector, not a final model.
tgi_mining_early_stopping_rounds integer 10 Early-stopping patience for the TGI mining LightGBM.
tgi_top_k_pairs integer 12 Pair selection.
tgi_raw_top_k_pairs integer 6 Upper bound on pairs taken from the extra raw-attribute mining pass. That pass mines only the original numerics + categoricals (no text/ cluster twins), so genuine cat$cat / num$cat combinations can be born; without it LightGBM splits on the numeric twins and the categorical combination routes are starved. This is a ceiling (give combinations a chance), never a quota — injected pairs still must survive the downstream importance prune. Set to 0 to disable the raw pass.
tgi_min_pair_score number 0.01 Minimum CO-MEMBERSHIP score for a pair to be considered — not an interaction score (TGI-1; see add_tree_guided_interactions for what the miner actually measures). Pair scores are normalised to a fraction-of-best in [0, 1], so this is a relative threshold (e.g. 0.01 = keep pairs scoring >= 1% of the strongest pair). It admits candidates for the importance prune to test; it certifies nothing about f(A, B).
tgi_max_total_candidates integer 25 Routing / generation.
tgi_num_operations list of one of: PL, MI, DI, MU ['PL', 'MI', 'DI', 'MU'] Arithmetic ops for (num, num) pairs — SQ removed (not an interaction). Numeric-pair operators considered by TGI when generating candidate interaction features. PL = add, MI = subtract, DI = divide, MU = multiply. Subset to narrow the search — e.g. ["PL", "MU"] for ratio-free domains. SQ (square) was removed in because it is not an interaction operator (single-operand transform).
tgi_scale_ratio_mi_threshold number 50.0 Guard against extreme scale ratio between two numeric operands before generating MI (mutual_info)-validated DI (division) features. Higher ⇒ less conservative, more features.
tgi_di_zero_fraction_threshold number 0.2 Skip DI (a / b) features when the denominator's near-zero fraction exceeds this — division by ~0 is numerically unstable.
tgi_raw_as_cat_max_unique integer 20 Numerics with at most this many unique values are treated as categoricals for TGI pair selection. Captures encoded enums (e.g. tier 1-5) that shouldn't be binned further.
tgi_bin_fallback_q integer 4 Fallback quantile-bin count when TGI needs to discretise a numeric to a categorical for pair scoring (e.g. 4 = quartiles).
tgi_concat_max_levels integer 500 Hard ceiling on the DISTINCT LEVELS a minted concat column (<A>$<B>) may carry. Enforced at MINING time, so a doomed pair is never minted rather than materialised and then argued about. A joint category is a grouping: past a few hundred levels it stops grouping rows and starts identifying them, and both a tree split and the target statistic built on it collapse into memorisation. Nothing downstream catches this — AutoGluon's CategoryFeatureGenerator keeps every level seen twice (minimum_cat_count=2), so a 200x200 categorical pair over 100k rows measured 36 718 joint levels of which 28 468 survived into the model frame. Absolute (row-count independent) precisely so the ratio rule below cannot authorise a 500 000-level column on a 10M-row table.
tgi_concat_min_rows_per_level integer 20 The statistical half of the same guard: every minted concat level must be backed by at least this many training rows on average, i.e. the effective cap is min(tgi_concat_max_levels, n_rows // tgi_concat_min_rows_per_level) and shrinks with the data. Without it a 500-level ceiling would still wave through a 500-level joint category on 800 rows (1.6 rows per level — the target statistic is then the row's own label). 20 is deliberately the same number as cv_cat_num_min_group_count / group_rank_min_group_count: it answers the same question ("can a per-bucket statistic be estimated at all?"), so it must not drift between the encoders that ask it.
tgi_importance_prune boolean True Materialise the mined interaction columns at plan time, fit one cheap balanced LightGBM over [base features + interactions], and thin by the booster's per-column gain — measuring the ACTUAL column, not the mining pair proxy. For num_num it keeps the top tgi_max_variants_per_pair operator variants per A-vs-B pair (the data picks the winning operator, killing the PL/MI/DI/MU collinear-copy explosion) then caps the total at tgi_max_final_survivors; concat / bin columns are dropped only when the booster never splits on them. Default-on (adds one cheap plan-time LGBM fit). See prune_interaction_candidates. Cross-section dep: requires add_tree_guided_interactions.
tgi_max_variants_per_pair integer 1 Max arithmetic variants kept per num-num pair. 1 drops collinear copies (the data picks the winning operator per pair); raise to keep more variants when several genuinely help.
tgi_max_final_survivors integer 20 Global cap on surviving num-num interactions after the per-pair dedup, ranked by importance. A safety ceiling on arithmetic feature count.
add_tgi_feedback_round boolean False Mine a SECOND interaction round after feature selection has ranked the engineered columns, restricted to the operands that ranking found useful. Round 1 mines on raw importance; round 2 mines on post-FE importance attributed back to the raw operands — so a column that only became useful once it was encoded (a joint category, a target encoding) can pull its operands back into the pool. Off by default: it is a second mining pass plus the encoders for whatever it mints, which a fast run should not pay. Cross-section dep: requires add_tree_guided_interactions.
tgi_feedback_top_operand_fraction number 0.5 Share of total attributed importance the round-2 operand pool must cover. 0.5 keeps the operands accounting for the top half of the gain; 1.0 keeps every operand with a positive score.
tgi_round2_max_candidates integer 40 Ceiling on the columns round 2 may mint. Deliberately tighter than round one's: these are second-order combinations and are added WITHOUT a further selection pass, so the cap is what bounds them.
add_cv_cat_num boolean False Emit per-category aggregates of each numeric — a fold-local <NUM>_<AGG>_PER_<CAT> column for every (categorical, numeric) pair and every aggregate in cv_cat_num_aggregations (so 2 columns per pair on the default MEDIAN+STD). Pairs are a capped cartesian product over the feature-selection survivors, bounded by cv_cat_num_max_candidates. Independent of tree-guided interactions: it mines nothing and reuses no TGI state, so it works with add_tree_guided_interactions off (DEP-1 — a cross-section rule used to claim otherwise and silently switched this back off). Off by default: it is n_cat × n_num × n_aggs columns.
cv_cat_num_aggregations list of one of: MEAN, MEDIAN, STD, MIN, MAX, SUM, NUNIQUE ['MEDIAN', 'STD'] Subset of {MEAN, MEDIAN, STD, MIN, MAX, SUM, NUNIQUE}. Default tracks centre + dispersion (MEDIAN robust to outliers; STD complementary). NUNIQUE emits the per-category count of distinct numeric values (opt-in — not in the default set). SUM and NUNIQUE are the extensive aggregates: they scale with the bucket's row count, so they are emitted as raw hard counts/totals (cv_cat_num_alpha does not apply) and their unseen-category prior is the mean per-bucket value rather than the all-rows aggregate.
cv_cat_num_max_candidates integer 60 Hard cap on generated CV-CatNum features. Prevents combinatorial explosion (n_cat × n_num × n_aggregations).
cv_cat_num_min_group_count integer 20 Minimum rows-per-(category) for the encoder to compute an aggregate. Smaller groups fall back to the empirical-Bayes prior.
cv_cat_num_alpha integer 10 Empirical-Bayes smoothing weight blending the per-category aggregate toward the global aggregate. Larger ⇒ more shrinkage toward the prior — protects low-count categories. Applies to the intensive aggregates only (MEAN/MEDIAN/STD/MIN/MAX); SUM and NUNIQUE are emitted raw — see cv_cat_num_aggregations.
add_group_rank boolean False Emit the within-category percentile rank of each numeric (a per-row <NUM>_RANK_PER_<CAT> in (0, 1]). Reuses the cv_cat_num (cat, num) PAIRING RULE — the same capped cartesian product — not cv_cat_num's output, so it needs neither that encoder nor tree-guided interactions switched on (DEP-1: this line used to claim a dependency on add_tree_guided_interactions that no rule enforced and no code honoured). Off by default: it carries a heavier per-group-distribution artifact than the scalar aggregates, so it is an opt-in for the heavier presets.
group_rank_min_group_count integer 20 Categories with fewer rows than this on the fit fold rank against the global distribution instead of their own (noisy) grid.
group_rank_max_candidates integer 30 Hard cap on generated group-rank features (one column per (cat, num) pair).
group_rank_grid_size integer 101 Quantile breakpoints frozen per category; the apply-time rank resolution is 1 / grid_size. 101 → ~1% percentile resolution at small artifact cost.
add_entity_cardinality boolean False Emit per-entity distinct-value counts <VALUE>_NUNIQUE_PER_<KEY> — the fraud fan-out signal (distinct emails per IP, distinct cards per device). Pairs are entity-scoped (data.key1 / key2 × their linked categoricals), NOT cartesian, so this stays bounded. Off by default (opt-in).
entity_cardinality_min_group_count integer 1 Keys seen fewer than this many times on the fit fold fall back to the prior: the MEAN per-key distinct count, i.e. what a typical key's fan-out looks like (the all-rows distinct count would be a dataset-wide total, so an unseen key would enter serving carrying the largest fan-out in the data — the inverse of the signal). Default 1 keeps every key's own count (informative even small).
entity_cardinality_max_candidates integer 30 Hard cap on generated fan-out features (one column per (key, value) pair).

text_embedding — TextEmbeddingConfig

Parameter Type Default Description
source one of: local, provider local "local" runs a sentence-transformers model on this machine (needs the parleml-ml4[embeddings] extra); "provider" calls an embedding-provider connection, which sends the text to that provider at fit and at every scoring call.
model string (optional) None Local: a sentence-transformers model id or a directory (None: BAAI/bge-small-en-v1.5). Provider: the model the connection serves (None: whatever it serves at fit).
connection string (optional) None The embedding-provider connection (Settings → Connections). Provider only.
device string auto Torch device for a local model: "auto" (CUDA, then MPS, else CPU), "cpu", "cuda" or "mps". Ignored by a provider.
batch_size integer 256 Texts per model call (local) or per request (provider). Throughput only; it never changes the vectors.

temporal — TemporalEncodersConfig

Mode-1 temporal encoders (Lags, EwmaLags, LagsAggregates).

Parameter Type Default Description
enabled boolean False Master switch — when False the auto-FE pipeline does NOT instantiate any of the encoders (they remain importable for standalone use).
event_time_column string (optional) None Optional override of the time / entity columns. None means "derive from DataConfig.time_domain / DataConfig.key1+key2".
entity_columns list of string (optional) None Entity grouping columns for the lag windows (e.g. ["user_id"]). None ⇒ [k for k in (DataConfig.key1, DataConfig.key2) if k]. Empty list / no key means "global" lags across all rows (rarely what you want — usually a sign DataConfig.key{1,2} should be set).
lag_features list of string (optional) None Which numeric columns to lag. None = "every column in DataConfig.num_features"; pass an explicit list to subset.
lags list of integer [1, 2, 3, 7, 14] Lag offsets in prior events of the same entity (not global table rows, not wall-clock units), emitted as <feature>_LAG_<k>. With entity_columns=["account_id"], lag 1 is that account's previous event. Default [1, 2, 3, 7, 14] covers immediate history and weekly-shaped recency when entities are roughly daily. Sorted + deduped at construction time. See Temporal encoders.
min_lag integer 1 Leakage guard. min_lag=1 means lag_0 is forbidden; min_lag=0 allows using the current event itself (rarely desirable — defaults to 1).
ewma_halflives list of number [3.0, 7.0, 30.0] Half-life values (in lag units) for the exponentially-weighted moving averages. [3, 7, 30] covers short / medium / long- term smoothing — one emitted column per half-life per lag-feature per entity.
aggregates list of one of: mean, min, max, median, sum, std, skew, kurtosis ['mean', 'min', 'max', 'std'] Functions applied over each rolling window per entity per lag feature. Default = centre + dispersion (mean / min / max / std); add median for outlier-robust centres, skew / kurtosis for distribution-shape features.
aggregate_windows list of integer [7, 14, 30] Window sizes (in lag units) for the rolling aggregates. Each window spawns len(aggregates) columns per lag_feature per entity. Keep small (≤ 5 windows) on wide tables — feature count explodes.
min_periods integer 1 Minimum number of past events required before a rolling aggregate is defined; windows with fewer past observations emit NaN (or fill_value when set). Mirrors pandas' Rolling(min_periods=...). Default 1 = defined from the first past event.
time_windows list of string ['1h', '24h', '7d'] Trailing TIME windows (pandas offset strings, e.g. "1h"/"24h"/"7d") for per-entity velocity: each spawns one <time>_CNT_T<window> = count of the entity's prior events in that window (closed='left' excludes the current row, leakage-safe). These are TIME-based, unlike aggregate_windows (event-count/N-row). One column per window (not per feature), so cheap.
add_time_since_last boolean True Emit <time>_TSLAST = seconds since the entity's previous event (recency).
fill_value number (optional) None Fill value for missing lags (early rows where lag_k looks beyond the start of the entity's history). None keeps NaN — useful when the downstream model handles NaN natively (LightGBM, XGBoost, CatBoost). Pass a number (e.g. 0.0) for sklearn estimators.
dropout_mode one of: off, dependent, independent off Stochastic dropout of generated lag columns at fit time — a "lag dropout" regulariser for the tree backend's Optuna search. 'off' = deterministic.
dropout_prob number 0.0 Per-column drop probability when dropout_mode != "off". Acts as a regulariser for the tree-booster Optuna search; 0.1–0.2 typical. No-op when dropout_mode == "off".

feature_selection — FeatureSelectionConfig

Instrumental feature selection (importance-based) + correlation filter.

Parameter Type Default Description
fs_enabled boolean True Master switch. When off, no importance pass runs at all — including the correlation filter, which ranks its survivors by the same fit.
fs_rankers list of one of: lgbm, catboost ['lgbm', 'catboost'] Which instrumental models rank the features (see FSRanker for why these two and not more). Both by default: LightGBM alone systematically under-ranks high-cardinality categoricals, and a categorical it drops takes its target-encoded twins down with it, because selection runs BEFORE the recipe builds them. CatBoost's ordered target statistic is the closest cheap proxy for what that encoding would produce, so it is the ranker that can rescue such a column. catboost is skipped (with a log line) when the frame declares no categoricals: it is here for categorical coverage, and on an all-numeric table it would pay a full second fit for a near-duplicate view. Trim the list to ["lgbm"] for a latency-bound path — the real_time and large_dataset presets do — or keep both for a deeper pass.
fs_feature_ratio number 0.3 Share of the declared features to keep, ranked by importance (0.3 = top 30%).
fs_min_features integer 30 Hard floor on the retained feature count, and the threshold that arms selection at all: a table with this many declared features or fewer is passed through untouched, on the grounds that there is nothing to prune. At the default 30 that makes selection a no-op for most tabular datasets — lower it to prune a narrow table.
fs_max_features integer 100 Hard ceiling on the retained feature count. Caps wide tables (post-FE explosions) so the downstream fits are not spent on near-duplicate columns.
fs_rounds integer 600 Training rounds for each instrumental ranker. 600 is the ROI default: importance ordering (all the rank cut needs) stabilises far earlier than the loss does, so 2000-round rankers were precision that could not be used. Early stopping (below) still allows an earlier exit; this ceiling only shapes worst-case time on hard-to-converge data. high_accuracy raises it to 1000. Applies to every entry in fs_rankers, so the rounds budget is per model and the wall-clock cost scales with the list length.
fs_early_stopping_rounds integer 50 Early-stopping patience for the instrumental rankers. 50 is ample for a ranking fit (100-round patience is precision the rank cut cannot exploit).
fs_revert_margin number (optional) 0.1 How much measured skill the prune may cost before it is thrown away wholesale. The rank cut above is unverified by construction: it keeps the top fs_feature_ratio because a booster's split gain ordered them that way, and nothing downstream ever asks what the model gave up. select_features therefore fits a probe on the kept set and one on the full set, scores both on a policy-aware held-out slice, and REVERTS the entire prune (every declared feature is kept) when (full_skill - kept_skill) / full_skill exceeds this margin. 0.10 because the bar has to sit above the probe's own noise and below a real regression. The probe scores one carved slice, so a few percent of its measured skill is sampling noise; a 0.02 margin would revert on that noise and quietly turn fs_enabled into a no-op. Losing a tenth of the model's skill is not noise, and the cost of being wrong is asymmetric: a spurious revert only means the roster trains on more columns than it needed, while a missed revert ships a model with its one predictive column deleted. 0.0 (or null on the wire) means never revert — and skips the two probe fits entirely, so opting out is also how you buy back the plan-time budget. That is why there is no separate on/off flag: a second knob could contradict this one. Skill is measured on a scale anchored at zero (Gini = 2·AUC−1 for classification, R² for regression) so the RELATIVE margin means what it says — on raw AUC, a set that loses half its usable signal (0.80 -> 0.65) would read as a 19% drop and slip under any sane bar.
overcorrelated_threshold number 0.95 Pearson |r| threshold above which two features are considered duplicates; the lower-importance member of each pair is dropped. The matrix covers the declared NUMERICS only. Pearson on an integer-coded categorical measures whether the level codes move together, not whether the two level partitions are redundant — so a dtype-based candidate set deleted nested pairs like store_id/region_id (and their whole target-encoding subtree) on the strength of how the levels happened to be numbered, while never comparing the string-coded categoricals that genuinely were duplicates. Categorical redundancy needs Cramér's V / η², which this filter does not implement; until it does, a declared categorical is never a candidate here.
max_rows_for_correlation integer 20000 Subsample size used to compute the correlation matrix. Caps the O(n_rows * n_features^2) cost on big tables — sampling at 20 000 rows is empirically stable for |r| within ±0.01.
fs_noise_probe boolean True Rank the declared features against columns of pure noise and REPORT the ones that score no better (measures.json -> noise_probe; the run's feature-selection details). Never a prune: a feature below noise is evidence for a person to weigh, and dropping on one noisy comparison is what the acceptance test above exists to stop. Independent of fs_enabled and fs_min_features: "is this column better than noise?" is as fair to ask of a ten-column table as of a wide one. Three cheap LightGBM fits at plan time; the noise never reaches the fitted pipeline. Off only to buy back those seconds.

calibration — CalibrationConfig

Post-hoc probability calibration.

Parameter Type Default Description
calibration_method one of: auto, isotonic, platt, temperature auto Calibrator family. auto (default) IS TASK-DEPENDENT: on a BINARY target it fits the one-vs-rest family, isotonic for well-populated classes and Platt for rare ones; on a MULTICLASS target it fits temperature. isotonic / platt force the one-vs-rest family on either task, and temperature forces the frame-level scalar on either task. Narrowed to a Literal so the schema advertises it as a dropdown. Whatever is asked for here still has to win the bake-off; this knob chooses the CHALLENGER, not what ships. WHY MULTICLASS DEFAULTS TO TEMPERATURE. One-vs-rest calibrates each class against the rest with its own independently-fitted curve and then renormalises the row, and no step of that is monotone in the row as a whole — two classes can swap order, so the calibrated argmax is not always the raw argmax and the layer can change the predicted CLASS while claiming only to repair its probability. Temperature scaling divides one scalar into every logit, which is monotone across the whole vector at once and therefore argmax-preserving by construction. It pays for that with a single parameter: it can rescale confidence but cannot reshape a mis-shaped reliability curve the way isotonic can, which is exactly the trade the bake-off exists to settle per dataset. Binary keeps the one-vs-rest family because there the argmax is a threshold decision that ml4 tunes afterwards anyway, so the inversion risk temperature removes is not a risk the binary path carries. THE RAREST-CLASS FLOOR IS INERT UNDER temperature. Isotonic-vs-Platt is a choice between per-class curves and temperature fits no per-class curve, so nothing consults the floor on a temperature run and measures.json reports a single fitted temperature instead of a family per class. Setting it explicitly on a binary target is therefore a deliberate trade of the floor's small-sample protection for argmax safety, and the run log says so. auto ON A BINARY TARGET IS RE-RESOLVED FOR EVERY CALIBRATOR FITTED, NOT DECIDED ONCE FOR THE RUN (CAL-3), and on one narrow band of dataset that means the bake-off validates a different family from the one that ships. The rule is "isotonic once the rarest class reaches 1000 rows, Platt below it" (rare_class_floor), and the gate fits each of its 5 folds on 4/5 of the rows. So on a fitting set whose rarest class lands in [1000, 1250) every fold measures PLATT while the full-set refit that is actually deployed is ISOTONIC — the gate's verdict is then about a family the run does not serve. Left that way deliberately: pre-resolving the family off the full set would score a calibrator fitted on more rows than any fold has, trading a narrow family mismatch for a systematically optimistic gate. What it costs is observability, and that is paid instead — the family each class actually got is published in measures.json under calibration.resolved, or calibration.resolved_per_class when the classes disagree. Read those to learn what shipped; this knob records only what was ASKED for. Setting isotonic or platt explicitly removes the divergence, because there is then nothing left to resolve, and so does temperature (and auto on a multiclass target, which resolves to it) — a scalar fitted on 4/5 of the rows is the same estimator as one fitted on all of them, so there is no family for the folds and the refit to disagree about.
calibration_advantage_margin number 0.005 How much better the calibrated probabilities must be than the raw ones for the calibrator to ship, as an ABSOLUTE improvement in out-of-fold log-loss (0.005 = 0.005 nats better). At or below the margin the raw probabilities serve and the run records that calibration was measured and declined, which is a different statement from "not attempted". 0 turns the check off and ships on any strict improvement, however narrow. The gate used to be a bare cal_ll < raw_ll, so a 1e-9 edge — comfortably inside the fold-to-fold noise of the OOF estimate that produced it — was enough to install a map on every served prediction for the life of the model, and the layer's own report then read as evidence that calibration had helped. A margin is what makes "it won" mean more than "it did not lose". ABSOLUTE, and this is the one place it diverges from ensemble_advantage_margin, which is a fraction of the incumbent's score. That gate compares two models on whatever eval metric the run chose, whose scale and direction it does not know, so only a relative margin generalises. This gate always measures one quantity in one unit — out-of-fold log-loss, in nats — so the threshold can be stated directly, and a fraction would silently demand a larger repair from a hard problem than from an easy one purely because its log-loss is bigger. 0.005 is inherited from the ensemble margin's reasoning rather than measured for this gate: it is a floor under noise, not a calibrated decision boundary. Raise it if you would rather serve raw probabilities than a map whose benefit is marginal.
apply_at_predict_time boolean True Apply calibration to the deployed model. True (default) fits + serves the bake-off-gated calibrator and fits the decision threshold on the calibrated probabilities. False disables calibration entirely — raw probabilities serve and the threshold is fit on them — for shadow / A-B comparisons against the uncalibrated baseline.

intervals — IntervalsConfig

Calibrated prediction intervals for regression targets.

Parameter Type Default Description
enabled boolean True Master switch. Regression only — a classification run ignores this section (predict_proba calibration is a separate concern).
levels list of integer [80, 95] Nominal coverage levels (percent). Strictly increasing ints in (0, 100); each L yields lo_L / hi_L band columns.
method one of: auto, cqr, conformal auto Band construction: * cqr — conformalized quantile-regression companions (adaptive widths); requires a single-family winner whose family has a quantile objective. * conformal — split-conformal offsets on the point residuals (constant-ish widths); always available. * auto — CQR when the companions fit, else conformal. Conformity scores are drawn from a deterministic internal holdout of the training frame that the companions are excluded from, which keeps CQR coverage honest without a separate calibration column on the wire.

deployment — DeploymentConfig

Serving-artifact delivery options for the persisted model.

Parameter Type Default Description
persist_decision_basis boolean True When True, the bundle carries decision_basis/ — the strongest model's out-of-sample probabilities, the labels they were graded against and the bake-off's fold geometry. That is what lets calibration, the decision threshold and the baseline gate be RE-FITTED later without retraining the model: they are all decided from that frame and nothing else. Switching it off produces a model that serves identically and can never be recalibrated. This lives in deployment, not in calibration: it decides what goes into the bundle, which is the same kind of question deliver_treelite answers. calibration is classification-only and purely statistical, and a basis is neither.
decision_basis_max_cells integer 50000000 Ceiling on n_rows x n_classes above which the basis is not kept. Above it the fit records that it was omitted for size rather than storing a sample: a smaller basis would answer a different bake-off, so a layer refitted from it would not be the layer a refit produces — and being exactly that is the only reason to keep one. The default is roughly 200 MB at float32.
deliver_treelite boolean False When True, persist attempts to attach a compiler-free Treelite (GTIL) accelerator to the final model core and drop a standalone treelite_model.bin checkpoint next to the joblib. Only eligible single tree boosters (XGBoost gbtree/dart, LightGBM) qualify, and only when GTIL output matches the native booster within tolerance (an absolute bound of 1e-6, scaled by the output's magnitude for regression) on a parity sample — otherwise the model ships native-only. The accelerated FittedPipeline behaves identically to the native one; it just scores faster. The real_time preset turns this on. Requires the optional treelite package (the realtime extra); absent, the build is skipped and the model ships native-only. See parleml_ml4.delivery.treelite_accel.
max_inference_seconds_per_row number (optional) None Maximum average prediction time per row, in seconds, that the served model is allowed to cost. Models slower than this are dropped from the ensemble during the fit. None (the default) = no latency ceiling. This lives with the DELIVERY knobs rather than the training engine on purpose: the number is a property of the host the model is served on, not of how the model was trained, so the same recipe is legitimately deployed under different budgets. No preset sets it — a value guessed without knowing the serving hardware either binds on nothing or empties the roster on a slower box. Set it only when there is a real per-row SLA to honour, and read measures.json (the measured end-to-end infer_cost_s_per_row) to learn what the strongest model actually costs before choosing one.
inference_latency_batch_size integer (optional) None Batch size assumed when timing prediction latency against max_inference_seconds_per_row. Per-row cost is size-dependent (vectorised scoring amortises fixed overhead), so this should mirror the batch the production caller sends. None = engine default. Inert unless a latency budget is set.

Task-specific configuration

These standalone configs drive the non-supervised entry points — they are not sections of the supervised Ml4Config.

ForecastConfig — forecasting

Top-level forecasting task configuration.

Used by Forecaster. See the forecasting guide.

Parameter Type Default Description
horizon int 14 Steps to forecast, counted on the MODELLING grid — i.e. after resample_to, so 8 on a weekly rollup of daily rows means 8 weeks, not 8 days. Must be a positive int. It also sizes cross-validation: every window covers horizon observations and cv_step_size defaults to it, so a series needs horizon * cv_n_windows + cv_gap + 1 observations (43 at the defaults) to enter model selection at all — on tree plus its longest lag, which must be warmed up before the first cutoff — and shorter ones are still forecast, but carry a default winner label (a not_validated weak series). With exog_cols declared, each series must supply at least this many trailing future rows (contract A). On foundation a horizon past the checkpoint's native length (64 for Chronos-Bolt, 1024 for Chronos-2) is warned about, never clamped: the model then feeds its own predictions back as context and accuracy decays with distance.
freq Optional[str] None Pandas offset alias ('D', 'W', 'ME', '15min') ASSERTING the grid the data already sits on. It never resamples — resample_to is the knob that does, and setting both to different values is rejected rather than silently resolved. None (the default) majority-votes the frequency out of the panel and fails loudly when no series has a regular spacing. Pinning it turns a per-series disagreement into an error naming the offending series instead of a silent reindex that would discard their off-grid observations. It is also what supplies the textbook seasonal period when no detected period is convincing ('D'→7, 'ME'→12, 'B'→5, '15min'→96), so a wrong alias quietly re-scales MASE.
season_length Optional[int] None Seasonal period in observations — 12 for monthly data with a yearly cycle, 7 for daily with a weekly one; 1 means non-seasonal. None (the default) detects it: the strongest statistically significant period wins if its strength clears the fixed 0.30 trust gate; otherwise freq's textbook cycle is used, or 1 when the history holds 3+ cycles of it and it tests not significant. Setting it SKIPS detection entirely, and this number is load-bearing — it is the MASE denominator, the SeasonalNaive baseline the weak-series warnings are defined against, and every routed seasonal order — so a guessed value re-scales the whole leaderboard rather than just one model. AutoETS / AutoCES are separately clamped above 24 (their state arrays cannot hold a longer season), which the run reports.
backend Literal['stats', 'tree', 'foundation', 'autogluon'] stats The analyst picks the backend explicitly; mode then chooses within it: auto races that backend's candidate list (per-series winner), single pins model_name. (There is no cross-backend "auto"/escalation — a weak series is surfaced as an honest warning, never silently swapped.)
mode Literal['auto', 'single'] auto Orchestration WITHIN the chosen backend, never across backends. auto races that backend's router-curated candidate list on rolling-origin CV and keeps the per-series argmin of metric_primary, so different series can ship different models. single skips the race and fits the one model named by model_name (required, and validated against the backend). The choice is only substantive for stats and tree: foundation and autogluon surface exactly one candidate either way. Cross-validation still runs under single — it is what the honesty gate and the conformal intervals are computed from.
model_name Optional[str] None The one model to fit when mode='single'; ignored (and normally left None) in auto mode, where the router chooses. Case-insensitive, and validated against the chosen backend's own vocabulary: the statsforecast catalogue for stats, lgbm / xgb / catboost for tree, a Chronos checkpoint for foundation (where it takes precedence over foundation_model_name), and the sole name "autogluon". Naming a model that belongs to a different backend is rejected with the backend it does belong to, rather than quietly ignored.
model_kwargs dict[str, Any] {} Extra constructor arguments for the pinned model — read ONLY on the mode='single' path. In auto mode the router owns each candidate's kwargs and anything set here is dropped. They reach the statsforecast constructor on stats and the booster hyper-parameters on tree; foundation and autogluon are built from their own foundation_* / autogluon_* fields and ignore this entirely. season_length is injected automatically for the models that accept one (an explicit value here wins), and this is the escape hatch for stats models whose constructors have required arguments — seasonal_window_average's window_size, tsb's alpha_d / alpha_p.
foundation_model_name str chronos2 Which pretrained model the backend='foundation' auto path uses; mode='single' takes the model from model_name instead. Chronos-2 is the default: it consumes known-future and past-only covariates (Chronos-Bolt is univariate) and emits 1024 steps per pass against Bolt's 64, beyond which either model rolls over autoregressively and accuracy decays. The Bolt ladder remains selectable as the lighter CPU-friendly option.
foundation_device Literal['auto', 'cpu', 'cuda'] auto Torch device for the foundation backend's Chronos checkpoint. Anything other than auto is passed straight into the Chronos hyperparameters, where AG-TS honours it; auto lets AG-TS decide, which means GPU when a CUDA-enabled torch build is present and CPU otherwise. This is the only device knob: the autogluon backend leaves device placement to AG-TS. Validated whatever the backend, so a config that later flips backends never carries an invalid value; inert on the other three.
foundation_dtype Literal['auto', 'float32', 'bfloat16'] auto Inference precision for the foundation backend, sent to AG-TS as torch_dtype. Chronos-Bolt only — Chronos-2 (the default foundation_model_name) picks its own precision and does not declare this hyperparameter, so a value set here is dropped with a debug line rather than smuggled into a knob AG-TS would reject. auto sends nothing and leaves the checkpoint's own default alone; bfloat16 halves the weight memory on hardware that supports it, float32 is the safe choice where it does not.
foundation_batch_size int 256 Series contexts per forward pass. On the default chronos2 checkpoint this is a modelling knob, not a throughput knob. Chronos-2 defaults to cross_learning=True, which lets a series borrow context from the others in its batch — AG-TS's own docstring warns that "enabling this mode makes the results sensitive to the batch_size used", and upstream recommends ~100 (the value used in the Chronos-2 technical report) rather than a throughput-maximal one. So changing this changes forecasts, and a series' forecast depends on which other series share its batch — see cross_learning to turn that off. (256 matches AG-TS's default for chronos2 and for Bolt mini/small/base; bolt_tiny alone defaults to 8 upstream.)
cross_learning Optional[bool] None Chronos-2 cross-learning: let each series borrow context from the others in its batch. None keeps the checkpoint's own default (True for Chronos-2), which is usually the accurate choice and is why the fit path leaves it alone. Set False when a run must be reproducible under a changing panel: with it on, adding or removing an unrelated series changes another series' forecast, and no seed fixes that — AG's random_seed does not control batch composition, which is by item order. Inert on every checkpoint that does not support it.
foundation_context_length Optional[int] None Cap on the per-series context window; None = the model maximum.
autogluon_preset Optional[str] None Read by the autogluon backend, and — for autogluon_time_limit_s — by foundation too, which subclasses it and shares _time_limit_seconds. The rest are inert on the other three backends. autogluon_preset: an AutoGluon-TimeSeries predictor preset ('medium_quality', 'high_quality', 'best_quality', 'experimental_quality', the Chronos presets 'chronos2' / 'chronos2_small' / 'chronos2_ensemble' / 'bolt_', or AG's aliases 'medium' / 'high' / 'best' / 'experimental' / 'mq' / 'hq' / 'bq'). None => derived from budget. The retired 'fast_training' and 'very_light' are refused with the preset to use instead.
autogluon_models Optional[tuple[str,...]] None Restrict AG-TS's model zoo to these AutoGluon model names (e.g. ("Chronos2", "Toto2", "RecursiveTabular")); None => the preset's zoo, or the cheap fixed zoo on budget="fast".
autogluon_time_limit_s Optional[int] None Hard AG-TS fit time budget in seconds, per predictor fit; None => derived from max_runtime_minutes split across the fits a run performs (cv_n_windows cross-validation refits plus the final fit). Also read by the foundation backend, which shares this resolver.
lags LagsConfig LagsConfig(...) Lag / rolling-aggregate / calendar feature plan for the tree backend — see LagsConfig. Nothing else reads it: stats models take a season_length instead, and foundation / autogluon derive their own features. Carried regardless of backend so one config can move between them. What it yields is the base plan: at any budget other than fast the Optuna search perturbs it (prob_default_lags / prob_aggregation_lags) and a successful search replaces it wholesale, while an explicit lags_override is honoured verbatim and never perturbed.
rolling_method Literal['tta', 'refit'] tta How the tree backend advances its rolling-origin CV windows; inert on the other three. tta fits only the EARLIEST window and reuses those weights over recomputed features for every later one — fast, but window 0 is the shortest history in the run, so most windows are scored by an under-trained model. refit fits each window honestly and costs cv_n_windows fits. Not a cosmetic choice: the conformal intervals are calibrated on exactly these residuals, so under tta the run records a note that their stated coverage carries no finite-sample guarantee.
tree_target_transform Literal['auto', 'none', 'standard', 'mean_abs', 'difference'] auto Target transform for the tree backend, before the GBM sees y; inert on the other three. auto (the default) uses AutoGluon's own choice — a standard scaler plus seasonal differencing — and that is what lets a GBM forecast a trend: its prediction is a leaf mean, so without differencing the recursive forecast is bounded by the training range and flattens at the last observed level for the whole horizon on any trending series, and because this is one global model pooled across every unique_id, an unscaled target lets the largest-scale series dominate the squared-error objective while the small ones are fitted as noise. none fits raw y: SHAP values and the reconstructed design matrix are then in target units, and lag1 equals the previous observation exactly, which is what makes the tree explanations legible. It costs trend extrapolation to buy that, so the run says so rather than leaving the reader to infer it from a flat forecast.
emit_holdout_predictions bool True Publish the raw rolling-origin CV predictions a second time, as ForecastResult.holdout_predictions — a copy of cv_table (one row per (series, window, step) for every raced candidate). Result shaping only: it changes no fit, no forecast and no score, and the attribute is None when this is off or CV produced nothing. A reloaded deploy bundle rehydrates it, and the reconstruction is not always the same frame. With save_cv_table=True it comes back from cv_table.parquet intact. Otherwise it is rebuilt from conformal_cv.parquet, which is always written but carries only the selection windows (the held-out window is excluded so it cannot serve as both test and calibration data) and only the winning alias per series. So in that case the in-memory result carries every raced candidate over every window and the reloaded one does not — which is a degradation worth knowing about rather than a silent shape change.
budget Literal['fast', 'balanced', 'thorough'] balanced Effort tier. NOT a wall clock (that is max_runtime_minutes) — it buys candidates and search, and it means something different on each backend. On stats it trims the roster: fast keeps the honest baselines plus the one cheapest primary and drops AutoARIMA / AutoTBATS / MSTL, thorough turns off AutoARIMA's stepwise search. On tree it sets both the roster (lgbm / +xgb / +catboost) and the per-candidate Optuna trial count (0 / 10 / 30 — so fast ships hand-picked defaults and no feature-plan search). On autogluon it picks the AG-TS preset (medium_quality / high_quality for balanced / thorough); fast sets none and trains a cheap fixed zoo (Naive, SeasonalNaive, ETS, Theta, RecursiveTabular, DirectTabular; no foundation models). A pinned autogluon_preset wins on every tier. On foundation it does nothing: a zero-shot weight load has nothing to search. On every backend thorough additionally raises the default cv_n_windows from 3 to 4.
max_runtime_minutes Optional[int] None Soft wall-clock budget (minutes) for the whole fit — one run, not one model fit. On tree it is sliced across the per-spec Optuna HP search (Optuna's timeout); on autogluon / foundation it is divided across the fits a run actually performs (cv_n_windows cross-validation refits plus the final fit) rather than applied to each of them, which would have multiplied the stated budget by cv_n_windows + 1. stats has no time-limit API and ignores it. It is a soft cap: an already-running statsforecast fit / CV pass is atomic and runs to completion, so a tiny budget can still be overshot by one such call. None (the default, and what the shipped templates leave it as) means unbounded — budget / per-spec n_trials alone bound the search.
n_jobs int 1 Worker processes for the stats backend only, handed to StatsForecast, which parallelises across SERIES — so it buys nothing on a single series and scales with panel width, not with model complexity. The other three backends tree threads it into its Optuna scorer, and treats a value greater than 1 as a deliberate ceiling on the AutoGluon fit's threads. The default 1 is NOT forwarded as a cap there: this knob was written for the stats backend, where 1 means "one worker process" and is the safe default, so forwarding it verbatim would pin the booster to a single core on every default run. foundation / autogluon manage their own parallelism and ignore it entirely. It cannot shorten any one model's fit, so it neither substitutes for budget nor stretches what fits inside max_runtime_minutes.
cv_n_windows Optional[int] None Rolling-origin CV window count; None means "let budget decide" and resolves in __post_init__ to 3, or 4 under thorough (more windows improve model-selection reliability). An explicit value is ALWAYS honoured, including an explicit 3 under thorough. None rather than a plain int because that promise is otherwise unkeepable: a dataclass cannot distinguish "left at the default" from "explicitly set to the default value". Not cosmetic — this value sets the minimum series length for CV eligibility, so a silent bump would change which series are scored at all. It also sizes the conformal intervals' calibration set: every series adds one independent score per window except the held-out one, and a level L needs about (100+L)/(100-L) of them panel-wide (9 for 80%, 39 for 95%). Raising the horizon does not substitute.
cv_step_size Optional[int] None Observations between consecutive rolling-origin cutoffs. Each window covers horizon observations, so a step below the horizon makes adjacent evaluation blocks overlap — the same points are scored more than once, the effective sample is smaller than the window count implies, and the held-out test window can no longer be kept disjoint from the selection windows. Rejected for that reason. None = use the horizon (contiguous, non-overlapping blocks).
cv_gap int 0 Embargo: number of most-recent training observations dropped before each validation block (leakage guard for feature-derived models). Enforced by the shared CV grid (parleml_ml4.forecasting.splitting): the tree backend recurses across the gap and AG-TS / foundation forecast gap + h steps across it. statsforecast's internal CV cannot honour it (it trains through to each cutoff) — recorded per fit under failures['_cv_gap_unsupported_stats'].
metric_primary Literal['mase', 'smape', 'mae', 'wape', 'rmsse'] mase The metric the per-series CV winner is the argmin of, and the eval_metric handed to AG-TS on the autogluon / foundation backends. Units differ: mase and wape are ratios (1.0 = the SeasonalNaive baseline, and the total actual, respectively), mae is in target units and therefore only comparable within one series, smape is a percentage in [0, 200]. Prefer the default — MASE is scored against SeasonalNaive at the run's resolved season_length, which is the same yardstick the weak-series honesty gate uses, so choosing another metric makes the leaderboard and the gate disagree about which series are deployable. rmsse is MASE's squared-error companion (the M5 metric) on the same seasonal-naive scale, so it is read against 1.0 the same way but punishes large misses harder. cv_scores always carries mase and smape alongside whatever is selected here. All of these are POINT metrics. Interval quality is reported separately — empirical coverage and mean band width, measured on the held-out window the bands were not calibrated on — because a point metric cannot see an interval defect at all, and for a long time nothing here could.
resample_to Optional[str] None Roll every series up onto this pandas offset alias ('D', 'W', 'ME', '15min', …) before anything else. This is what makes irregularly-timed data forecastable at all: event-level rows ("one row per signup") become a series ("signups per week"). It DEFINES the grid, so nothing is inferred and freq becomes redundant — set one or the other, not both. Buckets with no observation come back as gaps and are handled by fill_strategy exactly like any other gap. None = model the data's own cadence.
resample_agg Literal['sum', 'mean', 'max', 'min', 'median', 'first', 'last'] sum How the TARGET is aggregated within each resample_to bucket. This encodes what the quantity is, so it is the analyst's call: "sum" for flows (sales, signups, counts), "mean" for levels (temperature, price, inventory). Getting it wrong rescales the forecast without changing its shape — a scale-free metric like MASE will NOT catch it. Covariates are not a judgement call and are aggregated by dtype (numeric -> mean, otherwise -> first). Ignored unless resample_to is set.
fill_strategy Optional[Literal['ffill', 'zero', 'drop_series_if_gap_pct']] None How to fill the gaps that regularising a series onto a complete date grid opens up. Every option is causal — a filled value depends only on earlier observations — which is what lets the fill run once over the full history instead of per CV window: a two-sided fill such as interpolation would rebuild training rows from evaluation-window values across a CV cutoff. None = resolve it from the data (the default). Rolling an event log up with resample_agg="sum" makes an empty bucket a genuine zero — no signups that week is zero signups — while ffill carries the previous week's total forward and invents demand that never happened: it inflates the level, destroys the intermittency structure, and biases every metric computed on it. So __post_init__ resolves None to "zero" when summing a rollup and "ffill" otherwise; an explicit value is always honoured.
drop_gap_pct_threshold float 0.3 Share of a series' rows that may be imputed by fill_strategy before the series is treated as fabricated rather than merely gappy. Read under EVERY strategy: with fill_strategy="drop_series_if_gap_pct" the series is dropped; otherwise it is kept but reported as a mostly_imputed WeakSeries plus a failures["_mostly_imputed"] note — a monthly series in a daily panel is a 96%-invented step function that would otherwise score a perfect MASE and win its race.
intermittent_zeros_threshold float 0.3 Share of a series' OBSERVED values that must be exactly zero before that series counts as intermittent; the PANEL is intermittent when a majority of its series are. Zeros that fill_strategy="zero" invented are subtracted first, so rolling an event log up does not fake intermittent demand. The verdict is exclusive and it is read on the stats router alone: crossing this line swaps the whole roster to the Croston / ADIDA / IMAPA / TSB family and removes AutoETS / AutoARIMA / AutoTheta / AutoCES from the race, so lowering it on smooth data deletes every smooth model from the run.
multi_season_strength_threshold float 0.1 Seasonal strength (the bias-corrected share of de-trended variance a period explains, 0.0 unless statistically significant) a candidate period must reach to be COUNTED as present; two or more present periods make the profile "multi-seasonal", which routes the MSTL / TBATS decomposition branch instead of the univariate one. A branch selector only — the CV race scores that branch against the baselines and can reject it, which is why this one is safe to expose and safe to lower. It does NOT choose the run's season. That is a second, deliberately stricter cut-off on the same strength — parleml_ml4.forecasting.season.SEASON_STRENGTH_THRESHOLD (0.30) — which is a fixed constant, not a knob: the season it picks IS the MASE denominator and the SeasonalNaive baseline, so nothing downstream can reject a bad choice. Setting this knob to 0.10 therefore admits periods the resolver will refuse to seasonalise on, and that is intended and safe; the router filters every period through the 0.30 gate before it reaches a model spec.
outlier_mark bool False Tag outliers on the preprocessed history — a per-series expanding 1.5×IQR fence (needs at least 4 observations) written to a __outlier_mark column. It TAGS only: y is never winsorised, clipped or dropped. The fence is expanding, not full-sample, and that is load-bearing. A full-sample IQR makes a row's flag at time t a function of values at t' > t — including every cross-validation evaluation window — the leakage a two-sided fill would cause. The column sits in NON_EXOG_COLS, so the tree feature builder and the statsforecast exog resolver strip it, but AG-TS consults that list only when choosing known covariates: a column present in the frame and not declared known is a past covariate there, i.e. a training feature, and Chronos-2 consumes past covariates. So switching it on can change the autogluon and foundation fits, and causality is what keeps that honest.
confidence_levels tuple[int,...] (80, 95) Two-sided interval coverages in percent — strictly increasing, no duplicates, each in (0, 100). Where the band comes from depends on the backend, and the difference is epistemic rather than cosmetic. stats emits analytic bands from the model's own distribution. autogluon emits AG-TS's native quantile heads. Only tree and foundation are point-only and take their bands from the split-conformal layer — so the run's interval-honesty machinery (_intervals_uncalibrated) is defined over those two, and a native band from the other paths ships without a residual-count check. For the conformal path, each residual is divided by its series' seasonal-naive error at the window's origin (the MASE denominator) and the scaled residuals are pooled across the panel, so every series is banded on its own scale, even with one calibration window; a series whose history has no seasonal-naive error (constant, or shorter than a season) gets no band, which the run reports. What counts is independent (series, CV window) scores, not rows: the h residuals of one window share a single recursive path, so a longer horizon buys rows and no exchangeability. A level L needs about (100+L)/(100-L) scores — 9 for 80%, 39 for 95%, 199 for 99% — and below that the band is still a valid conservative interval but does not carry its stated coverage, which the run reports. At the default cv_n_windows=3 that takes about 5 series for 80% and 20 for 95%; raise cv_n_windows or fit more series together. Interval quality is also measured: coverage and mean width are scored on the held-out window, which the bands are not calibrated on. Surfaces that publish a single band publish the WIDEST level configured here, so the tail of this tuple is what a reader sees.
non_negative Literal['auto', 'on', 'off'] auto Whether forecasts are floored at zero. "auto" floors them when every training target value is >= 0 (demand, counts, sales: a negative forecast is impossible, and a conformal band around a small point on an intermittent series reaches below zero easily); "on" always floors; "off" never does. The floor applies to every point and every -lo-/-hi- bound, at fit and at serve time, AND to the cross-validation predictions before they are scored — so selection, the held-out metrics and the measured interval coverage describe exactly what is published. Raising a lower bound to zero cannot exclude a non-negative actual, so coverage is unchanged or higher. A run whose values were floored says so in its notes.
exog_cols tuple[str,...] () Driver columns to carry alongside the target. Declaring any switches on contract A: the trailing rows of each series whose target is blank are read as FUTURE regressor values (at least horizon rows per series) and split off before the history frame is built. Support varies sharply, and a column the chosen backend cannot use is dropped with a run note rather than an error — tree consumes known-future regressors natively, stats only through AutoARIMA and numeric-only (with no exog-capable model in the race the run downgrades to univariate), foundation only on Chronos-2 since the Bolt ladder is univariate, autogluon throughout. Use future_exog_cols to separate the known-future drivers from the history-only ones.
future_exog_cols Optional[tuple[str,...]] None Subset of exog_cols whose FUTURE values are known at predict time (e.g. planned promotions, calendar drivers). None => every exog is treated as known-future (the default). Columns NOT in this set are "historical-only", and the backends differ: the tree backend LAGS them forward at >= max(horizon, min_lag) — far enough that a lagged value is always one the model could have observed at forecast time, so no future values are ever required for them — while stats drops them (statsforecast has no mechanism to lag an exogenous column) and AG-TS lets them ride the training frame as implicit past covariates. Only numeric historical-only columns can be lagged; a non-numeric one is excluded and named in the run notes.
static_cols tuple[str,...] () Per-series CONSTANT attributes (store format, region, product family). Lifted off the long frame into a one-row-per-unique_id table before preprocessing drops non-target columns, then joined back by unique_id. "Constant" is taken on trust, not checked — a column that in fact varies within a series is reduced to its first non-null value rather than rejected. Only tree and autogluon consume them; stats has nowhere to put them, and no Chronos checkpoint supports static features at all, so a foundation run reports them as ignored. Not read when fit is handed a pre-built SeriesFrame, which must carry its own static_df instead.
hierarchy_cols tuple[str,...] () Ordered (coarse -> fine) series-level grouping columns that define an aggregation hierarchy over the bottom series (e.g. ("region", "store")). When empty, nothing changes (flat-series runs are unaffected). When set, the bottom-level forecast is rolled up into coherent per-level forecasts via hierarchicalforecast BottomUp; the result lands on ForecastResult.reconciled_forecast.
random_state int 0 Seed for every stochastic choice a fit makes. On tree it seeds the Optuna sampler, where it is unusually load-bearing: a successful search replaces both the model hyper-parameters AND the entire feature plan, so an unseeded run deploys different lags each time and no AutoDoc card, interpretation or regression test describes a stable artefact. On autogluon / foundation it becomes AG-TS's random_seed for both fit and predict (AG otherwise uses its own 123). stats is deterministic and ignores it, as does the final booster fit, whose seed AutoGluon owns. It does NOT make a cross-learning Chronos-2 run reproducible across a changing panel. Batching there is by item order, and AG's random_seed does not control batch composition — so adding or removing an unrelated series still changes another series' forecast. cross_learning=False is the knob for that, not this one.
save_cv_table bool True Keep the full raw rolling-origin CV predictions on ForecastResult.cv_table (and as cv_table.parquet in the deploy bundle). Set False to shed that frame — one row per (series, window, step) across EVERY raced candidate — when you only need the aggregated cv_scores. It does NOT affect the served forecast: the conformal layer's residual source is persisted separately as conformal_cv.parquet.

AnomalyConfig — anomaly detection

Anomaly-detection task configuration.

Used by AnomalyDetector. See the unsupervised guide.

Parameter Type Default Description
mode Literal['auto', 'single'] auto auto lets PyOD's ADEngine profile the data and run a small consensus; single fits exactly the detector named in detector_name. Note auto can only reach 8 of the 23 curated detectors — see the class docstring — so reaching the rest means single.
priority Literal['speed', 'accuracy', 'balanced'] balanced ADEngine router hint — an override, not a dial. Exactly two rules in PyOD's knowledge base carry a priority condition, both additionally require n_features >= 100, and they match 'speed' and 'accuracy' only. No rule matches 'balanced' — the default — so at the default value, and at any width below 100 encoded columns whatever the value, the detector pick is decided entirely by the width/size rules and this field changes nothing.
contamination float 0.05 In (0, 0.5). Fraction of training rows expected to be anomalous.
exclude_detectors list[str] [] Detectors ADEngine may not pick — a constraints={'exclude_detectors': [...]} passthrough. Names are matched case-insensitively against the knowledge base and an unknown one raises rather than being dropped, because an exclusion that silently does nothing leaves the run looking constrained. mode='auto' only: under mode='single' the detector is already named, so nothing would read this — supplying both is refused rather than ignored.
detector_name str iforest suod carries a reproducibility caveat the rest of the table does not: PyOD 3.5.2's SUOD.__init__ takes no random_state, so ml4 supplies an explicitly-seeded copy of its stock base-estimator list. Its random Johnson-Lindenstrauss projections (rp_flag_global=True) remain unseeded upstream, so consecutive runs can still differ slightly — it is the one detector here that is not bit-reproducible from its manifest (ANO-9).
detector_kwargs dict[str, Any] {} Extra constructor kwargs for the picked detector — the escape hatch for parameters this config does not model (e.g. {'n_neighbors': 35} for KNN). Two rules, both enforced at construction rather than documented and hoped for: * Config-owned keys are ignored here. contamination, random_state, n_jobs and n_features are set from their own fields; a colliding entry is dropped with a warning naming it. The old "user extras win" precedence let {'contamination': 0.2} change the fitted model while AnomalyResult.contamination kept reporting the configured 0.05 — so the Summary header and the threshold curve's "current=..." annotation both stated a number the run never used, and a random_state override made the manifest's recorded seed a fiction (ANO-13 / ). * Unknown keys raise. Validated against the target constructor's signature, so a typo fails immediately instead of after the upload and the whole preprocessing pass. Skipped for the few PyOD classes that absorb **kwargs, where no such check is possible.
top_k_anomalies int 50 Rows surfaced in the result + UI as the "top-K anomalies" table.
score_quantile_summary tuple[int,...] (50, 75, 90, 95, 99) Percentiles populated in AnomalyResult.score_summary — kept configurable so callers can mirror PyOD's ADEngine.contamination_diagnostics score_percentiles set (default 50/75/90/95/99 — same as PyOD). Plan.
enable_findings_explanation bool True Forwarded to ADEngine.explain_findings (auto) or computed inline via per-feature z-scores (single).
explain_top_k int 10 How many contributing features each per-row explanation carries. Independent of top_k_anomalies: that decides how many rows are surfaced, this decides how deep the explanation for each of them goes.
random_state int 42 Seeds every stochastic detector that accepts a seed (iforest, loda, dif, the deep models, and ADEngine's own routing). suod is the exception — PyOD 3.5 exposes no seed for it, so its random projections stay irreproducible however this is set.
n_jobs int 1 Default 1: avoid colliding with parleml's outer joblib pool on Windows. Applied in both modes — forwarded to the constructor in single, and written into ADEngine's detector plans in auto (ADEngine exposes no parallelism knob of its own, so the plan's params is the only route in). It used to be threaded through single only, leaving the field unread under the default mode: setting it on an auto run did nothing. Written only for detectors whose constructor declares n_jobs explicitly — several PyOD classes forward **kwargs straight to scikit-learn (PyOD issue #685), where an unexpected keyword raises.

ClusteringConfig — clustering

Clustering task configuration.

Used by ClusteringDetector. See the unsupervised guide.

Parameter Type Default Description
algorithm Literal['kmeans', 'minibatch_kmeans', 'dbscan', 'hdbscan', 'gmm', 'agglomerative', 'birch'] hdbscan Which of the seven scikit-learn algorithms to fit. The default hdbscan discovers the cluster count from density and rejects outliers as noise, so it needs no k — but it is transductive, meaning the fitted model cannot score new rows and cannot be registered as a servable model. Pick a centroid or parametric algorithm (kmeans / minibatch_kmeans / gmm / birch) when the run has to be deployable.
n_clusters int \| Literal['auto'] auto The cluster count, or "auto" to sweep [auto_k_min, auto_k_max] and keep the best by auto_k_score. Read by the four algorithms that take a count — KMeans, MiniBatchKMeans, Agglomerative and Birch. GMM uses gmm_n_components instead, and the density algorithms discover it.
auto_k_min int 2 Lower bound of the sweep. 2 because a "clustering" of one group is the no-structure answer, which ClusteringResult.verdict reports rather than the sweep selecting it.
auto_k_max int 15 15 (not 12) so the common customer-segmentation use case can resolve a higher natural cluster count. The sweep costs one fit plus one score per k: linear in k, but the silhouette itself is quadratic in n, so the ceiling is only cheap because silhouette_max_samples bounds the rows each score sees (CLU-15). Raising this ceiling also raises the reported score's selection optimism — see auto_k_median_score on the result.
auto_k_score Literal['silhouette', 'davies_bouldin', 'calinski_harabasz'] silhouette Which internal validity index picks the winning k. Direction is handled for you — davies_bouldin is minimised, the other two maximised — so a change here cannot silently invert the selection. All three are scored in the clustering geometry, which is where the sweep was choosing; the indices reported on the result are always measured in feature space.
gmm_n_components int \| Literal['auto'] auto GMM's own cluster count — it does not read n_clusters. "auto" sweeps by an information criterion rather than a distance-based index, which is the natural fit for a generative model.
gmm_auto_components_criterion Literal['bic', 'aic'] bic Which criterion the GMM sweep minimises. BIC penalises parameters harder than AIC, so it tends to choose fewer components on the same data.
gmm_n_init int 10 EM is a non-convex local optimiser on a notoriously multimodal likelihood surface, and its log-likelihood feeds BIC/AIC straight into the component-count choice. sklearn's default of one restart made that choice a comparison of single random draws — one per k — so the argmin was partly noise (CLU-6). Ten matches the restarts the KMeans family already gets.
gmm_covariance_type Literal['full', 'tied', 'diag', 'spherical'] full full estimates k·d(d+1)/2 parameters, which is singular unless n ≫ d²; on a one-hot-inflated matrix that bites quickly. diag or spherical are the usual escapes on wide data. Previously hardcoded and reachable only through algorithm_kwargs (CLU-A2).
gmm_reg_covar float 1e-06 Ridge added to each covariance diagonal. Raise it when a component collapses: a singular covariance makes sklearn raise, and the sweep then drops that k (recorded on ClusteringResult.auto_k_skipped_k).
dbscan_eps float \| Literal['auto'] auto eps is an absolute radius in the units of the fused, scaled matrix, so no fixed default can be right across datasets: RobustScaler puts numerics in IQR units, every one-hot level adds a 0/1 axis (two rows differing in one category are already √2 apart), and the expected pairwise distance grows as √(2d). A hardcoded 0.5 therefore returned 100 % noise on anything wide (CLU-4). "auto" derives it per fit from the k-distance knee (Ester et al.) and records the value it picked on ClusteringResult.dbscan_eps_used.
dbscan_min_samples int 5 Neighbourhood size that makes a point a DBSCAN core point. Also the k whose distance curve the dbscan_eps="auto" knee is read from, so the two knobs move together.
hdbscan_min_cluster_size int 20 A ceiling, not a fixed value: the detector applies max(2, min(hdbscan_min_cluster_size, n_samples // 10)). As a fixed 20 on the default algorithm this guaranteed all-noise below ~20 rows and capped a 50-row study at two clusters (CLU-5); as a ceiling it still prevents micro-clusters on big frames while scaling down on small ones.
hdbscan_min_samples int \| None None HDBSCAN's core-distance neighbourhood — how conservative the density estimate is, independent of how large a group has to be to count. None follows sklearn and reuses the resolved min_cluster_size; raising it alone marks more points as noise without changing what qualifies as a cluster.
n_jobs int -1 Threads for the neighbour search only: of the seven algorithms, only DBSCAN and HDBSCAN accept n_jobs — KMeans, MiniBatchKMeans, GaussianMixture, AgglomerativeClustering and Birch would TypeError on it, so it is not forwarded to them. It also does not reach the UMAP pre-reduction, and could not usefully: umap-learn forces n_jobs = 1 whenever random_state is set (umap/umap_.py: "n_jobs value... overridden to 1 by setting random_state"), so the most expensive stage of a density fit is single-threaded by construction. That is the reproducibility / parallelism trade-off, and this config picks reproducibility.
birch_threshold float \| Literal['auto'] auto Maximum radius of a CF subcluster, in the units of the matrix that reaches the estimator — the same class of absolute-scale parameter as DBSCAN's eps, and sklearn's fixed default of 0.5 is wrong for the same reason (CLU-9). Too small and every point becomes its own subcluster, so the CF tree grows to O(n) leaves and the global step runs an O(m²) agglomerative pass over them — the blow-up Birch exists to avoid. Too large and the tree collapses below n_clusters, at which point sklearn substitutes arange(len(centroids)) behind a ConvergenceWarning and the reported cluster count is unrelated to the request. "auto" (default) derives it per fit from the data's own neighbourhood scale and records it on ClusteringResult.birch_threshold_used.
algorithm_kwargs dict[str, Any] {} Merged under the fields this config owns, not over them: a key that duplicates an owned parameter (n_clusters, n_components, random_state, min_cluster_size, eps, n_jobs, the gmm_* knobs, threshold) is dropped with a warning naming the config field to use instead. Last-writer-wins made the persisted config snapshot and the MLflow params disagree with what was actually fitted — silently. Everything the config does not own (linkage, connectivity, branching_factor, metric, …) still passes through untouched.
pre_reduce_umap_components int \| None \| Literal['auto'] auto Reduce the (already fused) feature matrix to this many dimensions for the clustering geometry only, and only when the fused matrix is wider than that. The per-cluster profiles and the reported validity indices are always computed on the un-reduced fused features, so reduction never sacrifices interpretability or inflates the quality number. "auto" (default) resolves in __post_init__ to AUTO_PRE_REDUCE_COMPONENTS for the density algorithms and None for everyone else — reduction is a density-clustering technique, not a width-triggered one. Under the previous unconditional 15 any fused matrix wider than 15 columns (a single 16-level categorical is enough) was UMAP-reduced before KMeans / GMM, which minimise a Euclidean / Gaussian objective in a space where neither is preserved (CLU-2). An explicit int or None always wins over the algorithm rule. Because the resolution happens once at construction, dataclasses.replace(cfg, algorithm=...) carries the already resolved value across — switching algorithm means constructing a fresh config, not patching one.
silhouette_max_samples int \| None 10000 silhouette_score materialises every pairwise distance, so it is O(n²) in time — paid once per candidate k in the auto sweep plus once on the winner. Cap the rows it sees; None disables the cap. The sample is drawn once per fit and reused by every k (a sweep scored on shifting subsamples compares partly-noise). This is a cap on the draw; the rows the metric actually scored (the draw minus its noise rows) are recorded on ClusteringResult.silhouette_sample_size (CLU-15, CLU2-09).
compute_cluster_profiles bool True Per-cluster feature signature table on ClusteringResult: which fields most distinguish each cluster. This table is descriptive. It ranks fields by how far they set a cluster apart from the dataset average, with cluster size, indicator geometry and a source's axis count all priced in, and it makes no claim that a reported field could not have arisen by chance — the clusters were chosen by an algorithm maximising exactly that separation on this matrix, so no valid p-value exists to attach. A profile_fdr_alpha knob used to sit here, feeding a Benjamini-Hochberg step over per-axis p-values that all assumed the cluster was a random subset of the rows. On structure-free data those p's fired on hundreds of fields in every KMeans cluster (0 under a genuinely random labelling), so the knob and its certificate are gone rather than retuned. The one hard gate that remains is not a knob either: it is the support floor passive_stats.MIN_CLUSTER_SUPPORT, shared with the passive-field surface so the two cards on one page cannot disagree about whether a small cluster may claim a distinguishing field.
random_state int 42 Seeds the algorithm (KMeans/MiniBatchKMeans init, GMM's EM restarts) and the post-fusion pre-reduction. It does not seed the text-embedding block or the 2-D map — those are UnsupervisedConfig.random_state and embedding_random_state. Moving only one of the two fit-path seeds holds part of the geometry fixed and makes a seed-stability check look better than it is, so the detector warns when exactly one has been changed.

UnsupervisedConfig — shared unsupervised preprocessing

Preprocessor + embedding settings shared by anomaly and clustering.

Shared preprocessing / embedding settings for both unsupervised entry points. See the unsupervised guide.

Parameter Type Default Description
max_levels_for_onehot int 20 Cats with more than this many distinct values get frequency-encoded (plus a rare-category indicator) instead of one-hot. Keeps the feature matrix from exploding on high-card columns. The cost above the cap is not just width, and it is worth knowing before you tune this: a frequency-encoded column contributes only its value's train-set frequency and an unseen flag, so the category's identity is nowhere in the matrix. Two merchants each seen 5 times sit at distance exactly 0 on the only axis their column provides, and on a 30 000-merchant column that collapses a great deal. This is a standard, deliberate technique — but the trade-off is information, not just size, so raise the cap when a column's identity is the signal and you can afford the one-hot width (PRE-15).
numeric_imputation Literal['median', 'mean', 'zero', 'knn'] knn How a missing numeric value is filled. knn (default) reads the row's other columns; median / mean put the gap at the centre of its axis; zero is only right when zero genuinely means "none". Whether the gap also gets its own <col>__missing axis is missing_indicators. This also selects where the fill happens. median/mean are applied AFTER scaling, so the scaler's centre and spread are computed from observed values only — filling first would put a growing artificial point mass at the column's median and collapse RobustScaler's IQR in proportion to how empty the column is, which makes a sparse column dominate every distance. Both remain exact in meaning under that order: the median is invariant under any monotone map, and the mean commutes with the affine scalers (under numeric_scaler="quantile" it becomes the mean in normal-score space, which is the only reading available once the axis has been re-spaced). zero is applied BEFORE scaling, because it asserts the missing rows ARE zeros: they are data, and they belong in the column's centre and spread. knn (the default) fills from the row's nearest neighbours on the axes it has, after scaling, from a seeded sample of at most 5,000 donor rows: on correlated columns a median fill builds a combination no real row has (a fast-spinning machine at median current), and distance detectors then rank the rows with a missing value first — 91 % of an anomaly ensemble's top 11 on the sensor-health sample, 18 % under knn.
missing_indicators bool False Whether a numeric column with missing values at fit also gets a binary <col>__missing axis, making the gap itself part of the geometry. Turn it on when absence is the signal (an unfilled field on a claim). Off by default: a rare 0/1 axis is isolated by an isolation forest in one split whatever its scale, and pushes a distance detector's neighbours away by a full unit, so gaps alone — sensor dropouts — topped the anomaly list (64-84 % of an ensemble's top 25 on the sensor-health sample were rows with a gap and nothing else unusual, 0 % without the axis). With knn imputation a filled value already sits where the rest of the row puts it, not at the most typical point, which was the reason for the axis under a median fill.
numeric_scaler Literal['robust', 'standard', 'quantile'] robust Scaler for the numeric/datetime track. "robust" (median/IQR, the default) deliberately does NOT bound the transformed range: a log-normal column routinely reaches 40-60 IQR units in its tail, and for ANOMALY DETECTION that is exactly the point. For KMeans / HDBSCAN clustering the same property cuts the other way — a centroid is a mean, so one 50-unit point drags it, and HDBSCAN's mutual-reachability distance is driven by the largest coordinate gap — so those tail points can define the partition and the discovered "clusters" become "the tail of that column" and "everything else". "quantile" (rank -> normal, a monotone per-column transform) is the standard remedy; "standard" is mean/std. One preprocessor serves both tasks, so this is the explicit lever rather than a per-task default.
cat_imputation_fill str __MISSING__ Fill value for categorical NaNs before encoding. Picking a sentinel rather than the modal value keeps "missing" as its own signal — important for anomaly detection where missingness can itself be anomalous.
cat_null_tokens list[str] ['', 'na', 'n/a', 'nan', 'none', 'null', '-', '--', '?'] Case-insensitive text spellings of "absent", folded into cat_imputation_fill before encoding so missingness stays ONE signal. astype("string") already normalises None/NaN/pd.NA, but a CSV-sourced column routinely carries several textual spellings at once (the exporter writes "", a pandas round-trip writes "nan", the source system writes "NULL"), and each would otherwise become its own one-hot level. Set to an empty list on a dataset where one of these — typically "-" — is a genuine category.
drop_constant_columns bool True Drop columns with a single distinct value at fit time. They add no information to either detector family and inflate one-hot widths. Scope note: this governs constant columns only — the separate identifier drop below is not gated on it, because a per-row unique key is useless to a distance whether or not you asked for constant-pruning, and tying the two would mean "keep my constant columns" silently also meant "encode my primary key" (PRE-16).
identifier_distinct_ratio float 0.95 A high-cardinality categorical whose distinct/row ratio reaches this is treated as a row identifier and dropped rather than frequency-encoded: at that density essentially every serving value is unseen, so the axis carries the train-set count of values that never recur. Raise it toward 1.0 to keep a legitimately near-unique dimension (an order id used as a grouping key, a free-text code); 1.0 keeps everything short of perfectly unique. Was a module constant with no config surface (PRE-16).
embedding_method Literal['umap', 'pca', 'tsne'] umap Projection behind the 2-D anomaly / cluster map. Visualisation only — it never feeds a detector. umap and tsne place points by neighbourhood, so distance on the map is not distance in the data; pca is linear and its axes are comparable, at the cost of showing less structure.
embedding_n_samples int 50000 Subsample cap for the embedding fit. Visualisation only — beyond ~50k rows UMAP/t-SNE become both slow and visually indecipherable.
embedding_random_state int 42 Seeds the 2-D VISUALISATION only (see random_state for the fit path).
use_text_embeddings bool True Master switch, on by default: the configured text_columns are embedded with neural sentence embeddings, reduced and fused with the structured feature block before clustering. False turns the embedding pass off whatever text_columns holds (a host that fills it from the dataset's Embedding domain keeps the switch with the caller).
text_columns list[str] [] Free-text columns to embed. Empty ⇒ the text-aware path is inert.
text_embedding TextEmbeddingConfig TextEmbeddingConfig(...) Where the vectors come from: a local sentence-transformers model (the default; needs the parleml-ml4[embeddings] extra, and the fit pins the model's revision), or an embedding-provider connection, which sends the text to that provider at fit and at every scoring call and stops scoring if the provider changes the model. The same config the supervised embedding encoder takes.
text_embedding_cache_dir Optional[str] None DATASET-scoped cache directory (resolved by the backend). None ⇒ no cache.
text_embedding_reducer Literal['umap', 'pca', 'none'] pca How the embedding block is reduced before fusion. "pca" (default) is linear and deterministic, so transform is a fixed projection and a served row's coordinates do not depend on which other rows were scored with it. "umap" is the BERTopic-style choice and separates topics better for density clustering — but UMAP.transform derives its epoch count from the batch row count, thresholds fuzzy-graph edges against a batch-global maximum, and shares one rng stream across the batch, so a row's position genuinely depends on its batch. A pipeline carrying a UMAP in its transform chain is therefore refused for serving (UnsupervisedPipeline.serves_rows_independently), which is correct but means an anomaly run or an inductive clustering run cannot be registered. Pick "umap" when you are clustering transductively (hdbscan/dbscan, which are not servable anyway) and want the sharper topic structure. "none" fuses the raw embedding block unreduced.
text_embedding_umap_n_components int 10 Reduced width, and the UMAP-only shape knobs below it (inert under text_embedding_reducer other than "umap").
text_embedding_umap_n_neighbors int 15 UMAP's neighbourhood size — the knob that trades local detail (small) for global shape (large).
text_embedding_umap_min_dist float 0.0 How tightly UMAP may pack points. 0.0 is the BERTopic setting and the right one here: any larger value spreads points apart and erodes the very density contrast a following density clustering depends on.
text_embedding_umap_metric str cosine Distance used on the embedding block. cosine because sentence-embedding similarity is angular — magnitude carries little meaning there.
text_block_standardize bool True Normalise EVERY text sub-block (TF-IDF, NMF and embedding alike) before the three are concatenated and balanced against the structured block: centre each one per column, then divide the WHOLE sub-block by a single scalar, its sqrt(Σ per-feature variance). Two consequences, both deliberate: (1) the three encodings contribute EQUALLY to the text geometry regardless of how many axes each was given, so tfidf_svd_components no longer silently buys TF-IDF more of the distance mass than the (far more expensive) embedding pass; (2) WITHIN a sub-block the relative variances survive, so a variance-ordered factorisation keeps its spectrum — the leading SVD/PCA axes stay heavier than the trailing noise ones. This used to be a per-column StandardScaler, which whitened that spectrum and gave the 50th TF-IDF component exactly the weight of the 1st in every distance. Named for the whole text block, not the embedding one, because that is what it gates: raw NMF loadings are non-negative and can be orders of magnitude away from raw SVD scores, so turning this off makes the combined text block's scale arbitrary — and FusionScaler then derives its factor from a block whose scale means nothing.
fusion_balance Literal['equal_variance', 'weight', 'none'] equal_variance Balance / fusion of the combined text block against the structured block.
text_block_weight float 1.0 Multiplier applied to the whole text block after balancing. Above 1.0 the text drives the geometry, below it the structured columns do. Under fusion_balance="equal_variance" it no longer has to cancel out your component count — the blocks are already matched on total variance — so a value tuned before that change will behave differently.
tfidf_columns list[str] [] Cheaper, more interpretable alternatives to neural embeddings, selected per column from the dataset domain mapping (text default -> TF-IDF; wt and mt -> NMF). Each fitted encoder is persisted on the preprocessor so transform reproduces the columns and downstream code can read its component_insight. Independent of use_text_embeddings — a run may mix embedding, TF-IDF and NMF text columns. Applies to clustering and anomaly.
nmf_columns list[str] [] Columns encoded as interpretable NMF topics instead of embeddings. Suits delimited tag/keyword fields, where each component reads as a theme and the axes can be named — unlike the opaque __emb_* ones.
token_delimiters dict[str, str] {} Per-column bare-token delimiter (name such as "comma" / "pipe" or a literal char) for the NMF (weighted-token) encoder. Columns absent fall back to a comma split.
tfidf_svd_components int 50 Reduced widths per text column (defaults mirror StatefulFEConfig text_svd_components / wt_components).
nmf_components int 10 Topics extracted per NMF column; each becomes one <col>_WT_* axis.
text_stop_words list[str] [] Words the run ignores in free text, case-insensitive: dropped from the TF-IDF encoding of tfidf_columns and from every cluster's (or the anomalous rows') keywords, on top of the English stop words those already skip. For words that are in the text but must not shape the groups — product names substituted into templated subjects, a signature, a site name. Each entry is one word as the tokenizer splits text (two or more letters or digits), so list the words of a multi-word name separately. Neural embeddings (text_columns) still read the full text: strip such words with a recipe when they must not shape an embedding column.
passive_columns list[str] [] Columns EXCLUDED from the clustering feature matrix but summarised per discovered cluster (per-cluster distribution / summary statistics). This mirrors the jira_themes "topics_per_class" pattern: e.g. cluster on text + a few numerics, then describe each cluster's priority / status / record-type mix without letting those fields steer the clustering geometry. Named "passive" to avoid confusion with cluster_profiles (z-scores on the active clustering features).
passive_top_n int 5 Top-N categorical values to keep per passive field.
description_n_representatives int 20 How many per-cluster text samples to draw from the dense centre (+ a few spread-out members) for the cluster namer and the UI evidence panel. Larger gives a namer more context at the cost of tokens; a handful are diverse (description_diverse_count) so 20 medoids don't collapse to near duplicates. Each sample is truncated to description_max_example_chars.
description_diverse_count int 3 Of the sampled rows, how many are drawn from the cluster's edges rather than its dense centre — so the namer sees the spread, not just the archetype, and cannot name a cluster after its core alone.
description_max_example_chars int 240 Per-example truncation, to keep the prompt inside a sensible budget when a text column holds long free text.
embedding_n_jobs int -1 Threads for the 2-D visualisation embedding only — nothing else on this config reads it, which is why it is not called n_jobs. In practice it reaches exactly one estimator: t-SNE's neighbour search. The UMAP branch pins n_jobs=1 because umap-learn forces single-threaded execution whenever random_state is set (and warns if asked otherwise) — reproducibility wins there — and PCA takes no such argument. t-SNE on up to embedding_n_samples rows genuinely wants the threads. embedding_method="tsne" is reachable on the wire and from MCP even though the wizard does not offer it, so this is a live knob rather than a dead one. The detectors' own n_jobs live on ClusteringConfig / AnomalyConfig and are unrelated to this field.
random_state int 42 Seeds everything the PREPROCESSOR does: the embedding-block reducer (PCA/UMAP), the TF-IDF SVD, the NMF and the theme KMeans. It is not the only seed a run has, and the split matters for a seed-stability study — the standard way to ask whether a clustering is real is to re-run it under several seeds and see whether the partition survives. ClusteringConfig.random_state separately seeds the algorithm (KMeans/GMM) and the post-fusion pre-reduction UMAP, and embedding_random_state above seeds the 2-D map. Varying only one of them holds part of the geometry fixed and makes the run look more stable than it is; vary both, or neither. The detectors emit a WARNING at construction when exactly one of the two fit-path seeds has been moved off its default (parleml_ml4.unsupervised.config_checks.warn_split_random_state). They never propagate one to the other: an explicitly-set 42 is indistinguishable from an omitted one, so rewriting it would silently break a caller who pinned a single stage on purpose.

RecoConfig — recommendation

Top-level recommendation (OvR) task configuration.

Used by Recommender. See the recommendation guide.

Parameter Type Default Description
grain Literal['long', 'event'] long grain selects the input shape: * long — one row per (entity, target_key) candidate + binary label_col (negatives pre-materialised by the caller). * event — one row per purchase event; target_key_col is the product taken (implicit positive) and label_col is unused — the engine derives positives / negatives per class (the TMO shape).
entity_col str entity_id The "who": the entity a recommendation is for (customer / account / subscriber). It is the unit every split is taken on — no entity ever spans train and holdout — and the row key of the entity-level evaluation frame (one row per entity, scored against every class). In event grain it is also the false-negative guard key: an entity that bought a class in any event is never used as a negative for that class.
target_key_col str target_key The "what": the product / item being recommended. Its distinct values over the positive rows are the modeled catalogue — one One-vs-Rest sub-model family and one column of the (n, K) score matrix per key. In long grain it is the candidate the row is about; in event grain it is the product that was taken (the row's implicit positive).
label_col Optional[str] label Binary {0, 1} outcome of one (entity, target_key) candidate row: 1 = the entity took this product, 0 = a negative the caller materialised. Required and non-empty in grain="long", and validated as strictly binary (a NaN or a third value raises). Forced to None in event grain, where every row is an implicit positive and negatives are derived.
timestamp_col Optional[str] None Optional per-row event time. Its effect is to make every entity split time-based rather than random — the ranking holdout, each sub-model's realistic-eval carve, and the trainer's train/valid/test split all order entities by when they FIRST appeared and hold out the newest. Read what that is and is not. The split unit is the entity, so this is a cold-start-entity evaluation: "how well does the model rank for customers it has never seen?" It is NOT a backtest — a held-out entity contributes all of its events including its oldest, and a training entity contributes events later than most held-out ones, so no time boundary separates the two sides. Ordering is by each entity's first event, not its last: a last-event order is monotone in how many events an entity has, so it would hold out the most active customers — who also carry the most history — and flatter every metric computed on them. Entities with no usable timestamp are treated as oldest and stay on the train side. Reserved — never a model feature.
target_group_col Optional[str] None Optional product-hierarchy column: maps each target_key to a coarser group / category (e.g. usage plans vs add-ons vs services). Display-only — it never enters a sub-model as a feature; the engine records the per-class group on the result so the UI can group / filter the catalogue and nest a category ring in the routing chart.
holdings_col Optional[str] None Per-row list of the products the entity holds now (a weighted-token string, e.g. "planA,planB"). Setting it is what switches subcasing on: a candidate whose target_key appears in the list is an existing (expansion / upgrade) case and everything else is new (cross-sell), with a separate sub-model, negative population and calibration per state. Reserved — never a feature. Its tokens must share the target_key_col vocabulary or the split is silently inert (a >50% mismatch is warned), and the column must be present at score time too or every cell falls back to one default sub-model per class.
prior_holdings_col Optional[str] None Same shape as holdings_col, for products held previously. It adds exactly one state: a candidate absent from current holdings but present here becomes returning (win-back) instead of new. Precedence is current > prior > none, so it never overrides existing. Inert on its own — state derivation starts at holdings_col, so this does nothing unless that is set too.
min_subcase_positives int 50 Positives a derived state needs before it earns its own sub-model. States under the floor train no expert of their own: when none qualify the class collapses to a single unified_only model, and when only some do it becomes a partial_split — the qualifying states plus one unified model fitted on the whole positive population to catch the thin ones. Also doubles as the hard-mining floor: the lookalike cloud needs min_subcase_positives // 2 positives (min 2) or it quietly degrades to uniform negatives.
subcase_col Optional[str] None Escape hatch: an explicit opaque (entity, target_key)-grain subcase column overriding membership derivation. When set, holdings/prior are ignored for state derivation. Long grain only — ignored in event grain. LIMITATION: these states are opaque values read off the training frame, so — unlike holdings-derived states, which are recomputed per class at score time — they cannot be recovered when scoring an entity-level frame (one row per entity, scored against every class). A run that trains state-specific sub-models this way therefore RAISES at eval/serve rather than routing every cell to one arbitrary sub-model per class. Use it only where the split collapses to unified_only; prefer holdings_col / prior_holdings_col for a genuinely routable split.
min_positives int 30 Positive rows a target_key needs in the training split to be modeled at all. A class below the floor is dropped before any fit and recorded in excluded_classes with reason below_min_positives: no sub-model, no score column, never recommendable — so this trades catalogue breadth for per-class evidence. Counted after the ranking holdout is carved off, so a large holdout_fraction can push borderline classes under it.
max_classes int 200 Hard cap on the modeled catalogue. Eligible classes are ranked by positive count (descending, then key ascending, so a tie is not decided by row order) and everything past the cap is dropped with reason over_max_classes — the tail simply becomes unrecommendable. It is also the main cost lever: the run fits one binary per (class, subcase), i.e. up to max_classes × the number of states.
submodel_preset str recommendation_ovr Name of the per-sub-model training preset, forwarded to the trainer for API / UI provenance. Advisory under ml4: there is no named recommendation preset registry, so a non-default value is logged and otherwise ignored — the effective budget comes from the built-in light single-family (GBM) roster plus submodel_config_overrides below. Do not expect a string swap here to change what is fitted.
submodel_config_overrides dict[str, Any] {} Supervised-config diff (delta-vs-Ml4Config, optionally nested by section) deep-merged onto a bare config before every sub-model fit — the real way to change how the binaries train. The deliberately light budget (the train/validation policy — no bagging, stacking or refit — and a per-fit time cap) yields field by field: an engine field you declare wins, and is then paid once per (class, subcase), while the fields you leave out stay light. A feature_selection section keeps your rankers instead of the single cheap lgbm ranker that keeps plan-time cost off the per-sub-model multiplier.
submodel_template_name Optional[str] None Display-only label of the template the wizard launched from (e.g. recommendation_ovr_balanced or a custom template name). Carried so a clone / rerun can re-select the same card; the engine itself ignores it (the effective budget comes from submodel_preset / overrides above).
negative_strategy Literal['hybrid', 'random'] hybrid hybrid = capped hybrid hard-negative mining (GA default): a lookalike cloud (clustered positive centroids) scores each candidate negative and a hard_neg_fraction share of the hardest (most buyer-like, below the similarity_cap_pct PU-trap guard) are mixed with random negatives. random draws every negative uniformly. Either way each served column is put back on its class's population prior by a map fitted on the sub-model's uniformly sampled realistic-eval pool; a sub-model too small to reserve that pool trains on uniform negatives instead of hard ones, since only then does its own sampling rate undo the resampling.
neg_ratio float 5.0 Negatives per positive in each sub-model's training pool: the quota is round(neg_ratio × n_positives), capped by the pool actually available, so a thin class silently trains at a lower effective ratio. Raising it sharpens the decision boundary but pushes the training prior further from the production base rate; that prior shift is undone at scoring by each sub-model's prior correction, recalibrated on its uniformly sampled realistic-eval pool, so hybrid's hard-mined columns land on the population prior too. Dropping it to ~1 trains on a balanced world the model will never be served. Must be > 0 — unlike eval_neg_ratio there is no "use the whole pool" setting here.
hard_neg_fraction float 0.5 Share of that training quota drawn from the hardest (most buyer-like) candidates under the similarity cap; the remainder is filled uniformly. hybrid only — random ignores it. At 1.0 the model only ever sees borderline non-buyers, so it over-separates on the resampled distribution and generalises worse on the realistic one (watch roc_gap); low values converge on plain random negatives. It cannot be set to 0 — switch negative_strategy to random instead.
similarity_cap_pct float 90.0 The positive-unlabeled trap guard, as a percentile of the positives' similarity to the buyer cloud: candidates scoring above it are barred from the hard tail. The most buyer-like non-buyers are disproportionately real prospects who simply have not converted yet, and mining them as negatives teaches the model to suppress exactly the customers worth targeting. 100 disables the guard; a low value starves the hard tail so mining decays toward random. It filters only that tail — the uniform backfill may still draw above-cap rows.
population_fraction float 1.0 Uniform down-sample of each (class, subcase) eligible negative pool before any sampling — a runtime lever for very large candidate frames (positives are never dropped). Because it shrinks the pool every quota is capped against, a low value can silently cut the achieved neg_ratio, thin the lookalike cloud's candidate set, and drop the realistic-eval negatives below the floor that reserves an honest eval split at all — leaving the gate with nothing but the optimistic validation ROC. 1.0 keeps everything.
lookalike_min_members int 20 Minimum members per buyer centroid in the lookalike cloud behind hard-negative mining: it decides how many k-means centroids the positives are summarised into (capped at 8) and, below twice this many positives, collapses the cloud to one pooled centroid. Ignored under negative_strategy="random".
eval_neg_ratio float 0.0 Negatives per positive in each sub-model's realistic-eval pool, drawn uniformly; 0 uses every available negative.
submodel_holdout_fraction float 0.25 Share of each sub-model's positives + negatives held out (disjoint from training) as its realistic-eval pool: it estimates the realistic-negative generalisation ROC, and it is where the sub-model's column is recalibrated onto the class's population prior.
submodel_valid_fraction float 0.2 The last two fractions in the chain that decides how much evidence the admission gate below actually sees. A class admitted at min_positives keeps (1 - submodel_holdout_fraction) of its positives for training, and the trainer then reserves submodel_valid_fraction of those to measure roc_val — the number the gate's threshold reads. At the shipped defaults that is 30 x 0.75 x 0.2 ~= 4 positives, where a ROC estimate's standard error (~0.13) dwarfs the 0.05 that min_submodel_roc sits above no-skill. Raise min_positives or these fractions to give the gate more evidence; n_val_pos on the sub-model scorecard reports the resulting count. submodel_test_fraction is reserved only when no realistic-negative eval pool exists; when one does, those rows go to the fit instead (the pool's ROC is what roc_test reports, so a second carve would be scored and then overwritten).
submodel_test_fraction float 0.2
min_submodel_roc float 0.55 Admission gate: a sub-model whose validation ROC falls below this — or is undefined, since a single-class validation slice yields NaN and must fail rather than sneak through — is excluded from the ensemble. It is measured on the trainer's own split of the resampled hard-negative distribution (the optimistic one). The realistic-negative roc_test only vetoes: below 0.5 on at least 30 positives and 30 negatives the sub-model ranks real data backwards and is excluded (below_chance_on_test); roc_gap is reported only. A failing state routes to the class's unified model when one exists (partial splits only) or to its most-populous surviving expert, and if nothing passes the class is dropped whole with reason no_submodel_passed_gate — it leaves the catalogue silently, so read excluded_classes after a run.
calibration CalibrationConfig CalibrationConfig(...) Per-sub-model probability calibration, applied inside each binary fit. It matters more here than in a single-model task: a top-N list mixes scores from K independently fitted binaries, so uncalibrated columns are not comparable across products and the ranking drifts toward whichever class sits on the widest scale. The request is not a guarantee — a calibrator ships only when it beats raw probabilities in the k-fold bake-off. A sub-model's served column is recalibrated on its realistic-eval pool either way, so the per-sub-model calibrated flag reports whether the column is calibrated. Ignored when a custom trainer is injected into Recommender.
ranking_objective RankingObjective accuracy Where to sit on the accuracy ↔ coverage trade-off: accuracy (α=0, the raw calibrated order), balanced (α=0.5), coverage (α=1 — divide by the base rate, so a rare product genuinely can outrank a popular one). A post-training, monotone transform of the score matrix: it changes the order served and the headline report, never a fitted sub-model, so switching it needs no refit. Used as the report's primary objective and as the default sort key of FittedRecoPipeline.recommend; popularity_discount overrides its α.
popularity_discount Optional[float] None α; overrides the preset when set
shrinkage_strength Optional[float] None EB shrinkage; off when None
k int 10 List length: the @k cutoff every ranking metric is computed at (hit@1 / hit@5 / hit@k, mrr@k, ndcg@k — a reference cutoff above k is dropped rather than credited to a list that short), and the default number of products FittedRecoPipeline.recommend returns. Evaluation and serving only; no sub-model changes when it does.
primary_metric str ndcg Which metric family becomes the run's single headline score, reported as {primary_metric}@{k}: ndcg (rank-position weighted), mrr (reciprocal rank of the truth) or hit (was the truth in the list at all). Report-only. Validated against the emitted key set, because an unrecognised stem resolves to a missing key and would publish a plausible-looking 0.0 as the run's result.
rare_class_pctile float 0.5 Where the long tail starts, as a quantile of the per-class frequency distribution — 0.5 calls the less-frequent half of the catalogue rare. It defines the row subset behind the coverage-family metrics (tail_hit@k, tail_ndcg@k, n_rare_classes) and is taken from the training marginal, so the definition of "tail" does not move with the holdout sample. Report-only.
eval_all_objectives bool True Score the holdout under all three objectives (accuracy / balanced / coverage) instead of only ranking_objective, so the report shows what the accuracy ↔ coverage trade-off actually costs on this data before anyone commits to a point on it. Report-only and cheap — the extra objectives are re-transforms of one already-computed score matrix, not refits.
holdout_fraction float 0.2 Share of entities reserved from the whole frame for the top-level ranking evaluation (the latest by timestamp when timestamp_col is set, otherwise random). Those entities are withheld from every sub-model's training data, so this trades ranking-evaluation precision for training rows and can push thin classes under min_positives. Distinct from submodel_holdout_fraction, which carves each sub-model's own realistic-negative eval pool. A degenerate value — one selecting zero or all entities — leaves no usable split, and the run warns and reports NO ranking metrics rather than in-sample ones.
random_state int 0 Seed for every stochastic choice: holdout entities, negative sampling, the lookalike cloud's k-means, and each sub-model fit. Per-class sampling is keyed on (random_state, class_index) so a class draws the same negatives whether it was trained fresh or reached after a resume. It is part of the checkpoint fingerprint, so changing it invalidates a prior run's checkpoint and forces a full refit.
resume bool True When true (and outputs_dir is set), reuse a prior run's per-class checkpoint: reload finished classes and refit only the missing ones. Set false to force a clean run (the prior checkpoint is discarded).

LTRConfig — learning-to-rank

LightGBM LambdaRank task configuration.

Used by LTRRanker. See the learning-to-rank guide.

Parameter Type Default Description
grain Literal['long', 'event'] event event — one row per observed purchase (implicit positive); the engine samples negatives from the product catalog per decision occasion. long — one row per (entity, product) candidate + binary label_col (negatives pre-materialised by the caller).
entity_col str entity_id Who the decision belongs to (customer / account id). It is the first component of every decision-occasion key, the unit the holdout is drawn over under split_strategy="entity", and a reserved column — never a feature. Nulls are rejected rather than coerced: every key here goes through astype(str), so a missing id would silently merge every such row into one mega-group. Same name and same meaning as RecoConfig.entity_col.
target_key_col str target_key The product / item id. In event grain the value on a row is the product taken (an implicit positive); in long grain it is the candidate the row scores. Its frequency order also defines the modeled catalogue (see max_products). The engine feeds this column to the booster itself, as ONE categorical feature spanning the whole catalogue (one global ranker; the product is a feature, not a model per product), so it must not appear in feature_cols. Nulls are rejected — under astype(str) a missing id would enter the catalogue as a real, rankable product named "nan".
label_col Optional[str] None Binary 0/1 relevance of the (occasion, candidate) row: REQUIRED in long grain, and forced back to None in event grain, where every row is an implicit positive. Graded relevance is rejected rather than truncated — the LightGBM backend runs label_gain=[0, 1] and the evaluator, popularity baseline and comparability state all read "relevant" as == 1, so a graded 0–4 label aborts LightGBM outright and, on CatBoost, would train happily on signal the report then measures as zero positives. Defaults to None rather than RecoConfig's "label" because the default grain here is event.
timestamp_col Optional[str] None Event time. It does double duty, so setting it changes the run twice over: it is the fallback occasion key (with bundle_col unset a group becomes (entity, timestamp floored to bundle_time_granularity)), and it is what orders the holdout — split_strategy="auto" resolves to temporal when it is set and entity when it is not, and an explicit temporal requires it. Occasions whose value will not parse are treated as oldest and kept on the train side. A configured column missing from the frame is an error, never a silent fall-through to entity grouping.
target_group_col Optional[str] None Optional product → category column. Unlike its RecoConfig / AssociationConfig namesakes — which publish the mapping for the UI catalogue and the network colouring — nothing in the LTR result reads it today. Naming it only reserves the column: it is kept out of the derived feature_cols (and warned about if you pass it explicitly) so a category label is not quietly learned as an entity feature.
bundle_col Optional[str] None A "group" is one decision occasion. Resolution priority: 1. bundle_col set -> (entity_col, bundle_col) 2. else timestamp_col set -> (entity_col, timestamp@bundle_time_granularity) 3. else -> (entity_col)
bundle_time_granularity str D pandas floor freq, used in priority 2
negatives_per_positive float 9.0 Negatives drawn per decision occasion, as a ratio to the occasion's positives: round(this * n_pos). The default builds a ~10-candidate list around a single-item occasion and grows the list with the basket, so it sets the within-group positive rate the ranker trains against — not a global sampling rate. Fractional values are allowed (the product is rounded). Event grain only; long-grain candidates arrive pre-materialised from the caller.
min_neg int 1 Floor applied after that ratio, so a small occasion still gets a contrast: any ratio under ~0.5 rounds a single-positive group to zero negatives, and a positives-only group gives the ranking loss no pair to learn from — it costs training rows and teaches nothing. 0 is accepted and disables the floor.
max_neg int 200 Ceiling on the same count, so one outsized occasion cannot dominate the training matrix — at the default ratio a 50-item basket would otherwise ask for 450 negatives. Whatever survives the clamp is capped again at catalog_size - n_pos, so a group never requests more products than remain in the catalogue. Validated to be >= min_neg.
negative_sampling Literal['uniform', 'popularity'] popularity popularity (default) samples negatives ∝ freq**temperature so popular items become hard negatives; uniform samples every product equally. temperature 0 → uniform, 1 → raw popularity, 0.75 → the usual middle.
negative_sampling_temperature float 0.75 Exponent on the popularity weights (freq ** t); read only when negative_sampling="popularity". It reshapes the draw rather than resizing it. Raise it and popular items crowd the negatives — which is the point, since evaluation ranks against the FULL catalogue, so hard negatives make the train-time contrast match the test-time competition. Lower it and the negatives drift to the obscure tail, the ranker is never forced to discriminate among the items that actually compete at the top, and it regresses toward the popularity baseline. Products with no observed purchase get a floor count of 1.0 so their weight stays strictly positive. Accepted range [0, 10].
exclude_holdings_from_negatives bool False When True, products already held by the entity are excluded from the sampled negatives (default False — reorders / add-ons are valid positives).
holdings_col Optional[str] None Products the entity ALREADY holds at the occasion — a list/tuple/set, or a comma / pipe / semicolon separated string — read from the group's FIRST row (it describes the occasion, not the row) and treated as empty when missing. Read only while exclude_holdings_from_negatives is on; otherwise naming it merely reserves the column out of feature_cols. Same name as RecoConfig.holdings_col, different job: there it decides which class-conditional sub-model a cell routes to, here it only removes products from the sampled negatives.
max_products int 500 Cap on the modeled catalogue: the max_products most frequent products of the TRAIN split become the candidate categorical, the columns of the score matrix, and therefore how many candidates every list ranks (the width of the list, not the k cutoff read off the top of it). It bounds cost directly — scoring crosses each row against the catalogue — but it also decides what is answerable: a product outside the cap can never be recommended, and a holdout occasion whose realised product falls outside it is DROPPED from the evaluation rather than counted as a miss (the run reports how many, as n_eval_groups_dropped).
ranker_objective Literal['lambdarank', 'rank_xendcg', 'yetirank', 'yetirank_pairwise'] lambdarank NOTE: do not confuse ranker_objective (here) with ranking_objective (further down). ranker_objective is the training loss — it picks the ranking loss the booster minimises and the backend that owns it (LightGBM for lambdarank / rank_xendcg, CatBoost for the yeti pair). ranking_objective is the evaluation/report metric used to score and rank the holdout. One is a "how to fit" knob, the other a "how to report" knob; they are independent. label_gain=[0, 1] for binary relevance; truncation defaults to ~k+3. PERFORMANCE: lambdarank (default) is fast. The CatBoost yetirank / yetirank_pairwise losses may rank better on some data but are noticeably slower to train (yetirank_pairwise most of all) — prefer lambdarank unless you have the training-time budget.
lambdarank_truncation_level Optional[int] None LightGBM lambdarank-only knobs (ignored by every other objective).
lambdarank_norm bool True LightGBM's per-query lambda normalisation — emitted only for ranker_objective="lambdarank"; the other three objectives never see it. On (the default) every decision occasion pulls on the model about equally regardless of how many pairs it contributes. Turning it OFF restores the original LambdaRank formulation, where a query's influence scales with its pair count — here that means the multi-positive occasions, whose lists are larger by construction (see negatives_per_positive), dominate the fit.
sigmoid Optional[float] None None -> LightGBM default (1.0)
yetirank_mode Optional[Literal['Classic', 'DCG', 'NDCG', 'MRR', 'ERR', 'MAP']] None CatBoost YetiRank-only optimisation mode (ignored by the LightGBM objectives).
n_trials int 20 Optuna trials. Each one trains the deployed model shape — trial fits do not early-stop, because stopping on a sampled-negative metric would evaluate a different model from the one that ships. Measured cost: 5.4x per trial (0.25s -> 1.36s at 3000 rounds). With max_iterations this is one of the two levers on search cost (see the block comment above max_iterations).
max_depth int 10 Upper bound of the tuned tree-depth range. Mirrors max_iterations — both cap an Optuna search range rather than pinning a value.
max_iterations Optional[int] None Cap on the boosting-round budget (iterations / n_estimators). None keeps the full 3000-round search. Lower it for fast/smoke configs: the CatBoost YetiRank refit is CPU-only and pairwise, so a large budget can dominate runtime. Unlike max_depth it caps both the Optuna search range and the refit that runs when there was no search to draw from — too few groups to tune, no usable inner validation split, or every trial failed. Those paths used to fall back to a hard-coded 500 rounds, so a smoke cap of 30 bought 16x what it asked for, and it stopped capping in precisely the situations that had already gone wrong (LTR4-4).
k int 10 Ranked-list depth — every @k in the report, and NOT report-only. It is also the Optuna objective's cutoff (each trial is scored on full-catalogue NDCG@k, so k picks the hyperparameters) and, while lambdarank_truncation_level is unset, the training loss's truncation level defaults to k + 3. Changing it therefore changes the fitted model, not just the scoreboard. The fixed reference depths hit@1 / hit@5 are reported only while they fall inside k, so a run at k=3 never claims a hit at rank 5 it would not have surfaced.
primary_metric str ndcg Which already-computed metric family supplies the run's single headline number: primary_score is <primary_metric>@k read off the ranking_objective block's micro family, and that is the value the experiment catalogue and the UI KPI show. One of hit / mrr / ndcg, and report-only — it selects a number, it does not touch the fit or the search. Validated up front because an unknown stem resolves to a missing key and would report a plausible-looking 0.0 as a score.
ranking_objective RankingObjective accuracy ranking_objective is the report/evaluation metric (see the note on ranker_objective above) — it shapes how the holdout is scored and ranked in ranking.json, NOT how the model is trained. Distinct from the training loss ranker_objective.
eval_all_objectives bool False Score the holdout under EVERY ranking objective (accuracy / balanced / coverage) instead of ranking_objective alone, so ranking.json carries one block per objective and the UI can draw the accuracy-vs- coverage trade-off rather than a single operating point. Purely report-side: the same fitted model is re-scored through each objective's transform, so it buys the comparison for one extra metric pass per objective and changes nothing about the fit. (RecoConfig ships this on; here it is off.)
rare_class_pctile float 0.5 Where the catalogue's "tail" starts, as a quantile of the per-class frequency distribution: products at or below it are the rare classes, and the coverage family reports tail_hit@k over just the lists whose realised product is one of them. The default calls the less-frequent half the tail. Taken from the TRAIN label marginal when there is one, so the tail definition is leak-free. Report-only — it labels classes, it does not reweight anything the model fits.
holdout_fraction float 0.2 Share held out — of split UNITS, not rows: a unit is a whole entity or a whole decision occasion depending on the resolved split_strategy, so how many rows land in the holdout varies with basket sizes. The same value is spent twice: once to carve the evaluation holdout out of the data, and again inside the search to carve an Optuna validation split out of what remains. At least one unit is always left on each side. In (0, 1), exclusive.
split_strategy Literal['auto', 'temporal', 'entity', 'random'] auto Which occasions are graded: auto (temporal when timestamp_col is set, else entity), temporal (latest occasions — fit on the past, grade the future), entity (whole entities — cold start), random (legacy; optimistic whenever an occasion is finer than an entity). See the learning-to-rank guide for the rationale.
random_state int 0 One seed behind every stochastic step, so a run reproduces end to end: the negative draw, the random / entity holdout shuffle, the Optuna sampler and its fit/valid split (offset by 1 so it does not mirror the holdout shuffle), the MLI population subsample, and the booster's own seed (clamped into each library's accepted range). The temporal split ignores it — ordering occasions by time is deterministic.
n_jobs int 0 Worker threads for the booster, applied identically to every Optuna trial and to the final refit so a tuned round count is not measured under one thread budget and deployed under another. 0 (the default) means "let the library decide": it is passed straight through as LightGBM's num_threads (0 = the OpenMP default) and translated to CatBoost's thread_count=-1 (all cores). Note this is not the scikit-learn convention — set a positive count to pin it, not -1.

AssociationConfig — association rules

Association-rules task configuration.

Used by AssociationMiner. See the association rules guide.

Parameter Type Default Description
grain AssociationGrain long long — one row per (entity, item); basket = items per entity. event — one row per event; baskets grouped per entity, or per (entity, basket_col), or per (entity, floor(timestamp)). basket — one row per basket; items_col holds a delimited token list. Weighted / packed-count items are out of scope (v1).
entity_col str entity_id Who owns the basket. In long grain the basket IS this entity's whole item set; in event grain it is the FIRST group key, refined by basket_col or the floored timestamp. It also keys the published holdings fact (entity -> items held), which is what turns a rule into a targeting list. Rows with a missing value are DROPPED rather than grouped: a shared NA key would fuse every orphan row into one phantom basket whose co-occurrences never happened. The basket grain never reads it (packed rows carry no entity), though it is still validated as a non-empty string.
target_key_col str target_key the item column (long / event)
items_col Optional[str] None REQUIRED for basket grain
item_delimiter str , basket grain only
timestamp_col Optional[str] None event grain only
basket_col Optional[str] None event grain: explicit basket id
basket_time_granularity str D event grain: pandas floor freq
target_group_col Optional[str] None Optional item -> category column (long / event grains, where every row carries an item). Display-only: network colouring + the published assoc_items.category column. Ignored (with an INFO log) for basket grain, whose rows carry no per-item fields.
target_itemset_count int 2000 Auto-tuned support floor: when min_support is None the miner searches DOWNWARD for the largest support whose count of multi-item frequent itemsets (two or more items, the only ones that can yield a rule) reaches target_itemset_count, so the user never guesses a threshold. NOTE this is a floor-pushing target, not a ceiling: the search stops descending once the count is reached, and a single probe can overshoot it by orders of magnitude on dense data. The independent upper guard is max_lattice_itemsets below.
min_support Optional[float] None expert pin; None => auto search
max_itemset_len int 4 native mlxtend max_len
max_lattice_factor int 25 Hard ceiling on the mined lattice, enforced on EVERY probe of the auto-search (including the first). When a probe exceeds it the search ascends — the previous, sparser support is accepted instead — so a dense catalogue cannot hand generate_rules a lattice whose 2^k-per-itemset rule enumeration exhausts memory. Expressed as a multiple of target_itemset_count. NOTE it applies to the SEARCH. Pinning min_support skips the search, and with it this ceiling: the pin is honoured verbatim and the miner only warns when the resulting lattice exceeds what the ceiling would have allowed. target_itemset_count is likewise unread on the pinned path.
min_basket_size int 1 Baskets with fewer observed items than this are dropped BEFORE the one-hot. Default 1 keeps every basket — the textbook market-basket semantics where all baskets count in every support denominator (singleton baskets cannot contain a 2-itemset, so they never create rules; raising this is an explicit data-cleaning choice that inflates all supports).
exclude_items Tuple[str,...] () Dropped pre-mining (never enter the itemset lattice).
focus_items Tuple[str,...] () Post-mining filter: keep only rules involving ANY of these items on the focus_side. Focus items are exempt from the ubiquitous-item guard (explicit interest wins).
focus_side FocusSide any Which side of the arrow a focus item must sit on for the rule to survive: antecedent keeps the rules those items TRIGGER ("what do buyers of X go on to take?"), consequent keeps the rules that LEAD TO them ("what predicts X?"), any (default) keeps either. Inert while focus_items is empty. The filter deliberately runs before the significance test: it is a hard user constraint with no statistical role, and testing rules that are about to be discarded would inflate the family and depress the q-values of the rules actually asked for.
max_item_support float 0.95 Ubiquitous-item guard: items present in more than this share of baskets ("everyone holds the base plan") flood the lattice with junk rules and are auto-excluded pre-mining, with a WARNING and a record on the result. 1.0 disables the guard.
max_consequent_len Optional[int] 1 mlxtend enumerates EVERY antecedent/consequent split of each frequent itemset (2^k - 2 rules per k-itemset), so a 5-itemset alone yields 30 candidates — most of them multi-item-consequent rules like {a} -> {b,c,d,e}. Those are the bulk of the rule explosion, they inflate the multiple-testing family, and they are near-useless for the product's actual question ("what single item attaches to this basket?"). The default therefore ships SINGLE-ITEM CONSEQUENTS. Set to None for the unrestricted mlxtend behaviour. Applied immediately after rule generation — before the lift filter, the significance test and the redundancy pass — so every downstream stage gets cheaper too.
max_antecedent_len Optional[int] None None leaves the antecedent bounded only by max_itemset_len.
min_confidence float 0.3 Keep a rule only when P(consequent \| antecedent) reaches this. Not a post-filter: it is the threshold handed to mlxtend's rule generation, so it prunes before the shape / focus / lift stages and before anything is tested. On its own it is NOT evidence of association — a consequent half the baskets already hold clears 0.30 by being common — which is what min_lift is for. Raising it also shrinks the family the BY adjustment runs over, so the q-values improve without the rules getting truer; see significance.
min_lift float 1.1 Keep a rule only when its lift — P(consequent \| antecedent) over the consequent's own base rate — reaches this. 1.0 is exactly independence, so the default asks the antecedent to buy a 10% improvement on chance. This is the effect-size gate confidence is not: it is scale-free and cannot be earned by a merely popular consequent. Applied after the shape / focus filters and BEFORE the Fisher + BY pass, which is precisely why the q-values are conditioned on it and understate the true false-discovery rate (see significance; fwer_significant is the companion that is not).
max_rules int 500 Cap on the shipped list, applied to EACH block separately: the ranked rules; the runners-up — rules that passed every gate but ranked beyond the cap, kept unranked with pruned_by = "max_rules" so the rules a short list left out stay readable; and the flagged (redundant or insignificant) ones kept for the UI toggles. Capping only the ranked side meant a run whose gates rejected almost everything returned everything it rejected. rule_stats records how many rules each cap removed, so a truncated list never reads as complete.
rank_by RuleRankMetric lift Which rule metric orders the output — and so, because max_rules caps the sorted list (both blocks), which rules you actually receive. It is not a gate: no rule passes or fails because of it. All five choices are higher-is-better, so one descending sort suffices; ties break on rule_id to keep the order total and reproducible. It does decide what "top rule" means: lift / zhangs_metric reward surprise (rare pairs, disproportionate together), confidence rewards reliability (often true — possibly just because the consequent is common), while leverage / conviction weight the volume of excess co-occurrence.
one_rule_per_itemset bool True Rank at most one rule per item set. A -> B and B -> A are one co-occurrence read in two directions, and lift — symmetric — ties them exactly, so without this every finding took two max_rules slots (and each split of a larger set, {a,b} -> c / {a,c} -> b, one more). On, the best rule of each item set by rank_by (ties: the higher confidence) takes the slot; the other directions stay in the result, unranked, with pruned_by naming that rule. Off ranks every direction.
significance bool True Fisher's exact test per rule + Benjamini–YEKUTIELI across rules; when on, only significant rules are ranked / counted toward max_rules, and only productive ones (see min_improvement). Why Benjamini–Yekutieli and not Benjamini–Hochberg: BH controls the FDR only under independence or positive regression dependence (PRDS). Rules mined from an overlapping itemset lattice violate PRDS in both directions — {a,b}->c and {a,b,d}->c share a count (positive), while the 2^k-2 splits of one itemset are constrained by a common total (negative). BY is the member of the family that is valid under arbitrary dependence, at the cost of a ln(m) + gamma factor. IMPORTANT — what significant does and does not mean. The q-values are adjusted across the rules that SURVIVED the support / confidence / lift filters, not across every rule the search considered. Because those filters select on the same co-occurrence counts the test measures, the q-values UNDERSTATE the true false-discovery rate. The honest, assumption-free companion is fwer_significant (see parleml_ml4.association.rules), which compares p * candidate_family_size against alpha over the whole searched space.
significance_alpha float 0.05 Level of both statistical gates: the BY false-discovery rate of the significance test, and the Holm family-wise level of the productive-rule test (see min_improvement).
min_improvement float 0.01 Minimum-improvement redundancy pruning (Bayardo et al.): a rule must beat the confidence of EVERY proper sub-rule with the same consequent — down to the empty antecedent, i.e. the consequent's own base rate — by at least this much, or it is flagged pruned_by. Note 0 does NOT disable it: the comparison is a strict <, so a rule scoring below its best sub-rule is still flagged. With significance on, a rule that clears the margin must also be productive: each antecedent item has to raise the consequent's rate significantly (Fisher, Holm-adjusted at significance_alpha) among the baskets holding the rest of the antecedent — so an item that merely rides along with a strong shorter rule is flagged against it.
min_superset_lift_gain float 0.1 A rule with a multi-item antecedent takes a ranked slot only when each of its antecedent items earns its place at equal basket breadth: among the baskets holding the rest of the antecedent, grouped by how many other items they hold (parleml_ml4.association.breadth), the consequent must be at least 1 + min_superset_lift_gain times as frequent with the item as without it (a Mantel–Haenszel rate ratio). Otherwise the rule is flagged pruned_by the shorter rule, which carries the finding. On holdings data the plain lift of a longer rule exceeds its pair's because holding one more item selects broader baskets, so a list ranked by lift filled with near-identical triples and pushed out the pairs; at equal breadth such an item adds nothing. Unlike min_improvement it applies with or without significance. 0 asks only that the item does not lower the consequent's rate.
holdout_fraction Optional[float] None Fraction of baskets held out of mining entirely, then used to re-measure every surviving rule. None (default) mines on all baskets and every reported number is in-sample. This is the only statistic here that is not selection-conditioned: the lattice, the rules and every gate come from the train side alone, so a rule's holdout lift is measured on baskets that had no say in choosing it. It is the honest answer to "is this rule an artifact of the sample I mined?" — which the q-values, adjusted across rules that already passed a screen, cannot give (see parleml_ml4.association.significance). Off by default deliberately: enabling it changes what the headline numbers mean, because support / confidence / lift are then measured over the train baskets rather than all of them. That is the correct trade, but it is the operator's to make, not one to impose silently on every run.
engine MiningEngine hmine hmine is 23-806x faster on the matrices this pipeline builds, and the gap GROWS with basket count (recorded in ml4/docs/reference/association-engines.md — flip only with a rerun). The two agree except exactly at a support boundary, where their float comparisons differ: hmine admits on count >= ceil(min_support * n), fpgrowth on the fraction count / n >= min_support. fpgrowth is an expert escape hatch, never a heuristic: nothing selects it automatically.
random_state int 0 Mining itself is deterministic. The seed drives the holdout basket split (parleml_ml4.association.holdout.split_baskets) and evidence-basket sampling (mli layer) — so when holdout_fraction is set this decides which baskets are withheld, and therefore every validation number the run reports. Re-seeding a holdout run moves the headline; it is not inert.