Model Evaluation¶
The Modeling_Tool.Eval subpackage turns scores and labels into the artifacts a model review expects:
KS/AUC summaries across train, test, and out-of-time samples, decile Gains tables, challenger-versus-champion
comparisons, cross-risk matrices, slice-by-slice evaluation, and ROC/KDE/percentile/gain figures.
| I want to... | Use | Import |
|---|---|---|
| Summarize KS / AUC / top-decile bad rate across train, test, OOT | PerformanceEvaluator |
from Modeling_Tool import PerformanceEvaluator |
| Do the same with a one-call function | get_perf_summary |
from Modeling_Tool import get_perf_summary |
| Build a score-band (decile) Gains table | GainsTableCalculator, get_gains_table |
from Modeling_Tool import GainsTableCalculator, get_gains_table |
| Add business columns to a Gains table | calculate(add_func=...), get_gains_table_by_cust_metrics |
from Modeling_Tool import get_gains_table_by_cust_metrics |
| Compare a challenger score with the champion | Model_Evaluation_Tool |
from Modeling_Tool import Model_Evaluation_Tool |
| Cross two scores into a risk matrix | cross_risk |
from Modeling_Tool import cross_risk |
| Evaluate every month, channel, or segment | EvaluationPipeline |
from Modeling_Tool import EvaluationPipeline |
| Draw ROC / KDE / percentile / gain figures | evaluate_performance, comparison_performance |
from Modeling_Tool import evaluate_performance, comparison_performance |
| Find score cut-offs for target lift multiples | calc_lift_apt |
from Modeling_Tool import calc_lift_apt |
| Build curves yourself | calc_roc, calc_pr, calc_equid_dist, calc_equid_pct, calc_fixed_pct |
from Modeling_Tool import calc_roc, calc_pr, ... |
The plot_* helpers, evaluate_distribution, summarize_pr, summarize_pct, tie_score_rate, and score_unique_rate are
not exported at the top level; import them from Modeling_Tool.Eval. summarize_roc lives in
Modeling_Tool.Eval.evaluate_model.
Score direction
Every evaluator assumes that a higher score means a higher chance of the bad outcome, as with a predicted
probability of default. AUC is computed as roc_auc_score(y_true, y_score), and the Gains table's
RANK_ORDER_BUMP flags bins where the bad rate moves against that direction. If your score runs the other way (a
scorecard where more points mean lower risk), evaluate a flipped copy such as df["risk"] = df["points"].max() - df["points"].
Example data¶
Every snippet on this page runs top to bottom in one Python session. The setup below creates a synthetic portfolio, a time-based train / test / out-of-time split, a LightGBM model, and the score columns used later.
import os
import numpy as np
import pandas as pd
from Modeling_Tool import GradientBoostingModel
rng = np.random.default_rng(42)
n = 8000
months = [f"2025-{m:02d}" for m in range(1, 10)]
df = pd.DataFrame({
"flow_id": np.arange(n), # unique application id
"apply_month": rng.choice(months, n),
"city_grade": rng.choice(["A", "B", "C", "D"], 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), # e.g. balance or correction weight
})
logit = -2.2 - 0.02 * (df["score_b"] - 600) + 0.5 * df["n_overdue"] - 0.8 * df["utilization"]
df["bad_flag"] = rng.binomial(1, 1 / (1 + np.exp(-logit)))
df["bad_dpd30"] = df["bad_flag"] * rng.binomial(1, 0.6, n) # a second, stricter label
features = ["age", "income", "score_b", "utilization", "n_overdue"]
train = df[df["apply_month"] <= "2025-06"].copy()
test = df[df["apply_month"] == "2025-07"].copy()
oot = df[df["apply_month"] >= "2025-08"].copy()
gbm = GradientBoostingModel(
"lgb",
params={"n_estimators": 200, "learning_rate": 0.05, "max_depth": 3,
"early_stopping_rounds": 20, "eval_metric": "auc", "verbose": -1},
)
gbm.fit(train[features], train["bad_flag"], test[features], test["bad_flag"])
for frame in (train, test, oot):
frame["prob"] = gbm.predict(frame[features]) # challenger score
frame["score_old"] = 1 / (1 + np.exp((frame["score_b"] - 600) / 60 + 2)) # an older, simpler score
holdout = pd.concat([test, oot], ignore_index=True) # three months of unseen data
os.makedirs("output", exist_ok=True)
Sample-Weighted Evaluation¶
Pass weight_col (a column name) to PerformanceEvaluator, GainsTableCalculator, get_gains_table,
Model_Evaluation_Tool, or cross_risk, or sample_weight (an array) to the low-level functions, and the metrics are
aggregated by weight. Without weights the behavior is the historical, unweighted one.
N and N_RAW¶
Weighted tables report sample size two ways:
| Column | Meaning | Typical use |
|---|---|---|
N |
Sum of weights in the bin or dataset (effective sample size) | Weighted bad rate, Lift, capture rate |
N_RAW |
Number of rows | Auditing, reconciling record counts |
If each row is an account and the weight is the loan balance, N_RAW is the number of accounts and N is the total
balance. The weighted bad rate is sum(y * w) / sum(w), and Lift is the bin's weighted bad rate divided by the overall
weighted bad rate. If all weights are 1, N == N_RAW.
Weighted metric semantics¶
| Metric | Weighted behavior |
|---|---|
AUC |
scikit-learn roc_auc_score(..., sample_weight=...) |
KS |
Maximum gap between the weighted cumulative bad and good shares (abs(TPR - FPR) on the weighted ROC curve) |
avgTrue, avgScore |
Weighted averages |
Gains AVG_BAD, LIFT, WOE, IV |
Computed from the weighted bad and good totals in each bin |
A weighted PerformanceEvaluator.evaluate() returns a narrower table than the unweighted one:
index, dataset, DATASET, AUC, KS, LIFT, IV, N, N_RAW, avgTrue, avgScore, where LIFT is the largest bin lift and IV is the
Gains-table IV. The Top10%_* / Btm10%_*, *_Shift, and other Gains-summary columns exist only in the unweighted table.
Where weights are applied¶
| API | Weights are applied | Weights are silently not applied |
|---|---|---|
PerformanceEvaluator.evaluate |
weight_col on the constructor, on evaluate, or per dataset on add_dataset |
When oot_grp_name or benchmark_dataset is passed |
get_perf_summary |
weight_col, when oot_grp_name is None. Requires scr_name: with model and feature_cols the weighted call raises KeyError, so add a score column first |
When oot_grp_name is passed |
get_gains_table, GainsTableCalculator |
weight_col |
When grp_name is passed. On the weighted path the bins are always equal-weight (weighted_binning does not change them in 0.8.2); add_func and withSummary apply there too (they were ignored up to 0.9.0) |
Model_Evaluation_Tool |
weight_col, in model_perf_compare and get_gains_summary |
With grp_name; get_cross_risk_summary, cross_perf_eval, and multi_dim_eval are unweighted |
cross_risk |
weight_col or sample_weight |
|
evaluate_performance |
The 'sample_weight' key of each dataset (or the function-level sample_weight) |
|
calc_roc, calc_pr, calc_equid_dist, calc_equid_pct, calc_fixed_pct, calc_lift_apt |
sample_weight |
|
get_gains_table_by_cust_metrics, comparison_performance |
Not supported (no weight argument) |
Use the same weights in training and evaluation, otherwise the metrics describe a different population than the one the
model was fitted on. When weights are applied to figures, the panel titles are marked Weighted.
1. Multi-dataset summary: PerformanceEvaluator¶
Register each dataset once, then call evaluate(). The model must expose predict_proba (a GradientBoostingModel
or any scikit-learn-style classifier); alternatively pass scr_name to evaluate an existing score column.
from Modeling_Tool import PerformanceEvaluator
evaluator = PerformanceEvaluator(tgt_name="bad_flag", model=gbm, feature_cols=features)
perf = (
evaluator.add_dataset("train", train)
.add_dataset("test", test)
.add_dataset("oot", oot)
.evaluate(display=False) # display=True (the default) renders the table with IPython
)
print(perf[["index", "N", "KS", "AUC", "Top10%_TargetRate", "Top10%_Lift"]])
display
evaluate() calls IPython.display.display when display=True, which is the default. Outside a notebook (or when
IPython is not installed) pass display=False. The table is returned either way.
Output columns (unweighted)¶
| Column | Meaning |
|---|---|
index |
The dataset name given to add_dataset |
N, avgTrue, avgScore |
Row count and overall bad rate of all rows, mean score of the ranked rows. With spec_values, N_SPECIAL and N_SPECIAL_RAW count the sentinel rows (as on the weighted path) |
KS, AUC |
Maximum TPR − FPR, and the area under the ROC curve |
Btm10%_TargetRate, Top10%_TargetRate |
Bad rate in the lowest- and highest-scored band. The percentage is 100 / pct_bins: pct_bins=5 gives Btm20%_* and Top20%_* |
Btm10%_Lift, Top10%_Lift |
Band bad rate divided by the bad rate of the ranked rows (avgTrue when there is no spec_values) |
AUC_Shift, KS_Shift |
Previous row's metric divided by this row's, minus 1: a positive value means this dataset is weaker than the one above it |
N_BUMP, MIN_RISK_DEP, MAX_RISK_DEP, KS_IN_GAINS, LIFT_IN_GAINS, IV, N_BINS |
Summary of the dataset's Gains table: rank-order breaks, smallest and largest bin-to-bin bad-rate change, KS, largest lift, total IV, and number of bins |
Parameters¶
| Parameter | Default | Meaning |
|---|---|---|
tgt_name |
required | Target column, or a list/tuple of target columns (see several labels) |
scr_name |
None |
Score column to evaluate. Use it instead of model and feature_cols |
model, feature_cols |
None |
A fitted classifier and the columns passed to its predict_proba |
dist_bins |
20 |
Equal-width score bins in the distribution panels |
pct_bins |
10 |
Equal-frequency bins for the percentile/gain panels, Gains tables, and Top/Btm bands |
weight_col |
None |
Default weight column; add_dataset can override it per dataset |
spec_values |
None |
Sentinel scores (for example -1) excluded from the ranking metrics (KS, AUC, the Top/Btm bands and lifts, avgScore). N and avgTrue still count every row and N_SPECIAL / N_SPECIAL_RAW report the sentinel part, weighted or not |
ascending |
None |
None keeps each panel's historical direction; a bool applies one direction to the Gains tables and figures |
precision, min_bin_prop, include_missing, equal_freq, chi2_method, chi2_p, init_equi_bins, tree_binning, random_state |
5, 0.05, False, True, False, 0.9, 1000, False, 42 |
Binning controls forwarded to the Gains table. min_bin_prop is the minimum share of a Gains bin on both paths: the table uses pct_bins bins capped at 1 / min_bin_prop |
add_dataset(name, data, weight_col=None, overwrite=False) returns the evaluator, so calls chain. Registering an existing
name raises KeyError unless overwrite=True, so build a new evaluator when re-running a notebook cell.
evaluate(oot_grp_name=None, min_data_size=100, grp_colname=None, fig_save_path=None, rpt_save_path=None, to_show=False, display=True, gains_table=False, benchmark_dataset=None, weight_col=None):
| Argument | Meaning |
|---|---|
oot_grp_name |
Evaluate each dataset separately for every value of this column; groups smaller than min_data_size are skipped. The result gains a column named grp_colname (default: the same name) |
fig_save_path |
File path for the figure, for example output/perf.png; the folder must exist |
rpt_save_path |
CSV path for the summary table |
to_show |
Display the figures (notebooks). Default False |
gains_table |
Build the percentile and gain panels from the Gains-table binning |
benchmark_dataset |
Name of a registered dataset (or a DataFrame) whose score bins are reused for every dataset, so bins are comparable |
weight_col |
Overrides the constructor's weight column for this call |
Evaluate by group¶
by_month = (
PerformanceEvaluator(tgt_name="bad_flag", model=gbm, feature_cols=features)
.add_dataset("oot", oot)
.evaluate(oot_grp_name="apply_month", min_data_size=100, display=False)
)
print(by_month[["index", "apply_month", "N", "KS", "AUC"]])
Fixed bin edges from a benchmark dataset¶
fixed = (
PerformanceEvaluator(tgt_name="bad_flag", model=gbm, feature_cols=features)
.add_dataset("train", train)
.add_dataset("test", test)
.add_dataset("oot", oot)
.evaluate(benchmark_dataset="train", display=False) # train's score bins for every dataset
)
print(fixed[["index", "N", "KS", "AUC", "IV", "N_BINS"]])
Several target labels¶
tgt_name accepts a list or tuple. Each label is evaluated separately and the results are stacked, with a leading
tgt_name column. Each label gets its own figure, and fig_save_path receives the label as a suffix
(output/perf.png becomes output/perf_bad_dpd30.png). A single str behaves as before, with no tgt_name column.
multi = (
PerformanceEvaluator(tgt_name=["bad_flag", "bad_dpd30"], model=gbm, feature_cols=features)
.add_dataset("train", train)
.add_dataset("oot", oot)
.evaluate(display=False, fig_save_path="output/perf.png")
)
print(multi[["tgt_name", "index", "KS", "AUC"]])
Weighted evaluation¶
weighted = (
PerformanceEvaluator(tgt_name="bad_flag", model=gbm, feature_cols=features, weight_col="sample_wgt")
.add_dataset("train", train)
.add_dataset("test", test)
.evaluate(display=False)
)
print(weighted[["index", "N", "N_RAW", "KS", "AUC", "LIFT", "IV"]])
Figures are produced on the weighted path only when fig_save_path is set. Datasets can use different weight columns
(add_dataset(name, data, weight_col=...)). A dataset registered without one falls back to the evaluator's weight_col,
and if that is not set either it is evaluated with unit weights.
Function form: get_perf_summary¶
get_perf_summary(train, validation, oot, tgt_name, ...) evaluates up to three frames in one call (pass None for a missing
one). The rows are labeled ins, oos, and oot. It accepts the same scr_name, model, feature_cols, oot_grp_name,
weight_col, and binning arguments as PerformanceEvaluator. For a weighted summary pass a score column through
scr_name (see Where weights are applied).
from Modeling_Tool import get_perf_summary
summary = get_perf_summary(
train, test, oot, tgt_name="bad_flag", model=gbm, feature_cols=features, display=False,
)
print(summary[["index", "N", "KS", "AUC"]])
2. Gains tables: GainsTableCalculator¶
A Gains table bins rows by score and reports, for each bin, how many rows, how many bads, the bad rate, the lift, and the cumulative capture. It is the report form most familiar to business stakeholders.
from Modeling_Tool import GainsTableCalculator
calc = GainsTableCalculator(data=test, dep="bad_flag", score="prob", nbins=10)
gains = calc.calculate()
print(gains[["MIN", "MAX", "N", "PROP", "AVG_BAD", "LIFT", "CUM_BAD_PCT", "KS_PER_BIN", "RANK_ORDER_BUMP"]])
The index is (_bin_num, _bin_range). With the default ascending=False, bin 0 holds the highest scores. Instead of
score, you can pass model and varlist (the feature columns) and the calculator scores the data itself.
| Column | Meaning |
|---|---|
MIN, MAX |
Lowest and highest score in the bin |
N, PROP |
Rows in the bin, and their share of all rows. With spec_values, each special score gets a row of its own after the bins, and PROP adds up to 1 over all rows |
PERF_CNT |
Rows with an observed target |
AVG_SCORE, UNIQUE_SCORE |
Mean score, and the number of distinct score values |
AVG_BAD, AVG_GOOD, N_BAD, N_GOOD |
Bad rate, good rate, and the bad and good counts |
LIFT |
AVG_BAD divided by the overall bad rate |
BAD_PCT_IN_EACH_BIN, GOOD_PCT_IN_EACH_BIN |
Share of all bads (goods) that fall in this bin (the capture rate) |
N_CUM_BAD, N_CUM_GOOD, CUM_BAD_PCT, CUM_GOOD_PCT |
Cumulative counts and shares down the table |
KS_PER_BIN |
abs(CUM_BAD_PCT - CUM_GOOD_PCT); its maximum is the KS statistic |
TRUE_BAD_SHIFT |
Relative bad-rate change versus the previous bin |
RANK_ORDER_BUMP |
1 where the bad rate moves against the expected direction (a rank-ordering break) |
WOE, IV |
Weight of evidence and information value of the bin |
With include_missing=True (the default of GainsTableCalculator and get_gains_table), rows with a missing score
are filled with fillna and form a bin of their own, (-inf, fillna], with NaN in MIN and MAX; the other bins are
the equal-frequency bins of the real scores. A score that already holds the fillna value lands in the same bin. Up to
0.9.0 the equal-frequency quantiles counted the filled rows as the lowest scores, so the missing rows shared the lowest
bin with real values (unless they made up more than one bin's share). cross_risk follows the same rule.
GainsTableCalculator(data, dep, nbins=10, precision=5, min_bin_prop=0.05, include_missing=True, score=None, model=None, varlist=None, equal_freq=True, chi2_method=False, chi2_p=0.95, init_equi_bins=100, fillna=-999999, spec_values=[], tree_binning=False, random_state=42, ascending=False, weight_col=None, weighted_binning=None)
holds the configuration, and
calculate(grp_name=None, min_data_size=100, grp_colname=None, sync_range=True, retSummary=False, withSummary=False, wholeGroup=False, add_func=None, weight_col=None)
produces the table:
calculate argument |
Meaning |
|---|---|
grp_name |
Build one Gains table per value of this column and stack them, with the group value in a new column (grp_colname, default: grp_name). Groups with fewer than min_data_size rows are skipped |
sync_range |
With grp_name, reuse the first group's bin edges for every group so the bins line up (default True) |
wholeGroup |
With grp_name, sync_range=True, and retSummary=True, take the shared bin edges from all rows instead of the first group |
withSummary |
Append a Grand Summary row (tables without grp_name, weighted or not) |
retSummary |
Return a one-row summary (N_BUMP, MIN_RISK_DEP, MAX_RISK_DEP, KS_IN_GAINS, LIFT_IN_GAINS, IV, N_BINS) instead of the table |
add_func |
Custom per-bin statistics; see Custom columns |
weight_col |
Overrides the calculator's weight_col for this call |
The function form get_gains_table(data, dep, nbins=10, ..., score=None, model=None, varlist=None, ..., grp_name=None, ..., add_func=None, weight_col=None, weighted_binning=None)
takes the same arguments.
from Modeling_Tool import get_gains_table
by_grade = get_gains_table(
test, dep="bad_flag", score="prob", nbins=5, grp_name="city_grade", min_data_size=100,
)
print(by_grade[["MIN", "MAX", "N", "AVG_BAD", "LIFT", "city_grade"]].head(8))
summary_row = get_gains_table(test, dep="bad_flag", score="prob", nbins=10, retSummary=True)
print(summary_row)
Weighted Gains table¶
weighted_gains = GainsTableCalculator(
data=test, dep="bad_flag", score="prob", weight_col="sample_wgt", nbins=10,
).calculate()
print(weighted_gains[["MIN", "MAX", "N", "N_RAW", "AVG_BAD", "LIFT", "KS", "AUC"]])
The weighted table has bins 1 to nbins (bin 1 holds the highest scores), each carrying about 1 / nbins of the total
weight, and extra N_RAW, KS, and AUC columns. See Where weights are applied for what the
weighted path ignores.
It follows the unweighted rules for incomplete rows. A row with a missing target counts in N but not in PERF_CNT,
N_BAD or N_GOOD, so AVG_BAD is N_BAD / PERF_CNT and the AUC uses the labelled rows. A row with a missing score
never enters the bins: it is left out, or reported in a Missing row after the bins with include_missing=True. Up to
0.9.0 the weighted table counted unlabelled rows as goods and ranked missing scores into the last bins.
add_func gets the rows of every row of the weighted table (the bins, the special:<value> rows and the Missing
row), with the weight column among the columns. With withSummary=True the table ends with a Grand Summary row whose
counts (N, N_RAW, PERF_CNT, N_BAD, N_GOOD) and rates (AVG_BAD, AVG_GOOD) cover every row of the table and
whose KS, IV and AUC summarize the bins.
Custom columns with add_func¶
add_func receives the rows of one bin as a DataFrame (all columns of your data, plus the bin columns _bin_num and
_bin_range) and returns a Series. Its values become extra columns on the Gains table. On the weighted path (without
grp_name) it is applied too, to every row of the table, and it sees the weight column like any other column; up to
0.9.0 it was ignored there.
def bin_profile(group):
return pd.Series({
"mean_income": group["income"].mean(),
"overdue_rate": (group["n_overdue"] > 0).mean(),
})
gains_plus = GainsTableCalculator(data=test, dep="bad_flag", score="prob", nbins=10).calculate(add_func=bin_profile)
print(gains_plus[["N", "AVG_BAD", "mean_income", "overdue_rate"]])
get_gains_table_by_cust_metrics is the column-name version: it adds the aggregate (metric_agg_func, default "mean") of
each column in eval_metrics to a compact Gains table.
from Modeling_Tool import get_gains_table_by_cust_metrics
cust = get_gains_table_by_cust_metrics(
data=test, dep="bad_flag", score="prob", nbins=10,
eval_metrics=["income", "n_overdue"], metric_agg_func="mean",
)
print(cust)
Defaults tied to a legacy dataset
eval_metrics defaults to ["age", "monthly_income", "education"], so always pass it explicitly. This function has no
weight_col, and its default ascending=True differs from GainsTableCalculator (False): bin 0 holds the lowest
scores.
3. Challenger versus champion: Model_Evaluation_Tool¶
Model_Evaluation_Tool evaluates several score columns on the same frame. base_score names the champion and
comp_scrlist lists the challengers; the comparison methods report one block per score, labeled in a score_name column.
from Modeling_Tool import Model_Evaluation_Tool
m_eval = Model_Evaluation_Tool(
data=holdout,
dep="bad_flag",
base_score="score_old",
comp_scrlist=["prob"],
)
compare = m_eval.model_perf_compare()
print(compare[["score_name", "index", "N", "KS", "AUC", "Top10%_TargetRate"]])
print(m_eval.get_score_correlation()) # long format: base, compare, corr
gains_by_score = m_eval.get_gains_summary(disp=False)
print(gains_by_score[["score_name", "_bin_num", "N", "AVG_BAD", "LIFT"]].head())
Positive scores only
model_perf_compare keeps only rows whose scores are greater than 0 when positive_score_only=True (the default);
pass False if your scores can legitimately be zero or negative. Missing and infinite scores are never used. get_score_correlation, get_cross_risk_summary,
and cross_perf_eval always drop rows with a score of 0 or below, because 0 and negative values conventionally mark
"no score".
| Method | Returns |
|---|---|
model_perf_compare(data=None, grp_name=None, dist_bins=100, pct_bins=10, min_data_size=50, sync_data_size=True, min_bin_prop=None, include_missing=None, equal_freq=None, sample_name=None) |
One performance row per score (the PerformanceEvaluator columns plus score_name and N_OWN); sample_name labels the index column (default 'all'); grp_name adds a per-group evaluation. With sync_data_size=True every score, the base score included, is evaluated on the rows where all the scores are valid; N_OWN is the score's own number of valid rows. A score without valid rows is left out with a warning |
get_gains_summary(data=None, grp_name=None, disp=True, grp_disp_metric=None, grp_nbins=5, withSummary=True, add_func=None, sync_range=True, spec_values=None, include_missing=None, fillna=None) |
Gains tables for every score, stacked, with score_name. Without add_func, only the columns in gains_display_metric_list are kept; with add_func, all columns plus yours |
get_score_correlation(score_list=None, method='pearson') |
Long-format correlation table with base, compare, corr |
get_base_score(scorename='_base_model_score_', disp=False) |
Returns self after adding the fitted model's probability as the base-score column (the model needs feature_names_in_) |
get_cross_risk_summary(cross_agg_dict=None, nbins=5, equal_freq=None, disp=True, spec_values=None, binning_numeric=None) |
Base-score × challenger cross matrices (see below) |
cross_perf_eval(bad_list, scr_list, data=None, eval_metric=None, melt=True) |
AUC, KS, N for every label × score pair |
run_variable_analysis_summary(varlist, ...) |
Per-variable KS, lift, IV, and missing rate |
multi_subset_wrapper, multi_ylabel_wrapper, multi_group_wrapper, multi_dim_eval |
Repeat an evaluation over subsets, labels, and groups |
pipe(data=None) |
An EvaluationPipeline on this object |
cross_perf = m_eval.cross_perf_eval(bad_list=["bad_flag", "bad_dpd30"], scr_list=["score_old", "prob"])
print(cross_perf) # columns: score_name, variable (e.g. AUC_bad_flag), value
Configure the legacy defaults¶
Several constructor defaults come from the library author's production data. Override them whenever you use the methods that read them:
| Constructor argument | Legacy default | Read by |
|---|---|---|
cross_agg_dict |
Columns is_dpd7, credit_limit, monthlyincome, education |
get_cross_risk_summary |
subset_condition_dict |
Queries on aprvvrsn_2 |
multi_subset_wrapper, multi_dim_eval |
eval_ylabels |
['is_dpd7'] |
multi_ylabel_wrapper, multi_dim_eval |
grp_namelist |
['sample_ind_fnl', 'aprvvrsn_2', 'week_start_date'] |
multi_dim_eval |
get_cross_risk_summary additionally needs a unique-id column named flow_id: it appends a count of flow_id to every
cell.
cross = m_eval.get_cross_risk_summary(
cross_agg_dict={"bad_flag": ["count", lambda x: x.mean()]}, # {column: aggregation(s)}
nbins=5,
disp=False,
)
print(cross.head()) # long format: base_scr_range, eval_metric, score_name, comp_scr_range, value
grid = Model_Evaluation_Tool(
data=holdout,
dep="bad_flag",
base_score="score_old",
comp_scrlist=["prob"],
subset_condition_dict={"Overall": "", "Top cities": "city_grade in ['A', 'B']"}, # label -> query ("" = all rows)
eval_ylabels=["bad_flag", "bad_dpd30"],
grp_namelist=["apply_month"],
min_data_size=50,
)
multi_dim = grid.multi_dim_eval()
print(multi_dim[["score_name", "eval_subset", "eval_ylabel", "group_name", "group_value", "N", "AUC", "KS"]].head())
Weighted comparison¶
weighted_eval = Model_Evaluation_Tool(
data=holdout, dep="bad_flag", base_score="score_old", comp_scrlist=["prob"], weight_col="sample_wgt",
)
print(weighted_eval.model_perf_compare()[["score_name", "N", "N_RAW", "KS", "AUC"]])
4. Cross-risk matrix: cross_risk¶
cross_risk(data, score_list, dep, nbins, agg_col=None, ..., agg_func='mean', ..., weight_col=None, sample_weight=None)
bins two scores and aggregates a column inside every cell. With the defaults, the cells hold the bad rate (the mean of
dep) and the margins are labeled Total_Avg_Risk. This is the usual way to see where a new score disagrees with an old one.
from Modeling_Tool import cross_risk
risk_matrix = cross_risk(
data=holdout,
score_list=["score_old", "prob"], # exactly two score columns
dep="bad_flag",
nbins=5, # an int, or a list with one value per score
)
print(risk_matrix)
# Any other column and aggregation: here, the mean income per cell, with weights
income_matrix = cross_risk(
data=holdout, score_list=["score_old", "prob"], dep="bad_flag", nbins=5,
agg_col="income", agg_func="mean", weight_col="sample_wgt",
)
agg_func accepts 'mean', 'sum', 'count', or a callable, as in pandas.crosstab. It also supports a ratio of two
columns: agg_col=(numerator, denominator), agg_func='ratio'.
5. Slice-by-slice evaluation: EvaluationPipeline¶
EvaluationPipeline(m_eval, data=None) runs a function once per slice. Chain group_by and subset_by, then call
apply(func):
group_by(group_name, min_size=100, group_var_name=None)splits by the values of a column.subset_by(condition_dict, name='eval_subset', min_size=100)filters by{label: query}, where each query is aDataFrame.querystring and""keeps every row.- Slices with fewer than
min_sizerows are skipped, and the slice labels are appended to the result as columns.
func either takes a current_data argument and receives the slice, or takes no data argument and is a method of your
Model_Evaluation_Tool, which the pipeline points at the slice while it runs.
from Modeling_Tool import EvaluationPipeline
pipeline = (
EvaluationPipeline(m_eval)
.group_by("apply_month", min_size=100)
.subset_by({"All cities": "", "Top cities": "city_grade in ['A', 'B']"}, name="city_subset", min_size=100)
)
def slice_metrics(current_data):
return pd.DataFrame({
"n": [len(current_data)],
"bad_rate": [current_data["bad_flag"].mean()],
"avg_score": [current_data["prob"].mean()],
})
by_slice = pipeline.apply(slice_metrics)
print(by_slice)
# Reuse a method of m_eval on every month
monthly_perf = EvaluationPipeline(m_eval).group_by("apply_month", min_size=100).apply(m_eval.model_perf_compare)
print(monthly_perf[["apply_month", "score_name", "N", "KS", "AUC"]])
Errors inside func are swallowed
An exception raised by func is logged at INFO level and that slice yields an empty result. If the output is
unexpectedly empty, call func on a single slice to see the real error.
6. Figures: evaluate_performance and comparison_performance¶
Both functions take a dictionary of datasets and return a summary DataFrame. save_path is the image file to write
(for example output/perf.png; the folder must exist). Pass to_show=False in scripts.
from Modeling_Tool import evaluate_performance, comparison_performance
# One score, several datasets: ROC, score distribution, percentile, and gain panels per dataset
perf_summary = evaluate_performance(
datasets={
"test": {"y_true": test["bad_flag"], "y_score": test["prob"]},
"oot": {"y_true": oot["bad_flag"], "y_score": oot["prob"]},
},
to_show=False,
save_path="output/perf.png",
)
print(perf_summary) # columns: index, N, avgTrue, avgScore, KS, AUC, Btm10%_TargetRate, Top10%_TargetRate
# Weighted: add a 'sample_weight' key to each dataset
weighted_summary = evaluate_performance(
datasets={"test": {"y_true": test["bad_flag"], "y_score": test["prob"], "sample_weight": test["sample_wgt"]}},
to_show=False,
save_path="output/perf_weighted.png",
)
# Several scores on the same labels: y_score_dict maps a model name to its scores (unweighted only)
comparison = comparison_performance(
datasets={
"test": {"y_true": test["bad_flag"], "y_score_dict": {"gbm": test["prob"], "old": test["score_old"]}},
"oot": {"y_true": oot["bad_flag"], "y_score_dict": {"gbm": oot["prob"], "old": oot["score_old"]}},
},
to_show=False,
save_path="output/perf_compare.png",
)
print(comparison) # columns: model, KS, AUC, Btm10%_TargetRate, Top10%_TargetRate, N, avgTrue, dataset
evaluate_performance(datasets, dist_bins=20, pct_bins=10, square_figsize=5, fontdicts=..., to_show=True, save_path=None, gains_table=True, equal_freq=True, pct_bin_edges=None, sample_weight=None, ascending=None)
draws one row of four panels per dataset. comparison_performance(datasets, pct_bins=10, square_figsize=5, fontdicts=..., to_show=True, save_path=None)
draws one row of three panels (ROC, percentile bad rate, cumulative bad rate) per dataset.
Building blocks¶
The curve tables behind the figures are public. Each takes sample_weight= for the weighted version.
from Modeling_Tool import calc_roc, calc_pr, calc_equid_pct
from Modeling_Tool.Eval import summarize_pct
from Modeling_Tool.Eval.evaluate_model import summarize_roc
roc = calc_roc(test["bad_flag"], test["prob"]) # fpr, tpr, thresholds, thresholds_percentile
print(summarize_roc(roc)) # {'auc': ..., 'ks_index': ..., 'ks_threshold': ..., 'ks': ...}
pr = calc_pr(test["bad_flag"], test["prob"]) # precision, recall, thresholds
deciles = calc_equid_pct(test["bad_flag"], test["prob"], bins=10) # equal-frequency table with lift and gain
print(deciles[["min_score", "max_score", "n", "avg_true", "lift", "gain"]].head(3))
print(summarize_pct(deciles)) # {'pct_bins': ..., 'pct_interval': ..., 'pct_top_avgTrue': ..., 'pct_btm_avgTrue': ...}
| Function | Returns |
|---|---|
calc_roc(y_true, y_score, sample_weight=None) |
ROC table: fpr, tpr, thresholds, thresholds_percentile (the weighted version adds FPR, TPR, and KS) |
calc_pr(y_true, y_score, sample_weight=None) |
Precision-recall table: precision, recall, thresholds |
calc_equid_dist(y_true, y_score, y_group=None, bins=10, sample_weight=None) |
Equal-width score-bin table |
calc_equid_pct(y_true, y_score, y_group=None, bins=10, ascending=True, sample_weight=None) |
Equal-frequency table with lift and gain; row 0 holds the lowest scores when ascending=True |
calc_fixed_pct(y_true, y_score, y_group=None, bin_edges=None, ascending=True, sample_weight=None) |
Table over the bin edges you supply |
The weighted versions of the three binning functions return a Gains-style table (N, N_RAW, AVG_BAD, LIFT, ...) instead.
summarize_roc (in Modeling_Tool.Eval.evaluate_model), summarize_pr, and summarize_pct (in Modeling_Tool.Eval) reduce
these tables to a dictionary of headline numbers. The plot_roc_curve, plot_ks_curve, plot_pr_curve, plot_kde_curve,
plot_dist_curve, plot_cumdist_curve, plot_pct_curve, plot_cumpct_curve, and plot_gain_curve functions draw
individual panels from the tables; import them from Modeling_Tool.Eval.
7. Lift cut-offs: calc_lift_apt¶
calc_lift_apt(y_true, y_score, start, stop, step, score_ascending=True, sample_weight=None) answers "what score cut-off
captures a lift of at least 1.5×, 2×, 2.5×, 3×?". start, stop, and step are required.
from Modeling_Tool import calc_lift_apt
lift_table = calc_lift_apt(test["bad_flag"], test["prob"], start=1.5, stop=3.0, step=0.5)
print(lift_table)
Each row is a target lift, the lift_actual achieved, the score cut-off (lower_limit: rows scoring at least this
value form the group), and that group's cumulative size (cumsum_n, cumsum_proportion), bad count (cumsum_true), and
bad rate (cumavg_true).
Weights
With integer-valued weights the rows are replicated and the same table is returned. With fractional weights the function returns a one-dimensional NumPy array holding, for each target lift, the closest lift the weighted Gains table can achieve, not a table.
Troubleshooting¶
ModuleNotFoundError: No module named 'IPython' from evaluate()
display=True is the default and imports IPython.display. Pass display=False outside notebooks.
KeyError: 'is_dpd7' or 'flow_id' from Model_Evaluation_Tool
You used a method that reads a legacy default. Pass cross_agg_dict, subset_condition_dict, eval_ylabels, and
grp_namelist for your own columns, and make sure the frame has a flow_id column for get_cross_risk_summary. See
Configure the legacy defaults.
My weighted results look unweighted (no N_RAW)
Weights are not applied together with oot_grp_name, benchmark_dataset, or grp_name. See
Where weights are applied.
AUC is below 0.5
The score runs the wrong way: a higher score must mean a higher chance of the bad outcome. See the score-direction note at the top of this page.
AUC and KS disagree (high AUC, low KS)
AUC summarizes the whole ranking, while KS is the single largest gap between the cumulative bad and good shares. They can
diverge when the separation is concentrated in a narrow score range. Check KS_PER_BIN and LIFT in the Gains table to
see where the separation comes from.
OOT AUC is far lower than on the test set
Check for population drift first: compute the PSI of the model's inputs between the development and OOT samples (see Feature Screening); a PSI above 0.25 is conventionally treated as significant drift. If the inputs are stable, the relationship itself may have changed and the model needs a more recent training window.
N and N_RAW differ a lot. Which one should I report?
Business metrics (bad rate, lift, capture rate) are based on N, the sum of weights. N_RAW is the number of rows and
answers "how many accounts are in this bin?". If every weight is 1, N == N_RAW.
I trained with weights but forgot to pass them at evaluation
Evaluation then treats every row equally, which no longer matches the population the model was fitted on, and AUC, KS, and Lift can look different from training time. Pass the same weights to every evaluation entry point.