matchlab 0.1.0.dev1__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.
- matchlab/__init__.py +55 -0
- matchlab/adapters/__init__.py +24 -0
- matchlab/adapters/base.py +446 -0
- matchlab/adapters/duckdb.py +659 -0
- matchlab/cli.py +177 -0
- matchlab/core/__init__.py +1 -0
- matchlab/core/dataframes.py +254 -0
- matchlab/core/dsu.py +62 -0
- matchlab/core/exceptions.py +40 -0
- matchlab/core/hash.py +141 -0
- matchlab/core/kinds.py +12 -0
- matchlab/core/logging.py +190 -0
- matchlab/core/resolver_output.py +173 -0
- matchlab/core/schemas.py +82 -0
- matchlab/core/sql.py +12 -0
- matchlab/document.py +329 -0
- matchlab/eval/__init__.py +11 -0
- matchlab/eval/judgements.py +41 -0
- matchlab/eval/metrics.py +244 -0
- matchlab/eval/samples.py +415 -0
- matchlab/eval/tui/__init__.py +66 -0
- matchlab/eval/tui/app.py +418 -0
- matchlab/eval/tui/modals.py +84 -0
- matchlab/eval/tui/styles.tcss +184 -0
- matchlab/eval/tui/widgets/__init__.py +1 -0
- matchlab/eval/tui/widgets/assignment.py +72 -0
- matchlab/eval/tui/widgets/styling.py +75 -0
- matchlab/eval/tui/widgets/table.py +285 -0
- matchlab/lineage.py +239 -0
- matchlab/locations.py +347 -0
- matchlab/models/__init__.py +10 -0
- matchlab/models/comparison.py +67 -0
- matchlab/models/dedupers/__init__.py +5 -0
- matchlab/models/dedupers/base.py +38 -0
- matchlab/models/dedupers/naive.py +77 -0
- matchlab/models/linkers/__init__.py +9 -0
- matchlab/models/linkers/base.py +41 -0
- matchlab/models/linkers/deterministic.py +229 -0
- matchlab/models/linkers/splinklinker.py +262 -0
- matchlab/models/linkers/weighteddeterministic.py +143 -0
- matchlab/models/models.py +198 -0
- matchlab/progress.py +455 -0
- matchlab/recordstep.py +181 -0
- matchlab/resolvers/__init__.py +10 -0
- matchlab/resolvers/base.py +63 -0
- matchlab/resolvers/components.py +66 -0
- matchlab/resolvers/resolvers.py +409 -0
- matchlab/sources.py +291 -0
- matchlab/specs.py +129 -0
- matchlab/steps.py +284 -0
- matchlab/testkit/__init__.py +104 -0
- matchlab/testkit/_generate.py +240 -0
- matchlab/testkit/compare.py +178 -0
- matchlab/testkit/entities.py +290 -0
- matchlab/testkit/features.py +146 -0
- matchlab/testkit/linked.py +527 -0
- matchlab/testkit/matchers.py +233 -0
- matchlab/testkit/models.py +165 -0
- matchlab/testkit/sources.py +327 -0
- matchlab/transformers/__init__.py +24 -0
- matchlab/transformers/base.py +41 -0
- matchlab/transformers/clean.py +48 -0
- matchlab/transformers/explode.py +67 -0
- matchlab/transformers/group.py +56 -0
- matchlab/transformers/select.py +31 -0
- matchlab/transformers/transform.py +101 -0
- matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
- matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
- matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
- matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
- matchlab-0.1.0.dev1.dist-info/top_level.txt +1 -0
matchlab/eval/samples.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Client-side helpers for retrieving and preparing evaluation samples.
|
|
2
|
+
|
|
3
|
+
Everything here takes a **Resolver**: one you are holding, the label one was
|
|
4
|
+
published under, or a sequence of either. The sequence form is what makes two
|
|
5
|
+
methodologies comparable. Sampling across several resolvers unions their components,
|
|
6
|
+
so one round of judging covers all of them and the scores answer the same question.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import TYPE_CHECKING, Any, TypeAlias
|
|
11
|
+
|
|
12
|
+
import polars as pl
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from matchlab.core.dataframes import qualify
|
|
16
|
+
from matchlab.core.dsu import DisjointSet
|
|
17
|
+
from matchlab.core.exceptions import SourceTableError
|
|
18
|
+
from matchlab.core.resolver_output import root_id
|
|
19
|
+
from matchlab.eval.judgements import Judgement
|
|
20
|
+
from matchlab.eval.metrics import PrecisionRecall, precision_recall
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from matchlab.adapters import Adapter, Fingerprint
|
|
24
|
+
from matchlab.resolvers import Resolver
|
|
25
|
+
else:
|
|
26
|
+
Adapter = Any
|
|
27
|
+
Fingerprint = Any
|
|
28
|
+
Resolver = Any
|
|
29
|
+
|
|
30
|
+
ResolverRef: TypeAlias = "Resolver | str"
|
|
31
|
+
"""One resolver to read: a live `Resolver`, or the label one was published under."""
|
|
32
|
+
|
|
33
|
+
Reading: TypeAlias = "tuple[Adapter, Fingerprint]"
|
|
34
|
+
"""A located resolver: the store holding it, and its fingerprint."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EvaluationFieldMetadata(BaseModel):
|
|
38
|
+
"""Metadata describing one field shown to a reviewer during evaluation."""
|
|
39
|
+
|
|
40
|
+
display_name: str
|
|
41
|
+
source_columns: list[str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EvaluationItem(BaseModel):
|
|
45
|
+
"""One cluster shown to a reviewer, with its leaves' source data alongside it.
|
|
46
|
+
|
|
47
|
+
`records` holds the leaf IDs and their source-qualified data columns. For example:
|
|
48
|
+
|
|
49
|
+
| leaf | src_a_first | src_a_last | src_b_first | src_b_last |
|
|
50
|
+
|------|-------------|------------|-------------|------------|
|
|
51
|
+
| 1 | Thomas | Bayes | | |
|
|
52
|
+
| 2 | Tommy | B | | |
|
|
53
|
+
| 12 | | | Tom | Bayes |
|
|
54
|
+
|
|
55
|
+
`fields` maps each display name to the source-qualified columns holding it, so a
|
|
56
|
+
reviewer sees one column per field rather than one per source. For example:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
EvaluationFieldMetadata(
|
|
60
|
+
display_name="first", source_columns=["src_a_first", "src_b_first"]
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
66
|
+
|
|
67
|
+
leaves: list[int]
|
|
68
|
+
records: pl.DataFrame
|
|
69
|
+
fields: list[EvaluationFieldMetadata]
|
|
70
|
+
|
|
71
|
+
def get_unique_record_groups(self) -> list[list[int]]:
|
|
72
|
+
"""Group identical records by leaf ID.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of groups, where each group is a list of leaf IDs
|
|
76
|
+
that have identical values across all data fields.
|
|
77
|
+
Example: [[1, 3], [2], [4, 5, 6]] means records 1 & 3 are identical.
|
|
78
|
+
"""
|
|
79
|
+
data_cols = [col for field in self.fields for col in field.source_columns]
|
|
80
|
+
grouped = self.records.group_by(data_cols, maintain_order=True).agg(
|
|
81
|
+
pl.col("leaf")
|
|
82
|
+
)
|
|
83
|
+
return [group for group in grouped["leaf"]]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_judgement(
|
|
87
|
+
item: EvaluationItem,
|
|
88
|
+
assignments: dict[int, str],
|
|
89
|
+
tag: str | None = None,
|
|
90
|
+
) -> Judgement:
|
|
91
|
+
"""Build the Judgement for one reviewed item from its column assignments.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
item: The reviewed cluster.
|
|
95
|
+
assignments: Group letter chosen for each unique record group, keyed by that
|
|
96
|
+
group's index in `item.get_unique_record_groups()`.
|
|
97
|
+
tag: Tag to record on the judgement.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A Judgement with one endorsed group per distinct letter used in
|
|
101
|
+
`assignments`. A leaf whose group was never assigned is missing from
|
|
102
|
+
`endorsed`, which `Judgement`'s own validation rejects, so an incomplete
|
|
103
|
+
`assignments` raises rather than producing a partial judgement.
|
|
104
|
+
"""
|
|
105
|
+
groups: dict[str, list[int]] = {}
|
|
106
|
+
unique_record_groups = item.get_unique_record_groups()
|
|
107
|
+
|
|
108
|
+
for col_idx, group in assignments.items():
|
|
109
|
+
leaf_ids = unique_record_groups[col_idx]
|
|
110
|
+
groups.setdefault(group, []).extend(leaf_ids)
|
|
111
|
+
|
|
112
|
+
endorsed = [sorted(set(leaf_ids)) for leaf_ids in groups.values()]
|
|
113
|
+
return Judgement(shown=item.leaves, endorsed=endorsed, tag=tag)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def create_evaluation_item(
|
|
117
|
+
df: pl.DataFrame,
|
|
118
|
+
source_fields: list[tuple[str, list[str]]],
|
|
119
|
+
leaves: list[int],
|
|
120
|
+
) -> EvaluationItem:
|
|
121
|
+
"""Build an EvaluationItem, grouping each cluster's columns by field across sources.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
df: The cluster's rows, with source-qualified data columns.
|
|
125
|
+
source_fields: `(prefix, qualified columns)` per source. The columns come
|
|
126
|
+
from the fetched data rather than from the sources, which would have to
|
|
127
|
+
re-read the warehouse just to list their names.
|
|
128
|
+
leaves: The leaf IDs in this cluster.
|
|
129
|
+
"""
|
|
130
|
+
data_cols = [c for c in df.columns if c not in ["root", "leaf", "key"]]
|
|
131
|
+
|
|
132
|
+
# The same field in two sources shares a display name, which is what lines them
|
|
133
|
+
# up for review.
|
|
134
|
+
field_to_columns: dict[str, list[str]] = {}
|
|
135
|
+
|
|
136
|
+
for prefix, columns in source_fields:
|
|
137
|
+
for column in columns:
|
|
138
|
+
if column in data_cols:
|
|
139
|
+
field = column.removeprefix(prefix)
|
|
140
|
+
field_to_columns.setdefault(field, []).append(column)
|
|
141
|
+
|
|
142
|
+
fields: list[EvaluationFieldMetadata] = []
|
|
143
|
+
for field_name, source_columns in field_to_columns.items():
|
|
144
|
+
fields.append(
|
|
145
|
+
EvaluationFieldMetadata(
|
|
146
|
+
display_name=field_name, source_columns=source_columns
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
records = df.select(["leaf"] + data_cols)
|
|
151
|
+
|
|
152
|
+
return EvaluationItem(leaves=leaves, records=records, fields=fields)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _many(resolver: "ResolverRef | Sequence[ResolverRef]") -> bool:
|
|
156
|
+
"""Whether several resolvers were asked for.
|
|
157
|
+
|
|
158
|
+
A `str` is itself a `Sequence`. A label names one resolver, not a pile of
|
|
159
|
+
one-character ones, so it is excluded before the sequence check, not after.
|
|
160
|
+
"""
|
|
161
|
+
return not isinstance(resolver, str) and isinstance(resolver, Sequence)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _locate(
|
|
165
|
+
resolver: "ResolverRef", adapter: "Adapter | None"
|
|
166
|
+
) -> tuple["Adapter", bytes]:
|
|
167
|
+
"""Turn a resolver or a label into the store and fingerprint to read.
|
|
168
|
+
|
|
169
|
+
The two forms differ only in how the fingerprint is found. A live resolver
|
|
170
|
+
already knows its own fingerprint, while a label needs a lookup in a store.
|
|
171
|
+
Everything downstream wants the same pair, so this is the only place the
|
|
172
|
+
distinction exists.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
SourceTableError: If nothing is published under that label.
|
|
176
|
+
"""
|
|
177
|
+
from matchlab.steps import default_adapter # noqa: PLC0415 - avoids a cycle
|
|
178
|
+
|
|
179
|
+
if isinstance(resolver, str):
|
|
180
|
+
adapter = adapter or default_adapter()
|
|
181
|
+
fingerprint = adapter.find(resolver)
|
|
182
|
+
if fingerprint is None:
|
|
183
|
+
known = ", ".join(adapter.labels()) or "none"
|
|
184
|
+
raise SourceTableError(
|
|
185
|
+
f"No resolver is published under the label '{resolver}'. "
|
|
186
|
+
f"Known labels: {known}."
|
|
187
|
+
)
|
|
188
|
+
return adapter, fingerprint
|
|
189
|
+
|
|
190
|
+
if not resolver.is_collected:
|
|
191
|
+
resolver.collect(adapter)
|
|
192
|
+
collected_in, fingerprint = resolver._collected()
|
|
193
|
+
return adapter or collected_in, fingerprint
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _readings(
|
|
197
|
+
resolver: "ResolverRef | Sequence[ResolverRef]", adapter: "Adapter | None"
|
|
198
|
+
) -> list[Reading]:
|
|
199
|
+
"""Locate every resolver asked for, in the order given."""
|
|
200
|
+
if not _many(resolver):
|
|
201
|
+
return [_locate(resolver, adapter)]
|
|
202
|
+
if not resolver:
|
|
203
|
+
raise ValueError("At least one resolver must be given.")
|
|
204
|
+
return [_locate(one, adapter) for one in resolver]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _sources_of(readings: list[Reading]) -> dict[str, Reading]:
|
|
208
|
+
"""Map each source name to the store and fingerprint its rows come from.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
SourceTableError: If two resolvers cover the same source name with different
|
|
212
|
+
artifacts. Names repeat across generations of a source, so agreeing on the
|
|
213
|
+
name is not agreeing on the data, and comparing methodologies over different
|
|
214
|
+
data is not a comparison.
|
|
215
|
+
"""
|
|
216
|
+
located: dict[str, Reading] = {}
|
|
217
|
+
for store, fp in readings:
|
|
218
|
+
for name, source_fp in store.resolver_output_sources(fp).items():
|
|
219
|
+
seen = located.setdefault(name, (store, source_fp))
|
|
220
|
+
if seen[1] != source_fp:
|
|
221
|
+
raise SourceTableError(
|
|
222
|
+
f"These resolvers disagree about source '{name}': one covers "
|
|
223
|
+
f"{seen[1].hex()[:8]}, another {source_fp.hex()[:8]}. They are "
|
|
224
|
+
"built over different data, so their clusters cannot be compared. "
|
|
225
|
+
"Re-collect them over the same sources."
|
|
226
|
+
)
|
|
227
|
+
return located
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _merged_resolver_output(readings: list[Reading]) -> pl.DataFrame:
|
|
231
|
+
"""Union several resolvers' output into one `(root, leaf, key, source)` table.
|
|
232
|
+
|
|
233
|
+
Two records land in the same merged component if either resolver put them
|
|
234
|
+
together. This is the right sample for a bake-off. Every cluster where the
|
|
235
|
+
methodologies could disagree ends up on screen, so one judgement settles it for
|
|
236
|
+
both, and neither resolver gets to pick the clusters it is scored on.
|
|
237
|
+
|
|
238
|
+
Merged roots are minted with `root_id`, the same content-addressed function a
|
|
239
|
+
resolver uses for its own roots, so two people running the same comparison arrive
|
|
240
|
+
at identical root IDs for identical components. Nothing persists these merged roots.
|
|
241
|
+
`store_judgement` re-mints its own root from the leaves it is given, so a merged
|
|
242
|
+
root only ever lives as far as the reviewer.
|
|
243
|
+
"""
|
|
244
|
+
frames = [store.read_resolver(fp) for store, fp in readings]
|
|
245
|
+
|
|
246
|
+
components = DisjointSet[int]()
|
|
247
|
+
for frame in frames:
|
|
248
|
+
for leaves in frame.group_by("root").agg("leaf")["leaf"].to_list():
|
|
249
|
+
components.add(leaves[0])
|
|
250
|
+
for leaf in leaves[1:]:
|
|
251
|
+
components.union(leaves[0], leaf)
|
|
252
|
+
|
|
253
|
+
# `root_id` is invariant to leaf order but the caller does the sorting, and it is
|
|
254
|
+
# vectorised because there are as many clusters here as there are entities.
|
|
255
|
+
merged = (
|
|
256
|
+
pl.DataFrame(
|
|
257
|
+
{"leaf": [sorted(component) for component in components.get_components()]},
|
|
258
|
+
schema={"leaf": pl.List(pl.UInt64)},
|
|
259
|
+
)
|
|
260
|
+
.with_columns(root_id(pl.col("leaf")).alias("root"))
|
|
261
|
+
# A component always holds at least one leaf, so the empty-list case this
|
|
262
|
+
# settles cannot arise. Pinning it keeps the polars 2.0 default change quiet.
|
|
263
|
+
.explode("leaf", empty_as_null=False)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
records = pl.concat(
|
|
267
|
+
[frame.select("leaf", "key", "source") for frame in frames]
|
|
268
|
+
).unique()
|
|
269
|
+
return merged.join(records, on="leaf").select("root", "leaf", "key", "source")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _sample_clusters(
|
|
273
|
+
resolver_output: pl.DataFrame, n: int, seed: int | None
|
|
274
|
+
) -> pl.DataFrame:
|
|
275
|
+
"""Take up to `n` whole clusters from merge-forwarded Resolver output in memory.
|
|
276
|
+
|
|
277
|
+
The in-memory twin of `Adapter.sample`, used for merged Resolver output from
|
|
278
|
+
several readings. No store holds that merged output, since it exists only for
|
|
279
|
+
this comparison.
|
|
280
|
+
"""
|
|
281
|
+
roots = resolver_output["root"].unique()
|
|
282
|
+
if n < roots.len():
|
|
283
|
+
roots = roots.sample(n=n, seed=seed, shuffle=True)
|
|
284
|
+
return resolver_output.filter(pl.col("root").is_in(roots.to_list()))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def get_samples(
|
|
288
|
+
n: int,
|
|
289
|
+
resolver: "ResolverRef | Sequence[ResolverRef]",
|
|
290
|
+
adapter: "Adapter | None" = None,
|
|
291
|
+
seed: int | None = None,
|
|
292
|
+
) -> dict[int, EvaluationItem]:
|
|
293
|
+
"""Retrieve sampled clusters enriched with source data, as EvaluationItems.
|
|
294
|
+
|
|
295
|
+
Record values come from the extract stored when each source was collected, not
|
|
296
|
+
from a fresh warehouse read. This works offline, and shows the data the matching
|
|
297
|
+
actually saw.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
n: Number of clusters to sample.
|
|
301
|
+
resolver: The resolver to sample from, collected first if it isn't
|
|
302
|
+
already, or the label one was published under. The label form needs no
|
|
303
|
+
plan, because a stored resolver's output records which source artifacts
|
|
304
|
+
it covers. Pass several and the sample is drawn from their merged
|
|
305
|
+
components, so one round of judging scores all of them against the same
|
|
306
|
+
clusters.
|
|
307
|
+
adapter: Where to read from. Defaults to the resolver's, else the module
|
|
308
|
+
default.
|
|
309
|
+
seed: Fixes which clusters come back. The same store, `n` and seed give the
|
|
310
|
+
same sample, which is how two people review the same clusters.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Dictionary of cluster ID to EvaluationItems describing the cluster.
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
SourceTableError: If nothing is published under `resolver`, if a source the
|
|
317
|
+
resolver's output covers isn't in the store, or if several resolvers
|
|
318
|
+
disagree about a source.
|
|
319
|
+
ValueError: If `resolver` is an empty sequence.
|
|
320
|
+
"""
|
|
321
|
+
readings = _readings(resolver, adapter)
|
|
322
|
+
|
|
323
|
+
if len(readings) == 1:
|
|
324
|
+
store, resolver_fp = readings[0]
|
|
325
|
+
samples = store.sample(resolver_fp, n, seed)
|
|
326
|
+
else:
|
|
327
|
+
samples = _sample_clusters(_merged_resolver_output(readings), n, seed)
|
|
328
|
+
|
|
329
|
+
if not len(samples):
|
|
330
|
+
return {}
|
|
331
|
+
|
|
332
|
+
sources = _sources_of(readings)
|
|
333
|
+
results_by_source: list[pl.DataFrame] = []
|
|
334
|
+
source_fields: list[tuple[str, list[str]]] = []
|
|
335
|
+
|
|
336
|
+
for source_step in samples["source"].unique():
|
|
337
|
+
located = sources.get(source_step)
|
|
338
|
+
if located is None:
|
|
339
|
+
raise SourceTableError(
|
|
340
|
+
f"This resolver references source '{source_step}', which is not "
|
|
341
|
+
"in the store. Re-collect the plan to repopulate it."
|
|
342
|
+
)
|
|
343
|
+
source_store, source_fp = located
|
|
344
|
+
|
|
345
|
+
samples_by_source = samples.filter(pl.col("source") == source_step)
|
|
346
|
+
rows, qualified_key = source_store.read_source_records(
|
|
347
|
+
source_fp, source_step, samples_by_source["key"]
|
|
348
|
+
)
|
|
349
|
+
values = [column for column in rows.columns if column != qualified_key]
|
|
350
|
+
|
|
351
|
+
samples_and_source = samples_by_source.join(
|
|
352
|
+
rows, left_on="key", right_on=qualified_key
|
|
353
|
+
)
|
|
354
|
+
source_fields.append((qualify(source_step), values))
|
|
355
|
+
results_by_source.append(samples_and_source[["root", "leaf", "key"] + values])
|
|
356
|
+
|
|
357
|
+
if not results_by_source:
|
|
358
|
+
return {}
|
|
359
|
+
|
|
360
|
+
all_results: pl.DataFrame = pl.concat(results_by_source, how="diagonal")
|
|
361
|
+
|
|
362
|
+
results_by_root: dict[int, EvaluationItem] = {}
|
|
363
|
+
for root in all_results["root"].unique():
|
|
364
|
+
cluster_df = all_results.filter(pl.col("root") == root).drop("root")
|
|
365
|
+
leaves = cluster_df.select("leaf").to_series().unique().to_list()
|
|
366
|
+
evaluation_item = create_evaluation_item(cluster_df, source_fields, leaves)
|
|
367
|
+
results_by_root[root] = evaluation_item
|
|
368
|
+
|
|
369
|
+
return results_by_root
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class EvalData:
|
|
373
|
+
"""Caches a store's judgements, and scores resolvers against them."""
|
|
374
|
+
|
|
375
|
+
def __init__(self, adapter: Adapter, tag: str | None = None) -> None:
|
|
376
|
+
"""Load judgement and expansion data used to compute evaluation metrics.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
adapter: The storage adapter holding judgements, for example the one a
|
|
380
|
+
resolver was collected into.
|
|
381
|
+
tag: Optional tag to filter judgements by.
|
|
382
|
+
"""
|
|
383
|
+
self.adapter = adapter
|
|
384
|
+
self.tag = tag
|
|
385
|
+
self.judgements, self.expansion = adapter.read_eval_data(tag)
|
|
386
|
+
|
|
387
|
+
def precision_recall(
|
|
388
|
+
self, resolver: "ResolverRef | Sequence[ResolverRef]"
|
|
389
|
+
) -> PrecisionRecall | list[PrecisionRecall]:
|
|
390
|
+
"""Score one or more resolvers against these judgements.
|
|
391
|
+
|
|
392
|
+
Only pairs present in every resolver's output *and* in the judgements are
|
|
393
|
+
compared, so scoring several at once is the fair way to rank them. Each is
|
|
394
|
+
measured over the same records, and none is flattered by clusters the others
|
|
395
|
+
never saw. Scoring them one at a time gives each its own comparison set, and
|
|
396
|
+
those numbers do not line up.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
resolver: A resolver, the label one was published under, or a sequence of
|
|
400
|
+
either.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
One `(precision, recall)` pair, or a list of them in the order given if a
|
|
404
|
+
sequence was passed.
|
|
405
|
+
"""
|
|
406
|
+
readings = _readings(resolver, self.adapter)
|
|
407
|
+
scores = precision_recall(
|
|
408
|
+
[
|
|
409
|
+
store.read_resolver(fp).select("root", "leaf").unique()
|
|
410
|
+
for store, fp in readings
|
|
411
|
+
],
|
|
412
|
+
self.judgements,
|
|
413
|
+
self.expansion,
|
|
414
|
+
)
|
|
415
|
+
return scores if _many(resolver) else scores[0]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""An interactive reviewer for resolved clusters.
|
|
2
|
+
|
|
3
|
+
`review()` opens a terminal app showing one cluster at a time: the records that were
|
|
4
|
+
grouped together, laid out so you can see what matched. You paint them into groups and
|
|
5
|
+
each decision is stored as a `Judgement`, which `EvalData.precision_recall` then scores
|
|
6
|
+
a resolver against.
|
|
7
|
+
|
|
8
|
+
`app` is imported inside `review()` rather than here, so that importing `matchlab.eval`
|
|
9
|
+
for its scoring helpers doesn't pay for Textual's import.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from matchlab.adapters import Adapter
|
|
17
|
+
from matchlab.eval.samples import ResolverRef
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def review(
|
|
21
|
+
resolver: "ResolverRef | Sequence[ResolverRef]",
|
|
22
|
+
n: int = 5,
|
|
23
|
+
adapter: "Adapter | None" = None,
|
|
24
|
+
tag: str | None = None,
|
|
25
|
+
seed: int | None = None,
|
|
26
|
+
show_help: bool = True,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Review resolved clusters interactively, recording judgements.
|
|
29
|
+
|
|
30
|
+
Takes either a live `Resolver`, collected first if it isn't already, or the
|
|
31
|
+
*name* of a resolver already published in a store. The second form needs no plan
|
|
32
|
+
and no warehouse. Record values come from the extract cached when each source was
|
|
33
|
+
collected, which is the data the matching actually saw.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
review(companies, tag="session-1") # from a plan
|
|
37
|
+
review("companies", adapter=DuckDBAdapter("run.duckdb")) # from a store alone
|
|
38
|
+
review([naive, splink], tag="bakeoff") # judge both at once
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
resolver: The resolver whose clusters to review, or the name one was
|
|
43
|
+
published under. Pass several and you review their merged components, so
|
|
44
|
+
one session's judgements score every one of them.
|
|
45
|
+
n: How many clusters to hold in the queue at once.
|
|
46
|
+
adapter: Where judgements are stored, and where samples are drawn from.
|
|
47
|
+
Defaults to the module-level adapter.
|
|
48
|
+
tag: Tags every judgement made in this session, so a later
|
|
49
|
+
`EvalData(adapter, tag=...)` can score against just these.
|
|
50
|
+
seed: Fixes which clusters this session draws, so someone else with the same
|
|
51
|
+
store can review the same ones.
|
|
52
|
+
show_help: Show the key bindings on start.
|
|
53
|
+
"""
|
|
54
|
+
from matchlab.eval.tui.app import EntityResolutionApp # noqa: PLC0415 - lazy
|
|
55
|
+
|
|
56
|
+
EntityResolutionApp(
|
|
57
|
+
resolver=resolver,
|
|
58
|
+
num_samples=n,
|
|
59
|
+
adapter=adapter,
|
|
60
|
+
session_tag=tag,
|
|
61
|
+
seed=seed,
|
|
62
|
+
show_help=show_help,
|
|
63
|
+
).run()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["review"]
|