|
|
@@ -0,0 +1,606 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+"""Generate the detailed pressure/vibration model report from current artifacts."""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import csv
|
|
|
+import json
|
|
|
+from datetime import date
|
|
|
+from pathlib import Path
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+import numpy as np
|
|
|
+import pandas as pd
|
|
|
+from docx import Document
|
|
|
+from docx.enum.section import WD_ORIENT
|
|
|
+from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT
|
|
|
+from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
|
|
|
+from docx.oxml import OxmlElement
|
|
|
+from docx.oxml.ns import qn
|
|
|
+from docx.shared import Cm, Pt, RGBColor
|
|
|
+
|
|
|
+from models.common import modeling, paths
|
|
|
+from models.generalized import features_pressure
|
|
|
+from models.vibration import features_vibration
|
|
|
+
|
|
|
+OUT = paths.MODELS.parent / "总结文档" / "润滑油压力和振动模型详细报告.docx"
|
|
|
+BODY_FONT = "宋体"
|
|
|
+HEAD_FONT = "黑体"
|
|
|
+BODY_SIZE = 12.0
|
|
|
+TABLE_SIZE = 12.0
|
|
|
+SMALL_SIZE = 9.0
|
|
|
+BLACK = RGBColor(0, 0, 0)
|
|
|
+
|
|
|
+KINDS = {
|
|
|
+ "pressure": {
|
|
|
+ "label": "润滑油压力",
|
|
|
+ "stage_col": "压力阶段",
|
|
|
+ "label_file": paths.REFERENCE / "润滑油压力小时阶段段.csv",
|
|
|
+ "features": features_pressure,
|
|
|
+ "feature_count": 22,
|
|
|
+ },
|
|
|
+ "vibration": {
|
|
|
+ "label": "振动",
|
|
|
+ "stage_col": "振动阶段",
|
|
|
+ "label_file": paths.REFERENCE / "振动异常段.csv",
|
|
|
+ "features": features_vibration,
|
|
|
+ "feature_count": 25,
|
|
|
+ },
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+def set_run_font(run, name=BODY_FONT, size=BODY_SIZE, bold=False) -> None:
|
|
|
+ run.font.name = name
|
|
|
+ run.font.size = Pt(size)
|
|
|
+ run.font.bold = bold
|
|
|
+ run.font.color.rgb = BLACK
|
|
|
+ run._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), name)
|
|
|
+
|
|
|
+
|
|
|
+def set_style_font(style, name: str, size: float, bold: bool) -> None:
|
|
|
+ style.font.name = name
|
|
|
+ style.font.size = Pt(size)
|
|
|
+ style.font.bold = bold
|
|
|
+ style.font.color.rgb = BLACK
|
|
|
+ style.element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), name)
|
|
|
+
|
|
|
+
|
|
|
+def configure_document(doc: Document) -> None:
|
|
|
+ section = doc.sections[0]
|
|
|
+ section.orientation = WD_ORIENT.PORTRAIT
|
|
|
+ section.page_width = Cm(21.0)
|
|
|
+ section.page_height = Cm(29.7)
|
|
|
+ section.top_margin = Cm(2.2)
|
|
|
+ section.bottom_margin = Cm(2.2)
|
|
|
+ section.left_margin = Cm(2.2)
|
|
|
+ section.right_margin = Cm(2.2)
|
|
|
+
|
|
|
+ normal = doc.styles["Normal"]
|
|
|
+ set_style_font(normal, BODY_FONT, BODY_SIZE, False)
|
|
|
+ normal.paragraph_format.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
|
|
+ normal.paragraph_format.space_before = Pt(0)
|
|
|
+ normal.paragraph_format.space_after = Pt(0)
|
|
|
+
|
|
|
+ for name, size in (("Heading 1", 16), ("Heading 2", 14), ("Heading 3", 12)):
|
|
|
+ style = doc.styles[name]
|
|
|
+ set_style_font(style, HEAD_FONT, size, True)
|
|
|
+ style.paragraph_format.space_before = Pt(14 if name == "Heading 1" else 10)
|
|
|
+ style.paragraph_format.space_after = Pt(8)
|
|
|
+ style.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
|
|
+ style.paragraph_format.keep_with_next = True
|
|
|
+
|
|
|
+ header = section.header.paragraphs[0]
|
|
|
+ header.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ run = header.add_run("润滑油压力和振动模型详细报告")
|
|
|
+ set_run_font(run, BODY_FONT, 9)
|
|
|
+
|
|
|
+ footer = section.footer.paragraphs[0]
|
|
|
+ footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ run = footer.add_run("第 ")
|
|
|
+ set_run_font(run, BODY_FONT, 9)
|
|
|
+ field = OxmlElement("w:fldSimple")
|
|
|
+ field.set(qn("w:instr"), "PAGE")
|
|
|
+ footer._p.append(field)
|
|
|
+ run = footer.add_run(" 页")
|
|
|
+ set_run_font(run, BODY_FONT, 9)
|
|
|
+
|
|
|
+
|
|
|
+def body(doc: Document, text: str, bold_prefix: str = "") -> None:
|
|
|
+ paragraph = doc.add_paragraph()
|
|
|
+ paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
|
|
+ paragraph.paragraph_format.space_after = Pt(6)
|
|
|
+ if bold_prefix and text.startswith(bold_prefix):
|
|
|
+ run = paragraph.add_run(bold_prefix)
|
|
|
+ set_run_font(run, BODY_FONT, BODY_SIZE, True)
|
|
|
+ run = paragraph.add_run(text[len(bold_prefix):])
|
|
|
+ set_run_font(run, BODY_FONT, BODY_SIZE)
|
|
|
+ else:
|
|
|
+ run = paragraph.add_run(text)
|
|
|
+ set_run_font(run, BODY_FONT, BODY_SIZE)
|
|
|
+
|
|
|
+
|
|
|
+def source(doc: Document, text: str) -> None:
|
|
|
+ paragraph = doc.add_paragraph()
|
|
|
+ paragraph.paragraph_format.space_after = Pt(8)
|
|
|
+ run = paragraph.add_run("数据来源:" + text)
|
|
|
+ set_run_font(run, BODY_FONT, SMALL_SIZE)
|
|
|
+
|
|
|
+
|
|
|
+def fix_table(table, widths: list[float]) -> None:
|
|
|
+ table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
|
+ table.autofit = False
|
|
|
+ tbl_pr = table._tbl.tblPr
|
|
|
+ layout = OxmlElement("w:tblLayout")
|
|
|
+ layout.set(qn("w:type"), "fixed")
|
|
|
+ tbl_pr.append(layout)
|
|
|
+ tbl_w = tbl_pr.find(qn("w:tblW"))
|
|
|
+ if tbl_w is None:
|
|
|
+ tbl_w = OxmlElement("w:tblW")
|
|
|
+ tbl_pr.append(tbl_w)
|
|
|
+ tbl_w.set(qn("w:w"), str(int(sum(widths) * 567)))
|
|
|
+ tbl_w.set(qn("w:type"), "dxa")
|
|
|
+ for index, column in enumerate(table._tbl.tblGrid.gridCol_lst):
|
|
|
+ column.set(qn("w:w"), str(int(widths[index] * 567)))
|
|
|
+
|
|
|
+
|
|
|
+def cell_margins(cell) -> None:
|
|
|
+ tc_pr = cell._tc.get_or_add_tcPr()
|
|
|
+ mar = tc_pr.first_child_found_in("w:tcMar")
|
|
|
+ if mar is None:
|
|
|
+ mar = OxmlElement("w:tcMar")
|
|
|
+ tc_pr.append(mar)
|
|
|
+ for key, value in (("w:left", 60), ("w:right", 60), ("w:top", 30), ("w:bottom", 30)):
|
|
|
+ node = mar.find(qn(key))
|
|
|
+ if node is None:
|
|
|
+ node = OxmlElement(key)
|
|
|
+ mar.append(node)
|
|
|
+ node.set(qn("w:w"), str(value))
|
|
|
+ node.set(qn("w:type"), "dxa")
|
|
|
+
|
|
|
+
|
|
|
+def add_table(doc: Document, headers: list[str], rows: list[list[Any]], widths: list[float], size=TABLE_SIZE, center=True) -> None:
|
|
|
+ table = doc.add_table(rows=1, cols=len(headers))
|
|
|
+ table.style = "Table Grid"
|
|
|
+ fix_table(table, widths)
|
|
|
+ header_row = table.rows[0]
|
|
|
+ tr_pr = header_row._tr.get_or_add_trPr()
|
|
|
+ tr_pr.append(OxmlElement("w:tblHeader"))
|
|
|
+ for index, text in enumerate(headers):
|
|
|
+ cell = header_row.cells[index]
|
|
|
+ cell.width = Cm(widths[index])
|
|
|
+ cell_margins(cell)
|
|
|
+ cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
|
|
|
+ paragraph = cell.paragraphs[0]
|
|
|
+ paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
|
|
+ run = paragraph.add_run(str(text))
|
|
|
+ set_run_font(run, BODY_FONT, size, True)
|
|
|
+ for values in rows:
|
|
|
+ row = table.add_row()
|
|
|
+ row._tr.get_or_add_trPr().append(OxmlElement("w:cantSplit"))
|
|
|
+ for index, text in enumerate(values):
|
|
|
+ cell = row.cells[index]
|
|
|
+ cell.width = Cm(widths[index])
|
|
|
+ cell_margins(cell)
|
|
|
+ cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
|
|
|
+ paragraph = cell.paragraphs[0]
|
|
|
+ paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER if center else WD_ALIGN_PARAGRAPH.LEFT
|
|
|
+ paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
|
|
+ run = paragraph.add_run("" if text is None else str(text))
|
|
|
+ set_run_font(run, BODY_FONT, size)
|
|
|
+ doc.add_paragraph()
|
|
|
+
|
|
|
+
|
|
|
+def pct(value: Any, digits: int = 2) -> str:
|
|
|
+ if value is None:
|
|
|
+ return "—"
|
|
|
+ return f"{float(value) * 100:.{digits}f}%"
|
|
|
+
|
|
|
+
|
|
|
+def num(value: Any, digits: int = 4) -> str:
|
|
|
+ if value is None:
|
|
|
+ return "—"
|
|
|
+ return f"{float(value):.{digits}f}"
|
|
|
+
|
|
|
+
|
|
|
+def load_json(path: Path) -> dict:
|
|
|
+ return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
|
+
|
|
|
+
|
|
|
+def load_model(kind: str):
|
|
|
+ bundle = __import__("joblib").load(paths.MODEL_ARTIFACTS / kind / f"{kind}_model.joblib")
|
|
|
+ metadata = load_json(paths.MODEL_ARTIFACTS / kind / f"{kind}_metadata.json")
|
|
|
+ return bundle, metadata
|
|
|
+
|
|
|
+
|
|
|
+def predict(samples: list[dict], bundle: dict, kind: str) -> np.ndarray:
|
|
|
+ feature_module = KINDS[kind]["features"]
|
|
|
+ names = bundle["feature_names"]
|
|
|
+ x = modeling.matrix(feature_module.feature_matrix(samples, bundle["unit_baselines"]), names)
|
|
|
+ x = bundle["imputer"].transform(x)
|
|
|
+ return bundle["model"].predict_proba(x)[:, 1]
|
|
|
+
|
|
|
+
|
|
|
+def metrics_block(samples: list[dict], probabilities: np.ndarray, threshold: float) -> tuple[dict, dict]:
|
|
|
+ labels = np.asarray([sample["label"] for sample in samples])
|
|
|
+ predicted = (probabilities >= threshold).astype(int)
|
|
|
+ return modeling.metrics(labels, predicted, probabilities), modeling.event_metrics(samples, predicted)
|
|
|
+
|
|
|
+
|
|
|
+def class_rows(metrics: dict, threshold: float) -> list[list[str]]:
|
|
|
+ normal = metrics["class_metrics"]["normal"]
|
|
|
+ anomaly = metrics["class_metrics"]["anomaly"]
|
|
|
+ return [
|
|
|
+ ["正常", normal["support"], pct(normal["precision"]), pct(normal["recall"]), pct(normal["f1"]), pct(metrics["specificity"]), "—"],
|
|
|
+ ["异常", anomaly["support"], pct(anomaly["precision"]), pct(anomaly["recall"]), pct(anomaly["f1"]), "—", pct(metrics["pr_auc"])],
|
|
|
+ ]
|
|
|
+
|
|
|
+
|
|
|
+def overall_rows(metrics: dict) -> list[list[str]]:
|
|
|
+ c = metrics["confusion_matrix_counts"]
|
|
|
+ return [[
|
|
|
+ metrics["samples"], metrics["normal_samples"], metrics["anomaly_samples"],
|
|
|
+ c["tp"], c["fp"], c["fn"], c["tn"], pct(metrics["accuracy"]), pct(metrics["balanced_accuracy"]),
|
|
|
+ pct(metrics["macro_f1"]), pct(metrics["pr_auc"]),
|
|
|
+ ]]
|
|
|
+
|
|
|
+
|
|
|
+def event_rows(events: dict) -> list[list[str]]:
|
|
|
+ return [[
|
|
|
+ events["anomaly_events"], events["detected_anomaly_events"], events["missed_anomaly_events"],
|
|
|
+ events["normal_events"], events["false_positive_normal_events"], pct(events["event_recall"]),
|
|
|
+ ]]
|
|
|
+
|
|
|
+
|
|
|
+def add_class_tables(doc: Document, metrics: dict, threshold: float, title: str, source_text: str) -> None:
|
|
|
+ doc.add_heading(title, level=3)
|
|
|
+ add_table(
|
|
|
+ doc, ["类别", "样本数", "查准率", "召回率", "F1", "特异度", "PR-AUC"],
|
|
|
+ class_rows(metrics, threshold), [2.2, 2.2, 2.4, 2.4, 2.2, 2.2, 2.4], size=12.0,
|
|
|
+ )
|
|
|
+ add_table(
|
|
|
+ doc, ["样本数", "正常", "异常", "TP", "FP", "FN", "TN"],
|
|
|
+ [overall_rows(metrics)[0][:7]], [2.4, 2.2, 2.2, 2.2, 2.2, 2.2, 2.2], size=12.0,
|
|
|
+ )
|
|
|
+ add_table(
|
|
|
+ doc, ["准确率", "平衡准确率", "macro-F1", "PR-AUC"],
|
|
|
+ [overall_rows(metrics)[0][7:]], [4.0, 4.0, 4.0, 4.0], size=12.0,
|
|
|
+ )
|
|
|
+ source(doc, source_text)
|
|
|
+
|
|
|
+
|
|
|
+def add_event_table(doc: Document, events: dict, title: str, source_text: str) -> None:
|
|
|
+ doc.add_heading(title, level=3)
|
|
|
+ add_table(
|
|
|
+ doc, ["异常事件", "检出", "漏报", "正常事件", "正常事件误报", "事件召回"],
|
|
|
+ event_rows(events), [2.6, 2.2, 2.2, 2.6, 3.2, 2.6], size=12.0,
|
|
|
+ )
|
|
|
+ source(doc, source_text)
|
|
|
+
|
|
|
+
|
|
|
+def add_unit_table(doc: Document, evaluation: dict, title: str, source_text: str) -> None:
|
|
|
+ doc.add_heading(title, level=3)
|
|
|
+ rows = []
|
|
|
+ for unit, data in evaluation["by_unit"].items():
|
|
|
+ normal = data["class_metrics"]["normal"]
|
|
|
+ anomaly = data["class_metrics"]["anomaly"]
|
|
|
+ rows.append([
|
|
|
+ unit, data["normal_samples"], data["anomaly_samples"],
|
|
|
+ pct(normal["precision"]), pct(normal["recall"]), pct(normal["f1"]),
|
|
|
+ pct(anomaly["precision"]), pct(anomaly["recall"]), pct(anomaly["f1"]),
|
|
|
+ pct(data["specificity"]), pct(data["pr_auc"]),
|
|
|
+ ])
|
|
|
+ add_table(
|
|
|
+ doc, ["机组", "正常窗口", "正常查准", "正常召回", "正常F1", "特异度"],
|
|
|
+ [[row[0], row[1], row[3], row[4], row[5], row[9]] for row in rows],
|
|
|
+ [2.6, 2.6, 2.6, 2.6, 2.6, 2.6], size=12.0,
|
|
|
+ )
|
|
|
+ add_table(
|
|
|
+ doc, ["机组", "异常窗口", "异常查准", "异常召回", "异常F1", "PR-AUC"],
|
|
|
+ [[row[0], row[2], row[6], row[7], row[8], row[10]] for row in rows],
|
|
|
+ [2.6, 2.6, 2.6, 2.6, 2.6, 2.6], size=12.0,
|
|
|
+ )
|
|
|
+ source(doc, source_text)
|
|
|
+
|
|
|
+
|
|
|
+def add_stage_table(doc: Document, labels: pd.DataFrame, stage_col: str, title: str, source_text: str) -> None:
|
|
|
+ doc.add_heading(title, level=3)
|
|
|
+ rows = []
|
|
|
+ for unit in ("7号机", "8号机", "9号机"):
|
|
|
+ subset = labels[labels["机组"] == unit]
|
|
|
+ counts = subset[stage_col].value_counts().to_dict()
|
|
|
+ rows.append([
|
|
|
+ unit, counts.get("轻微", 0), counts.get("异常", 0), counts.get("严重异常", 0), len(subset),
|
|
|
+ ])
|
|
|
+ counts = labels[stage_col].value_counts().to_dict()
|
|
|
+ rows.append(["合计", counts.get("轻微", 0), counts.get("异常", 0), counts.get("严重异常", 0), len(labels)])
|
|
|
+ add_table(doc, ["机组", "轻微", "异常", "严重异常", "合计"], rows, [4.0, 3.0, 3.0, 3.2, 3.0], size=12.0)
|
|
|
+ source(doc, source_text)
|
|
|
+
|
|
|
+
|
|
|
+def error_tables(doc: Document, kind: str, threshold: float, evaluation: dict) -> None:
|
|
|
+ frame = pd.read_csv(paths.RESULTS / kind / f"{kind}_test_predictions.csv")
|
|
|
+ frame = frame.rename(columns={"anomaly_probability": "probability", "true_label": "label", "predicted_label": "predicted"})
|
|
|
+ fp = frame[(frame["label"] == 0) & (frame["predicted"] == 1)].sort_values("probability", ascending=False).head(5)
|
|
|
+ fn = frame[(frame["label"] == 1) & (frame["predicted"] == 0)].sort_values("probability").head(5)
|
|
|
+ c = evaluation["window_metrics"]["confusion_matrix_counts"]
|
|
|
+
|
|
|
+ body(
|
|
|
+ doc,
|
|
|
+ f"测试集错判 {c['fp']} 个、漏判 {c['fn']} 个,真正例 {c['tp']} 个、真负例 {c['tn']} 个。"
|
|
|
+ f"错判是正常窗口被判为异常,漏判是异常窗口被判为正常;同时给出事件级结果,避免只看窗口。",
|
|
|
+ )
|
|
|
+ add_table(
|
|
|
+ doc, ["类型", "机组", "窗口开始", "窗口结束", "概率", "事件编号"],
|
|
|
+ [["错判", r["unit"], r["start_hour"][:16], r["end_hour"][:16], num(r["probability"], 4), r["event_group_id"]]
|
|
|
+ for _, r in fp.iterrows()] or [["错判", "无", "—", "—", "—", "—"]],
|
|
|
+ [1.4, 1.6, 3.2, 3.2, 1.8, 4.8], size=12.0,
|
|
|
+ )
|
|
|
+ add_table(
|
|
|
+ doc, ["类型", "机组", "窗口开始", "窗口结束", "概率", "事件编号"],
|
|
|
+ [["漏判", r["unit"], r["start_hour"][:16], r["end_hour"][:16], num(r["probability"], 4), r["event_group_id"]]
|
|
|
+ for _, r in fn.iterrows()] or [["漏判", "无", "—", "—", "—", "—"]],
|
|
|
+ [1.4, 1.6, 3.2, 3.2, 1.8, 4.8], size=12.0,
|
|
|
+ )
|
|
|
+ source(doc, f"models/results/{kind}/{kind}_test_predictions.csv;按阈值 {threshold:.6f} 判定")
|
|
|
+
|
|
|
+
|
|
|
+def feature_table(doc: Document, bundle: dict, metadata: dict, title: str, source_text: str) -> None:
|
|
|
+ doc.add_heading(title, level=3)
|
|
|
+ names = bundle["feature_names"]
|
|
|
+ values = bundle["model"].feature_importances_
|
|
|
+ items = sorted(zip(names, values), key=lambda item: item[1], reverse=True)
|
|
|
+ top = items[:10]
|
|
|
+ rest = sum(value for _, value in items[10:])
|
|
|
+ rows = [[name, pct(value)] for name, value in top]
|
|
|
+ rows.append(["其余特征合计", pct(rest)])
|
|
|
+ add_table(doc, ["特征", "重要度"], rows, [12.5, 4.0], size=12.0, center=False)
|
|
|
+ source(doc, source_text)
|
|
|
+
|
|
|
+
|
|
|
+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:
|
|
|
+ info = KINDS[kind]
|
|
|
+ title = info["label"]
|
|
|
+ doc.add_heading(f"{'五' if kind == 'pressure' else '六'}、{title}模型详细", level=1)
|
|
|
+
|
|
|
+ doc.add_heading("模型与训练", level=2)
|
|
|
+ body(
|
|
|
+ doc,
|
|
|
+ f"模型为 XGBoost 二分类,输出窗口异常概率;特征版本为 {metadata.get('feature_version', '')},"
|
|
|
+ f"特征数 {info['feature_count']}。训练使用全部正常样本和异常样本,按验证集选择阈值。"
|
|
|
+ )
|
|
|
+ rows = [
|
|
|
+ ["窗口长度", "3 小时"],
|
|
|
+ ["训练轮数(请求/实际)", f"800 / {metadata.get('rounds_run', '—')}"],
|
|
|
+ ["早停轮数", "50"],
|
|
|
+ ["最佳轮次", str(metadata.get("best_iteration", "—"))],
|
|
|
+ ["最佳验证 logloss", num(metadata.get("best_score"), 5)],
|
|
|
+ ["正常/异常训练样本", f"{metadata.get('training_normal', '—')} / {metadata.get('training_anomaly', '—')}"],
|
|
|
+ ["scale_pos_weight", num(metadata.get("scale_pos_weight"), 3)],
|
|
|
+ ["分类阈值", num(metadata.get("threshold"), 6)],
|
|
|
+ ["阈值选择", "验证集最大 F1"],
|
|
|
+ ]
|
|
|
+ add_table(doc, ["项目", "值"], rows, [6.0, 9.5], size=12.0, center=False)
|
|
|
+ source(doc, f"models/datas/artifacts/{kind}/{kind}_metadata.json;models/common/modeling.py")
|
|
|
+
|
|
|
+ # Training set (reference only)
|
|
|
+ threshold = float(metadata["threshold"])
|
|
|
+ train_p = predict(train, bundle, kind)
|
|
|
+ train_metrics, train_events = metrics_block(train, train_p, threshold)
|
|
|
+ add_class_tables(
|
|
|
+ doc, train_metrics, threshold, "训练集(仅参考)",
|
|
|
+ f"models/datas/{kind}/train;使用保存模型重新打分,训练集指标偏乐观,不作为泛化结论",
|
|
|
+ )
|
|
|
+ add_event_table(doc, train_events, "训练集事件级", f"models/datas/{kind}/train;保存模型重新打分")
|
|
|
+
|
|
|
+ val_p = predict(validation, bundle, kind)
|
|
|
+ val_metrics, val_events = metrics_block(validation, val_p, threshold)
|
|
|
+ add_class_tables(
|
|
|
+ doc, val_metrics, threshold, "验证集",
|
|
|
+ f"models/datas/{kind}/validation;使用保存模型重新打分,阈值来自 metadata",
|
|
|
+ )
|
|
|
+ add_event_table(doc, val_events, "验证集事件级", f"models/datas/{kind}/validation;保存模型重新打分")
|
|
|
+
|
|
|
+ add_class_tables(
|
|
|
+ doc, evaluation["window_metrics"], threshold, "封存测试集",
|
|
|
+ f"models/results/{kind}/{kind}_test_evaluation.json 的 window_metrics",
|
|
|
+ )
|
|
|
+ add_event_table(doc, evaluation["event_metrics"], "测试集事件级", f"models/results/{kind}/{kind}_test_evaluation.json 的 event_metrics")
|
|
|
+ add_unit_table(doc, evaluation, "测试集分机组", f"models/results/{kind}/{kind}_test_evaluation.json 的 by_unit")
|
|
|
+ add_stage_table(doc, labels, info["stage_col"], "标签阶段分布", f"models/datas/reference/{info['label_file'].name}")
|
|
|
+
|
|
|
+ doc.add_heading("错判与漏判", level=2)
|
|
|
+ error_tables(doc, kind, threshold, evaluation)
|
|
|
+ if kind == "pressure":
|
|
|
+ body(
|
|
|
+ doc,
|
|
|
+ "从测试结果看,错判主要是正常窗口概率接近阈值,漏判主要是轻微、单小时或边界窗口;"
|
|
|
+ "7 号机测试异常窗口 10 个、9 号机没有异常窗口,因此分机组指标只能作为参考。事件级 10/10 检出。",
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ body(
|
|
|
+ doc,
|
|
|
+ "从测试结果看,错判和漏判都集中在短阶段和峰值型阶段附近,概率接近阈值;"
|
|
|
+ "9 号机没有测试异常事件,7/8 号事件级 4/4 检出,但窗口级召回有限,需结合事件级结果看。",
|
|
|
+ )
|
|
|
+
|
|
|
+ feature_table(doc, bundle, metadata, "特征重要度(前 10)", f"{kind}_model.joblib 的 feature_importances_;特征名来自 metadata")
|
|
|
+
|
|
|
+
|
|
|
+def add_summary_section(doc: Document, data: dict[str, dict]) -> None:
|
|
|
+ doc.add_heading("七、两模型汇总", level=1)
|
|
|
+ rows = []
|
|
|
+ for kind, item in data.items():
|
|
|
+ for split_name, metrics in (("验证集", item["validation"]), ("测试集", item["test"])):
|
|
|
+ normal = metrics["class_metrics"]["normal"]
|
|
|
+ anomaly = metrics["class_metrics"]["anomaly"]
|
|
|
+ rows.append([
|
|
|
+ KINDS[kind]["label"], split_name, metrics["samples"],
|
|
|
+ pct(normal["precision"]), pct(normal["recall"]), pct(normal["f1"]),
|
|
|
+ pct(anomaly["precision"]), pct(anomaly["recall"]), pct(anomaly["f1"]),
|
|
|
+ pct(metrics["pr_auc"]),
|
|
|
+ ])
|
|
|
+ add_table(
|
|
|
+ doc, ["模型", "集合", "样本数", "正常查准", "正常召回", "正常F1"],
|
|
|
+ [row[:6] for row in rows], [3.2, 2.8, 2.6, 2.6, 2.6, 2.6], size=12.0,
|
|
|
+ )
|
|
|
+ add_table(
|
|
|
+ doc, ["模型", "集合", "样本数", "异常查准", "异常召回", "异常F1", "PR-AUC"],
|
|
|
+ [[row[0], row[1], row[2], *row[6:]] for row in rows],
|
|
|
+ [2.6, 2.4, 2.2, 2.4, 2.4, 2.4, 2.2], size=12.0,
|
|
|
+ )
|
|
|
+ source(doc, "压力/振动 validation 重新打分;test 来自各自 *_test_evaluation.json")
|
|
|
+ rows = []
|
|
|
+ for kind, item in data.items():
|
|
|
+ for split_name, events in (("验证集", item["validation_events"]), ("测试集", item["test_events"])):
|
|
|
+ rows.append([
|
|
|
+ KINDS[kind]["label"], split_name, events["anomaly_events"], events["detected_anomaly_events"],
|
|
|
+ events["missed_anomaly_events"], events["false_positive_normal_events"], pct(events["event_recall"]),
|
|
|
+ ])
|
|
|
+ add_table(doc, ["模型", "集合", "异常事件", "检出", "漏报", "正常事件误报", "事件召回"], rows, [2.2, 1.6, 2.0, 1.6, 1.6, 2.6, 2.2], size=12.0)
|
|
|
+ source(doc, "validation 事件级重新打分;test 事件级来自 evaluation event_metrics")
|
|
|
+
|
|
|
+
|
|
|
+def main() -> None:
|
|
|
+ doc = Document()
|
|
|
+ configure_document(doc)
|
|
|
+
|
|
|
+ # Cover
|
|
|
+ for _ in range(5):
|
|
|
+ doc.add_paragraph()
|
|
|
+ paragraph = doc.add_paragraph()
|
|
|
+ paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ run = paragraph.add_run("润滑油压力和振动模型详细报告")
|
|
|
+ set_run_font(run, HEAD_FONT, 26, True)
|
|
|
+ paragraph = doc.add_paragraph()
|
|
|
+ paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ run = paragraph.add_run("备份版本:压力 72 条 / 振动 25 条")
|
|
|
+ set_run_font(run, BODY_FONT, 14)
|
|
|
+ for _ in range(6):
|
|
|
+ doc.add_paragraph()
|
|
|
+ paragraph = doc.add_paragraph()
|
|
|
+ paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ run = paragraph.add_run(f"{date.today().year} 年 {date.today().month} 月")
|
|
|
+ set_run_font(run, BODY_FONT, 12)
|
|
|
+ doc.add_page_break()
|
|
|
+
|
|
|
+ doc.add_heading("一、文档说明", level=1)
|
|
|
+ body(doc, "本报告对应当前已恢复的备份版本:润滑油压力 72 条标签、振动 25 条标签。报告不包含后续 64 条压力标签或振动调参实验。")
|
|
|
+ body(doc, "模型均为二分类“正常/异常”窗口模型,输出异常概率;标签中的轻微/异常/严重异常是样本来源阶段,不是模型的三分类输出。")
|
|
|
+ body(doc, "所有指标均由当前模型文件、验证样本和封存测试结果重新计算或读取;每张表下方标注数据来源。")
|
|
|
+
|
|
|
+ doc.add_heading("二、数据与标签来源", level=1)
|
|
|
+ rows = [
|
|
|
+ ["小时运行数据", "models/datas/hourly/pks_hourly_full.csv", "7/8/9 号机 5 秒数据按小时聚合"],
|
|
|
+ ["压力标签", "models/datas/reference/润滑油压力小时阶段段.csv", "72 条"],
|
|
|
+ ["振动标签", "models/datas/reference/振动异常段.csv", "25 条"],
|
|
|
+ ["压力训练样本", "models/datas/pressure/", "manifest、train、validation、test"],
|
|
|
+ ["振动训练样本", "models/datas/vibration/", "manifest、train、validation、test"],
|
|
|
+ ["压力模型", "models/datas/artifacts/pressure/", "XGBoost + 相似度索引"],
|
|
|
+ ["振动模型", "models/datas/artifacts/vibration/", "XGBoost + 相似度索引"],
|
|
|
+ ]
|
|
|
+ add_table(doc, ["类型", "路径", "说明"], rows, [3.2, 8.0, 5.0], size=12.0, center=False)
|
|
|
+ source(doc, "项目内实际文件;标签时间范围和数量由 reference CSV 统计")
|
|
|
+
|
|
|
+ pressure_labels = pd.read_csv(KINDS["pressure"]["label_file"])
|
|
|
+ vibration_labels = pd.read_csv(KINDS["vibration"]["label_file"])
|
|
|
+ rows = [
|
|
|
+ ["润滑油压力", len(pressure_labels), pressure_labels["开始小时"].min()[:10], pressure_labels["结束小时"].max()[:10]],
|
|
|
+ ["振动", len(vibration_labels), vibration_labels["开始小时"].min()[:10], vibration_labels["结束小时"].max()[:10]],
|
|
|
+ ]
|
|
|
+ add_table(doc, ["类型", "标签数", "最早开始", "最晚结束"], rows, [4.0, 3.0, 4.0, 4.0], size=12.0)
|
|
|
+ source(doc, "models/datas/reference/润滑油压力小时阶段段.csv、振动异常段.csv")
|
|
|
+
|
|
|
+ doc.add_heading("三、样本构建与划分", level=1)
|
|
|
+ body(doc, "压力与振动均使用 3 小时滑动窗口。窗口与任一异常阶段重叠即标为异常;训练样本使用全部正常窗口。")
|
|
|
+ body(doc, "切分比例为 6.5 / 2 / 1.5,随机种子 20260921,事件组不跨 train/validation/test。压力异常按样本分配,振动按事件组分配。")
|
|
|
+ rows = []
|
|
|
+ for kind in ("pressure", "vibration"):
|
|
|
+ report = load_json(paths.DATAS / kind / "split_report.json")[kind]
|
|
|
+ counts = report["sample_counts"]
|
|
|
+ for split_name in ("train", "validation", "test"):
|
|
|
+ item = counts[split_name]
|
|
|
+ rows.append([KINDS[kind]["label"], split_name, item["samples_normal"], item["samples_anomaly"]])
|
|
|
+ add_table(doc, ["模型", "划分", "正常", "异常"], rows, [4.0, 3.0, 4.0, 4.0], size=12.0)
|
|
|
+ body(doc, "排除样本:压力 750、振动 519;不计入正常/异常训练或测试指标。")
|
|
|
+ source(doc, "models/datas/pressure/split_report.json、models/datas/vibration/split_report.json")
|
|
|
+
|
|
|
+ rows = []
|
|
|
+ for kind in ("pressure", "vibration"):
|
|
|
+ manifest = pd.read_csv(paths.DATAS / kind / "manifest.csv")
|
|
|
+ for unit in ("7号机", "8号机", "9号机"):
|
|
|
+ for split_name in ("train", "validation", "test"):
|
|
|
+ subset = manifest[(manifest["unit"] == unit) & (manifest["split"] == split_name)]
|
|
|
+ if subset.empty:
|
|
|
+ continue
|
|
|
+ rows.append([
|
|
|
+ KINDS[kind]["label"], unit, split_name,
|
|
|
+ int((subset["label_name"] == "normal").sum()),
|
|
|
+ int((subset["label_name"] == "anomaly").sum()),
|
|
|
+ ])
|
|
|
+ add_table(doc, ["模型", "机组", "split", "正常", "异常"], rows, [3.0, 2.4, 2.6, 2.2, 2.2], size=12.0)
|
|
|
+ source(doc, "models/datas/pressure/manifest.csv、models/datas/vibration/manifest.csv")
|
|
|
+
|
|
|
+ doc.add_heading("四、指标定义与来源", level=1)
|
|
|
+ body(doc, "TP:异常检出;FP:正常误报;FN:异常漏报;TN:正常正确排除。")
|
|
|
+ body(doc, "查准率 = TP/(TP+FP);异常召回 = TP/(TP+FN);正常特异度 = TN/(TN+FP);正常召回等于特异度。")
|
|
|
+ body(doc, "F1 为查准率与召回率的调和平均;平衡准确率 =(正常召回 + 异常召回)/2;macro-F1 为两类 F1 的算术平均;PR-AUC 基于异常概率计算。")
|
|
|
+ body(doc, "事件级指标按事件组聚合:事件内任一窗口检出,该事件算检出;正常事件内任一窗口报警,算正常事件误报。")
|
|
|
+ rows = [
|
|
|
+ ["验证集/训练集分类指标", "保存模型对对应样本重新打分", "models/datas/{kind}/train、validation;models/datas/artifacts/{kind}"],
|
|
|
+ ["测试集分类指标", "封存测试评估 JSON", "models/results/{kind}/{kind}_test_evaluation.json"],
|
|
|
+ ["分机组指标", "by_unit", "同上"],
|
|
|
+ ["事件级指标", "event_metrics", "验证集重新计算;测试集读取 JSON"],
|
|
|
+ ["错判/漏判明细", "测试预测 CSV", "models/results/{kind}/{kind}_test_predictions.csv"],
|
|
|
+ ]
|
|
|
+ add_table(doc, ["指标", "计算方式", "来源"], rows, [4.6, 5.8, 6.0], size=12.0, center=False)
|
|
|
+ source(doc, "models/common/modeling.py 的 metrics / event_metrics / choose_threshold")
|
|
|
+
|
|
|
+ data: dict[str, dict] = {}
|
|
|
+ for kind in ("pressure", "vibration"):
|
|
|
+ bundle, metadata = load_model(kind)
|
|
|
+ train = modeling.load_samples(paths.DATAS / kind / "train")
|
|
|
+ validation = modeling.load_samples(paths.DATAS / kind / "validation")
|
|
|
+ test = modeling.load_samples(paths.DATAS / kind / "test")
|
|
|
+ evaluation = load_json(paths.RESULTS / kind / f"{kind}_test_evaluation.json")
|
|
|
+ labels = pd.read_csv(KINDS[kind]["label_file"])
|
|
|
+ add_model_section(doc, kind, labels, evaluation, bundle, metadata, train, validation, test)
|
|
|
+ threshold = float(metadata["threshold"])
|
|
|
+ val_p = predict(validation, bundle, kind)
|
|
|
+ val_metrics, val_events = metrics_block(validation, val_p, threshold)
|
|
|
+ data[kind] = {
|
|
|
+ "validation": val_metrics,
|
|
|
+ "validation_events": val_events,
|
|
|
+ "test": evaluation["window_metrics"],
|
|
|
+ "test_events": evaluation["event_metrics"],
|
|
|
+ }
|
|
|
+
|
|
|
+ add_summary_section(doc, data)
|
|
|
+
|
|
|
+ doc.add_heading("八、结论与限制", level=1)
|
|
|
+ body(doc, "压力模型在测试集上正常类 F1 99.74%、异常类 F1 96.59%,事件级 10/10;但 7 号机只有 10 个测试异常窗口、9 号机没有测试异常窗口,分机组指标只能作参考。")
|
|
|
+ body(doc, "振动模型在测试集上正常类 F1 99.72%、异常类 F1 71.43%,事件级 4/4;异常阶段短、测试事件少,窗口级指标波动较大。")
|
|
|
+ body(doc, "两个模型的测试集均为封存集,没有参与训练或阈值选择;验证集用于阈值选择。标签来自规则扫描阶段,未经人工确认,指标代表对规则标签的泛化一致性,不等同于物理故障诊断。")
|
|
|
+ body(doc, "错判和漏判主要出现在轻微、短阶段或边界窗口;实际使用时应结合事件级结果、连续窗口和现场维修记录复核。")
|
|
|
+
|
|
|
+ doc.add_heading("附录:文件路径", level=1)
|
|
|
+ rows = [
|
|
|
+ ["压力标签", "models/datas/reference/润滑油压力小时阶段段.csv"],
|
|
|
+ ["振动标签", "models/datas/reference/振动异常段.csv"],
|
|
|
+ ["压力样本", "models/datas/pressure/"],
|
|
|
+ ["振动样本", "models/datas/vibration/"],
|
|
|
+ ["压力模型", "models/datas/artifacts/pressure/pressure_model.joblib"],
|
|
|
+ ["振动模型", "models/datas/artifacts/vibration/vibration_model.joblib"],
|
|
|
+ ["压力评估", "models/results/pressure/pressure_test_evaluation.json"],
|
|
|
+ ["振动评估", "models/results/vibration/vibration_test_evaluation.json"],
|
|
|
+ ["压力预测明细", "models/results/pressure/pressure_test_predictions.csv"],
|
|
|
+ ["振动预测明细", "models/results/vibration/vibration_test_predictions.csv"],
|
|
|
+ ]
|
|
|
+ add_table(doc, ["对象", "路径"], rows, [4.0, 12.0], size=12.0, center=False)
|
|
|
+
|
|
|
+ OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
|
+ doc.save(OUT)
|
|
|
+ print(f"已生成:{OUT}")
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ main()
|