Skip to content

Commit 8259e11

Browse files
committed
Add DBSCAN clustering algorithm in machine_learning/
1 parent e3b01ec commit 8259e11

1 file changed

Lines changed: 202 additions & 0 deletions

File tree

machine_learning/dbscan.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""
2+
DBSCAN (Density-Based Spatial Clustering of Applications with Noise)
3+
4+
A density-based clustering algorithm that groups together points that are
5+
closely packed together, while marking points in low-density regions as outliers.
6+
7+
Unlike K-Means, DBSCAN:
8+
- Does NOT require specifying the number of clusters in advance
9+
- Can find clusters of arbitrary shapes
10+
- Is robust to outliers (labels them as noise, cluster id = -1)
11+
12+
Key Parameters:
13+
epsilon (eps): The maximum distance between two points to be considered neighbors
14+
min_points: Minimum number of points to form a dense region (core point)
15+
16+
Point Types:
17+
- Core point: Has at least `min_points` neighbors within `epsilon` distance
18+
- Border point: Within `epsilon` of a core point, but has fewer than
19+
`min_points` neighbors
20+
- Noise point: Neither core nor border — labeled as -1
21+
22+
Time Complexity: O(n²) with brute-force neighbor search
23+
Space Complexity: O(n)
24+
25+
References:
26+
- https://en.wikipedia.org/wiki/DBSCAN
27+
- Ester, M., et al. "A density-based algorithm for discovering clusters."
28+
KDD 1996. https://dl.acm.org/doi/10.5555/3001460.3001507
29+
"""
30+
31+
32+
def euclidean_distance(point_a: list[float], point_b: list[float]) -> float:
33+
"""
34+
Compute the Euclidean distance between two points in n-dimensional space.
35+
36+
>>> euclidean_distance([0.0, 0.0], [3.0, 4.0])
37+
5.0
38+
>>> euclidean_distance([1.0, 2.0, 3.0], [1.0, 2.0, 3.0])
39+
0.0
40+
>>> euclidean_distance([0.0], [5.0])
41+
5.0
42+
>>> euclidean_distance([0.0, 0.0], [1.0])
43+
Traceback (most recent call last):
44+
...
45+
ValueError: Both points must have the same number of dimensions.
46+
"""
47+
if len(point_a) != len(point_b):
48+
raise ValueError("Both points must have the same number of dimensions.")
49+
return sum((a - b) ** 2 for a, b in zip(point_a, point_b)) ** 0.5
50+
51+
52+
def get_neighbors(
53+
data: list[list[float]], point_index: int, epsilon: float
54+
) -> list[int]:
55+
"""
56+
Return indices of all points within epsilon distance of data[point_index].
57+
58+
>>> data = [[0.0, 0.0], [0.1, 0.1], [5.0, 5.0]]
59+
>>> get_neighbors(data, 0, 0.5)
60+
[0, 1]
61+
>>> get_neighbors(data, 2, 0.5)
62+
[2]
63+
>>> get_neighbors(data, 0, 10.0)
64+
[0, 1, 2]
65+
"""
66+
return [
67+
index
68+
for index, point in enumerate(data)
69+
if euclidean_distance(data[point_index], point) <= epsilon
70+
]
71+
72+
73+
def dbscan(
74+
data: list[list[float]],
75+
epsilon: float,
76+
min_points: int,
77+
) -> list[int]:
78+
"""
79+
Perform DBSCAN clustering on a dataset.
80+
81+
Args:
82+
data: List of n-dimensional data points, e.g. [[x1,y1], [x2,y2], ...]
83+
epsilon: Maximum distance between two points to be considered neighbors.
84+
Must be greater than 0.
85+
min_points: Minimum number of neighbors (including self) to be a core point.
86+
Must be at least 1.
87+
88+
Returns:
89+
A list of integer cluster labels, one per input point.
90+
Noise points are labeled -1.
91+
Cluster IDs start from 0.
92+
93+
Raises:
94+
ValueError: If data is empty.
95+
ValueError: If epsilon is not positive.
96+
ValueError: If min_points is less than 1.
97+
98+
Example — two well-separated clusters:
99+
>>> data = [
100+
... [1.0, 1.0], [1.1, 1.0], [1.0, 1.1],
101+
... [9.0, 9.0], [9.1, 9.0], [9.0, 9.1],
102+
... ]
103+
>>> labels = dbscan(data, epsilon=0.5, min_points=2)
104+
>>> len(set(labels)) # two clusters
105+
2
106+
>>> labels[0] == labels[1] == labels[2] # first three in same cluster
107+
True
108+
>>> labels[3] == labels[4] == labels[5] # last three in same cluster
109+
True
110+
>>> labels[0] != labels[3] # different clusters
111+
True
112+
113+
Example — isolated noise point:
114+
>>> data = [[0.0, 0.0], [0.1, 0.0], [0.0, 0.1], [99.0, 99.0]]
115+
>>> labels = dbscan(data, epsilon=0.5, min_points=2)
116+
>>> labels[3] # noise
117+
-1
118+
>>> labels[0] == labels[1] == labels[2] # one cluster
119+
True
120+
121+
Example — all points are noise (min_points too high):
122+
>>> data = [[0.0, 0.0], [5.0, 5.0]]
123+
>>> dbscan(data, epsilon=0.3, min_points=5)
124+
[-1, -1]
125+
126+
Example — single cluster (all points close together):
127+
>>> data = [[0.0, 0.0], [0.1, 0.0], [0.0, 0.1], [0.1, 0.1]]
128+
>>> labels = dbscan(data, epsilon=0.5, min_points=2)
129+
>>> len(set(labels))
130+
1
131+
>>> -1 not in labels
132+
True
133+
134+
Example — invalid inputs:
135+
>>> dbscan([], epsilon=0.5, min_points=2)
136+
Traceback (most recent call last):
137+
...
138+
ValueError: Data must not be empty.
139+
>>> dbscan([[1.0, 2.0]], epsilon=0.0, min_points=2)
140+
Traceback (most recent call last):
141+
...
142+
ValueError: Epsilon must be greater than 0.
143+
>>> dbscan([[1.0, 2.0]], epsilon=0.5, min_points=0)
144+
Traceback (most recent call last):
145+
...
146+
ValueError: min_points must be at least 1.
147+
"""
148+
if not data:
149+
raise ValueError("Data must not be empty.")
150+
if epsilon <= 0:
151+
raise ValueError("Epsilon must be greater than 0.")
152+
if min_points < 1:
153+
raise ValueError("min_points must be at least 1.")
154+
155+
labels = [-1] * len(data) # all points start as noise
156+
current_cluster_id = 0
157+
158+
for point_index in range(len(data)):
159+
if labels[point_index] != -1:
160+
continue # already assigned
161+
162+
neighbors = get_neighbors(data, point_index, epsilon)
163+
164+
if len(neighbors) < min_points:
165+
continue # not a core point — remains noise for now
166+
167+
# point_index is a core point — start a new cluster
168+
labels[point_index] = current_cluster_id
169+
seeds = [n for n in neighbors if n != point_index]
170+
171+
while seeds:
172+
current_point = seeds.pop()
173+
174+
if labels[current_point] == -1:
175+
# was noise — reassign as border point of this cluster
176+
labels[current_point] = current_cluster_id
177+
178+
already_in_another = (
179+
labels[current_point] != -1
180+
and labels[current_point] != current_cluster_id
181+
)
182+
if already_in_another:
183+
continue # already in another cluster
184+
185+
labels[current_point] = current_cluster_id
186+
current_neighbors = get_neighbors(data, current_point, epsilon)
187+
188+
if len(current_neighbors) >= min_points:
189+
# current_point is also a core point — expand cluster
190+
for neighbor in current_neighbors:
191+
if labels[neighbor] == -1:
192+
seeds.append(neighbor)
193+
194+
current_cluster_id += 1
195+
196+
return labels
197+
198+
199+
if __name__ == "__main__":
200+
import doctest
201+
202+
doctest.testmod(verbose=True)

0 commit comments

Comments
 (0)