train_model.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. #!/usr/bin/env python3
  2. """Train one XGBoost PKS assistant model without reading the test set."""
  3. from __future__ import annotations
  4. import argparse
  5. import importlib
  6. import json
  7. from collections import defaultdict
  8. from pathlib import Path
  9. import joblib
  10. import numpy as np
  11. from sklearn.impute import SimpleImputer
  12. from sklearn.metrics import average_precision_score, precision_recall_curve
  13. from sklearn.neighbors import KNeighborsClassifier
  14. from sklearn.pipeline import Pipeline
  15. from sklearn.preprocessing import StandardScaler
  16. from xgboost import XGBClassifier
  17. from xgboost.callback import TrainingCallback
  18. def feature_module(model_type: str):
  19. """Pressure and vibration use independent feature modules."""
  20. return importlib.import_module(
  21. "generalized_features" if model_type == "pressure" else "generalized_features_vibration"
  22. )
  23. class ProgressCallback(TrainingCallback):
  24. """Print round, learning rate, and train/validation loss for each boosting round."""
  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(
  35. f"[round {epoch + 1:>4}/{self.rounds}] lr={self.learning_rate:.4f} " + " ".join(losses),
  36. flush=True,
  37. )
  38. return False
  39. def load_samples(directory: Path) -> list[dict]:
  40. return [json.loads(path.read_text(encoding="utf-8")) for path in sorted(directory.glob("*.json"))]
  41. def matrix(feature_dicts: list[dict], names: list[str]) -> np.ndarray:
  42. return np.asarray([[item.get(name) for name in names] for item in feature_dicts], dtype=float)
  43. def stratified_normal_sample(samples: list[dict], max_ratio: int, seed: int) -> list[dict]:
  44. positives = [sample for sample in samples if sample["label"] == 1]
  45. normals = [sample for sample in samples if sample["label"] == 0]
  46. limit = len(positives) * max_ratio
  47. if len(normals) <= limit:
  48. return samples
  49. buckets = defaultdict(list)
  50. for sample in normals:
  51. month = sample["start_hour"][:7]
  52. buckets[(sample["unit"], sample["batch"], month)].append(sample)
  53. rng = np.random.default_rng(seed)
  54. selected = []
  55. # Round-robin quotas preserve unit/month coverage rather than favoring the
  56. # longest uninterrupted running cycle.
  57. keys = sorted(buckets)
  58. quota, remainder = divmod(limit, len(keys))
  59. for index, key in enumerate(keys):
  60. bucket = buckets[key]
  61. take = min(len(bucket), quota + (1 if index < remainder else 0))
  62. selected.extend(bucket[index] for index in rng.choice(len(bucket), size=take, replace=False))
  63. if len(selected) < limit:
  64. leftovers = [sample for sample in normals if sample not in selected]
  65. need = min(limit - len(selected), len(leftovers))
  66. selected.extend(leftovers[index] for index in rng.choice(len(leftovers), size=need, replace=False))
  67. return positives + selected
  68. def choose_threshold(y: np.ndarray, probabilities: np.ndarray) -> tuple[float, dict]:
  69. precision, recall, thresholds = precision_recall_curve(y, probabilities)
  70. f1 = 2 * precision * recall / np.maximum(precision + recall, 1e-12)
  71. index = int(np.nanargmax(f1[:-1])) if len(thresholds) else 0
  72. threshold = float(thresholds[index]) if len(thresholds) else 0.5
  73. return threshold, {
  74. "threshold_selection": "max_validation_f1",
  75. "threshold": threshold,
  76. "validation_precision": float(precision[index]),
  77. "validation_recall": float(recall[index]),
  78. "validation_f1": float(f1[index]),
  79. "validation_pr_auc": float(average_precision_score(y, probabilities)),
  80. }
  81. def main() -> None:
  82. parser = argparse.ArgumentParser(description="训练 PKS 压力或振动模型,不读取测试集")
  83. parser.add_argument("model_type", choices=("pressure", "vibration"))
  84. parser.add_argument("--datasets-dir", default=str(Path(__file__).resolve().parent / "datasets"))
  85. parser.add_argument("--models-dir", default=str(Path(__file__).resolve().parent / "models"))
  86. sampling = parser.add_mutually_exclusive_group()
  87. sampling.add_argument("--normal-ratio", type=int, default=None)
  88. sampling.add_argument("--all-normal", action="store_true", help="使用全部正常训练样本,不下采样、不复制")
  89. parser.add_argument("--seed", type=int, default=20260921)
  90. parser.add_argument("--rounds", type=int, default=250)
  91. parser.add_argument("--early-stopping-rounds", type=int, default=30)
  92. args = parser.parse_args()
  93. if args.normal_ratio is not None and args.normal_ratio < 1:
  94. parser.error("--normal-ratio 必须大于等于 1")
  95. if args.rounds < 1 or args.early_stopping_rounds < 1:
  96. parser.error("--rounds 和 --early-stopping-rounds 必须大于等于 1")
  97. ratio = args.normal_ratio if args.normal_ratio is not None else (4 if args.model_type == "pressure" else 5)
  98. gf = feature_module(args.model_type)
  99. dataset = Path(args.datasets_dir) / args.model_type
  100. train_all = load_samples(dataset / "train")
  101. validation = load_samples(dataset / "validation")
  102. if not train_all or not validation:
  103. parser.error("训练或验证样本为空")
  104. if not any(sample["label"] == 1 for sample in train_all) or not any(sample["label"] == 1 for sample in validation):
  105. parser.error("训练和验证集都必须包含异常样本")
  106. train = train_all if args.all_normal else stratified_normal_sample(train_all, ratio, args.seed)
  107. baselines = gf.compute_unit_baselines(train_all)
  108. names = gf.feature_names()
  109. x_train, y_train = matrix(gf.feature_matrix(train, baselines), names), np.asarray([sample["label"] for sample in train])
  110. x_validation, y_validation = matrix(gf.feature_matrix(validation, baselines), names), np.asarray([sample["label"] for sample in validation])
  111. imputer = SimpleImputer(strategy="median")
  112. x_train = imputer.fit_transform(x_train)
  113. x_validation = imputer.transform(x_validation)
  114. scale_pos_weight = float(np.sum(y_train == 0) / max(np.sum(y_train == 1), 1))
  115. learning_rate = 0.05
  116. print(
  117. f"training: rounds={args.rounds}, learning_rate={learning_rate:.4f}, "
  118. f"early_stopping_rounds={args.early_stopping_rounds}, samples={len(train):,}",
  119. flush=True,
  120. )
  121. model = XGBClassifier(
  122. n_estimators=args.rounds, max_depth=3, learning_rate=learning_rate,
  123. subsample=0.8, colsample_bytree=0.8, min_child_weight=3,
  124. objective="binary:logistic", eval_metric="logloss", scale_pos_weight=scale_pos_weight,
  125. random_state=args.seed, n_jobs=4, early_stopping_rounds=args.early_stopping_rounds,
  126. callbacks=[ProgressCallback(args.rounds, learning_rate)],
  127. )
  128. eval_set = [(x_train, y_train), (x_validation, y_validation)]
  129. model.fit(x_train, y_train, eval_set=eval_set, verbose=False)
  130. rounds_run = len(model.evals_result()["validation_0"]["logloss"])
  131. stopped_early = rounds_run < args.rounds
  132. print(
  133. f"training complete: rounds_run={rounds_run}/{args.rounds}, "
  134. f"best_iteration={model.best_iteration + 1}, best_validation_logloss={float(model.best_score):.5f}, "
  135. f"early_stopped={'yes' if stopped_early else 'no'}",
  136. flush=True,
  137. )
  138. validation_probabilities = model.predict_proba(x_validation)[:, 1]
  139. threshold, validation_metrics = choose_threshold(y_validation, validation_probabilities)
  140. similarity = Pipeline([("scale", StandardScaler()), ("knn", KNeighborsClassifier(n_neighbors=min(5, len(train)), weights="distance"))])
  141. similarity.fit(x_train, y_train)
  142. models = Path(args.models_dir)
  143. models.mkdir(parents=True, exist_ok=True)
  144. # The progress callback is defined in this script (module __main__); drop it
  145. # before serialization so the artifact can be loaded by other entry points.
  146. model.callbacks = []
  147. joblib.dump(
  148. {"model": model, "imputer": imputer, "knn": similarity, "feature_names": names, "unit_baselines": baselines},
  149. models / f"{args.model_type}_model.joblib",
  150. )
  151. (models / f"{args.model_type}_unit_baselines.json").write_text(
  152. json.dumps(baselines, ensure_ascii=False, indent=2), encoding="utf-8"
  153. )
  154. metadata = {
  155. "model_type": args.model_type, "algorithm": "XGBoost binary classifier + KNN similarity",
  156. "feature_version": "generalized_cycle_relative_v2",
  157. "feature_names": names,
  158. "dropped_absolute_features": list(gf.ABSOLUTE_FEATURES),
  159. "unit_baselines": baselines,
  160. "normal_sampling_strategy": "all_normal" if args.all_normal else "stratified_max_ratio",
  161. "normal_to_anomaly_training_ratio_limit": None if args.all_normal else ratio,
  162. "actual_normal_to_anomaly_training_ratio": float(np.sum(y_train == 0) / max(np.sum(y_train == 1), 1)),
  163. "training_samples_before_sampling": len(train_all), "training_samples_after_sampling": len(train),
  164. "training_normal": int(np.sum(y_train == 0)), "training_anomaly": int(np.sum(y_train == 1)),
  165. "scale_pos_weight": scale_pos_weight, "validation_samples": len(validation),
  166. "test_set_read": False, **validation_metrics,
  167. "rounds_requested": args.rounds, "early_stopping_rounds_requested": args.early_stopping_rounds,
  168. "rounds_run": rounds_run, "early_stopped": stopped_early,
  169. "best_iteration": int(model.best_iteration), "best_score": float(model.best_score),
  170. }
  171. (models / f"{args.model_type}_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
  172. print(json.dumps(metadata, ensure_ascii=False, indent=2))
  173. if __name__ == "__main__":
  174. main()