reliability-lab 0.1.0a1__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.
- reliability_lab/__init__.py +62 -0
- reliability_lab/_version.py +1 -0
- reliability_lab/alpha.py +290 -0
- reliability_lab/data.py +280 -0
- reliability_lab/design.py +253 -0
- reliability_lab/exceptions.py +42 -0
- reliability_lab/inference.py +724 -0
- reliability_lab/py.typed +0 -0
- reliability_lab/report.py +717 -0
- reliability_lab/split.py +338 -0
- reliability_lab/study.py +824 -0
- reliability_lab-0.1.0a1.dist-info/METADATA +137 -0
- reliability_lab-0.1.0a1.dist-info/RECORD +15 -0
- reliability_lab-0.1.0a1.dist-info/WHEEL +4 -0
- reliability_lab-0.1.0a1.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""reliability-lab: study design for the reliability of LLM and human judgments."""
|
|
2
|
+
|
|
3
|
+
from reliability_lab._version import __version__
|
|
4
|
+
from reliability_lab.alpha import INTERVAL, NOMINAL, ORDINAL, RATIO, Distance
|
|
5
|
+
from reliability_lab.data import RatingFrame, Status
|
|
6
|
+
from reliability_lab.design import CoderSpec, Design
|
|
7
|
+
from reliability_lab.exceptions import (
|
|
8
|
+
BootstrapWarning,
|
|
9
|
+
CategorySkewWarning,
|
|
10
|
+
CoderDependenceWarning,
|
|
11
|
+
DesignError,
|
|
12
|
+
DomainSizeWarning,
|
|
13
|
+
LeakageError,
|
|
14
|
+
ReliabilityLabError,
|
|
15
|
+
ReliabilityWarning,
|
|
16
|
+
SchemaError,
|
|
17
|
+
UndefinedAlphaError,
|
|
18
|
+
)
|
|
19
|
+
from reliability_lab.report import (
|
|
20
|
+
AlphaEstimate,
|
|
21
|
+
AlphaResult,
|
|
22
|
+
BootstrapResult,
|
|
23
|
+
ComparisonResult,
|
|
24
|
+
Coverage,
|
|
25
|
+
Interval,
|
|
26
|
+
Provenance,
|
|
27
|
+
)
|
|
28
|
+
from reliability_lab.split import SplitManifest
|
|
29
|
+
from reliability_lab.study import Fold, ReliabilityStudy
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"INTERVAL",
|
|
33
|
+
"NOMINAL",
|
|
34
|
+
"ORDINAL",
|
|
35
|
+
"RATIO",
|
|
36
|
+
"AlphaEstimate",
|
|
37
|
+
"AlphaResult",
|
|
38
|
+
"BootstrapResult",
|
|
39
|
+
"BootstrapWarning",
|
|
40
|
+
"CategorySkewWarning",
|
|
41
|
+
"CoderDependenceWarning",
|
|
42
|
+
"CoderSpec",
|
|
43
|
+
"ComparisonResult",
|
|
44
|
+
"Coverage",
|
|
45
|
+
"Design",
|
|
46
|
+
"DesignError",
|
|
47
|
+
"Distance",
|
|
48
|
+
"DomainSizeWarning",
|
|
49
|
+
"Fold",
|
|
50
|
+
"Interval",
|
|
51
|
+
"LeakageError",
|
|
52
|
+
"Provenance",
|
|
53
|
+
"RatingFrame",
|
|
54
|
+
"ReliabilityLabError",
|
|
55
|
+
"ReliabilityStudy",
|
|
56
|
+
"ReliabilityWarning",
|
|
57
|
+
"SchemaError",
|
|
58
|
+
"SplitManifest",
|
|
59
|
+
"Status",
|
|
60
|
+
"UndefinedAlphaError",
|
|
61
|
+
"__version__",
|
|
62
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0a1"
|
reliability_lab/alpha.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Krippendorff's alpha from coincidence matrices.
|
|
2
|
+
|
|
3
|
+
Notation follows Krippendorff (2011), *Computing Krippendorff's alpha-reliability*:
|
|
4
|
+
``o`` is the V x V coincidence matrix, ``n_c`` its marginals, ``n`` the number of
|
|
5
|
+
pairable values and ``delta2`` the squared distances between values.
|
|
6
|
+
|
|
7
|
+
D_o = sum(o * delta2) / n
|
|
8
|
+
D_e = sum(n_c n_k delta2) / (n (n - 1))
|
|
9
|
+
alpha = 1 - D_o / D_e
|
|
10
|
+
|
|
11
|
+
The coincidence matrix is a sum of per-unit contributions, and those do not depend
|
|
12
|
+
on the distance. Precomputing them once therefore serves every distance and every
|
|
13
|
+
bootstrap replicate. Only distances that depend on the marginals (ordinal) must be
|
|
14
|
+
rebuilt from each replicate's ``n_c``.
|
|
15
|
+
|
|
16
|
+
This module works on integer-coded numpy arrays only; ``study.py`` does the coding.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
import numpy.typing as npt
|
|
27
|
+
from scipy import sparse
|
|
28
|
+
|
|
29
|
+
from reliability_lab.exceptions import DesignError
|
|
30
|
+
|
|
31
|
+
FloatArray = npt.NDArray[np.float64]
|
|
32
|
+
IntArray = npt.NDArray[np.intp]
|
|
33
|
+
|
|
34
|
+
#: Above this many declared values a DomainSizeWarning is raised.
|
|
35
|
+
DOMAIN_SIZE_WARN = 1_000
|
|
36
|
+
#: Above this many declared values the study is refused.
|
|
37
|
+
DOMAIN_SIZE_MAX = 10_000
|
|
38
|
+
|
|
39
|
+
#: ``fn(values, marginals) -> V x V`` squared distances over the declared domain.
|
|
40
|
+
DistanceFn = Callable[[npt.NDArray[Any], FloatArray], FloatArray]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, eq=False)
|
|
44
|
+
class Distance:
|
|
45
|
+
"""A squared-distance function delta² over a declared value domain.
|
|
46
|
+
|
|
47
|
+
``fn`` receives the declared values (in declared order) and the marginal count
|
|
48
|
+
of each value, and returns a symmetric V x V matrix with a zero diagonal.
|
|
49
|
+
``depends_on_marginals`` marks metrics such as ordinal whose distances change
|
|
50
|
+
with the category distribution. ``numeric`` marks metrics that need numbers.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
fn: DistanceFn
|
|
55
|
+
depends_on_marginals: bool = False
|
|
56
|
+
numeric: bool = False
|
|
57
|
+
|
|
58
|
+
def matrix(self, values: Sequence[Any], marginals: npt.ArrayLike) -> FloatArray:
|
|
59
|
+
v = np.asarray(values, dtype=float if self.numeric else object)
|
|
60
|
+
m = np.asarray(marginals, dtype=float)
|
|
61
|
+
d = np.asarray(self.fn(v, m), dtype=float)
|
|
62
|
+
_validate_distance_matrix(d, len(values), self.name)
|
|
63
|
+
return d
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_matrix(cls, name: str, matrix: npt.ArrayLike) -> Distance:
|
|
67
|
+
"""A custom distance from a fixed V x V matrix of squared distances.
|
|
68
|
+
|
|
69
|
+
Rows and columns follow the order of the declared ``values``.
|
|
70
|
+
"""
|
|
71
|
+
fixed = np.array(matrix, dtype=float)
|
|
72
|
+
if fixed.ndim != 2 or fixed.shape[0] != fixed.shape[1]:
|
|
73
|
+
raise DesignError(f"distance {name!r} must be a square matrix, got {fixed.shape}")
|
|
74
|
+
_validate_distance_matrix(fixed, fixed.shape[0], name)
|
|
75
|
+
fixed.setflags(write=False)
|
|
76
|
+
|
|
77
|
+
def fn(values: npt.NDArray[Any], marginals: FloatArray) -> FloatArray:
|
|
78
|
+
if len(values) != fixed.shape[0]:
|
|
79
|
+
raise DesignError(
|
|
80
|
+
f"distance {name!r} is {fixed.shape[0]} x {fixed.shape[0]} but the value "
|
|
81
|
+
f"domain has {len(values)} values"
|
|
82
|
+
)
|
|
83
|
+
return fixed
|
|
84
|
+
|
|
85
|
+
return cls(name=name, fn=fn)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _validate_distance_matrix(d: FloatArray, size: int, name: str) -> None:
|
|
89
|
+
if d.shape != (size, size):
|
|
90
|
+
raise DesignError(f"distance {name!r} returned shape {d.shape}, expected ({size}, {size})")
|
|
91
|
+
if not np.isfinite(d).all():
|
|
92
|
+
raise DesignError(f"distance {name!r} has non-finite entries")
|
|
93
|
+
if (d < 0).any():
|
|
94
|
+
raise DesignError(f"distance {name!r} has negative entries")
|
|
95
|
+
if not np.allclose(d, d.T, rtol=1e-12, atol=1e-12):
|
|
96
|
+
raise DesignError(f"distance {name!r} is not symmetric")
|
|
97
|
+
if np.any(np.diag(d) != 0):
|
|
98
|
+
raise DesignError(f"distance {name!r} must be zero on the diagonal")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _nominal(values: npt.NDArray[Any], marginals: FloatArray) -> FloatArray:
|
|
102
|
+
return 1.0 - np.eye(len(values))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ordinal(values: npt.NDArray[Any], marginals: FloatArray) -> FloatArray:
|
|
106
|
+
# delta_ck = sum_{g=c..k} n_g - (n_c + n_k)/2, which equals r_k - r_c with
|
|
107
|
+
# r = cumsum(n) - n/2 (the mid-rank of each value in declared order).
|
|
108
|
+
r = np.cumsum(marginals) - marginals / 2.0
|
|
109
|
+
return np.asarray((r[:, None] - r[None, :]) ** 2, dtype=float)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _interval(values: npt.NDArray[Any], marginals: FloatArray) -> FloatArray:
|
|
113
|
+
v = values.astype(float)
|
|
114
|
+
return np.asarray((v[:, None] - v[None, :]) ** 2, dtype=float)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _ratio(values: npt.NDArray[Any], marginals: FloatArray) -> FloatArray:
|
|
118
|
+
v = values.astype(float)
|
|
119
|
+
if (v < 0).any():
|
|
120
|
+
raise DesignError("ratio distances need non-negative values")
|
|
121
|
+
total = v[:, None] + v[None, :]
|
|
122
|
+
diff = v[:, None] - v[None, :]
|
|
123
|
+
safe = np.where(total == 0, 1.0, total)
|
|
124
|
+
return np.asarray(np.where(total == 0, 0.0, (diff / safe) ** 2), dtype=float)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
NOMINAL = Distance("nominal", _nominal)
|
|
128
|
+
ORDINAL = Distance("ordinal", _ordinal, depends_on_marginals=True)
|
|
129
|
+
INTERVAL = Distance("interval", _interval, numeric=True)
|
|
130
|
+
RATIO = Distance("ratio", _ratio, numeric=True)
|
|
131
|
+
|
|
132
|
+
BUILTIN_DISTANCES: dict[str, Distance] = {d.name: d for d in (NOMINAL, ORDINAL, INTERVAL, RATIO)}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def check_domain_size(n_values: int) -> str | None:
|
|
136
|
+
"""Refuse oversized domains; return a warning message for large ones."""
|
|
137
|
+
megabytes = n_values * n_values * 8 / 1e6
|
|
138
|
+
if n_values > DOMAIN_SIZE_MAX:
|
|
139
|
+
raise DesignError(
|
|
140
|
+
f"the value domain has {n_values} values (more than {DOMAIN_SIZE_MAX}); each "
|
|
141
|
+
f"V x V matrix would need about {megabytes:,.0f} MB"
|
|
142
|
+
)
|
|
143
|
+
if n_values > DOMAIN_SIZE_WARN:
|
|
144
|
+
return (
|
|
145
|
+
f"the value domain has {n_values} values; each V x V matrix needs about "
|
|
146
|
+
f"{megabytes:,.0f} MB"
|
|
147
|
+
)
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def per_unit_coincidences(
|
|
152
|
+
unit_idx: IntArray, value_idx: IntArray, n_units: int, n_values: int
|
|
153
|
+
) -> tuple[sparse.csr_array, FloatArray]:
|
|
154
|
+
"""Each unit's contribution to the coincidence matrix.
|
|
155
|
+
|
|
156
|
+
Row ``u`` of the returned ``(n_units, n_values**2)`` sparse array is unit u's
|
|
157
|
+
V x V contribution, flattened in C order: ``(n_u n_u' - diag(n_u)) / (m_u - 1)``.
|
|
158
|
+
Here ``n_u`` counts the unit's pairable values and ``m_u`` is their total.
|
|
159
|
+
Units with fewer than two pairable values contribute nothing. Summing the rows
|
|
160
|
+
(or weighting them, in a bootstrap) gives the coincidence matrix.
|
|
161
|
+
|
|
162
|
+
Returns the sparse array and ``m_u`` for every unit.
|
|
163
|
+
"""
|
|
164
|
+
unit_idx = np.asarray(unit_idx, dtype=np.intp)
|
|
165
|
+
value_idx = np.asarray(value_idx, dtype=np.intp)
|
|
166
|
+
counts = sparse.csr_array(
|
|
167
|
+
(np.ones(len(unit_idx)), (unit_idx, value_idx)), shape=(n_units, n_values)
|
|
168
|
+
)
|
|
169
|
+
counts.sum_duplicates()
|
|
170
|
+
counts.sort_indices()
|
|
171
|
+
pairable = np.asarray(counts.sum(axis=1), dtype=float).ravel()
|
|
172
|
+
|
|
173
|
+
indptr = counts.indptr.astype(np.intp)
|
|
174
|
+
cols = counts.indices.astype(np.intp)
|
|
175
|
+
data = counts.data.astype(float)
|
|
176
|
+
distinct = np.diff(indptr)
|
|
177
|
+
# Pair every non-zero entry with every non-zero entry of the same unit.
|
|
178
|
+
entry_unit = np.repeat(np.arange(n_units), distinct)
|
|
179
|
+
reps = distinct[entry_unit]
|
|
180
|
+
left = np.repeat(np.arange(len(cols)), reps)
|
|
181
|
+
block_start = np.repeat(np.cumsum(reps) - reps, reps)
|
|
182
|
+
right = np.repeat(indptr[entry_unit], reps) + (np.arange(len(left)) - block_start)
|
|
183
|
+
|
|
184
|
+
unit = entry_unit[left]
|
|
185
|
+
same = cols[left] == cols[right]
|
|
186
|
+
weight = data[left] * data[right] - np.where(same, data[left], 0.0)
|
|
187
|
+
keep = (pairable[unit] >= 2) & (weight != 0)
|
|
188
|
+
contribution = weight[keep] / (pairable[unit][keep] - 1)
|
|
189
|
+
flat = cols[left][keep] * n_values + cols[right][keep]
|
|
190
|
+
out = sparse.csr_array((contribution, (unit[keep], flat)), shape=(n_units, n_values * n_values))
|
|
191
|
+
return out, pairable
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def coincidence_matrix(per_unit: sparse.csr_array, n_values: int) -> FloatArray:
|
|
195
|
+
"""Sum the per-unit contributions into the V x V coincidence matrix."""
|
|
196
|
+
flat = np.asarray(per_unit.sum(axis=0), dtype=float).ravel()
|
|
197
|
+
return flat.reshape(n_values, n_values)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True, eq=False)
|
|
201
|
+
class AlphaComponents:
|
|
202
|
+
"""Alpha and the quantities it is computed from.
|
|
203
|
+
|
|
204
|
+
When alpha is undefined, ``alpha``, ``d_o`` and ``d_e`` hold what could be
|
|
205
|
+
computed (NaN otherwise) and ``undefined_reason`` says why. Nothing is raised
|
|
206
|
+
here, so bootstrap code can count failed replicates.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
alpha: float
|
|
210
|
+
d_o: float
|
|
211
|
+
d_e: float
|
|
212
|
+
n: float
|
|
213
|
+
marginals: FloatArray
|
|
214
|
+
delta2: FloatArray
|
|
215
|
+
expected: FloatArray
|
|
216
|
+
undefined_reason: str | None
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def undefined(self) -> bool:
|
|
220
|
+
return self.undefined_reason is not None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def alpha_from_coincidences(
|
|
224
|
+
coincidence: FloatArray, values: Sequence[Any], distance: Distance
|
|
225
|
+
) -> AlphaComponents:
|
|
226
|
+
"""Alpha from a coincidence matrix over the declared ``values``."""
|
|
227
|
+
o = np.asarray(coincidence, dtype=float)
|
|
228
|
+
n_c = o.sum(axis=1)
|
|
229
|
+
n = float(n_c.sum())
|
|
230
|
+
delta2 = distance.matrix(values, n_c)
|
|
231
|
+
if n < 2:
|
|
232
|
+
nan = float("nan")
|
|
233
|
+
return AlphaComponents(
|
|
234
|
+
nan,
|
|
235
|
+
nan,
|
|
236
|
+
nan,
|
|
237
|
+
n,
|
|
238
|
+
n_c,
|
|
239
|
+
delta2,
|
|
240
|
+
np.zeros_like(o),
|
|
241
|
+
"no unit has two or more pairable values",
|
|
242
|
+
)
|
|
243
|
+
expected = (np.outer(n_c, n_c) - np.diag(n_c)) / (n - 1)
|
|
244
|
+
d_o = float((o * delta2).sum() / n)
|
|
245
|
+
d_e = float((expected * delta2).sum() / n)
|
|
246
|
+
if not d_e > 0:
|
|
247
|
+
return AlphaComponents(
|
|
248
|
+
float("nan"),
|
|
249
|
+
d_o,
|
|
250
|
+
d_e,
|
|
251
|
+
n,
|
|
252
|
+
n_c,
|
|
253
|
+
delta2,
|
|
254
|
+
expected,
|
|
255
|
+
"expected disagreement is zero (every pairable value is in one category or at "
|
|
256
|
+
"zero distance), so alpha is undefined",
|
|
257
|
+
)
|
|
258
|
+
return AlphaComponents(1.0 - d_o / d_e, d_o, d_e, n, n_c, delta2, expected, None)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass(frozen=True, eq=False)
|
|
262
|
+
class AlphaComputation:
|
|
263
|
+
"""Alpha for one set of pairable values, with its white-box decomposition.
|
|
264
|
+
|
|
265
|
+
``unit_disagreement[u]`` is ``sum(o_u * delta2)``. Divided by ``n`` it is unit
|
|
266
|
+
u's contribution to D_o (the contributions sum to D_o). Divided by ``m_u`` it is
|
|
267
|
+
the mean squared distance over the unit's ordered pairs of values.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
components: AlphaComponents
|
|
271
|
+
coincidence: FloatArray
|
|
272
|
+
per_unit: sparse.csr_array
|
|
273
|
+
pairable: FloatArray
|
|
274
|
+
unit_disagreement: FloatArray
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def compute_alpha(
|
|
278
|
+
unit_idx: IntArray,
|
|
279
|
+
value_idx: IntArray,
|
|
280
|
+
n_units: int,
|
|
281
|
+
values: Sequence[Any],
|
|
282
|
+
distance: Distance,
|
|
283
|
+
) -> AlphaComputation:
|
|
284
|
+
"""Alpha from integer-coded pairable values (one entry per rating)."""
|
|
285
|
+
n_values = len(values)
|
|
286
|
+
per_unit, pairable = per_unit_coincidences(unit_idx, value_idx, n_units, n_values)
|
|
287
|
+
o = coincidence_matrix(per_unit, n_values)
|
|
288
|
+
components = alpha_from_coincidences(o, values, distance)
|
|
289
|
+
unit_disagreement = np.asarray(per_unit @ components.delta2.ravel(), dtype=float)
|
|
290
|
+
return AlphaComputation(components, o, per_unit, pairable, unit_disagreement)
|
reliability_lab/data.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Long-form ratings: one row per rating attempt, each with an explicit status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections.abc import Hashable, Iterable
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import numpy.typing as npt
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from reliability_lab.exceptions import SchemaError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Status(StrEnum):
|
|
18
|
+
"""Outcome of one rating attempt."""
|
|
19
|
+
|
|
20
|
+
OBSERVED = "observed"
|
|
21
|
+
ABSTAINED = "abstained"
|
|
22
|
+
INAPPLICABLE = "inapplicable"
|
|
23
|
+
PARSE_ERROR = "parse_error"
|
|
24
|
+
PROVIDER_ERROR = "provider_error"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
#: Statuses of failed attempts, as plain strings (the stored form).
|
|
28
|
+
ERROR_STATUSES: frozenset[str] = frozenset({Status.PARSE_ERROR.value, Status.PROVIDER_ERROR.value})
|
|
29
|
+
|
|
30
|
+
#: Canonical field names, in the order a RatingFrame stores them.
|
|
31
|
+
FIELDS: tuple[str, ...] = (
|
|
32
|
+
"unit_id",
|
|
33
|
+
"rater_id",
|
|
34
|
+
"value",
|
|
35
|
+
"status",
|
|
36
|
+
"group_id",
|
|
37
|
+
"stratum",
|
|
38
|
+
"model_id",
|
|
39
|
+
"model_family",
|
|
40
|
+
"condition_id",
|
|
41
|
+
"run_id",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def preview(labels: Iterable[Any], limit: int = 5) -> str:
|
|
46
|
+
"""Format the first few labels of a collection for an error message."""
|
|
47
|
+
items = list(labels)
|
|
48
|
+
shown = ", ".join(repr(_plain(x)) for x in items[:limit])
|
|
49
|
+
more = f" and {len(items) - limit} more" if len(items) > limit else ""
|
|
50
|
+
return f"[{shown}]{more}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _plain(x: Any) -> Any:
|
|
54
|
+
"""Turn numpy scalars into Python scalars so they print cleanly."""
|
|
55
|
+
return x.item() if isinstance(x, np.generic) else x
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def canonical_text(x: Any) -> str:
|
|
59
|
+
"""Stable text for fingerprints: missing is empty, integral floats print as ints."""
|
|
60
|
+
if x is None or (not isinstance(x, str) and bool(pd.isna(x))):
|
|
61
|
+
return ""
|
|
62
|
+
x = _plain(x)
|
|
63
|
+
if isinstance(x, float) and x.is_integer():
|
|
64
|
+
return str(int(x))
|
|
65
|
+
return str(x)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fingerprint_rows(columns: dict[str, Iterable[Any]]) -> str:
|
|
69
|
+
"""sha256 over rows of named columns, independent of row order."""
|
|
70
|
+
names = list(columns)
|
|
71
|
+
texts = [[canonical_text(v) for v in col] for col in columns.values()]
|
|
72
|
+
rows = sorted("\x1f".join(parts) for parts in zip(*texts, strict=True))
|
|
73
|
+
digest = hashlib.sha256("\x1f".join(names).encode("utf-8"))
|
|
74
|
+
for row in rows:
|
|
75
|
+
digest.update(b"\n" + row.encode("utf-8"))
|
|
76
|
+
return digest.hexdigest()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RatingFrame:
|
|
80
|
+
"""Validated long-form ratings.
|
|
81
|
+
|
|
82
|
+
Build one with :meth:`from_long`. Each row is one rating attempt by one rater
|
|
83
|
+
on one unit. Its ``status`` says whether a value was observed, the rater
|
|
84
|
+
abstained, the item was inapplicable, or the attempt failed (parse or provider
|
|
85
|
+
error). The frame is copied on construction and never mutated afterwards.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, data: pd.DataFrame, declared: frozenset[str], implicit_status: bool) -> None:
|
|
89
|
+
self._data = data
|
|
90
|
+
self._declared = declared
|
|
91
|
+
self._implicit_status = implicit_status
|
|
92
|
+
self._fingerprint: str | None = None
|
|
93
|
+
|
|
94
|
+
def fingerprint(self) -> str:
|
|
95
|
+
"""sha256 of the validated ratings (declared fields only).
|
|
96
|
+
|
|
97
|
+
Rows are sorted before hashing, and values are written as canonical text,
|
|
98
|
+
so the fingerprint ignores row order and dtype changes such as ``1`` vs
|
|
99
|
+
``1.0``.
|
|
100
|
+
"""
|
|
101
|
+
if self._fingerprint is None:
|
|
102
|
+
fields = [f for f in FIELDS if f in self._declared]
|
|
103
|
+
self._fingerprint = fingerprint_rows({f: self._data[f] for f in fields})
|
|
104
|
+
return self._fingerprint
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_long(
|
|
108
|
+
cls,
|
|
109
|
+
df: pd.DataFrame,
|
|
110
|
+
*,
|
|
111
|
+
unit: Hashable,
|
|
112
|
+
rater: Hashable,
|
|
113
|
+
value: Hashable,
|
|
114
|
+
status: Hashable | None = None,
|
|
115
|
+
group: Hashable | None = None,
|
|
116
|
+
stratum: Hashable | None = None,
|
|
117
|
+
model_id: Hashable | None = None,
|
|
118
|
+
model_family: Hashable | None = None,
|
|
119
|
+
condition_id: Hashable | None = None,
|
|
120
|
+
run_id: Hashable | None = None,
|
|
121
|
+
) -> RatingFrame:
|
|
122
|
+
"""Validate a long-form table and map its columns to canonical fields.
|
|
123
|
+
|
|
124
|
+
Each keyword names the column of ``df`` that holds that field. ``unit``,
|
|
125
|
+
``rater`` and ``value`` are required. Without ``status`` every row must
|
|
126
|
+
carry a value and is recorded as observed. Pass a status column whenever
|
|
127
|
+
abstentions or failed attempts exist.
|
|
128
|
+
"""
|
|
129
|
+
if not isinstance(df, pd.DataFrame):
|
|
130
|
+
raise TypeError(f"expected a pandas DataFrame, got {type(df).__name__}")
|
|
131
|
+
mapping: dict[str, Hashable | None] = {
|
|
132
|
+
"unit_id": unit,
|
|
133
|
+
"rater_id": rater,
|
|
134
|
+
"value": value,
|
|
135
|
+
"status": status,
|
|
136
|
+
"group_id": group,
|
|
137
|
+
"stratum": stratum,
|
|
138
|
+
"model_id": model_id,
|
|
139
|
+
"model_family": model_family,
|
|
140
|
+
"condition_id": condition_id,
|
|
141
|
+
"run_id": run_id,
|
|
142
|
+
}
|
|
143
|
+
absent = [c for c in mapping.values() if c is not None and c not in df.columns]
|
|
144
|
+
if absent:
|
|
145
|
+
raise SchemaError(f"columns not found in the ratings table: {preview(absent)}")
|
|
146
|
+
|
|
147
|
+
n = len(df)
|
|
148
|
+
columns: dict[str, Any] = {}
|
|
149
|
+
for field, column in mapping.items():
|
|
150
|
+
if column is None:
|
|
151
|
+
columns[field] = np.full(n, None, dtype=object)
|
|
152
|
+
else:
|
|
153
|
+
columns[field] = df[column].to_numpy(copy=True)
|
|
154
|
+
data = pd.DataFrame(columns, index=df.index)
|
|
155
|
+
|
|
156
|
+
for field in ("unit_id", "rater_id"):
|
|
157
|
+
_require_complete(data, field, mapping[field])
|
|
158
|
+
|
|
159
|
+
if status is None:
|
|
160
|
+
missing = data.index[data["value"].isna()]
|
|
161
|
+
if len(missing):
|
|
162
|
+
raise SchemaError(
|
|
163
|
+
f"value is missing in {len(missing)} rows {preview(missing)}. Pass status= "
|
|
164
|
+
"with a status column so that abstentions and failed attempts are explicit."
|
|
165
|
+
)
|
|
166
|
+
data["status"] = Status.OBSERVED.value
|
|
167
|
+
else:
|
|
168
|
+
data["status"] = _normalise_status(data["status"], status)
|
|
169
|
+
unobserved = data.index[(data["status"] == Status.OBSERVED) & data["value"].isna()]
|
|
170
|
+
if len(unobserved):
|
|
171
|
+
raise SchemaError(
|
|
172
|
+
f"{len(unobserved)} rows have status 'observed' but no value "
|
|
173
|
+
f"{preview(unobserved)}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
declared = frozenset(f for f, c in mapping.items() if c is not None)
|
|
177
|
+
for field in ("group_id", "stratum"):
|
|
178
|
+
if field in declared:
|
|
179
|
+
_require_complete(data, field, mapping[field])
|
|
180
|
+
_require_constant_within(data, field, "unit_id")
|
|
181
|
+
|
|
182
|
+
is_llm = data["model_id"].notna()
|
|
183
|
+
if is_llm.any():
|
|
184
|
+
if "condition_id" not in declared:
|
|
185
|
+
raise SchemaError(
|
|
186
|
+
"LLM rows (non-null model_id) need condition_id: the frozen prompt, rubric, "
|
|
187
|
+
"parser, candidate order and generation settings of the judge."
|
|
188
|
+
)
|
|
189
|
+
bad = data.index[is_llm & data["condition_id"].isna()]
|
|
190
|
+
if len(bad):
|
|
191
|
+
raise SchemaError(f"{len(bad)} LLM rows have no condition_id {preview(bad)}")
|
|
192
|
+
if "model_family" in declared:
|
|
193
|
+
_require_constant_within(data[is_llm], "model_family", "model_id")
|
|
194
|
+
|
|
195
|
+
return cls(data, declared | {"status"}, implicit_status=status is None)
|
|
196
|
+
|
|
197
|
+
def __len__(self) -> int:
|
|
198
|
+
return len(self._data)
|
|
199
|
+
|
|
200
|
+
def __repr__(self) -> str:
|
|
201
|
+
counts = self._data["status"].value_counts()
|
|
202
|
+
status = ", ".join(f"{k}={int(v)}" for k, v in counts.items())
|
|
203
|
+
return (
|
|
204
|
+
f"RatingFrame(rows={len(self)}, units={self.n_units}, "
|
|
205
|
+
f"raters={self._data['rater_id'].nunique()}, status: {status})"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def declared(self) -> frozenset[str]:
|
|
210
|
+
"""Canonical fields that were mapped to a column (``status`` always counts)."""
|
|
211
|
+
return self._declared
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def implicit_status(self) -> bool:
|
|
215
|
+
"""True when no status column was given and every row was taken as observed."""
|
|
216
|
+
return self._implicit_status
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def n_units(self) -> int:
|
|
220
|
+
return int(self._data["unit_id"].nunique())
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def is_llm(self) -> npt.NDArray[np.bool_]:
|
|
224
|
+
"""Mask of rows produced by an LLM judge (rows with a model_id)."""
|
|
225
|
+
return self._data["model_id"].notna().to_numpy()
|
|
226
|
+
|
|
227
|
+
def to_frame(self) -> pd.DataFrame:
|
|
228
|
+
"""A copy of the validated table, with canonical column names."""
|
|
229
|
+
return self._data.copy()
|
|
230
|
+
|
|
231
|
+
def _subset(self, mask: npt.NDArray[np.bool_]) -> RatingFrame:
|
|
232
|
+
"""The rows selected by a boolean mask. Every row and per-unit check that
|
|
233
|
+
held for the whole frame still holds for a subset, so nothing is re-run."""
|
|
234
|
+
return RatingFrame(self._data[mask].copy(), self._declared, self._implicit_status)
|
|
235
|
+
|
|
236
|
+
def observed_values(self) -> list[Any]:
|
|
237
|
+
"""Distinct observed values, sorted when they are mutually comparable.
|
|
238
|
+
|
|
239
|
+
Useful for declaring a value domain explicitly:
|
|
240
|
+
``values=ratings.observed_values()``.
|
|
241
|
+
"""
|
|
242
|
+
observed = self._data.loc[self._data["status"] == Status.OBSERVED, "value"]
|
|
243
|
+
unique = [_plain(v) for v in pd.unique(observed)]
|
|
244
|
+
try:
|
|
245
|
+
return sorted(unique)
|
|
246
|
+
except TypeError:
|
|
247
|
+
return unique
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _require_complete(data: pd.DataFrame, field: str, column: Hashable | None) -> None:
|
|
251
|
+
missing = data.index[data[field].isna()]
|
|
252
|
+
if len(missing):
|
|
253
|
+
raise SchemaError(
|
|
254
|
+
f"{field} (column {column!r}) is missing in {len(missing)} rows {preview(missing)}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _require_constant_within(data: pd.DataFrame, field: str, key: str) -> None:
|
|
259
|
+
per_key = data.groupby(key, sort=False)[field].nunique()
|
|
260
|
+
bad = per_key.index[per_key > 1]
|
|
261
|
+
if len(bad):
|
|
262
|
+
raise SchemaError(
|
|
263
|
+
f"each {key} must have exactly one {field}; {len(bad)} have several {preview(bad)}"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _normalise_status(raw: pd.Series[Any], column: Hashable) -> pd.Series[Any]:
|
|
268
|
+
missing = raw.index[raw.isna()]
|
|
269
|
+
if len(missing):
|
|
270
|
+
raise SchemaError(
|
|
271
|
+
f"status (column {column!r}) is missing in {len(missing)} rows {preview(missing)}"
|
|
272
|
+
)
|
|
273
|
+
labels = raw.map(str)
|
|
274
|
+
legal = {s.value for s in Status}
|
|
275
|
+
unknown = sorted(set(labels[~labels.isin(legal)]))
|
|
276
|
+
if unknown:
|
|
277
|
+
raise SchemaError(
|
|
278
|
+
f"unknown status labels {preview(unknown)}; expected one of {sorted(legal)}"
|
|
279
|
+
)
|
|
280
|
+
return labels.astype(object)
|