connected-k-center 0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Algorithms and Data Structures Group of the Heinrich Heine University Düsseldorf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: connected-k-center
3
+ Version: 0.1
4
+ Summary: Implementations of algorithms for connected k-center on path graphs.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: Lukas Drexler
8
+ Author-email: lukas.drexler@hhu.de
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Dist: numpy (>=1.26.4,<2.0.0)
18
+ Requires-Dist: scikit-learn (>=1.6.1,<2.0.0)
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Connected Path Graph Clustering
22
+
23
+ A library for algorithms for the connected k-center problem (as described in [1]). In this problem setting, the input consists of a point set $P$ and a desired number of centers $k$, along with a *connectivity graph* $G = (P,E)$. The goal is to partition $P$ into (at most) $k$ *clusters* $C_1, \ldots, C_k$, such that, for every $i$, the subgraph of $G$ induced by $C_i$ is connected.
24
+
25
+ As of now, only an algorithm for path graphs is implemented. A path graph is a graph whose connected components are simple paths. This algorithm was developed by Johanna Hillebrand and implemented by Julius Mann.
26
+
27
+
28
+ ### References
29
+ [1] Drexler, L., Eube, J., Luo, K., Reineccius, D., Röglin, H., Schmidt, M., & Wargalla, J. (2024). Connected k-center and k-diameter clustering. Algorithmica, 86(11), 3425-3464.
30
+
@@ -0,0 +1,9 @@
1
+ # Connected Path Graph Clustering
2
+
3
+ A library for algorithms for the connected k-center problem (as described in [1]). In this problem setting, the input consists of a point set $P$ and a desired number of centers $k$, along with a *connectivity graph* $G = (P,E)$. The goal is to partition $P$ into (at most) $k$ *clusters* $C_1, \ldots, C_k$, such that, for every $i$, the subgraph of $G$ induced by $C_i$ is connected.
4
+
5
+ As of now, only an algorithm for path graphs is implemented. A path graph is a graph whose connected components are simple paths. This algorithm was developed by Johanna Hillebrand and implemented by Julius Mann.
6
+
7
+
8
+ ### References
9
+ [1] Drexler, L., Eube, J., Luo, K., Reineccius, D., Röglin, H., Schmidt, M., & Wargalla, J. (2024). Connected k-center and k-diameter clustering. Algorithmica, 86(11), 3425-3464.
@@ -0,0 +1,69 @@
1
+ import sys
2
+ from typing import Any, Dict, List
3
+
4
+ from setuptools import Extension
5
+ from setuptools.command.build_ext import build_ext
6
+ from setuptools.errors import CompileError
7
+
8
+ extension = Extension(
9
+ name="connected_k_center._core",
10
+ sources=[
11
+ "connected_k_center/_core.cpp",
12
+ "connected_k_center/cpp/solver.cpp",
13
+ "connected_k_center/cpp/clustering.cpp",
14
+ "connected_k_center/cpp/geometry.cpp",
15
+ "connected_k_center/cpp/graph.cpp",
16
+ ],
17
+
18
+ include_dirs=["connected_k_center/cpp"],
19
+ )
20
+
21
+
22
+ class BuildExt(build_ext):
23
+ """Compiler-aware build: select C++17 and OpenMP flags per toolchain.
24
+
25
+ OpenMP parallelises the pairwise-distance and per-component computations.
26
+ The way it is enabled differs by compiler:
27
+ * GCC / Linux clang: ``-fopenmp`` for both compile and link.
28
+ * Apple clang (macOS): ``-Xpreprocessor -fopenmp`` and link ``libomp``
29
+ (its headers/libs come from Homebrew via CPPFLAGS/LDFLAGS).
30
+ * MSVC (Windows): ``/openmp`` and ``/std:c++17`` (it ignores ``-std=``).
31
+ """
32
+
33
+ def build_extensions(self) -> None:
34
+ if self.compiler.compiler_type == "msvc":
35
+ compile_args = ["/std:c++17", "/openmp", "/EHsc"]
36
+ link_args: List[str] = []
37
+ elif sys.platform == "darwin":
38
+ compile_args = ["-std=c++17", "-Xpreprocessor", "-fopenmp"]
39
+ link_args = ["-lomp"]
40
+ else:
41
+ compile_args = ["-std=c++17", "-fopenmp"]
42
+ link_args = ["-fopenmp"]
43
+
44
+ for extension in self.extensions:
45
+ extension.extra_compile_args = (
46
+ list(extension.extra_compile_args) + compile_args
47
+ )
48
+ extension.extra_link_args = (
49
+ list(extension.extra_link_args) + link_args
50
+ )
51
+
52
+ try:
53
+ build_ext.build_extensions(self)
54
+ except CompileError:
55
+ # Workaround Issue #2.
56
+ # '-stdlib=libc++' is added so the code can compile on macOS with
57
+ # Anaconda. Irrelevant (and unsupported) for MSVC.
58
+ if self.compiler.compiler_type == "msvc":
59
+ raise
60
+ for extension in self.extensions:
61
+ extension.extra_compile_args.append("-stdlib=libc++")
62
+ extension.extra_link_args.append("-stdlib=libc++")
63
+ build_ext.build_extensions(self)
64
+
65
+
66
+ def build(setup_kwargs: Dict[str, Any]) -> None:
67
+ setup_kwargs.update(
68
+ {"ext_modules": [extension], "cmdclass": {"build_ext": BuildExt}}
69
+ )
@@ -0,0 +1,4 @@
1
+ from connected_k_center.core import PathCKC
2
+ from connected_k_center.io import read_instance
3
+
4
+ __all__ = ["PathCKC", "read_instance"]
@@ -0,0 +1,107 @@
1
+ #include <Python.h>
2
+
3
+ #include <vector>
4
+
5
+ #include "connected_k_center/types.hpp"
6
+ #include "connected_k_center/solver.hpp"
7
+
8
+ typedef unsigned int uint;
9
+
10
+ // Build the list of ckc::Point from a flat, row-major coordinate array.
11
+ // Each point's id is its row index, matching the indexing used for the
12
+ // adjacency list and the output labels.
13
+ static std::vector<ckc::Point> array_to_points(const double *coords, uint n, uint d)
14
+ {
15
+ std::vector<ckc::Point> points;
16
+ points.reserve(n);
17
+ for (uint i = 0; i < n; ++i)
18
+ {
19
+ ckc::Point p;
20
+ p.id = static_cast<int>(i);
21
+ p.coords.assign(coords + i * d, coords + i * d + d);
22
+ points.push_back(std::move(p));
23
+ }
24
+ return points;
25
+ }
26
+
27
+ // Rebuild the path-graph adjacency from per-point component ids. Points that
28
+ // share a component id are chained in array order, reproducing the implicit
29
+ // path structure of the original CSV format (consecutive rows linked, a new
30
+ // component id starting a fresh path).
31
+ static ckc::AdjacencyList component_ids_to_adjacency(const int *component_ids, uint n)
32
+ {
33
+ ckc::AdjacencyList adj(n);
34
+ for (uint i = 1; i < n; ++i)
35
+ {
36
+ if (component_ids[i] == component_ids[i - 1])
37
+ {
38
+ adj[i].push_back(static_cast<int>(i - 1));
39
+ adj[i - 1].push_back(static_cast<int>(i));
40
+ }
41
+ }
42
+ return adj;
43
+ }
44
+
45
+ extern "C"
46
+ {
47
+ // "__declspec(dllexport)" causes the function to be exported when compiling on Windows.
48
+ // Otherwise the symbol is not exported and ctypes raises
49
+ // "AttributeError: function 'connected_k_center_c' not found".
50
+ #if defined(_WIN32) || defined(__CYGWIN__)
51
+ __declspec(dllexport)
52
+ #endif
53
+ double
54
+ connected_k_center_c(double *coords, // input points, row-major (n x d)
55
+ int *component_ids, // length n, path membership (array order = path order)
56
+ uint n, // number of points
57
+ uint d, // number of features
58
+ int k, // cluster budget (result uses at most k)
59
+ int metric, // distance metric used by the algorithm (0 for RMSE, 1 for Euclidean, 2 for Manhattan)
60
+ int *out_labels, // length n, filled with the assigned center id per point
61
+ int *out_num_centers) // scalar, filled with the total number of centers used
62
+ {
63
+ std::vector<ckc::Point> points = array_to_points(coords, n, d);
64
+ ckc::AdjacencyList adj = component_ids_to_adjacency(component_ids, n);
65
+
66
+ ckc::SolverResult result = ckc::connected_k_center(k, points, adj, static_cast<ckc::Metric>(metric));
67
+
68
+ // optimal_radius < 0 signals that no feasible clustering was found.
69
+ if (result.optimal_radius < 0.0)
70
+ {
71
+ *out_num_centers = 0;
72
+ return -1.0;
73
+ }
74
+
75
+ // Default to -1 so any point left unassigned is detectable on the Python side.
76
+ for (uint i = 0; i < n; ++i)
77
+ {
78
+ out_labels[i] = -1;
79
+ }
80
+
81
+ int num_centers = 0;
82
+ for (const auto &component : result.components)
83
+ {
84
+ num_centers += component.num_centers;
85
+ for (const auto &assignment : component.assignments)
86
+ {
87
+ out_labels[assignment.point_id] = assignment.center_id;
88
+ }
89
+ }
90
+
91
+ *out_num_centers = num_centers;
92
+ return result.optimal_radius;
93
+ }
94
+ } // extern "C"
95
+
96
+ static struct PyModuleDef _coremodule = {
97
+ PyModuleDef_HEAD_INIT,
98
+ "connected_k_center._core",
99
+ NULL,
100
+ -1,
101
+ NULL,
102
+ };
103
+
104
+ PyMODINIT_FUNC PyInit__core(void)
105
+ {
106
+ return PyModule_Create(&_coremodule);
107
+ }
@@ -0,0 +1,153 @@
1
+ import ctypes
2
+ from typing import Any, Optional, Sequence
3
+
4
+ import numpy as np
5
+ from sklearn.base import BaseEstimator, ClusterMixin
6
+ from sklearn.utils.validation import validate_data
7
+
8
+ import connected_k_center._core # type: ignore
9
+
10
+ _DLL = ctypes.cdll.LoadLibrary(connected_k_center._core.__file__)
11
+
12
+ _METRIC_CODES = {
13
+ "rmse": 0,
14
+ "euclidean": 1,
15
+ "manhattan": 2,
16
+ } # for passing metric parameter down to c++
17
+
18
+
19
+ class PathCKC(ClusterMixin, BaseEstimator):
20
+ """Connected k-center clustering on path graphs.
21
+
22
+ The algorithm performs a binary search over all pairwise distances and
23
+ finds the minimum radius for which the points can be covered by at most
24
+ ``n_clusters`` connected clusters. The graph is assumed to be a disjoint
25
+ union of paths, supplied via ``component_ids``: points sharing a component
26
+ id are linked, in the order in which they appear in ``X``, to form a path.
27
+
28
+ This estimator is transductive -- it clusters a fixed, structured input and
29
+ does not learn a reusable predictor -- so it only exposes ``fit`` /
30
+ ``fit_predict`` and does not implement ``predict``, ``transform`` or
31
+ ``score``.
32
+ """
33
+
34
+ def __init__(self, n_clusters: int = 8, metric: str = "rmse"):
35
+ self.n_clusters = n_clusters
36
+ self.metric = metric
37
+
38
+ def fit(
39
+ self,
40
+ X: Sequence[Sequence[float]],
41
+ y: Any = None,
42
+ component_ids: Optional[Sequence[int]] = None,
43
+ ) -> "PathCKC":
44
+ self._validate_params()
45
+
46
+ _X = validate_data(
47
+ self,
48
+ X,
49
+ reset=True,
50
+ accept_sparse=False,
51
+ dtype=np.float64,
52
+ order="C",
53
+ accept_large_sparse=False,
54
+ )
55
+ assert isinstance(_X, np.ndarray), type(_X)
56
+ _X = np.ascontiguousarray(_X)
57
+
58
+ n_samples = _X.shape[0]
59
+ self.n_features_in_ = _X.shape[1]
60
+
61
+ if n_samples < self.n_clusters:
62
+ raise ValueError(
63
+ f"n_samples={n_samples} should be >= n_clusters={self.n_clusters}."
64
+ )
65
+
66
+ _component_ids = _validate_component_ids(component_ids, n_samples)
67
+
68
+ # Declare c types
69
+ c_coords = _X.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
70
+ c_component_ids = _component_ids.ctypes.data_as(ctypes.POINTER(ctypes.c_int))
71
+ c_n = ctypes.c_uint(n_samples)
72
+ c_d = ctypes.c_uint(self.n_features_in_)
73
+ c_k = ctypes.c_int(self.n_clusters)
74
+ c_metric = ctypes.c_int(_METRIC_CODES[self.metric])
75
+
76
+ labels = np.empty(n_samples, dtype=np.int32, order="C")
77
+ c_labels = labels.ctypes.data_as(ctypes.POINTER(ctypes.c_int))
78
+ c_num_centers = ctypes.c_int()
79
+
80
+ _DLL.connected_k_center_c.argtypes = [
81
+ ctypes.POINTER(ctypes.c_double), # coords
82
+ ctypes.POINTER(ctypes.c_int), # component_ids
83
+ ctypes.c_uint, # n_samples
84
+ ctypes.c_uint, # n_features_in_
85
+ ctypes.c_int, # n_clusters (k)
86
+ ctypes.c_int, # metric
87
+ ctypes.POINTER(ctypes.c_int), # labels
88
+ ctypes.POINTER(ctypes.c_int), # num_centers
89
+ ]
90
+ _DLL.connected_k_center_c.restype = ctypes.c_double
91
+
92
+ radius = _DLL.connected_k_center_c(
93
+ c_coords,
94
+ c_component_ids,
95
+ c_n,
96
+ c_d,
97
+ c_k,
98
+ c_metric,
99
+ c_labels,
100
+ ctypes.byref(c_num_centers),
101
+ )
102
+
103
+ if radius < 0:
104
+ raise ValueError(
105
+ f"No feasible connected clustering exists for n_clusters="
106
+ f"{self.n_clusters}."
107
+ )
108
+
109
+ self.optimal_radius_ = radius
110
+ self.labels_ = labels
111
+ self.cluster_centers_indices_ = np.unique(labels)
112
+ self.n_clusters_used_ = c_num_centers.value
113
+
114
+ return self
115
+
116
+ def fit_predict(
117
+ self,
118
+ X: Sequence[Sequence[float]],
119
+ y: Any = None,
120
+ component_ids: Optional[Sequence[int]] = None,
121
+ ) -> np.ndarray:
122
+ return self.fit(X, component_ids=component_ids).labels_
123
+
124
+ def _validate_params(self) -> None:
125
+ if not isinstance(self.n_clusters, (int, np.integer)):
126
+ raise TypeError(
127
+ f"n_clusters must be an integer, got {type(self.n_clusters).__name__}"
128
+ )
129
+ if self.n_clusters < 1:
130
+ raise ValueError(f"n_clusters must be >= 1, got {self.n_clusters}")
131
+
132
+ if self.metric not in _METRIC_CODES:
133
+ raise ValueError(
134
+ f"metric must be one of {sorted(_METRIC_CODES)}, got {self.metric!r}"
135
+ )
136
+
137
+
138
+ def _validate_component_ids(
139
+ component_ids: Optional[Sequence[int]],
140
+ n_samples: int,
141
+ ) -> np.ndarray:
142
+ # Default: a single path over all points, in the given order.
143
+ if component_ids is None:
144
+ return np.zeros(n_samples, dtype=np.int32)
145
+
146
+ ids = np.ascontiguousarray(component_ids, dtype=np.int32)
147
+ if ids.ndim != 1:
148
+ raise ValueError(f"component_ids must be 1D, got {ids.ndim}D")
149
+ if ids.shape[0] != n_samples:
150
+ raise ValueError(
151
+ f"component_ids has length {ids.shape[0]}, expected {n_samples}."
152
+ )
153
+ return ids
@@ -0,0 +1,138 @@
1
+ #include "connected_k_center/clustering.hpp"
2
+ #include "connected_k_center/geometry.hpp"
3
+
4
+ #include <algorithm>
5
+ #include <limits>
6
+ #include <iterator>
7
+
8
+ namespace ckc {
9
+
10
+ // Intersection of two SORTED vectors
11
+ static std::vector<int> intersect(const std::vector<int>& x, const std::vector<int>& y) {
12
+ std::vector<int> result;
13
+ std::set_intersection(x.begin(), x.end(), y.begin(), y.end(),
14
+ std::back_inserter(result));
15
+ return result;
16
+ }
17
+
18
+ // Radius membership: M[i] = all centers that could cover point i within radius r *contiguously*
19
+ std::vector<std::vector<int>> get_memberships_for_path(const std::vector<int>& path, const std::vector<Point>& points, double r, int n, Metric metric) {
20
+ std::vector<std::vector<int>> M(n);
21
+ const int m = static_cast<int>(path.size());
22
+
23
+ for (int center_idx = 0; center_idx < m; ++center_idx) {
24
+ const int center_id = path[center_idx];
25
+
26
+ // Extend to the left along the path
27
+ for (int i = center_idx; i >= 0; --i) {
28
+ const int target_id = path[i];
29
+ if (dist(points[target_id], points[center_id], metric) <= r) {
30
+ M[target_id].push_back(center_id);
31
+ } else {
32
+ break;
33
+ }
34
+ }
35
+
36
+ // Extend to the right along the path
37
+ for (int i = center_idx + 1; i < m; ++i) {
38
+ const int target_id = path[i];
39
+ if (dist(points[target_id], points[center_id], metric) <= r) {
40
+ M[target_id].push_back(center_id);
41
+ } else {
42
+ break;
43
+ }
44
+ }
45
+ }
46
+
47
+ // Sort (precondition for intersect())
48
+ for (int i = 0; i < n; ++i) {
49
+ std::sort(M[i].begin(), M[i].end());
50
+ }
51
+
52
+ return M;
53
+ }
54
+
55
+
56
+ // Find the optimal clustering of a connected component
57
+ ComponentResult solve_dp_forward(const std::vector<int>& path_indices, const std::vector<std::vector<int>>& M, const std::vector<Point>& points) {
58
+ const int n = static_cast<int>(path_indices.size());
59
+
60
+ // Initialize DP matrix
61
+ std::vector<int> dp_num_centers(n, std::numeric_limits<int>::max());
62
+
63
+ // Avoid explicit backtracking; store results directly instead
64
+ std::vector<std::vector<int>> partial_centers(n);
65
+ std::vector<std::vector<ClusterAssignment>> partial_assignments(n);
66
+
67
+ // Right interval boundary
68
+ for (int i = 0; i < n; ++i) {
69
+ std::vector<int> current_valid_centers;
70
+
71
+ // Backward search for the (left) start point j of the 'current' cluster
72
+ for (int j = i; j >= 0; --j) {
73
+ const int u = path_indices[j];
74
+
75
+ if (j == i) {
76
+ current_valid_centers = M[u];
77
+ } else {
78
+ current_valid_centers = intersect(current_valid_centers, M[u]); // Only centers valid for ALL
79
+ }
80
+
81
+ if (current_valid_centers.empty()) break; // No suitable common center exists
82
+
83
+ // Center must lie within the interval [j...i]
84
+ int chosen_center = -1;
85
+ for (int c : current_valid_centers) {
86
+ for (int idx = j; idx <= i; ++idx) {
87
+ if (path_indices[idx] == c) {
88
+ chosen_center = c;
89
+ break;
90
+ }
91
+ }
92
+ if (chosen_center != -1) break; // Take the first valid one
93
+ }
94
+
95
+ // None of the centers lies WITHIN the interval
96
+ if (chosen_center == -1) continue;
97
+
98
+ // Combine the found cluster with those for [0,...,j-1]
99
+ const int prev_num_centers = (j == 0) ? 0 : dp_num_centers[j - 1];
100
+ if (prev_num_centers != std::numeric_limits<int>::max()) {
101
+ if (prev_num_centers + 1 < dp_num_centers[i]) {
102
+ dp_num_centers[i] = prev_num_centers + 1; // Interval [j,...,i] has ONE additional cluster
103
+
104
+ if (j > 0) {
105
+ partial_centers[i] = partial_centers[j - 1];
106
+ partial_assignments[i] = partial_assignments[j - 1];
107
+ } else {
108
+ partial_centers[i].clear();
109
+ partial_assignments[i].clear();
110
+ }
111
+
112
+ // Append the new center and the point assignments in the interval
113
+ partial_centers[i].push_back(points[chosen_center].id);
114
+ for (int idx = j; idx <= i; ++idx) {
115
+ partial_assignments[i].push_back(
116
+ {points[path_indices[idx]].id, points[chosen_center].id}
117
+ );
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Build the ComponentResult if a solution exists
125
+ ComponentResult result;
126
+ if (dp_num_centers[n - 1] == std::numeric_limits<int>::max()) {
127
+ result.feasible = false;
128
+ result.num_centers = 0;
129
+ } else {
130
+ result.feasible = true;
131
+ result.num_centers = dp_num_centers[n - 1];
132
+ result.centers = partial_centers[n - 1];
133
+ result.assignments = partial_assignments[n - 1];
134
+ }
135
+ return result;
136
+ }
137
+
138
+ } // namespace ckc
@@ -0,0 +1,46 @@
1
+ #pragma once
2
+
3
+ #include "types.hpp"
4
+ #include <vector>
5
+
6
+ namespace ckc {
7
+
8
+ /**
9
+ * Computes the radius membership for a path.
10
+ *
11
+ * M[i] contains all center IDs that could cover point i at radius r
12
+ * contiguously (i.e. with no gaps along the path).
13
+ *
14
+ * @param path Ordered path as a vector of point IDs.
15
+ * @param points All points.
16
+ * @param r Search radius.
17
+ * @param n Total number of points.
18
+ * @param metric Distance metric to use.
19
+ * @return Membership vector of size n.
20
+ */
21
+ std::vector<std::vector<int>> get_memberships_for_path(
22
+ const std::vector<int>& path,
23
+ const std::vector<Point>& points,
24
+ double r,
25
+ int n,
26
+ Metric metric
27
+ );
28
+
29
+ /**
30
+ * Solves the 1D k-center problem on a path via forward DP.
31
+ *
32
+ * Finds the minimum number of clusters such that every point lies within
33
+ * radius r of its center and the center lies in the same interval.
34
+ *
35
+ * @param path_indices Ordered point IDs of the path.
36
+ * @param M Membership vector from get_memberships_for_path().
37
+ * @param points All points.
38
+ * @return ComponentResult (feasibility, centers and assignments).
39
+ */
40
+ ComponentResult solve_dp_forward(
41
+ const std::vector<int>& path_indices,
42
+ const std::vector<std::vector<int>>& M,
43
+ const std::vector<Point>& points
44
+ );
45
+
46
+ } // namespace ckc
@@ -0,0 +1,27 @@
1
+ #pragma once
2
+
3
+ #include "types.hpp"
4
+ #include <vector>
5
+
6
+ namespace ckc {
7
+
8
+ /**
9
+ * Computes the distance between two points.
10
+ * (e.g. root mean squared error over all coordinate dimensions)
11
+ *
12
+ * @return Distance, or 0.0 if a point has no coordinates.
13
+ */
14
+ double dist(const Point& a, const Point& b, Metric metric);
15
+
16
+ /**
17
+ * Computes all pairwise distances between the given points.
18
+ * The result is sorted and deduplicated.
19
+ * The computation is (optionally) parallelized (OpenMP).
20
+ *
21
+ * @param points Set of input points.
22
+ * @param metric Distance metric to use.
23
+ * @return Sorted, deduplicated vector of all pairwise distances.
24
+ */
25
+ std::vector<double> get_all_distances(const std::vector<Point>& points, Metric metric);
26
+
27
+ } // namespace ckc
@@ -0,0 +1,46 @@
1
+ #pragma once
2
+
3
+ #include "types.hpp"
4
+ #include<numeric>
5
+ #include<vector>
6
+
7
+ namespace ckc{
8
+ class Graph{
9
+ public:
10
+
11
+ std::vector<int> vertices; // list of vertices
12
+ std::vector<int> degrees; // list of vertex degrees
13
+ AdjacencyList adj; // list of lists, where adj[i] contains all ids of neighbors of vertex i
14
+
15
+
16
+ explicit Graph(int n): vertices(n), degrees(n,0), adj(n){ // constructor (creates graph with n vertices and no edges)
17
+ std::iota(vertices.begin(), vertices.end(), 0);
18
+ }
19
+
20
+ Graph(AdjacencyList adjacency): vertices(adjacency.size()), degrees(adjacency.size()), adj(std::move(adjacency)){
21
+ std::iota(vertices.begin(), vertices.end(), 0);
22
+ for (std::size_t i = 0; i < adj.size(); ++i)
23
+ {
24
+ degrees[i] = static_cast<int>(adj[i].size());
25
+ }
26
+ }
27
+
28
+
29
+ void add_edge(int u, int v); //adds edge {u,v} to graph
30
+
31
+
32
+ /**
33
+ * Determines all connected components (sub-paths) from the adjacency list.
34
+ *
35
+ * Each component is returned as an ordered path (vector of point IDs),
36
+ * starting at an endpoint (degree 0 or 1).
37
+ *
38
+ * @param adj Adjacency list of the graph.
39
+ * @param n Number of nodes.
40
+ * @return Vector of paths; each path is a vector of point IDs.
41
+ */
42
+ std::vector<std::vector<int>> extract_paths();
43
+
44
+
45
+ };
46
+ }
@@ -0,0 +1,28 @@
1
+ #pragma once
2
+
3
+ #include "types.hpp"
4
+ #include <vector>
5
+
6
+ namespace ckc {
7
+
8
+ /// Return value of connected_k_center().
9
+ struct SolverResult {
10
+ double optimal_radius = -1.0; ///< -1.0 = no solution found
11
+ std::vector<ComponentResult> components; ///< result per connected component
12
+ };
13
+
14
+ /**
15
+ * Solves the connected k-center problem.
16
+ *
17
+ * Performs a binary search over all pairwise distances and determines the
18
+ * minimum radius r* for which a covering with at most k clusters is possible.
19
+ *
20
+ * @param k Maximum number of allowed cluster centers.
21
+ * @param points Input points.
22
+ * @param adj Adjacency list (i.e. graph structure).
23
+ * @param metric Distance metric to use.
24
+ * @return SolverResult with the optimal radius and per-component results.
25
+ */
26
+ SolverResult connected_k_center(int k, const std::vector<Point>& points, const AdjacencyList& adj, Metric metric = Metric::RMSE);
27
+
28
+ } // namespace ckc
@@ -0,0 +1,35 @@
1
+ #pragma once
2
+
3
+ #include <vector>
4
+
5
+
6
+
7
+
8
+ namespace ckc {
9
+
10
+ enum class Metric { RMSE = 0, Euclidean = 1, Manhattan = 2 };
11
+
12
+ /// A point has an ID + a multi-dimensional coordinate
13
+ struct Point {
14
+ int id;
15
+ std::vector<double> coords;
16
+ };
17
+
18
+ /// Assignment: point to a cluster center
19
+ struct ClusterAssignment {
20
+ int point_id;
21
+ int center_id;
22
+ };
23
+
24
+ /// Result of the k-center algorithm for ONE connected component
25
+ struct ComponentResult {
26
+ bool feasible;
27
+ int num_centers;
28
+ std::vector<int> centers;
29
+ std::vector<ClusterAssignment> assignments;
30
+ };
31
+
32
+ /// Adjacency list: adj[i] ~> neighbor IDs of point i
33
+ using AdjacencyList = std::vector<std::vector<int>>;
34
+
35
+ } // namespace ckc
@@ -0,0 +1,69 @@
1
+ #include "connected_k_center/geometry.hpp"
2
+
3
+ #include <cmath>
4
+ #include <algorithm>
5
+
6
+ namespace ckc {
7
+
8
+ // Distance function
9
+ double dist(const Point& a, const Point& b, Metric metric) {
10
+ const int d = static_cast<int>(a.coords.size());
11
+ if (d == 0) return 0.0;
12
+
13
+ double sum = 0.0;
14
+
15
+ switch(metric) {
16
+ case Metric::RMSE: //sqrt(mean of squares)
17
+ for (int i = 0; i < d; ++i)
18
+ {
19
+ double diff = a.coords[i] - b.coords[i];
20
+ sum += diff * diff;
21
+ }
22
+ return std::sqrt(sum / d);
23
+
24
+ case Metric::Euclidean: //standard Euclidean distance
25
+ for (int i = 0; i < d; ++i)
26
+ {
27
+ double diff = a.coords[i] - b.coords[i];
28
+ sum += diff * diff;
29
+ }
30
+ return std::sqrt(sum);
31
+
32
+ case Metric::Manhattan: //Manhattan aka l_1 metric
33
+ for (int i = 0; i < d; ++i)
34
+ {
35
+ sum += std::abs(a.coords[i] - b.coords[i]);
36
+ }
37
+ return sum;
38
+ }
39
+ return 0.0;
40
+
41
+ }
42
+
43
+
44
+ // Compute the sorted list of all distinct pairwise distances
45
+ std::vector<double> get_all_distances(const std::vector<Point>& points, Metric metric) {
46
+ const int n = static_cast<int>(points.size());
47
+ if (n < 2) return {0.0};
48
+
49
+ // Pre-allocate (required for parallel writes)
50
+ const int num_pairs = n * (n - 1) / 2;
51
+ std::vector<double> distances(num_pairs + 1);
52
+ distances[0] = 0.0;
53
+
54
+ // Computation can be parallelized
55
+ #pragma omp parallel for schedule(dynamic) default(none) shared(n, points, distances, metric)
56
+ for (int i = 0; i < n; ++i) {
57
+ for (int j = i + 1; j < n; ++j) {
58
+ int idx = 1 + i * n - (i * (i + 1)) / 2 + (j - i - 1);
59
+ distances[idx] = dist(points[i], points[j], metric);
60
+ }
61
+ }
62
+
63
+ // Remove duplicates
64
+ std::sort(distances.begin(), distances.end());
65
+ distances.erase(std::unique(distances.begin(), distances.end()), distances.end());
66
+ return distances;
67
+ }
68
+
69
+ } // namespace ckc
@@ -0,0 +1,48 @@
1
+ #include "connected_k_center/graph.hpp"
2
+
3
+ namespace ckc{
4
+
5
+ void Graph::add_edge(int u, int v){
6
+ adj[u].push_back(v);
7
+ adj[v].push_back(u);
8
+ degrees[u]++;
9
+ degrees[v]++;
10
+ }
11
+
12
+
13
+ // Extract connected components as ordered paths
14
+ std::vector<std::vector<int>> Graph::extract_paths() {
15
+
16
+ std::vector<std::vector<int>> paths;
17
+ std::vector<bool> visited(vertices.size(), false);
18
+
19
+ // Use path endpoints (degree 0 or 1) as start nodes
20
+ for (std::size_t i = 0; i < vertices.size(); ++i) {
21
+ if (!visited[i] && degrees[i] <= 1) {
22
+ std::vector<int> path;
23
+ int curr = i;
24
+ int prev = -1; // Predecessor node on the path
25
+
26
+ // Follow edges
27
+ while (curr != -1) {
28
+ path.push_back(curr);
29
+ visited[curr] = true;
30
+
31
+ int next = -1;
32
+ for (int nb : adj[curr]) {
33
+ if (nb != prev) {
34
+ next = nb;
35
+ break; // In a path graph there is at most one such neighbor
36
+ }
37
+ }
38
+
39
+ prev = curr;
40
+ curr = next;
41
+ }
42
+ paths.push_back(path);
43
+ }
44
+ }
45
+
46
+ return paths;
47
+ }
48
+ }
@@ -0,0 +1,72 @@
1
+ #include "connected_k_center/solver.hpp"
2
+ #include "connected_k_center/geometry.hpp"
3
+ #include "connected_k_center/graph.hpp"
4
+ #include "connected_k_center/clustering.hpp"
5
+
6
+ #include <vector>
7
+
8
+ namespace ckc {
9
+
10
+ /*///////////////////////////////////////////////////
11
+ //////////////////// Overall logic //////////////////
12
+ ///////////////////////////////////////////////////*/
13
+
14
+ SolverResult connected_k_center(int k, const std::vector<Point>& points, const AdjacencyList& adj, Metric metric) {
15
+ SolverResult result;
16
+ const int n = static_cast<int>(points.size());
17
+ if (n == 0) return result;
18
+
19
+ /// [Step 0] Compute pairwise distances
20
+
21
+ std::vector<double> distances = get_all_distances(points, metric);
22
+
23
+ /// [Step 1] Build the graph and extract paths (connected components)
24
+ Graph G(adj);
25
+ std::vector<std::vector<int>> paths = G.extract_paths();
26
+
27
+ /// [Step 2] Binary search over distances
28
+ int low_idx = 0;
29
+ int high_idx = static_cast<int>(distances.size()) - 1;
30
+
31
+ while (low_idx <= high_idx) {
32
+ const int mid_idx = low_idx + (high_idx - low_idx) / 2;
33
+ const double r = distances[mid_idx];
34
+
35
+ int total_clusters = 0;
36
+ bool possible = true;
37
+ std::vector<ComponentResult> current_results;
38
+
39
+ const int num_paths = static_cast<int>(paths.size());
40
+ #pragma omp parallel for schedule(dynamic) default(none) shared(n, points, paths, r, possible, total_clusters, current_results, metric, num_paths)
41
+ for (int pi = 0; pi < num_paths; ++pi) {
42
+ const auto& path = paths[pi];
43
+
44
+ auto M = get_memberships_for_path(path, points, r, n, metric);
45
+
46
+ ComponentResult res = solve_dp_forward(path, M, points);
47
+
48
+ if (!res.feasible) {
49
+ possible = false; // No valid clustering
50
+ // break;
51
+ } else {
52
+ #pragma omp critical
53
+ {
54
+ total_clusters += res.num_centers;
55
+ current_results.push_back(res);
56
+ }
57
+ }
58
+ }
59
+
60
+ if (possible && total_clusters <= k) {
61
+ result.optimal_radius = r;
62
+ result.components = current_results;
63
+ high_idx = mid_idx - 1;
64
+ } else {
65
+ low_idx = mid_idx + 1;
66
+ }
67
+ }
68
+
69
+ return result;
70
+ }
71
+
72
+ } // namespace ckc
@@ -0,0 +1,54 @@
1
+ from typing import Tuple, Union
2
+ from os import PathLike
3
+
4
+ import numpy as np
5
+
6
+
7
+ def read_instance(
8
+ path: Union[str, "PathLike[str]"],
9
+ ) -> Tuple[np.ndarray, np.ndarray]:
10
+ """Read a path-graph clustering instance from a CSV/text file.
11
+
12
+ File format (as produced for the sanity-check instances):
13
+ * one point per non-empty line, given as its comma-separated coordinates
14
+ (a single value per line for the 1D case);
15
+ * an empty line starts a new connected component;
16
+ * the order of the lines encodes the order of the points along each path.
17
+
18
+ Returns ``(X, component_ids)`` ready to pass to
19
+ :meth:`PathCKC.fit`: ``X`` has shape ``(n_points, n_features)``
20
+ and ``component_ids`` is the length-``n_points`` path-membership array.
21
+ """
22
+ coords = []
23
+ component_ids = []
24
+ component = 0
25
+ seen_in_component = False
26
+
27
+ with open(path, "r") as f:
28
+ for line in f:
29
+ stripped = line.strip()
30
+
31
+ # Empty line -> boundary between components (ignore leading/repeated
32
+ # blanks so they don't create empty components).
33
+ if stripped == "":
34
+ if seen_in_component:
35
+ component += 1
36
+ seen_in_component = False
37
+ continue
38
+
39
+ values = [v for v in stripped.replace(",", " ").split() if v]
40
+ coords.append([float(v) for v in values])
41
+ component_ids.append(component)
42
+ seen_in_component = True
43
+
44
+ if not coords:
45
+ raise ValueError(f"No points found in instance file: {path}")
46
+
47
+ n_features = len(coords[0])
48
+ if any(len(row) != n_features for row in coords):
49
+ raise ValueError(
50
+ f"Inconsistent point dimensionality in instance file: {path}"
51
+ )
52
+
53
+ X = np.asarray(coords, dtype=np.float64)
54
+ return X, np.asarray(component_ids, dtype=np.int32)
@@ -0,0 +1,43 @@
1
+ [tool.poetry]
2
+ name = "connected-k-center"
3
+ version = "0.1"
4
+ description = "Implementations of algorithms for connected k-center on path graphs."
5
+ authors = ["Lukas Drexler <lukas.drexler@hhu.de>", "Julius Mann"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ packages = [
9
+ { include = "connected_k_center" }
10
+ ]
11
+ include = ["images"]
12
+ exclude = [
13
+ "connected_k_center/**/*.so",
14
+ "connected_k_center/**/*.pyd",
15
+ "connected_k_center/**/__pycache__",
16
+ ]
17
+
18
+ [tool.poetry.dependencies]
19
+ python = "^3.10"
20
+ scikit-learn = "^1.6.1"
21
+ numpy = "^1.26.4"
22
+
23
+ [tool.poetry.group.dev.dependencies]
24
+ black = {extras = ["jupyter"], version = "^24.4.2"}
25
+ pre-commit = "^2.14.0"
26
+ flake8 = "^7.0.0"
27
+ mypy = "^1.10.0"
28
+ tqdm = "^4.66.4"
29
+
30
+ [tool.poetry.group.test.dependencies]
31
+ pandas = "^2.2.2"
32
+
33
+ [tool.poetry.build]
34
+ script = "build_extension.py"
35
+ generate-setup-file = true
36
+
37
+ [build-system]
38
+ requires = ["poetry-core", "setuptools"]
39
+ build-backend = "poetry.core.masonry.api"
40
+
41
+ [tool.isort]
42
+ profile = "black"
43
+ line_length = 88
@@ -0,0 +1,31 @@
1
+ # -*- coding: utf-8 -*-
2
+ from setuptools import setup
3
+
4
+ packages = \
5
+ ['connected_k_center']
6
+
7
+ package_data = \
8
+ {'': ['*'], 'connected_k_center': ['cpp/*', 'cpp/connected_k_center/*']}
9
+
10
+ install_requires = \
11
+ ['numpy>=1.26.4,<2.0.0', 'scikit-learn>=1.6.1,<2.0.0']
12
+
13
+ setup_kwargs = {
14
+ 'name': 'connected-k-center',
15
+ 'version': '0.1',
16
+ 'description': 'Implementations of algorithms for connected k-center on path graphs.',
17
+ 'long_description': '# Connected Path Graph Clustering\n\nA library for algorithms for the connected k-center problem (as described in [1]). In this problem setting, the input consists of a point set $P$ and a desired number of centers $k$, along with a *connectivity graph* $G = (P,E)$. The goal is to partition $P$ into (at most) $k$ *clusters* $C_1, \\ldots, C_k$, such that, for every $i$, the subgraph of $G$ induced by $C_i$ is connected.\n\nAs of now, only an algorithm for path graphs is implemented. A path graph is a graph whose connected components are simple paths. This algorithm was developed by Johanna Hillebrand and implemented by Julius Mann.\n\n\n### References\n[1] Drexler, L., Eube, J., Luo, K., Reineccius, D., Röglin, H., Schmidt, M., & Wargalla, J. (2024). Connected k-center and k-diameter clustering. Algorithmica, 86(11), 3425-3464.\n',
18
+ 'author': 'Lukas Drexler',
19
+ 'author_email': 'lukas.drexler@hhu.de',
20
+ 'maintainer': 'None',
21
+ 'maintainer_email': 'None',
22
+ 'url': 'None',
23
+ 'packages': packages,
24
+ 'package_data': package_data,
25
+ 'install_requires': install_requires,
26
+ 'python_requires': '>=3.10,<4.0',
27
+ }
28
+ from build_extension import *
29
+ build(setup_kwargs)
30
+
31
+ setup(**setup_kwargs)