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.
Files changed (78) hide show
  1. synthbench/__init__.py +16 -0
  2. synthbench/__main__.py +5 -0
  3. synthbench/adapter.py +131 -0
  4. synthbench/anomaly.py +503 -0
  5. synthbench/baseline_floors.py +213 -0
  6. synthbench/baselines.py +902 -0
  7. synthbench/cli.py +2929 -0
  8. synthbench/config_id.py +424 -0
  9. synthbench/contamination.py +783 -0
  10. synthbench/convergence/__init__.py +67 -0
  11. synthbench/convergence/baseline.py +172 -0
  12. synthbench/convergence/bootstrap.py +60 -0
  13. synthbench/convergence/cli_report.py +556 -0
  14. synthbench/convergence/curves.py +111 -0
  15. synthbench/convergence/real_sampling.py +146 -0
  16. synthbench/convergence/thresholds.py +49 -0
  17. synthbench/datasets/__init__.py +37 -0
  18. synthbench/datasets/base.py +147 -0
  19. synthbench/datasets/eurobarometer.py +335 -0
  20. synthbench/datasets/globalopinionqa.py +252 -0
  21. synthbench/datasets/gss.py +323 -0
  22. synthbench/datasets/michigan.py +505 -0
  23. synthbench/datasets/ntia.py +357 -0
  24. synthbench/datasets/opinionsqa.py +412 -0
  25. synthbench/datasets/pewtech.py +334 -0
  26. synthbench/datasets/policy.py +142 -0
  27. synthbench/datasets/subpop.py +302 -0
  28. synthbench/datasets/wvs.py +229 -0
  29. synthbench/findings.py +988 -0
  30. synthbench/holdout.py +347 -0
  31. synthbench/human_distributions.py +243 -0
  32. synthbench/leaderboard.py +715 -0
  33. synthbench/leaderboard_pr.py +274 -0
  34. synthbench/metrics/__init__.py +38 -0
  35. synthbench/metrics/composite.py +72 -0
  36. synthbench/metrics/conditioning.py +39 -0
  37. synthbench/metrics/distributional.py +41 -0
  38. synthbench/metrics/ranking.py +37 -0
  39. synthbench/metrics/refusal.py +270 -0
  40. synthbench/metrics/subgroup.py +58 -0
  41. synthbench/private_holdout.py +240 -0
  42. synthbench/providers/__init__.py +43 -0
  43. synthbench/providers/_parsing.py +150 -0
  44. synthbench/providers/_retry.py +107 -0
  45. synthbench/providers/base.py +212 -0
  46. synthbench/providers/http.py +108 -0
  47. synthbench/providers/majority_baseline.py +23 -0
  48. synthbench/providers/ollama.py +109 -0
  49. synthbench/providers/openrouter.py +144 -0
  50. synthbench/providers/population_baseline.py +87 -0
  51. synthbench/providers/random_baseline.py +24 -0
  52. synthbench/providers/raw_anthropic.py +149 -0
  53. synthbench/providers/raw_gemini.py +145 -0
  54. synthbench/providers/raw_openai.py +139 -0
  55. synthbench/providers/synthpanel.py +978 -0
  56. synthbench/publish.py +2772 -0
  57. synthbench/r2_upload.py +178 -0
  58. synthbench/recompute.py +235 -0
  59. synthbench/report.py +493 -0
  60. synthbench/run_hash.py +111 -0
  61. synthbench/run_validity.py +232 -0
  62. synthbench/runner.py +834 -0
  63. synthbench/stats.py +1492 -0
  64. synthbench/submission.py +284 -0
  65. synthbench/submission_pr.py +436 -0
  66. synthbench/submit_adapter.py +748 -0
  67. synthbench/suite.py +424 -0
  68. synthbench/suites/__init__.py +94 -0
  69. synthbench/topics.py +256 -0
  70. synthbench/user_config.py +409 -0
  71. synthbench/validation.py +1556 -0
  72. synthbench/visualize.py +455 -0
  73. synthbench_eval-0.4.0.dist-info/METADATA +279 -0
  74. synthbench_eval-0.4.0.dist-info/RECORD +78 -0
  75. synthbench_eval-0.4.0.dist-info/WHEEL +5 -0
  76. synthbench_eval-0.4.0.dist-info/entry_points.txt +2 -0
  77. synthbench_eval-0.4.0.dist-info/licenses/LICENSE +21 -0
  78. 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]