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 +1 -0
- interrater/cleaners/__init__.py +19 -0
- interrater/cleaners/_string_utils.py +37 -0
- interrater/cleaners/category_report.py +96 -0
- interrater/cleaners/cleaner_config.py +308 -0
- interrater/cleaners/cleaning_report.py +125 -0
- interrater/cleaners/drop_report.py +159 -0
- interrater/cleaners/frame_layout.py +102 -0
- interrater/cleaners/insufficient_data_policy.py +123 -0
- interrater/cleaners/matching_report.py +146 -0
- interrater/contracts.py +120 -0
- interrater/data_objects/__init__.py +3 -0
- interrater/data_objects/_ratings_validation.py +160 -0
- interrater/data_objects/ratings.py +188 -0
- interrater/semantics/__init__.py +19 -0
- interrater/semantics/categories.py +327 -0
- interrater/semantics/ratings_semantics.py +253 -0
- interrater-0.0.1.dev0.dist-info/METADATA +82 -0
- interrater-0.0.1.dev0.dist-info/RECORD +21 -0
- interrater-0.0.1.dev0.dist-info/WHEEL +4 -0
- interrater-0.0.1.dev0.dist-info/licenses/LICENSE +674 -0
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()
|