Sam Austinon October 24, 202618 min read

Outlier Treatment for Credit Scorecards: Capping, Winsorizing, and Binning

Outlier Treatment for Credit Scorecards: Capping, Winsorizing, and Binning
Contents

Figure 1: Outlier Treatment for Credit Scorecards — one mis-keyed income can flatten the whole model

One applicant lists an annual income of 299 million dollars. Another lists zero. Your scorecard model has to score both, and neither number tells you anything true about the person.

Outliers hurt credit scorecards in specific ways. This guide compares three treatments, capping, winsorizing, and binning, on a simulated credit dataset. I ran every example, so the numbers below are real, and they show which treatment earns its place.

Why Outliers Matter in Credit Scoring

A credit scorecard usually rests on logistic regression. It turns applicant features into a score that ranks borrowers by default risk. Outliers distort that ranking in three ways:

  1. Data errors: mis-keyed incomes and impossible values add pure noise.
  2. Skewed scaling: extreme values inflate the mean and standard deviation, so normal applicants get squeezed into a tiny range.
  3. Unstable coefficients: a few extreme rows can drag a coefficient around, especially in a linear model.

Before we compare treatments, let us define the metrics. AUC measures how well the model ranks bad borrowers above good ones. Gini equals 2 * AUC - 1, and credit teams love it. KS (Kolmogorov-Smirnov) measures the maximum gap between the score distributions of good and bad borrowers.

Metric Definition What it tells you
AUC Area under the ROC curve Ranking quality, 0.5 to 1
Gini 2 × AUC − 1 The same ranking quality on a 0–1 scale
KS Max gap between good and bad score distributions Separation at the best cut-off

Build a Simulated Credit Dataset

I generated 40,000 applicants with four features: age, income, credit utilization, and debt-to-income ratio (DTI). The true default risk depends on the log of income, which reflects the idea that income matters less at high levels. About 20% of applicants default.

Then I planted realistic data problems. I multiplied 120 incomes by 1,000, as if someone typed three extra zeros. I set 80 incomes to zero. I also multiplied 100 DTI values by 15.

import numpy as np
import pandas as pd

rng = np.random.default_rng(7)
n = 40000

age = rng.integers(21, 75, n).astype(float)
income = rng.lognormal(10.5, 0.6, n)
util = np.clip(rng.beta(2, 3, n), 0, 1)
dti = np.clip(rng.gamma(2.0, 0.12, n), 0, None)

logit = (-2.6 - 0.9 * (np.log(income) - 10.5)
         + 1.6 * util + 2.0 * dti - 0.015 * (age - 45))
y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)

df = pd.DataFrame({'age': age, 'income': income, 'util': util, 'dti': dti})
df.loc[rng.choice(n, 120, replace=False), 'income'] *= 1000   # typos
df.loc[rng.choice(n, 80, replace=False), 'income'] = 0        # zeros
df.loc[rng.choice(n, 100, replace=False), 'dti'] *= 15        # extremes

The damage shows up in the summary statistics. Income now ranges from 0 to about 299 million, even though the 99th percentile sits near 158,000. The DTI maximum reached 13.4, while its 99th percentile stayed near 0.84.

Feature p99 Maximum
Income ~158,000 ~299,000,000
DTI ~0.84 13.4

Set Up the Evaluation

I split the data 70/30 with stratification. Each treatment gets fitted on the training set only, then applied to the test set. That order matters, so keep it in mind as we go.

from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.metrics import roc_auc_score, roc_curve
from sklearn.model_selection import train_test_split

tr, te = train_test_split(np.arange(n), test_size=0.3, stratify=y, random_state=1)
Xtr, Xte = df.iloc[tr].copy(), df.iloc[te].copy()
ytr, yte = y[tr], y[te]

def ks(y_true, p):
    fpr, tpr, _ = roc_curve(y_true, p)
    return np.max(tpr - fpr)

def evaluate(name, Xa, Xb):
    model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000))
    model.fit(Xa, ytr)
    p = model.predict_proba(Xb)[:, 1]
    auc = roc_auc_score(yte, p)
    print(f'{name:28s} AUC {auc:.4f}  Gini {2*auc-1:.4f}  KS {ks(yte, p):.4f}')

First, the baseline with no treatment at all:

evaluate('raw', Xtr, Xte)

The raw data scored an AUC of 0.628, a Gini of 0.256, and a KS of 0.188. The income standard deviation sat at about 3.5 million, which crushed every normal income into a sliver near zero after scaling. That is our baseline to beat.

Treatment 1: Capping With Business Rules

Capping replaces values beyond a fixed limit with that limit. In scorecards, you usually set the limits using business rules and policy knowledge. For example, you might decide that incomes below 5,000 or above 300,000 get capped.

def cap_rules(X):
    X = X.copy()
    X['income'] = X['income'].clip(lower=5000, upper=300000)
    X['dti'] = X['dti'].clip(upper=1.5)
    return X

evaluate('capping (business rules)', cap_rules(Xtr), cap_rules(Xte))

The jump was dramatic: AUC rose to 0.685, Gini to 0.370, and KS to 0.273. Only about 0.55% of rows fell outside the rules, yet fixing them lifted the Gini by more than 0.11. A tiny fraction of bad rows had wrecked the model.

Capping's strength is explainability. A reviewer can read the rule and understand it in one sentence. You must document each cap and apply the identical rule in production, or your live scores will drift from your development scores.

Treatment 2: Winsorizing With Percentiles

Winsorizing replaces extreme values with chosen percentiles of the data, such as the 1st and 99th. People often use the two terms interchangeably. I treat capping as rule-based and winsorizing as data-driven.

The critical detail: compute the percentiles on the training set only, then reuse those same limits on new data.

lo = Xtr.quantile(0.01)
hi = Xtr.quantile(0.99)

def winsorize(X):
    return X.clip(lower=lo, upper=hi, axis=1)

evaluate('winsorize 1/99', winsorize(Xtr), winsorize(Xte))

Winsorizing at the 1st and 99th percentiles scored an AUC of 0.685 and a Gini of 0.370, essentially tied with business-rule capping. A more aggressive 5th and 95th percentile version scored a Gini of 0.369. Both landed in the same neighbourhood.

Notice the trade-off. Percentile limits adapt automatically as data changes, which saves effort. But they also clip legitimate high earners, because they cut a fixed share of rows regardless of whether those rows look wrong. Also, the limits drift when your applicant population shifts, which complicates monitoring.

Capping or Winsorizing?

Both treatments gave the same result here, so choose based on process:

  1. Choose capping when policy or domain knowledge defines sensible limits.
  2. Choose winsorizing when you lack domain rules and want a data-driven default.
  3. Combine them: use a business rule for impossible values and percentiles for the rest.

Add a Transformation

Capping fixes errors, but it leaves income right-skewed. Because the true risk depends on log income, a log transform should help. I capped first, then applied the log.

def cap_log(X):
    X = cap_rules(X)
    X['income'] = np.log(X['income'])
    return X

evaluate('capping + log income', cap_log(Xtr), cap_log(Xte))

The Gini climbed to 0.376 with a KS of 0.277, the best linear result so far. The log transform matched the true shape of the relationship. Order matters, though: always clean the extremes before you take the log, because the log of zero blows up.

Treatment 3: Binning With Weight of Evidence

Traditional scorecards take a different route. Binning slices each feature into groups, and each group gets a weight of evidence (WoE) value. WoE equals the log of the ratio between the share of good borrowers and the share of bad borrowers in that bin. The model then uses WoE values instead of raw numbers.

Binning handles outliers naturally. An income of 299 million simply lands in the top bin, alongside every other high income. Its extreme size no longer matters.

def woe_table(x, y, bins=10):
    edges = np.unique(np.quantile(x, np.linspace(0, 1, bins + 1)))
    edges[0], edges[-1] = -np.inf, np.inf
    b = pd.cut(x, edges, include_lowest=True)
    t = pd.DataFrame({'b': b, 'y': y})
    g = t.groupby('b', observed=True)['y'].agg(['sum', 'count'])
    bad = g['sum'] + 0.5
    good = g['count'] - g['sum'] + 0.5
    woe = np.log((good / good.sum()) / (bad / bad.sum()))
    iv = ((good / good.sum() - bad / bad.sum()) * woe).sum()
    return edges, woe.values, iv

def woe_transform(X_train, y_train, frames, bins=10):
    outs = [pd.DataFrame(index=X.index) for X in frames]
    for c in X_train.columns:
        edges, woe, iv = woe_table(X_train[c].values, y_train, bins)
        for out, X in zip(outs, frames):
            idx = np.searchsorted(edges, X[c].values, side='right') - 1
            out[c] = woe[np.clip(idx, 0, len(woe) - 1)]
    return outs

Wtr, Wte = woe_transform(Xtr, ytr, [Xtr, Xte])
evaluate('WoE binning (10 quantile)', Wtr, Wte)

The + 0.5 smoothing prevents division by zero in bins with no bad or no good borrowers. The edges come from the training data only, and the first and last edges stretch to infinity so that new extremes still land somewhere.

WoE binning with 10 quantile bins scored a Gini of 0.370 and a KS of 0.267, matching capping on Gini. Five bins scored 0.368. The binned model reached that result with zero manual outlier rules.

Read the WoE Table

The income bin edges ran from about 16,600 up to 78,900, and the WoE values climbed smoothly from −0.88 in the lowest bin to +0.95 in the highest. That monotonic pattern shows that higher income means lower risk, exactly as designed. Scorecard builders prize monotonic WoE, because it makes the story easy to defend.

The information value (IV) summarises each feature's strength.

Feature IV Reading
Income 0.25 medium strength
DTI 0.12 medium strength
Utilization 0.09 weak
Age 0.05 weak

A common rule of thumb calls an IV between 0.1 and 0.3 medium strength, so income and DTI carry the most signal.

Look Before You Cap: Do Outliers Carry Signal?

Not every outlier is an error. Sometimes extreme values predict behaviour. Before you cap, check the default rate inside the extreme groups.

In my data, the 60 training applicants with income under 5,000 defaulted 26.7% of the time. The 95 applicants above 300,000 defaulted 16.8%, against an overall rate near 20%. Those groups are too small to trust, and I planted the errors randomly, so the differences mean little here.

On real data, the check can reveal gold. Zero income might signal students, retirees, or missing information, each with different risk. If an extreme group behaves differently, give it its own bin or its own flag feature instead of hiding it with a cap.

Comparison at a Glance

Here are all the results on the held-out test set:

Treatment Gini KS
Raw data 0.256 0.188
Capping (business rules) 0.370 0.273
Winsorizing 1% / 99% 0.370 0.273
Winsorizing 5% / 95% 0.369 0.271
Capping + log income 0.376 0.277
WoE binning, 10 bins 0.370 0.267
WoE binning, 5 bins 0.368 0.270

Any treatment beats doing nothing by a wide margin. The differences among the treatments stay small. The biggest decision is whether you treat outliers at all, not which method you pick.

Which Treatment Should You Choose?

Match the method to your situation:

  1. You build a traditional scorecard: use WoE binning with a monotonic check. It handles outliers automatically.
  2. You use a linear model on raw numbers: cap or winsorize, then transform skewed features.
  3. Policy defines valid ranges: use business-rule capping, and document every rule.
  4. You lack domain rules: winsorize at percentiles from the training set.
  5. Extreme groups behave differently: give them their own bin or flag.
  6. You use tree-based models: trees handle outliers well already, though impossible values still deserve cleanup.

Common Pitfalls

I see these mistakes in model reviews:

  1. Computing percentiles on the full dataset. Fit limits on training data only, or you leak information from the test set.
  2. Changing the treatment between development and production. The live model must use the identical rules.
  3. Capping before checking for signal. Some extremes carry real risk information.
  4. Treating special codes as numbers. Values like −999 or 9999999 often mean missing or not applicable. Handle them as their own category.
  5. Skipping documentation. Reviewers and auditors need every cap, bin, and percentile written down.
  6. Binning on the full sample and then splitting. The bin edges encode test information, the same leak as percentile limits fitted on everything.
  7. Reporting Gini without the KS and the cut-off population. A single ranking number hides how the score behaves at the decision boundary.

Best Book to Read

CoverBookDescriptionGet it
Cover of “Credit Risk Scorecards” Credit Risk Scorecardsby Naeem Siddiqi the standard practitioner reference on WoE binning, characteristic analysis and scorecard documentation, written for the exact workflow this post measures. View on Amazon

As an Amazon Associate I earn from qualifying purchases. Links on this page are affiliate links, and I may earn a commission at no extra cost to you.

Wrapping Up

Outlier treatment lifted my test Gini from 0.256 to about 0.37, and a log transform pushed it to 0.376. Capping, winsorizing, and WoE binning all worked, and they differed far less than they differed from doing nothing. Pick the one that fits your process, document it, and apply it identically in production.

So take your current scorecard data and check the minimum, maximum, and 1st and 99th percentiles for each feature. If any gap looks absurd, you just found your next improvement. That ten-minute check often beats an afternoon of model tuning.

If you want the general case behind the capping decision, the z-score failure guide shows why fixed thresholds break on fat tails. For the coefficient side, the collinearity guide explains what a single extreme row can do to a linear model's weights.

Frequently Asked Questions

What is the difference between capping and winsorizing?

Both replace extreme values with a limit; the difference is where the limit comes from. Capping uses a business rule, such as income must lie between 5,000 and 300,000 because policy says so. Winsorizing derives the limit from percentiles of the training data, such as the 1st and 99th. In this experiment they produced identical Gini (0.370) and KS (0.273), so the choice is about governance and maintenance rather than accuracy.

Why does capping only 0.55% of rows improve Gini by 0.11?

Because the damage is not proportional to the count. A handful of incomes near 299 million multiplies the standard deviation of income to roughly 3.5 million, and standardising then squeezes every ordinary applicant into a sliver near zero. Logistic regression cannot find a slope in that compressed range. Removing the impossible values restores the spread that the coefficient needs.

Should percentile limits be computed on train or test data?

Train only, always. Computing the 1st and 99th percentiles on the combined dataset leaks information about the test distribution into the preprocessing step, which inflates the measured Gini and hides how the rule behaves on genuinely unseen applicants. Fit the limits inside the pipeline or in a preprocessing object fitted on train, then apply the frozen limits to validation, test and production data.

What is weight of evidence and why does monotonicity matter?

WoE is the log ratio of the share of good borrowers to the share of bad borrowers in a bin. A monotonic WoE sequence across ordered bins means the feature's relationship with risk runs in one direction, as income did here: −0.88 in the lowest bin rising smoothly to +0.95 in the highest. Scorecard builders prize that shape because it is easy to defend to a reviewer and avoids bins that reverse direction for no reason.

How do I read information value?

IV sums the WoE contribution of each bin, weighted by how much the good and bad distributions differ there. Income scored 0.25, DTI 0.12, utilization 0.09 and age 0.05. A common convention treats 0.1 to 0.3 as medium predictive strength and above 0.3 as strong, with the caveat that very high IV can signal leakage rather than signal.

Should I always cap extreme values before modelling?

No. Check whether the extreme group behaves differently first. Here, 60 training applicants with income under 5,000 defaulted at 26.7% and 95 above 300,000 defaulted at 16.8%, against roughly 20% overall — too few rows to trust, and the errors were planted at random. On real data the same check can reveal that zero income means student, retiree or missing data, each with its own risk, which deserves a bin or a flag rather than a cap.

Sources

  • [1] scikit-learn, roc_auc_score — the AUC computation behind the Gini figures, including the 2 × AUC − 1 conversion used throughout: scikit-learn.org/.../roc_auc_score
  • [2] scikit-learn, KBinsDiscretizer — the library implementation of quantile and uniform binning, useful as a checked alternative to the hand-written edge logic: scikit-learn.org/.../KBinsDiscretizer
  • [3] scikit-learn, "Preprocessing data" — scaling, skew correction and the transformer API that keeps fitted limits inside a pipeline: scikit-learn.org/modules/preprocessing
  • [4] scikit-learn, "Common pitfalls and recommended practices" — the leakage failure modes that make fitting limits on the full dataset so tempting and so wrong: scikit-learn.org/common_pitfalls
  • [5] SciPy, scipy.stats.ks_2samp — the two-sample Kolmogorov-Smirnov test, useful when you want a p-value alongside the KS statistic quoted on a scorecard: docs.scipy.org/.../ks_2samp

What are You Looking For?

esc