build_model_detail_report.py 29 KB

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