Skip to content

feature_selection: validate binary outcome in filter_LR / filter_D (#349)#898

Open
jbbqqf wants to merge 1 commit intouber:masterfrom
jbbqqf:feat/349-filter-binary-y-validation
Open

feature_selection: validate binary outcome in filter_LR / filter_D (#349)#898
jbbqqf wants to merge 1 commit intouber:masterfrom
jbbqqf:feat/349-filter-binary-y-validation

Conversation

@jbbqqf
Copy link
Copy Markdown

@jbbqqf jbbqqf commented May 9, 2026

Proposed changes

The likelihood-ratio filter (filter_LR) and bin-based divergence filters
(filter_D with method KL / ED / Chi) only support binary outcomes,
but the public API does not validate this. Passing a multi-class label set
(e.g. {0, 1, 2, 3}) silently produces meaningless feature-importance scores
or surfaces an inscrutable statsmodels error from deep in the call stack.

This PR adds a _check_binary_outcome validator at both filter_LR and
filter_D entry points so the user gets a clear ValueError naming the
offending column, the limitation, and the supported alternative
(filter_F for continuous outcomes). The module docstring is also updated
to document the constraint.

Fixes #349Clarify the limitations (specifically the label) of the current
implementation of filter methods

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • Documentation Update (if none of the other choices apply)

Context

The OP cited causalml/feature_selection/filters.py:210 (the sm.Logit line,
now around L199 on master) and noted:

the filter method will appear to work without error unless a particular node
has no 0,1 labels in it.

The bin-based path is even more silent: _GetNodeSummary (filters.py:302) does
results_series = data.groupby([experiment_group_column, y_name]).size() and
then results[ti].get(1, 0) / results[ti].get(0, 0) — every value other
than 0 or 1 is silently dropped from the per-bin probability calculation.

filter_F uses OLS and is documented as compatible with continuous outcomes,
so it is not validated. A new test guards against accidentally tightening
that.

Changes

  • causalml/feature_selection/filters.py
    • Module docstring rewritten with an explicit note:: block documenting
      the binary-outcome constraint and pointing to filter_F for continuous
      outcomes.
    • New module-level helper _check_binary_outcome(y, y_name) that drops
      NaNs, computes the unique label set, and raises ValueError if any
      value other than {0, 1} is present.
    • filter_LR and filter_D call the validator before any work.
  • tests/test_feature_selection.py
    • Parametrized regression test test_filter_rejects_non_binary_outcome
      covering LR, KL, ED, Chi — each fails on master (no ValueError
      is raised) and passes on this branch.
    • New test_filter_f_accepts_continuous_outcome to lock in that filter_F
      is unaffected.

Reproduce BEFORE/AFTER yourself (copy-paste)

# --- one-time setup ---
git clone https://github.com/uber/causalml.git /tmp/repro-349 && cd /tmp/repro-349
python -m venv .venv && source .venv/bin/activate
pip install -e '.[test]'

# Define the test payload once — the only thing that changes between
# BEFORE and AFTER is the checked-out git ref.
cat > /tmp/repro-349-snippet.py <<'PY'
import numpy as np, pandas as pd
from causalml.feature_selection import FilterSelect
np.random.seed(0)
n = 200
df = pd.DataFrame({
    "x1": np.random.rand(n),
    "x2": np.random.rand(n),
    "treatment_group_key": np.random.choice(["control", "treatment1"], size=n),
    # Non-binary outcome (4 classes including 0 and 1)
    "conversion": np.random.randint(0, 4, size=n),
})
fs = FilterSelect()
try:
    out = fs.get_importance(df, ["x1", "x2"], "conversion", "KL", treatment_group="treatment1")
    print("RESULT:", out.to_dict("records"))
except Exception as e:
    print(f"RAISED {type(e).__name__}: {e}")
PY

# --- BEFORE (origin/master) ---
git checkout origin/master
python /tmp/repro-349-snippet.py
# Expected: silent run; nonsensical scores from values 2/3 ignored, no warning.

# --- AFTER (this PR) ---
git fetch https://github.com/jbbqqf/causalml.git feat/349-filter-binary-y-validation
git checkout FETCH_HEAD
pip install -e '.[test]'
python /tmp/repro-349-snippet.py
# Expected: RAISED ValueError: Filter feature selection only supports binary outcomes ...

What I ran locally

  • pytest tests/test_feature_selection.py -v
    8/8 passed on this branch (was 3/3 before; added 5 parametrized + lock-in tests).
  • pytest tests/test_feature_selection.py::test_filter_rejects_non_binary_outcome -v
    on origin/master4/4 FAIL (no ValueError raised; silent or wrong-error path).
  • black --fast causalml/feature_selection/filters.py tests/test_feature_selection.py → clean.

Edge cases tested

# Scenario Expected Verified by
1 LR filter, multi-class outcome ValueError("...binary...") test_filter_rejects_non_binary_outcome[LR]
2 KL/ED/Chi filter, multi-class outcome ValueError("...binary...") test_filter_rejects_non_binary_outcome[KL/ED/Chi]
3 F filter, continuous outcome normal run, no error test_filter_f_accepts_continuous_outcome
4 LR filter, well-formed binary outcome normal run test_filter_lr (existing, unchanged)
5 KL filter, well-formed binary outcome normal run test_filter_kl (existing, unchanged)

Risk / blast radius

Release note

`FilterSelect.filter_LR` and `FilterSelect.filter_D` now raise a clear
`ValueError` when the outcome column contains values other than `{0, 1}`.
Use `filter_F` for continuous outcomes (Zhao et al. 2020, this library's
binary-only assumption is documented in `feature_selection/filters.py`).

PR drafted with assistance from Claude Code. The change was reviewed manually
against causalml/feature_selection/filters.py (the sm.Logit and
_GetNodeSummary paths cited in the issue) and the existing
tests/test_feature_selection.py patterns. The reproducer block above was
used during development; it is the same one a reviewer can paste verbatim.

…ber#349)

The LR and bin-based divergence (KL/ED/Chi) filters silently mis-handle
non-binary outcomes: ``filter_LR`` propagates a ``statsmodels`` Logit
error from deep in the call stack, and ``filter_D`` produces nonsense
scores because ``_GetNodeSummary`` only counts ``y == 0`` and
``y == 1`` rows. Add an explicit ``_check_binary_outcome`` validator
called from both public entry points, and document the limitation in
the module docstring. ``filter_F`` (OLS) is unaffected — it tolerates
continuous outcomes and a regression test guards against accidental
over-validation.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

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.

Clarify the limitations (specifically the label) of the current implementation of filter methods

2 participants