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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""A linking methodology based on a deterministic set of conditions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import duckdb
|
|
6
|
+
import polars as pl
|
|
7
|
+
from pydantic import Field, field_validator
|
|
8
|
+
|
|
9
|
+
from matchlab.core.logging import logger
|
|
10
|
+
from matchlab.core.sql import SQLCondition, SQLQuery
|
|
11
|
+
from matchlab.models import comparison
|
|
12
|
+
from matchlab.models.linkers.base import Linker, LinkerSettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DeterministicSettings(LinkerSettings):
|
|
16
|
+
"""Settings for `DeterministicLinker`."""
|
|
17
|
+
|
|
18
|
+
comparisons: list[SQLCondition] | list[list[SQLCondition]] = Field(
|
|
19
|
+
description="""
|
|
20
|
+
Match conditions, in DuckDB SQL. Qualify every column with `l` or `r`.
|
|
21
|
+
|
|
22
|
+
A flat list applies every condition in parallel, unioned with OR logic:
|
|
23
|
+
|
|
24
|
+
[
|
|
25
|
+
"l.company_number = r.company_number",
|
|
26
|
+
"l.name = r.name",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
A nested list runs sequential rounds instead. Conditions within a round
|
|
30
|
+
use OR logic. After a round, matched records leave the pool before the
|
|
31
|
+
next round runs:
|
|
32
|
+
|
|
33
|
+
[
|
|
34
|
+
[
|
|
35
|
+
"l.company_number = r.company_number",
|
|
36
|
+
"l.name = r.name",
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
"l.name_normalised = r.name_normalised",
|
|
40
|
+
"l.website = r.website",
|
|
41
|
+
],
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
Supports any DuckDB SQL expression, not just equality.
|
|
45
|
+
""",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@field_validator("comparisons", mode="before")
|
|
49
|
+
@classmethod
|
|
50
|
+
def validate_comparison(
|
|
51
|
+
cls, value: SQLCondition | list[SQLCondition] | list[list[SQLCondition]]
|
|
52
|
+
) -> list[list[SQLCondition]]:
|
|
53
|
+
"""Normalise to a list of rounds, and validate each comparison string."""
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
return [[comparison(value, dialect="duckdb")]]
|
|
56
|
+
if not value:
|
|
57
|
+
raise ValueError("comparisons cannot be empty")
|
|
58
|
+
if all(isinstance(v, str) for v in value):
|
|
59
|
+
return [[comparison(v, dialect="duckdb") for v in value]]
|
|
60
|
+
if all(isinstance(v, list) for v in value):
|
|
61
|
+
for round_idx, round_comparisons in enumerate(value):
|
|
62
|
+
if not round_comparisons:
|
|
63
|
+
raise ValueError(f"Round {round_idx} cannot be empty")
|
|
64
|
+
if not all(isinstance(c, str) for c in round_comparisons):
|
|
65
|
+
raise ValueError(f"Round {round_idx} must contain only strings")
|
|
66
|
+
return [[comparison(c, dialect="duckdb") for c in r] for r in value]
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"comparisons must be a string, list of strings, or list of lists"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DeterministicLinker(Linker):
|
|
73
|
+
"""A deterministic linker that links based on a set of boolean conditions.
|
|
74
|
+
|
|
75
|
+
Uses DuckDB as the SQL backend, enabling rich SQL operations while maintaining
|
|
76
|
+
a Polars DataFrame interface. Supports both parallel matching (single round)
|
|
77
|
+
and sequential matching (multiple rounds where matched records are removed
|
|
78
|
+
after each round).
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
settings: DeterministicSettings
|
|
82
|
+
|
|
83
|
+
def prepare(self, left: pl.DataFrame, right: pl.DataFrame) -> None:
|
|
84
|
+
"""No preparation needed."""
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def link(self, left: pl.DataFrame, right: pl.DataFrame) -> pl.DataFrame:
|
|
88
|
+
"""Link the left and right dataframes.
|
|
89
|
+
|
|
90
|
+
If comparisons is a flat list, applies all comparisons in parallel.
|
|
91
|
+
If comparisons is a nested list, applies each round sequentially,
|
|
92
|
+
removing matched records from the pool after each round.
|
|
93
|
+
"""
|
|
94
|
+
con: duckdb.DuckDBPyConnection = duckdb.connect(":memory:")
|
|
95
|
+
try:
|
|
96
|
+
all_matches: list[pl.DataFrame] = []
|
|
97
|
+
remaining_left, remaining_right = left, right
|
|
98
|
+
|
|
99
|
+
for round_num, round_comparisons in enumerate(
|
|
100
|
+
self.settings.comparisons, start=1
|
|
101
|
+
):
|
|
102
|
+
if remaining_left.is_empty() or remaining_right.is_empty():
|
|
103
|
+
logger.info(f"Round {round_num}: Skipping - no records remaining")
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
logger.info(
|
|
107
|
+
f"Round {round_num}: {len(remaining_left):,} left × "
|
|
108
|
+
f"{len(remaining_right):,} right"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
matches = self._link_round(
|
|
112
|
+
con, remaining_left, remaining_right, round_comparisons, round_num
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
logger.info(f"Round {round_num}: Found {len(matches):,} matches")
|
|
116
|
+
|
|
117
|
+
if not matches.is_empty():
|
|
118
|
+
all_matches.append(matches)
|
|
119
|
+
matched_left = matches.select("left_id").unique()
|
|
120
|
+
matched_right = matches.select("right_id").unique()
|
|
121
|
+
remaining_left = remaining_left.join(
|
|
122
|
+
matched_left,
|
|
123
|
+
left_on=self.settings.left_id,
|
|
124
|
+
right_on="left_id",
|
|
125
|
+
how="anti",
|
|
126
|
+
)
|
|
127
|
+
remaining_right = remaining_right.join(
|
|
128
|
+
matched_right,
|
|
129
|
+
left_on=self.settings.right_id,
|
|
130
|
+
right_on="right_id",
|
|
131
|
+
how="anti",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return self._finalise_results(all_matches)
|
|
135
|
+
finally:
|
|
136
|
+
con.close()
|
|
137
|
+
|
|
138
|
+
def _link_round(
|
|
139
|
+
self,
|
|
140
|
+
con: duckdb.DuckDBPyConnection,
|
|
141
|
+
left: pl.DataFrame,
|
|
142
|
+
right: pl.DataFrame,
|
|
143
|
+
comparisons: list[SQLCondition],
|
|
144
|
+
round_num: int,
|
|
145
|
+
) -> pl.DataFrame:
|
|
146
|
+
"""Apply all comparisons in a round using OR logic via DuckDB."""
|
|
147
|
+
con.register("left_df", left)
|
|
148
|
+
con.register("right_df", right)
|
|
149
|
+
|
|
150
|
+
subqueries: list[SQLQuery] = []
|
|
151
|
+
for condition in comparisons:
|
|
152
|
+
subquery: SQLQuery = f"""
|
|
153
|
+
SELECT
|
|
154
|
+
l.{self.settings.left_id} AS left_id,
|
|
155
|
+
r.{self.settings.right_id} AS right_id,
|
|
156
|
+
1.0 AS score
|
|
157
|
+
FROM left_df l
|
|
158
|
+
INNER JOIN right_df r
|
|
159
|
+
ON {condition}
|
|
160
|
+
"""
|
|
161
|
+
subqueries.append(subquery)
|
|
162
|
+
|
|
163
|
+
query: SQLQuery = f"""
|
|
164
|
+
SELECT DISTINCT *
|
|
165
|
+
FROM ({" UNION ALL ".join(subqueries)})
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
max_est: int = self._get_max_cardinality(con, query)
|
|
169
|
+
logger.info(f"Round {round_num}: Estimated max cardinality: {max_est:,}")
|
|
170
|
+
|
|
171
|
+
return con.execute(query).pl()
|
|
172
|
+
|
|
173
|
+
def _get_max_cardinality(
|
|
174
|
+
self, con: duckdb.DuckDBPyConnection, query: SQLQuery
|
|
175
|
+
) -> int:
|
|
176
|
+
"""Get max cardinality estimate from DuckDB plan, or -1 if unavailable."""
|
|
177
|
+
explain = con.execute(
|
|
178
|
+
f"PRAGMA explain_output = 'all'; EXPLAIN (FORMAT json) {query}"
|
|
179
|
+
).fetchall()
|
|
180
|
+
plans = {k: json.loads(v) for k, v in dict(explain).items()}
|
|
181
|
+
|
|
182
|
+
estimates: list[dict] = []
|
|
183
|
+
for plan_name, plan_type in [
|
|
184
|
+
("physical_plan", "physical"),
|
|
185
|
+
("logical_opt", "optimised"),
|
|
186
|
+
]:
|
|
187
|
+
if plan := plans.get(plan_name):
|
|
188
|
+
estimates.extend(self._traverse_plan(plan[0], plan_type))
|
|
189
|
+
|
|
190
|
+
# Debug: log full tree breakdown
|
|
191
|
+
logger.debug("Plan breakdown:")
|
|
192
|
+
for plan_type in ["physical", "optimised"]:
|
|
193
|
+
plan_ests = [e for e in estimates if e["plan_type"] == plan_type]
|
|
194
|
+
if plan_ests:
|
|
195
|
+
logger.debug(f" {plan_type.capitalize()} plan:")
|
|
196
|
+
for est in plan_ests:
|
|
197
|
+
indent = " " + " " * est["depth"]
|
|
198
|
+
logger.debug(f"{indent}{est['node_name']}: {est['cardinality']:,}")
|
|
199
|
+
|
|
200
|
+
# Return max for info logging
|
|
201
|
+
return max(
|
|
202
|
+
(e["cardinality"] for e in estimates if e["cardinality"] > 0),
|
|
203
|
+
default=-1,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def _traverse_plan(self, node: dict, plan_type: str, depth: int = 0) -> list[dict]:
|
|
207
|
+
"""Recursively collect cardinality estimates with metadata from plan tree."""
|
|
208
|
+
estimates: list[dict] = []
|
|
209
|
+
cardinality = node.get("extra_info", {}).get("Estimated Cardinality")
|
|
210
|
+
if cardinality is not None:
|
|
211
|
+
estimates.append(
|
|
212
|
+
{
|
|
213
|
+
"cardinality": int(cardinality),
|
|
214
|
+
"node_name": node.get("name", "UNKNOWN"),
|
|
215
|
+
"depth": depth,
|
|
216
|
+
"plan_type": plan_type,
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
for child in node.get("children", []):
|
|
220
|
+
estimates.extend(self._traverse_plan(child, plan_type, depth + 1))
|
|
221
|
+
return estimates
|
|
222
|
+
|
|
223
|
+
def _finalise_results(self, all_matches: list[pl.DataFrame]) -> pl.DataFrame:
|
|
224
|
+
"""Combine matches from all rounds and ensure correct schema."""
|
|
225
|
+
if all_matches:
|
|
226
|
+
return pl.concat(all_matches).with_columns(pl.col("score").cast(pl.Float32))
|
|
227
|
+
return pl.DataFrame({"left_id": [], "right_id": [], "score": []}).with_columns(
|
|
228
|
+
pl.col("score").cast(pl.Float32)
|
|
229
|
+
)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""A linking methodology leveraging Splink."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
from pydantic import (
|
|
9
|
+
BaseModel,
|
|
10
|
+
ConfigDict,
|
|
11
|
+
Field,
|
|
12
|
+
field_serializer,
|
|
13
|
+
field_validator,
|
|
14
|
+
model_validator,
|
|
15
|
+
)
|
|
16
|
+
from pydantic.functional_serializers import SerializationInfo
|
|
17
|
+
from splink import DuckDBAPI, SettingsCreator
|
|
18
|
+
from splink import Linker as SplinkLibLinkerClass
|
|
19
|
+
from splink.internals.linker_components.training import LinkerTraining
|
|
20
|
+
|
|
21
|
+
from matchlab.core.logging import logger
|
|
22
|
+
from matchlab.models.linkers.base import Linker, LinkerSettings
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SplinkLinkerFunction(BaseModel):
|
|
26
|
+
"""A method of splink.Linker.training used to train the linker."""
|
|
27
|
+
|
|
28
|
+
function: str
|
|
29
|
+
arguments: dict[str, Any]
|
|
30
|
+
|
|
31
|
+
@model_validator(mode="after")
|
|
32
|
+
def validate_function_and_arguments(self) -> "SplinkLinkerFunction":
|
|
33
|
+
"""Ensure the function and arguments are valid."""
|
|
34
|
+
if not hasattr(LinkerTraining, self.function):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Function {self.function} not found as method of Splink Linker class"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
splink_linker_func = getattr(LinkerTraining, self.function)
|
|
40
|
+
splink_linker_func_param_set = set(
|
|
41
|
+
inspect.signature(splink_linker_func).parameters.keys()
|
|
42
|
+
)
|
|
43
|
+
current_func_param_set = set(self.arguments.keys())
|
|
44
|
+
|
|
45
|
+
if not current_func_param_set <= splink_linker_func_param_set:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Function {self.function} given incorrect arguments: "
|
|
48
|
+
f"{current_func_param_set.difference(splink_linker_func_param_set)}. "
|
|
49
|
+
"Consider referring back to the Splink documentation: "
|
|
50
|
+
"https://moj-analytical-services.github.io/splink/linker.html"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SplinkSettings(LinkerSettings):
|
|
57
|
+
"""A data class to enforce the Splink linker's settings dictionary shape.
|
|
58
|
+
|
|
59
|
+
!!! warning "Pass a seed to any sampling training function"
|
|
60
|
+
Step fingerprints are derived from configuration, so two collections that
|
|
61
|
+
share these settings share a cache entry. Training functions that sample —
|
|
62
|
+
`estimate_u_using_random_sampling`, for one — are non-deterministic unless
|
|
63
|
+
given a `seed` in their `arguments`, and without one the first result is
|
|
64
|
+
cached and silently reused. See `Step._fingerprint`.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
68
|
+
|
|
69
|
+
linker_training_functions: list[SplinkLinkerFunction] = Field(
|
|
70
|
+
description="""
|
|
71
|
+
A list of dictionaries where keys are the names of methods for
|
|
72
|
+
splink.Linker.training and values are dictionaries encoding the arguments of
|
|
73
|
+
those methods. Each function will be run in the order supplied.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
|
|
77
|
+
>>> linker_training_functions=[
|
|
78
|
+
... {
|
|
79
|
+
... "function": "estimate_probability_two_random_records_match",
|
|
80
|
+
... "arguments": {
|
|
81
|
+
... "deterministic_matching_rules": \"""
|
|
82
|
+
... l.company_name = r.company_name
|
|
83
|
+
... \""",
|
|
84
|
+
... "recall": 0.7,
|
|
85
|
+
... },
|
|
86
|
+
... },
|
|
87
|
+
... {
|
|
88
|
+
... "function": "estimate_u_using_random_sampling",
|
|
89
|
+
... "arguments": {"max_pairs": 1e6},
|
|
90
|
+
... }
|
|
91
|
+
... ]
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
)
|
|
95
|
+
linker_settings: SettingsCreator = Field(
|
|
96
|
+
description="""
|
|
97
|
+
A valid Splink SettingsCreator.
|
|
98
|
+
|
|
99
|
+
See Splink's documentation for a full description of available settings.
|
|
100
|
+
https://moj-analytical-services.github.io/splink/api_docs/settings_dict_guide.html
|
|
101
|
+
|
|
102
|
+
* link_type must be set to "link_only"
|
|
103
|
+
* unique_id_name is overridden to the value of left_id and right_id,
|
|
104
|
+
which must match
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
|
|
108
|
+
>>> from splink import SettingsCreator, block_on
|
|
109
|
+
... import splink.comparison_library as cl
|
|
110
|
+
... import splink.comparison_template_library as ctl
|
|
111
|
+
...
|
|
112
|
+
... splink_settings = SettingsCreator(
|
|
113
|
+
... retain_matching_columns=False,
|
|
114
|
+
... retain_intermediate_calculation_columns=False,
|
|
115
|
+
... blocking_rules_to_generate_predictions=[
|
|
116
|
+
... block_on("company_name"),
|
|
117
|
+
... block_on("postcode"),
|
|
118
|
+
... ],
|
|
119
|
+
... comparisons=[
|
|
120
|
+
... cl.jaro_winkler_at_thresholds(
|
|
121
|
+
... "company_name",
|
|
122
|
+
... [0.9, 0.6],
|
|
123
|
+
... term_frequency_adjustments=True
|
|
124
|
+
... ),
|
|
125
|
+
... ctl.postcode_comparison("postcode"),
|
|
126
|
+
... ]
|
|
127
|
+
... )
|
|
128
|
+
"""
|
|
129
|
+
)
|
|
130
|
+
threshold: float | None = Field(
|
|
131
|
+
default=None,
|
|
132
|
+
description="""
|
|
133
|
+
The score above which matches will be kept.
|
|
134
|
+
|
|
135
|
+
None is used to indicate no threshold.
|
|
136
|
+
|
|
137
|
+
Inclusive, so a value of 1 will keep only exact matches across all
|
|
138
|
+
comparisons.
|
|
139
|
+
""",
|
|
140
|
+
gt=0,
|
|
141
|
+
le=1,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@model_validator(mode="after")
|
|
145
|
+
def check_link_only(self) -> "SplinkSettings":
|
|
146
|
+
"""Ensure link_type is set to "link_only"."""
|
|
147
|
+
if self.linker_settings.link_type != "link_only":
|
|
148
|
+
raise ValueError('link_type must be set to "link_only"')
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
@model_validator(mode="after")
|
|
152
|
+
def add_enforced_settings(self) -> "SplinkSettings":
|
|
153
|
+
"""Ensure ID is the only field we link on."""
|
|
154
|
+
self.linker_settings.unique_id_column_name = self.left_id
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
@field_validator("linker_settings", mode="before")
|
|
158
|
+
@classmethod
|
|
159
|
+
def load_linker_settings(cls, value: str | SettingsCreator) -> SettingsCreator:
|
|
160
|
+
"""Load serialised settings into SettingsCreator."""
|
|
161
|
+
if isinstance(value, str):
|
|
162
|
+
value = SettingsCreator.from_path_or_dict(json.loads(value))
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
@field_serializer("linker_settings")
|
|
166
|
+
def serialise_settings(
|
|
167
|
+
self, value: SettingsCreator, info: SerializationInfo
|
|
168
|
+
) -> str:
|
|
169
|
+
"""Convert Splink settings to string."""
|
|
170
|
+
return json.dumps(value.create_settings_dict("duckdb"))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class SplinkLinker(Linker):
|
|
174
|
+
"""A linker that leverages Bayesian record linkage using Splink."""
|
|
175
|
+
|
|
176
|
+
settings: SplinkSettings
|
|
177
|
+
|
|
178
|
+
_linker: SplinkLibLinkerClass
|
|
179
|
+
_id_dtype_l: pl.DataType
|
|
180
|
+
_id_dtype_r: pl.DataType
|
|
181
|
+
|
|
182
|
+
def prepare(self, left: pl.DataFrame, right: pl.DataFrame) -> None:
|
|
183
|
+
"""Build the Splink linker over left and right, and run its training functions.
|
|
184
|
+
|
|
185
|
+
Runs each function in `settings.linker_training_functions`, in order, against
|
|
186
|
+
the built linker. `link()` then just predicts against the trained state.
|
|
187
|
+
"""
|
|
188
|
+
if (set(left.columns) != set(right.columns)) or not left.dtypes == right.dtypes:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
"SplinkLinker requires input data to be conformant, meaning they "
|
|
191
|
+
"share the same column names and data formats."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
self._id_dtype_l = left[self.settings.left_id].dtype
|
|
195
|
+
self._id_dtype_r = right[self.settings.right_id].dtype
|
|
196
|
+
|
|
197
|
+
# Convert to pandas for Splink compatibility
|
|
198
|
+
left_pd = left.with_columns(
|
|
199
|
+
pl.col(self.settings.left_id).cast(pl.String)
|
|
200
|
+
).to_pandas()
|
|
201
|
+
right_pd = right.with_columns(
|
|
202
|
+
pl.col(self.settings.right_id).cast(pl.String)
|
|
203
|
+
).to_pandas()
|
|
204
|
+
|
|
205
|
+
self._linker = SplinkLibLinkerClass(
|
|
206
|
+
input_table_or_tables=[left_pd, right_pd],
|
|
207
|
+
input_table_aliases=["l", "r"],
|
|
208
|
+
settings=self.settings.linker_settings,
|
|
209
|
+
db_api=DuckDBAPI(),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
for func in self.settings.linker_training_functions:
|
|
213
|
+
proc_func = getattr(self._linker.training, func.function)
|
|
214
|
+
proc_func(**func.arguments)
|
|
215
|
+
|
|
216
|
+
def link(
|
|
217
|
+
self, left: pl.DataFrame = None, right: pl.DataFrame = None
|
|
218
|
+
) -> pl.DataFrame:
|
|
219
|
+
"""Predict match scores using the linker trained in `prepare()`.
|
|
220
|
+
|
|
221
|
+
`left`/`right` are accepted only to satisfy the `Linker` contract. The data
|
|
222
|
+
was already fixed when `prepare()` built the underlying Splink linker, so
|
|
223
|
+
passing values here logs a warning and has no effect.
|
|
224
|
+
"""
|
|
225
|
+
if left is not None or right is not None:
|
|
226
|
+
logger.warning(
|
|
227
|
+
"Left and right data are declared in .prepare() for SplinkLinker. "
|
|
228
|
+
"These values will be ignored"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
res = self._linker.inference.predict(
|
|
232
|
+
threshold_match_probability=self.settings.threshold
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
res.as_duckdbpyrelation()
|
|
237
|
+
.pl()
|
|
238
|
+
.lazy()
|
|
239
|
+
.select(
|
|
240
|
+
[
|
|
241
|
+
f"{self.settings.left_id}_l",
|
|
242
|
+
f"{self.settings.right_id}_r",
|
|
243
|
+
"match_probability",
|
|
244
|
+
]
|
|
245
|
+
)
|
|
246
|
+
.with_columns(
|
|
247
|
+
[
|
|
248
|
+
pl.col(f"{self.settings.left_id}_l")
|
|
249
|
+
.cast(self._id_dtype_l)
|
|
250
|
+
.alias("left_id"),
|
|
251
|
+
pl.col(f"{self.settings.right_id}_r")
|
|
252
|
+
.cast(self._id_dtype_r)
|
|
253
|
+
.alias("right_id"),
|
|
254
|
+
pl.col("match_probability").cast(pl.Float32).alias("score"),
|
|
255
|
+
]
|
|
256
|
+
)
|
|
257
|
+
.select(["left_id", "right_id", "score"])
|
|
258
|
+
# Multiple blocking rules can lead to multiple matches
|
|
259
|
+
.group_by(["left_id", "right_id"])
|
|
260
|
+
.agg(pl.col("score").max())
|
|
261
|
+
.collect()
|
|
262
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""A linking methodology that applies different weights to field comparisons."""
|
|
2
|
+
|
|
3
|
+
import duckdb
|
|
4
|
+
import polars as pl
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator
|
|
6
|
+
|
|
7
|
+
from matchlab.core.sql import SQLCondition
|
|
8
|
+
from matchlab.models import comparison
|
|
9
|
+
from matchlab.models.linkers.base import Linker, LinkerSettings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WeightedComparison(BaseModel):
|
|
13
|
+
"""A comparison condition, and the weight it contributes to a pair's score."""
|
|
14
|
+
|
|
15
|
+
comparison: SQLCondition = Field(
|
|
16
|
+
description="""
|
|
17
|
+
A valid ON clause comparing fields between the left and the right data.
|
|
18
|
+
|
|
19
|
+
Qualify every column with `l` or `r`. For example:
|
|
20
|
+
|
|
21
|
+
"l.company_name = r.company_name"
|
|
22
|
+
"""
|
|
23
|
+
)
|
|
24
|
+
weight: float = Field(
|
|
25
|
+
description="""
|
|
26
|
+
A weight to give this comparison. Use 1 for all comparisons to give
|
|
27
|
+
uniform weight to each.
|
|
28
|
+
"""
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@field_validator("comparison")
|
|
32
|
+
@classmethod
|
|
33
|
+
def validate_comparison(cls, v: SQLCondition) -> SQLCondition:
|
|
34
|
+
"""Validate the comparison string."""
|
|
35
|
+
comp_val = comparison(v, dialect="duckdb")
|
|
36
|
+
return comp_val
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WeightedDeterministicSettings(LinkerSettings):
|
|
40
|
+
"""Settings for `WeightedDeterministicLinker`.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
>>> {
|
|
44
|
+
... weighted_comparisons: [
|
|
45
|
+
... ("l.company_name = r.company_name", 0.7),
|
|
46
|
+
... ("l.postcode = r.postcode", 0.7),
|
|
47
|
+
... ("l.company_id = r.company_id", 1),
|
|
48
|
+
... ],
|
|
49
|
+
... threshold: 0.8,
|
|
50
|
+
... }
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
weighted_comparisons: list[WeightedComparison] = Field(
|
|
54
|
+
description="A list of tuples in the form of a comparison, and a weight."
|
|
55
|
+
)
|
|
56
|
+
threshold: float = Field(
|
|
57
|
+
description="""
|
|
58
|
+
The score above which matches will be kept.
|
|
59
|
+
|
|
60
|
+
Inclusive, so a value of 1 will keep only exact matches across all
|
|
61
|
+
comparisons.
|
|
62
|
+
""",
|
|
63
|
+
ge=0,
|
|
64
|
+
le=1,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WeightedDeterministicLinker(Linker):
|
|
69
|
+
"""Scores a pair by the weighted share of comparisons it matches on."""
|
|
70
|
+
|
|
71
|
+
settings: WeightedDeterministicSettings
|
|
72
|
+
|
|
73
|
+
_id_dtype_l: pl.DataType
|
|
74
|
+
_id_dtype_r: pl.DataType
|
|
75
|
+
|
|
76
|
+
def prepare(self, left: pl.DataFrame, right: pl.DataFrame) -> None:
|
|
77
|
+
"""No preparation needed."""
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
def link(self, left: pl.DataFrame, right: pl.DataFrame) -> pl.DataFrame:
|
|
81
|
+
"""Score each pair by summed matching weight over total weight.
|
|
82
|
+
|
|
83
|
+
Keeps only pairs scoring at or above `settings.threshold`.
|
|
84
|
+
"""
|
|
85
|
+
self._id_dtype_l = left[self.settings.left_id].dtype
|
|
86
|
+
self._id_dtype_r = right[self.settings.right_id].dtype
|
|
87
|
+
|
|
88
|
+
# Used below but ruff can't detect
|
|
89
|
+
left_df = left.clone() # noqa: F841
|
|
90
|
+
right_df = right.clone() # noqa: F841
|
|
91
|
+
|
|
92
|
+
match_subquery = []
|
|
93
|
+
weights = []
|
|
94
|
+
|
|
95
|
+
for weighted_comparison in self.settings.weighted_comparisons:
|
|
96
|
+
match_subquery.append(
|
|
97
|
+
f"""
|
|
98
|
+
select distinct on (list_sort([raw.left_id, raw.right_id]))
|
|
99
|
+
raw.left_id,
|
|
100
|
+
raw.right_id,
|
|
101
|
+
1.0 * {weighted_comparison.weight} as score
|
|
102
|
+
from (
|
|
103
|
+
select
|
|
104
|
+
l.{self.settings.left_id} as left_id,
|
|
105
|
+
r.{self.settings.right_id} as right_id,
|
|
106
|
+
from
|
|
107
|
+
left_df l
|
|
108
|
+
inner join right_df r on
|
|
109
|
+
{weighted_comparison.comparison}
|
|
110
|
+
) raw
|
|
111
|
+
"""
|
|
112
|
+
)
|
|
113
|
+
weights.append(weighted_comparison.weight)
|
|
114
|
+
|
|
115
|
+
match_subquery = " union all ".join(match_subquery)
|
|
116
|
+
total_weight = sum(weights)
|
|
117
|
+
|
|
118
|
+
sql = f"""
|
|
119
|
+
select
|
|
120
|
+
matches.left_id,
|
|
121
|
+
matches.right_id,
|
|
122
|
+
sum(matches.score) / {total_weight} as score
|
|
123
|
+
from
|
|
124
|
+
({match_subquery}) matches
|
|
125
|
+
group by
|
|
126
|
+
matches.left_id,
|
|
127
|
+
matches.right_id
|
|
128
|
+
having
|
|
129
|
+
sum(matches.score) /
|
|
130
|
+
{total_weight} >= {self.settings.threshold};
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
duckdb.sql(sql)
|
|
135
|
+
.pl()
|
|
136
|
+
.with_columns(
|
|
137
|
+
[
|
|
138
|
+
pl.col("left_id").cast(self._id_dtype_l),
|
|
139
|
+
pl.col("right_id").cast(self._id_dtype_r),
|
|
140
|
+
pl.col("score").cast(pl.Float32),
|
|
141
|
+
]
|
|
142
|
+
)
|
|
143
|
+
)
|