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.
- ExcelMaster/ExcelFormatTool.py +487 -0
- ExcelMaster/ExcelMaster.py +917 -0
- ExcelMaster/Template.py +525 -0
- ExcelMaster/Utility.py +233 -0
- ExcelMaster/__init__.py +3 -0
- Modeling_Tool/Core/Binning_Tool.py +1608 -0
- Modeling_Tool/Core/Binning_Tool.pyi +48 -0
- Modeling_Tool/Core/Check_DuckDB_Compatibility.py +835 -0
- Modeling_Tool/Core/Json_Data_Converter.py +621 -0
- Modeling_Tool/Core/Model_Registry_Tool.py +199 -0
- Modeling_Tool/Core/ODPS_Tool.py +284 -0
- Modeling_Tool/Core/Slope_Tool.py +356 -0
- Modeling_Tool/Core/Slope_Tool.pyi +31 -0
- Modeling_Tool/Core/XOR_Encryptor.py +207 -0
- Modeling_Tool/Core/XOR_Encryptor.pyi +26 -0
- Modeling_Tool/Core/__init__.py +99 -0
- Modeling_Tool/Core/kDataFrame.py +228 -0
- Modeling_Tool/Core/kDataFrame.pyi +43 -0
- Modeling_Tool/Core/sample_weight_utils.py +77 -0
- Modeling_Tool/Core/utils.py +2672 -0
- Modeling_Tool/Eval/Evaluation_Tool.py +1452 -0
- Modeling_Tool/Eval/Evaluation_Tool.pyi +47 -0
- Modeling_Tool/Eval/Model_Eval_Tool.py +2548 -0
- Modeling_Tool/Eval/Model_Eval_Tool.pyi +37 -0
- Modeling_Tool/Eval/__init__.py +54 -0
- Modeling_Tool/Eval/evaluate_model.py +2008 -0
- Modeling_Tool/Eval/evaluate_model.pyi +50 -0
- Modeling_Tool/Eval/weighted_eval_utils.py +326 -0
- Modeling_Tool/Explainability/Coalition_Structure.py +305 -0
- Modeling_Tool/Explainability/Model_Explainer.py +743 -0
- Modeling_Tool/Explainability/__init__.py +24 -0
- Modeling_Tool/Feature/Distribution_Tool.py +509 -0
- Modeling_Tool/Feature/Distribution_Tool.pyi +42 -0
- Modeling_Tool/Feature/Feature_Insights.py +762 -0
- Modeling_Tool/Feature/Feature_Insights.pyi +33 -0
- Modeling_Tool/Feature/PSI_Tool.py +1195 -0
- Modeling_Tool/Feature/PSI_Tool.pyi +29 -0
- Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +355 -0
- Modeling_Tool/Feature/__init__.py +40 -0
- Modeling_Tool/Model/Backward_Tool.py +778 -0
- Modeling_Tool/Model/Backward_Tool.pyi +45 -0
- Modeling_Tool/Model/GBM_Search_Tool.py +251 -0
- Modeling_Tool/Model/GBM_Tool.py +1610 -0
- Modeling_Tool/Model/GBM_Tool.pyi +90 -0
- Modeling_Tool/Model/LRM_Tool.py +1198 -0
- Modeling_Tool/Model/LRM_Tool.pyi +47 -0
- Modeling_Tool/Model/__init__.py +61 -0
- Modeling_Tool/Sample/Distribution_Adaptation.py +131 -0
- Modeling_Tool/Sample/Distribution_Adaptation.pyi +30 -0
- Modeling_Tool/Sample/Reject_Infer.py +413 -0
- Modeling_Tool/Sample/Reject_Infer.pyi +43 -0
- Modeling_Tool/Sample/Sample_Split.py +520 -0
- Modeling_Tool/Sample/Sample_Split.pyi +43 -0
- Modeling_Tool/Sample/__init__.py +31 -0
- Modeling_Tool/UAT/UAT_Consistency_Checker.py +1180 -0
- Modeling_Tool/UAT/__init__.py +19 -0
- Modeling_Tool/WOE/WOE_Adapter.py +204 -0
- Modeling_Tool/WOE/WOE_Adapter.pyi +21 -0
- Modeling_Tool/WOE/WOE_Master.py +491 -0
- Modeling_Tool/WOE/WOE_Master.pyi +40 -0
- Modeling_Tool/WOE/WOE_Monotone_Binner.py +3324 -0
- Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +71 -0
- Modeling_Tool/WOE/WOE_Plot_Tool.py +919 -0
- Modeling_Tool/WOE/WOE_Plot_Tool.pyi +46 -0
- Modeling_Tool/WOE/WOE_Report_Builder.py +214 -0
- Modeling_Tool/WOE/WOE_Report_Builder.pyi +24 -0
- Modeling_Tool/WOE/WOE_Tool.py +1094 -0
- Modeling_Tool/WOE/WOE_Tool.pyi +41 -0
- Modeling_Tool/WOE/__init__.py +86 -0
- Modeling_Tool/WOE/plot_woe_tool.py +290 -0
- Modeling_Tool/WOE/plot_woe_tool.pyi +25 -0
- Modeling_Tool/__init__.py +176 -0
- Report/Report_Tool.py +380 -0
- Report/__init__.py +3 -0
- supermodelingfactory-0.2.0.dist-info/METADATA +268 -0
- supermodelingfactory-0.2.0.dist-info/RECORD +79 -0
- supermodelingfactory-0.2.0.dist-info/WHEEL +5 -0
- supermodelingfactory-0.2.0.dist-info/licenses/LICENSE +102 -0
- 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.")
|