Files
remove-ai-watermarks/scripts/analyze_engine_selection_study.py
T

122 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""Summarize paired fidelity results from the engine-selection study.
Run from the repository root after ``engine_selection_study.py``:
uv run scripts/analyze_engine_selection_study.py
The report uses per-image paired differences and exact two-sided sign tests.
It does not turn the discovery set into a production routing rule.
"""
from __future__ import annotations
import argparse
import json
import logging
import math
import statistics
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
log = logging.getLogger(__name__)
_METRICS: dict[str, tuple[Callable[[float], float], str]] = {
"lpips_512": (lambda value: value, "lower"),
"ssim": (lambda value: -value, "higher"),
"psnr_db": (lambda value: -value, "higher"),
"mae": (lambda value: value, "lower"),
"edge_f1": (lambda value: -value, "higher"),
"laplacian_ratio": (lambda value: abs(math.log(max(value, 1e-12))), "closer_to_one"),
}
def _sign_test_two_sided(wins: int, losses: int) -> float:
"""Return the exact two-sided sign-test p-value, excluding ties."""
trials = wins + losses
if trials == 0:
return 1.0
tail = sum(math.comb(trials, index) for index in range(min(wins, losses) + 1))
return min(1.0, 2.0 * tail / (2**trials))
def _index(rows: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]:
return {(row["provider"], row["pair_id"]): row for row in rows}
def analyze(report: dict[str, Any]) -> dict[str, Any]:
qwen = _index(report["qwen-zimage"]["rows"])
chroma = _index(report["chroma-zimage"]["rows"])
if qwen.keys() != chroma.keys():
raise ValueError("Qwen and Chroma result keys differ")
by_provider: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = defaultdict(list)
for key in sorted(qwen):
by_provider[key[0]].append((qwen[key], chroma[key]))
providers: dict[str, Any] = {}
for provider, pairs in sorted(by_provider.items()):
metrics: dict[str, Any] = {}
for metric, (loss_transform, direction) in _METRICS.items():
deltas: list[float] = []
chroma_wins = 0
qwen_wins = 0
ties = 0
for qwen_row, chroma_row in pairs:
qwen_value = float(qwen_row["fidelity"][metric])
chroma_value = float(chroma_row["fidelity"][metric])
delta = loss_transform(chroma_value) - loss_transform(qwen_value)
deltas.append(delta)
if math.isclose(delta, 0.0, abs_tol=1e-12):
ties += 1
elif delta < 0:
chroma_wins += 1
else:
qwen_wins += 1
metrics[metric] = {
"preferred_direction": direction,
"delta_definition": "chroma_loss_minus_qwen_loss; negative favors Chroma",
"chroma_wins": chroma_wins,
"qwen_wins": qwen_wins,
"ties": ties,
"mean_delta": statistics.fmean(deltas),
"median_delta": statistics.median(deltas),
"sign_test_two_sided_p": _sign_test_two_sided(chroma_wins, qwen_wins),
}
providers[provider] = {"pairs": len(pairs), "metrics": metrics}
return {
"method": "paired per-image differences with exact two-sided sign tests; ties excluded",
"warning": "Discovery-set evidence only; challenge any proposed rule on unused generations.",
"providers": providers,
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--input",
type=Path,
default=Path("out/engine-selection-study/summary.json"),
)
parser.add_argument(
"--output",
type=Path,
default=Path("out/engine-selection-study/analysis.json"),
)
args = parser.parse_args()
report = json.loads(args.input.read_text(encoding="utf-8"))
analysis = analyze(report)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(analysis, indent=2) + "\n", encoding="utf-8")
log.info("Wrote %s", args.output)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
main()