DBSCAN with Custom Distance Metrics

SkillMonitoring & ops

Run DBSCAN clustering with a custom distance metric using sklearn, including how to define weighted Euclidean metrics and extract cluster centroids.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the DBSCAN with Custom Distance Metrics skill

What this skill tells your AI

The instructions your AI receives, as published by cxcscmu/skilllearnbench in skills/b1-one-shot-claude-sonnet-4-6/dbscan-parameter-tuning/dbscan-custom-metric/SKILL.md and read by ahel’s review.

Overview

scikit-learn's DBSCAN accepts a metric parameter that can be a callable Python function. When using a custom metric, set algorithm='ball_tree' or algorithm='brute' (default 'auto' may not support custom callables).

Installation

pip install scikit-learn numpy

Custom Metric Definition

A custom metric must accept two 1D arrays and return a scalar distance.

import numpy as np

def weighted_euclidean(a, b, w=1.0):
    """
    Weighted Euclidean distance:
      d(a, b) = sqrt((w * Δx)² + ((2 - w) * Δy)²)

    When w=1: standard Euclidean distance.
    w>1: attenuates y-distances (stretches x influence).
    w<1: attenuates x-distances (stretches y influence).
    """
    dx = a[0] - b[0]
    dy = a[1] - b[1]
    return np.sqrt((w * dx)**2 + ((2 - w) * dy)**2)

Using with DBSCAN

DBSCAN requires a metric with a fixed signature (a, b) -> float. Use functools.partial or a closure to bind parameters:

from sklearn.cluster import DBSCAN
from functools import partial
import numpy as np

def make_metric(shape_weight):
    def metric(a, b):
        dx = a[0] - b[0]
        dy = a[1] - b[1]
        return np.sqrt((shape_weight * dx)**2 + ((2 - shape_weight) * dy)**2)
    return metric

# Run DBSCAN
points = np.array([[x1, y1], [x2, y2], ...])  # shape (N, 2)
dbscan = DBSCAN(eps=epsilon, min_samples=min_samples, metric=make_metric(1.2))
labels = dbscan.fit_predict(points)

Important: When using a custom callable metric, sklearn uses algorithm='brute' internally. You do NOT need to pass a precomputed distance matrix — just pass the raw points array.

Extracting Cluster Centroids

import numpy as np

def get_cluster_centroids(points, labels):
    """
    Returns array of centroids for each cluster (excluding noise label -1).
    points: np.array of shape (N, 2)
    labels: np.array of cluster labels from DBSCAN
    """
    unique_labels = set(labels) - {-1}  # exclude noise
    centroids = []
    for label in unique_labels:
        mask = labels == label
        centroid = points[mask].mean(axis=0)
        centroids.append(centroid)
    return np.array(centroids) if centroids else np.empty((0, 2))

Greedy Matching of Centroids to Expert Points

Match predicted cluster centroids to expert annotations using greedy closest-pair matching with a max distance threshold:

from scipy.spatial.distance import cdist

def greedy_match(pred_centroids, expert_points, max_dist=100):
    """
    Greedily match predicted centroids to expert points.
    Returns (matched_pred, matched_expert) index pairs and distances.
    Uses standard Euclidean distance (not custom metric).
    """
    if len(pred_centroids) == 0 or len(expert_points) == 0:
        return [], []

    dist_matrix = cdist(pred_centroids, expert_points, metric='euclidean')

    matched_pred = []
    matched_expert = []
    used_pred = set()
    used_expert = set()

    # Flatten and sort all pairs by distance
    pairs = sorted(
        [(dist_matrix[i, j], i, j)
         for i in range(len(pred_centroids))
         for j in range(len(expert_points))],
        key=lambda x: x[0]
    )

    for dist, i, j in pairs:
        if dist > max_dist:
            break
        if i not in used_pred and j not in used_expert:
            matched_pred.append(i)
            matched_expert.append(j)
            used_pred.add(i)
            used_expert.add(j)

    return matched_pred, matched_expert

def compute_f1_delta(pred_centroids, expert_points, max_dist=100):
    """
    Compute F1 score and average delta for one image.
    Returns (f1, delta) where delta is NaN if no matches found.
    """
    if len(pred_centroids) == 0 or len(expert_points) == 0:
        return 0.0, float('nan')

    dist_matrix = cdist(pred_centroids, expert_points, metric='euclidean')
    matched_pred, matched_expert = greedy_match(pred_centroids, expert_points, max_dist)

    tp = len(matched_pred)
    fp = len(pred_centroids) - tp
    fn = len(expert_points) - tp

    precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0

    if tp == 0:
        delta = float('nan')
    else:
        deltas = [dist_matrix[matched_pred[k], matched_expert[k]] for k in range(tp)]
        delta = np.mean(deltas)

    return f1, delta

Full Pipeline Example

import pandas as pd
import numpy as np
from sklearn.cluster import DBSCAN

citsci = pd.read_csv('citsci_train.csv')
expert = pd.read_csv('expert_train.csv')

all_images = expert['file_rad'].unique()

f1_scores, deltas = [], []
for img in all_images:
    cit_pts = citsci[citsci['file_rad'] == img][['x', 'y']].values
    exp_pts = expert[expert['file_rad'] == img][['x', 'y']].values

    if len(cit_pts) < min_samples:
        f1_scores.append(0.0)
        deltas.append(float('nan'))
        continue

    dbscan = DBSCAN(eps=epsilon, min_samples=min_samples, metric=make_metric(shape_weight))
    labels = dbscan.fit_predict(cit_pts)
    centroids = get_cluster_centroids(cit_pts, labels)

    f1, delta = compute_f1_delta(centroids, exp_pts)
    f1_scores.append(f1)
    deltas.append(delta)

avg_f1 = np.mean(f1_scores)
valid_deltas = [d for d in deltas if not np.isnan(d)]
avg_delta = np.mean(valid_deltas) if valid_deltas else float('nan')

Signals

GitHub stars
83
Forks
5
Last commit
Jul 2026
Advanced
Catalog kind
skill
Gateway key
dbscan-custom-metric
Source
github.com/cxcscmu/skilllearnbench