reliability-lab 0.1.2__tar.gz → 0.1.3__tar.gz

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 (73) hide show
  1. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/CHANGELOG.md +13 -0
  2. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/PKG-INFO +7 -6
  3. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/README.md +5 -5
  4. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/pyproject.toml +1 -0
  5. reliability_lab-0.1.3/src/reliability_lab/_version.py +1 -0
  6. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/src/reliability_lab/inference.py +117 -97
  7. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_bca.py +83 -20
  8. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_compare.py +51 -0
  9. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_inference_units.py +18 -0
  10. reliability_lab-0.1.2/src/reliability_lab/_version.py +0 -1
  11. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/.gitattributes +0 -0
  12. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/.github/workflows/ci.yml +0 -0
  13. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/.github/workflows/release.yml +0 -0
  14. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/.gitignore +0 -0
  15. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/.python-version +0 -0
  16. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/LICENSE +0 -0
  17. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/docs/design/framing-v2.md +0 -0
  18. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/docs/guidance/bootstrap-coverage.md +0 -0
  19. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/docs/guidance/figures/coverage_by_factor-dark.png +0 -0
  20. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/docs/guidance/figures/coverage_by_factor-light.png +0 -0
  21. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/docs/guidance/figures/coverage_by_n-dark.png +0 -0
  22. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/docs/guidance/figures/coverage_by_n-light.png +0 -0
  23. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/docs/guidance/figures/coverage_by_spread-dark.png +0 -0
  24. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/docs/guidance/figures/coverage_by_spread-light.png +0 -0
  25. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/docs/releasing.md +0 -0
  26. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/examples/llm_judge.py +0 -0
  27. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/examples/quickstart.py +0 -0
  28. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/examples/splits.py +0 -0
  29. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/simulations/__init__.py +0 -0
  30. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/simulations/coders.py +0 -0
  31. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/simulations/coverage.py +0 -0
  32. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/simulations/grid.py +0 -0
  33. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/simulations/report.py +0 -0
  34. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/simulations/results/coders/coverage.csv +0 -0
  35. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/simulations/results/coders/run_info.json +0 -0
  36. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/simulations/results/coverage.csv +0 -0
  37. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/simulations/results/run_info.json +0 -0
  38. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/simulations/run_coverage.py +0 -0
  39. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/simulations/templates/bootstrap-coverage.md +0 -0
  40. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/src/reliability_lab/__init__.py +0 -0
  41. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/src/reliability_lab/alpha.py +0 -0
  42. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/src/reliability_lab/data.py +0 -0
  43. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/src/reliability_lab/design.py +0 -0
  44. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/src/reliability_lab/exceptions.py +0 -0
  45. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/src/reliability_lab/llm.py +0 -0
  46. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/src/reliability_lab/py.typed +0 -0
  47. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/src/reliability_lab/report.py +0 -0
  48. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/src/reliability_lab/split.py +0 -0
  49. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/src/reliability_lab/study.py +0 -0
  50. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/fixtures/__init__.py +0 -0
  51. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/fixtures/krippendorff2011.py +0 -0
  52. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/fixtures/llm_logs.py +0 -0
  53. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/helpers.py +0 -0
  54. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/naive_bootstrap.py +0 -0
  55. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/reference.py +0 -0
  56. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_alpha.py +0 -0
  57. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_coder_jackknife.py +0 -0
  58. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_coder_resampling.py +0 -0
  59. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_data.py +0 -0
  60. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_design.py +0 -0
  61. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_kalpha.py +0 -0
  62. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_llm_end_to_end.py +0 -0
  63. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_parity.py +0 -0
  64. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_properties.py +0 -0
  65. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_report.py +0 -0
  66. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_report_io.py +0 -0
  67. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_retries.py +0 -0
  68. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_simulations.py +0 -0
  69. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_smoke.py +0 -0
  70. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_split.py +0 -0
  71. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_status_policies.py +0 -0
  72. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/tests/test_trace.py +0 -0
  73. {reliability_lab-0.1.2 → reliability_lab-0.1.3}/uv.lock +0 -0
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.3
6
+
7
+ Faster bootstrap.
8
+
9
+ - Every scheme is faster. With 2,000 resamples of 5,000 units and 5 coders (ordinal, 3 estimates):
10
+ - units, percentile: 1.4 s → 0.5 s;
11
+ - units, BCa: 6.4 s → 0.5 s, and `compare(method="bca")`: 18 s → 0.5 s;
12
+ - kalpha: 5.1 s → 2.8 s;
13
+ - coders: 8.2 s → 1.4 s, and two_way: 10 s → 2.1 s.
14
+ - BCa intervals no longer go through `scipy.stats.bootstrap`. The jackknife leaves each cluster out in closed form, instead of recomputing alpha from k × k resample weights, so BCa now runs on large studies: 20,000 units take 3.7 s, where they previously ran out of memory. The resamples and the interval are still the ones `scipy.stats.bootstrap` gives, and the tests check this.
15
+ - The random draws are unchanged: a given `random_state` gives the same intervals as in 0.1.2, up to floating-point rounding.
16
+ - The README links to the docs and examples on GitHub, so the links work on PyPI. The project links on PyPI include the changelog.
17
+
5
18
  ## 0.1.2
6
19
 
7
20
  Generalising over coders.
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: reliability-lab
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Study-design library for the reliability of LLM and human judgments, built on Krippendorff's alpha.
5
5
  Project-URL: Repository, https://github.com/Sathapana/reliability-lab
6
6
  Project-URL: Issues, https://github.com/Sathapana/reliability-lab/issues
7
+ Project-URL: Changelog, https://github.com/Sathapana/reliability-lab/blob/main/CHANGELOG.md
7
8
  Author: Sathapana Chawananon
8
9
  License-Expression: BSD-3-Clause
9
10
  License-File: LICENSE
@@ -30,7 +31,7 @@ Description-Content-Type: text/markdown
30
31
 
31
32
  # reliability-lab
32
33
 
33
- **Status: alpha (0.1.2).** The API may still change between releases.
34
+ **Status: alpha (0.1.3).** The API may still change between releases.
34
35
 
35
36
  reliability-lab is a study-design library for measuring the reliability of LLM and human judgments. It is built on Krippendorff's alpha. Alpha implementations already exist in Python. This package adds the workflow around the coefficient:
36
37
 
@@ -45,7 +46,7 @@ reliability-lab is a study-design library for measuring the reliability of LLM a
45
46
  - versioned JSON and Parquet reports with provenance;
46
47
  - LLM judge workflows: a trace sidecar of judge configurations, declared retry handling, and evidence specific to each design (run agreement, order swaps and position bias).
47
48
 
48
- Mixed human-LLM panels are next on the roadmap ([design document](docs/design/framing-v2.md)).
49
+ Mixed human-LLM panels are next on the roadmap ([design document](https://github.com/Sathapana/reliability-lab/blob/main/docs/design/framing-v2.md)).
49
50
 
50
51
  > **Reliability is not validity.** Coders can agree consistently and still be wrong in the same way, for example because of a flawed rubric or a bias they share. When results come from different designs (repeatability, inter-model agreement, human panels), they are reported as different quantities and are never pooled.
51
52
 
@@ -111,7 +112,7 @@ print(jk.summary()) # interval, standard error, and the most influential coders
111
112
 
112
113
  When coders are resampled, the copies of one coder are never paired with each other, since a coder always agrees with itself. These schemes need at least 3 coders and give percentile intervals.
113
114
 
114
- Which scheme to trust, and when, is measured by simulation in the [bootstrap coverage guidance](docs/guidance/bootstrap-coverage.md).
115
+ Which scheme to trust, and when, is measured by simulation in the [bootstrap coverage guidance](https://github.com/Sathapana/reliability-lab/blob/main/docs/guidance/bootstrap-coverage.md).
115
116
 
116
117
  To compare two configurations of a judge, for example two prompts, select each one's rows. Both must cover the same units. Every replicate resamples the same units for both, and the interval is on the difference:
117
118
 
@@ -150,7 +151,7 @@ dev2, test2 = later_study.apply_manifest("split.json") # same units, new judge
150
151
  test.manifest.assert_no_leakage(few_shot_units) # raises LeakageError
151
152
  ```
152
153
 
153
- See [`examples/splits.py`](examples/splits.py) for a full run. Releases are described in [docs/releasing.md](docs/releasing.md).
154
+ See [`examples/splits.py`](https://github.com/Sathapana/reliability-lab/blob/main/examples/splits.py) for a full run. Releases are described in [docs/releasing.md](https://github.com/Sathapana/reliability-lab/blob/main/docs/releasing.md).
154
155
 
155
156
  ## LLM judge workflows
156
157
 
@@ -190,7 +191,7 @@ result.trace_findings # confounds and caveats from the trace
190
191
 
191
192
  The single-configuration designs (repeatability, prompt robustness, order robustness) can hold several configurations to compare, such as an old and a new judge. Each configuration is checked on its own. `alpha()` refuses to pool them; `study.compare(a={"condition_id": "old"}, b={"condition_id": "new"})` reports each one's alpha and diagnostics, with a paired interval on the difference.
192
193
 
193
- Retried calls are never resolved silently. Number the attempts with an `attempt` column, then declare `retries="last"` or `retries="first_observed"`. Coverage then reports error rates both per attempt and for the attempts that count. See [`examples/llm_judge.py`](examples/llm_judge.py).
194
+ Retried calls are never resolved silently. Number the attempts with an `attempt` column, then declare `retries="last"` or `retries="first_observed"`. Coverage then reports error rates both per attempt and for the attempts that count. See [`examples/llm_judge.py`](https://github.com/Sathapana/reliability-lab/blob/main/examples/llm_judge.py).
194
195
 
195
196
  ## Licence
196
197
 
@@ -1,6 +1,6 @@
1
1
  # reliability-lab
2
2
 
3
- **Status: alpha (0.1.2).** The API may still change between releases.
3
+ **Status: alpha (0.1.3).** The API may still change between releases.
4
4
 
5
5
  reliability-lab is a study-design library for measuring the reliability of LLM and human judgments. It is built on Krippendorff's alpha. Alpha implementations already exist in Python. This package adds the workflow around the coefficient:
6
6
 
@@ -15,7 +15,7 @@ reliability-lab is a study-design library for measuring the reliability of LLM a
15
15
  - versioned JSON and Parquet reports with provenance;
16
16
  - LLM judge workflows: a trace sidecar of judge configurations, declared retry handling, and evidence specific to each design (run agreement, order swaps and position bias).
17
17
 
18
- Mixed human-LLM panels are next on the roadmap ([design document](docs/design/framing-v2.md)).
18
+ Mixed human-LLM panels are next on the roadmap ([design document](https://github.com/Sathapana/reliability-lab/blob/main/docs/design/framing-v2.md)).
19
19
 
20
20
  > **Reliability is not validity.** Coders can agree consistently and still be wrong in the same way, for example because of a flawed rubric or a bias they share. When results come from different designs (repeatability, inter-model agreement, human panels), they are reported as different quantities and are never pooled.
21
21
 
@@ -81,7 +81,7 @@ print(jk.summary()) # interval, standard error, and the most influential coders
81
81
 
82
82
  When coders are resampled, the copies of one coder are never paired with each other, since a coder always agrees with itself. These schemes need at least 3 coders and give percentile intervals.
83
83
 
84
- Which scheme to trust, and when, is measured by simulation in the [bootstrap coverage guidance](docs/guidance/bootstrap-coverage.md).
84
+ Which scheme to trust, and when, is measured by simulation in the [bootstrap coverage guidance](https://github.com/Sathapana/reliability-lab/blob/main/docs/guidance/bootstrap-coverage.md).
85
85
 
86
86
  To compare two configurations of a judge, for example two prompts, select each one's rows. Both must cover the same units. Every replicate resamples the same units for both, and the interval is on the difference:
87
87
 
@@ -120,7 +120,7 @@ dev2, test2 = later_study.apply_manifest("split.json") # same units, new judge
120
120
  test.manifest.assert_no_leakage(few_shot_units) # raises LeakageError
121
121
  ```
122
122
 
123
- See [`examples/splits.py`](examples/splits.py) for a full run. Releases are described in [docs/releasing.md](docs/releasing.md).
123
+ See [`examples/splits.py`](https://github.com/Sathapana/reliability-lab/blob/main/examples/splits.py) for a full run. Releases are described in [docs/releasing.md](https://github.com/Sathapana/reliability-lab/blob/main/docs/releasing.md).
124
124
 
125
125
  ## LLM judge workflows
126
126
 
@@ -160,7 +160,7 @@ result.trace_findings # confounds and caveats from the trace
160
160
 
161
161
  The single-configuration designs (repeatability, prompt robustness, order robustness) can hold several configurations to compare, such as an old and a new judge. Each configuration is checked on its own. `alpha()` refuses to pool them; `study.compare(a={"condition_id": "old"}, b={"condition_id": "new"})` reports each one's alpha and diagnostics, with a paired interval on the difference.
162
162
 
163
- Retried calls are never resolved silently. Number the attempts with an `attempt` column, then declare `retries="last"` or `retries="first_observed"`. Coverage then reports error rates both per attempt and for the attempts that count. See [`examples/llm_judge.py`](examples/llm_judge.py).
163
+ Retried calls are never resolved silently. Number the attempts with an `attempt` column, then declare `retries="last"` or `retries="first_observed"`. Coverage then reports error rates both per attempt and for the attempts that count. See [`examples/llm_judge.py`](https://github.com/Sathapana/reliability-lab/blob/main/examples/llm_judge.py).
164
164
 
165
165
  ## Licence
166
166
 
@@ -44,6 +44,7 @@ parquet = ["pyarrow>=15"]
44
44
  [project.urls]
45
45
  Repository = "https://github.com/Sathapana/reliability-lab"
46
46
  Issues = "https://github.com/Sathapana/reliability-lab/issues"
47
+ Changelog = "https://github.com/Sathapana/reliability-lab/blob/main/CHANGELOG.md"
47
48
 
48
49
  [dependency-groups]
49
50
  dev = [
@@ -0,0 +1 @@
1
+ __version__ = "0.1.3"
@@ -23,15 +23,14 @@ configurations, so the interval falls on the difference alpha_b - alpha_a.
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
- import warnings
27
- from collections.abc import Callable, Mapping
26
+ from collections.abc import Mapping
28
27
  from dataclasses import dataclass
29
28
  from typing import Any
30
29
 
31
30
  import numpy as np
32
31
  import numpy.typing as npt
33
32
  import pandas as pd
34
- from scipy import sparse, stats
33
+ from scipy import sparse, special, stats
35
34
 
36
35
  from reliability_lab.alpha import ORDINAL, Distance, compute_alpha, per_unit_coincidences
37
36
  from reliability_lab.data import preview
@@ -218,17 +217,41 @@ def replicate_alphas(
218
217
  distance: Distance,
219
218
  ) -> FloatArray:
220
219
  """Alpha for each row of cluster weights; NaN where alpha is undefined."""
221
- w = np.atleast_2d(np.asarray(weights, dtype=float))
220
+ w = np.atleast_2d(np.asarray(weights))
222
221
  n_values = len(values)
222
+ n_flat = n_values * n_values
223
+ # A dense product is one BLAS call; large value domains stay sparse.
224
+ dense = clusters.shape[0] * n_flat <= BATCH_FLOATS
225
+ contributions = clusters.toarray() if dense else sparse.csr_array(clusters.T)
223
226
  out = np.empty(len(w))
224
- batch = max(1, BATCH_FLOATS // max(n_values * n_values, 1))
227
+ batch = max(1, BATCH_FLOATS // max(n_flat, clusters.shape[0], 1))
225
228
  for start in range(0, len(w), batch):
226
- wb = w[start : start + batch]
227
- o = np.asarray(clusters.T @ wb.T, dtype=float).T.reshape(len(wb), n_values, n_values)
229
+ wb = np.asarray(w[start : start + batch], dtype=float)
230
+ if dense:
231
+ flat = wb @ contributions
232
+ else:
233
+ flat = np.asarray(contributions @ np.asfortranarray(wb.T)).T
234
+ o = flat.reshape(len(wb), n_values, n_values)
228
235
  out[start : start + len(wb)] = alphas_from_coincidences(o, values, distance)
229
236
  return out
230
237
 
231
238
 
239
+ def jackknife_alphas(
240
+ clusters: sparse.csr_array, values: tuple[Any, ...], distance: Distance
241
+ ) -> FloatArray:
242
+ """Alpha without each cluster in turn: the coincidences less that cluster's."""
243
+ n_values = len(values)
244
+ n_flat = n_values * n_values
245
+ total = np.asarray(clusters.sum(axis=0), dtype=float).ravel()
246
+ out = np.empty(clusters.shape[0])
247
+ batch = max(1, BATCH_FLOATS // max(n_flat, 1))
248
+ for start in range(0, clusters.shape[0], batch):
249
+ part = clusters[start : start + batch].toarray()
250
+ o = (total - part).reshape(len(part), n_values, n_values)
251
+ out[start : start + len(part)] = alphas_from_coincidences(o, values, distance)
252
+ return out
253
+
254
+
232
255
  def alphas_from_coincidences(
233
256
  o: FloatArray, values: tuple[Any, ...], distance: Distance
234
257
  ) -> FloatArray:
@@ -285,25 +308,26 @@ def two_way_alphas(
285
308
  ``(n_u n_u' - diag n_u) / (m_u - 1)``.
286
309
  """
287
310
  v = np.atleast_2d(np.asarray(coder_weights, dtype=float))
288
- n_units, _, n_values = onehot.shape
289
- w = (
290
- np.ones((len(v), n_units))
291
- if unit_weights is None
292
- else np.atleast_2d(np.asarray(unit_weights, dtype=float))
293
- )
311
+ n_units, n_coders, n_values = onehot.shape
312
+ w = None if unit_weights is None else np.atleast_2d(np.asarray(unit_weights))
313
+ # Every sum over coders is a matrix product with the coder weights.
314
+ by_coder = onehot.transpose(1, 0, 2).reshape(n_coders, n_units * n_values)
315
+ rated = onehot.sum(axis=2).T
294
316
  out = np.empty(len(v))
295
317
  per_replicate = max(n_units * n_values * 2, n_values * n_values, 1)
296
318
  batch = max(1, BATCH_FLOATS // per_replicate)
297
319
  for start in range(0, len(v), batch):
298
- vb, wb = v[start : start + batch], w[start : start + batch]
299
- counts = np.einsum("ujc,rj->ruc", onehot, vb)
300
- squares = np.einsum("ujc,rj->ruc", onehot, vb * vb)
301
- m = counts.sum(axis=2)
302
- pairs = m * m - squares.sum(axis=2)
320
+ vb = v[start : start + batch]
321
+ v2 = vb * vb
322
+ wb = 1.0 if w is None else np.asarray(w[start : start + batch], dtype=float)
323
+ counts = (vb @ by_coder).reshape(len(vb), n_units, n_values)
324
+ squares = (v2 @ by_coder).reshape(len(vb), n_units, n_values)
325
+ m = vb @ rated
326
+ pairs = m * m - v2 @ rated
303
327
  with np.errstate(divide="ignore", invalid="ignore"):
304
328
  scale = np.where(pairs > 0, wb * m / pairs, 0.0)
305
- o = np.einsum("ru,ruc,rud->rcd", scale, counts, counts)
306
- diagonal = np.einsum("ru,ruc->rc", scale, squares)
329
+ o = (counts * scale[:, :, None]).transpose(0, 2, 1) @ counts
330
+ diagonal = (scale[:, None, :] @ squares)[:, 0, :]
307
331
  o[:, np.arange(n_values), np.arange(n_values)] -= diagonal
308
332
  out[start : start + len(vb)] = alphas_from_coincidences(o, values, distance)
309
333
  return out
@@ -389,12 +413,14 @@ def kalpha_replicates(pool: KalphaPool, draws: IntArray, redraws: IntArray) -> F
389
413
  ``redraws`` (replicates x len(second_pick)) replaces a unit's second draw when
390
414
  it repeats the first.
391
415
  """
392
- d = np.array(draws, dtype=np.intp)
393
- if len(pool.second_pick):
394
- first = d[:, pool.second_pick - 1]
395
- second = d[:, pool.second_pick]
396
- d[:, pool.second_pick] = np.where(first == second, redraws, second)
397
- alpha = 1.0 - (pool.errors[d] * pool.weights).sum(axis=1)
416
+ d = np.asarray(draws, dtype=np.intp)
417
+ sp = pool.second_pick
418
+ alpha = 1.0 - np.take(pool.errors, d) @ pool.weights
419
+ if len(sp):
420
+ # Repeats are rare, so their redraws correct the sums afterwards.
421
+ rows, cols = np.nonzero(d[:, sp - 1] == d[:, sp])
422
+ redrawn = pool.errors[np.asarray(redraws)[rows, cols]] - pool.errors[d[rows, sp[cols]]]
423
+ np.subtract.at(alpha, rows, redrawn * pool.weights[sp[cols]])
398
424
  return np.asarray(np.maximum(alpha, -1.0), dtype=float)
399
425
 
400
426
 
@@ -452,37 +478,34 @@ def percentile_interval(
452
478
  def bca_interval(
453
479
  name: str,
454
480
  point: float,
455
- statistic: Callable[[IntArray], FloatArray],
456
- n_clusters: int,
457
- n_resamples: int,
481
+ distribution: FloatArray,
482
+ theta_hat: float,
483
+ jackknife: FloatArray,
458
484
  confidence: float,
459
- seed: int,
460
485
  alpha_min: float | None = None,
461
486
  ) -> Interval:
462
- """BCa interval through ``scipy.stats.bootstrap`` over resampled cluster indices."""
463
-
464
- def vectorised(idx: IntArray, axis: int = -1) -> FloatArray:
465
- return statistic(np.moveaxis(np.asarray(idx, dtype=np.intp), axis, -1))
466
-
467
- # scipy's own warning about a degenerate BCa is replaced by this library's
468
- # BootstrapWarning, which names the estimate and the fallback.
469
- with np.errstate(divide="ignore", invalid="ignore"), warnings.catch_warnings():
470
- warnings.simplefilter("ignore", stats.DegenerateDataWarning)
471
- res = stats.bootstrap(
472
- (np.arange(n_clusters),),
473
- vectorised,
474
- n_resamples=n_resamples,
475
- confidence_level=confidence,
476
- method="BCa",
477
- vectorized=True,
478
- rng=np.random.default_rng(seed),
479
- )
480
- distribution = np.asarray(res.bootstrap_distribution, dtype=float)
487
+ """BCa interval (Efron and Tibshirani 1993, 14.3 and 15.4), as ``scipy.stats.bootstrap`` does.
488
+
489
+ ``theta_hat`` is the statistic on the full sample, computed like the replicates,
490
+ and ``jackknife`` the statistic without each cluster in turn.
491
+ """
492
+ n_resamples = len(distribution)
481
493
  failed = np.isnan(distribution)
482
- low, high = float(res.confidence_interval.low), float(res.confidence_interval.high)
483
- reason = None
484
- if not (np.isfinite(low) and np.isfinite(high)):
485
- reason = "the BCa interval is undefined (undefined replicates or a degenerate jackknife)"
494
+ z_alpha = float(special.ndtri((1.0 - confidence) / 2.0))
495
+ with np.errstate(divide="ignore", invalid="ignore"):
496
+ below_hat = (distribution < theta_hat).sum() + (distribution <= theta_hat).sum()
497
+ z0 = special.ndtri(below_hat / (2 * n_resamples))
498
+ u = jackknife.mean() - jackknife
499
+ a_hat = (u**3).sum() / (6.0 * (u**2).sum() ** 1.5)
500
+ num = z0 + np.array([z_alpha, -z_alpha])
501
+ levels = special.ndtr(z0 + num / (1.0 - a_hat * num))
502
+ low = high = np.nan
503
+ reason: str | None = (
504
+ "the BCa interval is undefined (undefined replicates or a degenerate jackknife)"
505
+ )
506
+ if not failed.any() and np.isfinite(levels).all():
507
+ low, high = (float(q) for q in np.quantile(distribution, levels))
508
+ reason = None
486
509
  defined = distribution[~failed]
487
510
  below = float((defined < alpha_min).mean()) if alpha_min is not None and len(defined) else None
488
511
  return Interval(
@@ -490,6 +513,30 @@ def bca_interval(
490
513
  )
491
514
 
492
515
 
516
+ def bca_weights(seed: int, n_clusters: int, n_resamples: int) -> npt.NDArray[np.int64]:
517
+ """Cluster weights of the BCa resamples (n x clusters).
518
+
519
+ The indices are the ones ``scipy.stats.bootstrap`` draws from the same seed,
520
+ drawn in batches (which leaves the stream unchanged) and counted.
521
+ """
522
+ rng = np.random.default_rng(seed)
523
+ batch = max(1, BATCH_FLOATS // max(n_clusters, 1))
524
+ parts = []
525
+ for start in range(0, n_resamples, batch):
526
+ size = min(batch, n_resamples - start)
527
+ idx = rng.integers(0, n_clusters, (size, n_clusters))
528
+ parts.append(weights_from_indices(idx, n_clusters))
529
+ return np.concatenate(parts)
530
+
531
+
532
+ def bca_parts(
533
+ clusters: sparse.csr_array, values: tuple[Any, ...], distance: Distance
534
+ ) -> tuple[float, FloatArray]:
535
+ """The full-sample statistic and the leave-one-cluster-out jackknife for BCa."""
536
+ theta_hat = replicate_alphas(clusters, np.ones(clusters.shape[0]), values, distance)[0]
537
+ return float(theta_hat), jackknife_alphas(clusters, values, distance)
538
+
539
+
493
540
  def weights_from_indices(idx: IntArray, n_clusters: int) -> npt.NDArray[np.int64]:
494
541
  """Count cluster indices along the last axis: (..., k) -> (prod(...), n_clusters)."""
495
542
  flat = np.asarray(idx, dtype=np.intp).reshape(-1, np.shape(idx)[-1])
@@ -498,17 +545,6 @@ def weights_from_indices(idx: IntArray, n_clusters: int) -> npt.NDArray[np.int64
498
545
  return counts.reshape(len(flat), n_clusters)
499
546
 
500
547
 
501
- def _alpha_statistic(
502
- clusters: sparse.csr_array, n_clusters: int, values: tuple[Any, ...], distance: Distance
503
- ) -> Callable[[IntArray], FloatArray]:
504
- def statistic(idx: IntArray) -> FloatArray:
505
- lead = np.shape(idx)[:-1]
506
- w = weights_from_indices(idx, n_clusters)
507
- return replicate_alphas(clusters, w, values, distance).reshape(lead)
508
-
509
- return statistic
510
-
511
-
512
548
  # -- entry points ---------------------------------------------------------------------
513
549
 
514
550
 
@@ -624,35 +660,27 @@ def bootstrap(
624
660
  recorded.append(emit(BootstrapWarning, level_warning, stacklevel=3))
625
661
  if method == "bca" and strata:
626
662
  raise DesignError(
627
- "BCa intervals cannot resample within strata (scipy.stats.bootstrap has no "
628
- "strata); use method='percentile'"
663
+ "BCa intervals cannot resample within strata; use method='percentile'"
629
664
  )
630
665
  plan = cluster_plan(sample, level_used, strata)
631
666
  n_clusters = plan.n_clusters
632
667
  weights = (
633
668
  draw_weights(np.random.default_rng(seed), plan, n_resamples)
634
669
  if method == "percentile"
635
- else None
670
+ else bca_weights(seed, n_clusters, n_resamples)
636
671
  )
637
672
  for name in names:
638
673
  values = sample.inputs[name].values
639
674
  clusters = cluster_coincidences(sample, name, plan)
640
- if weights is not None:
641
- distribution = replicate_alphas(clusters, weights, values, sample.distance)
675
+ distribution = replicate_alphas(clusters, weights, values, sample.distance)
676
+ if method == "percentile":
642
677
  intervals[name] = percentile_interval(
643
678
  name, points[name], distribution, confidence, alpha_min
644
679
  )
645
680
  else:
646
- statistic = _alpha_statistic(clusters, n_clusters, values, sample.distance)
681
+ theta_hat, jackknife = bca_parts(clusters, values, sample.distance)
647
682
  intervals[name] = bca_interval(
648
- name,
649
- points[name],
650
- statistic,
651
- n_clusters,
652
- n_resamples,
653
- confidence,
654
- seed,
655
- alpha_min,
683
+ name, points[name], distribution, theta_hat, jackknife, confidence, alpha_min
656
684
  )
657
685
  notes.append(UNITS_NOTE.format(level=level_used, within=" within strata" if strata else ""))
658
686
  if len(names) > 1:
@@ -795,7 +823,7 @@ def compare(
795
823
  weights = (
796
824
  draw_weights(np.random.default_rng(seed), plan_a, n_resamples)
797
825
  if method == "percentile"
798
- else None
826
+ else bca_weights(seed, plan_a.n_clusters, n_resamples)
799
827
  )
800
828
 
801
829
  arm_intervals: dict[str, dict[str, Interval]] = {"a": {}, "b": {}}
@@ -806,30 +834,22 @@ def compare(
806
834
  clusters_a = cluster_coincidences(sample_a, name, plan_a)
807
835
  clusters_b = cluster_coincidences(sample_b, name, plan_b)
808
836
  values_a, values_b = sample_a.inputs[name].values, sample_b.inputs[name].values
809
- if weights is not None:
810
- dist_a = replicate_alphas(clusters_a, weights, values_a, sample_a.distance)
811
- dist_b = replicate_alphas(clusters_b, weights, values_b, sample_b.distance)
837
+ dist_a = replicate_alphas(clusters_a, weights, values_a, sample_a.distance)
838
+ dist_b = replicate_alphas(clusters_b, weights, values_b, sample_b.distance)
839
+ if method == "percentile":
812
840
  arm_intervals["a"][name] = percentile_interval(name, point_a, dist_a, confidence)
813
841
  arm_intervals["b"][name] = percentile_interval(name, point_b, dist_b, confidence)
814
842
  differences[name] = percentile_interval(
815
843
  name, point_b - point_a, dist_b - dist_a, confidence
816
844
  )
817
845
  continue
818
- k = plan_a.n_clusters
819
- stat_a = _alpha_statistic(clusters_a, k, values_a, sample_a.distance)
820
- stat_b = _alpha_statistic(clusters_b, k, values_b, sample_b.distance)
821
-
822
- def stat_diff(
823
- idx: IntArray,
824
- stat_a: Callable[[IntArray], FloatArray] = stat_a,
825
- stat_b: Callable[[IntArray], FloatArray] = stat_b,
826
- ) -> FloatArray:
827
- return stat_b(idx) - stat_a(idx)
828
-
829
- args = (k, n_resamples, confidence, seed)
830
- arm_intervals["a"][name] = bca_interval(name, point_a, stat_a, *args)
831
- arm_intervals["b"][name] = bca_interval(name, point_b, stat_b, *args)
832
- differences[name] = bca_interval(name, point_b - point_a, stat_diff, *args)
846
+ theta_a, jack_a = bca_parts(clusters_a, values_a, sample_a.distance)
847
+ theta_b, jack_b = bca_parts(clusters_b, values_b, sample_b.distance)
848
+ arm_intervals["a"][name] = bca_interval(name, point_a, dist_a, theta_a, jack_a, confidence)
849
+ arm_intervals["b"][name] = bca_interval(name, point_b, dist_b, theta_b, jack_b, confidence)
850
+ differences[name] = bca_interval(
851
+ name, point_b - point_a, dist_b - dist_a, theta_b - theta_a, jack_b - jack_a, confidence
852
+ )
833
853
 
834
854
  recorded.extend(_interval_warnings(differences, n_resamples))
835
855
  within = " within strata" if strata else ""
@@ -6,10 +6,11 @@ from fixtures.krippendorff2011 import FOUR_CODERS_MISSING
6
6
  from helpers import human_study
7
7
  from scipy import stats
8
8
 
9
- from reliability_lab import BootstrapWarning, DesignError
9
+ from reliability_lab import AlphaResult, BootstrapWarning, DesignError
10
10
  from reliability_lab.inference import (
11
11
  cluster_coincidences,
12
12
  cluster_plan,
13
+ jackknife_alphas,
13
14
  replicate_alphas,
14
15
  weights_from_indices,
15
16
  )
@@ -17,6 +18,35 @@ from reliability_lab.inference import (
17
18
  F4 = FOUR_CODERS_MISSING
18
19
 
19
20
 
21
+ def direct_scipy_bca(
22
+ result: AlphaResult, name: str, n_resamples: int, seed: int
23
+ ) -> tuple[float, float]:
24
+ """The BCa interval of a direct ``scipy.stats.bootstrap`` call over cluster indices."""
25
+ sample = result._sample
26
+ assert sample is not None
27
+ plan = cluster_plan(sample, "unit", strata=False)
28
+ clusters = cluster_coincidences(sample, name, plan)
29
+ values = sample.inputs[name].values
30
+
31
+ def statistic(idx: np.ndarray, axis: int = -1) -> np.ndarray:
32
+ idx = np.moveaxis(idx, axis, -1)
33
+ w = weights_from_indices(idx, plan.n_clusters)
34
+ return replicate_alphas(clusters, w, values, sample.distance).reshape(idx.shape[:-1])
35
+
36
+ with np.errstate(divide="ignore", invalid="ignore"), warnings.catch_warnings():
37
+ warnings.simplefilter("ignore", stats.DegenerateDataWarning)
38
+ direct = stats.bootstrap(
39
+ (np.arange(plan.n_clusters),),
40
+ statistic,
41
+ n_resamples=n_resamples,
42
+ confidence_level=0.95,
43
+ method="BCa",
44
+ vectorized=True,
45
+ rng=np.random.default_rng(seed),
46
+ )
47
+ return float(direct.confidence_interval.low), float(direct.confidence_interval.high)
48
+
49
+
20
50
  def test_bca_matches_a_direct_scipy_call() -> None:
21
51
  result = human_study(F4["matrix"], "interval", F4["values"]).alpha()
22
52
  with warnings.catch_warnings():
@@ -25,30 +55,63 @@ def test_bca_matches_a_direct_scipy_call() -> None:
25
55
  assert not boot.experimental
26
56
  assert "[experimental]" not in boot.summary()
27
57
 
58
+ low, high = direct_scipy_bca(result, "as_missing", 999, 5)
59
+ interval = boot["as_missing"]
60
+ assert interval.low == pytest.approx(low)
61
+ assert interval.high == pytest.approx(high)
62
+ assert interval.low < interval.point < interval.high
63
+
64
+
65
+ @pytest.mark.filterwarnings("ignore::reliability_lab.CategorySkewWarning")
66
+ @pytest.mark.filterwarnings("ignore::reliability_lab.BootstrapWarning")
67
+ @pytest.mark.parametrize(
68
+ ("matrix", "n_resamples", "seed"),
69
+ [
70
+ # Undefined replicates: scipy gives no interval.
71
+ ({"a": ["x"] * 19 + ["y"], "b": ["x"] * 20}, 500, 1),
72
+ ({"a": ["x"] * 9 + ["y"], "b": ["x"] * 9 + ["y"]}, 50, 3),
73
+ # Every replicate defined in a short run, undefined ones in a long run.
74
+ (
75
+ {"a": ["x"] * 5 + ["y"] * 5, "b": ["x"] * 4 + ["y"] * 6, "c": ["x"] * 6 + ["y"] * 4},
76
+ 50,
77
+ 4,
78
+ ),
79
+ (
80
+ {"a": ["x"] * 5 + ["y"] * 5, "b": ["x"] * 4 + ["y"] * 6, "c": ["x"] * 6 + ["y"] * 4},
81
+ 500,
82
+ 4,
83
+ ),
84
+ ],
85
+ )
86
+ def test_bca_matches_scipy_on_degenerate_data(
87
+ matrix: dict[str, list[str]], n_resamples: int, seed: int
88
+ ) -> None:
89
+ result = human_study(matrix, "nominal", ["x", "y"]).alpha()
90
+ boot = result.bootstrap(
91
+ scheme="units", method="bca", n_resamples=n_resamples, random_state=seed
92
+ )
93
+ low, high = direct_scipy_bca(result, "as_missing", n_resamples, seed)
94
+ interval = boot["as_missing"]
95
+ np.testing.assert_allclose([interval.low, interval.high], [low, high], equal_nan=True)
96
+ assert interval.defined == np.isfinite(low)
97
+
98
+
99
+ @pytest.mark.parametrize("budget", [None, 20])
100
+ def test_jackknife_alphas_leave_out_each_cluster(
101
+ budget: int | None, monkeypatch: pytest.MonkeyPatch
102
+ ) -> None:
103
+ if budget is not None:
104
+ monkeypatch.setattr("reliability_lab.inference.BATCH_FLOATS", budget)
105
+ result = human_study(F4["matrix"], "interval", F4["values"]).alpha()
28
106
  sample = result._sample
29
107
  assert sample is not None
30
108
  plan = cluster_plan(sample, "unit", strata=False)
31
109
  clusters = cluster_coincidences(sample, "as_missing", plan)
32
110
  values = sample.inputs["as_missing"].values
33
-
34
- def statistic(idx: np.ndarray, axis: int = -1) -> np.ndarray:
35
- idx = np.moveaxis(idx, axis, -1)
36
- w = weights_from_indices(idx, plan.n_clusters)
37
- return replicate_alphas(clusters, w, values, sample.distance).reshape(idx.shape[:-1])
38
-
39
- direct = stats.bootstrap(
40
- (np.arange(plan.n_clusters),),
41
- statistic,
42
- n_resamples=999,
43
- confidence_level=0.95,
44
- method="BCa",
45
- vectorized=True,
46
- rng=np.random.default_rng(5),
47
- )
48
- interval = boot["as_missing"]
49
- assert interval.low == pytest.approx(direct.confidence_interval.low)
50
- assert interval.high == pytest.approx(direct.confidence_interval.high)
51
- assert interval.low < interval.point < interval.high
111
+ k = plan.n_clusters
112
+ left_out = replicate_alphas(clusters, np.ones((k, k)) - np.eye(k), values, sample.distance)
113
+ jackknife = jackknife_alphas(clusters, values, sample.distance)
114
+ np.testing.assert_allclose(jackknife, left_out, atol=1e-12, equal_nan=True)
52
115
 
53
116
 
54
117
  @pytest.mark.filterwarnings("ignore::reliability_lab.CategorySkewWarning")
@@ -1,12 +1,22 @@
1
1
  import json
2
+ from collections.abc import Callable
2
3
  from typing import Any
3
4
 
4
5
  import numpy as np
5
6
  import pandas as pd
6
7
  import pytest
7
8
  from reference import reference_alpha
9
+ from scipy import stats
8
10
 
9
11
  from reliability_lab import DesignError, RatingFrame, ReliabilityStudy
12
+ from reliability_lab.inference import (
13
+ ClusterPlan,
14
+ StudySample,
15
+ cluster_coincidences,
16
+ cluster_plan,
17
+ replicate_alphas,
18
+ weights_from_indices,
19
+ )
10
20
 
11
21
  pytestmark = pytest.mark.filterwarnings("ignore::reliability_lab.CategorySkewWarning")
12
22
 
@@ -135,3 +145,44 @@ def test_bca_comparison() -> None:
135
145
  assert not [w for w in diff.warnings if w.category == "BootstrapWarning"]
136
146
  d = diff["as_missing"]
137
147
  assert d.low <= d.point <= d.high
148
+
149
+
150
+ def test_bca_comparison_matches_direct_scipy_calls() -> None:
151
+ diff = study_of(panel_matrix()).compare(A, B, method="bca", n_resamples=500, random_state=2)
152
+ sample_a, sample_b = diff.result_a._sample, diff.result_b._sample
153
+ assert sample_a is not None and sample_b is not None
154
+ plan_a = cluster_plan(sample_a, "unit", strata=False)
155
+ plan_b = cluster_plan(sample_b, "unit", strata=False, universe=plan_a.labels)
156
+ k = plan_a.n_clusters
157
+
158
+ def arm(sample: StudySample, plan: ClusterPlan) -> Callable[[np.ndarray], np.ndarray]:
159
+ clusters = cluster_coincidences(sample, "as_missing", plan)
160
+ values = sample.inputs["as_missing"].values
161
+
162
+ def statistic(idx: np.ndarray, axis: int = -1) -> np.ndarray:
163
+ idx = np.moveaxis(idx, axis, -1)
164
+ w = weights_from_indices(idx, k)
165
+ return replicate_alphas(clusters, w, values, sample.distance).reshape(idx.shape[:-1])
166
+
167
+ return statistic
168
+
169
+ stat_a, stat_b = arm(sample_a, plan_a), arm(sample_b, plan_b)
170
+
171
+ def stat_diff(idx: np.ndarray, axis: int = -1) -> np.ndarray:
172
+ return stat_b(idx, axis) - stat_a(idx, axis)
173
+
174
+ for statistic, interval in [
175
+ (stat_a, diff.arm_a["as_missing"]),
176
+ (stat_b, diff.arm_b["as_missing"]),
177
+ (stat_diff, diff["as_missing"]),
178
+ ]:
179
+ direct = stats.bootstrap(
180
+ (np.arange(k),),
181
+ statistic,
182
+ n_resamples=500,
183
+ method="BCa",
184
+ vectorized=True,
185
+ rng=np.random.default_rng(2),
186
+ )
187
+ assert interval.low == pytest.approx(direct.confidence_interval.low)
188
+ assert interval.high == pytest.approx(direct.confidence_interval.high)
@@ -76,6 +76,24 @@ def test_vectorised_units_scheme_matches_naive_loop(level: str, resample: str) -
76
76
  np.testing.assert_allclose(fast, slow, atol=1e-12, equal_nan=True)
77
77
 
78
78
 
79
+ @pytest.mark.parametrize("level", LEVELS)
80
+ def test_sparse_batched_product_matches_naive_loop(
81
+ level: str, monkeypatch: pytest.MonkeyPatch
82
+ ) -> None:
83
+ # A tiny budget keeps the cluster contributions sparse, with a few replicates per batch.
84
+ monkeypatch.setattr("reliability_lab.inference.BATCH_FLOATS", 50)
85
+ matrix, study = nested_study(level)
86
+ sample = study.alpha()._sample
87
+ assert sample is not None
88
+ plan = cluster_plan(sample, "unit", strata=False)
89
+ weights = draw_weights(np.random.default_rng(11), plan, 40)
90
+ clusters = cluster_coincidences(sample, "as_missing", plan)
91
+ fast = replicate_alphas(clusters, weights, sample.inputs["as_missing"].values, sample.distance)
92
+ mapping = {int(label): i for i, label in enumerate(sample.unit_labels)}
93
+ slow = naive_units_alphas(matrix, level, VALUES, mapping, weights)
94
+ np.testing.assert_allclose(fast, slow, atol=1e-12, equal_nan=True)
95
+
96
+
79
97
  def test_custom_marginal_dependent_distance_matches_naive_loop() -> None:
80
98
  custom = Distance("my_ordinal", ORDINAL.fn, depends_on_marginals=True)
81
99
  matrix, study = nested_study(custom)
@@ -1 +0,0 @@
1
- __version__ = "0.1.2"
File without changes
File without changes