| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138 |
- #!/usr/bin/env python3
- """Evaluate a fixed PKS assistant model against the sealed test directory."""
- from __future__ import annotations
- import argparse
- import csv
- import importlib
- import json
- from collections import Counter, defaultdict
- from pathlib import Path
- import joblib
- import numpy as np
- from sklearn.metrics import average_precision_score, confusion_matrix, precision_recall_fscore_support
- def feature_module(model_type: str):
- """Pressure and vibration use independent feature modules."""
- return importlib.import_module(
- "generalized_features" if model_type == "pressure" else "generalized_features_vibration"
- )
- def load_samples(directory: Path) -> list[dict]:
- return [json.loads(path.read_text(encoding="utf-8")) for path in sorted(directory.glob("*.json"))]
- def matrix(feature_dicts: list[dict], names: list[str]) -> np.ndarray:
- return np.asarray([[item.get(name) for name in names] for item in feature_dicts], dtype=float)
- def metrics(y: np.ndarray, predicted: np.ndarray, probabilities: np.ndarray) -> dict:
- labels = [0, 1]
- per_precision, per_recall, per_f1, per_support = precision_recall_fscore_support(
- y, predicted, labels=labels, zero_division=0
- )
- tn, fp, fn, tp = confusion_matrix(y, predicted, labels=labels).ravel()
- samples = int(len(y))
- normal_support, anomaly_support = int(per_support[0]), int(per_support[1])
- specificity = float(tn / (tn + fp)) if (tn + fp) else 0.0
- accuracy = float((tp + tn) / samples) if samples else 0.0
- return {
- "samples": samples, "normal_samples": normal_support, "anomaly_samples": anomaly_support,
- "tp": int(tp), "fp": int(fp), "fn": int(fn), "tn": int(tn),
- "precision": float(per_precision[1]), "recall": float(per_recall[1]), "f1": float(per_f1[1]),
- "pr_auc": float(average_precision_score(y, probabilities)) if len(set(y)) == 2 else None,
- "specificity": specificity, "accuracy": accuracy,
- "balanced_accuracy": float((per_recall[0] + per_recall[1]) / 2),
- "macro_f1": float((per_f1[0] + per_f1[1]) / 2),
- "weighted_f1": float((per_f1[0] * normal_support + per_f1[1] * anomaly_support) / samples) if samples else 0.0,
- "class_metrics": {
- "normal": {"precision": float(per_precision[0]), "recall": float(per_recall[0]), "f1": float(per_f1[0]), "support": normal_support},
- "anomaly": {"precision": float(per_precision[1]), "recall": float(per_recall[1]), "f1": float(per_f1[1]), "support": anomaly_support},
- },
- "confusion_matrix_counts": {"tn": int(tn), "fp": int(fp), "fn": int(fn), "tp": int(tp)},
- "confusion_matrix_normalized": {
- "normal": {"predicted_normal": float(tn / normal_support) if normal_support else 0.0, "predicted_anomaly": float(fp / normal_support) if normal_support else 0.0},
- "anomaly": {"predicted_normal": float(fn / anomaly_support) if anomaly_support else 0.0, "predicted_anomaly": float(tp / anomaly_support) if anomaly_support else 0.0},
- },
- }
- def event_metrics(samples: list[dict], predicted: np.ndarray) -> dict:
- groups = defaultdict(list)
- for sample, value in zip(samples, predicted):
- groups[sample["event_group_id"]].append((sample, int(value)))
- positives = [values for values in groups.values() if values[0][0]["label"] == 1]
- negatives = [values for values in groups.values() if values[0][0]["label"] == 0]
- tp = sum(any(value for _, value in values) for values in positives)
- fn = len(positives) - tp
- fp = sum(any(value for _, value in values) for values in negatives)
- return {
- "anomaly_events": len(positives), "normal_events": len(negatives),
- "detected_anomaly_events": tp, "missed_anomaly_events": fn, "false_positive_normal_events": fp,
- "event_recall": tp / len(positives) if positives else None,
- }
- def main() -> None:
- parser = argparse.ArgumentParser(description="仅对封存PKS测试集做最终模型评估")
- parser.add_argument("model_type", choices=("pressure", "vibration"))
- parser.add_argument("--datasets-dir", default=str(Path(__file__).resolve().parent / "datasets"))
- parser.add_argument("--models-dir", default=str(Path(__file__).resolve().parent / "models"))
- parser.add_argument("--output-dir", default=str(Path(__file__).resolve().parents[2] / "cache" / "pks_scanner" / "model_assistant_generalized"))
- args = parser.parse_args()
- gf = feature_module(args.model_type)
- bundle = joblib.load(Path(args.models_dir) / f"{args.model_type}_model.joblib")
- metadata = json.loads((Path(args.models_dir) / f"{args.model_type}_metadata.json").read_text(encoding="utf-8"))
- samples = load_samples(Path(args.datasets_dir) / args.model_type / "test")
- if not samples or not any(sample["label"] == 1 for sample in samples):
- parser.error("封存测试集必须包含至少一个异常样本")
- x = bundle["imputer"].transform(matrix(gf.feature_matrix(samples, bundle["unit_baselines"]), bundle["feature_names"]))
- y = np.asarray([sample["label"] for sample in samples])
- probabilities = bundle["model"].predict_proba(x)[:, 1]
- predicted = (probabilities >= metadata["threshold"]).astype(int)
- distances, neighbors = bundle["knn"].named_steps["knn"].kneighbors(bundle["knn"].named_steps["scale"].transform(x), n_neighbors=1)
- rows = []
- for index, sample in enumerate(samples):
- rows.append({
- "sample_id": sample["sample_id"], "unit": sample["unit"], "batch": sample["batch"],
- "event_group_id": sample["event_group_id"], "start_hour": sample["start_hour"], "end_hour": sample["end_hour"],
- "true_label": sample["label"], "anomaly_probability": probabilities[index], "predicted_label": int(predicted[index]),
- "nearest_training_sample_index": int(neighbors[index][0]), "nearest_training_distance": float(distances[index][0]),
- "nearest_training_similarity": float(1 / (1 + distances[index][0])),
- })
- by_unit = {}
- for unit in sorted({sample["unit"] for sample in samples}):
- indices = [index for index, sample in enumerate(samples) if sample["unit"] == unit]
- by_unit[unit] = metrics(y[indices], predicted[indices], probabilities[indices])
- report = {
- "model_type": args.model_type, "evaluation_set": "sealed_test", "threshold": metadata["threshold"],
- "window_metrics": metrics(y, predicted, probabilities), "event_metrics": event_metrics(samples, predicted),
- "by_unit": by_unit,
- "warning": "Labels originate from current scanner rules unless manually updated; metrics measure held-out rule-label agreement, not confirmed physical-fault diagnosis.",
- }
- output = Path(args.output_dir)
- output.mkdir(parents=True, exist_ok=True)
- with (output / f"{args.model_type}_test_predictions.csv").open("w", encoding="utf-8-sig", newline="") as handle:
- writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
- writer.writeheader()
- writer.writerows(rows)
- (output / f"{args.model_type}_test_evaluation.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
- summary = report["window_metrics"]
- print(f"测试集:{summary['samples']} 窗口(正常 {summary['normal_samples']}、异常 {summary['anomaly_samples']}),阈值 {report['threshold']:.6f}")
- print(f"{'类别':<6}{'查准率':>10}{'召回率':>10}{'F1':>10}{'样本数':>10}")
- for name, label in (("正常", "normal"), ("异常", "anomaly")):
- item = summary["class_metrics"][label]
- print(f"{name:<6}{item['precision']:>10.4f}{item['recall']:>10.4f}{item['f1']:>10.4f}{item['support']:>10}")
- print(
- f"准确率 {summary['accuracy']:.4f} 平衡准确率 {summary['balanced_accuracy']:.4f} "
- f"macro-F1 {summary['macro_f1']:.4f} 加权F1 {summary['weighted_f1']:.4f} "
- f"特异度 {summary['specificity']:.4f} PR-AUC {summary['pr_auc']:.4f}"
- )
- print(json.dumps(report, ensure_ascii=False, indent=2))
- if __name__ == "__main__":
- main()
|