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/recordstep.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""RecordStep, the records a model matches over, and the verbs that build a plan.
|
|
2
|
+
|
|
3
|
+
A `RecordStep` is a step whose artifact is a table of records carrying an `id`.
|
|
4
|
+
`Source`, `Resolver` and `Transform` are the three kinds. On a `Source`, `id` is the
|
|
5
|
+
leaf. On a `Resolver`, `id` is the entity root. Every `RecordStep` chains the same
|
|
6
|
+
verbs (`select`, `clean`, `group`, `transform`, `dedupe`, `link`). A `Model` reads a
|
|
7
|
+
`RecordStep` and yields edges, not records.
|
|
8
|
+
|
|
9
|
+
`RecordStep` is not user-facing. Users hold a `Source`, a `Resolver` or a `Transform`
|
|
10
|
+
and call verbs on it. Each concrete kind supplies only how it materialises
|
|
11
|
+
(`_read_cache`) and which source rows it stands for (`_identifier_reads`).
|
|
12
|
+
|
|
13
|
+
A record step does not store `identifiers()`, the `(id, source, key, leaf)` mapping a
|
|
14
|
+
downstream resolver needs. Reshaping cannot change it, so it is read back from the
|
|
15
|
+
source leaves and the upstream resolver output instead.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from abc import abstractmethod
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
import polars as pl
|
|
22
|
+
|
|
23
|
+
from matchlab.adapters import Adapter, Fingerprint
|
|
24
|
+
from matchlab.core.dataframes import (
|
|
25
|
+
DataFrameClass,
|
|
26
|
+
DataFrameType,
|
|
27
|
+
qualify,
|
|
28
|
+
to_dataframe,
|
|
29
|
+
)
|
|
30
|
+
from matchlab.core.sql import SQLExpression
|
|
31
|
+
from matchlab.steps import Step
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
# Each of these modules imports this one. Only for annotations, so importing the
|
|
35
|
+
# real classes (rather than typing them `Any`) costs nothing at runtime.
|
|
36
|
+
from matchlab.models import Model
|
|
37
|
+
from matchlab.models.dedupers.base import Deduper, DeduperSettings
|
|
38
|
+
from matchlab.models.linkers.base import Linker, LinkerSettings
|
|
39
|
+
from matchlab.resolvers import Resolver
|
|
40
|
+
from matchlab.sources import Source
|
|
41
|
+
from matchlab.transformers import Transform, Transformer
|
|
42
|
+
|
|
43
|
+
# What one source's identifiers are read with, `(source_fp, source_name, resolver_fp)`,
|
|
44
|
+
# the arguments of `Adapter.read_identifiers`. Deduplicating these is deduplicating the
|
|
45
|
+
# queries, which is why they travel as a value rather than as a call.
|
|
46
|
+
IdentifierRead = tuple[Fingerprint | None, str, Fingerprint | None]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_record_step(
|
|
50
|
+
adapter: Adapter,
|
|
51
|
+
sources: "tuple[Source, ...]",
|
|
52
|
+
resolver: "Resolver | None",
|
|
53
|
+
) -> pl.DataFrame:
|
|
54
|
+
"""Assemble the `id` + qualified-columns table from stored extracts and identifiers.
|
|
55
|
+
|
|
56
|
+
Every source's extract is prefixed with the source name (`company` → `crn_company`)
|
|
57
|
+
and joined to its identifiers, so each row gains the `id` it belongs to. That is the
|
|
58
|
+
record's leaf when read directly, or the entity root when read through `resolver`.
|
|
59
|
+
Several sources are concatenated diagonally, each row carrying its own source's
|
|
60
|
+
columns and nulls for the rest. This is what both `Source` and `Resolver` read.
|
|
61
|
+
"""
|
|
62
|
+
resolver_fp = resolver._fp if resolver is not None else None
|
|
63
|
+
identifiers = pl.concat(
|
|
64
|
+
[
|
|
65
|
+
adapter.read_identifiers(source._fp, source.name, resolver_fp)
|
|
66
|
+
for source in sources
|
|
67
|
+
],
|
|
68
|
+
how="vertical",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
extracts = [
|
|
72
|
+
adapter.read_source_extract(source._fp)
|
|
73
|
+
.select(pl.all().name.prefix(qualify(source.name)))
|
|
74
|
+
.with_columns(pl.lit(source.name).alias("source"))
|
|
75
|
+
.rename({source.qualified_key: "key"})
|
|
76
|
+
for source in sources
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
pl.concat(extracts, how="diagonal")
|
|
81
|
+
.join(identifiers.drop("leaf"), how="inner", on=("source", "key"))
|
|
82
|
+
.drop("source", "key")
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RecordStep(Step):
|
|
87
|
+
"""A step whose artifact is a table of records a model matches over."""
|
|
88
|
+
|
|
89
|
+
# -- RecordStep contract -------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def _read_cache(self, adapter: Adapter) -> pl.DataFrame:
|
|
93
|
+
"""Return this record step's records from wherever they are materialised."""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def _identifier_reads(self) -> tuple[IdentifierRead, ...]:
|
|
99
|
+
"""The `Adapter.read_identifiers` arguments this record step's data comes from.
|
|
100
|
+
|
|
101
|
+
One per source, naming what is read rather than reading it. Two record steps
|
|
102
|
+
built from the same source and resolver read identical rows, even if they then
|
|
103
|
+
reshape those rows differently. A downstream resolver wants the *set* of these
|
|
104
|
+
reads across every record step feeding it, so naming them lets it ask once
|
|
105
|
+
instead of once per record step.
|
|
106
|
+
"""
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
def identifiers(self, adapter: Adapter) -> pl.DataFrame:
|
|
110
|
+
"""Return `(id, source, key, leaf)` for every record this record step reads.
|
|
111
|
+
|
|
112
|
+
`id` is the resolver's entity root when reading through one, otherwise the
|
|
113
|
+
source leaf. This is the upstream resolver output a downstream resolver needs to
|
|
114
|
+
carry every reachable leaf forward, including records no model matched.
|
|
115
|
+
"""
|
|
116
|
+
return pl.concat(
|
|
117
|
+
[adapter.read_identifiers(*read) for read in self._identifier_reads],
|
|
118
|
+
how="vertical",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def data(self, return_type: DataFrameType = DataFrameType.POLARS) -> DataFrameClass:
|
|
122
|
+
"""Return this record step's records, collecting the plan first if needed."""
|
|
123
|
+
if not self.is_collected:
|
|
124
|
+
self.collect()
|
|
125
|
+
return to_dataframe(self._read_cache(self._require_adapter()), return_type)
|
|
126
|
+
|
|
127
|
+
# -- verbs ------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def transform(
|
|
130
|
+
self,
|
|
131
|
+
transformer: "Transformer | type[Transformer] | str",
|
|
132
|
+
transformer_settings: dict | None = None,
|
|
133
|
+
) -> "Transform":
|
|
134
|
+
"""Reshape this record step with a transformer."""
|
|
135
|
+
from matchlab.transformers import Transform # noqa: PLC0415 - avoids a cycle
|
|
136
|
+
|
|
137
|
+
return Transform(self, transformer, transformer_settings)
|
|
138
|
+
|
|
139
|
+
def select(self, *columns: str) -> "Transform":
|
|
140
|
+
"""Keep only the named columns, plus `id`."""
|
|
141
|
+
from matchlab.transformers import Select # noqa: PLC0415 - avoids a cycle
|
|
142
|
+
|
|
143
|
+
return self.transform(Select(*columns))
|
|
144
|
+
|
|
145
|
+
def clean(self, cleaning: dict[str, SQLExpression]) -> "Transform":
|
|
146
|
+
"""Derive columns with DuckDB SQL, keeping the rest."""
|
|
147
|
+
from matchlab.transformers import Clean # noqa: PLC0415 - avoids a cycle
|
|
148
|
+
|
|
149
|
+
return self.transform(Clean(cleaning=cleaning))
|
|
150
|
+
|
|
151
|
+
def group(self, aggregates: dict[str, SQLExpression]) -> "Transform":
|
|
152
|
+
"""Collapse each `id` to one row using aggregate SQL."""
|
|
153
|
+
from matchlab.transformers import Group # noqa: PLC0415 - avoids a cycle
|
|
154
|
+
|
|
155
|
+
return self.transform(Group(aggregates=aggregates))
|
|
156
|
+
|
|
157
|
+
def dedupe(
|
|
158
|
+
self,
|
|
159
|
+
model_class: "type[Deduper] | str",
|
|
160
|
+
model_settings: "DeduperSettings | dict",
|
|
161
|
+
) -> "Model":
|
|
162
|
+
"""Deduplicate this record step."""
|
|
163
|
+
from matchlab.models import Model # noqa: PLC0415 - avoids a cycle
|
|
164
|
+
|
|
165
|
+
return Model(left=self, model_class=model_class, model_settings=model_settings)
|
|
166
|
+
|
|
167
|
+
def link(
|
|
168
|
+
self,
|
|
169
|
+
other: "RecordStep",
|
|
170
|
+
model_class: "type[Linker] | str",
|
|
171
|
+
model_settings: "LinkerSettings | dict",
|
|
172
|
+
) -> "Model":
|
|
173
|
+
"""Link this record step to another. A `Source` is one, needing no wrapping."""
|
|
174
|
+
from matchlab.models import Model # noqa: PLC0415 - avoids a cycle
|
|
175
|
+
|
|
176
|
+
return Model(
|
|
177
|
+
left=self,
|
|
178
|
+
right=other,
|
|
179
|
+
model_class=model_class,
|
|
180
|
+
model_settings=model_settings,
|
|
181
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Base classes for resolver methodologies."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import ClassVar, Final
|
|
7
|
+
|
|
8
|
+
import polars as pl
|
|
9
|
+
import pyarrow as pa
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
|
|
12
|
+
SCHEMA_CLUSTERS: Final[pa.Schema] = pa.schema(
|
|
13
|
+
[
|
|
14
|
+
("parent_id", pa.uint64()),
|
|
15
|
+
("child_id", pa.uint64()),
|
|
16
|
+
]
|
|
17
|
+
)
|
|
18
|
+
"""A resolver methodology's cluster assignments take this shape."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ResolverType(StrEnum):
|
|
22
|
+
"""Enumeration of supported resolver methodology types.
|
|
23
|
+
|
|
24
|
+
This lives with `ResolverMethod`, not in `matchlab.specs`, because it is not part
|
|
25
|
+
of any spec. A `ResolverSpec` identifies a methodology only by its registered
|
|
26
|
+
`resolver_class`, so a `ResolverType` never reaches a fingerprint or a document.
|
|
27
|
+
It is just a class attribute methodologies use to declare which family they
|
|
28
|
+
belong to, and nothing more.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
COMPONENTS = "components"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResolverSettings(BaseModel, ABC):
|
|
35
|
+
"""Base settings type for resolver methodologies.
|
|
36
|
+
|
|
37
|
+
Settings that point at one of the resolver's inputs refer to it by **position**.
|
|
38
|
+
`Resolver` accepts the `Model` object at the API and translates it. Nothing here
|
|
39
|
+
holds a plan node. A methodology stays a pure function of edges and numbers.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ResolverMethod(BaseModel, ABC):
|
|
46
|
+
"""Base class for resolver methodologies."""
|
|
47
|
+
|
|
48
|
+
resolver_type: ClassVar[ResolverType]
|
|
49
|
+
settings: ResolverSettings
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def compute_clusters(self, model_edges: Mapping[int, pl.DataFrame]) -> pl.DataFrame:
|
|
53
|
+
"""Compute cluster assignments from model edges.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
model_edges: Input position to that model's edges, conforming to
|
|
57
|
+
SCHEMA_MODEL_EDGES. Positions index the resolver's inputs, in the
|
|
58
|
+
order they were given, and are what per-model settings key by.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
A Polars DataFrame which conforms to SCHEMA_CLUSTERS
|
|
62
|
+
"""
|
|
63
|
+
...
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Connected-components resolver methodology."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Annotated, ClassVar
|
|
5
|
+
|
|
6
|
+
import polars as pl
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from matchlab.core.dsu import DisjointSet
|
|
10
|
+
from matchlab.resolvers.base import (
|
|
11
|
+
SCHEMA_CLUSTERS,
|
|
12
|
+
ResolverMethod,
|
|
13
|
+
ResolverSettings,
|
|
14
|
+
ResolverType,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ComponentsSettings(ResolverSettings):
|
|
19
|
+
"""Settings for the Components resolver methodology."""
|
|
20
|
+
|
|
21
|
+
thresholds: dict[int, Annotated[float, Field(ge=0.0, le=1.0)]] = Field(
|
|
22
|
+
default_factory=dict,
|
|
23
|
+
description=(
|
|
24
|
+
"Minimum score for an edge to count, per input, keyed by the input's "
|
|
25
|
+
"position. Write these as `{model: 0.9}`. `Resolver` takes the model "
|
|
26
|
+
"object and works out the position."
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Components(ResolverMethod):
|
|
32
|
+
"""Resolver methodology that computes connected components.
|
|
33
|
+
|
|
34
|
+
A threshold defaults to 0.0 if not set.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
resolver_type: ClassVar[ResolverType] = ResolverType.COMPONENTS
|
|
38
|
+
settings: ComponentsSettings
|
|
39
|
+
|
|
40
|
+
def compute_clusters( # noqa: D102
|
|
41
|
+
self, model_edges: Mapping[int, pl.DataFrame]
|
|
42
|
+
) -> pl.DataFrame:
|
|
43
|
+
djs = DisjointSet[int]()
|
|
44
|
+
|
|
45
|
+
for position, edges in model_edges.items():
|
|
46
|
+
if edges.height == 0:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
threshold = self.settings.thresholds.get(position, 0.0)
|
|
50
|
+
filtered_edges = edges.filter(pl.col("score") >= threshold)
|
|
51
|
+
|
|
52
|
+
for left_id, right_id in filtered_edges.select(
|
|
53
|
+
"left_id", "right_id"
|
|
54
|
+
).iter_rows():
|
|
55
|
+
djs.union(left_id, right_id)
|
|
56
|
+
|
|
57
|
+
rows: list[dict[str, int]] = []
|
|
58
|
+
for parent_id, component in enumerate(djs.get_components(), start=1):
|
|
59
|
+
rows.extend(
|
|
60
|
+
{"parent_id": parent_id, "child_id": node_id} for node_id in component
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if not rows:
|
|
64
|
+
return pl.from_arrow(SCHEMA_CLUSTERS.empty_table())
|
|
65
|
+
|
|
66
|
+
return pl.DataFrame(rows).cast(pl.Schema(SCHEMA_CLUSTERS))
|