"""客群分析与偏移监控模块.
提供客群画像、客群偏移分析、多期监控报告等功能,
面向策略分析人员的全链路客群稳定性监控需求。
主要函数:
- population_profile: 客群画像(特征统计 + 坏率,支持分组)
- population_shift_analysis: 两个数据集的客群偏移分析(PSI/均值变化)
- population_monitoring_report: 多期客群监控 Excel 报告
- segment_drift_analysis: 分客群、分时间的三维偏移矩阵
- feature_cross_segment_effectiveness: 特征在不同客群下的有效性矩阵
"""
import warnings
import numpy as np
import pandas as pd
from typing import Any, Dict, List, Optional, Union
from ...utils.parallel import parallel_execute
from .utils import _eda_workload, validate_dataframe, psi_rating, iv_rating
# ---------------------------------------------------------------------------
# 内部辅助
# ---------------------------------------------------------------------------
def _quick_psi(base: np.ndarray, target: np.ndarray, n_bins: int = 10) -> float:
"""两个分布之间的快速 PSI 计算."""
base = np.asarray(base, dtype=float)
target = np.asarray(target, dtype=float)
base = base[~np.isnan(base)]
target = target[~np.isnan(target)]
if len(base) == 0 or len(target) == 0:
return np.nan
quantiles = np.linspace(0, 100, n_bins + 1)
bin_edges = np.percentile(base, quantiles)
bin_edges = np.unique(bin_edges)
if len(bin_edges) < 2:
return 0.0
eps = 1e-8
base_counts = np.histogram(base, bins=bin_edges)[0].astype(float)
tgt_counts = np.histogram(target, bins=bin_edges)[0].astype(float)
base_total = base_counts.sum()
target_total = tgt_counts.sum()
if base_total == 0 or target_total == 0:
return np.nan
base_pct = base_counts / base_total + eps
tgt_pct = tgt_counts / target_total + eps
return float(np.sum((tgt_pct - base_pct) * np.log(tgt_pct / base_pct)))
def _safe_badrate(y: pd.Series) -> Optional[float]:
"""安全计算坏率,目标为 0/1 时有效."""
y = y.dropna()
if len(y) == 0:
return np.nan
u = y.unique()
if set(u).issubset({0, 1, 0.0, 1.0, True, False}):
return float(y.mean())
return np.nan
def _population_profile_worker(task):
"""计算一个分组内单个特征的画像行。"""
feature_series, target_series, feature, group_value, percentiles, percentile_labels = task
valid = pd.to_numeric(feature_series, errors="coerce").dropna()
row: Dict[str, Any] = {
"特征": feature,
"分组": group_value,
"样本数": len(feature_series),
"缺失率(%)": round(feature_series.isna().mean() * 100, 2),
"均值": round(float(valid.mean()), 4) if len(valid) > 0 else np.nan,
"标准差": round(float(valid.std()), 4) if len(valid) > 1 else np.nan,
}
values = [
round(float(np.percentile(valid, percentile * 100)), 4) if len(valid) > 0 else np.nan
for percentile in percentiles
]
for label, value in zip(percentile_labels, values):
row[label] = value
if target_series is not None:
bad_rate = _safe_badrate(target_series)
row["坏率(%)"] = round(bad_rate * 100, 4) if not np.isnan(bad_rate) else np.nan
return row
def _population_shift_worker(task):
"""计算单个特征的两客群偏移行。"""
(
base_series,
target_series,
feature,
psi_n_bins,
warning_threshold,
alert_threshold,
base_bad_rate,
target_bad_rate,
) = task
base_col = pd.to_numeric(base_series, errors="coerce")
target_col = pd.to_numeric(target_series, errors="coerce")
psi_value = _quick_psi(base_col.dropna().values, target_col.dropna().values, psi_n_bins)
mean_base = float(base_col.mean()) if base_col.notna().sum() > 0 else np.nan
mean_target = float(target_col.mean()) if target_col.notna().sum() > 0 else np.nan
mean_change = (
round((mean_target - mean_base) / abs(mean_base) * 100, 2)
if not np.isnan(mean_base) and not np.isnan(mean_target) and mean_base != 0
else np.nan
)
if np.isnan(psi_value):
level, suggestion = "未知", "数据不足,无法评估"
elif psi_value < warning_threshold:
level, suggestion = "稳定", "无需关注"
elif psi_value < alert_threshold:
level, suggestion = "轻微偏移", "建议关注,监控趋势"
else:
level, suggestion = "显著偏移", "强烈建议排查原因,可能影响模型效果"
missing_base = round(base_series.isna().mean() * 100, 2)
missing_target = round(target_series.isna().mean() * 100, 2)
row: Dict[str, Any] = {
"特征名": feature,
"基准样本数": len(base_series),
"目标样本数": len(target_series),
"PSI": round(psi_value, 4) if not np.isnan(psi_value) else np.nan,
"偏移等级": level,
"基准均值": round(mean_base, 4) if not np.isnan(mean_base) else np.nan,
"目标均值": round(mean_target, 4) if not np.isnan(mean_target) else np.nan,
"均值变化(%)": mean_change,
"基准缺失率(%)": missing_base,
"目标缺失率(%)": missing_target,
"缺失率变化(%)": round(missing_target - missing_base, 2),
"建议": suggestion,
}
if base_bad_rate is not None:
row["基准坏率(%)"] = round(base_bad_rate * 100, 4) if not np.isnan(base_bad_rate) else np.nan
row["目标坏率(%)"] = round(target_bad_rate * 100, 4) if not np.isnan(target_bad_rate) else np.nan
return row
def _monitoring_psi_worker(task):
"""计算监控期和特征的快速 PSI。"""
base_series, compare_series, feature, label, n_bins = task
value = _quick_psi(
pd.to_numeric(base_series, errors="coerce").dropna().values,
pd.to_numeric(compare_series, errors="coerce").dropna().values,
n_bins,
)
return feature, label, round(value, 4) if not np.isnan(value) else np.nan
def _monitoring_distribution_worker(task):
"""生成单个 Top 偏移特征的多期分布明细。"""
feature, base, comparisons, labels, n_bins = task
base_col = pd.to_numeric(base.get(feature), errors="coerce").dropna()
if len(base_col) == 0:
return feature, pd.DataFrame()
edges = np.unique(np.percentile(base_col, np.linspace(0, 100, n_bins + 1)))
if len(edges) < 2:
return feature, pd.DataFrame()
rows = []
for label, frame in [("基准", base)] + list(zip(labels, comparisons)):
if feature not in frame.columns:
continue
counts = np.histogram(pd.to_numeric(frame[feature], errors="coerce").dropna().values, bins=edges)[0]
total = counts.sum()
for index, count in enumerate(counts):
rows.append(
{
"特征名": feature,
"期次": label,
"分箱": f"bin_{index + 1}",
"样本数": int(count),
"占比(%)": round(count / total * 100, 2) if total > 0 else 0,
}
)
return feature, pd.DataFrame(rows)
def _segment_drift_worker(task):
"""计算一个客群、时期和特征的漂移行。"""
base_frame, target_frame, feature, segment, period, base_period, target, n_bins = task
psi_value = _quick_psi(
pd.to_numeric(base_frame[feature], errors="coerce").dropna().values,
pd.to_numeric(target_frame[feature], errors="coerce").dropna().values,
n_bins,
)
row: Dict[str, Any] = {
"特征名": feature,
"客群": segment,
"时间": period,
"基准期": base_period,
"PSI": round(psi_value, 4) if not np.isnan(psi_value) else np.nan,
"偏移等级": psi_rating(psi_value) if not np.isnan(psi_value) else "未知",
}
if target is not None and target in target_frame.columns:
base_bad_rate = _safe_badrate(base_frame[target])
target_bad_rate = _safe_badrate(target_frame[target])
row["基准坏率(%)"] = round(base_bad_rate * 100, 2) if not np.isnan(base_bad_rate) else np.nan
row["当期坏率(%)"] = round(target_bad_rate * 100, 2) if not np.isnan(target_bad_rate) else np.nan
return row
def _cross_segment_metric_worker(task):
"""计算一个特征在一个客群下的 IV/KS/AUC。"""
series, target, feature, label, metric, min_segment_size = task
from ..metrics import auc, iv, ks
try:
numeric = pd.to_numeric(series, errors="coerce")
mask = numeric.notna() & target.notna()
if mask.sum() < min_segment_size or target[mask].nunique() < 2:
value = np.nan
elif metric == "iv":
value = round(float(iv(target[mask], numeric[mask])), 4)
elif metric == "ks":
value = round(float(ks(target[mask], numeric[mask])), 4)
else:
value = round(float(auc(target[mask], numeric[mask])), 4)
except Exception:
value = np.nan
return feature, label, value
# ---------------------------------------------------------------------------
# 1. population_profile
# ---------------------------------------------------------------------------
[文档]
def population_profile(
df: pd.DataFrame,
features: List[str],
segment_col: Optional[str] = None,
date_col: Optional[str] = None,
target: Optional[str] = None,
freq: str = 'M',
percentiles: List[float] = [0.25, 0.5, 0.75],
n_jobs=-1,
parallel_backend=None,
parallel_config=None,
) -> pd.DataFrame:
"""客群画像分析.
计算各特征的均值、分位数、缺失率,以及坏率(若提供标签),
支持按客群维度(segment_col)或时间(date_col)分组对比。
:param df: 输入数据
:param features: 需要分析的特征列表
:param segment_col: 客群分组列(如渠道、产品线),为 None 时不分组
:param date_col: 时间列,为 None 时不按时间分组;与 segment_col 互斥,优先 segment_col
:param target: 目标变量列名(0/1),提供时输出各组坏率
:param freq: 时间聚合粒度,'M'=月,'Q'=季度,'Y'=年,仅 date_col 非 None 时有效
:param percentiles: 分位数列表,默认 [0.25, 0.5, 0.75]
:return: 客群画像 DataFrame
**参考样例**
>>> profile = population_profile(df, features=['age', 'income'], target='fpd15')
>>> profile_by_seg = population_profile(df, features=['age'], segment_col='channel', target='fpd15')
"""
validate_dataframe(df, required_cols=features)
df = df.copy()
# 确定分组列
group_col: Optional[str] = None
if segment_col is not None and segment_col in df.columns:
group_col = segment_col
elif date_col is not None and date_col in df.columns:
freq_map = {'M': 'M', 'Q': 'Q', 'Y': 'A'}
pf = freq_map.get(freq, 'M')
df['__period__'] = pd.to_datetime(df[date_col]).dt.to_period(pf).astype(str)
group_col = '__period__'
pct_labels = [f'p{int(p * 100)}' for p in percentiles]
stat_cols = ['均值', '标准差', '缺失率(%)'] + pct_labels + ['样本数']
if target is not None and target in df.columns:
stat_cols.append('坏率(%)')
if group_col is not None:
groups = list(df.groupby(group_col, sort=True))
else:
groups = [('全量', df)]
def iter_tasks():
for group_value, group_frame in groups:
target_series = group_frame[target] if target is not None and target in group_frame.columns else None
for feature in features:
if feature in group_frame.columns:
yield (
group_frame[feature],
target_series,
feature,
group_value,
tuple(percentiles),
tuple(pct_labels),
)
actual_task_count = sum(1 for _, group_frame in groups for feature in features if feature in group_frame.columns)
rows = parallel_execute(
_population_profile_worker,
iter_tasks(),
n_jobs=n_jobs,
parallel_backend=parallel_backend,
parallel_config=parallel_config,
task_labels=[
f"{group_value}:{feature}"
for group_value, group_frame in groups
for feature in features
if feature in group_frame.columns
],
default_backend="threading",
workload=_eda_workload(df, actual_task_count, operation="客群画像", cost_per_item=5.0),
)
result = pd.DataFrame(rows)
# 清理临时列
if '__period__' in df.columns and group_col == '__period__':
result = result.rename(columns={'分组': date_col or '时间周期'})
return result
# ---------------------------------------------------------------------------
# 2. population_shift_analysis
# ---------------------------------------------------------------------------
[文档]
def population_shift_analysis(
df_base: pd.DataFrame,
df_target: pd.DataFrame,
features: List[str],
target: Optional[str] = None,
psi_n_bins: int = 10,
psi_threshold_warn: float = 0.1,
psi_threshold_alert: float = 0.25,
n_jobs=-1,
parallel_backend=None,
parallel_config=None,
) -> pd.DataFrame:
"""客群偏移分析.
计算基准数据集与目标数据集之间各特征的 PSI、均值变化、坏率变化,
输出偏移摘要表,标注偏移等级和建议。
:param df_base: 基准数据集(如训练集/历史月份)
:param df_target: 目标数据集(如生产数据/近期月份)
:param features: 需要分析的特征列表
:param target: 目标变量列名(0/1),提供时输出坏率变化
:param psi_n_bins: PSI 分箱数,默认 10
:param psi_threshold_warn: PSI 警告阈值,默认 0.1(黄色)
:param psi_threshold_alert: PSI 告警阈值,默认 0.25(红色)
:return: 偏移摘要 DataFrame,含 特征名/PSI/均值变化/偏移等级/建议
**参考样例**
>>> result = population_shift_analysis(train_df, prod_df, features=['age', 'income'])
>>> print(result[['特征名', 'PSI', '偏移等级', '建议']])
"""
validate_dataframe(df_base, required_cols=features)
validate_dataframe(df_target, required_cols=features)
valid_features = [feature for feature in features if feature in df_base.columns and feature in df_target.columns]
base_bad_rate = _safe_badrate(df_base.get(target)) if target is not None else None # type: ignore[arg-type]
target_bad_rate = _safe_badrate(df_target.get(target)) if target is not None else None # type: ignore[arg-type]
rows = parallel_execute(
_population_shift_worker,
(
(
df_base[feature],
df_target[feature],
feature,
psi_n_bins,
psi_threshold_warn,
psi_threshold_alert,
base_bad_rate,
target_bad_rate,
)
for feature in valid_features
),
n_jobs=n_jobs,
parallel_backend=parallel_backend,
parallel_config=parallel_config,
task_labels=valid_features,
default_backend="threading",
workload=_eda_workload(
df_base.loc[:, valid_features],
len(valid_features),
operation="客群偏移分析",
cost_per_item=6.0,
additional_data=(df_target.loc[:, valid_features],),
),
)
result = pd.DataFrame(rows)
if not result.empty and 'PSI' in result.columns:
result = result.sort_values('PSI', ascending=False).reset_index(drop=True)
return result
# ---------------------------------------------------------------------------
# 3. population_monitoring_report
# ---------------------------------------------------------------------------
[文档]
def population_monitoring_report(
df_base: pd.DataFrame,
df_compare_list: List[pd.DataFrame],
compare_labels: List[str],
features: List[str],
target: Optional[str] = None,
psi_n_bins: int = 10,
top_drift_n: int = 10,
output_path: str = 'population_monitor.xlsx',
n_jobs=-1,
parallel_backend=None,
parallel_config=None,
) -> str:
"""多期客群监控 Excel 报告.
生成包含以下 Sheet 的 Excel 报告:
- **总览**:各期相对基准的 PSI 汇总热力表,含稳定性等级
- **趋势**:各期样本量和坏率趋势(若提供 target)
- **偏移Top{top_drift_n}**:PSI 均值最高的特征详细对比分布
:param df_base: 基准数据集
:param df_compare_list: 各期对比数据集列表
:param compare_labels: 各期标签(与 df_compare_list 一一对应)
:param features: 监控特征列表
:param target: 目标变量列名
:param psi_n_bins: PSI 计算分箱数
:param top_drift_n: 输出偏移最大的 N 个特征
:param output_path: 输出 Excel 路径
:return: 输出文件路径
**参考样例**
>>> path = population_monitoring_report(
... df_base=train_df,
... df_compare_list=[prod_2024q1, prod_2024q2],
... compare_labels=['2024Q1', '2024Q2'],
... features=['age', 'income', 'credit_score'],
... target='fpd15',
... output_path='monitor.xlsx',
... )
"""
from ...excel import ExcelWriter, dataframe2excel
assert len(df_compare_list) == len(compare_labels), \
"df_compare_list 与 compare_labels 长度必须一致"
writer = ExcelWriter()
# ======================================================================
# Sheet 1 - PSI 总览矩阵
# ======================================================================
psi_matrix_rows: Dict[str, Dict[str, Any]] = {f: {} for f in features}
valid_pairs = []
for label, compare_frame in zip(compare_labels, df_compare_list):
for feature in features:
psi_matrix_rows[feature][label] = np.nan
if feature in df_base.columns and feature in compare_frame.columns:
valid_pairs.append((label, compare_frame, feature))
psi_results = parallel_execute(
_monitoring_psi_worker,
(
(df_base[feature], compare_frame[feature], feature, label, psi_n_bins)
for label, compare_frame, feature in valid_pairs
),
n_jobs=n_jobs,
parallel_backend=parallel_backend,
parallel_config=parallel_config,
task_labels=[f"{label}:{feature}" for label, _, feature in valid_pairs],
default_backend="threading",
workload=_eda_workload(
df_base,
len(valid_pairs),
operation="客群监控PSI矩阵",
cost_per_item=6.0,
additional_data=tuple(df_compare_list),
),
)
for feature, label, value in psi_results:
psi_matrix_rows[feature][label] = value
psi_df = pd.DataFrame(psi_matrix_rows).T.reset_index().rename(columns={'index': '特征名'})
# 添加均值 PSI 和等级
numeric_cols = compare_labels
psi_df['平均PSI'] = psi_df[numeric_cols].mean(axis=1).round(4)
psi_df['稳定性'] = psi_df['平均PSI'].apply(psi_rating)
psi_df = psi_df.sort_values('平均PSI', ascending=False).reset_index(drop=True)
ws = writer.get_sheet_by_name('PSI总览')
end_row, _ = writer.insert_value2sheet(ws, (2, 2), value='客群监控 - PSI总览', style='header_middle', end_space=(2, 30))
end_row, _ = dataframe2excel(psi_df, writer, sheet_name=ws, start_row=end_row + 1,
title='各期特征PSI(相对基准)')
# ======================================================================
# Sheet 2 - 趋势:样本量 + 坏率
# ======================================================================
trend_rows = []
base_bad = _safe_badrate(df_base[target]) if target and target in df_base.columns else np.nan
trend_rows.append({
'期次': '基准',
'样本数': len(df_base),
'坏率(%)': round(base_bad * 100, 4) if not np.isnan(base_bad) else np.nan,
})
for label, df_cmp in zip(compare_labels, df_compare_list):
br = _safe_badrate(df_cmp[target]) if target and target in df_cmp.columns else np.nan
trend_rows.append({
'期次': label,
'样本数': len(df_cmp),
'坏率(%)': round(br * 100, 4) if not np.isnan(br) else np.nan,
})
trend_df = pd.DataFrame(trend_rows)
ws2 = writer.get_sheet_by_name('样本趋势')
end_row2, _ = writer.insert_value2sheet(ws2, (2, 2), value='各期样本量与坏率趋势', style='header_middle', end_space=(2, 20))
end_row2, _ = dataframe2excel(trend_df, writer, sheet_name=ws2, start_row=end_row2 + 1,
percent_cols=['坏率(%)'])
# ======================================================================
# Sheet 3 - 偏移 Top N 详细分布对比
# ======================================================================
top_features = psi_df['特征名'].head(top_drift_n).tolist()
ws3 = writer.get_sheet_by_name(f'偏移Top{top_drift_n}')
cur_row = 2
end_row3, _ = writer.insert_value2sheet(ws3, (cur_row, 2),
value=f'偏移最大 Top{top_drift_n} 特征分布对比',
style='header_middle', end_space=(cur_row, 30))
cur_row = end_row3 + 1
distribution_results = parallel_execute(
_monitoring_distribution_worker,
(
(feature, df_base, tuple(df_compare_list), tuple(compare_labels), psi_n_bins)
for feature in top_features
),
n_jobs=n_jobs,
parallel_backend=parallel_backend,
parallel_config=parallel_config,
task_labels=top_features,
default_backend="threading",
workload=_eda_workload(
df_base,
len(top_features),
operation="客群监控Top特征分布",
cost_per_item=4.0,
additional_data=tuple(df_compare_list),
),
)
for feat, dist_df in distribution_results:
if not dist_df.empty:
end_row3, _ = dataframe2excel(
dist_df, writer, sheet_name=ws3,
title=f'{feat} 分布对比', start_row=cur_row,
percent_cols=['占比(%)'],
)
cur_row = end_row3 + 2
writer.save(output_path)
return output_path
# ---------------------------------------------------------------------------
# 4. segment_drift_analysis
# ---------------------------------------------------------------------------
[文档]
def segment_drift_analysis(
df: pd.DataFrame,
date_col: str,
segment_col: str,
features: List[str],
target: Optional[str] = None,
base_period: Optional[str] = None,
freq: str = 'M',
psi_n_bins: int = 10,
n_jobs=-1,
parallel_backend=None,
parallel_config=None,
) -> pd.DataFrame:
"""分客群、分时间的特征偏移三维矩阵.
计算每个 (客群, 时间) 组合相对于基准期(或第一期)的特征 PSI,
输出长格式三维结果表,列为 [特征名, 客群, 时间, PSI, 偏移等级]。
:param df: 输入数据(须含 date_col 和 segment_col)
:param date_col: 日期列名
:param segment_col: 客群分组列名
:param features: 分析特征列表
:param target: 目标变量(可选,用于输出各组坏率)
:param base_period: 基准期字符串(如 '2024-01'),None 时取最早一期
:param freq: 时间聚合频率,'M'=月,'Q'=季度,'Y'=年
:param psi_n_bins: PSI 计算分箱数
:return: 长格式 DataFrame,含 特征名/客群/时间/PSI/偏移等级
**参考样例**
>>> result = segment_drift_analysis(df, date_col='apply_month',
... segment_col='channel', features=['age', 'income'], base_period='2024-01')
>>> print(result.pivot_table(index=['客群', '时间'], columns='特征名', values='PSI'))
"""
validate_dataframe(df, required_cols=[date_col, segment_col] + features)
df = df.copy()
freq_map = {'M': 'M', 'Q': 'Q', 'Y': 'A'}
pf = freq_map.get(freq, 'M')
df['__period__'] = pd.to_datetime(df[date_col]).dt.to_period(pf).astype(str)
all_periods = sorted(df['__period__'].unique())
if base_period is None:
base_period = all_periods[0]
segments = sorted(df[segment_col].dropna().unique())
task_specs = []
for seg in segments:
seg_df = df[df[segment_col] == seg]
base_seg = seg_df[seg_df['__period__'] == base_period]
if len(base_seg) == 0:
continue
for period in all_periods:
if period == base_period:
continue
period_seg = seg_df[seg_df['__period__'] == period]
if len(period_seg) == 0:
continue
for feat in features:
if feat in base_seg.columns:
task_specs.append((base_seg, period_seg, feat, seg, period, base_period, target, psi_n_bins))
rows = parallel_execute(
_segment_drift_worker,
iter(task_specs),
n_jobs=n_jobs,
parallel_backend=parallel_backend,
parallel_config=parallel_config,
task_labels=[f"{segment}:{period}:{feature}" for _, _, feature, segment, period, *_ in task_specs],
default_backend="threading",
workload=_eda_workload(df, len(task_specs), operation="分客群分时间偏移", cost_per_item=6.0),
)
result = pd.DataFrame(rows)
if not result.empty:
result = result.sort_values(['特征名', '客群', '时间']).reset_index(drop=True)
return result
# ---------------------------------------------------------------------------
# 5. feature_cross_segment_effectiveness
# ---------------------------------------------------------------------------
[文档]
def feature_cross_segment_effectiveness(
df: pd.DataFrame,
features: List[str],
target: str,
segment_col: str,
metric: str = 'iv',
n_bins: int = 10,
min_segment_size: int = 50,
n_jobs=-1,
parallel_backend=None,
parallel_config=None,
) -> pd.DataFrame:
"""特征在不同客群下的有效性矩阵.
计算每个特征在每个客群下的 IV / KS / AUC,
输出宽格式矩阵(行=特征,列=客群,格=指标值),
用于快速发现特征在哪些客群有效、哪些失效。
:param df: 输入数据
:param features: 特征列表
:param target: 目标变量列名(0/1)
:param segment_col: 客群分组列名
:param metric: 有效性指标,'iv' / 'ks' / 'auc'
:param n_bins: 分箱数(iv/ks 计算使用)
:param min_segment_size: 最小客群样本量,低于此值跳过
:return: 宽格式有效性矩阵 DataFrame
**参考样例**
>>> matrix = feature_cross_segment_effectiveness(
... df, features=['age', 'income'], target='fpd15',
... segment_col='channel', metric='iv')
>>> print(matrix)
# channel_A channel_B 全量
# age 0.15 0.08 0.12
# income 0.22 0.19 0.20
"""
validate_dataframe(df, required_cols=[target, segment_col] + features)
metric = metric.lower()
assert metric in ('iv', 'ks', 'auc'), "metric 须为 'iv' / 'ks' / 'auc'"
segments = sorted(df[segment_col].dropna().unique())
records: Dict[str, Dict[str, Any]] = {f: {} for f in features}
task_specs = [(df[feature], df[target], feature, "全量", metric, min_segment_size) for feature in features]
for seg in segments:
seg_df = df[df[segment_col] == seg]
if len(seg_df) < min_segment_size:
continue
task_specs.extend(
(seg_df[feature], seg_df[target], feature, str(seg), metric, min_segment_size)
for feature in features
)
metric_results = parallel_execute(
_cross_segment_metric_worker,
iter(task_specs),
n_jobs=n_jobs,
parallel_backend=parallel_backend,
parallel_config=parallel_config,
task_labels=[f"{label}:{feature}" for _, _, feature, label, *_ in task_specs],
default_backend="loky" if metric == "iv" else "threading",
workload=_eda_workload(
df,
len(task_specs),
operation="特征跨客群有效性",
cost_per_item=12.0 if metric == "iv" else 4.0,
capability="process_safe" if metric == "iv" else "thread_safe",
),
)
for feature, label, value in metric_results:
records[feature][label] = value
result = pd.DataFrame(records).T
result.index.name = '特征名'
result = result.reset_index()
# 按全量指标降序
if '全量' in result.columns:
result = result.sort_values('全量', ascending=False).reset_index(drop=True)
return result