interrater 0.0.1.dev0__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.
interrater/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """interrater -- agreement between raters, with auditable inputs."""
@@ -0,0 +1,19 @@
1
+ from .category_report import CategoryReport
2
+ from .cleaner_config import CleanerConfig
3
+ from .cleaning_report import CleaningReport
4
+ from .drop_report import DropPass, DropReport
5
+ from .frame_layout import FrameLayout
6
+ from .insufficient_data_policy import MAX_DROP_PASSES, InsufficientDataPolicy
7
+ from .matching_report import MatchingReport
8
+
9
+ __all__ = [
10
+ "CleanerConfig",
11
+ "FrameLayout",
12
+ "InsufficientDataPolicy",
13
+ "MAX_DROP_PASSES",
14
+ "CleaningReport",
15
+ "MatchingReport",
16
+ "DropReport",
17
+ "DropPass",
18
+ "CategoryReport",
19
+ ]
@@ -0,0 +1,37 @@
1
+ """
2
+ String work for the cleaner.
3
+
4
+ Free functions over plain strings. Nothing here knows what a study is, raises,
5
+ or holds state. The config calls into it to check its own consistency before
6
+ any data exists; the cleaner calls the same functions on every cell. One rule,
7
+ one implementation -- a config validated under one interpretation and matched
8
+ under another would be worse than no validation at all.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ def fold(value: str, *, case_sensitive: bool, strip_whitespace: bool) -> str:
15
+ """
16
+ Normalise a string the way it will be matched.
17
+
18
+ Under the defaults ``" NA "`` becomes ``"na"``, so a whitespace variant
19
+ need not appear in every list and a value cannot match a category
20
+ case-insensitively while missing the missing-values list.
21
+
22
+ Named for ``str.casefold``, which is the comparison-grade lowercase: it
23
+ handles cases ``lower()`` does not, such as German ``ß`` folding to ``ss``.
24
+ """
25
+ if strip_whitespace:
26
+ value = value.strip()
27
+ return value if case_sensitive else value.casefold()
28
+
29
+
30
+ def fold_all(
31
+ values, *, case_sensitive: bool, strip_whitespace: bool
32
+ ) -> list[str]:
33
+ """``fold`` over an iterable, in order."""
34
+ return [
35
+ fold(v, case_sensitive=case_sensitive, strip_whitespace=strip_whitespace)
36
+ for v in values
37
+ ]
@@ -0,0 +1,96 @@
1
+ """Which declared categories were actually used."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class CategoryReport:
10
+ """
11
+ I say which of the declared categories anybody chose.
12
+
13
+ A category nobody chose is not automatically wrong. If the scheme offered
14
+ five grades and no student earned a D, that is a fact about the cohort. But
15
+ it is also the shape a mistake takes when a study is not what its owner
16
+ thinks it is, which is why the default policy stops rather than shrugs.
17
+
18
+ Keeping an unused category is the safe answer, because K is a fact about
19
+ what raters could have said. Dropping one changes any coefficient whose
20
+ chance term counts categories -- Gwet's AC1 and Brennan-Prediger both --
21
+ while leaving kappa untouched, so the damage is quiet and easy to miss.
22
+
23
+ I also record the rare categories, which the unused check does not catch
24
+ and which are more dangerous than the empty ones. A category chosen once in
25
+ three hundred cells passes any test for being used, and every statistic
26
+ involving it is noise.
27
+
28
+ Attributes
29
+ ----------
30
+ counts:
31
+ Category name to how many ratings it received.
32
+ unused:
33
+ Categories with no ratings at all.
34
+ policy:
35
+ What ``unused_categories`` was set to.
36
+ action:
37
+ What that policy did. Under ``error`` there is no report to read, so in
38
+ practice this is 'kept' or 'dropped'.
39
+ rare_threshold:
40
+ Below this many ratings a category is called rare.
41
+ """
42
+
43
+ counts: dict[str, int]
44
+ policy: str
45
+ action: str = "kept"
46
+ rare_threshold: int = 10
47
+ unused: tuple[str, ...] = ()
48
+
49
+ @property
50
+ def rare(self) -> dict[str, int]:
51
+ """Used, but so seldom that anything involving them is noise."""
52
+ return {
53
+ name: n
54
+ for name, n in self.counts.items()
55
+ if 0 < n < self.rare_threshold
56
+ }
57
+
58
+ def summary(self) -> dict:
59
+ return {
60
+ "counts": dict(self.counts),
61
+ "unused": list(self.unused),
62
+ "rare": dict(self.rare),
63
+ "rare_threshold": self.rare_threshold,
64
+ "policy": self.policy,
65
+ "action": self.action,
66
+ }
67
+
68
+ def render(self, verbosity: str = "summary") -> str:
69
+ pad = " "
70
+ total = sum(self.counts.values()) or 1
71
+ lines = ["Categories"]
72
+
73
+ if verbosity == "full":
74
+ for name, n in self.counts.items():
75
+ lines.append(f"{pad}{name:<14} {n:>8,} ({n / total:.1%})")
76
+ else:
77
+ shown = ", ".join(
78
+ f"{name} {n / total:.0%}" for name, n in self.counts.items()
79
+ )
80
+ lines.append(f"{pad}used : {shown}")
81
+
82
+ if self.unused:
83
+ lines.append(
84
+ f"{pad}unused : {list(self.unused)} ({self.action})"
85
+ )
86
+ if self.rare:
87
+ shown = ", ".join(f"{k} ({v})" for k, v in self.rare.items())
88
+ lines.append(
89
+ f"{pad}rare : {shown} -- fewer than "
90
+ f"{self.rare_threshold} ratings; statistics involving these "
91
+ f"are not estimable"
92
+ )
93
+ return "\n".join(lines)
94
+
95
+ def __repr__(self) -> str:
96
+ return self.render()
@@ -0,0 +1,308 @@
1
+ """Every cleaning decision, declared in one place and versioned with the study."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ._string_utils import fold
8
+ from .frame_layout import FrameLayout
9
+ from .insufficient_data_policy import InsufficientDataPolicy
10
+
11
+
12
+ class CleanerConfig:
13
+ """
14
+ I am every decision a cleaner makes, written down before it runs.
15
+
16
+ I am the methods section of a paper in machine-readable form. Which strings
17
+ meant nothing, which were repaired and into what, what happened to a
18
+ response nobody declared, how thin the data was allowed to get -- all of it
19
+ stated in one file that is versioned with the analysis rather than
20
+ reconstructed from memory later.
21
+
22
+ I know nothing about any particular study. I have never seen a category
23
+ list or a rater name, which is why I can only check my own shape: that my
24
+ keys are strings, that a value is not claimed twice, that a minimum is not
25
+ below its floor. Whether ``value_map`` sends a string to a category that
26
+ actually exists is a question about a study, and the cleaner -- which holds
27
+ both me and the semantics -- is the one that can ask it and give a useful
28
+ answer. That check runs before any row is touched.
29
+
30
+ I delegate two concerns that have rules of their own. ``FrameLayout``
31
+ describes the file rather than the study, and is the part of me that does
32
+ not travel to a second export. ``InsufficientDataPolicy`` owns dropping,
33
+ which needs an order and a loop and so is more than a pair of numbers. What
34
+ is left on me directly is string matching, which is the one thing every
35
+ other part depends on being consistent.
36
+
37
+ Attributes
38
+ ----------
39
+ frame_layout:
40
+ How to read the frame. See ``FrameLayout``.
41
+ case_sensitive:
42
+ Whether string matching distinguishes case. Governs everything matched:
43
+ category names, ``missing_values``, and ``value_map`` keys alike, so
44
+ there is no way for a value to match one and miss another.
45
+ strip_whitespace:
46
+ Whether to trim before matching. On, so ``" NA "`` catches without
47
+ needing a whitespace variant in every list.
48
+ missing_values:
49
+ Strings that mean no rating. A separate list rather than entries in
50
+ ``value_map`` because absence is the decision most worth seeing at a
51
+ glance. Nulls are always missing regardless of what is listed here --
52
+ an empty spreadsheet cell arrives as a null, not as a string, so it
53
+ could not be listed anyway.
54
+ value_map:
55
+ Repairs: a string as it appears in the file, mapped to the category it
56
+ was meant to be.
57
+ unknown_policy:
58
+ What to do with a value that survives normalisation and is still not a
59
+ category. ``error`` by default, because a response nobody declared is
60
+ usually a sign the data is not what its owner thinks it is.
61
+ unused_categories:
62
+ What to do when a declared category was never chosen. ``error`` by
63
+ default, for the same reason. ``keep`` is the safe alternative: K is a
64
+ fact about what raters could have said, and dropping a category would
65
+ move any coefficient whose chance term counts them.
66
+ insufficient_data_drops:
67
+ How thin the data may get. See ``InsufficientDataPolicy``.
68
+ """
69
+
70
+ # ----------------------------------------------------------- Attributes #
71
+
72
+ frame_layout: FrameLayout
73
+ case_sensitive: bool
74
+ strip_whitespace: bool
75
+ missing_values: tuple[str, ...]
76
+ value_map: dict[str, str]
77
+ unknown_policy: str
78
+ unused_categories: str
79
+ insufficient_data_drops: InsufficientDataPolicy
80
+
81
+ _UNKNOWN_POLICIES = ("error", "empty")
82
+ _UNUSED_POLICIES = ("error", "keep", "drop")
83
+
84
+ DEFAULT_MISSING_VALUES = ("", "NA", "N/A")
85
+
86
+ # --------------------------------------------------------- Construction #
87
+
88
+ def __init__(
89
+ self,
90
+ frame_layout: FrameLayout | None = None,
91
+ case_sensitive: bool = False,
92
+ strip_whitespace: bool = True,
93
+ missing_values: object = None,
94
+ value_map: dict[str, str] | None = None,
95
+ unknown_policy: str = "error",
96
+ unused_categories: str = "error",
97
+ insufficient_data_drops: InsufficientDataPolicy | None = None,
98
+ ) -> None:
99
+ self.frame_layout = frame_layout or FrameLayout()
100
+ self.case_sensitive = case_sensitive
101
+ self.strip_whitespace = strip_whitespace
102
+ self.missing_values = tuple(
103
+ self.DEFAULT_MISSING_VALUES if missing_values is None
104
+ else missing_values
105
+ )
106
+ self.value_map = dict(value_map or {})
107
+ self.unknown_policy = unknown_policy
108
+ self.unused_categories = unused_categories
109
+ self.insufficient_data_drops = (
110
+ insufficient_data_drops or InsufficientDataPolicy()
111
+ )
112
+ self._validate()
113
+
114
+ # ---- Validation: shape only. I have never seen a study. ---- #
115
+
116
+ def _validate(self) -> None:
117
+ self._validate_delegates()
118
+ self._validate_flags()
119
+ self._validate_missing_values()
120
+ self._validate_value_map()
121
+ self._validate_no_double_claim()
122
+ self._validate_policies()
123
+
124
+ def _validate_delegates(self) -> None:
125
+ if not isinstance(self.frame_layout, FrameLayout):
126
+ raise TypeError(
127
+ f"CleanerConfig.frame_layout must be a FrameLayout, got "
128
+ f"{type(self.frame_layout).__name__}."
129
+ )
130
+ if not isinstance(self.insufficient_data_drops, InsufficientDataPolicy):
131
+ raise TypeError(
132
+ f"CleanerConfig.insufficient_data_drops must be an "
133
+ f"InsufficientDataPolicy, got "
134
+ f"{type(self.insufficient_data_drops).__name__}."
135
+ )
136
+
137
+ def _validate_flags(self) -> None:
138
+ for field in ("case_sensitive", "strip_whitespace"):
139
+ if not isinstance(getattr(self, field), bool):
140
+ raise TypeError(
141
+ f"CleanerConfig.{field} must be a boolean, got "
142
+ f"{type(getattr(self, field)).__name__}."
143
+ )
144
+
145
+ def _validate_missing_values(self) -> None:
146
+ bad = [v for v in self.missing_values if not isinstance(v, str)]
147
+ if bad:
148
+ raise TypeError(
149
+ f"CleanerConfig.missing_values must hold strings, got "
150
+ f"{[type(v).__name__ for v in bad[:3]]}. A null cell is always "
151
+ f"missing and cannot be listed here."
152
+ )
153
+
154
+ def _validate_value_map(self) -> None:
155
+ if not isinstance(self.value_map, dict):
156
+ raise TypeError(
157
+ f"CleanerConfig.value_map must be a mapping of file string to "
158
+ f"category name, got {type(self.value_map).__name__}."
159
+ )
160
+ for key, value in self.value_map.items():
161
+ if not isinstance(key, str) or not isinstance(value, str):
162
+ raise TypeError(
163
+ f"CleanerConfig.value_map must map string to string, but "
164
+ f"{key!r} -> {value!r} is "
165
+ f"{type(key).__name__} -> {type(value).__name__}. To mark "
166
+ f"a string as absent, list it in missing_values instead."
167
+ )
168
+ if not key:
169
+ raise ValueError(
170
+ "CleanerConfig.value_map has an empty key. An empty cell "
171
+ "is handled by missing_values, not here."
172
+ )
173
+
174
+ def _validate_no_double_claim(self) -> None:
175
+ """
176
+ A string cannot both mean nothing and mean something.
177
+
178
+ Folded before comparing, using the same rule the cleaner will apply to
179
+ data, so a config that validates here cannot behave differently there.
180
+ """
181
+ missing = {self._fold(v) for v in self.missing_values}
182
+ claimed = sorted(
183
+ key for key in self.value_map if self._fold(key) in missing
184
+ )
185
+ if claimed:
186
+ raise ValueError(
187
+ f"CleanerConfig claims {len(claimed)} string(s) twice "
188
+ f"(e.g. {claimed[:3]}): they appear in missing_values and are "
189
+ f"also mapped by value_map. Decide whether each means no "
190
+ f"rating or a category, and list it once."
191
+ + ("" if self.case_sensitive else
192
+ " Matching is case-insensitive, so entries differing only "
193
+ "in case collide.")
194
+ )
195
+
196
+ def _validate_policies(self) -> None:
197
+ if self.unknown_policy not in self._UNKNOWN_POLICIES:
198
+ raise ValueError(
199
+ f"CleanerConfig.unknown_policy must be one of "
200
+ f"{list(self._UNKNOWN_POLICIES)}, got {self.unknown_policy!r}. "
201
+ f"'error' stops on a response nobody declared; 'empty' treats "
202
+ f"it as no rating."
203
+ )
204
+ if self.unused_categories not in self._UNUSED_POLICIES:
205
+ raise ValueError(
206
+ f"CleanerConfig.unused_categories must be one of "
207
+ f"{list(self._UNUSED_POLICIES)}, got "
208
+ f"{self.unused_categories!r}."
209
+ )
210
+
211
+ def _fold(self, value: str) -> str:
212
+ """My matching rule, applied to one string."""
213
+ return fold(
214
+ value,
215
+ case_sensitive = self.case_sensitive,
216
+ strip_whitespace = self.strip_whitespace,
217
+ )
218
+
219
+ # --------------------------------------------------------- Declarative #
220
+
221
+ @classmethod
222
+ def from_dict(cls, spec: dict) -> CleanerConfig:
223
+ spec = dict(spec)
224
+ layout = spec.pop("frame_layout", None)
225
+ drops = spec.pop("insufficient_data_drops", None)
226
+ known = {
227
+ "case_sensitive", "strip_whitespace", "missing_values",
228
+ "value_map", "unknown_policy", "unused_categories",
229
+ }
230
+ unknown = sorted(set(spec) - known)
231
+ if unknown:
232
+ raise KeyError(
233
+ f"CleanerConfig has unknown key(s) {unknown}. Known: "
234
+ f"{sorted(known)}, frame_layout, insufficient_data_drops."
235
+ )
236
+ return cls(
237
+ frame_layout=FrameLayout.from_dict(layout) if layout else None,
238
+ insufficient_data_drops=(
239
+ InsufficientDataPolicy.from_dict(drops) if drops else None
240
+ ),
241
+ **spec,
242
+ )
243
+
244
+ @classmethod
245
+ def build_from_yaml(cls, path: str | Path) -> CleanerConfig:
246
+ """
247
+ Build from a YAML file:
248
+
249
+ .. code-block:: yaml
250
+
251
+ frame_layout:
252
+ use_column_names: true
253
+ subject_id_col: abstract_id
254
+ case_sensitive: false
255
+ missing_values: ["", "NA", "NOT SHOWN"]
256
+ value_map:
257
+ inc: include
258
+ exc: exclude
259
+ unknown_policy: error
260
+ unused_categories: error
261
+ insufficient_data_drops:
262
+ min_ratings_per_rater: 2
263
+ min_ratings_per_subject: 2
264
+ method: raters_first
265
+ """
266
+ import yaml
267
+
268
+ with open(path, "r", encoding="utf-8") as handle:
269
+ spec = yaml.safe_load(handle)
270
+ if not isinstance(spec, dict):
271
+ raise TypeError(
272
+ f"{path} must contain a YAML mapping, got "
273
+ f"{type(spec).__name__}."
274
+ )
275
+ return cls.from_dict(spec)
276
+
277
+ # ------------------------------------------------------------ Public API #
278
+
279
+ def __eq__(self, other: object) -> bool:
280
+ if not isinstance(other, CleanerConfig):
281
+ return NotImplemented
282
+ return all(
283
+ getattr(self, field) == getattr(other, field)
284
+ for field in (
285
+ "frame_layout", "case_sensitive", "strip_whitespace",
286
+ "missing_values", "value_map", "unknown_policy",
287
+ "unused_categories", "insufficient_data_drops",
288
+ )
289
+ )
290
+
291
+ __hash__ = None # type: ignore[assignment]
292
+
293
+ def __repr__(self) -> str:
294
+ drops = self.insufficient_data_drops
295
+ return (
296
+ f"CleanerConfig(\n"
297
+ f" frame_layout = {self.frame_layout}\n"
298
+ f" case_sensitive = {self.case_sensitive}\n"
299
+ f" strip_whitespace = {self.strip_whitespace}\n"
300
+ f" missing_values = {list(self.missing_values)}\n"
301
+ f" value_map = {len(self.value_map)} repair(s)\n"
302
+ f" unknown_policy = {self.unknown_policy!r}\n"
303
+ f" unused_categories = {self.unused_categories!r}\n"
304
+ f" drops = per_rater>={drops.min_ratings_per_rater}, "
305
+ f"per_subject>={drops.min_ratings_per_subject}, "
306
+ f"{drops.method!r}\n"
307
+ f")"
308
+ )
@@ -0,0 +1,125 @@
1
+ """What a cleaner did, in a form you can read or file away."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .category_report import CategoryReport
9
+ from .cleaner_config import CleanerConfig
10
+ from .drop_report import DropReport
11
+ from .matching_report import MatchingReport
12
+
13
+ _VERBOSITIES = ("summary", "full")
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CleaningReport:
18
+ """
19
+ I am the receipt for a clean. The soup does not remember the carrots; I do.
20
+
21
+ A ``Ratings`` describes its own contents and nothing about where they came
22
+ from, which is what keeps it comparable to any other ratings with the same
23
+ values. Everything that was normalised, repaired, treated as absent or
24
+ dropped along the way lives in me instead, beside the config that decided
25
+ it -- so a methods section can be read off rather than reconstructed.
26
+
27
+ I always hold everything. Verbosity is a property of how I am printed,
28
+ never of what I computed: two runs of the same data must produce the same
29
+ record, or a reader cannot tell whether a section is missing because
30
+ nothing happened or because somebody turned it off. Counting is cheap next
31
+ to the work of matching every cell, so there is nothing to buy back by
32
+ computing less.
33
+
34
+ I am frozen. I describe a run that already finished.
35
+
36
+ Attributes
37
+ ----------
38
+ config:
39
+ The rules that were applied. Without it my numbers say what happened
40
+ but not why, and the two together are the whole account.
41
+ shape_in:
42
+ Rows and columns of the frame handed to the cleaner.
43
+ shape_out:
44
+ Subjects and raters of the Ratings that came out.
45
+ matching, drops, categories:
46
+ The three stages. See ``MatchingReport``, ``DropReport``,
47
+ ``CategoryReport``.
48
+ """
49
+
50
+ config: CleanerConfig
51
+ shape_in: tuple[int, int]
52
+ shape_out: tuple[int, int]
53
+ matching: MatchingReport
54
+ drops: DropReport
55
+ categories: CategoryReport
56
+
57
+ # ------------------------------------------------------------- Reading #
58
+
59
+ @property
60
+ def n_subjects_lost(self) -> int:
61
+ return self.shape_in[0] - self.shape_out[0]
62
+
63
+ @property
64
+ def n_raters_lost(self) -> int:
65
+ return self.drops.n_raters_dropped
66
+
67
+ @property
68
+ def survived(self) -> float:
69
+ """Fraction of input rows that made it into the Ratings."""
70
+ return self.shape_out[0] / self.shape_in[0] if self.shape_in[0] else 0.0
71
+
72
+ def summary(self) -> dict:
73
+ """
74
+ Everything I hold, in plain types.
75
+
76
+ Complete at every verbosity: this is the machine-readable record, and
77
+ it would be useless if a display setting could change it. Write it
78
+ beside the config and the pair reproduces the run.
79
+ """
80
+ return {
81
+ "shape_in": list(self.shape_in),
82
+ "shape_out": list(self.shape_out),
83
+ "n_subjects_lost": self.n_subjects_lost,
84
+ "n_raters_lost": self.n_raters_lost,
85
+ "survived": round(self.survived, 6),
86
+ "matching": self.matching.summary(),
87
+ "drops": self.drops.summary(),
88
+ "categories": self.categories.summary(),
89
+ }
90
+
91
+ def to_yaml(self, path: str | Path) -> None:
92
+ """Write my summary beside the config that produced it."""
93
+ import yaml
94
+
95
+ with open(path, "w", encoding="utf-8") as handle:
96
+ yaml.safe_dump(self.summary(), handle, sort_keys=False)
97
+
98
+ # ------------------------------------------------------------ Printing #
99
+
100
+ def render(self, verbosity: str = "summary") -> str:
101
+ if verbosity not in _VERBOSITIES:
102
+ raise ValueError(
103
+ f"verbosity must be one of {list(_VERBOSITIES)}, got "
104
+ f"{verbosity!r}."
105
+ )
106
+ rows_in, cols_in = self.shape_in
107
+ rows_out, cols_out = self.shape_out
108
+ head = [
109
+ "CleaningReport",
110
+ f" in : {rows_in:,} row(s) x {cols_in} column(s)",
111
+ f" out : {rows_out:,} subject(s) x {cols_out} rater(s) "
112
+ f"({self.survived:.1%} of rows kept)",
113
+ "",
114
+ ]
115
+ body = [
116
+ self.matching.render(verbosity),
117
+ "",
118
+ self.drops.render(verbosity),
119
+ "",
120
+ self.categories.render(verbosity),
121
+ ]
122
+ return "\n".join(head + body)
123
+
124
+ def __repr__(self) -> str:
125
+ return self.render()