第3章 卡方分箱原理:基于卡方检验的自适应分箱方法
卡方分箱这个名字,听起来挺唬人。但我告诉你它到底在干嘛——说白了,就是让数据自己决定哪些相邻的区间可以合并,而不是靠你拍脑袋定边界。我当年做信用评分卡时第一次接触它,当时就一个感觉:这玩意儿比手动分箱靠谱太多了。
3.1 卡方检验在分箱里到底怎么用
卡方检验本是用来判断两个分类变量是否独立的。放到分箱场景里,我们关心的是:相邻两个箱,它们的正负样本占比是否真的不同。
举个具体例子。假设我们正在处理年龄特征,目标是「是否逾期」。现在有两个相邻的年龄区间:20-25 岁和 26-30 岁。如果这两个区间的逾期率差不多,那把它们合成一个箱,损失的信息很少。反过来,如果逾期率差异很大,硬要合在一起反而会模糊掉特征和目标的真实关系。
卡方统计量的计算我简化一下:
χ² = Σ (观测频数 - 期望频数)² / 期望频数
观测频数就是每个箱里的实际正负样本数。期望频数是假设两个箱分布一致时,理论上应该有多少。卡方值越小,说明两个箱越「像」,越该合并。
3.2 卡方分箱的完整流程
我个人习惯把卡方分箱理解成一个自底向上的归并过程。下面这张图可以帮你把整个链路刻在脑子里:
流程其实就五个步骤,你按照这个节奏来就不会乱:
- 排序初始化——把特征值从小到大排好,每个值单独当做一个箱。
- 算卡方——逐一计算相邻两个箱的卡方统计量,得到一个卡方值列表。
- 找最小——从列表里挑出最小的那个卡方值,它对应的两个箱差异最小。
- 合并——把那两个箱合二为一,箱数减一。
- 判停——检查是否达到了你设的最大箱数,或者所有卡方值都超过了阈值。没达到就回到步骤2继续。
这个逻辑有点像聚类里的凝聚层次聚类,但合并的依据换成了卡方检验。
3.3 Python 代码实现
我带你把上面的流程翻译成可运行的代码。这里实现一个 ChiMerge 类,保持和 sklearn 风格一致,有 fit 和 transform。
import numpy as np
import pandas as pd
from scipy.stats import chi2_contingency
class ChiMerge:
"""卡方分箱实现,自底向上合并相邻箱"""
def __init__(self, max_bins=5, chi_threshold=0.95, min_samples=30):
self.max_bins = max_bins
self.chi_threshold = chi_threshold
self.min_samples = min_samples
self.split_points = None
def _chi_square(self, c1, c2):
"""计算两个箱的卡方统计量"""
table = np.array([c1, c2])
# 如果某行或某列全为0,返回无穷大(不合并)
if table.shape[1] < 2 or np.any(table.sum(axis=1) == 0):
return float('inf')
chi2, p, _, _ = chi2_contingency(table, correction=False)
return chi2
def fit(self, X, y):
"""训练分箱,找到最优切分点"""
df = pd.DataFrame({'X': X, 'y': y})
df = df.sort_values('X').reset_index(drop=True)
# 初始化:每个值独立成箱
bins = []
for val in df['X'].unique():
mask = df['X'] == val
pos = mask & (df['y'] == 1)
neg = mask & (df['y'] == 0)
bins.append({
'min': val, 'max': val,
'pos': pos.sum(), 'neg': neg.sum(),
'count': mask.sum()
})
# 边界检查:箱内样本数不能小于min_samples
# 迭代合并
while len(bins) > self.max_bins:
chi_values = []
for i in range(len(bins) - 1):
c1 = [bins[i]['pos'], bins[i]['neg']]
c2 = [bins[i + 1]['pos'], bins[i + 1]['neg']]
chi2 = self._chi_square(c1, c2)
chi_values.append(chi2)
min_idx = np.argmin(chi_values)
# 如果最小卡方值对应的p值大于阈值,说明差异不显著,必须合并
# 这里用卡方值本身做判断,也可以转为p值
chi2_min = chi_values[min_idx]
# 计算p值
_, p, _, _ = chi2_contingency(
np.array([[bins[min_idx]['pos'], bins[min_idx]['neg']],
[bins[min_idx + 1]['pos'], bins[min_idx + 1]['neg']]]),
correction=False
)
# 当p值大于(1 - chi_threshold)时合并,或箱数仍超过max_bins
if p > (1 - self.chi_threshold) or len(bins) > self.max_bins:
# 合并相邻箱
bins[min_idx]['max'] = bins[min_idx + 1]['max']
bins[min_idx]['pos'] += bins[min_idx + 1]['pos']
bins[min_idx]['neg'] += bins[min_idx + 1]['neg']
bins[min_idx]['count'] += bins[min_idx + 1]['count']
bins.pop(min_idx + 1)
else:
# 卡方值已经全部显著,停止合并
break
# 提取切分点
self.split_points = [b['min'] for b in bins] + [bins[-1]['max']]
self.split_points = sorted(set(self.split_points))
return self
def transform(self, X):
"""应用分箱,返回箱编号"""
if self.split_points is None:
raise ValueError("请先调用 fit 方法")
return pd.cut(X, bins=self.split_points, labels=False, include_lowest=True)
def fit_transform(self, X, y):
self.fit(X, y)
return self.transform(X)
3.4 关键参数与调优
卡方分箱的参数不多,但每个都很「要命」。我整理了一张表,方便你对照调参:
| 参数 | 作用 | 典型范围 | 我的建议 |
|---|---|---|---|
max_bins |
控制最终箱数的上限 | 3 ~ 10 | 先设 5,跑完看单调性再调 |
chi_threshold |
卡方检验的显著性门槛,值越大合并越激进 | 0.85 ~ 0.99 | 我个人习惯从 0.95 起步 |
min_samples |
每个箱最少样本数,防止统计失效 | 20 ~ 100 | 看数据量,数据少就设 20 |
调优节奏我一般是这么走的:
- 先用
max_bins=10、chi_threshold=0.95跑一轮,看合并后实际得到几个箱。 - 如果箱数过多(比如 8 个以上),我拉高
chi_threshold到 0.98 或 0.99,让合并更积极。 - 如果箱数太少(比如只剩 2 个),我降低
chi_threshold到 0.90,或者提高max_bins的上限。 - 最后检查每个箱的样本量——任何一个箱的样本数少于 30,卡方检验的结果就不可信了。
3.5 实战避坑指南
做卡方分箱这几年,我踩过不少坑。挑几个典型的跟你聊聊:
第一个坑:忽视样本量不足。 我曾经在一个信贷项目中处理「居住年限」特征,数据集中在 1-5 年,后面 15-20 年的样本寥寥无几。卡方检验在样本少的箱上算出来的 p 值很不稳定,动不动就认为差异显著,导致该合并的没合并。后来我强制每个箱至少 50 个样本,才把结果稳住。
第二个坑:没检查单调性。 卡方分箱只管分布差异,不管 bad rate 是否单调。但你想想看,如果分完箱后 bad rate 忽上忽下,模型学到的就是噪音而不是趋势。我建议你在分箱完成后,手动算一下每个箱的坏样本率,画个折线图看一眼。不单调就微调切分点,或者跟业务规则结合一下。
第三个坑:跟缺失值较劲。 卡方分箱不能直接处理缺失值。我记得第一次用的时候,没注意数据里有 NaN,结果分箱结果全乱了套。现在我的习惯是:分箱前先用 -999 或单独类别把缺失值填好,等分箱完毕后,再单独决定缺失值算哪个箱。
嗯,如果你正打算把卡方分箱用到自己的项目里,建议你先拿一个特征跑通流程,看看切分点是否符合直觉。代码有了,参数表也有了,剩下的就是动手试。