Skip to content

Commit e2bdbac

Browse files
fix(impute): never impute declared id_columns or target_column (#423)
* fix(impute): never impute declared id_columns or target_column Explicit imputation (impute=/impute_strategy=) filled the declared id_columns and target_column, contradicting the documented safe default ('never imputes an identifier, modifies a target column'). Only context-protected columns were skipped. Skip the declared identifier and target columns in the simple mean/median/ mode strategies too, resolving names after renaming like the protected-column guard. Report 'skipped: identifier column' / 'skipped: target column' when a value would have changed, and warn when an impute_strategy entry names one of them. MissForest already gates these roles with its own audited action, so its candidate list is unchanged. Defaults are unaffected (impute is off). * fix(impute): rename loop var to satisfy mypy (str vs str|None)
1 parent bcd8b5d commit e2bdbac

3 files changed

Lines changed: 134 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ adheres to [Semantic Versioning](https://semver.org/).
77
## [Unreleased]
88

99
### Fixed
10+
- Explicit imputation (`impute="mean"`, `"median"`, `"mode"`, `"auto"`,
11+
`"missforest"` or an `impute_strategy` entry) no longer fills the declared
12+
`id_columns` or `target_column`, as documented. Those columns keep their
13+
missing values and the report records `skipped: identifier column` or
14+
`skipped: target column`. An `impute_strategy` entry naming one of them is
15+
ignored with a warning. Declared names resolve after column renaming, and the
16+
columns can still serve as MissForest features for other columns.
1017
- `fd.clean` on a Spark DataFrame no longer raises
1118
`TypeError: cannot materialize source of type DataFrame`. Under the default
1219
`strategy="balanced"` the pandas fallback now materializes a Spark source

‎src/freshdata/steps/missing.py‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
from collections.abc import Iterable
1011
from typing import Any
1112

1213
import pandas as pd
@@ -48,14 +49,51 @@ def _strategy_for_column(col: object, config: CleanConfig) -> str | None:
4849
return config.impute
4950

5051

52+
def _declared_roles(config: CleanConfig, columns: Iterable[object]) -> dict[str, str]:
53+
"""Declared identifier and target columns present in *columns*, by role.
54+
55+
Names resolve like context-protected columns (exact, else snake case), so a
56+
declared ``"Customer ID"`` still matches ``customer_id`` after renaming. A
57+
column declared as both is reported as the target.
58+
"""
59+
from ..guard import _match_columns # noqa: PLC0415 — cycle-safe lazy import
60+
61+
names = [str(c) for c in columns]
62+
roles: dict[str, str] = {}
63+
if config.target_column is not None:
64+
for name in _match_columns([str(config.target_column)], names):
65+
if name in names:
66+
roles[name] = "target"
67+
for name in _match_columns([str(c) for c in config.id_columns], names):
68+
if name in names:
69+
roles.setdefault(name, "identifier")
70+
return roles
71+
72+
5173
def impute_missing(df: pd.DataFrame, config: CleanConfig,
5274
report: CleanReport) -> pd.DataFrame:
53-
"""Fill missing values per column according to explicit impute config."""
75+
"""Fill missing values per column according to explicit impute config.
76+
77+
Context-protected columns and the declared ``id_columns`` and
78+
``target_column`` are never filled, whatever ``impute`` or
79+
``impute_strategy`` says: imputing an identifier corrupts keys and imputing
80+
the target leaks into it. They can still inform ``"missforest"`` as
81+
features for other columns.
82+
"""
5483
if config.impute is None and not config.impute_strategy:
5584
return df
5685
from ..guard import hard_protected_columns # noqa: PLC0415 — cycle-safe lazy import
5786

5887
protected = hard_protected_columns(config, df.columns)
88+
roles = _declared_roles(config, df.columns)
89+
for name, declared_role in roles.items():
90+
if config.impute_strategy and name in config.impute_strategy:
91+
report.add_warning(
92+
f"impute_strategy for '{name}' ignored: it is the declared "
93+
f"{declared_role} column")
94+
# MissForest applies its own role gates (target and identifier columns are
95+
# preserved with an audited fallback action), so declared roles stay in its
96+
# column list and are reported there.
5997
missforest_columns = [
6098
col for col in df.columns
6199
if str(col) not in protected
@@ -78,6 +116,11 @@ def impute_missing(df: pd.DataFrame, config: CleanConfig,
78116
strategy = _strategy_for_column(col, config)
79117
if strategy is None or strategy == "missforest":
80118
continue
119+
role = roles.get(str(col))
120+
if role is not None:
121+
if int(df[col].isna().sum()) and df[col].notna().any():
122+
report.add("impute", f"skipped: {role} column", column=str(col))
123+
continue
81124
s = df[col]
82125
n_missing = int(s.isna().sum())
83126
if n_missing == 0 or s.notna().sum() == 0:

‎tests/test_missing.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import numpy as np
12
import pandas as pd
3+
import pytest
24

35
import freshdata as fd
46

@@ -87,3 +89,84 @@ def test_boolean_mode_imputation_via_auto():
8789
out = fd.clean(df, impute="auto", **KEEP_ROWS)
8890
assert out["b"].isna().sum() == 0
8991
assert bool(out["b"].iloc[2]) is True
92+
93+
94+
def _roles_frame() -> pd.DataFrame:
95+
return pd.DataFrame(
96+
{
97+
"customer_id": [101.0, None, 103.0, 104.0, 105.0],
98+
"churn": [0.0, 1.0, None, 1.0, 0.0],
99+
"spend": [10.0, None, 30.0, 40.0, 50.0],
100+
}
101+
)
102+
103+
104+
@pytest.mark.parametrize("impute", ["mean", "median", "auto", "mode"])
105+
def test_declared_id_and_target_are_never_imputed(impute):
106+
df = _roles_frame()
107+
out, report = fd.clean(
108+
df,
109+
impute=impute,
110+
id_columns=("customer_id",),
111+
target_column="churn",
112+
return_report=True,
113+
**KEEP_ROWS,
114+
)
115+
assert out["customer_id"].isna().sum() == 1
116+
assert out["churn"].isna().sum() == 1
117+
assert out["spend"].isna().sum() == 0
118+
notes = {(a.column, a.description) for a in report if a.step == "impute"}
119+
assert ("customer_id", "skipped: identifier column") in notes
120+
assert ("churn", "skipped: target column") in notes
121+
122+
123+
def test_explicit_impute_strategy_for_target_is_ignored_with_warning():
124+
df = _roles_frame()
125+
out, report = fd.clean(
126+
df,
127+
impute_strategy={"churn": "mean", "spend": "mean"},
128+
target_column="churn",
129+
return_report=True,
130+
**KEEP_ROWS,
131+
)
132+
assert out["churn"].isna().sum() == 1
133+
assert out["spend"].isna().sum() == 0
134+
assert any(
135+
"impute_strategy for 'churn' ignored: it is the declared target column" in w
136+
for w in report.warnings
137+
)
138+
139+
140+
def test_declared_id_matches_renamed_column():
141+
df = pd.DataFrame({"Customer ID": [1.0, None, 3.0, 4.0], "Spend": [1.0, None, 3.0, 4.0]})
142+
out = fd.clean(df, impute="mean", id_columns=("Customer ID",), **KEEP_ROWS)
143+
id_col = next(c for c in out.columns if "customer" in str(c).lower())
144+
spend_col = next(c for c in out.columns if "spend" in str(c).lower())
145+
assert out[id_col].isna().sum() == 1
146+
assert out[spend_col].isna().sum() == 0
147+
148+
149+
def test_no_skip_note_when_declared_column_has_nothing_to_fill():
150+
df = _roles_frame().fillna({"customer_id": 102.0, "churn": 1.0})
151+
_, report = fd.clean(
152+
df,
153+
impute="mean",
154+
id_columns=("customer_id",),
155+
target_column="churn",
156+
return_report=True,
157+
**KEEP_ROWS,
158+
)
159+
assert not [a for a in report if a.step == "impute" and "skipped:" in a.description]
160+
161+
162+
def test_missforest_never_imputes_declared_target():
163+
pytest.importorskip("sklearn")
164+
rng = np.random.default_rng(0)
165+
n = 60
166+
x = rng.normal(size=n)
167+
df = pd.DataFrame({"x": x, "y": x * 2.0 + rng.normal(scale=0.1, size=n)})
168+
df.loc[[3, 7, 11], "y"] = np.nan
169+
df.loc[[5, 9], "x"] = np.nan
170+
out = fd.clean(df, impute="missforest", target_column="y", **KEEP_ROWS)
171+
assert out["y"].isna().sum() == 3
172+
assert out["x"].isna().sum() == 0

0 commit comments

Comments
 (0)