|
| 1 | +--- |
| 2 | +title: Choose and audit missing-value imputation |
| 3 | +description: Compare simple and MissForest imputation, configure column rules, protect declared roles, and inspect actual repairs. |
| 4 | +--- |
| 5 | + |
| 6 | +# Choose and audit missing-value imputation |
| 7 | + |
| 8 | +Missing values need a decision: retain the gap, fill it from the observed |
| 9 | +column, or estimate it from other features. An imputed value is an estimate, |
| 10 | +not a recovered observation. Start with the simplest strategy that fits your |
| 11 | +data and inspect its effects before using the result downstream. |
| 12 | + |
| 13 | +## Choose a strategy |
| 14 | + |
| 15 | +| `impute` value | Behavior | Tradeoff | |
| 16 | +| --- | --- | --- | |
| 17 | +| `"mean"` | Fill numeric columns with their observed mean. Non-numeric columns are skipped. | Sensitive to extreme values; repeated fills reduce variation. | |
| 18 | +| `"median"` | Fill numeric columns with their observed median. Non-numeric columns are skipped. | More resistant to extreme values, but still a constant estimate. | |
| 19 | +| `"mode"` | Fill from the most frequent observed value, including text columns. | Can reinforce the majority category; inspect ties and rare categories. | |
| 20 | +| `"auto"` | Use median for numeric columns and mode for other columns. | A simple per-column rule, not model selection. | |
| 21 | +| `"missforest"` | Use iterative random-forest regression/classification with other columns as predictors. | Ineligible data can take an audited simple fallback; model-eligible data requires the ML dependencies. | |
| 22 | + |
| 23 | +Simple imputation uses only the observed values of the column being filled. |
| 24 | +FreshData's MissForest-inspired implementation initializes gaps, fits a model |
| 25 | +for each eligible column, and iterates up to `missforest_max_iter`, stopping |
| 26 | +earlier if it converges. More complexity does not guarantee better estimates. |
| 27 | +Compare choices on representative data using validation suited to your task. |
| 28 | + |
| 29 | +`strategy` controls the broader cleaning engine; `impute` and `impute_strategy` |
| 30 | +are explicit missing-value overrides: |
| 31 | + |
| 32 | +| Combination | What to expect | |
| 33 | +| --- | --- | |
| 34 | +| `strategy="conservative"`, `impute=None` | Representation repairs; no decision-engine imputation. | |
| 35 | +| `strategy="balanced"`, `impute=None` | Accuracy-first decisions based on column roles, missingness, and available evidence. Gaps may remain. | |
| 36 | +| `strategy="aggressive"`, `impute=None` | More active filling and column-drop decisions, with default outlier flagging. Capping requires `outlier_action="cap"`. | |
| 37 | +| Any strategy with an explicit `impute` choice | Apply that override to eligible columns; declared ID/target protection still applies. | |
| 38 | +| Any strategy with `impute_strategy={...}` | Rules for the named columns override the global `impute` choice. | |
| 39 | + |
| 40 | +The examples use `"conservative"` to make explicit imputation easy to inspect. |
| 41 | +Other strategies can make additional decisions; none guarantees that every |
| 42 | +gap will be filled. See the [cleaning engine](../cleaning-engine.md). |
| 43 | + |
| 44 | +## Apply global and per-column rules |
| 45 | + |
| 46 | +Fill age with its median and group with its mode. Deliberately request fills |
| 47 | +for declared ID and target columns to verify that those requests are ignored |
| 48 | +and audited. One age and one group are missing at `row-c`. |
| 49 | + |
| 50 | +```python |
| 51 | +import pandas as pd |
| 52 | +import freshdata as fd |
| 53 | + |
| 54 | +frame = pd.DataFrame( |
| 55 | + { |
| 56 | + "record_key": ["A", None, "C", "D", "E"], |
| 57 | + "age": [20.0, 30.0, None, 40.0, 90.0], |
| 58 | + "group": ["red", "blue", None, "red", "red"], |
| 59 | + "outcome": [1.0, 0.0, 1.0, None, 0.0], |
| 60 | + }, |
| 61 | + index=["row-a", "row-b", "row-c", "row-d", "row-e"], |
| 62 | +) |
| 63 | + |
| 64 | +cleaned, report = fd.clean( |
| 65 | + frame, |
| 66 | + strategy="conservative", |
| 67 | + impute="median", # global numeric rule |
| 68 | + impute_strategy={ |
| 69 | + "group": "mode", # overrides the global rule |
| 70 | + "record_key": "mode", # ignored: declared identifier |
| 71 | + "outcome": "mean", # ignored: declared target |
| 72 | + }, |
| 73 | + id_columns=("record_key",), |
| 74 | + target_column="outcome", |
| 75 | + fix_dtypes=False, |
| 76 | + drop_empty_rows=False, |
| 77 | + drop_empty_columns=False, |
| 78 | + return_report=True, |
| 79 | + verbose=False, |
| 80 | +) |
| 81 | + |
| 82 | +assert cleaned.loc["row-c", "age"] == 35.0 |
| 83 | +assert cleaned.loc["row-c", "group"] == "red" |
| 84 | +pd.testing.assert_series_equal(cleaned["record_key"], frame["record_key"]) |
| 85 | +pd.testing.assert_series_equal(cleaned["outcome"], frame["outcome"]) |
| 86 | +print(cleaned[["age", "group"]]) |
| 87 | +print(report.warnings) |
| 88 | +``` |
| 89 | + |
| 90 | +The warnings identify ignored rules for `record_key` and `outcome`. Declare |
| 91 | +important roles explicitly: explicit simple imputation protects `id_columns` |
| 92 | +and `target_column`, so do not rely on name heuristics to protect arbitrary |
| 93 | +keys or labels. Context-protected columns also remain untouched. All-missing |
| 94 | +columns have no observed fill value, and numeric rules do not apply to text; |
| 95 | +those gaps can remain. |
| 96 | + |
| 97 | +Use the post-normalization names for `impute_strategy`, `id_columns`, and |
| 98 | +`target_column` when the default `column_names=True` renames input headers. |
| 99 | +For `"Customer Age"`, use `"customer_age"`. This is essential for MissForest: |
| 100 | +its role gate matches the normalized column name exactly; declaring a custom |
| 101 | +target under its original spaced name can leave it unprotected. Alternatively, |
| 102 | +use `column_names=False` and declarations matching the unchanged headers. |
| 103 | +Configure these options through `fd.clean` keywords or `fd.CleanConfig`. |
| 104 | + |
| 105 | +## Inspect the audit and the changed cells |
| 106 | + |
| 107 | +Continue with `frame`, `cleaned`, and `report` from the previous example: |
| 108 | + |
| 109 | +```python |
| 110 | +for action in report.actions: |
| 111 | + if action.step == "impute": |
| 112 | + print(action.column, action.count, action.description, action.metadata) |
| 113 | + |
| 114 | +# Actions report a column and affected count, not every cell's location. |
| 115 | +# This example keeps the index, columns, and row order, so the frames align. |
| 116 | +pd.testing.assert_index_equal(cleaned.index, frame.index) |
| 117 | +assert all(column in cleaned.columns for column in frame.columns) |
| 118 | +filled = frame.isna() & cleaned[frame.columns].notna() |
| 119 | +repairs = [ |
| 120 | + { |
| 121 | + "row_position": position, |
| 122 | + "row_label": frame.index[position], |
| 123 | + "column": column, |
| 124 | + "value": cleaned[column].iloc[position], |
| 125 | + } |
| 126 | + for column in frame.columns |
| 127 | + for position in range(len(frame)) |
| 128 | + if filled[column].iloc[position] |
| 129 | +] |
| 130 | +print(repairs) |
| 131 | +assert [(item["row_label"], item["column"]) for item in repairs] == [ |
| 132 | + ("row-c", "age"), |
| 133 | + ("row-c", "group"), |
| 134 | +] |
| 135 | + |
| 136 | +# Persist the structured aggregate audit when you need a portable record. |
| 137 | +audit_json = report.to_json() |
| 138 | +``` |
| 139 | + |
| 140 | +The filled cells are age `35.0` and group `"red"` at `row-c`; ID and target gaps |
| 141 | +remain. Imputation actions report one affected cell per fill and zero-count |
| 142 | +notes for protected columns. `CleanReport` also captures warnings and overall |
| 143 | +before/after missing counts. `columns_imputed` is a decision-engine summary; |
| 144 | +use the actual `impute` actions for explicit overrides. |
| 145 | + |
| 146 | +This comparison identifies missing-to-present repairs. If your plan drops or |
| 147 | +reorders rows or renames columns, align the frames using a stable record |
| 148 | +identity first. It is not a history of every non-missing value change. See |
| 149 | +[audit automated cleaning](audit-automated-data-cleaning.md) and the |
| 150 | +[audit schema](../audit-trail.md) for supported report fields. |
| 151 | + |
| 152 | +## Try model-based imputation |
| 153 | + |
| 154 | +Install the optional ML dependencies: |
| 155 | + |
| 156 | +```bash |
| 157 | +pip install "freshdata-cleaner[ml]" |
| 158 | +``` |
| 159 | + |
| 160 | +This separate example has 80 rows and related numeric/categorical predictors. |
| 161 | +It fills eight missing charges with a small forest and at most two iterations. |
| 162 | +No identifier or target column is included in this model example. |
| 163 | + |
| 164 | +```python |
| 165 | +import pandas as pd |
| 166 | +import freshdata as fd |
| 167 | + |
| 168 | +training = pd.DataFrame( |
| 169 | + { |
| 170 | + "hours": [float(i % 12) for i in range(80)], |
| 171 | + "charges": [10 + 2 * (i % 12) + 0.5 * (i % 5) for i in range(80)], |
| 172 | + "kind": ["standard", "priority"] * 40, |
| 173 | + } |
| 174 | +) |
| 175 | +training.loc[training.index[::11], "charges"] = float("nan") |
| 176 | + |
| 177 | +estimated, model_report = fd.clean( |
| 178 | + training, |
| 179 | + strategy="conservative", |
| 180 | + impute_strategy={"charges": "missforest"}, |
| 181 | + missforest_n_estimators=10, |
| 182 | + missforest_max_iter=2, |
| 183 | + missforest_random_state=42, |
| 184 | + missforest_add_indicators=False, |
| 185 | + fix_dtypes=False, |
| 186 | + return_report=True, |
| 187 | + verbose=False, |
| 188 | +) |
| 189 | + |
| 190 | +assert estimated["charges"].notna().all() |
| 191 | +for action in model_report.actions: |
| 192 | + if action.step == "impute" and action.column == "charges": |
| 193 | + print(action.count, action.metadata) |
| 194 | + assert action.count == 8 |
| 195 | +``` |
| 196 | + |
| 197 | +Inspect `selected_model_type`, `iterations`, `converged`, and `fallback_reason` |
| 198 | +in action metadata. With ML dependencies installed, this example reports |
| 199 | +`selected_model_type="regressor"` and `fallback_reason=None`. Reaching the |
| 200 | +iteration limit is recorded separately from convergence. Numerical estimates |
| 201 | +and model diagnostics can vary with dependency versions. |
| 202 | + |
| 203 | +`missforest_min_rows_for_model` defaults to 50. Smaller inputs take a recorded |
| 204 | +simple-imputation fallback. Use the earlier five-row `frame`: |
| 205 | + |
| 206 | +```python |
| 207 | +small, small_report = fd.clean( |
| 208 | + frame, |
| 209 | + strategy="conservative", |
| 210 | + impute_strategy={"age": "missforest"}, |
| 211 | + id_columns=("record_key",), |
| 212 | + target_column="outcome", |
| 213 | + missforest_add_indicators=False, |
| 214 | + fix_dtypes=False, |
| 215 | + drop_empty_rows=False, |
| 216 | + drop_empty_columns=False, |
| 217 | + return_report=True, |
| 218 | + verbose=False, |
| 219 | +) |
| 220 | + |
| 221 | +assert small.loc["row-c", "age"] == 35.0 |
| 222 | +fallback_actions = [ |
| 223 | + action for action in small_report.actions |
| 224 | + if action.step == "impute" and action.column == "age" |
| 225 | +] |
| 226 | +assert len(fallback_actions) == 1 |
| 227 | +print(fallback_actions[0].metadata["fallback_reason"]) |
| 228 | +assert "fewer rows" in fallback_actions[0].metadata["fallback_reason"] |
| 229 | +``` |
| 230 | + |
| 231 | +Insufficient predictors and ineligible columns can also lead to fallback or |
| 232 | +preservation; inspect the audit rather than assuming a requested model ran. |
| 233 | +If a column is model-eligible but scikit-learn is absent, cleaning raises |
| 234 | +`ImportError` with the ML installation hint. No result or report is returned |
| 235 | +for that failed call; install `freshdata-cleaner[ml]` before retrying. |
| 236 | + |
| 237 | +The following example protects a custom target whose input header is renamed. |
| 238 | +The declaration and imputation rule both use its post-normalization name: |
| 239 | + |
| 240 | +```python |
| 241 | +raw = pd.DataFrame( |
| 242 | + { |
| 243 | + "Observed Hours": [float(i % 12) for i in range(80)], |
| 244 | + "Future Sales": [10 + 2 * (i % 12) + 0.5 * (i % 5) for i in range(80)], |
| 245 | + } |
| 246 | +) |
| 247 | +raw.loc[raw.index[::11], "Future Sales"] = float("nan") |
| 248 | + |
| 249 | +protected, protected_report = fd.clean( |
| 250 | + raw, |
| 251 | + strategy="conservative", |
| 252 | + impute_strategy={"future_sales": "missforest"}, |
| 253 | + target_column="future_sales", # matches after header normalization |
| 254 | + fix_dtypes=False, |
| 255 | + return_report=True, |
| 256 | + verbose=False, |
| 257 | +) |
| 258 | + |
| 259 | +pd.testing.assert_series_equal( |
| 260 | + protected["future_sales"], raw["Future Sales"].rename("future_sales") |
| 261 | +) |
| 262 | +assert protected["future_sales"].isna().sum() == 8 |
| 263 | +``` |
| 264 | + |
| 265 | +With matching declarations, ID and target gaps are preserved. Role protection |
| 266 | +is not a train/test leakage guarantee: cleaning learns from the supplied frame. |
| 267 | +Isolate training data and validate your downstream workflow before estimating |
| 268 | +values in evaluation data. |
0 commit comments