hscredit.core.eda.feature 源代码

"""特征分析模块.

提供特征分布、异常值检测、集中度分析等功能.
"""

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

from ...utils.parallel import parallel_execute
from .utils import (
    _eda_workload,
    infer_feature_types,
    validate_dataframe,
    calculate_gini,
    remove_outliers_iqr
)


def _outlier_feature_worker(task):
    """计算单个数值特征的异常值统计。"""
    series, col, method, threshold = task
    series = series.dropna()
    if len(series) == 0:
        return None
    if method == "iqr":
        q1 = series.quantile(0.25)
        q3 = series.quantile(0.75)
        iqr = q3 - q1
        lower = q1 - threshold * iqr
        upper = q3 + threshold * iqr
        outliers = (series < lower) | (series > upper)
    elif method == "zscore":
        z_scores = np.abs((series - series.mean()) / series.std())
        outliers = z_scores > threshold
        lower = series.mean() - threshold * series.std()
        upper = series.mean() + threshold * series.std()
    elif method == "mad":
        median = series.median()
        mad = np.median(np.abs(series - median))
        modified_z = 0.6745 * (series - median) / mad if mad != 0 else 0
        outliers = np.abs(modified_z) > threshold
        lower = median - threshold * mad / 0.6745
        upper = median + threshold * mad / 0.6745
    outlier_count = outliers.sum()
    return {
        "特征名": col,
        "异常值数": int(outlier_count),
        "异常值率(%)": round(outlier_count / len(series) * 100, 2),
        "正常范围": f"[{lower:.2f}, {upper:.2f}]",
        "最小值": round(series.min(), 4),
        "最大值": round(series.max(), 4),
    }


def _rare_category_feature_worker(task):
    """返回单个特征的全部稀有类别行。"""
    series, col, threshold, total = task
    rows = []
    for value, count in series.value_counts().items():
        rate = count / total
        if rate < threshold:
            rows.append(
                {
                    "特征名": col,
                    "稀有类别": value,
                    "频数": int(count),
                    "频率(%)": round(rate * 100, 3),
                    "建议": "合并或删除",
                }
            )
    return rows


def _concentration_feature_worker(task):
    """计算单个数值特征的 Gini 集中度。"""
    series, col = task
    series = series.dropna()
    if len(series) == 0:
        return None
    gini = calculate_gini(series.values)
    if gini < 0.2:
        level = "低集中"
    elif gini < 0.4:
        level = "中等集中"
    elif gini < 0.6:
        level = "高集中"
    else:
        level = "极高集中"
    return {"特征名": col, "Gini系数": round(gini, 4), "集中度评级": level}


def _feature_stability_worker(task):
    """计算单个数值特征的跨期稳定性。"""
    frame, col, period_col = task
    period_stats = frame.groupby(period_col)[col].agg(["mean", "std", "count"]).reset_index()
    if len(period_stats) < 2:
        return None
    mean_std = period_stats["mean"].std()
    mean_mean = period_stats["mean"].mean()
    cv = mean_std / mean_mean if mean_mean != 0 else 0
    if cv < 0.05:
        level = "非常稳定"
    elif cv < 0.1:
        level = "相对稳定"
    else:
        level = "不稳定"
    return {
        "特征名": col,
        "均值标准差": round(mean_std, 4),
        "均值变异系数": round(cv, 4),
        "稳定性评级": level,
        "统计期数": len(period_stats),
    }


[文档] def feature_type_inference(df: pd.DataFrame, categorical_threshold: int = 20, unique_ratio_threshold: float = 0.05, numeric_as_categorical: Optional[List[str]] = None, force_numeric: Optional[List[str]] = None) -> pd.DataFrame: """自动推断特征类型. 默认严格按照实际数据类型判断: - 数值类型(int/float)-> 'numerical' - 非数值类型(object/string/category)-> 'categorical' 仅当用户指定参数时才进行特殊处理: - numeric_as_categorical: 将指定的数值列视为 categorical - force_numeric: 将指定的列视为 numerical :param df: 输入数据 :param categorical_threshold: 保留参数,不再用于默认类型判断 :param unique_ratio_threshold: 保留参数,不再用于默认类型判断 :param numeric_as_categorical: 强制视为分类变量的数值列名列表 :param force_numeric: 强制视为数值变量的列名列表 :return: 特征类型DataFrame **参考样例** >>> types = feature_type_inference(df) >>> print(types[['特征名', '特征类型', '唯一值数', '建议处理方式']]) >>> # 将特定数值列视为分类 >>> types = feature_type_inference(df, numeric_as_categorical=['education_level']) """ validate_dataframe(df) feature_types = infer_feature_types(df, categorical_threshold, unique_ratio_threshold, numeric_as_categorical, force_numeric) results = [] for col, ftype in feature_types.items(): n_unique = df[col].nunique(dropna=True) # 建议处理方式 if ftype == 'constant': suggestion = '考虑删除' elif ftype == 'id': suggestion = '不参与建模' elif ftype == 'datetime': suggestion = '提取时间特征' elif ftype == 'categorical': suggestion = '编码处理' elif ftype == 'text': suggestion = '文本特征工程' else: suggestion = '标准化/归一化' results.append({ '特征名': col, '特征类型': ftype, '数据类型': str(df[col].dtype), '唯一值数': n_unique, '建议处理方式': suggestion, }) return pd.DataFrame(results)
[文档] def numeric_distribution(df: pd.DataFrame, feature: str, n_bins: int = 20) -> pd.DataFrame: """数值特征分布统计. :param df: 输入数据 :param feature: 特征名 :param n_bins: 分箱数 :return: 分布统计DataFrame **参考样例** >>> dist = numeric_distribution(df, 'age', n_bins=10) >>> print(dist[['分箱区间', '频数', '频率(%)', '累计频率(%)']]) """ validate_dataframe(df, required_cols=[feature]) series = df[feature].dropna() if len(series) == 0: return pd.DataFrame() # 分箱统计 counts, bin_edges = np.histogram(series, bins=n_bins) total = counts.sum() results = [] cumsum = 0 for i in range(len(counts)): freq = counts[i] ratio = freq / total * 100 if total > 0 else 0 cumsum += ratio results.append({ '分箱编号': i + 1, '分箱区间': f'[{bin_edges[i]:.2f}, {bin_edges[i+1]:.2f})', '频数': int(freq), '频率(%)': round(ratio, 2), '累计频率(%)': round(cumsum, 2), }) return pd.DataFrame(results)
[文档] def categorical_distribution(df: pd.DataFrame, feature: str, top_n: int = None) -> pd.DataFrame: """类别特征分布统计. :param df: 输入数据 :param feature: 特征名 :param top_n: 仅显示前N个类别 :return: 分布统计DataFrame **参考样例** >>> dist = categorical_distribution(df, 'education', top_n=5) >>> print(dist[['类别值', '频数', '频率(%)']]) """ validate_dataframe(df, required_cols=[feature]) series = df[feature] total = len(series) value_counts = series.value_counts() if top_n: value_counts = value_counts.head(top_n) 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), }) result_df = pd.DataFrame(results) # 添加其他类别统计 if top_n and len(value_counts) < series.nunique(): other_count = total - value_counts.sum() other_ratio = other_count / total * 100 other_row = pd.DataFrame({ '类别值': ['其他'], '频数': [other_count], '频率(%)': [round(other_ratio, 2)], '累计频率(%)': [100.0], }) result_df = pd.concat([result_df, other_row], ignore_index=True) return result_df
[文档] def outlier_detection(df: pd.DataFrame, features: List[str] = None, method: Literal['iqr', 'zscore', 'mad'] = 'iqr', threshold: float = 1.5, n_jobs=-1, parallel_backend=None, parallel_config=None) -> pd.DataFrame: """异常值检测. :param df: 输入数据 :param features: 指定检测的特征,None则检测全部数值型 :param method: 检测方法,'iqr'/'zscore'/'mad' :param threshold: 阈值 :return: 异常值统计DataFrame **参考样例** >>> outliers = outlier_detection(df, method='iqr') >>> print(outliers[['特征名', '异常值数', '异常值率(%)', '正常范围']]) """ validate_dataframe(df) if features is None: features = df.select_dtypes(include=[np.number]).columns.tolist() valid_features = [ col for col in features if col in df.columns and pd.api.types.is_numeric_dtype(df[col]) ] tasks = ((df[col], col, method, threshold) for col in valid_features) analyzed = parallel_execute( _outlier_feature_worker, tasks, n_jobs=n_jobs, parallel_backend=parallel_backend, parallel_config=parallel_config, task_labels=valid_features, default_backend="threading", workload=_eda_workload(df.loc[:, valid_features], len(valid_features), operation="异常值检测", cost_per_item=4.0), ) results = [row for row in analyzed if row is not None] return pd.DataFrame(results).sort_values('异常值率(%)', ascending=False)
[文档] def rare_category_detection(df: pd.DataFrame, features: List[str] = None, threshold: float = 0.01, n_jobs=-1, parallel_backend=None, parallel_config=None) -> pd.DataFrame: """稀有类别检测. :param df: 输入数据 :param features: 指定检测的特征,None则检测全部分类别 :param threshold: 稀有阈值(频率低于此值视为稀有) :return: 稀有类别统计DataFrame **参考样例** >>> rare = rare_category_detection(df, threshold=0.01) >>> print(rare[['特征名', '稀有类别', '频数', '频率(%)', '建议']]) """ validate_dataframe(df) if features is None: feature_types = infer_feature_types(df) features = [f for f, t in feature_types.items() if t == 'categorical'] total = len(df) valid_features = [col for col in features if col in df.columns] analyzed = parallel_execute( _rare_category_feature_worker, ((df[col], col, threshold, total) for col 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.loc[:, valid_features], len(valid_features), operation="稀有类别检测", cost_per_item=2.0), ) results = [row for feature_rows in analyzed for row in feature_rows] if not results: return pd.DataFrame({'信息': ['未发现稀有类别']}) return pd.DataFrame(results).sort_values('频率(%)')
[文档] def concentration_analysis(df: pd.DataFrame, features: List[str] = None, n_jobs=-1, parallel_backend=None, parallel_config=None) -> pd.DataFrame: """集中度分析(Gini系数). :param df: 输入数据 :param features: 指定分析的特征,None则分析全部数值型 :return: 集中度分析DataFrame **参考样例** >>> concentration = concentration_analysis(df) >>> print(concentration[['特征名', 'Gini系数', '集中度评级']]) """ validate_dataframe(df) if features is None: features = df.select_dtypes(include=[np.number]).columns.tolist() valid_features = [ col for col in features if col in df.columns and pd.api.types.is_numeric_dtype(df[col]) ] analyzed = parallel_execute( _concentration_feature_worker, ((df[col], col) for col 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.loc[:, valid_features], len(valid_features), operation="集中度分析", cost_per_item=3.0), ) results = [row for row in analyzed if row is not None] if not results: return pd.DataFrame(columns=['特征名', 'Gini系数', '集中度评级']) return pd.DataFrame(results).sort_values('Gini系数', ascending=False)
[文档] def feature_stability_over_time(df: pd.DataFrame, features: List[str], date_col: str, freq: str = 'M', n_jobs=-1, parallel_backend=None, parallel_config=None) -> pd.DataFrame: """特征时序稳定性分析. :param df: 输入数据 :param features: 指定分析的特征列表 :param date_col: 日期列名 :param freq: 时间聚合频率,``'D'`` 日 / ``'W'`` 周 / ``'M'`` 月 / ``'Q'`` 季度,默认 ``'M'`` :return: 时序稳定性DataFrame **参考样例** >>> stability = feature_stability_over_time(df, ['age', 'income'], 'apply_date') >>> print(stability[['特征名', '均值标准差', '变异系数', '稳定性评级']]) """ validate_dataframe(df, required_cols=[date_col]) df = df.copy() df[date_col] = pd.to_datetime(df[date_col]) # 创建时间周期 period_col = '时间周期' if freq == 'M': df[period_col] = df[date_col].dt.to_period('M').astype(str) elif freq == 'W': df[period_col] = df[date_col].dt.to_period('W').astype(str) elif freq == 'Q': df[period_col] = df[date_col].dt.to_period('Q').astype(str) valid_features = [ col for col in features if col in df.columns and pd.api.types.is_numeric_dtype(df[col]) ] analyzed = parallel_execute( _feature_stability_worker, ((df.loc[:, [period_col, col]], col, period_col) for col 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.loc[:, list(dict.fromkeys([period_col] + valid_features))], len(valid_features), operation="特征时序稳定性", cost_per_item=4.0, ), ) results = [row for row in analyzed if row is not None] return pd.DataFrame(results).sort_values('均值变异系数', ascending=False)