SuperModelingFactory 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. ExcelMaster/ExcelFormatTool.py +487 -0
  2. ExcelMaster/ExcelMaster.py +917 -0
  3. ExcelMaster/Template.py +525 -0
  4. ExcelMaster/Utility.py +233 -0
  5. ExcelMaster/__init__.py +3 -0
  6. Modeling_Tool/Core/Binning_Tool.py +1608 -0
  7. Modeling_Tool/Core/Binning_Tool.pyi +48 -0
  8. Modeling_Tool/Core/Check_DuckDB_Compatibility.py +835 -0
  9. Modeling_Tool/Core/Json_Data_Converter.py +621 -0
  10. Modeling_Tool/Core/Model_Registry_Tool.py +199 -0
  11. Modeling_Tool/Core/ODPS_Tool.py +284 -0
  12. Modeling_Tool/Core/Slope_Tool.py +356 -0
  13. Modeling_Tool/Core/Slope_Tool.pyi +31 -0
  14. Modeling_Tool/Core/XOR_Encryptor.py +207 -0
  15. Modeling_Tool/Core/XOR_Encryptor.pyi +26 -0
  16. Modeling_Tool/Core/__init__.py +99 -0
  17. Modeling_Tool/Core/kDataFrame.py +228 -0
  18. Modeling_Tool/Core/kDataFrame.pyi +43 -0
  19. Modeling_Tool/Core/sample_weight_utils.py +77 -0
  20. Modeling_Tool/Core/utils.py +2672 -0
  21. Modeling_Tool/Eval/Evaluation_Tool.py +1452 -0
  22. Modeling_Tool/Eval/Evaluation_Tool.pyi +47 -0
  23. Modeling_Tool/Eval/Model_Eval_Tool.py +2548 -0
  24. Modeling_Tool/Eval/Model_Eval_Tool.pyi +37 -0
  25. Modeling_Tool/Eval/__init__.py +54 -0
  26. Modeling_Tool/Eval/evaluate_model.py +2008 -0
  27. Modeling_Tool/Eval/evaluate_model.pyi +50 -0
  28. Modeling_Tool/Eval/weighted_eval_utils.py +326 -0
  29. Modeling_Tool/Explainability/Coalition_Structure.py +305 -0
  30. Modeling_Tool/Explainability/Model_Explainer.py +743 -0
  31. Modeling_Tool/Explainability/__init__.py +24 -0
  32. Modeling_Tool/Feature/Distribution_Tool.py +509 -0
  33. Modeling_Tool/Feature/Distribution_Tool.pyi +42 -0
  34. Modeling_Tool/Feature/Feature_Insights.py +762 -0
  35. Modeling_Tool/Feature/Feature_Insights.pyi +33 -0
  36. Modeling_Tool/Feature/PSI_Tool.py +1195 -0
  37. Modeling_Tool/Feature/PSI_Tool.pyi +29 -0
  38. Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +355 -0
  39. Modeling_Tool/Feature/__init__.py +40 -0
  40. Modeling_Tool/Model/Backward_Tool.py +778 -0
  41. Modeling_Tool/Model/Backward_Tool.pyi +45 -0
  42. Modeling_Tool/Model/GBM_Search_Tool.py +251 -0
  43. Modeling_Tool/Model/GBM_Tool.py +1610 -0
  44. Modeling_Tool/Model/GBM_Tool.pyi +90 -0
  45. Modeling_Tool/Model/LRM_Tool.py +1198 -0
  46. Modeling_Tool/Model/LRM_Tool.pyi +47 -0
  47. Modeling_Tool/Model/__init__.py +61 -0
  48. Modeling_Tool/Sample/Distribution_Adaptation.py +131 -0
  49. Modeling_Tool/Sample/Distribution_Adaptation.pyi +30 -0
  50. Modeling_Tool/Sample/Reject_Infer.py +413 -0
  51. Modeling_Tool/Sample/Reject_Infer.pyi +43 -0
  52. Modeling_Tool/Sample/Sample_Split.py +520 -0
  53. Modeling_Tool/Sample/Sample_Split.pyi +43 -0
  54. Modeling_Tool/Sample/__init__.py +31 -0
  55. Modeling_Tool/UAT/UAT_Consistency_Checker.py +1180 -0
  56. Modeling_Tool/UAT/__init__.py +19 -0
  57. Modeling_Tool/WOE/WOE_Adapter.py +204 -0
  58. Modeling_Tool/WOE/WOE_Adapter.pyi +21 -0
  59. Modeling_Tool/WOE/WOE_Master.py +491 -0
  60. Modeling_Tool/WOE/WOE_Master.pyi +40 -0
  61. Modeling_Tool/WOE/WOE_Monotone_Binner.py +3324 -0
  62. Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +71 -0
  63. Modeling_Tool/WOE/WOE_Plot_Tool.py +919 -0
  64. Modeling_Tool/WOE/WOE_Plot_Tool.pyi +46 -0
  65. Modeling_Tool/WOE/WOE_Report_Builder.py +214 -0
  66. Modeling_Tool/WOE/WOE_Report_Builder.pyi +24 -0
  67. Modeling_Tool/WOE/WOE_Tool.py +1094 -0
  68. Modeling_Tool/WOE/WOE_Tool.pyi +41 -0
  69. Modeling_Tool/WOE/__init__.py +86 -0
  70. Modeling_Tool/WOE/plot_woe_tool.py +290 -0
  71. Modeling_Tool/WOE/plot_woe_tool.pyi +25 -0
  72. Modeling_Tool/__init__.py +176 -0
  73. Report/Report_Tool.py +380 -0
  74. Report/__init__.py +3 -0
  75. supermodelingfactory-0.2.0.dist-info/METADATA +268 -0
  76. supermodelingfactory-0.2.0.dist-info/RECORD +79 -0
  77. supermodelingfactory-0.2.0.dist-info/WHEEL +5 -0
  78. supermodelingfactory-0.2.0.dist-info/licenses/LICENSE +102 -0
  79. supermodelingfactory-0.2.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,1180 @@
1
+ """
2
+ uat_consistency_checker.py — 线上-线下一致性 UAT 校验模块
3
+ ==========================================================
4
+
5
+ 将 99_uat_validation.ipynb 的完整逻辑封装为可复用的类,每个 notebook
6
+ 章节对应一个方法,由 run() 统一编排。
7
+
8
+ 主要导出:
9
+ UATConfig — 全量配置 dataclass
10
+ UATConsistencyChecker — 校验器主类
11
+ safe_diff / safe_eq — 数值比较工具函数
12
+ mismatch_mask — 数值不一致判定(超容差 OR 单侧为空)
13
+ time_diff_seconds / time_mismatch_mask — 时间字段比较(秒级时间差容差)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import multiprocessing
20
+ import os
21
+ from dataclasses import dataclass, field
22
+ from datetime import datetime
23
+ from typing import Dict, List, Optional
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _import_excel_master():
32
+ """Import the bundled ExcelMaster class with a source-tree fallback."""
33
+ try:
34
+ from ExcelMaster.ExcelMaster import ExcelMaster
35
+ return ExcelMaster
36
+ except ModuleNotFoundError as exc:
37
+ if exc.name and not exc.name.startswith("ExcelMaster"):
38
+ raise
39
+
40
+ project_root = os.path.abspath(
41
+ os.path.join(os.path.dirname(__file__), "..", "..")
42
+ )
43
+ excelmaster_dir = os.path.join(project_root, "ExcelMaster")
44
+
45
+ if os.path.isdir(excelmaster_dir):
46
+ import sys as _sys
47
+
48
+ if project_root not in _sys.path:
49
+ _sys.path.insert(0, project_root)
50
+ try:
51
+ from ExcelMaster.ExcelMaster import ExcelMaster
52
+ return ExcelMaster
53
+ except ModuleNotFoundError as fallback_exc:
54
+ if (
55
+ fallback_exc.name
56
+ and not fallback_exc.name.startswith("ExcelMaster")
57
+ ):
58
+ raise
59
+
60
+ raise ImportError(
61
+ "ExcelMaster could not be imported. This usually means the "
62
+ "installed SuperModelingFactory package was built without the "
63
+ "bundled ExcelMaster package. Reinstall/upgrade to a build that "
64
+ "includes SuperModelingFactory/ExcelMaster/."
65
+ ) from exc
66
+
67
+
68
+ # ─────────────────────────────────────────────────────────────────────────────
69
+ # 数值比较工具函数
70
+ # ─────────────────────────────────────────────────────────────────────────────
71
+
72
+ def safe_diff(a: pd.Series, b: pd.Series) -> pd.Series:
73
+ """Return a - b after coercing both to numeric (errors → NaN)."""
74
+ return pd.to_numeric(a, errors="coerce") - pd.to_numeric(b, errors="coerce")
75
+
76
+
77
+ def _apply_excel_font(em, font_name: str) -> None:
78
+ """将 ExcelMaster 实例中所有已注册的 xlsxwriter Format 对象的字体统一替换。
79
+
80
+ xlsxwriter Workbook 的 ``formats`` 列表持有全部 Format 对象的引用,
81
+ 在 ``close_workbook()`` 前直接修改 ``fmt.font_name`` 即可生效。
82
+
83
+ ⚠ 必须在所有 ``write_dataframe`` / ``merge_col`` 等写入**完成之后**、
84
+ ``close_workbook()`` 之前调用:pandas ``df.to_excel`` 会为 header 行、datetime
85
+ 列等**动态新建** Format 对象,部分 ExcelFormatTool 格式(NUM_COMMA 等)也未设
86
+ font_name。过早调用会漏掉这些后建的 Format,导致 header 行、cdc_inserttime 等
87
+ datetime 列仍是默认 Calibri 字体。
88
+ """
89
+ for fmt in em.workbook.formats:
90
+ fmt.font_name = font_name
91
+
92
+
93
+ _EXCEL_SHEET_MAX_LEN = 31
94
+ _EXCEL_SHEET_INVALID_CHARS = str.maketrans({
95
+ "[": "(",
96
+ "]": ")",
97
+ ":": "-",
98
+ "*": "_",
99
+ "?": "_",
100
+ "/": "_",
101
+ "\\": "_",
102
+ })
103
+
104
+
105
+ def _sanitize_excel_sheet_name(name: str) -> str:
106
+ """Return a xlsxwriter-compatible worksheet name before de-duplication."""
107
+ sheet_name = str(name).translate(_EXCEL_SHEET_INVALID_CHARS).strip()
108
+ sheet_name = sheet_name.strip("'")
109
+ return sheet_name or "Sheet"
110
+
111
+
112
+ def _make_unique_excel_sheet_name(raw_name: str, used_sheet_names: set[str]) -> str:
113
+ """Create a valid, case-insensitively unique Excel worksheet name.
114
+
115
+ Excel worksheet names are limited to 31 characters and xlsxwriter treats
116
+ duplicate names case-insensitively. UAT feature sheet names are generated
117
+ from raw feature names, so two long names can collide after truncation.
118
+ This helper truncates only after reserving room for a deterministic suffix.
119
+ ``used_sheet_names`` stores lower-cased names and is updated in-place.
120
+ """
121
+ base_name = _sanitize_excel_sheet_name(raw_name)
122
+ candidate = base_name[:_EXCEL_SHEET_MAX_LEN].strip("'") or "Sheet"
123
+
124
+ if candidate.lower() not in used_sheet_names:
125
+ used_sheet_names.add(candidate.lower())
126
+ return candidate
127
+
128
+ counter = 2
129
+ while True:
130
+ suffix = f"_{counter:02d}" if counter < 100 else f"_{counter}"
131
+ max_base_len = _EXCEL_SHEET_MAX_LEN - len(suffix)
132
+ stem = base_name[:max_base_len].strip("'") or "Sheet"[:max_base_len]
133
+ candidate = f"{stem}{suffix}"
134
+ if candidate.lower() not in used_sheet_names:
135
+ used_sheet_names.add(candidate.lower())
136
+ return candidate
137
+ counter += 1
138
+
139
+
140
+ def safe_eq(a: pd.Series, b: pd.Series) -> pd.Series:
141
+ """Return a == b after coercing both to numeric (errors → NaN)."""
142
+ return pd.to_numeric(a, errors="coerce") == pd.to_numeric(b, errors="coerce")
143
+
144
+
145
+ def mismatch_mask(a: pd.Series, b: pd.Series, tol: float) -> pd.Series:
146
+ """返回 a / b 不一致的布尔掩码。
147
+
148
+ 判定为不一致(True)的两种情形:
149
+ * 两侧均为数值且 ``|a - b| > tol``;或
150
+ * 恰好一侧为空 / 非数值(单侧缺失,XOR)。
151
+
152
+ 两侧皆空视为一致(双方都未取到值,无可比较),返回 False。
153
+ """
154
+ a_num = pd.to_numeric(a, errors="coerce")
155
+ b_num = pd.to_numeric(b, errors="coerce")
156
+ over_tol = (a_num - b_num).abs() > tol # 两侧有值且超容差(NaN 比较结果为 False)
157
+ one_side_null = a_num.isna() ^ b_num.isna() # 恰好一侧为空
158
+ return over_tol | one_side_null
159
+
160
+
161
+ def time_diff_seconds(a: pd.Series, b: pd.Series) -> pd.Series:
162
+ """Return (a - b) in seconds after parsing both to datetime (errors → NaT)."""
163
+ a_dt = pd.to_datetime(a, errors="coerce")
164
+ b_dt = pd.to_datetime(b, errors="coerce")
165
+ return (a_dt - b_dt).dt.total_seconds()
166
+
167
+
168
+ def time_mismatch_mask(a: pd.Series, b: pd.Series, tol_seconds: float) -> pd.Series:
169
+ """时间字段不一致判定(按秒级时间差容差)。
170
+
171
+ 判定为不一致(True)的两种情形:
172
+ * 两侧均可解析为时间且 ``|a - b| > tol_seconds`` 秒;或
173
+ * 恰好一侧无法解析 / 为空(XOR)。
174
+
175
+ 两侧皆无法解析(NaT)视为一致,返回 False。与 ``mismatch_mask`` 语义对齐,
176
+ 区别仅在于用 ``pd.to_datetime`` 解析并以秒为单位比较,适配时间字符串/时间戳。
177
+ """
178
+ a_dt = pd.to_datetime(a, errors="coerce")
179
+ b_dt = pd.to_datetime(b, errors="coerce")
180
+ diff_s = (a_dt - b_dt).dt.total_seconds()
181
+ over_tol = diff_s.abs() > tol_seconds
182
+ one_side_null = a_dt.isna() ^ b_dt.isna()
183
+ return over_tol | one_side_null
184
+
185
+
186
+ # ─────────────────────────────────────────────────────────────────────────────
187
+ # 配置 dataclass
188
+ # ─────────────────────────────────────────────────────────────────────────────
189
+
190
+ @dataclass
191
+ class UATConfig:
192
+ """UAT 校验所有可调入参集中管理。
193
+
194
+ Parameters
195
+ ----------
196
+ main_model_score_col : str
197
+ 主模型分列名(离线/线上 SQL 均以此命名)。
198
+ Pandas merge 后线上列自动加 ``_online`` 后缀。
199
+ include_submodel_scores : bool
200
+ True → 子模型分已作为特征被 §6 自动检测覆盖,跳过 §5 子模型专项验证。
201
+ False → 需运行 §5 子模型分专项验证;需同时填写 ``submodel_pairs``。
202
+ excel_output_path : str
203
+ Excel 报告输出路径(含文件名)。默认含秒级时间戳保证唯一性。
204
+ sql_dir : str
205
+ SQL 文件所在目录(绝对路径或相对于 CWD 的路径)。
206
+ offline_sql / online_sql / joined_sql : str
207
+ 三个 SQL 文件名。
208
+ tol_score : float
209
+ 主模型分 / 子模型分比较容差(默认 1e-6)。
210
+ tol_feat : float
211
+ 特征变量比较容差(默认 1e-2)。
212
+ n_process : int
213
+ SQL 并发拉取进程数(默认 cpu_count - 1)。
214
+ submodel_pairs : dict
215
+ 子模型分列对 ``{offline_col: online_col}``,仅 ``include_submodel_scores=False`` 时使用。
216
+ excel_font : str
217
+ Excel 报告全局字体名称(默认 ``"Arial"``)。
218
+ 覆盖 ExcelMaster/ExcelFormatTool 中所有 format 对象的 ``font_name``。
219
+ info_list : list of str
220
+ 除 flow_id 外需随报告一并输出的辅助信息字段(如 user_id / curp / launch_time)。
221
+ 这些字段会附加在每个逐 flow_id 明细表(主模型分 / 子模型 / Feat_* / Per-Flow)
222
+ 的 flow_id 之后;同时从 §6 特征自动检测中排除(视为标识字段而非待比较特征)。
223
+ 仅保留实际存在于数据中的字段,缺失项忽略并告警。
224
+ time_featlist : list of str
225
+ 需做时间语义对比的时间字段(原始字段名,线上线下必须同名)。结构与普通入模特征
226
+ 一致:离线列为 ``col``,线上列为 ``col_online``。仅此列表内的时间字段会在 §7 用
227
+ ``pd.to_datetime`` 解析后按秒级时间差容差比较;配置后这些字段会从 §6 数值特征
228
+ 对比中排除。
229
+ tol_time_seconds : float
230
+ 时间差容差(秒,默认 60)。``|线上 - 线下| ≤ tol_time_seconds`` 视为一致。
231
+ """
232
+
233
+ main_model_score_col: str = "credit_risk_ltrs_subomdel_score"
234
+
235
+ include_submodel_scores: bool = True
236
+
237
+ excel_output_path: str = field(
238
+ default_factory=lambda: (
239
+ f"online_offline_consistency_report_"
240
+ f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
241
+ )
242
+ )
243
+
244
+ sql_dir: str = "sql"
245
+
246
+ offline_sql: str = "pull_offline.sql"
247
+ online_sql: str = "pull_online.sql"
248
+ # joined_sql: str = "pull_online_offline.sql"
249
+
250
+ tol_score: float = 1e-6
251
+ tol_feat: float = 1e-2
252
+
253
+ n_process: int = field(
254
+ default_factory=lambda: max(1, multiprocessing.cpu_count() - 1)
255
+ )
256
+
257
+ submodel_pairs: Dict[str, str] = field(default_factory=dict)
258
+
259
+ excel_font: str = "Arial"
260
+
261
+ info_list: List[str] = field(default_factory=list)
262
+
263
+ time_featlist: List[str] = field(default_factory=list) # 需时间语义对比的字段(线上线下同名)
264
+ tol_time_seconds: float = 60.0 # 时间差容差(秒)
265
+
266
+
267
+ # ─────────────────────────────────────────────────────────────────────────────
268
+ # 校验器主类
269
+ # ─────────────────────────────────────────────────────────────────────────────
270
+
271
+ class UATConsistencyChecker:
272
+ """线上-线下一致性 UAT 校验器。
273
+
274
+ 将 notebook 99_uat_validation 的完整逻辑封装为可复用的类:
275
+
276
+ §1 数据拉取 → §2 覆盖度检查 → §3 主模型分一致性
277
+ → §5 子模型专项(可选) → §6 全量特征一致性
278
+ → §8 Per-Flow 报告 → §9 汇总 → §10 Excel 输出
279
+
280
+ Parameters
281
+ ----------
282
+ config : UATConfig
283
+ 全量配置参数。
284
+ sqlrunner : object
285
+ 已初始化的 ODPSRunner 实例,需提供 ``run_sql(sql, n_process)`` 方法。
286
+ """
287
+
288
+ def __init__(self, config: UATConfig, sqlrunner) -> None:
289
+ self.cfg = config
290
+ self.sqlrunner = sqlrunner
291
+
292
+ # ── 数据容器 ──────────────────────────────────────────────────────
293
+ self.df_offline: Optional[pd.DataFrame] = None
294
+ self.df_online: Optional[pd.DataFrame] = None
295
+ self.df_onoff: Optional[pd.DataFrame] = None
296
+ self.df_compare: Optional[pd.DataFrame] = None
297
+ self.df_both: Optional[pd.DataFrame] = None # df_compare 中 _merge=="both" 的子集,一致性对比基准
298
+ self._info_cols: List[str] = [] # info_list 中实际存在的字段(随明细报告输出)
299
+
300
+ # ── §2 覆盖度检查结果 ─────────────────────────────────────────────
301
+ self.offline_fids: set = set()
302
+ self.online_fids: set = set()
303
+ self.common_fids: set = set()
304
+ self.only_offline: set = set()
305
+ self.only_online: set = set()
306
+
307
+ # ── §3 主模型分检查结果 ───────────────────────────────────────────
308
+ self.offline_score_col: Optional[str] = None
309
+ self.online_score_col: Optional[str] = None
310
+ self.main_score_mismatch_df: Optional[pd.DataFrame] = None
311
+
312
+ # ── §5 子模型分专项结果 ───────────────────────────────────────────
313
+ self.submodel_summary: List[dict] = []
314
+
315
+ # ── §6 特征一致性结果 ─────────────────────────────────────────────
316
+ self.feature_pairs: Dict[str, str] = {} # {offline_col: online_col}
317
+ self.diff_summary: Optional[pd.DataFrame] = None
318
+
319
+ # ── §7 时间字段一致性结果 ─────────────────────────────────────────
320
+ self.time_summary: Optional[pd.DataFrame] = None
321
+ self.time_fields_resolved: Dict[str, str] = {} # 实际存在的时间字段 {off_col: on_col}
322
+
323
+ # ── §8-§9 汇总报告 ────────────────────────────────────────────────
324
+ self.per_flow_df: Optional[pd.DataFrame] = None
325
+ self.summary_df: Optional[pd.DataFrame] = None
326
+
327
+ # ─────────────────────────────────────────────────────────────────────────
328
+ # §1 数据拉取
329
+ # ─────────────────────────────────────────────────────────────────────────
330
+
331
+ def load_data(self) -> None:
332
+ """执行三个 SQL,拉取数据并在 Pandas 侧完成 outer merge。"""
333
+ logger.info("=" * 60)
334
+ logger.info("§1 数据拉取")
335
+ logger.info("=" * 60)
336
+
337
+ # 1.1 离线回溯表
338
+ self.df_offline = self.sqlrunner.run_sql(
339
+ self._read_sql(self.cfg.offline_sql), n_process=self.cfg.n_process
340
+ )
341
+ logger.info("Offline: shape=%s | flow_id nunique=%d",
342
+ self.df_offline.shape, self.df_offline["flow_id"].nunique())
343
+
344
+ # 1.2 线上 PATA 表
345
+ self.df_online = self.sqlrunner.run_sql(
346
+ self._read_sql(self.cfg.online_sql), n_process=self.cfg.n_process
347
+ )
348
+ logger.info("Online: shape=%s | flow_id nunique=%d",
349
+ self.df_online.shape, self.df_online["flow_id"].nunique())
350
+
351
+ # 1.3 SQL 侧联表(备用,当前未参与任何校验逻辑;
352
+ # 若需在 SQL 侧直接做 diff 而非 Pandas 侧 merge,可在此基础上扩展)
353
+ # self.df_onoff = self.sqlrunner.run_sql(
354
+ # self._read_sql(self.cfg.joined_sql), n_process=self.cfg.n_process
355
+ # )
356
+ # logger.info("Joined: shape=%s", self.df_onoff.shape)
357
+
358
+ # 1.4 Pandas 侧 outer merge(线上列加 _online 后缀)
359
+ online_extra = [c for c in self.df_online.columns if c != "flow_id"]
360
+ self.df_compare = self.df_offline.merge(
361
+ self.df_online[["flow_id"] + online_extra],
362
+ on="flow_id",
363
+ how="outer",
364
+ suffixes=("", "_online"),
365
+ indicator=True,
366
+ )
367
+
368
+ # object 列转 numeric(防止后续比较 int/str TypeError)
369
+ converted = 0
370
+ for col in self.df_compare.columns:
371
+ if col in ("flow_id", "_merge"):
372
+ continue
373
+ if self.df_compare[col].dtype == object:
374
+ as_num = pd.to_numeric(self.df_compare[col], errors="coerce")
375
+ if as_num.notna().any():
376
+ self.df_compare[col] = as_num
377
+ converted += 1
378
+
379
+ logger.info("df_compare: shape=%s | columns=%d | numeric_converted=%d",
380
+ self.df_compare.shape, len(self.df_compare.columns), converted)
381
+
382
+ # 1.5 一致性对比基准:仅保留 flow_id 线上线下均存在的行(_merge=="both")。
383
+ # only_offline / only_online 属覆盖度问题(见 §2),其在另一侧的列天然全 NaN,
384
+ # 若纳入会被单侧为空判定误判为海量 mismatch,污染 §6 特征排序。故一致性对比统一用 df_both。
385
+ self.df_both = self.df_compare[self.df_compare["_merge"] == "both"].copy()
386
+ logger.info("df_both (一致性对比基准, both only): shape=%s", self.df_both.shape)
387
+
388
+ # 1.6 解析 info_list:仅保留实际存在于数据中的字段(排除 flow_id),随明细报告一并输出
389
+ self._info_cols = [c for c in self.cfg.info_list
390
+ if c != "flow_id" and c in self.df_both.columns]
391
+ _missing = [c for c in self.cfg.info_list
392
+ if c != "flow_id" and c not in self.df_both.columns]
393
+ if _missing:
394
+ logger.warning("info_list 中以下字段在数据中不存在,已忽略: %s", _missing)
395
+ if self._info_cols:
396
+ logger.info("info_list 字段将随明细报告输出: %s", self._info_cols)
397
+
398
+ # ─────────────────────────────────────────────────────────────────────────
399
+ # §2 Flow ID 覆盖度检查
400
+ # ─────────────────────────────────────────────────────────────────────────
401
+
402
+ def check_coverage(self) -> dict:
403
+ """检查 flow_id 覆盖与重复情况,返回覆盖度统计字典。"""
404
+ self._assert_loaded()
405
+ logger.info("=" * 60)
406
+ logger.info("§2 Flow ID 覆盖度检查")
407
+ logger.info("=" * 60)
408
+
409
+ self.offline_fids = set(self.df_offline["flow_id"].unique())
410
+ self.online_fids = set(self.df_online["flow_id"].unique())
411
+ self.common_fids = self.offline_fids & self.online_fids
412
+ self.only_offline = self.offline_fids - self.online_fids
413
+ self.only_online = self.online_fids - self.offline_fids
414
+
415
+ dup_off = int(self.df_offline["flow_id"].duplicated().sum())
416
+ dup_on = int(self.df_online["flow_id"].duplicated().sum())
417
+
418
+ result = {
419
+ "n_offline": len(self.offline_fids),
420
+ "n_online": len(self.online_fids),
421
+ "n_common": len(self.common_fids),
422
+ "n_only_offline": len(self.only_offline),
423
+ "n_only_online": len(self.only_online),
424
+ "dup_offline": dup_off,
425
+ "dup_online": dup_on,
426
+ }
427
+ logger.info(
428
+ "offline=%d | online=%d | common=%d | only_offline=%d | only_online=%d",
429
+ result["n_offline"], result["n_online"], result["n_common"],
430
+ result["n_only_offline"], result["n_only_online"],
431
+ )
432
+ if dup_off or dup_on:
433
+ logger.warning("Duplicates: offline=%d | online=%d", dup_off, dup_on)
434
+
435
+ return result
436
+
437
+ # ─────────────────────────────────────────────────────────────────────────
438
+ # §3 主模型分一致性检查
439
+ # ─────────────────────────────────────────────────────────────────────────
440
+
441
+ def check_main_score(self) -> dict:
442
+ """比较线上/线下主模型分,返回差异统计字典。"""
443
+ self._assert_loaded()
444
+ logger.info("=" * 60)
445
+ logger.info("§3 主模型分一致性检查 — %s", self.cfg.main_model_score_col)
446
+ logger.info("=" * 60)
447
+
448
+ off_col = self.cfg.main_model_score_col
449
+ on_col = self.cfg.main_model_score_col + "_online"
450
+
451
+ if off_col not in self.df_compare.columns:
452
+ logger.warning("Offline score column '%s' not found in df_compare.", off_col)
453
+ off_col = None
454
+ if on_col not in self.df_compare.columns:
455
+ logger.warning("Online score column '%s' not found in df_compare.", on_col)
456
+ on_col = None
457
+
458
+ self.offline_score_col = off_col
459
+ self.online_score_col = on_col
460
+
461
+ if not (off_col and on_col):
462
+ return {"offline_score_col": off_col, "online_score_col": on_col}
463
+
464
+ on_num = pd.to_numeric(self.df_both[on_col], errors="coerce")
465
+ off_num = pd.to_numeric(self.df_both[off_col], errors="coerce")
466
+ diff = on_num - off_num
467
+
468
+ mm_mask = (diff.abs() > self.cfg.tol_score) | (on_num.isna() ^ off_num.isna())
469
+ n_mismatch = int(mm_mask.sum())
470
+ n_one_null = int((on_num.isna() ^ off_num.isna()).sum())
471
+
472
+ result = {
473
+ "offline_score_col": off_col,
474
+ "online_score_col": on_col,
475
+ "n_compared": int(diff.count()),
476
+ "n_null": int(diff.isna().sum()),
477
+ "n_one_side_null": n_one_null,
478
+ "mean_diff": float(diff.mean()),
479
+ "max_abs_diff": float(diff.abs().max()),
480
+ "n_mismatch": n_mismatch,
481
+ "consistent": n_mismatch == 0,
482
+ }
483
+ logger.info("n_compared=%d | n_null=%d | n_one_side_null=%d | mean_diff=%.2e | max_abs_diff=%.2e | n_mismatch=%d",
484
+ result["n_compared"], result["n_null"], n_one_null,
485
+ result["mean_diff"], result["max_abs_diff"], n_mismatch)
486
+
487
+ if mm_mask.sum() > 0:
488
+ mdf = self.df_both.loc[
489
+ mm_mask, ["flow_id", "launch_time", off_col, on_col]
490
+ ].copy()
491
+ mdf["diff"] = diff[mm_mask]
492
+ self.main_score_mismatch_df = mdf
493
+ logger.warning("⚠ %d flow_ids 主模型分不一致 (|diff| > %.0e 或单侧为空,其中单侧为空 %d)",
494
+ n_mismatch, self.cfg.tol_score, n_one_null)
495
+ else:
496
+ logger.info("✅ All main scores consistent (|diff| <= %.0e)", self.cfg.tol_score)
497
+
498
+ return result
499
+
500
+ # ─────────────────────────────────────────────────────────────────────────
501
+ # §5 子模型分专项验证(条件执行)
502
+ # ─────────────────────────────────────────────────────────────────────────
503
+
504
+ def check_submodel_features(self) -> List[dict]:
505
+ """子模型分专项一致性检查。
506
+
507
+ 当 ``config.include_submodel_scores=True`` 时直接返回空列表(跳过);
508
+ 否则逐一比较 ``config.submodel_pairs`` 中的每对列。
509
+
510
+ Returns
511
+ -------
512
+ list of dict
513
+ 每个子模型的统计:submodel, n_compared, n_mismatch, n_mismatch_gt_1e6, max_abs_diff。
514
+ """
515
+ self._assert_loaded()
516
+
517
+ if self.cfg.include_submodel_scores:
518
+ logger.info("§5 跳过:include_submodel_scores=True,子模型分由 §6 特征自动检测覆盖。")
519
+ self.submodel_summary = []
520
+ return []
521
+
522
+ logger.info("=" * 60)
523
+ logger.info("§5 子模型分专项验证")
524
+ logger.info("=" * 60)
525
+
526
+ self.submodel_summary = []
527
+ for off_col, on_col in self.cfg.submodel_pairs.items():
528
+ if off_col not in self.df_compare.columns or on_col not in self.df_compare.columns:
529
+ logger.warning("Missing column pair: %s / %s", off_col, on_col)
530
+ continue
531
+ on_num = pd.to_numeric(self.df_both[on_col], errors="coerce")
532
+ off_num = pd.to_numeric(self.df_both[off_col], errors="coerce")
533
+ diff = on_num - off_num
534
+ one_null = on_num.isna() ^ off_num.isna()
535
+ record = {
536
+ "submodel": off_col,
537
+ "n_compared": int(diff.notna().sum()),
538
+ "n_one_side_null": int(one_null.sum()),
539
+ "n_mismatch": int(((diff.abs() > self.cfg.tol_score) | one_null).sum()),
540
+ "n_mismatch_gt_1e6": int(((diff.abs() > 1e-6) | one_null).sum()),
541
+ "max_abs_diff": float(diff.abs().max()),
542
+ }
543
+ self.submodel_summary.append(record)
544
+ status = "✅" if record["n_mismatch"] == 0 else "⚠"
545
+ logger.info("%s %s: n_mismatch=%d | n_one_side_null=%d | max_abs_diff=%.6f",
546
+ status, off_col, record["n_mismatch"],
547
+ record["n_one_side_null"], record["max_abs_diff"])
548
+
549
+ return self.submodel_summary
550
+
551
+ # ─────────────────────────────────────────────────────────────────────────
552
+ # §6 全量特征一致性检查
553
+ # ─────────────────────────────────────────────────────────────────────────
554
+
555
+ def check_all_features(self) -> pd.DataFrame:
556
+ """自动发现所有 col / col_online 列对并逐对比较。
557
+
558
+ 不一致判定:两侧均有值且 |diff| > tol_feat,或恰好单侧为空(XOR)。
559
+ 两侧皆空的行不参与比较(既不计 n_compared 也不计 n_mismatch)。
560
+
561
+ Returns
562
+ -------
563
+ pd.DataFrame
564
+ feature, n_compared, n_one_side_null, n_mismatch, pct_mismatch, mean_diff, max_abs_diff
565
+ (pct_mismatch 分母为可比较行数 = n_compared + n_one_side_null)
566
+ """
567
+ self._assert_loaded()
568
+ logger.info("=" * 60)
569
+ logger.info("§6 全量特征一致性检查 (tol_feat=%.0e)", self.cfg.tol_feat)
570
+ logger.info("=" * 60)
571
+
572
+ all_cols = set(self.df_compare.columns)
573
+ # info_list 字段(标识)与 time_featlist 字段(§7 用时间语义单独比较)均不作为数值特征
574
+ excl = set(self.cfg.info_list)
575
+ excl |= set(self.cfg.time_featlist)
576
+ excl |= {c + "_online" for c in self.cfg.time_featlist}
577
+ self.feature_pairs = {
578
+ col[:-7]: col
579
+ for col in sorted(all_cols)
580
+ if col.endswith("_online") and col[:-7] in all_cols
581
+ and col[:-7] not in excl and col not in excl
582
+ }
583
+ logger.info("Found %d online/offline column pairs (已排除 info_list / time_featlist 字段).",
584
+ len(self.feature_pairs))
585
+
586
+ records = []
587
+ for off_col, on_col in sorted(self.feature_pairs.items()):
588
+ on_num = pd.to_numeric(self.df_both[on_col], errors="coerce")
589
+ off_num = pd.to_numeric(self.df_both[off_col], errors="coerce")
590
+ diff = on_num - off_num
591
+ one_null = on_num.isna() ^ off_num.isna()
592
+
593
+ n_valid = int(diff.notna().sum()) # 两侧均有值
594
+ n_one_null = int(one_null.sum()) # 单侧为空 → 计为不一致
595
+ n_value_mm = int((diff.abs() > self.cfg.tol_feat).sum()) # 两侧有值且超容差
596
+ n_mismatch = n_value_mm + n_one_null
597
+ n_population = n_valid + n_one_null # 可比较行数(排除两侧皆空)
598
+ records.append({
599
+ "feature": off_col,
600
+ "n_compared": n_valid,
601
+ "n_one_side_null": n_one_null,
602
+ "n_mismatch": n_mismatch,
603
+ "pct_mismatch": round(n_mismatch / n_population * 100, 2) if n_population > 0 else 0.0,
604
+ "mean_diff": float(diff.mean()) if n_valid > 0 else float("nan"),
605
+ "max_abs_diff": float(diff.abs().max()) if n_valid > 0 else float("nan"),
606
+ })
607
+
608
+ self.diff_summary = pd.DataFrame(records).sort_values("n_mismatch", ascending=False)
609
+ n_ok = int((self.diff_summary["n_mismatch"] == 0).sum())
610
+ n_bad = int((self.diff_summary["n_mismatch"] > 0).sum())
611
+ logger.info("✅ Consistent: %d / %d | ⚠ Mismatched: %d / %d",
612
+ n_ok, len(self.diff_summary), n_bad, len(self.diff_summary))
613
+ return self.diff_summary
614
+
615
+ # ─────────────────────────────────────────────────────────────────────────
616
+ # §7 时间字段一致性检查(跨名字段对,按秒级时间差容差)
617
+ # ─────────────────────────────────────────────────────────────────────────
618
+
619
+ def check_time_fields(self) -> pd.DataFrame:
620
+ """对 ``config.time_featlist`` 中的时间字段,按秒级时间差容差比较。
621
+
622
+ 与 §6 不同:时间字段是字符串/时间戳,需用 ``pd.to_datetime`` 解析后比较时间差,
623
+ 而非数值容差。字段结构与普通入模特征一致:离线列 ``col``、线上列 ``col_online``。
624
+ 未配置 ``time_featlist`` 时直接跳过。
625
+
626
+ Returns
627
+ -------
628
+ pd.DataFrame
629
+ time_field, offline_col, online_col, n_compared, n_one_side_null,
630
+ n_mismatch, pct_mismatch, mean_diff_sec, max_abs_diff_sec
631
+ (不一致 = ``|时间差| > tol_time_seconds`` 或单侧无法解析;两侧皆空视为一致)
632
+ """
633
+ self._assert_loaded()
634
+
635
+ if not self.cfg.time_featlist:
636
+ logger.info("§7 跳过:未配置 time_featlist。")
637
+ self.time_summary = pd.DataFrame()
638
+ self.time_fields_resolved = {}
639
+ return self.time_summary
640
+
641
+ logger.info("=" * 60)
642
+ logger.info("§7 时间字段一致性检查 (tol=%.0fs)", self.cfg.tol_time_seconds)
643
+ logger.info("=" * 60)
644
+
645
+ records = []
646
+ self.time_fields_resolved = {}
647
+ for off_col in self.cfg.time_featlist:
648
+ on_col = off_col + "_online" # 线上线下同名,线上列加 _online 后缀(同普通特征)
649
+ if off_col not in self.df_both.columns or on_col not in self.df_both.columns:
650
+ logger.warning("时间字段缺失,跳过: %s / %s", off_col, on_col)
651
+ continue
652
+ self.time_fields_resolved[off_col] = on_col
653
+
654
+ a_dt = pd.to_datetime(self.df_both[on_col], errors="coerce")
655
+ b_dt = pd.to_datetime(self.df_both[off_col], errors="coerce")
656
+ diff_s = (a_dt - b_dt).dt.total_seconds()
657
+ one_null = a_dt.isna() ^ b_dt.isna()
658
+
659
+ n_valid = int(diff_s.notna().sum()) # 两侧均可解析
660
+ n_one_null = int(one_null.sum()) # 单侧无法解析 → 不一致
661
+ n_value_mm = int((diff_s.abs() > self.cfg.tol_time_seconds).sum()) # 两侧可解析且超容差
662
+ n_mismatch = n_value_mm + n_one_null
663
+ n_population = n_valid + n_one_null
664
+ records.append({
665
+ "time_field": f"{off_col} ↔ {on_col}",
666
+ "offline_col": off_col,
667
+ "online_col": on_col,
668
+ "n_compared": n_valid,
669
+ "n_one_side_null": n_one_null,
670
+ "n_mismatch": n_mismatch,
671
+ "pct_mismatch": round(n_mismatch / n_population * 100, 2) if n_population > 0 else 0.0,
672
+ "mean_diff_sec": float(diff_s.mean()) if n_valid > 0 else float("nan"),
673
+ "max_abs_diff_sec": float(diff_s.abs().max()) if n_valid > 0 else float("nan"),
674
+ })
675
+ status = "✅" if n_mismatch == 0 else "⚠"
676
+ logger.info("%s %s ↔ %s: n_compared=%d | n_mismatch=%d (单侧空 %d) | max|Δ|=%.1fs",
677
+ status, off_col, on_col, n_valid, n_mismatch, n_one_null,
678
+ records[-1]["max_abs_diff_sec"] if n_valid > 0 else 0.0)
679
+
680
+ self.time_summary = pd.DataFrame(records)
681
+ if len(self.time_summary):
682
+ n_ok = int((self.time_summary["n_mismatch"] == 0).sum())
683
+ logger.info("✅ 时间字段一致: %d / %d", n_ok, len(self.time_summary))
684
+ return self.time_summary
685
+
686
+ # ─────────────────────────────────────────────────────────────────────────
687
+ # §8 Per-Flow_ID 汇总报告
688
+ # ─────────────────────────────────────────────────────────────────────────
689
+
690
+ def build_per_flow_report(self) -> pd.DataFrame:
691
+ """为每个 common flow_id 生成主模型分差异 + 特征不一致计数的汇总行。"""
692
+ self._assert_loaded()
693
+ logger.info("=" * 60)
694
+ logger.info("§8 Per-Flow_ID 汇总报告")
695
+ logger.info("=" * 60)
696
+
697
+ df_idx = self.df_compare.set_index("flow_id")
698
+ off_col = self.offline_score_col
699
+ on_col = self.online_score_col
700
+ records = []
701
+
702
+ for fid in sorted(self.common_fids):
703
+ try:
704
+ row = df_idx.loc[fid]
705
+ if isinstance(row, pd.DataFrame):
706
+ row = row.iloc[0]
707
+ except KeyError:
708
+ continue
709
+
710
+ rec: dict = {"flow_id": fid}
711
+ for _ic in self._info_cols: # flow_id 之后附加 info_list 字段
712
+ rec[_ic] = row[_ic]
713
+
714
+ # 主模型分差异(单侧为空 → 不一致;两侧皆空 → 无可比较,视为一致)
715
+ if off_col and on_col:
716
+ ov = pd.to_numeric(row[off_col], errors="coerce")
717
+ nv = pd.to_numeric(row[on_col], errors="coerce")
718
+ d = nv - ov if pd.notna(ov) and pd.notna(nv) else float("nan")
719
+ rec["main_score_diff"] = d
720
+ if pd.isna(ov) and pd.isna(nv):
721
+ rec["main_score_ok"] = True
722
+ elif pd.isna(ov) ^ pd.isna(nv):
723
+ rec["main_score_ok"] = False
724
+ else:
725
+ rec["main_score_ok"] = bool(abs(d) <= self.cfg.tol_score)
726
+
727
+ # 子模型分差异(include_submodel_scores=False 时有效)
728
+ for s_off, s_on in self.cfg.submodel_pairs.items():
729
+ if s_off in self.df_compare.columns and s_on in self.df_compare.columns:
730
+ ov = pd.to_numeric(row[s_off], errors="coerce")
731
+ nv = pd.to_numeric(row[s_on], errors="coerce")
732
+ rec[f"{s_off}_diff"] = (
733
+ nv - ov if pd.notna(ov) and pd.notna(nv) else float("nan")
734
+ )
735
+
736
+ # 特征不一致计数(单侧为空亦计为不一致;两侧皆空跳过)
737
+ feat_bad = []
738
+ for f_off, f_on in sorted(self.feature_pairs.items()):
739
+ if f_off in self.df_compare.columns and f_on in self.df_compare.columns:
740
+ ov = pd.to_numeric(row[f_off], errors="coerce")
741
+ nv = pd.to_numeric(row[f_on], errors="coerce")
742
+ if pd.isna(ov) and pd.isna(nv):
743
+ continue
744
+ if (pd.isna(ov) ^ pd.isna(nv)) or abs(nv - ov) > self.cfg.tol_feat:
745
+ feat_bad.append(f_off)
746
+
747
+ rec["n_feature_mismatch"] = len(feat_bad)
748
+ rec["mismatch_features"] = ", ".join(feat_bad)
749
+ records.append(rec)
750
+
751
+ self.per_flow_df = pd.DataFrame(records)
752
+ n_issues = int((self.per_flow_df["n_feature_mismatch"] > 0).sum())
753
+ logger.info("Per-flow report: %d flows | %d with feature mismatches",
754
+ len(self.per_flow_df), n_issues)
755
+ return self.per_flow_df
756
+
757
+ # ─────────────────────────────────────────────────────────────────────────
758
+ # §9 汇总与结论
759
+ # ─────────────────────────────────────────────────────────────────────────
760
+
761
+ def build_summary(self) -> pd.DataFrame:
762
+ """生成整体一致性 Summary DataFrame。"""
763
+ logger.info("=" * 60)
764
+ logger.info("§9 总结与结论")
765
+ logger.info("=" * 60)
766
+
767
+ rows = []
768
+
769
+ # 1. Flow ID 覆盖
770
+ rows.append((
771
+ "Flow ID Coverage",
772
+ f"Offline: {len(self.offline_fids)} | Online: {len(self.online_fids)} | Common: {len(self.common_fids)}",
773
+ "✅",
774
+ ))
775
+
776
+ # 2. 主模型分(含单侧为空)
777
+ if self.offline_score_col and self.online_score_col:
778
+ n_mm = int(mismatch_mask(
779
+ self.df_both[self.online_score_col],
780
+ self.df_both[self.offline_score_col],
781
+ self.cfg.tol_score,
782
+ ).sum())
783
+ rows.append((
784
+ "Main Model Score",
785
+ f"{n_mm} flow_ids mismatch (|diff| > {self.cfg.tol_score:.0e} 或单侧为空)",
786
+ "✅" if n_mm == 0 else "⚠️",
787
+ ))
788
+
789
+ # 3. 子模型分
790
+ if not self.cfg.include_submodel_scores:
791
+ n_sub_ok = sum(1 for r in self.submodel_summary if r.get("n_mismatch", 1) == 0)
792
+ n_sub_total = len(self.submodel_summary)
793
+ rows.append((
794
+ "Submodel Scores",
795
+ f"{n_sub_ok}/{n_sub_total} consistent",
796
+ "✅" if n_sub_ok == n_sub_total else "⚠️",
797
+ ))
798
+ else:
799
+ rows.append((
800
+ "Submodel Scores",
801
+ "Skipped (include_submodel_scores=True,子模型分由 §6 覆盖)",
802
+ "ℹ️",
803
+ ))
804
+
805
+ # 4. 全量特征
806
+ if self.diff_summary is not None:
807
+ n_feat_total = len(self.diff_summary)
808
+ n_feat_ok = int((self.diff_summary["n_mismatch"] == 0).sum())
809
+ rows.append((
810
+ "Feature Variables",
811
+ f"{n_feat_ok}/{n_feat_total} consistent",
812
+ "✅" if n_feat_ok == n_feat_total else "⚠️",
813
+ ))
814
+
815
+ # 4.5 时间字段(含单侧无法解析)
816
+ if self.time_summary is not None and len(self.time_summary) > 0:
817
+ n_time_total = len(self.time_summary)
818
+ n_time_ok = int((self.time_summary["n_mismatch"] == 0).sum())
819
+ rows.append((
820
+ "Time Fields",
821
+ f"{n_time_ok}/{n_time_total} consistent (tol={self.cfg.tol_time_seconds:.0f}s)",
822
+ "✅" if n_time_ok == n_time_total else "⚠️",
823
+ ))
824
+
825
+ # 5. 整体
826
+ all_ok = all(r[2] in ("✅", "ℹ️") for r in rows)
827
+ rows.append((
828
+ "OVERALL",
829
+ "All checks passed" if all_ok else "Some checks failed — review details above",
830
+ "✅" if all_ok else "⚠️",
831
+ ))
832
+
833
+ self.summary_df = pd.DataFrame(rows, columns=["Check Item", "Detail", "Status"])
834
+ for _, row in self.summary_df.iterrows():
835
+ logger.info("%s %-25s %s", row["Status"], row["Check Item"], row["Detail"])
836
+ return self.summary_df
837
+
838
+ # ─────────────────────────────────────────────────────────────────────────
839
+ # §10 Excel 报告输出
840
+ # ─────────────────────────────────────────────────────────────────────────
841
+
842
+ def export_excel(self) -> str:
843
+ """将校验结果导出为结构化 Excel 报告。
844
+
845
+ Returns
846
+ -------
847
+ str
848
+ 实际写入的 Excel 文件路径。
849
+
850
+ Sheets
851
+ ------
852
+ 1. Executive Summary — 整体指标 + 子模型分汇总 + Top 20 不一致特征
853
+ 2. Main Score Mismatch — 主模型分不一致明细
854
+ 3. Submodel Score Detail — 子模型分不一致明细(或跳过提示)
855
+ 4. Feature Mismatch Summary — 全量特征不一致汇总
856
+ 5-N. Feat_<name> — Top 10 不一致特征的逐 flow_id 明细
857
+ N+1. Per Flow-ID Report — 按 flow_id 汇总的问题报告
858
+ """
859
+ ExcelMaster = _import_excel_master()
860
+
861
+ logger.info("=" * 60)
862
+ logger.info("§10 Excel 报告输出 → %s", self.cfg.excel_output_path)
863
+ logger.info("=" * 60)
864
+
865
+ em = ExcelMaster(self.cfg.excel_output_path, verbose=False)
866
+ # 注意:字体覆盖改到所有写入完成后、close 之前执行(见函数末尾 _apply_excel_font 调用),
867
+ # 否则 pandas to_excel 后建的 header / datetime 列 Format 漏覆盖。
868
+ TOL_SCORE = self.cfg.tol_score
869
+ TOL_FEAT = self.cfg.tol_feat
870
+ df_both = self.df_both # 一致性对比基准(load_data 中已派生,与 §3/§5/§6/§9 同口径)
871
+ info_cols = [c for c in self._info_cols if c in df_both.columns]
872
+ used_sheet_names: set[str] = set()
873
+
874
+ def _reserve_sheet_name(raw_name: str) -> str:
875
+ return _make_unique_excel_sheet_name(raw_name, used_sheet_names)
876
+
877
+ def _add_worksheet(raw_name: str, **kwargs):
878
+ return em.add_worksheet(_reserve_sheet_name(raw_name), **kwargs)
879
+
880
+ def _sel(*value_cols):
881
+ """逐 flow_id 明细表取列:flow_id + info_list 字段 + 业务列(去重保序)。"""
882
+ return list(dict.fromkeys(["flow_id"] + info_cols + list(value_cols)))
883
+
884
+ # ── Sheet 1: Executive Summary ─────────────────────────────────────
885
+ ws0 = _add_worksheet("Executive Summary", zoom_perc=90)
886
+ em.merge_col(ws0, ncols=4,
887
+ text="Online-Offline Consistency Check — Executive Summary",
888
+ cformat="BLUE_H4")
889
+
890
+ n_off = len(self.offline_fids)
891
+ n_on = len(self.online_fids)
892
+ n_common = len(self.common_fids)
893
+
894
+ main_diff_all = pd.Series(dtype=float)
895
+ main_mm_mask = pd.Series(dtype=bool)
896
+ n_main_mm = 0
897
+ if self.offline_score_col and self.online_score_col:
898
+ main_diff_all = safe_diff(df_both[self.online_score_col], df_both[self.offline_score_col])
899
+ main_mm_mask = mismatch_mask(df_both[self.online_score_col], df_both[self.offline_score_col], TOL_SCORE)
900
+ n_main_mm = int(main_mm_mask.sum())
901
+
902
+ if not self.cfg.include_submodel_scores:
903
+ n_sub_ok = sum(1 for r in self.submodel_summary if r.get("n_mismatch_gt_1e6", 1) == 0)
904
+ sub_str = f"{n_sub_ok}/{len(self.submodel_summary)} fully consistent"
905
+ else:
906
+ sub_str = "Skipped (include_submodel_scores=True)"
907
+
908
+ n_feat_total = len(self.diff_summary) if self.diff_summary is not None else 0
909
+ n_feat_ok = int((self.diff_summary["n_mismatch"] == 0).sum()) if self.diff_summary is not None else 0
910
+ n_feat_bad = n_feat_total - n_feat_ok
911
+ total_ids = n_off + n_on - n_common
912
+
913
+ summary_data = {
914
+ "Metric": [
915
+ "Offline flow_ids (total)", "Online flow_ids (total)",
916
+ "Common flow_ids (intersection)",
917
+ "Only in Offline (no online counterpart)",
918
+ "Only in Online (no offline counterpart)", "",
919
+ f"Main Model Score Mismatches (|diff| > {TOL_SCORE:.0e})",
920
+ "Main Model Score — Mean Diff",
921
+ "Main Model Score — Max Abs Diff", "",
922
+ "Submodel Scores", "",
923
+ f"Feature Variables Consistent ({n_feat_ok}/{n_feat_total})",
924
+ "Feature Variables with Mismatches (> 0)", "",
925
+ "Score Tolerance (Main + Submodel)", "Feature Tolerance",
926
+ ],
927
+ "Value": [
928
+ str(n_off), str(n_on), str(n_common),
929
+ str(len(self.only_offline)), str(len(self.only_online)), "",
930
+ str(n_main_mm),
931
+ f"{main_diff_all.mean():.10f}" if len(main_diff_all) else "N/A",
932
+ f"{main_diff_all.abs().max():.10f}" if len(main_diff_all) else "N/A", "",
933
+ sub_str, "",
934
+ f"{n_feat_ok}/{n_feat_total} fully consistent",
935
+ str(n_feat_bad), "",
936
+ str(TOL_SCORE), str(TOL_FEAT),
937
+ ],
938
+ }
939
+ em.write_dataframe(ws0, pd.DataFrame(summary_data), title="Overall Metrics", index=False)
940
+
941
+ if not self.cfg.include_submodel_scores and self.submodel_summary:
942
+ em.write_dataframe(ws0, pd.DataFrame(self.submodel_summary),
943
+ title="Submodel Score Summary", index=False)
944
+
945
+ if self.diff_summary is not None:
946
+ top20 = self.diff_summary[self.diff_summary["n_mismatch"] > 0].head(20)
947
+ if len(top20) > 0:
948
+ em.write_dataframe(ws0, top20, title="Top 20 Features with Mismatches", index=False)
949
+
950
+ if self.time_summary is not None and len(self.time_summary) > 0:
951
+ em.write_dataframe(ws0, self.time_summary,
952
+ title=f"Time Field Summary (tol={self.cfg.tol_time_seconds:.0f}s)",
953
+ index=False)
954
+
955
+ em.write_dataframe(ws0, pd.DataFrame({
956
+ "Category": ["Both (Online + Offline)", "Offline Only", "Online Only"],
957
+ "Count": [n_common, len(self.only_offline), len(self.only_online)],
958
+ "Pct": [
959
+ f"{n_common / total_ids * 100:.1f}%" if total_ids else "0%",
960
+ f"{len(self.only_offline) / total_ids * 100:.1f}%" if total_ids else "0%",
961
+ f"{len(self.only_online) / total_ids * 100:.1f}%" if total_ids else "0%",
962
+ ],
963
+ }), title="Flow ID Coverage Distribution", index=False)
964
+ logger.info(" ✅ Executive Summary")
965
+
966
+ # ── Sheet 2: Main Score Mismatch ───────────────────────────────────
967
+ ws1 = _add_worksheet("Main Score Mismatch", zoom_perc=90)
968
+ em.merge_col(ws1, ncols=6, text="Main Model Score Mismatch — Detail", cformat="BLUE_H4")
969
+
970
+ if self.offline_score_col and self.online_score_col:
971
+ mask = main_mm_mask
972
+ if mask.sum() > 0:
973
+ det = df_both.loc[mask, _sel(self.offline_score_col, self.online_score_col)].copy()
974
+ det["diff (online - offline)"] = main_diff_all[mask]
975
+ det["abs_diff"] = det["diff (online - offline)"].abs()
976
+ det = det.sort_values("abs_diff", ascending=False, na_position="last")
977
+ det.insert(0, "rank", range(1, len(det) + 1))
978
+ em.write_dataframe(ws1, det,
979
+ title=f"{len(det)} flow_ids 不一致 (|diff| > {TOL_SCORE} 或单侧为空)",
980
+ index=False)
981
+ em.write_dataframe(ws1, main_diff_all[mask].describe().to_frame().T,
982
+ title="Diff Distribution (mismatched subset)", index=False)
983
+ else:
984
+ em.write_text_content(ws1, input_text="✅ No main score mismatches found.\n")
985
+ else:
986
+ em.write_text_content(ws1, input_text="⚠️ Main score columns not found.\n")
987
+ logger.info(" ✅ Main Score Mismatch")
988
+
989
+ # ── Sheet 3: Submodel Score Detail ─────────────────────────────────
990
+ ws2 = _add_worksheet("Submodel Score Detail", zoom_perc=90)
991
+ em.merge_col(ws2, ncols=6,
992
+ text="Submodel Score Mismatch — Per Submodel Detail", cformat="BLUE_H4")
993
+
994
+ if self.cfg.include_submodel_scores:
995
+ em.write_text_content(
996
+ ws2,
997
+ input_text="ℹ️ Skipped: include_submodel_scores=True,子模型分已由特征检测覆盖。\n",
998
+ )
999
+ else:
1000
+ for off_col, on_col in self.cfg.submodel_pairs.items():
1001
+ if off_col not in df_both.columns or on_col not in df_both.columns:
1002
+ em.write_text_content(ws2, input_text=f"⚠️ Missing: {off_col} / {on_col}\n")
1003
+ continue
1004
+ sd = safe_diff(df_both[on_col], df_both[off_col])
1005
+ mask = mismatch_mask(df_both[on_col], df_both[off_col], TOL_SCORE)
1006
+ if mask.sum() > 0:
1007
+ detail = df_both.loc[mask, _sel(off_col, on_col)].copy()
1008
+ detail["diff (online - offline)"] = sd[mask]
1009
+ detail["abs_diff"] = detail["diff (online - offline)"].abs()
1010
+ detail = detail.sort_values("abs_diff", ascending=False, na_position="last")
1011
+ detail.insert(0, "rank", range(1, len(detail) + 1))
1012
+ em.write_dataframe(ws2, detail,
1013
+ title=f"{off_col} — {len(detail)} mismatches",
1014
+ index=False)
1015
+ else:
1016
+ em.write_text_content(ws2, input_text=f"✅ {off_col}: All consistent.\n")
1017
+ logger.info(" ✅ Submodel Score Detail")
1018
+
1019
+ # ── Sheet 4: Feature Mismatch Summary ──────────────────────────────
1020
+ if self.diff_summary is not None:
1021
+ ws3 = _add_worksheet("Feature Mismatch Summary", zoom_perc=90)
1022
+ em.merge_col(ws3, ncols=6,
1023
+ text="Feature Variable Mismatch — Full Summary", cformat="BLUE_H4")
1024
+
1025
+ feat_bad = self.diff_summary[self.diff_summary["n_mismatch"] > 0].sort_values(
1026
+ "n_mismatch", ascending=False
1027
+ )
1028
+ top10_feats = feat_bad["feature"].tolist()[:10]
1029
+ feature_detail_sheets: Dict[str, str] = {}
1030
+ for feat in top10_feats:
1031
+ on_col = feat + "_online"
1032
+ if on_col not in df_both.columns:
1033
+ continue
1034
+ mask = mismatch_mask(df_both[on_col], df_both[feat], TOL_FEAT)
1035
+ if mask.sum() == 0:
1036
+ continue
1037
+ feature_detail_sheets[feat] = _reserve_sheet_name(f"Feat_{feat}")
1038
+
1039
+ feat_bad_report = feat_bad.copy()
1040
+ if len(feat_bad_report) > 0:
1041
+ feat_bad_report["detail_sheet"] = (
1042
+ feat_bad_report["feature"].map(feature_detail_sheets).fillna("")
1043
+ )
1044
+ em.write_dataframe(
1045
+ ws3, feat_bad_report,
1046
+ title=f"All Features with Mismatches (TOL={TOL_FEAT}, n={len(feat_bad)})",
1047
+ index=False,
1048
+ )
1049
+ feat_ok = self.diff_summary[self.diff_summary["n_mismatch"] == 0].sort_values("feature")
1050
+ if len(feat_ok) > 0:
1051
+ em.write_dataframe(ws3, feat_ok[["feature", "n_compared"]],
1052
+ title=f"Fully Consistent Features (n={len(feat_ok)})",
1053
+ index=False)
1054
+ logger.info(" ✅ Feature Mismatch Summary")
1055
+
1056
+ # ── Sheets 5-N: Per-Feature Detail (Top 10) ──────────────────
1057
+ for feat in top10_feats:
1058
+ on_col = feat + "_online"
1059
+ if on_col not in df_both.columns:
1060
+ continue
1061
+ fd = safe_diff(df_both[on_col], df_both[feat])
1062
+ mask = mismatch_mask(df_both[on_col], df_both[feat], TOL_FEAT)
1063
+ if mask.sum() == 0:
1064
+ continue
1065
+ sname = feature_detail_sheets[feat]
1066
+ ws_f = em.add_worksheet(sname, zoom_perc=90)
1067
+ fd_det = df_both.loc[mask, _sel(feat, on_col)].copy()
1068
+ fd_det["diff (online - offline)"] = fd[mask]
1069
+ fd_det["abs_diff"] = fd_det["diff (online - offline)"].abs()
1070
+ fd_det = fd_det.sort_values("abs_diff", ascending=False, na_position="last")
1071
+ fd_det.insert(0, "rank", range(1, len(fd_det) + 1))
1072
+ em.write_dataframe(ws_f, fd_det,
1073
+ title=f"{feat} — {len(fd_det)} mismatches (|diff| > {TOL_FEAT})",
1074
+ index=False)
1075
+ logger.info(" ✅ Per-feature detail sheets (top %d)", len(top10_feats))
1076
+
1077
+ # ── Sheet: Time Field Consistency ──────────────────────────────────
1078
+ if self.time_summary is not None and len(self.time_summary) > 0:
1079
+ ws_t = _add_worksheet("Time Field Consistency", zoom_perc=90)
1080
+ em.merge_col(ws_t, ncols=6,
1081
+ text=f"Time Field Consistency (tol={self.cfg.tol_time_seconds:.0f}s)",
1082
+ cformat="BLUE_H4")
1083
+ em.write_dataframe(ws_t, self.time_summary, title="Time Field Summary", index=False)
1084
+ for off_col, on_col in self.time_fields_resolved.items():
1085
+ t_diff = time_diff_seconds(df_both[on_col], df_both[off_col])
1086
+ mask = time_mismatch_mask(df_both[on_col], df_both[off_col], self.cfg.tol_time_seconds)
1087
+ if mask.sum() == 0:
1088
+ em.write_text_content(ws_t, input_text=f"✅ {off_col} ↔ {on_col}: All consistent.\n")
1089
+ continue
1090
+ det = df_both.loc[mask, _sel(off_col, on_col)].copy()
1091
+ det["diff_sec (online - offline)"] = t_diff[mask]
1092
+ det["abs_diff_sec"] = det["diff_sec (online - offline)"].abs()
1093
+ det = det.sort_values("abs_diff_sec", ascending=False, na_position="last")
1094
+ det.insert(0, "rank", range(1, len(det) + 1))
1095
+ em.write_dataframe(
1096
+ ws_t, det,
1097
+ title=f"{off_col} ↔ {on_col} — {len(det)} mismatches "
1098
+ f"(|Δ| > {self.cfg.tol_time_seconds:.0f}s 或单侧无法解析)",
1099
+ index=False,
1100
+ )
1101
+ logger.info(" ✅ Time Field Consistency")
1102
+
1103
+ # ── Sheet: Per Flow-ID Report ───────────────────────────────────────
1104
+ if self.per_flow_df is not None:
1105
+ ws_flow = _add_worksheet("Per Flow-ID Report", zoom_perc=90)
1106
+ em.merge_col(ws_flow, ncols=5, text="Per Flow-ID Consistency Report", cformat="BLUE_H4")
1107
+
1108
+ issues = self.per_flow_df[self.per_flow_df["n_feature_mismatch"] > 0].sort_values(
1109
+ "n_feature_mismatch", ascending=False
1110
+ )
1111
+ if len(issues) > 0:
1112
+ em.write_dataframe(ws_flow, issues,
1113
+ title=f"Flow IDs with Feature Mismatches (n={len(issues)})",
1114
+ index=False)
1115
+ else:
1116
+ em.write_text_content(ws_flow, input_text="✅ No flow_ids with feature mismatches.\n")
1117
+
1118
+ if "main_score_ok" in self.per_flow_df.columns:
1119
+ # main_score_ok=False 即不一致(含单侧为空);两侧皆空已记为 True,自动排除
1120
+ main_issues = self.per_flow_df[~self.per_flow_df["main_score_ok"]]
1121
+ if len(main_issues) > 0:
1122
+ em.write_dataframe(
1123
+ ws_flow,
1124
+ main_issues.sort_values("main_score_diff", key=lambda x: x.abs(),
1125
+ ascending=False, na_position="last"),
1126
+ title=f"Flow IDs with Main Score Mismatch (n={len(main_issues)})",
1127
+ index=False,
1128
+ )
1129
+
1130
+ clean = self.per_flow_df[
1131
+ (self.per_flow_df["n_feature_mismatch"] == 0)
1132
+ & self.per_flow_df.get("main_score_ok", pd.Series([True] * len(self.per_flow_df)))
1133
+ ]
1134
+ em.write_dataframe(ws_flow, clean[["flow_id"]].head(50),
1135
+ title=f"Sample Clean Flow IDs (first 50 of {len(clean)})",
1136
+ index=False)
1137
+ logger.info(" ✅ Per Flow-ID Report")
1138
+
1139
+ # 字体统一:必须在所有写入完成后、close 之前执行,确保 pandas to_excel 动态创建的
1140
+ # header / datetime 列等 Format 也被覆盖(详见 _apply_excel_font 说明)。
1141
+ _apply_excel_font(em, self.cfg.excel_font)
1142
+ em.close_workbook()
1143
+ logger.info("✅ Excel saved → %s", self.cfg.excel_output_path)
1144
+ return self.cfg.excel_output_path
1145
+
1146
+ # ─────────────────────────────────────────────────────────────────────────
1147
+ # 主编排入口
1148
+ # ─────────────────────────────────────────────────────────────────────────
1149
+
1150
+ def run(self) -> pd.DataFrame:
1151
+ """按顺序执行完整 UAT 校验流程,返回 summary DataFrame。
1152
+
1153
+ 步骤顺序:
1154
+ load_data → check_coverage → check_main_score
1155
+ → check_submodel_features → check_all_features → check_time_fields
1156
+ → build_per_flow_report → build_summary → export_excel
1157
+ """
1158
+ self.load_data()
1159
+ self.check_coverage()
1160
+ self.check_main_score()
1161
+ self.check_submodel_features()
1162
+ self.check_all_features()
1163
+ self.check_time_fields()
1164
+ self.build_per_flow_report()
1165
+ self.build_summary()
1166
+ self.export_excel()
1167
+ return self.summary_df
1168
+
1169
+ # ─────────────────────────────────────────────────────────────────────────
1170
+ # 内部工具
1171
+ # ─────────────────────────────────────────────────────────────────────────
1172
+
1173
+ def _read_sql(self, filename: str) -> str:
1174
+ path = os.path.join(self.cfg.sql_dir, filename)
1175
+ with open(path, "r") as fh:
1176
+ return fh.read()
1177
+
1178
+ def _assert_loaded(self) -> None:
1179
+ if self.df_compare is None:
1180
+ raise RuntimeError("Data not loaded. Call load_data() first.")