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.
Files changed (72) hide show
  1. matchlab/__init__.py +55 -0
  2. matchlab/adapters/__init__.py +24 -0
  3. matchlab/adapters/base.py +446 -0
  4. matchlab/adapters/duckdb.py +659 -0
  5. matchlab/cli.py +177 -0
  6. matchlab/core/__init__.py +1 -0
  7. matchlab/core/dataframes.py +254 -0
  8. matchlab/core/dsu.py +62 -0
  9. matchlab/core/exceptions.py +40 -0
  10. matchlab/core/hash.py +141 -0
  11. matchlab/core/kinds.py +12 -0
  12. matchlab/core/logging.py +190 -0
  13. matchlab/core/resolver_output.py +173 -0
  14. matchlab/core/schemas.py +82 -0
  15. matchlab/core/sql.py +12 -0
  16. matchlab/document.py +329 -0
  17. matchlab/eval/__init__.py +11 -0
  18. matchlab/eval/judgements.py +41 -0
  19. matchlab/eval/metrics.py +244 -0
  20. matchlab/eval/samples.py +415 -0
  21. matchlab/eval/tui/__init__.py +66 -0
  22. matchlab/eval/tui/app.py +418 -0
  23. matchlab/eval/tui/modals.py +84 -0
  24. matchlab/eval/tui/styles.tcss +184 -0
  25. matchlab/eval/tui/widgets/__init__.py +1 -0
  26. matchlab/eval/tui/widgets/assignment.py +72 -0
  27. matchlab/eval/tui/widgets/styling.py +75 -0
  28. matchlab/eval/tui/widgets/table.py +285 -0
  29. matchlab/lineage.py +239 -0
  30. matchlab/locations.py +347 -0
  31. matchlab/models/__init__.py +10 -0
  32. matchlab/models/comparison.py +67 -0
  33. matchlab/models/dedupers/__init__.py +5 -0
  34. matchlab/models/dedupers/base.py +38 -0
  35. matchlab/models/dedupers/naive.py +77 -0
  36. matchlab/models/linkers/__init__.py +9 -0
  37. matchlab/models/linkers/base.py +41 -0
  38. matchlab/models/linkers/deterministic.py +229 -0
  39. matchlab/models/linkers/splinklinker.py +262 -0
  40. matchlab/models/linkers/weighteddeterministic.py +143 -0
  41. matchlab/models/models.py +198 -0
  42. matchlab/progress.py +455 -0
  43. matchlab/recordstep.py +181 -0
  44. matchlab/resolvers/__init__.py +10 -0
  45. matchlab/resolvers/base.py +63 -0
  46. matchlab/resolvers/components.py +66 -0
  47. matchlab/resolvers/resolvers.py +409 -0
  48. matchlab/sources.py +291 -0
  49. matchlab/specs.py +129 -0
  50. matchlab/steps.py +284 -0
  51. matchlab/testkit/__init__.py +104 -0
  52. matchlab/testkit/_generate.py +240 -0
  53. matchlab/testkit/compare.py +178 -0
  54. matchlab/testkit/entities.py +290 -0
  55. matchlab/testkit/features.py +146 -0
  56. matchlab/testkit/linked.py +527 -0
  57. matchlab/testkit/matchers.py +233 -0
  58. matchlab/testkit/models.py +165 -0
  59. matchlab/testkit/sources.py +327 -0
  60. matchlab/transformers/__init__.py +24 -0
  61. matchlab/transformers/base.py +41 -0
  62. matchlab/transformers/clean.py +48 -0
  63. matchlab/transformers/explode.py +67 -0
  64. matchlab/transformers/group.py +56 -0
  65. matchlab/transformers/select.py +31 -0
  66. matchlab/transformers/transform.py +101 -0
  67. matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
  68. matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
  69. matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
  70. matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  71. matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
  72. 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
+ )