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,327 @@
|
|
|
1
|
+
"""One generated source: a real plan node, its rows, and the partition they imply."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from functools import cache, wraps
|
|
5
|
+
from typing import Any, ParamSpec, Self, TypeVar
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
import pyarrow as pa
|
|
9
|
+
from faker import Faker
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
from sqlalchemy import Engine, create_engine
|
|
12
|
+
from sqlglot import cast, select
|
|
13
|
+
from sqlglot.expressions import column
|
|
14
|
+
|
|
15
|
+
from matchlab.locations import RelationalDBLocation
|
|
16
|
+
from matchlab.sources import Source
|
|
17
|
+
from matchlab.testkit._generate import generate_entities, generate_source
|
|
18
|
+
from matchlab.testkit.entities import Cluster, EntityReference, TrueEntity
|
|
19
|
+
from matchlab.testkit.features import FeatureConfig
|
|
20
|
+
|
|
21
|
+
P = ParamSpec("P")
|
|
22
|
+
R = TypeVar("R")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_features_hashable(func: Callable[P, R]) -> Callable[P, R]:
|
|
26
|
+
"""Let callers configure `source_factory` with plain dicts.
|
|
27
|
+
|
|
28
|
+
Converts each dict to a `FeatureConfig` before the wrapped function runs, so
|
|
29
|
+
`source_factory` stays hashable, which `@cache` needs, while its callers don't have
|
|
30
|
+
to build `FeatureConfig` objects by hand.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@wraps(func)
|
|
34
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
35
|
+
# Handle features in first positional arg
|
|
36
|
+
if args and args[0] is not None:
|
|
37
|
+
if isinstance(args[0][0], dict):
|
|
38
|
+
args = (tuple(FeatureConfig(**d) for d in args[0]),) + args[1:]
|
|
39
|
+
else:
|
|
40
|
+
args = (tuple(args[0]),) + args[1:]
|
|
41
|
+
|
|
42
|
+
# Handle features in kwargs
|
|
43
|
+
if "features" in kwargs and kwargs["features"] is not None:
|
|
44
|
+
if isinstance(kwargs["features"][0], dict):
|
|
45
|
+
kwargs["features"] = tuple(
|
|
46
|
+
FeatureConfig(**d) for d in kwargs["features"]
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
kwargs["features"] = tuple(kwargs["features"])
|
|
50
|
+
|
|
51
|
+
return func(*args, **kwargs)
|
|
52
|
+
|
|
53
|
+
return wrapper
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class GeneratedSource(BaseModel):
|
|
57
|
+
"""Generated rows, and the `Source` plan node that reads them.
|
|
58
|
+
|
|
59
|
+
Sources that share entities are what make linking testable. Use
|
|
60
|
+
`linked_sources_factory` in `linked` instead when you need that.
|
|
61
|
+
|
|
62
|
+
This exposes what it knows about the *fixture*: the rows, the features they were
|
|
63
|
+
generated from, and the partition they imply. Anything you do to the *plan* goes
|
|
64
|
+
through `.source`, which is the node itself: `source.source.clean(...)`,
|
|
65
|
+
`source.source.name`, `source.source.spec`. There is deliberately no shortcut. A
|
|
66
|
+
testkit that forwarded those would be indistinguishable from the node it wraps, and
|
|
67
|
+
knowing which one you are holding is the whole point.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
71
|
+
|
|
72
|
+
source: Source = Field(
|
|
73
|
+
description="The Source object containing the spec and convenience methods."
|
|
74
|
+
)
|
|
75
|
+
features: tuple[FeatureConfig, ...] | None = Field(
|
|
76
|
+
description=(
|
|
77
|
+
"The features used to generate the data. "
|
|
78
|
+
"If None, the source data was not generated, but set manually."
|
|
79
|
+
),
|
|
80
|
+
default=None,
|
|
81
|
+
)
|
|
82
|
+
data: pa.Table = Field(
|
|
83
|
+
description="Data corresponding to the output of queries, without leaf data."
|
|
84
|
+
)
|
|
85
|
+
input_clusters: tuple[Cluster, ...] = Field(
|
|
86
|
+
description=(
|
|
87
|
+
"The partition a matcher starts from: generated rows grouped by identical "
|
|
88
|
+
"content. Not the answer, that is LinkedSources.true_entities."
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def field_names(self) -> list[str]:
|
|
94
|
+
"""The non-key columns of this testkit's data, in generation order.
|
|
95
|
+
|
|
96
|
+
Taken from the testkit rather than from `Source.index_fields`, which would have
|
|
97
|
+
to read the warehouse. These names are needed before a plan is even built.
|
|
98
|
+
Falls back to the data's own columns when the source was set manually rather
|
|
99
|
+
than generated from features.
|
|
100
|
+
"""
|
|
101
|
+
if self.features is not None:
|
|
102
|
+
return [feature.name for feature in self.features]
|
|
103
|
+
return [
|
|
104
|
+
column for column in self.data.column_names if column not in ("key", "id")
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
def write_to_location(self, client: Any | None = None) -> Self: # noqa: ANN401
|
|
108
|
+
"""Write the data to the source's location.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
client: Write through this client instead of the source's own, rebuilding
|
|
112
|
+
the source's location around it. Testkits are generated against an
|
|
113
|
+
in-memory SQLite engine by default, so a test that wants a real
|
|
114
|
+
warehouse repoints one here.
|
|
115
|
+
"""
|
|
116
|
+
if client:
|
|
117
|
+
self.source.location = type(self.source.location)(
|
|
118
|
+
name=self.source.location.name, client=client
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
pl.from_arrow(self.data).write_database(
|
|
122
|
+
table_name=self.source.name,
|
|
123
|
+
connection=self.source.location.client,
|
|
124
|
+
if_table_exists="replace",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def values_of(
|
|
131
|
+
entity: TrueEntity | Cluster, sources: dict[str, GeneratedSource]
|
|
132
|
+
) -> dict[str, dict[str, list[str]]]:
|
|
133
|
+
"""Every unique value this entity took, per source and per feature.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
entity: The entity whose rows to look up.
|
|
137
|
+
sources: The generated sources to look in, by name.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
`{source: {feature: sorted unique values}}`. Each source may vary the same base
|
|
141
|
+
value differently, so they are kept apart.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
ValueError: If the entity references a source that was not supplied.
|
|
145
|
+
"""
|
|
146
|
+
values: dict[str, dict[str, list[str]]] = {}
|
|
147
|
+
|
|
148
|
+
for source_name, keys in entity.keys.items():
|
|
149
|
+
source = sources.get(source_name)
|
|
150
|
+
if source is None:
|
|
151
|
+
raise ValueError(f"Source testkit not found: {source_name}")
|
|
152
|
+
|
|
153
|
+
rows = pl.DataFrame(source.data).filter(pl.col("key").is_in(list(keys)))
|
|
154
|
+
values[source_name] = {
|
|
155
|
+
feature.name: sorted(rows[feature.name].unique().to_list())
|
|
156
|
+
for feature in source.features
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return values
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@make_features_hashable
|
|
163
|
+
@cache
|
|
164
|
+
def source_factory(
|
|
165
|
+
features: list[FeatureConfig] | list[dict] | None = None,
|
|
166
|
+
name: str | None = None,
|
|
167
|
+
location_name: str = "dbname",
|
|
168
|
+
engine: Engine | None = None,
|
|
169
|
+
n_true_entities: int = 10,
|
|
170
|
+
repetition: int = 0,
|
|
171
|
+
seed: int = 42,
|
|
172
|
+
) -> GeneratedSource:
|
|
173
|
+
"""Generate a complete source testkit from configured features.
|
|
174
|
+
|
|
175
|
+
Sources created with the factory system can only use a RelationalDBLocation,
|
|
176
|
+
and the data at that location will be stored in a single table.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
features: List of FeatureConfig objects or dictionaries to use for generating
|
|
180
|
+
the source data. If None, defaults to a set of common features.
|
|
181
|
+
name: Name of the source. If None, a unique name is generated. This will be
|
|
182
|
+
used as the name of the table in the RelationalDBLocation, but also in
|
|
183
|
+
the str for the source.
|
|
184
|
+
location_name: Name of the location for the source.
|
|
185
|
+
engine: SQLAlchemy engine to use for the source's RelationalDBLocation. If
|
|
186
|
+
None, an in-memory SQLite engine is created.
|
|
187
|
+
n_true_entities: Number of true entities to generate. Defaults to 10.
|
|
188
|
+
repetition: Number of times to repeat the generated data. Defaults to 0.
|
|
189
|
+
seed: Random seed for reproducibility. Defaults to 42.
|
|
190
|
+
"""
|
|
191
|
+
generator = Faker()
|
|
192
|
+
generator.seed_instance(seed)
|
|
193
|
+
|
|
194
|
+
if features is None:
|
|
195
|
+
features = (
|
|
196
|
+
FeatureConfig(
|
|
197
|
+
name="company_name",
|
|
198
|
+
base_generator="company",
|
|
199
|
+
),
|
|
200
|
+
FeatureConfig(
|
|
201
|
+
name="crn",
|
|
202
|
+
base_generator="bothify",
|
|
203
|
+
parameters=(("text", "???-###-???-###"),),
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if name is None:
|
|
208
|
+
name = generator.unique.word()
|
|
209
|
+
|
|
210
|
+
if engine is None:
|
|
211
|
+
engine = create_engine("sqlite:///:memory:")
|
|
212
|
+
|
|
213
|
+
# Generate base entities
|
|
214
|
+
base_entities = generate_entities(
|
|
215
|
+
generator=generator,
|
|
216
|
+
features=features,
|
|
217
|
+
n=n_true_entities,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Generate data using the base entities
|
|
221
|
+
data, entity_keys, row_keys = generate_source(
|
|
222
|
+
generator=generator,
|
|
223
|
+
n_true_entities=n_true_entities,
|
|
224
|
+
features=features,
|
|
225
|
+
repetition=repetition,
|
|
226
|
+
seed_entities=base_entities,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Create true entities with references
|
|
230
|
+
true_entities = []
|
|
231
|
+
for entity in base_entities:
|
|
232
|
+
keys = entity_keys.get(entity.id, [])
|
|
233
|
+
if keys:
|
|
234
|
+
entity.add_source_reference(name, keys)
|
|
235
|
+
true_entities.append(entity)
|
|
236
|
+
|
|
237
|
+
# Create Cluster objects from row_keys
|
|
238
|
+
clusters = [
|
|
239
|
+
Cluster(
|
|
240
|
+
id=row_id,
|
|
241
|
+
keys=EntityReference({name: frozenset(keys)}),
|
|
242
|
+
)
|
|
243
|
+
for row_id, keys in row_keys.items()
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
# Create source config
|
|
247
|
+
source = Source(
|
|
248
|
+
location=RelationalDBLocation(name=location_name, client=engine),
|
|
249
|
+
name=name,
|
|
250
|
+
extract_transform=select(
|
|
251
|
+
cast(column("key"), "string").as_("key"),
|
|
252
|
+
*[column(feature.name) for feature in features],
|
|
253
|
+
)
|
|
254
|
+
.from_(name)
|
|
255
|
+
.sql(),
|
|
256
|
+
key_field="key",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return GeneratedSource(
|
|
260
|
+
source=source,
|
|
261
|
+
features=features,
|
|
262
|
+
data=data,
|
|
263
|
+
input_clusters=tuple(sorted(clusters)),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def source_from_tuple(
|
|
268
|
+
data_tuple: tuple[dict[str, Any], ...],
|
|
269
|
+
data_keys: tuple[Any],
|
|
270
|
+
name: str | None = None,
|
|
271
|
+
location_name: str = "dbname",
|
|
272
|
+
engine: Engine | None = None,
|
|
273
|
+
seed: int = 42,
|
|
274
|
+
) -> GeneratedSource:
|
|
275
|
+
"""Generate a complete source testkit from dummy data."""
|
|
276
|
+
generator = Faker()
|
|
277
|
+
generator.seed_instance(seed)
|
|
278
|
+
|
|
279
|
+
if name is None:
|
|
280
|
+
name = generator.unique.word()
|
|
281
|
+
|
|
282
|
+
if engine is None:
|
|
283
|
+
engine = create_engine("sqlite:///:memory:")
|
|
284
|
+
|
|
285
|
+
base_entities = tuple(TrueEntity(base_values=row) for row in data_tuple)
|
|
286
|
+
|
|
287
|
+
# Create true entities with references
|
|
288
|
+
true_entities: list[TrueEntity] = []
|
|
289
|
+
for entity, key in zip(base_entities, data_keys, strict=True):
|
|
290
|
+
entity.add_source_reference(name, [key])
|
|
291
|
+
true_entities.append(entity)
|
|
292
|
+
# A list, not a set: these are zipped against `data_keys` below, so they must stay
|
|
293
|
+
# in row order. A set comprehension paired each key with an arbitrary entity.
|
|
294
|
+
entity_ids = [entity.id for entity in true_entities]
|
|
295
|
+
|
|
296
|
+
# Create Cluster objects from row_keys
|
|
297
|
+
clusters = [
|
|
298
|
+
Cluster(
|
|
299
|
+
id=entity_id,
|
|
300
|
+
keys=EntityReference({name: frozenset([key])}),
|
|
301
|
+
)
|
|
302
|
+
for key, entity_id in zip(data_keys, entity_ids, strict=True)
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
# Create source config
|
|
306
|
+
source = Source(
|
|
307
|
+
location=RelationalDBLocation(name=location_name, client=engine),
|
|
308
|
+
name=name,
|
|
309
|
+
extract_transform=select(
|
|
310
|
+
cast(column("key"), "string").as_("key"),
|
|
311
|
+
*[column(field) for field in data_tuple[0]],
|
|
312
|
+
)
|
|
313
|
+
.from_(name)
|
|
314
|
+
.sql(),
|
|
315
|
+
key_field="key",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
raw_data = pa.Table.from_pylist(list(data_tuple))
|
|
319
|
+
raw_keys = pa.array(data_keys)
|
|
320
|
+
|
|
321
|
+
data = raw_data.append_column("id", [entity_ids]).append_column("key", raw_keys)
|
|
322
|
+
|
|
323
|
+
return GeneratedSource(
|
|
324
|
+
source=source,
|
|
325
|
+
data=data,
|
|
326
|
+
input_clusters=tuple(sorted(clusters)),
|
|
327
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Pluggable, serialisable reshapings of a record step.
|
|
2
|
+
|
|
3
|
+
`Select` projects, `Clean` derives, `Group` collapses each `id` to one row, and
|
|
4
|
+
`Explode` cross-joins each `id`'s columns back out to every combination, each one job.
|
|
5
|
+
Register a custom one with `add_transformer_class`, as `add_model_class` does a
|
|
6
|
+
deduper.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from matchlab.transformers.base import Transformer
|
|
10
|
+
from matchlab.transformers.clean import Clean
|
|
11
|
+
from matchlab.transformers.explode import Explode
|
|
12
|
+
from matchlab.transformers.group import Group
|
|
13
|
+
from matchlab.transformers.select import Select
|
|
14
|
+
from matchlab.transformers.transform import Transform, add_transformer_class
|
|
15
|
+
|
|
16
|
+
__all__ = (
|
|
17
|
+
"Clean",
|
|
18
|
+
"Explode",
|
|
19
|
+
"Group",
|
|
20
|
+
"Select",
|
|
21
|
+
"Transform",
|
|
22
|
+
"Transformer",
|
|
23
|
+
"add_transformer_class",
|
|
24
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Base class for transformer methodologies, and the DuckDB query runner some share.
|
|
2
|
+
|
|
3
|
+
A `Transformer` is a pure function of a record step, called by a `Transform` step's
|
|
4
|
+
`apply()` on every collect. Both input and output must carry `id`, the grouping every
|
|
5
|
+
downstream model and resolver reads.
|
|
6
|
+
|
|
7
|
+
Transformers are declarative and serialisable. Their fields are the configuration a
|
|
8
|
+
`Transform` folds into its spec and cache key, so keep them to plain data, with no
|
|
9
|
+
callables, exactly as a `Deduper`'s settings are.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
|
|
14
|
+
import duckdb
|
|
15
|
+
import polars as pl
|
|
16
|
+
from pydantic import BaseModel, ConfigDict
|
|
17
|
+
|
|
18
|
+
from matchlab.core.sql import SQLQuery
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Transformer(BaseModel, ABC):
|
|
22
|
+
"""Base contract every transformer implements.
|
|
23
|
+
|
|
24
|
+
Concrete transformers (`Select`, `Clean`, `Group`, `Explode`) carry their
|
|
25
|
+
configuration as flat fields, so `MyTransformer(...)` reads naturally, and
|
|
26
|
+
`model_dump(mode="json")` is the whole of its serialisation.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(frozen=True)
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def apply(self, data: pl.DataFrame) -> pl.DataFrame:
|
|
33
|
+
"""Return `data` reshaped. Both the input and the output carry `id`."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_sql(query: SQLQuery, data: pl.DataFrame) -> pl.DataFrame:
|
|
38
|
+
"""Execute one DuckDB query against `data`, registered as `data`."""
|
|
39
|
+
with duckdb.connect(":memory:") as connection:
|
|
40
|
+
connection.register("data", data)
|
|
41
|
+
return connection.execute(query).pl()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Clean derives columns. Add or replace named columns, keep the rest."""
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
from pydantic import Field, field_validator
|
|
5
|
+
from sqlglot import expressions, parse_one
|
|
6
|
+
from sqlglot import select as sqlglot_select
|
|
7
|
+
|
|
8
|
+
from matchlab.core.sql import SQLExpression
|
|
9
|
+
from matchlab.transformers.base import Transformer, run_sql
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Clean(Transformer):
|
|
13
|
+
"""Derive columns with DuckDB SQL, without dropping unrelated fields.
|
|
14
|
+
|
|
15
|
+
Each entry maps an output column name to a SQL expression over the record step's
|
|
16
|
+
columns
|
|
17
|
+
(the source-qualified names, `crn_company`). An alias that names an existing column
|
|
18
|
+
replaces it. A new alias is added. Every other column, and `id`, passes through
|
|
19
|
+
untouched. Dropping is `Select`'s job, not `Clean`'s.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
cleaning: dict[str, SQLExpression] = Field(
|
|
23
|
+
description="Output column name to a DuckDB SQL expression over the step."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@field_validator("cleaning")
|
|
27
|
+
@classmethod
|
|
28
|
+
def _non_empty(cls, cleaning: dict[str, SQLExpression]) -> dict[str, SQLExpression]:
|
|
29
|
+
"""A clean that derives nothing is meaningless. 'No cleaning' is no `Clean`."""
|
|
30
|
+
if not cleaning:
|
|
31
|
+
raise ValueError("Clean needs at least one expression to derive.")
|
|
32
|
+
return cleaning
|
|
33
|
+
|
|
34
|
+
def apply(self, data: pl.DataFrame) -> pl.DataFrame:
|
|
35
|
+
"""Add or replace the named columns, keeping the rest.
|
|
36
|
+
|
|
37
|
+
Invalid SQL raises at build time, naming the expression.
|
|
38
|
+
"""
|
|
39
|
+
collisions = [alias for alias in self.cleaning if alias in data.columns]
|
|
40
|
+
star = expressions.Star(except_=[expressions.column(a) for a in collisions])
|
|
41
|
+
projection: list[expressions.Expression] = [star]
|
|
42
|
+
for alias, sql in self.cleaning.items():
|
|
43
|
+
projection.append(
|
|
44
|
+
expressions.alias_(parse_one(sql, dialect="duckdb"), alias)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
query = sqlglot_select(*projection, dialect="duckdb").from_("data")
|
|
48
|
+
return run_sql(query.sql(dialect="duckdb"), data)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Explode changes granularity. Cross-join each `id`'s columns to every combination."""
|
|
2
|
+
|
|
3
|
+
from functools import reduce
|
|
4
|
+
from operator import mul
|
|
5
|
+
|
|
6
|
+
import polars as pl
|
|
7
|
+
from pydantic import Field, field_validator
|
|
8
|
+
|
|
9
|
+
from matchlab.transformers.base import Transformer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Explode(Transformer):
|
|
13
|
+
"""Give every combination of each `id`'s non-null column values its own row.
|
|
14
|
+
|
|
15
|
+
`Group` collapses each `id` to one row. `Explode` goes the other way, giving the
|
|
16
|
+
true cross product a `list` aggregate can't express. Every non-null value in one
|
|
17
|
+
column pairs with every non-null value in every other column, for the same `id`.
|
|
18
|
+
Values are deduplicated per column first, so a value repeated across records
|
|
19
|
+
counts once. A column that is all null for one `id` still keeps that row, with
|
|
20
|
+
`null` there, instead of dropping it.
|
|
21
|
+
|
|
22
|
+
An `id` with several populated columns can generate many rows fast, so
|
|
23
|
+
`max_combinations` caps it and raises, naming the `id`, rather than silently
|
|
24
|
+
building an enormous table.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
max_combinations: int = Field(
|
|
28
|
+
default=100,
|
|
29
|
+
description="Reject a group whose cross product would exceed this many rows.",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@field_validator("max_combinations")
|
|
33
|
+
@classmethod
|
|
34
|
+
def _positive(cls, max_combinations: int) -> int:
|
|
35
|
+
"""Zero or fewer combinations is a way to reject every group, not a cap."""
|
|
36
|
+
if max_combinations <= 0:
|
|
37
|
+
raise ValueError("max_combinations must be positive.")
|
|
38
|
+
return max_combinations
|
|
39
|
+
|
|
40
|
+
def apply(self, data: pl.DataFrame) -> pl.DataFrame:
|
|
41
|
+
"""Cross-join each `id`'s columns to every combination of their values."""
|
|
42
|
+
columns = [column for column in data.columns if column != "id"]
|
|
43
|
+
if not columns:
|
|
44
|
+
return data.select("id").unique(maintain_order=True)
|
|
45
|
+
|
|
46
|
+
grouped = data.group_by("id", maintain_order=True).agg(
|
|
47
|
+
[pl.col(column).drop_nulls().unique() for column in columns]
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Length 1 stands in for an empty list too, since exploding one keeps the row.
|
|
51
|
+
sizes = [pl.col(column).list.len().clip(lower_bound=1) for column in columns]
|
|
52
|
+
counts = grouped.select("id", reduce(mul, sizes).alias("combinations"))
|
|
53
|
+
overflow = counts.filter(pl.col("combinations") > self.max_combinations)
|
|
54
|
+
if overflow.height:
|
|
55
|
+
row = overflow.row(0, named=True)
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"id {row['id']} would generate {row['combinations']:,} "
|
|
58
|
+
f"combinations, which exceeds the maximum of "
|
|
59
|
+
f"{self.max_combinations}. This would cause a Cartesian explosion."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# One column at a time compounds into the true cross product. Exploding
|
|
63
|
+
# several columns in one call zips them in parallel instead.
|
|
64
|
+
exploded = grouped
|
|
65
|
+
for column in columns:
|
|
66
|
+
exploded = exploded.explode(column, empty_as_null=True)
|
|
67
|
+
return exploded
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Group changes granularity. Collapse each `id` to one row."""
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
from pydantic import Field, field_validator
|
|
5
|
+
from sqlglot import expressions, parse_one
|
|
6
|
+
from sqlglot import select as sqlglot_select
|
|
7
|
+
|
|
8
|
+
from matchlab.core.sql import SQLExpression
|
|
9
|
+
from matchlab.transformers.base import Transformer, run_sql
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Group(Transformer):
|
|
13
|
+
"""Collapse each `id` to a single row, combining columns with aggregate SQL.
|
|
14
|
+
|
|
15
|
+
Reading several records per entity (through a resolver) gives a model more evidence,
|
|
16
|
+
but a comparison needs one row. `Group` says how each column combines. `id` is the
|
|
17
|
+
grouping key, and every named expression is a DuckDB aggregate, for example
|
|
18
|
+
`any_value(crn_company)` where records agree or `list(distinct crn_town)` where they
|
|
19
|
+
don't. Only `id` and the named aggregates survive, since grouping has to decide how
|
|
20
|
+
every column collapses.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
aggregates: dict[str, SQLExpression] = Field(
|
|
24
|
+
description="Output column name to a DuckDB aggregate expression."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@field_validator("aggregates")
|
|
28
|
+
@classmethod
|
|
29
|
+
def _non_empty(
|
|
30
|
+
cls, aggregates: dict[str, SQLExpression]
|
|
31
|
+
) -> dict[str, SQLExpression]:
|
|
32
|
+
"""Grouping collapses rows, so each column must say how it combines."""
|
|
33
|
+
if not aggregates:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"Group needs aggregate expressions: grouping collapses several records "
|
|
36
|
+
"into one row, so each column has to say how it combines. Pass "
|
|
37
|
+
"aggregates such as {'name': 'any_value(crn_company)'}."
|
|
38
|
+
)
|
|
39
|
+
return aggregates
|
|
40
|
+
|
|
41
|
+
def apply(self, data: pl.DataFrame) -> pl.DataFrame:
|
|
42
|
+
"""Collapse each `id` to one row using the aggregate expressions.
|
|
43
|
+
|
|
44
|
+
A non-aggregate expression raises DuckDB's own error, naming the column.
|
|
45
|
+
"""
|
|
46
|
+
identifier = expressions.alias_(expressions.column("id"), "id")
|
|
47
|
+
projection: list[expressions.Expression] = [identifier]
|
|
48
|
+
for alias, sql in self.aggregates.items():
|
|
49
|
+
projection.append(
|
|
50
|
+
expressions.alias_(parse_one(sql, dialect="duckdb"), alias)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
query = (
|
|
54
|
+
sqlglot_select(*projection, dialect="duckdb").from_("data").group_by("id")
|
|
55
|
+
)
|
|
56
|
+
return run_sql(query.sql(dialect="duckdb"), data)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Select projects. Keep the named columns, drop the rest."""
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from matchlab.transformers.base import Transformer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Select(Transformer):
|
|
10
|
+
"""Keep only the named columns, plus `id`.
|
|
11
|
+
|
|
12
|
+
Projection is `Select`'s one job. Naming a column keeps it, and every column you do
|
|
13
|
+
not name is dropped. `id` survives whether or not you name it, since it is the
|
|
14
|
+
grouping a model matches on. Columns are the source-qualified names (`crn_company`).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
columns: tuple[str, ...] = Field(
|
|
18
|
+
default=(),
|
|
19
|
+
description="The columns to keep, by their current (qualified) names.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def __init__(self, *columns: str, **data: object) -> None:
|
|
23
|
+
"""Take the columns to keep positionally, e.g. `Select('crn_company')`."""
|
|
24
|
+
if columns:
|
|
25
|
+
data["columns"] = columns
|
|
26
|
+
super().__init__(**data)
|
|
27
|
+
|
|
28
|
+
def apply(self, data: pl.DataFrame) -> pl.DataFrame:
|
|
29
|
+
"""Project to `id` and the named columns, in that order."""
|
|
30
|
+
kept = [column for column in self.columns if column != "id"]
|
|
31
|
+
return data.select("id", *kept)
|