hscredit.core.binning.spec 源代码

"""与显示标签无关、可持久化的分箱语义。"""

from dataclasses import dataclass, field, replace
import keyword
from typing import Any, Optional, Tuple

import numpy as np
import pandas as pd


def field_expression(feature):
    """返回 pandas 表达式字段引用;反引号字段无法无损表达时明确拒绝。"""
    name = str(feature)
    if "`" in name:
        raise ValueError("规则字段名不能包含反引号,请先重命名该字段")
    return name if name.isidentifier() and not keyword.iskeyword(name) else f"`{name}`"


def value_expression(value):
    """不引入 np 名称或无穷变量的标量字面量。"""
    if isinstance(value, np.generic):
        value = value.item()
    if isinstance(value, float) and np.isinf(value):
        return "1e999" if value > 0 else "-1e999"
    if not isinstance(value, (str, bool, int, float, type(None))):
        raise ValueError(f"规则类别暂不支持 {type(value).__name__},请先显式编码")
    return repr(value)


[文档] @dataclass(frozen=True) class BinSpec: """分箱执行语义,标签精度不会改变边界。 kind 支持 interval、categories、missing、other。other 表示不属于 values 的非缺失值;exclude_values 用于将特殊值从普通数值箱排除。 include_missing 可以将缺失合并到指定普通箱。 """ feature: str bin_id: int kind: str = "interval" lower: Optional[float] = None upper: Optional[float] = None closed_left: bool = True closed_right: bool = False values: Tuple[Any, ...] = field(default_factory=tuple) include_values: Tuple[Any, ...] = field(default_factory=tuple) known_values: Tuple[Any, ...] = field(default_factory=tuple) exclude_values: Tuple[Any, ...] = field(default_factory=tuple) include_missing: bool = False special: bool = False unknown: bool = False transform_ref: Optional[str] = None def __post_init__(self): if self.kind not in {"interval", "categories", "missing", "other"}: raise ValueError(f"不支持的分箱语义: {self.kind}") if self.lower is not None and self.upper is not None and self.lower > self.upper: raise ValueError("分箱下界不能大于上界")
[文档] def mask(self, data): """在原始列上执行,不解析展示字符串。""" values = data[self.feature] if isinstance(data, pd.DataFrame) else data missing = values.isna() if self.kind == "missing": return missing if self.kind in {"categories", "other"}: result = values.isin(self.values) if self.kind == "other": result = ~result else: result = pd.Series(True, index=values.index) if self.lower is not None: result &= values.ge(self.lower) if self.closed_left else values.gt(self.lower) if self.upper is not None: result &= values.le(self.upper) if self.closed_right else values.lt(self.upper) result = result.fillna(False) & ~missing if self.exclude_values: result &= ~values.isin(self.exclude_values) if self.include_values: result |= values.isin(self.include_values) if self.unknown and self.kind != "other": result |= ~values.isin(self.known_values) & ~missing return result | missing if self.include_missing else result
[文档] def to_expression(self): """生成可直接用于 Rule 的原始字段表达式。""" name = field_expression(self.feature) missing = f"({name}.isna())" if self.kind == "missing": return missing parts = [] if self.kind in {"categories", "other"}: members = ", ".join(value_expression(value) for value in self.values) parts.append(f"({name} {'not in' if self.kind == 'other' else 'in'} [{members}])") else: if self.lower is not None: parts.append(f"({name} {'>=' if self.closed_left else '>'} {value_expression(self.lower)})") if self.upper is not None: parts.append(f"({name} {'<=' if self.closed_right else '<'} {value_expression(self.upper)})") parts.append(f"({name}.notna())") if self.exclude_values: members = ", ".join(value_expression(value) for value in self.exclude_values) parts.append(f"({name} not in [{members}])") expression = " & ".join(parts) if self.include_values: members = ", ".join(value_expression(value) for value in self.include_values) expression = f"({expression}) | ({name} in [{members}])" if self.unknown and self.kind != "other": members = ", ".join(value_expression(value) for value in self.known_values) expression = f"({expression}) | (({name} not in [{members}]) & ({name}.notna()))" return f"({expression}) | {missing}" if self.include_missing else expression
[文档] @classmethod def from_binner(cls, binner, feature): """从本库已拟合分箱器取得箱号到语义的映射,不读取显示标签。 使用 BaseBinning 的真实保留箱策略处理缺失/特殊/未知类别归属。 对不提供该契约的外部分箱器明确拒绝,不能猜测字符串区间。 """ if feature not in getattr(binner, "splits_", {}): raise ValueError(f"分箱器不存在已拟合字段: {feature}") if not hasattr(binner, "_assign_base_feature_bins") or not hasattr(binner, "_apply_reserved_bin_policy"): raise ValueError("当前分箱器不支持结构化分箱契约") from ._categorical import is_missing_marker special = tuple(value for value in (binner.special_codes or []) if not is_missing_marker(value)) def code_for(value): series = pd.Series([value]) codes = binner._assign_base_feature_bins(feature, series) return int(binner._apply_reserved_bin_policy(feature, series, codes)[0]) missing_target = code_for(np.nan) specs = {} categorical = binner.feature_types_.get(feature) == "categorical" if categorical: groups = binner._cat_bins_[feature] known = tuple(value for group in groups for value in group if not is_missing_marker(value)) + special for index, group in enumerate(groups): members = tuple(value for value in group if not is_missing_marker(value)) specs[index] = cls(feature, index, kind="categories", values=members) else: splits = np.asarray(binner.splits_[feature], dtype=float) for index in range(len(splits) + 1): specs[index] = cls( feature, index, lower=None if index == 0 else float(splits[index - 1]), upper=None if index == len(splits) else float(splits[index]), exclude_values=special, ) for value in special: target = code_for(value) spec = specs.get(target, cls(feature, target, kind="categories", special=True)) specs[target] = replace(spec, include_values=spec.include_values + (value,)) if missing_target in specs: specs[missing_target] = replace(specs[missing_target], include_missing=True) else: specs[missing_target] = cls(feature, missing_target, kind="missing", special=missing_target == -2) unknown_target = binner.handle_unknown if categorical and unknown_target != "raise": unknown_target = int(unknown_target) spec = specs.get(unknown_target, cls(feature, unknown_target, kind="categories")) if spec.kind == "missing": spec = replace(spec, kind="categories", include_missing=True) specs[unknown_target] = replace(spec, unknown=True, known_values=known) return specs
[文档] def label(self, precision=4): """只用于展示的中文标签。""" if self.special: return "特殊值" if self.kind == "missing": return "缺失" if self.kind == "other": return "其他" if self.kind == "categories": return str(list(self.values)) lower = "-inf" if self.lower is None else format(self.lower, f".{precision}g") upper = "+inf" if self.upper is None else format(self.upper, f".{precision}g") return f"{'[' if self.closed_left else '('}{lower}, {upper}{']' if self.closed_right else ')'}"