"""CatBoost风控模型.
基于CatBoost实现的风控模型,对类别特征处理更友好。
**依赖**
pip install catboost
**参考样例**
>>> from hscredit.core.models import CatBoost
>>> model = CatBoost(
... depth=6,
... learning_rate=0.1,
... iterations=100,
... eval_metric='AUC'
... )
>>> model.fit(X_train, y_train)
>>> proba = model.predict_proba(X_test)
"""
from typing import Any, Dict, List, Optional, Tuple, Union
import numpy as np
import pandas as pd
from sklearn.metrics import roc_curve
try:
import catboost as cb
CATBOOST_AVAILABLE = True
except ImportError:
CATBOOST_AVAILABLE = False
cb = None
# 检测CatBoost版本
def _get_catboost_version():
"""获取CatBoost版本号."""
if not CATBOOST_AVAILABLE:
return None
try:
from packaging import version
return version.parse(cb.__version__)
except Exception:
return None
CATBOOST_VERSION = _get_catboost_version()
from ..base import BaseRiskModel
class CatBoostKSMetric:
"""CatBoost Python 自定义 KS 指标。"""
def is_max_optimal(self):
return True
def evaluate(self, approxes, target, weight):
raw_prediction = np.asarray(approxes[0], dtype=float)
probability = 1.0 / (1.0 + np.exp(-raw_prediction))
target = np.asarray(target)
if np.unique(target).size < 2:
return 0.0, 1.0
sample_weight = None if weight is None or len(weight) == 0 else np.asarray(weight)
fpr, tpr, _ = roc_curve(
target,
probability,
pos_label=1,
sample_weight=sample_weight,
)
return float(np.max(np.abs(tpr - fpr))), 1.0
def get_final_error(self, error, weight):
return error / weight
[文档]
class CatBoost(BaseRiskModel):
"""CatBoost风控模型.
基于CatBoost的二分类模型,针对风控场景优化。
CatBoost对类别特征有原生支持,无需编码。
**参数**
:param depth: 树深度,默认6
:param learning_rate: 学习率,默认0.1
:param iterations: 迭代次数,默认100
:param l2_leaf_reg: L2正则化系数,默认3.0
:param border_count: 边界分割数,默认254
:param random_strength: 随机强度,默认1
:param bagging_temperature: 采样温度,默认1
:param scale_pos_weight: 正负样本权重比,默认1
:param min_data_in_leaf: 叶子节点最小样本数,默认1
:param grow_policy: 生长策略,默认'SymmetricTree'
- 'SymmetricTree': 对称树
- 'Depthwise': 逐层生长
- 'Lossguide': 按损失导向生长
:param objective: 目标函数,默认'Logloss'
:param eval_metric: 评估指标,默认'AUC'
- 支持字符串或列表(多个评估指标)
:param early_stopping_rounds: 早停轮数,默认None
- 当验证集指标连续N轮没有提升时停止训练
- CatBoost仍支持此参数(与XGBoost/LightGBM新版不同)
:param early_stopping_metric: 用于早停的评估指标名称,默认None(使用eval_metric)
- 当eval_metric有多个时,指定用哪个指标进行早停判断
:param validation_fraction: 验证集比例,默认0.2
:param random_state: 随机种子,默认None
:param verbose: 是否输出详细信息,默认False
:param params: CatBoost原生参数字典,默认None
- 如果传入,将覆盖其他参数设置
- 可直接使用CatBoost原生参数名
:param kwargs: 其他CatBoost参数
**属性**
:ivar feature_importances_: 特征重要性
:ivar evals_result_: 训练过程评估结果
:ivar best_iteration_: 最佳迭代次数
:ivar best_score_: 最佳得分
**参考样例**
>>> # 基础使用
>>> model = CatBoost(depth=6, learning_rate=0.1)
>>> model.fit(X_train, y_train)
>>> # 使用原生CatBoost参数
>>> params = {'depth': 6, 'learning_rate': 0.05, 'l2_leaf_reg': 3.0}
>>> model = CatBoost(params=params)
>>> model.fit(X_train, y_train)
**引用**
基于 CatBoost 梯度提升框架(有序提升 + 类别特征原生处理),见
Prokhorenkova, L. et al. (2018). *CatBoost: unbiased boosting with
categorical features.* NeurIPS;文档 https://catboost.ai/docs/ 。
"""
def __init__(
self,
depth: int = 6,
learning_rate: float = 0.1,
iterations: int = 100,
l2_leaf_reg: float = 3.0,
border_count: int = 254,
random_strength: float = 1.0,
bagging_temperature: float = 1.0,
scale_pos_weight: float = 1.0,
min_data_in_leaf: int = 1,
grow_policy: str = "SymmetricTree",
objective: str = "Logloss",
eval_metric: Union[str, List[str], None] = "AUC",
early_stopping_rounds: Optional[int] = None,
early_stopping_metric: Optional[str] = None,
validation_fraction: float = 0.2,
random_state: Optional[int] = None,
n_jobs: int = -1,
verbose: bool = False,
params: Optional[Dict[str, Any]] = None,
scorecard_params: Optional[Dict[str, Any]] = None,
**kwargs,
):
if not CATBOOST_AVAILABLE:
raise ImportError("CatBoost未安装,请使用 pip install catboost 安装")
# 保存原生params参数
self.params = params # 用于sklearn get_params兼容性
self._native_params = params or {}
# 从params中提取参数(如果提供了原生参数)
depth = self._native_params.get("depth", depth)
learning_rate = self._native_params.get("learning_rate", learning_rate)
iterations = self._native_params.get("iterations", iterations)
# n_estimators / num_boost_round / num_trees 是 CatBoost iterations 的常见别名,
# 统一映射到 iterations 并从 kwargs/native_params 中移除,避免与 iterations 同时
# 传入 CatBoost 触发 "only one of the parameters ... should be initialized" 错误,
# 同时保持与其它 boosting 模型(均接受 n_estimators)的接口一致性
for _alias in ("n_estimators", "num_boost_round", "num_trees"):
if _alias in self._native_params:
iterations = self._native_params.pop(_alias)
if _alias in kwargs:
iterations = kwargs.pop(_alias)
l2_leaf_reg = self._native_params.get("l2_leaf_reg", l2_leaf_reg)
border_count = self._native_params.get("border_count", border_count)
random_strength = self._native_params.get("random_strength", random_strength)
bagging_temperature = self._native_params.get("bagging_temperature", bagging_temperature)
scale_pos_weight = self._native_params.get("scale_pos_weight", scale_pos_weight)
min_data_in_leaf = self._native_params.get("min_data_in_leaf", min_data_in_leaf)
grow_policy = self._native_params.get("grow_policy", grow_policy)
objective = self._native_params.get("loss_function", objective)
random_state = self._native_params.get("random_seed", random_state)
super().__init__(
objective=objective,
eval_metric=eval_metric,
early_stopping_rounds=early_stopping_rounds,
validation_fraction=validation_fraction,
random_state=random_state,
n_jobs=n_jobs,
verbose=verbose,
scorecard_params=scorecard_params,
**kwargs,
)
# CatBoost特有参数
self.depth = depth
self.learning_rate = learning_rate
self.iterations = iterations
self.l2_leaf_reg = l2_leaf_reg
self.border_count = border_count
self.random_strength = random_strength
self.bagging_temperature = bagging_temperature
self.scale_pos_weight = scale_pos_weight
self.min_data_in_leaf = min_data_in_leaf
self.grow_policy = grow_policy
# 早停相关参数
self.early_stopping_metric = early_stopping_metric
[文档]
def fit(
self,
X: Union[np.ndarray, pd.DataFrame],
y: Optional[Union[np.ndarray, pd.Series]] = None,
sample_weight: Optional[np.ndarray] = None,
eval_set: Optional[List[Tuple]] = None,
cat_features: Optional[List[int]] = None,
**fit_params,
) -> "CatBoost":
"""训练CatBoost模型.
支持两种调用方式:
1. 常规方式: fit(X, y)
2. scorecardpipeline风格: fit(X) 在init中指定target
:param X: 特征矩阵
:param y: 目标变量,可选
:param sample_weight: 样本权重
:param eval_set: 验证集列表
:param cat_features: 类别特征索引列表
:param fit_params: 其他fit参数
:return: self
"""
# CatBoost 在 numpy 矩阵上要求 cat_features 为列下标;若传入列名则先映射
if cat_features is not None and isinstance(X, pd.DataFrame):
cols = list(X.columns)
cat_features = [cols.index(c) if isinstance(c, str) else int(c) for c in cat_features]
# 准备数据(支持从X中提取target)
X, y, sample_weight = self._prepare_data(X, y, sample_weight, extract_target=True, training=True)
self._validate_probability_scorecard_labels(y)
# 保存特征信息
self.n_features_in_ = X.shape[1]
self.classes_ = np.unique(y)
# 创建验证集
auto_eval_split = eval_set is None and self.validation_fraction > 0 and self.early_stopping_rounds is not None
sw_val = None
if auto_eval_split:
X_train, X_val, y_train, y_val, sw_train, sw_val = self._create_eval_set(X, y, sample_weight)
sample_weight = sw_train
else:
X_train, y_train = X, y
# 构建参数
params = {
"depth": self.depth,
"learning_rate": self.learning_rate,
"iterations": self.iterations,
"l2_leaf_reg": self.l2_leaf_reg,
"border_count": self.border_count,
"random_strength": self.random_strength,
"bagging_temperature": self.bagging_temperature,
"scale_pos_weight": self.scale_pos_weight,
"min_data_in_leaf": self.min_data_in_leaf,
"grow_policy": self.grow_policy,
"loss_function": self.objective,
"random_seed": self.random_state,
"verbose": self.verbose,
"thread_count": -1, # 使用所有CPU
}
# 处理评估指标
requested_metrics = []
if self.eval_metric is not None:
requested_metrics = [self.eval_metric] if isinstance(self.eval_metric, str) else list(self.eval_metric)
wants_ks = any(str(metric).lower() == "ks" for metric in requested_metrics)
if wants_ks:
params["eval_metric"] = CatBoostKSMetric()
native_metrics = [
self._convert_metrics(metric) for metric in requested_metrics if str(metric).lower() != "ks"
]
if native_metrics:
params["custom_metric"] = native_metrics
else:
# CatBoost 只允许一个主评估指标,其余指标放入 custom_metric。
converted = [self._convert_metrics(metric) for metric in requested_metrics]
params["eval_metric"] = converted[0]
if len(converted) > 1:
params["custom_metric"] = converted[1:]
# 处理早停 - CatBoost仍支持early_stopping_rounds参数
if self.early_stopping_rounds is not None:
params["early_stopping_rounds"] = self.early_stopping_rounds
# 如果指定了专门的早停指标,覆盖eval_metric
if self.early_stopping_metric is not None:
if str(self.early_stopping_metric).lower() == "ks":
params["eval_metric"] = CatBoostKSMetric()
else:
params["eval_metric"] = self._convert_metrics(self.early_stopping_metric)
# 如果有多个评估指标且没有指定早停指标,使用第一个
elif (
isinstance(self.eval_metric, list)
and len(self.eval_metric) > 0
and not any(str(metric).lower() == "ks" for metric in self.eval_metric)
):
params["eval_metric"] = self._convert_metrics(self.eval_metric[0])
# 更新kwargs参数
params.update(self.kwargs)
# 最后更新原生params(优先级最高)
params.update(self._native_params)
# 公共 n_jobs 是 HSCredit 的统一总预算,优先于历史 thread_count=-1
# 和 params/kwargs 中可能造成嵌套超额并发的设置。
params["thread_count"] = max(1, int(self.n_jobs or 1))
# 解析自定义损失(BaseLoss 实例 -> CatBoost 可用的损失对象)
resolved_loss = self._resolve_catboost_loss(params.get("loss_function"))
params["loss_function"] = resolved_loss
# CatBoost 自定义损失(非内置字符串)不支持 scale_pos_weight,需移除以避免报错
if not isinstance(resolved_loss, str) and "scale_pos_weight" in params:
params.pop("scale_pos_weight", None)
# 创建模型
self._model = cb.CatBoostClassifier(**params)
# 准备训练参数
fit_kwargs = dict(fit_params)
if auto_eval_split:
validation_pool_kwargs = {}
if "baseline" in fit_kwargs:
train_baseline, val_baseline = self._split_row_aligned_value(fit_kwargs["baseline"])
fit_kwargs["baseline"] = train_baseline
if val_baseline is not None:
validation_pool_kwargs["baseline"] = val_baseline
if sw_val is not None:
validation_pool_kwargs["weight"] = sw_val
if cat_features is not None:
validation_pool_kwargs["cat_features"] = cat_features
eval_set = cb.Pool(X_val, y_val, **validation_pool_kwargs)
if eval_set:
fit_kwargs["eval_set"] = eval_set
if sample_weight is not None:
fit_kwargs["sample_weight"] = sample_weight
if cat_features is not None:
fit_kwargs["cat_features"] = cat_features
# 训练
self._model.fit(X_train, y_train, **fit_kwargs)
# 保存结果
self._best_iteration = self._model.get_best_iteration()
self._best_score = self._model.get_best_score()
self._evals_result = self._model.get_evals_result()
for dataset_metrics in self._evals_result.values():
if "CatBoostKSMetric" in dataset_metrics:
dataset_metrics["ks"] = dataset_metrics.pop("CatBoostKSMetric")
if isinstance(self._best_score, dict):
for dataset_metrics in self._best_score.values():
if isinstance(dataset_metrics, dict) and "CatBoostKSMetric" in dataset_metrics:
dataset_metrics["ks"] = dataset_metrics.pop("CatBoostKSMetric")
self._is_fitted = True
self._fit_probability_scorecard(X, y)
return self
@staticmethod
def _resolve_catboost_loss(loss_function):
"""将自定义损失对象解析为 CatBoost 可用的损失对象.
当传入 :class:`~hscredit.core.models.losses.BaseLoss` 实例时,通过
:class:`~hscredit.core.models.losses.CatBoostLossAdapter` 转换为带有
``calc_ders_range`` 接口的 CatBoost 自定义损失对象;其他对象原样返回。
"""
try:
from ..losses.base import BaseLoss
from ..losses.adapters import CatBoostLossAdapter
except Exception:
return loss_function
if isinstance(loss_function, BaseLoss):
return CatBoostLossAdapter(loss_function).objective()
return loss_function
[文档]
def predict(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray:
"""预测类别标签.
基于 predict_proba 取阈值,确保自定义损失(原始分数输出)下也能返回正确类别。
"""
self._require_fitted()
proba = self.predict_proba(X)
indices = np.argmax(proba, axis=1)
return np.asarray(self.classes_)[indices]
[文档]
def predict_proba(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray:
"""预测概率.
当使用自定义损失函数(loss_function 为可调用对象)时,CatBoost 返回的是
未经过链接函数转换的原始分数(raw margin,一维数组),此处自动应用
sigmoid 转换为概率并补齐为二维 (n_samples, 2) 输出,与内置目标保持一致。
"""
self._require_fitted()
X = self._prepare_data(X)[0]
proba = np.asarray(self._model.predict_proba(X))
# 自定义损失返回一维原始分数,应用 sigmoid 并补齐为两列概率
if proba.ndim == 1:
p1 = 1.0 / (1.0 + np.exp(-proba))
proba = np.column_stack([1.0 - p1, p1])
return proba
[文档]
def get_feature_importances(self, importance_type: str = "PredictionValuesChange") -> pd.Series:
"""获取特征重要性.
:param importance_type: 重要性类型,可选:
- 'PredictionValuesChange': 预测值变化 (默认)
- 'LossFunctionChange': 损失函数变化
- 'FeatureImportance': 分裂次数
:return: 特征重要性Series
"""
self._require_fitted()
importances = self._model.get_feature_importance(type=importance_type)
# 创建Series
importance_series = pd.Series(importances, index=self.feature_names_in_, name="importance").sort_values(
ascending=False
)
self._feature_importances = importance_series
return importance_series
@property
def feature_importances_(self) -> np.ndarray:
"""特征重要性属性 (兼容sklearn风格).
直接在包装类上暴露重要性,兼容sklearn RFE/SFS等组件的 importance_getter。
"""
self._require_fitted()
if self._feature_importances is None:
self._feature_importances = self.get_feature_importances()
return self._feature_importances.values
[文档]
def plot_tree(self, tree_index: int = 0, **kwargs):
"""绘制树结构.
:param tree_index: 树的索引
:param kwargs: 其他绘图参数
"""
self._require_fitted()
return self._model.plot_tree(tree_idx=tree_index, **kwargs)
[文档]
def get_leaf_indices(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray:
"""获取叶子节点索引.
返回每棵树上的叶子节点索引,用于GBDT+LR等场景。
:param X: 特征矩阵
:return: 叶子节点索引,形状 (n_samples, n_trees)
**参考样例**
>>> model = CatBoost(iterations=50)
>>> model.fit(X, y)
>>> leaf_indices = model.get_leaf_indices(X)
>>> print(leaf_indices.shape)
"""
self._require_fitted()
X = self._prepare_data(X)[0]
return self._model.calc_leaf_indexes(X)
[文档]
def save_model(self, path: str):
"""保存底层CatBoost模型(原生格式).
:param path: 保存路径(.cbm/.json 格式)
"""
self._require_fitted()
self._model.save_model(path)
self._save_score_transformer_sidecar(path)
[文档]
def load_model(self, path: str) -> "CatBoost":
"""加载底层CatBoost模型(原生格式).
:param path: 模型路径
:return: self
"""
self._model = cb.CatBoostClassifier()
self._model.load_model(path)
self._is_fitted = True
self.classes_ = getattr(self, "classes_", np.array([0, 1]))
if not hasattr(self, "feature_names_in_"):
n_feat = self._model.feature_count_ if hasattr(self._model, "feature_count_") else 0
self.feature_names_in_ = [f"feature_{i}" for i in range(n_feat)]
self.n_features_in_ = n_feat
self._load_score_transformer_sidecar(path)
return self
def _convert_metrics(self, metrics: Union[str, List[str]]) -> Union[str, List[str]]:
"""转换评估指标名称.
:param metrics: 指标名称或列表
:return: CatBoost格式的指标名称
"""
metric_map = {
"auc": "AUC",
"logloss": "Logloss",
"error": "Accuracy",
"rmse": "RMSE",
"mae": "MAE",
"mse": "MSE",
"msle": "MSLE",
"poisson": "Poisson",
"quantile": "Quantile",
"mape": "MAPE",
"r2": "R2",
"ndcg": "NDCG",
"map": "MAP",
"recall": "Recall",
"precision": "Precision",
"f1": "F1",
"balanced_accuracy": "BalancedAccuracy",
"balanced_error_rate": "BalancedErrorRate",
"kappa": "Kappa",
"wkappa": "WKappa",
"total_f1": "TotalF1",
"mcc": "MCC",
"brier_score": "BrierScore",
"hinge_loss": "HingeLoss",
"hamming_loss": "HammingLoss",
"zero_one_loss": "ZeroOneLoss",
"kappa:use_weights": "Kappa:use_weights",
"wkappa:use_weights": "WKappa:use_weights",
}
if isinstance(metrics, str):
return metric_map.get(metrics.lower(), metrics)
return [metric_map.get(m.lower(), m) for m in metrics]