Files
mt/tools/plot_scripts/setup_runtime_tables.py
Jan Kowalczyk cf15d5501e update
2025-09-10 19:41:00 +02:00

705 lines
22 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import json
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
import numpy as np
import pandas as pd
import polars as pl
from load_results import (
load_pretraining_results_dataframe,
load_results_dataframe,
)
# ----------------------------
# Config
# ----------------------------
RESULTS_ROOT = Path("/home/fedex/mt/results/done") # folder with experiment subdirs
OUTPUT_DIR = Path("/home/fedex/mt/plots/setup_runtime_tables") # where .tex goes
# If you want to optionally prefer a specific network label for baselines in column names,
# set to a substring to detect (e.g. "efficient"). If None, keep network as-is.
BASELINE_NETWORK_HINT: Optional[str] = None # e.g., "efficient" or None
# ----------------------------
# Helpers
# ----------------------------
def _net_label_for_display(net: str | None) -> str:
s = (net or "").lower()
if "effic" in s:
return "Efficient"
if "lenet" in s:
return "LeNet"
return net or ""
def _fmt_mean_std_n(
mean: float | None, std: float | None, n: int | None, unit: str = ""
) -> str:
if mean is None or (isinstance(mean, float) and (np.isnan(mean) or np.isinf(mean))):
return "-"
base = f"{mean:.2f}"
if std is not None and not (
isinstance(std, float) and (np.isnan(std) or np.isinf(std))
):
base = f"{base} ± {std:.2f}"
if unit:
base = f"{base} {unit}"
if n is not None and n > 0:
base = f"{base} (n={n})"
return base
def _fmt_pair(n: int, m: int) -> str:
return f"{n}/{m}"
def _fmt_mean_std(mean: float | None, std: float | None, n: int | None) -> str:
if mean is None or (isinstance(mean, float) and (np.isnan(mean) or np.isinf(mean))):
return "-"
if std is None or (isinstance(std, float) and (np.isnan(std) or np.isinf(std))):
return f"{mean:.2f}"
if n is None or n < 1:
return f"{mean:.2f} ± {std:.2f}"
return f"{mean:.2f} ± {std:.2f} (n={n})"
def _parse_cfg(cfg_json: Optional[str]) -> Dict[str, Any]:
if not cfg_json:
return {}
try:
return json.loads(cfg_json)
except Exception:
return {}
def _key_params(model: str, cfg: Dict[str, Any]) -> str:
"""Compact, model-specific parameter string for the table."""
if model == "deepsad":
bs = cfg.get("batch_size")
ne = cfg.get("n_epochs")
lr = cfg.get("lr")
wd = cfg.get("weight_decay")
return f"bs={bs}, epochs={ne}, lr={lr}, wd={wd}"
if model == "isoforest":
est = cfg.get("isoforest_n_estimators")
ms = cfg.get("isoforest_max_samples")
cont = cfg.get("isoforest_contamination")
return f"n_estimators={est}, max_samples={ms}, cont={cont}"
if model == "ocsvm":
ker = cfg.get("ocsvm_kernel")
nu = cfg.get("ocsvm_nu")
return f"kernel={ker}, nu={nu}"
return "-"
def _method_col_name(model: str, network: str) -> str:
"""
Column heading for pivot tables:
- deepsad carries the network (e.g., 'DeepSAD / LeNet')
- baselines carry their own model name; optionally annotate network
"""
label = model.lower()
if label == "deepsad":
return f"DeepSAD / {network}"
# baselines; optionally simplify/standardize network name
if (
BASELINE_NETWORK_HINT
and BASELINE_NETWORK_HINT.lower() not in (network or "").lower()
):
# If you want to collapse baseline duplicates to a single name, you can force it here
return model.capitalize()
# Otherwise, keep network variant explicit
return f"{model.capitalize()} / {network}"
def _prepare_per_fold_metrics(df: pl.DataFrame) -> pl.DataFrame:
"""
Returns one row per (folder, model, fold) with:
- train_time, test_time
- n_test (len(scores))
- n_epochs (from config_json; DeepSAD only)
- latency_ms = 1000 * test_time / n_test
- time_per_epoch = train_time / n_epochs (DeepSAD only)
"""
base = (
df.select(
"folder",
"network",
"model",
"latent_dim",
"semi_normals",
"semi_anomalous",
"fold",
"train_time",
"test_time",
"scores",
"config_json",
)
.with_columns(
n_test=pl.col("scores").list.len(),
n_epochs=pl.col("config_json")
.str.json_path_match("$.n_epochs")
.cast(pl.Int64),
)
.drop("scores")
)
# de-dup across evals
uniq = base.unique(subset=["folder", "model", "fold"])
# derived metrics
uniq = uniq.with_columns(
latency_ms=pl.when((pl.col("test_time") > 0) & (pl.col("n_test") > 0))
.then(1000.0 * pl.col("test_time") / pl.col("n_test"))
.otherwise(None)
.cast(pl.Float64),
time_per_epoch=pl.when(
(pl.col("model") == "deepsad") & (pl.col("n_epochs") > 0)
)
.then(pl.col("train_time") / pl.col("n_epochs"))
.otherwise(None)
.cast(pl.Float64),
network_disp=pl.col("network")
.cast(pl.Utf8)
.map_elements(_net_label_for_display, return_dtype=pl.Utf8),
)
return uniq
def _prepare_aggregates(df: pl.DataFrame) -> pl.DataFrame:
"""
Deduplicate across evals, then aggregate times across folds for each
(network, model, latent_dim, semi_normals, semi_anomalous).
"""
# Keep only columns we need
base = df.select(
"folder",
"network",
"model",
"latent_dim",
"semi_normals",
"semi_anomalous",
"fold",
"train_time",
"test_time",
"config_json",
)
# Drop duplicates across evals: same (folder, model, fold) should have identical timings
uniq = base.unique(subset=["folder", "model", "fold"]).with_columns(
# Normalize network to a simpler display label, if your config used long names
pl.col("network").cast(pl.Utf8)
)
# Group across folds
agg = (
uniq.group_by(
["network", "model", "latent_dim", "semi_normals", "semi_anomalous"]
)
.agg(
pl.len().alias("n_folds"),
pl.col("train_time").mean().alias("train_mean"),
pl.col("train_time").std(ddof=1).alias("train_std"),
pl.col("test_time").mean().alias("test_mean"),
pl.col("test_time").std(ddof=1).alias("test_std"),
pl.col("config_json")
.first()
.alias("config_json"), # one exemplar cfg per group
)
.sort(["semi_normals", "semi_anomalous", "latent_dim", "network", "model"])
)
return agg
def make_training_runtime_table(df: pl.DataFrame) -> str:
"""
Returns a LaTeX table (string) for TRAIN runtimes: mean ± std (seconds) across folds.
Rows: Semi (N/O), Latent Dim
Columns: methods split (DeepSAD/LeNet, DeepSAD/Efficient, IsoForest[/net], OCSVM[/net])
"""
agg = _prepare_aggregates(df)
# Prepare display strings and column keys
tbl = agg.with_columns(
pl.format("{}/{}", pl.col("semi_normals"), pl.col("semi_anomalous")).alias(
"semi"
),
pl.col("model").cast(pl.Utf8),
pl.col("network").cast(pl.Utf8),
pl.col("latent_dim").cast(pl.Int64),
# ADD return_dtype here
pl.struct(["train_mean", "train_std", "n_folds"])
.map_elements(
lambda s: _fmt_mean_std(s["train_mean"], s["train_std"], s["n_folds"]),
return_dtype=pl.Utf8,
)
.alias("train_fmt"),
# And here
pl.struct(["model", "network"])
.map_elements(
lambda s: _method_col_name(s["model"], s["network"]),
return_dtype=pl.Utf8,
)
.alias("method"),
).select("semi", "latent_dim", "method", "train_fmt")
# Pivot to wide form: one cell per (semi, latent_dim, method)
wide = tbl.pivot(
values="train_fmt",
index=["semi", "latent_dim"],
columns="method",
aggregate_function="first",
).sort(["semi", "latent_dim"])
# Fill missing with '-' and export
pdf = wide.fill_null("-").to_pandas()
pdf.index = pd.MultiIndex.from_frame(pdf[["semi", "latent_dim"]])
pdf = pdf.drop(columns=["semi", "latent_dim"])
latex = pdf.to_latex(
index=True,
escape=True,
na_rep="-",
multicolumn=True,
multicolumn_format="c",
bold_rows=False,
caption="Training runtime (seconds): mean ± std across folds (n in parentheses).",
label="tab:train_runtimes",
)
return latex
def make_inference_runtime_table(df: pl.DataFrame) -> str:
"""
Returns a LaTeX table (string) for TEST/INFERENCE runtimes: mean ± std (seconds) across folds.
Same layout as training table.
"""
agg = _prepare_aggregates(df)
tbl = agg.with_columns(
pl.format("{}/{}", pl.col("semi_normals"), pl.col("semi_anomalous")).alias(
"semi"
),
pl.col("model").cast(pl.Utf8),
pl.col("network").cast(pl.Utf8),
pl.col("latent_dim").cast(pl.Int64),
pl.struct(["test_mean", "test_std", "n_folds"])
.map_elements(
lambda s: _fmt_mean_std(s["test_mean"], s["test_std"], s["n_folds"]),
return_dtype=pl.Utf8,
)
.alias("test_fmt"),
pl.struct(["model", "network"])
.map_elements(
lambda s: _method_col_name(s["model"], s["network"]),
return_dtype=pl.Utf8,
)
.alias("method"),
).select("semi", "latent_dim", "method", "test_fmt")
wide = tbl.pivot(
values="test_fmt",
index=["semi", "latent_dim"],
columns="method",
aggregate_function="first",
).sort(["semi", "latent_dim"])
pdf = wide.fill_null("-").to_pandas()
pdf.index = pd.MultiIndex.from_frame(pdf[["semi", "latent_dim"]])
pdf = pdf.drop(columns=["semi", "latent_dim"])
latex = pdf.to_latex(
index=True,
escape=True,
na_rep="-",
multicolumn=True,
multicolumn_format="c",
bold_rows=False,
caption="Inference/Test runtime (seconds): mean ± std across folds (n in parentheses).",
label="tab:test_runtimes",
)
return latex
def make_longform_train_table_with_params(df: pl.DataFrame) -> str:
"""
(Optional) Long-form table that includes a 'Params' column extracted from config_json.
Useful if you want to show per-model settings alongside the runtimes.
"""
agg = _prepare_aggregates(df)
# Build params column from JSON for readability
long = (
agg.with_columns(
pl.format("{}/{}", pl.col("semi_normals"), pl.col("semi_anomalous")).alias(
"semi"
),
pl.col("latent_dim").cast(pl.Int64),
pl.struct(["model", "config_json"])
.map_elements(
lambda s: _key_params(s["model"], _parse_cfg(s["config_json"])),
return_dtype=pl.Utf8,
)
.alias("params"),
pl.struct(["train_mean", "train_std", "n_folds"])
.map_elements(
lambda s: _fmt_mean_std(s["train_mean"], s["train_std"], s["n_folds"])
)
.alias("train_time_fmt"),
)
.select(
"network",
"model",
"latent_dim",
"semi",
"params",
"train_time_fmt",
)
.sort(["semi", "latent_dim", "network", "model"])
)
pdf = long.to_pandas()
pdf.rename(
columns={
"network": "Network",
"model": "Method",
"latent_dim": "Latent Dim",
"semi": "Semi (N/O)",
"params": "Params",
"train_time_fmt": "Train time [s] (mean ± std)",
},
inplace=True,
)
latex = pdf.to_latex(
index=False,
escape=True,
longtable=False,
caption="Training runtime with key parameters.",
label="tab:train_runtimes_params",
)
return latex
def make_training_runtime_table_compact(df: pl.DataFrame) -> str:
per_fold = _prepare_per_fold_metrics(df)
# DeepSAD: keep LeNet vs Efficient, collapse semis
ds = (
per_fold.filter(pl.col("model") == "deepsad")
.group_by(["model", "network_disp", "latent_dim"])
.agg(
n=pl.len(),
train_mean=pl.mean("train_time"),
train_std=pl.std("train_time", ddof=1),
tpe_mean=pl.mean("time_per_epoch"),
tpe_std=pl.std("time_per_epoch", ddof=1),
)
.with_columns(
method=pl.format("DeepSAD / {}", pl.col("network_disp")),
)
)
# Baselines: collapse networks & semis; only vary by latent_dim
bl = (
per_fold.filter(pl.col("model").is_in(["isoforest", "ocsvm"]))
.group_by(["model", "latent_dim"])
.agg(
n=pl.len(),
train_mean=pl.mean("train_time"),
train_std=pl.std("train_time", ddof=1),
)
.with_columns(
method=pl.when(pl.col("model") == "isoforest")
.then(pl.lit("IsoForest"))
.when(pl.col("model") == "ocsvm")
.then(pl.lit("OCSVM"))
.otherwise(pl.lit("Baseline"))
)
)
# --- Standardize schemas before concat ---
ds_std = ds.select(
pl.col("latent_dim").cast(pl.Int64),
pl.col("method").cast(pl.Utf8),
pl.col("train_mean").cast(pl.Float64),
pl.col("train_std").cast(pl.Float64),
pl.col("tpe_mean").cast(pl.Float64),
pl.col("tpe_std").cast(pl.Float64),
pl.col("n").cast(pl.Int64),
)
bl_std = bl.select(
pl.col("latent_dim").cast(pl.Int64),
pl.col("method").cast(pl.Utf8),
pl.col("train_mean").cast(pl.Float64),
pl.col("train_std").cast(pl.Float64),
pl.lit(None, dtype=pl.Float64).alias("tpe_mean"),
pl.lit(None, dtype=pl.Float64).alias("tpe_std"),
pl.col("n").cast(pl.Int64),
)
agg = pl.concat([ds_std, bl_std], how="vertical")
# Format cell: total [s]; DeepSAD also appends (italic) per-epoch
def _fmt_train_cell(s: dict) -> str:
total = _fmt_mean_std_n(s["train_mean"], s["train_std"], s["n"], "s")
if s.get("tpe_mean") is None or (
isinstance(s.get("tpe_mean"), float) and np.isnan(s["tpe_mean"])
):
return total
tpe = _fmt_mean_std_n(s["tpe_mean"], s["tpe_std"], None, "s/epoch")
return f"{total} (\\textit{{{tpe}}})"
tbl = agg.with_columns(
pl.struct(["train_mean", "train_std", "tpe_mean", "tpe_std", "n"])
.map_elements(_fmt_train_cell, return_dtype=pl.Utf8)
.alias("train_fmt"),
).select("latent_dim", "method", "train_fmt")
# Pivot and order columns nicely
wide = tbl.pivot(
values="train_fmt",
index=["latent_dim"],
columns="method",
aggregate_function="first",
).sort("latent_dim")
pdf = wide.fill_null("-").to_pandas().set_index("latent_dim")
desired_cols = [
c
for c in ["DeepSAD / LeNet", "DeepSAD / Efficient", "IsoForest", "OCSVM"]
if c in pdf.columns
]
if desired_cols:
pdf = pdf.reindex(columns=desired_cols)
latex = pdf.to_latex(
index=True,
escape=True,
na_rep="-",
multicolumn=True,
multicolumn_format="c",
bold_rows=False,
caption="Training runtime: total seconds (mean ± std). DeepSAD cells also show \\textit{seconds per epoch} in parentheses.",
label="tab:train_runtimes_compact",
)
return latex
def make_inference_latency_table_compact(df: pl.DataFrame) -> str:
per_fold = _prepare_per_fold_metrics(df)
# DeepSAD: keep networks; collapse semis
ds = (
per_fold.filter(pl.col("model") == "deepsad")
.group_by(["model", "network_disp", "latent_dim"])
.agg(
n=pl.len(),
lat_mean=pl.mean("latency_ms"),
lat_std=pl.std("latency_ms", ddof=1),
)
.with_columns(
method=pl.format("DeepSAD / {}", pl.col("network_disp")),
)
)
# Baselines: collapse networks & semis
bl = (
per_fold.filter(pl.col("model").is_in(["isoforest", "ocsvm"]))
.group_by(["model", "latent_dim"])
.agg(
n=pl.len(),
lat_mean=pl.mean("latency_ms"),
lat_std=pl.std("latency_ms", ddof=1),
)
.with_columns(
method=pl.when(pl.col("model") == "isoforest")
.then(pl.lit("IsoForest"))
.when(pl.col("model") == "ocsvm")
.then(pl.lit("OCSVM"))
.otherwise(pl.lit("Baseline"))
)
)
# --- Standardize schemas before concat ---
ds_std = ds.select(
pl.col("latent_dim").cast(pl.Int64),
pl.col("method").cast(pl.Utf8),
pl.col("lat_mean").cast(pl.Float64),
pl.col("lat_std").cast(pl.Float64),
pl.col("n").cast(pl.Int64),
)
bl_std = bl.select(
pl.col("latent_dim").cast(pl.Int64),
pl.col("method").cast(pl.Utf8),
pl.col("lat_mean").cast(pl.Float64),
pl.col("lat_std").cast(pl.Float64),
pl.col("n").cast(pl.Int64),
)
agg = pl.concat([ds_std, bl_std], how="vertical")
def _fmt_lat_cell(s: dict) -> str:
return _fmt_mean_std_n(s["lat_mean"], s["lat_std"], s["n"], "ms")
tbl = agg.with_columns(
pl.struct(["lat_mean", "lat_std", "n"])
.map_elements(_fmt_lat_cell, return_dtype=pl.Utf8)
.alias("lat_fmt"),
).select("latent_dim", "method", "lat_fmt")
wide = tbl.pivot(
values="lat_fmt",
index=["latent_dim"],
columns="method",
aggregate_function="first",
).sort("latent_dim")
pdf = wide.fill_null("-").to_pandas().set_index("latent_dim")
desired_cols = [
c
for c in ["DeepSAD / LeNet", "DeepSAD / Efficient", "IsoForest", "OCSVM"]
if c in pdf.columns
]
if desired_cols:
pdf = pdf.reindex(columns=desired_cols)
latex = pdf.to_latex(
index=True,
escape=True,
na_rep="-",
multicolumn=True,
multicolumn_format="c",
bold_rows=False,
caption="Inference latency (ms/sample): mean ± std across folds; baselines collapsed across networks and semi-labeling.",
label="tab:inference_latency_compact",
)
return latex
def make_ae_pretraining_runtime_table(df_pre: pl.DataFrame) -> str:
"""
LaTeX table: Autoencoder (pretraining) runtime per latent dim.
Rows: latent_dim
Cols: AE / LeNet, AE / Efficient (mean ± std seconds across folds)
"""
# minimal columns we need
base = df_pre.select(
pl.col("network").cast(pl.Utf8),
pl.col("latent_dim").cast(pl.Int64),
pl.col("fold").cast(pl.Int64),
pl.col("train_time").cast(pl.Float64),
).drop_nulls(subset=["network", "latent_dim", "train_time"])
# Nice display label for network
network_disp = (
pl.when(pl.col("network").str.contains("efficient"))
.then(pl.lit("Efficient"))
.when(pl.col("network").str.contains("LeNet"))
.then(pl.lit("LeNet"))
.otherwise(pl.col("network"))
.alias("network_disp")
)
agg = (
base.with_columns(network_disp)
.group_by(["network_disp", "latent_dim"])
.agg(
n=pl.len(),
train_mean=pl.mean("train_time"),
train_std=pl.std("train_time", ddof=1),
)
.with_columns(
pl.format("AE / {}", pl.col("network_disp")).alias("method"),
pl.struct(["train_mean", "train_std", "n"])
.map_elements(
lambda s: _fmt_mean_std(s["train_mean"], s["train_std"], s["n"]),
return_dtype=pl.Utf8,
)
.alias("train_fmt"),
)
.select("latent_dim", "method", "train_fmt")
.sort(["latent_dim", "method"])
)
wide = agg.pivot(
values="train_fmt",
index=["latent_dim"],
columns="method",
aggregate_function="first",
).sort("latent_dim")
pdf = wide.fill_null("-").to_pandas().set_index("latent_dim")
# Order columns if both exist
desired = [
c for c in ["Autoencoder LeNet", "Autoencoder Efficient"] if c in pdf.columns
]
if desired:
pdf = pdf.reindex(columns=desired)
latex = pdf.to_latex(
index=True,
escape=True,
na_rep="-",
multicolumn=True,
multicolumn_format="c",
bold_rows=False,
caption="Autoencoder pretraining runtime (seconds): mean ± std across folds.",
label="tab:ae_pretrain_runtimes",
)
return latex
# ----------------------------
# Main
# ----------------------------
def main():
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# Main results
df = load_results_dataframe(RESULTS_ROOT, allow_cache=True)
if "config_json" not in df.columns:
df = df.with_columns(pl.lit(None).alias("config_json"))
# AE pretraining results
df_pre = load_pretraining_results_dataframe(RESULTS_ROOT, allow_cache=True)
# Build LaTeX tables
latex_train = make_training_runtime_table(df)
latex_test = make_inference_runtime_table(df)
latex_train_params = make_longform_train_table_with_params(df)
latex_ae = make_ae_pretraining_runtime_table(df_pre)
# Timestamped output dirs
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
ts_dir = OUTPUT_DIR / "archive" / ts
ts_dir.mkdir(parents=True, exist_ok=True)
# Write files
(ts_dir / "train_runtimes.tex").write_text(latex_train)
(ts_dir / "test_runtimes.tex").write_text(latex_test)
(ts_dir / "train_runtimes_with_params.tex").write_text(latex_train_params)
(ts_dir / "ae_pretraining_runtimes.tex").write_text(latex_ae)
# Save script & mirror latest
script_path = Path(__file__)
shutil.copy2(script_path, ts_dir)
latest = OUTPUT_DIR / "latest"
latest.mkdir(exist_ok=True, parents=True)
for f in ts_dir.iterdir():
if f.is_file():
shutil.copy2(f, latest / f.name)
print(f"Saved LaTeX tables to: {ts_dir}")
print(f"Also updated: {latest}")
if __name__ == "__main__":
main()