File size: 3,761 Bytes
f0fae3f
 
 
 
 
 
 
dbcbe7b
f0fae3f
e1257cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
e1257cf
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1257cf
f0fae3f
 
 
 
 
 
 
e1257cf
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
e1257cf
 
 
 
 
 
f0fae3f
 
e1257cf
f0fae3f
 
e1257cf
 
f0fae3f
 
e1257cf
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
"""
anomaly_model.py
-----------------
Isolation Forest based anomaly detector for conveyor / crane motor sensor
streams (motor temperature, vibration, current draw, belt speed). This
powers the "Predictive Maintenance" tab -- flags abnormal equipment
behaviour before it causes an unplanned stoppage, which is exactly the kind
of workload modern intralogistics platforms (e.g. AS/RS, sorters, AGVs)
generate continuously in production.

Threshold calibration
----------------------
The model itself is fit unsupervised (IsolationForest never sees labels).
However, converting its continuous anomaly score into a binary decision
requires a threshold, and scikit-learn's default choices for this (a fixed
"contamination" rate) are either a blind guess or -- if set to the true
label rate -- a form of leakage (see build_artifacts.py for the full
writeup of that bug and fix). The approach used here instead is standard
practice for production anomaly detection: fit the model unsupervised on
unlabelled data, then calibrate the decision threshold using a small
*labelled calibration set* (e.g. a handful of confirmed historical
incidents), completely separate from both model fitting and the final
held-out test set used to report metrics. `calibrated_threshold` below is
that calibrated cutoff, chosen to maximise F1 on the calibration set only.
"""

from dataclasses import dataclass
from typing import Optional

import joblib
import numpy as np
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler

FEATURES = ["motor_temp_c", "vibration_mm_s", "current_amps", "belt_speed_mps"]


@dataclass
class AnomalyResult:
    is_anomaly: bool
    anomaly_score: float  # higher = more anomalous, roughly in [0, 1]
    raw_score: float


def build_model(contamination="auto", seed: int = 42) -> IsolationForest:
    return IsolationForest(
        n_estimators=200,
        contamination=contamination,
        random_state=seed,
    )


def score_reading(
    model: IsolationForest,
    scaler: StandardScaler,
    reading: dict,
    threshold: Optional[float] = None,
) -> AnomalyResult:
    """
    Score a single sensor reading.

    If `threshold` is given, it's compared against the 0-1 anomaly_score
    (this is the calibrated-threshold path used by the deployed app). If
    omitted, falls back to the IsolationForest's own .predict() (its
    internal contamination-derived offset).
    """
    x = np.array([[reading[f] for f in FEATURES]])
    x_scaled = scaler.transform(x)
    raw = model.decision_function(x_scaled)[0]  # higher = more normal
    # squash raw decision_function (~[-0.5, 0.5]) into a 0-1 "anomaly score"
    anomaly_score = float(np.clip(0.5 - raw, 0, 1))
    if threshold is not None:
        is_anomaly = anomaly_score >= threshold
    else:
        pred = model.predict(x_scaled)[0]  # 1 = normal, -1 = anomaly
        is_anomaly = (pred == -1)
    return AnomalyResult(is_anomaly=bool(is_anomaly), anomaly_score=anomaly_score, raw_score=float(raw))


def save_artifacts(model, scaler, model_path: str, scaler_path: str, threshold: Optional[float] = None, threshold_path: Optional[str] = None):
    joblib.dump(model, model_path)
    joblib.dump(scaler, scaler_path)
    if threshold is not None and threshold_path is not None:
        joblib.dump(float(threshold), threshold_path)


def load_artifacts(model_path: str, scaler_path: str, threshold_path: Optional[str] = None):
    model = joblib.load(model_path)
    scaler = joblib.load(scaler_path)
    if threshold_path is not None:
        try:
            threshold = joblib.load(threshold_path)
            return model, scaler, threshold
        except FileNotFoundError:
            return model, scaler, None
    return model, scaler