feat: add Camera Work AI package

This commit is contained in:
KINDNICK
2026-08-03 04:42:44 +09:00
parent 26c578416f
commit 02d76bc87c
2187 changed files with 119610 additions and 1 deletions
@@ -0,0 +1,41 @@
# Fast inner loop for DBSCAN.
# Authors: The scikit-learn developers
# SPDX-License-Identifier: BSD-3-Clause
from libcpp.vector cimport vector
from ..utils._typedefs cimport uint8_t, intp_t
def dbscan_inner(const uint8_t[::1] is_core,
object[:] neighborhoods,
intp_t[::1] labels):
cdef intp_t i, label_num = 0, v
cdef intp_t[:] neighb
cdef vector[intp_t] stack
for i in range(labels.shape[0]):
if labels[i] != -1 or not is_core[i]:
continue
# Depth-first search starting from i, ending at the non-core points.
# This is very similar to the classic algorithm for computing connected
# components, the difference being that we label non-core points as
# part of a cluster (component), but don't expand their neighborhoods.
while True:
if labels[i] == -1:
labels[i] = label_num
if is_core[i]:
neighb = neighborhoods[i]
for i in range(neighb.shape[0]):
v = neighb[i]
if labels[v] == -1:
stack.push_back(v)
if stack.size() == 0:
break
i = stack.back()
stack.pop_back()
label_num += 1
@@ -0,0 +1,274 @@
# Minimum spanning tree single linkage implementation for hdbscan
# Authors: The scikit-learn developers
# SPDX-License-Identifier: BSD-3-Clause
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
cimport numpy as cnp
from libc.float cimport DBL_MAX
import numpy as np
from ...metrics._dist_metrics cimport DistanceMetric64
from ...cluster._hierarchical_fast cimport UnionFind
from ...cluster._hdbscan._tree cimport HIERARCHY_t
from ...cluster._hdbscan._tree import HIERARCHY_dtype
from ...utils._typedefs cimport intp_t, float64_t, int64_t, uint8_t
cnp.import_array()
cdef extern from "numpy/arrayobject.h":
intp_t * PyArray_SHAPE(cnp.PyArrayObject *)
# Numpy structured dtype representing a single ordered edge in Prim's algorithm
MST_edge_dtype = np.dtype([
("current_node", np.int64),
("next_node", np.int64),
("distance", np.float64),
])
# Packed shouldn't make a difference since they're all 8-byte quantities,
# but it's included just to be safe.
ctypedef packed struct MST_edge_t:
int64_t current_node
int64_t next_node
float64_t distance
cpdef cnp.ndarray[MST_edge_t, ndim=1, mode='c'] mst_from_mutual_reachability(
cnp.ndarray[float64_t, ndim=2] mutual_reachability
):
"""Compute the Minimum Spanning Tree (MST) representation of the mutual-
reachability graph using Prim's algorithm.
Parameters
----------
mutual_reachability : ndarray of shape (n_samples, n_samples)
Array of mutual-reachabilities between samples.
Returns
-------
mst : ndarray of shape (n_samples - 1,), dtype=MST_edge_dtype
The MST representation of the mutual-reachability graph. The MST is
represented as a collection of edges.
"""
cdef:
# Note: we utilize ndarray's over memory-views to make use of numpy
# binary indexing and sub-selection below.
cnp.ndarray[int64_t, ndim=1, mode='c'] current_labels
cnp.ndarray[float64_t, ndim=1, mode='c'] min_reachability, left, right
cnp.ndarray[MST_edge_t, ndim=1, mode='c'] mst
cnp.ndarray[uint8_t, mode='c'] label_filter
int64_t n_samples = PyArray_SHAPE(<cnp.PyArrayObject*> mutual_reachability)[0]
int64_t current_node, new_node_index, new_node, i
mst = np.empty(n_samples - 1, dtype=MST_edge_dtype)
current_labels = np.arange(n_samples, dtype=np.int64)
current_node = 0
min_reachability = np.full(n_samples, fill_value=np.inf, dtype=np.float64)
for i in range(0, n_samples - 1):
label_filter = current_labels != current_node
current_labels = current_labels[label_filter]
left = min_reachability[label_filter]
right = mutual_reachability[current_node][current_labels]
min_reachability = np.minimum(left, right)
new_node_index = np.argmin(min_reachability)
new_node = current_labels[new_node_index]
mst[i].current_node = current_node
mst[i].next_node = new_node
mst[i].distance = min_reachability[new_node_index]
current_node = new_node
return mst
cpdef cnp.ndarray[MST_edge_t, ndim=1, mode='c'] mst_from_data_matrix(
const float64_t[:, ::1] raw_data,
const float64_t[::1] core_distances,
DistanceMetric64 dist_metric,
float64_t alpha=1.0
):
"""Compute the Minimum Spanning Tree (MST) representation of the mutual-
reachability graph generated from the provided `raw_data` and
`core_distances` using Prim's algorithm.
Parameters
----------
raw_data : ndarray of shape (n_samples, n_features)
Input array of data samples.
core_distances : ndarray of shape (n_samples,)
An array containing the core-distance calculated for each corresponding
sample.
dist_metric : DistanceMetric
The distance metric to use when calculating pairwise distances for
determining mutual-reachability.
Returns
-------
mst : ndarray of shape (n_samples - 1,), dtype=MST_edge_dtype
The MST representation of the mutual-reachability graph. The MST is
represented as a collection of edges.
"""
cdef:
uint8_t[::1] in_tree
float64_t[::1] min_reachability
int64_t[::1] current_sources
cnp.ndarray[MST_edge_t, ndim=1, mode='c'] mst
int64_t current_node, source_node, new_node, next_node_source
int64_t i, j, n_samples, num_features
float64_t current_node_core_dist, new_reachability, mutual_reachability_distance
float64_t next_node_min_reach, pair_distance, next_node_core_dist
n_samples = raw_data.shape[0]
num_features = raw_data.shape[1]
mst = np.empty(n_samples - 1, dtype=MST_edge_dtype)
in_tree = np.zeros(n_samples, dtype=np.uint8)
min_reachability = np.full(n_samples, fill_value=np.inf, dtype=np.float64)
current_sources = np.ones(n_samples, dtype=np.int64)
current_node = 0
# The following loop dynamically updates minimum reachability node-by-node,
# avoiding unnecessary computation where possible.
for i in range(0, n_samples - 1):
in_tree[current_node] = 1
current_node_core_dist = core_distances[current_node]
new_reachability = DBL_MAX
source_node = 0
new_node = 0
for j in range(n_samples):
if in_tree[j]:
continue
next_node_min_reach = min_reachability[j]
next_node_source = current_sources[j]
pair_distance = dist_metric.dist(
&raw_data[current_node, 0],
&raw_data[j, 0],
num_features
)
pair_distance /= alpha
next_node_core_dist = core_distances[j]
mutual_reachability_distance = max(
current_node_core_dist,
next_node_core_dist,
pair_distance
)
# If MRD(i, j) is smaller than node j's min_reachability, we update
# node j's min_reachability for future reference.
if mutual_reachability_distance < next_node_min_reach:
min_reachability[j] = mutual_reachability_distance
current_sources[j] = current_node
# If MRD(i, j) is also smaller than node i's current
# min_reachability, we update and set their edge as the current
# MST edge candidate.
if mutual_reachability_distance < new_reachability:
new_reachability = mutual_reachability_distance
source_node = current_node
new_node = j
# If the node j is closer to another node already in the tree, we
# make their edge the current MST candidate edge.
elif next_node_min_reach < new_reachability:
new_reachability = next_node_min_reach
source_node = next_node_source
new_node = j
mst[i].current_node = source_node
mst[i].next_node = new_node
mst[i].distance = new_reachability
current_node = new_node
return mst
cpdef cnp.ndarray[HIERARCHY_t, ndim=1, mode="c"] make_single_linkage(const MST_edge_t[::1] mst):
"""Construct a single-linkage tree from an MST.
Parameters
----------
mst : ndarray of shape (n_samples - 1,), dtype=MST_edge_dtype
The MST representation of the mutual-reachability graph. The MST is
represented as a collection of edges.
Returns
-------
single_linkage : ndarray of shape (n_samples - 1,), dtype=HIERARCHY_dtype
The single-linkage tree tree (dendrogram) built from the MST. Each
of the array represents the following:
- left node/cluster
- right node/cluster
- distance
- new cluster size
"""
cdef:
cnp.ndarray[HIERARCHY_t, ndim=1, mode="c"] single_linkage
# Note mst.shape[0] is one fewer than the number of samples
int64_t n_samples = mst.shape[0] + 1
intp_t current_node_cluster, next_node_cluster
int64_t current_node, next_node, i
float64_t distance
UnionFind U = UnionFind(n_samples)
single_linkage = np.zeros(n_samples - 1, dtype=HIERARCHY_dtype)
for i in range(n_samples - 1):
current_node = mst[i].current_node
next_node = mst[i].next_node
distance = mst[i].distance
current_node_cluster = U.fast_find(current_node)
next_node_cluster = U.fast_find(next_node)
single_linkage[i].left_node = current_node_cluster
single_linkage[i].right_node = next_node_cluster
single_linkage[i].value = distance
single_linkage[i].cluster_size = U.size[current_node_cluster] + U.size[next_node_cluster]
U.union(current_node_cluster, next_node_cluster)
return single_linkage
@@ -0,0 +1,210 @@
# mutual reachability distance computations
# Authors: The scikit-learn developers
# SPDX-License-Identifier: BSD-3-Clause
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
cimport numpy as cnp
import numpy as np
from scipy.sparse import issparse
from cython cimport floating, integral
from libc.math cimport isfinite, INFINITY
from ...utils._typedefs cimport intp_t
cnp.import_array()
def mutual_reachability_graph(
distance_matrix, min_samples=5, max_distance=0.0
):
"""Compute the weighted adjacency matrix of the mutual reachability graph.
The mutual reachability distance used to build the graph is defined as::
max(d_core(x_p), d_core(x_q), d(x_p, x_q))
and the core distance `d_core` is defined as the distance between a point
`x_p` and its k-th nearest neighbor.
Note that all computations are done in-place.
Parameters
----------
distance_matrix : {ndarray, sparse matrix} of shape (n_samples, n_samples)
Array of distances between samples. If sparse, the array must be in
`CSR` format.
min_samples : int, default=5
The parameter `k` used to calculate the distance between a point
`x_p` and its k-th nearest neighbor.
max_distance : float, default=0.0
The distance which `np.inf` is replaced with. When the true mutual-
reachability distance is measured to be infinite, it is instead
truncated to `max_dist`. Only used when `distance_matrix` is a sparse
matrix.
Returns
-------
mututal_reachability_graph: {ndarray, sparse matrix} of shape \
(n_samples, n_samples)
Weighted adjacency matrix of the mutual reachability graph.
References
----------
.. [1] Campello, R. J., Moulavi, D., & Sander, J. (2013, April).
Density-based clustering based on hierarchical density estimates.
In Pacific-Asia Conference on Knowledge Discovery and Data Mining
(pp. 160-172). Springer Berlin Heidelberg.
"""
further_neighbor_idx = min_samples - 1
if issparse(distance_matrix):
if distance_matrix.format != "csr":
raise ValueError(
"Only sparse CSR matrices are supported for `distance_matrix`."
)
_sparse_mutual_reachability_graph(
distance_matrix.data,
distance_matrix.indices,
distance_matrix.indptr,
distance_matrix.shape[0],
further_neighbor_idx=further_neighbor_idx,
max_distance=max_distance,
)
else:
_dense_mutual_reachability_graph(
distance_matrix, further_neighbor_idx=further_neighbor_idx
)
return distance_matrix
def _dense_mutual_reachability_graph(
floating[:, :] distance_matrix,
intp_t further_neighbor_idx,
):
"""Dense implementation of mutual reachability graph.
The computation is done in-place, i.e. the distance matrix is modified
directly.
Parameters
----------
distance_matrix : ndarray of shape (n_samples, n_samples)
Array of distances between samples.
further_neighbor_idx : int
The index of the furthest neighbor to use to define the core distances.
"""
cdef:
intp_t i, j, n_samples = distance_matrix.shape[0]
floating mutual_reachability_distance
floating[::1] core_distances
# We assume that the distance matrix is symmetric. We choose to sort every
# row to have the same implementation than the sparse case that requires
# CSR matrix.
core_distances = np.ascontiguousarray(
np.partition(
distance_matrix, further_neighbor_idx, axis=1
)[:, further_neighbor_idx]
)
with nogil:
# TODO: Update w/ prange with thread count based on
# _openmp_effective_n_threads
for i in range(n_samples):
for j in range(n_samples):
mutual_reachability_distance = max(
core_distances[i],
core_distances[j],
distance_matrix[i, j],
)
distance_matrix[i, j] = mutual_reachability_distance
def _sparse_mutual_reachability_graph(
cnp.ndarray[floating, ndim=1, mode="c"] data,
cnp.ndarray[integral, ndim=1, mode="c"] indices,
cnp.ndarray[integral, ndim=1, mode="c"] indptr,
intp_t n_samples,
intp_t further_neighbor_idx,
floating max_distance,
):
"""Sparse implementation of mutual reachability graph.
The computation is done in-place, i.e. the distance matrix is modified
directly. This implementation only accepts `CSR` format sparse matrices.
Parameters
----------
distance_matrix : sparse matrix of shape (n_samples, n_samples)
Sparse matrix of distances between samples. The sparse format should
be `CSR`.
further_neighbor_idx : int
The index of the furthest neighbor to use to define the core distances.
max_distance : float
The distance which `np.inf` is replaced with. When the true mutual-
reachability distance is measured to be infinite, it is instead
truncated to `max_dist`. Only used when `distance_matrix` is a sparse
matrix.
"""
cdef:
integral i, col_ind, row_ind
floating mutual_reachability_distance
floating[:] core_distances
floating[:] row_data
if floating is float:
dtype = np.float32
else:
dtype = np.float64
core_distances = np.empty(n_samples, dtype=dtype)
for i in range(n_samples):
row_data = data[indptr[i]:indptr[i + 1]]
if further_neighbor_idx < row_data.size:
core_distances[i] = np.partition(
row_data, further_neighbor_idx
)[further_neighbor_idx]
else:
core_distances[i] = INFINITY
with nogil:
for row_ind in range(n_samples):
for i in range(indptr[row_ind], indptr[row_ind + 1]):
col_ind = indices[i]
mutual_reachability_distance = max(
core_distances[row_ind], core_distances[col_ind], data[i]
)
if isfinite(mutual_reachability_distance):
data[i] = mutual_reachability_distance
elif max_distance > 0:
data[i] = max_distance
@@ -0,0 +1,49 @@
# Copyright (c) 2015, Leland McInnes
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from ...utils._typedefs cimport intp_t, float64_t, uint8_t
cimport numpy as cnp
# This corresponds to the scipy.cluster.hierarchy format
ctypedef packed struct HIERARCHY_t:
intp_t left_node
intp_t right_node
float64_t value
intp_t cluster_size
# Effectively an edgelist encoding a parent/child pair, along with a value and
# the corresponding cluster_size in each row providing a tree structure.
ctypedef packed struct CONDENSED_t:
intp_t parent
intp_t child
float64_t value
intp_t cluster_size
cdef extern from "numpy/arrayobject.h":
intp_t * PyArray_SHAPE(cnp.PyArrayObject *)
@@ -0,0 +1,799 @@
# Tree handling (condensing, finding stable clusters) for hdbscan
# Authors: The scikit-learn developers
# SPDX-License-Identifier: BSD-3-Clause
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
cimport numpy as cnp
from libc.math cimport isinf
import cython
import numpy as np
cnp.import_array()
cdef extern from "numpy/arrayobject.h":
intp_t * PyArray_SHAPE(cnp.PyArrayObject *)
cdef cnp.float64_t INFTY = np.inf
cdef cnp.intp_t NOISE = -1
HIERARCHY_dtype = np.dtype([
("left_node", np.intp),
("right_node", np.intp),
("value", np.float64),
("cluster_size", np.intp),
])
CONDENSED_dtype = np.dtype([
("parent", np.intp),
("child", np.intp),
("value", np.float64),
("cluster_size", np.intp),
])
cpdef tuple tree_to_labels(
const HIERARCHY_t[::1] single_linkage_tree,
cnp.intp_t min_cluster_size=10,
cluster_selection_method="eom",
bint allow_single_cluster=False,
cnp.float64_t cluster_selection_epsilon=0.0,
max_cluster_size=None,
):
cdef:
cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] condensed_tree
cnp.ndarray[cnp.intp_t, ndim=1, mode='c'] labels
cnp.ndarray[cnp.float64_t, ndim=1, mode='c'] probabilities
condensed_tree = _condense_tree(single_linkage_tree, min_cluster_size)
labels, probabilities = _get_clusters(
condensed_tree,
_compute_stability(condensed_tree),
cluster_selection_method,
allow_single_cluster,
cluster_selection_epsilon,
max_cluster_size,
)
return (labels, probabilities)
cdef list bfs_from_hierarchy(
const HIERARCHY_t[::1] hierarchy,
cnp.intp_t bfs_root
):
"""
Perform a breadth first search on a tree in scipy hclust format.
"""
cdef list process_queue, next_queue, result
cdef cnp.intp_t n_samples = hierarchy.shape[0] + 1
cdef cnp.intp_t node
process_queue = [bfs_root]
result = []
while process_queue:
result.extend(process_queue)
# By construction, node i is formed by the union of nodes
# hierarchy[i - n_samples, 0] and hierarchy[i - n_samples, 1]
process_queue = [
x - n_samples
for x in process_queue
if x >= n_samples
]
if process_queue:
next_queue = []
for node in process_queue:
next_queue.extend(
[
hierarchy[node].left_node,
hierarchy[node].right_node,
]
)
process_queue = next_queue
return result
cpdef cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] _condense_tree(
const HIERARCHY_t[::1] hierarchy,
cnp.intp_t min_cluster_size=10
):
"""Condense a tree according to a minimum cluster size. This is akin
to the runt pruning procedure of Stuetzle. The result is a much simpler
tree that is easier to visualize. We include extra information on the
lambda value at which individual points depart clusters for later
analysis and computation.
Parameters
----------
hierarchy : ndarray of shape (n_samples,), dtype=HIERARCHY_dtype
A single linkage hierarchy in scipy.cluster.hierarchy format.
min_cluster_size : int, optional (default 10)
The minimum size of clusters to consider. Clusters smaller than this
are pruned from the tree.
Returns
-------
condensed_tree : ndarray of shape (n_samples,), dtype=CONDENSED_dtype
Effectively an edgelist encoding a parent/child pair, along with a
value and the corresponding cluster_size in each row providing a tree
structure.
"""
cdef:
cnp.intp_t root = 2 * hierarchy.shape[0]
cnp.intp_t n_samples = hierarchy.shape[0] + 1
cnp.intp_t next_label = n_samples + 1
list result_list, node_list = bfs_from_hierarchy(hierarchy, root)
cnp.intp_t[::1] relabel
cnp.uint8_t[::1] ignore
cnp.intp_t node, sub_node, left, right
cnp.float64_t lambda_value, distance
cnp.intp_t left_count, right_count
HIERARCHY_t children
relabel = np.empty(root + 1, dtype=np.intp)
relabel[root] = n_samples
result_list = []
ignore = np.zeros(len(node_list), dtype=bool)
for node in node_list:
if ignore[node] or node < n_samples:
continue
children = hierarchy[node - n_samples]
left = children.left_node
right = children.right_node
distance = children.value
if distance > 0.0:
lambda_value = 1.0 / distance
else:
lambda_value = INFTY
if left >= n_samples:
left_count = hierarchy[left - n_samples].cluster_size
else:
left_count = 1
if right >= n_samples:
right_count = hierarchy[right - n_samples].cluster_size
else:
right_count = 1
if left_count >= min_cluster_size and right_count >= min_cluster_size:
relabel[left] = next_label
next_label += 1
result_list.append(
(relabel[node], relabel[left], lambda_value, left_count)
)
relabel[right] = next_label
next_label += 1
result_list.append(
(relabel[node], relabel[right], lambda_value, right_count)
)
elif left_count < min_cluster_size and right_count < min_cluster_size:
for sub_node in bfs_from_hierarchy(hierarchy, left):
if sub_node < n_samples:
result_list.append(
(relabel[node], sub_node, lambda_value, 1)
)
ignore[sub_node] = True
for sub_node in bfs_from_hierarchy(hierarchy, right):
if sub_node < n_samples:
result_list.append(
(relabel[node], sub_node, lambda_value, 1)
)
ignore[sub_node] = True
elif left_count < min_cluster_size:
relabel[right] = relabel[node]
for sub_node in bfs_from_hierarchy(hierarchy, left):
if sub_node < n_samples:
result_list.append(
(relabel[node], sub_node, lambda_value, 1)
)
ignore[sub_node] = True
else:
relabel[left] = relabel[node]
for sub_node in bfs_from_hierarchy(hierarchy, right):
if sub_node < n_samples:
result_list.append(
(relabel[node], sub_node, lambda_value, 1)
)
ignore[sub_node] = True
return np.array(result_list, dtype=CONDENSED_dtype)
cdef dict _compute_stability(
cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] condensed_tree
):
cdef:
cnp.float64_t[::1] result, births
cnp.intp_t[:] parents = condensed_tree['parent']
cnp.intp_t parent, cluster_size, result_index, idx
cnp.float64_t lambda_val
CONDENSED_t condensed_node
cnp.intp_t largest_child = condensed_tree['child'].max()
cnp.intp_t smallest_cluster = np.min(parents)
cnp.intp_t num_clusters = np.max(parents) - smallest_cluster + 1
dict stability_dict = {}
largest_child = max(largest_child, smallest_cluster)
births = np.full(largest_child + 1, np.nan, dtype=np.float64)
for idx in range(PyArray_SHAPE(<cnp.PyArrayObject*> condensed_tree)[0]):
condensed_node = condensed_tree[idx]
births[condensed_node.child] = condensed_node.value
births[smallest_cluster] = 0.0
result = np.zeros(num_clusters, dtype=np.float64)
for idx in range(PyArray_SHAPE(<cnp.PyArrayObject*> condensed_tree)[0]):
condensed_node = condensed_tree[idx]
parent = condensed_node.parent
lambda_val = condensed_node.value
cluster_size = condensed_node.cluster_size
result_index = parent - smallest_cluster
result[result_index] += (lambda_val - births[parent]) * cluster_size
for idx in range(num_clusters):
stability_dict[idx + smallest_cluster] = result[idx]
return stability_dict
cdef list bfs_from_cluster_tree(
cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] condensed_tree,
cnp.intp_t bfs_root
):
cdef:
list result = []
cnp.ndarray[cnp.intp_t, ndim=1] process_queue = (
np.array([bfs_root], dtype=np.intp)
)
cnp.ndarray[cnp.intp_t, ndim=1] children = condensed_tree['child']
cnp.intp_t[:] parents = condensed_tree['parent']
while len(process_queue) > 0:
result.extend(process_queue.tolist())
process_queue = children[np.isin(parents, process_queue)]
return result
cdef cnp.float64_t[::1] max_lambdas(cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] condensed_tree):
cdef:
cnp.intp_t parent, current_parent, idx
cnp.float64_t lambda_val, max_lambda
cnp.float64_t[::1] deaths
cnp.intp_t largest_parent = condensed_tree['parent'].max()
deaths = np.zeros(largest_parent + 1, dtype=np.float64)
current_parent = condensed_tree[0].parent
max_lambda = condensed_tree[0].value
for idx in range(1, PyArray_SHAPE(<cnp.PyArrayObject*> condensed_tree)[0]):
parent = condensed_tree[idx].parent
lambda_val = condensed_tree[idx].value
if parent == current_parent:
max_lambda = max(max_lambda, lambda_val)
else:
deaths[current_parent] = max_lambda
current_parent = parent
max_lambda = lambda_val
deaths[current_parent] = max_lambda # value for last parent
return deaths
@cython.final
cdef class TreeUnionFind:
cdef cnp.intp_t[:, ::1] data
cdef cnp.uint8_t[::1] is_component
def __init__(self, size):
cdef cnp.intp_t idx
self.data = np.zeros((size, 2), dtype=np.intp)
for idx in range(size):
self.data[idx, 0] = idx
self.is_component = np.ones(size, dtype=np.uint8)
cdef void union(self, cnp.intp_t x, cnp.intp_t y):
cdef cnp.intp_t x_root = self.find(x)
cdef cnp.intp_t y_root = self.find(y)
if self.data[x_root, 1] < self.data[y_root, 1]:
self.data[x_root, 0] = y_root
elif self.data[x_root, 1] > self.data[y_root, 1]:
self.data[y_root, 0] = x_root
else:
self.data[y_root, 0] = x_root
self.data[x_root, 1] += 1
return
cdef cnp.intp_t find(self, cnp.intp_t x):
if self.data[x, 0] != x:
self.data[x, 0] = self.find(self.data[x, 0])
self.is_component[x] = False
return self.data[x, 0]
cpdef cnp.ndarray[cnp.intp_t, ndim=1, mode='c'] labelling_at_cut(
const HIERARCHY_t[::1] linkage,
cnp.float64_t cut,
cnp.intp_t min_cluster_size
):
"""Given a single linkage tree and a cut value, return the
vector of cluster labels at that cut value. This is useful
for Robust Single Linkage, and extracting DBSCAN results
from a single HDBSCAN run.
Parameters
----------
linkage : ndarray of shape (n_samples,), dtype=HIERARCHY_dtype
The single linkage tree in scipy.cluster.hierarchy format.
cut : double
The cut value at which to find clusters.
min_cluster_size : int
The minimum cluster size; clusters below this size at
the cut will be considered noise.
Returns
-------
labels : ndarray of shape (n_samples,)
The cluster labels for each point in the data set;
a label of -1 denotes a noise assignment.
"""
cdef:
cnp.intp_t n, cluster, root, n_samples, cluster_label
cnp.intp_t[::1] unique_labels, cluster_size
cnp.ndarray[cnp.intp_t, ndim=1, mode='c'] result
TreeUnionFind union_find
dict cluster_label_map
HIERARCHY_t node
root = 2 * linkage.shape[0]
n_samples = root // 2 + 1
result = np.empty(n_samples, dtype=np.intp)
union_find = TreeUnionFind(root + 1)
cluster = n_samples
for node in linkage:
if node.value < cut:
union_find.union(node.left_node, cluster)
union_find.union(node.right_node, cluster)
cluster += 1
cluster_size = np.zeros(cluster, dtype=np.intp)
for n in range(n_samples):
cluster = union_find.find(n)
cluster_size[cluster] += 1
result[n] = cluster
cluster_label_map = {-1: NOISE}
cluster_label = 0
unique_labels = np.unique(result)
for cluster in unique_labels:
if cluster_size[cluster] < min_cluster_size:
cluster_label_map[cluster] = NOISE
else:
cluster_label_map[cluster] = cluster_label
cluster_label += 1
for n in range(n_samples):
result[n] = cluster_label_map[result[n]]
return result
cpdef cnp.ndarray[cnp.intp_t, ndim=1, mode='c'] _do_labelling(
cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] condensed_tree,
set clusters,
dict cluster_label_map,
cnp.intp_t allow_single_cluster,
cnp.float64_t cluster_selection_epsilon
):
"""Given a condensed tree, clusters and a labeling map for the clusters,
return an array containing the labels of each point based on cluster
membership. Note that this is where points may be marked as noisy
outliers. The determination of some points as noise is in large, single-
cluster datasets is controlled by the `allow_single_cluster` and
`cluster_selection_epsilon` parameters.
Parameters
----------
condensed_tree : ndarray of shape (n_samples,), dtype=CONDENSED_dtype
Effectively an edgelist encoding a parent/child pair, along with a
value and the corresponding cluster_size in each row providing a tree
structure.
clusters : set
The set of nodes corresponding to identified clusters. These node
values should be the same as those present in `condensed_tree`.
cluster_label_map : dict
A mapping from the node values present in `clusters` to the labels
which will be returned.
Returns
-------
labels : ndarray of shape (n_samples,)
The cluster labels for each point in the data set;
a label of -1 denotes a noise assignment.
"""
cdef:
cnp.intp_t root_cluster
cnp.ndarray[cnp.intp_t, ndim=1, mode='c'] result
cnp.ndarray[cnp.intp_t, ndim=1] parent_array, child_array
cnp.ndarray[cnp.float64_t, ndim=1] lambda_array
TreeUnionFind union_find
cnp.intp_t n, parent, child, cluster
cnp.float64_t threshold
child_array = condensed_tree['child']
parent_array = condensed_tree['parent']
lambda_array = condensed_tree['value']
root_cluster = np.min(parent_array)
result = np.empty(root_cluster, dtype=np.intp)
union_find = TreeUnionFind(np.max(parent_array) + 1)
for n in range(PyArray_SHAPE(<cnp.PyArrayObject*> condensed_tree)[0]):
child = child_array[n]
parent = parent_array[n]
if child not in clusters:
union_find.union(parent, child)
for n in range(root_cluster):
cluster = union_find.find(n)
label = NOISE
if cluster != root_cluster:
label = cluster_label_map[cluster]
elif len(clusters) == 1 and allow_single_cluster:
# There can only be one edge with this particular child hence this
# expression extracts a unique, scalar lambda value.
parent_lambda = lambda_array[child_array == n]
if cluster_selection_epsilon != 0.0:
threshold = 1 / cluster_selection_epsilon
else:
# The threshold should be calculated per-sample based on the
# largest lambda of any simbling node.
threshold = lambda_array[parent_array == cluster].max()
if parent_lambda >= threshold:
label = cluster_label_map[cluster]
result[n] = label
return result
cdef cnp.ndarray[cnp.float64_t, ndim=1, mode='c'] get_probabilities(
cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] condensed_tree,
dict cluster_map,
cnp.intp_t[::1] labels
):
cdef:
cnp.ndarray[cnp.float64_t, ndim=1, mode='c'] result
cnp.float64_t[:] lambda_array
cnp.float64_t[::1] deaths
cnp.intp_t[:] child_array, parent_array
cnp.intp_t root_cluster, n, point, cluster_num, cluster
cnp.float64_t max_lambda, lambda_val
child_array = condensed_tree['child']
parent_array = condensed_tree['parent']
lambda_array = condensed_tree['value']
result = np.zeros(labels.shape[0])
deaths = max_lambdas(condensed_tree)
root_cluster = np.min(parent_array)
for n in range(PyArray_SHAPE(<cnp.PyArrayObject*> condensed_tree)[0]):
point = child_array[n]
if point >= root_cluster:
continue
cluster_num = labels[point]
if cluster_num == -1:
continue
cluster = cluster_map[cluster_num]
max_lambda = deaths[cluster]
if max_lambda == 0.0 or isinf(lambda_array[n]):
result[point] = 1.0
else:
lambda_val = min(lambda_array[n], max_lambda)
result[point] = lambda_val / max_lambda
return result
cpdef list recurse_leaf_dfs(
cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] cluster_tree,
cnp.intp_t current_node
):
cdef cnp.intp_t[:] children
cdef cnp.intp_t child
children = cluster_tree[cluster_tree['parent'] == current_node]['child']
if children.shape[0] == 0:
return [current_node,]
else:
return sum([recurse_leaf_dfs(cluster_tree, child) for child in children], [])
cpdef list get_cluster_tree_leaves(cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] cluster_tree):
cdef cnp.intp_t root
if PyArray_SHAPE(<cnp.PyArrayObject*> cluster_tree)[0] == 0:
return []
root = cluster_tree['parent'].min()
return recurse_leaf_dfs(cluster_tree, root)
cdef cnp.intp_t traverse_upwards(
cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] cluster_tree,
cnp.float64_t cluster_selection_epsilon,
cnp.intp_t leaf,
cnp.intp_t allow_single_cluster
):
cdef cnp.intp_t root, parent
cdef cnp.float64_t parent_eps
root = cluster_tree['parent'].min()
parent = cluster_tree[cluster_tree['child'] == leaf]['parent']
if parent == root:
if allow_single_cluster:
return parent
else:
return leaf # return node closest to root
parent_eps = 1 / cluster_tree[cluster_tree['child'] == parent]['value']
if parent_eps > cluster_selection_epsilon:
return parent
else:
return traverse_upwards(
cluster_tree,
cluster_selection_epsilon,
parent,
allow_single_cluster
)
cdef set epsilon_search(
set leaves,
cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] cluster_tree,
cnp.float64_t cluster_selection_epsilon,
cnp.intp_t allow_single_cluster
):
cdef:
list selected_clusters = list()
list processed = list()
cnp.intp_t leaf, epsilon_child, sub_node
cnp.float64_t eps
cnp.uint8_t[:] leaf_nodes
cnp.ndarray[cnp.intp_t, ndim=1] children = cluster_tree['child']
cnp.ndarray[cnp.float64_t, ndim=1] distances = cluster_tree['value']
for leaf in leaves:
leaf_nodes = children == leaf
eps = 1 / distances[leaf_nodes][0]
if eps < cluster_selection_epsilon:
if leaf not in processed:
epsilon_child = traverse_upwards(
cluster_tree,
cluster_selection_epsilon,
leaf,
allow_single_cluster
)
selected_clusters.append(epsilon_child)
for sub_node in bfs_from_cluster_tree(cluster_tree, epsilon_child):
if sub_node != epsilon_child:
processed.append(sub_node)
else:
selected_clusters.append(leaf)
return set(selected_clusters)
@cython.wraparound(True)
cdef tuple _get_clusters(
cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] condensed_tree,
dict stability,
cluster_selection_method='eom',
cnp.uint8_t allow_single_cluster=False,
cnp.float64_t cluster_selection_epsilon=0.0,
max_cluster_size=None
):
"""Given a tree and stability dict, produce the cluster labels
(and probabilities) for a flat clustering based on the chosen
cluster selection method.
Parameters
----------
condensed_tree : ndarray of shape (n_samples,), dtype=CONDENSED_dtype
Effectively an edgelist encoding a parent/child pair, along with a
value and the corresponding cluster_size in each row providing a tree
structure.
stability : dict
A dictionary mapping cluster_ids to stability values
cluster_selection_method : string, optional (default 'eom')
The method of selecting clusters. The default is the
Excess of Mass algorithm specified by 'eom'. The alternate
option is 'leaf'.
allow_single_cluster : boolean, optional (default False)
Whether to allow a single cluster to be selected by the
Excess of Mass algorithm.
cluster_selection_epsilon: double, optional (default 0.0)
A distance threshold for cluster splits.
max_cluster_size: int, default=None
The maximum size for clusters located by the EOM clusterer. Can
be overridden by the cluster_selection_epsilon parameter in
rare cases.
Returns
-------
labels : ndarray of shape (n_samples,)
An integer array of cluster labels, with -1 denoting noise.
probabilities : ndarray (n_samples,)
The cluster membership strength of each sample.
stabilities : ndarray (n_clusters,)
The cluster coherence strengths of each cluster.
"""
cdef:
list node_list
cnp.ndarray[CONDENSED_t, ndim=1, mode='c'] cluster_tree
cnp.uint8_t[::1] child_selection
cnp.ndarray[cnp.intp_t, ndim=1, mode='c'] labels
dict is_cluster, cluster_sizes
cnp.float64_t subtree_stability
cnp.intp_t node, sub_node, cluster, n_samples
cnp.ndarray[cnp.float64_t, ndim=1, mode='c'] probs
# Assume clusters are ordered by numeric id equivalent to
# a topological sort of the tree; This is valid given the
# current implementation above, so don't change that ... or
# if you do, change this accordingly!
if allow_single_cluster:
node_list = sorted(stability.keys(), reverse=True)
else:
node_list = sorted(stability.keys(), reverse=True)[:-1]
# (exclude root)
cluster_tree = condensed_tree[condensed_tree['cluster_size'] > 1]
is_cluster = {cluster: True for cluster in node_list}
n_samples = np.max(condensed_tree[condensed_tree['cluster_size'] == 1]['child']) + 1
if max_cluster_size is None:
max_cluster_size = n_samples + 1 # Set to a value that will never be triggered
cluster_sizes = {
child: cluster_size for child, cluster_size
in zip(cluster_tree['child'], cluster_tree['cluster_size'])
}
if allow_single_cluster:
# Compute cluster size for the root node
cluster_sizes[node_list[-1]] = np.sum(
cluster_tree[cluster_tree['parent'] == node_list[-1]]['cluster_size'])
if cluster_selection_method == 'eom':
for node in node_list:
child_selection = (cluster_tree['parent'] == node)
subtree_stability = np.sum([
stability[child] for
child in cluster_tree['child'][child_selection]])
if subtree_stability > stability[node] or cluster_sizes[node] > max_cluster_size:
is_cluster[node] = False
stability[node] = subtree_stability
else:
for sub_node in bfs_from_cluster_tree(cluster_tree, node):
if sub_node != node:
is_cluster[sub_node] = False
if cluster_selection_epsilon != 0.0 and PyArray_SHAPE(<cnp.PyArrayObject*> cluster_tree)[0] > 0:
eom_clusters = [c for c in is_cluster if is_cluster[c]]
selected_clusters = []
# first check if eom_clusters only has root node, which skips epsilon check.
if (len(eom_clusters) == 1 and eom_clusters[0] == cluster_tree['parent'].min()):
if allow_single_cluster:
selected_clusters = eom_clusters
else:
selected_clusters = epsilon_search(
set(eom_clusters),
cluster_tree,
cluster_selection_epsilon,
allow_single_cluster
)
for c in is_cluster:
if c in selected_clusters:
is_cluster[c] = True
else:
is_cluster[c] = False
elif cluster_selection_method == 'leaf':
leaves = set(get_cluster_tree_leaves(cluster_tree))
if len(leaves) == 0:
for c in is_cluster:
is_cluster[c] = False
is_cluster[condensed_tree['parent'].min()] = True
if cluster_selection_epsilon != 0.0:
selected_clusters = epsilon_search(
leaves,
cluster_tree,
cluster_selection_epsilon,
allow_single_cluster
)
else:
selected_clusters = leaves
for c in is_cluster:
if c in selected_clusters:
is_cluster[c] = True
else:
is_cluster[c] = False
clusters = set([c for c in is_cluster if is_cluster[c]])
cluster_map = {c: n for n, c in enumerate(sorted(list(clusters)))}
reverse_cluster_map = {n: c for c, n in cluster_map.items()}
labels = _do_labelling(
condensed_tree,
clusters,
cluster_map,
allow_single_cluster,
cluster_selection_epsilon
)
probs = get_probabilities(condensed_tree, reverse_cluster_map, labels)
return (labels, probs)
@@ -0,0 +1,15 @@
cluster_hdbscan_extension_metadata = {
'_linkage': {'sources': [cython_gen.process('_linkage.pyx'), metrics_cython_tree]},
'_reachability': {'sources': [cython_gen.process('_reachability.pyx')]},
'_tree': {'sources': [cython_gen.process('_tree.pyx')]}
}
foreach ext_name, ext_dict : cluster_hdbscan_extension_metadata
py.extension_module(
ext_name,
ext_dict.get('sources'),
dependencies: [np_dep],
subdir: 'sklearn/cluster/_hdbscan',
install: true
)
endforeach
@@ -0,0 +1,9 @@
from ..utils._typedefs cimport intp_t
cdef class UnionFind:
cdef intp_t next_label
cdef intp_t[:] parent
cdef intp_t[:] size
cdef void union(self, intp_t m, intp_t n) noexcept
cdef intp_t fast_find(self, intp_t n) noexcept
@@ -0,0 +1,507 @@
# Authors: The scikit-learn developers
# SPDX-License-Identifier: BSD-3-Clause
import numpy as np
cimport cython
from ..metrics._dist_metrics cimport DistanceMetric64
from ..utils._fast_dict cimport IntFloatDict
from ..utils._typedefs cimport float64_t, intp_t, uint8_t
# C++
from cython.operator cimport dereference as deref, preincrement as inc
from libcpp.map cimport map as cpp_map
from libc.math cimport fmax, INFINITY
###############################################################################
# Utilities for computing the ward momentum
def compute_ward_dist(
const float64_t[::1] m_1,
const float64_t[:, ::1] m_2,
const intp_t[::1] coord_row,
const intp_t[::1] coord_col,
float64_t[::1] res
):
cdef intp_t size_max = coord_row.shape[0]
cdef intp_t n_features = m_2.shape[1]
cdef intp_t i, j, row, col
cdef float64_t pa, n
for i in range(size_max):
row = coord_row[i]
col = coord_col[i]
n = (m_1[row] * m_1[col]) / (m_1[row] + m_1[col])
pa = 0.
for j in range(n_features):
pa += (m_2[row, j] / m_1[row] - m_2[col, j] / m_1[col]) ** 2
res[i] = pa * n
###############################################################################
# Utilities for cutting and exploring a hierarchical tree
def _hc_get_descendent(intp_t node, children, intp_t n_leaves):
"""
Function returning all the descendent leaves of a set of nodes in the tree.
Parameters
----------
node : integer
The node for which we want the descendents.
children : list of pairs, length n_nodes
The children of each non-leaf node. Values less than `n_samples` refer
to leaves of the tree. A greater value `i` indicates a node with
children `children[i - n_samples]`.
n_leaves : integer
Number of leaves.
Returns
-------
descendent : list of int
"""
ind = [node]
if node < n_leaves:
return ind
descendent = []
# It is actually faster to do the accounting of the number of
# elements is the list ourselves: len is a lengthy operation on a
# chained list
cdef intp_t i, n_indices = 1
while n_indices:
i = ind.pop()
if i < n_leaves:
descendent.append(i)
n_indices -= 1
else:
ind.extend(children[i - n_leaves])
n_indices += 1
return descendent
def hc_get_heads(intp_t[:] parents, copy=True):
"""Returns the heads of the forest, as defined by parents.
Parameters
----------
parents : array of integers
The parent structure defining the forest (ensemble of trees)
copy : boolean
If copy is False, the input 'parents' array is modified inplace
Returns
-------
heads : array of integers of same shape as parents
The indices in the 'parents' of the tree heads
"""
cdef intp_t parent, node0, node, size
if copy:
parents = np.copy(parents)
size = parents.size
# Start from the top of the tree and go down
for node0 in range(size - 1, -1, -1):
node = node0
parent = parents[node]
while parent != node:
parents[node0] = parent
node = parent
parent = parents[node]
return parents
def _get_parents(
nodes,
heads,
const intp_t[:] parents,
uint8_t[::1] not_visited
):
"""Returns the heads of the given nodes, as defined by parents.
Modifies 'heads' and 'not_visited' in-place.
Parameters
----------
nodes : list of integers
The nodes to start from
heads : list of integers
A list to hold the results (modified inplace)
parents : array of integers
The parent structure defining the tree
not_visited
The tree nodes to consider (modified inplace)
"""
cdef intp_t parent, node
for node in nodes:
parent = parents[node]
while parent != node:
node = parent
parent = parents[node]
if not_visited[node]:
not_visited[node] = 0
heads.append(node)
###############################################################################
# merge strategies implemented on IntFloatDicts
# These are used in the hierarchical clustering code, to implement
# merging between two clusters, defined as a dict containing node number
# as keys and edge weights as values.
def max_merge(
IntFloatDict a,
IntFloatDict b,
const intp_t[:] mask,
intp_t n_a,
intp_t n_b
):
"""Merge two IntFloatDicts with the max strategy: when the same key is
present in the two dicts, the max of the two values is used.
Parameters
==========
a, b : IntFloatDict object
The IntFloatDicts to merge
mask : ndarray array of dtype integer and of dimension 1
a mask for keys to ignore: if not mask[key] the corresponding key
is skipped in the output dictionary
n_a, n_b : float
n_a and n_b are weights for a and b for the merge strategy.
They are not used in the case of a max merge.
Returns
=======
out : IntFloatDict object
The IntFloatDict resulting from the merge
"""
cdef IntFloatDict out_obj = IntFloatDict.__new__(IntFloatDict)
cdef cpp_map[intp_t, float64_t].iterator a_it = a.my_map.begin()
cdef cpp_map[intp_t, float64_t].iterator a_end = a.my_map.end()
cdef intp_t key
cdef float64_t value
# First copy a into out
while a_it != a_end:
key = deref(a_it).first
if mask[key]:
out_obj.my_map[key] = deref(a_it).second
inc(a_it)
# Then merge b into out
cdef cpp_map[intp_t, float64_t].iterator out_it = out_obj.my_map.begin()
cdef cpp_map[intp_t, float64_t].iterator out_end = out_obj.my_map.end()
cdef cpp_map[intp_t, float64_t].iterator b_it = b.my_map.begin()
cdef cpp_map[intp_t, float64_t].iterator b_end = b.my_map.end()
while b_it != b_end:
key = deref(b_it).first
value = deref(b_it).second
if mask[key]:
out_it = out_obj.my_map.find(key)
if out_it == out_end:
# Key not found
out_obj.my_map[key] = value
else:
deref(out_it).second = fmax(deref(out_it).second, value)
inc(b_it)
return out_obj
def average_merge(
IntFloatDict a,
IntFloatDict b,
const intp_t[:] mask,
intp_t n_a,
intp_t n_b
):
"""Merge two IntFloatDicts with the average strategy: when the
same key is present in the two dicts, the weighted average of the two
values is used.
Parameters
==========
a, b : IntFloatDict object
The IntFloatDicts to merge
mask : ndarray array of dtype integer and of dimension 1
a mask for keys to ignore: if not mask[key] the corresponding key
is skipped in the output dictionary
n_a, n_b : float
n_a and n_b are weights for a and b for the merge strategy.
They are used for a weighted mean.
Returns
=======
out : IntFloatDict object
The IntFloatDict resulting from the merge
"""
cdef IntFloatDict out_obj = IntFloatDict.__new__(IntFloatDict)
cdef cpp_map[intp_t, float64_t].iterator a_it = a.my_map.begin()
cdef cpp_map[intp_t, float64_t].iterator a_end = a.my_map.end()
cdef intp_t key
cdef float64_t value
cdef float64_t n_out = <float64_t> (n_a + n_b)
# First copy a into out
while a_it != a_end:
key = deref(a_it).first
if mask[key]:
out_obj.my_map[key] = deref(a_it).second
inc(a_it)
# Then merge b into out
cdef cpp_map[intp_t, float64_t].iterator out_it = out_obj.my_map.begin()
cdef cpp_map[intp_t, float64_t].iterator out_end = out_obj.my_map.end()
cdef cpp_map[intp_t, float64_t].iterator b_it = b.my_map.begin()
cdef cpp_map[intp_t, float64_t].iterator b_end = b.my_map.end()
while b_it != b_end:
key = deref(b_it).first
value = deref(b_it).second
if mask[key]:
out_it = out_obj.my_map.find(key)
if out_it == out_end:
# Key not found
out_obj.my_map[key] = value
else:
deref(out_it).second = (n_a * deref(out_it).second
+ n_b * value) / n_out
inc(b_it)
return out_obj
###############################################################################
# An edge object for fast comparisons
cdef class WeightedEdge:
cdef public intp_t a
cdef public intp_t b
cdef public float64_t weight
def __init__(self, float64_t weight, intp_t a, intp_t b):
self.weight = weight
self.a = a
self.b = b
def __richcmp__(self, WeightedEdge other, int op):
"""Cython-specific comparison method.
op is the comparison code::
< 0
== 2
> 4
<= 1
!= 3
>= 5
"""
if op == 0:
return self.weight < other.weight
elif op == 1:
return self.weight <= other.weight
elif op == 2:
return self.weight == other.weight
elif op == 3:
return self.weight != other.weight
elif op == 4:
return self.weight > other.weight
elif op == 5:
return self.weight >= other.weight
def __repr__(self):
return "%s(weight=%f, a=%i, b=%i)" % (self.__class__.__name__,
self.weight,
self.a, self.b)
################################################################################
# Efficient labelling/conversion of MSTs to single linkage hierarchies
cdef class UnionFind(object):
def __init__(self, N):
self.parent = np.full(2 * N - 1, -1., dtype=np.intp, order='C')
self.next_label = N
self.size = np.hstack((np.ones(N, dtype=np.intp),
np.zeros(N - 1, dtype=np.intp)))
cdef void union(self, intp_t m, intp_t n) noexcept:
self.parent[m] = self.next_label
self.parent[n] = self.next_label
self.size[self.next_label] = self.size[m] + self.size[n]
self.next_label += 1
return
@cython.wraparound(True)
cdef intp_t fast_find(self, intp_t n) noexcept:
cdef intp_t p
p = n
# find the highest node in the linkage graph so far
while self.parent[n] != -1:
n = self.parent[n]
# provide a shortcut up to the highest node
while self.parent[p] != n:
p, self.parent[p] = self.parent[p], n
return n
def _single_linkage_label(const float64_t[:, :] L):
"""
Convert an linkage array or MST to a tree by labelling clusters at merges.
This is done by using a Union find structure to keep track of merges
efficiently. This is the private version of the function that assumes that
``L`` has been properly validated. See ``single_linkage_label`` for the
user facing version of this function.
Parameters
----------
L: array of shape (n_samples - 1, 3)
The linkage array or MST where each row specifies two samples
to be merged and a distance or weight at which the merge occurs. This
array is assumed to be sorted by the distance/weight.
Returns
-------
A tree in the format used by scipy.cluster.hierarchy.
"""
cdef float64_t[:, ::1] result_arr
cdef intp_t left, left_cluster, right, right_cluster, index
cdef float64_t delta
result_arr = np.zeros((L.shape[0], 4), dtype=np.float64)
U = UnionFind(L.shape[0] + 1)
for index in range(L.shape[0]):
left = <intp_t> L[index, 0]
right = <intp_t> L[index, 1]
delta = L[index, 2]
left_cluster = U.fast_find(left)
right_cluster = U.fast_find(right)
result_arr[index][0] = left_cluster
result_arr[index][1] = right_cluster
result_arr[index][2] = delta
result_arr[index][3] = U.size[left_cluster] + U.size[right_cluster]
U.union(left_cluster, right_cluster)
return np.asarray(result_arr)
@cython.wraparound(True)
def single_linkage_label(L):
"""
Convert an linkage array or MST to a tree by labelling clusters at merges.
This is done by using a Union find structure to keep track of merges
efficiently.
Parameters
----------
L: array of shape (n_samples - 1, 3)
The linkage array or MST where each row specifies two samples
to be merged and a distance or weight at which the merge occurs. This
array is assumed to be sorted by the distance/weight.
Returns
-------
A tree in the format used by scipy.cluster.hierarchy.
"""
# Validate L
if L[:, :2].min() < 0 or L[:, :2].max() >= 2 * L.shape[0] + 1:
raise ValueError("Input MST array is not a validly formatted MST array")
is_sorted = lambda x: np.all(x[:-1] <= x[1:])
if not is_sorted(L[:, 2]):
raise ValueError("Input MST array must be sorted by weight")
return _single_linkage_label(L)
# Implements MST-LINKAGE-CORE from https://arxiv.org/abs/1109.2378
def mst_linkage_core(
const float64_t [:, ::1] raw_data,
DistanceMetric64 dist_metric):
"""
Compute the necessary elements of a minimum spanning
tree for computation of single linkage clustering. This
represents the MST-LINKAGE-CORE algorithm (Figure 6) from
:arxiv:`Daniel Mullner, "Modern hierarchical, agglomerative clustering
algorithms" <1109.2378>`.
In contrast to the scipy implementation is never computes
a full distance matrix, generating distances only as they
are needed and releasing them when no longer needed.
Parameters
----------
raw_data: array of shape (n_samples, n_features)
The array of feature data to be clustered. Must be C-aligned
dist_metric: DistanceMetric64
A DistanceMetric64 object conforming to the API from
``sklearn.metrics._dist_metrics.pxd`` that will be
used to compute distances.
Returns
-------
mst_core_data: array of shape (n_samples, 3)
An array providing information from which one
can either compute an MST, or the linkage hierarchy
very efficiently. See :arxiv:`Daniel Mullner, "Modern hierarchical,
agglomerative clustering algorithms" <1109.2378>` algorithm
MST-LINKAGE-CORE for more details.
"""
cdef:
intp_t n_samples = raw_data.shape[0]
uint8_t[:] in_tree = np.zeros(n_samples, dtype=bool)
float64_t[:, ::1] result = np.zeros((n_samples - 1, 3))
intp_t current_node = 0
intp_t new_node
intp_t i
intp_t j
intp_t num_features = raw_data.shape[1]
float64_t right_value
float64_t left_value
float64_t new_distance
float64_t[:] current_distances = np.full(n_samples, INFINITY)
for i in range(n_samples - 1):
in_tree[current_node] = 1
new_distance = INFINITY
new_node = 0
for j in range(n_samples):
if in_tree[j]:
continue
right_value = current_distances[j]
left_value = dist_metric.dist(&raw_data[current_node, 0],
&raw_data[j, 0],
num_features)
if left_value < right_value:
current_distances[j] = left_value
if current_distances[j] < new_distance:
new_distance = current_distances[j]
new_node = j
result[i, 0] = current_node
result[i, 1] = new_node
result[i, 2] = new_distance
current_node = new_node
return np.array(result)
@@ -0,0 +1,48 @@
from cython cimport floating
cdef floating _euclidean_dense_dense(
const floating*,
const floating*,
int,
bint
) noexcept nogil
cdef floating _euclidean_sparse_dense(
const floating[::1],
const int[::1],
const floating[::1],
floating,
bint
) noexcept nogil
cpdef void _relocate_empty_clusters_dense(
const floating[:, ::1],
const floating[::1],
const floating[:, ::1],
floating[:, ::1],
floating[::1],
const int[::1]
)
cpdef void _relocate_empty_clusters_sparse(
const floating[::1],
const int[::1],
const int[::1],
const floating[::1],
const floating[:, ::1],
floating[:, ::1],
floating[::1],
const int[::1]
)
cdef void _average_centers(
floating[:, ::1],
const floating[::1]
)
cdef void _center_shift(
const floating[:, ::1],
const floating[:, ::1],
floating[::1]
)
@@ -0,0 +1,328 @@
# Authors: The scikit-learn developers
# SPDX-License-Identifier: BSD-3-Clause
import numpy as np
from cython cimport floating
from cython.parallel cimport prange
from libc.math cimport sqrt
from ..utils.extmath import row_norms
# Number of samples per data chunk defined as a global constant.
CHUNK_SIZE = 256
cdef floating _euclidean_dense_dense(
const floating* a, # IN
const floating* b, # IN
int n_features,
bint squared
) noexcept nogil:
"""Euclidean distance between a dense and b dense"""
cdef:
int i
int n = n_features // 4
int rem = n_features % 4
floating result = 0
# We manually unroll the loop for better cache optimization.
for i in range(n):
result += (
(a[0] - b[0]) * (a[0] - b[0]) +
(a[1] - b[1]) * (a[1] - b[1]) +
(a[2] - b[2]) * (a[2] - b[2]) +
(a[3] - b[3]) * (a[3] - b[3])
)
a += 4
b += 4
for i in range(rem):
result += (a[i] - b[i]) * (a[i] - b[i])
return result if squared else sqrt(result)
def _euclidean_dense_dense_wrapper(
const floating[::1] a,
const floating[::1] b,
bint squared
):
"""Wrapper of _euclidean_dense_dense for testing purpose"""
return _euclidean_dense_dense(&a[0], &b[0], a.shape[0], squared)
cdef floating _euclidean_sparse_dense(
const floating[::1] a_data, # IN
const int[::1] a_indices, # IN
const floating[::1] b, # IN
floating b_squared_norm,
bint squared
) noexcept nogil:
"""Euclidean distance between a sparse and b dense"""
cdef:
int nnz = a_indices.shape[0]
int i
floating tmp, bi
floating result = 0.0
for i in range(nnz):
bi = b[a_indices[i]]
tmp = a_data[i] - bi
result += tmp * tmp - bi * bi
result += b_squared_norm
if result < 0:
result = 0.0
return result if squared else sqrt(result)
def _euclidean_sparse_dense_wrapper(
const floating[::1] a_data,
const int[::1] a_indices,
const floating[::1] b,
floating b_squared_norm,
bint squared
):
"""Wrapper of _euclidean_sparse_dense for testing purpose"""
return _euclidean_sparse_dense(
a_data, a_indices, b, b_squared_norm, squared)
cpdef floating _inertia_dense(
const floating[:, ::1] X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers, # IN
const int[::1] labels, # IN
int n_threads,
int single_label=-1,
):
"""Compute inertia for dense input data
Sum of squared distance between each sample and its assigned center.
If single_label is >= 0, the inertia is computed only for that label.
"""
cdef:
int n_samples = X.shape[0]
int n_features = X.shape[1]
int i, j
floating sq_dist = 0.0
floating inertia = 0.0
for i in prange(n_samples, nogil=True, num_threads=n_threads,
schedule='static'):
j = labels[i]
if single_label < 0 or single_label == j:
sq_dist = _euclidean_dense_dense(&X[i, 0], &centers[j, 0],
n_features, True)
inertia += sq_dist * sample_weight[i]
return inertia
cpdef floating _inertia_sparse(
X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers, # IN
const int[::1] labels, # IN
int n_threads,
int single_label=-1,
):
"""Compute inertia for sparse input data
Sum of squared distance between each sample and its assigned center.
If single_label is >= 0, the inertia is computed only for that label.
"""
cdef:
floating[::1] X_data = X.data
int[::1] X_indices = X.indices
int[::1] X_indptr = X.indptr
int n_samples = X.shape[0]
int i, j
floating sq_dist = 0.0
floating inertia = 0.0
floating[::1] centers_squared_norms = row_norms(centers, squared=True)
for i in prange(n_samples, nogil=True, num_threads=n_threads,
schedule='static'):
j = labels[i]
if single_label < 0 or single_label == j:
sq_dist = _euclidean_sparse_dense(
X_data[X_indptr[i]: X_indptr[i + 1]],
X_indices[X_indptr[i]: X_indptr[i + 1]],
centers[j], centers_squared_norms[j], True)
inertia += sq_dist * sample_weight[i]
return inertia
cpdef void _relocate_empty_clusters_dense(
const floating[:, ::1] X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
floating[:, ::1] centers_new, # INOUT
floating[::1] weight_in_clusters, # INOUT
const int[::1] labels # IN
):
"""Relocate centers which have no sample assigned to them."""
cdef:
int[::1] empty_clusters = np.where(np.equal(weight_in_clusters, 0))[0].astype(np.int32)
int n_empty = empty_clusters.shape[0]
if n_empty == 0:
return
cdef:
int n_features = X.shape[1]
floating[::1] distances = ((np.asarray(X) - np.asarray(centers_old)[labels])**2).sum(axis=1)
int[::1] far_from_centers = np.argpartition(distances, -n_empty)[:-n_empty-1:-1].astype(np.int32)
int new_cluster_id, old_cluster_id, far_idx, idx, k
floating weight
if np.max(distances) == 0:
# Happens when there are more clusters than non-duplicate samples. Relocating
# is pointless in this case.
return
for idx in range(n_empty):
new_cluster_id = empty_clusters[idx]
far_idx = far_from_centers[idx]
weight = sample_weight[far_idx]
old_cluster_id = labels[far_idx]
for k in range(n_features):
centers_new[old_cluster_id, k] -= X[far_idx, k] * weight
centers_new[new_cluster_id, k] = X[far_idx, k] * weight
weight_in_clusters[new_cluster_id] = weight
weight_in_clusters[old_cluster_id] -= weight
cpdef void _relocate_empty_clusters_sparse(
const floating[::1] X_data, # IN
const int[::1] X_indices, # IN
const int[::1] X_indptr, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
floating[:, ::1] centers_new, # INOUT
floating[::1] weight_in_clusters, # INOUT
const int[::1] labels # IN
):
"""Relocate centers which have no sample assigned to them."""
cdef:
int[::1] empty_clusters = np.where(np.equal(weight_in_clusters, 0))[0].astype(np.int32)
int n_empty = empty_clusters.shape[0]
if n_empty == 0:
return
cdef:
int n_samples = X_indptr.shape[0] - 1
int i, j, k
floating[::1] distances = np.zeros(n_samples, dtype=X_data.base.dtype)
floating[::1] centers_squared_norms = row_norms(centers_old, squared=True)
for i in range(n_samples):
j = labels[i]
distances[i] = _euclidean_sparse_dense(
X_data[X_indptr[i]: X_indptr[i + 1]],
X_indices[X_indptr[i]: X_indptr[i + 1]],
centers_old[j], centers_squared_norms[j], True)
if np.max(distances) == 0:
# Happens when there are more clusters than non-duplicate samples. Relocating
# is pointless in this case.
return
cdef:
int[::1] far_from_centers = np.argpartition(distances, -n_empty)[:-n_empty-1:-1].astype(np.int32)
int new_cluster_id, old_cluster_id, far_idx, idx
floating weight
for idx in range(n_empty):
new_cluster_id = empty_clusters[idx]
far_idx = far_from_centers[idx]
weight = sample_weight[far_idx]
old_cluster_id = labels[far_idx]
for k in range(X_indptr[far_idx], X_indptr[far_idx + 1]):
centers_new[old_cluster_id, X_indices[k]] -= X_data[k] * weight
centers_new[new_cluster_id, X_indices[k]] = X_data[k] * weight
weight_in_clusters[new_cluster_id] = weight
weight_in_clusters[old_cluster_id] -= weight
cdef void _average_centers(
floating[:, ::1] centers, # INOUT
const floating[::1] weight_in_clusters # IN
):
"""Average new centers wrt weights."""
cdef:
int n_clusters = centers.shape[0]
int n_features = centers.shape[1]
int j, k
floating alpha
int argmax_weight = np.argmax(weight_in_clusters)
for j in range(n_clusters):
if weight_in_clusters[j] > 0:
alpha = 1.0 / weight_in_clusters[j]
for k in range(n_features):
centers[j, k] *= alpha
else:
# For convenience, we avoid setting empty clusters at the origin but place
# them at the location of the biggest cluster.
for k in range(n_features):
centers[j, k] = centers[argmax_weight, k]
cdef void _center_shift(
const floating[:, ::1] centers_old, # IN
const floating[:, ::1] centers_new, # IN
floating[::1] center_shift # OUT
):
"""Compute shift between old and new centers."""
cdef:
int n_clusters = centers_old.shape[0]
int n_features = centers_old.shape[1]
int j
for j in range(n_clusters):
center_shift[j] = _euclidean_dense_dense(
&centers_new[j, 0], &centers_old[j, 0], n_features, False)
def _is_same_clustering(
const int[::1] labels1,
const int[::1] labels2,
n_clusters
):
"""Check if two arrays of labels are the same up to a permutation of the labels"""
cdef int[::1] mapping = np.full(fill_value=-1, shape=(n_clusters,), dtype=np.int32)
cdef int i
for i in range(labels1.shape[0]):
if mapping[labels1[i]] == -1:
mapping[labels1[i]] = labels2[i]
elif mapping[labels1[i]] != labels2[i]:
return False
return True
@@ -0,0 +1,686 @@
# Authors: The scikit-learn developers
# SPDX-License-Identifier: BSD-3-Clause
from cython cimport floating
from cython.parallel import prange, parallel
from libc.stdlib cimport calloc, free
from libc.string cimport memset
from ..utils._openmp_helpers cimport omp_lock_t
from ..utils._openmp_helpers cimport omp_init_lock
from ..utils._openmp_helpers cimport omp_destroy_lock
from ..utils._openmp_helpers cimport omp_set_lock
from ..utils._openmp_helpers cimport omp_unset_lock
from ..utils.extmath import row_norms
from ._k_means_common import CHUNK_SIZE
from ._k_means_common cimport _relocate_empty_clusters_dense
from ._k_means_common cimport _relocate_empty_clusters_sparse
from ._k_means_common cimport _euclidean_dense_dense
from ._k_means_common cimport _euclidean_sparse_dense
from ._k_means_common cimport _average_centers
from ._k_means_common cimport _center_shift
def init_bounds_dense(
const floating[:, ::1] X, # IN
const floating[:, ::1] centers, # IN
const floating[:, ::1] center_half_distances, # IN
int[::1] labels, # OUT
floating[::1] upper_bounds, # OUT
floating[:, ::1] lower_bounds, # OUT
int n_threads):
"""Initialize upper and lower bounds for each sample for dense input data.
Given X, centers and the pairwise distances divided by 2.0 between the
centers this calculates the upper bounds and lower bounds for each sample.
The upper bound for each sample is set to the distance between the sample
and the closest center.
The lower bound for each sample is a one-dimensional array of n_clusters.
For each sample i assume that the previously assigned cluster is c1 and the
previous closest distance is dist, for a new cluster c2, the
lower_bound[i][c2] is set to distance between the sample and this new
cluster, if and only if dist > center_half_distances[c1][c2]. This prevents
computation of unnecessary distances for each sample to the clusters that
it is unlikely to be assigned to.
Parameters
----------
X : ndarray of shape (n_samples, n_features), dtype=floating
The input data.
centers : ndarray of shape (n_clusters, n_features), dtype=floating
The cluster centers.
center_half_distances : ndarray of shape (n_clusters, n_clusters), \
dtype=floating
The half of the distance between any 2 clusters centers.
labels : ndarray of shape(n_samples), dtype=int
The label for each sample. This array is modified in place.
upper_bounds : ndarray of shape(n_samples,), dtype=floating
The upper bound on the distance between each sample and its closest
cluster center. This array is modified in place.
lower_bounds : ndarray, of shape(n_samples, n_clusters), dtype=floating
The lower bound on the distance between each sample and each cluster
center. This array is modified in place.
n_threads : int
The number of threads to be used by openmp.
"""
cdef:
int n_samples = X.shape[0]
int n_clusters = centers.shape[0]
int n_features = X.shape[1]
floating min_dist, dist
int best_cluster, i, j
for i in prange(
n_samples, num_threads=n_threads, schedule='static', nogil=True
):
best_cluster = 0
min_dist = _euclidean_dense_dense(&X[i, 0], &centers[0, 0],
n_features, False)
lower_bounds[i, 0] = min_dist
for j in range(1, n_clusters):
if min_dist > center_half_distances[best_cluster, j]:
dist = _euclidean_dense_dense(&X[i, 0], &centers[j, 0],
n_features, False)
lower_bounds[i, j] = dist
if dist < min_dist:
min_dist = dist
best_cluster = j
labels[i] = best_cluster
upper_bounds[i] = min_dist
def init_bounds_sparse(
X, # IN
const floating[:, ::1] centers, # IN
const floating[:, ::1] center_half_distances, # IN
int[::1] labels, # OUT
floating[::1] upper_bounds, # OUT
floating[:, ::1] lower_bounds, # OUT
int n_threads):
"""Initialize upper and lower bounds for each sample for sparse input data.
Given X, centers and the pairwise distances divided by 2.0 between the
centers this calculates the upper bounds and lower bounds for each sample.
The upper bound for each sample is set to the distance between the sample
and the closest center.
The lower bound for each sample is a one-dimensional array of n_clusters.
For each sample i assume that the previously assigned cluster is c1 and the
previous closest distance is dist, for a new cluster c2, the
lower_bound[i][c2] is set to distance between the sample and this new
cluster, if and only if dist > center_half_distances[c1][c2]. This prevents
computation of unnecessary distances for each sample to the clusters that
it is unlikely to be assigned to.
Parameters
----------
X : sparse matrix of shape (n_samples, n_features), dtype=floating
The input data. Must be in CSR format.
centers : ndarray of shape (n_clusters, n_features), dtype=floating
The cluster centers.
center_half_distances : ndarray of shape (n_clusters, n_clusters), \
dtype=floating
The half of the distance between any 2 clusters centers.
labels : ndarray of shape(n_samples), dtype=int
The label for each sample. This array is modified in place.
upper_bounds : ndarray of shape(n_samples,), dtype=floating
The upper bound on the distance between each sample and its closest
cluster center. This array is modified in place.
lower_bounds : ndarray of shape(n_samples, n_clusters), dtype=floating
The lower bound on the distance between each sample and each cluster
center. This array is modified in place.
n_threads : int
The number of threads to be used by openmp.
"""
cdef:
int n_samples = X.shape[0]
int n_clusters = centers.shape[0]
floating[::1] X_data = X.data
int[::1] X_indices = X.indices
int[::1] X_indptr = X.indptr
floating min_dist, dist
int best_cluster, i, j
floating[::1] centers_squared_norms = row_norms(centers, squared=True)
for i in prange(
n_samples, num_threads=n_threads, schedule='static', nogil=True
):
best_cluster = 0
min_dist = _euclidean_sparse_dense(
X_data[X_indptr[i]: X_indptr[i + 1]],
X_indices[X_indptr[i]: X_indptr[i + 1]],
centers[0], centers_squared_norms[0], False)
lower_bounds[i, 0] = min_dist
for j in range(1, n_clusters):
if min_dist > center_half_distances[best_cluster, j]:
dist = _euclidean_sparse_dense(
X_data[X_indptr[i]: X_indptr[i + 1]],
X_indices[X_indptr[i]: X_indptr[i + 1]],
centers[j], centers_squared_norms[j], False)
lower_bounds[i, j] = dist
if dist < min_dist:
min_dist = dist
best_cluster = j
labels[i] = best_cluster
upper_bounds[i] = min_dist
def elkan_iter_chunked_dense(
const floating[:, ::1] X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
floating[:, ::1] centers_new, # OUT
floating[::1] weight_in_clusters, # OUT
const floating[:, ::1] center_half_distances, # IN
const floating[::1] distance_next_center, # IN
floating[::1] upper_bounds, # INOUT
floating[:, ::1] lower_bounds, # INOUT
int[::1] labels, # INOUT
floating[::1] center_shift, # OUT
int n_threads,
bint update_centers=True):
"""Single iteration of K-means Elkan algorithm with dense input.
Update labels and centers (inplace), for one iteration, distributed
over data chunks.
Parameters
----------
X : ndarray of shape (n_samples, n_features), dtype=floating
The observations to cluster.
sample_weight : ndarray of shape (n_samples,), dtype=floating
The weights for each observation in X.
centers_old : ndarray of shape (n_clusters, n_features), dtype=floating
Centers before previous iteration, placeholder for the centers after
previous iteration.
centers_new : ndarray of shape (n_clusters, n_features), dtype=floating
Centers after previous iteration, placeholder for the new centers
computed during this iteration.
weight_in_clusters : ndarray of shape (n_clusters,), dtype=floating
Placeholder for the sums of the weights of every observation assigned
to each center.
center_half_distances : ndarray of shape (n_clusters, n_clusters), \
dtype=floating
Half pairwise distances between centers.
distance_next_center : ndarray of shape (n_clusters,), dtype=floating
Distance between each center its closest center.
upper_bounds : ndarray of shape (n_samples,), dtype=floating
Upper bound for the distance between each sample and its center,
updated inplace.
lower_bounds : ndarray of shape (n_samples, n_clusters), dtype=floating
Lower bound for the distance between each sample and each center,
updated inplace.
labels : ndarray of shape (n_samples,), dtype=int
labels assignment.
center_shift : ndarray of shape (n_clusters,), dtype=floating
Distance between old and new centers.
n_threads : int
The number of threads to be used by openmp.
update_centers : bool
- If True, the labels and the new centers will be computed, i.e. runs
the E-step and the M-step of the algorithm.
- If False, only the labels will be computed, i.e runs the E-step of
the algorithm. This is useful especially when calling predict on a
fitted model.
"""
cdef:
int n_samples = X.shape[0]
int n_features = X.shape[1]
int n_clusters = centers_new.shape[0]
if n_samples == 0:
# An empty array was passed, do nothing and return early (before
# attempting to compute n_chunks). This can typically happen when
# calling the prediction function of a bisecting k-means model with a
# large fraction of outliers.
return
cdef:
# hard-coded number of samples per chunk. Splitting in chunks is
# necessary to get parallelism. Chunk size chosen to be same as lloyd's
int n_samples_chunk = CHUNK_SIZE if n_samples > CHUNK_SIZE else n_samples
int n_chunks = n_samples // n_samples_chunk
int n_samples_rem = n_samples % n_samples_chunk
int chunk_idx
int start, end
int i, j, k
floating *centers_new_chunk
floating *weight_in_clusters_chunk
omp_lock_t lock
# count remainder chunk in total number of chunks
n_chunks += n_samples != n_chunks * n_samples_chunk
# number of threads should not be bigger than number of chunks
n_threads = min(n_threads, n_chunks)
if update_centers:
memset(&centers_new[0, 0], 0, n_clusters * n_features * sizeof(floating))
memset(&weight_in_clusters[0], 0, n_clusters * sizeof(floating))
omp_init_lock(&lock)
with nogil, parallel(num_threads=n_threads):
# thread local buffers
centers_new_chunk = <floating*> calloc(n_clusters * n_features, sizeof(floating))
weight_in_clusters_chunk = <floating*> calloc(n_clusters, sizeof(floating))
for chunk_idx in prange(n_chunks, schedule='static'):
start = chunk_idx * n_samples_chunk
if chunk_idx == n_chunks - 1 and n_samples_rem > 0:
end = start + n_samples_rem
else:
end = start + n_samples_chunk
_update_chunk_dense(
X[start: end],
sample_weight[start: end],
centers_old,
center_half_distances,
distance_next_center,
labels[start: end],
upper_bounds[start: end],
lower_bounds[start: end],
centers_new_chunk,
weight_in_clusters_chunk,
update_centers)
# reduction from local buffers.
if update_centers:
# The lock is necessary to avoid race conditions when aggregating
# info from different thread-local buffers.
omp_set_lock(&lock)
for j in range(n_clusters):
weight_in_clusters[j] += weight_in_clusters_chunk[j]
for k in range(n_features):
centers_new[j, k] += centers_new_chunk[j * n_features + k]
omp_unset_lock(&lock)
free(centers_new_chunk)
free(weight_in_clusters_chunk)
if update_centers:
omp_destroy_lock(&lock)
_relocate_empty_clusters_dense(X, sample_weight, centers_old,
centers_new, weight_in_clusters, labels)
_average_centers(centers_new, weight_in_clusters)
_center_shift(centers_old, centers_new, center_shift)
# update lower and upper bounds
for i in range(n_samples):
upper_bounds[i] += center_shift[labels[i]]
for j in range(n_clusters):
lower_bounds[i, j] -= center_shift[j]
if lower_bounds[i, j] < 0:
lower_bounds[i, j] = 0
cdef void _update_chunk_dense(
const floating[:, ::1] X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
const floating[:, ::1] center_half_distances, # IN
const floating[::1] distance_next_center, # IN
int[::1] labels, # INOUT
floating[::1] upper_bounds, # INOUT
floating[:, ::1] lower_bounds, # INOUT
floating *centers_new, # OUT
floating *weight_in_clusters, # OUT
bint update_centers) noexcept nogil:
"""K-means combined EM step for one dense data chunk.
Compute the partial contribution of a single data chunk to the labels and
centers.
"""
cdef:
int n_samples = labels.shape[0]
int n_clusters = centers_old.shape[0]
int n_features = centers_old.shape[1]
floating upper_bound, distance
int i, j, k, label
for i in range(n_samples):
upper_bound = upper_bounds[i]
bounds_tight = 0
label = labels[i]
# Next center is not far away from the currently assigned center.
# Sample might need to be assigned to another center.
if not distance_next_center[label] >= upper_bound:
for j in range(n_clusters):
# If this holds, then center_index is a good candidate for the
# sample to be relabelled, and we need to confirm this by
# recomputing the upper and lower bounds.
if (
j != label
and (upper_bound > lower_bounds[i, j])
and (upper_bound > center_half_distances[label, j])
):
# Recompute upper bound by calculating the actual distance
# between the sample and its current assigned center.
if not bounds_tight:
upper_bound = _euclidean_dense_dense(
&X[i, 0], &centers_old[label, 0], n_features, False)
lower_bounds[i, label] = upper_bound
bounds_tight = 1
# If the condition still holds, then compute the actual
# distance between the sample and center. If this is less
# than the previous distance, reassign label.
if (
upper_bound > lower_bounds[i, j]
or (upper_bound > center_half_distances[label, j])
):
distance = _euclidean_dense_dense(
&X[i, 0], &centers_old[j, 0], n_features, False)
lower_bounds[i, j] = distance
if distance < upper_bound:
label = j
upper_bound = distance
labels[i] = label
upper_bounds[i] = upper_bound
if update_centers:
weight_in_clusters[label] += sample_weight[i]
for k in range(n_features):
centers_new[label * n_features + k] += X[i, k] * sample_weight[i]
def elkan_iter_chunked_sparse(
X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
floating[:, ::1] centers_new, # OUT
floating[::1] weight_in_clusters, # OUT
const floating[:, ::1] center_half_distances, # IN
const floating[::1] distance_next_center, # IN
floating[::1] upper_bounds, # INOUT
floating[:, ::1] lower_bounds, # INOUT
int[::1] labels, # INOUT
floating[::1] center_shift, # OUT
int n_threads,
bint update_centers=True):
"""Single iteration of K-means Elkan algorithm with sparse input.
Update labels and centers (inplace), for one iteration, distributed
over data chunks.
Parameters
----------
X : sparse matrix of shape (n_samples, n_features)
The observations to cluster. Must be in CSR format.
sample_weight : ndarray of shape (n_samples,), dtype=floating
The weights for each observation in X.
centers_old : ndarray of shape (n_clusters, n_features), dtype=floating
Centers before previous iteration, placeholder for the centers after
previous iteration.
centers_new : ndarray of shape (n_clusters, n_features), dtype=floating
Centers after previous iteration, placeholder for the new centers
computed during this iteration.
weight_in_clusters : ndarray of shape (n_clusters,), dtype=floating
Placeholder for the sums of the weights of every observation assigned
to each center.
center_half_distances : ndarray of shape (n_clusters, n_clusters), \
dtype=floating
Half pairwise distances between centers.
distance_next_center : ndarray of shape (n_clusters,), dtype=floating
Distance between each center its closest center.
upper_bounds : ndarray of shape (n_samples,), dtype=floating
Upper bound for the distance between each sample and its center,
updated inplace.
lower_bounds : ndarray of shape (n_samples, n_clusters), dtype=floating
Lower bound for the distance between each sample and each center,
updated inplace.
labels : ndarray of shape (n_samples,), dtype=int
labels assignment.
center_shift : ndarray of shape (n_clusters,), dtype=floating
Distance between old and new centers.
n_threads : int
The number of threads to be used by openmp.
update_centers : bool
- If True, the labels and the new centers will be computed, i.e. runs
the E-step and the M-step of the algorithm.
- If False, only the labels will be computed, i.e runs the E-step of
the algorithm. This is useful especially when calling predict on a
fitted model.
"""
cdef:
int n_samples = X.shape[0]
int n_features = X.shape[1]
int n_clusters = centers_new.shape[0]
if n_samples == 0:
# An empty array was passed, do nothing and return early (before
# attempting to compute n_chunks). This can typically happen when
# calling the prediction function of a bisecting k-means model with a
# large fraction of outliers.
return
cdef:
floating[::1] X_data = X.data
int[::1] X_indices = X.indices
int[::1] X_indptr = X.indptr
# hard-coded number of samples per chunk. Splitting in chunks is
# necessary to get parallelism. Chunk size chosen to be same as lloyd's
int n_samples_chunk = CHUNK_SIZE if n_samples > CHUNK_SIZE else n_samples
int n_chunks = n_samples // n_samples_chunk
int n_samples_rem = n_samples % n_samples_chunk
int chunk_idx
int start, end
int i, j, k
floating[::1] centers_squared_norms = row_norms(centers_old, squared=True)
floating *centers_new_chunk
floating *weight_in_clusters_chunk
omp_lock_t lock
# count remainder chunk in total number of chunks
n_chunks += n_samples != n_chunks * n_samples_chunk
# number of threads should not be bigger than number of chunks
n_threads = min(n_threads, n_chunks)
if update_centers:
memset(&centers_new[0, 0], 0, n_clusters * n_features * sizeof(floating))
memset(&weight_in_clusters[0], 0, n_clusters * sizeof(floating))
omp_init_lock(&lock)
with nogil, parallel(num_threads=n_threads):
# thread local buffers
centers_new_chunk = <floating*> calloc(n_clusters * n_features, sizeof(floating))
weight_in_clusters_chunk = <floating*> calloc(n_clusters, sizeof(floating))
for chunk_idx in prange(n_chunks, schedule='static'):
start = chunk_idx * n_samples_chunk
if chunk_idx == n_chunks - 1 and n_samples_rem > 0:
end = start + n_samples_rem
else:
end = start + n_samples_chunk
_update_chunk_sparse(
X_data[X_indptr[start]: X_indptr[end]],
X_indices[X_indptr[start]: X_indptr[end]],
X_indptr[start: end+1],
sample_weight[start: end],
centers_old,
centers_squared_norms,
center_half_distances,
distance_next_center,
labels[start: end],
upper_bounds[start: end],
lower_bounds[start: end],
centers_new_chunk,
weight_in_clusters_chunk,
update_centers)
# reduction from local buffers.
if update_centers:
# The lock is necessary to avoid race conditions when aggregating
# info from different thread-local buffers.
omp_set_lock(&lock)
for j in range(n_clusters):
weight_in_clusters[j] += weight_in_clusters_chunk[j]
for k in range(n_features):
centers_new[j, k] += centers_new_chunk[j * n_features + k]
omp_unset_lock(&lock)
free(centers_new_chunk)
free(weight_in_clusters_chunk)
if update_centers:
omp_destroy_lock(&lock)
_relocate_empty_clusters_sparse(
X_data, X_indices, X_indptr, sample_weight,
centers_old, centers_new, weight_in_clusters, labels)
_average_centers(centers_new, weight_in_clusters)
_center_shift(centers_old, centers_new, center_shift)
# update lower and upper bounds
for i in range(n_samples):
upper_bounds[i] += center_shift[labels[i]]
for j in range(n_clusters):
lower_bounds[i, j] -= center_shift[j]
if lower_bounds[i, j] < 0:
lower_bounds[i, j] = 0
cdef void _update_chunk_sparse(
const floating[::1] X_data, # IN
const int[::1] X_indices, # IN
const int[::1] X_indptr, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
const floating[::1] centers_squared_norms, # IN
const floating[:, ::1] center_half_distances, # IN
const floating[::1] distance_next_center, # IN
int[::1] labels, # INOUT
floating[::1] upper_bounds, # INOUT
floating[:, ::1] lower_bounds, # INOUT
floating *centers_new, # OUT
floating *weight_in_clusters, # OUT
bint update_centers) noexcept nogil:
"""K-means combined EM step for one sparse data chunk.
Compute the partial contribution of a single data chunk to the labels and
centers.
"""
cdef:
int n_samples = labels.shape[0]
int n_clusters = centers_old.shape[0]
int n_features = centers_old.shape[1]
floating upper_bound, distance
int i, j, k, label
int s = X_indptr[0]
for i in range(n_samples):
upper_bound = upper_bounds[i]
bounds_tight = 0
label = labels[i]
# Next center is not far away from the currently assigned center.
# Sample might need to be assigned to another center.
if not distance_next_center[label] >= upper_bound:
for j in range(n_clusters):
# If this holds, then center_index is a good candidate for the
# sample to be relabelled, and we need to confirm this by
# recomputing the upper and lower bounds.
if (
j != label
and (upper_bound > lower_bounds[i, j])
and (upper_bound > center_half_distances[label, j])
):
# Recompute upper bound by calculating the actual distance
# between the sample and its current assigned center.
if not bounds_tight:
upper_bound = _euclidean_sparse_dense(
X_data[X_indptr[i] - s: X_indptr[i + 1] - s],
X_indices[X_indptr[i] - s: X_indptr[i + 1] - s],
centers_old[label], centers_squared_norms[label], False)
lower_bounds[i, label] = upper_bound
bounds_tight = 1
# If the condition still holds, then compute the actual
# distance between the sample and center. If this is less
# than the previous distance, reassign label.
if (
upper_bound > lower_bounds[i, j]
or (upper_bound > center_half_distances[label, j])
):
distance = _euclidean_sparse_dense(
X_data[X_indptr[i] - s: X_indptr[i + 1] - s],
X_indices[X_indptr[i] - s: X_indptr[i + 1] - s],
centers_old[j], centers_squared_norms[j], False)
lower_bounds[i, j] = distance
if distance < upper_bound:
label = j
upper_bound = distance
labels[i] = label
upper_bounds[i] = upper_bound
if update_centers:
weight_in_clusters[label] += sample_weight[i]
for k in range(X_indptr[i] - s, X_indptr[i + 1] - s):
centers_new[label * n_features + X_indices[k]] += X_data[k] * sample_weight[i]
@@ -0,0 +1,420 @@
# Licence: BSD 3 clause
from cython cimport floating
from cython.parallel import prange, parallel
from libc.stdlib cimport malloc, calloc, free
from libc.string cimport memset
from libc.float cimport DBL_MAX, FLT_MAX
from ..utils._openmp_helpers cimport omp_lock_t
from ..utils._openmp_helpers cimport omp_init_lock
from ..utils._openmp_helpers cimport omp_destroy_lock
from ..utils._openmp_helpers cimport omp_set_lock
from ..utils._openmp_helpers cimport omp_unset_lock
from ..utils.extmath import row_norms
from ..utils._cython_blas cimport _gemm
from ..utils._cython_blas cimport RowMajor, Trans, NoTrans
from ._k_means_common import CHUNK_SIZE
from ._k_means_common cimport _relocate_empty_clusters_dense
from ._k_means_common cimport _relocate_empty_clusters_sparse
from ._k_means_common cimport _average_centers, _center_shift
def lloyd_iter_chunked_dense(
const floating[:, ::1] X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
floating[:, ::1] centers_new, # OUT
floating[::1] weight_in_clusters, # OUT
int[::1] labels, # OUT
floating[::1] center_shift, # OUT
int n_threads,
bint update_centers=True):
"""Single iteration of K-means lloyd algorithm with dense input.
Update labels and centers (inplace), for one iteration, distributed
over data chunks.
Parameters
----------
X : ndarray of shape (n_samples, n_features), dtype=floating
The observations to cluster.
sample_weight : ndarray of shape (n_samples,), dtype=floating
The weights for each observation in X.
centers_old : ndarray of shape (n_clusters, n_features), dtype=floating
Centers before previous iteration, placeholder for the centers after
previous iteration.
centers_new : ndarray of shape (n_clusters, n_features), dtype=floating
Centers after previous iteration, placeholder for the new centers
computed during this iteration. `centers_new` can be `None` if
`update_centers` is False.
weight_in_clusters : ndarray of shape (n_clusters,), dtype=floating
Placeholder for the sums of the weights of every observation assigned
to each center. `weight_in_clusters` can be `None` if `update_centers`
is False.
labels : ndarray of shape (n_samples,), dtype=int
labels assignment.
center_shift : ndarray of shape (n_clusters,), dtype=floating
Distance between old and new centers.
n_threads : int
The number of threads to be used by openmp.
update_centers : bool
- If True, the labels and the new centers will be computed, i.e. runs
the E-step and the M-step of the algorithm.
- If False, only the labels will be computed, i.e runs the E-step of
the algorithm. This is useful especially when calling predict on a
fitted model.
"""
cdef:
int n_samples = X.shape[0]
int n_features = X.shape[1]
int n_clusters = centers_old.shape[0]
if n_samples == 0:
# An empty array was passed, do nothing and return early (before
# attempting to compute n_chunks). This can typically happen when
# calling the prediction function of a bisecting k-means model with a
# large fraction of outliers.
return
cdef:
# hard-coded number of samples per chunk. Appeared to be close to
# optimal in all situations.
int n_samples_chunk = CHUNK_SIZE if n_samples > CHUNK_SIZE else n_samples
int n_chunks = n_samples // n_samples_chunk
int n_samples_rem = n_samples % n_samples_chunk
int chunk_idx
int start, end
int j, k
floating[::1] centers_squared_norms = row_norms(centers_old, squared=True)
floating *centers_new_chunk
floating *weight_in_clusters_chunk
floating *pairwise_distances_chunk
omp_lock_t lock
# count remainder chunk in total number of chunks
n_chunks += n_samples != n_chunks * n_samples_chunk
# number of threads should not be bigger than number of chunks
n_threads = min(n_threads, n_chunks)
if update_centers:
memset(&centers_new[0, 0], 0, n_clusters * n_features * sizeof(floating))
memset(&weight_in_clusters[0], 0, n_clusters * sizeof(floating))
omp_init_lock(&lock)
with nogil, parallel(num_threads=n_threads):
# thread local buffers
centers_new_chunk = <floating*> calloc(n_clusters * n_features, sizeof(floating))
weight_in_clusters_chunk = <floating*> calloc(n_clusters, sizeof(floating))
pairwise_distances_chunk = <floating*> malloc(n_samples_chunk * n_clusters * sizeof(floating))
for chunk_idx in prange(n_chunks, schedule='static'):
start = chunk_idx * n_samples_chunk
if chunk_idx == n_chunks - 1 and n_samples_rem > 0:
end = start + n_samples_rem
else:
end = start + n_samples_chunk
_update_chunk_dense(
X[start: end],
sample_weight[start: end],
centers_old,
centers_squared_norms,
labels[start: end],
centers_new_chunk,
weight_in_clusters_chunk,
pairwise_distances_chunk,
update_centers)
# reduction from local buffers.
if update_centers:
# The lock is necessary to avoid race conditions when aggregating
# info from different thread-local buffers.
omp_set_lock(&lock)
for j in range(n_clusters):
weight_in_clusters[j] += weight_in_clusters_chunk[j]
for k in range(n_features):
centers_new[j, k] += centers_new_chunk[j * n_features + k]
omp_unset_lock(&lock)
free(centers_new_chunk)
free(weight_in_clusters_chunk)
free(pairwise_distances_chunk)
if update_centers:
omp_destroy_lock(&lock)
_relocate_empty_clusters_dense(
X, sample_weight, centers_old, centers_new, weight_in_clusters, labels
)
_average_centers(centers_new, weight_in_clusters)
_center_shift(centers_old, centers_new, center_shift)
cdef void _update_chunk_dense(
const floating[:, ::1] X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
const floating[::1] centers_squared_norms, # IN
int[::1] labels, # OUT
floating *centers_new, # OUT
floating *weight_in_clusters, # OUT
floating *pairwise_distances, # OUT
bint update_centers) noexcept nogil:
"""K-means combined EM step for one dense data chunk.
Compute the partial contribution of a single data chunk to the labels and
centers.
"""
cdef:
int n_samples = labels.shape[0]
int n_clusters = centers_old.shape[0]
int n_features = centers_old.shape[1]
floating sq_dist, min_sq_dist
int i, j, k, label
# Instead of computing the full pairwise squared distances matrix,
# ||X - C||² = ||X||² - 2 X.C^T + ||C||², we only need to store
# the - 2 X.C^T + ||C||² term since the argmin for a given sample only
# depends on the centers.
# pairwise_distances = ||C||²
for i in range(n_samples):
for j in range(n_clusters):
pairwise_distances[i * n_clusters + j] = centers_squared_norms[j]
# pairwise_distances += -2 * X.dot(C.T)
_gemm(RowMajor, NoTrans, Trans, n_samples, n_clusters, n_features,
-2.0, &X[0, 0], n_features, &centers_old[0, 0], n_features,
1.0, pairwise_distances, n_clusters)
for i in range(n_samples):
min_sq_dist = pairwise_distances[i * n_clusters]
label = 0
for j in range(1, n_clusters):
sq_dist = pairwise_distances[i * n_clusters + j]
if sq_dist < min_sq_dist:
min_sq_dist = sq_dist
label = j
labels[i] = label
if update_centers:
weight_in_clusters[label] += sample_weight[i]
for k in range(n_features):
centers_new[label * n_features + k] += X[i, k] * sample_weight[i]
def lloyd_iter_chunked_sparse(
X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
floating[:, ::1] centers_new, # OUT
floating[::1] weight_in_clusters, # OUT
int[::1] labels, # OUT
floating[::1] center_shift, # OUT
int n_threads,
bint update_centers=True):
"""Single iteration of K-means lloyd algorithm with sparse input.
Update labels and centers (inplace), for one iteration, distributed
over data chunks.
Parameters
----------
X : sparse matrix of shape (n_samples, n_features), dtype=floating
The observations to cluster. Must be in CSR format.
sample_weight : ndarray of shape (n_samples,), dtype=floating
The weights for each observation in X.
centers_old : ndarray of shape (n_clusters, n_features), dtype=floating
Centers before previous iteration, placeholder for the centers after
previous iteration.
centers_new : ndarray of shape (n_clusters, n_features), dtype=floating
Centers after previous iteration, placeholder for the new centers
computed during this iteration. `centers_new` can be `None` if
`update_centers` is False.
weight_in_clusters : ndarray of shape (n_clusters,), dtype=floating
Placeholder for the sums of the weights of every observation assigned
to each center. `weight_in_clusters` can be `None` if `update_centers`
is False.
labels : ndarray of shape (n_samples,), dtype=int
labels assignment.
center_shift : ndarray of shape (n_clusters,), dtype=floating
Distance between old and new centers.
n_threads : int
The number of threads to be used by openmp.
update_centers : bool
- If True, the labels and the new centers will be computed, i.e. runs
the E-step and the M-step of the algorithm.
- If False, only the labels will be computed, i.e runs the E-step of
the algorithm. This is useful especially when calling predict on a
fitted model.
"""
cdef:
int n_samples = X.shape[0]
int n_features = X.shape[1]
int n_clusters = centers_old.shape[0]
if n_samples == 0:
# An empty array was passed, do nothing and return early (before
# attempting to compute n_chunks). This can typically happen when
# calling the prediction function of a bisecting k-means model with a
# large fraction of outliers.
return
cdef:
# Choose same as for dense. Does not have the same impact since with
# sparse data the pairwise distances matrix is not precomputed.
# However, splitting in chunks is necessary to get parallelism.
int n_samples_chunk = CHUNK_SIZE if n_samples > CHUNK_SIZE else n_samples
int n_chunks = n_samples // n_samples_chunk
int n_samples_rem = n_samples % n_samples_chunk
int chunk_idx
int start = 0, end = 0
int j, k
floating[::1] X_data = X.data
int[::1] X_indices = X.indices
int[::1] X_indptr = X.indptr
floating[::1] centers_squared_norms = row_norms(centers_old, squared=True)
floating *centers_new_chunk
floating *weight_in_clusters_chunk
omp_lock_t lock
# count remainder chunk in total number of chunks
n_chunks += n_samples != n_chunks * n_samples_chunk
# number of threads should not be bigger than number of chunks
n_threads = min(n_threads, n_chunks)
if update_centers:
memset(&centers_new[0, 0], 0, n_clusters * n_features * sizeof(floating))
memset(&weight_in_clusters[0], 0, n_clusters * sizeof(floating))
omp_init_lock(&lock)
with nogil, parallel(num_threads=n_threads):
# thread local buffers
centers_new_chunk = <floating*> calloc(n_clusters * n_features, sizeof(floating))
weight_in_clusters_chunk = <floating*> calloc(n_clusters, sizeof(floating))
for chunk_idx in prange(n_chunks, schedule='static'):
start = chunk_idx * n_samples_chunk
if chunk_idx == n_chunks - 1 and n_samples_rem > 0:
end = start + n_samples_rem
else:
end = start + n_samples_chunk
_update_chunk_sparse(
X_data[X_indptr[start]: X_indptr[end]],
X_indices[X_indptr[start]: X_indptr[end]],
X_indptr[start: end+1],
sample_weight[start: end],
centers_old,
centers_squared_norms,
labels[start: end],
centers_new_chunk,
weight_in_clusters_chunk,
update_centers)
# reduction from local buffers.
if update_centers:
# The lock is necessary to avoid race conditions when aggregating
# info from different thread-local buffers.
omp_set_lock(&lock)
for j in range(n_clusters):
weight_in_clusters[j] += weight_in_clusters_chunk[j]
for k in range(n_features):
centers_new[j, k] += centers_new_chunk[j * n_features + k]
omp_unset_lock(&lock)
free(centers_new_chunk)
free(weight_in_clusters_chunk)
if update_centers:
omp_destroy_lock(&lock)
_relocate_empty_clusters_sparse(
X_data, X_indices, X_indptr, sample_weight,
centers_old, centers_new, weight_in_clusters, labels)
_average_centers(centers_new, weight_in_clusters)
_center_shift(centers_old, centers_new, center_shift)
cdef void _update_chunk_sparse(
const floating[::1] X_data, # IN
const int[::1] X_indices, # IN
const int[::1] X_indptr, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
const floating[::1] centers_squared_norms, # IN
int[::1] labels, # OUT
floating *centers_new, # OUT
floating *weight_in_clusters, # OUT
bint update_centers) noexcept nogil:
"""K-means combined EM step for one sparse data chunk.
Compute the partial contribution of a single data chunk to the labels and
centers.
"""
cdef:
int n_samples = labels.shape[0]
int n_clusters = centers_old.shape[0]
int n_features = centers_old.shape[1]
floating sq_dist, min_sq_dist
int i, j, k, label
floating max_floating = FLT_MAX if floating is float else DBL_MAX
int s = X_indptr[0]
# XXX Precompute the pairwise distances matrix is not worth for sparse
# currently. Should be tested when BLAS (sparse x dense) matrix
# multiplication is available.
for i in range(n_samples):
min_sq_dist = max_floating
label = 0
for j in range(n_clusters):
sq_dist = 0.0
for k in range(X_indptr[i] - s, X_indptr[i + 1] - s):
sq_dist += centers_old[j, X_indices[k]] * X_data[k]
# Instead of computing the full squared distance with each cluster,
# ||X - C||² = ||X||² - 2 X.C^T + ||C||², we only need to compute
# the - 2 X.C^T + ||C||² term since the argmin for a given sample
# only depends on the centers C.
sq_dist = centers_squared_norms[j] -2 * sq_dist
if sq_dist < min_sq_dist:
min_sq_dist = sq_dist
label = j
labels[i] = label
if update_centers:
weight_in_clusters[label] += sample_weight[i]
for k in range(X_indptr[i] - s, X_indptr[i + 1] - s):
centers_new[label * n_features + X_indices[k]] += X_data[k] * sample_weight[i]
@@ -0,0 +1,218 @@
from cython cimport floating
from cython.parallel cimport parallel, prange
from libc.stdlib cimport malloc, free
def _minibatch_update_dense(
const floating[:, ::1] X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
floating[:, ::1] centers_new, # OUT
floating[::1] weight_sums, # INOUT
const int[::1] labels, # IN
int n_threads):
"""Update of the centers for dense MiniBatchKMeans.
Parameters
----------
X : ndarray of shape (n_samples, n_features), dtype=floating
The observations to cluster.
sample_weight : ndarray of shape (n_samples,), dtype=floating
The weights for each observation in X.
centers_old : ndarray of shape (n_clusters, n_features), dtype=floating
Centers before previous iteration, placeholder for the centers after
previous iteration.
centers_new : ndarray of shape (n_clusters, n_features), dtype=floating
Centers after previous iteration, placeholder for the new centers
computed during this iteration.
weight_sums : ndarray of shape (n_clusters,), dtype=floating
Current sums of the accumulated weights for each center.
labels : ndarray of shape (n_samples,), dtype=int
labels assignment.
n_threads : int
The number of threads to be used by openmp.
"""
cdef:
int n_samples = X.shape[0]
int n_clusters = centers_old.shape[0]
int cluster_idx
int *indices
with nogil, parallel(num_threads=n_threads):
indices = <int*> malloc(n_samples * sizeof(int))
for cluster_idx in prange(n_clusters, schedule="static"):
update_center_dense(cluster_idx, X, sample_weight,
centers_old, centers_new, weight_sums, labels,
indices)
free(indices)
cdef void update_center_dense(
int cluster_idx,
const floating[:, ::1] X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
floating[:, ::1] centers_new, # OUT
floating[::1] weight_sums, # INOUT
const int[::1] labels, # IN
int *indices) noexcept nogil: # TMP
"""Update of a single center for dense MinibatchKMeans"""
cdef:
int n_samples = sample_weight.shape[0]
int n_features = centers_old.shape[1]
floating alpha
int n_indices
int k, sample_idx, feature_idx
floating wsum = 0
# indices = np.where(labels == cluster_idx)[0]
k = 0
for sample_idx in range(n_samples):
if labels[sample_idx] == cluster_idx:
indices[k] = sample_idx
wsum += sample_weight[sample_idx]
k += 1
n_indices = k
if wsum > 0:
# Undo the previous count-based scaling for this cluster center
for feature_idx in range(n_features):
centers_new[cluster_idx, feature_idx] = centers_old[cluster_idx, feature_idx] * weight_sums[cluster_idx]
# Update cluster with new point members
for k in range(n_indices):
sample_idx = indices[k]
for feature_idx in range(n_features):
centers_new[cluster_idx, feature_idx] += X[sample_idx, feature_idx] * sample_weight[sample_idx]
# Update the count statistics for this center
weight_sums[cluster_idx] += wsum
# Rescale to compute mean of all points (old and new)
alpha = 1 / weight_sums[cluster_idx]
for feature_idx in range(n_features):
centers_new[cluster_idx, feature_idx] *= alpha
else:
# No sample was assigned to this cluster in this batch of data
for feature_idx in range(n_features):
centers_new[cluster_idx, feature_idx] = centers_old[cluster_idx, feature_idx]
def _minibatch_update_sparse(
X, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
floating[:, ::1] centers_new, # OUT
floating[::1] weight_sums, # INOUT
const int[::1] labels, # IN
int n_threads):
"""Update of the centers for sparse MiniBatchKMeans.
Parameters
----------
X : sparse matrix of shape (n_samples, n_features), dtype=floating
The observations to cluster. Must be in CSR format.
sample_weight : ndarray of shape (n_samples,), dtype=floating
The weights for each observation in X.
centers_old : ndarray of shape (n_clusters, n_features), dtype=floating
Centers before previous iteration, placeholder for the centers after
previous iteration.
centers_new : ndarray of shape (n_clusters, n_features), dtype=floating
Centers after previous iteration, placeholder for the new centers
computed during this iteration.
weight_sums : ndarray of shape (n_clusters,), dtype=floating
Current sums of the accumulated weights for each center.
labels : ndarray of shape (n_samples,), dtype=int
labels assignment.
n_threads : int
The number of threads to be used by openmp.
"""
cdef:
floating[::1] X_data = X.data
int[::1] X_indices = X.indices
int[::1] X_indptr = X.indptr
int n_samples = X.shape[0]
int n_clusters = centers_old.shape[0]
int cluster_idx
int *indices
with nogil, parallel(num_threads=n_threads):
indices = <int*> malloc(n_samples * sizeof(int))
for cluster_idx in prange(n_clusters, schedule="static"):
update_center_sparse(cluster_idx, X_data, X_indices, X_indptr,
sample_weight, centers_old, centers_new,
weight_sums, labels, indices)
free(indices)
cdef void update_center_sparse(
int cluster_idx,
const floating[::1] X_data, # IN
const int[::1] X_indices, # IN
const int[::1] X_indptr, # IN
const floating[::1] sample_weight, # IN
const floating[:, ::1] centers_old, # IN
floating[:, ::1] centers_new, # OUT
floating[::1] weight_sums, # INOUT
const int[::1] labels, # IN
int *indices) noexcept nogil: # TMP
"""Update of a single center for sparse MinibatchKMeans"""
cdef:
int n_samples = sample_weight.shape[0]
int n_features = centers_old.shape[1]
floating alpha
int n_indices
int k, sample_idx, feature_idx
floating wsum = 0
# indices = np.where(labels == cluster_idx)[0]
k = 0
for sample_idx in range(n_samples):
if labels[sample_idx] == cluster_idx:
indices[k] = sample_idx
wsum += sample_weight[sample_idx]
k += 1
n_indices = k
if wsum > 0:
# Undo the previous count-based scaling for this cluster center:
for feature_idx in range(n_features):
centers_new[cluster_idx, feature_idx] = centers_old[cluster_idx, feature_idx] * weight_sums[cluster_idx]
# Update cluster with new point members
for k in range(n_indices):
sample_idx = indices[k]
for feature_idx in range(X_indptr[sample_idx], X_indptr[sample_idx + 1]):
centers_new[cluster_idx, X_indices[feature_idx]] += X_data[feature_idx] * sample_weight[sample_idx]
# Update the count statistics for this center
weight_sums[cluster_idx] += wsum
# Rescale to compute mean of all points (old and new)
alpha = 1 / weight_sums[cluster_idx]
for feature_idx in range(n_features):
centers_new[cluster_idx, feature_idx] *= alpha
else:
# No sample was assigned to this cluster in this batch of data
for feature_idx in range(n_features):
centers_new[cluster_idx, feature_idx] = centers_old[cluster_idx, feature_idx]
@@ -0,0 +1,26 @@
cluster_extension_metadata = {
'_dbscan_inner':
{'sources': [cython_gen_cpp.process('_dbscan_inner.pyx')]},
'_hierarchical_fast':
{'sources': [cython_gen_cpp.process('_hierarchical_fast.pyx'), metrics_cython_tree]},
'_k_means_common':
{'sources': [cython_gen.process('_k_means_common.pyx')], 'dependencies': [openmp_dep]},
'_k_means_lloyd':
{'sources': [cython_gen.process('_k_means_lloyd.pyx')], 'dependencies': [openmp_dep]},
'_k_means_elkan':
{'sources': [cython_gen.process('_k_means_elkan.pyx')], 'dependencies': [openmp_dep]},
'_k_means_minibatch':
{'sources': [cython_gen.process('_k_means_minibatch.pyx')], 'dependencies': [openmp_dep]},
}
foreach ext_name, ext_dict : cluster_extension_metadata
py.extension_module(
ext_name,
[ext_dict.get('sources'), utils_cython_tree],
dependencies: [np_dep] + ext_dict.get('dependencies', []),
subdir: 'sklearn/cluster',
install: true
)
endforeach
subdir('_hdbscan')