anomaly-detection / src /stream /generator.py
zeetay's picture
initial deploy
b0add2b verified
Raw
History Blame Contribute Delete
4.92 kB
"""Synthetic multivariate stream generator with ground truth labels."""
import asyncio
import itertools
import random
from collections.abc import AsyncIterator, Iterator
from typing import Any
from src.stream.models import Observation, Label
# Baseline means for Phase A
BASELINE_TEMP = 25.0
BASELINE_PRESSURE = 101.3
BASELINE_VIBRATION = 0.5
# Standard deviation for correlated noise
NOISE_SIGMA = 0.5
# Correlation: shared latent factor weight
LATENT_WEIGHT = 0.7
INDEPENDENT_WEIGHT = (1 - LATENT_WEIGHT**2) ** 0.5
def _correlated_noise(rng: random.Random) -> tuple[float, float, float]:
"""Generate correlated noise: one latent factor, then map to three sensors."""
z = rng.gauss(0, NOISE_SIGMA)
eps_t = rng.gauss(0, NOISE_SIGMA * INDEPENDENT_WEIGHT)
eps_p = rng.gauss(0, NOISE_SIGMA * INDEPENDENT_WEIGHT)
eps_v = rng.gauss(0, NOISE_SIGMA * INDEPENDENT_WEIGHT)
return (
LATENT_WEIGHT * z + eps_t,
LATENT_WEIGHT * z + eps_p,
LATENT_WEIGHT * z + eps_v,
)
def _generate_sync(
*,
phase_a_length: int = 300,
phase_b_length: int = 200,
phase_c_length: int = 300,
drift_magnitude: float = 0.3,
anomaly_rate: float = 0.05,
point_ratio: float = 0.6,
seed: int = 42,
) -> Iterator[Observation]:
rng = random.Random(seed)
t = 0.0
# Phase A: normal baseline
for i in range(phase_a_length):
nt, np_, nv = _correlated_noise(rng)
yield Observation(
timestamp=t,
temperature=BASELINE_TEMP + nt,
pressure=BASELINE_PRESSURE + np_,
vibration=BASELINE_VIBRATION + nv,
label="normal",
phase="A",
)
t += 1.0
# Phase B: linear drift
for i in range(phase_b_length):
alpha = (i + 1) / phase_b_length
mt = BASELINE_TEMP + drift_magnitude * alpha
mp = BASELINE_PRESSURE + drift_magnitude * alpha
mv = BASELINE_VIBRATION + drift_magnitude * alpha
nt, np_, nv = _correlated_noise(rng)
yield Observation(
timestamp=t,
temperature=mt + nt,
pressure=mp + np_,
vibration=mv + nv,
label="normal",
phase="B",
)
t += 1.0
# Post-drift means
end_temp = BASELINE_TEMP + drift_magnitude
end_pressure = BASELINE_PRESSURE + drift_magnitude
end_vibration = BASELINE_VIBRATION + drift_magnitude
# Phase C: post-drift + anomalies
# Anomaly injection: every ~1/anomaly_rate observations on average
obs_count = 0
anomaly_choice = itertools.cycle(["point_anomaly", "contextual_anomaly"])
next_anomaly_step = max(1, int(rng.expovariate(anomaly_rate)))
for i in range(phase_c_length):
obs_count += 1
nt, np_, nv = _correlated_noise(rng)
if obs_count >= next_anomaly_step:
# Inject anomaly; pick type by point_ratio
if rng.random() < point_ratio:
label: Label = "point_anomaly"
# Spike in one or more sensors (3-5 sigma)
spike = rng.uniform(3.0, 5.0) * NOISE_SIGMA
which = rng.sample([0, 1, 2], k=rng.randint(1, 3))
if 0 in which:
nt += spike * rng.choice([-1, 1])
if 1 in which:
np_ += spike * rng.choice([-1, 1])
if 2 in which:
nv += spike * rng.choice([-1, 1])
else:
label = "contextual_anomaly"
# High temp + low pressure, opposite to the normal positive correlation.
# Max per-feature z-score ~2.4, below the 2.5 Z-Score cutoff.
nt = 1.8 * NOISE_SIGMA
np_ = -1.8 * NOISE_SIGMA
nv = 0.0
next_anomaly_step = obs_count + max(1, int(rng.expovariate(anomaly_rate)))
else:
label = "normal"
yield Observation(
timestamp=t,
temperature=end_temp + nt,
pressure=end_pressure + np_,
vibration=end_vibration + nv,
label=label,
phase="C",
)
t += 1.0
async def generate_stream(
*,
phase_a_length: int = 300,
phase_b_length: int = 200,
phase_c_length: int = 300,
drift_magnitude: float = 0.3,
anomaly_rate: float = 0.05,
point_ratio: float = 0.6,
delay: float = 0.0,
seed: int = 42,
) -> AsyncIterator[Observation]:
"""Async generator yielding one observation at a time with optional delay."""
sync_gen = _generate_sync(
phase_a_length=phase_a_length,
phase_b_length=phase_b_length,
phase_c_length=phase_c_length,
drift_magnitude=drift_magnitude,
anomaly_rate=anomaly_rate,
point_ratio=point_ratio,
seed=seed,
)
for obs in sync_gen:
if delay > 0:
await asyncio.sleep(delay)
yield obs