Feature Screening¶
The Modeling_Tool.Feature subpackage answers three questions about every candidate variable before
it reaches a model:
- Is it stable between the development sample and later data? Use PSI.
- Does it separate good from bad accounts? Use IV, KS, and lift.
- Is it redundant with another variable? Use correlation and VIF.
Screen in that order: drop unstable variables first, then weak ones, then duplicates. feature_screen runs the three
stages in one call. The subpackage also summarizes distributions by group and measures distribution shift.
flowchart LR
A[Raw feature set] --> B[PSI stability]
B --> C[IV / KS information value]
C --> D[Correlation de-redundancy]
D --> E[Final feature set]
| I want to... | Use | Import |
|---|---|---|
| Measure how far each variable drifted between two samples | PSICalculator |
from Modeling_Tool import PSICalculator |
| Compare every month with a benchmark month | calculate_psi_within_dataset |
from Modeling_Tool import calculate_psi_within_dataset |
| Rank variables by IV, KS, and lift | VarExtractionInsights |
from Modeling_Tool import VarExtractionInsights |
| Drop the weaker variable of each highly correlated pair | CorrelationFilter |
from Modeling_Tool import CorrelationFilter |
| Get chi-square p-values, VIF, or a plain correlation filter | FeatureSelectionAnalyzer |
from Modeling_Tool import FeatureSelectionAnalyzer |
| Run PSI, IV, and correlation (plus VIF and size limits) in one call | feature_screen, FeatureScreenConfig |
from Modeling_Tool import feature_screen, FeatureScreenConfig |
| Do the same with sample weights from one tagged frame | weighted_feature_screen |
from Modeling_Tool import weighted_feature_screen |
| Summarize variables by group | proc_means_by_grp |
from Modeling_Tool.Feature import proc_means_by_grp |
| Summarize variables inside MaxCompute | proc_means_odps |
from Modeling_Tool import proc_means_odps |
| Measure how far each group deviates from a benchmark group | DistributionShiftAnalyzer |
from Modeling_Tool import DistributionShiftAnalyzer |
proc_means_by_grp, proc_means, var_corr_filter, calculate_psi, calculate_within_psi,
calculate_multivar_psi_two_sets, get_distribution_shift, and plot_distribution are not exported at the top level.
Import them from Modeling_Tool.Feature.
Use one binner for every step
By default the three screening tools bin a variable independently: PSICalculator and CorrelationFilter use
equal-frequency bins, while VarExtractionInsights uses decision-tree bins (tree_binning=True). Tree bins are
chosen with the target, so they overstate the IV of weak variables.
Fit one WOE_Master or MonotoneWOEBinner on the development sample and pass the same object to all three tools
(binning_engine= for PSICalculator, woe_binner= for the other two). PSI is then computed on the binner's own bins,
the IV equals the IV in the binner's WOE table, and the choice between two correlated variables uses that IV. The
binner must have been fitted on every variable you screen (see the FAQ). For the engine API, see
WOE Binning Engine.
Example data¶
Every snippet on this page runs top to bottom in one Python session. The synthetic sample has one strong predictor
(score_b), a noisier copy of it (score_b2), two weak predictors (utilization, n_overdue), and two pure-noise
variables (age, income). income also drifts upward in the last two months.
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n = 8000
months = [f"2025-{m:02d}" for m in range(1, 10)]
data = pd.DataFrame({
"flow_id": [f"f{i:05d}" for i in range(n)],
"apply_month": rng.choice(months, n),
"age": rng.normal(35, 8, n).clip(18, 70),
"income": rng.lognormal(10, 0.4, n),
"score_b": rng.normal(600, 60, n),
"utilization": rng.uniform(0, 1, n),
"n_overdue": rng.poisson(0.3, n),
"sample_wgt": rng.uniform(0.5, 2.0, n),
})
data["apply_time"] = pd.to_datetime(data["apply_month"] + "-15")
data["score_b2"] = data["score_b"] * 0.98 + rng.normal(0, 20, n) # a noisier copy of score_b
data.loc[data["apply_month"] >= "2025-08", "income"] *= 1.6 # income drifts in the last two months
logit = -2.0 - 0.01 * (data["score_b"] - 600) + 0.3 * data["n_overdue"] - 0.5 * data["utilization"]
data["bad_flag"] = rng.binomial(1, 1 / (1 + np.exp(-logit)))
features = ["age", "income", "score_b", "score_b2", "utilization", "n_overdue"]
# Months 1-7 are the development sample (70% train / 30% test); months 8-9 are out-of-time
is_dev = data["apply_month"] < "2025-08"
is_train = is_dev & (rng.random(n) < 0.7)
data["sample_ind"] = np.where(is_train, "ins", np.where(is_dev, "oos", "oot"))
train_df, test_df, oot_df = (data[data["sample_ind"] == s].copy() for s in ("ins", "oos", "oot"))
Fit the binners on the training sample only. WOE_Master is the quick default; MonotoneWOEBinner forces monotone
bins and suits scorecards.
from Modeling_Tool import WOE_Master, MonotoneWOEBinner
woe = WOE_Master(train_data=train_df, varlist=features, dep="bad_flag")
woe.fit(nbins=10, equal_freq=True)
binner = MonotoneWOEBinner(feature_cols=features, target_col="bad_flag")
binner.fit(train_df, chi2_binning=True, chi2_p=0.95)
1. PSI: Population Stability Index¶
PSI compares the distribution of a variable in two samples, bin by bin:
PSI = sum((actual% - expected%) * ln(actual% / expected%)). The expected sample defines the bins.
from Modeling_Tool import PSICalculator
psi = PSICalculator(buckets=10, equal_freq=True, min_bin_prop=0.05, feature_block_size=64)
psi_table = psi.calculate(train_df, oot_df, features) # bins are built on train_df
print(psi_table.sort_values("psi", ascending=False))
stable_features = psi_table.loc[psi_table["psi"] < 0.1, "var"].tolist()
calculate(expected_df, current_data, varlist, group_by=None, group_name=None, return_details=False, missing_policy=None, psi_missing_bucket_policy=None)
returns one row per variable with the columns var and psi. In this sample only income, which drifts in the
out-of-time months, is unstable.
| PSI range | Meaning |
|---|---|
< 0.1 |
Stable. No action needed |
0.1 to 0.25 |
Slight drift. A review is recommended |
>= 0.25 |
Significant drift. Investigate before using the variable |
Constructor parameters¶
| Parameter | Default | Meaning |
|---|---|---|
buckets |
10 |
Number of bins in the default binning |
equal_freq |
True |
Equal-frequency bins. False gives equal-width bins |
min_bin_prop |
0.05 |
Minimum share of rows per bin. It caps the number of bins at max(5, 1 / min_bin_prop) |
content |
1e-06 |
Floor for a bin share, so the logarithm stays finite |
precision |
5 |
Decimals of the bin edges. With binning_engine, the decimals of the PSI value |
binning_engine |
None |
A fitted WOE_Master, MonotoneWOEBinner, or WOEEngineAdapter. PSI is then computed on that engine's bins, and buckets, equal_freq, min_bin_prop, and missing_policy have no effect |
missing_policy |
'include' |
'include' gives missing values their own bin, so a change in the missing rate raises the PSI. 'drop' ignores missing rows. 'warn_and_drop' drops them and emits a RuntimeWarning with the counts |
psi_missing_bucket_policy |
'smooth_laplace' |
How to treat a bin that exists on one side only (below) |
feature_block_size |
64 |
Variables transformed per block when binning_engine is set. None processes all at once |
A passed binning_engine of any other type raises TypeError. A variable the engine was not fitted on raises KeyError.
Reuse a fitted binner¶
psi_woe = PSICalculator(binning_engine=woe).calculate(train_df, oot_df, features)
psi_mono = PSICalculator(binning_engine=binner).calculate(train_df, oot_df, features)
print(psi_woe)
The numbers differ from the default table because the bins differ. This is the intended behavior: monitoring PSI should use the mapping the model uses.
Categorical variables
The default binning handles numeric variables only, and a string column raises TypeError. For categorical variables,
fit a MonotoneWOEBinner with cate_feats=[...] and pass it as binning_engine.
One-sided bins¶
When a bin holds rows on one side only, for example a category that disappears, the PSI term involves the logarithm of
zero. The psi_missing_bucket_policy argument decides how to handle it:
| Policy | Behavior |
|---|---|
'smooth_laplace' (default) |
Add one to every bin count, then compute shares. Applied only when a one-sided bin exists |
'floor_1e6' |
Clip the shares at content (1e-06). The empty side then counts as 1e-06, so every one-sided bin adds a large term |
'exclude' |
Drop one-sided bins from the sum |
expected = pd.DataFrame({"x": rng.choice([0, 1, 2, 3, 4], 5000, p=[0.3, 0.3, 0.2, 0.18, 0.02])})
actual = pd.DataFrame({"x": rng.choice([0, 1, 2, 3], 5000, p=[0.3, 0.3, 0.2, 0.2])}) # value 4 never occurs
for policy in ["smooth_laplace", "floor_1e6", "exclude"]:
value = PSICalculator(psi_missing_bucket_policy=policy).calculate(expected, actual, ["x"])["psi"].iloc[0]
print(f"{policy:15s} {value:.4f}")
Use floor_1e6 to reproduce reports from before 0.5.1. Any other value raises ValueError.
PSI by group, and bin details¶
Pass group_name to compare every value of a column in current_data with the expected sample. The bins are built once
on expected_df.
by_month = psi.calculate(train_df, oot_df, ["income"], group_name="apply_month")
print(by_month) # columns: apply_month, var, psi
detail = psi.calculate(train_df, oot_df, ["income"], return_details=True)
print(detail["psi"])
print(detail["details"][["bin", "expected_percent", "actual_percent", "psi_component"]])
group_by and return_details
Use group_name for grouping. group_by is a legacy argument and does nothing on its own.
With the default binning, return_details=True returns a dictionary with the keys 'psi' and 'details'. With a
binning_engine it returns a tuple (psi_table, shares), where shares[var] holds the expected_bins and
current_bins share series.
PSI over time against a benchmark month¶
calculate_psi_within_dataset compares every value of a group column with a benchmark value, for several variables at
once.
from Modeling_Tool import calculate_psi_within_dataset
monthly = calculate_psi_within_dataset(
data, grp_name="apply_month", varlist=["income", "score_b"], benchmark="2025-01",
)
print(monthly.pivot(index="apply_month", columns="var", values="psi").round(3))
The result has the columns apply_month, psi, and var, and does not list the benchmark month itself. With
benchmark=None, every group is compared with the whole dataset. The other arguments are equal_freq, buckets,
min_bin_prop, content, precision, missing_policy, and psi_missing_bucket_policy, with the same defaults as above.
calculate_psi, calculate_within_psi, and calculate_multivar_psi_two_sets in Modeling_Tool.Feature are the
single-variable building blocks of the same computation.
2. IV, KS, and Lift: VarExtractionInsights¶
VarExtractionInsights bins each variable, builds a bin table against the target, and reports IV, KS, and lift.
from Modeling_Tool import VarExtractionInsights
insights = VarExtractionInsights(
data=train_df,
dep="bad_flag",
plot_path="iv_plots",
nbins=10,
equal_freq=True,
tree_binning=True,
)
report = insights.get_var_analysis_report(train_df, features)
print(report[["var", "iv", "ks_in_gains", "lift_in_gains", "n_bins"]])
get_var_analysis_report(data, varlist, dep=None, iv_cut=0.01) returns one row per variable. Variables with
iv < iv_cut are left out of the report, so pass iv_cut=0 to keep them all. A variable that cannot be binned is left
out too: it is listed in insights.failed_variables as (name, exception_type) pairs, and one UserWarning summarizes
the failures. A constant column is skipped silently. The default binning handles numeric variables only; for a string
variable, pass a MonotoneWOEBinner fitted with cate_feats=[...] as woe_binner.
| Column | Meaning |
|---|---|
var |
Variable name |
n_all, n |
Number of rows, and the number with a non-missing value |
ks_in_gains |
KS of the variable's bin table |
lift_in_gains |
Largest bin lift (bin bad rate divided by the overall bad rate) |
iv |
Information value, rounded to four decimals. With a woe_binner it follows screen_iv_convention (below) |
iv_raw, iv_sv_pure, n_sv_pure |
With a woe_binner only (0.9.2): the IV with every bin counted, and the IV and rows of the class-pure bins |
n_bump |
Rank-order breaks between bins. With a woe_binner the column holds the bin count instead |
missing_rate |
Share of missing values |
min, mean, max |
Summary statistics of numeric variables. NaN for categorical variables |
n_bins |
Number of bins |
With the default binning the rows are sorted by iv, descending. With a woe_binner they follow varlist. A bool
variable is binned normally, but its n_all, n, missing_rate, min, mean, and max are NaN.
Constructor parameters¶
| Parameter | Default | Meaning |
|---|---|---|
data |
required | Stored on the instance. Every method takes its own data argument |
dep |
required | Target column (0/1) |
plot_path |
required | Output folder of plot_woe. Pass None if you do not plot. get_var_analysis_report writes no files |
nbins |
10 |
Bins per variable |
equal_freq |
True |
Equal-frequency bins. False gives equal-width bins |
min_bin_prop |
0.05 |
Minimum share of rows per bin |
precision |
5 |
Decimals of the bin edges |
chi2_method |
False |
Merge an initial fine binning with a chi-square test |
chi2_p |
0.9 |
Confidence level of the chi-square merge |
init_equi_bins |
5000 |
Number of fine bins before the chi-square merge |
tree_binning |
True |
Decision-tree (supervised) bin edges. See the warning below |
include_missing |
True |
Missing values form their own bin |
seed |
3407 |
Random seed of the tree binning |
missing_rate_ref |
-999999 |
Sentinel for missing values. It fills NaN in the default binning and is treated as missing in missing_rate, min, mean, and max |
spec_values |
None |
Special values that get their own bins |
woe_engine |
'master' |
'monotone' makes the object fit its own MonotoneWOEBinner on first use and keep it in insights.woe_binner. Any other value behaves like 'master' |
woe_binner |
None |
A fitted WOE_Master or MonotoneWOEBinner. Bins and IV then come from it, and nbins, equal_freq, min_bin_prop, precision, chi2_method, chi2_p, init_equi_bins, tree_binning, include_missing, seed, and spec_values are ignored |
woe_engine_params |
None |
Constructor arguments for the self-fitted MonotoneWOEBinner. The key fit_params is passed on to its fit. chi2_method, chi2_p, init_equi_bins, and spec_values also feed that fit |
screen_iv_convention |
'guarded' |
With a WOE engine: 'guarded' leaves class-pure bins out of the IV sum, 'raw' counts every bin (the IV up to 0.9.1). See Class-pure bins in the screening IV |
Tree binning overstates the IV of weak variables
The default tree_binning=True places bin edges with the help of the target on the same sample, so even a
pure-noise variable collects IV. Compare four ways of binning the same sample:
def iv_by_var(**kwargs):
rep = VarExtractionInsights(train_df, "bad_flag", None, **kwargs).get_var_analysis_report(
train_df, features, iv_cut=0,
)
return rep.set_index("var")["iv"]
iv_compare = pd.DataFrame({
"tree_binning=True": iv_by_var(),
"tree_binning=False": iv_by_var(tree_binning=False),
"woe_binner=woe": iv_by_var(woe_binner=woe),
"woe_binner=binner": iv_by_var(woe_binner=binner),
}).round(4)
print(iv_compare)
age and income carry no signal, yet tree bins give them an IV of 0.07 to 0.08. Ten equal-frequency bins
(tree_binning=False, or the WOE_Master) give about 0.03, and the monotone binner, which merges bins that do not
separate good from bad, gives less than 0.01. Fit a binner and pass it as woe_binner before you apply an IV threshold.
With a woe_binner, the reported IV equals the IV of that binner's WOE table:
from Modeling_Tool import as_woe_engine
woe_table_iv = as_woe_engine(woe).get_woe_table().groupby("VAR")["IV"].sum()
print((iv_compare["woe_binner=woe"] - woe_table_iv).abs().max()) # rounding error only
Class-pure bins in the screening IV (0.9.2)¶
With a WOE engine the screening scores each bin with woe = ln((bad share + 1e-6) / (good share + 1e-6)) and sums
(bad share - good share) * woe. A class-pure bin (no bad or no good row, usually a special-value bin) gets a WOE
that the 1e-6 alone sets, and up to 0.9.1 it inflated the IV. Since 0.9.2 screen_iv_convention='guarded' (the
default of VarExtractionInsights, CorrelationFilter, FeatureScreenConfig and both pipelines) leaves such bins out
of the IV sum. Their rows stay in the bad and good totals, so every other bin keeps its WOE and IV. A special-value
bin that the binner smoothed (sv_woe_smoothing='laplace') is not left out; it counts with its finite WOE.
screen_iv_convention='raw' gives the 0.9.1 numbers back.
The report then carries four IV columns:
| Column | Meaning |
|---|---|
iv |
The IV under screen_iv_convention: the value that iv_cut, the IV threshold, the correlation stage and the ranking use |
iv_raw |
The IV with every bin counted (0.9.1) |
iv_sv_pure |
The IV contribution of the class-pure bins that the convention leaves out (special-value or ordinary) |
n_sv_pure |
The rows in those bins |
insights.iv_sv_pure_bins lists the class-pure special-value bins of the last report (bins that the binner flags as
special, or whose rows are all missing): var, special_value, bin_label, n, bad, good, bad_rate, woe (the
WOE that the binning gives the bin), iv (its contribution to iv_raw) and low_count (n < 30). Hand it to the
strategy team as candidate rules; nothing in SMF acts on it.
rng_sv = np.random.default_rng(0)
x = rng_sv.normal(size=5000)
y = (rng_sv.random(5000) < 1 / (1 + np.exp(-(-2 + 1.2 * x)))).astype(int)
x[:150], y[:150] = -999.0, 0 # a special value whose 150 rows are all good
sv_demo = pd.DataFrame({"x": x, "y": y})
sv_binner = MonotoneWOEBinner(feature_cols=["x"], target_col="y", special_values=[-999.0]).fit(sv_demo)
for convention in ("guarded", "raw"):
sv_insights = VarExtractionInsights(sv_demo, "y", None, woe_binner=sv_binner, screen_iv_convention=convention)
sv_report = sv_insights.get_var_analysis_report(sv_demo, ["x"], iv_cut=0)
print(convention, sv_report[["var", "iv", "iv_raw", "iv_sv_pure", "n_sv_pure"]].to_dict("records"))
print(sv_insights.iv_sv_pure_bins) # [sv=-999.0]: n=150, bad=0, good=150, WOE -10.50, IV 0.38
The guarded IV is 1.36 against 1.74: the [sv=-999.0] bin (WOE -10.50, IV 0.38) is left out, and the 15 ordinary bins
keep their WOE and IV. Purity is judged on the quantities the IV uses: the weighted bad and good sums on a weighted run,
the counts otherwise, so a weighted screening with weights of 1 flags the same bins and gives the same IV.
Because the guarded IV leaves class-pure bins out, a near-perfect separator can look ordinary in iv (on the leak frame
of the tests, 1.8 against an iv_raw of 13.8). Since 0.9.3 feature_screen therefore warns about, and flags in
iv_table["iv_gap_flag"], every variable with iv_raw / iv >= 2 or iv_raw - iv >= 0.5, and its leakage limit
(iv_upper_threshold, 2.0 by default) never tests the guarded iv. Every variable the limit drops gets its own
UserWarning ("possible target leakage; check the field's time point (PIT) and source before training"), next to the gap
warning when both apply, and stays dropped: no empty-stage fallback brings it back (see on_empty_stage). The -999
feature above is neither flagged (ratio 1.28, difference 0.38) nor dropped (1.74 < 2.0), and gets neither warning.
The WOE of a class-pure bin has no statistical meaning
screen_iv_convention changes the screening only: apply_woe still gives the [sv=-999.0] rows a WOE of -10.50.
That WOE is set by eps=1e-6 (it grows with the size of the bin, not with its risk). When you build a new model,
resolve every bin flagged sv_class_pure (see the WOE guide) before training: a separate rule for the
special value, sv_small_policy='neutral' (WOE 0) or sv_small_policy='merge_missing' (the WOE of [Missing]).
Plot WOE charts¶
insights.plot_woe(train_df, ["score_b", "utilization"]) # writes iv_plots/var_analysis_plot/<var>.png
plot_woe(data, varlist, plot_group=None, plot_dirname='var_analysis_plot', plot_path=None) writes one bivariate chart per
variable to <plot_path>/<plot_dirname>/, creating the folders; plot_path defaults to the constructor's. With
plot_group, a second chart per variable, split by that column, is written as <var>_<plot_group>.png. When the object
holds a MonotoneWOEBinner, it plots every variable the binner was fitted on and ignores varlist.
IV rules of thumb¶
| IV | Predictive power |
|---|---|
< 0.02 |
None. Drop the variable |
0.02 to 0.1 |
Weak |
0.1 to 0.3 |
Medium |
0.3 to 0.5 |
Strong |
>= 0.5 |
Suspiciously strong. Check for overfitting or information leakage |
3. Correlation and Collinearity¶
CorrelationFilter¶
CorrelationFilter finds pairs whose absolute correlation exceeds corr_cutpoint and, from each group of correlated
variables, keeps the one with the highest IV (or KS). It repeats until no variable is removed or max_iterations is reached.
from Modeling_Tool import CorrelationFilter
corr_filter = CorrelationFilter(data=train_df, dep="bad_flag", corr_cutpoint=0.7, woe_binner=woe)
keep_vars = corr_filter.remove_highly_correlated(features)
print("kept: ", keep_vars)
print("removed:", corr_filter.filtered_varlist)
remove_highly_correlated(varlist, max_iterations=10) returns the kept variables, with the winner of each group first.
filtered_varlist lists the removed variables, and correlated_dict maps each group's anchor variable to the
correlated pairs and the metric table behind the decision. Use var_corr_filter to list the pairs without removing anything:
from Modeling_Tool.Feature import var_corr_filter
print(var_corr_filter(train_df, features, corr_cutpoint=0.7)) # columns: VAR1, VAR2, CORR
| Parameter | Default | Meaning |
|---|---|---|
data, dep |
required | Frame and target column |
corr_cutpoint |
0.8 |
A pair is correlated when abs(corr) > corr_cutpoint |
method |
'pearson' |
'pearson', 'spearman', or 'kendall', passed to DataFrame.corr |
base_metric |
'iv' |
Metric that decides which variable survives: 'iv' or 'ks' |
woe_binner |
None |
Fitted WOE_Master or MonotoneWOEBinner that supplies the IV and KS |
woe_engine |
'master' |
'monotone' without a woe_binner fits a MonotoneWOEBinner on data. Any other value behaves like 'master' |
woe_engine_params |
None |
Constructor arguments for that self-fitted binner (fit_params is passed to fit) |
tree_binning, chi2_method, chi2_p, init_equi_bins, seed, missing_rate_ref |
False, False, 0.999, 1000, 42, -9999999 |
Binning controls of the default IV and KS computation. Ignored when a woe_binner is given |
spec_values |
[] |
Special values (for example -1 or 999 for "no record") that get rows or bins of their own in the IV and KS calculation, instead of being mixed into the numeric bins. It works with the default computation and with an engine alike, and it can change which variable of a correlated group is kept. The correlation itself is still computed on the raw values |
screen_iv_convention |
'guarded' |
With an engine, the IV that decides the winner leaves class-pure bins out of the sum; 'raw' counts every bin (0.9.1). See Class-pure bins in the screening IV |
Raw values decide the correlation, the binner decides the winner
Correlation is computed on the raw values of numeric variables, so pass raw data together with woe_binner. Non-numeric
variables cannot be correlated on raw values: without a woe_binner they are skipped with a warning and kept; with
one they are WOE-encoded first. Defaults differ from VarExtractionInsights: tree_binning=False here.
VIF and chi-square: FeatureSelectionAnalyzer¶
FeatureSelectionAnalyzer collects the statistical helpers: chi-square p-values, variance inflation factors (VIF), and a
plain correlation filter. compute_vif needs the optional statsmodels dependency:
pip install "SuperModelingFactory[stats]".
from Modeling_Tool import FeatureSelectionAnalyzer
woe_features = [f"{f}_woe" for f in features]
train_woe = woe.transform(train_df)
analyzer = FeatureSelectionAnalyzer(significance_level=0.05)
chi2_table = analyzer.chi2_selection(train_woe, woe_features, "bad_flag")
vif_table = analyzer.compute_vif(train_woe[woe_features])
kept = analyzer.correlation_filter(train_woe[woe_features], threshold=0.8)
print(chi2_table) # feature, chi2, p_value, selected
print(vif_table) # feature, VIF
print(kept)
| Method | Returns |
|---|---|
chi2_selection(data, feature_cols, target_col, nan_handling='fillna_median', nan_warn_threshold=0.05) |
DataFrame feature, chi2, p_value, selected, sorted by chi2. Features are min-max scaled first, and selected means p_value < significance_level. The result is also stored in chi2_results_, and the selected names in selected_features_ |
compute_vif(data, nan_handling='fillna_median', nan_warn_threshold=0.05, sample_weight=None) |
DataFrame feature, VIF, sorted descending. A perfectly collinear variable has inf |
correlation_filter(data, threshold=0.8) |
List of kept columns. Within each correlated pair the later column is dropped, with no IV comparison |
nan_handling is 'fillna_median', 'fillna_mean', 'fillna_0', 'drop_rows', or 'raise'; a NaN share above
nan_warn_threshold triggers a warning. As a rule of thumb, a VIF above 10 signals serious collinearity.
CorrelationFilter.calculate_vif(df) is a static helper that returns the VIF as a table with the columns index and VIF,
without NaN handling or weights.
Weighted and unweighted VIF are computed differently
With constant or no weights, compute_vif calls statsmodels.stats.outliers_influence.variance_inflation_factor,
whose behavior depends on the statsmodels version (0.15 standardizes the columns first). With non-constant
sample_weight it fits a weighted least-squares regression without an intercept. On WOE-encoded variables the two
agree within about 15%. On raw variables with a large mean, such as age or a score, the weighted VIF can be orders of
magnitude higher. Compute VIF on WOE-encoded variables when you use weights.
4. Unified Feature Screening: feature_screen¶
feature_screen chains the stages on pre-split INS, OOS, and OOT frames and returns the surviving variables with an
audit trail: missing rate, PSI, IV, correlation, then the post-selection gates (section 7). A stage that is not
configured is skipped.
from Modeling_Tool import FeatureScreenConfig, feature_screen
splits = {"ins": train_df, "oos": test_df, "oot": oot_df} # all three keys are required
config = FeatureScreenConfig(
psi_compare_splits=["oos", "oot"],
psi_use_woe_bins=True,
iv_use_woe_bins=True,
corr_use_woe_bins=True,
)
result = feature_screen(splits, features, "bad_flag", config=config, prefit_woe_engine=binner)
print(result.selected_features)
print(result.summary)
feature_screen(splits, feature_cols, target_col, *, weight_col=None, config=None, prefit_woe_engine=None, selection_evidence=None):
splitsneeds the keysins,oos, andoot. Pass an empty frame (df.iloc[0:0]) for a split you do not have; a missing key raisesKeyError. Bins and IV always come fromins.configis aFeatureScreenConfig. Without it, every default applies.prefit_woe_engineis a fittedWOE_Master,MonotoneWOEBinner, orWOEEngineAdapter. On an unweighted run, each stage uses it only when its*_use_woe_binsflag is set; without any flag it is merely attached to the result. A weighted run with an engine always bins PSI and IV with it.weight_colswitches to the weighted path (section 5).selection_evidencecarries the evidence for group-stability and multi-target gates. OnlyFeatureValidationPipelinebuilds it.
Use feature_screen_from_dataframe(data, feature_cols, target_col, split_col, *, weight_col=None, config=None, prefit_woe_engine=None)
when one frame holds a split column with the values ins, oos, and oot. It drops the target, split, and weight columns from
feature_cols.
Stages and thresholds¶
| Stage | Keeps a variable when | Main settings |
|---|---|---|
missing_rate |
missing_rate <= missing_rate_threshold (off when None) |
missing_rate_threshold, missing_rate_ref |
psi |
max(PSI of the compared splits) < psi_threshold |
psi_threshold, psi_compare_splits, psi_buckets, psi_use_woe_bins |
iv |
iv_threshold <= IV, and the tested IV <= iv_upper_threshold (2.0 by default since 0.9.3; None switches it off) |
iv_threshold, iv_upper_threshold, iv_bins, iv_use_woe_bins |
corr |
It wins its correlated group, with abs(corr) > corr_threshold |
corr_threshold, corr_max_iterations, corr_use_woe_bins |
FeatureScreenConfig¶
| Field | Default | Meaning |
|---|---|---|
psi_enabled |
True |
Run the PSI stage |
psi_threshold |
0.2 |
Drop a variable when its largest PSI is not below this value |
psi_compare_splits |
['oos'] |
Splits compared with ins: 'oos', 'oot', or both. Case and spaces are ignored and a bare string is one split; any other name raises ValueError. A listed split without rows gives a UserWarning and is skipped |
psi_buckets |
10 |
Bins of the default PSI binning |
psi_use_woe_bins |
False |
Compute PSI on the screening binner's bins |
iv_enabled |
True |
Run the IV stage |
iv_threshold |
0.02 |
Drop a variable with a lower IV |
iv_upper_threshold |
2.0 |
Drop a variable whose IV is above it: suspected leakage (gate G02). On by default since 0.9.3 (None before); None switches it off. On WOE bins under screen_iv_convention='guarded' it tests the larger of iv_raw and the zero-cell-floored IV, never the guarded iv. See section 7 |
iv_bins, iv_min_bin_prop, iv_equal_freq |
10, 0.05, True |
Binning of the default IV. iv_equal_freq must stay True: the IV binning cannot switch equal-frequency bins off, so False raises ValueError. A weighted run without WOE bins uses weighted equal-frequency bins and reads min_bin_prop instead of iv_min_bin_prop |
iv_use_woe_bins |
False |
Compute IV on the screening binner's bins |
screen_iv_convention |
'guarded' |
IV on WOE bins (iv_use_woe_bins, or a weighted run with an engine): 'guarded' leaves class-pure bins out of the sum, 'raw' reproduces 0.9.1. The IV threshold, the correlation stage and the ranking of the gates use that IV; the leakage gate does not (see iv_upper_threshold) |
iv_gap_ratio_warn, iv_gap_abs_warn |
2.0, 0.5 |
Leak warning (0.9.3): a variable with iv > 0 and iv_raw / iv >= iv_gap_ratio_warn, or with iv_raw - iv >= iv_gap_abs_warn, gets iv_gap_flag=True in iv_table and a UserWarning ("possible leakage or unresolved class-pure bins; review before training"). It never drops a variable. An iv at or below 1e-12 counts as zero (difference rule only). inf switches a rule off |
corr_enabled |
True |
Run the correlation stage |
corr_threshold |
0.75 |
Absolute correlation above which two variables are redundant |
corr_max_iterations |
10 |
Maximum filtering rounds |
corr_use_woe_bins |
False |
Let the screening binner supply the IV and the encoding of non-numeric variables |
corr_method |
'pearson' |
'pearson', 'spearman', or 'kendall'. Weighted runs with non-constant weights support Pearson only (ValueError otherwise) |
corr_base_metric |
'iv' |
'iv' or 'ks': the higher value wins between two correlated variables |
corr_nan_policy |
'pairwise' |
Weighted runs only: 'pairwise', 'median_fill', or 'raise' on NaN |
corr_block_size |
256 |
Weighted runs only: columns per block of the correlation matrix. Limits memory only |
on_empty_stage |
'keep_all_warn' |
When a stage would drop every variable (each fallback adds a <stage>_fallback row to summary). 'keep_all_warn' (the name is kept; since 0.9.3 it no longer keeps all) never brings back a variable that the IV upper limit dropped (a hard drop, reason iv_above_upper or iv_above_upper_sv_pure). From the IV stage on (the IV stage and the VIF, group-stability and multi-target gates) it keeps the fallback_top_k other variables with the highest screening IV (the iv column, never iv_raw), in that order, warns (IV stage empty; fallback kept z (iv=0.0008); review the data before training) and marks them fallback_kept in dropped_detail; the missing-rate and PSI stages run before any IV exists and keep every variable, as before. When only hard drops are left it raises EmptyStageError (a ValueError) with their names and reasons. 'keep_all' keeps every variable, hard drops included: the behavior up to 0.9.3, which can let a leak back in, so use it only to compare with old results. 'raise' raises EmptyStageError |
fallback_top_k |
5 |
Number of variables the fallback of 'keep_all_warn' keeps (0.9.3). A whole number of at least 1 |
missing_rate_threshold |
None |
Maximum missing rate. None skips the stage |
missing_rate_ref |
-999999 |
Value treated as missing, in addition to NaN |
woe_engine |
'equal_freq' |
Engine fitted when a *_use_woe_bins flag is set and no prefit_woe_engine is given: 'equal_freq' (a WOE_Master) or 'monotone' |
woe_fit_query |
None |
DataFrame.query string that selects the INS rows used to fit the screening binner. A query that fails or selects no row raises ValueError (it used to be ignored, and the binner was fitted on every INS row) |
woe_params, monotone_woe_params |
see below | Arguments of the screening WOE_Master.fit and MonotoneWOEBinner |
categorical_features |
None |
Variables to treat as categorical in a self-fitted monotone binner |
plot_path, plot_outputs |
None, False |
With both set, unweighted runs write the IV-stage charts to <plot_path>/overall/ |
content, precision, min_bin_prop |
1e-06, 5, 0.05 |
Shared numeric settings |
| Gate fields | all off | See section 7 |
The defaults of woe_params are {'nbins': 10, 'equal_freq': True, 'min_bin_prop': 0.05}, and those of monotone_woe_params
are {'n_init_bins': 20, 'min_bin_size': 0.03, 'min_n_bins': 2}.
screen_config_from_mapping(mapping, *, woe_engine=None, woe_fit_query=None, woe_params=None, monotone_woe_params=None, plot_path=None, plot_outputs=False, screen_iv_convention=None)
builds a config from a plain dictionary with the same field names (iv_nbins is accepted as an alias of iv_bins, and
psi_buckets defaults to it; a missing iv_upper_threshold key takes the default 2.0, a key set to None switches the
limit off). Pipelines use it for their feature_selection settings.
from Modeling_Tool import screen_config_from_mapping
config = screen_config_from_mapping(
{"psi_threshold": 0.25, "iv_threshold": 0.03, "corr_threshold": 0.8, "iv_nbins": 8},
woe_engine="monotone",
)
print(config.iv_bins, config.psi_buckets)
The result¶
feature_screen returns a FeatureScreenResult, which is also exported as WeightedScreenResult:
| Attribute | Content |
|---|---|
selected_features |
Names of the surviving variables |
summary |
One row per stage: stage, n_in, n_out, threshold, weight_col. A stage that would have dropped every variable adds a <stage>_fallback row (n_out is the number the fallback kept). Some gates add audit columns such as note |
psi_table |
var, psi_ins_oos, psi_ins_oot, psi_max |
iv_table |
var, iv_weighted, n_bins, missing_rate, iv, iv_raw, iv_sv_pure, n_sv_pure, iv_gap_flag (the leak warning of 0.9.3, set also for a variable that the upper limit drops). iv_weighted and iv hold the IV the stage tests (unweighted runs store the plain IV). On WOE bins iv follows screen_iv_convention, iv_raw counts every bin (0.9.1) and iv_sv_pure / n_sv_pure give the IV and rows of the class-pure bins; without WOE bins iv_raw equals iv and the last two are NaN |
corr_dropped |
var_a, var_b, corr, iv_a, iv_b, kept, dropped. Filled on weighted runs only |
missing_rate_table, missing_rate_dropped |
var, missing_rate, for all variables and for the dropped ones |
dropped_detail |
var, stage, metric, value, threshold, reason. Records drops by the IV upper limit (iv_above_upper, or iv_above_upper_sv_pure when the feature exceeds the limit only because of its class-pure special-value bins; these hard drops are never brought back) and by the gates. A variable that an empty-stage fallback kept has the reason fallback_kept (on its own row of the stage, whose metric, value and threshold stay, or on a new row with metric='iv', its IV and the stage threshold) |
stage_tables |
Evidence frames of the gates, keyed by vif, group_stability, group_stability_violations, multi_target, and truncation; with iv_sv_pure_bins the list of class-pure special-value bins of the IV stage on WOE bins, when there is one |
woe_engine, woe_engine_meta |
The screening binner and its metadata (below) |
Where to find what the correlation stage dropped
On unweighted runs corr_dropped stays empty even when the corr row of summary shows removed variables. Compare
result.selected_features with the variables that entered the stage, or run CorrelationFilter yourself and read
its filtered_varlist.
A screen that fits its own binner (a *_use_woe_bins flag without prefit_woe_engine) keeps it on the result. A
MonotoneWOEBinner (woe_engine='monotone') is attached as result.woe_engine, so you can apply the same bins to the final
WOE encoding. A WOE_Master is not attached, because it holds the whole training frame: only result.woe_engine_meta carries its
woe_table, and a warning says so. fit_screening_woe_engine(train, features, target_col, *, woe_engine='monotone', woe_fit_query=None, woe_params=None, monotone_woe_params=None, categorical_features=None)
fits the same kind of binner by hand:
from Modeling_Tool import fit_screening_woe_engine
screen_binner = fit_screening_woe_engine(train_df, features, "bad_flag", woe_engine="monotone")
print(type(screen_binner).__name__)
5. Weighted Feature Screening¶
Use weights when a row stands for more than one account: balance-weighted samples, sampling corrections, or reject-inference
rows such as those of fuzzy augmentation, where each rejected application appears twice with the
weights 1 - p and p. Unweighted counts distort IV and PSI in those samples.
weighted_feature_screen takes one frame with a split column and runs feature_screen_from_dataframe underneath:
from Modeling_Tool import weighted_feature_screen
weighted = weighted_feature_screen(
data=data, # split_col must hold the values 'ins', 'oos', and 'oot'
feature_cols=features,
target_col="bad_flag",
split_col="sample_ind",
weight_col="sample_wgt", # None runs the legacy unweighted tools
psi_compare_splits=["oos", "oot"], # the function's default; FeatureScreenConfig defaults to ['oos']
)
print(weighted.selected_features)
print(weighted.iv_table) # var, iv_weighted, n_bins, missing_rate, iv, iv_raw, iv_sv_pure, n_sv_pure
print(weighted.psi_table) # var, psi_ins_oos, psi_ins_oot, psi_max
The equivalent call with explicit splits is
feature_screen(splits, features, "bad_flag", weight_col="sample_wgt", config=FeatureScreenConfig(psi_compare_splits=["oos", "oot"])).
The other arguments of weighted_feature_screen (psi_threshold, iv_threshold, corr_threshold, iv_bins,
min_bin_prop, the *_use_woe_bins flags, prefit_woe_engine, corr_nan_policy, on_empty_stage, and so on) have the
meaning and defaults of the FeatureScreenConfig fields of the same name; since 0.9.3 that includes
screen_iv_convention, iv_upper_threshold (2.0), the leak-warning thresholds iv_gap_ratio_warn and
iv_gap_abs_warn, on_empty_stage='keep_all' and fallback_top_k (5). It does not expose missing_rate_threshold,
categorical_features, or the gate fields (vif_*, max_selected_features, and so on): use feature_screen with a
FeatureScreenConfig for those.
What a weighted run does:
- Bins and statistics. Bin edges are weighted quantiles of the INS sample. PSI and IV use the weights, and the correlation is the weighted Pearson correlation, with IV deciding between two correlated variables.
- Numeric variables only, unless a WOE engine is in use: without one, a string column raises
ValueError. Useiv_use_woe_binsand a monotone binner for categorical variables. - Constant weights. When every weight is the same positive number and
corr_nan_policy='pairwise', the correlation stage reuses the unweightedCorrelationFilter, so decisions match an unweighted run.corr_droppedis still filled.corr_nan_policy='raise'still checks for NaN first. - NaN policy.
'pairwise'correlates each pair on the rows where both values exist,'median_fill'fills NaN with the weighted median, and'raise'stops with aValueError. - Non-numeric columns in the WOE correlation. The adapter's WOE suffix is respected. If only some variables can be encoded, the
usable sub-matrix is still computed, the others stay in the full matrix as
NaNrows and columns with a warning, and they are never silently dropped. - VIF gate.
weight_colis passed on toFeatureSelectionAnalyzer.compute_vif(..., sample_weight=...); see the warning in section 3.
6. Distribution Summaries and Shift¶
proc_means_by_grp¶
proc_means_by_grp(data, varlist, groupby=None, spec_missing_value=None, q=None, feature_block_size=128) summarizes
variables, optionally by one or more group columns.
from Modeling_Tool.Feature import proc_means_by_grp
summary = proc_means_by_grp(
data,
features,
groupby=["apply_month", "sample_ind"],
feature_block_size=128,
)
print(summary.head())
| Argument | Meaning |
|---|---|
groupby |
Group column or list of columns. None summarizes the whole frame |
spec_missing_value |
Value (or list of values) to treat as missing, such as -999 |
q |
Quantiles to report. Default [0.05, 0.15, 0.25, 0.5, 0.75, 0.95, 0.99]. Columns are named Q<percent>: Q5, Q15, and Q99.5 for 0.995. pandas always adds the median, so a q without 0.5 yields an extra column named Q50 (before, the median and the fractional quantiles kept their pandas names 50% and 99.5%, and 0.29 produced 29% instead of Q29) |
feature_block_size |
Variables summarized per block. Limits peak memory on wide tables and does not change the result |
For numeric variables the result has the group columns, then attribute, N_ALL, N, MEAN, STD, MIN, one column
per quantile, MAX, and MISSING_RATE. Non-numeric variables get UNIQUE, TOP, and FREQ instead of the numeric
statistics. Rows are sorted by the group columns and attribute.
bool variables
A bool variable is left out of the result, without a warning, when the same call also contains numeric variables.
Cast it with astype("int8") to get numeric statistics, or summarize bool variables in a separate call, where they
are reported with UNIQUE, TOP, and FREQ.
proc_means_odps¶
When the data lives in MaxCompute, proc_means_odps aggregates in feature batches on the ODPS side and downloads only the
final statistics. It needs ODPSRunner credentials in the environment (see ODPS Data Extraction).
# check: skip (needs MaxCompute credentials)
from Modeling_Tool import proc_means_odps
summary = proc_means_odps(
input_table_name="my_project.feature_wide_table",
select_cols=features,
group=["apply_month", "channel"],
batch_size=50,
where_clause="dt >= '2026-01-01'",
)
The result has the same columns as the numeric proc_means_by_grp. batch_size is the number of variables in one aggregation
SQL statement. Source rows are never downloaded. Rows follow the order of select_cols. For quantile modes, special missing
values, and writing results back to ODPS, see
proc_means_odps.
DistributionShiftAnalyzer¶
DistributionShiftAnalyzer(data, grp_name, benchmark_value) takes the outlier_value quantile of each variable in the
benchmark group, then reports, for every group, the share of rows above that threshold. The benchmark group's own share is
about 1 - outlier_value; a clearly higher share elsewhere means the upper tail has grown.
from Modeling_Tool import DistributionShiftAnalyzer
analyzer = DistributionShiftAnalyzer(data, grp_name="apply_month", benchmark_value="2025-01")
shift_table = analyzer.analyze(varlist=features, outlier_value=0.99)
print(shift_table.round(4)) # rows: variables; columns: months
analyze_single_var(var, outlier_value=0.99) returns a dictionary for one variable. The function forms
get_distribution_shift(data, varlist, grp_name, benchmark_value, outlier_value=0.99) and
get_distribution_shift_single_var(data, var, grp_name, benchmark_value, outlier_value=0.99) do the same.
outlier_value must be a whole percentile such as 0.9, 0.95, or 0.99; a value such as 0.995 raises KeyError.
7. Post-Selection Gates¶
After missing rate, PSI, IV, and correlation, feature_screen can apply gates that are all off by default. They run in the
order VIF, group stability, multi-label, truncation and record their evidence in result.stage_tables and
result.dropped_detail.
| Gate | Fields of FeatureScreenConfig |
Effect |
|---|---|---|
| G02 IV upper limit | iv_upper_threshold (2.0 by default since 0.9.3; None switches it off) |
Drops variables whose IV is above the limit (suspected leakage). It never tests the guarded iv: on WOE bins under screen_iv_convention='guarded' it tests the larger of iv_raw and an IV with zero cells floored (metric in dropped_detail names which one), otherwise the floored IV as before. The check uses an IV with zero cells floored, so near-perfect separators are caught. For numeric variables it is computed on equal-frequency bins (weighted on weighted runs) on every path, so it does not depend on how the WOE engine merged its bins (since 0.9.1 the monotone engine merges class-pure bins by default, which can fold a leak into one ordinary bin) nor on screen_iv_convention: a special value that holds only bads can be a label leak. A variable that exceeds the limit only because of its class-pure special-value bins is dropped all the same, with the reason iv_above_upper_sv_pure, so that a human can review it. Each drop warns (possible target leakage; check the field's time point and source) and is a hard drop that no empty-stage fallback brings back |
| G06 VIF | vif_enabled, vif_threshold=10.0, vif_min_features=2, vif_use_woe_bins, vif_tie_break_metric='iv' |
Repeatedly drops the variable with the highest VIF above the threshold, until none remains or vif_min_features is reached. Ties go to the lower IV |
| G03 group stability | monthly_iv_min, monthly_iv_cv_max, direction_consistency_min, min_group_n, insufficient_group_policy='keep_warn' |
Per-group IV floor, cap on the coefficient of variation of the group IVs, and floor on the share of groups with the same direction. Needs group evidence |
| G04 multi-label | target_rules, min_pass_count, per_target_iv_range, direction_reference_target |
A variable must pass its IV range and direction on all, any, or at least min_pass_count labels. Needs per-label evidence |
| G05 truncation | max_selected_features, min_selected_features, ranking_metric='iv', tie_breaker='name' |
Keeps the top N variables by IV, ties broken by ascending name ('name' is the only accepted tie_breaker; any other value raises ValueError). It never backfills: when fewer than min_selected_features survive, it only warns |
G02, G05, and G06 work directly in feature_screen. The demo adds two columns: leaky_score, which is almost a copy of
the label, and blend, a mix of score_b and utilization. Pairwise correlation misses blend (about 0.65 with each
component), but its VIF is high.
def add_demo_columns(frame):
mix = (frame["score_b"] - 600) / 60 + (frame["utilization"] - 0.5) / 0.29
return frame.assign(
leaky_score=frame["bad_flag"] * 3 + rng.normal(size=len(frame)),
blend=mix + rng.normal(0, 0.5, len(frame)),
)
gate_splits = {name: add_demo_columns(frame) for name, frame in splits.items()}
gate_config = FeatureScreenConfig(
psi_compare_splits=["oos", "oot"],
iv_upper_threshold=2.0, # G02
vif_enabled=True, vif_threshold=5.0, # G06
max_selected_features=3, # G05
)
gated = feature_screen(gate_splits, features + ["leaky_score", "blend"], "bad_flag", config=gate_config)
print(gated.selected_features)
print(gated.summary[["stage", "n_in", "n_out"]])
print(gated.dropped_detail) # var, stage, metric, value, threshold, reason
print(gated.stage_tables["vif"]) # one row per variable the VIF gate dropped
leaky_score falls to G02, blend to G06, and the weakest remaining variable to G05. The VIF gate drops the variable with the
highest VIF. For two near-duplicates the VIFs are almost equal, so the choice between them is not meaningful: keep the
correlation stage enabled (it keeps the higher IV) and use VIF for collinearity among three or more variables.
The VIF gate has two bases:
vif_use_woe_bins=False(default) computes VIF on the raw numeric columns. Non-numeric survivors are kept with a warning and noted insummaryandstage_tables["vif"]. With too few numeric columns the gate is skipped andsummaryrecordsskipped_insufficient_numeric. bool and pandas nullable numeric columns are converted to floats inside the VIF matrix only.vif_use_woe_bins=Truecomputes VIF on the WOE-encoded INS matrix, so categorical variables take part. A pre-fitted binner must cover every surviving variable. Whencategorical_featuresis declared, setwoe_engine='monotone'; the'equal_freq'engine rejects that combination before fitting.
G03 and G04 need evidence that only FeatureValidationPipeline (FVP) builds, as lazy closures priced only on the variables
that survived the correlation stage. Setting their thresholds on a plain feature_screen call raises ValueError. In the
pipeline, enable the selection and name the grouping columns:
from Modeling_Tool import FeatureValidationPipeline, FeatureValidationPipelineConfig
data["bad_dpd30"] = data["bad_flag"] * rng.binomial(1, 0.6, len(data)) # a second label for the multi-label gate
fvp_config = FeatureValidationPipelineConfig(
target_cols=["bad_flag", "bad_dpd30"],
new_feature_cols=features,
id_col="flow_id",
apply_time_col="apply_time",
time_dims=["apply_month"],
write_outputs=False, write_excel=False, plot_outputs=False,
selection_enabled=True,
selection_group_dims=["apply_month"], # grouping columns of the G03 evidence
selection_params={
"iv_upper_threshold": 2.0, # G02
"monthly_iv_min": 0.02, # G03: lower limit of the group IV
"monthly_iv_cv_max": 1.0, # G03: upper limit of the coefficient of variation
"direction_consistency_min": 0.9, # G03: share of groups with the same direction
"insufficient_group_policy": "keep_warn", # fewer than 2 qualifying groups: keep_warn, drop, or raise
"target_rules": "all", # G04: all, any, or min_pass_count
"per_target_iv_range": {"bad_flag": (0.02, None)},
"direction_reference_target": "bad_flag",
"max_selected_features": 30, # G05
"vif_enabled": True, "vif_threshold": 10.0, # G06
"vif_use_woe_bins": True,
},
)
fvp_result = FeatureValidationPipeline(fvp_config).run(data)
print(fvp_result.selected_features)
print(fvp_result.selection_summary["screen_summary"][["stage", "n_in", "n_out"]])
Notes on the gates:
- The direction of a variable is the sign of its point-biserial association with the target
(
Modeling_Tool.Feature.Screen_Gates.point_biserial_direction). - The monotone engine that a screen fits by itself is attached as
result.woe_engineand enters the reuse contract ofFeatureScreeningArtifact. AWOE_Masterattaches only its WOE table and a warning, because it holds the training frame and should not be serialized. - For the pipeline settings, see Top-Level Pipelines.
Complete Screening Pipeline¶
from Modeling_Tool import PSICalculator, VarExtractionInsights, CorrelationFilter
# 1) Stability
psi_table = PSICalculator(binning_engine=woe).calculate(train_df, oot_df, features)
selected = psi_table.loc[psi_table["psi"] < 0.1, "var"].tolist()
# 2) Predictive power
iv_report = VarExtractionInsights(train_df, "bad_flag", None, woe_binner=woe).get_var_analysis_report(
train_df, selected,
)
selected = iv_report.loc[iv_report["iv"].between(0.02, 0.5), "var"].tolist()
# 3) Redundancy
selected = CorrelationFilter(train_df, "bad_flag", corr_cutpoint=0.7, woe_binner=woe).remove_highly_correlated(selected)
print(selected)
from Modeling_Tool import PSICalculator, VarExtractionInsights, CorrelationFilter
# 1) Stability
psi_table = PSICalculator(binning_engine=binner).calculate(train_df, oot_df, features)
selected = psi_table.loc[psi_table["psi"] < 0.1, "var"].tolist()
# 2) Predictive power
iv_report = VarExtractionInsights(
train_df, "bad_flag", None, woe_engine="monotone", woe_binner=binner,
).get_var_analysis_report(train_df, selected)
selected = iv_report.loc[iv_report["iv"].between(0.02, 0.5), "var"].tolist()
# 3) Redundancy
selected = CorrelationFilter(
train_df, "bad_flag", corr_cutpoint=0.7, woe_engine="monotone", woe_binner=binner,
).remove_highly_correlated(selected)
print(selected)
Each tab uses one binner at every step, so the PSI, the IV, and the choice between correlated variables agree with the WOE
encoding of the final model. The two binners can disagree about weak variables: with the WOE_Master bins age clears the
0.02 IV cut-off, while the monotone binner rejects it.
FAQ¶
PSI and IV results do not match the WOE plots
The screening step was not given the binner used for the final model. Pass the same WOE_Master or MonotoneWOEBinner:
binning_engine= for PSICalculator, woe_binner= for VarExtractionInsights and CorrelationFilter.
What happens when the binner was not fitted on a variable I screen?
PSICalculator raises KeyError. VarExtractionInsights leaves the variable out of the report, lists it in
failed_variables, and warns once. CorrelationFilter has no IV for it, so the variable loses against every
correlated variable and is removed without a warning (WOE_Master prints was not fitted, skipping lines).
Fit the binner on the full variable list.
After monotone binning, does the correlation filter need raw data or WOE data?
Raw data, together with woe_binner. The correlation is computed on raw numeric values, and the choice between two
correlated variables uses the IV or KS of the same WOE bins.
A variable I expected is missing from the IV report
get_var_analysis_report drops variables with iv < iv_cut, and its default is 0.01. Pass iv_cut=0. If it is still
missing, check insights.failed_variables.
My weighted screen raised could not convert string to float
The weighted path without WOE bins works on numeric columns only. Set iv_use_woe_bins=True (and the other *_use_woe_bins
flags you need) with a monotone binner that declares the categorical variables.
A warning says a stage eliminated all variables, or that a stage is empty
With the default on_empty_stage='keep_all_warn', the missing-rate and PSI stages keep every variable when they would
drop them all, and warn: [feature_screen] stage 'psi' eliminated all 40 variables; keeping all of them
(on_empty_stage='keep_all_warn'). From the IV stage on, the fallback keeps only the fallback_top_k (5) variables
with the highest screening IV, never one that the IV upper limit dropped, and warns:
IV stage empty; fallback kept z (iv=0.0008); review the data before training. When the upper limit dropped all of
them it raises EmptyStageError. The summary frame has a <stage>_fallback row, and dropped_detail marks the
kept variables fallback_kept. Loosen that stage's threshold, check the dropped variables for leakage, or pass
on_empty_stage='raise' to fail with a ValueError instead.
Notes on Specific Versions¶
bool features (0.8.2)¶
bool variables work in every binner and screener since 0.8.2, and the binning, WOE table, IV, and scores equal those of the
same column cast to int8. In 0.8.1 and earlier, any quantile-based binning raised TypeError: numpy boolean subtract on a
bool column; cast it with astype("int8") there. pandas nullable boolean columns are converted to Int8, with missing
values staying missing. The FeatureValidationPipeline distribution summary reports bool as a categorical variable;
proc_means_by_grp has the limitation described in section 6. See the 0.8.2 changelog.
FeatureValidationPipeline audit of categorical and special values (0.7.2 and 0.8.2)¶
FeatureValidationPipeline freezes the categorical coverage and unseen-value statistics right after each split's
transform, so a later transform cannot overwrite them. Read them from
result.woe_artifacts["by_target"][target] under categorical_transform_stats_by_split,
unseen_category_stats_by_split, and unseen_special_stats_by_split (declared special values that never occur in the fit
sample; see the WOE guide). Per-target summaries are in woe_artifacts["categorical_transform_stats_by_target"],
["unseen_category_stats_by_target"], and ["unseen_special_stats_by_target"]. If a batch merge finds conflicting statistics for
the same target, split, and feature, it raises ValueError instead of overwriting them.