stevenmcdermott commited on
Commit
33d95bf
Β·
verified Β·
1 Parent(s): fc81710

Upload ts-anomaly-benchmark/models/moment_detector.py

Browse files
ts-anomaly-benchmark/models/moment_detector.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models/moment_detector.py β€” MOMENT foundation model wrapper
3
+ =============================================================
4
+
5
+ MOMENT uses a reconstruction-based approach:
6
+ 1. Feed your time series into the pre-trained model.
7
+ 2. The model reconstructs what it thinks the series "should" look like.
8
+ 3. The reconstruction error at each timestep is the anomaly score.
9
+
10
+ High reconstruction error β†’ the model couldn't predict that pattern
11
+ from its learned baseline β†’ likely anomalous.
12
+
13
+ Reference: https://github.com/moment-timeseries-foundation-model/moment
14
+ """
15
+
16
+ import numpy as np
17
+ import torch
18
+ from models.base import AnomalyDetector
19
+
20
+
21
+ class MOMENTDetector(AnomalyDetector):
22
+ """
23
+ Wraps the MOMENT foundation model for anomaly detection
24
+ via reconstruction error scoring.
25
+ """
26
+
27
+ def __init__(self, config: dict):
28
+ super().__init__(name="MOMENT", config=config)
29
+ self.model = None
30
+ self._model_loaded = False
31
+
32
+ def _load_model(self):
33
+ """
34
+ Lazy-load the MOMENT model on first use.
35
+
36
+ Why lazy? The model is large (~1GB+). We only want to load it
37
+ if it's actually going to be used in this benchmark run.
38
+ """
39
+ if self._model_loaded:
40
+ return
41
+
42
+ try:
43
+ from momentfm import MOMENTPipeline
44
+
45
+ pretrained = self.config.get("pretrained", "AutonLab/MOMENT-1-large")
46
+ task = self.config.get("task", "reconstruction")
47
+
48
+ print(f" Loading MOMENT from '{pretrained}'...")
49
+
50
+ # ── Step 1: Create the pipeline ──
51
+ # MOMENTPipeline.from_pretrained() downloads the model weights
52
+ # from Hugging Face and sets up the architecture.
53
+ # model_kwargs tells it which task head to use.
54
+ self.model = MOMENTPipeline.from_pretrained(
55
+ pretrained,
56
+ model_kwargs={"task_name": task},
57
+ )
58
+
59
+ # ── Step 2: Initialise the model ──
60
+ # .init() prepares internal buffers and moves to the
61
+ # appropriate device (GPU if available, else CPU).
62
+ self.model.init()
63
+
64
+ self._model_loaded = True
65
+ print(f" MOMENT loaded successfully.")
66
+
67
+ except ImportError:
68
+ raise ImportError(
69
+ "momentfm is not installed. Install it with:\n"
70
+ " pip install momentfm\n"
71
+ "Then re-run the benchmark."
72
+ )
73
+ except Exception as e:
74
+ raise RuntimeError(f"Failed to load MOMENT model: {e}")
75
+
76
+ def detect(self, series: np.ndarray) -> np.ndarray:
77
+ """
78
+ Run MOMENT anomaly detection on a 1-D time series.
79
+
80
+ Parameters
81
+ ----------
82
+ series : np.ndarray
83
+ 1-D array of float values (your time-series data).
84
+
85
+ Returns
86
+ -------
87
+ scores : np.ndarray
88
+ Normalised anomaly scores in [0, 1], same length as input.
89
+
90
+ How it works (step by step)
91
+ ---------------------------
92
+ 1. We reshape your 1-D array into the tensor shape MOMENT expects:
93
+ (batch_size=1, n_channels=1, sequence_length).
94
+ 2. We pass it through the model, which returns a reconstruction.
95
+ 3. We compute the absolute difference between the original
96
+ and the reconstruction at every timestep.
97
+ 4. We normalise those errors to [0, 1].
98
+ """
99
+ self._load_model()
100
+
101
+ # ── Step 1: Prepare the input tensor ──
102
+ # MOMENT expects shape: (batch, channels, sequence_length)
103
+ # For univariate data: batch=1, channels=1
104
+ input_tensor = torch.tensor(
105
+ series, dtype=torch.float32
106
+ ).unsqueeze(0).unsqueeze(0)
107
+ # Shape is now: (1, 1, len(series))
108
+
109
+ # ── Handle sequence length requirements ──
110
+ # MOMENT has a fixed input length (typically 512).
111
+ # If your series is longer, we process it in sliding windows
112
+ # and stitch the results together.
113
+ model_seq_len = 512 # MOMENT's expected input length
114
+
115
+ if len(series) <= model_seq_len:
116
+ # Short enough β€” process in one go
117
+ scores = self._detect_single(series, model_seq_len)
118
+ else:
119
+ # Longer series β€” use sliding window with overlap
120
+ scores = self._detect_sliding(series, model_seq_len)
121
+
122
+ return self.normalise_scores(scores)
123
+
124
+ def _detect_single(self, series: np.ndarray, model_seq_len: int) -> np.ndarray:
125
+ """
126
+ Process a series that fits within MOMENT's context window.
127
+
128
+ If the series is shorter than model_seq_len, we pad it
129
+ with the mean value, run detection, then trim the result.
130
+ """
131
+ original_len = len(series)
132
+
133
+ # Pad if shorter than expected length
134
+ if len(series) < model_seq_len:
135
+ pad_val = np.mean(series)
136
+ padded = np.pad(
137
+ series,
138
+ (0, model_seq_len - len(series)),
139
+ mode="constant",
140
+ constant_values=pad_val,
141
+ )
142
+ else:
143
+ padded = series[:model_seq_len]
144
+
145
+ # Create input tensor: (batch=1, channels=1, seq_len)
146
+ input_tensor = torch.tensor(
147
+ padded, dtype=torch.float32
148
+ ).unsqueeze(0).unsqueeze(0)
149
+
150
+ # Run the model
151
+ with torch.no_grad():
152
+ output = self.model(x_enc=input_tensor)
153
+
154
+ # Extract reconstruction and compute error
155
+ reconstruction = output.reconstruction.squeeze().cpu().numpy()
156
+ errors = np.abs(padded - reconstruction)
157
+
158
+ # Trim back to original length
159
+ return errors[:original_len]
160
+
161
+ def _detect_sliding(self, series: np.ndarray, model_seq_len: int) -> np.ndarray:
162
+ """
163
+ Process a long series using overlapping sliding windows.
164
+
165
+ Strategy:
166
+ - Slide a window of model_seq_len across the series
167
+ - Overlap by 50% to ensure smooth transitions
168
+ - Average the error scores where windows overlap
169
+ """
170
+ step = model_seq_len // 2 # 50% overlap
171
+ n = len(series)
172
+
173
+ # Accumulators for averaging overlapping regions
174
+ score_sum = np.zeros(n)
175
+ score_count = np.zeros(n)
176
+
177
+ # Slide across the series
178
+ start = 0
179
+ while start < n:
180
+ end = min(start + model_seq_len, n)
181
+ window = series[start:end]
182
+
183
+ # Pad if the last window is shorter
184
+ if len(window) < model_seq_len:
185
+ pad_val = np.mean(window)
186
+ window = np.pad(
187
+ window,
188
+ (0, model_seq_len - len(window)),
189
+ mode="constant",
190
+ constant_values=pad_val,
191
+ )
192
+
193
+ # Create input tensor
194
+ input_tensor = torch.tensor(
195
+ window, dtype=torch.float32
196
+ ).unsqueeze(0).unsqueeze(0)
197
+
198
+ # Run the model
199
+ with torch.no_grad():
200
+ output = self.model(x_enc=input_tensor)
201
+
202
+ reconstruction = output.reconstruction.squeeze().cpu().numpy()
203
+ errors = np.abs(window - reconstruction)
204
+
205
+ # Only accumulate scores for the actual (non-padded) region
206
+ actual_len = min(model_seq_len, n - start)
207
+ score_sum[start : start + actual_len] += errors[:actual_len]
208
+ score_count[start : start + actual_len] += 1
209
+
210
+ start += step
211
+
212
+ # Average overlapping scores
213
+ score_count[score_count == 0] = 1 # Avoid division by zero
214
+ return score_sum / score_count