"""WOE (Weight of Evidence) 编码器.
提供直接计算WOE的编码功能,不依赖分箱模块。
"""
from typing import Optional, List, Dict, Union, Any
import numpy as np
import pandas as pd
from .base import BaseEncoder
from ._category_protocol import CategoryToken, MISSING, UNKNOWN
from ...exceptions import NotFittedError, FeatureNotFoundError
[文档]
class WOEEncoder(BaseEncoder):
"""WOE (证据权重) 编码器.
直接对类别特征计算WOE值,不依赖分箱功能。
WOE计算公式:
WOE = ln(P(坏样本|类别) / P(好样本|类别)) = ln(坏样本占比/好样本占比)
**参数**
:param cols: 需要编码的列名列表。如果为None,则自动识别所有类别型列
:param regularization: 正则化参数,防止除零,默认为1.0
:param woe_clip: WOE值截断阈值,默认为5.0
当某个分箱无坏样本或无好样本时,WOE可能变得极大(如±10以上),
这会导致评分卡中对应分箱的分数异常。
设置此参数可将WOE限制在[-woe_clip, woe_clip]范围内。
设置为None则不进行截断。
:param handle_unknown: transform 时遇到 fit 未见过的类别的处理方式,默认为 ``'value'``:
- ``'value'``:编码为 0.0(中性 WOE)
- ``'return_nan'``:编码为 NaN
- ``'error'``:抛出异常
:param handle_missing: 缺失值(NaN)的处理方式,默认为 ``'value'``:
- ``'value'``:编码为 0.0(中性 WOE)
- ``'return_nan'``:编码为 NaN
- ``'error'``:fit/transform 遇缺失即抛出异常
:param drop_invariant: 是否删除方差为0的列,默认为False
:param return_df: 是否返回DataFrame,默认为True
:param target: scorecardpipeline 风格的目标列名,提供后 fit 时从 X 中提取该列作为 y
:param training_mode: 默认 in_sample;oof 时 fit_transform 生成折外训练编码,
transform 使用全训练集映射。时间切分未覆盖的起始行保留 NaN,见 oof_coverage_。
:param cv: 折外编码的折数或 sklearn 切分器;fit_transform 的 groups 参数按位置传入
:param random_state: 整数 cv 随机分折的种子,默认 42
**属性**
- mapping_: WOE编码映射字典,格式为 {col: {category: woe_value}}
- iv_: 各特征的IV值,格式为 {col: iv_value}
**参考样例**
>>> from hscredit.core.encoders import WOEEncoder
>>> encoder = WOEEncoder(cols=['category', 'score'])
>>> X_encoded = encoder.fit_transform(X, y)
>>> print(encoder.iv_)
>>>
>>> # 获取IV摘要
>>> summary = encoder.summary()
>>> print(summary)
导出/加载(与 toad、scorecardpipeline 规则格式互通)::
>>> rules = encoder.export(to_json='woe_rules.json')
>>> WOEEncoder().load('woe_rules.json')
**注意**
与 :class:`~hscredit.core.binning.BaseBinning` 的 ``metric='woe'`` 不同,本编码器
直接对原始类别取值计算 WOE、不做数值分箱,适合基数适中的类别特征。``regularization``
采用加性平滑以避免某类别好/坏样本数为 0 时 WOE 取 ±∞。
**引用**
WOE(证据权重)与 IV(信息价值)出自信息论,系统应用于信用评分见
Siddiqi, N. (2006). *Credit Risk Scorecards.* Wiley;
公式与直观解释参考
https://www.listendata.com/2015/03/weight-of-evidence-woe-and-information.html
"""
# iv_ 为各特征信息价值,随映射一并序列化以便 import_mapping 后仍可查
_EXTRA_STATE_ATTRS = ["iv_", "_legacy_string_keys_"]
_TARGET_TYPE = "binary"
_OUTPUT_ATTRS = {"hscredit_encoding": "woe", "hscredit_source": "WOEEncoder"}
def __init__(
self,
cols: Optional[List[str]] = None,
regularization: float = 1.0,
woe_clip: Optional[float] = 5.0,
handle_unknown: str = 'value',
handle_missing: str = 'value',
drop_invariant: bool = False,
return_df: bool = True,
target: Optional[str] = None,
n_jobs: Optional[Union[int, float]] = -1,
parallel_backend: Optional[str] = None,
parallel_config: Optional[Dict[str, Any]] = None,
training_mode: str = "in_sample",
cv: Any = 5,
random_state: Optional[int] = 42,
passthrough_target: bool = False,
):
"""初始化WOE编码器。
:param cols: 需要编码的列名列表
:param regularization: 正则化参数,防止除零,默认为1.0
:param woe_clip: WOE值截断阈值,默认为5.0
当某个分箱的WOE绝对值超过此阈值时会被截断。
这可以防止因分箱中无坏样本或无好样本导致的极端WOE值,
避免评分卡中对应分箱的分数过大。
设置为None则不进行截断。
:param handle_unknown: 处理未知类别的方式,默认为'value'
:param handle_missing: 处理缺失值的方式,默认为'value'
:param drop_invariant: 是否删除方差为0的列,默认为False
:param return_df: 是否返回DataFrame,默认为True
:param target: scorecardpipeline风格的目标列名。如果提供,fit时从X中提取该列作为y
"""
super().__init__(
cols=cols,
drop_invariant=drop_invariant,
return_df=return_df,
handle_unknown=handle_unknown,
handle_missing=handle_missing,
target=target,
n_jobs=n_jobs,
parallel_backend=parallel_backend,
parallel_config=parallel_config,
passthrough_target=passthrough_target,
)
self.regularization = regularization
self.woe_clip = woe_clip
self.training_mode = training_mode
self.cv = cv
self.random_state = random_state
self.iv_: Dict[str, float] = {}
self._legacy_string_keys_ = False
def _get_category_cols(self, X: pd.DataFrame) -> List[str]:
"""自动识别需要编码的列。
WOEEncoder支持数值型和类别型列,因此返回所有列(除了目标列)。
:param X: 输入数据
:return: 列名列表
"""
return X.columns.tolist()
def _fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
"""拟合WOE编码器。
:param X: 输入数据,shape (n_samples, n_features)
:param y: 目标变量,二分类 (0/1)
:raises ValueError: 当y为空或目标变量不是二元时抛出
"""
if y is None:
raise ValueError("WOEEncoder是有监督编码器,必须提供目标变量y")
y = pd.Series(y).astype(int)
unique = y.unique()
if len(unique) != 2:
raise ValueError(f"目标变量必须是二元的,当前有{len(unique)}个唯一值")
if not set(unique).issubset({0, 1}):
raise ValueError("目标变量必须是0和1")
self._fit_columns(X, y, state_attrs=("mapping_", "iv_"))
def _fit_column(self, column, values, y=None):
total_good = (y == 0).sum()
total_bad = (y == 1).sum()
woe_map, iv = self._fit_categorical(values, y, total_good, total_bad)
return {"mapping_": woe_map, "iv_": iv}
def _fit_categorical(
self, x: pd.Series, y: pd.Series, total_good: int, total_bad: int
) -> tuple:
"""拟合类别特征的WOE。
:param x: 特征列
:param y: 目标变量
:param total_good: 好样本总数
:param total_bad: 坏样本总数
:return: WOE映射和IV值的元组 (woe_map, iv)
"""
woe_map = {}
codes, categories = pd.factorize(x, sort=False)
valid = codes >= 0
good_counts = np.bincount(codes[valid], weights=(np.asarray(y)[valid] == 0), minlength=len(categories))
bad_counts = np.bincount(codes[valid], weights=(np.asarray(y)[valid] == 1), minlength=len(categories))
iv = 0.0
for category, good_count, bad_count in zip(categories, good_counts, bad_counts):
woe = self._compute_woe(good_count, bad_count, total_good, total_bad)
woe_map[category] = woe
good_dist = (good_count + self.regularization) / (total_good + 2 * self.regularization)
bad_dist = (bad_count + self.regularization) / (total_bad + 2 * self.regularization)
iv += (bad_dist - good_dist) * np.log(bad_dist / good_dist)
if self.handle_missing == 'value':
woe_map[MISSING] = 0.0
elif self.handle_missing == 'return_nan':
woe_map[MISSING] = np.nan
if self.handle_unknown == 'value':
woe_map[UNKNOWN] = 0.0
elif self.handle_unknown == 'return_nan':
woe_map[UNKNOWN] = np.nan
return woe_map, iv
def _compute_woe(
self, good_count: int, bad_count: int, total_good: int, total_bad: int
) -> float:
"""计算WOE值(带正则化和截断)。
WOE = ln(坏样本占比 / 好样本占比)
与 toad、scorecardpipeline 及 hscredit 分箱模块保持一致。
坏样本集中的箱 WOE > 0,好样本集中的箱 WOE < 0,
LR 系数为正,便于理解。
当某个分箱无坏样本或无好样本时,WOE值可能变得极大,
此时会根据 woe_clip 参数进行截断,防止评分卡分数异常。
:param good_count: 好样本数量
:param bad_count: 坏样本数量
:param total_good: 好样本总数
:param total_bad: 坏样本总数
:return: WOE值
"""
good_rate = (good_count + self.regularization) / (total_good + 2 * self.regularization)
bad_rate = (bad_count + self.regularization) / (total_bad + 2 * self.regularization)
woe = np.log(bad_rate / good_rate)
# 截断极端WOE值,防止评分卡分数异常
if self.woe_clip is not None:
woe = np.clip(woe, -self.woe_clip, self.woe_clip)
return woe
def _compute_iv_categorical(
self, x: pd.Series, y: pd.Series, total_good: int, total_bad: int
) -> float:
"""计算类别特征的IV。
:param x: 特征列
:param y: 目标变量
:param total_good: 好样本总数
:param total_bad: 坏样本总数
:return: IV值
"""
iv = 0.0
for category in x.dropna().unique():
mask = x == category
good_count = (y[mask] == 0).sum()
bad_count = (y[mask] == 1).sum()
good_dist = (good_count + self.regularization) / (total_good + 2 * self.regularization)
bad_dist = (bad_count + self.regularization) / (total_bad + 2 * self.regularization)
iv += (bad_dist - good_dist) * np.log(bad_dist / good_dist)
return iv
def _transform(self, X: pd.DataFrame, y: Optional[pd.Series] = None) -> pd.DataFrame:
"""转换数据为WOE编码。
:param X: 输入数据,shape (n_samples, n_features)
:param y: 目标变量(可选)
:return: 编码后的数据
"""
output = self._transform_columns(X, y)
output.attrs['hscredit_encoding'] = 'woe'
output.attrs['hscredit_source'] = 'WOEEncoder'
return output
def _transform_column(self, column, values, y=None, context=None):
woe_map = self.mapping_[column]
mapped = self._map_values(values, woe_map)
# 仅旧格式允许数字字符串回退,维持传统export→load互操作。
# fit及v2类型映射按真实类别类型匹配,不将未见字符串悄悄当数值类别。
unmapped = mapped.isna() & values.notna()
if unmapped.any() and getattr(self, "_legacy_string_keys_", False):
str_map = {
str(k): v
for k, v in woe_map.items()
if not isinstance(k, CategoryToken) and not (isinstance(k, float) and pd.isna(k))
}
if str_map:
mapped.loc[unmapped] = values.loc[unmapped].astype(str).map(str_map)
# 旧JSON的整数键是"1";Excel/pandas遇缺失可能把输入提升为1.0。
# 仅在显式旧格式且普通字符串回退仍未命中时,兼容这类整数浮点值。
remaining = mapped.isna() & values.notna()
if remaining.any():
def legacy_key(value):
if isinstance(value, (float, np.floating)) and np.isfinite(value) and float(value).is_integer():
return str(int(value))
return str(value)
mapped.loc[remaining] = values.loc[remaining].map(legacy_key).map(str_map)
return self._apply_missing_unknown(column, values, mapped, 0.0)
[文档]
def get_iv(self) -> Dict[str, float]:
"""获取各特征的IV值。
:return: 特征名到IV值的映射字典
"""
return self.iv_
[文档]
def get_mapping(self, col: Optional[str] = None) -> Union[Dict, Dict[str, Dict]]:
"""获取WOE编码映射。
:param col: 列名。如果提供,返回该列的映射;
如果为None,返回所有列的映射
:return: WOE映射字典。当 col 指定时返回 {category: woe_value},
col 为 None 时返回 {col: {category: woe_value}}
:raises NotFittedError: 当编码器尚未拟合时抛出
:raises FeatureNotFoundError: 当指定的 col 不在编码器中时抛出
"""
if not hasattr(self, 'mapping_') or not self.mapping_:
raise NotFittedError("WOEEncoder 尚未拟合,请先调用 fit 方法")
if col is None:
return self.mapping_
if col not in self.mapping_:
raise FeatureNotFoundError(f"列 '{col}' 不在编码器中,请检查列名是否正确")
return self.mapping_[col]
[文档]
def summary(self) -> pd.DataFrame:
"""获取 WOE 编码摘要表(按 IV 降序)。
对每个已编码特征给出 IV 值及对应的预测能力评级,评级阈值为:
- IV < 0.02:无预测力
- 0.02 ≤ IV < 0.1:弱预测力
- 0.1 ≤ IV < 0.3:中等预测力
- 0.3 ≤ IV < 0.5:强预测力
- IV ≥ 0.5:超强预测力(需检查是否标签泄漏)
:return: 含 ``特征`` / ``IV值`` / ``预测能力`` 三列的 DataFrame,按 ``IV值`` 降序;
未拟合或无特征时返回空 DataFrame
**参考样例**
>>> encoder.fit(X, y)
>>> encoder.summary()
"""
if not self.iv_:
return pd.DataFrame()
summary = []
for col, iv in self.iv_.items():
if iv < 0.02:
power = '无预测力'
elif iv < 0.1:
power = '弱预测力'
elif iv < 0.3:
power = '中等预测力'
elif iv < 0.5:
power = '强预测力'
else:
power = '超强预测力(需检查)'
summary.append({
'特征': col,
'IV值': round(iv, 4),
'预测能力': power,
})
return pd.DataFrame(summary).sort_values('IV值', ascending=False)
[文档]
def export(self, to_json: Optional[str] = None) -> Dict[str, Dict]:
"""导出WOE编码规则,兼容 toad/scorecardpipeline 格式.
导出格式与 toad.WOETransformer.export() 和 scorecardpipeline.WOETransformer.export() 保持一致。
:param to_json: 可选,JSON 文件保存路径。如果提供,将规则保存到该文件
:return: WOE编码规则字典,格式为 {feature: {value: woe_value, ...}, ...}
**参考样例**
>>> from hscredit.core.encoders import WOEEncoder
>>> encoder = WOEEncoder(cols=['category', 'city'])
>>> encoder.fit(X, y)
>>>
>>> # 导出为字典
>>> rules = encoder.export()
>>>
>>> # 导出并保存到 JSON 文件
>>> rules = encoder.export(to_json='woe_rules.json')
**与 toad/scorecardpipeline 的兼容性**
导出的规则可以直接被 toad 和 scorecardpipeline 加载:
>>> import toad
>>> transformer = toad.transform.WOETransformer()
>>> transformer.load(rules)
>>>
>>> from scorecardpipeline import WOETransformer
>>> transformer = WOETransformer()
>>> transformer.load(rules)
"""
import json
if not hasattr(self, 'mapping_') or not self.mapping_:
raise ValueError("WOEEncoder 尚未拟合,请先调用 fit 方法")
# 构建与 toad 兼容的格式: {feature: {value: woe_value}}
rules = {}
for col, woe_map in self.mapping_.items():
# 将 WOE 映射转换为可 JSON 序列化的格式
col_rules = {}
for value, woe in woe_map.items():
# 处理特殊值
if value == UNKNOWN:
continue # toad 不保存 __UNKNOWN__
if value == MISSING or pd.isna(value):
if 'nan' in col_rules:
raise ValueError("传统 WOE JSON 缺失键与业务类别冲突,请使用 export_mapping()")
col_rules['nan'] = woe # toad 使用字符串 'nan'
else:
key = str(value)
if key in col_rules:
raise ValueError("传统 WOE JSON 字符串键存在类型碰撞,请使用 export_mapping() 的类型化协议")
col_rules[key] = float(woe)
rules[col] = col_rules
if to_json is not None:
# 确保目录存在
import os
dir_path = os.path.dirname(to_json)
if dir_path and not os.path.exists(dir_path):
os.makedirs(dir_path, exist_ok=True)
with open(to_json, 'w', encoding='utf-8') as f:
json.dump(rules, f, ensure_ascii=False, indent=2)
return rules
[文档]
def load(self, from_json: Union[str, Dict], update: bool = False) -> 'WOEEncoder':
"""加载WOE编码规则,兼容 toad/scorecardpipeline 格式.
从字典或 JSON 文件加载WOE编码规则,支持 toad 和 scorecardpipeline 导出的格式。
:param from_json: WOE规则字典或 JSON 文件路径
- 字典: {'category': {'A': 0.5, 'B': -0.3}}
- 文件路径: 'woe_rules.json'
:param update: 是否更新现有规则(而非替换),默认为 False
:return: self,支持链式调用
**参考样例**
>>> from hscredit.core.encoders import WOEEncoder
>>> encoder = WOEEncoder()
>>>
>>> # 从字典加载
>>> rules = {'category': {'A': 0.5, 'B': -0.3}}
>>> encoder.load(rules)
>>>
>>> # 从 JSON 文件加载
>>> encoder.load('woe_rules.json')
>>>
>>> # 更新现有规则
>>> encoder.load({'new_feature': {'X': 0.2}}, update=True)
**与 toad/scorecardpipeline 的兼容性**
可以直接加载 toad 和 scorecardpipeline 导出的规则:
>>> import toad
>>> toad_transformer = toad.transform.WOETransformer()
>>> toad_transformer.fit(df, y)
>>> rules = toad_transformer.export()
>>>
>>> encoder = WOEEncoder()
>>> encoder.load(rules)
"""
import json
self._legacy_string_keys_ = True
if isinstance(from_json, str):
# 从文件加载
with open(from_json, 'r', encoding='utf-8') as f:
rules = json.load(f)
else:
# 直接使用字典
rules = from_json
if not update:
self.mapping_ = {}
self.cols_ = []
# 规范化容器:update=True 时若在未 fit 的新实例上调用,
# cols_/mapping_ 仍为基类初始的 None,下方成员判断会崩溃,这里统一兜底为空容器
if self.cols_ is None:
self.cols_ = []
if self.mapping_ is None:
self.mapping_ = {}
# 加载规则
for col, col_rules in rules.items():
if col not in self.mapping_:
self.mapping_[col] = {}
if col not in self.cols_:
self.cols_.append(col)
for value, woe in col_rules.items():
# toad/scorecardpipeline 用字符串 'nan' 表示缺失键
if value == 'nan':
self.mapping_[col][MISSING] = woe
else:
# 保持原始(字符串)键,不做 int/float 强转:
# 否则数字型字符串类别(如城市码/商品码 '100')会被转成 int,
# 与 transform 时的字符串输入不匹配,导致 WOE 全部落到未知值(静默错误)。
# 数值型输入的兼容由 _transform 的字符串回退映射保证。
self.mapping_[col][value] = woe
# 添加未知值处理
if self.handle_unknown == 'value':
self.mapping_[col][UNKNOWN] = 0.0
elif self.handle_unknown == 'return_nan':
self.mapping_[col][UNKNOWN] = np.nan
self._is_fitted = True
return self