Skip to content

Commit b4ed6f0

Browse files
Fix Hierarchical hanging on non-finite dissimilarities (#353)
A non-finite dissimilarity, from NaN or infinite values in the data or in a precomputed matrix, can make argmin's tie-breaking point two clades at each other without either being picked as the other's mutual nearest neighbor. No merge happens that round, and since nothing about the state changes, no merge ever happens again: fit/2 loops forever. This cannot happen for finite dissimilarities, where the globally closest pair of live clades is always mutually nearest, guaranteeing at least one merge per round. Stop the loop when a round makes no progress instead of running forever. The merges that could not be made are reported as clades of [-1, -1], sizes of 0, and NaN dissimilarities sorted to the end, rather than guessing a pairing that has no finite distance to justify it. Finite input is unaffected, since a round with no progress is impossible there.
1 parent 21be3c5 commit b4ed6f0

2 files changed

Lines changed: 96 additions & 6 deletions

File tree

lib/scholar/cluster/hierarchical.ex

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,17 @@ defmodule Scholar.Cluster.Hierarchical do
135135
If clade `k` was created by merging clades `i` and `j`, then
136136
`sizes[k] == sizes[i] + sizes[j]`.
137137
138+
## Incomplete dendrograms
139+
140+
Non-finite dissimilarities, which come from `:nan` or infinite values in `data` or in a
141+
precomputed matrix, can leave two or more clades with no finite distance to merge by. There
142+
is then no meaningful pair to merge next, so the remaining merges are not made and are
143+
reported as `clades` of `[-1, -1]`, `sizes` of `0`, and `NaN` dissimilarities, sorted to the
144+
end. Test for them with `clades` or `sizes`, since a merge that really was made can also
145+
carry a `NaN` dissimilarity when the underlying distances are themselves `NaN`:
146+
147+
Nx.any(Nx.equal(model.sizes, 0))
148+
138149
## Examples
139150
140151
iex> data = Nx.tensor([[2], [7], [9], [0], [3]])
@@ -231,9 +242,12 @@ defmodule Scholar.Cluster.Hierarchical do
231242
cluster_sizes = Nx.broadcast(1, {n})
232243
diss = Nx.tensor(:infinity, type: Nx.type(pairwise)) |> Nx.broadcast({n - 1})
233244

234-
{{clades, diss, sizes, _cluster_sizes}, _} =
235-
while {{clades, diss, sizes, cluster_sizes}, {count = 0, pointers, pairwise}},
236-
count < n - 1 do
245+
{{clades, diss, sizes, _cluster_sizes, count}, _} =
246+
while {{clades, diss, sizes, cluster_sizes, count = 0},
247+
{pointers, pairwise, aborted = Nx.u8(0)}},
248+
count < n - 1 and aborted == 0 do
249+
count_before_round = count
250+
237251
# Indexes of who I am nearest to
238252
nearest = Nx.argmin(pairwise, axis: 1)
239253

@@ -261,10 +275,29 @@ defmodule Scholar.Cluster.Hierarchical do
261275
update_fun
262276
)
263277

264-
{{clades, diss, sizes, cluster_sizes}, {count, pointers, pairwise}}
278+
# Non-finite dissimilarities (from NaN or infinite input) can make argmin's
279+
# tie-breaking point two clades at each other asymmetrically, so that neither
280+
# is ever picked as the other's mutual nearest neighbor and no merge happens.
281+
# That is otherwise impossible: for finite dissimilarities the globally closest
282+
# pair of live clades is always mutually nearest, guaranteeing progress every
283+
# round, which is why this never triggers on well formed input (count changing
284+
# is the only thing checked, so it costs nothing when it doesn't apply). When it
285+
# does happen, stop instead of guessing a merge: the two clades are stuck exactly
286+
# because there is no finite distance to justify pairing them over any other.
287+
aborted = count == count_before_round
288+
289+
{{clades, diss, sizes, cluster_sizes, count}, {pointers, pairwise, aborted}}
265290
end
266291

267292
sizes = sizes[n..(2 * n - 2)]
293+
294+
# Rows the loop never got to fill, if it aborted above. Marking their dissimilarity
295+
# NaN keeps them out of the way of the sort below (NaN orders after every real value,
296+
# including infinity) and reports the incomplete merges as such instead of leaving
297+
# their initial values looking like real ones.
298+
incomplete = Nx.iota({n - 1}) >= count
299+
diss = Nx.select(incomplete, Nx.Constants.nan(Nx.type(diss)), diss)
300+
268301
perm = Nx.argsort(diss, stable: true, type: :u32)
269302

270303
# A row at index `i` creates clade `n + i`. Reordering the rows therefore also requires
@@ -276,8 +309,20 @@ defmodule Scholar.Cluster.Hierarchical do
276309
clade_id_mapping =
277310
Nx.concatenate([Nx.iota({n}, type: Nx.type(clades)), inverse_perm + n])
278311

279-
clades = Nx.take(clade_id_mapping, clades[perm])
280-
{clades, diss[perm], sizes[perm]}
312+
# Incomplete rows sort to the tail, so the mask still marks them after the permutation.
313+
# Their clade ids stay -1 rather than being mapped through the table.
314+
sorted_clades = clades[perm]
315+
316+
clades =
317+
Nx.select(
318+
Nx.broadcast(Nx.new_axis(incomplete, -1), Nx.shape(sorted_clades)),
319+
-1,
320+
Nx.take(clade_id_mapping, Nx.max(sorted_clades, 0))
321+
)
322+
323+
sizes = Nx.select(incomplete, 0, sizes[perm])
324+
325+
{clades, diss[perm], sizes}
281326
end
282327

283328
defnp merge_clades(

test/scholar/cluster/hierarchical_test.exs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,51 @@ defmodule Scholar.Cluster.HierarchicalTest do
280280
end
281281
end
282282

283+
describe "non-finite dissimilarities" do
284+
# Regression tests for a real hang: with a non-finite dissimilarity (from :nan or
285+
# :infinity), argmin's tie-breaking can point two clades at each other without either
286+
# being picked as the other's mutual nearest neighbor, so no merge ever happens. This is
287+
# impossible for finite dissimilarities, where the globally closest pair of clades is
288+
# always mutually nearest to each other, guaranteeing at least one merge every round.
289+
# When it happens the loop now stops instead of running forever, and the merges it could
290+
# not make are reported as NaN dissimilarities with clade -1 and size 0.
291+
test "an infinite dissimilarity between the last two clades reports an incomplete merge" do
292+
# Point 3 is infinitely far from everyone. Once points 0, 1 and 2 (mutually close)
293+
# have merged, only two clades remain: {0, 1, 2} and {3}, at distance infinity, and
294+
# there is no finite distance left to justify merging them over any other pairing.
295+
d =
296+
Nx.tensor([
297+
[0.0, 1.0, 1.0, :infinity],
298+
[1.0, 0.0, 1.4142135623730951, :infinity],
299+
[1.0, 1.4142135623730951, 0.0, :infinity],
300+
[:infinity, :infinity, :infinity, 0.0]
301+
])
302+
303+
model = Hierarchical.fit(d, dissimilarity: :precomputed, linkage: :single)
304+
305+
assert model.num_points == 4
306+
# The two finite merges are made and kept, in ascending order.
307+
assert Nx.to_flat_list(model.dissimilarities) |> Enum.take(2) == [1.0, 1.0]
308+
# The merge that could not be made is reported instead of guessed.
309+
assert Nx.to_number(Nx.is_nan(model.dissimilarities[-1])) == 1
310+
assert Nx.to_flat_list(model.clades[-1]) == [-1, -1]
311+
assert Nx.to_number(model.sizes[-1]) == 0
312+
end
313+
314+
test "a NaN coordinate still makes every merge" do
315+
# NaN alone does not stall the loop: argmin keeps picking a mutual pair, so every
316+
# merge is made. The dissimilarities really are NaN here, since the distances are,
317+
# which is why an incomplete merge is identified by its clade and size, not by NaN.
318+
x = Nx.tensor([[0.0, 0.0], [0.1, 0.0], [0.2, 0.0], [5.0, 5.0], [5.1, 5.0], [:nan, 0.0]])
319+
320+
model = Hierarchical.fit(x)
321+
322+
assert model.num_points == 6
323+
assert Nx.to_number(Nx.all(Nx.not_equal(model.clades, -1))) == 1
324+
assert Nx.to_number(Nx.all(Nx.greater(model.sizes, 0))) == 1
325+
end
326+
end
327+
283328
describe "errors" do
284329
test "need a square tensor when dissimilarity is precomputed" do
285330
assert_raise(

0 commit comments

Comments
 (0)