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/document.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Serialise a plan to a portable document, and rebuild it somewhere else.
|
|
2
|
+
|
|
3
|
+
A `PlanDocument` is a **derived view** of a plan, not a source format. Humans write
|
|
4
|
+
Python, and a document is what you hand to another environment so it can run the same
|
|
5
|
+
plan. It is dumped, transferred and loaded, never hand-authored. That is why nodes
|
|
6
|
+
refer to each other by position rather than by any human-facing name.
|
|
7
|
+
|
|
8
|
+
Nodes come out in `lineage.walk` order, so every input index is smaller than the index
|
|
9
|
+
of the step that consumes it. Positions also preserve structural sharing. A record step
|
|
10
|
+
feeding two models is one node referenced twice. Nesting each step's inputs inside it
|
|
11
|
+
would have inlined the whole subtree twice over instead.
|
|
12
|
+
|
|
13
|
+
**Edges live here, settings live in the spec.** That split is the point of this
|
|
14
|
+
module. A spec is hashed into a fingerprint, so it must carry everything that changes
|
|
15
|
+
the step's output and nothing else. A serialised plan must also carry the edges, which
|
|
16
|
+
a fingerprint already covers by folding in its parents' fingerprints. Putting both in
|
|
17
|
+
one model would have forced a choice: a spec that lied about identity, where a rename
|
|
18
|
+
invalidates a subtree that produces identical bytes, or a document that could not be
|
|
19
|
+
rebuilt.
|
|
20
|
+
|
|
21
|
+
Edges are not the only thing that lands on this side of the split. A source's
|
|
22
|
+
`LocationRef` says which location class to build and under what name, so that `load`
|
|
23
|
+
can attach a client. Neither fact changes a byte the source produces, which is why a
|
|
24
|
+
`SourceSpec` records nothing about where its rows came from.
|
|
25
|
+
|
|
26
|
+
**What a document cannot carry:**
|
|
27
|
+
|
|
28
|
+
* *Clients.* A document names the locations its sources read and stops there. `load`
|
|
29
|
+
takes the clients and attaches them by name, so credentials never enter a document.
|
|
30
|
+
* *Code.* `model_class` and `resolver_class` are registry names, so the target
|
|
31
|
+
environment must have the same classes registered (`add_model_class`). A document is
|
|
32
|
+
portable across environments, not across codebases.
|
|
33
|
+
* *Labels.* Publishing is something you do to a *result*, using `Resolver.publish`. It
|
|
34
|
+
is not part of the plan, so the receiving environment collects and then publishes
|
|
35
|
+
under whatever label it wants. The only names in a document are sources' names,
|
|
36
|
+
which are part of their output.
|
|
37
|
+
* *Data, or any hash of it.* A source's fingerprint folds in a content hash of what it
|
|
38
|
+
actually read, and that hash is derived on load from the target warehouse. Same rows
|
|
39
|
+
give the same fingerprints, so the target store hits cache instead of recomputing.
|
|
40
|
+
Different rows give different fingerprints, so it re-runs. Both are the intended
|
|
41
|
+
behaviour.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from collections.abc import Mapping
|
|
45
|
+
from typing import Any, TypeVar, cast
|
|
46
|
+
|
|
47
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
48
|
+
|
|
49
|
+
from matchlab.core.kinds import StepKind
|
|
50
|
+
from matchlab.lineage import walk
|
|
51
|
+
from matchlab.locations import build_location
|
|
52
|
+
from matchlab.models import Model
|
|
53
|
+
from matchlab.recordstep import RecordStep
|
|
54
|
+
from matchlab.resolvers import Resolver
|
|
55
|
+
from matchlab.sources import Source
|
|
56
|
+
from matchlab.specs import (
|
|
57
|
+
ModelSpec,
|
|
58
|
+
ResolverSpec,
|
|
59
|
+
SourceSpec,
|
|
60
|
+
TransformSpec,
|
|
61
|
+
)
|
|
62
|
+
from matchlab.steps import Step
|
|
63
|
+
from matchlab.transformers import Transform
|
|
64
|
+
|
|
65
|
+
StepSpec = SourceSpec | TransformSpec | ModelSpec | ResolverSpec
|
|
66
|
+
|
|
67
|
+
SpecT = TypeVar("SpecT", bound=BaseModel)
|
|
68
|
+
StepT = TypeVar("StepT", bound=Step)
|
|
69
|
+
|
|
70
|
+
_SPEC_TYPES: dict[StepKind, type[BaseModel]] = {
|
|
71
|
+
StepKind.SOURCE: SourceSpec,
|
|
72
|
+
StepKind.TRANSFORM: TransformSpec,
|
|
73
|
+
StepKind.MODEL: ModelSpec,
|
|
74
|
+
StepKind.RESOLVER: ResolverSpec,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LocationRef(BaseModel):
|
|
79
|
+
"""Everything needed to rebuild a source's location."""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(frozen=True)
|
|
82
|
+
|
|
83
|
+
location_class: str = Field(
|
|
84
|
+
description="The registered name of the Location subclass to build."
|
|
85
|
+
)
|
|
86
|
+
name: str = Field(
|
|
87
|
+
description="The key `load` looks up in `clients` to find this location's."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class StepNode(BaseModel):
|
|
92
|
+
"""One step: what kind it is, what it specifies, and what it reads."""
|
|
93
|
+
|
|
94
|
+
model_config = ConfigDict(frozen=True)
|
|
95
|
+
|
|
96
|
+
kind: StepKind = Field(description="Which kind of step this is.")
|
|
97
|
+
spec: StepSpec = Field(
|
|
98
|
+
description="This step's own settings, exactly what its fingerprint hashes."
|
|
99
|
+
)
|
|
100
|
+
inputs: tuple[int, ...] = Field(
|
|
101
|
+
default=(),
|
|
102
|
+
description=(
|
|
103
|
+
"Positions of this step's inputs, in order. Always smaller than this "
|
|
104
|
+
"step's own position, and order matters: it is the order the fingerprint "
|
|
105
|
+
"folds parents in, and the order a linker's left and right arrive in."
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
location: LocationRef | None = Field(
|
|
109
|
+
default=None,
|
|
110
|
+
description=(
|
|
111
|
+
"For a source, how to rebuild the location it reads. Here rather than in "
|
|
112
|
+
"the spec because it describes reconstruction rather than output. See "
|
|
113
|
+
"`LocationRef`. Every other kind of step leaves it unset."
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@model_validator(mode="after")
|
|
118
|
+
def _check_location(self) -> "StepNode":
|
|
119
|
+
"""A source names a location. Nothing else has one to name."""
|
|
120
|
+
if self.kind is StepKind.SOURCE and self.location is None:
|
|
121
|
+
raise ValueError("A source step must name the location it reads.")
|
|
122
|
+
if self.kind is not StepKind.SOURCE and self.location is not None:
|
|
123
|
+
raise ValueError(f"A {self.kind} step must not name a location.")
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
@model_validator(mode="before")
|
|
127
|
+
@classmethod
|
|
128
|
+
def _spec_from_kind(cls, data: Any) -> Any: # noqa: ANN401 - raw pydantic input
|
|
129
|
+
"""Parse `spec` as the type `kind` names, rather than guessing.
|
|
130
|
+
|
|
131
|
+
The specs are structurally close enough that a plain union mis-assigns. A
|
|
132
|
+
`TransformSpec` and a `ModelSpec` overlap once optional settings default, so a
|
|
133
|
+
union could bind the wrong one. `kind` is the discriminator the document holds.
|
|
134
|
+
"""
|
|
135
|
+
if not isinstance(data, dict):
|
|
136
|
+
return data
|
|
137
|
+
# `kind` is still whatever the input carried, a plain string, from JSON. It
|
|
138
|
+
# finds its entry anyway, because a `StrEnum` member hashes and compares as
|
|
139
|
+
# its value.
|
|
140
|
+
kind, spec = data.get("kind"), data.get("spec")
|
|
141
|
+
if isinstance(spec, dict) and kind in _SPEC_TYPES:
|
|
142
|
+
return {**data, "spec": _SPEC_TYPES[kind].model_validate(spec)}
|
|
143
|
+
return data
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class PlanDocument(BaseModel):
|
|
147
|
+
"""A whole plan: its steps in topological order, and the edges between them."""
|
|
148
|
+
|
|
149
|
+
model_config = ConfigDict(frozen=True)
|
|
150
|
+
|
|
151
|
+
steps: tuple[StepNode, ...] = Field(
|
|
152
|
+
description=(
|
|
153
|
+
"Every step reachable from the plan's apex, inputs before consumers."
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@model_validator(mode="after")
|
|
158
|
+
def _check_edges(self) -> "PlanDocument":
|
|
159
|
+
"""Reject a document whose edges do not point backwards at real steps."""
|
|
160
|
+
for position, node in enumerate(self.steps):
|
|
161
|
+
for target in node.inputs:
|
|
162
|
+
if not 0 <= target < position:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"Step {position} ({node.kind}) reads step {target}, which is "
|
|
165
|
+
"not an earlier step. Inputs must precede their consumers."
|
|
166
|
+
)
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def dump(root: Step) -> PlanDocument:
|
|
171
|
+
"""Describe `root` and everything it reads as a portable document.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
root: The apex of the plan. Only what is reachable upstream of it is included,
|
|
175
|
+
exactly as `collect()` would run.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The document. It holds no client, no credentials and no data.
|
|
179
|
+
"""
|
|
180
|
+
ordered = walk(root)
|
|
181
|
+
position = {id(step): index for index, step in enumerate(ordered)}
|
|
182
|
+
return PlanDocument(
|
|
183
|
+
steps=tuple(
|
|
184
|
+
_node(step, tuple(position[id(parent)] for parent in step.upstream))
|
|
185
|
+
for step in ordered
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _node(step: Step, inputs: tuple[int, ...]) -> StepNode:
|
|
191
|
+
"""Describe one step, checking its spec is the one its kind implies."""
|
|
192
|
+
spec_type = _SPEC_TYPES.get(step.kind)
|
|
193
|
+
if spec_type is None or not isinstance(step.spec, spec_type):
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"A {step.kind} step has no matching spec type. A new kind of step "
|
|
196
|
+
"needs an entry in `_SPEC_TYPES`."
|
|
197
|
+
)
|
|
198
|
+
return StepNode(
|
|
199
|
+
kind=step.kind,
|
|
200
|
+
spec=cast(StepSpec, step.spec),
|
|
201
|
+
inputs=inputs,
|
|
202
|
+
location=(
|
|
203
|
+
LocationRef(
|
|
204
|
+
location_class=type(step.location).__name__, name=step.location.name
|
|
205
|
+
)
|
|
206
|
+
if isinstance(step, Source)
|
|
207
|
+
else None
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def load(document: PlanDocument, clients: Mapping[str, Any]) -> Step:
|
|
213
|
+
"""Rebuild a plan from a document, returning its apex.
|
|
214
|
+
|
|
215
|
+
Nothing is collected. This reconstructs the same lazy plan, so the returned step
|
|
216
|
+
fingerprints identically to the one that was dumped, given the same data.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
document: A dumped plan.
|
|
220
|
+
clients: Location name to client, for every location the document's sources
|
|
221
|
+
name. A `RelationalDBLocation` takes a SQLAlchemy engine or an ADBC
|
|
222
|
+
connection.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The plan's apex, the last step in the document.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ValueError: If the document is empty, names a location with no client or an
|
|
229
|
+
unregistered location class, or wires a step to an input of the wrong kind.
|
|
230
|
+
"""
|
|
231
|
+
if not document.steps:
|
|
232
|
+
raise ValueError("A plan document needs at least one step")
|
|
233
|
+
|
|
234
|
+
built: list[Step] = []
|
|
235
|
+
for position, node in enumerate(document.steps):
|
|
236
|
+
built.append(_rebuild(node, position, [built[i] for i in node.inputs], clients))
|
|
237
|
+
return built[-1]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _rebuild(
|
|
241
|
+
node: StepNode, position: int, inputs: list[Step], clients: Mapping[str, Any]
|
|
242
|
+
) -> Step:
|
|
243
|
+
"""Rebuild one step from its node and its already-rebuilt inputs."""
|
|
244
|
+
match node.kind:
|
|
245
|
+
case StepKind.SOURCE:
|
|
246
|
+
spec = _expect(node, position, SourceSpec)
|
|
247
|
+
reference = cast(LocationRef, node.location)
|
|
248
|
+
if reference.name not in clients:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
f"Source '{spec.name}' reads location '{reference.name}', which "
|
|
251
|
+
f"has no client. Pass one in `clients`: "
|
|
252
|
+
f"{{'{reference.name}': engine}}."
|
|
253
|
+
)
|
|
254
|
+
return Source(
|
|
255
|
+
location=build_location(
|
|
256
|
+
reference.location_class,
|
|
257
|
+
name=reference.name,
|
|
258
|
+
client=clients[reference.name],
|
|
259
|
+
),
|
|
260
|
+
name=spec.name,
|
|
261
|
+
extract_transform=spec.extract_transform,
|
|
262
|
+
key_field=spec.key_field,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
case StepKind.TRANSFORM:
|
|
266
|
+
spec = _expect(node, position, TransformSpec)
|
|
267
|
+
record_steps = _all_of(node, position, inputs, RecordStep)
|
|
268
|
+
if len(record_steps) != 1:
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"Step {position} (transform) reads {len(record_steps)} record "
|
|
271
|
+
"steps; a transform reshapes exactly one."
|
|
272
|
+
)
|
|
273
|
+
return Transform(
|
|
274
|
+
record_steps[0],
|
|
275
|
+
transformer=spec.transformer_class,
|
|
276
|
+
transformer_settings=spec.transformer_settings,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
case StepKind.MODEL:
|
|
280
|
+
spec = _expect(node, position, ModelSpec)
|
|
281
|
+
record_steps = _all_of(node, position, inputs, RecordStep)
|
|
282
|
+
if not 1 <= len(record_steps) <= 2:
|
|
283
|
+
raise ValueError(
|
|
284
|
+
f"Step {position} (model) reads {len(record_steps)} record "
|
|
285
|
+
"steps; a deduper reads one and a linker two."
|
|
286
|
+
)
|
|
287
|
+
return Model(
|
|
288
|
+
left=record_steps[0],
|
|
289
|
+
right=record_steps[1] if len(record_steps) > 1 else None,
|
|
290
|
+
model_class=spec.model_class,
|
|
291
|
+
model_settings=spec.model_settings,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
case StepKind.RESOLVER:
|
|
295
|
+
spec = _expect(node, position, ResolverSpec)
|
|
296
|
+
return Resolver(
|
|
297
|
+
*_all_of(node, position, inputs, Model),
|
|
298
|
+
resolver_class=spec.resolver_class,
|
|
299
|
+
resolver_settings=spec.resolver_settings,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
case _: # every member of StepKind is handled above
|
|
303
|
+
raise ValueError(
|
|
304
|
+
f"No rebuild for a {node.kind} step. A new kind of step needs an arm "
|
|
305
|
+
"here as well as an entry in `_SPEC_TYPES`."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _expect(node: StepNode, position: int, spec_type: type[SpecT]) -> SpecT:
|
|
310
|
+
"""Narrow a node's spec to the type its kind implies."""
|
|
311
|
+
if not isinstance(node.spec, spec_type):
|
|
312
|
+
raise ValueError( # the validator parses spec by kind
|
|
313
|
+
f"Step {position} ({node.kind}) has a "
|
|
314
|
+
f"{type(node.spec).__name__}, expected {spec_type.__name__}."
|
|
315
|
+
)
|
|
316
|
+
return node.spec
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _all_of(
|
|
320
|
+
node: StepNode, position: int, inputs: list[Step], step_type: type[StepT]
|
|
321
|
+
) -> tuple[StepT, ...]:
|
|
322
|
+
"""Assert every input is the kind this step can read, and return them in order."""
|
|
323
|
+
checked = tuple(step for step in inputs if isinstance(step, step_type))
|
|
324
|
+
if len(checked) != len(inputs):
|
|
325
|
+
raise ValueError(
|
|
326
|
+
f"Step {position} ({node.kind}) must read only "
|
|
327
|
+
f"{step_type.__name__.lower()}s, got {[step.kind for step in inputs]}."
|
|
328
|
+
)
|
|
329
|
+
return checked
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Representation of human judgements."""
|
|
2
|
+
|
|
3
|
+
from itertools import chain
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, model_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Judgement(BaseModel):
|
|
10
|
+
"""A user's decision on how one reviewed cluster's leaves split into entities."""
|
|
11
|
+
|
|
12
|
+
tag: str | None = None
|
|
13
|
+
shown: list[int] = Field(
|
|
14
|
+
description="Leaf IDs of the whole cluster shown to the user"
|
|
15
|
+
)
|
|
16
|
+
endorsed: list[list[int]] = Field(
|
|
17
|
+
description="Leaf IDs the user grouped together, one list per endorsed entity"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
@model_validator(mode="after")
|
|
21
|
+
def check_no_duplicates(self) -> Self:
|
|
22
|
+
"""Reject leaf IDs repeated within `shown`, or within `endorsed`."""
|
|
23
|
+
if len(self.shown) != len(set(self.shown)):
|
|
24
|
+
raise ValueError("One or more leaf IDs were repeated in the shown data")
|
|
25
|
+
concat_ids = list(chain(*self.endorsed))
|
|
26
|
+
if len(concat_ids) != len(set(concat_ids)):
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"One or more leaf IDs were repeated in the endorsement data"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
@model_validator(mode="after")
|
|
34
|
+
def check_consistency(self) -> Self:
|
|
35
|
+
"""Check that `endorsed`'s leaves are exactly the ones in `shown`."""
|
|
36
|
+
all_shown = set(self.shown)
|
|
37
|
+
all_endorsed = set(chain(*self.endorsed))
|
|
38
|
+
if all_shown != all_endorsed:
|
|
39
|
+
raise ValueError("Inconsistent leaf IDs between shown and endorsed")
|
|
40
|
+
|
|
41
|
+
return self
|
matchlab/eval/metrics.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Score a resolver's clusters against stored human judgements."""
|
|
2
|
+
|
|
3
|
+
from itertools import chain, combinations
|
|
4
|
+
from typing import TypeAlias
|
|
5
|
+
|
|
6
|
+
import polars as pl
|
|
7
|
+
|
|
8
|
+
Pair: TypeAlias = tuple[int, int]
|
|
9
|
+
Pairs: TypeAlias = set[Pair]
|
|
10
|
+
|
|
11
|
+
PrecisionRecall: TypeAlias = tuple[float, float]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def precision_recall(
|
|
15
|
+
models_root_leaf: list[pl.DataFrame],
|
|
16
|
+
judgements: pl.DataFrame,
|
|
17
|
+
expansion: pl.DataFrame,
|
|
18
|
+
) -> list[PrecisionRecall]:
|
|
19
|
+
"""Compute precision and recall for each model's clusters against judgements.
|
|
20
|
+
|
|
21
|
+
The function turns both model clusters and judgements into pairs of leaves, then
|
|
22
|
+
compares them:
|
|
23
|
+
|
|
24
|
+
- Convert each model's clusters and the judgements into pairwise connections
|
|
25
|
+
between leaves. For judgements, this includes pairs the user was shown but
|
|
26
|
+
rejected, not just the ones they endorsed. Each pair's net score is the number
|
|
27
|
+
of times it was endorsed, minus the number of times it was rejected.
|
|
28
|
+
- Keep only pairs whose leaves appear in every model and in the judgements, so
|
|
29
|
+
each model is compared over the same data.
|
|
30
|
+
- Drop a pair from both model and validation pairs if it was rejected as often as
|
|
31
|
+
it was endorsed. If it was rejected more often than endorsed, drop it from
|
|
32
|
+
validation pairs only, and keep it in model pairs.
|
|
33
|
+
- Compute precision and recall for each model against the remaining validation
|
|
34
|
+
pairs.
|
|
35
|
+
|
|
36
|
+
This ignores user IDs, so judgements are not yet weighted by reviewer.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
models_root_leaf: One `(root, leaf)` table per model. Each must be
|
|
40
|
+
merge-forward, including every leaf reachable from the model's inputs,
|
|
41
|
+
even ones no model touched, which appear as their own root.
|
|
42
|
+
judgements: Table following `SCHEMA_JUDGEMENTS`.
|
|
43
|
+
expansion: Table following `SCHEMA_CLUSTER_EXPANSION`.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
One `(precision, recall)` pair per model, in the order given.
|
|
47
|
+
"""
|
|
48
|
+
leaves_per_set: list[set[int]] = [] # one entry for each model, one for judgements
|
|
49
|
+
pairs_per_model: list[Pairs] = []
|
|
50
|
+
|
|
51
|
+
if not len(judgements):
|
|
52
|
+
raise ValueError("Judgements data cannot be empty.")
|
|
53
|
+
|
|
54
|
+
# Process models and judgements
|
|
55
|
+
for root_leaf in models_root_leaf:
|
|
56
|
+
if not len(root_leaf):
|
|
57
|
+
raise ValueError("Model data cannot be empty.")
|
|
58
|
+
leaves_per_set.append(set(root_leaf["leaf"].to_list()))
|
|
59
|
+
clusters = (
|
|
60
|
+
root_leaf.group_by("root")
|
|
61
|
+
.agg(pl.col("leaf").alias("leaves"))
|
|
62
|
+
.select("leaves")
|
|
63
|
+
.to_series()
|
|
64
|
+
.to_list()
|
|
65
|
+
)
|
|
66
|
+
model_pairs = set()
|
|
67
|
+
for c in clusters:
|
|
68
|
+
model_pairs.update(combinations(sorted(c), r=2))
|
|
69
|
+
pairs_per_model.append(model_pairs)
|
|
70
|
+
|
|
71
|
+
validation_pairs, validation_net_count, validation_leaves = process_judgements(
|
|
72
|
+
judgements, expansion
|
|
73
|
+
)
|
|
74
|
+
leaves_per_set.append(validation_leaves)
|
|
75
|
+
|
|
76
|
+
# Filter pairs to the leaves shared by every model and the judgements. For
|
|
77
|
+
# example, if model1 has (1,2), (1,3), (2,3), model2 has (1,10), (2,20), and
|
|
78
|
+
# judgements have (1), (2,3), only (1,2) is kept.
|
|
79
|
+
shared_leaves = set.intersection(*leaves_per_set)
|
|
80
|
+
|
|
81
|
+
for i, model_pairs in enumerate(pairs_per_model):
|
|
82
|
+
pairs_per_model[i] = {
|
|
83
|
+
(a, b)
|
|
84
|
+
for a, b in model_pairs
|
|
85
|
+
if (
|
|
86
|
+
a in shared_leaves
|
|
87
|
+
and b in shared_leaves
|
|
88
|
+
# Remove neutrally-judged pairs from the model
|
|
89
|
+
and validation_net_count[(a, b)] != 0
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
validation_pairs = {
|
|
94
|
+
(a, b)
|
|
95
|
+
for a, b in validation_pairs
|
|
96
|
+
if a in shared_leaves
|
|
97
|
+
and b in shared_leaves
|
|
98
|
+
# Remove all neutrally or negatively judged pairs
|
|
99
|
+
and validation_net_count[(a, b)] > 0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if not validation_pairs:
|
|
103
|
+
raise ValueError("Validation data has no pairs to evaluate.")
|
|
104
|
+
|
|
105
|
+
# Compute PR scores for each model
|
|
106
|
+
pr_scores: list[PrecisionRecall] = []
|
|
107
|
+
for i, model_pairs in enumerate(pairs_per_model):
|
|
108
|
+
true_positive_pairs = model_pairs & validation_pairs
|
|
109
|
+
if not model_pairs:
|
|
110
|
+
raise ValueError(f"Model at index {i} has no pairs to evaluate.")
|
|
111
|
+
pr_scores.append(
|
|
112
|
+
(
|
|
113
|
+
len(true_positive_pairs) / len(model_pairs),
|
|
114
|
+
len(true_positive_pairs) / len(validation_pairs),
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return pr_scores
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def process_judgements(
|
|
122
|
+
judgements: pl.DataFrame, expansion: pl.DataFrame
|
|
123
|
+
) -> tuple[Pairs, dict[Pair, float], set[int]]:
|
|
124
|
+
"""Turn judgements into leaf pairs, a net score per pair, and the leaves shown.
|
|
125
|
+
|
|
126
|
+
Expanding a cluster's leaves into every sorted pair is not quite enough on its
|
|
127
|
+
own. When a user is shown (123) and endorses (12), they also reject pairs (1,3)
|
|
128
|
+
and (2,3), not just endorse (1,2). Each pair's net score sums +1 for every
|
|
129
|
+
endorsement and -1 for every rejection implied this way.
|
|
130
|
+
|
|
131
|
+
This function assumes well-formed input:
|
|
132
|
+
|
|
133
|
+
- Every shown cluster ID has a matching row in `expansion`.
|
|
134
|
+
- Every endorsed cluster ID has a matching row in `expansion`, unless it is a
|
|
135
|
+
single leaf.
|
|
136
|
+
- No cluster is split across judgement rows unless every other part is expanded
|
|
137
|
+
too. For example, if (123)->(12) appears, (123)->(3) must appear too.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
judgements: Table following `SCHEMA_JUDGEMENTS`.
|
|
141
|
+
expansion: Table following `SCHEMA_CLUSTER_EXPANSION`.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
A tuple of:
|
|
145
|
+
|
|
146
|
+
- Every pair implied by an endorsement or a rejection.
|
|
147
|
+
- Each pair's net score, where positive means endorsed more than rejected.
|
|
148
|
+
- Every leaf ID shown to a user.
|
|
149
|
+
"""
|
|
150
|
+
expanded_judgements = (
|
|
151
|
+
judgements.join(expansion, left_on="shown", right_on="root")
|
|
152
|
+
.rename({"leaves": "shown_leaves"})
|
|
153
|
+
.join(
|
|
154
|
+
expansion, left_on="endorsed", right_on="root", how="left"
|
|
155
|
+
) # left join as singleton leaves won't be expanded
|
|
156
|
+
.rename({"leaves": "endorsed_leaves"})
|
|
157
|
+
# Assume a missing expansion means a singleton leaf
|
|
158
|
+
.with_columns(
|
|
159
|
+
pl.when(pl.col("endorsed_leaves").is_null())
|
|
160
|
+
.then(
|
|
161
|
+
pl.col("endorsed").map_elements(
|
|
162
|
+
lambda x: [x], return_dtype=pl.List(pl.UInt64)
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
.otherwise(pl.col("endorsed_leaves"))
|
|
166
|
+
.alias("endorsed_leaves")
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
all_leaves = set(
|
|
171
|
+
chain.from_iterable(expanded_judgements["endorsed_leaves"].to_list())
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if len(expanded_judgements) != len(judgements):
|
|
175
|
+
raise ValueError("Malformed judgements / expansion data received.")
|
|
176
|
+
|
|
177
|
+
validation_net_count: dict[Pair, float] = {}
|
|
178
|
+
|
|
179
|
+
for row in expanded_judgements.rows(named=True):
|
|
180
|
+
shown = row["shown_leaves"]
|
|
181
|
+
endorsed = row["endorsed_leaves"]
|
|
182
|
+
|
|
183
|
+
positive_pairs = set(combinations(sorted(endorsed), r=2))
|
|
184
|
+
potential_pairs = set(combinations(sorted(shown), r=2))
|
|
185
|
+
negative_pairs = potential_pairs - positive_pairs
|
|
186
|
+
|
|
187
|
+
# -- WORKED EXAMPLE --
|
|
188
|
+
# A user is shown cluster (1234) and endorses three groups: (1), (23), (4).
|
|
189
|
+
# This makes pair (2,3) good, and (1,2), (1,3), (1,4), (2,4), (3,4) bad.
|
|
190
|
+
# Target result: +1 for (2,3), -1 for every bad pair.
|
|
191
|
+
|
|
192
|
+
# The data does not arrive as one row holding the whole judgement. It arrives
|
|
193
|
+
# as one row per endorsed group, and these rows can be mixed with other users'
|
|
194
|
+
# judgements:
|
|
195
|
+
# Row A: shown (1234), endorsed (1)
|
|
196
|
+
# Row B: shown (1234), endorsed (23)
|
|
197
|
+
# Row C: shown (1234), endorsed (4)
|
|
198
|
+
# Each row below is scored on its own. The partial scores still add up to the
|
|
199
|
+
# right total, whatever order the rows arrive in.
|
|
200
|
+
|
|
201
|
+
# Row A: shown (1234), endorsed (1)
|
|
202
|
+
# - Potential pairs: (1,2) (1,3) (1,4) (2,3) (2,4) (3,4).
|
|
203
|
+
# - Endorsed pairs: none. A single item forms no pair.
|
|
204
|
+
# - Every potential pair is tentatively rejected, weight -1/4, because this
|
|
205
|
+
# endorsed group holds 1 of the 4 shown items.
|
|
206
|
+
# - Running scores: all pairs = -0.25.
|
|
207
|
+
|
|
208
|
+
# Row B: shown (1234), endorsed (23)
|
|
209
|
+
# - Endorsed pair (2,3) gets +1, plus +2/4 to cancel the rejection weight it
|
|
210
|
+
# took in rows A and C, the non-endorsed share of this group.
|
|
211
|
+
# Total for (2,3): +1 + 0.5 = +1.5.
|
|
212
|
+
# - Remaining rejected pairs take a further -2/4 = -0.5.
|
|
213
|
+
# - Running scores: (2,3) = -0.25 + 1.5 = +1.25, others = -0.25 - 0.5 = -0.75.
|
|
214
|
+
|
|
215
|
+
# Row C: shown (1234), endorsed (4)
|
|
216
|
+
# - Final -1/4 rejection weight lands on every potential pair.
|
|
217
|
+
# - Final scores: (2,3) = +1.25 - 0.25 = +1.0, others = -0.75 - 0.25 = -1.0.
|
|
218
|
+
|
|
219
|
+
# Result: (2,3) nets +1, matching the endorsement, and every rejected pair
|
|
220
|
+
# nets -1, whatever order the rows arrive in or however they interleave with
|
|
221
|
+
# other judgements, as long as no cluster is split without expanding its
|
|
222
|
+
# other part too.
|
|
223
|
+
|
|
224
|
+
# Subtract negative weight for all shown pairs not endorsed on this row
|
|
225
|
+
negative_adjustment = len(endorsed) / len(shown)
|
|
226
|
+
validation_net_count.update(
|
|
227
|
+
{
|
|
228
|
+
p: validation_net_count.get(p, 0) - negative_adjustment
|
|
229
|
+
for p in negative_pairs
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Add 1 plus positive weight for all pairs endorsed on this row
|
|
234
|
+
positive_adjustment = 1 + ((len(shown) - len(endorsed)) / len(shown))
|
|
235
|
+
validation_net_count.update(
|
|
236
|
+
{
|
|
237
|
+
p: validation_net_count.get(p, 0) + positive_adjustment
|
|
238
|
+
for p in positive_pairs
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
all_pairs = set(validation_net_count.keys())
|
|
243
|
+
|
|
244
|
+
return all_pairs, validation_net_count, all_leaves
|