Переглянути джерело

新增润滑油压力和振动模型详细报告

18922397810 7 годин тому
батько
коміт
25b151528b

+ 606 - 0
build_model_detail_report.py

@@ -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()

BIN
总结文档/润滑油压力和振动模型详细报告.docx