verify.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. #!/usr/bin/env python3
  2. """Automated verification of the reconstructed algorithms and 3h models.
  3. Checks
  4. ------
  5. 1. Algorithm scan CSVs are byte-identical to the golden reference.
  6. 2. DB full rerun of the hourly intermediate matches the cached table.
  7. 3. Pressure / vibration 3h model metrics match the documented results.
  8. 4. Dataset candidate and split counts match the documented results.
  9. Writes ``models/results/verification/verification_report.json`` and exits
  10. non-zero if any check fails.
  11. """
  12. from __future__ import annotations
  13. import argparse
  14. import csv
  15. import json
  16. import sys
  17. from collections import Counter
  18. from pathlib import Path
  19. sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
  20. from models.common import alarm_store, devices, paths
  21. ALGORITHM_FILES = {
  22. "pressure": ["润滑油压力小时等级.csv", "润滑油压力小时阶段段.csv", "润滑油压力演化周期.csv"],
  23. "vibration": ["振动异常段.csv"],
  24. "common": ["报警阈值配置.csv"],
  25. }
  26. EXPECTED = {
  27. "pressure": {
  28. "candidates_normal": 12789, "candidates_anomaly": 1016,
  29. "splits": {"train": (9095, 8439, 656), "validation": (2457, 2256, 201), "test": (2253, 2094, 159)},
  30. "threshold": 0.8625534176826477,
  31. "anomaly": {"precision": 0.9512, "recall": 0.9811, "f1": 0.9659},
  32. "normal_f1": 0.9974,
  33. "pr_auc": 0.9961,
  34. "event_recall": (10, 10),
  35. "by_unit_normal_fp_rate": {"7号机": 0.0032, "8号机": 0.0113, "9号机": 0.0},
  36. },
  37. "vibration": {
  38. "candidates_normal": 13908, "candidates_anomaly": 231,
  39. "splits": {"train": (9302, 9112, 190), "validation": (2657, 2637, 20), "test": (2180, 2159, 21)},
  40. "threshold": 0.9166295528411865,
  41. "anomaly": {"precision": 0.7143, "recall": 0.7143, "f1": 0.7143},
  42. "normal_f1": 0.9972,
  43. "pr_auc": 0.8270,
  44. "event_recall": (4, 4),
  45. "by_unit_normal_fp_rate": {"7号机": 0.0, "8号机": 0.0071, "9号机": 0.0013},
  46. },
  47. }
  48. METRIC_TOLERANCE = 1e-3
  49. THRESHOLD_TOLERANCE = 1e-6
  50. def _csv_header_rows(path: Path):
  51. with path.open(encoding="utf-8-sig", newline="") as handle:
  52. reader = csv.DictReader(handle)
  53. return list(reader.fieldnames or []), list(reader)
  54. def compare_files(reference: Path, candidate: Path) -> dict:
  55. """Byte-compare when identical; otherwise compare the reference columns as a subset.
  56. The model-enabled run appends model-reference columns, so the base algorithm
  57. columns are compared while extra columns are allowed.
  58. """
  59. base = {"reference": str(reference), "candidate": str(candidate)}
  60. if reference.read_bytes() == candidate.read_bytes():
  61. return {**base, "identical": True, "mode": "byte"}
  62. ref_header, ref_rows = _csv_header_rows(reference)
  63. cand_header, cand_rows = _csv_header_rows(candidate)
  64. missing = [column for column in ref_header if column not in cand_header]
  65. extra = [column for column in cand_header if column not in ref_header]
  66. if missing or len(ref_rows) != len(cand_rows):
  67. return {**base, "identical": False, "mode": "subset", "missing_columns": missing,
  68. "row_counts": [len(ref_rows), len(cand_rows)]}
  69. identical = all(
  70. ref_rows[index].get(column) == cand_rows[index].get(column)
  71. for index in range(len(ref_rows))
  72. for column in ref_header
  73. )
  74. return {**base, "identical": identical, "mode": "subset", "extra_columns": extra}
  75. def check_algorithm():
  76. checks = []
  77. for group, files in ALGORITHM_FILES.items():
  78. for name in files:
  79. reference = paths.REFERENCE / name
  80. candidate = (paths.COMMON_RESULTS if group == "common" else paths.RESULTS / group) / name
  81. checks.append({"group": group, "file": name, **compare_files(reference, candidate)})
  82. return {"passed": all(item["identical"] for item in checks), "checks": checks}
  83. def manifest_candidate_counts(dataset_dir: Path) -> dict:
  84. counts = {"normal": 0, "anomaly": 0}
  85. with (dataset_dir / "manifest.csv").open(encoding="utf-8-sig", newline="") as handle:
  86. for row in csv.DictReader(handle):
  87. if row["label"] == "0":
  88. counts["normal"] += 1
  89. elif row["label"] == "1":
  90. counts["anomaly"] += 1
  91. return counts
  92. def check_model(model_type: str):
  93. expected = EXPECTED[model_type]
  94. dataset_dir = paths.DATAS / model_type
  95. models_dir = paths.MODEL_ARTIFACTS / model_type
  96. results_dir = paths.RESULTS / model_type
  97. candidates = manifest_candidate_counts(dataset_dir)
  98. split_report = json.loads((dataset_dir / "split_report.json").read_text(encoding="utf-8"))[model_type]
  99. metadata = json.loads((models_dir / f"{model_type}_metadata.json").read_text(encoding="utf-8"))
  100. evaluation = json.loads((results_dir / f"{model_type}_test_evaluation.json").read_text(encoding="utf-8"))
  101. summary = evaluation["window_metrics"]
  102. details = []
  103. def check(name, actual, want, tolerance):
  104. passed = abs(actual - want) <= tolerance if isinstance(want, float) else actual == want
  105. details.append({"name": name, "actual": actual, "expected": want, "tolerance": tolerance, "passed": bool(passed)})
  106. check("candidates_normal", candidates["normal"], expected["candidates_normal"], 0)
  107. check("candidates_anomaly", candidates["anomaly"], expected["candidates_anomaly"], 0)
  108. for split, (total, normal, anomaly) in expected["splits"].items():
  109. got = split_report["sample_counts"][split]
  110. check(f"{split}_total", got["samples_total"], total, 0)
  111. check(f"{split}_normal", got["samples_normal"], normal, 0)
  112. check(f"{split}_anomaly", got["samples_anomaly"], anomaly, 0)
  113. check("threshold", metadata["threshold"], expected["threshold"], THRESHOLD_TOLERANCE)
  114. check("anomaly_precision", summary["class_metrics"]["anomaly"]["precision"], expected["anomaly"]["precision"], METRIC_TOLERANCE)
  115. check("anomaly_recall", summary["class_metrics"]["anomaly"]["recall"], expected["anomaly"]["recall"], METRIC_TOLERANCE)
  116. check("anomaly_f1", summary["class_metrics"]["anomaly"]["f1"], expected["anomaly"]["f1"], METRIC_TOLERANCE)
  117. check("normal_f1", summary["class_metrics"]["normal"]["f1"], expected["normal_f1"], METRIC_TOLERANCE)
  118. check("pr_auc", summary["pr_auc"], expected["pr_auc"], METRIC_TOLERANCE)
  119. detected, total = expected["event_recall"]
  120. check("event_recall", evaluation["event_metrics"]["detected_anomaly_events"], detected, 0)
  121. check("event_total", evaluation["event_metrics"]["anomaly_events"], total, 0)
  122. for unit, want in expected["by_unit_normal_fp_rate"].items():
  123. got = 1 - evaluation["by_unit"][unit]["specificity"]
  124. check(f"{unit}_normal_fp_rate", got, want, METRIC_TOLERANCE)
  125. return {"passed": all(item["passed"] for item in details), "checks": details}
  126. def check_db_parity():
  127. report_path = paths.RESULTS / "verification" / "db_parity.json"
  128. if not report_path.is_file():
  129. return {"passed": False, "reason": "db_parity.json 缺失"}
  130. report = json.loads(report_path.read_text(encoding="utf-8"))
  131. return {"passed": bool(report.get("identical")), "report": report}
  132. def _reference_counts(path: Path, batch_to_device: dict[int, str]) -> Counter:
  133. counts: Counter = Counter()
  134. if not path.is_file():
  135. return counts
  136. with path.open(encoding="utf-8-sig", newline="") as handle:
  137. for row in csv.DictReader(handle):
  138. counts[batch_to_device[int(row["批次"])]] += 1
  139. return counts
  140. def check_alarm_store():
  141. """Compare compressor_alarm rows to the reference stage CSVs (1 row per segment)."""
  142. device_list = list(devices.DEVICE_CODES)
  143. device_batches = devices.load_device_batches(device_list)
  144. batch_device = devices.batch_to_device(device_batches)
  145. pressure_ref = _reference_counts(paths.REFERENCE / "润滑油压力小时阶段段.csv", batch_device)
  146. vibration_ref = _reference_counts(paths.REFERENCE / "振动异常段.csv", batch_device)
  147. expected: Counter = Counter()
  148. for device in device_list:
  149. expected[(device, "pressure")] = pressure_ref.get(device, 0)
  150. expected[(device, "vibration")] = vibration_ref.get(device, 0)
  151. rows = alarm_store.fetch_alarms(device_list)
  152. actual: Counter = Counter()
  153. for row in rows:
  154. kind = "pressure" if row["alarm_type"] == alarm_store.OIL_ALARM_TYPE else "vibration"
  155. actual[(row["device_code"], kind)] += 1
  156. checks = []
  157. for key in sorted(set(expected) | set(actual), key=str):
  158. checks.append({"name": f"{key[0]}_{key[1]}", "actual": actual.get(key, 0), "expected": expected.get(key, 0),
  159. "passed": actual.get(key, 0) == expected.get(key, 0)})
  160. status_ok = all(int(row["status"]) == 1 for row in rows)
  161. json_ok = True
  162. des_ok = True
  163. for row in rows:
  164. try:
  165. json.loads(row["alarm_info_json"])
  166. except (TypeError, ValueError):
  167. json_ok = False
  168. if len(row["alarm_des"] or "") > 4000:
  169. des_ok = False
  170. checks.append({"name": "status_all_1", "actual": status_ok, "expected": True, "passed": status_ok})
  171. checks.append({"name": "json_valid", "actual": json_ok, "expected": True, "passed": json_ok})
  172. checks.append({"name": "des_within_4000", "actual": des_ok, "expected": True, "passed": des_ok})
  173. return {"passed": all(item["passed"] for item in checks), "checks": checks, "rows": len(rows)}
  174. def main() -> None:
  175. parser = argparse.ArgumentParser(description="PKS 重构算法/模型自动校验")
  176. parser.add_argument("--with-db", action="store_true", help="额外校验 compressor_alarm 与参考 CSV 的对应关系")
  177. args = parser.parse_args()
  178. report = {
  179. "algorithm": check_algorithm(),
  180. "db_parity": check_db_parity(),
  181. "models": {mt: check_model(mt) for mt in ("pressure", "vibration")},
  182. }
  183. if args.with_db:
  184. report["alarm_store"] = check_alarm_store()
  185. report["passed"] = (
  186. report["algorithm"]["passed"]
  187. and report["db_parity"]["passed"]
  188. and all(item["passed"] for item in report["models"].values())
  189. and report.get("alarm_store", {"passed": True})["passed"]
  190. )
  191. output = paths.RESULTS / "verification"
  192. output.mkdir(parents=True, exist_ok=True)
  193. (output / "verification_report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
  194. print(f"算法扫描结果一致性:{'通过' if report['algorithm']['passed'] else '失败'}")
  195. print(f"数据库全量重跑一致性:{'通过' if report['db_parity']['passed'] else '失败'}")
  196. for model_type, result in report["models"].items():
  197. failed = [item["name"] for item in result["checks"] if not item["passed"]]
  198. print(f"{model_type} 模型复现:{'通过' if result['passed'] else '失败'}" + (f" 差异={failed}" if failed else ""))
  199. if "alarm_store" in report:
  200. failed = [item["name"] for item in report["alarm_store"]["checks"] if not item["passed"]]
  201. print(f"compressor_alarm 对应:{'通过' if report['alarm_store']['passed'] else '失败'}"
  202. + (f" 差异={failed}" if failed else "") + f"({report['alarm_store']['rows']} 行)")
  203. print(f"总体:{'全部通过' if report['passed'] else '存在失败项'}")
  204. print(f"报告:{output / 'verification_report.json'}")
  205. if not report["passed"]:
  206. sys.exit(1)
  207. if __name__ == "__main__":
  208. main()