hscredit.core.models.explainability.counterfactual 源代码

"""不依赖额外解释库的受约束反事实建议。"""

import numpy as np
import pandas as pd

from hscredit.exceptions import ValidationError

COUNTERFACTUAL_COLUMNS = [
    "样本索引",
    "方案编号",
    "是否达标",
    "变更特征数",
    "总成本",
    "预测前值",
    "预测后值",
    "目标值",
    "特征",
    "原值",
    "新值",
    "变化方向",
    "约束检查",
    "失败原因",
    "说明",
]


[文档] class CounterfactualExplainer: """在模型与显式约束下搜索最小特征变更方案。 输出仅表示模型条件下的非因果建议,不代表真实因果效果或授信承诺。 """ def __init__( self, model, reference_data, constraints=None, output_type="auto", positive_class=1, max_candidates=100, ): """创建受约束反事实搜索器。 :param model: 已拟合模型;概率模式要求 ``predict_proba``,评分模式要求 ``predict_score``。 :param reference_data: 用于候选值和数值尺度的非空 DataFrame。 :param constraints: 各特征的 mutable、weight、direction、min、max 和 allowed 约束。 :param output_type: ``auto``、``probability`` 或 ``score``。auto 优先使用概率接口。 :param positive_class: 概率模式下的风险正类标签。 :param max_candidates: 每个类别特征最多保留的确定性候选数。 """ if not isinstance(reference_data, pd.DataFrame) or reference_data.empty: raise ValidationError("reference_data 必须是非空 DataFrame") if output_type not in {"auto", "probability", "score"}: raise ValidationError("output_type 必须是 auto、probability 或 score") if not isinstance(max_candidates, int) or isinstance(max_candidates, bool) or max_candidates <= 0: raise ValidationError("max_candidates 必须是正整数") self.model = model self.reference_data = reference_data.copy() self.positive_class = positive_class self.output_type = self._resolve_output_type(output_type) self.max_candidates = max_candidates self.constraints = self._normalize_constraints(constraints or {}) self._candidates = self._build_candidates() def _resolve_output_type(self, output_type): """按显式配置或公开预测接口确定目标输出。""" if output_type == "auto": output_type = "probability" if hasattr(self.model, "predict_proba") else "score" required = "predict_proba" if output_type == "probability" else "predict_score" if not hasattr(self.model, required): raise ValidationError(f"{output_type} 模式要求模型实现 {required}") if output_type == "probability": classes = list(getattr(self.model, "classes_", [0, 1])) if self.positive_class not in classes: raise ValidationError(f"positive_class={self.positive_class!r} 不在模型类别 {classes!r} 中") return output_type def _normalize_constraints(self, constraints): unknown = set(constraints) - set(self.reference_data.columns) if unknown: raise ValidationError(f"约束包含未知特征: {sorted(unknown)}") normalized = {} for feature in self.reference_data.columns: config = dict(constraints.get(feature, {})) mutable = config.get("mutable", True) if not isinstance(mutable, bool): raise ValidationError(f"{feature} 的 mutable 必须是布尔值") weight = config.get("weight", 1.0) if isinstance(weight, bool) or not np.isscalar(weight) or not np.isfinite(weight) or float(weight) < 0: raise ValidationError(f"{feature} 的 weight 必须是有限非负数") direction = config.get("direction", "both") if direction not in {"both", "increase_only", "decrease_only"}: raise ValidationError(f"{feature} 的 direction 约束无效") if direction != "both" and not pd.api.types.is_numeric_dtype(self.reference_data[feature]): raise ValidationError(f"{feature} 是类别字段,direction 只能是 both") for bound in ("min", "max"): if bound in config and ( isinstance(config[bound], bool) or not np.isscalar(config[bound]) or not np.isfinite(config[bound]) ): raise ValidationError(f"{feature}{bound} 必须是有限数") if "min" in config and "max" in config and config["min"] > config["max"]: raise ValidationError(f"{feature} 的 min 不能大于 max") config["mutable"] = mutable config["weight"] = float(weight) config["direction"] = direction normalized[feature] = config return normalized def _build_candidates(self): candidates = {} for feature in self.reference_data.columns: series = self.reference_data[feature].dropna() config = self.constraints[feature] if not config["mutable"]: candidates[feature] = [] elif pd.api.types.is_numeric_dtype(series): values = series.quantile([0, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 1]).unique() lower = config.get("min", -np.inf) upper = config.get("max", np.inf) candidates[feature] = sorted(float(value) for value in values if lower <= value <= upper) else: allowed = config.get("allowed") observed = list(dict.fromkeys(series.tolist())) candidates[feature] = [value for value in observed if allowed is None or value in allowed][ : self.max_candidates ] return candidates def _uses_score_output(self): return self.output_type == "score" def _predict_many(self, frame): """批量返回当前输出尺度的一维预测。""" if self._uses_score_output(): predictions = np.asarray(self.model.predict_score(frame), dtype=float).reshape(-1) if len(predictions) != len(frame): raise ValidationError("predict_score 返回数量与输入样本数不一致") return predictions probabilities = np.asarray(self.model.predict_proba(frame)) if probabilities.ndim != 2 or probabilities.shape[0] != len(frame): raise ValidationError("predict_proba 必须返回与输入等行的二维概率") classes = list(getattr(self.model, "classes_", [0, 1])) index = classes.index(self.positive_class) if probabilities.shape[1] != len(classes): raise ValidationError("predict_proba 概率列数与 classes_ 不一致") return np.asarray(probabilities[:, index], dtype=float) def _predict(self, frame): """返回单行输入的标量预测。""" return float(self._predict_many(frame)[0]) def _valid_value(self, feature, old, new): config = self.constraints[feature] if new == old: return False if config["direction"] == "increase_only" and new < old: return False if config["direction"] == "decrease_only" and new > old: return False if "min" in config and new < config["min"]: return False if "max" in config and new > config["max"]: return False if "allowed" in config and new not in config["allowed"]: return False return True def _cost(self, original, changed): total = 0.0 for feature, new in changed.items(): old = original[feature] weight = float(self.constraints[feature]["weight"]) series = self.reference_data[feature] if pd.api.types.is_numeric_dtype(series): span = float(series.max() - series.min()) scale = span if np.isfinite(span) and span > 0 else 1.0 total += weight * abs(float(new) - float(old)) / scale else: total += weight return total def _search(self, subject, target, max_changes, top_n, beam_width): original_frame = subject.to_frame().T[self.reference_data.columns].copy() numeric_columns = [ column for column in original_frame.columns if pd.api.types.is_numeric_dtype(self.reference_data[column]) ] if numeric_columns: original_frame[numeric_columns] = original_frame[numeric_columns].astype(float) before = self._predict(original_frame) reached = (lambda value: value >= target) if self._uses_score_output() else (lambda value: value <= target) if reached(before): return before, [({}, before, 0.0)] states = [({}, before, 0.0)] solutions = [] for _depth in range(1, max_changes + 1): expanded = {} pending = {} for changes, _prediction, _old_cost in states: for feature in self.reference_data.columns: if feature in changes or not self.constraints[feature]["mutable"]: continue old = subject[feature] for value in self._candidates[feature]: if not self._valid_value(feature, old, value): continue candidate_changes = dict(changes) candidate_changes[feature] = value key = tuple(sorted((name, repr(item)) for name, item in candidate_changes.items())) if key in pending: continue candidate = original_frame.copy() for name, item in candidate_changes.items(): candidate.iloc[0, candidate.columns.get_loc(name)] = item pending[key] = (candidate_changes, candidate) if not pending: states = [] break candidate_frame = pd.concat( [candidate for _changes, candidate in pending.values()], ignore_index=True, ) predictions = self._predict_many(candidate_frame) for (key, (candidate_changes, _candidate)), prediction in zip(pending.items(), predictions): cost = self._cost(subject, candidate_changes) expanded[key] = (candidate_changes, float(prediction), cost) if reached(prediction): solutions.append((candidate_changes, float(prediction), cost)) if solutions: break def objective_key(state): return abs(state[1] - target), state[2], repr(state[0]) states = sorted(expanded.values(), key=objective_key)[:beam_width] solutions.sort(key=lambda state: (len(state[0]), state[2], abs(state[1] - target), repr(state[0]))) return before, solutions[:top_n]
[文档] def generate( self, X, *, target_probability=None, target_score=None, max_changes=3, top_n=5, beam_width=50, ) -> pd.DataFrame: """生成满足风险概率上限或评分下限的确定性候选方案。""" if not isinstance(X, pd.DataFrame) or X.empty: raise ValidationError("X 必须是非空 DataFrame") if list(X.columns) != list(self.reference_data.columns): raise ValidationError("X 的特征名称或顺序与 reference_data 不一致") uses_score = self._uses_score_output() if target_probability is not None and target_score is not None: raise ValidationError("target_probability 与 target_score 不能同时提供") target = target_score if uses_score else target_probability if target is None: required = "target_score" if uses_score else "target_probability" raise ValidationError(f"必须提供 {required}") if isinstance(target, bool) or not np.isscalar(target) or not np.isfinite(target): required = "target_score" if uses_score else "target_probability" raise ValidationError(f"{required} 必须是有限数") target = float(target) if not uses_score and not 0 <= target <= 1: raise ValidationError("target_probability 必须在[0, 1]范围内") for name, value in (("max_changes", max_changes), ("top_n", top_n), ("beam_width", beam_width)): if not isinstance(value, int) or isinstance(value, bool) or value <= 0: raise ValidationError(f"{name} 必须是正整数") rows = [] for sample_id, subject in X.iterrows(): before, solutions = self._search(subject, target, max_changes, top_n, beam_width) if not solutions: immutable = all(not config["mutable"] for config in self.constraints.values()) rows.append({"样本索引": sample_id, "方案编号": 0, "是否达标": "否", "变更特征数": 0, "总成本": np.nan, "预测前值": before, "预测后值": before, "目标值": target, "约束检查": "通过", "失败原因": "所有特征均不可变" if immutable else "约束和搜索范围内未找到可行方案", "说明": "模型条件下的非因果建议,不代表真实因果效果或授信承诺"}) continue for plan_number, (changes, after, cost) in enumerate(solutions, 1): items = changes.items() or [(None, None)] for feature, new in items: old = subject[feature] if feature is not None else np.nan if feature is None: direction = "不变" elif ( pd.api.types.is_numeric_dtype(self.reference_data[feature]) and pd.notna(old) and pd.notna(new) ): direction = "增加" if new > old else "减少" if new < old else "替换" else: direction = "替换" rows.append({"样本索引": sample_id, "方案编号": plan_number, "是否达标": "是", "变更特征数": len(changes), "总成本": cost, "预测前值": before, "预测后值": after, "目标值": target, "特征": feature, "原值": old, "新值": new, "变化方向": direction, "约束检查": "通过", "失败原因": None, "说明": "模型条件下的非因果建议,不代表真实因果效果或授信承诺"}) return pd.DataFrame(rows).reindex(columns=COUNTERFACTUAL_COLUMNS)