hscredit.core.binning.target_bad_rate_binning 源代码

"""目标坏样本率分箱算法.

基于目标坏样本率的分箱方法,支持两种模式:
1. 严格边界模式:按指定的坏样本率边界严格划分
2. 自动模式:自动寻找使每箱间坏样本率差异最大的划分
"""

from typing import Union, List, Dict, Optional, Any, Tuple
import numpy as np
import pandas as pd
import warnings

from ...exceptions import NotFittedError
from .base import BaseBinning


[文档] class TargetBadRateBinning(BaseBinning): """目标坏样本率分箱. 支持两种分箱模式: **模式1:严格边界模式**(指定 target_bad_rates) 按目标坏样本率边界严格划分,确保每箱的坏样本率在指定区间内。 :param target_bad_rates: 目标坏样本率边界列表,例如 [0.05, 0.10, 0.20] - 会产生 len(target_bad_rates)+1 个分箱 - 第0箱:坏样本率 <= target_bad_rates[0] - 第1箱:target_bad_rates[0] < 坏样本率 <= target_bad_rates[1] - 依此类推 **模式2:自动模式**(不指定 target_bad_rates,指定 max_n_bins) 自动寻找使每箱之间坏样本率差异最大的划分。 :param max_n_bins: 最大分箱数,默认为5 :param min_n_bins: 最小分箱数,默认为2 :param min_bin_size: 每箱最小样本数或占比,默认为0.01 :param max_bin_size: 每箱最大样本数或占比,默认为None :param strict_mode: 是否严格模式(严格限制边界),默认为True - True: 严格按照目标坏样本率边界划分,可能产生空箱 - False: 在满足约束下尽量接近目标坏样本率 :param merge_empty_bins: 是否合并空箱,默认为True :param monotonic: 是否要求单调性,默认为True :param missing_separate: 缺失值是否单独分箱,默认为True :param special_codes: 特殊值列表,默认为None :param decimal: 切分点小数点保留精度,默认为4 **参考样例** 严格边界模式: >>> # 指定坏样本率边界:5%, 10%, 20% >>> binner = TargetBadRateBinning( ... target_bad_rates=[0.05, 0.10, 0.20], ... strict_mode=True ... ) >>> # 结果:4个分箱,坏样本率分别在 <=5%, 5%-10%, 10%-20%, >20% 自动模式: >>> # 自动寻找最优划分 >>> binner = TargetBadRateBinning(max_n_bins=5) >>> # 结果:5个分箱,每箱间坏样本率差异最大 **注意** 本方法以"业务目标坏样本率"为切分依据,属业务驱动的风险分层分箱(risk-based segmentation),无单一学术出处;严格边界模式便于将分箱直接对齐既定的风险定价/ 准入档位。坏样本率(bad rate)即各箱内坏样本占比。 """ def __init__( self, target: str = "target", target_bad_rates: Optional[List[float]] = None, 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, strict_mode: bool = True, merge_empty_bins: bool = True, monotonic: bool = True, 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, random_state: Optional[int] = None, decimal: int = 4, 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, List[bool]]]]] = None, **kwargs, ): super().__init__( target=target, 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, decimal=decimal, n_jobs=n_jobs, parallel_backend=parallel_backend, parallel_config=parallel_config, **kwargs, ) self.target_bad_rates = sorted(target_bad_rates) if target_bad_rates else None self.strict_mode = strict_mode self.merge_empty_bins = merge_empty_bins self._actual_rates: Dict[str, List[float]] = {}
[文档] def fit( self, X: Union[pd.DataFrame, np.ndarray], y: Optional[Union[pd.Series, np.ndarray]] = None, **kwargs ) -> "TargetBadRateBinning": """拟合目标坏样本率分箱. :param X: 训练数据 :param y: 目标变量 :return: 拟合后的分箱器 """ X, y = self._check_input(X, y) self._fit_features(X, y, "_fit_feature") self._apply_post_fit_constraints(X, y, enforce_monotonic=True) 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: """对单个特征进行分箱.""" 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] y_valid = y[valid_mask] if feature_type == "categorical": splits, cat_bins = self._fit_categorical(X_valid, y_valid) if cat_bins: self._cat_bins_[feature] = cat_bins self.splits_[feature] = cat_bins self.n_bins_[feature] = len(cat_bins) else: self.splits_[feature] = [] self.n_bins_[feature] = len(X_valid.unique()) if len(X_valid) > 0 else 0 else: splits = self._fit_numerical(X_valid, y_valid) 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 # 记录实际坏样本率 if feature_type == "categorical": valid_bins = self._assign_bins(X_valid, feature) actual_rates = [] for bin_idx in sorted(idx for idx in np.unique(valid_bins) if idx >= 0): bin_mask = valid_bins == bin_idx if bin_mask.any(): actual_rates.append(float(y_valid[bin_mask].mean())) self._actual_rates[feature] = actual_rates else: self._actual_rates[feature] = self._compute_actual_rates(X_valid, y_valid, splits) def _fit_numerical(self, X: pd.Series, y: pd.Series) -> List[float]: """对数值型变量进行分箱.""" if len(X) == 0: return [] if self.target_bad_rates is not None: # 严格边界模式 return self._fit_with_target_rates(X, y) else: # 自动模式:寻找最优划分 return self._fit_auto(X, y) def _fit_with_target_rates(self, X: pd.Series, y: pd.Series) -> List[float]: """按目标坏样本率边界严格划分. 算法思路: 1. 将数据按特征值排序 2. 计算每个可能切割点的累积坏样本率 3. 找到累积坏样本率最接近目标边界的切割点 """ x_vals = X.values y_vals = y.values n_samples = len(x_vals) # 计算样本数约束 min_samples = self._get_min_samples(n_samples) # 排序 sorted_idx = np.argsort(x_vals) x_sorted = x_vals[sorted_idx] y_sorted = y_vals[sorted_idx] # 计算累积坏样本数和累积样本数 cum_bad = np.cumsum(y_sorted) cum_total = np.arange(1, n_samples + 1) # 计算每个位置的累积坏样本率 cum_rate = cum_bad / cum_total # 计算全局坏样本率 global_bad_rate = y_sorted.mean() # 确定目标边界(考虑全局坏样本率) target_rates = self.target_bad_rates.copy() # 如果全局坏样本率不在目标范围内,添加到边界 if global_bad_rate < target_rates[0]: target_rates = [global_bad_rate] + target_rates if global_bad_rate > target_rates[-1]: target_rates = target_rates + [global_bad_rate] target_rates = sorted(set(target_rates)) splits = [] # 对每个目标边界,找到最佳切割点 for target in target_rates: if target >= global_bad_rate: # 目标坏样本率高于全局,需要在数据后半部分找 # 找到累积坏样本率最接近目标的位置 diff = np.abs(cum_rate - target) else: # 目标坏样本率低于全局,需要在数据前半部分找 diff = np.abs(cum_rate - target) # 在满足最小样本数的范围内搜索 valid_start = min_samples valid_end = n_samples - min_samples if valid_start >= valid_end: continue # 在有效范围内找最接近目标的位置 valid_diff = diff[valid_start:valid_end] if len(valid_diff) == 0: continue best_pos = valid_start + np.argmin(valid_diff) # 确定切割点 split_val = (x_sorted[best_pos] + x_sorted[best_pos + 1]) / 2 # 避免重复切割点 if split_val not in splits: splits.append(split_val) # 合并空箱或样本数过少的箱 if self.merge_empty_bins: splits = self._merge_small_bins(x_sorted, y_sorted, splits, min_samples) return sorted(splits) def _fit_auto(self, X: pd.Series, y: pd.Series) -> List[float]: """自动模式:寻找使每箱间坏样本率差异最大的划分. 使用动态规划或贪心算法,在满足约束下最大化箱间差异。 """ x_vals = X.values y_vals = y.values n_samples = len(x_vals) min_samples = self._get_min_samples(n_samples) n_bins = self.max_n_bins # 排序 sorted_idx = np.argsort(x_vals) x_sorted = x_vals[sorted_idx] y_sorted = y_vals[sorted_idx] # 使用贪心算法找最优划分 # 目标:最大化相邻箱之间的坏样本率差异 splits = [] # 计算所有可能的切割点及其对应的坏样本率 candidates = [] for i in range(min_samples, n_samples - min_samples): left_rate = y_sorted[:i].mean() right_rate = y_sorted[i:].mean() # 差异度量:相邻箱坏样本率的绝对差异 diff = abs(left_rate - right_rate) # 检查最小样本数约束 left_count = i right_count = n_samples - i if left_count >= min_samples and right_count >= min_samples: split_val = (x_sorted[i - 1] + x_sorted[i]) / 2 candidates.append((i, split_val, diff, left_rate, right_rate)) if not candidates: return [] # 按差异排序,选择差异最大的切割点 candidates.sort(key=lambda x: x[2], reverse=True) # 贪心选择切割点 selected_positions = [] for pos, split_val, diff, left_rate, right_rate in candidates: if len(selected_positions) >= n_bins - 1: break # 检查与已选切割点的距离是否满足最小样本数 valid = True for selected_pos in selected_positions: if abs(pos - selected_pos) < min_samples: valid = False break if valid: selected_positions.append(pos) splits.append(split_val) # 按位置排序 splits = [s for _, s in sorted(zip(selected_positions, splits))] # 验证并调整分箱 if len(splits) > 0: splits = self._validate_and_adjust_splits(x_sorted, y_sorted, splits, min_samples) return sorted(splits) def _validate_and_adjust_splits( self, x_sorted: np.ndarray, y_sorted: np.ndarray, splits: List[float], min_samples: int ) -> List[float]: """验证并调整分箱,确保满足约束.""" if not splits: return splits n_samples = len(x_sorted) positions = [] for split in splits: pos = np.searchsorted(x_sorted, split, side="right") positions.append(pos) positions = sorted(positions) # 检查每个箱的样本数 valid_positions = [] prev_pos = 0 for pos in positions: if pos - prev_pos >= min_samples and n_samples - pos >= min_samples: valid_positions.append(pos) prev_pos = pos # 根据有效位置重建切割点 valid_splits = [] for pos in valid_positions: if pos < n_samples: split_val = (x_sorted[pos - 1] + x_sorted[pos]) / 2 valid_splits.append(split_val) return valid_splits def _merge_small_bins( self, x_sorted: np.ndarray, y_sorted: np.ndarray, splits: List[float], min_samples: int ) -> List[float]: """合并样本数过少的相邻箱.""" if not splits: return splits n_samples = len(x_sorted) positions = [np.searchsorted(x_sorted, s, side="right") for s in sorted(splits)] # 计算每个箱的样本数 boundaries = [0] + positions + [n_samples] bin_counts = [boundaries[i + 1] - boundaries[i] for i in range(len(boundaries) - 1)] # 找出需要合并的箱 valid_splits = [] for i, (pos, count) in enumerate(zip(positions, bin_counts[:-1])): if count >= min_samples and bin_counts[i + 1] >= min_samples: valid_splits.append(sorted(splits)[i]) return valid_splits def _fit_categorical(self, X: pd.Series, y: pd.Series) -> Tuple[List[float], List[List[Any]]]: """对类别型变量进行分箱.""" # 计算每个类别的坏样本率 df = pd.DataFrame({"X": X, "y": y}) category_stats = df.groupby("X")["y"].agg(["mean", "count"]).reset_index() category_stats.columns = ["category", "bad_rate", "count"] n_total = len(X) min_samples = self._get_min_samples(n_total) # 过滤样本数过少的类别 category_stats = category_stats[category_stats["count"] >= min_samples] if len(category_stats) <= 1: categories = category_stats["category"].tolist() return [], [[category] for category in categories] # 按坏样本率排序 category_stats = category_stats.sort_values("bad_rate").reset_index(drop=True) if self.target_bad_rates is not None: # 严格边界模式 splits = self._fit_categorical_with_target_rates(category_stats, n_total, min_samples) else: # 自动模式 splits = self._fit_categorical_auto(category_stats, n_total, min_samples) categories = category_stats["category"].tolist() cat_bins = self._build_categorical_bins(categories, splits) return splits, cat_bins @staticmethod def _build_categorical_bins(categories: List[Any], splits: List[float]) -> List[List[Any]]: """根据排序后的类别和切分点构造类别分组.""" if not categories: return [] if not splits: return [[category] for category in categories] boundaries = [0] boundaries.extend(int(split + 0.5) for split in sorted(splits)) boundaries.append(len(categories)) cat_bins = [] for start, end in zip(boundaries[:-1], boundaries[1:]): if start < end: cat_bins.append(categories[start:end]) return cat_bins def _fit_categorical_with_target_rates( self, category_stats: pd.DataFrame, n_total: int, min_samples: int ) -> List[float]: """类别型变量按目标坏样本率边界划分.""" splits = [] for target in self.target_bad_rates: best_idx = None best_diff = float("inf") for idx in range(len(category_stats)): temp_count = category_stats.iloc[: idx + 1]["count"].sum() temp_bad = (category_stats.iloc[: idx + 1]["bad_rate"] * category_stats.iloc[: idx + 1]["count"]).sum() if temp_count < min_samples: continue if n_total - temp_count < min_samples: break temp_rate = temp_bad / temp_count diff = abs(temp_rate - target) if diff < best_diff: best_diff = diff best_idx = idx if best_idx is not None and best_idx < len(category_stats) - 1: split_point = best_idx + 0.5 if split_point not in splits: splits.append(split_point) return sorted(splits) def _fit_categorical_auto(self, category_stats: pd.DataFrame, n_total: int, min_samples: int) -> List[float]: """类别型变量自动模式:最大化箱间差异.""" n_cats = len(category_stats) splits = [] # 计算累积统计 cum_count = category_stats["count"].cumsum().values cum_bad = (category_stats["bad_rate"] * category_stats["count"]).cumsum().values candidates = [] for i in range(n_cats - 1): left_count = cum_count[i] right_count = n_total - left_count if left_count < min_samples or right_count < min_samples: continue left_rate = cum_bad[i] / left_count right_rate = (cum_bad[-1] - cum_bad[i]) / right_count diff = abs(left_rate - right_rate) candidates.append((i + 0.5, diff)) # 按差异排序,选择最大的几个 candidates.sort(key=lambda x: x[1], reverse=True) for split, _ in candidates: if len(splits) >= self.max_n_bins - 1: break splits.append(split) return sorted(splits) def _compute_actual_rates(self, X: pd.Series, y: pd.Series, splits: List[float]) -> List[float]: """计算各箱的实际坏样本率.""" if len(X) == 0: return [] if not splits: return [y.mean()] y_vals = y.values sorted_splits = sorted(splits) # 数值特征直接按切分点分箱;非数值特征先转为类别编码, # 避免 np.searchsorted 在字符串与浮点切分点比较时报错。 if pd.api.types.is_numeric_dtype(X): x_vals = X.values else: x_vals = pd.Categorical(X).codes bins = np.searchsorted(sorted_splits, x_vals, side="right") rates = [] for i in range(len(sorted_splits) + 1): mask = bins == i if mask.sum() > 0: rates.append(y_vals[mask].mean()) else: rates.append(0.0) return rates def _assign_bins(self, X: pd.Series, feature: str) -> np.ndarray: """为数据分配分箱索引.""" x_vals = X.values 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": if feature in self._cat_bins_ and self._cat_bins_[feature]: bins = np.zeros(len(X), dtype=int) x_str = X.astype(str).where(X.notna(), other=np.nan) for i, group in enumerate(self._cat_bins_[feature]): for value in group: if isinstance(value, float) and np.isnan(value): bins[X.isna()] = i else: bins[x_str == str(value)] = i bins[X.isna()] = -1 if self.special_codes: for code in self.special_codes: bins[(X == code) | (x_str == str(code))] = -2 return bins codes = pd.Categorical(X).codes bins = np.where(X.isna(), -1, codes) if self.special_codes: for code in self.special_codes: bins[x_vals == code] = -2 return bins else: splits = self.splits_[feature] n = len(x_vals) bins = np.zeros(n, dtype=int) missing_mask = X.isna() bins[missing_mask] = -1 if self.special_codes: for code in self.special_codes: special_mask = ~missing_mask & (x_vals == code) bins[special_mask] = -2 valid_mask = ~missing_mask if self.special_codes: for code in self.special_codes: valid_mask = valid_mask & (x_vals != code) if valid_mask.any() and len(splits) > 0: valid_indices = np.where(valid_mask)[0] bins[valid_indices] = np.searchsorted(splits, x_vals[valid_indices], side="right") return bins
[文档] def transform( self, X: Union[pd.DataFrame, np.ndarray], metric: str = "indices", **kwargs ) -> Union[pd.DataFrame, np.ndarray]: """应用分箱转换.""" if not self._is_fitted: raise NotFittedError("分箱器尚未拟合,请先调用fit方法") if not isinstance(X, pd.DataFrame): X = pd.DataFrame(X) return self._transform_binning_features( X, metric, lambda feature: self._assign_bins(X[feature], feature), woe_default=0.0, )
[文档] def get_bad_rate_summary(self, feature: str) -> pd.DataFrame: """获取坏样本率摘要. :param feature: 特征名 :return: 分箱坏样本率摘要表 """ if feature not in self.bin_tables_: raise KeyError(f"特征 '{feature}' 未找到") bin_table = self.bin_tables_[feature] # 排除缺失和特殊值箱 valid_mask = ~bin_table["分箱标签"].isin(["缺失", "special"]) valid_table = bin_table[valid_mask].copy() summary = pd.DataFrame( { "分箱": valid_table["分箱"].values, "分箱标签": valid_table["分箱标签"].values, "样本总数": valid_table["样本总数"].values, "样本占比": valid_table["样本占比"].values, "坏样本率": valid_table["坏样本率"].values, } ) if self.target_bad_rates: # 添加目标坏样本率 n_bins = len(summary) targets = [] for i in range(n_bins): if i < len(self.target_bad_rates): targets.append(self.target_bad_rates[i]) elif i == n_bins - 1: targets.append(None) # 最后一箱没有上界 else: targets.append(None) summary["目标坏样本率"] = targets return summary
if __name__ == "__main__": import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) # 测试代码 np.random.seed(42) n_samples = 5000 # 生成测试数据 x = np.random.uniform(0, 100, n_samples) bad_rate = 0.05 + 0.004 * x # 从5%递增到45% y = np.random.binomial(1, bad_rate) X = pd.DataFrame({"feature": x}) y = pd.Series(y) print("=" * 60) print("目标坏样本率分箱测试 - 严格边界模式") print("=" * 60) # 测试严格边界模式 binner1 = TargetBadRateBinning(target_bad_rates=[0.10, 0.20, 0.30], strict_mode=True, min_bin_size=0.01) binner1.fit(X, y) print("\n分箱统计表:") print(binner1.get_bin_table("feature")) print("\n坏样本率摘要:") print(binner1.get_bad_rate_summary("feature")) print("\n" + "=" * 60) print("目标坏样本率分箱测试 - 自动模式") print("=" * 60) # 测试自动模式 binner2 = TargetBadRateBinning(max_n_bins=5, min_bin_size=0.05) binner2.fit(X, y) print("\n分箱统计表:") print(binner2.get_bin_table("feature")) print("\n坏样本率摘要:") print(binner2.get_bad_rate_summary("feature"))