Viewing files at commit ebd567552629Commit detailsView latest

Browse files
scripts/benchmark_stats.py 164 lines · 6.8 KB · python
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
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
    grouped: dict[tuple[str, str, str], list[float]] = {}
84
    population_rows: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
85
    outcomes: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
86
    for row in iter_rows(args.jsonl):
87
        operation = str(row.get("operation") or "")
88
        if not args.include_subops and operation.endswith(SUBOP_SUFFIXES):
89
            continue
90
        key = (str(row.get("subject")), str(row.get("scenario")), operation)
91
        outcomes.setdefault(key, []).append(row)
92
        grouped.setdefault(key, [])
93
        if row_returncode(row) != 0:
94
            continue
95
        population_rows.setdefault(key, []).append(row)
96
        try:
97
            value = float(row[args.metric])
98
        except (KeyError, TypeError, ValueError):
99
            continue
100
        grouped.setdefault(key, []).append(value)
101
102
    conflicts = [(key, population_conflicts(rows)) for key, rows in population_rows.items()]
103
    conflicts = [(key, fields) for key, fields in conflicts if fields]
104
    if conflicts:
105
        for key, fields in conflicts:
106
            print(f"INVALID incompatible population {'/'.join(key)}: {', '.join(fields)}")
107
        print("Split inputs by measurement identity before computing statistics.")
108
        return 1
109
110
    rng = random.Random(args.seed)
111
    print(f"# Bootstrap Statistics: `{args.metric}`")
112
    print()
113
    print(f"- Inputs: {', '.join(f'`{path}`' for path in args.jsonl)}")
114
    print(f"- Bootstrap samples: {args.bootstrap_samples}")
115
    print(f"- Confidence: {args.confidence:.3f}")
116
    print("- Latency statistics condition on successful measured trials; all outcomes remain below.")
117
    print()
118
    print("| Subject | Scenario / operation | Attempts (excluding skips) | Successful | Failed | Unknown outcome | Skipped | Metric unmeasured |")
119
    print("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |")
120
    for key, rows in sorted(outcomes.items()):
121
        codes = [row_returncode(row) for row in rows]
122
        skipped = codes.count(77)
123
        success = codes.count(0)
124
        unknown = sum(row.get("returncode") is None for row in rows)
125
        failed = len(rows) - skipped - success - unknown
126
        print(f"| `{key[0]}` | `{key[1]}/{key[2]}` | {len(rows)-skipped} | {success} | {failed} | {unknown} | {skipped} | {success-len(grouped[key])} |")
127
    print()
128
    print(
129
        "| Subject | Scenario / operation | n | median | median CI | p90 | p90 CI | "
130
        "p95 | p99 | max | mean | mean CI |"
131
    )
132
    print("| --- | --- | ---: | ---: | --- | ---: | --- | ---: | ---: | ---: | ---: | --- |")
133
    for (subject, scenario, operation), values in sorted(grouped.items()):
134
        if not values:
135
            print(f"| `{subject}` | `{scenario}/{operation}` | 0 | unmeasured | | | | | | | | |")
136
            continue
137
        median_value = float(statistics.median(values))
138
        p90_value = percentile_nearest(values, 90.0)
139
        mean_value = float(statistics.mean(values))
140
        median_ci = bootstrap_ci(values, "median", args.bootstrap_samples, args.confidence, rng)
141
        p90_ci = bootstrap_ci(values, "p90", args.bootstrap_samples, args.confidence, rng)
142
        mean_ci = bootstrap_ci(values, "mean", args.bootstrap_samples, args.confidence, rng)
143
        # Tail honesty: a nearest-rank percentile below 1/(1-p) samples is just
144
        # the max relabelled. Blank cells mean "unmeasurable at this n", never 0.
145
        n = len(values)
146
        p95_cell = f"{percentile_nearest(values, 95.0):.3f}" if n >= TAIL_MIN_SAMPLES["p95"] else ""
147
        p99_cell = f"{percentile_nearest(values, 99.0):.3f}" if n >= TAIL_MIN_SAMPLES["p99"] else ""
148
        print(
149
            f"| `{subject}` | `{scenario}/{operation}` | {n} | "
150
            f"{median_value:.3f} | [{median_ci[0]:.3f}, {median_ci[1]:.3f}] | "
151
            f"{p90_value:.3f} | [{p90_ci[0]:.3f}, {p90_ci[1]:.3f}] | "
152
            f"{p95_cell} | {p99_cell} | {max(values):.3f} | "
153
            f"{mean_value:.3f} | [{mean_ci[0]:.3f}, {mean_ci[1]:.3f}] |"
154
        )
155
    print()
156
    print(
157
        f"p95/p99 cells are blank below n={TAIL_MIN_SAMPLES['p95']}/n={TAIL_MIN_SAMPLES['p99']}: "
158
        "at smaller n the nearest-rank tail percentile equals the max, so claiming it would overstate precision."
159
    )
160
    return 0
161
162
163
if __name__ == "__main__":
164
    raise SystemExit(main())