You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When a data scientist uses DataForgeML to impute a dataset, the automated routing engine selects imputation strategies based on statistical signals — missingness severity, mechanism, distribution shape, and dataset size. This works well for the majority of columns, but fails when the user has domain knowledge the engine cannot derive.
The only current override path is mnar_columns, which forces MNAR semantics: a data-derived mean/median fill plus a binary missingness indicator. This is semantically wrong for three documented cases:
A column classified as MCAR Minor might be known to follow a strong pattern predictable from other columns — the user wants Regression rather than Mean, but mnar_columns gives them a scalar fill with an indicator they do not need.
A column with High severity might be known to be truly random (e.g. a sensor dropout with no correlation to data values) — the user wants Median rather than MICE/KNN, but there is no clean override path.
A column where null semantically means zero (e.g. a transaction count column where missing means no transactions occurred) — the user wants Constant(0), but mnar_columns would fill with the observed mean of non-missing rows, which is actively wrong.
There is also no defined insertion point in the routing priority chain for a user override, making future extension of the chain fragile.
Solution
Add two new fields to NumericImputationConfig:
per_column_strategy: dict[str, ImputationStrategy] — an explicit per-column strategy assignment that fires at Priority 1.5 in the routing chain, after DropCandidate (which remains a hard gate) but before MNAR routing, bypassing all routing priorities 2–7.
per_column_constant_fill: dict[str, float] — the companion fill value required when a column is declared Constant in per_column_strategy.
The user declares overrides directly on the config:
ColumnImputationRecord.signals records "per_column_strategy_override: user forced strategy=X" for every overridden column, preserving full audit visibility.
Construction-time validation catches contradictions before any data is touched. Fit-time validation catches size-guard violations before any model is trained.
User Stories
As a data scientist, I want to force Regression imputation on a specific column that the engine routes to Mean, so that I can apply my domain knowledge that the column is strongly predictable from other features.
As a data scientist, I want to force Median imputation on a High-severity column that the engine routes to MICE, so that I can override the model-based strategy when I know the missingness is truly random.
As a data scientist, I want to fill a transaction-count column with Constant(0) when null means no transactions occurred, so that imputed values reflect the correct domain interpretation rather than the column mean.
As a data scientist, I want to force KNN on a column the engine routes to Mean, so that I can leverage neighbour-based imputation when I know similar rows exist in my dataset.
As a data scientist, I want to force MICE on a column the engine routes to Median, so that I can use joint multi-column imputation when I know the missingness is jointly structured.
As a data scientist, I want to force Mode on a Continuous numeric column, so that I can handle a domain-specific column where the mode is the most appropriate fill.
As a data scientist, I want the override to fire after the DropCandidate gate, so that the 50%-missing floor remains a reliable library guarantee and is not accidentally bypassed.
As a data scientist, I want a ValueError at config construction time if I declare a column in both mnar_columns and per_column_strategy, so that contradictory declarations are caught before any data is touched.
As a data scientist, I want a ValueError at config construction time if I declare Constant strategy for a column without providing a fill value in per_column_constant_fill, so that the required domain knowledge is never silently defaulted.
As a data scientist, I want a ValueError at config construction time if I use Passthrough, Indicator, MNAR, or Dropped in per_column_strategy, so that internal-only and semantically-reserved strategies are clearly separated from user-facing ones.
As a data scientist, I want a ValueError at fit time if I force a model-based strategy (KNN, Regression, MICE) but my dataset does not meet the size guard for that strategy, so that I am told exactly what to change rather than silently receiving a Median fill.
As a data scientist, I want ColumnImputationRecord.signals to record "per_column_strategy_override: user forced strategy=X" when an override fires, so that I can audit which columns were routed by user intent rather than the automatic engine.
As a data scientist, I want per_column_strategy to appear in the to_dict / from_dict round-trip of NumericImputationConfig, so that overrides survive serialisation and can be stored alongside a fitted imputer.
As a data scientist, I want per_column_constant_fill to appear in the to_dict / from_dict round-trip of NumericImputationConfig, so that the fill value survives serialisation alongside the strategy declaration.
As a data scientist, I want add_indicator_columns to still apply independently when per_column_strategy also overrides a column, so that indicator and strategy decisions remain orthogonal and I do not need to re-declare the column to get both.
As a data scientist, I want suggest_refit_config (Scope 3) to skip MNAR-declared columns when it populates per_column_strategy, so that the auto-generated refit config never produces a construction-time conflict error.
As a data scientist, I want the ValueError for Dropped in per_column_strategy to name the correct alternative (PipelineConfig.exclude_columns), so that I know where to go when I want to force-remove a column.
As a data scientist, I want the ValueError for MNAR in per_column_strategy to name the correct alternative (mnar_columns), so that I know where to go when I want MNAR semantics.
As a data scientist, I want the ValueError for size-guard violations to name the specific guard, the current dataset values, and the configured threshold, so that I know exactly what to change.
As a data scientist, I want the strategy recorded in ColumnImputationRecord to always match what per_column_strategy declared, so that the audit trail never contradicts my config.
Implementation Decisions
1. New fields on NumericImputationConfig
Two new fields are added:
per_column_strategy: dict[str, ImputationStrategy] — defaults to empty dict. Maps column name → forced strategy.
per_column_constant_fill: dict[str, float] — defaults to empty dict. Maps column name → constant fill value. Required when any column is declared Constant in per_column_strategy.
Both fields participate in to_dict / from_dict serialisation.
2. Construction-time validation (__post_init__)
NumericImputationConfig.__post_init__ enforces four rules, raising ValueError immediately:
Invalid strategy: any column in per_column_strategy with value Passthrough, Indicator, Dropped, or MNAR → ValueError naming the blocked strategy and the correct alternative.
Missing Constant fill: any column with per_column_strategy[col] == Constant that has no entry in per_column_constant_fill → ValueError.
MNAR conflict: ImputationConfig.__post_init__ (the parent config) raises ValueError if any column appears in both mnar_columns and per_column_strategy. This validation lives one level up because it requires visibility of both fields simultaneously.
3. Override priority: Priority 1.5 in _fit_one
per_column_strategy fires at Priority 1.5 — after the DropCandidate check (Priority 1) and before MNAR routing (Priority 2). If the column is in per_column_strategy, the declared strategy is used directly and all routing priorities 2–7 are bypassed. The signal "per_column_strategy_override: user forced strategy=X" is appended to ColumnImputationRecord.signals.
For Constant strategy, the fill value is read from per_column_constant_fill[col].
4. Fit-time size-guard validation
When per_column_strategy forces a model-based strategy (KNN, Regression, MICE), the relevant size guard is checked at the start of NumericImputer.fit() before any model is trained:
KNN: n_rows <= knn_max_rows AND n_features <= knn_max_features, else ValueError.
MICE: same size guards as the routing engine's MICE path.
The ValueError message names the column, the declared strategy, the specific guard that was not met, the current dataset value, and the configured threshold.
ImputationConfig.__post_init__ computes the intersection of mnar_columns and per_column_strategy.keys(). If non-empty, raises ValueError naming the conflicting columns.
When suggest_refit_config (Issue #92) populates per_column_strategy[col] = Median for low-R² columns, it must skip any column already in mnar_columns. MNAR columns do not go through the routing engine; their R² is not a meaningful signal and adding them to per_column_strategy would fail the construction-time conflict check.
7. No routing-engine bypass for DropCandidate
DropCandidate (Priority 1) fires unconditionally before the per_column_strategy check. A column that is >50% missing is dropped regardless of any override declaration. There is no mechanism in this scope to rescue a DropCandidate column; that requires a dedicated escape hatch in a future scope.
8. add_indicator_columns is orthogonal
If a column appears in both per_column_strategy and add_indicator_columns, the indicator is still added. Strategy and indicator decisions are independent.
Testing Decisions
A good test for this feature asserts external behaviour — the strategy recorded in ColumnImputationRecord, the error raised at construction or fit time, the signal text — not internal routing logic. Tests should not inspect which branch of _fit_one was taken; they should observe what came out.
Seam 1: NumericImputationConfig construction — test_imputation_config.py
Pure unit tests with no DataFrames. Tests for:
ValueError when Passthrough, Indicator, Dropped, or MNAR appear in per_column_strategy (one test per blocked strategy)
ValueError when Constant is declared with no companion per_column_constant_fill entry
ValueError from ImputationConfig.__post_init__ when a column appears in both mnar_columns and per_column_strategy
Valid construction succeeds with all allowed strategies
to_dict / from_dict round-trip preserves both new fields
Empty dicts are the correct default
Prior art: the entire existing test_imputation_config.py — all tests are pure config construction and round-trip assertions.
Tests using real small DataFrames (no profiler stub needed for the error path). Tests for:
ValueError at fit time when per_column_strategy = Regression and n_rows < regression_min_rows
ValueError at fit time when per_column_strategy = KNN and n_rows > knn_max_rows
Message names the column, strategy, guard, actual value, and threshold
Prior art: existing orchestrator tests.
Out of Scope
per_column_strategy for non-Numeric semantic types. Categorical and Boolean columns have trivial routing (always Mode); there is no routing tree to override. If a future scope adds complex routing for those types, per-column overrides for them will be designed separately.
Rescuing DropCandidate columns. A column that is >50% missing cannot be kept via per_column_strategy. A dedicated escape hatch (e.g. force_keep_columns) is a future scope.
per_column_strategy for Dropped strategy. Force-dropping a column below the 50% threshold is out of scope. Use PipelineConfig.exclude_columns (Hard Exclusion) for intentional column removal.
Issue Scope 0: Regression Imputer Overhaul — IterativeImputer, NonlinearityTag, dynamic convergence #89 (Scope 0) — Regression Internals: when a user forces Regression via per_column_strategy, the quality of the imputed values depends on Scope 0's IterativeImputer and NonlinearityTag-driven estimator selection. Scope 14 can ship before Scope 0 (the override mechanism is correct regardless), but imputation quality for forced Regression will improve once Scope 0 lands.
Issue Scope 3: Multi-round Iteration and Feedback Loop — ImputationFitDiagnostic and suggest_refit_config #92 (Scope 3) — suggest_refit_config: Scope 3 writes to per_column_strategy and per_column_max_iter. It depends on both fields existing in NumericImputationConfig — introduced by this scope. Scope 3 must also implement the MNAR-skip constraint (Implementation Decision 6 above). Scope 14 should ship before or together with Scope 3.
Issue Scope 8: MNAR Treatment — data-derived fill replaces sentinel constant #97 (Scope 8) — MNAR Data-Derived Fill: the motivating use case for Constant strategy (transaction count = 0) is explicitly differentiated from MNAR semantics. The clean separation between per_column_strategy = Constant (user-specified domain fill) and mnar_columns (data-derived fill + indicator) relies on Scope 8 having completed the MNAR path rewrite. Scope 14 works correctly before Scope 8 ships but the conceptual boundary is sharpest once both are in place.
ADR 0029
Two architectural decisions from this scope are recorded in ADR 0029: (1) per_column_strategy fires after DropCandidate and before MNAR, and (2) size-guard violations raise ValueError at fit time rather than falling back silently to Median. The rationale and rejected alternatives are documented there.
Problem Statement
When a data scientist uses DataForgeML to impute a dataset, the automated routing engine selects imputation strategies based on statistical signals — missingness severity, mechanism, distribution shape, and dataset size. This works well for the majority of columns, but fails when the user has domain knowledge the engine cannot derive.
The only current override path is
mnar_columns, which forces MNAR semantics: a data-derived mean/median fill plus a binary missingness indicator. This is semantically wrong for three documented cases:mnar_columnsgives them a scalar fill with an indicator they do not need.Constant(0), butmnar_columnswould fill with the observed mean of non-missing rows, which is actively wrong.There is also no defined insertion point in the routing priority chain for a user override, making future extension of the chain fragile.
Solution
Add two new fields to
NumericImputationConfig:per_column_strategy: dict[str, ImputationStrategy]— an explicit per-column strategy assignment that fires at Priority 1.5 in the routing chain, afterDropCandidate(which remains a hard gate) but before MNAR routing, bypassing all routing priorities 2–7.per_column_constant_fill: dict[str, float]— the companion fill value required when a column is declaredConstantinper_column_strategy.The user declares overrides directly on the config:
ColumnImputationRecord.signalsrecords"per_column_strategy_override: user forced strategy=X"for every overridden column, preserving full audit visibility.Construction-time validation catches contradictions before any data is touched. Fit-time validation catches size-guard violations before any model is trained.
User Stories
Constant(0)when null means no transactions occurred, so that imputed values reflect the correct domain interpretation rather than the column mean.DropCandidategate, so that the 50%-missing floor remains a reliable library guarantee and is not accidentally bypassed.ValueErrorat config construction time if I declare a column in bothmnar_columnsandper_column_strategy, so that contradictory declarations are caught before any data is touched.ValueErrorat config construction time if I declareConstantstrategy for a column without providing a fill value inper_column_constant_fill, so that the required domain knowledge is never silently defaulted.ValueErrorat config construction time if I usePassthrough,Indicator,MNAR, orDroppedinper_column_strategy, so that internal-only and semantically-reserved strategies are clearly separated from user-facing ones.ValueErrorat fit time if I force a model-based strategy (KNN, Regression, MICE) but my dataset does not meet the size guard for that strategy, so that I am told exactly what to change rather than silently receiving a Median fill.ColumnImputationRecord.signalsto record"per_column_strategy_override: user forced strategy=X"when an override fires, so that I can audit which columns were routed by user intent rather than the automatic engine.per_column_strategyto appear in theto_dict/from_dictround-trip ofNumericImputationConfig, so that overrides survive serialisation and can be stored alongside a fitted imputer.per_column_constant_fillto appear in theto_dict/from_dictround-trip ofNumericImputationConfig, so that the fill value survives serialisation alongside the strategy declaration.add_indicator_columnsto still apply independently whenper_column_strategyalso overrides a column, so that indicator and strategy decisions remain orthogonal and I do not need to re-declare the column to get both.suggest_refit_config(Scope 3) to skip MNAR-declared columns when it populatesper_column_strategy, so that the auto-generated refit config never produces a construction-time conflict error.ValueErrorforDroppedinper_column_strategyto name the correct alternative (PipelineConfig.exclude_columns), so that I know where to go when I want to force-remove a column.ValueErrorforMNARinper_column_strategyto name the correct alternative (mnar_columns), so that I know where to go when I want MNAR semantics.ValueErrorfor size-guard violations to name the specific guard, the current dataset values, and the configured threshold, so that I know exactly what to change.ColumnImputationRecordto always match whatper_column_strategydeclared, so that the audit trail never contradicts my config.Implementation Decisions
1. New fields on
NumericImputationConfigTwo new fields are added:
per_column_strategy: dict[str, ImputationStrategy]— defaults to empty dict. Maps column name → forced strategy.per_column_constant_fill: dict[str, float]— defaults to empty dict. Maps column name → constant fill value. Required when any column is declaredConstantinper_column_strategy.Both fields participate in
to_dict/from_dictserialisation.2. Construction-time validation (
__post_init__)NumericImputationConfig.__post_init__enforces four rules, raisingValueErrorimmediately:per_column_strategywith valuePassthrough,Indicator,Dropped, orMNAR→ValueErrornaming the blocked strategy and the correct alternative.per_column_strategy[col] == Constantthat has no entry inper_column_constant_fill→ValueError.ImputationConfig.__post_init__(the parent config) raisesValueErrorif any column appears in bothmnar_columnsandper_column_strategy. This validation lives one level up because it requires visibility of both fields simultaneously.3. Override priority: Priority 1.5 in
_fit_oneper_column_strategyfires at Priority 1.5 — after theDropCandidatecheck (Priority 1) and before MNAR routing (Priority 2). If the column is inper_column_strategy, the declared strategy is used directly and all routing priorities 2–7 are bypassed. The signal"per_column_strategy_override: user forced strategy=X"is appended toColumnImputationRecord.signals.For
Constantstrategy, the fill value is read fromper_column_constant_fill[col].4. Fit-time size-guard validation
When
per_column_strategyforces a model-based strategy (KNN, Regression, MICE), the relevant size guard is checked at the start ofNumericImputer.fit()before any model is trained:Regression:n_rows >= regression_min_rows, elseValueError.KNN:n_rows <= knn_max_rowsANDn_features <= knn_max_features, elseValueError.MICE: same size guards as the routing engine's MICE path.The
ValueErrormessage names the column, the declared strategy, the specific guard that was not met, the current dataset value, and the configured threshold.5.
ImputationConfigconstruction-time MNAR conflict checkImputationConfig.__post_init__computes the intersection ofmnar_columnsandper_column_strategy.keys(). If non-empty, raisesValueErrornaming the conflicting columns.6.
suggest_refit_configMNAR-skip constraint (Scope 3 dependency)When
suggest_refit_config(Issue #92) populatesper_column_strategy[col] = Medianfor low-R² columns, it must skip any column already inmnar_columns. MNAR columns do not go through the routing engine; their R² is not a meaningful signal and adding them toper_column_strategywould fail the construction-time conflict check.7. No routing-engine bypass for DropCandidate
DropCandidate(Priority 1) fires unconditionally before theper_column_strategycheck. A column that is >50% missing is dropped regardless of any override declaration. There is no mechanism in this scope to rescue a DropCandidate column; that requires a dedicated escape hatch in a future scope.8.
add_indicator_columnsis orthogonalIf a column appears in both
per_column_strategyandadd_indicator_columns, the indicator is still added. Strategy and indicator decisions are independent.Testing Decisions
A good test for this feature asserts external behaviour — the strategy recorded in
ColumnImputationRecord, the error raised at construction or fit time, the signal text — not internal routing logic. Tests should not inspect which branch of_fit_onewas taken; they should observe what came out.Seam 1:
NumericImputationConfigconstruction —test_imputation_config.pyPure unit tests with no DataFrames. Tests for:
ValueErrorwhenPassthrough,Indicator,Dropped, orMNARappear inper_column_strategy(one test per blocked strategy)ValueErrorwhenConstantis declared with no companionper_column_constant_fillentryValueErrorfromImputationConfig.__post_init__when a column appears in bothmnar_columnsandper_column_strategyto_dict/from_dictround-trip preserves both new fieldsPrior art: the entire existing
test_imputation_config.py— all tests are pure config construction and round-trip assertions.Seam 2:
NumericImputer.fit()routing —test_numeric_imputer.pyTests using minimal
StructuralProfileResultstubs (no real profiler). Tests for:per_column_strategy = Regressionproducesstrategy=Regressionin the recordper_column_strategythat is not inmnar_columnsdoes not receive MNAR treatmentDropCandidatestill fires: a column withDropCandidateflag still producesstrategy=Droppedeven when it appears inper_column_strategy"per_column_strategy_override: user forced strategy=X"appears inrecord.signalsper_column_constant_fill: the record'sfill_valuematches the declared constantPrior art: the existing profile-stub pattern in
test_numeric_imputer.py—_make_profileand_numeric_cphelpers.Seam 3:
ImputationOrchestrator.fit()size-guard validation —test_orchestrator.pyTests using real small DataFrames (no profiler stub needed for the error path). Tests for:
ValueErrorat fit time whenper_column_strategy = Regressionandn_rows < regression_min_rowsValueErrorat fit time whenper_column_strategy = KNNandn_rows > knn_max_rowsPrior art: existing orchestrator tests.
Out of Scope
per_column_strategyfor non-Numeric semantic types. Categorical and Boolean columns have trivial routing (always Mode); there is no routing tree to override. If a future scope adds complex routing for those types, per-column overrides for them will be designed separately.DropCandidatecolumns. A column that is >50% missing cannot be kept viaper_column_strategy. A dedicated escape hatch (e.g.force_keep_columns) is a future scope.per_column_strategyforDroppedstrategy. Force-dropping a column below the 50% threshold is out of scope. UsePipelineConfig.exclude_columns(Hard Exclusion) for intentional column removal.per_column_n_neighborsandper_column_max_iter. These parameter overrides are already defined in the CONTEXT.md glossary and are populated bysuggest_refit_config(Issue Scope 3: Multi-round Iteration and Feedback Loop — ImputationFitDiagnostic and suggest_refit_config #92, Scope 3). Their implementation is owned by Scope 3, not this scope.Further Notes
Dependencies on open GitHub Issues
Regressionviaper_column_strategy, the quality of the imputed values depends on Scope 0'sIterativeImputerandNonlinearityTag-driven estimator selection. Scope 14 can ship before Scope 0 (the override mechanism is correct regardless), but imputation quality for forced Regression will improve once Scope 0 lands.KNNoverrides. Scope 14 can ship before Scope 1; adaptive k and NaN-safe scaling land with Scope 1.MICEoverrides. Estimator selection and dynamic convergence land with Scope 2.suggest_refit_config: Scope 3 writes toper_column_strategyandper_column_max_iter. It depends on both fields existing inNumericImputationConfig— introduced by this scope. Scope 3 must also implement the MNAR-skip constraint (Implementation Decision 6 above). Scope 14 should ship before or together with Scope 3.Constantstrategy (transaction count = 0) is explicitly differentiated from MNAR semantics. The clean separation betweenper_column_strategy = Constant(user-specified domain fill) andmnar_columns(data-derived fill + indicator) relies on Scope 8 having completed the MNAR path rewrite. Scope 14 works correctly before Scope 8 ships but the conceptual boundary is sharpest once both are in place.ADR 0029
Two architectural decisions from this scope are recorded in ADR 0029: (1)
per_column_strategyfires afterDropCandidateand before MNAR, and (2) size-guard violations raiseValueErrorat fit time rather than falling back silently to Median. The rationale and rejected alternatives are documented there.