From 48c5cab3fdf6662b9b109e1ecbea44bdf8b34cc5 Mon Sep 17 00:00:00 2001 From: Ricardo Carvalho Santos Date: Tue, 20 Oct 2026 19:46:24 -0300 Subject: [PATCH 1/2] Fix DBSCAN scaling each sample by its own weight The :weights tensor was broadcast along the wrong axis, so the density of a sample came out as its own weight times the size of its neighborhood, instead of the sum of the weights inside that neighborhood. Core points and clusters were wrong whenever the weights were not uniform, and nothing raised. scikit-learn sums sample_weight over the neighborhood, which is also what the :min_samples documentation already describes as the total weight. --- lib/scholar/cluster/dbscan.ex | 2 +- test/scholar/cluster/dbscan_test.exs | 30 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/scholar/cluster/dbscan.ex b/lib/scholar/cluster/dbscan.ex index 39af5be5..bcaf51cc 100644 --- a/lib/scholar/cluster/dbscan.ex +++ b/lib/scholar/cluster/dbscan.ex @@ -94,7 +94,7 @@ defmodule Scholar.Cluster.DBSCAN do defnp fit_n(x, opts) do num_samples = Nx.axis_size(x, 0) weights = validate_weights(opts[:weights], num_samples, type: to_float_type(x)) - weights = if Nx.rank(weights) == 0, do: weights, else: Nx.new_axis(weights, -1) + weights = if Nx.rank(weights) == 0, do: weights, else: Nx.new_axis(weights, 0) y_dummy = Nx.broadcast(Nx.tensor(0), {num_samples}) neighbor_model = diff --git a/test/scholar/cluster/dbscan_test.exs b/test/scholar/cluster/dbscan_test.exs index 667a5389..73f155cc 100644 --- a/test/scholar/cluster/dbscan_test.exs +++ b/test/scholar/cluster/dbscan_test.exs @@ -40,6 +40,36 @@ defmodule Scholar.Cluster.DBSCANTest do assert model.core_sample_indices == Nx.tensor([0, 1, 0, 1, 0, 1, 0, 1, 0, 0], type: :u8) end + test "fit with weights" do + # Expected values from scikit-learn 1.6.1 on this data. + x = Nx.tensor([[1, 2], [2, 2], [2, 3], [8, 7], [8, 8], [25, 80]]) + weights = Nx.tensor([5.0, 0.1, 0.1, 1.0, 1.0, 1.0]) + model = DBSCAN.fit(x, eps: 3, min_samples: 2, weights: weights) + + assert model.labels == Nx.tensor([0, 0, 0, 1, 1, -1]) + assert model.core_sample_indices == Nx.tensor([1, 1, 1, 1, 1, 0], type: :u8) + end + + test "fit with weights joining two clusters through a light sample" do + # Expected values from scikit-learn 1.6.1 on this data. + x = Nx.tensor([[0, 0], [1, 0], [2, 0]]) + weights = Nx.tensor([5.0, 0.1, 5.0]) + model = DBSCAN.fit(x, eps: 1.5, min_samples: 3, weights: weights) + + assert model.labels == Nx.tensor([0, 0, 0]) + assert model.core_sample_indices == Nx.tensor([1, 1, 1], type: :u8) + end + + test "fit with negative weights" do + # Expected values from scikit-learn 1.6.1 on this data. + x = Nx.tensor([[0, 0], [1, 0], [2, 0]]) + weights = Nx.tensor([3.0, -1.0, 3.0]) + model = DBSCAN.fit(x, eps: 1.5, min_samples: 3, weights: weights) + + assert model.labels == Nx.tensor([0, 0, 0]) + assert model.core_sample_indices == Nx.tensor([0, 1, 0], type: :u8) + end + test "test with artificially created 3 blobs" do x = Nx.tensor([ From 5ff1a19620bb9a3724b8ebc048f2aa664f7341f5 Mon Sep 17 00:00:00 2001 From: Ricardo Carvalho Santos Date: Tue, 20 Oct 2026 19:46:32 -0300 Subject: [PATCH 2/2] Pass DBSCAN weights as an argument to the numerical definition A tensor given as an option is inlined into the expression rather than becoming a parameter, so fitting with :weights while the default backend is a device backend raised Nx.Defn.IncompatibleBackendsError. K-Means already validates its weights in the transform and forwards them as an argument, so DBSCAN now does the same. --- lib/scholar/cluster/dbscan.ex | 8 +++++--- test/scholar/cluster/dbscan_test.exs | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/scholar/cluster/dbscan.ex b/lib/scholar/cluster/dbscan.ex index bcaf51cc..87e2bf20 100644 --- a/lib/scholar/cluster/dbscan.ex +++ b/lib/scholar/cluster/dbscan.ex @@ -88,12 +88,14 @@ defmodule Scholar.Cluster.DBSCAN do } """ deftransform fit(x, opts \\ []) do - fit_n(x, NimbleOptions.validate!(opts, @opts_schema)) + opts = NimbleOptions.validate!(opts, @opts_schema) + {weights, opts} = Keyword.pop(opts, :weights, nil) + weights = validate_weights(weights, Nx.axis_size(x, 0), type: to_float_type(x)) + fit_n(x, weights, opts) end - defnp fit_n(x, opts) do + defnp fit_n(x, weights, opts) do num_samples = Nx.axis_size(x, 0) - weights = validate_weights(opts[:weights], num_samples, type: to_float_type(x)) weights = if Nx.rank(weights) == 0, do: weights, else: Nx.new_axis(weights, 0) y_dummy = Nx.broadcast(Nx.tensor(0), {num_samples}) diff --git a/test/scholar/cluster/dbscan_test.exs b/test/scholar/cluster/dbscan_test.exs index 73f155cc..f9add4ff 100644 --- a/test/scholar/cluster/dbscan_test.exs +++ b/test/scholar/cluster/dbscan_test.exs @@ -70,6 +70,20 @@ defmodule Scholar.Cluster.DBSCANTest do assert model.core_sample_indices == Nx.tensor([0, 1, 0], type: :u8) end + test "works with jit_apply" do + opts = [ + eps: 3.3, + min_samples: 2, + weights: Nx.tensor([5.0, 0.1, 0.1, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]) + ] + + model = DBSCAN.fit(x(), opts) + jitted = Nx.Defn.jit_apply(&DBSCAN.fit(&1, opts), [x()]) + + assert jitted.labels == model.labels + assert jitted.core_sample_indices == model.core_sample_indices + end + test "test with artificially created 3 blobs" do x = Nx.tensor([