"""金融风控专用指标.
提供金融风控场景下的评估指标,包括Lift类指标、坏账率分析、评分统计与稳定性分析。
**参考样例**
>>> from hscredit.core.metrics import lift, lift_at, badrate, rule_lift
>>> import numpy as np
>>> np.random.seed(42)
>>> y_true = np.random.randint(0, 2, 1000) # 模拟真实标签(0=好,1=坏)
>>> y_prob = np.random.uniform(0, 1, 1000) # 模拟模型预测概率
>>> print(f"Lift@0.5={lift(y_true, y_prob, 0.5):.4f}") # threshold=0.5时命中的LIFT值
>>> print(f"Badrate={badrate(y_true):.4f}") # 整体坏账率
**引用**
- Lift / Gain(提升度与增益)作为风控与营销响应模型的标准评估手段,见
Siddiqi, N. (2006). *Credit Risk Scorecards.* Wiley,以及
Berry, M. & Linoff, G. (2004). *Data Mining Techniques.* Wiley(Lift/Gains 图)。
- Lift@k% = 头部 k% 客群坏账率 / 总体坏账率,衡量评分在指定通过率下的集中度。
"""
import numpy as np
import pandas as pd
from typing import Union, Tuple, Optional, Dict, Any, List
from ._base import _validate_same_length, _validate_binary_target
from ._binning import compute_bin_stats
[文档]
def lift(y_true: Union[np.ndarray, pd.Series],
y_prob: Union[np.ndarray, pd.Series],
threshold: float = 0.5) -> float:
"""计算Lift值 (模型提升度).
Lift = 命中样本坏账率 / 总体坏账率。
Lift > 1 表示模型效果优于随机,Lift值越高表示模型对高风险群体的区分能力越强。
**参数**
:param y_true: 真实标签 (0/1),0为负样本(好样本),1为正样本(坏样本)
:param y_prob: 预测概率或评分(高值表示高风险)
:param threshold: 分类阈值,默认为0.5。高于此阈值的样本视为"命中"
:return: Lift值(>1表示有效,>3表示良好)
:raises ValueError: y_true和y_prob长度不一致时
**参考样例**
>>> from hscredit.core.metrics import lift
>>> import numpy as np
>>> np.random.seed(42)
>>> y_true = np.random.randint(0, 2, 1000) # 真实标签(0=好,1=坏)
>>> y_prob = np.random.uniform(0, 1, 1000) # 预测概率
>>> lift(y_true, y_prob, threshold=0.8) # 高阈值命中高分样本,计算LIFT值
1.5
"""
y_true = np.asarray(y_true)
y_prob = np.asarray(y_prob)
_validate_same_length(y_true, y_prob, ("y_true", "y_prob"))
overall_badrate = np.mean(y_true)
if overall_badrate == 0:
return 0.0
hit_mask = y_prob >= threshold
hit_count = np.sum(hit_mask)
if hit_count == 0:
return 0.0
hit_badrate = np.mean(y_true[hit_mask])
return hit_badrate / overall_badrate
[文档]
def lift_table(y_true: Union[np.ndarray, pd.Series],
y_prob: Union[np.ndarray, pd.Series],
method: str = 'quantile',
max_n_bins: int = 10,
min_bin_size: float = 0.01,
**kwargs) -> pd.DataFrame:
"""计算Lift详细统计表.
按预测概率分箱后计算每箱的样本统计、Lift值和累积Lift值。
**参数**
:param y_true: 真实标签 (0/1),0为负样本(好样本),1为正样本(坏样本)
:param y_prob: 预测概率或评分
:param method: 分箱方法,默认为'quantile'(等频分箱)
:param max_n_bins: 最大分箱数,默认为10
:param min_bin_size: 每箱最小样本占比,默认为0.01
:param kwargs: 其他传递给OptimalBinning的参数
:return: 包含各分箱详细统计的DataFrame,列包括:分箱序号、最小概率、最大概率、
样本数、好样本数、坏样本数、坏样本率、样本占比、Lift值、坏账改善、
累积Lift值、累积坏账改善
:raises ValueError: 数据全部为缺失值时
**参考样例**
>>> from hscredit.core.metrics import lift_table
>>> import numpy as np
>>> np.random.seed(42)
>>> y_true = np.random.randint(0, 2, 1000)
>>> y_prob = np.random.uniform(0, 1, 1000)
>>> table = lift_table(y_true, y_prob, max_n_bins=5)
>>> print(table[['分箱', '样本数', '坏样本率', 'Lift值']])
"""
y_true = np.asarray(y_true)
y_prob = np.asarray(y_prob)
_validate_same_length(y_true, y_prob, ("y_true", "y_prob"))
valid_mask = ~(pd.isna(y_prob) | pd.isna(y_true))
y_prob_clean = y_prob[valid_mask]
y_true_clean = y_true[valid_mask]
if len(y_prob_clean) == 0:
raise ValueError("没有有效数据(全部为缺失值)")
from ..binning import OptimalBinning
df = pd.DataFrame({'prob': y_prob_clean, 'target': y_true_clean})
binner = OptimalBinning(
method=method,
max_n_bins=max_n_bins,
min_bin_size=min_bin_size,
verbose=False,
**kwargs
)
binner.fit(df[['prob']], df['target'])
bins = binner.transform(df[['prob']], metric='indices').values.flatten()
bin_labels = None
if 'prob' in binner.bin_tables_:
bin_table = binner.bin_tables_['prob']
if '分箱标签' in bin_table.columns:
bin_labels = bin_table['分箱标签'].tolist()
stats_df = compute_bin_stats(bins, y_true_clean, bin_labels=bin_labels, round_digits=False)
result_rows = []
for _, row in stats_df.iterrows():
bin_idx = int(row['分箱'])
mask = bins == bin_idx
prob_min = y_prob_clean[mask].min() if mask.sum() > 0 else np.nan
prob_max = y_prob_clean[mask].max() if mask.sum() > 0 else np.nan
result_rows.append({
'分箱': bin_idx + 1,
'最小概率': round(prob_min, 6) if not np.isnan(prob_min) else np.nan,
'最大概率': round(prob_max, 6) if not np.isnan(prob_max) else np.nan,
'样本数': int(row['样本总数']),
'好样本数': int(row['好样本数']),
'坏样本数': int(row['坏样本数']),
'坏样本率': round(row['坏样本率'], 6),
'样本占比': round(row['样本占比'], 6),
'Lift值': round(row['LIFT值'], 4),
'坏账改善': round(row['坏账改善'], 4),
'累积Lift值': round(row['累积LIFT值'], 4),
'累积坏账改善': round(row['累积坏账改善'], 4),
})
return pd.DataFrame(result_rows)
[文档]
def lift_curve(y_true: Union[np.ndarray, pd.Series],
y_prob: Union[np.ndarray, pd.Series],
percentages: List[float] = None,
tail: bool = False) -> pd.DataFrame:
"""计算Lift曲线数据.
返回在不同覆盖率下的样本统计和Lift值,用于绘制Lift曲线和Decile Lift分析。
**参数**
:param y_true: 真实标签 (0/1),0为负样本(好样本),1为正样本(坏样本)
:param y_prob: 预测概率或评分(高值表示高风险)
:param percentages: 百分比切分点列表,默认为 [0.01, 0.03, 0.05, 0.10, 0.20, 0.30, 0.50]
支持任意比例组合
:param tail: False=从高概率端截取(头部高风险客群,适合风险识别场景),
True=从低概率端截取(尾部低风险客群,适合优质客群筛选),默认为False
:return: 包含各覆盖率统计的DataFrame,列包括:
- 覆盖率: 百分比标签
- 样本数: 截取的样本数
- 坏样本数: 截取样本中的坏样本数
- 坏样本捕获率: 坏样本捕获率
- Lift值: 该覆盖率下的Lift值
**参考样例**
>>> from hscredit.core.metrics import lift_curve
>>> import numpy as np
>>> np.random.seed(42)
>>> y_true = np.random.randint(0, 2, 1000)
>>> y_prob = np.random.uniform(0, 1, 1000)
>>> curve = lift_curve(y_true, y_prob, percentages=[0.05, 0.10, 0.20])
>>> print(curve)
"""
if percentages is None:
percentages = [0.01, 0.03, 0.05, 0.10, 0.20, 0.30, 0.50]
y_true = np.asarray(y_true)
y_prob = np.asarray(y_prob)
_validate_same_length(y_true, y_prob, ("y_true", "y_prob"))
total = len(y_true)
total_bad = np.sum(y_true)
overall_badrate = total_bad / total if total > 0 else 0.0
# tail=True时从低概率端截取(分析低风险客群纯净度)
if tail:
sorted_indices = np.argsort(y_prob) # 升序,低概率在前
else:
sorted_indices = np.argsort(y_prob)[::-1] # 降序,高概率在前
y_true_sorted = y_true[sorted_indices]
results = []
for pct in percentages:
n_samples = int(total * pct)
n_samples = max(1, min(n_samples, total))
hit_y_true = y_true_sorted[:n_samples]
hit_bad = np.sum(hit_y_true)
hit_badrate = hit_bad / n_samples if n_samples > 0 else 0.0
lift_value = hit_badrate / overall_badrate if overall_badrate > 0 else 0.0
capture_rate = hit_bad / total_bad if total_bad > 0 else 0.0
results.append({
'覆盖率': f"{pct:.0%}" if pct >= 0.01 else f"{pct*100:.1f}%",
'样本数': n_samples,
'坏样本数': int(hit_bad),
'坏样本捕获率': f"{capture_rate:.2%}",
'Lift值': round(lift_value, 4),
})
return pd.DataFrame(results)
[文档]
def lift_at(
y_true: Union[np.ndarray, pd.Series],
y_prob: Union[np.ndarray, pd.Series],
ratios: Union[float, List[float]] = None,
ascending: bool = False,
) -> Union[float, pd.DataFrame]:
"""计算指定覆盖率下的LIFT值.
支持 1%/3%/5%/10% 等任意比例,适配风控场景中头部/尾部区分能力分析。
**参数**
:param y_true: 真实标签 (0/1),0为负样本(好样本),1为正样本(坏样本)
:param y_prob: 预测概率或评分(高值表示高风险)
:param ratios: 覆盖率,标量(如 0.05)或列表(如 [0.01, 0.03, 0.05, 0.10])。
默认为 [0.01, 0.03, 0.05, 0.10]
:param ascending: False=高概率排前(风险模型头部),True=低概率排前(尾部分析),
默认为False
:return: 单个 float(ratios 为标量时)或 DataFrame(ratios 为列表时),
DataFrame列包括:覆盖率、样本数、坏样本数、坏样本率、坏样本捕获率、LIFT值
**参考样例**
>>> from hscredit.core.metrics import lift_at
>>> import numpy as np
>>> np.random.seed(42)
>>> y_true = np.random.randint(0, 2, 1000)
>>> y_prob = np.random.uniform(0, 1, 1000)
>>> lift_at(y_true, y_prob, ratios=0.05)
1.4
>>> result = lift_at(y_true, y_prob, ratios=[0.01, 0.03, 0.05, 0.10])
>>> print(result)
"""
_return_scalar = isinstance(ratios, (int, float))
if ratios is None:
ratios = [0.01, 0.03, 0.05, 0.10]
elif _return_scalar:
ratios = [float(ratios)]
else:
ratios = list(ratios)
y_true = np.asarray(y_true)
y_prob = np.asarray(y_prob)
_validate_same_length(y_true, y_prob, ("y_true", "y_prob"))
total = len(y_true)
total_bad = int(np.sum(y_true))
overall_badrate = total_bad / total if total > 0 else 0.0
if ascending:
sorted_indices = np.argsort(y_prob)
else:
sorted_indices = np.argsort(y_prob)[::-1]
y_sorted = y_true[sorted_indices]
results = []
for ratio in ratios:
n_samples = max(1, min(int(total * ratio), total))
hit_y = y_sorted[:n_samples]
hit_bad = int(np.sum(hit_y))
hit_badrate = hit_bad / n_samples if n_samples > 0 else 0.0
lift_value = hit_badrate / overall_badrate if overall_badrate > 0 else 0.0
capture_rate = hit_bad / total_bad if total_bad > 0 else 0.0
pct_label = f"{ratio*100:.0f}%" if ratio >= 0.01 else f"{ratio*100:.1f}%"
results.append({
'覆盖率': pct_label,
'样本数': n_samples,
'坏样本数': hit_bad,
'坏样本率': f"{hit_badrate:.2%}",
'坏样本捕获率': f"{capture_rate:.2%}",
'LIFT值': round(lift_value, 4),
})
if _return_scalar:
return results[0]['LIFT值']
return pd.DataFrame(results)
[文档]
def lift_monotonicity_check(
y_true: Union[np.ndarray, pd.Series],
y_prob: Union[np.ndarray, pd.Series],
n_bins: int = 10,
direction: str = 'both',
) -> Dict[str, Any]:
"""检查LIFT单调性.
风控场景理想状态:高风险端(头部)坏率单调递减,低风险端(尾部)坏率单调递增。
违反单调性意味着评分在某区间区分能力弱,可作为调参约束目标。
**参数**
:param y_true: 真实标签 (0/1),0为负样本(好样本),1为正样本(坏样本)
:param y_prob: 预测概率或评分(高值表示高风险)
:param n_bins: 分箱数,默认为10
:param direction: 检验方向,默认为'both'
- 'head': 仅检验头部(高概率→低概率坏率是否递减)
- 'tail': 仅检验尾部
- 'both': 头尾均检验
:return: 字典,含以下键:
- head_monotonic (bool): 头部是否单调
- tail_monotonic (bool): 尾部是否单调
- head_lift_values (list): 头部各分箱LIFT值(由高风险到低风险)
- tail_lift_values (list): 尾部各分箱LIFT值(由低风险到高风险)
- head_violations (list): 头部违反单调性的分箱对 [(i, j, 差值)]
- tail_violations (list): 尾部违反单调性的分箱对
- head_violation_ratio (float): 头部违反比例 0.0~1.0
- tail_violation_ratio (float): 尾部违反比例 0.0~1.0
- head_bin_table (pd.DataFrame): 头部分箱坏率统计表
- tail_bin_table (pd.DataFrame): 尾部分箱坏率统计表
**参考样例**
>>> from hscredit.core.metrics import lift_monotonicity_check
>>> import numpy as np
>>> np.random.seed(42)
>>> y_true = np.random.randint(0, 2, 1000)
>>> y_prob = np.random.uniform(0, 1, 1000)
>>> result = lift_monotonicity_check(y_true, y_prob, n_bins=10)
>>> print(result['head_monotonic'])
"""
y_true = np.asarray(y_true)
y_prob = np.asarray(y_prob)
_validate_same_length(y_true, y_prob, ("y_true", "y_prob"))
total = len(y_true)
total_bad = int(np.sum(y_true))
overall_badrate = total_bad / total if total > 0 else 0.0
def _check_direction(sorted_indices: np.ndarray, label: str) -> Dict[str, Any]:
"""对给定排序(头部/尾部)计算各分箱坏率和单调性."""
y_sorted = y_true[sorted_indices]
prob_sorted = y_prob[sorted_indices]
bin_size = total // n_bins
bin_badrates = []
bin_lifts = []
bin_rows = []
for i in range(n_bins):
start = i * bin_size
end = (i + 1) * bin_size if i < n_bins - 1 else total
seg = y_sorted[start:end]
seg_prob = prob_sorted[start:end]
n = len(seg)
n_bad = int(np.sum(seg))
br = n_bad / n if n > 0 else 0.0
lv = br / overall_badrate if overall_badrate > 0 else 0.0
bin_badrates.append(br)
bin_lifts.append(round(lv, 4))
bin_rows.append({
'分箱': i + 1,
'样本数': n,
'坏样本数': n_bad,
'坏样本率': round(br, 6),
'LIFT值': round(lv, 4),
'概率均值': round(float(seg_prob.mean()), 6),
})
bin_table = pd.DataFrame(bin_rows)
# 检测单调性:对于头部,坏率应单调递减(每个箱 <= 前一个箱)
violations = []
for i in range(1, len(bin_badrates)):
if bin_badrates[i] > bin_badrates[i - 1] + 1e-8: # 允许极小数值误差
diff = bin_badrates[i] - bin_badrates[i - 1]
violations.append((i, i + 1, round(diff, 6)))
n_pairs = n_bins - 1
violation_ratio = len(violations) / n_pairs if n_pairs > 0 else 0.0
is_monotonic = len(violations) == 0
return {
'monotonic': is_monotonic,
'lift_values': bin_lifts,
'violations': violations,
'violation_ratio': round(violation_ratio, 4),
'bin_table': bin_table,
}
result: Dict[str, Any] = {}
if direction in ('head', 'both'):
# 头部:高概率排前,坏率应递减
head_indices = np.argsort(y_prob)[::-1]
head = _check_direction(head_indices, 'head')
result['head_monotonic'] = head['monotonic']
result['head_lift_values'] = head['lift_values']
result['head_violations'] = head['violations']
result['head_violation_ratio'] = head['violation_ratio']
result['head_bin_table'] = head['bin_table']
else:
result['head_monotonic'] = None
result['head_lift_values'] = []
result['head_violations'] = []
result['head_violation_ratio'] = None
result['head_bin_table'] = pd.DataFrame()
if direction in ('tail', 'both'):
# 尾部:低概率排前,坏率应递增(即低风险区纯净度递增)
# 注:对尾部客群而言,越靠前概率越低,坏率应越小,
# 若坏率单调递增(从低风险到高风险)即合理
tail_indices = np.argsort(y_prob) # 升序
tail = _check_direction(tail_indices, 'tail')
# 尾部单调性:坏率应单调递增(violations定义为坏率[i] < 坏率[i-1])
tail_violations = []
br_list = [row['坏样本率'] for _, row in tail['bin_table'].iterrows()]
for i in range(1, len(br_list)):
if br_list[i] < br_list[i - 1] - 1e-8:
tail_violations.append((i, i + 1, round(br_list[i - 1] - br_list[i], 6)))
n_pairs = n_bins - 1
tail_vr = len(tail_violations) / n_pairs if n_pairs > 0 else 0.0
result['tail_monotonic'] = len(tail_violations) == 0
result['tail_lift_values'] = tail['lift_values']
result['tail_violations'] = tail_violations
result['tail_violation_ratio'] = round(tail_vr, 4)
result['tail_bin_table'] = tail['bin_table']
else:
result['tail_monotonic'] = None
result['tail_lift_values'] = []
result['tail_violations'] = []
result['tail_violation_ratio'] = None
result['tail_bin_table'] = pd.DataFrame()
return result
[文档]
def rule_lift(y_true: Union[np.ndarray, pd.Series],
rule_mask: Union[np.ndarray, pd.Series],
amount: Optional[Union[np.ndarray, pd.Series]] = None) -> Dict[str, Any]:
"""计算规则的Lift指标.
评估单一规则的命中样本相对于总体的坏账率提升,用于规则效果评估与对比。
**参数**
:param y_true: 真实标签 (0/1),0为负样本(好样本),1为正样本(坏样本)
:param rule_mask: 规则命中掩码(布尔数组或0/1数组,True/1表示样本命中该规则)
:param amount: 金额数据(可选,用于计算金额维度的Lift指标)
:return: Lift指标字典,包含以下键:
- hit_count: 命中样本数
- hit_rate: 命中率(命中样本占总样本比例)
- bad_count: 命中样本中的坏样本数
- good_count: 命中样本中的好样本数
- badrate: 命中样本坏账率
- overall_badrate: 总体坏账率
- lift: Lift值(命中坏账率/总体坏账率)
- bad_improve: 坏账改善度(总体坏账率-非命中坏账率)/总体坏账率
- total_amount: 总金额(当amount非None时)
- hit_amount: 命中金额(当amount非None时)
- bad_amount: 命中金额中的坏样本对应金额(当amount非None时)
- amount_lift: 金额维度Lift值(当amount非None时)
:raises ValueError: y_true和rule_mask长度不一致时
:raises ValueError: amount长度与y_true不一致时
**参考样例**
>>> from hscredit.core.metrics import rule_lift
>>> import numpy as np
>>> np.random.seed(42)
>>> y_true = np.random.randint(0, 2, 1000)
>>> rule_mask = np.random.rand(1000) > 0.8 # 20%样本命中规则
>>> result = rule_lift(y_true, rule_mask)
>>> print(f"命中数={result['hit_count']}, Lift={result['lift']:.4f}")
"""
y_true = np.asarray(y_true)
rule_mask = np.asarray(rule_mask)
_validate_same_length(y_true, rule_mask, ("y_true", "rule_mask"))
total = len(y_true)
total_bad = np.sum(y_true)
overall_badrate = total_bad / total if total > 0 else 0.0
hit_count = np.sum(rule_mask)
hit_rate = hit_count / total if total > 0 else 0.0
if hit_count == 0:
return {
'hit_count': 0, 'hit_rate': 0.0, 'bad_count': 0, 'good_count': 0,
'badrate': 0.0, 'overall_badrate': overall_badrate,
'lift': 0.0, 'bad_improve': 0.0,
}
hit_bad = np.sum(y_true[rule_mask])
hit_good = hit_count - hit_bad
hit_badrate = hit_bad / hit_count
lift_value = hit_badrate / overall_badrate if overall_badrate > 0 else 0.0
other_bad = total_bad - hit_bad
other_total = total - hit_count
other_badrate = other_bad / other_total if other_total > 0 else 0.0
bad_improve = (overall_badrate - other_badrate) / overall_badrate if overall_badrate > 0 else 0.0
result = {
'hit_count': int(hit_count),
'hit_rate': round(hit_rate, 6),
'bad_count': int(hit_bad),
'good_count': int(hit_good),
'badrate': round(hit_badrate, 6),
'overall_badrate': round(overall_badrate, 6),
'lift': round(lift_value, 4),
'bad_improve': round(bad_improve, 4),
}
if amount is not None:
amount = np.asarray(amount)
if len(amount) != len(y_true):
raise ValueError("amount长度必须与其他参数相同")
total_amount = np.sum(amount)
hit_amount = np.sum(amount[rule_mask])
bad_amount = np.sum(amount[rule_mask] * y_true[rule_mask])
overall_bad_amount_ratio = np.sum(amount * y_true) / total_amount if total_amount > 0 else 0.0
hit_bad_amount_ratio = bad_amount / hit_amount if hit_amount > 0 else 0.0
amount_lift = hit_bad_amount_ratio / overall_bad_amount_ratio if overall_bad_amount_ratio > 0 else 0.0
result.update({
'total_amount': total_amount,
'hit_amount': hit_amount,
'bad_amount': bad_amount,
'amount_lift': round(amount_lift, 4),
})
return result
[文档]
def badrate(y_true: Union[np.ndarray, pd.Series],
weights: Optional[Union[np.ndarray, pd.Series]] = None) -> float:
"""计算总体坏账率.
坏账率 = 坏样本数 / 总样本数,即目标变量为1的样本占比。
**参数**
:param y_true: 真实标签 (0/1),0为负样本(好样本),1为正样本(坏样本)
:param weights: 样本权重(可选),用于加权计算坏账率
:return: 坏账率,取值范围[0, 1]
**参考样例**
>>> from hscredit.core.metrics import badrate
>>> import numpy as np
>>> np.random.seed(42)
>>> y_true = np.random.randint(0, 2, 1000)
>>> badrate(y_true)
0.5
"""
y_true = np.asarray(y_true)
if weights is not None:
weights = np.asarray(weights)
return np.average(y_true, weights=weights)
return np.mean(y_true)
[文档]
def badrate_by_group(y_true: Union[np.ndarray, pd.Series],
group: Union[np.ndarray, pd.Series],
weights: Optional[Union[np.ndarray, pd.Series]] = None) -> pd.DataFrame:
"""按分组计算坏账率.
将样本按指定分组变量进行分群,分别计算各组的坏账率,用于群体风险对比分析。
**参数**
:param y_true: 真实标签 (0/1),0为负样本(好样本),1为正样本(坏样本)
:param group: 分组标签(支持数值型或分类型,如渠道、地区、年龄段等)
:param weights: 样本权重(可选),用于加权计算各组坏账率
:return: 各组坏账率统计的DataFrame,按坏账率降序排列,列包括:
- 分组: 分组标签
- 样本数: 该组总样本数
- 好样本数: 该组好样本数量
- 坏样本数: 该组坏样本数量
- 坏账率: 该组坏账率
- 样本占比: 该组样本占总样本比例
- 与总体差异: 该组坏账率与总体坏账率的差值
:raises ValueError: y_true和group长度不一致时
**参考样例**
>>> from hscredit.core.metrics import badrate_by_group
>>> import numpy as np
>>> np.random.seed(42)
>>> y_true = np.random.randint(0, 2, 1000)
>>> groups = np.random.choice(['A', 'B', 'C', 'D'], size=1000)
>>> result = badrate_by_group(y_true, groups)
>>> print(result)
"""
y_true = np.asarray(y_true)
group = np.asarray(group)
_validate_same_length(y_true, group, ("y_true", "group"))
total = len(y_true)
overall_badrate = badrate(y_true, weights)
results = []
for g in np.unique(group):
mask = group == g
group_y_true = y_true[mask]
group_count = len(group_y_true)
group_bad = np.sum(group_y_true)
group_good = group_count - group_bad
if weights is not None:
group_weights = weights[mask]
group_badrate = np.average(group_y_true, weights=group_weights)
else:
group_badrate = group_bad / group_count if group_count > 0 else 0.0
results.append({
'分组': g,
'样本数': group_count,
'好样本数': group_good,
'坏样本数': group_bad,
'坏账率': round(group_badrate, 6),
'样本占比': round(group_count / total, 6),
'与总体差异': round(group_badrate - overall_badrate, 6),
})
return pd.DataFrame(results).sort_values('坏账率', ascending=False)
[文档]
def badrate_trend(y_true: Union[np.ndarray, pd.Series],
date: Union[np.ndarray, pd.Series],
freq: str = 'M') -> pd.DataFrame:
"""计算坏账率时间趋势.
按指定时间频率聚合样本,计算各时间段的坏账率及环比变化,
支持日/周/月/季度多时间粒度分析。
**参数**
:param y_true: 真实标签 (0/1),0为负样本(好样本),1为正样本(坏样本)
:param date: 日期数据(支持pandas datetime、numpy datetime64或字符串日期)
:param freq: 时间频率,默认为'M'(月)
- 'D': 按日统计
- 'W': 按周统计
- 'M': 按月统计
- 'Q': 按季度统计
:return: 时间趋势数据DataFrame,列包括:
- 时间周期: 统计时间段标签
- 样本数: 该时间段内样本总数
- 好样本数: 该时间段内好样本数量
- 坏样本数: 该时间段内坏样本数量
- 坏账率: 该时间段内坏账率
- 环比变化: 与上一期坏账率的差值
- 累积坏账率: 截至该时间段的累积坏账率
:raises ValueError: y_true和date长度不一致时
:raises ValueError: freq不在'D'/'W'/'M'/'Q'范围内时
**参考样例**
>>> from hscredit.core.metrics import badrate_trend
>>> import numpy as np
>>> import pandas as pd
>>> np.random.seed(42)
>>> n = 1000
>>> y_true = np.random.randint(0, 2, n)
>>> dates = pd.date_range('2024-01-01', periods=n, freq='D')
>>> result = badrate_trend(y_true, dates, freq='M')
>>> print(result)
"""
y_true = np.asarray(y_true)
date = pd.to_datetime(date)
_validate_same_length(y_true, date, ("y_true", "date"))
df = pd.DataFrame({'date': date, 'y_true': y_true})
if freq == 'D':
df['period'] = df['date'].dt.date
elif freq == 'W':
df['period'] = df['date'].dt.to_period('W').astype(str)
elif freq == 'M':
df['period'] = df['date'].dt.to_period('M').astype(str)
elif freq == 'Q':
df['period'] = df['date'].dt.to_period('Q').astype(str)
else:
raise ValueError("freq必须是'D'/'W'/'M'/'Q'之一")
results = []
cum_good = 0
cum_bad = 0
prev_badrate = None
for period, group in df.groupby('period'):
count = len(group)
bad = group['y_true'].sum()
good = count - bad
badrate_val = bad / count if count > 0 else 0.0
change = badrate_val - prev_badrate if prev_badrate is not None else np.nan
prev_badrate = badrate_val
cum_good += good
cum_bad += bad
cum_total = cum_good + cum_bad
cum_badrate = cum_bad / cum_total if cum_total > 0 else 0.0
results.append({
'时间周期': period,
'样本数': count,
'好样本数': good,
'坏样本数': bad,
'坏账率': round(badrate_val, 6),
'环比变化': round(change, 6) if not np.isnan(change) else None,
'累积坏账率': round(cum_badrate, 6),
})
return pd.DataFrame(results)
[文档]
def badrate_by_score_bin(y_true: Union[np.ndarray, pd.Series],
score: Union[np.ndarray, pd.Series],
method: str = 'quantile',
max_n_bins: int = 10,
min_bin_size: float = 0.01,
**kwargs) -> pd.DataFrame:
"""按评分分箱计算坏账率.
将评分按指定分箱方法划分为多个区间,计算各区间的坏账率及与总体坏账率的差异,
用于评分卡分布分析与区间风险对比。
**参数**
:param y_true: 真实标签 (0/1),0为负样本(好样本),1为正样本(坏样本)
:param score: 评分或预测概率(高评分/高概率表示高风险)
:param method: 分箱方法,默认为'quantile'(等频分箱),
支持与OptimalBinning相同的全部分箱方法
:param max_n_bins: 最大分箱数,默认为10
:param min_bin_size: 每箱最小样本占比,默认为0.01
:param kwargs: 其他传递给OptimalBinning的参数
:return: 分箱坏账率统计DataFrame,列包括:
- 分箱: 分箱序号
- 最小评分: 该分箱内评分最小值
- 最大评分: 该分箱内评分最大值
- 样本数: 该分箱内样本总数
- 好样本数: 该分箱内好样本数量
- 坏样本数: 该分箱内坏样本数量
- 坏账率: 该分箱内坏账率
- 与总体差异: 该分箱坏账率与总体坏账率的差值
:raises ValueError: 数据全部为缺失值时
:raises ValueError: y_true和score长度不一致时
**参考样例**
>>> from hscredit.core.metrics import badrate_by_score_bin
>>> import numpy as np
>>> np.random.seed(42)
>>> y_true = np.random.randint(0, 2, 1000)
>>> score = np.random.randn(1000) * 50 + 600 # 模拟评分分布
>>> result = badrate_by_score_bin(y_true, score, max_n_bins=5)
>>> print(result)
"""
y_true = np.asarray(y_true)
score = np.asarray(score)
_validate_same_length(y_true, score, ("y_true", "score"))
valid_mask = ~(pd.isna(score) | pd.isna(y_true))
score_clean = score[valid_mask]
y_true_clean = y_true[valid_mask]
if len(score_clean) == 0:
raise ValueError("没有有效数据(全部为缺失值)")
overall_badrate = badrate(y_true_clean)
from ..binning import OptimalBinning
df = pd.DataFrame({'score': score_clean, 'target': y_true_clean})
binner = OptimalBinning(
method=method,
max_n_bins=max_n_bins,
min_bin_size=min_bin_size,
verbose=False,
**kwargs
)
binner.fit(df[['score']], df['target'])
bins = binner.transform(df[['score']], metric='indices').values.flatten()
bin_labels = None
if 'score' in binner.bin_tables_:
bin_table = binner.bin_tables_['score']
if '分箱标签' in bin_table.columns:
bin_labels = bin_table['分箱标签'].tolist()
stats_df = compute_bin_stats(bins, y_true_clean, bin_labels=bin_labels, round_digits=False)
result_rows = []
for _, row in stats_df.iterrows():
bin_idx = int(row['分箱'])
mask = bins == bin_idx
score_min = score_clean[mask].min() if mask.sum() > 0 else np.nan
score_max = score_clean[mask].max() if mask.sum() > 0 else np.nan
result_rows.append({
'分箱': bin_idx + 1,
'最小评分': round(score_min, 2) if not np.isnan(score_min) else np.nan,
'最大评分': round(score_max, 2) if not np.isnan(score_max) else np.nan,
'样本数': int(row['样本总数']),
'好样本数': int(row['好样本数']),
'坏样本数': int(row['坏样本数']),
'坏账率': round(row['坏样本率'], 6),
'与总体差异': round(row['坏样本率'] - overall_badrate, 6),
})
return pd.DataFrame(result_rows)
[文档]
def score_stats(score: Union[np.ndarray, pd.Series],
y_true: Optional[Union[np.ndarray, pd.Series]] = None) -> Dict[str, Any]:
"""计算评分统计信息.
提供评分的完整描述性统计信息,包括样本量、缺失情况、分布特征,
以及(当提供目标变量时)KS值和AUC值。
**参数**
:param score: 评分或预测概率数据(支持数值型数组)
:param y_true: 真实标签(可选,0/1。如果提供则计算KS和AUC)
:return: 评分统计字典,包含以下键:
- 样本数: 评分总样本数
- 缺失数: 缺失值数量
- 缺失率: 缺失值占比
- 均值: 评分均值
- 标准差: 评分标准差
- 最小值: 评分最小值
- 最大值: 评分最大值
- 中位数: 评分中位数
- 分位数_25: 25%分位数
- 分位数_75: 75%分位数
- KS: (仅当y_true非None时)KS统计量
- AUC: (仅当y_true非None时)AUC值
**参考样例**
>>> from hscredit.core.metrics import score_stats
>>> import numpy as np
>>> np.random.seed(42)
>>> score = np.random.randn(1000) * 50 + 600
>>> stats = score_stats(score)
>>> print(f"均值={stats['均值']:.2f}, 标准差={stats['标准差']:.2f}")
>>> y_true = np.random.randint(0, 2, 1000)
>>> stats_with_target = score_stats(score, y_true)
>>> print(f"KS={stats_with_target.get('KS', 0):.4f}")
"""
score = np.asarray(score)
result = {
'样本数': len(score),
'缺失数': np.sum(pd.isna(score)),
'缺失率': np.mean(pd.isna(score)),
'均值': np.nanmean(score),
'标准差': np.nanstd(score),
'最小值': np.nanmin(score),
'最大值': np.nanmax(score),
'中位数': np.nanmedian(score),
'分位数_25': np.nanquantile(score, 0.25),
'分位数_75': np.nanquantile(score, 0.75),
}
if y_true is not None:
y_true = np.asarray(y_true)
valid_mask = ~(pd.isna(score) | pd.isna(y_true))
if valid_mask.sum() > 0:
try:
from .classification import ks, auc
result['KS'] = ks(y_true[valid_mask], score[valid_mask])
result['AUC'] = auc(y_true[valid_mask], score[valid_mask])
except Exception:
pass
return result
[文档]
def score_stability(score_train: Union[np.ndarray, pd.Series],
score_test: Union[np.ndarray, pd.Series],
method: str = 'quantile',
max_n_bins: int = 10,
min_bin_size: float = 0.01,
**kwargs) -> pd.DataFrame:
"""计算评分稳定性.
使用PSI(群体稳定性指标)评估训练集评分与测试集(新数据)评分之间的分布差异,
封装了psi_table函数,提供简化的稳定性分析接口。
**参数**
:param score_train: 训练集评分(期望分布)
:param score_test: 测试集评分(实际分布)
:param method: 分箱方法,默认为'quantile'(等频分箱),
支持与OptimalBinning相同的全部分箱方法
:param max_n_bins: 最大分箱数,默认为10
:param min_bin_size: 每箱最小样本占比,默认为0.01
:param kwargs: 其他传递给OptimalBinning的参数
:return: PSI详细统计表DataFrame,列包括:
- 分箱: 分箱标签
- 期望样本数: 训练集该分箱内样本数
- 实际样本数: 测试集该分箱内样本数
- 期望占比: 训练集该分箱样本占总样本比例
- 实际占比: 测试集该分箱样本占总样本比例
- PSI贡献: 该分箱对总PSI的贡献
**参考样例**
>>> from hscredit.core.metrics import score_stability
>>> import numpy as np
>>> np.random.seed(42)
>>> train_scores = np.random.randn(1000) * 50 + 600
>>> test_scores = np.random.randn(1000) * 50 + 605
>>> result = score_stability(train_scores, test_scores)
>>> print(result)
"""
from .stability import psi_table
return psi_table(score_train, score_test, method, max_n_bins, min_bin_size, **kwargs)