Skip to content

deps: replace hopcroftkarp with scipy.sparse.csgraph.maximum_bipartite_matching - #107

Closed
sushovan4 wants to merge 1 commit into
scikit-tda:masterfrom
sushovan4:replace-hopcroftkarp-with-scipy
Closed

deps: replace hopcroftkarp with scipy.sparse.csgraph.maximum_bipartite_matching#107
sushovan4 wants to merge 1 commit into
scikit-tda:masterfrom
sushovan4:replace-hopcroftkarp-with-scipy

Conversation

@sushovan4

Copy link
Copy Markdown

Closes the two changes requested in #106.

Why

hopcroftkarp is GPLv3 and was last released 2019-10-11 (three releases, single author), so no install carrying persim can offer a permissive dependency closure — and since ripser depends on persim, that propagates across much of the scikit-tda stack.

Why it adds nothing

scipy.sparse.csgraph.maximum_bipartite_matching is Hopcroft–Karp, and scipy is already present in every working install via scikit-learn, though it was undeclared. The scipy entry makes an existing requirement visible rather than introducing a new one; hopcroftkarp leaves. Net change to the closure is one package removed.

The call site changes shape, not just name

hopcroftkarp took a dict of string-keyed adjacency sets and returned a dict holding both directions of every matched pair, so a perfect matching was tested as len(res) == 2 * n. scipy takes a CSR matrix and returns an array where res[i] is the column matched to row i, or -1 — so the test becomes np.all(res >= 0), which is equivalent here because D is square. Building the CSR matrix from a boolean array drops non-edges rather than storing them as explicit zeros, which is what maximum_bipartite_matching reads.

Distances are unchanged

Verified identical across the existing suite and on 40 randomly generated diagram pairs.

The returned matching may differ, and I would rather flag it than have you find it

The bottleneck distance is unique; the optimal matching attaining it need not be, and the two routines break ties differently. On 17 of those 40 pairs the matching differed while the distance did not.

The row count can differ too. Where one routine sends a point to the diagonal and the other pairs it across, a diagonal-to-diagonal row is dropped on the way out, so the returned array can be shorter or longer. Both remain valid: every point of both diagrams is accounted for exactly once, and no pair exceeds the distance.

If any downstream consumer depends on the exact pairs rather than on the distance, this is the change that would reach it. Happy to adjust if you would rather preserve the previous tie-breaking exactly, though I think that would mean pinning an implementation detail rather than a contract.

test_matching_is_optimal_when_several_are pins the contract every optimal matching satisfies, on a pair where the choice is real rather than hypothetical, and deliberately does not pin the pairs themselves.

Incidentally, it is faster

Same distances throughout, measured on random diagram pairs:

bars per diagram hopcroftkarp scipy speedup
20 0.010s 0.002s 5.2x
50 0.054s 0.003s 16.8x
100 0.224s 0.008s 26.9x
200 1.297s 0.041s 31.5x

The old path rebuilt a Python dict of sets at every step of the binary search.

Verification

  • Full suite: 109 passed (108 before, plus the new test).
  • Also run with hopcroftkarp uninstalled, so nothing imports it residually.
  • The new test was mutation-checked: it fails if the matching drops a point.

Left open

Happy to pin the scipy floor to whatever matches your convention for the rest of the requirements. maximum_bipartite_matching has been in scipy.sparse.csgraph since 1.4, so anything scikit-learn already implies would do.

Closes the two changes requested in scikit-tda#106.

`hopcroftkarp` is GPLv3 and was last released 2019-10-11, so no
install carrying persim can offer a permissive dependency closure --
and since ripser depends on persim, that propagates across much of
the scikit-tda stack.

`scipy.sparse.csgraph.maximum_bipartite_matching` is Hopcroft-Karp,
and scipy is already present in every working install via
scikit-learn, though it was undeclared. So this removes a dependency
and adds none: the scipy entry makes an existing requirement visible.

The call site changes shape rather than name. hopcroftkarp took a
dict of string-keyed adjacency sets and returned a dict holding both
directions of every matched pair, so a perfect matching was tested as
len(res) == 2 * n. scipy takes a CSR matrix and returns an array
where res[i] is the column matched to row i, or -1 -- so the test
becomes np.all(res >= 0), which is equivalent here because D is
square. Building the CSR matrix from a boolean array drops non-edges
rather than storing them as explicit zeros, which is what
maximum_bipartite_matching reads.

Bottleneck distances are unchanged: verified identical on the
existing suite and on 40 randomly generated diagram pairs.

The returned matching may differ where more than one is optimal. The
distance is unique; the matching attaining it need not be, and the
two routines break ties differently. On 17 of those 40 pairs the
matching differs while the distance does not, and the row count can
differ too, since a diagonal-to-diagonal row is dropped on the way
out. Both remain valid: every point of both diagrams is accounted for
exactly once and no pair exceeds the distance. test_matching_is_
optimal_when_several_are pins that contract on a pair where the
choice is real, and deliberately does not pin the pairs themselves.
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.83%. Comparing base (11d984a) to head (eceb584).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #107      +/-   ##
==========================================
- Coverage   79.40%   78.83%   -0.57%     
==========================================
  Files          20       20              
  Lines        1481     1479       -2     
  Branches      271      270       -1     
==========================================
- Hits         1176     1166      -10     
- Misses        229      235       +6     
- Partials       76       78       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sushovan4

Copy link
Copy Markdown
Author

Closing this in favour of #108, which does the same swap and is the one to review. Apologies for the duplicate on your tracker — that was our error, not a disagreement about the approach.

Everything in the description above applies to #108 as well, and two points are worth carrying over rather than losing with this thread:

The returned matching can differ where more than one is optimal. The bottleneck distance is unique; the matching attaining it need not be, and the two routines break ties differently. On 40 randomly generated diagram pairs the distance was identical every time and the matching differed on 17 of them. The row count can differ too, since a diagonal-to-diagonal row is dropped on the way out. Both remain valid — every point of both diagrams accounted for exactly once, no pair exceeding the distance — but a consumer depending on the exact pairs rather than on the distance would see it.

It is also faster, same distances throughout, measured on random pairs: 5.2x at 20 bars per diagram, 16.8x at 50, 26.9x at 100, 31.5x at 200. The old path rebuilt a Python dict of sets at every step of the binary search.

Happy to fold the tie-breaking test into #108 if you would like it there.

@sushovan4 sushovan4 closed this Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant