Skip to content

Commit 1742ef3

Browse files
Add Ward linkage to hierarchical clustering (#336)
Ward linkage was disabled in the parallel nearest-neighbor dendrogram because it produced incorrect dissimilarities. Two things were missing: * Ward's Lance-Williams recurrence needs the size of the third cluster (the one whose distance is being updated), which differs per column of the pairwise matrix. The other linkages only need the sizes of the two clusters being merged, so those sizes were the only ones threaded through merge_clades. We now also track the per-cluster sizes and pass them to the update function. * The recurrence runs on squared distances, so the update squares the pairwise distances before combining them and takes the square root of the result. With both in place, the dissimilarities match SciPy's scipy.cluster.hierarchy.linkage(method: "ward"). Re-enables the linkage in fit/2, documents it, and restores the ward test.
1 parent 47bdfe0 commit 1742ef3

2 files changed

Lines changed: 66 additions & 19 deletions

File tree

lib/scholar/cluster/hierarchical.ex

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ defmodule Scholar.Cluster.Hierarchical do
1313
Due to the requirements of the current implementation, only these options are supported:
1414
1515
* `dissimilarity: :euclidean`
16-
* `linkage: :average | :complete | :single | :weighted`
16+
* `linkage: :average | :complete | :single | :ward | :weighted`
1717
1818
Our current algorithm is $O(\\frac{n^2}{p} \\cdot \\log(n))$ where $n$ is the number of data points
1919
and $p$ is the number of processors.
@@ -152,14 +152,14 @@ defmodule Scholar.Cluster.Hierarchical do
152152
:complete -> &complete/6
153153
# :median -> &median/6
154154
:single -> &single/6
155-
# :ward -> &ward/6
155+
:ward -> &ward/6
156156
:weighted -> &weighted/6
157157
end
158158

159159
dendrogram_fun =
160160
case linkage do
161-
# TODO: :centroid, :median, :ward
162-
l when l in [:average, :complete, :single, :weighted] ->
161+
# TODO: :centroid, :median
162+
l when l in [:average, :complete, :single, :ward, :weighted] ->
163163
&parallel_nearest_neighbor/3
164164
end
165165

@@ -196,10 +196,13 @@ defmodule Scholar.Cluster.Hierarchical do
196196
clades = Nx.broadcast(-1, {n - 1, 2})
197197
sizes = Nx.broadcast(1, {2 * n - 1})
198198
pointers = Nx.broadcast(-1, {2 * n - 2})
199+
200+
cluster_sizes = Nx.broadcast(1, {n})
199201
diss = Nx.tensor(:infinity, type: Nx.type(pairwise)) |> Nx.broadcast({n - 1})
200202

201-
{{clades, diss, sizes}, _} =
202-
while {{clades, diss, sizes}, {count = 0, pointers, pairwise}}, count < n - 1 do
203+
{{clades, diss, sizes, _cluster_sizes}, _} =
204+
while {{clades, diss, sizes, cluster_sizes}, {count = 0, pointers, pairwise}},
205+
count < n - 1 do
203206
# Indexes of who I am nearest to
204207
nearest = Nx.argmin(pairwise, axis: 1)
205208

@@ -213,27 +216,49 @@ defmodule Scholar.Cluster.Hierarchical do
213216
# They are bidirectional but let's keep only one side.
214217
links = Nx.select(clades_selector and nearest > nearest_of_nearest, nearest, n)
215218

216-
{clades, count, pointers, pairwise, diss, sizes} =
217-
merge_clades(clades, count, pointers, pairwise, diss, sizes, links, n, update_fun)
218-
219-
{{clades, diss, sizes}, {count, pointers, pairwise}}
219+
{clades, count, pointers, pairwise, diss, sizes, cluster_sizes} =
220+
merge_clades(
221+
clades,
222+
count,
223+
pointers,
224+
pairwise,
225+
diss,
226+
sizes,
227+
cluster_sizes,
228+
links,
229+
n,
230+
update_fun
231+
)
232+
233+
{{clades, diss, sizes, cluster_sizes}, {count, pointers, pairwise}}
220234
end
221235

222236
sizes = sizes[n..(2 * n - 2)]
223237
perm = Nx.argsort(diss, stable: false, type: :u32)
224238
{clades[perm], diss[perm], sizes[perm]}
225239
end
226240

227-
defnp merge_clades(clades, count, pointers, pairwise, diss, sizes, links, n, update_fun) do
228-
{{clades, count, pointers, pairwise, diss, sizes}, _} =
229-
while {{clades, count, pointers, pairwise, diss, sizes}, links},
241+
defnp merge_clades(
242+
clades,
243+
count,
244+
pointers,
245+
pairwise,
246+
diss,
247+
sizes,
248+
cluster_sizes,
249+
links,
250+
n,
251+
update_fun
252+
) do
253+
{{clades, count, pointers, pairwise, diss, sizes, cluster_sizes}, _} =
254+
while {{clades, count, pointers, pairwise, diss, sizes, cluster_sizes}, links},
230255
i <- 0..(Nx.size(links) - 1) do
231256
# i < j because of how links is formed.
232257
# i will become the new clade index and we "infinity-out" j.
233258
j = links[i]
234259

235260
if j == n do
236-
{{clades, count, pointers, pairwise, diss, sizes}, links}
261+
{{clades, count, pointers, pairwise, diss, sizes, cluster_sizes}, links}
237262
else
238263
# Clades a and b (i and j of pairwise) are being merged into c.
239264
indices = [i, j] |> Nx.stack() |> Nx.new_axis(-1)
@@ -251,6 +276,9 @@ defmodule Scholar.Cluster.Hierarchical do
251276
sc = sa + sb
252277
sizes = Nx.indexed_put(sizes, Nx.stack([i, c]) |> Nx.new_axis(-1), Nx.stack([sc, sc]))
253278

279+
cluster_sizes =
280+
Nx.indexed_put(cluster_sizes, Nx.stack([i, j]) |> Nx.new_axis(-1), Nx.stack([sc, sc]))
281+
254282
# Update dissimilarities
255283
diss = Nx.indexed_put(diss, Nx.stack([count]), pairwise[i][j])
256284

@@ -259,7 +287,7 @@ defmodule Scholar.Cluster.Hierarchical do
259287

260288
# Update pairwise
261289
updates =
262-
update_fun.(pairwise[i], pairwise[j], pairwise[i][j], sa, sb, sc)
290+
update_fun.(pairwise[i], pairwise[j], pairwise[i][j], sa, sb, cluster_sizes)
263291
|> Nx.indexed_put(indices, Nx.broadcast(:infinity, {2}))
264292

265293
pairwise =
@@ -269,11 +297,11 @@ defmodule Scholar.Cluster.Hierarchical do
269297
|> Nx.put_slice([j, 0], Nx.broadcast(:infinity, {1, n}))
270298
|> Nx.put_slice([0, j], Nx.broadcast(:infinity, {n, 1}))
271299

272-
{{clades, count + 1, pointers, pairwise, diss, sizes}, links}
300+
{{clades, count + 1, pointers, pairwise, diss, sizes, cluster_sizes}, links}
273301
end
274302
end
275303

276-
{clades, count, pointers, pairwise, diss, sizes}
304+
{clades, count, pointers, pairwise, diss, sizes, cluster_sizes}
277305
end
278306

279307
defnp find_clade(pointers, i) do
@@ -302,8 +330,8 @@ defmodule Scholar.Cluster.Hierarchical do
302330
defnp single(dac, dbc, _dab, _sa, _sb, _sc),
303331
do: Nx.min(dac, dbc)
304332

305-
# defnp ward(dac, dbc, dab, sa, sb, sc),
306-
# do: Nx.sqrt(((sa + sc) * dac + (sb + sc) * dbc - sc * dab) / (sa + sb + sc))
333+
defnp ward(dac, dbc, dab, sa, sb, sk),
334+
do: Nx.sqrt(((sa + sk) * dac ** 2 + (sb + sk) * dbc ** 2 - sk * dab ** 2) / (sa + sb + sk))
307335

308336
defnp weighted(dac, dbc, _dab, _sa, _sb, _sc),
309337
do: (dac + dbc) / 2

test/scholar/cluster/hierarchical_test.exs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,25 @@ defmodule Scholar.Cluster.HierarchicalTest do
127127
assert model.dissimilarities == Nx.tensor([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 2.0])
128128
end
129129

130+
test "ward", %{data: data} do
131+
model = Hierarchical.fit(data, linkage: :ward)
132+
133+
# Reference values taken from SciPy (scipy.cluster.hierarchy.linkage)
134+
assert_all_close(
135+
model.dissimilarities,
136+
Nx.tensor([
137+
1.0,
138+
1.0,
139+
1.0,
140+
1.29099445,
141+
1.29099445,
142+
1.29099445,
143+
5.77350269,
144+
7.45355992
145+
])
146+
)
147+
end
148+
130149
test "weighted", %{data: data} do
131150
model = Hierarchical.fit(data, linkage: :weighted)
132151

0 commit comments

Comments
 (0)