| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194 |
- #!/usr/bin/env python3
- """Train one XGBoost PKS assistant model without reading the test set."""
- from __future__ import annotations
- import argparse
- import importlib
- import json
- from collections import defaultdict
- from pathlib import Path
- import joblib
- import numpy as np
- from sklearn.impute import SimpleImputer
- from sklearn.metrics import average_precision_score, precision_recall_curve
- from sklearn.neighbors import KNeighborsClassifier
- from sklearn.pipeline import Pipeline
- from sklearn.preprocessing import StandardScaler
- from xgboost import XGBClassifier
- from xgboost.callback import TrainingCallback
- 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"
- )
- class ProgressCallback(TrainingCallback):
- """Print round, learning rate, and train/validation loss for each boosting round."""
- def __init__(self, rounds: int, learning_rate: float) -> None:
- self.rounds = rounds
- self.learning_rate = learning_rate
- def after_iteration(self, model, epoch: int, evals_log):
- losses = []
- for dataset, metrics in evals_log.items():
- for name, values in metrics.items():
- if values:
- losses.append(f"{dataset}-{name}={values[-1]:.5f}")
- print(
- f"[round {epoch + 1:>4}/{self.rounds}] lr={self.learning_rate:.4f} " + " ".join(losses),
- flush=True,
- )
- return False
- 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 stratified_normal_sample(samples: list[dict], max_ratio: int, seed: int) -> list[dict]:
- positives = [sample for sample in samples if sample["label"] == 1]
- normals = [sample for sample in samples if sample["label"] == 0]
- limit = len(positives) * max_ratio
- if len(normals) <= limit:
- return samples
- buckets = defaultdict(list)
- for sample in normals:
- month = sample["start_hour"][:7]
- buckets[(sample["unit"], sample["batch"], month)].append(sample)
- rng = np.random.default_rng(seed)
- selected = []
- # Round-robin quotas preserve unit/month coverage rather than favoring the
- # longest uninterrupted running cycle.
- keys = sorted(buckets)
- quota, remainder = divmod(limit, len(keys))
- for index, key in enumerate(keys):
- bucket = buckets[key]
- take = min(len(bucket), quota + (1 if index < remainder else 0))
- selected.extend(bucket[index] for index in rng.choice(len(bucket), size=take, replace=False))
- if len(selected) < limit:
- leftovers = [sample for sample in normals if sample not in selected]
- need = min(limit - len(selected), len(leftovers))
- selected.extend(leftovers[index] for index in rng.choice(len(leftovers), size=need, replace=False))
- return positives + selected
- def choose_threshold(y: np.ndarray, probabilities: np.ndarray) -> tuple[float, dict]:
- precision, recall, thresholds = precision_recall_curve(y, probabilities)
- f1 = 2 * precision * recall / np.maximum(precision + recall, 1e-12)
- index = int(np.nanargmax(f1[:-1])) if len(thresholds) else 0
- threshold = float(thresholds[index]) if len(thresholds) else 0.5
- return threshold, {
- "threshold_selection": "max_validation_f1",
- "threshold": threshold,
- "validation_precision": float(precision[index]),
- "validation_recall": float(recall[index]),
- "validation_f1": float(f1[index]),
- "validation_pr_auc": float(average_precision_score(y, probabilities)),
- }
- 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"))
- sampling = parser.add_mutually_exclusive_group()
- sampling.add_argument("--normal-ratio", type=int, default=None)
- sampling.add_argument("--all-normal", action="store_true", help="使用全部正常训练样本,不下采样、不复制")
- parser.add_argument("--seed", type=int, default=20260921)
- parser.add_argument("--rounds", type=int, default=250)
- parser.add_argument("--early-stopping-rounds", type=int, default=30)
- args = parser.parse_args()
- if args.normal_ratio is not None and args.normal_ratio < 1:
- parser.error("--normal-ratio 必须大于等于 1")
- if args.rounds < 1 or args.early_stopping_rounds < 1:
- parser.error("--rounds 和 --early-stopping-rounds 必须大于等于 1")
- ratio = args.normal_ratio if args.normal_ratio is not None else (4 if args.model_type == "pressure" else 5)
- gf = feature_module(args.model_type)
- dataset = Path(args.datasets_dir) / args.model_type
- train_all = load_samples(dataset / "train")
- validation = load_samples(dataset / "validation")
- if not train_all or not validation:
- parser.error("训练或验证样本为空")
- if not any(sample["label"] == 1 for sample in train_all) or not any(sample["label"] == 1 for sample in validation):
- parser.error("训练和验证集都必须包含异常样本")
- train = train_all if args.all_normal else stratified_normal_sample(train_all, ratio, args.seed)
- baselines = gf.compute_unit_baselines(train_all)
- names = gf.feature_names()
- x_train, y_train = matrix(gf.feature_matrix(train, baselines), names), np.asarray([sample["label"] for sample in train])
- x_validation, y_validation = matrix(gf.feature_matrix(validation, baselines), names), np.asarray([sample["label"] for sample in validation])
- imputer = SimpleImputer(strategy="median")
- x_train = imputer.fit_transform(x_train)
- x_validation = imputer.transform(x_validation)
- scale_pos_weight = float(np.sum(y_train == 0) / max(np.sum(y_train == 1), 1))
- learning_rate = 0.05
- print(
- f"training: rounds={args.rounds}, learning_rate={learning_rate:.4f}, "
- f"early_stopping_rounds={args.early_stopping_rounds}, samples={len(train):,}",
- flush=True,
- )
- model = XGBClassifier(
- n_estimators=args.rounds, max_depth=3, learning_rate=learning_rate,
- subsample=0.8, colsample_bytree=0.8, min_child_weight=3,
- objective="binary:logistic", eval_metric="logloss", scale_pos_weight=scale_pos_weight,
- random_state=args.seed, n_jobs=4, early_stopping_rounds=args.early_stopping_rounds,
- callbacks=[ProgressCallback(args.rounds, learning_rate)],
- )
- eval_set = [(x_train, y_train), (x_validation, y_validation)]
- model.fit(x_train, y_train, eval_set=eval_set, verbose=False)
- rounds_run = len(model.evals_result()["validation_0"]["logloss"])
- stopped_early = rounds_run < args.rounds
- print(
- f"training complete: rounds_run={rounds_run}/{args.rounds}, "
- f"best_iteration={model.best_iteration + 1}, best_validation_logloss={float(model.best_score):.5f}, "
- f"early_stopped={'yes' if stopped_early else 'no'}",
- flush=True,
- )
- validation_probabilities = model.predict_proba(x_validation)[:, 1]
- threshold, validation_metrics = choose_threshold(y_validation, validation_probabilities)
- similarity = Pipeline([("scale", StandardScaler()), ("knn", KNeighborsClassifier(n_neighbors=min(5, len(train)), weights="distance"))])
- similarity.fit(x_train, y_train)
- models = Path(args.models_dir)
- models.mkdir(parents=True, exist_ok=True)
- # The progress callback is defined in this script (module __main__); drop it
- # before serialization so the artifact can be loaded by other entry points.
- model.callbacks = []
- joblib.dump(
- {"model": model, "imputer": imputer, "knn": similarity, "feature_names": names, "unit_baselines": baselines},
- models / f"{args.model_type}_model.joblib",
- )
- (models / f"{args.model_type}_unit_baselines.json").write_text(
- json.dumps(baselines, ensure_ascii=False, indent=2), encoding="utf-8"
- )
- metadata = {
- "model_type": args.model_type, "algorithm": "XGBoost binary classifier + KNN similarity",
- "feature_version": "generalized_cycle_relative_v2",
- "feature_names": names,
- "dropped_absolute_features": list(gf.ABSOLUTE_FEATURES),
- "unit_baselines": baselines,
- "normal_sampling_strategy": "all_normal" if args.all_normal else "stratified_max_ratio",
- "normal_to_anomaly_training_ratio_limit": None if args.all_normal else ratio,
- "actual_normal_to_anomaly_training_ratio": float(np.sum(y_train == 0) / max(np.sum(y_train == 1), 1)),
- "training_samples_before_sampling": len(train_all), "training_samples_after_sampling": len(train),
- "training_normal": int(np.sum(y_train == 0)), "training_anomaly": int(np.sum(y_train == 1)),
- "scale_pos_weight": scale_pos_weight, "validation_samples": len(validation),
- "test_set_read": False, **validation_metrics,
- "rounds_requested": args.rounds, "early_stopping_rounds_requested": args.early_stopping_rounds,
- "rounds_run": rounds_run, "early_stopped": stopped_early,
- "best_iteration": int(model.best_iteration), "best_score": float(model.best_score),
- }
- (models / f"{args.model_type}_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
- print(json.dumps(metadata, ensure_ascii=False, indent=2))
- if __name__ == "__main__":
- main()
|