synthbench-eval 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- synthbench/__init__.py +16 -0
- synthbench/__main__.py +5 -0
- synthbench/adapter.py +131 -0
- synthbench/anomaly.py +503 -0
- synthbench/baseline_floors.py +213 -0
- synthbench/baselines.py +902 -0
- synthbench/cli.py +2929 -0
- synthbench/config_id.py +424 -0
- synthbench/contamination.py +783 -0
- synthbench/convergence/__init__.py +67 -0
- synthbench/convergence/baseline.py +172 -0
- synthbench/convergence/bootstrap.py +60 -0
- synthbench/convergence/cli_report.py +556 -0
- synthbench/convergence/curves.py +111 -0
- synthbench/convergence/real_sampling.py +146 -0
- synthbench/convergence/thresholds.py +49 -0
- synthbench/datasets/__init__.py +37 -0
- synthbench/datasets/base.py +147 -0
- synthbench/datasets/eurobarometer.py +335 -0
- synthbench/datasets/globalopinionqa.py +252 -0
- synthbench/datasets/gss.py +323 -0
- synthbench/datasets/michigan.py +505 -0
- synthbench/datasets/ntia.py +357 -0
- synthbench/datasets/opinionsqa.py +412 -0
- synthbench/datasets/pewtech.py +334 -0
- synthbench/datasets/policy.py +142 -0
- synthbench/datasets/subpop.py +302 -0
- synthbench/datasets/wvs.py +229 -0
- synthbench/findings.py +988 -0
- synthbench/holdout.py +347 -0
- synthbench/human_distributions.py +243 -0
- synthbench/leaderboard.py +715 -0
- synthbench/leaderboard_pr.py +274 -0
- synthbench/metrics/__init__.py +38 -0
- synthbench/metrics/composite.py +72 -0
- synthbench/metrics/conditioning.py +39 -0
- synthbench/metrics/distributional.py +41 -0
- synthbench/metrics/ranking.py +37 -0
- synthbench/metrics/refusal.py +270 -0
- synthbench/metrics/subgroup.py +58 -0
- synthbench/private_holdout.py +240 -0
- synthbench/providers/__init__.py +43 -0
- synthbench/providers/_parsing.py +150 -0
- synthbench/providers/_retry.py +107 -0
- synthbench/providers/base.py +212 -0
- synthbench/providers/http.py +108 -0
- synthbench/providers/majority_baseline.py +23 -0
- synthbench/providers/ollama.py +109 -0
- synthbench/providers/openrouter.py +144 -0
- synthbench/providers/population_baseline.py +87 -0
- synthbench/providers/random_baseline.py +24 -0
- synthbench/providers/raw_anthropic.py +149 -0
- synthbench/providers/raw_gemini.py +145 -0
- synthbench/providers/raw_openai.py +139 -0
- synthbench/providers/synthpanel.py +978 -0
- synthbench/publish.py +2772 -0
- synthbench/r2_upload.py +178 -0
- synthbench/recompute.py +235 -0
- synthbench/report.py +493 -0
- synthbench/run_hash.py +111 -0
- synthbench/run_validity.py +232 -0
- synthbench/runner.py +834 -0
- synthbench/stats.py +1492 -0
- synthbench/submission.py +284 -0
- synthbench/submission_pr.py +436 -0
- synthbench/submit_adapter.py +748 -0
- synthbench/suite.py +424 -0
- synthbench/suites/__init__.py +94 -0
- synthbench/topics.py +256 -0
- synthbench/user_config.py +409 -0
- synthbench/validation.py +1556 -0
- synthbench/visualize.py +455 -0
- synthbench_eval-0.4.0.dist-info/METADATA +279 -0
- synthbench_eval-0.4.0.dist-info/RECORD +78 -0
- synthbench_eval-0.4.0.dist-info/WHEEL +5 -0
- synthbench_eval-0.4.0.dist-info/entry_points.txt +2 -0
- synthbench_eval-0.4.0.dist-info/licenses/LICENSE +21 -0
- synthbench_eval-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""CLI-facing orchestration for ``synthbench convergence bootstrap``.
|
|
2
|
+
|
|
3
|
+
Loads a dataset, computes per-question convergence curves, builds a
|
|
4
|
+
dataset-wide summary, honors the dataset's ``redistribution_policy``, writes
|
|
5
|
+
JSON (and optionally a matplotlib PDF), and returns the payload for CLI
|
|
6
|
+
echoing.
|
|
7
|
+
|
|
8
|
+
Kept as a thin orchestration layer so it can be exercised directly by tests
|
|
9
|
+
without going through click.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Sequence
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from synthbench.convergence.curves import (
|
|
22
|
+
DEFAULT_BOOTSTRAP_B,
|
|
23
|
+
DEFAULT_SAMPLE_SIZES,
|
|
24
|
+
compute_curve,
|
|
25
|
+
curve_to_dicts,
|
|
26
|
+
)
|
|
27
|
+
from synthbench.convergence.thresholds import (
|
|
28
|
+
DEFAULT_DELTA,
|
|
29
|
+
DEFAULT_EPSILON,
|
|
30
|
+
find_convergence_n,
|
|
31
|
+
)
|
|
32
|
+
from synthbench.datasets import DATASETS
|
|
33
|
+
from synthbench.datasets.base import DatasetDownloadError, Question
|
|
34
|
+
from synthbench.datasets.policy import policy_for
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class QuestionReport:
|
|
39
|
+
"""One question's curve + convergence_n, pre-policy filtering."""
|
|
40
|
+
|
|
41
|
+
key: str
|
|
42
|
+
text: str
|
|
43
|
+
human_distribution: dict[str, float]
|
|
44
|
+
curve: list[dict]
|
|
45
|
+
convergence_n: int | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_sample_sizes(spec: str | None) -> tuple[int, ...]:
|
|
49
|
+
if not spec:
|
|
50
|
+
return DEFAULT_SAMPLE_SIZES
|
|
51
|
+
sizes: list[int] = []
|
|
52
|
+
for part in spec.split(","):
|
|
53
|
+
part = part.strip()
|
|
54
|
+
if not part:
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
n = int(part)
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise ValueError(f"invalid sample size: {part!r}") from exc
|
|
60
|
+
if n < 1:
|
|
61
|
+
raise ValueError(f"sample sizes must be positive, got {n}")
|
|
62
|
+
sizes.append(n)
|
|
63
|
+
if not sizes:
|
|
64
|
+
raise ValueError("sample sizes is empty")
|
|
65
|
+
return tuple(sizes)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def compute_reports(
|
|
69
|
+
questions: Sequence[Question],
|
|
70
|
+
sample_sizes: Sequence[int] = DEFAULT_SAMPLE_SIZES,
|
|
71
|
+
B: int = DEFAULT_BOOTSTRAP_B,
|
|
72
|
+
epsilon: float = DEFAULT_EPSILON,
|
|
73
|
+
delta: float = DEFAULT_DELTA,
|
|
74
|
+
rng: np.random.Generator | int | None = None,
|
|
75
|
+
) -> list[QuestionReport]:
|
|
76
|
+
"""Compute curves + convergence_n for each question."""
|
|
77
|
+
if isinstance(rng, np.random.Generator):
|
|
78
|
+
gen = rng
|
|
79
|
+
else:
|
|
80
|
+
gen = np.random.default_rng(rng)
|
|
81
|
+
|
|
82
|
+
reports: list[QuestionReport] = []
|
|
83
|
+
for q in questions:
|
|
84
|
+
curve = compute_curve(
|
|
85
|
+
q.human_distribution, sample_sizes=sample_sizes, B=B, rng=gen
|
|
86
|
+
)
|
|
87
|
+
conv_n = find_convergence_n(curve, epsilon=epsilon, delta=delta)
|
|
88
|
+
reports.append(
|
|
89
|
+
QuestionReport(
|
|
90
|
+
key=q.key,
|
|
91
|
+
text=q.text,
|
|
92
|
+
human_distribution=dict(q.human_distribution),
|
|
93
|
+
curve=curve_to_dicts(curve),
|
|
94
|
+
convergence_n=conv_n,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return reports
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _percentile_or_none(values: list[int], p: float) -> int | None:
|
|
101
|
+
if not values:
|
|
102
|
+
return None
|
|
103
|
+
return int(np.percentile(values, p))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def summarize(reports: Sequence[QuestionReport]) -> dict:
|
|
107
|
+
"""Dataset-wide summary: count of convergent questions + percentiles.
|
|
108
|
+
|
|
109
|
+
Only questions where convergence was reached contribute to percentiles.
|
|
110
|
+
Non-converging questions are counted separately so downstream consumers
|
|
111
|
+
can flag datasets that never stabilize within the sample-size grid.
|
|
112
|
+
"""
|
|
113
|
+
ns = [r.convergence_n for r in reports if r.convergence_n is not None]
|
|
114
|
+
return {
|
|
115
|
+
"n_questions": len(reports),
|
|
116
|
+
"n_converged": len(ns),
|
|
117
|
+
"n_unconverged": len(reports) - len(ns),
|
|
118
|
+
"convergence_n_p50": _percentile_or_none(ns, 50),
|
|
119
|
+
"convergence_n_p90": _percentile_or_none(ns, 90),
|
|
120
|
+
"convergence_n_p99": _percentile_or_none(ns, 99),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_payload(
|
|
125
|
+
dataset_name: str,
|
|
126
|
+
reports: Sequence[QuestionReport],
|
|
127
|
+
sample_sizes: Sequence[int],
|
|
128
|
+
B: int,
|
|
129
|
+
epsilon: float,
|
|
130
|
+
delta: float,
|
|
131
|
+
) -> dict:
|
|
132
|
+
"""Render the final JSON payload, honoring redistribution policy.
|
|
133
|
+
|
|
134
|
+
Tier behavior:
|
|
135
|
+
* ``full`` -- include full per-question curves + distributions
|
|
136
|
+
* ``gated`` -- include full per-question curves + distributions
|
|
137
|
+
(gated artifacts route to R2 at publish time;
|
|
138
|
+
the payload itself remains intact)
|
|
139
|
+
* ``aggregates_only`` -- drop per-question entries; keep summary only
|
|
140
|
+
* ``citation_only`` -- drop per-question AND summary stats; emit a
|
|
141
|
+
notice explaining the suppression
|
|
142
|
+
"""
|
|
143
|
+
policy = policy_for(dataset_name)
|
|
144
|
+
summary = summarize(reports)
|
|
145
|
+
|
|
146
|
+
payload: dict = {
|
|
147
|
+
"dataset": dataset_name,
|
|
148
|
+
"redistribution_policy": policy.redistribution_policy,
|
|
149
|
+
"license_url": policy.license_url,
|
|
150
|
+
"citation": policy.citation,
|
|
151
|
+
"parameters": {
|
|
152
|
+
"sample_sizes": list(sample_sizes),
|
|
153
|
+
"bootstrap_B": B,
|
|
154
|
+
"epsilon": epsilon,
|
|
155
|
+
"delta": delta,
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if policy.redistribution_policy == "citation_only":
|
|
160
|
+
payload["questions"] = []
|
|
161
|
+
payload["summary"] = None
|
|
162
|
+
payload["suppressed"] = (
|
|
163
|
+
"redistribution_policy=citation_only: per-question curves and "
|
|
164
|
+
"aggregate stats are suppressed. Only dataset metadata ships."
|
|
165
|
+
)
|
|
166
|
+
return payload
|
|
167
|
+
|
|
168
|
+
payload["summary"] = summary
|
|
169
|
+
|
|
170
|
+
if policy.redistribution_policy == "aggregates_only":
|
|
171
|
+
payload["questions"] = []
|
|
172
|
+
payload["suppressed"] = (
|
|
173
|
+
"redistribution_policy=aggregates_only: per-question curves "
|
|
174
|
+
"suppressed; only dataset-wide summary shipped."
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
# full or gated
|
|
178
|
+
payload["questions"] = [asdict(r) for r in reports]
|
|
179
|
+
|
|
180
|
+
return payload
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _render_plot(
|
|
184
|
+
payload: dict,
|
|
185
|
+
reports: Sequence[QuestionReport],
|
|
186
|
+
pdf_path: Path,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Render per-question convergence curves to a single multi-page PDF.
|
|
189
|
+
|
|
190
|
+
Matches the plotting style used in ``synthbench.visualize``: Agg backend,
|
|
191
|
+
log-scaled x-axis (since sample sizes span orders of magnitude), mean as
|
|
192
|
+
the main line, p10/p90 as a shaded band.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
import matplotlib
|
|
196
|
+
|
|
197
|
+
matplotlib.use("Agg")
|
|
198
|
+
import matplotlib.pyplot as plt
|
|
199
|
+
from matplotlib.backends.backend_pdf import PdfPages
|
|
200
|
+
except ImportError as exc:
|
|
201
|
+
raise RuntimeError(
|
|
202
|
+
"--plot requires matplotlib. Install with: pip install 'synthbench-eval[viz] @ git+https://github.com/DataViking-Tech/SynthBench.git'"
|
|
203
|
+
) from exc
|
|
204
|
+
|
|
205
|
+
if not reports:
|
|
206
|
+
raise ValueError("no questions to plot (policy may have suppressed them)")
|
|
207
|
+
|
|
208
|
+
with PdfPages(pdf_path) as pdf:
|
|
209
|
+
# Summary page: convergence_n distribution across the dataset
|
|
210
|
+
conv_ns = [r.convergence_n for r in reports if r.convergence_n is not None]
|
|
211
|
+
fig, ax = plt.subplots(figsize=(8, 5))
|
|
212
|
+
if conv_ns:
|
|
213
|
+
ax.hist(conv_ns, bins=20)
|
|
214
|
+
ax.set_xscale("log")
|
|
215
|
+
ax.set_xlabel("convergence n (log)")
|
|
216
|
+
ax.set_ylabel("question count")
|
|
217
|
+
ax.set_title(
|
|
218
|
+
f"{payload['dataset']}: convergence n distribution "
|
|
219
|
+
f"({len(conv_ns)} of {len(reports)} questions converged)"
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
ax.text(
|
|
223
|
+
0.5,
|
|
224
|
+
0.5,
|
|
225
|
+
"No questions converged under ε/δ thresholds",
|
|
226
|
+
ha="center",
|
|
227
|
+
va="center",
|
|
228
|
+
transform=ax.transAxes,
|
|
229
|
+
)
|
|
230
|
+
ax.set_title(f"{payload['dataset']}: no convergence")
|
|
231
|
+
fig.tight_layout()
|
|
232
|
+
pdf.savefig(fig)
|
|
233
|
+
plt.close(fig)
|
|
234
|
+
|
|
235
|
+
# Per-question curves
|
|
236
|
+
for r in reports:
|
|
237
|
+
fig, ax = plt.subplots(figsize=(8, 5))
|
|
238
|
+
ns = [p["n"] for p in r.curve]
|
|
239
|
+
means = [p["jsd_mean"] for p in r.curve]
|
|
240
|
+
p10s = [p["jsd_p10"] for p in r.curve]
|
|
241
|
+
p90s = [p["jsd_p90"] for p in r.curve]
|
|
242
|
+
ax.plot(ns, means, marker="o", label="JSD mean")
|
|
243
|
+
ax.fill_between(ns, p10s, p90s, alpha=0.2, label="p10–p90")
|
|
244
|
+
ax.set_xscale("log")
|
|
245
|
+
ax.set_xlabel("sample size n (log)")
|
|
246
|
+
ax.set_ylabel("JSD(sample, full)")
|
|
247
|
+
title_text = r.text[:80] + ("…" if len(r.text) > 80 else "")
|
|
248
|
+
conv_label = (
|
|
249
|
+
f"convergence n = {r.convergence_n}"
|
|
250
|
+
if r.convergence_n is not None
|
|
251
|
+
else "no convergence under ε/δ"
|
|
252
|
+
)
|
|
253
|
+
ax.set_title(f"{r.key}: {title_text}\n{conv_label}")
|
|
254
|
+
ax.legend()
|
|
255
|
+
fig.tight_layout()
|
|
256
|
+
pdf.savefig(fig)
|
|
257
|
+
plt.close(fig)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run_bootstrap(
|
|
261
|
+
dataset_name: str,
|
|
262
|
+
question_key: str | None = None,
|
|
263
|
+
output: Path | None = None,
|
|
264
|
+
plot: bool = False,
|
|
265
|
+
bootstraps: int | None = None,
|
|
266
|
+
sample_sizes: str | None = None,
|
|
267
|
+
epsilon: float | None = None,
|
|
268
|
+
delta: float | None = None,
|
|
269
|
+
seed: int | None = None,
|
|
270
|
+
n_limit: int | None = None,
|
|
271
|
+
) -> tuple[dict, Path | None]:
|
|
272
|
+
"""Top-level entry used by the CLI. Returns (payload, optional PDF path)."""
|
|
273
|
+
if dataset_name not in DATASETS:
|
|
274
|
+
known = ", ".join(sorted(DATASETS))
|
|
275
|
+
raise ValueError(f"unknown dataset {dataset_name!r}; known datasets: {known}")
|
|
276
|
+
|
|
277
|
+
sizes = _parse_sample_sizes(sample_sizes)
|
|
278
|
+
B = DEFAULT_BOOTSTRAP_B if bootstraps is None else int(bootstraps)
|
|
279
|
+
eps = DEFAULT_EPSILON if epsilon is None else float(epsilon)
|
|
280
|
+
dlt = DEFAULT_DELTA if delta is None else float(delta)
|
|
281
|
+
if B < 1:
|
|
282
|
+
raise ValueError(f"--bootstraps must be >= 1, got {B}")
|
|
283
|
+
|
|
284
|
+
dataset = DATASETS[dataset_name]()
|
|
285
|
+
questions = dataset.load(n=n_limit)
|
|
286
|
+
if question_key:
|
|
287
|
+
questions = [q for q in questions if q.key == question_key]
|
|
288
|
+
if not questions:
|
|
289
|
+
raise ValueError(
|
|
290
|
+
f"no question with key {question_key!r} in dataset {dataset_name!r}"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
reports = compute_reports(
|
|
294
|
+
questions,
|
|
295
|
+
sample_sizes=sizes,
|
|
296
|
+
B=B,
|
|
297
|
+
epsilon=eps,
|
|
298
|
+
delta=dlt,
|
|
299
|
+
rng=seed,
|
|
300
|
+
)
|
|
301
|
+
payload = build_payload(
|
|
302
|
+
dataset_name=dataset_name,
|
|
303
|
+
reports=reports,
|
|
304
|
+
sample_sizes=sizes,
|
|
305
|
+
B=B,
|
|
306
|
+
epsilon=eps,
|
|
307
|
+
delta=dlt,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
pdf_path: Path | None = None
|
|
311
|
+
if output:
|
|
312
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
output.write_text(json.dumps(payload, indent=2))
|
|
314
|
+
if plot:
|
|
315
|
+
pdf_path = output.with_suffix(".pdf")
|
|
316
|
+
# For plot purposes use the reports directly; respect suppression
|
|
317
|
+
# by skipping the plot when no per-question entries would ship.
|
|
318
|
+
if payload["questions"]:
|
|
319
|
+
_render_plot(payload, reports, pdf_path)
|
|
320
|
+
else:
|
|
321
|
+
pdf_path = None
|
|
322
|
+
elif plot:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
"--plot requires --output (the PDF is written alongside the JSON)"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
return payload, pdf_path
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _resolve_question(dataset, question_key: str) -> Question:
|
|
331
|
+
"""Find a single question by key, accepting bare or prefixed forms."""
|
|
332
|
+
questions = dataset.load()
|
|
333
|
+
matches = [q for q in questions if q.key == question_key]
|
|
334
|
+
if matches:
|
|
335
|
+
return matches[0]
|
|
336
|
+
# Allow callers to pass the bare upstream id (e.g. "SPKATH" for
|
|
337
|
+
# "GSS_SPKATH"). Useful from the CLI where the dataset prefix is
|
|
338
|
+
# implicit.
|
|
339
|
+
suffixed = [q for q in questions if q.key.endswith(f"_{question_key}")]
|
|
340
|
+
if len(suffixed) == 1:
|
|
341
|
+
return suffixed[0]
|
|
342
|
+
raise ValueError(
|
|
343
|
+
f"no question with key {question_key!r} in dataset {dataset.name!r}"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def run_real(
|
|
348
|
+
dataset_name: str,
|
|
349
|
+
question_key: str,
|
|
350
|
+
output: Path | None = None,
|
|
351
|
+
bootstraps: int | None = None,
|
|
352
|
+
sample_sizes: str | None = None,
|
|
353
|
+
epsilon: float | None = None,
|
|
354
|
+
delta: float | None = None,
|
|
355
|
+
seed: int | None = None,
|
|
356
|
+
) -> dict:
|
|
357
|
+
"""Real-sampling convergence for one question. Returns the JSON payload.
|
|
358
|
+
|
|
359
|
+
Errors cleanly when the dataset's adapter does not ship microdata. The
|
|
360
|
+
payload reuses the bootstrap schema's ``parameters`` block plus a
|
|
361
|
+
``real_curve`` array so existing renderers can pick it up unchanged.
|
|
362
|
+
"""
|
|
363
|
+
from synthbench.convergence.real_sampling import compute_real_curve
|
|
364
|
+
from synthbench.datasets import DATASETS
|
|
365
|
+
from synthbench.datasets.base import MicrodataNotAvailable
|
|
366
|
+
from synthbench.datasets.policy import policy_for
|
|
367
|
+
|
|
368
|
+
if dataset_name not in DATASETS:
|
|
369
|
+
known = ", ".join(sorted(DATASETS))
|
|
370
|
+
raise ValueError(f"unknown dataset {dataset_name!r}; known datasets: {known}")
|
|
371
|
+
|
|
372
|
+
sizes = _parse_sample_sizes(sample_sizes)
|
|
373
|
+
B = DEFAULT_BOOTSTRAP_B if bootstraps is None else int(bootstraps)
|
|
374
|
+
eps = DEFAULT_EPSILON if epsilon is None else float(epsilon)
|
|
375
|
+
dlt = DEFAULT_DELTA if delta is None else float(delta)
|
|
376
|
+
if B < 1:
|
|
377
|
+
raise ValueError(f"--bootstraps must be >= 1, got {B}")
|
|
378
|
+
|
|
379
|
+
dataset = DATASETS[dataset_name]()
|
|
380
|
+
# Resolving the question triggers a full dataset.load() on some adapters
|
|
381
|
+
# (e.g. OpinionsQA attempts an auto-download). For adapters that ship no
|
|
382
|
+
# microdata, convert any download error into the same clean
|
|
383
|
+
# "does not provide microdata" message as the dedicated MicrodataNotAvailable
|
|
384
|
+
# branch below.
|
|
385
|
+
try:
|
|
386
|
+
question = _resolve_question(dataset, question_key)
|
|
387
|
+
rows = dataset.load_microdata_for_question(question.key)
|
|
388
|
+
except MicrodataNotAvailable as exc:
|
|
389
|
+
raise ValueError(
|
|
390
|
+
f"dataset {dataset_name!r} does not provide microdata: {exc}"
|
|
391
|
+
) from exc
|
|
392
|
+
except DatasetDownloadError as exc:
|
|
393
|
+
raise ValueError(
|
|
394
|
+
f"dataset {dataset_name!r} does not provide microdata "
|
|
395
|
+
f"(aggregate-only; download attempt failed): {exc}"
|
|
396
|
+
) from exc
|
|
397
|
+
|
|
398
|
+
if not rows:
|
|
399
|
+
raise ValueError(
|
|
400
|
+
f"no microdata respondents answered question {question.key!r} "
|
|
401
|
+
f"in dataset {dataset_name!r}"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
curve = compute_real_curve(
|
|
405
|
+
rows,
|
|
406
|
+
question_key=question.key,
|
|
407
|
+
sample_sizes=sizes,
|
|
408
|
+
B=B,
|
|
409
|
+
rng=seed,
|
|
410
|
+
)
|
|
411
|
+
convergence_n = find_convergence_n(curve, epsilon=eps, delta=dlt)
|
|
412
|
+
|
|
413
|
+
policy = policy_for(dataset_name)
|
|
414
|
+
payload = {
|
|
415
|
+
"dataset": dataset_name,
|
|
416
|
+
"mode": "real",
|
|
417
|
+
"question_key": question.key,
|
|
418
|
+
"redistribution_policy": policy.redistribution_policy,
|
|
419
|
+
"license_url": policy.license_url,
|
|
420
|
+
"citation": policy.citation,
|
|
421
|
+
"parameters": {
|
|
422
|
+
"sample_sizes": list(sizes),
|
|
423
|
+
"bootstrap_B": B,
|
|
424
|
+
"epsilon": eps,
|
|
425
|
+
"delta": dlt,
|
|
426
|
+
},
|
|
427
|
+
"n_respondents": len(rows),
|
|
428
|
+
"real_curve": curve_to_dicts(curve),
|
|
429
|
+
"convergence_n": convergence_n,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if output:
|
|
433
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
434
|
+
output.write_text(json.dumps(payload, indent=2))
|
|
435
|
+
return payload
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def run_compare(
|
|
439
|
+
dataset_name: str,
|
|
440
|
+
question_key: str,
|
|
441
|
+
output: Path | None = None,
|
|
442
|
+
bootstraps: int | None = None,
|
|
443
|
+
sample_sizes: str | None = None,
|
|
444
|
+
epsilon: float | None = None,
|
|
445
|
+
delta: float | None = None,
|
|
446
|
+
seed: int | None = None,
|
|
447
|
+
) -> dict:
|
|
448
|
+
"""Side-by-side bootstrap vs. real-sampling curves for one question.
|
|
449
|
+
|
|
450
|
+
The two curves share ``parameters`` so they can be plotted on the same
|
|
451
|
+
axes. ``delta_jsd_mean[i] = real - bootstrap`` is precomputed for each
|
|
452
|
+
sample size present in both curves -- this is the headline number for
|
|
453
|
+
the marketable claim ("real sampling shows heavier tails than the
|
|
454
|
+
idealized i.i.d. floor").
|
|
455
|
+
"""
|
|
456
|
+
from synthbench.convergence.curves import compute_curve
|
|
457
|
+
from synthbench.convergence.real_sampling import compute_real_curve
|
|
458
|
+
from synthbench.datasets import DATASETS
|
|
459
|
+
from synthbench.datasets.base import MicrodataNotAvailable
|
|
460
|
+
from synthbench.datasets.policy import policy_for
|
|
461
|
+
|
|
462
|
+
if dataset_name not in DATASETS:
|
|
463
|
+
known = ", ".join(sorted(DATASETS))
|
|
464
|
+
raise ValueError(f"unknown dataset {dataset_name!r}; known datasets: {known}")
|
|
465
|
+
|
|
466
|
+
sizes = _parse_sample_sizes(sample_sizes)
|
|
467
|
+
B = DEFAULT_BOOTSTRAP_B if bootstraps is None else int(bootstraps)
|
|
468
|
+
eps = DEFAULT_EPSILON if epsilon is None else float(epsilon)
|
|
469
|
+
dlt = DEFAULT_DELTA if delta is None else float(delta)
|
|
470
|
+
if B < 1:
|
|
471
|
+
raise ValueError(f"--bootstraps must be >= 1, got {B}")
|
|
472
|
+
|
|
473
|
+
dataset = DATASETS[dataset_name]()
|
|
474
|
+
# Resolving the question triggers a full dataset.load() on some adapters
|
|
475
|
+
# (e.g. OpinionsQA attempts an auto-download). For adapters that ship no
|
|
476
|
+
# microdata, convert any download error into the same clean
|
|
477
|
+
# "does not provide microdata" message as the dedicated MicrodataNotAvailable
|
|
478
|
+
# branch below.
|
|
479
|
+
try:
|
|
480
|
+
question = _resolve_question(dataset, question_key)
|
|
481
|
+
rows = dataset.load_microdata_for_question(question.key)
|
|
482
|
+
except MicrodataNotAvailable as exc:
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"dataset {dataset_name!r} does not provide microdata: {exc}"
|
|
485
|
+
) from exc
|
|
486
|
+
except DatasetDownloadError as exc:
|
|
487
|
+
raise ValueError(
|
|
488
|
+
f"dataset {dataset_name!r} does not provide microdata "
|
|
489
|
+
f"(aggregate-only; download attempt failed): {exc}"
|
|
490
|
+
) from exc
|
|
491
|
+
if not rows:
|
|
492
|
+
raise ValueError(
|
|
493
|
+
f"no microdata respondents answered question {question.key!r} "
|
|
494
|
+
f"in dataset {dataset_name!r}"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
bootstrap_curve = compute_curve(
|
|
498
|
+
question.human_distribution,
|
|
499
|
+
sample_sizes=sizes,
|
|
500
|
+
B=B,
|
|
501
|
+
rng=seed,
|
|
502
|
+
)
|
|
503
|
+
real_curve = compute_real_curve(
|
|
504
|
+
rows,
|
|
505
|
+
question_key=question.key,
|
|
506
|
+
sample_sizes=sizes,
|
|
507
|
+
B=B,
|
|
508
|
+
rng=seed,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
bootstrap_by_n = {p.n: p.jsd_mean for p in bootstrap_curve}
|
|
512
|
+
deltas = [
|
|
513
|
+
{"n": p.n, "delta_jsd_mean": p.jsd_mean - bootstrap_by_n[p.n]}
|
|
514
|
+
for p in real_curve
|
|
515
|
+
if p.n in bootstrap_by_n
|
|
516
|
+
]
|
|
517
|
+
|
|
518
|
+
policy = policy_for(dataset_name)
|
|
519
|
+
payload = {
|
|
520
|
+
"dataset": dataset_name,
|
|
521
|
+
"mode": "compare",
|
|
522
|
+
"question_key": question.key,
|
|
523
|
+
"redistribution_policy": policy.redistribution_policy,
|
|
524
|
+
"license_url": policy.license_url,
|
|
525
|
+
"citation": policy.citation,
|
|
526
|
+
"parameters": {
|
|
527
|
+
"sample_sizes": list(sizes),
|
|
528
|
+
"bootstrap_B": B,
|
|
529
|
+
"epsilon": eps,
|
|
530
|
+
"delta": dlt,
|
|
531
|
+
},
|
|
532
|
+
"n_respondents": len(rows),
|
|
533
|
+
"bootstrap_curve": curve_to_dicts(bootstrap_curve),
|
|
534
|
+
"real_curve": curve_to_dicts(real_curve),
|
|
535
|
+
"convergence_n_bootstrap": find_convergence_n(
|
|
536
|
+
bootstrap_curve, epsilon=eps, delta=dlt
|
|
537
|
+
),
|
|
538
|
+
"convergence_n_real": find_convergence_n(real_curve, epsilon=eps, delta=dlt),
|
|
539
|
+
"delta_jsd_mean": deltas,
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if output:
|
|
543
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
544
|
+
output.write_text(json.dumps(payload, indent=2))
|
|
545
|
+
return payload
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
__all__ = [
|
|
549
|
+
"QuestionReport",
|
|
550
|
+
"compute_reports",
|
|
551
|
+
"summarize",
|
|
552
|
+
"build_payload",
|
|
553
|
+
"run_bootstrap",
|
|
554
|
+
"run_real",
|
|
555
|
+
"run_compare",
|
|
556
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Convergence curve computation over a single aggregate distribution.
|
|
2
|
+
|
|
3
|
+
For each sample size n, draw B bootstrap samples from the aggregate
|
|
4
|
+
distribution, compute JSD(empirical_sample, full_distribution), and summarize
|
|
5
|
+
as {mean, p10, p90}. Plotting the mean against n yields the characteristic
|
|
6
|
+
~1/sqrt(n) curve that an idealized i.i.d. sampler would produce -- the
|
|
7
|
+
theoretical floor real samples must beat.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Iterable, Sequence
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from synthbench.convergence.bootstrap import (
|
|
18
|
+
bootstrap_sample,
|
|
19
|
+
empirical_distribution,
|
|
20
|
+
)
|
|
21
|
+
from synthbench.metrics.distributional import jensen_shannon_divergence
|
|
22
|
+
|
|
23
|
+
DEFAULT_SAMPLE_SIZES: tuple[int, ...] = (
|
|
24
|
+
20,
|
|
25
|
+
50,
|
|
26
|
+
100,
|
|
27
|
+
200,
|
|
28
|
+
500,
|
|
29
|
+
1000,
|
|
30
|
+
2000,
|
|
31
|
+
5000,
|
|
32
|
+
10000,
|
|
33
|
+
)
|
|
34
|
+
DEFAULT_BOOTSTRAP_B: int = 500
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class CurvePoint:
|
|
39
|
+
"""One sample-size slice of a convergence curve."""
|
|
40
|
+
|
|
41
|
+
n: int
|
|
42
|
+
jsd_mean: float
|
|
43
|
+
jsd_p10: float
|
|
44
|
+
jsd_p90: float
|
|
45
|
+
bootstrap_B: int
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict:
|
|
48
|
+
return {
|
|
49
|
+
"n": self.n,
|
|
50
|
+
"jsd_mean": self.jsd_mean,
|
|
51
|
+
"jsd_p10": self.jsd_p10,
|
|
52
|
+
"jsd_p90": self.jsd_p90,
|
|
53
|
+
"bootstrap_B": self.bootstrap_B,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def compute_curve(
|
|
58
|
+
distribution: dict[str, float],
|
|
59
|
+
sample_sizes: Sequence[int] = DEFAULT_SAMPLE_SIZES,
|
|
60
|
+
B: int = DEFAULT_BOOTSTRAP_B,
|
|
61
|
+
rng: np.random.Generator | int | None = None,
|
|
62
|
+
) -> list[CurvePoint]:
|
|
63
|
+
"""Compute the bootstrap convergence curve for one distribution.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
distribution: Aggregate human distribution (option -> probability).
|
|
67
|
+
sample_sizes: Sample sizes n to evaluate. Must all be positive.
|
|
68
|
+
B: Bootstrap replicates per sample size. Must be positive.
|
|
69
|
+
rng: Optional seed or numpy Generator for reproducibility. An int is
|
|
70
|
+
passed to ``np.random.default_rng``; a Generator is used as-is.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
One :class:`CurvePoint` per entry in ``sample_sizes``, in the order
|
|
74
|
+
given. Empty distributions produce an empty list.
|
|
75
|
+
"""
|
|
76
|
+
if B < 1:
|
|
77
|
+
raise ValueError(f"B must be >= 1, got {B}")
|
|
78
|
+
for n in sample_sizes:
|
|
79
|
+
if n < 1:
|
|
80
|
+
raise ValueError(f"sample_sizes must be positive, got {n}")
|
|
81
|
+
|
|
82
|
+
if not distribution:
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
if isinstance(rng, np.random.Generator):
|
|
86
|
+
gen = rng
|
|
87
|
+
else:
|
|
88
|
+
gen = np.random.default_rng(rng)
|
|
89
|
+
|
|
90
|
+
curve: list[CurvePoint] = []
|
|
91
|
+
for n in sample_sizes:
|
|
92
|
+
jsds = np.empty(B, dtype=np.float64)
|
|
93
|
+
for b in range(B):
|
|
94
|
+
sample = bootstrap_sample(distribution, n, gen)
|
|
95
|
+
emp = empirical_distribution(sample)
|
|
96
|
+
jsds[b] = jensen_shannon_divergence(emp, distribution)
|
|
97
|
+
curve.append(
|
|
98
|
+
CurvePoint(
|
|
99
|
+
n=int(n),
|
|
100
|
+
jsd_mean=float(np.mean(jsds)),
|
|
101
|
+
jsd_p10=float(np.percentile(jsds, 10)),
|
|
102
|
+
jsd_p90=float(np.percentile(jsds, 90)),
|
|
103
|
+
bootstrap_B=int(B),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return curve
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def curve_to_dicts(curve: Iterable[CurvePoint]) -> list[dict]:
|
|
110
|
+
"""Helper to JSON-serialize a curve."""
|
|
111
|
+
return [p.to_dict() for p in curve]
|