Files
hypertower/scripts/exploratory/grid_search_analytics/derived_analysis.py
T

1827 lines
67 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Derived analysis wrapper for grid search analytics."""
from __future__ import annotations
import json
import math
import re
from pathlib import Path
from typing import Dict, Iterable, Iterator, List, Optional
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
from tqdm import tqdm
class derived_analysis:
def __init__(
self,
analysis_dir: Path | str,
exclude_keys: Optional[Iterable[str]] = None,
classification_mode: str = "binary",
) -> None:
self.analysis_dir = Path(analysis_dir)
if not self.analysis_dir.exists():
raise FileNotFoundError(f"analysis_dir does not exist: {self.analysis_dir}")
mode = str(classification_mode).strip().lower()
if mode not in {"binary", "multiclass"}:
raise ValueError(
f"classification_mode must be 'binary' or 'multiclass' (got {classification_mode!r})"
)
self.classification_mode = mode
self.exclude_keys = set(exclude_keys or [])
self.fusion_corrections = pd.DataFrame()
self.fusion_errors = pd.DataFrame()
self.statistics_df = pd.DataFrame()
self.primary_metrics = pd.DataFrame()
self.fusion_performance_corr = pd.DataFrame()
self.param_perf_corr = pd.DataFrame()
self.se_mode_effects = {}
def identify_fusion_corrections(
self, shallow: bool = True, existing: bool = True
) -> pd.DataFrame:
cache_path = self._fusion_corrections_path()
if existing and cache_path.exists():
df = self._read_fusion_corrections(cache_path)
self.fusion_corrections = df
errors_path = self._fusion_errors_path()
if errors_path.exists():
self.fusion_errors = self._read_fusion_errors(errors_path)
else:
self.fusion_errors = pd.DataFrame()
self.statistics_df = self._build_statistics_df(df, shallow=shallow)
return self.fusion_corrections
rows: List[Dict[str, object]] = []
error_rows: List[Dict[str, object]] = []
stats_rows: List[Dict[str, object]] = []
for run_dir in self._iter_run_dirs(shallow=shallow, show_progress=True):
summary = self._read_summary(run_dir)
cli = self._read_cli_args(run_dir)
mode = self._infer_mode(summary, cli)
if mode != self.classification_mode:
continue
run_id = self._read_run_id(run_dir, summary)
folds = self._available_folds(run_dir, summary)
run_count = 0
grid_params = self._grid_params_from_cli(cli, summary)
for fold in folds:
y_true = self._load_y_true(run_dir, fold)
if y_true is None:
continue
epoch_prob_paths = self._collect_epoch_prob_paths(run_dir, fold)
if not epoch_prob_paths:
base_paths = self._collect_base_prob_paths(run_dir, fold)
if base_paths:
epoch_hint = self._fold_epoch_hint(summary, fold)
epoch_prob_paths = {
epoch_hint if epoch_hint is not None else 0: base_paths
}
for epoch, paths in epoch_prob_paths.items():
arrays = {
head: self._load_probs_array(path)
for head, path in paths.items()
}
if not self._has_all_heads(arrays):
continue
events = self._fusion_corrections_for_probs(
y_true, arrays, run_id, fold, epoch
)
run_count += len(events)
rows.extend(events)
errors = self._fusion_errors_for_probs(
y_true, arrays, run_id, fold, epoch
)
error_rows.extend(errors)
stats_rows.append(
{
"run_id": run_id,
"run_dir": str(run_dir),
"classification_mode": mode,
"fusion_corrections": int(run_count),
**grid_params,
}
)
self.fusion_corrections = pd.DataFrame(rows)
self.fusion_errors = pd.DataFrame(error_rows)
self.statistics_df = pd.DataFrame(stats_rows)
return self.fusion_corrections
def write_fusion_corrections(self, output_path: Path | str | None = None) -> Path:
if self.fusion_corrections.empty:
self.identify_fusion_corrections(existing=True)
path = Path(output_path) if output_path else self._fusion_corrections_path()
path.parent.mkdir(parents=True, exist_ok=True)
self.fusion_corrections.to_csv(path, index=False)
return path
def write_fusion_errors(self, output_path: Path | str | None = None) -> Path:
if self.fusion_errors.empty:
self.identify_fusion_corrections(existing=True)
path = Path(output_path) if output_path else self._fusion_errors_path()
path.parent.mkdir(parents=True, exist_ok=True)
self.fusion_errors.to_csv(path, index=False)
return path
def populate_primary_metrics(
self, shallow: bool = True, show_progress: bool = True, existing: bool = True
) -> pd.DataFrame:
cache_path = self._primary_metrics_path()
if existing and cache_path.exists():
df = self._read_primary_metrics(cache_path)
self.primary_metrics = df
summary_df = self._aggregate_primary_metrics(df)
if summary_df.empty:
if self.statistics_df.empty:
self.statistics_df = summary_df
else:
if self.statistics_df.empty:
self.statistics_df = summary_df
else:
self.statistics_df = self.statistics_df.merge(
summary_df,
on=["run_id", "run_dir", "classification_mode"],
how="left",
)
return self.primary_metrics
rows: List[Dict[str, object]] = []
for run_dir in self._iter_run_dirs(
shallow=shallow, show_progress=show_progress
):
summary = self._read_summary(run_dir)
cli = self._read_cli_args(run_dir)
mode = self._infer_mode(summary, cli)
if mode != self.classification_mode:
continue
run_id = self._read_run_id(run_dir, summary)
folds = self._available_folds(run_dir, summary)
if not folds:
continue
for fold in folds:
log_df = self._read_epoch_log(run_dir, fold)
best_epoch, holdout_best_epoch = self._extract_best_epochs(log_df)
if best_epoch is None or holdout_best_epoch is None:
continue
row: Dict[str, object] = {
"run_id": run_id,
"run_dir": str(run_dir),
"classification_mode": mode,
"fold": int(fold),
"best_epoch": int(best_epoch),
"holdout_best_epoch": int(holdout_best_epoch),
}
for head_key, log_suffix, roc_suffix in (
("fused", "fused", "fused"),
("image", "img", "image"),
("metadata", "md", "metadata"),
):
best_auc = self._load_auc_for_epoch(
run_dir, fold, best_epoch, roc_suffix, holdout=False
)
hold_auc = self._load_auc_for_epoch(
run_dir, fold, holdout_best_epoch, roc_suffix, holdout=True
)
row[f"best_auc_{head_key}"] = best_auc
row[f"holdout_best_auc_{head_key}"] = hold_auc
if log_df is not None:
best_row = self._row_for_epoch(log_df, best_epoch)
hold_row = self._row_for_epoch(log_df, holdout_best_epoch)
best_acc = self._metric_from_row(best_row, f"acc_{log_suffix}")
hold_acc = self._metric_from_row(
hold_row, f"holdout_acc_{log_suffix}"
)
row[f"best_acc_{head_key}"] = best_acc
row[f"holdout_best_acc_{head_key}"] = hold_acc
rows.append(row)
self.primary_metrics = pd.DataFrame(rows)
summary_df = self._aggregate_primary_metrics(self.primary_metrics)
if summary_df.empty:
if self.statistics_df.empty:
self.statistics_df = summary_df
else:
if self.statistics_df.empty:
self.statistics_df = summary_df
else:
self.statistics_df = self.statistics_df.merge(
summary_df,
on=["run_id", "run_dir", "classification_mode"],
how="left",
)
return self.primary_metrics
def write_primary_metrics(self, output_path: Path | str | None = None) -> Path:
if self.primary_metrics.empty:
self.populate_primary_metrics()
path = Path(output_path) if output_path else self._primary_metrics_path()
path.parent.mkdir(parents=True, exist_ok=True)
self.primary_metrics.to_csv(path, index=False)
return path
def plot_fusion_corrections_errors(
self,
output_path: Path | str | None = None,
shallow: bool = True,
existing: bool = True,
top_n: int | None = None,
) -> pd.DataFrame:
if self.fusion_corrections.empty:
self.identify_fusion_corrections(shallow=shallow, existing=existing)
if self.fusion_corrections.empty:
raise RuntimeError(
"fusion_corrections is empty; run identify_fusion_corrections() first."
)
corrections = (
self.fusion_corrections.groupby("run_id")
.size()
.rename("fusion_corrections")
)
if self.fusion_errors.empty:
errors = corrections.copy() * 0
errors.name = "fusion_errors"
else:
errors = self.fusion_errors.groupby("run_id").size().rename("fusion_errors")
df = pd.concat([corrections, errors], axis=1).fillna(0).reset_index()
df = df.sort_values(by="run_id")
if top_n is not None:
df = df.head(int(top_n))
out_path = (
Path(output_path)
if output_path
else (
self.analysis_dir
/ "plots"
/ f"fusion_corrections_errors_{self.classification_mode}.png"
)
)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig, ax = plt.subplots(figsize=(10, 4.8))
x = np.arange(len(df))
ax.bar(
x,
df["fusion_corrections"],
color="steelblue",
width=1.0,
label="fusion_corrections",
)
ax.bar(
x,
df["fusion_errors"],
bottom=df["fusion_corrections"],
color="tomato",
width=1.0,
label="fusion_errors",
)
ax.set_xticks([])
ax.set_ylabel("Count")
ax.set_title(
f"Fusion corrections + errors per run ({self.classification_mode})"
)
ax.legend(loc="upper right")
ax.grid(True, axis="y", alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig(out_path, dpi=170)
plt.close(fig)
return df
def plot_conf_delta_boxplot(
self,
output_path: Path | str | None = None,
shallow: bool = True,
existing: bool = True,
top_n: int | None = None,
) -> pd.DataFrame:
if self.fusion_corrections.empty:
self.identify_fusion_corrections(shallow=shallow, existing=existing)
if self.fusion_corrections.empty:
raise RuntimeError(
"fusion_corrections is empty; run identify_fusion_corrections() first."
)
df = self.fusion_corrections.copy()
df["conf_delta"] = df["conf_fused"] - 0.5 * (df["conf_img"] + df["conf_md"])
mean_order = df.groupby("run_id")["conf_delta"].mean().sort_values()
run_order = mean_order.index.tolist()
if top_n is not None:
run_order = run_order[: int(top_n)]
data = [
df.loc[df["run_id"] == run_id, "conf_delta"].values for run_id in run_order
]
out_path = (
Path(output_path)
if output_path
else (
self.analysis_dir
/ "plots"
/ f"conf_delta_box_{self.classification_mode}.png"
)
)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig, ax = plt.subplots(figsize=(10, 4.8))
ax.boxplot(data, widths=0.6, showfliers=False)
ax.set_xticks([])
ax.set_ylabel("conf_delta (fused - mean(towers))")
ax.set_title(f"Confidence delta per run ({self.classification_mode})")
ax.grid(True, axis="y", alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig(out_path, dpi=170)
plt.close(fig)
return df
def param_performance_correlations(
self,
output_path: Path | str | None = None,
shallow: bool = True,
existing: bool = True,
method: str = "spearman",
cat_method: str = "kruskal",
) -> pd.DataFrame:
if self.statistics_df.empty:
self.identify_fusion_corrections(shallow=shallow, existing=existing)
if self.primary_metrics.empty:
self.populate_primary_metrics(shallow=shallow, existing=existing)
df = self.statistics_df.copy()
if df.empty:
raise RuntimeError(
"statistics_df is empty; run identify_fusion_corrections() first."
)
if self.fusion_corrections.empty:
self.identify_fusion_corrections(shallow=shallow, existing=existing)
conf_delta = None
if not self.fusion_corrections.empty:
fc = self.fusion_corrections.copy()
fc["conf_delta"] = fc["conf_fused"] - 0.5 * (fc["conf_img"] + fc["conf_md"])
conf_delta = (
fc.groupby("run_id")["conf_delta"].mean().rename("conf_delta_mean")
)
df = df.merge(conf_delta.reset_index(), on="run_id", how="left")
if self.fusion_errors.empty:
errors_path = self._fusion_errors_path()
if errors_path.exists():
self.fusion_errors = self._read_fusion_errors(errors_path)
if not self.fusion_errors.empty and "fusion_corrections" in df.columns:
err_counts = (
self.fusion_errors.groupby("run_id").size().rename("fusion_errors")
)
df = df.merge(err_counts.reset_index(), on="run_id", how="left")
df["fusion_errors"] = df["fusion_errors"].fillna(0)
eps = 1e-6
df["error_correction_ratio"] = (df["fusion_errors"] + eps) / (
df["fusion_corrections"] + eps
)
metric_cols = []
for cand in ("holdout_best_acc_fused_mean", "best_acc_fused_mean"):
if cand in df.columns:
metric_cols.append(("acc", cand))
break
for cand in ("holdout_best_auc_fused_mean", "best_auc_fused_mean"):
if cand in df.columns:
metric_cols.append(("auc", cand))
break
if "fusion_corrections" in df.columns:
metric_cols.append(("fusion_corrections", "fusion_corrections"))
if conf_delta is not None and "conf_delta_mean" in df.columns:
metric_cols.append(("conf_delta", "conf_delta_mean"))
if "error_correction_ratio" in df.columns:
metric_cols.append(("error_correction_ratio", "error_correction_ratio"))
if not metric_cols:
raise RuntimeError("No metrics found in statistics_df for correlation.")
grid_param_keys = [
"crop_variant",
"crop_normalize",
"crop_weights",
"crop_tta",
"loss_mode",
"thaw_mode",
"se_mode",
"se_bridge_pre_norm",
"se_tower_pre_norm",
]
param_cols = [c for c in grid_param_keys if c in df.columns]
def _to_float(v):
if v is None:
return None
if isinstance(v, bool):
return None
if isinstance(v, (int, float)) and not math.isnan(float(v)):
return float(v)
try:
return float(v)
except Exception:
return None
def _format_value(v: object) -> str:
if v is None:
return ""
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, int):
return str(v)
if isinstance(v, float):
return f"{v:.6g}"
return str(v)
def _rankdata(vals: List[float]) -> List[float]:
order = sorted(range(len(vals)), key=lambda i: vals[i])
ranks = [0.0] * len(vals)
i = 0
while i < len(vals):
j = i
while j + 1 < len(vals) and vals[order[j + 1]] == vals[order[i]]:
j += 1
avg_rank = (i + j) / 2.0 + 1.0
for k in range(i, j + 1):
ranks[order[k]] = avg_rank
i = j + 1
return ranks
def _pearson(x: List[float], y: List[float]) -> Optional[float]:
if len(x) < 2:
return None
mx = sum(x) / len(x)
my = sum(y) / len(y)
num = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y))
denx = sum((xi - mx) ** 2 for xi in x)
deny = sum((yi - my) ** 2 for yi in y)
if denx <= 0 or deny <= 0:
return None
return num / math.sqrt(denx * deny)
def _spearman(x: List[float], y: List[float]) -> Optional[float]:
return _pearson(_rankdata(x), _rankdata(y))
def _eta(categories: List[object], values: List[float]) -> Optional[float]:
if len(values) < 2:
return None
overall = sum(values) / len(values)
total = sum((v - overall) ** 2 for v in values)
if total <= 0:
return None
groups = {}
for cat, val in zip(categories, values):
groups.setdefault(cat, []).append(val)
between = 0.0
for vals in groups.values():
avg = sum(vals) / len(vals)
between += len(vals) * (avg - overall) ** 2
return math.sqrt(between / total)
cat_method_norm = cat_method.strip().lower()
if cat_method_norm not in {"eta", "anova", "kruskal"}:
raise ValueError(
f"cat_method must be 'eta', 'anova', or 'kruskal' (got {cat_method!r})"
)
rows: List[Dict[str, object]] = []
for name, metric_col in metric_cols:
metric_vals = df[metric_col]
for param in param_cols:
param_vals = df[param]
pairs = [
(p, m)
for p, m in zip(param_vals, metric_vals)
if m is not None and not (isinstance(m, float) and math.isnan(m))
]
if len(pairs) < 3:
continue
p_vals, m_vals = zip(*pairs)
group_means: Dict[object, float] = {}
for p, m in pairs:
group_means.setdefault(p, []).append(m)
group_means = {
k: float(sum(v) / len(v)) for k, v in group_means.items()
}
if name == "error_correction_ratio":
best_value = min(group_means.items(), key=lambda item: item[1])[0]
else:
best_value = max(group_means.items(), key=lambda item: item[1])[0]
num_vals = []
numeric_ok = True
for v in p_vals:
num = _to_float(v)
if num is None:
numeric_ok = False
break
num_vals.append(num)
if numeric_ok and len(set(num_vals)) >= 3:
p_val = None
if method == "spearman":
try:
corr, p_val = stats.spearmanr(num_vals, list(m_vals))
except Exception:
corr = None
else:
try:
corr, p_val = stats.pearsonr(num_vals, list(m_vals))
except Exception:
corr = None
if corr is not None and corr != corr:
corr = None
rows.append(
{
"metric": name,
"metric_col": metric_col,
"param": param,
"type": "numeric",
"n": len(pairs),
"corr": corr,
"stat": corr,
"p_value": p_val,
"method": method,
"best": _format_value(best_value),
}
)
else:
stat_val = None
p_val = None
corr = None
groups: Dict[object, List[float]] = {}
for p, m in pairs:
groups.setdefault(p, []).append(m)
group_vals = [vals for vals in groups.values() if len(vals) > 0]
if cat_method_norm == "eta":
corr = _eta(list(p_vals), list(m_vals))
stat_val = corr
elif cat_method_norm == "anova":
if len(group_vals) >= 2:
try:
stat_val, p_val = stats.f_oneway(*group_vals)
corr = stat_val
except Exception:
stat_val = None
elif cat_method_norm == "kruskal":
if len(group_vals) >= 2:
try:
stat_val, p_val = stats.kruskal(*group_vals)
corr = stat_val
except Exception:
stat_val = None
rows.append(
{
"metric": name,
"metric_col": metric_col,
"param": param,
"type": "categorical",
"n": len(pairs),
"corr": corr,
"stat": stat_val,
"p_value": p_val,
"method": cat_method_norm,
"best": _format_value(best_value),
}
)
out_df = pd.DataFrame(rows).sort_values(
by=["metric", "corr"], ascending=[True, False]
)
self.param_perf_corr = out_df
out_path = (
Path(output_path)
if output_path
else (
self.analysis_dir
/ "plots"
/ f"param_perf_corr_{self.classification_mode}.csv"
)
)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_df.to_csv(out_path, index=False)
return out_df
def plot_param_perf_corr_panels(
self,
corr_df: pd.DataFrame,
output_path: Path | str | None = None,
) -> pd.DataFrame:
if corr_df.empty:
raise RuntimeError(
"corr_df is empty; run param_performance_correlations() first."
)
metrics = ["auc", "acc", "fusion_corrections", "conf_delta"]
auc_df = corr_df[corr_df["metric"] == "auc"].copy()
if auc_df.empty:
raise RuntimeError("No 'auc' metric rows found in corr_df.")
auc_df = auc_df.sort_values(by="corr", ascending=False)
order = auc_df["param"].tolist()
out_path = (
Path(output_path)
if output_path
else (
self.analysis_dir
/ "plots"
/ f"param_perf_corr_panels_{self.classification_mode}.png"
)
)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig, axes = plt.subplots(2, 2, figsize=(12, 8), sharey=True)
axes = axes.flatten()
for idx, metric in enumerate(metrics):
ax = axes[idx]
sub = corr_df[corr_df["metric"] == metric].set_index("param")
sub = sub.reindex(order)
values = sub["corr"].astype(float).values
y = np.arange(len(order))
ax.barh(y, values, color="steelblue")
ax.axvline(0.0, color="black", lw=1)
ax.set_title(metric)
ax.set_yticks(y)
ax.set_yticklabels(order, fontsize=7)
ax.grid(True, axis="x", alpha=0.3, linestyle="--")
# annotate p-values when available
for i, param in enumerate(order):
if param not in sub.index:
continue
p_val = sub.loc[param, "p_value"]
if p_val is None or (isinstance(p_val, float) and np.isnan(p_val)):
continue
ax.text(
values[i] if not np.isnan(values[i]) else 0.0,
i,
f" p={p_val:.3g}",
va="center",
ha="left" if values[i] >= 0 else "right",
fontsize=7,
)
# print p-values to console for each metric
print(f"\n[{metric}] p-values")
for param in order:
if param not in sub.index:
continue
p_val = sub.loc[param, "p_value"]
if p_val is None or (isinstance(p_val, float) and np.isnan(p_val)):
continue
print(f" {param}: p={p_val:.4g}")
fig.suptitle("Parameter correlations (ordered by AUC correlation)", fontsize=12)
fig.tight_layout(rect=[0, 0.02, 1, 0.96])
fig.savefig(out_path, dpi=170)
plt.close(fig)
return corr_df
def se_mode_effects_summary(
self,
metric: str = "auc",
metric_col: str | None = None,
head: str = "fused",
prefer_holdout: bool = True,
top_n: int | None = None,
top_metric_col: str | None = None,
output_dir: Path | str | None = None,
pairwise_method: str = "mannwhitney",
shallow: bool = True,
existing: bool = True,
) -> Dict[str, pd.DataFrame]:
if self.statistics_df.empty:
self.identify_fusion_corrections(shallow=shallow, existing=existing)
if self.primary_metrics.empty:
self.populate_primary_metrics(shallow=shallow, existing=existing)
df = self.statistics_df.copy()
if df.empty:
raise RuntimeError(
"statistics_df is empty; run identify_fusion_corrections() first."
)
metric_norm = metric.strip().lower()
if metric_norm not in {"acc", "auc"}:
raise ValueError(f"metric must be 'acc' or 'auc' (got {metric!r})")
if metric_col is None:
candidates = []
if prefer_holdout:
candidates.append(f"holdout_best_{metric_norm}_{head}_mean")
candidates.append(f"best_{metric_norm}_{head}_mean")
else:
candidates.append(f"best_{metric_norm}_{head}_mean")
candidates.append(f"holdout_best_{metric_norm}_{head}_mean")
for cand in candidates:
if cand in df.columns:
metric_col = cand
break
if metric_col is None or metric_col not in df.columns:
raise RuntimeError(
"Could not find a metric column to summarize; run populate_primary_metrics() "
"or pass metric_col explicitly."
)
if "se_mode" not in df.columns:
raise RuntimeError("statistics_df is missing se_mode column.")
use_cols = ["run_id", "se_mode", metric_col]
if "se_bridge_pre_norm" in df.columns:
use_cols.append("se_bridge_pre_norm")
if "se_tower_pre_norm" in df.columns:
use_cols.append("se_tower_pre_norm")
df = df[use_cols].copy()
df = df.dropna(subset=[metric_col, "se_mode"])
if df.empty:
raise RuntimeError(
"No rows available after filtering for se_mode and metric."
)
if top_n is not None:
top_metric = top_metric_col or metric_col
if top_metric not in df.columns:
raise RuntimeError(
f"top_metric_col {top_metric!r} not found in statistics_df."
)
df = df.sort_values(by=top_metric, ascending=False).head(int(top_n))
if df.empty:
raise RuntimeError("No rows available after applying top_n filter.")
summary = (
df.groupby("se_mode")[metric_col]
.agg(["count", "mean", "median", "std"])
.reset_index()
.rename(columns={"count": "n"})
)
# Pairwise comparisons
pairwise_rows: List[Dict[str, object]] = []
modes = summary["se_mode"].tolist()
pairwise_method_norm = pairwise_method.strip().lower()
if pairwise_method_norm not in {"mannwhitney", "ttest"}:
raise ValueError(
f"pairwise_method must be 'mannwhitney' or 'ttest' (got {pairwise_method!r})"
)
def _cohens_d(a: np.ndarray, b: np.ndarray) -> float:
if len(a) < 2 or len(b) < 2:
return float("nan")
va = np.var(a, ddof=1)
vb = np.var(b, ddof=1)
pooled = ((len(a) - 1) * va + (len(b) - 1) * vb) / max(
len(a) + len(b) - 2, 1
)
if pooled <= 0:
return float("nan")
return (np.mean(a) - np.mean(b)) / math.sqrt(pooled)
for i, m1 in enumerate(modes):
vals1 = df.loc[df["se_mode"] == m1, metric_col].astype(float).values
if vals1.size == 0:
continue
for m2 in modes[i + 1 :]:
vals2 = df.loc[df["se_mode"] == m2, metric_col].astype(float).values
if vals2.size == 0:
continue
p_val = None
stat_val = None
if pairwise_method_norm == "mannwhitney":
try:
stat_val, p_val = stats.mannwhitneyu(
vals1, vals2, alternative="two-sided"
)
except Exception:
stat_val, p_val = None, None
else:
try:
stat_val, p_val = stats.ttest_ind(vals1, vals2, equal_var=False)
except Exception:
stat_val, p_val = None, None
pairwise_rows.append(
{
"metric_col": metric_col,
"se_mode_a": m1,
"se_mode_b": m2,
"n_a": int(vals1.size),
"n_b": int(vals2.size),
"mean_a": float(np.mean(vals1)),
"mean_b": float(np.mean(vals2)),
"mean_diff": float(np.mean(vals1) - np.mean(vals2)),
"median_a": float(np.median(vals1)),
"median_b": float(np.median(vals2)),
"median_diff": float(np.median(vals1) - np.median(vals2)),
"cohens_d": _cohens_d(vals1, vals2),
"stat": stat_val,
"p_value": p_val,
"method": pairwise_method_norm,
}
)
pairwise_df = pd.DataFrame(pairwise_rows)
# Stratified by pre-norm options (within relevant se_mode)
bridge_df = pd.DataFrame()
if "se_bridge_pre_norm" in df.columns:
bridge_df = (
df[df["se_mode"].isin(["bridge", "both"])]
.groupby(["se_mode", "se_bridge_pre_norm"])[metric_col]
.agg(["count", "mean", "median", "std"])
.reset_index()
.rename(columns={"count": "n"})
)
tower_df = pd.DataFrame()
if "se_tower_pre_norm" in df.columns:
tower_df = (
df[df["se_mode"].isin(["tower", "both"])]
.groupby(["se_mode", "se_tower_pre_norm"])[metric_col]
.agg(["count", "mean", "median", "std"])
.reset_index()
.rename(columns={"count": "n"})
)
result = {
"summary": summary,
"pairwise": pairwise_df,
"bridge_pre_norm": bridge_df,
"tower_pre_norm": tower_df,
}
self.se_mode_effects = result
if output_dir is not None:
out_dir = Path(output_dir)
else:
out_dir = self.analysis_dir / "plots"
out_dir.mkdir(parents=True, exist_ok=True)
summary.to_csv(out_dir / f"se_mode_summary_{metric_col}.csv", index=False)
if not pairwise_df.empty:
pairwise_df.to_csv(
out_dir / f"se_mode_pairwise_{metric_col}.csv", index=False
)
if not bridge_df.empty:
bridge_df.to_csv(
out_dir / f"se_mode_bridge_pre_norm_{metric_col}.csv", index=False
)
if not tower_df.empty:
tower_df.to_csv(
out_dir / f"se_mode_tower_pre_norm_{metric_col}.csv", index=False
)
return result
def fusion_corrections_correlation(
self,
output_path: Path | str | None = None,
method: str = "pearson",
metric_type: str = "acc",
) -> pd.DataFrame:
if self.fusion_corrections.empty:
raise RuntimeError(
"fusion_corrections is empty; run identify_fusion_corrections() first."
)
if self.primary_metrics.empty:
raise RuntimeError(
"primary_metrics is empty; run populate_primary_metrics() first."
)
if "fold" not in self.primary_metrics.columns:
raise RuntimeError(
"primary_metrics missing fold column; refresh populate_primary_metrics()."
)
method_norm = method.strip().lower()
if method_norm not in {"pearson", "spearman"}:
raise ValueError(f"method must be 'pearson' or 'spearman' (got {method!r})")
metric_norm = metric_type.strip().lower()
if metric_norm not in {"acc", "auc"}:
raise ValueError(
f"metric_type must be 'acc' or 'auc' (got {metric_type!r})"
)
metric_cols = [
c
for c in self.primary_metrics.columns
if c.startswith(("best_", "holdout_best_"))
and f"_{metric_norm}_" in c
and not c.endswith(("_mean", "_sd"))
]
if not metric_cols:
raise RuntimeError(
"No primary metric columns found in primary_metrics; run populate_primary_metrics() first."
)
fold_counts = self._fold_sample_and_opportunity_counts(self.primary_metrics)
best_epochs = self.primary_metrics[
["run_id", "run_dir", "fold", "best_epoch"]
].dropna()
warmup_map = self._build_warmup_map(best_epochs)
best_epochs = best_epochs.merge(warmup_map, on="run_id", how="left")
best_epochs["warmup_end"] = best_epochs["warmup_end"].fillna(0).astype(int)
best_epochs["best_epoch"] = best_epochs["best_epoch"].astype(int)
events = self.fusion_corrections.merge(
best_epochs[["run_id", "fold", "best_epoch", "warmup_end"]],
on=["run_id", "fold"],
how="inner",
)
if "epoch" in events.columns:
events = events[
(events["epoch"] >= events["warmup_end"])
& (events["epoch"] <= events["best_epoch"])
]
counts = (
events.groupby(["run_id", "fold"], as_index=False)
.size()
.rename(columns={"size": "fusion_corrections"})
)
merged = self.primary_metrics.merge(counts, on=["run_id", "fold"], how="left")
merged = merged.merge(fold_counts, on=["run_id", "run_dir", "fold"], how="left")
merged["fusion_corrections"] = merged["fusion_corrections"].fillna(0)
merged["n_samples"] = merged["n_samples"].replace(0, np.nan)
merged["both_wrong"] = merged["both_wrong"].replace(0, np.nan)
merged["fusion_corrections_rate"] = (
merged["fusion_corrections"] / merged["n_samples"]
)
merged["fusion_corrections_per_opportunity"] = (
merged["fusion_corrections"] / merged["both_wrong"]
)
merged = self._add_fusion_gain_columns(merged)
gain_cols = [
c
for c in merged.columns
if c.endswith("_fusion_gain")
and c.startswith(("best_", "holdout_best_"))
and f"_{metric_norm}_" in c
]
error_counts = self._fusion_errors_counts(merged)
merged = merged.merge(error_counts, on=["run_id", "fold"], how="left")
merged["fusion_errors"] = merged["fusion_errors"].fillna(0)
eps = 1e-6
merged["correction_error_rate"] = (merged["fusion_corrections"] + eps) / (
merged["fusion_errors"] + eps
)
rows = []
x_metrics = [
"fusion_corrections",
"fusion_corrections_rate",
"fusion_corrections_per_opportunity",
"fusion_errors",
"correction_error_rate",
]
all_metrics = metric_cols + gain_cols
for x in x_metrics:
if x not in merged.columns:
continue
for col in all_metrics:
sub = merged[[x, col]].dropna()
if len(sub) < 2:
corr = np.nan
else:
corr = float(sub[x].corr(sub[col], method=method_norm))
rows.append(
{
"x_metric": x,
"metric": col,
"corr": corr,
"n": int(len(sub)),
"metric_type": metric_norm,
}
)
out_df = pd.DataFrame(rows).sort_values(
by=["x_metric", "corr"], ascending=[True, False]
)
self.fusion_performance_corr = out_df
plot_x = "fusion_corrections_per_opportunity"
if plot_x not in out_df["x_metric"].unique():
plot_x = "fusion_corrections"
plot_df = out_df[out_df["x_metric"] == plot_x]
path = (
Path(output_path)
if output_path
else self._fusion_performance_corr_path(method_norm, metric_norm)
)
path.parent.mkdir(parents=True, exist_ok=True)
self._plot_correlation_bars(
plot_df, path, method=method_norm, x_metric=plot_x, metric_type=metric_norm
)
return out_df
def plot_fusion_perf_summary(
self,
corr_acc: pd.DataFrame,
corr_auc: pd.DataFrame,
output_path: Path | str | None = None,
method: str = "spearman",
x_metric: str = "fusion_corrections",
) -> pd.DataFrame:
keep_templates = [
"best_acc_fused",
"holdout_best_acc_fused",
"best_acc_fusion_gain",
"holdout_best_acc_fusion_gain",
"best_auc_fused",
"holdout_best_auc_fused",
"best_auc_fusion_gain",
"holdout_best_auc_fusion_gain",
]
def _select(df: pd.DataFrame) -> pd.DataFrame:
if df.empty:
return df
sub = df[df["x_metric"] == x_metric].copy()
sub = sub[sub["metric"].isin(keep_templates)]
sub = sub.drop_duplicates(subset=["metric"])
sub["metric"] = sub["metric"].str.replace("_fused", "", regex=False)
return sub
acc_df = _select(corr_acc)
auc_df = _select(corr_auc)
merged = pd.concat([acc_df, auc_df], axis=0, ignore_index=True)
if merged.empty:
raise RuntimeError("No matching rows found in corr_acc/corr_auc.")
merged = (
merged.set_index("metric")
.loc[[m.replace("_fused", "") for m in keep_templates]]
.reset_index()
)
safe_x = x_metric.replace("fusion_", "")
out_path = (
Path(output_path)
if output_path
else (
self.analysis_dir
/ "plots"
/ f"fusion_{safe_x}_vs_performance_{method}.png"
)
)
out_path.parent.mkdir(parents=True, exist_ok=True)
self._plot_correlation_bars(
merged,
out_path,
method=method,
x_metric=x_metric,
metric_type="acc/auc",
title=f"Fusion corrections vs performance ({method})",
)
return merged
def _iter_run_dirs(
self, shallow: bool = True, show_progress: bool = False
) -> Iterator[Path]:
if shallow:
candidates = [p for p in sorted(self.analysis_dir.iterdir()) if p.is_dir()]
else:
candidates = [p for p in self.analysis_dir.rglob("*") if p.is_dir()]
iterator = (
tqdm(candidates, desc="Scanning runs", unit="run", leave=False)
if show_progress
else candidates
)
for run_dir in iterator:
summary = run_dir / "summary.json"
cli = run_dir / "cli_args.json"
if summary.exists() or cli.exists():
yield run_dir
def _fusion_corrections_path(self) -> Path:
fname = f"fusion_corrections_{self.classification_mode}.csv"
return self.analysis_dir / fname
def _fusion_errors_path(self) -> Path:
fname = f"fusion_errors_{self.classification_mode}.csv"
return self.analysis_dir / fname
@staticmethod
def _read_fusion_errors(path: Path) -> pd.DataFrame:
try:
df = pd.read_csv(path)
except Exception:
return pd.DataFrame()
return df
def _primary_metrics_path(self) -> Path:
fname = f"primary_metrics_{self.classification_mode}.csv"
return self.analysis_dir / fname
def _fusion_performance_corr_path(self, method: str, metric_type: str) -> Path:
fname = f"fusion_performance_corr_{self.classification_mode}_{metric_type}_{method}.png"
return self.analysis_dir / fname
@staticmethod
def _read_primary_metrics(path: Path) -> pd.DataFrame:
try:
return pd.read_csv(path)
except Exception:
return pd.DataFrame()
@staticmethod
def _read_fusion_corrections(path: Path) -> pd.DataFrame:
try:
df = pd.read_csv(path)
except Exception:
return pd.DataFrame()
return df
def _build_statistics_df(
self, df: pd.DataFrame, shallow: bool = True
) -> pd.DataFrame:
counts = {}
if not df.empty and "run_id" in df.columns:
counts = df.groupby("run_id").size().to_dict()
rows: List[Dict[str, object]] = []
for run_dir in self._iter_run_dirs(shallow=shallow, show_progress=False):
summary = self._read_summary(run_dir)
cli = self._read_cli_args(run_dir)
mode = self._infer_mode(summary, cli)
if mode != self.classification_mode:
continue
run_id = self._read_run_id(run_dir, summary)
grid_params = self._grid_params_from_cli(cli, summary)
rows.append(
{
"run_id": run_id,
"run_dir": str(run_dir),
"classification_mode": mode,
"fusion_corrections": int(counts.get(run_id, 0)),
**grid_params,
}
)
return pd.DataFrame(rows)
@staticmethod
def _aggregate_primary_metrics(df: pd.DataFrame) -> pd.DataFrame:
if df.empty:
return pd.DataFrame()
required = ["run_id", "run_dir", "classification_mode"]
if not all(col in df.columns for col in required):
return pd.DataFrame()
metric_cols = [
c
for c in df.columns
if c.startswith(("best_", "holdout_best_"))
and not c.endswith(("_mean", "_sd"))
]
if not metric_cols:
return pd.DataFrame()
grouped = df.groupby(required)
agg = grouped[metric_cols].agg(["mean", "std"])
agg.columns = [
f"{col}_{stat}".replace("std", "sd") for col, stat in agg.columns
]
return agg.reset_index()
def _fold_sample_and_opportunity_counts(self, df: pd.DataFrame) -> pd.DataFrame:
if df.empty:
return pd.DataFrame()
cols = ["run_id", "run_dir", "fold"]
rows = []
for run_id, run_dir, fold in df[cols].drop_duplicates().itertuples(index=False):
run_path = Path(run_dir)
y_true = self._load_y_true(run_path, int(fold))
n_samples = int(len(y_true)) if y_true is not None else np.nan
both_wrong = self._count_both_wrong(run_path, int(fold), y_true)
rows.append(
{
"run_id": run_id,
"run_dir": str(run_path),
"fold": int(fold),
"n_samples": n_samples,
"both_wrong": both_wrong,
}
)
return pd.DataFrame(rows)
@staticmethod
def _count_both_wrong(
run_dir: Path, fold: int, y_true: Optional[np.ndarray]
) -> Optional[int]:
if y_true is None:
return np.nan
img_path = run_dir / f"fold{fold}_probs_img.npy"
md_path = run_dir / f"fold{fold}_probs_md.npy"
if not img_path.exists() or not md_path.exists():
return np.nan
try:
p_img = np.load(img_path)
p_md = np.load(md_path)
except Exception:
return np.nan
if p_img.ndim != 2 or p_md.ndim != 2:
return np.nan
if len(p_img) != len(y_true) or len(p_md) != len(y_true):
return np.nan
img_pred = p_img.argmax(axis=1)
md_pred = p_md.argmax(axis=1)
both_wrong = (img_pred != y_true) & (md_pred != y_true)
return int(both_wrong.sum())
@staticmethod
def _add_fusion_gain_columns(df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
for prefix in ("best", "holdout_best"):
acc_cols = [
f"{prefix}_acc_fused",
f"{prefix}_acc_image",
f"{prefix}_acc_metadata",
]
auc_cols = [
f"{prefix}_auc_fused",
f"{prefix}_auc_image",
f"{prefix}_auc_metadata",
]
if all(c in out.columns for c in acc_cols):
max_acc = out[[acc_cols[1], acc_cols[2]]].max(axis=1)
out[f"{prefix}_acc_fusion_gain"] = out[acc_cols[0]] - max_acc
if all(c in out.columns for c in auc_cols):
max_auc = out[[auc_cols[1], auc_cols[2]]].max(axis=1)
out[f"{prefix}_auc_fusion_gain"] = out[auc_cols[0]] - max_auc
return out
def _fusion_errors_counts(self, merged: pd.DataFrame) -> pd.DataFrame:
if self.fusion_errors.empty:
return pd.DataFrame(columns=["run_id", "fold", "fusion_errors"])
counts = (
self.fusion_errors.groupby(["run_id", "fold"], as_index=False)
.size()
.rename(columns={"size": "fusion_errors"})
)
return counts
def _build_warmup_map(self, df: pd.DataFrame) -> pd.DataFrame:
if df.empty or "run_id" not in df.columns or "run_dir" not in df.columns:
return pd.DataFrame(columns=["run_id", "warmup_end"])
rows = []
for run_id, run_dir in (
df[["run_id", "run_dir"]].drop_duplicates().itertuples(index=False)
):
cli = self._read_cli_args(Path(run_dir))
warmup_end = self._warmup_end_epoch(cli)
rows.append({"run_id": run_id, "warmup_end": warmup_end})
return pd.DataFrame(rows)
@staticmethod
def _warmup_end_epoch(cli: Optional[Dict[str, object]]) -> int:
if not cli:
return 0
tower = cli.get("warmup_tower_epochs")
fused = cli.get("warmup_fused_epochs")
try:
tower_val = int(tower) if tower is not None else 0
except Exception:
tower_val = 0
try:
fused_val = int(fused) if fused is not None else 0
except Exception:
fused_val = 0
return max(0, tower_val + fused_val)
def _grid_params_from_cli(
self, cli: Optional[Dict[str, object]], summary: Optional[Dict[str, object]]
) -> Dict[str, object]:
params: Dict[str, object] = {
"eval_mode": None,
"crop_variant": None,
"crop_normalize": None,
"crop_weights": None,
"crop_tta": None,
"loss_mode": None,
"thaw_mode": None,
"se_mode": None,
"se_bridge_pre_norm": None,
"se_tower_pre_norm": None,
}
eval_mode = None
for payload in (cli, summary):
if payload and isinstance(payload.get("eval_mode"), str):
eval_mode = payload["eval_mode"]
break
params["eval_mode"] = eval_mode
if cli:
crop_weights = cli.get("img_crop_weights")
crop_normalize = cli.get("img_crop_normalize")
crop_tta = cli.get("img_crop_tta")
params["crop_weights"] = crop_weights
params["crop_normalize"] = crop_normalize
params["crop_tta"] = crop_tta
params["crop_variant"] = self._infer_crop_variant(crop_weights)
params["loss_mode"] = self._infer_loss_mode(cli)
params["thaw_mode"] = self._infer_thaw_mode(cli)
params["se_mode"] = self._infer_se_mode(cli)
params["se_bridge_pre_norm"] = cli.get("se_pre_norm")
params["se_tower_pre_norm"] = cli.get("se_pre_norm_tower")
return params
@staticmethod
def _infer_crop_variant(crop_weights: object) -> Optional[str]:
if not crop_weights:
return None
text = str(crop_weights)
for key in ("norm_imagenet", "normalize_none", "norm_per_image"):
if key in text:
return key
return None
@staticmethod
def _infer_loss_mode(cli: Dict[str, object]) -> Optional[str]:
if cli.get("balanced_sampler"):
return "balanced"
gamma = cli.get("focal_gamma")
try:
if gamma is not None and float(gamma) > 0:
return "focal"
except Exception:
pass
return "none"
@staticmethod
def _infer_thaw_mode(cli: Dict[str, object]) -> Optional[str]:
return "gradual" if cli.get("gradual_thaw") else "none"
@staticmethod
def _infer_se_mode(cli: Dict[str, object]) -> Optional[str]:
if not cli.get("use_se"):
return "none"
se_where = cli.get("se_where")
if isinstance(se_where, str) and se_where.strip():
return se_where.strip()
return "bridge"
@staticmethod
def _read_json(path: Path) -> Optional[Dict[str, object]]:
if not path.exists():
return None
try:
data = json.loads(path.read_text())
except Exception:
return None
if not isinstance(data, dict):
return None
return data
@staticmethod
def _read_epoch_log(run_dir: Path, fold: int) -> Optional[pd.DataFrame]:
path = run_dir / f"fold{fold}_epoch_log.csv"
if not path.exists():
return None
try:
return pd.read_csv(path)
except Exception:
return None
def _read_summary(self, run_dir: Path) -> Optional[Dict[str, object]]:
return self._read_json(run_dir / "summary.json")
def _read_cli_args(self, run_dir: Path) -> Optional[Dict[str, object]]:
return self._read_json(run_dir / "cli_args.json")
@staticmethod
def _read_run_id(run_dir: Path, summary: Optional[Dict[str, object]]) -> str:
if summary:
rid = summary.get("run_id")
if isinstance(rid, str) and rid:
return rid
return run_dir.name
@staticmethod
def _infer_mode(
summary: Optional[Dict[str, object]], cli: Optional[Dict[str, object]]
) -> Optional[str]:
for payload in (summary, cli):
if not payload:
continue
eval_mode = payload.get("eval_mode")
if isinstance(eval_mode, str):
mode = eval_mode.strip().lower()
if mode == "binary":
return "binary"
if mode in {"multiclass", "multi", "multi-class"}:
return "multiclass"
num_classes = payload.get("num_classes")
if isinstance(num_classes, (int, float)):
return "binary" if int(num_classes) <= 2 else "multiclass"
class_names = payload.get("class_names")
if isinstance(class_names, list) and class_names:
return "binary" if len(class_names) <= 2 else "multiclass"
return None
@staticmethod
def _fold_epoch_hint(
summary: Optional[Dict[str, object]], fold: int
) -> Optional[int]:
if not summary:
return None
fold_metrics = summary.get("fold_metrics") or []
for entry in fold_metrics:
if not isinstance(entry, dict):
continue
if entry.get("fold") == fold:
stats = (
entry.get("stats") if isinstance(entry.get("stats"), dict) else {}
)
epoch = stats.get("epoch") or entry.get("best_epoch")
if isinstance(epoch, (int, float)):
return int(epoch)
return None
@staticmethod
def _extract_best_epochs(
log_df: Optional[pd.DataFrame],
) -> tuple[Optional[int], Optional[int]]:
if log_df is None or log_df.empty:
return None, None
best_epoch = None
holdout_best_epoch = None
if "best_epoch" in log_df.columns:
try:
best_epoch = int(log_df["best_epoch"].iloc[-1])
except Exception:
best_epoch = None
if "holdout_best_epoch" in log_df.columns:
try:
holdout_best_epoch = int(log_df["holdout_best_epoch"].iloc[-1])
except Exception:
holdout_best_epoch = None
return best_epoch, holdout_best_epoch
@staticmethod
def _row_for_epoch(
log_df: Optional[pd.DataFrame], epoch: Optional[int]
) -> Optional[pd.Series]:
if log_df is None or epoch is None:
return None
if "epoch" not in log_df.columns:
return None
rows = log_df[log_df["epoch"] == epoch]
if rows.empty:
return None
return rows.iloc[-1]
@staticmethod
def _metric_from_row(row: Optional[pd.Series], key: str) -> Optional[float]:
if row is None or key not in row:
return None
try:
val = float(row[key])
except Exception:
return None
if np.isnan(val):
return None
return val
@staticmethod
def _available_folds(
run_dir: Path, summary: Optional[Dict[str, object]]
) -> List[int]:
folds: List[int] = []
if summary:
for entry in summary.get("fold_metrics") or []:
if not isinstance(entry, dict):
continue
fold_idx = entry.get("fold")
if isinstance(fold_idx, int):
folds.append(fold_idx)
if not folds:
pattern = re.compile(r"fold(\d+)_y_true\.npy$")
for path in run_dir.glob("fold*_y_true.npy"):
match = pattern.match(path.name)
if match:
folds.append(int(match.group(1)))
return sorted(set(folds))
@staticmethod
def _load_y_true(run_dir: Path, fold: int) -> Optional[np.ndarray]:
path = run_dir / f"fold{fold}_y_true.npy"
if not path.exists():
return None
try:
return np.load(path)
except Exception:
return None
@staticmethod
def _collect_epoch_prob_paths(
run_dir: Path, fold: int
) -> Dict[int, Dict[str, Path]]:
pattern = re.compile(rf"fold{fold}_epoch(\d+)_probs_(\w+)\.npy$")
epoch_paths: Dict[int, Dict[str, Path]] = {}
for path in run_dir.glob(f"fold{fold}_epoch*_probs_*.npy"):
match = pattern.match(path.name)
if not match:
continue
epoch = int(match.group(1))
head = match.group(2)
epoch_paths.setdefault(epoch, {})[head] = path
return epoch_paths
@staticmethod
def _collect_base_prob_paths(run_dir: Path, fold: int) -> Dict[str, Path]:
paths: Dict[str, Path] = {}
for head in ("fused", "img", "md"):
candidate = run_dir / f"fold{fold}_probs_{head}.npy"
if candidate.exists():
paths[head] = candidate
return paths
@staticmethod
def _load_probs_array(path: Path) -> Optional[np.ndarray]:
try:
return np.load(path)
except Exception:
return None
def _load_auc_for_epoch(
self, run_dir: Path, fold: int, epoch: int, head: str, holdout: bool
) -> Optional[float]:
if epoch is None:
return None
tag = "holdout_" if holdout else ""
# Prefer per-epoch ROC curves (same evaluation used for epoch_log ACC).
epoch_dir = run_dir / f"fold{fold}_roc_curves"
if epoch_dir.exists():
path = epoch_dir / f"epoch{epoch}_{tag}{head}.json"
else:
path = None
# Fallback to best/holdout_best exports if epoch curves missing.
if path is None or not path.exists():
folder = (
"fold{}_roc_curves_holdout_best".format(fold)
if holdout
else "fold{}_roc_curves_best".format(fold)
)
target_dir = run_dir / folder
path = target_dir / f"epoch{epoch}_{tag}{head}.json"
if not path.exists():
return None
return self._extract_auc_from_json(path)
@staticmethod
def _extract_auc_from_json(path: Path) -> Optional[float]:
try:
payload = json.loads(path.read_text())
except Exception:
return None
if not isinstance(payload, dict):
return None
per_class = payload.get("per_class")
if isinstance(per_class, dict):
def _to_float(val):
try:
f = float(val)
except Exception:
return None
if np.isnan(f):
return None
return f
# Binary fix: class 0 stored with class-1 labels against class-0 scores.
if "0" in per_class and "1" in per_class:
v0 = _to_float(per_class.get("0", {}).get("auc"))
v1 = _to_float(per_class.get("1", {}).get("auc"))
if v0 is not None and v1 is None:
return float(1.0 - v0)
if v1 is not None and v0 is None:
return float(1.0 - v1)
if v0 is not None and v1 is not None:
return float(np.mean([v0, v1]))
vals = []
for entry in per_class.values():
if not isinstance(entry, dict):
continue
auc_val = entry.get("auc")
val = _to_float(auc_val)
if val is not None:
vals.append(val)
if vals:
return float(np.mean(vals))
macro_auc = payload.get("macro_auc")
try:
if macro_auc is not None and not np.isnan(float(macro_auc)):
return float(macro_auc)
except Exception:
pass
return None
@staticmethod
def _plot_correlation_bars(
df: pd.DataFrame,
path: Path,
method: str = "pearson",
x_metric: str = "fusion_corrections",
metric_type: str = "acc",
title: Optional[str] = None,
) -> None:
if df.empty:
return
height = max(4.0, 0.28 * len(df))
fig, ax = plt.subplots(figsize=(8.5, height))
ax.barh(df["metric"], df["corr"], color="steelblue")
ax.axvline(0.0, color="black", lw=1)
label = "Spearman ρ" if method == "spearman" else "Pearson r"
ax.set_xlabel(label)
ax.set_title(title or f"{x_metric} vs {metric_type} metrics ({method})")
fig.tight_layout()
fig.savefig(path, dpi=170)
plt.close(fig)
@staticmethod
def _prepare_probs(arr: np.ndarray) -> Optional[np.ndarray]:
if arr is None:
return None
probs = np.asarray(arr, dtype=float)
if probs.ndim == 1:
probs = np.stack([1.0 - probs, probs], axis=1)
if probs.ndim != 2:
return None
return probs
@staticmethod
def _has_all_heads(arrays: Dict[str, Optional[np.ndarray]]) -> bool:
needed = ("fused", "img", "md")
return all(arrays.get(head) is not None for head in needed)
def _fusion_corrections_for_probs(
self,
y_true: np.ndarray,
arrays: Dict[str, np.ndarray],
run_id: str,
fold: int,
epoch: int,
) -> List[Dict[str, object]]:
fused = self._prepare_probs(arrays.get("fused"))
img = self._prepare_probs(arrays.get("img"))
md = self._prepare_probs(arrays.get("md"))
if fused is None or img is None or md is None:
return []
if not (len(fused) == len(img) == len(md) == len(y_true)):
return []
fused_pred = fused.argmax(axis=1)
img_pred = img.argmax(axis=1)
md_pred = md.argmax(axis=1)
fused_conf = np.take_along_axis(fused, fused_pred[:, None], axis=1).squeeze(1)
img_conf = np.take_along_axis(img, img_pred[:, None], axis=1).squeeze(1)
md_conf = np.take_along_axis(md, md_pred[:, None], axis=1).squeeze(1)
mask = (fused_pred == y_true) & (img_pred != y_true) & (md_pred != y_true)
indices = np.nonzero(mask)[0]
events: List[Dict[str, object]] = []
for idx in indices:
events.append(
{
"run_id": run_id,
"fold": fold,
"epoch": epoch,
"index": int(idx),
"y_true": int(y_true[idx]),
"pred_fused": int(fused_pred[idx]),
"pred_img": int(img_pred[idx]),
"pred_md": int(md_pred[idx]),
"conf_fused": float(fused_conf[idx]),
"conf_img": float(img_conf[idx]),
"conf_md": float(md_conf[idx]),
}
)
return events
def _fusion_errors_for_probs(
self,
y_true: np.ndarray,
arrays: Dict[str, np.ndarray],
run_id: str,
fold: int,
epoch: int,
) -> List[Dict[str, object]]:
fused = self._prepare_probs(arrays.get("fused"))
img = self._prepare_probs(arrays.get("img"))
md = self._prepare_probs(arrays.get("md"))
if fused is None or img is None or md is None:
return []
if not (len(fused) == len(img) == len(md) == len(y_true)):
return []
fused_pred = fused.argmax(axis=1)
img_pred = img.argmax(axis=1)
md_pred = md.argmax(axis=1)
fused_conf = np.take_along_axis(fused, fused_pred[:, None], axis=1).squeeze(1)
img_conf = np.take_along_axis(img, img_pred[:, None], axis=1).squeeze(1)
md_conf = np.take_along_axis(md, md_pred[:, None], axis=1).squeeze(1)
mask = (fused_pred != y_true) & (img_pred == y_true) & (md_pred == y_true)
indices = np.nonzero(mask)[0]
events: List[Dict[str, object]] = []
for idx in indices:
events.append(
{
"run_id": run_id,
"fold": fold,
"epoch": epoch,
"index": int(idx),
"y_true": int(y_true[idx]),
"pred_fused": int(fused_pred[idx]),
"pred_img": int(img_pred[idx]),
"pred_md": int(md_pred[idx]),
"conf_fused": float(fused_conf[idx]),
"conf_img": float(img_conf[idx]),
"conf_md": float(md_conf[idx]),
}
)
return events
if __name__ == "__main__":
analysis = derived_analysis(
Path("analysis_data/grid_search"), classification_mode="binary"
)
df = analysis.identify_fusion_corrections(existing=True)
# analysis.write_fusion_corrections()
# analysis.write_fusion_errors()
print(f"Fusion correction events: {len(df)}")
analysis.primary_metrics = analysis.populate_primary_metrics(existing=True)
# analysis.write_primary_metrics()
# corr_acc = analysis.fusion_corrections_correlation(
# method="spearman", metric_type="acc"
# )
# corr_auc = analysis.fusion_corrections_correlation(
# method="spearman", metric_type="auc"
# )
# print(corr_acc)
# summary = analysis.plot_fusion_perf_summary(
# corr_acc,
# corr_auc,
# method="spearman",
# x_metric="fusion_corrections_per_opportunity",
# )
analysis.primary_metrics
df = analysis.param_performance_correlations(method="spearman")
df.columns
# analysis.plot_param_perf_corr_panels(df)
out = analysis.se_mode_effects_summary(metric="auc")
out2 = analysis.se_mode_effects_summary(metric="acc")
out3 = analysis.se_mode_effects_summary(metric="auc", head="fused", prefer_holdout=True, top_n=25)
out4 = analysis.se_mode_effects_summary(metric="acc", head="fused", prefer_holdout=True, top_n=25)
print(out["summary"])
print(out2["summary"])
print(out3["summary"])
print(out4["summary"])
print(out["pairwise"])
print(out3["pairwise"])
# analysis.fusion_corrections
# analysis.plot_fusion_corrections_errors()
# analysis.plot_conf_delta_boxplot()