| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292 |
- """Generic XGBoost training / evaluation / inference for both model types.
- Ports the original generalized model pipeline's training, evaluation and
- inference logic. The only model-specific input is the feature module
- (``features_pressure`` or ``features_vibration``).
- """
- from __future__ import annotations
- import csv
- 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, confusion_matrix, precision_recall_curve, precision_recall_fscore_support
- from sklearn.neighbors import KNeighborsClassifier, NearestNeighbors
- from sklearn.pipeline import Pipeline
- from sklearn.preprocessing import StandardScaler
- from xgboost import XGBClassifier
- from xgboost.callback import TrainingCallback
- def load_samples(directory: Path) -> list[dict]:
- return [json.loads(path.read_text(encoding="utf-8")) for path in sorted(Path(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)
- class ProgressCallback(TrainingCallback):
- 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 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 = []
- 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 train(model_type: str, feature_module, dataset_dir: Path, models_dir: Path, seed: int, rounds: int,
- early_stopping_rounds: int, all_normal: bool, normal_ratio: int | None) -> dict:
- ratio = normal_ratio if normal_ratio is not None else (4 if model_type == "pressure" else 5)
- dataset = Path(dataset_dir)
- train_all = load_samples(dataset / "train")
- validation = load_samples(dataset / "validation")
- if not train_all or not validation:
- raise RuntimeError("训练或验证样本为空")
- if not any(sample["label"] == 1 for sample in train_all) or not any(sample["label"] == 1 for sample in validation):
- raise RuntimeError("训练和验证集都必须包含异常样本")
- train = train_all if all_normal else stratified_normal_sample(train_all, ratio, seed)
- baselines = feature_module.compute_unit_baselines(train_all)
- names = feature_module.feature_names()
- x_train, y_train = matrix(feature_module.feature_matrix(train, baselines), names), np.asarray([sample["label"] for sample in train])
- x_validation, y_validation = matrix(feature_module.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={rounds}, learning_rate={learning_rate:.4f}, early_stopping_rounds={early_stopping_rounds}, samples={len(train):,}", flush=True)
- model = XGBClassifier(
- n_estimators=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=seed, n_jobs=4, early_stopping_rounds=early_stopping_rounds,
- callbacks=[ProgressCallback(rounds, learning_rate)],
- )
- model.fit(x_train, y_train, eval_set=[(x_train, y_train), (x_validation, y_validation)], verbose=False)
- rounds_run = len(model.evals_result()["validation_0"]["logloss"])
- stopped_early = rounds_run < rounds
- 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)
- 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(models_dir)
- models.mkdir(parents=True, exist_ok=True)
- model.callbacks = []
- joblib.dump(
- {"model": model, "imputer": imputer, "knn": similarity, "feature_names": names, "unit_baselines": baselines},
- models / f"{model_type}_model.joblib",
- )
- (models / f"{model_type}_unit_baselines.json").write_text(json.dumps(baselines, ensure_ascii=False, indent=2), encoding="utf-8")
- metadata = {
- "model_type": model_type, "algorithm": "XGBoost binary classifier + KNN similarity",
- "feature_version": "generalized_cycle_relative_v2",
- "feature_names": names,
- "dropped_absolute_features": list(feature_module.ABSOLUTE_FEATURES),
- "unit_baselines": baselines,
- "normal_sampling_strategy": "all_normal" if all_normal else "stratified_max_ratio",
- "normal_to_anomaly_training_ratio_limit": None if 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": rounds, "early_stopping_rounds_requested": 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"{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))
- return metadata
- 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 evaluate(model_type: str, feature_module, dataset_dir: Path, models_dir: Path, output_dir: Path) -> dict:
- bundle = joblib.load(Path(models_dir) / f"{model_type}_model.joblib")
- metadata = json.loads((Path(models_dir) / f"{model_type}_metadata.json").read_text(encoding="utf-8"))
- samples = load_samples(Path(dataset_dir) / "test")
- if not samples or not any(sample["label"] == 1 for sample in samples):
- raise RuntimeError("封存测试集必须包含至少一个异常样本")
- x = bundle["imputer"].transform(matrix(feature_module.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": 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(output_dir)
- output.mkdir(parents=True, exist_ok=True)
- with (output / f"{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"{model_type}_test_evaluation.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
- return report
- def combined_result(rule_label: int, probability: float, anomaly_similarity: float, threshold: float) -> str:
- model_support = probability >= threshold or anomaly_similarity >= 0.70
- if rule_label == 1 and model_support:
- return "模型辅助确认异常"
- if rule_label == 1:
- return "保留规则异常,模型意见不一致,人工复核"
- if model_support:
- return "规则正常但模型疑似异常,继续观察"
- return "正常"
- def infer(model_type: str, feature_module, dataset_dir: Path, models_dir: Path, output_dir: Path) -> dict:
- dataset = Path(dataset_dir)
- bundle = joblib.load(Path(models_dir) / f"{model_type}_model.joblib")
- metadata = json.loads((Path(models_dir) / f"{model_type}_metadata.json").read_text(encoding="utf-8"))
- samples = load_samples(dataset / "candidates")
- training = load_samples(dataset / "train")
- baselines = bundle["unit_baselines"]
- x = bundle["imputer"].transform(matrix(feature_module.feature_matrix(samples, baselines), bundle["feature_names"]))
- x_train = bundle["imputer"].transform(matrix(feature_module.feature_matrix(training, baselines), bundle["feature_names"]))
- probabilities = bundle["model"].predict_proba(x)[:, 1]
- scaled = bundle["knn"].named_steps["scale"].transform(x)
- train_scaled = bundle["knn"].named_steps["scale"].transform(x_train)
- anomaly_indexes = [index for index, sample in enumerate(training) if sample["label"] == 1]
- if not anomaly_indexes:
- raise RuntimeError("训练集没有异常样本,无法建立异常相似性索引")
- anomaly_neighbors = NearestNeighbors(n_neighbors=1).fit(train_scaled[anomaly_indexes])
- distances, nearest_indexes = anomaly_neighbors.kneighbors(scaled)
- rows = []
- for index, sample in enumerate(samples):
- nearest = training[anomaly_indexes[int(nearest_indexes[index][0])]]
- anomaly_similarity = float(1 / (1 + distances[index][0]))
- rule_label = int(sample["label"])
- rows.append({
- "机组": sample["unit"], "批次": sample["batch"], "窗口开始小时": sample["start_hour"], "窗口结束小时": sample["end_hour"],
- "样本编号": sample["sample_id"], "现有规则结果": "异常" if rule_label else "正常",
- "规则阶段编号": sample.get("rule_stage_id") or "", "模型异常概率": round(float(probabilities[index]), 6),
- "最相似历史异常样本": nearest["sample_id"], "历史异常相似度": round(anomaly_similarity, 6),
- "联合判断": combined_result(rule_label, probabilities[index], anomaly_similarity, metadata["threshold"]),
- })
- rows.sort(key=lambda row: (int(row["机组"][0]), row["窗口结束小时"], row["批次"]))
- output = Path(output_dir)
- output.mkdir(parents=True, exist_ok=True)
- name = "压力模型辅助确认.csv" if model_type == "pressure" else "振动模型辅助确认.csv"
- with (output / name).open("w", encoding="utf-8-sig", newline="") as handle:
- writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
- writer.writeheader()
- writer.writerows(rows)
- return {"rows": len(rows), "output": str(output / name)}
- __all__ = [
- "load_samples", "matrix", "train", "evaluate", "infer", "metrics", "event_metrics", "choose_threshold",
- ]
|