"""逐步回归特征筛选器.
使用逐步回归(Stepwise Regression)进行特征筛选。
支持前向、后向、双向逐步选择,基于AIC/BIC/KS/AUC等准则进行特征取舍。
参考实现:
- toad.selection.stepwise
- sklearn.feature_selection.SequentialFeatureSelector
- statsmodels based stepwise regression
**参考样例**
>>> from hscredit.core.selectors import StepwiseSelector
>>> import pandas as pd
>>> import numpy as np
>>> np.random.seed(42)
>>> X = pd.DataFrame(np.random.randn(200, 5), columns=[f'f{i}' for i in range(5)]) # 5个特征
>>> y = pd.Series(np.random.randint(0, 2, 200)) # 目标变量
>>> selector = StepwiseSelector(estimator='logit', direction='both', criterion='aic') # 双向逐步选择
>>> selector.fit(X, y)
>>> print(selector.selected_features_)
"""
import logging
from typing import Union, List, Optional, Dict, Any, Tuple
import numpy as np
import pandas as pd
from scipy import stats
from sklearn.base import BaseEstimator, TransformerMixin, is_classifier
from sklearn.linear_model import LogisticRegression, LinearRegression
import warnings
from .base import BaseFeatureSelector, _set_estimator_parallel_budget
from ._selection_history import event_bytes, initialize_history, record_event
from ..._lazy import LazyModule
from ...utils.parallel import ParallelWorkload, _current_parallel_budget
logger = logging.getLogger(__name__)
# 延迟加载 statsmodels:仅在首次访问 sm 属性(逐步回归拟合)时才导入,
# 避免 import hscredit 时即触发 statsmodels.api 的加载。
sm = LazyModule("statsmodels.api")
[文档]
class StepwiseSelector(BaseFeatureSelector):
"""逐步回归特征筛选器.
使用逐步回归方法进行特征筛选,支持前向、后向和双向选择策略。
基于统计准则(AIC/BIC/KS/AUC)评估特征组合的优劣。
**参数**
:param estimator: 评估器类型,默认为'logit'
- 'logit': 逻辑回归
- 'ols': 最小二乘回归
- 或传入 sklearn 兼容的评估器对象
:param direction: 选择方向,默认为'both'
- 'forward': 前向选择,从空模型开始逐步添加特征
- 'backward': 后向消除,从全特征开始逐步剔除特征
- 'both': 双向选择,结合前向和后向
:param criterion: 筛选准则,默认为'aic'
- 'aic': Akaike信息准则(越小越好)
- 'bic': 贝叶斯信息准则(越小越好)
- 'ks': Kolmogorov-Smirnov统计量(越大越好,仅适用于logit)
- 'auc': AUC值(越大越好,仅适用于logit)
:param max_features: 最大特征数量,默认为None(不限制)
- 如果为整数,限制选中的特征数量
- 如果为浮点数(0-1),限制为总特征数的比例
:param p_enter: 前向进入阈值,默认为0.01
- 特征加入后模型改善需要超过此阈值(对于AIC/BIC)
- 或特征p值需要低于此阈值(对于p_value_enter)
:param p_remove: 后向剔除阈值,默认为0.01
- 特征剔除后模型改善需要超过此阈值
:param p_value_enter: 双向选择中特征p值阈值,默认为0.2
- 在双向选择中,p值超过此阈值的特征会被剔除
:param intercept: 是否包含截距项,默认为True
:param max_iter: 最大迭代次数,默认为100
:param verbose: 是否打印详细信息,默认为False
:param target: 目标变量列名,默认为'target'
:param include: 强制保留的特征列表
:param exclude: 强制剔除的特征列表
:param n_jobs: 并行任务数(保留参数,暂未使用)
**属性**
:param selected_features_: 选中的特征列表
:param removed_features_: 被剔除的特征列表
:param scores_: 各特征在逐步回归中的得分
:param history_: 逐步回归过程历史记录
:param model_results_: 最终模型的统计结果
:param n_features_: 选中的特征数量
**参考样例**
>>> from hscredit.core.selectors import StepwiseSelector
>>> import pandas as pd
>>> import numpy as np
>>> np.random.seed(42)
>>> X = pd.DataFrame(np.random.randn(200, 5), columns=[f'f{i}' for i in range(5)])
>>> y = pd.Series(np.random.randint(0, 2, 200))
>>> selector = StepwiseSelector(
... estimator='logit',
... direction='both',
... criterion='aic',
... max_features=10,
... p_enter=0.05,
... p_remove=0.05,
... )
>>> selector.fit(X, y)
>>> print(selector.selected_features_)
**引用**
逐步回归参见 Hocking, R. R. (1976). *The Analysis and Selection of Variables in
Linear Regression.* Biometrics;准则出处:AIC = Akaike (1974)、BIC = Schwarz (1978)。
实现风格对齐 toad / scorecardpipeline 的 ``stepwise``。
"""
method_name = "逐步回归筛选"
def __init__(
self,
estimator: Union[str, object] = "logit",
direction: str = "both",
criterion: str = "aic",
max_features: Optional[Union[int, float]] = None,
p_enter: float = 0.01,
p_remove: float = 0.01,
p_value_enter: float = 0.2,
intercept: bool = True,
max_iter: int = 100,
verbose: bool = False,
target: str = "target",
include: Optional[List[str]] = None,
exclude: Optional[List[str]] = None,
force_drop: Optional[List[str]] = None,
threshold: Union[float, int, str] = 0.0,
n_jobs: Optional[Union[int, float]] = -1,
binner: Optional[Any] = None,
binning_params: Optional[Dict[str, Any]] = None,
parallel_backend: Optional[str] = None,
parallel_config: Optional[Dict[str, Any]] = None,
target_rm: bool = False,
report_history: str = "summary",
max_report_events: int = 10000,
max_report_bytes: int = 8 * 1024 * 1024,
):
super().__init__(
target=target,
include=include,
exclude=exclude,
force_drop=force_drop,
threshold=threshold,
n_jobs=n_jobs,
binner=binner,
binning_params=binning_params,
parallel_backend=parallel_backend,
parallel_config=parallel_config,
target_rm=target_rm,
)
self.estimator = estimator
self.direction = direction
self.criterion = criterion
self.max_features = max_features
self.p_enter = p_enter
self.p_remove = p_remove
self.p_value_enter = p_value_enter
self.intercept = intercept
self.max_iter = max_iter
self.verbose = verbose
self.report_history = report_history
self.max_report_events = max_report_events
self.max_report_bytes = max_report_bytes
def _included_features_participate_in_selection(self) -> bool:
"""逐步回归必须把强制保留字段放入固定起始模型。"""
return True
def _fit_impl(
self,
X: pd.DataFrame,
y: Optional[Union[pd.Series, np.ndarray]],
) -> None:
"""拟合逐步回归筛选器。
:param X: 输入特征DataFrame
:param y: 目标变量
"""
if self.direction not in {"forward", "backward", "both"}:
raise ValueError("direction 必须是 'forward'、'backward' 或 'both'")
if self.criterion not in {"aic", "bic", "ks", "auc"}:
raise ValueError("criterion 必须是 'aic'、'bic'、'ks' 或 'auc'")
if (
isinstance(self.max_iter, (bool, np.bool_))
or not isinstance(self.max_iter, (int, np.integer))
or int(self.max_iter) < 1
):
raise ValueError("max_iter 必须是正整数")
if y is None:
if self.target not in X.columns:
raise ValueError(f"需要传入y或X中包含{self.target}列")
y = X[self.target].values
X = X.drop(columns=self.target)
self._get_feature_names(X)
initialize_history(self)
for name in ("p_enter", "p_remove", "p_value_enter"):
value = getattr(self, name)
if (
isinstance(value, (bool, np.bool_))
or not isinstance(value, (int, float, np.number))
or not np.isfinite(value)
or value < 0
):
raise ValueError(f"{name} 必须为有限的非负数")
if self.p_value_enter > 1:
raise ValueError("p_value_enter 必须在 [0, 1] 范围内")
self.score_name_ = "最终模型1-p值"
self.score_direction_ = "越大越好"
self.effective_threshold_ = None
self.decision_criteria_ = {
"前向最小改善": self.p_enter,
"后向最小改善": self.p_remove,
"双向剔除p值上限": self.p_value_enter,
}
self.candidate_failures_ = 0
self.selection_stopping_reason_ = "未发现满足改善阈值的候选"
# 解析 max_features
max_features = self._parse_max_features(X)
# 移除强制剔除的特征
X = X.drop(columns=[c for c in self.exclude_ if c in X.columns])
# 强制保留的特征
forced_include = [c for c in X.columns if c in self.include_]
if max_features is not None and len(forced_include) > max_features:
raise ValueError("include 特征数不能超过 max_features")
# 初始化
remaining = [c for c in X.columns if c not in forced_include]
selected = forced_include.copy()
# 记录历史
self.history_ = []
self.history_bytes_ = 0
self.history_truncated_ = 0
self.iteration_info_ = []
# 根据方向初始化
if self.direction == "backward":
# 后向消除:从所有特征开始
selected = list(X.columns)
remaining = []
# 计算初始模型的分数
initial_result = self._fit_model(X, y, selected)
if initial_result["result"] is not None:
best_score = initial_result["criterion"]
else:
best_score = self._get_initial_score(y)
else:
# 固定变量构成真正的起始模型,不能把已拟合基准误写为无穷大。
if selected:
initial_result = self._fit_model(X, y, selected)
if initial_result["result"] is None:
raise ValueError(
f"强制保留特征的起始模型拟合失败:{initial_result.get('failure_reason', '统计量不可得')}"
)
best_score = initial_result["criterion"]
else:
best_score = self._get_initial_score(y)
self.initial_criterion_ = best_score
record_event(
self,
{
"轮次": 0,
"特征": None,
"动作": "起始模型",
"指标名称": self.criterion.upper(),
"指标值": best_score,
"有效阈值": None,
"是否有效": bool(np.isfinite(best_score)),
"原因": "强制保留字段基准" if forced_include else "初始基准",
},
)
iter_count = 0
improved = True
while improved and iter_count < self.max_iter:
iter_count += 1
self._selection_iteration = iter_count
# 检查是否达到最大特征数
if self.direction != "backward" and max_features is not None and len(selected) >= max_features:
if self.verbose:
logger.info(f"已达到最大特征数 {max_features},停止迭代")
self.selection_stopping_reason_ = "达到最大特征数"
break
if self.direction == "backward":
# 后向消除
force_remove = max_features is not None and len(selected) > max_features
improved, selected, remaining, best_score = self._backward_step(
X,
y,
selected,
remaining,
best_score,
forced_include,
force_remove=force_remove,
)
else:
# 前向选择或双向选择
improved, selected, remaining, best_score = self._forward_step(
X, y, selected, remaining, best_score, max_features
)
if not improved:
if self.verbose:
logger.info("前向选择无改善,停止迭代")
break
# 双向选择:后向检验
if self.direction == "both" and len(selected) > len(forced_include):
selected, remaining = self._backward_check(X, y, selected, remaining, forced_include)
updated = self._fit_model(X, y, selected)
if updated["result"] is not None:
best_score = updated["criterion"]
# ``max_features`` 是结果契约,不应因为 ``max_iter`` 较小或候选模型拟合失败而失效。
# 常规迭代结束后继续执行强制后退,直到满足上限;forced_include 已在前面校验不会超过上限。
while self.direction == "backward" and max_features is not None and len(selected) > max_features:
iter_count += 1
self._selection_iteration = iter_count
removed, selected, remaining, best_score = self._backward_step(
X,
y,
selected,
remaining,
best_score,
forced_include,
force_remove=True,
)
if not removed:
raise RuntimeError("无法在保留 include 特征的前提下满足 max_features 上限")
# 最终选中的特征
self.selected_features_ = selected
# 计算特征得分(基于最终模型的p值)
self._calculate_scores(X, y, selected)
# 记录特征数量
self.n_features_ = len(selected)
self.n_iterations_ = iter_count
self.final_criterion_ = getattr(self, "final_model_criterion_", np.nan)
if iter_count >= self.max_iter and improved:
self.selection_stopping_reason_ = "达到最大迭代次数"
self._drop_reason = "逐步回归筛选后被剔除"
def _parse_max_features(self, X: pd.DataFrame) -> Optional[int]:
"""解析 max_features 参数。
:param X: 输入特征DataFrame
:return: 解析后的最大特征数量
"""
if self.max_features is None:
return None
n_features = X.shape[1]
if isinstance(self.max_features, (bool, np.bool_)):
raise ValueError("max_features 不能是布尔值")
if isinstance(self.max_features, (int, np.integer)):
if self.max_features < 1:
raise ValueError("max_features 必须大于等于 1")
return min(int(self.max_features), n_features)
elif isinstance(self.max_features, (float, np.floating)):
if not 0 < self.max_features <= 1:
raise ValueError("max_features 为浮点数时必须在 (0, 1] 范围内")
return max(1, int(float(self.max_features) * n_features))
else:
raise ValueError(f"max_features 类型错误: {type(self.max_features)}")
def _get_initial_score(self, y) -> float:
"""获取初始模型分数。
:param y: 目标变量
:return: 初始分数
"""
# 对于 AIC/BIC,初始化为正无穷(越小越好)
# 对于 KS/AUC,初始化为负无穷(越大越好)
if self.criterion in ["aic", "bic"]:
return np.inf
else:
return -np.inf
def _fit_model(self, X: pd.DataFrame, y, features: List[str]) -> Dict[str, Any]:
"""拟合模型并返回统计结果。
:param X: 输入特征DataFrame
:param y: 目标变量
:param features: 要使用的特征列表
:return: 包含模型结果的字典
"""
if len(features) == 0:
# 空模型(只有截距)
X_model = np.ones((len(y), 1))
else:
X_model = X[features].values
if self.intercept:
X_model = sm.add_constant(X_model, has_constant="add")
try:
# 根据评估器类型拟合模型
if self.estimator == "logit":
model = sm.Logit(y, X_model)
result = model.fit(disp=0, method="newton", maxiter=100)
elif self.estimator == "ols":
model = sm.OLS(y, X_model)
result = model.fit()
else:
# 使用自定义评估器
return self._fit_custom_estimator(X, y, features)
# 计算准则
criterion_value = self._calculate_criterion(result, y, X_model)
if not np.isfinite(criterion_value):
raise ValueError("模型准则值不是有限数值")
return {
"criterion": criterion_value,
"result": result,
"p_values": result.pvalues,
"params": result.params,
}
except Exception as e:
if self.verbose:
logger.info(f"模型拟合失败: {e}")
return {
"criterion": self._get_initial_score(y),
"result": None,
"p_values": None,
"params": None,
"failure_reason": f"{type(e).__name__}: {str(e)[:500]}",
}
def _fit_custom_estimator(self, X: pd.DataFrame, y, features: List[str]) -> Dict[str, Any]:
"""使用自定义评估器拟合模型。
:param X: 输入特征DataFrame
:param y: 目标变量
:param features: 要使用的特征列表
:return: 包含模型结果的字典
"""
if len(features) == 0:
return {
"criterion": self._get_initial_score(y),
"result": None,
"p_values": None,
"params": None,
}
X_model = X[features].values
from sklearn.base import clone as sklearn_clone
model = sklearn_clone(self.estimator)
_set_estimator_parallel_budget(model, _current_parallel_budget().available)
if hasattr(model, "fit_intercept"):
model.fit_intercept = self.intercept
elif self.intercept:
X_model = sm.add_constant(X_model, has_constant="add")
try:
model.fit(X_model, y)
# 计算预测值
if hasattr(model, "predict_proba"):
y_pred = model.predict_proba(X_model)[:, 1]
else:
y_pred = model.predict(X_model)
# 计算准则值
criterion_value = self._calculate_criterion_from_predictions(
y,
y_pred,
len(features),
is_classifier_model=is_classifier(model),
)
if not np.isfinite(criterion_value):
raise ValueError("模型准则值不是有限数值")
return {
"criterion": criterion_value,
"result": model,
"p_values": None,
"params": getattr(model, "coef_", None),
}
except Exception as e:
if self.verbose:
logger.info(f"自定义评估器拟合失败: {e}")
return {
"criterion": self._get_initial_score(y),
"result": None,
"p_values": None,
"params": None,
"failure_reason": f"{type(e).__name__}: {str(e)[:500]}",
}
def _calculate_criterion(self, result, y, X_model) -> float:
"""计算准则值。
:param result: statsmodels 结果对象
:param y: 目标变量
:param X_model: 模型输入矩阵
:return: 准则值
"""
if self.criterion == "aic":
return result.aic
elif self.criterion == "bic":
return result.bic
elif self.criterion == "ks":
y_pred = result.predict(X_model)
return self._calculate_ks(y, y_pred)
elif self.criterion == "auc":
y_pred = result.predict(X_model)
return self._calculate_auc(y, y_pred)
else:
# 默认使用 AIC
return result.aic
def _calculate_criterion_from_predictions(
self,
y_true,
y_pred,
n_features: int,
is_classifier_model: bool = False,
) -> float:
"""从预测值计算准则值。
:param y_true: 真实标签
:param y_pred: 预测值
:param n_features: 特征数量
:return: 准则值
"""
if self.criterion == "ks":
return self._calculate_ks(y_true, y_pred)
elif self.criterion == "auc":
return self._calculate_auc(y_true, y_pred)
elif self.criterion in ["aic", "bic"]:
# 按目标类型使用 Bernoulli 或 Gaussian 对数似然。
n = len(y_true)
y_array = np.asarray(y_true)
pred_array = np.asarray(y_pred, dtype=float)
labels = set(np.unique(y_array).tolist())
if is_classifier_model and labels.issubset({0, 1}) and np.all((pred_array >= 0) & (pred_array <= 1)):
probabilities = np.clip(pred_array, 1e-12, 1 - 1e-12)
llf = float(np.sum(y_array * np.log(probabilities) + (1 - y_array) * np.log(1 - probabilities)))
else:
mse = max(float(np.mean((y_array - pred_array) ** 2)), 1e-12)
llf = -n / 2 * np.log(2 * np.pi * mse * np.e)
parameter_count = n_features + int(bool(self.intercept))
if self.criterion == "aic":
return 2 * parameter_count - 2 * llf
else:
return parameter_count * np.log(n) - 2 * llf
else:
return self._calculate_ks(y_true, y_pred)
def _calculate_ks(self, y_true, y_pred) -> float:
"""计算KS统计量。
:param y_true: 真实标签
:param y_pred: 预测概率
:return: KS统计量
"""
y_true = np.asarray(y_true)
y_pred = np.asarray(y_pred)
# 按预测概率排序
order = np.argsort(y_pred)
y_true_sorted = y_true[order]
# 计算累积分布
cum_positive = np.cumsum(y_true_sorted)
cum_negative = np.cumsum(1 - y_true_sorted)
# 归一化
total_positive = cum_positive[-1]
total_negative = cum_negative[-1]
if total_positive == 0 or total_negative == 0:
return 0
cum_positive = cum_positive / total_positive
cum_negative = cum_negative / total_negative
# KS统计量
ks = np.max(np.abs(cum_positive - cum_negative))
return ks
def _calculate_auc(self, y_true, y_pred) -> float:
"""计算AUC值。
:param y_true: 真实标签
:param y_pred: 预测概率
:return: AUC值
"""
from ..metrics import auc
try:
return auc(y_true, y_pred)
except Exception:
return 0.5
def _forward_step(
self,
X: pd.DataFrame,
y,
selected: List[str],
remaining: List[str],
best_score: float,
max_features: Optional[int],
) -> Tuple[bool, List[str], List[str], float]:
"""执行前向选择步骤。
:param X: 输入特征DataFrame
:param y: 目标变量
:param selected: 当前选中的特征
:param remaining: 剩余可选特征
:param best_score: 当前最佳分数
:param max_features: 最大特征数量
:return: (是否改善, 更新后的selected, 更新后的remaining, 更新后的best_score)
"""
if not remaining:
return False, selected, remaining, best_score
# 检查是否达到最大特征数
if max_features is not None and len(selected) >= max_features:
return False, selected, remaining, best_score
best_feature = None
best_criterion = best_score
test_results = []
tasks = [(feature, X, y, list(selected)) for feature in remaining]
candidate_results = self._parallel_execute(
self._evaluate_forward_candidate,
tasks,
task_labels=remaining,
has_parallel_children=True,
default_backend="loky",
workload=ParallelWorkload(
task_count=len(tasks),
rows=len(X),
columns=max(1, len(selected) + 1),
data_bytes=int(X.memory_usage(deep=True).sum()),
cost_per_item=20.0,
capability="process_safe",
has_parallel_children=True,
operation="Stepwise前向候选拟合",
),
)
for candidate in candidate_results:
if candidate is None:
continue
self._record_candidate(candidate, "前向候选")
if not candidate.get("valid", True):
continue
test_results.append(candidate)
criterion = candidate["criterion"]
if self._is_improvement(criterion, best_criterion):
best_criterion = criterion
best_feature = candidate["feature"]
if best_feature is None:
return False, selected, remaining, best_score
# 判断改善是否显著
if not self._is_significant_improvement(best_score, best_criterion):
return False, selected, remaining, best_score
# 添加最佳特征
selected = selected + [best_feature]
remaining = [f for f in remaining if f != best_feature]
if self.verbose:
logger.info(
f"步骤 {len(self.history_) + 1}: 添加特征 '{best_feature}', "
f"{self.criterion} = {best_criterion:.4f}, "
f"当前特征数: {len(selected)}"
)
self._append_history(
{
"step": len(self.history_) + 1,
"action": "add",
"feature": best_feature,
"criterion": best_criterion,
"selected": selected.copy(),
}
)
return True, selected, remaining, best_criterion
def _evaluate_forward_candidate(self, task) -> Optional[Dict[str, Any]]:
"""评估前向步骤当前轮的一个候选特征。"""
feature, X, y, selected = task
result = self._fit_model(X, y, selected + [feature])
if result["result"] is None:
return {
"feature": feature,
"criterion": np.nan,
"valid": False,
"reason": result.get("failure_reason", "候选模型未产生有效结果"),
}
p_values = result["p_values"]
return {
"feature": feature,
"criterion": result["criterion"],
"p_value": np.asarray(p_values)[-1] if p_values is not None else np.nan,
"valid": True,
}
def _backward_step(
self,
X: pd.DataFrame,
y,
selected: List[str],
remaining: List[str],
best_score: float,
forced_include: List[str],
force_remove: bool = False,
) -> Tuple[bool, List[str], List[str], float]:
"""执行后向消除步骤。
:param X: 输入特征DataFrame
:param y: 目标变量
:param selected: 当前选中的特征
:param remaining: 剩余可选特征
:param best_score: 当前最佳分数
:param forced_include: 强制包含的特征
:return: (是否改善, 更新后的selected, 更新后的remaining, 更新后的best_score)
"""
if len(selected) <= len(forced_include):
return False, selected, remaining, best_score
# 当前模型分数
current_result = self._fit_model(X, y, selected)
if current_result["result"] is None:
if not force_remove:
return False, selected, remaining, best_score
current_criterion = best_score
else:
current_criterion = current_result["criterion"]
worst_feature = None
worst_criterion = current_criterion
best_candidate_criterion = None
used_fallback = False
candidates = [feature for feature in selected if feature not in forced_include]
tasks = [(feature, X, y, list(selected)) for feature in candidates if len(selected) > 1]
candidate_results = self._parallel_execute(
self._evaluate_backward_candidate,
tasks,
task_labels=[task[0] for task in tasks],
has_parallel_children=True,
default_backend="loky",
workload=ParallelWorkload(
task_count=len(tasks),
rows=len(X),
columns=max(1, len(selected) - 1),
data_bytes=int(X.memory_usage(deep=True).sum()),
cost_per_item=20.0,
capability="process_safe",
has_parallel_children=True,
operation="Stepwise后向候选拟合",
),
)
for candidate in candidate_results:
if candidate is None:
continue
self._record_candidate(candidate, "后向候选")
if not candidate.get("valid", True):
continue
criterion = candidate["criterion"]
if force_remove and (
best_candidate_criterion is None or self._is_improvement(criterion, best_candidate_criterion)
):
best_candidate_criterion = criterion
worst_criterion = criterion
worst_feature = candidate["feature"]
elif not force_remove and self._is_improvement(criterion, worst_criterion):
worst_criterion = criterion
worst_feature = candidate["feature"]
if worst_feature is None:
if not force_remove or not candidates:
return False, selected, remaining, best_score
# 极端共线/数值失败时所有候选模型都可能拟合失败。为兑现硬上限,按稳定的输入逆序
# 剔除一个非 include 特征;分数沿用当前值,且不伪造统计改进。
worst_feature = candidates[-1]
worst_criterion = current_criterion
used_fallback = True
record_event(
self,
{
"轮次": getattr(self, "_selection_iteration", 0),
"特征": worst_feature,
"动作": "数量约束回退",
"指标名称": self.criterion.upper(),
"指标值": np.nan,
"有效阈值": None,
"是否有效": False,
"原因": "所有候选拟合失败;按输入逆序强制满足数量上限,不表示统计改善",
"补充信息": {"最大特征数": self.max_features},
},
)
# 判断改善是否显著
if not force_remove and not self._is_significant_improvement(
current_criterion, worst_criterion, threshold=self.p_remove
):
return False, selected, remaining, best_score
# 移除最差特征
selected = [f for f in selected if f != worst_feature]
remaining = remaining + [worst_feature]
if self.verbose:
logger.info(
f"步骤 {len(self.history_) + 1}: 剔除特征 '{worst_feature}', "
f"{self.criterion} = {worst_criterion:.4f}, "
f"当前特征数: {len(selected)}"
)
self._append_history(
{
"step": len(self.history_) + 1,
"action": "remove",
"feature": worst_feature,
"criterion": np.nan if used_fallback else worst_criterion,
"reason": "数量上限回退,删除后模型统计量不可得" if used_fallback else "后向模型判定",
"selected": selected.copy(),
}
)
return True, selected, remaining, worst_criterion
def _evaluate_backward_candidate(self, task) -> Optional[Dict[str, Any]]:
"""评估后向步骤当前轮的一个候选特征。"""
feature, X, y, selected = task
test_features = [item for item in selected if item != feature]
if not test_features:
return None
result = self._fit_model(X, y, test_features)
if result["result"] is None:
return {
"feature": feature,
"criterion": np.nan,
"valid": False,
"reason": result.get("failure_reason", "候选模型未产生有效结果"),
}
return {"feature": feature, "criterion": result["criterion"], "valid": True}
def _record_candidate(self, candidate, action):
"""显式记录有效和失败候选;失败不再伪装成一个低分。"""
valid = bool(candidate.get("valid", True) and np.isfinite(candidate["criterion"]))
self.candidate_failures_ = getattr(self, "candidate_failures_", 0) + int(not valid)
record_event(
self,
{
"轮次": getattr(self, "_selection_iteration", 0),
"特征": candidate["feature"],
"动作": action,
"指标名称": self.criterion.upper(),
"指标值": candidate["criterion"],
"有效阈值": None,
"是否有效": valid,
"原因": candidate.get("reason", "候选模型评估"),
"补充信息": {"最小改善幅度": self.p_enter if action == "前向候选" else self.p_remove},
},
diagnostic=valid,
)
def _append_history(self, item):
"""兼容动作历史并同时建立中文事件;完整特征子集仅在 full 模式保存。"""
selected = item.get("selected", [])
if self.report_history != "full":
item = {key: value for key, value in item.items() if key != "selected"}
item["n_features"] = len(selected)
size = event_bytes(item, self.max_report_bytes)
if (
len(self.history_) < self.max_report_events
and getattr(self, "history_bytes_", 0) + size <= self.max_report_bytes
):
self.history_.append(item)
self.history_bytes_ = getattr(self, "history_bytes_", 0) + size
else:
self.history_truncated_ = getattr(self, "history_truncated_", 0) + 1
value = item.get("p_value", item.get("criterion", np.nan))
record_event(
self,
{
"轮次": getattr(self, "_selection_iteration", item["step"]),
"特征": item["feature"],
"动作": {"add": "加入", "remove": "剔除", "both_remove": "显著性剔除"}[item["action"]],
"指标名称": "p值" if "p_value" in item else self.criterion.upper(),
"指标值": value,
"有效阈值": self.p_value_enter if "p_value" in item else None,
"是否有效": bool(np.isfinite(value)),
"原因": item.get("reason", "逐步模型判定"),
"补充信息": {"前向最小改善": self.p_enter, "后向最小改善": self.p_remove},
},
)
def _backward_check(
self,
X: pd.DataFrame,
y,
selected: List[str],
remaining: List[str],
forced_include: List[str],
) -> Tuple[List[str], List[str]]:
"""在双向选择中进行后向检验,剔除不显著的特征。
:param X: 输入特征DataFrame
:param y: 目标变量
:param selected: 当前选中的特征
:param remaining: 剩余可选特征
:param forced_include: 强制包含的特征
:return: (更新后的selected, 更新后的remaining)
"""
if len(selected) <= len(forced_include):
return selected, remaining
# 拟合当前模型
result = self._fit_model(X, y, selected)
if result["result"] is None or result["p_values"] is None:
return selected, remaining
# 检查p值
p_values = result["p_values"]
# 跳过截距项(第一个是截距)
names = (["const"] if self.intercept else []) + selected
feature_pvalues = dict(zip(names, p_values))
# 找出p值超过阈值的特征(排除强制包含的)
to_remove = []
for feature in selected:
if feature in forced_include:
continue
if feature in feature_pvalues:
if feature_pvalues[feature] > self.p_value_enter:
to_remove.append((feature, feature_pvalues[feature]))
# 按p值排序,从大到小剔除
to_remove.sort(key=lambda x: x[1], reverse=True)
for feature, pval in to_remove:
selected = [f for f in selected if f != feature]
remaining = remaining + [feature]
if self.verbose:
logger.info(f" 双向选择: 剔除特征 '{feature}' (p-value = {pval:.4f})")
self._append_history(
{
"step": len(self.history_) + 1,
"action": "both_remove",
"feature": feature,
"p_value": pval,
"selected": selected.copy(),
}
)
return selected, remaining
def _is_improvement(self, new_score: float, old_score: float) -> bool:
"""判断是否为改善。
:param new_score: 新分数
:param old_score: 旧分数
:return: 是否为改善
"""
if self.criterion in ["aic", "bic"]:
# 越小越好
return new_score < old_score
else:
# 越大越好(KS/AUC)
return new_score > old_score
def _is_significant_improvement(
self, old_score: float, new_score: float, threshold: Optional[float] = None
) -> bool:
"""判断改善是否显著。
:param old_score: 旧分数
:param new_score: 新分数
:param threshold: 阈值,默认使用 p_enter
:return: 是否显著改善
"""
if threshold is None:
threshold = self.p_enter
if self.criterion in ["aic", "bic"]:
# 对于 AIC/BIC,改善值 = 旧值 - 新值
improvement = old_score - new_score
return improvement > threshold
else:
# 对于 KS/AUC,改善值 = 新值 - 旧值
improvement = new_score - old_score
return improvement > threshold
def _calculate_scores(
self,
X: pd.DataFrame,
y,
selected: List[str],
) -> None:
"""计算特征得分。
:param X: 输入特征DataFrame
:param y: 目标变量
:param selected: 选中的特征
"""
self.scores_ = pd.Series(np.nan, index=X.columns, dtype=float)
self.p_values_ = pd.Series(np.nan, index=X.columns, dtype=float)
if not selected:
return
# 拟合最终模型
result = self._fit_model(X, y, selected)
self.model_results_ = result["result"]
self.final_model_criterion_ = result["criterion"] if result["result"] is not None else np.nan
if result["result"] is None or result["p_values"] is None:
record_event(
self,
{
"轮次": getattr(self, "_selection_iteration", 0),
"特征": None,
"动作": "最终统计量",
"指标名称": "p值",
"指标值": np.nan,
"是否有效": False,
"原因": result.get("failure_reason", "模型不提供有效 p 值;未计算,不填充虚构得分"),
},
)
return
# 使用p值作为得分(p值越小越好,得分越高)
# 显式转换为 ndarray,避免 pandas 3.x 将整数解释为标签而非位置。
p_values = np.asarray(result["p_values"])
# 创建特征到p值的映射(跳过截距)
scores = {}
offset = 1 if self.intercept else 0
for i, feature in enumerate(selected):
if i + offset < len(p_values):
# p值越小,得分越高(用1-p作为得分)
value = p_values[i + offset]
if np.isfinite(value):
scores[feature] = 1 - value
self.p_values_[feature] = value
for feature, score in scores.items():
self.scores_[feature] = score
# 保存模型结果
self.model_results_ = result["result"]
[文档]
def get_history_df(self) -> pd.DataFrame:
"""获取逐步回归历史的DataFrame格式。
:return: 包含历史记录的DataFrame
"""
if not hasattr(self, "history_") or not self.history_:
return pd.DataFrame()
df = pd.DataFrame(self.history_)
# 格式化
if "selected" in df.columns:
df["n_features"] = df["selected"].apply(len)
return df.rename(
columns={
"step": "步骤",
"action": "动作",
"feature": "特征",
"criterion": "模型准则值",
"p_value": "p值",
"selected": "当前特征",
"n_features": "当前特征数",
}
).replace({"动作": {"add": "加入", "remove": "剔除", "both_remove": "显著性剔除"}})
[文档]
def summary(self) -> str:
"""生成筛选结果摘要。
:return: 摘要文本
"""
lines = []
lines.append("=" * 70)
lines.append("逐步回归筛选结果摘要")
lines.append("=" * 70)
lines.append(f"评估器类型: {self.estimator}")
lines.append(f"选择方向: {self.direction}")
lines.append(f"筛选准则: {self.criterion}")
lines.append(f"最大特征数: {self.max_features}")
lines.append(f"前向进入阈值: {self.p_enter}")
lines.append(f"后向剔除阈值: {self.p_remove}")
lines.append(f"迭代次数: {len(self.history_)}")
lines.append("")
lines.append(f"选中特征数: {len(self.selected_features_)}")
lines.append(f"剔除特征数: {len(self.removed_features_)}")
lines.append("")
lines.append("选中的特征:")
for i, feature in enumerate(self.selected_features_, 1):
score = self.scores_.get(feature, 0)
lines.append(f" {i}. {feature} (得分: {score:.4f})")
lines.append("")
lines.append("剔除的特征:")
for feature in self.removed_features_:
lines.append(f" - {feature}")
lines.append("=" * 70)
return "\n".join(lines)