Skip to content

Commit 6feb044

Browse files
Fix tied nearest neighbors merging a clade twice
The chain only cycles back on its immediate predecessor while the distance along it strictly decreases. Ties break that: argmin can return a different clade that is tied for nearest, the chain walks back onto an entry it already holds, and the duplicate outlives the merge that consumed it. It is then merged a second time, after it is already gone, so a clade ends up under two parents, the sizes stop adding up, and the tree never closes over every point. End the chain whenever the previous entry is a nearest neighbor of the tip, not only when it is the one argmin returns.
1 parent c1f3a55 commit 6feb044

2 files changed

Lines changed: 76 additions & 9 deletions

File tree

lib/scholar/cluster/hierarchical.ex

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -295,10 +295,12 @@ defmodule Scholar.Cluster.Hierarchical do
295295
chain_length = Nx.select(needs_start, 1, chain_length)
296296

297297
# Extend the chain until its last two entries are mutual nearest neighbors.
298-
# Bounded by n: a real chain can never revisit a clade before terminating,
299-
# so exceeding n extensions only happens with a non-finite dissimilarity
300-
# (from NaN or infinite input), where argmin's tie-breaking can land on an
301-
# already-dead clade and loop without ever finding a genuine mutual pair.
298+
# Bounded by n: the distance along a chain strictly decreases, so a chain
299+
# can never revisit a clade before terminating, provided ties end it rather
300+
# than extend it (see `previous_is_nearest` below). Exceeding n extensions
301+
# then only happens with a non-finite dissimilarity (from NaN or infinite
302+
# input), where argmin's tie-breaking can land on an already-dead clade and
303+
# loop without ever finding a genuine mutual pair.
302304
{chain, chain_length, found, _steps, merge_diss, _pairwise, _alive} =
303305
while {chain, chain_length, found = Nx.u8(0), steps = 0,
304306
_chain_diss = Nx.Constants.infinity(Nx.type(pairwise)), pairwise, alive},
@@ -327,17 +329,44 @@ defmodule Scholar.Cluster.Hierarchical do
327329
# comment above); treat it as stalled rather than let the chain merge a
328330
# clade with itself.
329331
self_match = nearest == tip
330-
found = nearest == previous and not self_match
332+
333+
# The chain terminates whenever the previous entry is *a* nearest
334+
# neighbor of the tip, not only when it is the one argmin happens to
335+
# return. The two differ exactly when the tip's nearest distance is
336+
# tied, and preferring the previous entry there is what keeps the
337+
# chain from walking back onto a clade it already holds: a duplicated
338+
# entry survives the merge that pops its other occurrence, and is then
339+
# merged a second time, after it is already gone. The chain can only
340+
# cycle back on its immediate predecessor while distances strictly
341+
# decrease, which ties break.
342+
previous_is_nearest =
343+
chain_length > 1 and
344+
Nx.take(row, Nx.max(previous, 0)) == chain_diss
345+
346+
# `nearest == previous` on its own would miss a tie, and comparing the
347+
# distances on its own would miss a NaN, which is never equal to
348+
# itself. Either one ending the chain is enough.
349+
found = (previous_is_nearest or nearest == previous) and not self_match
350+
351+
# The chain holds live clades and so cannot outgrow its buffer, but a
352+
# non-finite dissimilarity can stall it without ever finding a pair, and
353+
# a write past the end would otherwise be silently clamped onto the last
354+
# slot. Treat it as stalled, like a self match.
355+
stalled = self_match or chain_length >= n
331356

332357
chain =
333358
Nx.select(
334-
found or self_match,
359+
found or stalled,
335360
chain,
336-
Nx.indexed_put(chain, Nx.reshape(chain_length, {1, 1}), Nx.reshape(nearest, {1}))
361+
Nx.indexed_put(
362+
chain,
363+
Nx.reshape(Nx.min(chain_length, n - 1), {1, 1}),
364+
Nx.reshape(nearest, {1})
365+
)
337366
)
338367

339-
chain_length = Nx.select(found or self_match, chain_length, chain_length + 1)
340-
steps = Nx.select(self_match, n + 1, steps + 1)
368+
chain_length = Nx.select(found or stalled, chain_length, chain_length + 1)
369+
steps = Nx.select(stalled, n + 1, steps + 1)
341370
{chain, chain_length, found, steps, chain_diss, pairwise, alive}
342371
end
343372

test/scholar/cluster/hierarchical_test.exs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,44 @@ defmodule Scholar.Cluster.HierarchicalTest do
227227
|> Nx.to_list()
228228
|> Enum.all?(fn [left, right] -> left < right end)
229229
end
230+
231+
test "tied nearest neighbors still merge every point exactly once" do
232+
# Integer coordinates over a small range, so distances tie constantly and a
233+
# clade's nearest neighbor is very often not unique. The chain extension can
234+
# then walk back onto a clade it already holds, and the duplicate outlives the
235+
# merge that consumed it, so it gets merged a second time: a clade is used
236+
# twice, the sizes stop adding up, and the tree never closes over every point.
237+
data =
238+
Nx.tensor([
239+
[0.0, 2.0, 2.0],
240+
[1.0, 3.0, 1.0],
241+
[2.0, 3.0, 1.0],
242+
[0.0, 0.0, 0.0],
243+
[0.0, 1.0, 3.0],
244+
[3.0, 0.0, 3.0],
245+
[1.0, 1.0, 1.0],
246+
[1.0, 1.0, 3.0],
247+
[2.0, 2.0, 1.0],
248+
[1.0, 0.0, 1.0],
249+
[1.0, 1.0, 3.0],
250+
[1.0, 0.0, 0.0],
251+
[3.0, 2.0, 3.0],
252+
[0.0, 3.0, 1.0],
253+
[1.0, 1.0, 0.0],
254+
[3.0, 2.0, 3.0],
255+
[2.0, 0.0, 0.0],
256+
[0.0, 1.0, 0.0]
257+
])
258+
259+
model = Hierarchical.fit(data, linkage: :single)
260+
261+
# The last merge has to gather every point.
262+
assert Nx.to_number(model.sizes[-1]) == model.num_points
263+
264+
# And no clade may be merged into two different parents.
265+
children = model.clades |> Nx.to_flat_list()
266+
assert length(children) == children |> Enum.uniq() |> length()
267+
end
230268
end
231269

232270
describe "precomputed dissimilarity" do

0 commit comments

Comments
 (0)