ARotting's picture
Publish 2.8K parameter label-free temporal autoencoder
a20d06e verified
Raw
History Blame Contribute Delete
7.8 kB
from __future__ import annotations
import json
import random
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import torch
import trackio
from model import TemporalAutoencoder, parameter_count
from safetensors.torch import save_file
from sklearn.metrics import (
average_precision_score,
confusion_matrix,
f1_score,
precision_score,
recall_score,
roc_auc_score,
)
from torch.nn import functional as F
from torch.utils.data import DataLoader, TensorDataset
PROJECT_DIR = Path(__file__).resolve().parent
SOURCE_DATA_DIR = PROJECT_DIR.parent / "edge-sentinel-ml" / "data"
ARTIFACT_DIR = PROJECT_DIR / "artifacts" / "temporal-autoencoder"
CHANNELS = [
"temperature",
"pressure",
"vibration",
"current",
"flow",
"packet_rate",
"command_rate",
"actuator_position",
"flow_actuator_residual",
"power_proxy",
]
WINDOW_SIZE = 32
STRIDE = 8
def seed_everything(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
def load_frame(name: str) -> pd.DataFrame:
path = SOURCE_DATA_DIR / f"{name}.parquet"
if not path.exists():
raise FileNotFoundError(
f"{path} is missing. Run edge-sentinel-ml/generate_data.py first."
)
return pd.read_parquet(path)
def windows_from_frame(
frame: pd.DataFrame,
mean: np.ndarray,
scale: np.ndarray,
) -> tuple[torch.Tensor, torch.Tensor]:
windows = []
labels = []
for _, device in frame.groupby("device_id", sort=False):
values = (device[CHANNELS].to_numpy(dtype=np.float32) - mean) / scale
anomaly = device["label"].to_numpy(dtype=np.int64)
for start in range(0, len(device) - WINDOW_SIZE + 1, STRIDE):
stop = start + WINDOW_SIZE
windows.append(values[start:stop].T)
labels.append(int(anomaly[start:stop].any()))
return (
torch.from_numpy(np.stack(windows).astype(np.float32)),
torch.tensor(labels, dtype=torch.long),
)
@torch.inference_mode()
def reconstruction_scores(
model: TemporalAutoencoder,
windows: torch.Tensor,
) -> np.ndarray:
model.eval()
loader = DataLoader(TensorDataset(windows), batch_size=512, shuffle=False)
scores = []
for (batch,) in loader:
reconstruction = model(batch)
scores.extend(
F.mse_loss(reconstruction, batch, reduction="none").mean((1, 2)).tolist()
)
return np.asarray(scores)
def best_threshold(labels: np.ndarray, scores: np.ndarray) -> tuple[float, float]:
candidates = np.quantile(scores, np.linspace(0.5, 0.999, 500))
ranked = [
(f1_score(labels, scores >= threshold, zero_division=0), threshold)
for threshold in candidates
]
f1, threshold = max(ranked)
return float(threshold), float(f1)
def metrics(labels: np.ndarray, scores: np.ndarray, threshold: float) -> dict:
predictions = scores >= threshold
matrix = confusion_matrix(labels, predictions, labels=[0, 1])
return {
"roc_auc": float(roc_auc_score(labels, scores)),
"average_precision": float(average_precision_score(labels, scores)),
"precision": float(precision_score(labels, predictions, zero_division=0)),
"recall": float(recall_score(labels, predictions, zero_division=0)),
"f1": float(f1_score(labels, predictions, zero_division=0)),
"false_positive_rate": float(matrix[0, 1] / max(1, matrix[0].sum())),
"confusion_matrix": matrix.tolist(),
}
def main() -> None:
seed_everything(2026)
train_frame = load_frame("train")
validation_frame = load_frame("validation")
test_frame = load_frame("test")
normal_train = train_frame[train_frame["label"] == 0]
mean = normal_train[CHANNELS].to_numpy(dtype=np.float32).mean(axis=0)
scale = normal_train[CHANNELS].to_numpy(dtype=np.float32).std(axis=0)
scale = np.maximum(scale, 1e-4)
train_windows, train_labels = windows_from_frame(train_frame, mean, scale)
validation_windows, validation_labels = windows_from_frame(
validation_frame,
mean,
scale,
)
test_windows, test_labels = windows_from_frame(test_frame, mean, scale)
normal_windows = train_windows[train_labels == 0]
loader = DataLoader(
TensorDataset(normal_windows),
batch_size=128,
shuffle=True,
generator=torch.Generator().manual_seed(2026),
)
model = TemporalAutoencoder(channels=len(CHANNELS))
optimizer = torch.optim.AdamW(model.parameters(), lr=0.002, weight_decay=0.001)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=35)
best_validation_auc = -1.0
best_state = None
trackio.init(
project="edge-sentinel-neural",
name="temporal-autoencoder-v1",
config={
"parameters": parameter_count(model),
"window_size": WINDOW_SIZE,
"stride": STRIDE,
"channels": len(CHANNELS),
"normal_train_windows": len(normal_windows),
},
)
for epoch in range(1, 36):
model.train()
running_loss = 0.0
examples = 0
for (batch,) in loader:
noisy = batch + torch.randn_like(batch) * 0.025
reconstruction = model(noisy)
loss = F.mse_loss(reconstruction, batch)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
running_loss += loss.item() * len(batch)
examples += len(batch)
scheduler.step()
validation_scores = reconstruction_scores(model, validation_windows)
validation_auc = roc_auc_score(validation_labels.numpy(), validation_scores)
if validation_auc > best_validation_auc:
best_validation_auc = validation_auc
best_state = {
key: value.detach().cpu().clone()
for key, value in model.state_dict().items()
}
trackio.log(
{
"epoch": epoch,
"train_reconstruction_mse": running_loss / examples,
"validation_roc_auc": validation_auc,
"learning_rate": scheduler.get_last_lr()[0],
}
)
trackio.finish()
if best_state is None:
sys.exit("Training did not produce a checkpoint.")
model.load_state_dict(best_state)
validation_scores = reconstruction_scores(model, validation_windows)
test_scores = reconstruction_scores(model, test_windows)
threshold, validation_f1 = best_threshold(
validation_labels.numpy(),
validation_scores,
)
results = {
"model": "Edge Sentinel Temporal Autoencoder",
"parameters": parameter_count(model),
"channels": CHANNELS,
"window_size": WINDOW_SIZE,
"stride": STRIDE,
"normal_train_windows": len(normal_windows),
"validation_windows": len(validation_windows),
"test_windows": len(test_windows),
"best_validation_roc_auc": float(best_validation_auc),
"validation_threshold_f1": validation_f1,
"threshold": threshold,
"validation": metrics(
validation_labels.numpy(),
validation_scores,
threshold,
),
"test": metrics(test_labels.numpy(), test_scores, threshold),
}
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
save_file(model.state_dict(), ARTIFACT_DIR / "model.safetensors")
np.savez(
ARTIFACT_DIR / "normalization.npz",
mean=mean,
scale=scale,
)
(ARTIFACT_DIR / "evaluation.json").write_text(
json.dumps(results, indent=2),
encoding="utf-8",
)
print(json.dumps(results, indent=2))
if __name__ == "__main__":
main()