NAAZ1605's picture
Prepare ISL command recognition project
e2d95ed
Raw History Blame Contribute Delete
21.1 kB
#!/usr/bin/env python3
"""
PHASE 7 -- evaluation and three-model comparison.
For every trained model (LSTM, GRU, GRU+MHA) this script evaluates the *same*
frozen test set and writes:
results/per_class_comparison.csv class, <model>_precision/recall/f1/support
results/summary_metrics.csv accuracy, macro/weighted P/R/F1, params, latency, FPS
results/confusion_<model>.png raw counts + row-normalised confusion matrices
results/model_comparison.png accuracy / macro-F1 / weighted-F1 bars
results/latency_comparison.png single-sample latency and throughput
results/evaluation_summary_<protocol>.json full detail (top confusions, failures)
Fairness: no augmentation or test-time tricks are applied here. Every model sees
exactly ``X_test`` from the same ``sequences_<protocol>.npz`` and the same class
mapping, so the only difference between columns is the architecture.
Usage
-----
python training/evaluate_models.py # default protocol
python training/evaluate_models.py --protocol official # leaked-split comparison
python training/evaluate_models.py --models lstm gru_mha
"""
from __future__ import annotations
import argparse
import csv
import json
import logging
import sys
import time
from collections import Counter
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import config # noqa: E402
from training import models as model_zoo # noqa: E402
from training import preprocess as prep # noqa: E402
LOG = logging.getLogger("evaluate")
import matplotlib # noqa: E402
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
# --------------------------------------------------------------------------
# metrics (computed directly - no dependency on sklearn for the core numbers)
# --------------------------------------------------------------------------
def confusion_matrix(y_true: np.ndarray, y_pred: np.ndarray, k: int) -> np.ndarray:
cm = np.zeros((k, k), dtype=np.int64)
for t, p in zip(y_true, y_pred):
cm[int(t), int(p)] += 1
return cm
def per_class_metrics(cm: np.ndarray) -> Dict[str, np.ndarray]:
tp = np.diag(cm).astype(np.float64)
fp = cm.sum(axis=0) - tp
fn = cm.sum(axis=1) - tp
precision = np.divide(tp, tp + fp, out=np.zeros_like(tp), where=(tp + fp) > 0)
recall = np.divide(tp, tp + fn, out=np.zeros_like(tp), where=(tp + fn) > 0)
f1 = np.divide(2 * precision * recall, precision + recall,
out=np.zeros_like(tp), where=(precision + recall) > 0)
support = cm.sum(axis=1).astype(np.int64)
return {"precision": precision, "recall": recall, "f1": f1, "support": support}
def aggregate(m: Dict[str, np.ndarray], y_true: np.ndarray) -> Dict[str, float]:
n = int(y_true.size)
present = m["support"] > 0
macro = {k: float(m[k][present].mean()) if present.any() else 0.0
for k in ("precision", "recall", "f1")}
weights = m["support"].astype(np.float64) / max(1, n)
weighted = {k: float((m[k] * weights).sum()) for k in ("precision", "recall", "f1")}
return {
"macro_precision": macro["precision"], "macro_recall": macro["recall"], "macro_f1": macro["f1"],
"weighted_precision": weighted["precision"], "weighted_recall": weighted["recall"],
"weighted_f1": weighted["f1"], "n_samples": n,
}
# --------------------------------------------------------------------------
# plotting
# --------------------------------------------------------------------------
def plot_confusion(cm: np.ndarray, classes: Sequence[str], model_name: str, out: Path) -> Path:
acc = float(np.trace(cm) / max(1, cm.sum()))
norm = cm.astype(np.float64) / np.maximum(1, cm.sum(axis=1, keepdims=True))
fig, axes = plt.subplots(1, 2, figsize=(22, 10))
for ax, mat, title, fmt in ((axes[0], cm, f"{model_name}: counts (acc={acc:.3f})", "d"),
(axes[1], norm, f"{model_name}: row-normalised", ".2f")):
im = ax.imshow(mat, cmap="Blues", vmin=0, vmax=(mat.max() if mat.max() > 0 else 1))
ax.set_xticks(range(len(classes)))
ax.set_yticks(range(len(classes)))
ax.set_xticklabels(classes, rotation=90, fontsize=5)
ax.set_yticklabels(classes, fontsize=5)
ax.set_xlabel("predicted")
ax.set_ylabel("true")
ax.set_title(title, fontsize=11)
fig.colorbar(im, ax=ax, fraction=0.046)
lim = len(classes)
if lim <= 50:
for i in range(lim):
for j in range(lim):
v = mat[i, j]
if v:
txt = f"{int(v)}" if fmt == "d" else f"{v:.2f}"
ax.text(j, i, txt, ha="center", va="center", fontsize=3.6,
color="white" if mat[i, j] > mat.max() * 0.6 else "black")
fig.tight_layout()
out.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(out, dpi=150)
plt.close(fig)
return out
def plot_model_comparison(summary: Dict[str, Dict[str, float]], out: Path) -> Path:
names = list(summary.keys())
metrics = ["accuracy", "macro_precision", "macro_recall", "macro_f1",
"weighted_precision", "weighted_recall", "weighted_f1"]
x = np.arange(len(metrics))
w = 0.8 / max(1, len(names))
fig, ax = plt.subplots(figsize=(12, 5.5))
for i, n in enumerate(names):
vals = [summary[n].get(m, 0.0) for m in metrics]
bars = ax.bar(x + i * w, vals, w, label=n)
for b, v in zip(bars, vals):
ax.text(b.get_x() + b.get_width() / 2, v + 0.008, f"{v:.3f}",
ha="center", fontsize=6.5, rotation=90)
ax.set_xticks(x + 0.4 - w / 2)
ax.set_xticklabels([m.replace("_", "\n") for m in metrics], fontsize=8)
ax.set_ylim(0, 1.05)
ax.set_ylabel("score")
ax.set_title("LSTM vs GRU vs GRU+MHA on the identical frozen test set")
ax.legend()
ax.grid(axis="y", alpha=0.3)
fig.tight_layout()
fig.savefig(out, dpi=150)
plt.close(fig)
return out
def plot_latency(summary: Dict[str, Dict[str, float]], out: Path) -> Path:
names = list(summary.keys())
x = np.arange(len(names))
fig, axes = plt.subplots(1, 2, figsize=(12, 4.6))
lat = [summary[n].get("latency_ms_mean", 0.0) for n in names]
p95 = [summary[n].get("latency_ms_p95", 0.0) for n in names]
fps = [summary[n].get("throughput_fps", 0.0) for n in names]
axes[0].bar(x - 0.2, lat, 0.4, label="mean latency (ms)")
axes[0].bar(x + 0.2, p95, 0.4, label="p95 latency (ms)")
axes[0].set_xticks(x)
axes[0].set_xticklabels(names)
axes[0].set_ylabel("milliseconds")
axes[0].set_title("Single-clip inference latency (CPU)")
axes[0].legend()
for i, v in enumerate(lat):
axes[0].text(i - 0.2, v, f"{v:.1f}", ha="center", va="bottom", fontsize=7)
for i, v in enumerate(p95):
axes[0].text(i + 0.2, v, f"{v:.1f}", ha="center", va="bottom", fontsize=7)
axes[1].bar(x, fps, 0.5, color="tab:green")
axes[1].set_xticks(x)
axes[1].set_xticklabels(names)
axes[1].set_ylabel("sequences / second")
axes[1].set_title("Throughput")
for i, v in enumerate(fps):
axes[1].text(i, v, f"{v:.1f}", ha="center", va="bottom", fontsize=7)
for ax in axes:
ax.grid(axis="y", alpha=0.3)
fig.tight_layout()
fig.savefig(out, dpi=150)
plt.close(fig)
return out
def plot_history(model_name: str, out: Path) -> Optional[Path]:
"""Training-history curves straight from ``models/<name>/training_history.json``."""
p = Path(config.MODEL_PATHS[model_name]) / "training_history.json"
if not p.exists():
return None
h = json.loads(p.read_text())["history"]
epochs = range(1, len(h.get("loss", [])) + 1)
fig, axes = plt.subplots(1, 2, figsize=(12, 4.4))
axes[0].plot(epochs, h.get("accuracy", []), label="train")
axes[0].plot(epochs, h.get("val_accuracy", []), label="val")
axes[0].set_title(f"{model_name}: accuracy")
axes[0].set_xlabel("epoch")
axes[0].legend()
axes[1].plot(epochs, h.get("loss", []), label="train")
axes[1].plot(epochs, h.get("val_loss", []), label="val")
axes[1].set_title(f"{model_name}: loss")
axes[1].set_xlabel("epoch")
axes[1].legend()
for ax in axes:
ax.grid(alpha=0.3)
fig.tight_layout()
out.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(out, dpi=150)
plt.close(fig)
return out
# --------------------------------------------------------------------------
# model loading / timing
# --------------------------------------------------------------------------
def load_model(model_name: str):
import tensorflow as tf
for fname in ("model.keras", "best.keras"):
p = Path(config.MODEL_PATHS[model_name]) / fname
if p.exists():
return tf.keras.models.load_model(str(p)), p
raise FileNotFoundError(
f"no trained model in {config.MODEL_PATHS[model_name]} - run: python training/train_{model_name}.py"
)
def measure_latency(model, X: np.ndarray, repeats: int = 3) -> Dict[str, float]:
"""Single-sample latency (what real-time inference pays) + batched throughput."""
sample = X[:1].astype(np.float32)
model.predict_on_batch(sample) # warm-up, excluded from timing
per_sample: List[float] = []
for i in range(min(repeats, len(X))):
t0 = time.perf_counter()
model.predict_on_batch(X[i:i + 1].astype(np.float32))
per_sample.append((time.perf_counter() - t0) * 1000.0)
t0 = time.perf_counter()
model.predict_on_batch(X.astype(np.float32))
batch_ms = (time.perf_counter() - t0) * 1000.0
return {
"latency_ms_mean": float(np.mean(per_sample)),
"latency_ms_p50": float(np.percentile(per_sample, 50)),
"latency_ms_p95": float(np.percentile(per_sample, 95)),
"latency_ms_batch_per_sample": batch_ms / max(1, len(X)),
"throughput_fps": float(1000.0 / np.mean(per_sample)) if per_sample else 0.0,
"batch_throughput_fps": float(len(X) / (batch_ms / 1000.0)) if batch_ms > 0 else 0.0,
}
def measure_per_class_latency(model, X: np.ndarray, y: np.ndarray,
classes: Sequence[str]) -> Dict[str, Dict[str, float]]:
"""Measure one warmed, real single-sequence inference per held-out test clip.
Per-class means are descriptive timing of the actual examples in that class,
not a claim that the output label changes model compute cost. Small support is
preserved for the UI to show beside every number.
"""
X = np.asarray(X, dtype=np.float32)
y = np.asarray(y, dtype=np.int64)
model.predict_on_batch(X[:1])
elapsed_ms = np.empty(len(X), dtype=np.float64)
for i in range(len(X)):
start = time.perf_counter()
model.predict_on_batch(X[i:i + 1])
elapsed_ms[i] = (time.perf_counter() - start) * 1000.0
result: Dict[str, Dict[str, float]] = {}
for i, label in enumerate(classes):
values = elapsed_ms[y == i]
result[str(label)] = {
"mean_ms": float(values.mean()) if values.size else 0.0,
"p50_ms": float(np.percentile(values, 50)) if values.size else 0.0,
"samples": int(values.size),
}
return result
# --------------------------------------------------------------------------
# main evaluation
# --------------------------------------------------------------------------
def evaluate(protocol: str, model_names: Sequence[str], out_dir: Path,
results_dir: Path, suffix: str = "", repeats: int = 3,
split: str = "test") -> Dict[str, object]:
data = prep.load_processed(protocol)
X = data.get(f"X_{split}")
y = data.get(f"y_{split}")
classes = [str(c) for c in data["classes"]]
k = len(classes)
if X is None or y is None:
raise KeyError(f"npz has no X_{split}/y_{split}")
LOG.info("protocol=%s split=%s X=%s classes=%d", protocol, split, X.shape, k)
per_class: Dict[str, Dict[str, np.ndarray]] = {}
summary_rows: Dict[str, Dict[str, object]] = {}
detail: Dict[str, object] = {"protocol": protocol, "split": split,
"n_samples": int(len(y)), "classes": classes,
"models": {}}
for name in model_names:
model, path = load_model(name)
params = model_zoo.parameter_count(model)
probs = model.predict(X.astype(np.float32), batch_size=config.BATCH_SIZE, verbose=0)
pred = np.argmax(probs, axis=1)
cm = confusion_matrix(y, pred, k)
m = per_class_metrics(cm)
agg = aggregate(m, y)
acc = float(np.trace(cm) / max(1, cm.sum()))
timing = measure_latency(model, X, repeats=repeats)
class_timing = measure_per_class_latency(model, X, y, classes)
per_class[name] = m
summary_rows[name] = {
"model": name, "protocol": protocol, "split": split, "n_samples": int(len(y)),
"n_classes": k, "parameters": params, "accuracy": acc,
"macro_precision": agg["macro_precision"], "macro_recall": agg["macro_recall"],
"macro_f1": agg["macro_f1"], "weighted_precision": agg["weighted_precision"],
"weighted_recall": agg["weighted_recall"], "weighted_f1": agg["weighted_f1"],
"latency_ms_mean": timing["latency_ms_mean"], "latency_ms_p50": timing["latency_ms_p50"],
"latency_ms_p95": timing["latency_ms_p95"],
"latency_ms_batch_per_sample": timing["latency_ms_batch_per_sample"],
"throughput_fps": timing["throughput_fps"],
"batch_throughput_fps": timing["batch_throughput_fps"],
}
plot_confusion(cm, classes, name, results_dir / f"confusion_{name}{suffix}.png")
plot_history(name, results_dir / f"training_history_{name}{suffix}.png")
# --- error analysis: most frequent confusions + worst classes -----------
off = [(classes[i], classes[j], int(cm[i, j]))
for i in range(k) for j in range(k) if i != j and cm[i, j] > 0]
off.sort(key=lambda t: -t[2])
weakest = sorted(
[{"class": classes[i], "recall": float(m["recall"][i]),
"precision": float(m["precision"][i]), "f1": float(m["f1"][i]),
"support": int(m["support"][i])}
for i in range(k) if m["support"][i] > 0],
key=lambda d: d["f1"],
)
detail["models"][name] = { # type: ignore[index]
"weights": str(path),
"metrics": summary_rows[name],
"per_class_latency": class_timing,
"top_confusions": [{"true": t, "predicted": p, "count": c} for t, p, c in off[:20]],
"weakest_classes": weakest[:10],
"strongest_classes": weakest[-5:][::-1],
"confusion_matrix": cm.tolist(),
}
LOG.info("%-8s acc=%.4f macro_f1=%.4f weighted_f1=%.4f params=%d lat=%.1fms",
name, acc, agg["macro_f1"], agg["weighted_f1"], params, timing["latency_ms_mean"])
# ---------------- per_class_comparison.csv (wide format) ----------------
header = ["class"]
for name in model_names:
header += [f"{name}_precision", f"{name}_recall", f"{name}_accuracy", f"{name}_f1", f"{name}_support",
f"{name}_latency_ms_mean", f"{name}_latency_samples"]
rows = []
for i, cname in enumerate(classes):
row = {"class": cname}
for name in model_names:
m = per_class[name]
row[f"{name}_precision"] = round(float(m["precision"][i]), 6)
row[f"{name}_recall"] = round(float(m["recall"][i]), 6)
# For a single true sign, within-sign accuracy is correct predictions
# divided by the held-out clips for that sign; this equals recall.
row[f"{name}_accuracy"] = round(float(m["recall"][i]), 6)
row[f"{name}_f1"] = round(float(m["f1"][i]), 6)
row[f"{name}_support"] = int(m["support"][i])
per_class_time = detail["models"][name]["per_class_latency"][cname] # type: ignore[index]
row[f"{name}_latency_ms_mean"] = round(per_class_time["mean_ms"], 4)
row[f"{name}_latency_samples"] = per_class_time["samples"]
rows.append(row)
per_class_path = out_dir / f"per_class_comparison{suffix}.csv"
with open(per_class_path, "w", newline="", encoding="utf-8") as f:
wr = csv.DictWriter(f, fieldnames=header)
wr.writeheader()
wr.writerows(rows)
# ---------------- summary_metrics.csv ----------------------------------
fields = ["model", "protocol", "split", "n_samples", "n_classes", "parameters", "accuracy",
"macro_precision", "macro_recall", "macro_f1",
"weighted_precision", "weighted_recall", "weighted_f1",
"latency_ms_mean", "latency_ms_p50", "latency_ms_p95",
"latency_ms_batch_per_sample", "throughput_fps", "batch_throughput_fps"]
summary_path = out_dir / f"summary_metrics{suffix}.csv"
with open(summary_path, "w", newline="", encoding="utf-8") as f:
wr = csv.DictWriter(f, fieldnames=fields)
wr.writeheader()
for name in model_names:
wr.writerow({k: (round(v, 6) if isinstance(v, float) else v)
for k, v in summary_rows[name].items() if k in fields})
# ---------------- comparison plots --------------------------------------
plot_model_comparison(summary_rows, results_dir / f"model_comparison{suffix}.png")
plot_latency(summary_rows, results_dir / f"latency_comparison{suffix}.png")
detail["ranking"] = sorted(
({"model": n, "accuracy": summary_rows[n]["accuracy"],
"macro_f1": summary_rows[n]["macro_f1"],
"weighted_f1": summary_rows[n]["weighted_f1"],
"parameters": summary_rows[n]["parameters"],
"latency_ms_mean": summary_rows[n]["latency_ms_mean"]} for n in model_names),
key=lambda d: -d["macro_f1"],
)
detail["best_model_by_macro_f1"] = detail["ranking"][0]["model"] if detail["ranking"] else None
detail["generated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
detail_path = results_dir / f"evaluation_summary_{protocol}.json"
detail_path.write_text(json.dumps(detail, indent=2))
return {"per_class_csv": per_class_path, "summary_csv": summary_path,
"summary": summary_rows, "detail_json": detail_path,
"ranking": detail["ranking"]}
def _print_report(res: Dict[str, object]) -> None:
summary: Dict[str, Dict[str, object]] = res["summary"] # type: ignore[assignment]
print("\n=== Summary metrics (identical frozen test set) ===")
print(f"{'model':<9}{'acc':>8}{'macroP':>9}{'macroR':>9}{'macroF1':>9}"
f"{'wF1':>9}{'params':>10}{'lat ms':>9}{'FPS':>7}")
for name, s in summary.items():
print(f"{name:<9}{s['accuracy']:>8.4f}{s['macro_precision']:>9.4f}{s['macro_recall']:>9.4f}"
f"{s['macro_f1']:>9.4f}{s['weighted_f1']:>9.4f}{s['parameters']:>10d}"
f"{s['latency_ms_mean']:>9.2f}{s['throughput_fps']:>7.2f}")
rank = res["ranking"] # type: ignore[assignment]
if rank:
print(f"\nbest by macro-F1: {rank[0]['model']} "
f"(macro-F1={rank[0]['macro_f1']:.4f}, acc={rank[0]['accuracy']:.4f})")
def main(argv: Optional[Sequence[str]] = None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--protocol", default=config.SPLIT_PROTOCOL,
choices=("session_disjoint", "official", "hf"))
ap.add_argument("--models", nargs="+", default=list(config.AVAILABLE_MODELS))
ap.add_argument("--split", default="test", choices=("val", "test"))
ap.add_argument("--results-dir", default=str(config.RESULTS_DIR))
ap.add_argument("--suffix", default=None,
help="filename suffix; defaults to '' for the primary protocol "
"and '_<protocol>' for any other protocol")
ap.add_argument("--repeats", type=int, default=3)
args = ap.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
results_dir = Path(args.results_dir)
results_dir.mkdir(parents=True, exist_ok=True)
suffix = args.suffix if args.suffix is not None else (
"" if args.protocol == config.SPLIT_PROTOCOL else f"_{args.protocol}")
res = evaluate(args.protocol, args.models, results_dir, results_dir,
suffix=suffix, repeats=args.repeats, split=args.split)
_print_report(res)
print(f"\nwrote {res['per_class_csv']}\nwrote {res['summary_csv']}\nwrote {res['detail_json']}")
print(f"plots: confusion_*{suffix}.png, model_comparison{suffix}.png, latency_comparison{suffix}.png")
return 0
if __name__ == "__main__":
raise SystemExit(main())