aggregate_benchmark.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.9"
  4. # ///
  5. """Variance benchmark: summarize a metric across N runs, and compare two configs.
  6. A single skill run is noisy. Running the same case N times and summarizing the
  7. spread tells you whether a difference between two versions is real or just noise.
  8. This script computes, per numeric metric, the mean, the sample standard deviation
  9. (n-1, the unbiased estimator for a sample), the min, and the max across N runs.
  10. Given two such config summaries it reports the delta on each shared metric so a
  11. "did the change help" question gets a number instead of a guess.
  12. Input shapes accepted for a single config:
  13. - a list of run records, each a flat dict of metric -> number
  14. [{"elapsed_s": 12.1, "total_tokens": 800}, {"elapsed_s": 11.4, ...}]
  15. - {"runs": [ ...records... ]}
  16. - a directory of run folders, each holding timing.json files written by
  17. run_evals.py (the script reads every timing.json under the directory and
  18. treats each as one run record)
  19. Usage:
  20. Summarize one config across its runs:
  21. python3 aggregate_benchmark.py --runs CONFIG_A.json
  22. python3 aggregate_benchmark.py --runs RUN_DIR/ (reads timing.json files)
  23. Compare two configs (each summarized, then delta = B - A):
  24. python3 aggregate_benchmark.py --baseline A.json --variant B.json
  25. Self-test on a known fixture (no external input needed):
  26. python3 aggregate_benchmark.py --self-test
  27. Output is one JSON object on stdout.
  28. """
  29. from __future__ import annotations
  30. import argparse
  31. import json
  32. import math
  33. import sys
  34. from pathlib import Path
  35. NUMERIC = (int, float)
  36. # --- statistics -------------------------------------------------------------
  37. def sample_stddev(values: list[float]) -> float:
  38. """Sample standard deviation using n-1 (Bessel's correction).
  39. Returns 0.0 for fewer than two values, where the sample variance is
  40. undefined and reporting zero spread is the least surprising choice.
  41. """
  42. n = len(values)
  43. if n < 2:
  44. return 0.0
  45. mean = sum(values) / n
  46. var = sum((x - mean) ** 2 for x in values) / (n - 1)
  47. return math.sqrt(var)
  48. def summarize_metric(values: list[float]) -> dict:
  49. return {
  50. "n": len(values),
  51. "mean": (sum(values) / len(values)) if values else 0.0,
  52. "stddev": sample_stddev(values),
  53. "min": min(values) if values else 0.0,
  54. "max": max(values) if values else 0.0,
  55. }
  56. def collect_numeric_metrics(records: list[dict]) -> dict[str, list[float]]:
  57. """Group every numeric field across records by metric name."""
  58. by_metric: dict[str, list[float]] = {}
  59. for rec in records:
  60. if not isinstance(rec, dict):
  61. continue
  62. for key, val in rec.items():
  63. if isinstance(val, bool):
  64. continue # bools are ints in Python; not a metric
  65. if isinstance(val, NUMERIC):
  66. by_metric.setdefault(key, []).append(float(val))
  67. return by_metric
  68. def summarize_config(records: list[dict]) -> dict:
  69. by_metric = collect_numeric_metrics(records)
  70. return {
  71. "runs": len(records),
  72. "metrics": {name: summarize_metric(vals)
  73. for name, vals in sorted(by_metric.items())},
  74. }
  75. def delta_configs(baseline: dict, variant: dict) -> dict:
  76. """Per shared metric, delta = variant.mean - baseline.mean, plus context."""
  77. b_metrics = baseline.get("metrics", {})
  78. v_metrics = variant.get("metrics", {})
  79. shared = sorted(set(b_metrics) & set(v_metrics))
  80. out: dict[str, dict] = {}
  81. for name in shared:
  82. b = b_metrics[name]
  83. v = v_metrics[name]
  84. diff = v["mean"] - b["mean"]
  85. pct = (diff / b["mean"] * 100.0) if b["mean"] != 0 else None
  86. out[name] = {
  87. "baseline_mean": b["mean"],
  88. "variant_mean": v["mean"],
  89. "delta": diff,
  90. "delta_pct": pct,
  91. "baseline_stddev": b["stddev"],
  92. "variant_stddev": v["stddev"],
  93. }
  94. return out
  95. # --- input loading ----------------------------------------------------------
  96. def load_records(path: Path) -> list[dict]:
  97. """Load run records from a JSON file, a {'runs': [...]} file, or a dir of
  98. timing.json files."""
  99. if path.is_dir():
  100. records: list[dict] = []
  101. for f in sorted(path.rglob("timing.json")):
  102. try:
  103. data = json.loads(f.read_text(encoding="utf-8"))
  104. except (OSError, json.JSONDecodeError):
  105. continue
  106. if isinstance(data, dict):
  107. records.append(data)
  108. return records
  109. data = json.loads(path.read_text(encoding="utf-8"))
  110. if isinstance(data, dict) and "runs" in data:
  111. data = data["runs"]
  112. if not isinstance(data, list):
  113. raise ValueError(f"expected a list of run records in {path}")
  114. return [r for r in data if isinstance(r, dict)]
  115. # --- self-test --------------------------------------------------------------
  116. def run_self_test() -> int:
  117. """Verify mean/stddev/min/max/delta on a known fixture."""
  118. config_a = [
  119. {"elapsed_s": 10.0, "total_tokens": 100},
  120. {"elapsed_s": 12.0, "total_tokens": 200},
  121. {"elapsed_s": 14.0, "total_tokens": 300},
  122. ]
  123. summary_a = summarize_config(config_a)
  124. el = summary_a["metrics"]["elapsed_s"]
  125. # mean of 10,12,14 = 12; n-1 stddev = sqrt(((-2)^2+0+2^2)/2)=sqrt(4)=2
  126. assert el["n"] == 3, el
  127. assert abs(el["mean"] - 12.0) < 1e-9, el
  128. assert abs(el["stddev"] - 2.0) < 1e-9, el
  129. assert el["min"] == 10.0 and el["max"] == 14.0, el
  130. tok = summary_a["metrics"]["total_tokens"]
  131. # mean of 100,200,300 = 200; n-1 stddev = sqrt((10000+0+10000)/2)=100
  132. assert abs(tok["mean"] - 200.0) < 1e-9, tok
  133. assert abs(tok["stddev"] - 100.0) < 1e-9, tok
  134. # single value -> stddev 0
  135. one = summarize_config([{"x": 5}])
  136. assert one["metrics"]["x"]["stddev"] == 0.0, one
  137. # bools are not treated as metrics
  138. with_bool = summarize_config([{"ok": True, "x": 1}, {"ok": False, "x": 3}])
  139. assert "ok" not in with_bool["metrics"], with_bool
  140. assert abs(with_bool["metrics"]["x"]["mean"] - 2.0) < 1e-9, with_bool
  141. # delta: variant slower by 3s on mean, faster question answered by sign
  142. config_b = [
  143. {"elapsed_s": 13.0, "total_tokens": 90},
  144. {"elapsed_s": 15.0, "total_tokens": 110},
  145. {"elapsed_s": 17.0, "total_tokens": 100},
  146. ]
  147. summary_b = summarize_config(config_b)
  148. d = delta_configs(summary_a, summary_b)
  149. # elapsed mean: A=12, B=15 -> delta +3, pct +25%
  150. assert abs(d["elapsed_s"]["delta"] - 3.0) < 1e-9, d
  151. assert abs(d["elapsed_s"]["delta_pct"] - 25.0) < 1e-9, d
  152. # tokens mean: A=200, B=100 -> delta -100, pct -50%
  153. assert abs(d["total_tokens"]["delta"] + 100.0) < 1e-9, d
  154. assert abs(d["total_tokens"]["delta_pct"] + 50.0) < 1e-9, d
  155. print(json.dumps({"self_test": "passed",
  156. "checked": ["mean", "stddev_n_minus_1", "min", "max",
  157. "single_value_stddev", "bool_excluded",
  158. "delta", "delta_pct"]}))
  159. return 0
  160. # --- main -------------------------------------------------------------------
  161. def main(argv: list[str] | None = None) -> int:
  162. p = argparse.ArgumentParser(
  163. description=__doc__,
  164. formatter_class=argparse.RawDescriptionHelpFormatter,
  165. )
  166. p.add_argument("--runs", type=Path,
  167. help="summarize one config (JSON file or dir of timing.json)")
  168. p.add_argument("--baseline", type=Path,
  169. help="baseline config for a two-config comparison")
  170. p.add_argument("--variant", type=Path,
  171. help="variant config for a two-config comparison")
  172. p.add_argument("--self-test", action="store_true",
  173. help="run the built-in fixture self-test and exit")
  174. args = p.parse_args(argv)
  175. if args.self_test:
  176. return run_self_test()
  177. if args.baseline and args.variant:
  178. b = summarize_config(load_records(args.baseline))
  179. v = summarize_config(load_records(args.variant))
  180. out = {
  181. "baseline": b,
  182. "variant": v,
  183. "delta": delta_configs(b, v),
  184. }
  185. print(json.dumps(out, indent=2))
  186. return 0
  187. if args.runs:
  188. out = summarize_config(load_records(args.runs))
  189. print(json.dumps(out, indent=2))
  190. return 0
  191. p.error("provide --runs, or both --baseline and --variant, or --self-test")
  192. return 2
  193. if __name__ == "__main__":
  194. sys.exit(main())