卡方分箱(Chi-square Binning)是一种基于卡方检验(Chi-square Test)原理的分箱方法,常用于对连续变量或有序离散变量进行区间划分,进而提升评分卡模型中特征的区分度和稳定性。由于评分卡模型(如 Logistic 回归)通常喜欢离散化(分桶)后的特征,因此卡方分箱在信用评分、风控建模等业务场景十分常见。
1. 为什么要分箱?
在信用评分、风控建模或者其它传统机器学习场景中,对连续变量进行分箱具有以下优势:
- 处理非线性关系:将连续变量分成多个区间,可以更灵活地捕捉与目标变量(如违约)的非线性关系。
- 稳定性与鲁棒性:分箱能减小噪声影响,将相似区间的数据聚合在一起,提升模型的稳健性。
- 可解释性:对连续变量的不同区间进行命名或分析,更符合业务直觉;评分卡模型中,经常会基于分箱结果来计算 WOE(Weight of Evidence)和 IV(Information Value)。
- 减少过拟合:相比使用原始的连续值,合并成有限个区间往往能减少数据维度,也有助于控制模型过拟合风险。
2. 什么是卡方分箱?
卡方分箱的核心思路是:
- 将样本数据按某一连续变量从小到大排序;
- 初始时,将每一个(或者每个相同取值的)样本当作单独的箱;
- 依次进行合并,每次合并相邻的两个箱,对比合并前后与目标变量(如是否违约)之间的统计差异,直到满足一些停止条件(如箱数达到目标、或卡方值阈值等)。
2.1 卡方统计量
-
若目标变量为二分类(如违约=1,未违约=0),可以构造 2×N 的列联表(N 表示当前有 N 个箱)。
-
对每一个相邻箱合并后,可以计算其对应的卡方统计量(Chi-square)来衡量合并前后“违约分布”是否存在显著差异:
χ 2 = ∑ all cells ( O − E ) 2 E \chi^2 = \sum_{\text{all cells}} \frac{(O - E)^2}{E} χ2=all cells∑E(O−E)2
其中 O O O 为该单元格中观测到的频数, E E E 为期望频数(基于独立假设或已知分布进行估算)。 -
当合并的两个区间在目标变量的分布上很相似,则它们的卡方值就小,说明“合并后对违约分布区分影响不大”,可以考虑合并。
2.2 迭代过程
-
初始化
- 对连续变量排序后,每个值或每个唯一值都作为一个“原子”箱。假设有 M 个原子箱。
-
合并相邻箱
- 在所有相邻箱对中,找到使“卡方值”最小的一对,说明这两个箱在目标变量分布上最为相似。
- 将它们合并成一个新箱,箱数减少 1。
-
重复合并
- 不断重复步骤 2,直到满足停止条件。停止条件通常包括:
- 箱数达到预设的目标数量(如 5~10 个);
- 所有箱的卡方值都大于某个阈值,或 p 值大于某阈值(无须再合并);
- 每个箱内的样本数或正负样本数至少大于某个最小阈值。
- 不断重复步骤 2,直到满足停止条件。停止条件通常包括:
2.3 结果
最终得到多个区间(Bins),每个区间里“目标变量”的分布相对相似、且区间之间“目标变量”差异相对较大。这些区间即可作为评分卡模型或其它传统模型的“离散化特征”输入。
3. 卡方分箱的算法流程
让我们用一个更清晰的小示例来描述卡方分箱的流程。
示例:假设我们要对“年龄”变量分箱,并且目标变量是“是否违约”。有 10 条样本,含年龄与违约(0/1)信息。我们想把它分成较少的箱,以便后续做评分卡。
-
数据排序
- 根据年龄从小到大排序。如果有相同年龄,可聚合在一起(因为它们都属于同一值)。
-
初始箱
- 每个不同年龄值都作为一个箱;如果有重复值也可视情况合并在一个箱中(因为相同值再细分并无意义)。
- 这样我们得到了若干个原子箱,每个箱有“违约数量 / 未违约数量”等信息。
-
计算相邻箱的卡方值
- 针对所有相邻箱,计算合并后它们对应的 2×2 列联表,与原先拆分时的列联表对比,获得卡方值。
- 选择卡方值最小的一对箱进行合并(最小表示它们在目标分布上最相似)。
-
重复
- 合并后,箱数减少 1。继续更新相邻关系,再寻找卡方值最小的一对箱,直到满足停止条件。
-
输出分箱区间
- 最终在年龄轴上就会形成若干段区间,例如:[18, 23), [23, 30), [30, 40), [40, +∞) 之类——具体区间边界取决于算法合并次序与停止策略。
4. 实现与示例(Python 思路)
在 Python 中,通常没有一个官方内置的 chi-square binning
函数,但可以手写脚本或结合第三方库。算法步骤如下:
import numpy as np
import pandas as pd
def chi_merge(data, col, target, max_bins=5, min_samples=0):
"""
data: DataFrame
col: str, 要分箱的列名
target: str, 目标变量列名(0/1)
max_bins: 最终期望的最大箱数
min_samples: 每个箱最少包含的样本数(可选)
"""
# 1. 仅保留 col, target 两列并排序
df = data[[col, target]].copy()
df = df.dropna().sort_values(by=col).reset_index(drop=True)
# 2. 初始箱 - 每个唯一值是一个箱
# 计算每个箱中的好/坏(或者 0/1)频数
# 这里假设 target=1 表示违约,target=0 表示未违约
# bin_info 列表形式存储,每个元素是(区间上边界, [坏样本数, 好样本数], 样本总数)
bin_info = []
unique_vals = df[col].unique()
for val in unique_vals:
sub = df[df[col] == val]
bad = sub[sub[target] == 1].shape[0]
good = sub[sub[target] == 0].shape[0]
bin_info.append({
"value": val,
"bad": bad,
"good": good,
"count": bad + good
})
# 3. 按数值从小到大排列
bin_info = sorted(bin_info, key=lambda x: x["value"])
# 4. 合并相邻箱
def chi2_stat(binA, binB):
# binA, binB 分别为两段区间的统计信息
# col_1 = [badA, goodA], col_2 = [badB, goodB]
badA, goodA = binA["bad"], binA["good"]
badB, goodB = binB["bad"], binB["good"]
totalA = badA + goodA
totalB = badB + goodB
# 2x2 卡方值计算
# 观测频数:O = [[badA, goodA],
# [badB, goodB]]
# 期望频数:E_ij = (row_sum * col_sum) / total_sum
# row_sum_1 = badA + goodA, row_sum_2 = badB + goodB
# col_sum_bad = badA + badB, col_sum_good = goodA + goodB
total = totalA + totalB
col_sum_bad = badA + badB
col_sum_good = goodA + goodB
# E(A_bad) = row_sum_A * col_sum_bad / total
def chi_square(O, E):
return 0 if E == 0 else (O - E)**2 / E
# 分别计算4个格子
eA_bad = totalA * col_sum_bad / total
eA_good = totalA * col_sum_good / total
eB_bad = totalB * col_sum_bad / total
eB_good = totalB * col_sum_good / total
chi_sq = (chi_square(badA, eA_bad) +
chi_square(goodA, eA_good) +
chi_square(badB, eB_bad) +
chi_square(goodB, eB_good))
return chi_sq
while len(bin_info) > max_bins:
# 计算所有相邻箱的卡方值,取最小者
min_chi = float("inf")
min_idx = None
for i in range(len(bin_info) - 1):
chi_val = chi2_stat(bin_info[i], bin_info[i+1])
if chi_val < min_chi:
min_chi = chi_val
min_idx = i
# 合并 min_idx 和 min_idx+1
bin_info[min_idx]["bad"] += bin_info[min_idx+1]["bad"]
bin_info[min_idx]["good"] += bin_info[min_idx+1]["good"]
bin_info[min_idx]["count"] += bin_info[min_idx+1]["count"]
# 上边界更新为右侧箱的 value
bin_info[min_idx]["value"] = bin_info[min_idx+1]["value"]
del bin_info[min_idx+1]
# 可增加其他停止条件,如 min_samples 等
if len(bin_info) <= max_bins:
break
# 得到分箱边界
cut_points = []
start_val = None
for b in bin_info:
if start_val is None:
start_val = b["value"]
cut_points.append(b["value"])
# cut_points 最后一个元素即最大值
return cut_points
说明:
- 以上只是示例,实际生产环境可能会有更复杂的逻辑(如缺失值处理、最少样本数、合并后 p 值判断等)。
- 函数返回的
cut_points
即为每个箱的上边界,可用它来对原始数据进行pd.cut()
等操作。
5. 卡方分箱的注意事项
-
初始箱的确定
- 通常以“每个取值”为初始箱,但如果数据量大、取值过多,会导致初始箱数庞大,计算量高。可先做分段或预合并,比如按分位数拆分或连续相同值归为一箱。
-
最小样本数约束
- 为避免某些箱样本量过少造成统计不稳定(WOE 波动大),往往要求每个箱至少含有一定比例的样本。若某箱低于门槛,就强制与相邻箱合并。
-
目标箱数
- 评分卡领域,通常将最终箱数控制在 5~10 个左右,每个区间能有较好的区分度又不会太稀碎。
-
单调性
- 有时我们期望分箱后的变量对目标变量呈单调关系(如违约率随收入递减);若分箱结果不单调,需要考虑合并或调整,使得最终分箱违约率随着区间的增大或减小而单调。这在评分卡建模中是常见需求,有利于模型可解释性与收敛稳定性。
-
对多分类或连续目标
- 卡方分箱通常应用在二分类目标;对于多分类或连续目标的情况,可使用其他分箱准则(如信息增益、方差、熵等),或将多分类合并为“正/负”形式,再做类似操作。
-
与IV值和WOE结合
- 分箱结果通常用 WOE(Weight of Evidence) 和 IV(Information Value) 来衡量区分度;若某一箱的 WOE 出现异常波动或与整体趋势矛盾,可能需要重新合并或拆分箱。
6. 在风控及评分卡中的应用
-
信用评分卡
- 对关键的数值型变量(如年龄、收入、授信额度、历史逾期次数等)通过卡方分箱,得到若干区间;
- 计算各区间的 WOE 值,搭建逻辑回归模型;
- 保证模型可解释性:各区间对应的风险水平清晰可见。
-
用户分层
- 某些场景需要基于用户特征做分层(如消费金额、还款行为),卡方分箱能自动找到区分度大的切分点。
-
PD(违约概率)建模
- 商业银行在巴塞尔协议下常需要对客户违约概率进行估计,也可先做卡方分箱再计算违约率,进行 PD 曲线拟合。
7. 小结
- 卡方分箱通过卡方统计量来衡量合并前后的“目标变量分布差异”,逐步把相似区间合并,形成更少、更稳定的分箱区间。
- 该方法在评分卡等风控建模中非常常见,能够有效捕捉到同质区间并减少噪声。
- 实际使用中,需要结合最小样本数、目标箱数、单调性等多方面要求来调整合并逻辑。
- 分箱完成后,往往会搭配 WOE / IV 等指标进行评估,以确保分箱对区分度提升和可解释性具有帮助。
总之,卡方分箱是一种巧妙利用卡方检验思想的区间划分方法,能为评分卡或传统风控模型提供更优质的离散化特征,帮助模型稳定且可解释地评估风险。