hscredit.core.binning.best_lift_binning 源代码

"""Best Lift 分箱算法.

基于最大化头尾部 Lift 差异的分箱方法。
核心思想:找到最优分割点,使得头部或尾部的 Lift 极端化(远高于或远低于1)。

算法流程:
1. 预分割:将数据分成足够细的初始箱(默认50个)
2. 合并优化:使用动态规划或贪心算法,在单调性约束下合并分箱
3. 目标:最大化头部/尾部的 Lift 差异
"""

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

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


[文档] class BestLiftBinning(BaseBinning): """Best Lift 分箱. 基于最大化 Lift 差异的分箱方法,特别关注头部或尾部的极端值。 Lift = 箱内坏样本率 / 总体坏样本率。 业务场景: - 高风险识别:寻找头部 Lift > 2 或尾部 Lift < 0.5 的分箱 - 风险分层:最大化不同分箱间的风险差异 :param max_n_bins: 最大分箱数,默认为5 :param min_n_bins: 最小分箱数,默认为2 :param min_bin_size: 每箱最小样本数或占比,默认为0.01 - 如果 < 1, 表示占比 (如 0.01 表示 1%) - 如果 >= 1, 表示绝对数量 (如 100 表示最少100个样本) :param max_bin_size: 每箱最大样本数或占比,默认为0.5 :param min_lift: 最小 Lift 阈值,默认为0(不限制) - 设为0:不限制,允许任何 Lift 值 - 设为0.5:过滤掉 Lift > 0.5 的箱(用于识别极低风险) - 设为1.5:只保留 Lift > 1.5 的箱(用于识别极高风险) :param monotonic: 坏样本率单调性约束,默认为True - False: 不要求单调性 - True 或 'auto': 自动检测并应用最佳单调方向 - 'ascending': 强制坏样本率递增 - 'descending': 强制坏样本率递减 :param n_prebins: 预分箱数量,默认为50 - 预分箱越细,结果越精确,但计算量越大 :param optimization: 优化目标,默认为'extreme' - 'extreme': 最大化极端箱的 Lift(头部最高或尾部最低) - 'spread': 最大化 Lift 分布范围(max - min) - 'iv': 最大化 IV 值 :param missing_separate: 缺失值是否单独分箱,默认为True :param special_codes: 特殊值列表,默认为None :param random_state: 随机种子,默认为None **参考样例** >>> from hscredit.core.binning import BestLiftBinning >>> # 识别高风险客群 >>> binner = BestLiftBinning(max_n_bins=5, optimization='extreme') >>> binner.fit(X_train, y_train) >>> X_binned = binner.transform(X_test) >>> bin_table = binner.get_bin_table('feature_name') **注意** Best Lift 分箱的特点: 1. 关注头部/尾部的极端风险识别 2. 支持单调性约束,保证业务可解释性 3. 自动确定最优单调方向 4. 高效的动态规划风格实现 其中 ``Lift(提升度)= 箱内坏样本率 / 总体坏样本率``,Lift>1 表示该箱风险高于均值。 **引用** Lift / 提升度是响应模型与风险分层的标准评估口径,参见 Siddiqi, N. (2006). *Credit Risk Scorecards.* Wiley,以及营销响应模型中的 lift chart 概念 https://en.wikipedia.org/wiki/Lift_(data_mining) """ def __init__( self, target: str = "target", 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]] = 0.5, min_bad_rate: float = 0.0, min_lift: float = 0.0, monotonic: Union[bool, str] = True, n_prebins: int = 50, optimization: str = "extreme", 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, 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, n_jobs=n_jobs, parallel_backend=parallel_backend, parallel_config=parallel_config, **kwargs, ) self.min_lift = min_lift self.n_prebins = n_prebins self.optimization = optimization
[文档] def fit( self, X: Union[pd.DataFrame, np.ndarray], y: Optional[Union[pd.Series, np.ndarray]] = None, **kwargs ) -> "BestLiftBinning": """拟合 Best Lift 分箱. :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: """对单个特征进行分箱. :param feature: 特征名 :param X: 特征数据 :param y: 目标变量 """ # 检测特征类型 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 = self._best_lift_categorical(X_valid, y_valid) self.splits_[feature] = np.array(splits) if splits else np.array([]) self.n_bins_[feature] = len(splits) + 1 if splits else len(X_valid.unique()) else: # 数值型变量 splits = self._best_lift_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 def _best_lift_numerical(self, X: pd.Series, y: pd.Series) -> List[float]: """对数值型变量进行 Best Lift 分箱. 算法流程: 1. 等频预分割成 n_prebins 个初始箱 2. 计算每个初始箱的统计信息 3. 使用贪心算法合并分箱,优化目标函数 :param X: 特征数据 :param y: 目标变量 :return: 分割点列表 """ x_vals = X.values y_vals = y.values n_samples = len(x_vals) if n_samples == 0: return [] total_bad_rate = np.mean(y_vals) if total_bad_rate == 0 or total_bad_rate == 1: return [] # 计算样本数约束 min_samples = self._get_min_samples(n_samples) max_samples = self._get_max_samples(n_samples) # 排序数据 sorted_indices = np.argsort(x_vals) x_sorted = x_vals[sorted_indices] y_sorted = y_vals[sorted_indices] # 阶段1:等频预分割 prebins = self._create_prebins(x_sorted, y_sorted, min_samples) if len(prebins) <= self.max_n_bins: # 预分箱数已经满足要求 splits = self._prebins_to_splits(prebins, x_sorted) else: # 阶段2:贪心合并分箱 splits = self._greedy_merge(prebins, x_sorted, y_sorted, min_samples, max_samples, total_bad_rate) # 阶段3:单调性调整 if self.monotonic and len(splits) > 0: splits = self._enforce_monotonic(splits, x_sorted, y_sorted, min_samples, total_bad_rate) return sorted(splits) def _create_prebins(self, x_sorted: np.ndarray, y_sorted: np.ndarray, min_samples: int) -> List[Dict]: """创建预分箱. 使用等频分割,确保每个预分箱至少有 min_samples 个样本。 :param x_sorted: 排序后的特征值 :param y_sorted: 排序后的目标变量 :param min_samples: 每箱最小样本数 :return: 预分箱列表,每个元素包含 {start_idx, end_idx, bad_count, total_count, bad_rate} """ n_samples = len(x_sorted) # 计算实际可用的预分箱数 n_prebins = min(self.n_prebins, n_samples // min_samples) n_prebins = max(n_prebins, self.max_n_bins) # 等频分割点 quantile_points = np.linspace(0, n_samples, n_prebins + 1, dtype=int) prebins = [] for i in range(n_prebins): start_idx = quantile_points[i] end_idx = quantile_points[i + 1] if end_idx <= start_idx: continue bin_y = y_sorted[start_idx:end_idx] bad_count = np.sum(bin_y) total_count = len(bin_y) bad_rate = bad_count / total_count if total_count > 0 else 0 prebins.append( { "start_idx": start_idx, "end_idx": end_idx, "bad_count": bad_count, "total_count": total_count, "bad_rate": bad_rate, } ) return prebins def _greedy_merge( self, prebins: List[Dict], x_sorted: np.ndarray, y_sorted: np.ndarray, min_samples: int, max_samples: int, total_bad_rate: float, ) -> List[float]: """贪心合并预分箱. 从预分箱开始,逐步合并相邻的分箱,直到达到目标分箱数。 每次选择对目标函数影响最小的相邻分箱进行合并。 :param prebins: 预分箱列表 :param x_sorted: 排序后的特征值 :param y_sorted: 排序后的目标变量 :param min_samples: 最小样本数 :param max_samples: 最大样本数 :param total_bad_rate: 总体坏样本率 :return: 分割点列表 """ # 复制预分箱列表 bins = [b.copy() for b in prebins] n_bins = len(bins) # 如果已经满足分箱数要求,直接返回 if n_bins <= self.max_n_bins: return self._bins_to_splits(bins, x_sorted) # 计算相邻分箱的合并损失 def calc_merge_loss(bins: List[Dict], merge_idx: int) -> float: """计算合并相邻两个分箱的损失.""" if merge_idx >= len(bins) - 1: return float("inf") left = bins[merge_idx] right = bins[merge_idx + 1] # 合并后的统计 merged_bad = left["bad_count"] + right["bad_count"] merged_total = left["total_count"] + right["total_count"] merged_rate = merged_bad / merged_total if merged_total > 0 else 0 # 检查样本数约束 if merged_total > max_samples: return float("inf") # 计算合并前后的目标函数差异 if self.optimization == "extreme": # 极端值优化:最大化头部或尾部的 Lift # 合并损失 = 合并前后 Lift 极值的变化 left_lift = left["bad_rate"] / total_bad_rate if total_bad_rate > 0 else 1 right_lift = right["bad_rate"] / total_bad_rate if total_bad_rate > 0 else 1 merged_lift = merged_rate / total_bad_rate if total_bad_rate > 0 else 1 # 损失 = 合并导致的极端 Lift 损失 pre_extreme = max(abs(left_lift - 1), abs(right_lift - 1)) post_extreme = abs(merged_lift - 1) loss = pre_extreme - post_extreme elif self.optimization == "spread": # 分布范围优化:最大化 Lift 的分布范围 all_rates = [b["bad_rate"] for b in bins] current_spread = max(all_rates) - min(all_rates) # 模拟合并后的 spread temp_rates = [b["bad_rate"] for b in bins] temp_rates[merge_idx] = merged_rate temp_rates.pop(merge_idx + 1) new_spread = max(temp_rates) - min(temp_rates) loss = current_spread - new_spread else: # optimization == 'iv' # IV 优化:最大化信息值 # 简化计算,使用坏样本率差异 rate_diff = abs(left["bad_rate"] - right["bad_rate"]) loss = rate_diff * (left["total_count"] + right["total_count"]) / len(y_sorted) return loss # 贪心合并 while len(bins) > self.max_n_bins: # 找到合并损失最小的相邻分箱对 min_loss = float("inf") best_merge_idx = -1 for i in range(len(bins) - 1): loss = calc_merge_loss(bins, i) if loss < min_loss: min_loss = loss best_merge_idx = i if best_merge_idx == -1: break # 执行合并 left = bins[best_merge_idx] right = bins[best_merge_idx + 1] merged_bin = { "start_idx": left["start_idx"], "end_idx": right["end_idx"], "bad_count": left["bad_count"] + right["bad_count"], "total_count": left["total_count"] + right["total_count"], "bad_rate": (left["bad_count"] + right["bad_count"]) / (left["total_count"] + right["total_count"]), } bins[best_merge_idx] = merged_bin bins.pop(best_merge_idx + 1) # 确保满足最小分箱数 while len(bins) < self.min_n_bins and len(bins) > 1: # 找到样本数最多的分箱进行拆分 max_idx = np.argmax([b["total_count"] for b in bins]) target_bin = bins[max_idx] if target_bin["total_count"] < min_samples * 2: break # 从中间拆分 mid_idx = (target_bin["start_idx"] + target_bin["end_idx"]) // 2 left_y = y_sorted[target_bin["start_idx"] : mid_idx] right_y = y_sorted[mid_idx : target_bin["end_idx"]] left_bin = { "start_idx": target_bin["start_idx"], "end_idx": mid_idx, "bad_count": np.sum(left_y), "total_count": len(left_y), "bad_rate": np.mean(left_y), } right_bin = { "start_idx": mid_idx, "end_idx": target_bin["end_idx"], "bad_count": np.sum(right_y), "total_count": len(right_y), "bad_rate": np.mean(right_y), } bins[max_idx] = left_bin bins.insert(max_idx + 1, right_bin) return self._bins_to_splits(bins, x_sorted) def _bins_to_splits(self, bins: List[Dict], x_sorted: np.ndarray) -> List[float]: """将分箱转换为分割点. :param bins: 分箱列表 :param x_sorted: 排序后的特征值 :return: 分割点列表 """ splits = [] for i in range(len(bins) - 1): # 分割点是相邻分箱边界的中点 end_idx = bins[i]["end_idx"] if end_idx < len(x_sorted): split_val = (x_sorted[end_idx - 1] + x_sorted[end_idx]) / 2 splits.append(split_val) return splits def _prebins_to_splits(self, prebins: List[Dict], x_sorted: np.ndarray) -> List[float]: """将预分箱直接转换为分割点. :param prebins: 预分箱列表 :param x_sorted: 排序后的特征值 :return: 分割点列表 """ return self._bins_to_splits(prebins, x_sorted) def _enforce_monotonic( self, splits: List[float], x_sorted: np.ndarray, y_sorted: np.ndarray, min_samples: int, total_bad_rate: float ) -> List[float]: """强制单调性约束. :param splits: 初始分割点 :param x_sorted: 排序后的特征值 :param y_sorted: 排序后的目标变量 :param min_samples: 最小样本数 :param total_bad_rate: 总体坏样本率 :return: 满足单调性的分割点 """ if not splits or len(splits) == 0: return splits splits = list(splits) # 计算每个箱的坏样本率 def get_bad_rates(splits_list: List[float]) -> List[float]: bins = np.searchsorted(splits_list, x_sorted, side="right") n_bins = len(splits_list) + 1 rates = [] for i in range(n_bins): mask = bins == i if mask.sum() > 0: rates.append(y_sorted[mask].mean()) else: rates.append(0) return rates bad_rates = get_bad_rates(splits) # 确定单调方向 if self.monotonic == "ascending": direction = "ascending" elif self.monotonic == "descending": direction = "descending" else: # auto 或 True # 计算违反单调性的次数 asc_violations = sum(1 for i in range(len(bad_rates) - 1) if bad_rates[i] > bad_rates[i + 1]) desc_violations = sum(1 for i in range(len(bad_rates) - 1) if bad_rates[i] < bad_rates[i + 1]) direction = "ascending" if asc_violations <= desc_violations else "descending" # 合并违反单调性的相邻箱 max_iter = len(splits) for _ in range(max_iter): if len(splits) <= 1: break bad_rates = get_bad_rates(splits) # 找到第一个违反单调性的位置 merge_idx = -1 for i in range(len(bad_rates) - 1): if direction == "ascending" and bad_rates[i] > bad_rates[i + 1]: merge_idx = i break elif direction == "descending" and bad_rates[i] < bad_rates[i + 1]: merge_idx = i break if merge_idx == -1: break # 删除分割点(合并相邻箱) splits.pop(merge_idx) # 检查是否满足最小分箱数 if len(splits) + 1 < self.min_n_bins: # 尝试重新分割(简单策略:等频分割) n_new_splits = self.min_n_bins - 1 n_samples = len(x_sorted) quantiles = np.linspace(0, n_samples, n_new_splits + 2, dtype=int)[1:-1] splits = [(x_sorted[q - 1] + x_sorted[q]) / 2 for q in quantiles if q < n_samples] return sorted(splits) def _best_lift_categorical(self, X: pd.Series, y: pd.Series) -> List[float]: """对类别型变量进行 Best Lift 分箱. :param X: 特征数据 :param y: 目标变量 :return: 分割点列表(编码边界) """ # 计算总体坏样本率 total_bad_rate = y.mean() if total_bad_rate == 0: return [] # 使用向量化操作计算类别统计 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"] # 计算 Lift category_stats["lift"] = category_stats["bad_rate"] / total_bad_rate # 过滤掉样本数过少的类别 min_samples = self._get_min_samples(len(X)) category_stats = category_stats[category_stats["count"] >= min_samples] if len(category_stats) <= 1: return [] # 按坏样本率排序 category_stats = category_stats.sort_values("bad_rate") # 返回编码边界 n_categories = len(category_stats) if n_categories <= self.max_n_bins: return [] return [i - 0.5 for i in range(1, min(n_categories, self.max_n_bins))] def _get_min_samples(self, n_total: int) -> int: """获取最小样本数. :param n_total: 总样本数 :return: 最小样本数 """ if self.min_bin_size < 1: return max(int(n_total * self.min_bin_size), 1) return max(int(self.min_bin_size), 1) def _get_max_samples(self, n_total: int) -> int: """获取最大样本数. :param n_total: 总样本数 :return: 最大样本数 """ if self.max_bin_size is None: return n_total if self.max_bin_size < 1: return int(n_total * self.max_bin_size) return int(self.max_bin_size) def _assign_bins(self, X: pd.Series, feature: str) -> np.ndarray: """为数据分配分箱索引. :param X: 特征数据 :param feature: 特征名 :return: 分箱索引数组 """ 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": codes = pd.Categorical(X).codes return np.where(X.isna(), -1, codes) 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: bins[x_vals == code] = -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: bins[valid_mask] = np.searchsorted(splits, x_vals[valid_mask], side="right") return bins
[文档] def transform( self, X: Union[pd.DataFrame, np.ndarray], metric: str = "indices", **kwargs ) -> Union[pd.DataFrame, np.ndarray]: """应用分箱转换. 将原始特征值转换为分箱索引、分箱标签、WOE值或LIFT值。 :param X: 待转换数据, DataFrame或数组格式 :param metric: 转换类型, 可选值: - 'indices': 返回分箱索引 (0, 1, 2, ...), 用于后续处理 - 'bins': 返回分箱标签字符串, 用于可视化或报告 - 'woe': 返回WOE值, 用于逻辑回归建模 - 'lift': 返回LIFT值, 用于评估分箱效果 :param kwargs: 其他参数 :return: 转换后的数据, 格式与输入X相同 :example: >>> binner = BestLiftBinning() >>> binner.fit(X_train, y_train) >>> >>> # 获取分箱索引 >>> X_binned = binner.transform(X_test, metric='indices') >>> >>> # 获取WOE编码 (用于建模) >>> X_woe = binner.transform(X_test, metric='woe') >>> >>> # 获取LIFT值 >>> X_lift = binner.transform(X_test, metric='lift') """ if not self._is_fitted: raise NotFittedError("分箱器尚未拟合,请先调用fit方法") if not isinstance(X, pd.DataFrame): if isinstance(X, np.ndarray): X = pd.DataFrame(X) else: X = pd.DataFrame(X) return self._transform_binning_features( X, metric, lambda feature: self._assign_bins(X[feature], feature), woe_default=0.0, extra_metric=self._transform_lift_metric, )
[文档] def get_bin_table(self, feature: str) -> pd.DataFrame: """获取分箱统计表. :param feature: 特征名 :return: 分箱统计表 """ return self._get_lift_bin_table(feature)
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.concatenate([np.random.normal(0, 1, n_samples // 2), np.random.normal(3, 1, n_samples // 2)]) # 构造有区分度的目标变量 y = np.array([np.random.binomial(1, 0.2, n_samples // 2), np.random.binomial(1, 0.6, n_samples // 2)]).flatten() # 添加一些缺失值 x[np.random.choice(n_samples, 100, replace=False)] = np.nan X = pd.DataFrame({"feature": x}) y = pd.Series(y) print("=" * 60) print("Best Lift 分箱测试 - 新算法") print("=" * 60) # 测试 Best Lift 分箱 binner = BestLiftBinning( max_n_bins=5, min_lift=0.0, monotonic=True, optimization="extreme", n_prebins=50, verbose=True ) binner.fit(X, y) print("\n分箱统计表:") print(binner.get_bin_table("feature")) # 转换测试 print("\n转换测试:") X_lift = binner.transform(X, metric="lift") print("\nLift值 (前10行):") print(X_lift.head(10)) print("\n切分点:", binner.splits_["feature"]) print("分箱数:", binner.n_bins_["feature"])