hscredit.core.selectors.reporting 源代码

"""筛选器的轻量、隔离报告协议与已拟合流程收集。

本模块不保存训练样本,不重算统计量,不导入 Excel 或报告发布依赖。
各表的列顺序和 dtype 是公开协议;算法特有指标使用长表表达。
"""

import copy
from datetime import datetime, timezone
from uuid import uuid4

import numpy as np
import pandas as pd

from ...exceptions import NotFittedError, ValidationError

SCHEMA_VERSION = 1
DETAIL_COLUMNS = [
    "阶段编号",
    "阶段路径",
    "阶段名称",
    "筛选器",
    "筛选方法",
    "特征",
    "输入顺序",
    "计算域",
    "评估状态",
    "筛选结果",
    "决策来源",
    "指标名称",
    "指标值",
    "指标方向",
    "判定方式",
    "比较符",
    "有效阈值",
    "决策轮次",
    "关联特征",
    "筛选原因",
]
SUMMARY_COLUMNS = [
    "阶段编号",
    "阶段路径",
    "父路径",
    "阶段名称",
    "筛选器",
    "关系类型",
    "执行状态",
    "输入特征数",
    "选中特征数",
    "剔除特征数",
    "保留率",
    "耗时秒",
    "停止原因",
]
METRIC_COLUMNS = [
    "阶段路径",
    "特征",
    "轮次",
    "折号",
    "指标名称",
    "数值",
    "文本值",
    "统计口径",
    "比较符",
    "阈值",
    "条件结果",
    "有效性",
]
HISTORY_COLUMNS = [
    "阶段路径",
    "事件编号",
    "轮次",
    "特征",
    "动作",
    "候选标识",
    "候选子集",
    "指标名称",
    "指标值",
    "基准分",
    "候选分",
    "改善量",
    "有效阈值",
    "是否有效",
    "是否采纳",
    "关联特征",
    "原因",
    "补充信息",
]
TRACE_COLUMNS = ["阶段路径", "阶段名称", "特征", "筛选结果", "评估状态", "筛选原因"]
_INT_COLUMNS = {
    "阶段编号",
    "输入顺序",
    "决策轮次",
    "输入特征数",
    "选中特征数",
    "剔除特征数",
    "轮次",
    "折号",
    "事件编号",
}
_FLOAT_COLUMNS = {"指标值", "有效阈值", "保留率", "耗时秒", "数值", "阈值", "基准分", "候选分", "改善量"}
_BOOL_COLUMNS = {"条件结果", "是否有效", "是否采纳"}


def _table(records, columns):
    frame = pd.DataFrame(records, columns=columns, dtype=object)
    for name in columns:
        if name in _INT_COLUMNS:
            frame[name] = pd.array(frame[name], dtype="Int64")
        elif name in _FLOAT_COLUMNS:
            frame[name] = pd.array(pd.to_numeric(frame[name], errors="coerce"), dtype="Float64")
        elif name in _BOOL_COLUMNS:
            frame[name] = pd.array(frame[name], dtype="boolean")
        else:
            frame[name] = pd.Series([copy.deepcopy(value) for value in frame[name]], index=frame.index, dtype=object)
    return frame


def _detached_frame(frame):
    # pandas deep=True does not isolate nested mutable objects in object columns.
    result = frame.copy(deep=True)
    for name in result.select_dtypes(include="object").columns:
        result[name] = pd.Series([copy.deepcopy(value) for value in result[name]], index=result.index, dtype=object)
    return result


[文档] class SelectionReport: """完整筛选报告快照。 **参数** :param summary: 固定结构的阶段汇总。 :param details: 每阶段每个输入字段的决策明细。 :param metrics: 多指标长表。 :param history: 迭代事件表。 :param metadata: 拟合快照和诊断,不含逐行训练样本。 **属性** 所有公开表和元数据属性均返回隔离副本,编辑报告不会改写模型。 **参考样例** >>> report = collect_selection_report(fitted_selector) >>> report.details >>> report.save('筛选报告') """ def __init__(self, summary=None, details=None, metrics=None, history=None, metadata=None): self._summary = _table([] if summary is None else summary, SUMMARY_COLUMNS) self._details = _table([] if details is None else details, DETAIL_COLUMNS) self._metrics = _table([] if metrics is None else metrics, METRIC_COLUMNS) self._history = _table([] if history is None else history, HISTORY_COLUMNS) self._metadata = copy.deepcopy(metadata or {}) self._metadata.setdefault("schema_version", SCHEMA_VERSION) self._metadata.setdefault("完整", True) self._metadata.setdefault("诊断", []) @property def summary(self): return _detached_frame(self._summary) @property def details(self): return _detached_frame(self._details) @property def metrics(self): return _detached_frame(self._metrics) @property def history(self): return _detached_frame(self._history) @property def metadata(self): return copy.deepcopy(self._metadata)
[文档] def copy(self): return SelectionReport(self.summary, self.details, self.metrics, self.history, self.metadata)
[文档] def to_dict(self): """返回完整 Python 报告;JSON 类型协议由保存适配器处理。""" return { "阶段汇总": self.summary.to_dict("records"), "特征决策": self.details.to_dict("records"), "指标明细": self.metrics.to_dict("records"), "迭代事件": self.history.to_dict("records"), "元数据": self.metadata, }
[文档] def get_feature_trace(self, *, max_rows=1000000): """按需展开全字段追踪;提前预算,避免阶段数乘字段数失控。""" features = list(dict.fromkeys(self._details["特征"].tolist())) if len(features) * len(self._summary) > max_rows: raise ValidationError("完整特征追踪超出 max_rows 预算,请使用稀疏特征决策表或提高预算") rows = [] for _, stage in self._summary.iterrows(): part = self._details[self._details["阶段路径"] == stage["阶段路径"]] mapping = {row["特征"]: row for row in part.to_dict("records")} for feature in features: source = mapping.get(feature) rows.append( { "阶段路径": stage["阶段路径"], "阶段名称": stage["阶段名称"], "特征": feature, "筛选结果": ( source["筛选结果"] if source else ("未执行" if stage["执行状态"] != "已完成" else "未进入") ), "评估状态": source["评估状态"] if source else "未计算", "筛选原因": source["筛选原因"] if source else stage["停止原因"], } ) return _table(rows, TRACE_COLUMNS)
[文档] def save(self, path, **kwargs): """延迟加载保存适配器,以版本目录及原子清单发布完整报告。""" from ...report.selection_report import save_selection_report return save_selection_report(self, path, **kwargs)
[文档] def to_report_result(self): """显式转换为报告层公共结果;仅此调用复制各章节表。""" from ...report.selection_report import to_report_result return to_report_result(self)
# 指标名称、方向、比较符、判定方式。主指标和有效阈值必须属于同一统计量。 _ADAPTERS = { "NullSelector": ("缺失率", "越小越优", "<", "数值阈值"), "ModeSelector": ("众数占比", "越小越优", "<", "数值阈值"), "CardinalitySelector": ("唯一值数", "越小越优", "<=", "数值阈值"), "TypeSelector": ("类型匹配", "不适用", "", "类型条件"), "RegexSelector": ("最终匹配", "不适用", "", "正则条件"), "VarianceSelector": ("总体方差", "越大越优", ">", "数值阈值"), "Chi2Selector": ("卡方统计量", "越大越优", ">=", "多条件"), "FTestSelector": ("F统计量", "越大越优", ">=", "多条件"), "MutualInfoSelector": ("互信息", "越大越优", ">=", "数值阈值"), "IVSelector": ("IV", "越大越优", ">=", "数值阈值"), "KSSelector": ("KS", "越大越优", ">=", "数值阈值"), "LiftSelector": ("方向改善量", "越大越优", ">=", "数值阈值"), "PSISelector": ("PSI", "越小越优", "<", "数值阈值"), "StabilityAwareSelector": ("综合分", "越大越优", ">=", "多条件"), "CorrSelector": ("保留优先权重", "越大越优", "", "相关冲突"), "VIFSelector": ("VIF", "越小越优", "<=", "迭代阈值"), "FeatureImportanceSelector": ("模型重要性", "越大越优", ">=", "重要性条件"), "NullImportanceSelector": ("零重要性得分", "越大越优", ">", "数值阈值"), "RFESelector": ("消除排名", "越小越优", "<=", "排名数量"), "SequentialFeatureSelector": ("选择标志", "不适用", "", "候选择优"), "BorutaSelector": ("显著性p值", "越小越优", "<=", "统计显著性"), "StepwiseSelector": ("1-p值", "越大越优", "", "候选模型准则"), } _SERIES_METRICS = { "scores_": "筛选得分", "valid_counts_": "有效样本数", "missing_counts_": "缺失数", "total_counts_": "总样本数", "mode_values_": "众数值", "mode_counts_": "众数计数", "cardinalities_": "唯一值数", "dtypes_": "原始类型", "matches_": "原始匹配", "variances_": "总体方差", "ranges_": "极差", "p_values_": "p值", "ranks_": "排名", "test_methods_": "检验方法", "category_counts_": "类别数", "metric_methods_": "计算口径", "psi_std_": "PSI标准差", "psi_max_": "PSI最大值", "reference_counts_": "基准样本数", "comparison_counts_": "比较样本数", "iv_scores_": "IV", "psi_scores_": "PSI", "combined_scores_": "综合分", "hits_": "命中次数", "support_": "强支持", "support_weak_": "弱支持", "decision_": "统计决策", "importance_scores_": "平均重要性", "actual_importances_": "真实重要性均值", "null_importances_": "随机重要性均值", "decision_vif_": "决策时VIF", "elimination_round_": "淘汰轮次", "candidate_scores_": "最近候选子集评分", "ranking_": "排名", "decision_importances_": "决策时重要性", "decision_correlations_": "冲突相关系数", "association_features_": "关联特征", "selection_reasons_": "决策解释", "raw_scores_": "原始检验统计量", "discrete_features_": "离散特征判定", } def _scalar(value): if isinstance(value, np.generic): return value.item() return value def _number(value): value = _scalar(value) return value if isinstance(value, (int, float, bool)) else None def _validity(value): number = _number(value) if number is None: return "文本" if value is not None and value is not pd.NA else "缺失" if np.isnan(number): return "缺失" if np.isposinf(number): return "正无穷" if np.isneginf(number): return "负无穷" return "有效" def _mapping(value, features): if isinstance(value, pd.Series): return value.to_dict() if isinstance(value, dict): return value if isinstance(value, (np.ndarray, list, tuple)) and len(value) == len(features): return dict(zip(features, value)) return {} def _parameters(value, seen=None, depth=0): """不保留样本或已训练模型的参数描述;容器递归和估计器循环受控。""" seen = set() if seen is None else seen value = _scalar(value) if value is None or isinstance(value, (str, int, float, bool)): return value if isinstance(value, (pd.DataFrame, pd.Series, np.ndarray)): result = {"对象类型": type(value).__name__, "形状": tuple(value.shape), "数据内容": "未保存"} if isinstance(value, pd.DataFrame): result["字段"] = list(value.columns) return result if id(value) in seen or depth > 6: return {"对象类型": type(value).__name__, "内容": "循环引用或超出深度"} seen.add(id(value)) try: if isinstance(value, dict): return {copy.deepcopy(k): _parameters(v, seen, depth + 1) for k, v in value.items()} if isinstance(value, (list, tuple)): return [_parameters(v, seen, depth + 1) for v in value] if hasattr(value, "get_params"): return { "对象类型": f"{type(value).__module__}.{type(value).__name__}", "参数": _parameters(value.get_params(deep=False), seen, depth + 1), } return {"对象类型": f"{type(value).__module__}.{type(value).__name__}", "内容": "未保存"} finally: seen.remove(id(value)) def _main_values(selector, features): cls = type(selector).__name__ name, direction, comparison, mode = _ADAPTERS.get(cls, ("筛选得分", "由筛选器定义", "", "自定义筛选")) name = getattr(selector, "score_name_", name) direction = getattr(selector, "score_direction_", direction) direction = {"越大越好": "越大越优", "越小越好": "越小越优", "候选子集评分越大越好": "越大越优"}.get( direction, direction ) values = _mapping(getattr(selector, "scores_", None), features) threshold = getattr( selector, "effective_threshold_", getattr(selector, "threshold_", getattr(selector, "threshold", None)) ) if cls == "VarianceSelector" and hasattr(selector, "variances_"): values = _mapping(selector.variances_, features) if cls == "FTestSelector" and hasattr(selector, "raw_scores_"): values = _mapping(selector.raw_scores_, features) if cls == "BorutaSelector": values = _mapping(getattr(selector, "p_values_", None), features) name, direction = "显著性p值", "越小越优" threshold = getattr(selector, "corrected_alpha_", float(selector.alpha) / max(1, len(values))) if cls == "StabilityAwareSelector": threshold = getattr(selector, "score_threshold", threshold) if cls == "RFESelector": threshold = 1 if cls in {"TypeSelector", "RegexSelector", "CorrSelector", "SequentialFeatureSelector", "StepwiseSelector"}: threshold = None if cls == "FeatureImportanceSelector" and getattr(selector, "selection_mode_", None) == "前K个": threshold, comparison, mode = None, "", "排名数量" # VIF 的最终 scores 可能只有幸存字段;优先用拟合时保存的原始淘汰值补齐。 if cls == "VIFSelector": values = dict(values) values.update(_mapping(getattr(selector, "decision_vif_", None), features)) dropped = getattr(selector, "dropped_", None) if isinstance(dropped, pd.DataFrame) and "VIF值" in dropped: values.update(dict(zip(dropped["特征"], dropped["VIF值"]))) return name, direction, comparison, mode, _number(threshold), values def _single_report(selector): if not bool(getattr(selector, "_is_fitted", False)): raise NotFittedError("筛选器尚未拟合,无法取得筛选报告") features = list(getattr(selector, "_feature_names", getattr(selector, "feature_names_in_", []))) feature_set = set(features) selected = set(getattr(selector, "selected_features_", [])) included = set(getattr(selector, "include_", []) or []) excluded = set(getattr(selector, "exclude_", []) or []) | set(getattr(selector, "forced_dropped_", []) or []) cls = type(selector).__name__ is_composite = hasattr(selector, "selectors") or hasattr(selector, "stage_selectors_") relation = getattr(selector, "strategy", "sequential" if hasattr(selector, "stage_selectors_") else "single") metric_name, direction, comparison, mode, threshold, values = _main_values(selector, features) dropped = getattr(selector, "dropped_", None) dropped_map = ( {row["特征"]: row for row in dropped.to_dict("records")} if isinstance(dropped, pd.DataFrame) and "特征" in dropped else {} ) conditions = getattr(selector, "condition_results_", None) condition_map = conditions.to_dict("index") if isinstance(conditions, pd.DataFrame) else {} reason_map = _mapping(getattr(selector, "selection_reasons_", None), features) association_map = _mapping(getattr(selector, "association_features_", None), features) round_map = _mapping(getattr(selector, "elimination_round_", None), features) condition_values = { name: _mapping(getattr(selector, name, None), features) for name in ("iv_scores_", "psi_scores_", "ranks_", "percentile_values_", "scores_") } details, metrics, history = [], [], [] domain = "分箱结果" if getattr(selector, "_binner_instance", None) is not None else "原始值" path = "selector" calculated_features = set(getattr(selector, "selection_input_features_", features)) for position, feature in enumerate(features): raw = values.get(feature) numeric = _number(raw) valid = _validity(raw) kept = feature in selected source = "组合规则" if is_composite else "算法" reason = str( reason_map.get( feature, dropped_map.get(feature, {}).get("剔除原因", "满足筛选条件" if kept else "不满足筛选条件") ) ) evaluated = ( "已计算" if feature in values and valid not in {"缺失"} else ("不适用" if is_composite else "未计算") ) if feature in values and valid == "缺失": evaluated = "无效" if cls in {"StepwiseSelector", "SequentialFeatureSelector"} and not kept: evaluated = "未计算" if feature not in calculated_features: evaluated = "未计算" if feature in included and kept: source, reason = "强制保留", "用户强制保留" if feature in excluded and not kept: source = "强制剔除" reason = "强制剔除覆盖强制保留" if feature in included else "用户强制剔除" feature_conditions = condition_map.get(feature, {}) failed = [name for name, ok in feature_conditions.items() if isinstance(ok, (bool, np.bool_)) and not ok] if failed and not kept and source == "算法": reason = "未通过条件:" + "、".join(map(str, failed)) details.append( { "阶段编号": 1, "阶段路径": path, "阶段名称": cls, "筛选器": cls, "筛选方法": getattr(selector, "method_name", cls), "特征": feature, "输入顺序": position, "计算域": domain, "评估状态": evaluated, "筛选结果": "保留" if kept else "剔除", "决策来源": source, "指标名称": metric_name if not is_composite else None, "指标值": numeric, "指标方向": direction if not is_composite else None, "判定方式": mode if not is_composite else relation, "比较符": comparison, "有效阈值": threshold, "决策轮次": round_map.get(feature), "关联特征": association_map.get(feature, dropped_map.get(feature, {}).get("相关特征")), "筛选原因": reason, } ) if feature in values: metrics.append( { "阶段路径": path, "特征": feature, "指标名称": metric_name, "数值": numeric, "文本值": None if numeric is not None else raw, "统计口径": domain, "比较符": comparison, "阈值": threshold, "条件结果": None, "有效性": valid, } ) for condition, passed in feature_conditions.items(): condition_value, condition_threshold, operator = numeric, threshold, comparison scope = "实际判定条件" if cls == "StabilityAwareSelector": if "IV" in condition: condition_value, condition_threshold, operator = ( condition_values["iv_scores_"].get(feature), selector.iv_threshold, ">=", ) elif "PSI" in condition: condition_value, condition_threshold, operator = ( condition_values["psi_scores_"].get(feature), selector.psi_threshold, "<=", ) if cls in {"Chi2Selector", "FTestSelector"}: if "数量" in condition: condition_value = condition_values["ranks_"].get(feature) condition_threshold = selector.k if selector.k != "all" else len(selector.ranks_) operator, scope = "<=", "按分数降序、同分保持输入顺序的排名" elif "百分位" in condition: condition_value = condition_values["percentile_values_"].get(feature) condition_threshold = getattr(selector, "percentile_cutoff_", None) operator, scope = ( ">或边界平局", f"稠密序号百分位;边界名额={getattr(selector, 'percentile_tie_slots_', None)};配置百分比={selector.percentile}", ) if selector.percentile in {None, 100}: operator, scope = "", "未限制百分位" elif "阈值" in condition: condition_value = condition_values["scores_"].get(feature) scope = "实际筛选统计量(原始NaN归0)" if cls == "FTestSelector" else scope metrics.append( { "阶段路径": path, "特征": feature, "指标名称": condition, "数值": _number(condition_value), "比较符": operator, "阈值": _number(condition_threshold), "条件结果": passed if isinstance(passed, (bool, np.bool_)) else None, "统计口径": scope, "有效性": _validity(condition_value), } ) for attr, label in _SERIES_METRICS.items(): mapping = _mapping(getattr(selector, attr, None), features) if attr == "scores_": label = metric_name if cls != "BorutaSelector" else "命中比例" if cls not in {"BorutaSelector", "VarianceSelector", "FTestSelector"}: continue if cls == "VarianceSelector": label = "筛选方差" if cls == "FTestSelector": label = "实际F筛选统计量(原始NaN归0)" for feature, value in mapping.items(): if feature not in feature_set: continue extra = {} if cls == "StabilityAwareSelector" and attr in {"iv_scores_", "psi_scores_", "combined_scores_"}: extra = { "阈值": { "iv_scores_": selector.iv_threshold, "psi_scores_": selector.psi_threshold, "combined_scores_": selector.score_threshold, }[attr], "比较符": "<=" if attr == "psi_scores_" else ">=", } metrics.append( { "阶段路径": path, "特征": feature, "指标名称": label, "数值": _number(value), "文本值": None if _number(value) is not None else str(value), "统计口径": "拟合时保存", "有效性": _validity(value), **extra, } ) for attr in ("lift_detail_", "importance_details_", "fold_scores_"): frame = getattr(selector, attr, None) if not isinstance(frame, pd.DataFrame): continue if "特征" in frame: frame = frame.set_index("特征") for feature, row in frame.iterrows(): if feature not in feature_set: continue for column, value in row.items(): metrics.append( { "阶段路径": path, "特征": feature, "指标名称": "PSI" if attr == "fold_scores_" else str(column), "折号": list(frame.columns).index(column) + 1 if attr == "fold_scores_" else None, "数值": _number(value), "文本值": None if _number(value) is not None else str(value), "统计口径": { "lift_detail_": "LIFT方向及覆盖", "importance_details_": "真实及随机重要性", "fold_scores_": "交叉验证分折", }[attr], "有效性": _validity(value), } ) if cls == "PSISelector": for attr, label in (("reference_counts_", "基准样本数"), ("comparison_counts_", "比较样本数")): counts = getattr(selector, attr, None) if isinstance(counts, pd.Series): for fold, (fold_name, count) in enumerate(counts.items()): metrics.append( { "阶段路径": path, "特征": None, "折号": fold + 1, "指标名称": label, "数值": _number(count), "统计口径": str(fold_name), "有效性": _validity(count), } ) for feature, row in dropped_map.items(): for column, value in row.items(): if column in {"特征", "剔除原因", "阈值", "筛选器", "筛选器类型", "筛选轮次", "筛选阶段", "筛选阶段名称"}: continue metrics.append( { "阶段路径": path, "特征": feature, "指标名称": column, "数值": _number(value), "文本值": None if _number(value) is not None else str(value), "统计口径": "淘汰时记录", "阈值": _number(row.get("阈值")), "有效性": _validity(value), } ) events = getattr(selector, "selection_events_", []) for number, event in enumerate(events): record = {name: copy.deepcopy(event.get(name)) for name in HISTORY_COLUMNS} record.update({"阶段路径": path, "事件编号": number + 1}) record["轮次"] = event.get("轮次", event.get("迭代")) history.append(record) final_events = {} for event in history: feature = event.get("特征") if feature in selected or feature in dropped_map: final_events[feature] = event for record in details: event = final_events.get(record["特征"]) if event: record["决策轮次"] = event["轮次"] if event.get("关联特征") is not None: record["关联特征"] = event["关联特征"] stop = getattr(selector, "selection_stopping_reason_", "筛选完成") summary = [ { "阶段编号": 1, "阶段路径": path, "父路径": None, "阶段名称": cls, "筛选器": cls, "关系类型": relation, "执行状态": "已完成", "输入特征数": len(features), "选中特征数": len(selected), "剔除特征数": len(features) - len(selected), "保留率": len(selected) / len(features) if features else None, "耗时秒": getattr(selector, "fit_duration_seconds_", None), "停止原因": stop, } ] snapshot = { "拟合参数": _parameters(selector.get_params(deep=False)), "输入字段": copy.deepcopy(features), "输出字段": [feature for feature in features if feature in selected], "拟合时间": datetime.now(timezone.utc).isoformat(), "fit_id": getattr(selector, "fit_id_", uuid4().hex), "算法指标": metric_name, "计算域": domain, "有效阈值": threshold, "样本数": getattr(selector, "n_samples_in_", None), "实际计算字段": list(getattr(selector, "selection_input_features_", values.keys())), "原始字段类型": {key: str(value) for key, value in getattr(selector, "input_dtypes_", {}).items()}, "历史采集": "已采集" if hasattr(selector, "selection_events_") else "未采集", } # 筛选特征与实际 DataFrame 边界分别记录;透传目标不是一次特征选择。 target = getattr(selector, "target", None) target_present = bool(getattr(selector, "target_present_at_fit_", False)) input_columns = getattr(selector, "input_columns_at_fit_", None) transform_input = list(features if input_columns is None else input_columns) if target_present and target not in transform_input: transform_input.append(target) transform_output = list(snapshot["输出字段"]) if target_present and not getattr(selector, "target_rm", False) and target not in transform_output: transform_output.append(target) snapshot.update( { "目标列": target, "拟合输入含目标": target_present, "目标列移除": bool(getattr(selector, "target_rm", False)), "目标字段": [target] if target_present else [], "转换输入字段": transform_input, "转换输出字段": transform_output, } ) for attr in ( "iv_normalizer_", "psi_normalizer_", "comparison_mode_", "corrected_alpha_", "report_events_truncated_", "report_events_bytes_", "report_candidates_truncated_", "report_decisions_truncated_", "decision_criteria_", "vif_history_truncated_", "vif_history_bytes_", "history_truncated_", "history_bytes_", "importance_runs_total_", "importance_runs_stored_", "importance_runs_truncated_", "selection_history_truncated_", "selection_history_bytes_", "converged_", "unresolved_features_", "n_cv_splits_", "candidate_failures_", "initial_criterion_", "final_criterion_", ): if hasattr(selector, attr): snapshot[attr] = _parameters(getattr(selector, attr)) snapshot["历史策略"] = getattr(selector, "report_history", "未配置") snapshot["历史截断条数"] = int(getattr(selector, "report_events_truncated_", 0)) snapshot["候选历史完整"] = ( hasattr(selector, "selection_events_") and snapshot["历史策略"] == "full" and snapshot["历史截断条数"] == 0 ) return SelectionReport( summary, details, metrics, history, {"阶段快照": {path: snapshot}, "关系类型": relation, "明细完整": True} ) def _rebase(report, path, name=None, parent=None): result = report.copy() old_root = result._metadata.get("根路径") or ( result._summary.iloc[0]["阶段路径"] if len(result._summary) else "selector" ) def replace(value): if value is None or value is pd.NA: return value return path + str(value)[len(old_root) :] if str(value).startswith(old_root) else value for frame in (result._summary, result._details, result._metrics, result._history): frame["阶段路径"] = frame["阶段路径"].map(replace) result._summary["父路径"] = result._summary["父路径"].map(replace) root_mask = result._summary["阶段路径"] == path result._summary.loc[root_mask, "父路径"] = parent if name is not None: result._summary.loc[root_mask, "阶段名称"] = str(name) result._details.loc[result._details["阶段路径"] == path, "阶段名称"] = str(name) result._metadata["阶段快照"] = {replace(k): v for k, v in result._metadata.get("阶段快照", {}).items()} for boundary in result._metadata.get("字段边界", []): boundary["阶段路径"] = replace(boundary["阶段路径"]) result._metadata["根路径"] = path return result def _combine(reports, relation): records = {"summary": [], "details": [], "metrics": [], "history": []} metadata = {"关系类型": relation, "阶段快照": {}, "字段边界": [], "完整": True, "诊断": []} for report in reports: for name in records: records[name].extend(getattr(report, "_" + name).to_dict("records")) metadata["阶段快照"].update(report._metadata.get("阶段快照", {})) metadata["完整"] = metadata["完整"] and report._metadata.get("完整", True) metadata["诊断"].extend(report._metadata.get("诊断", [])) metadata["字段边界"].extend(copy.deepcopy(report._metadata.get("字段边界", []))) positions = {row["阶段路径"]: index + 1 for index, row in enumerate(records["summary"])} for name in ("summary", "details"): for row in records[name]: row["阶段编号"] = positions[row["阶段路径"]] return SelectionReport(metadata=metadata, **records) def _skipped(selector, path, name, parent, reason, *, incomplete=False, selector_type=None): summary = [ { "阶段编号": 1, "阶段路径": path, "父路径": parent, "阶段名称": str(name), "筛选器": selector_type or type(selector).__name__, "关系类型": "single", "执行状态": "未拟合" if incomplete else "未执行", "停止原因": reason, } ] return SelectionReport(summary=summary, metadata={"完整": not incomplete, "诊断": [reason] if incomplete else []}) def _children(selector): if hasattr(selector, "selectors"): return [ (str(item[0]), item[1]) if isinstance(item, tuple) and len(item) == 2 else (type(item).__name__, item) for item in selector.selectors ] if hasattr(selector, "stage_selectors_"): planned = getattr(selector, "planned_stages_", None) if planned is not None: return [(item["name"], selector.stage_selectors_.get(item["key"])) for item in planned] return list(selector.stage_selectors_.items()) return [] def capture_selection_report(selector): """拟合完成时捕获结果;供基类在候选对象提交前调用。""" root = _single_report(selector) children = _children(selector) if not children: return root reports = [root] skipped = getattr(selector, "skipped_stages_", {}) executed = getattr(selector, "executed_stages_", None) for position, (name, child) in enumerate(children): path = f"selector/{position + 1:02d}_{name}" reason = skipped.get(position) if isinstance(skipped, dict) else None if reason is not None or (executed is not None and position not in executed): planned = getattr(selector, "planned_stages_", None) selector_type = planned[position].get("selector") if planned is not None else None reports.append( _skipped(child, path, name, "selector", reason or "上游无剩余特征,未执行", selector_type=selector_type) ) elif bool(getattr(child, "_is_fitted", False)): child_report = getattr(child, "selection_report_", None) if not isinstance(child_report, SelectionReport): child_report = capture_selection_report(child) reports.append(_rebase(child_report, path, name, "selector")) else: # Legacy composites did not persist skip metadata. Do not fabricate successful stages. reports.append( _skipped(child, path, name, "selector", "子筛选器未拟合,缺少明确的未执行记录", incomplete=True) ) return _combine(reports, root._metadata["关系类型"]) def _selector_report(selector, strict): report = getattr(selector, "selection_report_", None) if isinstance(report, SelectionReport): return report.copy() if bool(getattr(selector, "_is_fitted", False)): report = capture_selection_report(selector) report._metadata["诊断"].append("旧对象未保存拟合报告快照;根据当前已拟合状态捕获,不能保证拟合后配置未变化") report._metadata["历史快照"] = False return report if strict: raise NotFittedError(f"{type(selector).__name__} 尚未拟合,无法取得筛选报告") return _skipped(selector, "selector", type(selector).__name__, None, "筛选器尚未拟合", incomplete=True)
[文档] def collect_selection_report(source, relation="auto", strict=True): """直接读取单筛选器、Pipeline、组合器或列表的完整报告,不执行 fit。 列表默认独立比较。显式 sequential 必须满足字段链;intersection/union 产生独立根决策,不把每个子报告的淘汰次数当唯一淘汰数量。 """ if relation not in {"auto", "independent", "sequential", "intersection", "union"}: raise ValidationError("relation 必须为 auto、independent、sequential、intersection 或 union") if isinstance(source, SelectionReport): return source.copy() if hasattr(source, "selected_features_") or hasattr(source, "get_selection_report"): result = _selector_report(source, strict) if strict and not result._metadata.get("完整", True): raise ValidationError("筛选报告不完整:" + ";".join(result._metadata.get("诊断", []))) return result is_pipeline = hasattr(source, "steps") and isinstance(source.steps, (list, tuple)) if is_pipeline: items, effective = source.steps, "sequential" prefix = "pipeline" elif isinstance(source, (list, tuple)): items, effective = source, "independent" if relation == "auto" else relation prefix = "list" else: raise ValidationError("报告来源必须是筛选器、已拟合 Pipeline 或筛选器列表") reports, top, diagnostics, boundaries = [], [], [], [] previous = None transform_input = None target_fields = set() for position, item in enumerate(items): name, child = item if isinstance(item, tuple) and len(item) == 2 else (type(item).__name__, item) if ( isinstance(child, SelectionReport) and not (isinstance(item, tuple) and len(item) == 2) and len(child._summary) ): name = child._summary.iloc[0]["筛选器"] path = f"{prefix}/{position + 1:02d}_{name}" if child is None or (isinstance(child, str) and child == "passthrough"): continue is_selector = ( isinstance(child, SelectionReport) or hasattr(child, "get_selection_report") or hasattr(child, "selected_features_") ) if not is_selector and not hasattr(child, "steps"): if not is_pipeline: raise ValidationError("筛选器列表包含非筛选器对象") # Models terminate the chain. Transformations require an explicit field boundary. if hasattr(child, "transform"): incoming = list(getattr(child, "feature_names_in_", [])) if transform_input is None: transform_input = incoming try: outgoing = list(child.get_feature_names_out()) except (AttributeError, ValueError, TypeError): outgoing = None if previous is not None and incoming and previous != incoming: diagnostics.append(f"{path} 的输入字段与上游输出不一致") if outgoing is None: diagnostics.append(f"{path} 变换后的字段边界无法验证") boundaries.append({"阶段路径": path, "类型": "变换", "输入字段": incoming, "输出字段": outgoing}) previous = outgoing else: incoming = list(getattr(child, "feature_names_in_", [])) if previous is not None and incoming and previous != incoming: diagnostics.append(f"{path} 模型实际输入字段与上游转换输出不一致") count = getattr(child, "n_features_in_", None) if not incoming and count is None: diagnostics.append(f"{path} 模型没有已拟合输入字段或数量,无法验证实际入模边界") if previous is not None and count is not None and count != len(previous): diagnostics.append(f"{path} 模型实际输入数量与上游报告输出不一致") boundaries.append( { "阶段路径": path, "类型": "模型", "输入字段": incoming, "输入数量": count, "非筛选字段": [field for field in incoming if field in target_fields], } ) continue part = collect_selection_report(child, strict=strict) part = _rebase(part, path, name) stage = part._summary.iloc[0] if len(part._summary) else None snapshot = part._metadata.get("阶段快照", {}).get(path, {}) incoming, outgoing = snapshot.get("输入字段"), snapshot.get("输出字段") actual_input = snapshot.get("转换输入字段", incoming) actual_output = snapshot.get("转换输出字段", outgoing) target_fields.update(snapshot.get("目标字段", [])) if transform_input is None: transform_input = actual_input if effective == "sequential" and previous is not None and actual_input is not None and previous != actual_input: diagnostics.append(f"{path} 的输入字段不是上一阶段输出,不能解释为顺序筛选") previous = actual_output reports.append(part) top.append((stage, incoming, outgoing)) if not reports: diagnostics.append("来源中没有可收集的筛选器报告") if diagnostics and strict: raise ValidationError(";".join(diagnostics)) result = _combine(reports, effective) result._metadata["根路径"] = prefix result._metadata["字段边界"].extend(boundaries) result._metadata["诊断"].extend(diagnostics) result._metadata["完整"] = result._metadata["完整"] and not diagnostics if effective in {"intersection", "union"} and top: inputs = [entry[1] for entry in top] if any(item is None for item in inputs) or any(item != inputs[0] for item in inputs[1:]): raise ValidationError("集合组合要求每个筛选器有相同且可验证的原始输入字段") outputs = [set(entry[2] or []) for entry in top] final = set.intersection(*outputs) if effective == "intersection" else set.union(*outputs) features = inputs[0] details = [ { "阶段编号": 1, "阶段路径": "list", "阶段名称": "列表集合决策", "筛选器": "集合组合", "筛选方法": effective, "特征": f, "输入顺序": i, "评估状态": "不适用", "筛选结果": "保留" if f in final else "剔除", "决策来源": "组合规则", "判定方式": effective, "筛选原因": "按声明的集合规则组合", } for i, f in enumerate(features) ] summary = [ { "阶段编号": 1, "阶段路径": "list", "阶段名称": "列表集合决策", "筛选器": "集合组合", "关系类型": effective, "执行状态": "已完成", "输入特征数": len(features), "选中特征数": len(final), "剔除特征数": len(features) - len(final), "保留率": len(final) / len(features) if features else None, } ] root = SelectionReport( summary, details, metadata={"阶段快照": {"list": {"输入字段": features, "输出字段": [f for f in features if f in final]}}}, ) result._summary.loc[result._summary["父路径"].isna(), "父路径"] = "list" result = _combine([root, result], effective) if effective != "independent" and top: result._metadata["最终字段"] = ( [f for f in top[0][1] if f in final] if effective in {"intersection", "union"} else top[-1][2] ) if effective == "sequential" and top: incoming = ( [field for field in transform_input if field not in target_fields] if transform_input is not None else None ) outgoing = [field for field in previous if field not in target_fields] if previous is not None else None comparable = incoming is not None and outgoing is not None and set(outgoing).issubset(incoming) result._metadata["阶段快照"][prefix] = { "输入字段": incoming, "输出字段": outgoing, "转换输入字段": transform_input, "转换输出字段": previous, "目标字段": [field for field in transform_input or [] if field in target_fields], } # A flow node makes nested pipelines rebase as one subtree. It is not a selector decision stage. root_summary = [ { "阶段编号": 1, "阶段路径": prefix, "阶段名称": "顺序流程", "筛选器": "流程", "关系类型": effective, "执行状态": "已完成" if result._metadata["完整"] else "不完整", "输入特征数": len(incoming) if incoming is not None else None, "选中特征数": len(outgoing) if outgoing is not None else None, "剔除特征数": len(incoming) - len(outgoing) if comparable else None, "保留率": len(outgoing) / len(incoming) if incoming and comparable else None, } ] result._summary.loc[result._summary["父路径"].isna(), "父路径"] = prefix result = _combine([SelectionReport(summary=root_summary), result], effective) result._metadata["根路径"] = prefix result._metadata["最终字段"] = outgoing result._metadata["最终转换字段"] = previous return result