build_model_detail_report.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. #!/usr/bin/env python3
  2. """Generate the detailed pressure/vibration model report from current artifacts."""
  3. from __future__ import annotations
  4. import argparse
  5. import csv
  6. import json
  7. from datetime import date
  8. from pathlib import Path
  9. from typing import Any
  10. import numpy as np
  11. import pandas as pd
  12. from docx import Document
  13. from docx.enum.section import WD_ORIENT
  14. from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT
  15. from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
  16. from docx.oxml import OxmlElement
  17. from docx.oxml.ns import qn
  18. from docx.shared import Cm, Pt, RGBColor
  19. from models.common import modeling, paths
  20. from models.generalized import features_pressure
  21. from models.vibration import features_vibration
  22. OUT = paths.MODELS.parent / "总结文档" / "润滑油压力和振动模型详细报告_v2.docx"
  23. BODY_FONT = "宋体"
  24. HEAD_FONT = "黑体"
  25. BODY_SIZE = 12.0
  26. TABLE_SIZE = 12.0
  27. SMALL_SIZE = 9.0
  28. BLACK = RGBColor(0, 0, 0)
  29. KINDS = {
  30. "pressure": {
  31. "label": "润滑油压力",
  32. "stage_col": "压力阶段",
  33. "label_file": paths.REFERENCE / "润滑油压力小时阶段段.csv",
  34. "features": features_pressure,
  35. "feature_count": 22,
  36. },
  37. "vibration": {
  38. "label": "振动",
  39. "stage_col": "振动阶段",
  40. "label_file": paths.REFERENCE / "振动异常段.csv",
  41. "features": features_vibration,
  42. "feature_count": 25,
  43. },
  44. }
  45. def set_run_font(run, name=BODY_FONT, size=BODY_SIZE, bold=False) -> None:
  46. run.font.name = name
  47. run.font.size = Pt(size)
  48. run.font.bold = bold
  49. run.font.color.rgb = BLACK
  50. run._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), name)
  51. def set_style_font(style, name: str, size: float, bold: bool) -> None:
  52. style.font.name = name
  53. style.font.size = Pt(size)
  54. style.font.bold = bold
  55. style.font.color.rgb = BLACK
  56. style.element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), name)
  57. def configure_document(doc: Document) -> None:
  58. section = doc.sections[0]
  59. section.orientation = WD_ORIENT.PORTRAIT
  60. section.page_width = Cm(21.0)
  61. section.page_height = Cm(29.7)
  62. section.top_margin = Cm(2.2)
  63. section.bottom_margin = Cm(2.2)
  64. section.left_margin = Cm(2.2)
  65. section.right_margin = Cm(2.2)
  66. normal = doc.styles["Normal"]
  67. set_style_font(normal, BODY_FONT, BODY_SIZE, False)
  68. normal.paragraph_format.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
  69. normal.paragraph_format.space_before = Pt(0)
  70. normal.paragraph_format.space_after = Pt(0)
  71. for name, size in (("Heading 1", 16), ("Heading 2", 14), ("Heading 3", 12)):
  72. style = doc.styles[name]
  73. set_style_font(style, HEAD_FONT, size, True)
  74. style.paragraph_format.space_before = Pt(14 if name == "Heading 1" else 10)
  75. style.paragraph_format.space_after = Pt(8)
  76. style.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
  77. style.paragraph_format.keep_with_next = True
  78. header = section.header.paragraphs[0]
  79. header.alignment = WD_ALIGN_PARAGRAPH.CENTER
  80. run = header.add_run("润滑油压力和振动模型详细报告(v2)")
  81. set_run_font(run, BODY_FONT, 9)
  82. footer = section.footer.paragraphs[0]
  83. footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
  84. run = footer.add_run("第 ")
  85. set_run_font(run, BODY_FONT, 9)
  86. field = OxmlElement("w:fldSimple")
  87. field.set(qn("w:instr"), "PAGE")
  88. footer._p.append(field)
  89. run = footer.add_run(" 页")
  90. set_run_font(run, BODY_FONT, 9)
  91. def body(doc: Document, text: str, bold_prefix: str = "") -> None:
  92. paragraph = doc.add_paragraph()
  93. paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
  94. paragraph.paragraph_format.space_after = Pt(6)
  95. if bold_prefix and text.startswith(bold_prefix):
  96. run = paragraph.add_run(bold_prefix)
  97. set_run_font(run, BODY_FONT, BODY_SIZE, True)
  98. run = paragraph.add_run(text[len(bold_prefix):])
  99. set_run_font(run, BODY_FONT, BODY_SIZE)
  100. else:
  101. run = paragraph.add_run(text)
  102. set_run_font(run, BODY_FONT, BODY_SIZE)
  103. def source(doc: Document, text: str) -> None:
  104. paragraph = doc.add_paragraph()
  105. paragraph.paragraph_format.space_after = Pt(8)
  106. run = paragraph.add_run("数据来源:" + text)
  107. set_run_font(run, BODY_FONT, SMALL_SIZE)
  108. def fix_table(table, widths: list[float]) -> None:
  109. table.alignment = WD_TABLE_ALIGNMENT.CENTER
  110. table.autofit = False
  111. tbl_pr = table._tbl.tblPr
  112. layout = OxmlElement("w:tblLayout")
  113. layout.set(qn("w:type"), "fixed")
  114. tbl_pr.append(layout)
  115. tbl_w = tbl_pr.find(qn("w:tblW"))
  116. if tbl_w is None:
  117. tbl_w = OxmlElement("w:tblW")
  118. tbl_pr.append(tbl_w)
  119. tbl_w.set(qn("w:w"), str(int(sum(widths) * 567)))
  120. tbl_w.set(qn("w:type"), "dxa")
  121. for index, column in enumerate(table._tbl.tblGrid.gridCol_lst):
  122. column.set(qn("w:w"), str(int(widths[index] * 567)))
  123. def cell_margins(cell) -> None:
  124. tc_pr = cell._tc.get_or_add_tcPr()
  125. mar = tc_pr.first_child_found_in("w:tcMar")
  126. if mar is None:
  127. mar = OxmlElement("w:tcMar")
  128. tc_pr.append(mar)
  129. for key, value in (("w:left", 60), ("w:right", 60), ("w:top", 30), ("w:bottom", 30)):
  130. node = mar.find(qn(key))
  131. if node is None:
  132. node = OxmlElement(key)
  133. mar.append(node)
  134. node.set(qn("w:w"), str(value))
  135. node.set(qn("w:type"), "dxa")
  136. def add_table(doc: Document, headers: list[str], rows: list[list[Any]], widths: list[float], size=TABLE_SIZE, center=True) -> None:
  137. table = doc.add_table(rows=1, cols=len(headers))
  138. table.style = "Table Grid"
  139. fix_table(table, widths)
  140. header_row = table.rows[0]
  141. tr_pr = header_row._tr.get_or_add_trPr()
  142. tr_pr.append(OxmlElement("w:tblHeader"))
  143. for index, text in enumerate(headers):
  144. cell = header_row.cells[index]
  145. cell.width = Cm(widths[index])
  146. cell_margins(cell)
  147. cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
  148. paragraph = cell.paragraphs[0]
  149. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  150. paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
  151. run = paragraph.add_run(str(text))
  152. set_run_font(run, BODY_FONT, size, True)
  153. for values in rows:
  154. row = table.add_row()
  155. row._tr.get_or_add_trPr().append(OxmlElement("w:cantSplit"))
  156. for index, text in enumerate(values):
  157. cell = row.cells[index]
  158. cell.width = Cm(widths[index])
  159. cell_margins(cell)
  160. cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
  161. paragraph = cell.paragraphs[0]
  162. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER if center else WD_ALIGN_PARAGRAPH.LEFT
  163. paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
  164. run = paragraph.add_run("" if text is None else str(text))
  165. set_run_font(run, BODY_FONT, size)
  166. doc.add_paragraph()
  167. def pct(value: Any, digits: int = 2) -> str:
  168. if value is None:
  169. return "—"
  170. return f"{float(value) * 100:.{digits}f}%"
  171. def num(value: Any, digits: int = 4) -> str:
  172. if value is None:
  173. return "—"
  174. return f"{float(value):.{digits}f}"
  175. def load_json(path: Path) -> dict:
  176. return json.loads(Path(path).read_text(encoding="utf-8"))
  177. def load_model(kind: str):
  178. bundle = __import__("joblib").load(paths.MODEL_ARTIFACTS / kind / f"{kind}_model.joblib")
  179. metadata = load_json(paths.MODEL_ARTIFACTS / kind / f"{kind}_metadata.json")
  180. return bundle, metadata
  181. def predict(samples: list[dict], bundle: dict, kind: str) -> np.ndarray:
  182. feature_module = KINDS[kind]["features"]
  183. names = bundle["feature_names"]
  184. x = modeling.matrix(feature_module.feature_matrix(samples, bundle["unit_baselines"]), names)
  185. x = bundle["imputer"].transform(x)
  186. return bundle["model"].predict_proba(x)[:, 1]
  187. def metrics_block(samples: list[dict], probabilities: np.ndarray, threshold: float) -> tuple[dict, dict]:
  188. labels = np.asarray([sample["label"] for sample in samples])
  189. predicted = (probabilities >= threshold).astype(int)
  190. return modeling.metrics(labels, predicted, probabilities), modeling.event_metrics(samples, predicted)
  191. def class_rows(metrics: dict, threshold: float) -> list[list[str]]:
  192. normal = metrics["class_metrics"]["normal"]
  193. anomaly = metrics["class_metrics"]["anomaly"]
  194. return [
  195. ["正常", normal["support"], pct(normal["precision"]), pct(normal["recall"]), pct(normal["f1"]), pct(metrics["specificity"]), "—"],
  196. ["异常", anomaly["support"], pct(anomaly["precision"]), pct(anomaly["recall"]), pct(anomaly["f1"]), "—", pct(metrics["pr_auc"])],
  197. ]
  198. def overall_rows(metrics: dict) -> list[list[str]]:
  199. c = metrics["confusion_matrix_counts"]
  200. return [[
  201. metrics["samples"], metrics["normal_samples"], metrics["anomaly_samples"],
  202. c["tp"], c["fp"], c["fn"], c["tn"], pct(metrics["accuracy"]), pct(metrics["balanced_accuracy"]),
  203. pct(metrics["macro_f1"]), pct(metrics["pr_auc"]),
  204. ]]
  205. def event_rows(events: dict) -> list[list[str]]:
  206. return [[
  207. events["anomaly_events"], events["detected_anomaly_events"], events["missed_anomaly_events"],
  208. events["normal_events"], events["false_positive_normal_events"], pct(events["event_recall"]),
  209. ]]
  210. def add_class_tables(doc: Document, metrics: dict, threshold: float, title: str, source_text: str) -> None:
  211. doc.add_heading(title, level=3)
  212. add_table(
  213. doc, ["类别", "样本数", "查准率", "召回率", "F1", "特异度", "PR-AUC"],
  214. class_rows(metrics, threshold), [2.2, 2.2, 2.4, 2.4, 2.2, 2.2, 2.4], size=12.0,
  215. )
  216. add_table(
  217. doc, ["样本数", "正常", "异常", "异常检出", "正常误报", "异常漏报", "正常排除"],
  218. [overall_rows(metrics)[0][:7]], [2.4, 2.2, 2.2, 2.2, 2.2, 2.2, 2.2], size=12.0,
  219. )
  220. add_table(
  221. doc, ["准确率", "平衡准确率", "macro-F1", "PR-AUC"],
  222. [overall_rows(metrics)[0][7:]], [4.0, 4.0, 4.0, 4.0], size=12.0,
  223. )
  224. source(doc, source_text)
  225. def add_event_table(doc: Document, events: dict, title: str, source_text: str) -> None:
  226. doc.add_heading(title, level=3)
  227. add_table(
  228. doc, ["异常事件", "检出", "漏报", "正常事件", "正常事件误报", "事件召回"],
  229. event_rows(events), [2.6, 2.2, 2.2, 2.6, 3.2, 2.6], size=12.0,
  230. )
  231. source(doc, source_text)
  232. def add_unit_table(doc: Document, evaluation: dict, title: str, source_text: str) -> None:
  233. doc.add_heading(title, level=3)
  234. rows = []
  235. for unit, data in evaluation["by_unit"].items():
  236. normal = data["class_metrics"]["normal"]
  237. anomaly = data["class_metrics"]["anomaly"]
  238. rows.append([
  239. unit, data["normal_samples"], data["anomaly_samples"],
  240. pct(normal["precision"]), pct(normal["recall"]), pct(normal["f1"]),
  241. pct(anomaly["precision"]), pct(anomaly["recall"]), pct(anomaly["f1"]),
  242. pct(data["specificity"]), pct(data["pr_auc"]),
  243. ])
  244. add_table(
  245. doc, ["机组", "正常窗口", "正常查准", "正常召回", "正常F1", "特异度"],
  246. [[row[0], row[1], row[3], row[4], row[5], row[9]] for row in rows],
  247. [2.6, 2.6, 2.6, 2.6, 2.6, 2.6], size=12.0,
  248. )
  249. add_table(
  250. doc, ["机组", "异常窗口", "异常查准", "异常召回", "异常F1", "PR-AUC"],
  251. [[row[0], row[2], row[6], row[7], row[8], row[10]] for row in rows],
  252. [2.6, 2.6, 2.6, 2.6, 2.6, 2.6], size=12.0,
  253. )
  254. source(doc, source_text)
  255. def add_stage_table(doc: Document, labels: pd.DataFrame, stage_col: str, title: str, source_text: str) -> None:
  256. doc.add_heading(title, level=3)
  257. rows = []
  258. for unit in ("7号机", "8号机", "9号机"):
  259. subset = labels[labels["机组"] == unit]
  260. counts = subset[stage_col].value_counts().to_dict()
  261. rows.append([
  262. unit, counts.get("轻微", 0), counts.get("异常", 0), counts.get("严重异常", 0), len(subset),
  263. ])
  264. counts = labels[stage_col].value_counts().to_dict()
  265. rows.append(["合计", counts.get("轻微", 0), counts.get("异常", 0), counts.get("严重异常", 0), len(labels)])
  266. add_table(doc, ["机组", "轻微", "异常", "严重异常", "合计"], rows, [4.0, 3.0, 3.0, 3.2, 3.0], size=12.0)
  267. source(doc, source_text)
  268. def error_tables(doc: Document, kind: str, threshold: float, evaluation: dict) -> None:
  269. frame = pd.read_csv(paths.RESULTS / kind / f"{kind}_test_predictions.csv")
  270. frame = frame.rename(columns={"anomaly_probability": "probability", "true_label": "label", "predicted_label": "predicted"})
  271. fp = frame[(frame["label"] == 0) & (frame["predicted"] == 1)].sort_values("probability", ascending=False).head(5)
  272. fn = frame[(frame["label"] == 1) & (frame["predicted"] == 0)].sort_values("probability").head(5)
  273. c = evaluation["window_metrics"]["confusion_matrix_counts"]
  274. body(
  275. doc,
  276. f"测试集错判 {c['fp']} 个、漏判 {c['fn']} 个,真正例 {c['tp']} 个、真负例 {c['tn']} 个。"
  277. f"错判是正常窗口被判为异常,漏判是异常窗口被判为正常;同时给出事件级结果,避免只看窗口。",
  278. )
  279. add_table(
  280. doc, ["类型", "机组", "窗口开始", "窗口结束", "概率", "事件编号"],
  281. [["错判", r["unit"], r["start_hour"][:16], r["end_hour"][:16], num(r["probability"], 4), r["event_group_id"]]
  282. for _, r in fp.iterrows()] or [["错判", "无", "—", "—", "—", "—"]],
  283. [1.4, 1.6, 3.2, 3.2, 1.8, 4.8], size=12.0,
  284. )
  285. add_table(
  286. doc, ["类型", "机组", "窗口开始", "窗口结束", "概率", "事件编号"],
  287. [["漏判", r["unit"], r["start_hour"][:16], r["end_hour"][:16], num(r["probability"], 4), r["event_group_id"]]
  288. for _, r in fn.iterrows()] or [["漏判", "无", "—", "—", "—", "—"]],
  289. [1.4, 1.6, 3.2, 3.2, 1.8, 4.8], size=12.0,
  290. )
  291. source(doc, f"models/results/{kind}/{kind}_test_predictions.csv;按阈值 {threshold:.6f} 判定")
  292. def feature_table(doc: Document, bundle: dict, metadata: dict, title: str, source_text: str) -> None:
  293. doc.add_heading(title, level=3)
  294. names = bundle["feature_names"]
  295. values = bundle["model"].feature_importances_
  296. items = sorted(zip(names, values), key=lambda item: item[1], reverse=True)
  297. top = items[:10]
  298. rest = sum(value for _, value in items[10:])
  299. rows = [[name, pct(value)] for name, value in top]
  300. rows.append(["其余特征合计", pct(rest)])
  301. add_table(doc, ["特征", "重要度"], rows, [12.5, 4.0], size=12.0, center=False)
  302. source(doc, source_text)
  303. def add_model_section(doc: Document, kind: str, labels: pd.DataFrame, evaluation: dict, bundle: dict, metadata: dict, train: list[dict], validation: list[dict], test: list[dict]) -> None:
  304. info = KINDS[kind]
  305. title = info["label"]
  306. doc.add_heading(f"{'五' if kind == 'pressure' else '六'}、{title}模型详细", level=1)
  307. doc.add_heading("模型与训练", level=2)
  308. body(
  309. doc,
  310. f"模型为 XGBoost 二分类,输出窗口异常概率;特征版本为 {metadata.get('feature_version', '')},"
  311. f"特征数 {info['feature_count']}。训练使用全部正常样本和异常样本,按验证集选择阈值。"
  312. )
  313. rows = [
  314. ["窗口长度", "3 小时"],
  315. ["训练轮数(请求/实际)", f"800 / {metadata.get('rounds_run', '—')}"],
  316. ["早停轮数", "50"],
  317. ["最佳轮次", str(metadata.get("best_iteration", "—"))],
  318. ["最佳验证 logloss", num(metadata.get("best_score"), 5)],
  319. ["正常/异常训练样本", f"{metadata.get('training_normal', '—')} / {metadata.get('training_anomaly', '—')}"],
  320. ["scale_pos_weight", num(metadata.get("scale_pos_weight"), 3)],
  321. ["分类阈值", num(metadata.get("threshold"), 6)],
  322. ["阈值选择", "验证集最大 F1"],
  323. ]
  324. add_table(doc, ["项目", "值"], rows, [6.0, 9.5], size=12.0, center=False)
  325. source(doc, f"models/datas/artifacts/{kind}/{kind}_metadata.json;models/common/modeling.py")
  326. # Training set (reference only)
  327. threshold = float(metadata["threshold"])
  328. train_p = predict(train, bundle, kind)
  329. train_metrics, train_events = metrics_block(train, train_p, threshold)
  330. add_class_tables(
  331. doc, train_metrics, threshold, "训练集(仅参考)",
  332. f"models/datas/{kind}/train;使用保存模型重新打分,训练集指标偏乐观,不作为泛化结论",
  333. )
  334. add_event_table(doc, train_events, "训练集事件级", f"models/datas/{kind}/train;保存模型重新打分")
  335. val_p = predict(validation, bundle, kind)
  336. val_metrics, val_events = metrics_block(validation, val_p, threshold)
  337. add_class_tables(
  338. doc, val_metrics, threshold, "验证集",
  339. f"models/datas/{kind}/validation;使用保存模型重新打分,阈值来自 metadata",
  340. )
  341. add_event_table(doc, val_events, "验证集事件级", f"models/datas/{kind}/validation;保存模型重新打分")
  342. add_class_tables(
  343. doc, evaluation["window_metrics"], threshold, "封存测试集",
  344. f"models/results/{kind}/{kind}_test_evaluation.json 的 window_metrics",
  345. )
  346. add_event_table(doc, evaluation["event_metrics"], "测试集事件级", f"models/results/{kind}/{kind}_test_evaluation.json 的 event_metrics")
  347. add_unit_table(doc, evaluation, "测试集分机组", f"models/results/{kind}/{kind}_test_evaluation.json 的 by_unit")
  348. add_stage_table(doc, labels, info["stage_col"], "标签阶段分布", f"models/datas/reference/{info['label_file'].name}")
  349. doc.add_heading("错判与漏判", level=2)
  350. error_tables(doc, kind, threshold, evaluation)
  351. if kind == "pressure":
  352. anomaly_units = [
  353. f"{unit} {item['anomaly_samples']} 个"
  354. for unit, item in evaluation["by_unit"].items()
  355. if item["anomaly_samples"]
  356. ]
  357. body(
  358. doc,
  359. f"从测试结果看,错判主要是正常窗口概率接近阈值;本次漏判 {evaluation['window_metrics']['confusion_matrix_counts']['fn']} 个。"
  360. f"测试异常窗口分布:{'、'.join(anomaly_units) or '无'};没有异常窗口的机组分机组指标只能作为参考。"
  361. f"事件级 {evaluation['event_metrics']['detected_anomaly_events']}/"
  362. f"{evaluation['event_metrics']['anomaly_events']} 检出。",
  363. )
  364. else:
  365. body(
  366. doc,
  367. "从测试结果看,错判和漏判都集中在短阶段和峰值型阶段附近,概率接近阈值;"
  368. "9 号机没有测试异常事件,7/8 号事件级 4/4 检出,但窗口级召回有限,需结合事件级结果看。",
  369. )
  370. feature_table(doc, bundle, metadata, "特征重要度(前 10)", f"{kind}_model.joblib 的 feature_importances_;特征名来自 metadata")
  371. def add_summary_section(doc: Document, data: dict[str, dict]) -> None:
  372. doc.add_heading("七、两模型汇总", level=1)
  373. rows = []
  374. for kind, item in data.items():
  375. for split_name, metrics in (("验证集", item["validation"]), ("测试集", item["test"])):
  376. normal = metrics["class_metrics"]["normal"]
  377. anomaly = metrics["class_metrics"]["anomaly"]
  378. rows.append([
  379. KINDS[kind]["label"], split_name, metrics["samples"],
  380. pct(normal["precision"]), pct(normal["recall"]), pct(normal["f1"]),
  381. pct(anomaly["precision"]), pct(anomaly["recall"]), pct(anomaly["f1"]),
  382. pct(metrics["pr_auc"]),
  383. ])
  384. add_table(
  385. doc, ["模型", "集合", "样本数", "正常查准", "正常召回", "正常F1"],
  386. [row[:6] for row in rows], [3.2, 2.8, 2.6, 2.6, 2.6, 2.6], size=12.0,
  387. )
  388. add_table(
  389. doc, ["模型", "集合", "样本数", "异常查准", "异常召回", "异常F1", "PR-AUC"],
  390. [[row[0], row[1], row[2], *row[6:]] for row in rows],
  391. [2.6, 2.4, 2.2, 2.4, 2.4, 2.4, 2.2], size=12.0,
  392. )
  393. source(doc, "压力/振动 validation 重新打分;test 来自各自 *_test_evaluation.json")
  394. rows = []
  395. for kind, item in data.items():
  396. for split_name, events in (("验证集", item["validation_events"]), ("测试集", item["test_events"])):
  397. rows.append([
  398. KINDS[kind]["label"], split_name, events["anomaly_events"], events["detected_anomaly_events"],
  399. events["missed_anomaly_events"], events["false_positive_normal_events"], pct(events["event_recall"]),
  400. ])
  401. add_table(doc, ["模型", "集合", "异常事件", "检出", "漏报", "正常事件误报", "事件召回"], rows, [2.2, 1.6, 2.0, 1.6, 1.6, 2.6, 2.2], size=12.0)
  402. source(doc, "validation 事件级重新打分;test 事件级来自 evaluation event_metrics")
  403. def main() -> None:
  404. parser = argparse.ArgumentParser(description="生成润滑油压力和振动模型详细报告")
  405. parser.add_argument("--output", type=Path, default=OUT)
  406. args = parser.parse_args()
  407. output = args.output
  408. doc = Document()
  409. configure_document(doc)
  410. # Cover
  411. for _ in range(5):
  412. doc.add_paragraph()
  413. paragraph = doc.add_paragraph()
  414. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  415. run = paragraph.add_run("润滑油压力和振动模型详细报告(v2)")
  416. set_run_font(run, HEAD_FONT, 26, True)
  417. paragraph = doc.add_paragraph()
  418. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  419. run = paragraph.add_run("v2:压力 61 条 / 振动 25 条")
  420. set_run_font(run, BODY_FONT, 14)
  421. for _ in range(6):
  422. doc.add_paragraph()
  423. paragraph = doc.add_paragraph()
  424. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  425. run = paragraph.add_run(f"{date.today().year} 年 {date.today().month} 月")
  426. set_run_font(run, BODY_FONT, 12)
  427. doc.add_page_break()
  428. doc.add_heading("一、文档说明", level=1)
  429. body(doc, "本报告对应 v2:润滑油压力按“去掉 0 点、部分掉点无效、开机恢复瞬态剔除”规则重建后为 61 条标签;振动保持 25 条标签和原模型不变。")
  430. body(doc, "压力模型已按 v2 标签重新训练;7 号机剩余 2 条开机段标签经稳定恢复判断后剔除,因此 7 号机压力标签为 0 条,压力异常测试窗口全部来自 8 号机。")
  431. body(doc, "模型均为二分类“正常/异常”窗口模型,输出异常概率;标签中的轻微/异常/严重异常是样本来源阶段,不是模型的三分类输出。")
  432. body(doc, "所有指标均由当前模型文件、验证样本和封存测试结果重新计算或读取;每张表下方标注数据来源。")
  433. doc.add_heading("二、数据与标签来源", level=1)
  434. pressure_labels = pd.read_csv(KINDS["pressure"]["label_file"])
  435. vibration_labels = pd.read_csv(KINDS["vibration"]["label_file"])
  436. rows = [
  437. ["小时运行数据", "models/datas/hourly/pks_hourly_full.csv", "7/8/9 号机 5 秒数据按小时聚合,含 0 值统计"],
  438. ["压力标签", "models/datas/reference/润滑油压力小时阶段段.csv", f"{len(pressure_labels)} 条"],
  439. ["振动标签", "models/datas/reference/振动异常段.csv", f"{len(vibration_labels)} 条"],
  440. ["压力训练样本", "models/datas/pressure/", "manifest、train、validation、test"],
  441. ["振动训练样本", "models/datas/vibration/", "manifest、train、validation、test"],
  442. ["压力模型", "models/datas/artifacts/pressure/", "XGBoost + 相似度索引"],
  443. ["振动模型", "models/datas/artifacts/vibration/", "XGBoost + 相似度索引"],
  444. ]
  445. add_table(doc, ["类型", "路径", "说明"], rows, [3.2, 8.0, 5.0], size=12.0, center=False)
  446. source(doc, "项目内实际文件;标签时间范围和数量由 reference CSV 统计")
  447. rows = [
  448. ["润滑油压力", len(pressure_labels), pressure_labels["开始小时"].min()[:10], pressure_labels["结束小时"].max()[:10]],
  449. ["振动", len(vibration_labels), vibration_labels["开始小时"].min()[:10], vibration_labels["结束小时"].max()[:10]],
  450. ]
  451. add_table(doc, ["类型", "标签数", "最早开始", "最晚结束"], rows, [4.0, 3.0, 4.0, 4.0], size=12.0)
  452. source(doc, "models/datas/reference/润滑油压力小时阶段段.csv、振动异常段.csv")
  453. doc.add_heading("2.1 本版压力标签规则变化", level=2)
  454. body(doc, "压力标签先按 100% 方案去除 0 MPa 小时、部分掉点小时,再按连续有效小时计算斜率并合并前置下降。"
  455. "随后检查开机后 24 小时内出现的候选阶段:若该阶段结束后连续 12 个有效小时恢复到周期基线 95% 以上且无低报警,"
  456. "则判为开机恢复瞬态,删除标签,并将该阶段结束前的开机瞬态小时排除出正常训练样本。")
  457. removed_path = paths.PRESSURE_RESULTS / "压力开机恢复剔除标签.csv"
  458. removed = pd.read_csv(removed_path) if removed_path.is_file() else pd.DataFrame()
  459. if removed.empty:
  460. body(doc, "本版没有开机恢复瞬态被剔除。")
  461. else:
  462. rows = [
  463. [
  464. row["机组"], row["周期编号"], row["压力阶段"], row["开始小时"][:16],
  465. row["结束小时"][:16], row["稳定恢复开始小时"][:16], row["连续稳定小时数"],
  466. ]
  467. for _, row in removed.iterrows()
  468. ]
  469. add_table(
  470. doc, ["机组", "周期", "阶段", "开始", "结束", "稳定恢复开始", "连续稳定小时"],
  471. rows, [1.8, 2.0, 1.6, 2.8, 2.8, 2.8, 2.0], size=12.0,
  472. )
  473. body(doc, f"共剔除 {len(removed)} 条开机恢复瞬态标签;7 号机压力标签因此变为 0 条。")
  474. source(doc, "models/results/pressure/压力开机恢复剔除标签.csv、压力开机恢复剔除小时.csv")
  475. doc.add_heading("三、样本构建与划分", level=1)
  476. body(doc, "压力与振动均使用 3 小时滑动窗口。窗口与任一异常阶段重叠即标为异常;训练样本使用全部正常窗口。")
  477. body(doc, "切分比例为 6.5 / 2 / 1.5,随机种子 20260921,事件组不跨 train/validation/test。压力异常按样本分配,振动按事件组分配。")
  478. rows = []
  479. for kind in ("pressure", "vibration"):
  480. report = load_json(paths.DATAS / kind / "split_report.json")[kind]
  481. counts = report["sample_counts"]
  482. for split_name in ("train", "validation", "test"):
  483. item = counts[split_name]
  484. rows.append([KINDS[kind]["label"], split_name, item["samples_normal"], item["samples_anomaly"]])
  485. add_table(doc, ["模型", "划分", "正常", "异常"], rows, [4.0, 3.0, 4.0, 4.0], size=12.0)
  486. excluded_counts = {}
  487. for kind in ("pressure", "vibration"):
  488. manifest = pd.read_csv(paths.DATAS / kind / "manifest.csv")
  489. excluded_counts[kind] = int((manifest["label_name"] == "excluded").sum())
  490. body(doc, f"排除样本:压力 {excluded_counts['pressure']}、振动 {excluded_counts['vibration']};不计入正常/异常训练或测试指标。")
  491. source(doc, "models/datas/pressure/split_report.json、models/datas/vibration/split_report.json、manifest.csv")
  492. rows = []
  493. for kind in ("pressure", "vibration"):
  494. manifest = pd.read_csv(paths.DATAS / kind / "manifest.csv")
  495. for unit in ("7号机", "8号机", "9号机"):
  496. for split_name in ("train", "validation", "test"):
  497. subset = manifest[(manifest["unit"] == unit) & (manifest["split"] == split_name)]
  498. if subset.empty:
  499. continue
  500. rows.append([
  501. KINDS[kind]["label"], unit, split_name,
  502. int((subset["label_name"] == "normal").sum()),
  503. int((subset["label_name"] == "anomaly").sum()),
  504. ])
  505. add_table(doc, ["模型", "机组", "split", "正常", "异常"], rows, [3.0, 2.4, 2.6, 2.2, 2.2], size=12.0)
  506. source(doc, "models/datas/pressure/manifest.csv、models/datas/vibration/manifest.csv")
  507. doc.add_heading("四、指标定义与来源", level=1)
  508. body(doc, "TP:异常检出;FP:正常误报;FN:异常漏报;TN:正常正确排除。")
  509. body(doc, "查准率 = TP/(TP+FP);异常召回 = TP/(TP+FN);正常特异度 = TN/(TN+FP);正常召回等于特异度。")
  510. body(doc, "F1 为查准率与召回率的调和平均;平衡准确率 =(正常召回 + 异常召回)/2;macro-F1 为两类 F1 的算术平均;PR-AUC 基于异常概率计算。")
  511. body(doc, "事件级指标按事件组聚合:事件内任一窗口检出,该事件算检出;正常事件内任一窗口报警,算正常事件误报。")
  512. rows = [
  513. ["验证集/训练集分类指标", "保存模型对对应样本重新打分", "models/datas/{kind}/train、validation;models/datas/artifacts/{kind}"],
  514. ["测试集分类指标", "封存测试评估 JSON", "models/results/{kind}/{kind}_test_evaluation.json"],
  515. ["分机组指标", "by_unit", "同上"],
  516. ["事件级指标", "event_metrics", "验证集重新计算;测试集读取 JSON"],
  517. ["错判/漏判明细", "测试预测 CSV", "models/results/{kind}/{kind}_test_predictions.csv"],
  518. ]
  519. add_table(doc, ["指标", "计算方式", "来源"], rows, [4.6, 5.8, 6.0], size=12.0, center=False)
  520. source(doc, "models/common/modeling.py 的 metrics / event_metrics / choose_threshold")
  521. data: dict[str, dict] = {}
  522. for kind in ("pressure", "vibration"):
  523. bundle, metadata = load_model(kind)
  524. train = modeling.load_samples(paths.DATAS / kind / "train")
  525. validation = modeling.load_samples(paths.DATAS / kind / "validation")
  526. test = modeling.load_samples(paths.DATAS / kind / "test")
  527. evaluation = load_json(paths.RESULTS / kind / f"{kind}_test_evaluation.json")
  528. labels = pd.read_csv(KINDS[kind]["label_file"])
  529. add_model_section(doc, kind, labels, evaluation, bundle, metadata, train, validation, test)
  530. threshold = float(metadata["threshold"])
  531. val_p = predict(validation, bundle, kind)
  532. val_metrics, val_events = metrics_block(validation, val_p, threshold)
  533. data[kind] = {
  534. "validation": val_metrics,
  535. "validation_events": val_events,
  536. "test": evaluation["window_metrics"],
  537. "test_events": evaluation["event_metrics"],
  538. }
  539. add_summary_section(doc, data)
  540. doc.add_heading("八、结论与限制", level=1)
  541. pressure_test = data["pressure"]["test"]
  542. pressure_events = data["pressure"]["test_events"]
  543. vibration_test = data["vibration"]["test"]
  544. vibration_events = data["vibration"]["test_events"]
  545. body(
  546. doc,
  547. f"压力模型在测试集上正常类 F1 {pct(pressure_test['class_metrics']['normal']['f1'])}、"
  548. f"异常类 F1 {pct(pressure_test['class_metrics']['anomaly']['f1'])},"
  549. f"事件级 {pressure_events['detected_anomaly_events']}/{pressure_events['anomaly_events']};"
  550. "测试异常窗口全部来自 8 号机,7 号机和 9 号机没有压力测试异常窗口,分机组指标只能作参考。",
  551. )
  552. body(
  553. doc,
  554. f"振动模型在测试集上正常类 F1 {pct(vibration_test['class_metrics']['normal']['f1'])}、"
  555. f"异常类 F1 {pct(vibration_test['class_metrics']['anomaly']['f1'])},"
  556. f"事件级 {vibration_events['detected_anomaly_events']}/{vibration_events['anomaly_events']};"
  557. "异常阶段短、测试事件少,窗口级指标波动较大。",
  558. )
  559. body(doc, "两个模型的测试集均为封存集,没有参与训练或阈值选择;验证集用于阈值选择。标签来自规则扫描阶段,未经人工确认,指标代表对规则标签的泛化一致性,不等同于物理故障诊断。")
  560. body(doc, "v2 的主要变化是压力标签清洗:剔除 0 点和部分掉点小时,并把开机后稳定恢复的阶段判为瞬态;因此 7 号机压力标签为 0,模型对 7 号机压力异常的评估能力没有测试样本支撑。")
  561. body(doc, "错判和漏判主要出现在轻微、短阶段或边界窗口;实际使用时应结合事件级结果、连续窗口和现场维修记录复核。")
  562. doc.add_heading("附录:文件路径", level=1)
  563. rows = [
  564. ["压力标签", "models/datas/reference/润滑油压力小时阶段段.csv"],
  565. ["振动标签", "models/datas/reference/振动异常段.csv"],
  566. ["压力样本", "models/datas/pressure/"],
  567. ["振动样本", "models/datas/vibration/"],
  568. ["压力模型", "models/datas/artifacts/pressure/pressure_model.joblib"],
  569. ["振动模型", "models/datas/artifacts/vibration/vibration_model.joblib"],
  570. ["压力评估", "models/results/pressure/pressure_test_evaluation.json"],
  571. ["振动评估", "models/results/vibration/vibration_test_evaluation.json"],
  572. ["压力预测明细", "models/results/pressure/pressure_test_predictions.csv"],
  573. ["振动预测明细", "models/results/vibration/vibration_test_predictions.csv"],
  574. ]
  575. add_table(doc, ["对象", "路径"], rows, [4.0, 12.0], size=12.0, center=False)
  576. output.parent.mkdir(parents=True, exist_ok=True)
  577. doc.save(output)
  578. print(f"已生成:{output}")
  579. if __name__ == "__main__":
  580. main()