matchlab 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev4__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/document.py +44 -38
- matchlab/models/linkers/splinklinker.py +37 -6
- {matchlab-0.1.0.dev2.dist-info → matchlab-0.1.0.dev4.dist-info}/METADATA +1 -1
- {matchlab-0.1.0.dev2.dist-info → matchlab-0.1.0.dev4.dist-info}/RECORD +8 -8
- {matchlab-0.1.0.dev2.dist-info → matchlab-0.1.0.dev4.dist-info}/WHEEL +0 -0
- {matchlab-0.1.0.dev2.dist-info → matchlab-0.1.0.dev4.dist-info}/entry_points.txt +0 -0
- {matchlab-0.1.0.dev2.dist-info → matchlab-0.1.0.dev4.dist-info}/licenses/LICENSE +0 -0
- {matchlab-0.1.0.dev2.dist-info → matchlab-0.1.0.dev4.dist-info}/top_level.txt +0 -0
matchlab/document.py
CHANGED
|
@@ -158,15 +158,19 @@ class PlanDocument(BaseModel):
|
|
|
158
158
|
return {name for node in self.steps for name in node.resources.values()}
|
|
159
159
|
|
|
160
160
|
|
|
161
|
-
def dump(root: Step) ->
|
|
161
|
+
def dump(root: Step, *, indent: int | None = None) -> str:
|
|
162
162
|
"""Describe `root` and everything it reads as a portable document.
|
|
163
163
|
|
|
164
|
+
JSON rather than an object, because a document exists to be put somewhere: a
|
|
165
|
+
column, an object store, a request body, a file.
|
|
166
|
+
|
|
164
167
|
Args:
|
|
165
168
|
root: The apex of the plan. Only what is reachable upstream of it is included,
|
|
166
169
|
exactly as `collect()` would run.
|
|
170
|
+
indent: How the JSON is laid out. `None`, the default, is compact.
|
|
167
171
|
|
|
168
172
|
Returns:
|
|
169
|
-
The document
|
|
173
|
+
The document, as JSON.
|
|
170
174
|
|
|
171
175
|
Raises:
|
|
172
176
|
ResourceError: If a resource was passed without a name, so there is nothing for
|
|
@@ -174,23 +178,9 @@ def dump(root: Step) -> PlanDocument:
|
|
|
174
178
|
earlier, when the plan is built.
|
|
175
179
|
"""
|
|
176
180
|
ordered = walk(root)
|
|
177
|
-
_check_resources(ordered)
|
|
178
|
-
position = {id(step): index for index, step in enumerate(ordered)}
|
|
179
|
-
return PlanDocument(
|
|
180
|
-
steps=tuple(
|
|
181
|
-
_node(step, tuple(position[id(parent)] for parent in step.parents))
|
|
182
|
-
for step in ordered
|
|
183
|
-
)
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def _check_resources(steps: list[Step]) -> None:
|
|
188
|
-
"""Reject a plan whose resources have no names to record.
|
|
189
181
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
"""
|
|
193
|
-
for position, step in enumerate(steps):
|
|
182
|
+
# Check resources
|
|
183
|
+
for position, step in enumerate(ordered):
|
|
194
184
|
for field, resource in step._resources().items():
|
|
195
185
|
if resource.name is None:
|
|
196
186
|
raise ResourceError(
|
|
@@ -199,6 +189,15 @@ def _check_resources(steps: list[Step]) -> None:
|
|
|
199
189
|
f'{field}=Resource("some_name", ...).'
|
|
200
190
|
)
|
|
201
191
|
|
|
192
|
+
position = {id(step): index for index, step in enumerate(ordered)}
|
|
193
|
+
document = PlanDocument(
|
|
194
|
+
steps=tuple(
|
|
195
|
+
_node(step, tuple(position[id(parent)] for parent in step.parents))
|
|
196
|
+
for step in ordered
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
return document.model_dump_json(indent=indent)
|
|
200
|
+
|
|
202
201
|
|
|
203
202
|
def _node(step: Step, inputs: tuple[int, ...]) -> StepNode:
|
|
204
203
|
"""Describe one step, checking its spec is the one its kind implies."""
|
|
@@ -216,61 +215,68 @@ def _node(step: Step, inputs: tuple[int, ...]) -> StepNode:
|
|
|
216
215
|
)
|
|
217
216
|
|
|
218
217
|
|
|
219
|
-
def load(document:
|
|
218
|
+
def load(document: str | bytes, resources: Mapping[str, Any]) -> Step:
|
|
220
219
|
"""Rebuild a plan from a document, returning its apex.
|
|
221
220
|
|
|
222
221
|
Nothing is collected. This reconstructs the same lazy plan, so the returned step
|
|
223
222
|
fingerprints identically to the one that was dumped, given the same data.
|
|
224
223
|
|
|
225
224
|
Args:
|
|
226
|
-
document:
|
|
225
|
+
document: The JSON `dump` produced.
|
|
227
226
|
resources: Name to object, for every resource the document names.
|
|
228
|
-
`document.required_resources()` lists them.
|
|
229
227
|
|
|
230
228
|
Returns:
|
|
231
229
|
The plan's apex, the last step in the document.
|
|
232
230
|
|
|
233
231
|
Raises:
|
|
232
|
+
ValidationError: If the JSON does not describe a plan document.
|
|
234
233
|
ValueError: If the document is empty, names an unregistered location class, or
|
|
235
234
|
wires a step to an input of the wrong kind.
|
|
236
235
|
ResourceError: If a named resource was not supplied.
|
|
237
236
|
"""
|
|
238
|
-
|
|
237
|
+
parsed = PlanDocument.model_validate_json(document)
|
|
238
|
+
if not parsed.steps:
|
|
239
239
|
raise ValueError("A plan document needs at least one step")
|
|
240
240
|
|
|
241
|
+
_check_supplied(parsed, resources)
|
|
242
|
+
|
|
241
243
|
built: list[Step] = []
|
|
242
|
-
for position, node in enumerate(
|
|
244
|
+
for position, node in enumerate(parsed.steps):
|
|
243
245
|
built.append(
|
|
244
246
|
_rebuild(node, position, [built[i] for i in node.inputs], resources)
|
|
245
247
|
)
|
|
246
248
|
return built[-1]
|
|
247
249
|
|
|
248
250
|
|
|
249
|
-
def
|
|
250
|
-
|
|
251
|
-
) -> dict[str, Resource]:
|
|
252
|
-
"""Turn a node's `field -> resource name` map into bound resources.
|
|
251
|
+
def _check_supplied(document: PlanDocument, supplied: Mapping[str, Any]) -> None:
|
|
252
|
+
"""Reject a load missing any resource the document names, naming them all.
|
|
253
253
|
|
|
254
254
|
Raises:
|
|
255
255
|
ResourceError: If the document names a resource `supplied` does not have.
|
|
256
256
|
"""
|
|
257
|
-
|
|
258
|
-
for
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
f"
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
257
|
+
missing: dict[str, str] = {}
|
|
258
|
+
for position, node in enumerate(document.steps):
|
|
259
|
+
for field, name in node.resources.items():
|
|
260
|
+
if name not in supplied and name not in missing:
|
|
261
|
+
missing[name] = f"step {position} ({node.kind}) reads it as '{field}'"
|
|
262
|
+
if not missing:
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
wanted = "\n".join(f" '{name}' — {where}" for name, where in missing.items())
|
|
266
|
+
example = ", ".join(f"'{name}': ..." for name in missing)
|
|
267
|
+
raise ResourceError(
|
|
268
|
+
f"This plan needs resources that were not supplied:\n{wanted}\n"
|
|
269
|
+
f"Pass them when loading: resources={{{example}}}."
|
|
270
|
+
)
|
|
267
271
|
|
|
268
272
|
|
|
269
273
|
def _rebuild(
|
|
270
274
|
node: StepNode, position: int, inputs: list[Step], supplied: Mapping[str, Any]
|
|
271
275
|
) -> Step:
|
|
272
276
|
"""Rebuild one step from its node and its already-rebuilt inputs."""
|
|
273
|
-
resources =
|
|
277
|
+
resources = {
|
|
278
|
+
field: Resource(name, supplied[name]) for field, name in node.resources.items()
|
|
279
|
+
}
|
|
274
280
|
match node.kind:
|
|
275
281
|
case StepKind.SOURCE:
|
|
276
282
|
spec = _expect(node, position, SourceSpec)
|
|
@@ -174,21 +174,52 @@ class SplinkLinker(Linker):
|
|
|
174
174
|
_id_dtype_l: pl.DataType
|
|
175
175
|
_id_dtype_r: pl.DataType
|
|
176
176
|
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _schema_summary(data: pl.DataFrame) -> str:
|
|
179
|
+
"""Render a compact column-to-dtype summary for diagnostics."""
|
|
180
|
+
return (
|
|
181
|
+
", ".join(f"{column}={dtype}" for column, dtype in data.schema.items())
|
|
182
|
+
or "<empty>"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def _conformancy_error(cls, left: pl.DataFrame, right: pl.DataFrame) -> ValueError:
|
|
187
|
+
"""Build a detailed error describing how the two inputs differ."""
|
|
188
|
+
left_only = [column for column in left.columns if column not in right.columns]
|
|
189
|
+
right_only = [column for column in right.columns if column not in left.columns]
|
|
190
|
+
shared = [column for column in left.columns if column in right.columns]
|
|
191
|
+
differing_dtypes = [
|
|
192
|
+
f"{column}: left={left.schema[column]}, right={right.schema[column]}"
|
|
193
|
+
for column in shared
|
|
194
|
+
if left.schema[column] != right.schema[column]
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
details = [
|
|
198
|
+
"SplinkLinker requires input data to be conformant, meaning they "
|
|
199
|
+
"share the same column names and data formats.",
|
|
200
|
+
f"Left schema: {cls._schema_summary(left)}",
|
|
201
|
+
f"Right schema: {cls._schema_summary(right)}",
|
|
202
|
+
]
|
|
203
|
+
if left_only:
|
|
204
|
+
details.append(f"Columns only on left: {left_only}")
|
|
205
|
+
if right_only:
|
|
206
|
+
details.append(f"Columns only on right: {right_only}")
|
|
207
|
+
if differing_dtypes:
|
|
208
|
+
details.append("Differing dtypes: " + "; ".join(differing_dtypes))
|
|
209
|
+
return ValueError("\n".join(details))
|
|
210
|
+
|
|
177
211
|
def prepare(self, left: pl.DataFrame, right: pl.DataFrame) -> None:
|
|
178
212
|
"""Build the Splink linker over left and right, and run its training functions.
|
|
179
213
|
|
|
180
214
|
Runs each function in `settings.linker_training_functions`, in order, against
|
|
181
215
|
the built linker. `link()` then just predicts against the trained state.
|
|
182
216
|
"""
|
|
183
|
-
if (set(left.columns) != set(right.columns)) or not left.dtypes == right.dtypes:
|
|
184
|
-
raise ValueError(
|
|
185
|
-
"SplinkLinker requires input data to be conformant, meaning they "
|
|
186
|
-
"share the same column names and data formats."
|
|
187
|
-
)
|
|
188
|
-
|
|
189
217
|
self._id_dtype_l = left[self.left_id].dtype
|
|
190
218
|
self._id_dtype_r = right[self.right_id].dtype
|
|
191
219
|
|
|
220
|
+
if (set(left.columns) != set(right.columns)) or not left.dtypes == right.dtypes:
|
|
221
|
+
raise self._conformancy_error(left, right)
|
|
222
|
+
|
|
192
223
|
# Convert to pandas for Splink compatibility
|
|
193
224
|
left_pd = left.with_columns(pl.col(self.left_id).cast(pl.String)).to_pandas()
|
|
194
225
|
right_pd = right.with_columns(pl.col(self.right_id).cast(pl.String)).to_pandas()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: matchlab
|
|
3
|
-
Version: 0.1.0.
|
|
3
|
+
Version: 0.1.0.dev4
|
|
4
4
|
Summary: A local-first library for building, running and evaluating entity resolution pipelines.
|
|
5
5
|
Author: Department for Business and Trade
|
|
6
6
|
Project-URL: Documentation, https://uktrade.github.io/matchlab/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
matchlab/__init__.py,sha256=vjjzGMDlKk1D7GPHp5Yk8LqFs7yhQw8VANVtJh9HK_U,2419
|
|
2
2
|
matchlab/cli.py,sha256=Ka-clRCiPnxI5jmehjGCgdNyADcPFcnl3cuPI1rynYg,6209
|
|
3
|
-
matchlab/document.py,sha256=
|
|
3
|
+
matchlab/document.py,sha256=rCnGJxdu83Mqbf5PnIjda2mVM7StW3IXdMFb9MPx6nE,14927
|
|
4
4
|
matchlab/lineage.py,sha256=C2y2gclA-ehh2Yh45aLLaAwMgBZOK6tZmLgxjvycl-I,8865
|
|
5
5
|
matchlab/progress.py,sha256=t2PAj0YG2vAHguMFn9J0NL07R2K6wEnAkGELQpFNYNM,21330
|
|
6
6
|
matchlab/recordstep.py,sha256=E1-JkXsqcPmrlQw7AM-eDfHPcznLX0e_SzlVrTsADEA,7637
|
|
@@ -41,7 +41,7 @@ matchlab/models/dedupers/naive.py,sha256=fP8PFKXDDvNmCMhWPLZQO9KWJoagMIDBq-qYVdU
|
|
|
41
41
|
matchlab/models/linkers/__init__.py,sha256=qbj6hITLFBuONO0_fn3yPknTUAWjzWcFSQzIyi75Rmc,339
|
|
42
42
|
matchlab/models/linkers/base.py,sha256=Wp4Eym5ZyDy3nBeaFVLaHDgB4rC2CSV7VFclPD5PkAI,1488
|
|
43
43
|
matchlab/models/linkers/deterministic.py,sha256=TQmwy9tPL0F7BStpoQaET6xTbyt1znujebzV7IB3CG0,8735
|
|
44
|
-
matchlab/models/linkers/splinklinker.py,sha256=
|
|
44
|
+
matchlab/models/linkers/splinklinker.py,sha256=iB3h6jSGCzo4HsF3P0mOKt0YqTvq3deukz6VaFb6a6A,11513
|
|
45
45
|
matchlab/models/linkers/weighteddeterministic.py,sha256=sD2QosF4IJMQwUwmuWzDKCr6Cta7RZyjS43I15WMtTI,4050
|
|
46
46
|
matchlab/resolvers/__init__.py,sha256=QuWZ6zt2QK7tWn9lr83iYUTkNQxwtAM9PITaoJOOpPs,210
|
|
47
47
|
matchlab/resolvers/base.py,sha256=s8u3uDZEln9FadCLEc6nxZZICu3KgIP4ShSe-PxruZM,2281
|
|
@@ -68,9 +68,9 @@ matchlab/transformers/explode.py,sha256=k8Q__0aFdntyGxkffu2KWSJmCVCu7ImvJtRt-32Q
|
|
|
68
68
|
matchlab/transformers/group.py,sha256=oPswcSUdj_2W2WCb0gJ51YSWU3qI3MVfZ3aYw5EXDl0,2374
|
|
69
69
|
matchlab/transformers/select.py,sha256=s3NmMgL5IS7ifeIhj5ABQJ46XULW6s_kQKASOq7G0sQ,1125
|
|
70
70
|
matchlab/transformers/transform.py,sha256=KlzKlbEvA5tF6wBSK9RuXkHLd6gckfhDsT6SJQa3atQ,7007
|
|
71
|
-
matchlab-0.1.0.
|
|
72
|
-
matchlab-0.1.0.
|
|
73
|
-
matchlab-0.1.0.
|
|
74
|
-
matchlab-0.1.0.
|
|
75
|
-
matchlab-0.1.0.
|
|
76
|
-
matchlab-0.1.0.
|
|
71
|
+
matchlab-0.1.0.dev4.dist-info/licenses/LICENSE,sha256=7fhYMrjTXiy7Sf-9lBtZHNc4IjHTcfqmGW0AcjFMJJo,1090
|
|
72
|
+
matchlab-0.1.0.dev4.dist-info/METADATA,sha256=lbnW3f0uznFSzADcZkaA4AJ7H3p7TnxCR8Vn_PuX0qA,3268
|
|
73
|
+
matchlab-0.1.0.dev4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
74
|
+
matchlab-0.1.0.dev4.dist-info/entry_points.txt,sha256=giDdKIeNDxGNWrvoVuLA5mtQjwStupSwxDYmrRjM9Rs,47
|
|
75
|
+
matchlab-0.1.0.dev4.dist-info/top_level.txt,sha256=1ZVLg12gZgkOUbN5CfX-KmLTbHGRbZqFEZPqrfRVN-Y,9
|
|
76
|
+
matchlab-0.1.0.dev4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|