Log in
scripts/benchmark_stats.py 165 lines · 6.9 KB · python Blame
1
#!/usr/bin/env python3
2
"""Compute bootstrap confidence intervals for Oak benchmark JSONL files."""
3
4
from __future__ import annotations
5
6
import argparse
7
import json
8
import math
9
import random
10
import statistics
11
from pathlib import Path
12
from typing import Any, Iterable
13
14
from oakbench.rows import row_returncode
15
from oakbench.populations import population_conflicts
16
from oakbench.stats import percentile_nearest, validate_interval_settings
17
18
19
SUBOP_SUFFIXES = (".add", ".commit")
20
21
# Mirrors oakbench.reporting.TAIL_PERCENTILE_MIN_SAMPLES; kept import-free so
22
# this script stays runnable against result files on machines without the repo.
23
TAIL_MIN_SAMPLES = {"p95": 20, "p99": 100}
24
25
26
def parse_args() -> argparse.Namespace:
27
    parser = argparse.ArgumentParser(description=__doc__)
28
    parser.add_argument("jsonl", nargs="+", type=Path)
29
    parser.add_argument("--metric", default="elapsed_ms")
30
    parser.add_argument("--bootstrap-samples", type=int, default=2000)
31
    parser.add_argument("--confidence", type=float, default=0.95)
32
    parser.add_argument("--seed", type=int, default=20260609)
33
    parser.add_argument("--include-subops", action="store_true")
34
    return parser.parse_args()
35
36
37
def iter_rows(paths: Iterable[Path]) -> Iterable[dict[str, Any]]:
38
    for path in paths:
39
        with path.open() as fh:
40
            for raw_line in fh:
41
                line = raw_line.strip()
42
                if line:
43
                    yield json.loads(line)
44
45
46
def bootstrap_ci(
47
    values: list[float],
48
    statistic: str,
49
    samples: int,
50
    confidence: float,
51
    rng: random.Random,
52
) -> tuple[float, float]:
53
    if len(values) <= 1:
54
        value = values[0] if values else float("nan")
55
        return value, value
56
57
    stats: list[float] = []
58
    n = len(values)
59
    for _ in range(samples):
60
        sample = [values[rng.randrange(n)] for _ in range(n)]
61
        if statistic == "median":
62
            stats.append(float(statistics.median(sample)))
63
        elif statistic == "p90":
64
            stats.append(percentile_nearest(sample, 90.0))
65
        elif statistic == "p95":
66
            stats.append(percentile_nearest(sample, 95.0))
67
        elif statistic == "p99":
68
            stats.append(percentile_nearest(sample, 99.0))
69
        elif statistic == "mean":
70
            stats.append(float(statistics.mean(sample)))
71
        else:
72
            raise ValueError(statistic)
73
74
    stats.sort()
75
    alpha = 1.0 - confidence
76
    low_index = max(0, min(len(stats) - 1, math.floor((alpha / 2.0) * len(stats))))
77
    high_index = max(0, min(len(stats) - 1, math.ceil((1.0 - alpha / 2.0) * len(stats)) - 1))
78
    return stats[low_index], stats[high_index]
79
80
81
def main() -> int:
82
    args = parse_args()
83
    validate_interval_settings(args.bootstrap_samples, args.confidence)
84
    grouped: dict[tuple[str, str, str], list[float]] = {}
85
    population_rows: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
86
    outcomes: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
87
    for row in iter_rows(args.jsonl):
88
        operation = str(row.get("operation") or "")
89
        if not args.include_subops and operation.endswith(SUBOP_SUFFIXES):
90
            continue
91
        key = (str(row.get("subject")), str(row.get("scenario")), operation)
92
        outcomes.setdefault(key, []).append(row)
93
        grouped.setdefault(key, [])
94
        if row_returncode(row) != 0:
95
            continue
96
        population_rows.setdefault(key, []).append(row)
97
        try:
98
            value = float(row[args.metric])
99
        except (KeyError, TypeError, ValueError):
100
            continue
101
        grouped.setdefault(key, []).append(value)
102
103
    conflicts = [(key, population_conflicts(rows)) for key, rows in population_rows.items()]
104
    conflicts = [(key, fields) for key, fields in conflicts if fields]
105
    if conflicts:
106
        for key, fields in conflicts:
107
            print(f"INVALID incompatible population {'/'.join(key)}: {', '.join(fields)}")
108
        print("Split inputs by measurement identity before computing statistics.")
109
        return 1
110
111
    rng = random.Random(args.seed)
112
    print(f"# Bootstrap Statistics: `{args.metric}`")
113
    print()
114
    print(f"- Inputs: {', '.join(f'`{path}`' for path in args.jsonl)}")
115
    print(f"- Bootstrap samples: {args.bootstrap_samples}")
116
    print(f"- Confidence: {args.confidence:.3f}")
117
    print("- Latency statistics condition on successful measured trials; all outcomes remain below.")
118
    print()
119
    print("| Subject | Scenario / operation | Attempts (excluding skips) | Successful | Failed | Unknown outcome | Skipped | Metric unmeasured |")
120
    print("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |")
121
    for key, rows in sorted(outcomes.items()):
122
        codes = [row_returncode(row) for row in rows]
123
        skipped = codes.count(77)
124
        success = codes.count(0)
125
        unknown = sum(row.get("returncode") is None for row in rows)
126
        failed = len(rows) - skipped - success - unknown
127
        print(f"| `{key[0]}` | `{key[1]}/{key[2]}` | {len(rows)-skipped} | {success} | {failed} | {unknown} | {skipped} | {success-len(grouped[key])} |")
128
    print()
129
    print(
130
        "| Subject | Scenario / operation | n | median | median CI | p90 | p90 CI | "
131
        "p95 | p99 | max | mean | mean CI |"
132
    )
133
    print("| --- | --- | ---: | ---: | --- | ---: | --- | ---: | ---: | ---: | ---: | --- |")
134
    for (subject, scenario, operation), values in sorted(grouped.items()):
135
        if not values:
136
            print(f"| `{subject}` | `{scenario}/{operation}` | 0 | unmeasured | | | | | | | | |")
137
            continue
138
        median_value = float(statistics.median(values))
139
        p90_value = percentile_nearest(values, 90.0)
140
        mean_value = float(statistics.mean(values))
141
        median_ci = bootstrap_ci(values, "median", args.bootstrap_samples, args.confidence, rng)
142
        p90_ci = bootstrap_ci(values, "p90", args.bootstrap_samples, args.confidence, rng)
143
        mean_ci = bootstrap_ci(values, "mean", args.bootstrap_samples, args.confidence, rng)
144
        # Tail honesty: a nearest-rank percentile below 1/(1-p) samples is just
145
        # the max relabelled. Blank cells mean "unmeasurable at this n", never 0.
146
        n = len(values)
147
        p95_cell = f"{percentile_nearest(values, 95.0):.3f}" if n >= TAIL_MIN_SAMPLES["p95"] else ""
148
        p99_cell = f"{percentile_nearest(values, 99.0):.3f}" if n >= TAIL_MIN_SAMPLES["p99"] else ""
149
        print(
150
            f"| `{subject}` | `{scenario}/{operation}` | {n} | "
151
            f"{median_value:.3f} | [{median_ci[0]:.3f}, {median_ci[1]:.3f}] | "
152
            f"{p90_value:.3f} | [{p90_ci[0]:.3f}, {p90_ci[1]:.3f}] | "
153
            f"{p95_cell} | {p99_cell} | {max(values):.3f} | "
154
            f"{mean_value:.3f} | [{mean_ci[0]:.3f}, {mean_ci[1]:.3f}] |"
155
        )
156
    print()
157
    print(
158
        f"p95/p99 cells are blank below n={TAIL_MIN_SAMPLES['p95']}/n={TAIL_MIN_SAMPLES['p99']}: "
159
        "at smaller n the nearest-rank tail percentile equals the max, so claiming it would overstate precision."
160
    )
161
    return 0
162
163
164
if __name__ == "__main__":
165
    raise SystemExit(main())