hscredit.core.binning.uniform_binning 源代码
"""等距分箱算法.
基于数值范围等距切分的分箱方法,适用于均匀分布的数据。
"""
from typing import Union, List, Dict, Optional, Any, Sequence
import numpy as np
import pandas as pd
from ...exceptions import NotFittedError
from .base import BaseBinning
[文档]
class UniformBinning(BaseBinning):
"""等距分箱.
将特征值的范围等分为指定数量的区间,每个区间宽度相同。
适用于数据分布相对均匀的场景。
:param max_n_bins: 最大分箱数,默认为5
:param min_n_bins: 最小分箱数,默认为2
:param min_bin_size: 每箱最小样本占比,默认为0.01
:param max_bin_size: 每箱最大样本占比,默认为None
:param min_bad_rate: 每箱最小坏样本率,默认为0.0
:param monotonic: 是否要求单调性,默认为False
:param missing_separate: 缺失值是否单独分箱,默认为True
:param special_codes: 特殊值列表,默认为None,如[-999, -98]
:param left_clip: 左侧截断分位数,默认为None,如0.01表示截断1%分位数以下的值
:param right_clip: 右侧截断分位数,默认为None,如0.99表示截断99%分位数以上的值
:param force_numerical: 是否强制作为数值型处理,默认为False(自动识别类别型)
- True: 将所有特征视为数值型进行等距分箱(默认,因为等距分箱适用于数值型)
- False: 自动检测特征类型(根据dtype判断)
:param random_state: 随机种子,默认为None
**参考样例**
>>> from hscredit.core.binning import UniformBinning
>>> # 基础用法
>>> binner = UniformBinning(max_n_bins=5)
>>> binner.fit(X_train, y_train)
>>> X_binned = binner.transform(X_test)
>>>
>>> # 使用截断处理异常值
>>> binner = UniformBinning(max_n_bins=5, left_clip=0.01, right_clip=0.99)
>>> binner.fit(X_train, y_train)
>>>
>>> # 指定特殊值
>>> binner = UniformBinning(max_n_bins=5, special_codes=[-999, -98])
>>> binner.fit(X_train, y_train)
**注意**
等距分箱的特点:
1. 每个分箱的区间宽度相同
2. 分箱边界由 (max - min) / n_bins 计算得出
3. 支持通过left_clip/right_clip截断异常值
4. 支持通过special_codes处理特殊值(如-999表示缺失)
5. 默认force_numerical=False,自动识别类别型;如需强制数值等距分箱可显式设为 True
6. 计算速度快,实现简单;为无监督方法,不使用标签 ``y`` 决定切分
7. 对偏态分布或含极端值的特征不友好(可能某些箱样本极少),此时优先用等频分箱
**引用**
等距(equal-width)离散化综述见 Dougherty, J., Kohavi, R., & Sahami, M. (1995).
*Supervised and Unsupervised Discretization of Continuous Features.* ICML-95.
https://ai.stanford.edu/~ronnyk/disc.pdf
"""
def __init__(
self,
max_n_bins: int = 5,
min_n_bins: int = 2,
min_bin_size: Union[float, int] = 0.01,
max_bin_size: Optional[Union[float, int]] = None,
min_bad_rate: float = 0.0,
monotonic: Union[bool, str] = False,
missing_separate: bool = True,
special_codes: Optional[List] = None,
cat_cutoff: Optional[Union[float, int]] = None,
category_order=None,
handle_unknown: Union[int, str] = -3,
left_clip: Optional[float] = None,
right_clip: Optional[float] = None,
force_numerical: bool = False,
random_state: Optional[int] = None,
n_jobs: Union[int, float] = -1,
parallel_backend: Optional[str] = None,
parallel_config: Optional[Dict[str, Any]] = None,
user_splits: Optional[Dict[str, List]] = None,
user_splits_fixed: Optional[Union[bool, Dict[str, Union[bool, Sequence[bool]]]]] = None,
**kwargs,
):
super().__init__(
max_n_bins=max_n_bins,
min_n_bins=min_n_bins,
min_bin_size=min_bin_size,
max_bin_size=max_bin_size,
min_bad_rate=min_bad_rate,
monotonic=monotonic,
missing_separate=missing_separate,
special_codes=special_codes,
cat_cutoff=cat_cutoff,
user_splits=user_splits,
user_splits_fixed=user_splits_fixed,
category_order=category_order,
handle_unknown=handle_unknown,
random_state=random_state,
n_jobs=n_jobs,
parallel_backend=parallel_backend,
parallel_config=parallel_config,
**kwargs,
)
self.left_clip = left_clip
self.right_clip = right_clip
self.force_numerical = force_numerical
[文档]
def fit(
self, X: Union[pd.DataFrame, np.ndarray], y: Optional[Union[pd.Series, np.ndarray]] = None, **kwargs
) -> "UniformBinning":
"""拟合等距分箱.
:param X: 训练数据
:param y: 目标变量
:return: 拟合后的分箱器
"""
# 检查输入数据
X, y = self._check_input(X, y)
# 对每个特征进行分箱
self._fit_features(X, y, "_fit_feature")
self._finalize_categorical_fit()
self._finalize_reserved_bins(X, y)
self._is_fitted = True
return self
def _fit_feature(self, feature: str, X: pd.Series, y: pd.Series) -> None:
"""对单个特征进行分箱.
:param feature: 特征名
:param X: 特征数据
:param y: 目标变量
"""
# 检测特征类型
if self.force_numerical:
feature_type = "numerical"
else:
feature_type = self._detect_feature_type(X)
self.feature_types_[feature] = feature_type
# 处理缺失值和特殊值
missing_mask = X.isna()
special_mask = pd.Series(False, index=X.index)
if self.special_codes:
special_mask = X.isin(self.special_codes)
# 获取有效数据(非缺失、非特殊值)
valid_mask = ~(missing_mask | special_mask)
X_valid = X[valid_mask]
if feature_type == "categorical":
# 类别型变量:每个类别作为一个箱
unique_values = X_valid.unique()
self.splits_[feature] = np.array([]) # 类别型没有数值切分点
self.n_bins_[feature] = len(unique_values)
else:
# 数值型变量:等距分箱
# 转换为数值型,确保正确处理
X_numeric = pd.to_numeric(X_valid, errors="coerce")
X_numeric = X_numeric.dropna()
if len(X_numeric) == 0:
# 没有有效数值数据
self.splits_[feature] = np.array([])
self.n_bins_[feature] = 1
else:
# 应用截断(如果指定)
min_val = X_numeric.min()
max_val = X_numeric.max()
# 保存原始边界用于后续处理
clip_lower = None
clip_upper = None
if self.left_clip is not None and 0 <= self.left_clip < 1:
clip_lower = X_numeric.quantile(self.left_clip)
min_val = clip_lower
if self.right_clip is not None and 0 < self.right_clip <= 1:
clip_upper = X_numeric.quantile(self.right_clip)
max_val = clip_upper
# 保存截断边界
self.clip_bounds_ = getattr(self, "clip_bounds_", {})
self.clip_bounds_[feature] = (clip_lower, clip_upper)
# 计算切分点
n_bins = max(self.min_n_bins, min(self.max_n_bins, 10))
# 处理边界相同的情况(所有值相等)
if max_val == min_val:
self.splits_[feature] = np.array([])
self.n_bins_[feature] = 1
else:
bin_width = (max_val - min_val) / n_bins
# 生成切分点(不包括边界)
splits = []
for i in range(1, n_bins):
split_point = min_val + i * bin_width
splits.append(split_point)
self.splits_[feature] = self._round_splits(splits)
self.n_bins_[feature] = len(splits) + 1
# 生成分箱索引
bins = self._assign_bins(X, feature)
# 计算分箱统计
bin_table = self._compute_bin_stats(feature, X, y, bins)
self.bin_tables_[feature] = bin_table
def _assign_bins(self, X: pd.Series, feature: str) -> np.ndarray:
"""为数据分配分箱索引.
:param X: 特征数据
:param feature: 特征名
:return: 分箱索引数组
"""
if self.feature_types_[feature] == "categorical" and feature in self._cat_bins_:
return self._assign_categorical_bins(feature, X)
if self.feature_types_[feature] == "categorical":
# 类别型:使用类别编码
return pd.Categorical(X).codes
else:
# 数值型:使用切分点
splits = self.splits_[feature]
# 获取截断边界
clip_lower, clip_upper = None, None
if hasattr(self, "clip_bounds_") and feature in self.clip_bounds_:
clip_lower, clip_upper = self.clip_bounds_[feature]
# 处理缺失值和特殊值
bins = np.zeros(len(X), dtype=int)
for i, val in enumerate(X):
if pd.isna(val):
bins[i] = -1 # 缺失值
elif self.special_codes and val in self.special_codes:
bins[i] = -2 # 特殊值
else:
# 尝试转换为数值
try:
val_numeric = float(val)
except (ValueError, TypeError):
# 无法转换为数值,使用哈希分配
val_numeric = hash(val) % (2**31)
# 应用截断
if clip_lower is not None and val_numeric < clip_lower:
val_numeric = clip_lower
if clip_upper is not None and val_numeric > clip_upper:
val_numeric = clip_upper
# 找到对应的分箱
if len(splits) == 0:
bin_idx = 0
else:
bin_idx = np.searchsorted(splits, val_numeric, side="right")
bins[i] = bin_idx
return bins
[文档]
def transform(
self, X: Union[pd.DataFrame, np.ndarray], metric: str = "indices", **kwargs
) -> Union[pd.DataFrame, np.ndarray]:
"""应用分箱转换.
将原始特征值转换为分箱索引、分箱标签或WOE值。
:param X: 待转换数据, DataFrame或数组格式
:param metric: 转换类型, 可选值:
- 'indices': 返回分箱索引 (0, 1, 2, ...), 用于后续处理
- 'bins': 返回分箱标签字符串, 用于可视化或报告
- 'woe': 返回WOE值, 用于逻辑回归建模
:param kwargs: 其他参数
:return: 转换后的数据, 格式与输入X相同
:example:
>>> binner = UniformBinning()
>>> binner.fit(X_train, y_train)
>>>
>>> # 获取分箱索引
>>> X_binned = binner.transform(X_test, metric='indices')
>>>
>>> # 获取WOE编码 (用于建模)
>>> X_woe = binner.transform(X_test, metric='woe')
"""
if not self._is_fitted:
raise NotFittedError("分箱器尚未拟合,请先调用fit方法")
# 转换为DataFrame
if not isinstance(X, pd.DataFrame):
if isinstance(X, np.ndarray) and X.ndim == 1:
X = pd.DataFrame(X, columns=list(self.feature_names_in_))
else:
X = pd.DataFrame(X, columns=list(self.feature_names_in_))
else:
unknown = [feature for feature in X.columns if feature not in self.splits_]
if unknown:
raise KeyError(f"转换数据包含未拟合特征: {unknown}")
return self._transform_binning_features(
X, metric, lambda feature: self._assign_bins(X[feature], feature), woe_default=0.0
)