forked from TheAlgorithms/Python
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdbscan.py
More file actions
199 lines (163 loc) · 6.4 KB
/
Copy pathdbscan.py
File metadata and controls
199 lines (163 loc) · 6.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
"""
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)