Sample Management¶
The Modeling_Tool.Sample subpackage covers the first modeling step: turning raw data into samples you
can model on. It splits a frame into training and test samples, draws and rebalances samples, shows how much a split depends
on its random seed, and adds rejected applications to an approved-only sample.
| I want to... | Use | Import |
|---|---|---|
| Split into train and test samples with matching bad rates | SampleSplitter |
from Modeling_Tool import SampleSplitter |
| Draw a random subsample | StratifiedSampler.sample |
from Modeling_Tool import StratifiedSampler |
| Rebalance the classes (random under/over-sampling, SMOTE) | StratifiedSampler.balance |
from Modeling_Tool import StratifiedSampler |
| Undersample with NearMiss, Tomek links, or ENN | SampleBalancer |
from Modeling_Tool import SampleBalancer |
| See how split results change with the random seed | select_sample_seed |
from Modeling_Tool import select_sample_seed |
| Add rejected applications to an approved-only sample | RejectInferenceFactory |
from Modeling_Tool import RejectInferenceFactory |
| Label every row INS, OOS, or OOT from a sample analysis | SampleAnalysisPipeline |
from Modeling_Tool import SampleAnalysisPipeline, SampleAnalysisPipelineConfig |
SMOTE and the nearmiss, tomek, and enn balancers need the optional imbalanced-learn package:
Example data¶
Every snippet on this page runs top to bottom in one Python session. The setup below creates a synthetic loan sample with a rare bad outcome (about 5%), a month, a channel, and a unique loan id.
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n = 6000
months = [f"2025-{m:02d}" for m in range(1, 10)]
data = pd.DataFrame({
"loan_id": [f"L{i:06d}" for i in range(n)],
"apply_month": rng.choice(months, n),
"channel": rng.choice(["online", "branch", "partner"], 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),
})
data["apply_time"] = pd.to_datetime(data["apply_month"] + "-15")
logit = -3.2 - 0.02 * (data["score_b"] - 600) + 0.5 * data["n_overdue"] - 0.8 * data["utilization"]
data["bad_flag"] = rng.binomial(1, 1 / (1 + np.exp(-logit)))
features = ["age", "income", "score_b", "utilization", "n_overdue"]
print(f"rows={len(data)} bad rate={data['bad_flag'].mean():.3f}")
1. Sample Splitting: SampleSplitter¶
SampleSplitter splits rows at random into a training and a test sample. With stratify=True (the default) both samples
keep the bad rate of the input. It never looks at dates, so to keep an out-of-time (OOT) sample, cut the frame by time first
and split only the earlier part.
from Modeling_Tool import SampleSplitter
dev = data[data["apply_month"] < "2025-08"] # development window
oot = data[data["apply_month"] >= "2025-08"] # out-of-time window, never split
splitter = SampleSplitter(test_size=0.3, random_state=42, stratify=True)
train_df, test_df = splitter.split_df(dev, target="bad_flag")
print(len(train_df), len(test_df), round(train_df["bad_flag"].mean(), 4), round(test_df["bad_flag"].mean(), 4))
| Parameter | Default | Meaning |
|---|---|---|
test_size |
0.3 |
Share of the rows that go to the test sample (0 to 1) |
random_state |
None |
Random seed. Set it to get the same split on every run |
stratify |
True |
Keep the target's bad rate equal in both samples |
split_df(df, target, exclude_cols=None, test_size=None) returns (train_df, test_df). Both are copies that keep every
column and the original index labels, in shuffled row order. test_size overrides the constructor value for this call.
exclude_cols is accepted for backward compatibility and has no effect, because all columns are always kept.
Two lower-level methods work on arrays and indices instead of frames:
| Method | Returns |
|---|---|
split(X, y, test_size=None, stratify=None) |
(X_train, X_test, y_train, y_test), as sklearn.model_selection.train_test_split does |
split_indices(index, y, test_size=None, stratify=None) |
(train_index, test_index) as NumPy arrays. The last result is also kept in train_index_ and test_index_ |
2. Random Subsampling: StratifiedSampler.sample¶
Use it to downsample a training sample that is too large. Pass either n_samples (number of rows) or sample_frac
(fraction of rows). If both are given, sample_frac wins. With neither, a copy of the frame is returned.
from Modeling_Tool import StratifiedSampler
sampler = StratifiedSampler(random_state=42)
sample_df = sampler.sample(train_df, target="bad_flag", n_samples=1000)
subset_df = sampler.sample(train_df, target="bad_flag", sample_frac=0.2)
print(len(sample_df), len(subset_df))
Not stratified
Despite the class name, sample is a plain DataFrame.sample (without replacement). It keeps the bad rate only in
expectation and ignores target. To control the class ratio exactly, use balance below.
3. Class Balancing¶
Balancing changes the share of bad rows in a sample, which helps when bads are very rare. Balance only the training sample. Keep the validation, test, and OOT samples at their natural bad rate, because probabilities from a model trained on a balanced sample are overstated.
StratifiedSampler.balance¶
StratifiedSampler(target_rate=None, random_state=None) holds the settings, and
balance(df, target, method="undersample") returns the balanced frame. The input frame is not modified, and the input bad
rate is stored in original_rate_.
numeric_df = train_df[features + ["bad_flag"]] # SMOTE needs numeric columns without NaN
for method in ["undersample", "oversample", "smote"]:
balanced = StratifiedSampler(random_state=42).balance(numeric_df, target="bad_flag", method=method)
print(f"{method:12s} rows={len(balanced):5d} bad rate={balanced['bad_flag'].mean():.3f}")
# Aim for a 20% bad rate instead of 50% (honored by undersample and oversample only)
to_20pct = StratifiedSampler(target_rate=0.20, random_state=42).balance(numeric_df, "bad_flag", method="undersample")
print(len(to_20pct), round(to_20pct["bad_flag"].mean(), 3))
method |
What it does | target_rate |
|---|---|---|
"undersample" (default) |
Keeps every bad row and randomly drops good rows | Honored: keeps bads * (1 - rate) / rate good rows, at most all of them |
"oversample" |
Draws bad rows with replacement until their count matches the good rows. Duplicated rows keep their index label | Honored: draws goods * rate / (1 - rate) bad rows |
"smote" |
Synthesizes new bad rows with SMOTE | Ignored: the result is always 50/50 |
Without target_rate, the result is balanced 50/50.
Behavior to know
- Set
target_rateabove the current bad rate. Below it,undersamplereturns the frame unchanged andoversampleresamples the bad class down. smoteraisesValueErrorif any non-target column is non-numeric or contains NaN. It returns a new frame with a freshRangeIndex, float feature columns, and the target as the last column. With fewer than 6 bad rows it silently falls back to random oversampling.
SampleBalancer¶
SampleBalancer(method="random", target_ratio=None, random_state=None) returns an (X, y) pair instead of a frame, like
an imbalanced-learn sampler: fit_resample(X, y) gives (X_resampled, y_resampled). Pandas input comes back as a
DataFrame and a Series, and NumPy input as arrays.
from Modeling_Tool import SampleBalancer
X, y = numeric_df[features], numeric_df["bad_flag"]
for method in ["random", "nearmiss", "tomek", "enn"]:
X_res, y_res = SampleBalancer(method=method, random_state=42).fit_resample(X, y)
print(f"{method:9s} rows={len(X_res):5d} bad rate={y_res.mean():.3f}")
method |
What it does | Needs imbalanced-learn |
|---|---|---|
"random" (default) |
Randomly drops good rows until there are as many goods as bads (50/50) | No |
"nearmiss" |
NearMiss (version 1) undersampling of the good class (50/50) | Yes |
"tomek" |
Removes good rows that form Tomek links. The classes stay imbalanced | Yes |
"enn" |
Removes good rows that their nearest neighbors disagree with (Edited Nearest Neighbours). The classes stay imbalanced | Yes |
The nearmiss, tomek, and enn methods need numeric features without NaN. random keeps the original index labels, and
the other three return a new RangeIndex.
target_ratio does not work in 0.8.2
Only method="random" reads target_ratio, and it does not produce the documented ratio. On a sample with 5% bads,
target_ratio=0.5 returns every row unchanged, and target_ratio=0.2 raises a ValueError ("Cannot take a larger sample
than population"). Leave it unset, or use StratifiedSampler(target_rate=...) to reach a target bad rate.
Global random state
method="random" calls numpy.random.seed(random_state), so it resets NumPy's global random generator. With
random_state=None it reseeds from the operating system.
4. Seed Sensitivity: select_sample_seed¶
select_sample_seed(master_df, oot_split_col, model, tgt_name, seed_range=(3000, 3050), ins_prop=0.7) re-splits the
development rows once per seed and scores each split with a model you have already fitted. It does not choose a seed for
you. It returns a DataFrame with one row per seed and dataset, and you pick the seed from it.
| Argument | Meaning |
|---|---|
master_df |
All rows. It must contain the target, the split flag, and the model's input columns (or the score column) |
oot_split_col |
Flag column: 1 marks development rows, which are re-split by seed, and 2 marks OOT rows. Other values are ignored |
model |
A fitted model with predict_proba (GradientBoostingModel or a scikit-learn estimator fitted on a DataFrame), or the name of an existing score column |
tgt_name |
Target column |
seed_range |
(start, stop): the seeds start to stop - 1 |
ins_prop |
Share of the development rows that go to train (INS). The rest go to validation (OOS) |
For each seed the development rows are split with a stratified SampleSplitter, and the result is the
PerformanceEvaluator table for train, validation, and oot (the index column), plus a seed column.
A tqdm progress bar is shown while it runs. If no row has flag 2, the oot rows are left out.
from Modeling_Tool import GradientBoostingModel, select_sample_seed
gbm = GradientBoostingModel(
"lgb",
params={"n_estimators": 100, "learning_rate": 0.05, "max_depth": 3,
"early_stopping_rounds": 20, "eval_metric": "auc", "verbose": -1},
)
gbm.fit(train_df[features], train_df["bad_flag"], test_df[features], test_df["bad_flag"])
# 1 = development rows (re-split for every seed), 2 = out-of-time rows
master_df = pd.concat([dev.assign(sample_ind=1), oot.assign(sample_ind=2)])
seed_perf = select_sample_seed(
master_df=master_df,
oot_split_col="sample_ind",
model=gbm,
tgt_name="bad_flag",
seed_range=(3000, 3010), # seeds 3000 .. 3009
ins_prop=0.7,
)
# Pick the seed whose INS and OOS samples look most alike
ks = seed_perf.pivot(index="seed", columns="index", values="KS")
ks["gap"] = (ks["train"] - ks["validation"]).abs()
print(ks.sort_values("gap").head(3))
best_seed = int(ks["gap"].idxmin())
print("best seed:", best_seed)
What the table can and cannot tell you
- The model is not refitted per seed, and the OOT rows never change, so the
ootmetrics are identical for every seed. A seed cannot improve OOT performance. Rank seeds ontrainversusvalidation, as above. - The model has usually seen some of the development rows already, so both samples can be optimistic. Treat the table as a measure of how sensitive the metrics are to the split, then refit on the split you choose.
- The smaller the development sample, the more the metrics move from seed to seed.
5. Reject Inference: RejectInferenceFactory¶
Use it when the modeling data comes only from approved applications, so the outcome of rejected ones is unknown.
RejectInferenceFactory.create(method, **kwargs) builds an inferrer, and infer(approved_df, rejected_df, score_col)
returns the approved and rejected rows stacked in one frame.
from sklearn.linear_model import LogisticRegression
from Modeling_Tool import RejectInferenceFactory
# Approved applications have a label. Rejected ones do not
approved = (data["score_b"] + np.random.default_rng(1).normal(0, 30, n)) > 590
approved_df = data[approved].copy()
rejected_df = data[~approved].drop(columns="bad_flag")
# Score both groups with a model trained on the approved sample
scorer = LogisticRegression(max_iter=1000).fit(approved_df[features], approved_df["bad_flag"])
approved_df["prob"] = scorer.predict_proba(approved_df[features])[:, 1]
rejected_df["prob"] = scorer.predict_proba(rejected_df[features])[:, 1]
inferrer = RejectInferenceFactory.create(
"parceling", target_col="bad_flag", score_col="prob", score_direction="high_bad", random_state=42,
)
df_combined = inferrer.infer(approved_df, rejected_df, score_col="prob")
print(len(approved_df), len(rejected_df), len(df_combined), df_combined["bad_flag"].isna().sum())
rejected_df must contain the score column but no label. With "parceling", each rejected row gets a label drawn at random
from the bad rate of the approved rows in the same score band. prob is a probability of bad, so the example sets
score_direction="high_bad". The default, "high_good", is for credit scores where a higher value means lower risk. See
Reject Inference and Distribution Adaptation for the other methods.
6. Row-Level INS / OOS / OOT Labels: SampleAnalysisPipeline¶
SampleAnalysisPipeline evaluates many (OOT window, INS/OOS ratio, seed) combinations and recommends one per target. With
materialize_split=True (0.6.7+) it also replays the recommended split into row-level labels, so the modeling split
matches the statistics it was chosen from. Pass the labels to a modeling pipeline through its split_col (the values are
ins, oos, and oot).
Defaults tied to a legacy dataset
target_cols, time_dims, population_dims, profile_cols, and approved_col default to column names from the
library author's data (for example y_flag_dpd7_in_mob1 and strategy_version). On your own data, override them all, or
run() raises KeyError: Missing required columns. The default search is large (4 targets x 4 windows x 3 ratios x
20 seeds = 960 splits). Set dry_run=True to only estimate the number of splits.
from Modeling_Tool import SampleAnalysisPipeline, SampleAnalysisPipelineConfig
config = SampleAnalysisPipelineConfig(
target_cols=["bad_flag"],
time_col="apply_time",
time_dims=["apply_month"],
population_dims=["channel"],
profile_cols=["age", "income"],
oot_time_dim="apply_month",
oot_windows=[1, 2], # trailing periods to try as OOT
ins_oos_ratios=[0.7, 0.8],
random_seeds=range(3000, 3005),
min_sample_size=200,
approved_col=None,
write_outputs=False, # skip the summary CSV files
write_excel=False, # skip the Excel report
# Row-level labels
id_col="loan_id",
materialize_split=True,
oot_cutoff="2025-08", # optional: OOT = apply_month >= "2025-08"
split_col_name="sample_split",
persist_split_map=True, # write row_level_split.csv and split_artifact.json
output_dir="output/sample_analysis",
)
result = SampleAnalysisPipeline(config).run(data)
print(result.split_recommendation[["target_col", "oot_window_periods", "ins_ratio", "seed"]])
print(result.row_level_split.head(3))
print(result.row_level_split["sample_split"].value_counts().to_dict())
print(result.output_paths)
# Attach the labels to the modeling frame (pass sample_split as split_col to a pipeline)
modeling_df = data.merge(result.row_level_split[["loan_id", "sample_split"]], on="loan_id")
print(modeling_df["sample_split"].value_counts().to_dict())
| Result attribute | Content |
|---|---|
split_recommendation |
One row per target: the (oot_window_periods, ins_ratio, seed) with the smallest bad-rate gap between any two of INS, OOS, and OOT. Candidates need at least min_sample_size rows in each part (if none qualifies, all are considered). Ties go to the larger OOT, then to the ratio closest to 75/25 |
row_level_split |
None unless materialize_split=True. Otherwise a long frame with the id column, a column named target_col that holds the target's name, and the split_col_name column (ins, oos, or oot). Rows without a label for a target are left out |
split_artifact |
Audit record per target: seed, ratio, OOT basis, row counts, and a SHA-256 hash of the sorted ids of each part (ins, oos, oot, full). Two runs with equal hashes produced the same membership |
output_paths |
Paths of the files written |
oot_cutofffixes the OOT part asoot_time_dim >= oot_cutoffinstead of the recommended trailing window. The INS/OOS pool is then rebuilt from the new boundary, so the rows can differ from the ones scored in the statistics step. Withoutoot_cutoff, the replay reproduces those rows exactly.run()fails loudly ifid_colhas duplicates among the labeled rows (ValueError), or if the three parts overlap or do not cover every labeled row (AssertionError).persist_split_map=Truewritesrow_level_split.csvandsplit_artifact.jsontooutput_dir, even whenwrite_outputsandwrite_excelare off.
FAQ¶
The bad rate of the training sample differs from the full-sample bad rate
Check that stratify=True. With stratify=False the split is purely random, so the bad rates of the two samples vary
with the draw, and more so on small samples or with a rare bad outcome.
balance(method='smote') or SampleBalancer(method='nearmiss') raises ImportError: imbalanced-learn required
Install the optional dependency:
ValueError: could not convert string to float from SMOTE, NearMiss, Tomek, or ENN
These methods need numeric columns without NaN. Pass only the numeric feature columns, for example
train_df[features + ["bad_flag"]], and impute or encode the rest first.
Every seed gives the same OOT result in select_sample_seed
That is expected. The model is fixed and the OOT rows do not depend on the seed. Compare train with validation
instead. See Seed Sensitivity.