Replace Java DUKE with a re-tuned pure-Python rapidfuzz matching engine - #301
Draft
FabianHofmann wants to merge 9 commits into
Draft
Replace Java DUKE with a re-tuned pure-Python rapidfuzz matching engine#301FabianHofmann wants to merge 9 commits into
FabianHofmann wants to merge 9 commits into
Conversation
Add a pure-Python recordlinkage backend mirroring Comparison.xml (jarowinkler/qgram/numeric/geo + Fellegi-Sunter scoring) and a GEO x GPD benchmark vs DUKE. ~70% recall / 84% precision at threshold 0.965; findings in analysis/recordlinkage_findings.md.
…ernative Vectorised pure-Python matcher (rapidfuzz + numpy) mirroring both DUKE configs (Comparison.xml linkage, Deleteduplicates.xml dedup) with Fellegi-Sunter scoring. Selectable via config['matching_backend'] (default 'duke', unchanged); wired into compare_two_datasets and aggregate_units through duke.get_matcher. Validated against production-derived ground truth from powerplants.csv: - linkage (GEO x GPD): F1 0.394 vs DUKE 0.348, ~17x faster - dedup: 95% of merges principled, correctly collapses multi-unit plants DUKE misses, ~10x faster Adds rapidfuzz dependency, tests, and analysis/benchmark + findings.
Make the vectorised rapidfuzz + numpy record-linkage backend the sole matching engine and remove DUKE entirely. Matching no longer requires a Java installation or the bundled DUKE binaries, and is substantially faster (~17x linkage, ~10x dedup) at equal-or-better quality on an objective ground truth (see analysis/linkage_findings.md). - New powerplantmatching/linkage.py with match() (was duke_recordlinkage.duke) - Remove duke.py, get_matcher resolver, add_geoposition_for_duke - Remove duke_binaries/*.jar, Comparison.xml, Deleteduplicates.xml, LICENSES/Apache-2.0.txt and its REUSE/MANIFEST/doc references - matching.py/cleaning.py call match() directly; **dukeargs -> **kwargs - config: drop matching_backend; rename parallel_duke_processes -> parallel_processes - Rename tests/findings; drop benchmark that compared against DUKE
for more information, see https://pre-commit.ci
This was referenced Jul 11, 2026
…ion variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Drop the false DUKE-fidelity claim: the port uses a linear probability ramp, [0,1] geo falloff and Dice q-grams where DUKE used a quadratic curve, a [0.5,1] geo rescale and the overlap coefficient. The inherited constants were therefore re-tuned against the GEO/GPD ground truth (new analysis/benchmark_linkage.py), held out by country: threshold 0.965 -> 0.85, F1 0.844 -> 0.882 (pure recall). Bound dedup memory by row blocking (4.33 GB -> 334 MB at n=8662, results identical), fail fast on unknown kwargs, honour threads, treat "" as missing, warn and alias the renamed parallel_duke_processes config key, and cover the seven mutations the old suite let through.
Character-level ratios cannot separate "Doel 1" from "Doel 4", which collapsed each station's units into one record; combined with DateOut aggregation this dropped whole national nuclear fleets. Score names by mean best-token Jaro-Winkler instead, at unchanged speed and unchanged bounds/thresholds. Aggregate projectID/EIC into sorted lists so builds are byte-reproducible.
…hing-engine # Conflicts: # powerplantmatching/cleaning.py # powerplantmatching/duke.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
powerplantmatchinguses fuzzy record comparison for two jobs: linking records of the same plant across sources (GEO, GPD, ENTSO-E, OPSD and the rest), and deduplicating records within one source before those links are drawn. Both were done by DUKE, a Java record-linkage engine shipped as seven.jarfiles in the package data, invoked as a subprocess and configured through two XML files. Every user needed a working JVM to build a dataset, and the matching configuration lived outside Python where nothing could test it.This PR replaces DUKE with
powerplantmatching.linkage, a vectorised pure-Python backend built onrapidfuzzandnumpy, and makes it the sole engine. The jars, the XMLs,powerplantmatching/duke.pyand the Java setup step in CI are gone,rapidfuzzbecomes a hard dependency, and a full build takes 378 s instead of 950 s on identical inputs.Note
AI generated draft. Still experimental — see Known limitations.
The engine
linkagekeeps DUKE's overall shape. Two records are compared field by field; each field's similarity is mapped onto a probability between a configuredlowandhighbound; the fields are combined by a Fellegi-Sunter belief update over a 0.5 prior; pairs above a threshold are accepted.LINKAGE_FIELDS,DEDUP_FIELDSand the two thresholds are the direct successors of the deleted XMLs and now live in Python where tests can pin them.It is not a reimplementation, though. DUKE maps similarity onto probability with a quadratic curve and a hard floor below 0.5, treats geographic distance as always-positive evidence in
[0.5, 1], and uses the overlap coefficient for q-grams;linkageuses a linear ramp, a[0, 1]distance score and Dice. Around 10 % of accepted GEO×GPD pairs differ as a result, and constants fitted to one curve carry no justification under another, so the thresholds were re-tuned rather than inherited. The linkage threshold moves from 0.965 to 0.85; the field bounds and the dedup threshold ship unchanged.Names are compared token by token, following DUKE's
JaroWinklerTokenized: the similarity is the mean over one record's tokens of their best Jaro-Winkler match in the other, normalised by the longer token count. This is the one field where a character-level ratio is not usable, because plant records distinguish units of a station by a trailing designator — Kozloduy 1 through Kozloduy 6, Doel 1 and Doel 4, Neurath and Neurath F — and a one-character difference in six reads as near-identical to any character kernel. Sibling units score 0.50 and below and stay separate, while a genuine typo stays high: Gravelines 1 against Gravelins 1 scores 0.99. Reducing over the shared token vocabulary rather than over record pairs keeps this vectorised, at 6.5 s for a 6000-row dedup.Scoring runs in row blocks capped at
BLOCK_CELLS, so peak memory is flat innrather than quadratic: the MASTR dedup group of 8662 rows needs 337 MB instead of 4.33 GB, and takes 12.3 s instead of 17.8 s.Benchmarks
Two benchmarks live in
analysis/, answering different questions.analysis/benchmark_linkage.pyis new and is the tuning instrument. It recovers 567 GEO↔GPD pairs from theprojectIDcolumn of the productionpowerplants.csvas a cross-source ground truth, imports the production comparator, and evaluates a config in about 25 ms by caching the per-field similarity matrices. It is bit-exact with the production path — identical pairs tocompare_two_datasets, symmetric difference 0. Thresholds were fitted with a country-held-out protocol, and at the shipped 0.85 the engine reaches precision 0.920 / recall 0.847 / F1 0.882, against 0.934 / 0.771 / 0.844 at the old 0.965. This ground truth is DUKE-era pipeline output, so agreement with it partly measures agreement with DUKE.analysis/compare-with-entsoe-stats.pyis the independent check: the built dataset against ENTSO-E installed capacity for 2025, summed over the 162 country × fueltype cells ENTSO-E reports, excluding Wind and Solar (extended separately) and Other (an incomparable residual). The DUKE baseline is a dataset built from a worktree at the merge-base on identical inputs.linkageThe new engine is marginally better on absolute error and considerably better on bias. Nuclear lands 2.9 GW over the statistics against DUKE's 4.8 GW over, and Polish hard coal 18.6 GW against 19.0 reported. The largest residuals are data coverage rather than matching — IT Hard Coal −6.1, FR Natural Gas −4.4, NL Natural Gas −3.2, FR Hydro −3.2 — while slightly less merging than DUKE adds modest overestimates at DE Lignite +6.3 (DUKE +4.3), CH Hydro +4.2 and AT Hydro +3.8.
That script needed repair to produce any of these numbers.
entsoe-py0.7.0 silently returns all-NaN installed capacity for every CET/EET country, because yearlyP1Ydocuments start at 23:00 UTC anddate_range(freq='12MS')snaps forward past the only data point; the statistics are now parsed from the raw XML and cached underanalysis/data/. Two crashes and two taxonomy errors are fixed alongside, and the reference year moves from 2022 to 2025.Other fixes
projectIDandEICwere aggregated withset, whose iteration order follows the interpreter's hash seed, so two runs over identical inputs serialised different identifier strings. They are sorted lists now, and two full builds underPYTHONHASHSEED=0and=12345come out byte-identical across all 167,632 rows.match()no longer ends in a**_catch-all, so unknown keywords raise instead of being swallowed — which is howthreadscame to be dead end-to-end whileworkers=-1was hardcoded. It is now wired toprocess.cdist(workers=), taking a 6000-row dedup from 20.1 s to 6.3 s. Aparallel_duke_processeskey in an existing user config was silently ignored, dropping parallelism to 1; it now warns and aliases through toparallel_processes.aggregate_unitsfills string columns with""before dedup, so the presence mask never fired: two records both missingTechnologyscored a perfect match while missing-versus-populated scored a penalty. Zero-versus-zero capacity now scores identical rather than maximally different, results are dtype-stable, and the comparator moved ontoFieldSpec, deleting the stringly-typed dispatch.Tests
6 → 35 linkage tests, offline and deterministic, plus
test/test_linkage_integration.pycovering theaggregate_unitsandcompare_two_datasetspaths that had no non-network coverage. Field bounds and thresholds are pinned by a test, since the deleted XMLs were their only record. New cases assert that sibling units of one station do not merge, that the 1:1 reduction rather than the threshold resolves siblings across sources, that aggregated identifiers come out ordered, and that results are invariant acrossBLOCK_CELLSfrom 1 to 1e12.One existing case flipped: a 50× capacity difference at identical coordinates with the same fueltype, technology and country now links. It previously passed on the name score rather than on capacity, and strengthening the capacity veto to restore it is not supported by the benchmark, so the test id now says what it actually asserts.
Compatibility
Breaking changes:
powerplantmatching.dukeis gone andadd_geoposition_for_dukeis removed.parallel_duke_processesis renamed toparallel_processes, with a deprecating alias.projectIDandEIChold sorted lists instead of sets, and a missingEICis[]rather than{nan}, so set operations on those values need an explicitset(...).Known limitations
DateOut: "max"is a latent hazard. It skips NaN, so any aggregate mixing a retired unit with an operating one inherits the shutdown year. Returning NaN whenever a unit lacks a shutdown year was tried and measured worse — sources omitDateOutfor plants that are in fact closed, so it resurrects them, taking Σ|gap| from 101.9 to 121.1 GW — and was reverted."max"is the better estimator, but the ambiguity between "missing" and "still running" is unresolved in the data model.Deduplication has no pairwise ground truth. The intra-source truth in
powerplants.csvis circular, since production used DUKE for dedup. ENTSO-E validates it end to end instead, which is much coarser: it would not catch a merge error that conserves capacity within a country and fueltype. UK, Ukraine, Moldova and Kosovo return nothing from the transparency platform and sit outside that check entirely.Duplicate index labels silently collapse
singlematch. That is pre-existing, but nothing validates index uniqueness now that this is the only matcher.Background on the engine choice, the tuning protocol and the alternatives that were searched and rejected is in
analysis/linkage_findings.md.