331 lines
9.5 KiB
Python
Executable File
331 lines
9.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Aggregate label-efficiency supervised runs into a paper-style Table 4 CSV.
|
|
|
|
For each model, this collects the OOD F1-Macro across seeds and label
|
|
fractions (1%, 10%, 50%, 100%) and outputs mean +/- std in a wide CSV table.
|
|
|
|
It expects the run_info written by train_supervised.py to contain
|
|
`seed` and `data.label_fraction` (or the eval folder name to contain
|
|
`-lf<FRACTION>-seed<SEED>`). The 100% runs are the full-data runs already
|
|
configured for the paper.
|
|
|
|
Outputs:
|
|
experiment_logs/label-efficiency/summary.csv
|
|
experiment_logs/label-efficiency/<model>/summary.json
|
|
|
|
Usage:
|
|
python3 tools/aggregate_label_efficiency.py --root experiment_logs/eval-wilds
|
|
python3 tools/aggregate_label_efficiency.py --root experiment_logs/eval-wilds --metric F1-macro_all
|
|
"""
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
from collections import defaultdict
|
|
|
|
import yaml
|
|
|
|
OOD_METRICS_FILE = "iwildcam_test_metrics.json"
|
|
|
|
# Match trailing -lf0.01-seed0 or -seed0.
|
|
_LF_SEED_SUFFIX_RE = re.compile(r"-lf(?P<frac>0\.\d+|[1-9]\d*\.?\d*)-seed(?P<seed>\d+)$")
|
|
_SEED_SUFFIX_RE = re.compile(r"-seed(?P<seed>\d+)$")
|
|
|
|
|
|
FRACTIONS = [0.01, 0.10, 0.50, 1.00]
|
|
|
|
|
|
def _find_metric(metrics, key):
|
|
if isinstance(metrics, dict):
|
|
if key in metrics:
|
|
return metrics[key]
|
|
for value in metrics.values():
|
|
found = _find_metric(value, key)
|
|
if found is not None:
|
|
return found
|
|
elif isinstance(metrics, list):
|
|
for item in metrics:
|
|
found = _find_metric(item, key)
|
|
if found is not None:
|
|
return found
|
|
return None
|
|
|
|
|
|
def _extract_run_info(metrics_obj):
|
|
def _search(obj):
|
|
if isinstance(obj, dict):
|
|
ri = obj.get("run_info")
|
|
if isinstance(ri, dict):
|
|
return ri
|
|
for v in obj.values():
|
|
found = _search(v)
|
|
if found is not None:
|
|
return found
|
|
elif isinstance(obj, list):
|
|
for item in obj:
|
|
found = _search(item)
|
|
if found is not None:
|
|
return found
|
|
return None
|
|
|
|
return _search(metrics_obj) if metrics_obj else {}
|
|
|
|
|
|
def _load_json(path):
|
|
try:
|
|
with open(path, "r") as f:
|
|
return json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
|
|
|
|
def _load_yaml(path):
|
|
try:
|
|
with open(path, "r") as f:
|
|
return yaml.load(f, Loader=yaml.FullLoader)
|
|
except (OSError, yaml.YAMLError):
|
|
return None
|
|
|
|
|
|
def _parse_fraction_and_seed(run_name, run_info, run_dir):
|
|
"""Return (fraction, seed) from run_info, folder name, or params.yaml."""
|
|
fraction = run_info.get("label_fraction")
|
|
seed = run_info.get("seed")
|
|
|
|
# Try folder name first for both values.
|
|
m = _LF_SEED_SUFFIX_RE.search(run_name)
|
|
if m:
|
|
if fraction is None:
|
|
try:
|
|
fraction = float(m.group("frac"))
|
|
except ValueError:
|
|
pass
|
|
if seed is None:
|
|
try:
|
|
seed = int(m.group("seed"))
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
m = _SEED_SUFFIX_RE.search(run_name)
|
|
if m and seed is None:
|
|
try:
|
|
seed = int(m.group("seed"))
|
|
except ValueError:
|
|
pass
|
|
|
|
# Fallback to params.yaml.
|
|
if fraction is None or seed is None:
|
|
params = _load_yaml(os.path.join(run_dir, "params.yaml"))
|
|
if params:
|
|
if fraction is None:
|
|
fraction = _find_metric(params, "label_fraction")
|
|
if seed is None:
|
|
seed = _find_metric(params, "seed")
|
|
|
|
return fraction, seed
|
|
|
|
|
|
def _model_key(run_name):
|
|
"""Strip the label-fraction and seed suffix to obtain a model group key."""
|
|
key = _LF_SEED_SUFFIX_RE.sub("", run_name)
|
|
key = _SEED_SUFFIX_RE.sub("", key)
|
|
return key
|
|
|
|
|
|
def _mean_std(values):
|
|
vals = [v for v in values if v is not None and not _is_nan(v)]
|
|
if not vals:
|
|
return None, None, 0
|
|
n = len(vals)
|
|
mean = sum(vals) / n
|
|
if n > 1:
|
|
std = math.sqrt(sum((v - mean) ** 2 for v in vals) / (n - 1))
|
|
else:
|
|
std = 0.0
|
|
return mean, std, n
|
|
|
|
|
|
def _is_nan(v):
|
|
try:
|
|
return math.isnan(float(v))
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def _fmt(mean, std):
|
|
if mean is None:
|
|
return ""
|
|
if std is None or std == 0.0:
|
|
return f"{mean:.4f}"
|
|
return f"{mean:.4f} +/- {std:.4f}"
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
description=__doc__,
|
|
)
|
|
parser.add_argument(
|
|
"--root",
|
|
default="experiment_logs/eval-wilds",
|
|
help="root folder holding per-run eval subfolders (default: %(default)s)",
|
|
)
|
|
parser.add_argument(
|
|
"--out",
|
|
default="experiment_logs/label-efficiency",
|
|
help="output folder for summaries (default: %(default)s)",
|
|
)
|
|
parser.add_argument(
|
|
"--metric",
|
|
default="F1-macro_all",
|
|
help="OOD metric to aggregate (default: %(default)s)",
|
|
)
|
|
parser.add_argument(
|
|
"--min-seeds",
|
|
type=int,
|
|
default=1,
|
|
help="only report fractions with at least this many seeds (default: %(default)s)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if not os.path.isdir(args.root):
|
|
print(f"Root folder not found: {args.root}")
|
|
return
|
|
|
|
# model_key -> {fraction: [records]}
|
|
groups = defaultdict(lambda: defaultdict(list))
|
|
skipped = []
|
|
|
|
for entry in sorted(os.listdir(args.root)):
|
|
run_dir = os.path.join(args.root, entry)
|
|
if not os.path.isdir(run_dir):
|
|
continue
|
|
|
|
ood_metrics = _load_json(os.path.join(run_dir, OOD_METRICS_FILE))
|
|
if ood_metrics is None:
|
|
continue
|
|
|
|
run_info = _extract_run_info(ood_metrics)
|
|
fraction, seed = _parse_fraction_and_seed(entry, run_info, run_dir)
|
|
value = _find_metric(ood_metrics, args.metric)
|
|
|
|
if fraction is None:
|
|
skipped.append((entry, "no label_fraction"))
|
|
continue
|
|
if value is None:
|
|
skipped.append((entry, f"metric {args.metric} missing"))
|
|
continue
|
|
|
|
key = _model_key(entry)
|
|
groups[key][fraction].append(
|
|
{
|
|
"run_name": entry,
|
|
"seed": seed,
|
|
"value": float(value),
|
|
"run_info": run_info,
|
|
}
|
|
)
|
|
|
|
if not groups:
|
|
print(f"No usable metrics found under {args.root}")
|
|
return
|
|
|
|
os.makedirs(args.out, exist_ok=True)
|
|
|
|
# CSV fieldnames.
|
|
fieldnames = ["model"]
|
|
for frac in FRACTIONS:
|
|
frac_label = f"frac{frac:.2f}"
|
|
fieldnames.extend(
|
|
[
|
|
f"{frac_label}_mean",
|
|
f"{frac_label}_std",
|
|
f"{frac_label}_n",
|
|
]
|
|
)
|
|
fieldnames.append("seeds")
|
|
|
|
csv_rows = []
|
|
|
|
for model_key in sorted(groups.keys()):
|
|
fractions = groups[model_key]
|
|
row = {"model": model_key}
|
|
per_frac = {}
|
|
all_seeds = set()
|
|
|
|
for frac in FRACTIONS:
|
|
records = fractions.get(frac, [])
|
|
values = [r["value"] for r in records]
|
|
mean, std, n = _mean_std(values)
|
|
frac_label = f"frac{frac:.2f}"
|
|
row[f"{frac_label}_mean"] = mean
|
|
row[f"{frac_label}_std"] = std
|
|
row[f"{frac_label}_n"] = n
|
|
per_frac[frac] = {
|
|
"mean": mean,
|
|
"std": std,
|
|
"n": n,
|
|
"seeds": [r["seed"] for r in records],
|
|
"values": values,
|
|
"run_names": [r["run_name"] for r in records],
|
|
}
|
|
for r in records:
|
|
if r["seed"] is not None:
|
|
all_seeds.add(r["seed"])
|
|
|
|
row["seeds"] = " ".join(str(s) for s in sorted(all_seeds))
|
|
csv_rows.append(row)
|
|
|
|
# Write per-model JSON summary.
|
|
model_dir = os.path.join(args.out, model_key)
|
|
os.makedirs(model_dir, exist_ok=True)
|
|
with open(os.path.join(model_dir, "summary.json"), "w") as f:
|
|
json.dump(
|
|
{
|
|
"model": model_key,
|
|
"metric": args.metric,
|
|
"fractions": {f"{k:.2f}": v for k, v in per_frac.items()},
|
|
},
|
|
f,
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
|
|
csv_path = os.path.join(args.out, "summary.csv")
|
|
with open(csv_path, "w", newline="") as f:
|
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
for row in csv_rows:
|
|
writer.writerow(row)
|
|
|
|
# Terminal table.
|
|
print(f"\nAggregated {len(csv_rows)} model(s). Metric: {args.metric}\n")
|
|
header = f"{'model':<50}"
|
|
for frac in FRACTIONS:
|
|
header += f" {f'{int(frac*100)}%':>18}"
|
|
print(header)
|
|
print("-" * len(header))
|
|
for row in csv_rows:
|
|
line = f"{row['model'][:50]:<50}"
|
|
for frac in FRACTIONS:
|
|
frac_label = f"frac{frac:.2f}"
|
|
mean = row[f"{frac_label}_mean"]
|
|
std = row[f"{frac_label}_std"]
|
|
line += f" {_fmt(mean, std):>18}"
|
|
print(line)
|
|
|
|
if skipped:
|
|
print(f"\nSkipped {len(skipped)} run(s):")
|
|
for run_name, reason in skipped:
|
|
print(f" {run_name}: {reason}")
|
|
|
|
print(f"\nWrote per-model summaries to: {args.out}/<model>/summary.json")
|
|
print(f"Wrote combined CSV to: {csv_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|