Dbscan
"""
DBSCAN (Density-Based Spatial Clustering of Applications with Noise)
A density-based clustering algorithm that groups together points that are
closely packed together, while marking points in low-density regions as outliers.
Unlike K-Means, DBSCAN:
- Does NOT require specifying the number of clusters in advance
- Can find clusters of arbitrary shapes
- Is robust to outliers (labels them as noise, cluster id = -1)
Key Parameters:
epsilon (eps): The maximum distance between two points to be considered neighbors
min_points: Minimum number of points to form a dense region (core point)
Point Types:
- Core point: Has at least `min_points` neighbors within `epsilon` distance
- Border point: Within `epsilon` of a core point, but has fewer than
`min_points` neighbors
- Noise point: Neither core nor border — labeled as -1
Time Complexity: O(n²) with brute-force neighbor search
Space Complexity: O(n)
References:
- https://en.wikipedia.org/wiki/DBSCAN
- Ester, M., et al. "A density-based algorithm for discovering clusters."
KDD 1996. https://dl.acm.org/doi/10.5555/3001460.3001507
"""
def euclidean_distance(point_a: list[float], point_b: list[float]) -> float:
"""
Compute the Euclidean distance between two points in n-dimensional space.
>>> euclidean_distance([0.0, 0.0], [3.0, 4.0])
5.0
>>> euclidean_distance([1.0, 2.0, 3.0], [1.0, 2.0, 3.0])
0.0
>>> euclidean_distance([0.0], [5.0])
5.0
>>> euclidean_distance([0.0, 0.0], [1.0])
Traceback (most recent call last):
...
ValueError: Both points must have the same number of dimensions.
"""
if len(point_a) != len(point_b):
raise ValueError("Both points must have the same number of dimensions.")
return sum((a - b) ** 2 for a, b in zip(point_a, point_b)) ** 0.5
def get_neighbors(
data: list[list[float]], point_index: int, epsilon: float
) -> list[int]:
"""
Return indices of all points within epsilon distance of data[point_index].
>>> data = [[0.0, 0.0], [0.1, 0.1], [5.0, 5.0]]
>>> get_neighbors(data, 0, 0.5)
[0, 1]
>>> get_neighbors(data, 2, 0.5)
[2]
>>> get_neighbors(data, 0, 10.0)
[0, 1, 2]
"""
return [
index
for index, point in enumerate(data)
if euclidean_distance(data[point_index], point) <= epsilon
]
def dbscan(
data: list[list[float]],
epsilon: float,
min_points: int,
) -> list[int]:
"""
Perform DBSCAN clustering on a dataset.
Args:
data: List of n-dimensional data points, e.g. [[x1,y1], [x2,y2], ...]
epsilon: Maximum distance between two points to be considered neighbors.
Must be greater than 0.
min_points: Minimum number of neighbors (including self) to be a core point.
Must be at least 1.
Returns:
A list of integer cluster labels, one per input point.
Noise points are labeled -1.
Cluster IDs start from 0.
Raises:
ValueError: If data is empty.
ValueError: If epsilon is not positive.
ValueError: If min_points is less than 1.
Example — two well-separated clusters:
>>> data = [
... [1.0, 1.0], [1.1, 1.0], [1.0, 1.1],
... [9.0, 9.0], [9.1, 9.0], [9.0, 9.1],
... ]
>>> labels = dbscan(data, epsilon=0.5, min_points=2)
>>> len(set(labels)) # two clusters
2
>>> labels[0] == labels[1] == labels[2] # first three in same cluster
True
>>> labels[3] == labels[4] == labels[5] # last three in same cluster
True
>>> labels[0] != labels[3] # different clusters
True
Example — isolated noise point:
>>> data = [[0.0, 0.0], [0.1, 0.0], [0.0, 0.1], [99.0, 99.0]]
>>> labels = dbscan(data, epsilon=0.5, min_points=2)
>>> labels[3] # noise
-1
>>> labels[0] == labels[1] == labels[2] # one cluster
True
Example — all points are noise (min_points too high):
>>> data = [[0.0, 0.0], [5.0, 5.0]]
>>> dbscan(data, epsilon=0.3, min_points=5)
[-1, -1]
Example — single cluster (all points close together):
>>> data = [[0.0, 0.0], [0.1, 0.0], [0.0, 0.1], [0.1, 0.1]]
>>> labels = dbscan(data, epsilon=0.5, min_points=2)
>>> len(set(labels))
1
>>> -1 not in labels
True
Example — invalid inputs:
>>> dbscan([], epsilon=0.5, min_points=2)
Traceback (most recent call last):
...
ValueError: Data must not be empty.
>>> dbscan([[1.0, 2.0]], epsilon=0.0, min_points=2)
Traceback (most recent call last):
...
ValueError: Epsilon must be greater than 0.
>>> dbscan([[1.0, 2.0]], epsilon=0.5, min_points=0)
Traceback (most recent call last):
...
ValueError: min_points must be at least 1.
"""
if not data:
raise ValueError("Data must not be empty.")
if epsilon <= 0:
raise ValueError("Epsilon must be greater than 0.")
if min_points < 1:
raise ValueError("min_points must be at least 1.")
labels = [-1] * len(data) # all points start as noise
current_cluster_id = 0
for point_index in range(len(data)):
if labels[point_index] != -1:
continue # already assigned
neighbors = get_neighbors(data, point_index, epsilon)
if len(neighbors) < min_points:
continue # not a core point — remains noise for now
# point_index is a core point — start a new cluster
labels[point_index] = current_cluster_id
seeds = [n for n in neighbors if n != point_index]
while seeds:
current_point = seeds.pop()
# skip points already claimed by a different cluster
if (
labels[current_point] != -1
and labels[current_point] != current_cluster_id
):
continue
# assign noise points and unvisited points to this cluster
labels[current_point] = current_cluster_id
current_neighbors = get_neighbors(data, current_point, epsilon)
if len(current_neighbors) >= min_points:
# current_point is also a core point — expand cluster
for neighbor in current_neighbors:
if labels[neighbor] == -1:
seeds.append(neighbor)
current_cluster_id += 1
return labels
if __name__ == "__main__":
import doctest
doctest.testmod(verbose=True)
About this Algorithm
DBSCAN
This implementation and notebook is inspired from the original DBSCAN algorithm and article as given in DBSCAN Wikipedia.
Stands for Density-based spatial clustering of applications with noise .
DBSCAN is clustering algorithm that tries to captures the intuition that if two points belong to the same cluster they should be close to one another. It does so by finding regions that are densely packed together, i.e, the points that have many close neighbours.
When to use ?
- You need a robust clustering algorithm.
- You don't know how many clusters there are in the dataset
- You find it difficult to guess the number of clusters there are just by eyeballing the dataset.
- The clusters are of arbitrary shapes.
- You want to detect outliers/noise.
Why DBSCAN ?
This algorithm is way better than other clustering algorithms such as k-means whose only job is to find circular blobs. It is smart enough to figure out the number of clusters in the dataset on its own, unlike k-means where you need to specify 'k'. It can also find clusters of arbitrary shapes, not just circular blobs. Its too robust to be affected by outliers (the noise points) and isn't fooled by them, unlike k-means where the entire centroid get pulled thanks to pesky outliers. Plus, you can fine-tune its parameters depending on what you are clustering.
Have a look at these neat animations of DBSCAN to see for yourself.
import matplotlib.pyplot as plt
import numpy as np
%matplotlib inlineFirst lets grab a dataset
We will take the moons dataset which is pretty good at showing the power of DBSCAN.
Lets generate 200 random points in the shape of two moons
from sklearn.datasets import make_moons
x, label = make_moons(n_samples=200, noise=0.1, random_state=19)Visualize the dataset using matplotlib
You will observe that the points are in the shape of two crescent moons.
The challenge here is to cluster the two moons.
plt.plot(x[:,0], x[:,1],'ro')[<matplotlib.lines.Line2D at 0x11a00e588>]Abstract of the Algorithm
The DBSCAN algorithm can be abstracted into the following steps:
- Find the points in the $ε$ (eps) neighborhood of every point, and identify the core points with more than min_pts neighbors.
- Find the connected components of core points on the neighbor graph, ignoring all non-core points.
- Assign each non-core point to a nearby cluster if the cluster is an $ε$ (eps) neighbor, otherwise assign it to noise.
Preparing the points
Initially we label all the points in the dataset as undefined .
points is our database of all points in the dataset.
points = { (point[0],point[1]):{'label':'undefined'} for point in x }Helper functions
def euclidean_distance(q, p):
"""
Calculates the Euclidean distance
between points P and Q
"""
a = pow((q[0] - p[0]), 2)
b = pow((q[1] - p[1]), 2)
return pow((a + b), 0.5)def find_neighbors(db, q, eps):
"""
Finds all points in the DB that
are within a distance of eps from Q
"""
return [p for p in db if euclidean_distance(q, p) <= eps]def plot_cluster(db, clusters):
"""
Extracts all the points in the DB and puts them together
as seperate clusters and finally plots them
"""
temp = []
noise = []
for i in clusters:
stack = []
for k, v in db.items():
if v["label"] == i:
stack.append(k)
elif v["label"] == "noise":
noise.append(k)
temp.append(stack)
color = iter(plt.cm.rainbow(np.linspace(0, 1, len(clusters))))
for i in range(0, len(temp)):
c = next(color)
x = [l[0] for l in temp[i]]
y = [l[1] for l in temp[i]]
plt.plot(x, y, "ro", c=c)
x = [l[0] for l in noise]
y = [l[1] for l in noise]
plt.plot(x, y, "ro", c="0")Implementation of DBSCAN
Initialize an empty list, clusters = $[ ]$ and cluster identifier, c = 0
For each point p in our database/dict db :
1.1 Check if p is already labelled. If it's already labelled (means it already been associated to a cluster), continue to the next point,i.e, go to step 1
1.2. Find the list of neighbors of p , i.e, points that are within a distance of eps from p
1.3. If p does not have atleast min_pts neighbours, we label it as noise and go back to step 1
1.4. Initialize the cluster, by incrementing c by 1
1.5. Append the cluster identifier c to clusters
1.6. Label p with the cluster identifier c
1.7 Remove p from the list of neighbors (p will be detected as its own neighbor because it is within eps of itself)
1.8. Initialize the seed_set as a copy of neighbors
1.9. While the seed_set is not empty: 1.9.1. Removing the 1st point from seed_set and initialise it as q 1.9.2. If it's label is noise, label it with c 1.9.3. If it's not unlabelled, go back to step 1.9 1.9.4. Label q with c 1.9.5. Find the neighbours of q 1.9.6. If there are atleast min_pts neighbors, append them to the seed_set
def dbscan(db,eps,min_pts):
'''
Implementation of the DBSCAN algorithm
'''
clusters = []
c = 0
for p in db:
if db[p]["label"] != "undefined":
continue
neighbors = find_neighbors(db, p, eps)
if len(neighbors) < min_pts:
db[p]["label"] = "noise"
continue
c += 1
clusters.append(c)
db[p]["label"] = c
neighbors.remove(p)
seed_set = neighbors.copy()
while seed_set != []:
q = seed_set.pop(0)
if db[q]["label"] == "noise":
db[q]["label"] = c
if db[q]["label"] != "undefined":
continue
db[q]["label"] = c
neighbors_n = find_neighbors(db, q, eps)
if len(neighbors_n) >= min_pts:
seed_set = seed_set + neighbors_n
return db, clusters
Lets run it!
eps = 0.25
min_pts = 12
db,clusters = dbscan(points,eps,min_pts)
plot_cluster(db,clusters)I encourage you to try with different datasets and playing with the values of eps and min_pts.
Also, try kmeans on this dataset and see how it compares to dbscan.
I hope by now you are convinced about about how cool dbscan is. But it has its pitfalls.
When NOT to use ?
- You have a high dimentional dataset. Euclidean distance will fail thanks to 'curse of dimentionality'.
- We have used a dict to store the points. So we can't do anything about the order in which the points will be processed. So it's not entirely deterministic.
- Won't work well if there are large differences in density. Finding the min_pts and $ε$ combination will be difficult.
- Choosing the $ε$ without understanding the data and its scale, might result is poor clustering performance.