modeling.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. """Generic XGBoost training / evaluation / inference for both model types.
  2. Ports the original generalized model pipeline's training, evaluation and
  3. inference logic. The only model-specific input is the feature module
  4. (``features_pressure`` or ``features_vibration``).
  5. """
  6. from __future__ import annotations
  7. import csv
  8. import json
  9. from collections import defaultdict
  10. from pathlib import Path
  11. import joblib
  12. import numpy as np
  13. from sklearn.impute import SimpleImputer
  14. from sklearn.metrics import average_precision_score, confusion_matrix, precision_recall_curve, precision_recall_fscore_support
  15. from sklearn.neighbors import KNeighborsClassifier, NearestNeighbors
  16. from sklearn.pipeline import Pipeline
  17. from sklearn.preprocessing import StandardScaler
  18. from xgboost import XGBClassifier
  19. from xgboost.callback import TrainingCallback
  20. def load_samples(directory: Path) -> list[dict]:
  21. return [json.loads(path.read_text(encoding="utf-8")) for path in sorted(Path(directory).glob("*.json"))]
  22. def matrix(feature_dicts: list[dict], names: list[str]) -> np.ndarray:
  23. return np.asarray([[item.get(name) for name in names] for item in feature_dicts], dtype=float)
  24. class ProgressCallback(TrainingCallback):
  25. def __init__(self, rounds: int, learning_rate: float) -> None:
  26. self.rounds = rounds
  27. self.learning_rate = learning_rate
  28. def after_iteration(self, model, epoch: int, evals_log):
  29. losses = []
  30. for dataset, metrics in evals_log.items():
  31. for name, values in metrics.items():
  32. if values:
  33. losses.append(f"{dataset}-{name}={values[-1]:.5f}")
  34. print(f"[round {epoch + 1:>4}/{self.rounds}] lr={self.learning_rate:.4f} " + " ".join(losses), flush=True)
  35. return False
  36. def stratified_normal_sample(samples: list[dict], max_ratio: int, seed: int) -> list[dict]:
  37. positives = [sample for sample in samples if sample["label"] == 1]
  38. normals = [sample for sample in samples if sample["label"] == 0]
  39. limit = len(positives) * max_ratio
  40. if len(normals) <= limit:
  41. return samples
  42. buckets = defaultdict(list)
  43. for sample in normals:
  44. month = sample["start_hour"][:7]
  45. buckets[(sample["unit"], sample["batch"], month)].append(sample)
  46. rng = np.random.default_rng(seed)
  47. selected = []
  48. keys = sorted(buckets)
  49. quota, remainder = divmod(limit, len(keys))
  50. for index, key in enumerate(keys):
  51. bucket = buckets[key]
  52. take = min(len(bucket), quota + (1 if index < remainder else 0))
  53. selected.extend(bucket[index] for index in rng.choice(len(bucket), size=take, replace=False))
  54. if len(selected) < limit:
  55. leftovers = [sample for sample in normals if sample not in selected]
  56. need = min(limit - len(selected), len(leftovers))
  57. selected.extend(leftovers[index] for index in rng.choice(len(leftovers), size=need, replace=False))
  58. return positives + selected
  59. def choose_threshold(y: np.ndarray, probabilities: np.ndarray) -> tuple[float, dict]:
  60. precision, recall, thresholds = precision_recall_curve(y, probabilities)
  61. f1 = 2 * precision * recall / np.maximum(precision + recall, 1e-12)
  62. index = int(np.nanargmax(f1[:-1])) if len(thresholds) else 0
  63. threshold = float(thresholds[index]) if len(thresholds) else 0.5
  64. return threshold, {
  65. "threshold_selection": "max_validation_f1",
  66. "threshold": threshold,
  67. "validation_precision": float(precision[index]),
  68. "validation_recall": float(recall[index]),
  69. "validation_f1": float(f1[index]),
  70. "validation_pr_auc": float(average_precision_score(y, probabilities)),
  71. }
  72. def train(model_type: str, feature_module, dataset_dir: Path, models_dir: Path, seed: int, rounds: int,
  73. early_stopping_rounds: int, all_normal: bool, normal_ratio: int | None) -> dict:
  74. ratio = normal_ratio if normal_ratio is not None else (4 if model_type == "pressure" else 5)
  75. dataset = Path(dataset_dir)
  76. train_all = load_samples(dataset / "train")
  77. validation = load_samples(dataset / "validation")
  78. if not train_all or not validation:
  79. raise RuntimeError("训练或验证样本为空")
  80. if not any(sample["label"] == 1 for sample in train_all) or not any(sample["label"] == 1 for sample in validation):
  81. raise RuntimeError("训练和验证集都必须包含异常样本")
  82. train = train_all if all_normal else stratified_normal_sample(train_all, ratio, seed)
  83. baselines = feature_module.compute_unit_baselines(train_all)
  84. names = feature_module.feature_names()
  85. x_train, y_train = matrix(feature_module.feature_matrix(train, baselines), names), np.asarray([sample["label"] for sample in train])
  86. x_validation, y_validation = matrix(feature_module.feature_matrix(validation, baselines), names), np.asarray([sample["label"] for sample in validation])
  87. imputer = SimpleImputer(strategy="median")
  88. x_train = imputer.fit_transform(x_train)
  89. x_validation = imputer.transform(x_validation)
  90. scale_pos_weight = float(np.sum(y_train == 0) / max(np.sum(y_train == 1), 1))
  91. learning_rate = 0.05
  92. print(f"training: rounds={rounds}, learning_rate={learning_rate:.4f}, early_stopping_rounds={early_stopping_rounds}, samples={len(train):,}", flush=True)
  93. model = XGBClassifier(
  94. n_estimators=rounds, max_depth=3, learning_rate=learning_rate,
  95. subsample=0.8, colsample_bytree=0.8, min_child_weight=3,
  96. objective="binary:logistic", eval_metric="logloss", scale_pos_weight=scale_pos_weight,
  97. random_state=seed, n_jobs=4, early_stopping_rounds=early_stopping_rounds,
  98. callbacks=[ProgressCallback(rounds, learning_rate)],
  99. )
  100. model.fit(x_train, y_train, eval_set=[(x_train, y_train), (x_validation, y_validation)], verbose=False)
  101. rounds_run = len(model.evals_result()["validation_0"]["logloss"])
  102. stopped_early = rounds_run < rounds
  103. print(f"training complete: rounds_run={rounds_run}/{rounds}, best_iteration={model.best_iteration + 1}, best_validation_logloss={float(model.best_score):.5f}, early_stopped={'yes' if stopped_early else 'no'}", flush=True)
  104. validation_probabilities = model.predict_proba(x_validation)[:, 1]
  105. threshold, validation_metrics = choose_threshold(y_validation, validation_probabilities)
  106. similarity = Pipeline([("scale", StandardScaler()), ("knn", KNeighborsClassifier(n_neighbors=min(5, len(train)), weights="distance"))])
  107. similarity.fit(x_train, y_train)
  108. models = Path(models_dir)
  109. models.mkdir(parents=True, exist_ok=True)
  110. model.callbacks = []
  111. joblib.dump(
  112. {"model": model, "imputer": imputer, "knn": similarity, "feature_names": names, "unit_baselines": baselines},
  113. models / f"{model_type}_model.joblib",
  114. )
  115. (models / f"{model_type}_unit_baselines.json").write_text(json.dumps(baselines, ensure_ascii=False, indent=2), encoding="utf-8")
  116. metadata = {
  117. "model_type": model_type, "algorithm": "XGBoost binary classifier + KNN similarity",
  118. "feature_version": "generalized_cycle_relative_v2",
  119. "feature_names": names,
  120. "dropped_absolute_features": list(feature_module.ABSOLUTE_FEATURES),
  121. "unit_baselines": baselines,
  122. "normal_sampling_strategy": "all_normal" if all_normal else "stratified_max_ratio",
  123. "normal_to_anomaly_training_ratio_limit": None if all_normal else ratio,
  124. "actual_normal_to_anomaly_training_ratio": float(np.sum(y_train == 0) / max(np.sum(y_train == 1), 1)),
  125. "training_samples_before_sampling": len(train_all), "training_samples_after_sampling": len(train),
  126. "training_normal": int(np.sum(y_train == 0)), "training_anomaly": int(np.sum(y_train == 1)),
  127. "scale_pos_weight": scale_pos_weight, "validation_samples": len(validation),
  128. "test_set_read": False, **validation_metrics,
  129. "rounds_requested": rounds, "early_stopping_rounds_requested": early_stopping_rounds,
  130. "rounds_run": rounds_run, "early_stopped": stopped_early,
  131. "best_iteration": int(model.best_iteration), "best_score": float(model.best_score),
  132. }
  133. (models / f"{model_type}_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
  134. print(json.dumps(metadata, ensure_ascii=False, indent=2))
  135. return metadata
  136. def metrics(y: np.ndarray, predicted: np.ndarray, probabilities: np.ndarray) -> dict:
  137. labels = [0, 1]
  138. per_precision, per_recall, per_f1, per_support = precision_recall_fscore_support(y, predicted, labels=labels, zero_division=0)
  139. tn, fp, fn, tp = confusion_matrix(y, predicted, labels=labels).ravel()
  140. samples = int(len(y))
  141. normal_support, anomaly_support = int(per_support[0]), int(per_support[1])
  142. specificity = float(tn / (tn + fp)) if (tn + fp) else 0.0
  143. accuracy = float((tp + tn) / samples) if samples else 0.0
  144. return {
  145. "samples": samples, "normal_samples": normal_support, "anomaly_samples": anomaly_support,
  146. "tp": int(tp), "fp": int(fp), "fn": int(fn), "tn": int(tn),
  147. "precision": float(per_precision[1]), "recall": float(per_recall[1]), "f1": float(per_f1[1]),
  148. "pr_auc": float(average_precision_score(y, probabilities)) if len(set(y)) == 2 else None,
  149. "specificity": specificity, "accuracy": accuracy,
  150. "balanced_accuracy": float((per_recall[0] + per_recall[1]) / 2),
  151. "macro_f1": float((per_f1[0] + per_f1[1]) / 2),
  152. "weighted_f1": float((per_f1[0] * normal_support + per_f1[1] * anomaly_support) / samples) if samples else 0.0,
  153. "class_metrics": {
  154. "normal": {"precision": float(per_precision[0]), "recall": float(per_recall[0]), "f1": float(per_f1[0]), "support": normal_support},
  155. "anomaly": {"precision": float(per_precision[1]), "recall": float(per_recall[1]), "f1": float(per_f1[1]), "support": anomaly_support},
  156. },
  157. "confusion_matrix_counts": {"tn": int(tn), "fp": int(fp), "fn": int(fn), "tp": int(tp)},
  158. "confusion_matrix_normalized": {
  159. "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},
  160. "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},
  161. },
  162. }
  163. def event_metrics(samples: list[dict], predicted: np.ndarray) -> dict:
  164. groups = defaultdict(list)
  165. for sample, value in zip(samples, predicted):
  166. groups[sample["event_group_id"]].append((sample, int(value)))
  167. positives = [values for values in groups.values() if values[0][0]["label"] == 1]
  168. negatives = [values for values in groups.values() if values[0][0]["label"] == 0]
  169. tp = sum(any(value for _, value in values) for values in positives)
  170. fn = len(positives) - tp
  171. fp = sum(any(value for _, value in values) for values in negatives)
  172. return {
  173. "anomaly_events": len(positives), "normal_events": len(negatives),
  174. "detected_anomaly_events": tp, "missed_anomaly_events": fn, "false_positive_normal_events": fp,
  175. "event_recall": tp / len(positives) if positives else None,
  176. }
  177. def evaluate(model_type: str, feature_module, dataset_dir: Path, models_dir: Path, output_dir: Path) -> dict:
  178. bundle = joblib.load(Path(models_dir) / f"{model_type}_model.joblib")
  179. metadata = json.loads((Path(models_dir) / f"{model_type}_metadata.json").read_text(encoding="utf-8"))
  180. samples = load_samples(Path(dataset_dir) / "test")
  181. if not samples or not any(sample["label"] == 1 for sample in samples):
  182. raise RuntimeError("封存测试集必须包含至少一个异常样本")
  183. x = bundle["imputer"].transform(matrix(feature_module.feature_matrix(samples, bundle["unit_baselines"]), bundle["feature_names"]))
  184. y = np.asarray([sample["label"] for sample in samples])
  185. probabilities = bundle["model"].predict_proba(x)[:, 1]
  186. predicted = (probabilities >= metadata["threshold"]).astype(int)
  187. distances, neighbors = bundle["knn"].named_steps["knn"].kneighbors(bundle["knn"].named_steps["scale"].transform(x), n_neighbors=1)
  188. rows = []
  189. for index, sample in enumerate(samples):
  190. rows.append({
  191. "sample_id": sample["sample_id"], "unit": sample["unit"], "batch": sample["batch"],
  192. "event_group_id": sample["event_group_id"], "start_hour": sample["start_hour"], "end_hour": sample["end_hour"],
  193. "true_label": sample["label"], "anomaly_probability": probabilities[index], "predicted_label": int(predicted[index]),
  194. "nearest_training_sample_index": int(neighbors[index][0]), "nearest_training_distance": float(distances[index][0]),
  195. "nearest_training_similarity": float(1 / (1 + distances[index][0])),
  196. })
  197. by_unit = {}
  198. for unit in sorted({sample["unit"] for sample in samples}):
  199. indices = [index for index, sample in enumerate(samples) if sample["unit"] == unit]
  200. by_unit[unit] = metrics(y[indices], predicted[indices], probabilities[indices])
  201. report = {
  202. "model_type": model_type, "evaluation_set": "sealed_test", "threshold": metadata["threshold"],
  203. "window_metrics": metrics(y, predicted, probabilities), "event_metrics": event_metrics(samples, predicted),
  204. "by_unit": by_unit,
  205. "warning": "Labels originate from current scanner rules unless manually updated; metrics measure held-out rule-label agreement, not confirmed physical-fault diagnosis.",
  206. }
  207. output = Path(output_dir)
  208. output.mkdir(parents=True, exist_ok=True)
  209. with (output / f"{model_type}_test_predictions.csv").open("w", encoding="utf-8-sig", newline="") as handle:
  210. writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
  211. writer.writeheader()
  212. writer.writerows(rows)
  213. (output / f"{model_type}_test_evaluation.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
  214. return report
  215. def combined_result(rule_label: int, probability: float, anomaly_similarity: float, threshold: float) -> str:
  216. model_support = probability >= threshold or anomaly_similarity >= 0.70
  217. if rule_label == 1 and model_support:
  218. return "模型辅助确认异常"
  219. if rule_label == 1:
  220. return "保留规则异常,模型意见不一致,人工复核"
  221. if model_support:
  222. return "规则正常但模型疑似异常,继续观察"
  223. return "正常"
  224. def infer(model_type: str, feature_module, dataset_dir: Path, models_dir: Path, output_dir: Path) -> dict:
  225. dataset = Path(dataset_dir)
  226. bundle = joblib.load(Path(models_dir) / f"{model_type}_model.joblib")
  227. metadata = json.loads((Path(models_dir) / f"{model_type}_metadata.json").read_text(encoding="utf-8"))
  228. samples = load_samples(dataset / "candidates")
  229. training = load_samples(dataset / "train")
  230. baselines = bundle["unit_baselines"]
  231. x = bundle["imputer"].transform(matrix(feature_module.feature_matrix(samples, baselines), bundle["feature_names"]))
  232. x_train = bundle["imputer"].transform(matrix(feature_module.feature_matrix(training, baselines), bundle["feature_names"]))
  233. probabilities = bundle["model"].predict_proba(x)[:, 1]
  234. scaled = bundle["knn"].named_steps["scale"].transform(x)
  235. train_scaled = bundle["knn"].named_steps["scale"].transform(x_train)
  236. anomaly_indexes = [index for index, sample in enumerate(training) if sample["label"] == 1]
  237. if not anomaly_indexes:
  238. raise RuntimeError("训练集没有异常样本,无法建立异常相似性索引")
  239. anomaly_neighbors = NearestNeighbors(n_neighbors=1).fit(train_scaled[anomaly_indexes])
  240. distances, nearest_indexes = anomaly_neighbors.kneighbors(scaled)
  241. rows = []
  242. for index, sample in enumerate(samples):
  243. nearest = training[anomaly_indexes[int(nearest_indexes[index][0])]]
  244. anomaly_similarity = float(1 / (1 + distances[index][0]))
  245. rule_label = int(sample["label"])
  246. rows.append({
  247. "机组": sample["unit"], "批次": sample["batch"], "窗口开始小时": sample["start_hour"], "窗口结束小时": sample["end_hour"],
  248. "样本编号": sample["sample_id"], "现有规则结果": "异常" if rule_label else "正常",
  249. "规则阶段编号": sample.get("rule_stage_id") or "", "模型异常概率": round(float(probabilities[index]), 6),
  250. "最相似历史异常样本": nearest["sample_id"], "历史异常相似度": round(anomaly_similarity, 6),
  251. "联合判断": combined_result(rule_label, probabilities[index], anomaly_similarity, metadata["threshold"]),
  252. })
  253. rows.sort(key=lambda row: (int(row["机组"][0]), row["窗口结束小时"], row["批次"]))
  254. output = Path(output_dir)
  255. output.mkdir(parents=True, exist_ok=True)
  256. name = "压力模型辅助确认.csv" if model_type == "pressure" else "振动模型辅助确认.csv"
  257. with (output / name).open("w", encoding="utf-8-sig", newline="") as handle:
  258. writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
  259. writer.writeheader()
  260. writer.writerows(rows)
  261. return {"rows": len(rows), "output": str(output / name)}
  262. __all__ = [
  263. "load_samples", "matrix", "train", "evaluate", "infer", "metrics", "event_metrics", "choose_threshold",
  264. ]