Skip to content

fix(outliers): zero-IQR fallback fences; never clip declared id/target columns - #429

Merged
kevincostner17 merged 3 commits into
mainfrom
fix/outliers-zero-iqr
Sep 15, 2026
Merged

kevincostner17 merged 3 commits into
mainfrom
fix/outliers-zero-iqr

Conversation

@kevincostner17

@kevincostner17 kevincostner17 commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR fixes two cases where outliers= did the wrong thing.

  • Zero IQR. A non-constant numeric column whose quartiles coincide got no Tukey fences. An example is 95 zeros plus spikes of 1000, 5000 and -800. For that column, outlier_method="iqr" and "auto" flagged, clipped, capped and removed nothing, even though "zscore" still flagged the 5000.
  • Declared roles under clip. outliers="clip" rewrote values in columns declared through id_columns and target_column. A legitimate customer_id of 10,000,000 was clipped, and a declared target's maximum fell from 5000 to 138.1. That breaks the documented guarantee that identifiers and targets are never modified.

Root cause

  • Zero IQR. With Q1 == Q3 the fences collapse to a single value. The step treated that like a constant column and skipped it.
  • Declared roles. The explicit outlier step never consulted the declared roles. Only the decision engine did.

Fix

Zero-IQR fallback (steps/outliers.py, engine/outliers.py, FreshCore kernel)

  • Fences. When Q1 == Q3, the IQR is replaced by 1.6907 × MeanAD, where MeanAD is the mean absolute deviation around the median. The constant is sqrt(pi/2) (MeanAD as a σ estimate) times 1.349. The fences are median ± factor × 1.6907 × MeanAD.
  • 5% cap. The fallback applies only when it flags at most 5% of non-missing values. Above that, the off-center values are a second mode rather than rare outliers, and the column is left untouched, as a constant column is.
  • Report text. Action descriptions say "IQR is zero, so fences use 1.69 x mean absolute deviation from the median".
  • auto. Method selection is unchanged.
  • Non-zero IQR. Columns with a non-zero IQR get identical fences and output.
  • Backends. The FreshCore Rust kernel applies the same rule. Polars, DuckDB and Spark still skip zero-IQR columns; this is documented in docs/backends.md, and docs/cleaning-engine.md describes the rule.

Clip respects declared roles (steps/outliers.py)

  • What is skipped. Clipping skips declared id_columns and target_column, matched exactly or by snake-case name after renaming.
  • Report text. When such a column has values outside the fences, the report records "skipped: identifier column" or "skipped: target column".
  • Name-only roles. Roles inferred only from column names are not skipped.
  • Flagging. Flagging still reports these columns and never changes their values.
  • Context-protected columns. Columns protected by context (mutable=False or policy) are skipped under clip too. They used to raise ProtectedColumnError.

Default-output changes

Yes.

  • New outliers on zero-IQR columns. Default fd.clean (outlier_method="auto"/"iqr") can now flag outliers on zero-IQR columns where it found none before. Under the default flag action, this adds <col>_outlier columns and outlier actions or counts to the report.
    • The golden adult_income.balanced report is refreshed and gains one flagged column.
    • Wide sparse datasets such as spambase gain up to 20 flag columns.
  • clip with declared roles. Declared id_columns and target_column are no longer modified, and the report gains "skipped: …" entries.
  • clip with context-protected columns. These columns are skipped instead of raising.

Tests

  • tests/test_outliers.py:
    • the zero-IQR spike repro for iqr and auto, under flag, clip, cap and remove
    • the 5% second-mode cap leaves the column untouched
    • constant columns are still skipped
    • non-zero-IQR columns are unchanged
    • declared id and target columns are not clipped, including after renaming, and the "skipped" messages are recorded
    • context-protected columns are skipped under clip
  • tests/test_execution/test_freshcore_native_parity.py:
    • pandas and FreshCore flags match for a zero-IQR column under iqr and zscore
    • the kernel's clip values match pandas
  • Golden fixtures are refreshed: adult_income.balanced.report.json and golden_diff_summary.jsonl.

Verification

  • ruff check . passes.
  • test_freshcore_native_parity.py, test_outliers.py, test_engine_outliers.py and test_golden.py pass:
    • py3.12 / pandas 2.3.3: 74 passed, 1 skipped
    • py3.9 / pandas 1.5.3: 74 passed, 1 skipped
    • a scratch venv with the FreshCore native extension built from this branch: 121 passed, 1 skipped
  • Full not online and not large lanes on the rebased branch: py3.12 / pandas 2.3.3 6396 passed, 14 skipped; py3.9 / pandas 1.5.3 6367 passed, 18 skipped.
  • The rebase onto current main conflicted in test_freshcore_native_parity.py: both sides appended tests there. I resolved it by keeping both, and the tests above were rerun after the rebase.

A non-constant numeric column whose quartiles coincide (for example 95
zeros plus spikes of 1000, 5000 and -800) got no Tukey fences, so
outlier_method "iqr" and "auto" silently flagged, clipped, capped and
removed nothing, while "zscore" still flagged the 5000.

When Q1 == Q3, the IQR is now replaced by its normal-consistent
equivalent from the mean absolute deviation around the median (which
equals the quartiles here): 1.6907 x MeanAD, i.e. sqrt(pi/2) x MeanAD
as the sigma estimate times 1.349. The fallback only applies when it
flags at most 5% of the non-missing values. Above that, the off-center
values are a second mode rather than rare outliers, and the column stays
untouched, as a constant column does. Report descriptions note "IQR is
zero, so fences use 1.69 x mean absolute deviation from the median".

"auto" measures skewness untrimmed for such columns, as before, so method
selection is unchanged. Columns with a non-zero IQR get identical fences
and output. The FreshCore kernel applies the same rule; Polars, DuckDB
and Spark still skip zero-IQR columns (documented).
…allback

capital_loss in the cached adult_income sample is about 95% zeros, so its
IQR is zero and the balanced default used to skip it. With the zero-IQR
fallback the default flags its 100 non-zero values (5.0% of the column,
exactly the fallback's share limit), with the "IQR is zero" note in the
description. cols_after goes 19 -> 20 (capital_loss_outlier), and
outliers_handled goes 594 -> 694. No other action changed. The summary
line was appended by --update-golden.
outliers="clip" rewrote values in columns declared through id_columns
and target_column: a legitimate customer_id of 10,000,000 was clipped,
and a declared target's maximum fell from 5000 to 138.1. That breaks the
documented guarantee that targets and identifiers are never modified.
The explicit step never consulted the declared roles; only the decision
engine did.

Clipping now skips declared id_columns and target_column, matched exactly
or by snake-case name after renaming. When such a column has values
outside the fences, the report records "skipped: identifier column" or
"skipped: target column". Roles inferred only from names are not skipped.
Flagging still reports these columns and never changes their values.
Context-protected (mutable=False / policy) columns are skipped under clip
too, where they used to raise ProtectedColumnError.
@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: e38b2135-8a9b-406d-9bb7-a8d5f30e2f34


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

FreshData benchmark report — performance

  • freshdata: ?
  • python: ?
  • platform: ?
fixture n_rows n_cols p50 s p95 s peak MB repair % false-repair % preserve % trust monotonic export %

Authored-code reduction (Metric 6)

@kevincostner17
kevincostner17 merged commit e88d6d3 into main Sep 15, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant