hscredit.core.eda.target 源代码

"""目标变量分析模块.

提供目标变量分布、逾期率分析、时间趋势等功能.
专注于金融风控场景.
"""

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

from .utils import validate_dataframe, validate_binary_target, safe_divide


def _build_overdue_labels(overdue: Union[str, List[str]],
                          dpds: Union[int, List[int]]) -> List[Tuple[str, int, str]]:
    """构建逾期标签列表.
    
    :param overdue: 逾期天数字段名或列表
    :param dpds: 逾期天数或列表
    :return: 标签列表 [(标签名, dpd天数, 逾期字段), ...]
    """
    if isinstance(overdue, str):
        overdue = [overdue]
    if isinstance(dpds, int):
        dpds = [dpds]
    
    labels = []
    for od_field in overdue:
        for dpd in dpds:
            if dpd == 0:
                label_name = f"{od_field}>0"
            else:
                label_name = f"{od_field}>{dpd}"
            labels.append((label_name, dpd, od_field))
    
    return labels


def _create_binary_target(df: pd.DataFrame,
                          overdue_col: str,
                          dpd: int,
                          del_grey: bool = False) -> pd.Series:
    """根据逾期天数创建二元目标变量.
    
    :param df: 输入数据
    :param overdue_col: 逾期天数字段名
    :param dpd: 逾期定义天数
    :param del_grey: 是否删除灰样本
    :return: 二元目标变量 (0/1),灰样本为NaN
    """
    overdue = df[overdue_col].copy()
    
    if del_grey:
        # 删除灰样本:逾期天数在 (0, dpd] 区间设为NaN
        target = pd.Series(np.nan, index=df.index)
        target[overdue > dpd] = 1  # 坏样本
        target[overdue <= 0] = 0   # 好样本
    else:
        # 保留灰样本作为好样本
        target = (overdue > dpd).astype(int)
    
    return target


[文档] def target_distribution(df: pd.DataFrame, target_col: str) -> pd.DataFrame: """目标变量分布统计. :param df: 输入数据 :param target_col: 目标变量列名 :return: 目标分布DataFrame,列包括[类别, 样本数, 占比, 累计占比] **参考样例** >>> dist = target_distribution(df, 'fpd15') >>> print(dist) 类别 样本数 占比(%) 累计占比(%) 0 0 8500 85.00 85.00 1 1 1500 15.00 100.00 """ validate_dataframe(df, required_cols=[target_col]) value_counts = df[target_col].value_counts().sort_index() total = len(df) results = [] cumsum = 0 for value, count in value_counts.items(): ratio = count / total * 100 cumsum += ratio results.append({ '类别': value, '样本数': int(count), '占比(%)': round(ratio, 2), '累计占比(%)': round(cumsum, 2), }) return pd.DataFrame(results)
[文档] def bad_rate_overall(df: pd.DataFrame, target_col: Optional[str] = None, overdue: Optional[Union[str, List[str]]] = None, dpds: Optional[Union[int, List[int]]] = None, del_grey: bool = False, *, target: Optional[str] = None) -> Union[Dict, pd.DataFrame]: """计算整体逾期率. 支持单标签分析(通过target_col)或多标签分析(通过overdue+dpds)。 :param df: 输入数据 :param target_col: 目标变量列名(单标签模式) :param overdue: 逾期天数字段名或列表,如 'MOB1' 或 ['MOB1', 'MOB3'] :param dpds: 逾期定义天数或列表,如 7 或 [0, 7, 30] - 逾期天数 > dpds 为坏样本(1),其他为好样本(0) :param del_grey: 是否删除逾期天数在 (0, dpd] 区间的灰样本 :return: 单标签返回字典,多标签返回DataFrame **参考样例** >>> # 单标签分析 >>> result = bad_rate_overall(df, target_col='fpd15') >>> print(result) {'样本总数': 10000, '好样本数': 8500, '坏样本数': 1500, '逾期率(%)': 15.0} >>> # 多标签分析 >>> result = bad_rate_overall(df, overdue=['MOB1', 'MOB3'], dpds=[7, 30]) >>> print(result) 标签 样本总数 好样本数 坏样本数 逾期率(%) 0 MOB1>7 9800 8820 980 10.00 1 MOB1>30 9800 9400 400 4.08 """ target_col = target or target_col validate_dataframe(df) # 单标签模式 if target_col is not None: validate_binary_target(df[target_col]) total = len(df) bad_count = df[target_col].sum() good_count = total - bad_count bad_rate = bad_count / total * 100 return { '样本总数': int(total), '好样本数': int(good_count), '坏样本数': int(bad_count), '逾期率(%)': round(bad_rate, 2), } # 多标签模式 if overdue is None or dpds is None: raise ValueError("必须指定 target_col 或 (overdue + dpds)") labels = _build_overdue_labels(overdue, dpds) results = [] for label_name, dpd, od_field in labels: target = _create_binary_target(df, od_field, dpd, del_grey) valid_mask = target.notna() total = valid_mask.sum() if total == 0: results.append({ '标签': label_name, '样本总数': 0, '好样本数': 0, '坏样本数': 0, '逾期率(%)': np.nan, }) continue bad_count = target[valid_mask].sum() good_count = total - bad_count bad_rate = bad_count / total * 100 results.append({ '标签': label_name, '样本总数': int(total), '好样本数': int(good_count), '坏样本数': int(bad_count), '逾期率(%)': round(bad_rate, 2), }) return pd.DataFrame(results)
[文档] def bad_rate_by_dimension(df: pd.DataFrame, dim_col: str = None, target_col: Optional[str] = None, overdue: Optional[Union[str, List[str]]] = None, dpds: Optional[Union[int, List[int]]] = None, del_grey: bool = False, sort_by: str = 'bad_rate', *, target: Optional[str] = None, segment_col: Optional[str] = None) -> Union[pd.DataFrame, Dict[str, pd.DataFrame]]: """分维度逾期率分析. 支持单标签或多标签分析。 :param df: 输入数据 :param dim_col: 维度列名(如渠道、产品类型) :param target_col: 目标变量列名(单标签模式) :param overdue: 逾期天数字段名或列表 :param dpds: 逾期定义天数或列表 :param del_grey: 是否删除灰样本 :param sort_by: 排序方式,'bad_rate'或'count' :return: 单标签返回DataFrame,多标签返回{标签名: DataFrame}字典 **参考样例** >>> # 单标签 >>> result = bad_rate_by_dimension(df, 'channel', target_col='fpd15') >>> # 多标签 >>> result = bad_rate_by_dimension(df, 'channel', overdue='MOB1', dpds=[7, 30]) >>> print(result['MOB1>7']) """ target_col = target or target_col dim_col = segment_col or dim_col if dim_col is None: raise ValueError("必须指定 dim_col 或 segment_col 参数") validate_dataframe(df, required_cols=[dim_col]) # 单标签模式 if target_col is not None: validate_binary_target(df[target_col]) total = len(df) grouped = df.groupby(dim_col).agg({ target_col: ['count', 'sum', 'mean'] }).reset_index() grouped.columns = ['维度值', '样本数', '坏样本数', '逾期率'] grouped['好样本数'] = grouped['样本数'] - grouped['坏样本数'] grouped['逾期率(%)'] = (grouped['逾期率'] * 100).round(2) grouped['样本占比(%)'] = (grouped['样本数'] / total * 100).round(2) grouped = grouped.drop('逾期率', axis=1) if sort_by == 'bad_rate': grouped = grouped.sort_values('逾期率(%)', ascending=False) else: grouped = grouped.sort_values('样本数', ascending=False) return grouped.reset_index(drop=True) # 多标签模式 if overdue is None or dpds is None: raise ValueError("必须指定 target_col 或 (overdue + dpds)") labels = _build_overdue_labels(overdue, dpds) results = {} for label_name, dpd, od_field in labels: target = _create_binary_target(df, od_field, dpd, del_grey) valid_mask = target.notna() df_valid = df[valid_mask].copy() df_valid['_target'] = target[valid_mask] total = len(df_valid) if total == 0: results[label_name] = pd.DataFrame() continue grouped = df_valid.groupby(dim_col).agg({ '_target': ['count', 'sum', 'mean'] }).reset_index() grouped.columns = ['维度值', '样本数', '坏样本数', '逾期率'] grouped['好样本数'] = grouped['样本数'] - grouped['坏样本数'] grouped['逾期率(%)'] = (grouped['逾期率'] * 100).round(2) grouped['样本占比(%)'] = (grouped['样本数'] / total * 100).round(2) grouped = grouped.drop('逾期率', axis=1) if sort_by == 'bad_rate': grouped = grouped.sort_values('逾期率(%)', ascending=False) else: grouped = grouped.sort_values('样本数', ascending=False) results[label_name] = grouped.reset_index(drop=True) return results if len(results) > 1 else list(results.values())[0]
[文档] def bad_rate_trend(df: pd.DataFrame, date_col: str, target_col: Optional[str] = None, overdue: Optional[Union[str, List[str]]] = None, dpds: Optional[Union[int, List[int]]] = None, del_grey: bool = False, freq: str = 'M', dimensions: Optional[List[str]] = None, *, target: Optional[str] = None) -> Union[pd.DataFrame, Dict[str, pd.DataFrame]]: """逾期率时间趋势分析. 支持单标签或多标签分析。 :param df: 输入数据 :param date_col: 日期列名 :param target_col: 目标变量列名(单标签模式) :param overdue: 逾期天数字段名或列表 :param dpds: 逾期定义天数或列表 :param del_grey: 是否删除灰样本 :param freq: 时间频率,'D'日/'W'周/'M'月/'Q'季度 :param dimensions: 分维度分析列表 :return: 单标签返回DataFrame,多标签返回{标签名: DataFrame}字典 **参考样例** >>> # 单标签 >>> trend = bad_rate_trend(df, 'apply_date', target_col='fpd15', freq='M') >>> # 多标签 >>> trend = bad_rate_trend(df, 'apply_date', overdue=['MOB1', 'MOB3'], dpds=30, freq='M') >>> print(trend['MOB1>30']) """ target_col = target or target_col validate_dataframe(df, required_cols=[date_col]) # 确保日期格式正确 df = df.copy() df[date_col] = pd.to_datetime(df[date_col]) # 创建时间周期 period_col = '时间周期' if freq == 'D': df[period_col] = df[date_col].dt.date elif freq == 'W': df[period_col] = df[date_col].dt.to_period('W').astype(str) elif freq == 'M': df[period_col] = df[date_col].dt.to_period('M').astype(str) elif freq == 'Q': df[period_col] = df[date_col].dt.to_period('Q').astype(str) else: raise ValueError("freq必须是'D'/'W'/'M'/'Q'之一") # 单标签模式 if target_col is not None: validate_binary_target(df[target_col]) grouped = df.groupby(period_col).agg({ target_col: ['count', 'sum', 'mean'] }).reset_index() grouped.columns = [period_col, '样本数', '坏样本数', '逾期率'] grouped['好样本数'] = grouped['样本数'] - grouped['坏样本数'] grouped['逾期率(%)'] = (grouped['逾期率'] * 100).round(2) # 计算环比变化 grouped['环比变化(%)'] = grouped['逾期率(%)'].diff().round(2) # 计算同比变化(如果数据跨度足够) if len(grouped) > 12: grouped['同比变化(%)'] = (grouped['逾期率(%)'] - grouped['逾期率(%)'].shift(12)).round(2) return grouped[[period_col, '样本数', '好样本数', '坏样本数', '逾期率(%)', '环比变化(%)']].reset_index(drop=True) # 多标签模式 if overdue is None or dpds is None: raise ValueError("必须指定 target_col 或 (overdue + dpds)") labels = _build_overdue_labels(overdue, dpds) results = {} for label_name, dpd, od_field in labels: target = _create_binary_target(df, od_field, dpd, del_grey) valid_mask = target.notna() df_valid = df[valid_mask].copy() df_valid['_target'] = target[valid_mask] grouped = df_valid.groupby(period_col).agg({ '_target': ['count', 'sum', 'mean'] }).reset_index() if grouped.empty: results[label_name] = pd.DataFrame() continue grouped.columns = [period_col, '样本数', '坏样本数', '逾期率'] grouped['好样本数'] = grouped['样本数'] - grouped['坏样本数'] grouped['逾期率(%)'] = (grouped['逾期率'] * 100).round(2) grouped['环比变化(%)'] = grouped['逾期率(%)'].diff().round(2) if len(grouped) > 12: grouped['同比变化(%)'] = (grouped['逾期率(%)'] - grouped['逾期率(%)'].shift(12)).round(2) results[label_name] = grouped[[period_col, '样本数', '好样本数', '坏样本数', '逾期率(%)', '环比变化(%)']].reset_index(drop=True) return results if len(results) > 1 else list(results.values())[0]
[文档] def bad_rate_by_bins(df: pd.DataFrame, score_col: str, target_col: Optional[str] = None, overdue: Optional[Union[str, List[str]]] = None, dpds: Optional[Union[int, List[int]]] = None, del_grey: bool = False, n_bins: int = 10, method: str = 'quantile') -> Union[pd.DataFrame, Dict[str, pd.DataFrame]]: """评分分箱逾期率分析. 支持单标签或多标签分析。 :param df: 输入数据 :param score_col: 评分列名 :param target_col: 目标变量列名(单标签模式) :param overdue: 逾期天数字段名或列表 :param dpds: 逾期定义天数或列表 :param del_grey: 是否删除灰样本 :param n_bins: 分箱数 :param method: 分箱方法,'quantile'等频/'uniform'等距 :return: 单标签返回DataFrame,多标签返回{标签名: DataFrame}字典 **参考样例** >>> # 单标签 >>> bins = bad_rate_by_bins(df, 'score', target_col='fpd15', n_bins=10) >>> # 多标签 >>> bins = bad_rate_by_bins(df, 'score', overdue=['MOB1', 'MOB3'], dpds=30, n_bins=10) >>> print(bins['MOB1>30']) """ validate_dataframe(df, required_cols=[score_col]) series = df[score_col].dropna() if len(series) == 0: return pd.DataFrame() # 分箱 if method == 'quantile': bins = pd.qcut(df[score_col], q=n_bins, duplicates='drop') else: bins = pd.cut(df[score_col], bins=n_bins) # 单标签模式 if target_col is not None: validate_binary_target(df[target_col]) total_bad_rate = df[target_col].mean() result = df.groupby(bins).agg({ target_col: ['count', 'sum', 'mean'] }).reset_index() result.columns = ['分箱区间', '样本数', '坏样本数', '逾期率'] result['好样本数'] = result['样本数'] - result['坏样本数'] result['逾期率(%)'] = (result['逾期率'] * 100).round(2) result['样本占比(%)'] = (result['样本数'] / len(df) * 100).round(2) result['提升度'] = (result['逾期率'] / total_bad_rate).round(4) result['分箱'] = range(1, len(result) + 1) return result[['分箱', '分箱区间', '样本数', '好样本数', '坏样本数', '逾期率(%)', '样本占比(%)', '提升度']].reset_index(drop=True) # 多标签模式 if overdue is None or dpds is None: raise ValueError("必须指定 target_col 或 (overdue + dpds)") labels = _build_overdue_labels(overdue, dpds) results = {} for label_name, dpd, od_field in labels: target = _create_binary_target(df, od_field, dpd, del_grey) valid_mask = target.notna() df_valid = df[valid_mask].copy() df_valid['_target'] = target[valid_mask] bins_valid = bins[valid_mask] total_bad_rate = df_valid['_target'].mean() result = df_valid.groupby(bins_valid).agg({ '_target': ['count', 'sum', 'mean'] }).reset_index() if result.empty: results[label_name] = pd.DataFrame() continue result.columns = ['分箱区间', '样本数', '坏样本数', '逾期率'] result['好样本数'] = result['样本数'] - result['坏样本数'] result['逾期率(%)'] = (result['逾期率'] * 100).round(2) result['样本占比(%)'] = (result['样本数'] / len(df_valid) * 100).round(2) result['提升度'] = (result['逾期率'] / total_bad_rate).round(4) result['分箱'] = range(1, len(result) + 1) results[label_name] = result[['分箱', '分箱区间', '样本数', '好样本数', '坏样本数', '逾期率(%)', '样本占比(%)', '提升度']].reset_index(drop=True) return results if len(results) > 1 else list(results.values())[0]
[文档] def sample_distribution(df: pd.DataFrame, date_col: str, freq: str = 'M', target_col: Optional[str] = None, *, target: Optional[str] = None) -> pd.DataFrame: """样本时间分布分析. :param df: 输入数据 :param date_col: 日期列名 :param freq: 时间聚合频率,``'D'`` 日 / ``'W'`` 周 / ``'M'`` 月 / ``'Q'`` 季度,默认 ``'M'`` :param target_col: 目标变量列名(如有) :return: 样本分布DataFrame **参考样例** >>> dist = sample_distribution(df, 'apply_date', target_col='fpd15') >>> print(dist[['时间周期', '样本数', '坏样本数', '逾期率(%)']]) """ target_col = target or target_col validate_dataframe(df, required_cols=[date_col]) df = df.copy() df[date_col] = pd.to_datetime(df[date_col]) # 创建时间周期 period_col = '时间周期' if freq == 'D': df[period_col] = df[date_col].dt.date elif freq == 'W': df[period_col] = df[date_col].dt.to_period('W').astype(str) elif freq == 'M': df[period_col] = df[date_col].dt.to_period('M').astype(str) elif freq == 'Q': df[period_col] = df[date_col].dt.to_period('Q').astype(str) # 计算分布 if target_col: validate_binary_target(df[target_col]) grouped = df.groupby(period_col).agg({ target_col: ['count', 'sum', 'mean'] }).reset_index() grouped.columns = [period_col, '样本数', '坏样本数', '逾期率'] grouped['好样本数'] = grouped['样本数'] - grouped['坏样本数'] grouped['逾期率(%)'] = (grouped['逾期率'] * 100).round(2) grouped = grouped.drop('逾期率', axis=1) else: grouped = df.groupby(period_col).size().reset_index(name='样本数') # 计算环比 grouped['样本环比(%)'] = (grouped['样本数'].pct_change() * 100).round(2) return grouped.reset_index(drop=True)