2025-09-15 14:25:15 +02:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# results_inference_timelines_exp_compare.py
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
import pickle
|
|
|
|
|
|
import re
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Dict, Optional, Tuple
|
|
|
|
|
|
|
|
|
|
|
|
import matplotlib.pyplot as plt
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
import polars as pl
|
2025-09-22 09:41:58 +02:00
|
|
|
|
from load_results import load_inference_results_dataframe
|
|
|
|
|
|
from matplotlib.lines import Line2D
|
2025-09-15 14:25:15 +02:00
|
|
|
|
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
# User-configurable params
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
|
|
|
|
|
|
# Root directory that contains per-run outputs (your loader will scan this)
|
|
|
|
|
|
INFERENCE_ROOT = Path("/home/fedex/mt/results/inference/copy")
|
|
|
|
|
|
|
|
|
|
|
|
# Cached stats + manual labels (same location as your earlier scripts)
|
2025-09-22 09:41:58 +02:00
|
|
|
|
CACHE_PATH = Path("/home/fedex/mt/plots/results_inference_exp_compare")
|
2025-09-15 14:25:15 +02:00
|
|
|
|
|
|
|
|
|
|
# .bag directory (used only to rebuild experiment order for mapping stats)
|
|
|
|
|
|
ALL_DATA_PATH = Path("/home/fedex/mt/data/subter")
|
|
|
|
|
|
|
|
|
|
|
|
# Output base directory (timestamped subfolder will be created here, archived, and copied to latest/)
|
|
|
|
|
|
OUTPUT_PATH = Path("/home/fedex/mt/plots/results_inference_exp_compare")
|
|
|
|
|
|
|
|
|
|
|
|
# Two experiments to compare (exact strings as they appear in your DF’s `experiment` column)
|
|
|
|
|
|
EXPERIMENT_CLEAN = "2_static_no_artifacts_illuminated_2023-01-23-001"
|
|
|
|
|
|
EXPERIMENT_DEGRADED = "3_smoke_human_walking_2023-01-23"
|
|
|
|
|
|
|
|
|
|
|
|
# Shared model configuration for BOTH experiments
|
|
|
|
|
|
LATENT_DIM = 32
|
2025-09-22 09:41:58 +02:00
|
|
|
|
SEMI_NORMALS = 0
|
|
|
|
|
|
SEMI_ANOMALOUS = 0
|
2025-09-15 14:25:15 +02:00
|
|
|
|
|
|
|
|
|
|
# Comparison y-axis mode for methods: "baseline_z" or "baseline_tailprob"
|
|
|
|
|
|
Y_MODE = "baseline_z"
|
|
|
|
|
|
|
|
|
|
|
|
# Progress axis resolution (number of bins from 0% to 100%)
|
|
|
|
|
|
PROGRESS_BINS = 100
|
|
|
|
|
|
|
|
|
|
|
|
# Frames per second for building time axes before progress-binning (informational only)
|
|
|
|
|
|
FPS = 10.0
|
|
|
|
|
|
|
|
|
|
|
|
# ---- EMA smoothing only ----
|
|
|
|
|
|
# Methods (scores) EMA alpha
|
|
|
|
|
|
EMA_ALPHA_METHODS = 0.1 # (0,1], smaller = smoother
|
|
|
|
|
|
# Stats (absolute %) EMA alpha
|
|
|
|
|
|
EMA_ALPHA_STATS = 0.1 # (0,1], smaller = smoother
|
|
|
|
|
|
|
|
|
|
|
|
# LiDAR points per frame (for stats -> percent)
|
|
|
|
|
|
DATA_RESOLUTION = 32 * 2048
|
|
|
|
|
|
|
|
|
|
|
|
# Copy this script into outputs for provenance (best-effort if not running as a file)
|
|
|
|
|
|
COPY_SELF = True
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
# Setup output folders
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
datetime_folder_name = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
|
|
|
|
latest_folder_path = OUTPUT_PATH / "latest"
|
|
|
|
|
|
archive_folder_path = OUTPUT_PATH / "archive"
|
|
|
|
|
|
output_datetime_path = OUTPUT_PATH / datetime_folder_name
|
|
|
|
|
|
|
|
|
|
|
|
OUTPUT_PATH.mkdir(exist_ok=True, parents=True)
|
|
|
|
|
|
archive_folder_path.mkdir(exist_ok=True, parents=True)
|
|
|
|
|
|
latest_folder_path.mkdir(exist_ok=True, parents=True)
|
|
|
|
|
|
output_datetime_path.mkdir(exist_ok=True, parents=True)
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
# Load Polars DataFrame via your helper
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
|
|
|
|
|
|
df: pl.DataFrame = load_inference_results_dataframe(INFERENCE_ROOT)
|
|
|
|
|
|
|
|
|
|
|
|
required_cols = {
|
|
|
|
|
|
"experiment",
|
|
|
|
|
|
"network",
|
|
|
|
|
|
"latent_dim",
|
|
|
|
|
|
"semi_normals",
|
|
|
|
|
|
"semi_anomalous",
|
|
|
|
|
|
"model",
|
|
|
|
|
|
"scores",
|
|
|
|
|
|
"folder",
|
|
|
|
|
|
"config_json",
|
|
|
|
|
|
}
|
|
|
|
|
|
missing = required_cols - set(df.columns)
|
|
|
|
|
|
if missing:
|
|
|
|
|
|
raise KeyError(f"DataFrame missing required columns: {sorted(missing)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
# Rebuild experiment → stats mapping (like your original)
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
def rebuild_experiment_index():
|
|
|
|
|
|
normals, anomalies = [], []
|
|
|
|
|
|
if not ALL_DATA_PATH.exists():
|
|
|
|
|
|
return [], [], {}
|
|
|
|
|
|
for bag in ALL_DATA_PATH.iterdir():
|
|
|
|
|
|
if bag.suffix != ".bag":
|
|
|
|
|
|
continue
|
|
|
|
|
|
if "smoke" in bag.name:
|
|
|
|
|
|
anomalies.append(bag)
|
|
|
|
|
|
else:
|
|
|
|
|
|
normals.append(bag)
|
|
|
|
|
|
normals = sorted(normals, key=lambda p: p.stat().st_size)
|
|
|
|
|
|
anomalies = sorted(anomalies, key=lambda p: p.stat().st_size)
|
|
|
|
|
|
mapping = {}
|
|
|
|
|
|
for i, p in enumerate(normals):
|
|
|
|
|
|
mapping[p.stem] = (False, i, p)
|
|
|
|
|
|
for i, p in enumerate(anomalies):
|
|
|
|
|
|
mapping[p.stem] = (True, i, p)
|
|
|
|
|
|
return normals, anomalies, mapping
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
normal_paths, anomaly_paths, exp_map = rebuild_experiment_index()
|
|
|
|
|
|
|
|
|
|
|
|
# Load cached statistical data and manual labels
|
|
|
|
|
|
missing_points_cache = CACHE_PATH / "missing_points.pkl"
|
|
|
|
|
|
near_sensor_cache = CACHE_PATH / "particles_near_sensor_counts_500.pkl"
|
|
|
|
|
|
labels_json_path = CACHE_PATH / "manually_labeled_anomaly_frames.json"
|
|
|
|
|
|
|
|
|
|
|
|
missing_points_normal = missing_points_anomaly = None
|
|
|
|
|
|
near_sensor_normal = near_sensor_anomaly = None
|
|
|
|
|
|
if missing_points_cache.exists():
|
|
|
|
|
|
with open(missing_points_cache, "rb") as f:
|
|
|
|
|
|
missing_points_normal, missing_points_anomaly = pickle.load(f)
|
|
|
|
|
|
if near_sensor_cache.exists():
|
|
|
|
|
|
with open(near_sensor_cache, "rb") as f:
|
|
|
|
|
|
near_sensor_normal, near_sensor_anomaly = pickle.load(f)
|
|
|
|
|
|
|
|
|
|
|
|
manual_windows = {}
|
|
|
|
|
|
if labels_json_path.exists():
|
|
|
|
|
|
with open(labels_json_path, "r") as f:
|
|
|
|
|
|
labeled_json = json.load(f)
|
|
|
|
|
|
for file in labeled_json.get("files", []):
|
|
|
|
|
|
manual_windows[file["filename"]] = (
|
|
|
|
|
|
file.get("semi_target_begin_frame"),
|
|
|
|
|
|
file.get("semi_target_end_frame"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
# Helpers
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
def ema(x: np.ndarray, alpha: float) -> np.ndarray:
|
|
|
|
|
|
if x is None or len(x) == 0:
|
|
|
|
|
|
return x
|
|
|
|
|
|
y = np.empty_like(x, dtype=float)
|
|
|
|
|
|
y[0] = x[0]
|
|
|
|
|
|
for i in range(1, len(x)):
|
|
|
|
|
|
y[i] = alpha * x[i] + (1 - alpha) * y[i - 1]
|
|
|
|
|
|
return y
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def to_np_list(list_cell) -> Optional[np.ndarray]:
|
|
|
|
|
|
if list_cell is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return np.asarray(list_cell, dtype=float).ravel()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_exp_name(name: str) -> str:
|
|
|
|
|
|
# strip trailing run suffix like -001, -002 if present
|
|
|
|
|
|
return re.sub(r"-\d{3}$", "", name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def map_experiment_to_stats_stem(exp_name: str) -> Optional[str]:
|
|
|
|
|
|
"""Try exact match, then prefix match with / without -### suffix stripped."""
|
|
|
|
|
|
if exp_name in exp_map:
|
|
|
|
|
|
return exp_name
|
|
|
|
|
|
base = normalize_exp_name(exp_name)
|
|
|
|
|
|
if base in exp_map:
|
|
|
|
|
|
return base
|
|
|
|
|
|
for stem in exp_map.keys():
|
|
|
|
|
|
if stem.startswith(exp_name) or stem.startswith(base):
|
|
|
|
|
|
return stem
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_stats_for_experiment(
|
|
|
|
|
|
exp_name: str,
|
|
|
|
|
|
) -> Tuple[
|
|
|
|
|
|
Optional[np.ndarray], Optional[np.ndarray], Tuple[Optional[int], Optional[int]]
|
|
|
|
|
|
]:
|
|
|
|
|
|
key = map_experiment_to_stats_stem(exp_name)
|
|
|
|
|
|
if key is None:
|
|
|
|
|
|
return None, None, (None, None)
|
|
|
|
|
|
is_anomaly, idx, path = exp_map[key]
|
|
|
|
|
|
missing = near = None
|
|
|
|
|
|
if missing_points_normal is not None and missing_points_anomaly is not None:
|
|
|
|
|
|
series = (
|
|
|
|
|
|
missing_points_anomaly[idx] if is_anomaly else missing_points_normal[idx]
|
|
|
|
|
|
)
|
|
|
|
|
|
missing = (np.asarray(series, dtype=float) / DATA_RESOLUTION) * 100.0
|
|
|
|
|
|
if near_sensor_normal is not None and near_sensor_anomaly is not None:
|
|
|
|
|
|
series = near_sensor_anomaly[idx] if is_anomaly else near_sensor_normal[idx]
|
|
|
|
|
|
near = (np.asarray(series, dtype=float) / DATA_RESOLUTION) * 100.0
|
|
|
|
|
|
npy_key = path.with_suffix(".npy").name
|
|
|
|
|
|
window = manual_windows.get(npy_key, (None, None))
|
|
|
|
|
|
return missing, near, window
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _bin_to_progress(x: np.ndarray, bins: int = PROGRESS_BINS) -> np.ndarray:
|
|
|
|
|
|
"""Average x into fixed #bins across its length (progress-normalized timeline)."""
|
|
|
|
|
|
if x is None or len(x) == 0:
|
|
|
|
|
|
return x
|
|
|
|
|
|
n = len(x)
|
|
|
|
|
|
edges = np.linspace(0, n, bins + 1, dtype=int)
|
|
|
|
|
|
out = np.empty(bins, dtype=float)
|
|
|
|
|
|
for i in range(bins):
|
|
|
|
|
|
a, b = edges[i], edges[i + 1]
|
|
|
|
|
|
if b <= a:
|
|
|
|
|
|
out[i] = out[i - 1] if i > 0 else x[0]
|
|
|
|
|
|
else:
|
|
|
|
|
|
out[i] = float(np.mean(x[a:b]))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ecdf(x: np.ndarray):
|
|
|
|
|
|
xs = np.sort(np.asarray(x, dtype=float))
|
|
|
|
|
|
n = len(xs)
|
|
|
|
|
|
|
|
|
|
|
|
def F(t):
|
|
|
|
|
|
return float(np.searchsorted(xs, t, side="right")) / n
|
|
|
|
|
|
|
|
|
|
|
|
return F
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def baseline_transform(clean: np.ndarray, other: np.ndarray, mode: str):
|
|
|
|
|
|
"""Transform using stats from clean only."""
|
|
|
|
|
|
assert mode in ("baseline_z", "baseline_tailprob")
|
|
|
|
|
|
if clean is None or len(clean) == 0:
|
|
|
|
|
|
return clean, other, "raw"
|
|
|
|
|
|
if mode == "baseline_z":
|
|
|
|
|
|
mu = float(np.mean(clean))
|
|
|
|
|
|
sd = float(np.std(clean, ddof=0))
|
|
|
|
|
|
if sd < 1e-12:
|
|
|
|
|
|
zc = clean - mu
|
|
|
|
|
|
zo = other - mu if other is not None else None
|
|
|
|
|
|
else:
|
|
|
|
|
|
zc = (clean - mu) / sd
|
|
|
|
|
|
zo = (other - mu) / sd if other is not None else None
|
|
|
|
|
|
return zc, zo, "Anomaly score (σ above clean)"
|
|
|
|
|
|
else:
|
|
|
|
|
|
F = _ecdf(clean)
|
|
|
|
|
|
tp_clean = np.array([1.0 - F(v) for v in clean], dtype=float)
|
|
|
|
|
|
tp_other = (
|
|
|
|
|
|
np.array([1.0 - F(v) for v in other], dtype=float)
|
|
|
|
|
|
if other is not None
|
|
|
|
|
|
else None
|
|
|
|
|
|
)
|
|
|
|
|
|
return tp_clean, tp_other, "Tail probability vs clean (1 - F_clean)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pick_method_series(gdf: pl.DataFrame, label: str) -> Optional[np.ndarray]:
|
2025-10-21 19:04:19 +02:00
|
|
|
|
if label == "DeepSAD LeNet":
|
2025-09-15 14:25:15 +02:00
|
|
|
|
sel = gdf.filter(
|
|
|
|
|
|
(pl.col("network") == "subter_LeNet") & (pl.col("model") == "deepsad")
|
|
|
|
|
|
)
|
2025-10-21 19:04:19 +02:00
|
|
|
|
elif label == "DeepSAD Efficient":
|
2025-09-15 14:25:15 +02:00
|
|
|
|
sel = gdf.filter(
|
|
|
|
|
|
(pl.col("network") == "subter_efficient") & (pl.col("model") == "deepsad")
|
|
|
|
|
|
)
|
2025-09-22 09:41:58 +02:00
|
|
|
|
elif label == "OCSVM":
|
2025-09-15 14:25:15 +02:00
|
|
|
|
sel = gdf.filter(
|
|
|
|
|
|
(pl.col("network") == "subter_LeNet") & (pl.col("model") == "ocsvm")
|
|
|
|
|
|
)
|
2025-09-22 09:41:58 +02:00
|
|
|
|
elif label == "Isolation Forest":
|
2025-09-15 14:25:15 +02:00
|
|
|
|
sel = gdf.filter(
|
|
|
|
|
|
(pl.col("network") == "subter_LeNet") & (pl.col("model") == "isoforest")
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
sel = pl.DataFrame()
|
|
|
|
|
|
if sel.height == 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
row = sel.row(0)
|
|
|
|
|
|
row_dict = {c: row[i] for i, c in enumerate(sel.columns)}
|
|
|
|
|
|
return to_np_list(row_dict["scores"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def group_slice(
|
|
|
|
|
|
df: pl.DataFrame,
|
|
|
|
|
|
experiment: str,
|
|
|
|
|
|
latent_dim: int,
|
|
|
|
|
|
semi_normals: int,
|
|
|
|
|
|
semi_anomalous: int,
|
|
|
|
|
|
) -> pl.DataFrame:
|
|
|
|
|
|
return df.filter(
|
|
|
|
|
|
(pl.col("experiment") == experiment)
|
|
|
|
|
|
& (pl.col("latent_dim") == latent_dim)
|
|
|
|
|
|
& (pl.col("semi_normals") == semi_normals)
|
|
|
|
|
|
& (pl.col("semi_anomalous") == semi_anomalous)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compare_two_experiments_progress(
|
|
|
|
|
|
df: pl.DataFrame,
|
|
|
|
|
|
experiment_clean: str,
|
|
|
|
|
|
experiment_degraded: str,
|
|
|
|
|
|
latent_dim: int,
|
|
|
|
|
|
semi_normals: int,
|
|
|
|
|
|
semi_anomalous: int,
|
|
|
|
|
|
y_mode: str = "baseline_z",
|
|
|
|
|
|
include_stats: bool = True,
|
|
|
|
|
|
):
|
|
|
|
|
|
methods = [
|
2025-10-21 19:04:19 +02:00
|
|
|
|
"DeepSAD LeNet",
|
|
|
|
|
|
"DeepSAD Efficient",
|
2025-09-22 09:41:58 +02:00
|
|
|
|
"OCSVM",
|
|
|
|
|
|
"Isolation Forest",
|
2025-09-15 14:25:15 +02:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
g_clean = group_slice(
|
|
|
|
|
|
df, experiment_clean, latent_dim, semi_normals, semi_anomalous
|
|
|
|
|
|
)
|
|
|
|
|
|
g_deg = group_slice(
|
|
|
|
|
|
df, experiment_degraded, latent_dim, semi_normals, semi_anomalous
|
|
|
|
|
|
)
|
|
|
|
|
|
if g_clean.is_empty() or g_deg.is_empty():
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"[WARN] Missing one of the experiment groups: clean({g_clean.height}), degraded({g_deg.height}). Skipping."
|
|
|
|
|
|
)
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
# Stats (% absolute, EMA smoothed later)
|
|
|
|
|
|
mp_clean, ns_clean, _ = get_stats_for_experiment(experiment_clean)
|
|
|
|
|
|
mp_deg, ns_deg, _ = get_stats_for_experiment(experiment_degraded)
|
|
|
|
|
|
|
|
|
|
|
|
# Build baseline-anchored, progress-binned curves per method
|
|
|
|
|
|
curves_clean: Dict[str, np.ndarray] = {}
|
|
|
|
|
|
curves_deg: Dict[str, np.ndarray] = {}
|
|
|
|
|
|
y_label = "Anomaly"
|
|
|
|
|
|
|
|
|
|
|
|
for label in methods:
|
|
|
|
|
|
s_clean = pick_method_series(g_clean, label)
|
|
|
|
|
|
s_deg = pick_method_series(g_deg, label)
|
|
|
|
|
|
if s_clean is None or s_deg is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Smooth raw with EMA for stability before fitting baseline
|
|
|
|
|
|
s_clean_sm = ema(s_clean.astype(float), EMA_ALPHA_METHODS)
|
|
|
|
|
|
s_deg_sm = ema(s_deg.astype(float), EMA_ALPHA_METHODS)
|
|
|
|
|
|
|
|
|
|
|
|
t_clean, t_deg, y_label = baseline_transform(s_clean_sm, s_deg_sm, y_mode)
|
|
|
|
|
|
|
|
|
|
|
|
# Progress-bin both
|
|
|
|
|
|
curves_clean[label] = _bin_to_progress(t_clean, PROGRESS_BINS)
|
|
|
|
|
|
curves_deg[label] = _bin_to_progress(t_deg, PROGRESS_BINS)
|
|
|
|
|
|
|
|
|
|
|
|
if not curves_clean:
|
|
|
|
|
|
print("[WARN] No method curves available for comparison in this config.")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
x = np.linspace(0, 100, PROGRESS_BINS)
|
|
|
|
|
|
|
2025-09-22 09:41:58 +02:00
|
|
|
|
# Prep stats: absolute %, EMA, progress-binned
|
|
|
|
|
|
def prep_stat_pair(a, b):
|
|
|
|
|
|
if a is None or len(a) == 0 or b is None or len(b) == 0:
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
a_s = ema(a.astype(float), EMA_ALPHA_STATS)
|
|
|
|
|
|
b_s = ema(b.astype(float), EMA_ALPHA_STATS)
|
|
|
|
|
|
return _bin_to_progress(a_s, PROGRESS_BINS), _bin_to_progress(
|
|
|
|
|
|
b_s, PROGRESS_BINS
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
mp_c, mp_d = prep_stat_pair(mp_clean, mp_deg)
|
|
|
|
|
|
ns_c, ns_d = prep_stat_pair(ns_clean, ns_deg)
|
|
|
|
|
|
|
|
|
|
|
|
# Colors & styles
|
|
|
|
|
|
COLOR_METHOD = "#d62728" # vibrant red
|
|
|
|
|
|
COLOR_MISSING = "#9ecae1" # pale blue
|
|
|
|
|
|
COLOR_NEAR = "#a1d99b" # pale green
|
|
|
|
|
|
|
|
|
|
|
|
LS_CLEAN = "--" # dashed for normal/clean
|
|
|
|
|
|
LS_DEG = "-" # solid for anomalous/degraded
|
|
|
|
|
|
LW_METHOD = 1.8
|
|
|
|
|
|
LW_METHOD_CLEAN = 1.2
|
|
|
|
|
|
LW_STATS = 1.6
|
|
|
|
|
|
ALPHA_STATS = 0.95
|
|
|
|
|
|
|
|
|
|
|
|
# Build the 2x2 subplots
|
|
|
|
|
|
fig, axes = plt.subplots(
|
|
|
|
|
|
4, 1, figsize=(12, 16), constrained_layout=True, sharex=False
|
|
|
|
|
|
)
|
|
|
|
|
|
axes = axes.ravel()
|
|
|
|
|
|
|
|
|
|
|
|
method_to_axidx = {
|
2025-10-21 19:04:19 +02:00
|
|
|
|
"DeepSAD LeNet": 0,
|
|
|
|
|
|
"DeepSAD Efficient": 1,
|
2025-09-22 09:41:58 +02:00
|
|
|
|
"OCSVM": 2,
|
|
|
|
|
|
"Isolation Forest": 3,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stats_available = (
|
|
|
|
|
|
mp_c is not None and mp_d is not None and ns_c is not None and ns_d is not None
|
|
|
|
|
|
)
|
|
|
|
|
|
if not stats_available:
|
|
|
|
|
|
print("[WARN] One or both stats missing. Subplots will include methods only.")
|
|
|
|
|
|
|
2025-10-21 19:04:19 +02:00
|
|
|
|
letters = ["a", "b", "c", "d"]
|
|
|
|
|
|
|
2025-09-22 09:41:58 +02:00
|
|
|
|
for label, axidx in method_to_axidx.items():
|
|
|
|
|
|
ax = axes[axidx]
|
2025-09-15 14:25:15 +02:00
|
|
|
|
yc = curves_clean.get(label)
|
|
|
|
|
|
yd = curves_deg.get(label)
|
|
|
|
|
|
if yc is None or yd is None:
|
2025-09-22 09:41:58 +02:00
|
|
|
|
ax.text(
|
|
|
|
|
|
0.5, 0.5, "No data", ha="center", va="center", transform=ax.transAxes
|
2025-09-15 14:25:15 +02:00
|
|
|
|
)
|
2025-10-21 19:04:19 +02:00
|
|
|
|
ax.set_title(f"({letters[axidx]}) {label}")
|
2025-09-22 09:41:58 +02:00
|
|
|
|
ax.grid(True, alpha=0.3)
|
|
|
|
|
|
continue
|
2025-09-15 14:25:15 +02:00
|
|
|
|
|
2025-09-22 09:41:58 +02:00
|
|
|
|
# Left axis: method score (z or tailprob)
|
|
|
|
|
|
ax.plot(
|
|
|
|
|
|
x,
|
|
|
|
|
|
yd,
|
|
|
|
|
|
linestyle=LS_DEG,
|
|
|
|
|
|
color=COLOR_METHOD,
|
|
|
|
|
|
linewidth=LW_METHOD,
|
|
|
|
|
|
label=f"{label} — degraded",
|
|
|
|
|
|
)
|
|
|
|
|
|
ax.plot(
|
|
|
|
|
|
x,
|
|
|
|
|
|
yc,
|
|
|
|
|
|
linestyle=LS_CLEAN,
|
|
|
|
|
|
color=COLOR_METHOD,
|
|
|
|
|
|
linewidth=LW_METHOD_CLEAN,
|
|
|
|
|
|
label=f"{label} — clean",
|
|
|
|
|
|
)
|
|
|
|
|
|
ax.set_ylabel(y_label)
|
|
|
|
|
|
ax.set_title(label)
|
2025-10-21 19:04:19 +02:00
|
|
|
|
ax.set_title(f"({letters[axidx]}) {label}")
|
2025-09-22 09:41:58 +02:00
|
|
|
|
ax.grid(True, alpha=0.3)
|
2025-09-15 14:25:15 +02:00
|
|
|
|
|
2025-09-22 09:41:58 +02:00
|
|
|
|
# Right axis #1 (closest to plot): Missing points (%)
|
|
|
|
|
|
axy_miss = ax.twinx()
|
2025-09-15 14:25:15 +02:00
|
|
|
|
if mp_c is not None and mp_d is not None:
|
2025-09-22 09:41:58 +02:00
|
|
|
|
axy_miss.plot(
|
|
|
|
|
|
x,
|
|
|
|
|
|
mp_d,
|
|
|
|
|
|
linestyle=LS_DEG,
|
|
|
|
|
|
color=COLOR_MISSING,
|
|
|
|
|
|
alpha=ALPHA_STATS,
|
|
|
|
|
|
linewidth=LW_STATS,
|
|
|
|
|
|
label="Missing points — degraded (%)",
|
2025-09-15 14:25:15 +02:00
|
|
|
|
)
|
2025-09-22 09:41:58 +02:00
|
|
|
|
axy_miss.plot(
|
|
|
|
|
|
x,
|
|
|
|
|
|
mp_c,
|
|
|
|
|
|
linestyle=LS_CLEAN,
|
|
|
|
|
|
color=COLOR_MISSING,
|
|
|
|
|
|
alpha=ALPHA_STATS,
|
|
|
|
|
|
linewidth=LW_STATS,
|
|
|
|
|
|
label="Missing points — clean (%)",
|
2025-09-15 14:25:15 +02:00
|
|
|
|
)
|
2025-09-22 09:41:58 +02:00
|
|
|
|
axy_miss.set_ylabel("Missing points (%)")
|
|
|
|
|
|
axy_miss.tick_params(axis="y") # , colors=COLOR_MISSING)
|
|
|
|
|
|
# axy_miss.spines["right"].set_edgecolor(COLOR_MISSING)
|
|
|
|
|
|
|
|
|
|
|
|
# Right axis #2 (slightly offset): Near-sensor points (%)
|
|
|
|
|
|
axy_near = ax.twinx()
|
|
|
|
|
|
# push this spine outward so it doesn't overlap the first right axis
|
|
|
|
|
|
axy_near.spines["right"].set_position(("axes", 1.08))
|
|
|
|
|
|
# make patch invisible so only spine shows
|
|
|
|
|
|
axy_near.set_frame_on(True)
|
|
|
|
|
|
axy_near.patch.set_visible(False)
|
2025-09-15 14:25:15 +02:00
|
|
|
|
|
|
|
|
|
|
if ns_c is not None and ns_d is not None:
|
2025-09-22 09:41:58 +02:00
|
|
|
|
axy_near.plot(
|
|
|
|
|
|
x,
|
|
|
|
|
|
ns_d,
|
|
|
|
|
|
linestyle=LS_DEG,
|
|
|
|
|
|
color=COLOR_NEAR,
|
|
|
|
|
|
alpha=ALPHA_STATS,
|
|
|
|
|
|
linewidth=LW_STATS,
|
|
|
|
|
|
label="Near-sensor — degraded (%)",
|
2025-09-15 14:25:15 +02:00
|
|
|
|
)
|
2025-09-22 09:41:58 +02:00
|
|
|
|
axy_near.plot(
|
|
|
|
|
|
x,
|
|
|
|
|
|
ns_c,
|
|
|
|
|
|
linestyle=LS_CLEAN,
|
|
|
|
|
|
color=COLOR_NEAR,
|
|
|
|
|
|
alpha=ALPHA_STATS,
|
|
|
|
|
|
linewidth=LW_STATS,
|
|
|
|
|
|
label="Near-sensor — clean (%)",
|
2025-09-15 14:25:15 +02:00
|
|
|
|
)
|
2025-09-22 09:41:58 +02:00
|
|
|
|
axy_near.set_ylabel("Near-sensor points (%)")
|
|
|
|
|
|
axy_near.tick_params(axis="y") # , colors=COLOR_NEAR)
|
|
|
|
|
|
# axy_near.spines["right"].set_edgecolor(COLOR_NEAR)
|
|
|
|
|
|
|
|
|
|
|
|
# Compose legend: show *method name* explicitly, plus the two stats
|
|
|
|
|
|
handles = [
|
|
|
|
|
|
Line2D(
|
|
|
|
|
|
[0],
|
|
|
|
|
|
[0],
|
|
|
|
|
|
color=COLOR_METHOD,
|
|
|
|
|
|
lw=LW_METHOD,
|
|
|
|
|
|
ls=LS_DEG,
|
|
|
|
|
|
label=f"{label} — degraded",
|
|
|
|
|
|
),
|
|
|
|
|
|
Line2D(
|
|
|
|
|
|
[0],
|
|
|
|
|
|
[0],
|
|
|
|
|
|
color=COLOR_METHOD,
|
|
|
|
|
|
lw=LW_METHOD_CLEAN,
|
|
|
|
|
|
ls=LS_CLEAN,
|
|
|
|
|
|
label=f"{label} — clean",
|
|
|
|
|
|
),
|
|
|
|
|
|
Line2D(
|
|
|
|
|
|
[0],
|
|
|
|
|
|
[0],
|
|
|
|
|
|
color=COLOR_MISSING,
|
|
|
|
|
|
lw=LW_STATS,
|
|
|
|
|
|
ls=LS_DEG,
|
|
|
|
|
|
label="Missing points — degraded",
|
|
|
|
|
|
),
|
|
|
|
|
|
Line2D(
|
|
|
|
|
|
[0],
|
|
|
|
|
|
[0],
|
|
|
|
|
|
color=COLOR_MISSING,
|
|
|
|
|
|
lw=LW_STATS,
|
|
|
|
|
|
ls=LS_CLEAN,
|
|
|
|
|
|
label="Missing points — clean",
|
|
|
|
|
|
),
|
|
|
|
|
|
Line2D(
|
|
|
|
|
|
[0],
|
|
|
|
|
|
[0],
|
|
|
|
|
|
color=COLOR_NEAR,
|
|
|
|
|
|
lw=LW_STATS,
|
|
|
|
|
|
ls=LS_DEG,
|
|
|
|
|
|
label="Near-sensor — degraded",
|
|
|
|
|
|
),
|
|
|
|
|
|
Line2D(
|
|
|
|
|
|
[0],
|
|
|
|
|
|
[0],
|
|
|
|
|
|
color=COLOR_NEAR,
|
|
|
|
|
|
lw=LW_STATS,
|
|
|
|
|
|
ls=LS_CLEAN,
|
|
|
|
|
|
label="Near-sensor — clean",
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
ax.legend(handles=handles, loc="upper left", fontsize=9, framealpha=0.9)
|
|
|
|
|
|
|
|
|
|
|
|
# Shared labels / super-title
|
|
|
|
|
|
for ax in axes:
|
|
|
|
|
|
ax.set_xlabel("Progress through experiment (%)")
|
|
|
|
|
|
|
2025-10-21 19:04:19 +02:00
|
|
|
|
# fig.suptitle(
|
|
|
|
|
|
# f"AD Method vs Stats Inference — progress-normalized\n"
|
|
|
|
|
|
# f"Transform: z-score normalized to non-degraded experiment | EMA(α={EMA_ALPHA_METHODS})",
|
|
|
|
|
|
# fontsize=14,
|
|
|
|
|
|
# )
|
2025-09-22 09:41:58 +02:00
|
|
|
|
fig.tight_layout(rect=[0, 0, 1, 0.99])
|
|
|
|
|
|
|
|
|
|
|
|
out_name = (
|
|
|
|
|
|
f"4up_{EXPERIMENT_CLEAN}_vs_{EXPERIMENT_DEGRADED}"
|
|
|
|
|
|
f"_ld{latent_dim}_sn{semi_normals}_sa{semi_anomalous}_{y_mode}_methods_vs_stats.png"
|
|
|
|
|
|
)
|
|
|
|
|
|
fig.savefig(output_datetime_path / out_name, dpi=150)
|
|
|
|
|
|
plt.close(fig)
|
2025-09-15 14:25:15 +02:00
|
|
|
|
|
2025-09-22 09:41:58 +02:00
|
|
|
|
return 1
|
2025-09-15 14:25:15 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
# Run comparison & save
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
plots_made = compare_two_experiments_progress(
|
|
|
|
|
|
df=df,
|
|
|
|
|
|
experiment_clean=EXPERIMENT_CLEAN,
|
|
|
|
|
|
experiment_degraded=EXPERIMENT_DEGRADED,
|
|
|
|
|
|
latent_dim=LATENT_DIM,
|
|
|
|
|
|
semi_normals=SEMI_NORMALS,
|
|
|
|
|
|
semi_anomalous=SEMI_ANOMALOUS,
|
|
|
|
|
|
y_mode=Y_MODE,
|
|
|
|
|
|
include_stats=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
# Preserve latest/, archive/, copy script
|
|
|
|
|
|
# =====================================
|
|
|
|
|
|
# delete current latest folder
|
|
|
|
|
|
shutil.rmtree(latest_folder_path, ignore_errors=True)
|
|
|
|
|
|
# create new latest folder
|
|
|
|
|
|
latest_folder_path.mkdir(exist_ok=True, parents=True)
|
|
|
|
|
|
|
|
|
|
|
|
# copy contents of output folder to the latest folder
|
|
|
|
|
|
for file in output_datetime_path.iterdir():
|
|
|
|
|
|
shutil.copy2(file, latest_folder_path)
|
|
|
|
|
|
|
|
|
|
|
|
# copy this python script to preserve the code used (best effort)
|
|
|
|
|
|
if COPY_SELF:
|
|
|
|
|
|
try:
|
|
|
|
|
|
shutil.copy2(__file__, output_datetime_path)
|
|
|
|
|
|
shutil.copy2(__file__, latest_folder_path)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
(output_datetime_path / "run_config.json").write_text(
|
|
|
|
|
|
json.dumps(
|
|
|
|
|
|
{
|
|
|
|
|
|
"INFERENCE_ROOT": str(INFERENCE_ROOT),
|
|
|
|
|
|
"CACHE_PATH": str(CACHE_PATH),
|
|
|
|
|
|
"ALL_DATA_PATH": str(ALL_DATA_PATH),
|
|
|
|
|
|
"EXPERIMENT_CLEAN": EXPERIMENT_CLEAN,
|
|
|
|
|
|
"EXPERIMENT_DEGRADED": EXPERIMENT_DEGRADED,
|
|
|
|
|
|
"LATENT_DIM": LATENT_DIM,
|
|
|
|
|
|
"SEMI_NORMALS": SEMI_NORMALS,
|
|
|
|
|
|
"SEMI_ANOMALOUS": SEMI_ANOMALOUS,
|
|
|
|
|
|
"Y_MODE": Y_MODE,
|
|
|
|
|
|
"PROGRESS_BINS": PROGRESS_BINS,
|
|
|
|
|
|
"FPS": FPS,
|
|
|
|
|
|
"EMA_ALPHA_METHODS": EMA_ALPHA_METHODS,
|
|
|
|
|
|
"EMA_ALPHA_STATS": EMA_ALPHA_STATS,
|
|
|
|
|
|
"DATA_RESOLUTION": DATA_RESOLUTION,
|
|
|
|
|
|
"timestamp": datetime_folder_name,
|
|
|
|
|
|
},
|
|
|
|
|
|
indent=2,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# move output date folder to archive
|
|
|
|
|
|
shutil.move(output_datetime_path, archive_folder_path)
|
|
|
|
|
|
|
|
|
|
|
|
print(f"Done. Wrote {plots_made} figure(s). Archived under: {archive_folder_path}")
|