hscredit.report.mining.multi_feature 源代码

"""多特征交叉规则挖掘模块.

支持双特征交叉分析,生成交叉矩阵和交叉规则。
支持hscredit所有分箱方法。
"""

import copy

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

from .base import (
    BaseRuleMiner,
    FeatureNames,
    _binning_has_parallel_children,
    _mining_workload,
    resolve_feature_map,
)
from ...core.rules.rule import Rule
from ...core.binning import OptimalBinning


def _multi_feature_pair_worker(task):
    """生成一个独立特征组合的规则及临时拟合状态。"""
    miner, feature1, feature2, top_n, metric, min_samples, min_lift = task
    rules = miner.get_cross_rules(
        feature1,
        feature2,
        top_n=top_n,
        metric=metric,
        min_samples=min_samples,
        min_lift=min_lift,
    )
    pair = (feature1, feature2)
    pair_state = miner.cross_results_.get(pair)
    binners = {
        feature: miner._binner_instances_[feature]
        for feature in pair
        if feature in miner._binner_instances_
    }
    return pair, rules, pair_state, binners


[文档] class MultiFeatureRuleMiner(BaseRuleMiner): """多特征交叉规则挖掘器. 生成双特征交叉分析结果,支持hscredit所有分箱方法。 代码风格参考hscredit的binning模块,fit方法兼容scorecardpipeline风格。 :param target: 目标变量列名,默认为'target' :param exclude_cols: 需要排除的列名列表 :param features: 参与挖掘的字段名或字段名列表,默认None(使用全部候选字段) :param feature_map: 字段名到字段含义的映射,用于规则报告展示 :param method: 分箱方法,取值与 `OptimalBinning.VALID_METHODS` 完全一致,不支持别名。 默认为'quantile' :param max_n_bins: 最大分箱数,默认5。超过此值的数值型特征将分箱 :param min_n_bins: 最小分箱数,默认2 :param min_bin_size: 每箱最小样本数或占比,默认0.01 :param max_bin_size: 每箱最大样本数或占比,默认None :param monotonic: 坏样本率单调性约束,默认False :param cat_cutoff: 类别型变量处理阈值,默认None :param min_samples: 最小样本数,默认10 :param min_lift: 最小lift阈值,默认1.1 :param special_codes: 特殊值列表,默认None :param random_state: 随机种子,默认None :param verbose: 是否输出详细信息,默认False :param binning_kwargs: 分箱方法的其他参数,通过**kwargs传入 **参考样例** >>> miner = MultiFeatureRuleMiner(target='ISBAD', method='quantile', max_n_bins=5) # 等频分箱:双特征交叉规则挖掘 >>> miner.fit(df) >>> cross_matrix = miner.generate_cross_matrix('age', 'income') # 生成年龄×收入的交叉分箱矩阵 >>> rules = miner.get_cross_rules('age', 'income', top_n=10) # 获取TOP10交叉规则,按LIFT排序 >>> miner = MultiFeatureRuleMiner(target='ISBAD', method='chi', max_n_bins=4) # 卡方分箱:自动合并坏率相近的交叉箱 >>> miner.fit(df) """ VALID_METHODS = OptimalBinning.VALID_METHODS def __init__( self, target: str = 'target', exclude_cols: Optional[List[str]] = None, method: str = 'quantile', 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, monotonic: Union[bool, str] = False, cat_cutoff: Optional[Union[float, int]] = None, min_samples: int = 10, min_lift: float = 1.1, special_codes: Optional[List] = None, random_state: Optional[int] = None, verbose: Union[bool, int] = False, n_jobs: Optional[Union[int, float]] = -1, parallel_backend: Optional[str] = None, parallel_config: Optional[Dict[str, Any]] = None, features: FeatureNames = None, feature_map: Optional[Dict[str, str]] = None, **binning_kwargs ): super().__init__( target=target, exclude_cols=exclude_cols, n_jobs=n_jobs, parallel_backend=parallel_backend, parallel_config=parallel_config, features=features, feature_map=feature_map, ) # 仅校验不归一化:sklearn clone 要求构造参数按对象原样保存, # 大小写/空白归一化推迟到 fit 时统一进行 OptimalBinning.validate_method(method) self.method = method self.max_n_bins = max_n_bins self.min_n_bins = min_n_bins self.min_bin_size = min_bin_size self.max_bin_size = max_bin_size self.monotonic = monotonic self.cat_cutoff = cat_cutoff self.min_samples = min_samples self.min_lift = min_lift self.special_codes = special_codes self.random_state = random_state self.verbose = verbose self.binning_kwargs = binning_kwargs self._features_explicit_ = False self.features_ = [] self.numerical_features_ = [] self.categorical_features_ = [] self.overall_badrate_ = 0.0 self.cross_results_ = {} self._binner_instances_ = {}
[文档] def fit( self, X: Union[pd.DataFrame, np.ndarray], y: Optional[Union[pd.Series, np.ndarray]] = None, feature_names: FeatureNames = None, **kwargs ) -> 'MultiFeatureRuleMiner': """拟合挖掘器. :param X: 训练数据 :param y: 目标变量(可选) :param feature_names: 本次拟合使用的字段名或字段名列表,优先于构造参数 ``features`` :param kwargs: 额外参数,可覆盖初始化参数 :return: self """ working = copy.deepcopy(self) working.cross_results_ = {} working._binner_instances_ = {} # 更新参数 for key, value in kwargs.items(): if hasattr(working, key): setattr(working, key, value) working.method = OptimalBinning.validate_method(working.method) working._features_explicit_ = feature_names is not None or working.features is not None X, y = working._check_input_data(X, y, feature_names=feature_names) if y is None: raise ValueError("多特征规则挖掘需要目标变量y") working.X_ = X working.y_ = y # 分类特征 working.numerical_features_ = working._get_numeric_features(X) working.categorical_features_ = working._get_categorical_features(X) working.features_ = ( list(X.columns) if working._features_explicit_ else working.numerical_features_ + working.categorical_features_ ) # 计算整体坏账率 working.overall_badrate_ = y.mean() working._is_fitted = True self._commit_fitted_state(working) return self
def _get_binning_instance(self, **override_params) -> OptimalBinning: """获取分箱器实例. :param override_params: 覆盖参数 :return: hscredit分箱器实例 """ # 构建分箱器参数 binning_params = { 'target': self.target, 'method': self.method, 'max_n_bins': self.max_n_bins, 'min_n_bins': self.min_n_bins, 'min_bin_size': self.min_bin_size, 'max_bin_size': self.max_bin_size, 'monotonic': self.monotonic, 'special_codes': self.special_codes, 'cat_cutoff': self.cat_cutoff, 'random_state': self.random_state, 'verbose': self.verbose, 'n_jobs': -1, 'parallel_backend': self.parallel_backend, 'parallel_config': self.parallel_config, **self.binning_kwargs, **override_params } return OptimalBinning(**binning_params) def _prepare_feature( self, feature: str, custom_splits: Optional[List[float]] = None ) -> pd.Series: """准备特征,对取值多的特征进行分箱. :param feature: 特征名 :param custom_splits: 自定义分箱切分点 :return: 处理后的特征值 """ unique_count = self.X_[feature].nunique(dropna=False) # 如果唯一值较少,直接返回 if unique_count <= self.max_n_bins: return self.X_[feature].fillna('缺失') # 数值型特征分箱 if feature in self.numerical_features_: if custom_splits is not None: return pd.cut(self.X_[feature], bins=custom_splits, right=True).astype(str).replace('nan', '缺失') else: return self._bin_feature(feature).fillna('缺失') # 类别型特征:根据cat_cutoff处理 if self.cat_cutoff is not None: if self.cat_cutoff < 1: # 保留占比超过阈值的类别 min_count = len(self.X_) * self.cat_cutoff top_values = self.X_[feature].value_counts() top_values = top_values[top_values >= min_count].index else: # 保留频率最高的N个类别 top_values = self.X_[feature].value_counts().head(int(self.cat_cutoff)).index else: # 默认保留最常见的max_n_bins个值 top_values = self.X_[feature].value_counts().head(self.max_n_bins).index return self.X_[feature].apply(lambda x: x if x in top_values else '其他').fillna('缺失') def _bin_feature(self, feature: str) -> pd.Series: """使用hscredit分箱方法对特征分箱. :param feature: 特征名 :return: 分箱后的区间 """ # 创建分箱器 binner = self._get_binning_instance() # 准备数据 X_feature = self.X_[[feature]].copy() valid_mask = X_feature[feature].notna() if valid_mask.sum() < self.min_samples * 2: return self.X_[feature] # 拟合分箱器 binner.fit(X_feature[valid_mask], self.y_[valid_mask]) # 存储分箱器实例 self._binner_instances_[feature] = binner # 转换数据 X_binned = binner.transform(X_feature) # 获取分箱标签 if hasattr(binner, 'get_bin_table'): bin_table = binner.get_bin_table(feature) if bin_table is not None and '分箱标签' in bin_table.columns: # 创建映射 bin_labels = bin_table['分箱标签'].values result = pd.Series(index=self.X_.index, dtype=object) for i, label in enumerate(bin_labels): result[X_binned[feature] == i] = label return result # 回退:使用数值分箱索引 return X_binned[feature].astype(str).replace('nan', '缺失')
[文档] def generate_cross_matrix( self, feature1: str, feature2: str, custom_splits1: Optional[List[float]] = None, custom_splits2: Optional[List[float]] = None, **kwargs ) -> pd.DataFrame: """生成双特征交叉矩阵. :param feature1: 第一个特征 :param feature2: 第二个特征 :param custom_splits1: 特征1的自定义分箱切分点 :param custom_splits2: 特征2的自定义分箱切分点 :param kwargs: 其他参数 :return: 交叉矩阵DataFrame """ self._check_fitted() # 更新参数 for key, value in kwargs.items(): if hasattr(self, key): setattr(self, key, value) # 准备特征 f1_prepared = self._prepare_feature(feature1, custom_splits1) f2_prepared = self._prepare_feature(feature2, custom_splits2) # 创建交叉数据 cross_data = pd.DataFrame({ 'feature1': f1_prepared, 'feature2': f2_prepared, 'target': self.y_ }) # 计算各类统计量 count_matrix = pd.crosstab( cross_data['feature1'], cross_data['feature2'], rownames=[feature1], colnames=[feature2] ) bad_count_matrix = pd.crosstab( cross_data['feature1'], cross_data['feature2'], values=cross_data['target'], aggfunc='sum' ) # 坏账率矩阵 badrate_matrix = bad_count_matrix / count_matrix badrate_matrix = badrate_matrix.fillna(0) # 样本占比矩阵 total_samples = count_matrix.sum().sum() sample_ratio_matrix = count_matrix / total_samples if total_samples > 0 else count_matrix * 0 # lift矩阵 lift_matrix = badrate_matrix / self.overall_badrate_ if self.overall_badrate_ > 0 else badrate_matrix * 0 lift_matrix = lift_matrix.fillna(0) # 计算KS值(如果可能) try: from ...core.metrics.classification import ks as ks_statistic ks_matrix = pd.DataFrame(index=count_matrix.index, columns=count_matrix.columns) for f1_val in count_matrix.index: for f2_val in count_matrix.columns: mask = (cross_data['feature1'] == f1_val) & (cross_data['feature2'] == f2_val) if mask.sum() > 0: ks_matrix.loc[f1_val, f2_val] = ks_statistic( cross_data['target'], mask.astype(int) ) else: ks_matrix.loc[f1_val, f2_val] = 0 except Exception: ks_matrix = None # 构建MultiIndex DataFrame metrics = ['count', 'bad_count', 'badrate', 'sample_ratio', 'lift'] if ks_matrix is not None: metrics.append('ks') cols = pd.MultiIndex.from_product( [count_matrix.columns, metrics], names=[feature2, 'metric'] ) result = pd.DataFrame(index=count_matrix.index, columns=cols) for f2_val in count_matrix.columns: result[(f2_val, 'count')] = count_matrix[f2_val] result[(f2_val, 'bad_count')] = bad_count_matrix[f2_val] result[(f2_val, 'badrate')] = badrate_matrix[f2_val] result[(f2_val, 'sample_ratio')] = sample_ratio_matrix[f2_val] result[(f2_val, 'lift')] = lift_matrix[f2_val] if ks_matrix is not None: result[(f2_val, 'ks')] = ks_matrix[f2_val] # 保存结果供后续使用 key = (feature1, feature2) self.cross_results_[key] = { 'matrix': result, 'count': count_matrix, 'badrate': badrate_matrix, 'lift': lift_matrix, 'sample_ratio': sample_ratio_matrix, 'ks': ks_matrix } return result
[文档] def get_cross_rules( self, feature1: str, feature2: str, top_n: int = 10, metric: str = 'lift', min_samples: Optional[int] = None, min_lift: Optional[float] = None, datasets: Optional[pd.DataFrame] = None, target: Optional[str] = None ) -> pd.DataFrame: """获取双特征交叉的top规则(使用rule_expr+Rule.report命中结果).""" min_samples = min_samples or self.min_samples min_lift = min_lift or self.min_lift # 生成交叉矩阵并转长表 cross_matrix = self.generate_cross_matrix(feature1, feature2) long_df = self._matrix_to_long(cross_matrix, feature1, feature2) long_df = long_df[long_df['count'] >= min_samples] long_df = long_df[long_df['lift'] >= min_lift] if metric in long_df.columns: long_df = long_df.sort_values(by=metric, ascending=False) target_col = target or self.target if datasets is None: datasets = self.X_.copy() datasets[target_col] = self.y_.values rows = [] feature_map = resolve_feature_map(self.feature_map) input_fields = f"{feature1} × {feature2}" field_meanings = ( f"{feature_map.get(feature1, '')} × {feature_map.get(feature2, '')}" if feature_map is not None else "" ) for _, row in long_df.head(top_n).iterrows(): v1, v2 = row['feature1_value'], row['feature2_value'] f1_expr = f"`{feature1}`" if not str(feature1).isidentifier() else str(feature1) f2_expr = f"`{feature2}`" if not str(feature2).isidentifier() else str(feature2) if pd.isna(v1) or v1 == '缺失': c1 = f"({f1_expr} != {f1_expr})" else: c1 = f"({f1_expr} == {repr(v1)})" if pd.isna(v2) or v2 == '缺失': c2 = f"({f2_expr} != {f2_expr})" else: c2 = f"({f2_expr} == {repr(v2)})" expr = f"{c1} & {c2}" rule = Rule(expr=expr, name=expr, description=expr, weight=1.0) report_df = rule.report(datasets=datasets, target=target_col) hit_rows = report_df[report_df['分箱'] == '命中'] if '分箱' in report_df.columns else pd.DataFrame() hit = hit_rows.iloc[0].to_dict() if not hit_rows.empty else {} report_row = { '规则表达式': rule.expr, '规则名称': rule.name, 'feature1_value': v1, 'feature2_value': v2, '命中样本数': hit.get('样本总数'), '命中样本占比': hit.get('样本占比'), '命中坏样本数': hit.get('坏样本数'), '命中坏样本率': hit.get('坏样本率'), '命中LIFT值': hit.get('LIFT值'), '坏账改善': hit.get('坏账改善'), '规则报告': report_df, } if feature_map is not None: report_row = {'入参字段': input_fields, '字段含义': field_meanings, **report_row} rows.append(report_row) result_df = pd.DataFrame(rows) if not result_df.empty: sort_map = { 'lift': '命中LIFT值', 'badrate': '命中坏样本率', 'sample_ratio': '命中样本占比', 'count': '命中样本数', } sort_col = sort_map.get(metric, metric) if sort_col in result_df.columns: result_df = result_df.sort_values(sort_col, ascending=False).reset_index(drop=True) return result_df
def _matrix_to_long( self, matrix: pd.DataFrame, feature1: str, feature2: str ) -> pd.DataFrame: """将交叉矩阵转换为长格式. :param matrix: 交叉矩阵 :param feature1: 特征1名称 :param feature2: 特征2名称 :return: 长格式DataFrame """ rows = [] metrics = ['count', 'bad_count', 'badrate', 'sample_ratio', 'lift'] if (matrix.columns.get_level_values(0)[0], 'ks') in matrix.columns: metrics.append('ks') for f1_val in matrix.index: for f2_val in matrix.columns.get_level_values(0).unique(): row = {'feature1_value': f1_val, 'feature2_value': f2_val} for metric in metrics: try: row[metric] = matrix.loc[f1_val, (f2_val, metric)] except KeyError: row[metric] = 0 rows.append(row) return pd.DataFrame(rows)
[文档] def get_all_cross_rules( self, top_n: int = 10, metric: str = 'lift', max_feature_pairs: int = 50, min_samples: Optional[int] = None, min_lift: Optional[float] = None ) -> pd.DataFrame: """获取所有特征对的交叉规则. :param top_n: 每对特征的返回规则数 :param metric: 排序指标 :param max_feature_pairs: 最大特征对数量 :param min_samples: 最小样本数 :param min_lift: 最小lift :return: 所有交叉规则DataFrame """ self._check_fitted() all_features = self.features_ # 选择信息价值最高的特征对 if self._features_explicit_: selected_features = all_features elif len(all_features) > 10: # 数值特征按方差排序;日期、类别等非数值字段不能直接计算方差。 numeric_variance = ( self.X_[self.numerical_features_] .var(numeric_only=True) .sort_values(ascending=False) ) selected_features = numeric_variance.head(10).index.tolist() # 数值特征不足 10 个时,按类别基数补足候选特征。 remaining = 10 - len(selected_features) if remaining > 0: categorical_cardinality = ( self.X_[self.categorical_features_] .nunique(dropna=False) .sort_values(ascending=False) ) selected_features.extend(categorical_cardinality.head(remaining).index.tolist()) else: selected_features = all_features feature_pairs = list(combinations(selected_features, 2))[:max_feature_pairs] tasks = [ ( copy.deepcopy(self), f1, f2, top_n, metric, min_samples, min_lift, ) for f1, f2 in feature_pairs ] has_parallel_children = _binning_has_parallel_children(self.method, self.binning_kwargs) pair_results = self._parallel_execute( _multi_feature_pair_worker, tasks, task_labels=[f"{f1} × {f2}" for f1, f2 in feature_pairs], default_backend="threading", has_parallel_children=has_parallel_children, workload=_mining_workload( self.X_, len(tasks), operation="多特征交叉规则挖掘", cost_per_item=20.0, has_parallel_children=has_parallel_children, ), ) all_rules = [] next_cross_results = dict(self.cross_results_) next_binners = dict(self._binner_instances_) for (f1, f2), rules, pair_state, binners in pair_results: if pair_state is not None: next_cross_results[(f1, f2)] = pair_state next_binners.update(binners) if not rules.empty: rules = rules.copy() rules['特征组合'] = f"{f1} × {f2}" all_rules.append(rules) self.cross_results_ = next_cross_results self._binner_instances_ = next_binners if not all_rules: return pd.DataFrame() combined = pd.concat(all_rules, ignore_index=True) sort_map = { 'lift': '命中LIFT值', 'badrate': '命中坏样本率', 'sample_ratio': '命中样本占比', 'count': '命中样本数', } sort_col = sort_map.get(metric, metric) if sort_col in combined.columns: combined = combined.sort_values(by=sort_col, ascending=False, kind="mergesort") return combined
[文档] def get_rules( self, top_n: int = 10, metric: str = 'lift', target: Optional[str] = None, datasets: Optional[pd.DataFrame] = None, min_samples: Optional[int] = None, min_lift: Optional[float] = None ) -> List[Rule]: """获取挖掘的交叉规则(只使用Rule.expr,并基于Rule.report命中结果).""" rules_df = self.get_all_cross_rules( top_n=top_n, metric=metric, min_samples=min_samples, min_lift=min_lift ) if rules_df.empty: return [] target_col = target or self.target if datasets is None: datasets = self.X_.copy() datasets[target_col] = self.y_.values rule_objects = [] for _, row in rules_df.iterrows(): expr = row['规则表达式'] if '规则表达式' in row else None if not expr: feature_pair = row.get('特征组合', '') if ' × ' not in feature_pair: continue f1, f2 = feature_pair.split(' × ') v1, v2 = row['feature1_value'], row['feature2_value'] f1_expr = f"`{f1}`" if not str(f1).isidentifier() else str(f1) f2_expr = f"`{f2}`" if not str(f2).isidentifier() else str(f2) if pd.isna(v1) or v1 == '缺失': c1 = f"({f1_expr} != {f1_expr})" else: c1 = f"({f1_expr} == {repr(v1)})" if pd.isna(v2) or v2 == '缺失': c2 = f"({f2_expr} != {f2_expr})" else: c2 = f"({f2_expr} == {repr(v2)})" expr = f"{c1} & {c2}" rule = Rule(expr=expr, name=row.get('规则名称', expr), description=expr, weight=1.0) report_df = rule.report(datasets=datasets, target=target_col) hit_rows = report_df[report_df['分箱'] == '命中'] if '分箱' in report_df.columns else pd.DataFrame() hit = hit_rows.iloc[0].to_dict() if not hit_rows.empty else {} metadata = { '规则表达式': rule.expr, '规则报告': report_df, '命中样本数': hit.get('样本总数'), '命中样本占比': hit.get('样本占比'), '命中坏样本数': hit.get('坏样本数'), '命中坏样本率': hit.get('坏样本率'), '命中LIFT值': hit.get('LIFT值'), '坏账改善': hit.get('坏账改善'), } if '入参字段' in row: metadata.update({'入参字段': row['入参字段'], '字段含义': row['字段含义']}) rule.metadata_ = metadata rule.metric_score_ = metadata.get('命中LIFT值', 0) rule_objects.append(rule) return rule_objects
[文档] def get_rule_objects( self, top_n: int = 10, metric: str = 'lift', target: Optional[str] = None, datasets: Optional[pd.DataFrame] = None, min_samples: Optional[int] = None, min_lift: Optional[float] = None ) -> List[Rule]: """获取Rule对象列表(与get_rules保持一致).""" return self.get_rules( top_n=top_n, metric=metric, target=target, datasets=datasets, min_samples=min_samples, min_lift=min_lift )
[文档] def get_binning_table(self, feature: str) -> Optional[pd.DataFrame]: """获取特征的分箱统计表(如果使用了hscredit分箱方法). :param feature: 特征名 :return: 分箱统计表或None """ self._check_fitted() if feature not in self._binner_instances_: return None binner = self._binner_instances_[feature] if hasattr(binner, 'get_bin_table'): return binner.get_bin_table(feature) elif hasattr(binner, 'bin_tables_') and feature in binner.bin_tables_: return binner.bin_tables_[feature] return None
[文档] def plot_cross_heatmap( self, feature1: str, feature2: str, metric: str = 'lift', figsize: Tuple[int, int] = (12, 10), title: Optional[str] = None, save_path: Optional[str] = None, cmap: Optional[str] = None ): """绘制交叉热力图. :param feature1: 特征1 :param feature2: 特征2 :param metric: 指标 :param figsize: 图大小 :param title: 自定义标题 :param save_path: 保存路径 :param cmap: 颜色映射,默认使用RdYlGn :return: matplotlib Figure对象 """ try: import matplotlib.pyplot as plt import seaborn as sns except ImportError: raise ImportError("需要安装matplotlib和seaborn") from ...core.viz.utils import setup_axis_style cross_matrix = self.generate_cross_matrix(feature1, feature2) # 提取指标矩阵 metric_matrix = cross_matrix.xs(metric, level='metric', axis=1) fig, ax = plt.subplots(figsize=figsize) # 默认颜色映射 if cmap is None: cmap = 'RdYlGn' if metric in ['lift', 'badrate'] else 'Blues' # 中心值(lift以1为中心) center = 1.0 if metric == 'lift' else None sns.heatmap( metric_matrix, annot=True, fmt='.2f', cmap=cmap, center=center, ax=ax, linewidths=0.5 ) # 设置样式 setup_axis_style(ax) plot_title = title or f'{feature1} x {feature2} - {metric.upper()} 热力图' ax.set_title(plot_title) plt.tight_layout() if save_path: fig.savefig(save_path, dpi=240, bbox_inches='tight') return fig
[文档] def plot_2d_binning( self, feature1: str, feature2: str, metric: str = 'lift', figsize: Tuple[int, int] = (14, 10), title: Optional[str] = None, save_path: Optional[str] = None, cmap: Optional[str] = None ): """绘制二维分箱图(主图+上下文分箱轴). - 中心主图:二维网格指标热力图 - 顶部:feature2 分箱轴(与主图共享x轴) - 右侧:feature1 分箱轴(与主图共享y轴) """ try: import matplotlib.pyplot as plt import seaborn as sns from matplotlib import gridspec except ImportError: raise ImportError("需要安装matplotlib和seaborn") from ...core.viz.utils import setup_axis_style cross_matrix = self.generate_cross_matrix(feature1, feature2) metric_matrix = cross_matrix.xs(metric, level='metric', axis=1) # 数值化,避免object类型导致热力图显示异常 metric_matrix = metric_matrix.apply(pd.to_numeric, errors='coerce').fillna(0) if cmap is None: cmap = 'RdYlGn' if metric in ['lift', 'badrate'] else 'Blues' fig = plt.figure(figsize=figsize) gs = gridspec.GridSpec( nrows=2, ncols=2, height_ratios=[0.9, 8.0], width_ratios=[8.0, 1.4], hspace=0.02, wspace=0.02 ) ax_top = fig.add_subplot(gs[0, 0]) ax_main = fig.add_subplot(gs[1, 0], sharex=ax_top) ax_right = fig.add_subplot(gs[1, 1], sharey=ax_main) center = 1.0 if metric == 'lift' else None hm = sns.heatmap( metric_matrix, annot=True, fmt='.2f', cmap=cmap, center=center, ax=ax_main, linewidths=0.5, cbar=True, cbar_kws={'shrink': 0.9, 'pad': 0.02} ) # 明确标注主图数值含义,避免看不出来是lift cbar = hm.collections[0].colorbar cbar.set_label(f'{metric.upper()} value', rotation=90) ax_main.text( 0.005, 1.02, f'Cell number = {metric.upper()}', transform=ax_main.transAxes, fontsize=10, va='bottom', ha='left' ) # 顶部分箱轴:复用主图x轴(按列格对齐) n_cols = metric_matrix.shape[1] for i, label in enumerate(metric_matrix.columns): ax_top.add_patch(plt.Rectangle((i, 0), 1, 1, facecolor='#E8EEF7', edgecolor='white', linewidth=1.0)) ax_top.text(i + 0.5, 0.5, str(label), ha='center', va='center', fontsize=8, rotation=0) ax_top.set_xlim(0, n_cols) ax_top.set_ylim(0, 1) ax_top.set_xticks([]) ax_top.set_yticks([]) ax_top.set_ylabel(f'{feature2}\n分箱', fontsize=9) # 右侧分箱轴:复用主图y轴(按行格对齐) n_rows = metric_matrix.shape[0] for i, label in enumerate(metric_matrix.index): ax_right.add_patch(plt.Rectangle((0, i), 1, 1, facecolor='#E8EEF7', edgecolor='white', linewidth=1.0)) ax_right.text(0.5, i + 0.5, str(label), ha='center', va='center', fontsize=8, rotation=0) ax_right.set_xlim(0, 1) ax_right.set_ylim(0, n_rows) ax_right.set_xticks([]) ax_right.set_yticks([]) ax_right.set_xlabel(f'{feature1} 分箱', fontsize=9) # 统一样式 setup_axis_style(ax_main) for a in [ax_top, ax_right]: for spine in a.spines.values(): spine.set_visible(False) plot_title = title or f'{feature1} x {feature2} - {metric.upper()} 二维分箱图' ax_main.set_title(plot_title) ax_main.set_xlabel(feature2) ax_main.set_ylabel(feature1) plt.tight_layout() if save_path: fig.savefig(save_path, dpi=240, bbox_inches='tight') return fig
def _check_fitted(self): """检查是否已拟合.""" if not self._is_fitted: raise RuntimeError("请先调用fit()方法")