qualia_core_db/solvers/learning/clustering/
kmeans.rs1use crate::solvers::learning::LearningError;
11
12#[derive(Debug, Clone)]
14pub struct KMeansModel {
15 pub centroids: Vec<f64>,
17 pub labels: Vec<usize>,
19 pub inertia: f64,
21 pub k: usize,
22 pub p: usize,
23 pub n_iter: usize,
24 pub converged: bool,
25}
26
27impl KMeansModel {
28 pub fn predict_row(&self, x_row: &[f64]) -> usize {
30 nearest(&self.centroids, self.k, self.p, x_row).0
31 }
32}
33
34struct Lcg(u64);
35impl Lcg {
36 fn unit(&mut self) -> f64 {
37 self.0 = self
38 .0
39 .wrapping_mul(6364136223846793005)
40 .wrapping_add(1442695040888963407);
41 ((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
42 }
43}
44
45#[inline]
46fn sq_dist(a: &[f64], b: &[f64]) -> f64 {
47 a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
48}
49
50fn nearest(centroids: &[f64], k: usize, p: usize, point: &[f64]) -> (usize, f64) {
52 let mut best = 0;
53 let mut best_d = f64::INFINITY;
54 for c in 0..k {
55 let d = sq_dist(¢roids[c * p..(c + 1) * p], point);
56 if d < best_d {
57 best_d = d;
58 best = c;
59 }
60 }
61 (best, best_d)
62}
63
64fn kmeans_pp(x: &[f64], n: usize, p: usize, k: usize, rng: &mut Lcg) -> Vec<f64> {
66 let mut centroids = vec![0.0; k * p];
67 let first = ((rng.unit() * n as f64) as usize).min(n - 1);
69 centroids[..p].copy_from_slice(&x[first * p..(first + 1) * p]);
70 let mut d2 = vec![0.0; n];
71 for c in 1..k {
72 let mut total = 0.0;
74 for i in 0..n {
75 let (_, d) = nearest(¢roids[..c * p], c, p, &x[i * p..(i + 1) * p]);
76 d2[i] = d;
77 total += d;
78 }
79 let target = rng.unit() * total;
81 let mut acc = 0.0;
82 let mut chosen = n - 1;
83 for i in 0..n {
84 acc += d2[i];
85 if acc >= target {
86 chosen = i;
87 break;
88 }
89 }
90 centroids[c * p..(c + 1) * p].copy_from_slice(&x[chosen * p..(chosen + 1) * p]);
91 }
92 centroids
93}
94
95pub fn fit(
98 x: &[f64],
99 n: usize,
100 p: usize,
101 k: usize,
102 max_iter: usize,
103 seed: u64,
104) -> Result<KMeansModel, LearningError> {
105 if n == 0 || p == 0 || x.len() != n * p {
106 return Err(LearningError::InvalidDimension);
107 }
108 if k == 0 || k > n {
109 return Err(LearningError::InsufficientData);
110 }
111
112 let mut rng = Lcg(seed ^ 0x9E3779B97F4A7C15);
113 let mut centroids = kmeans_pp(x, n, p, k, &mut rng);
114 let mut labels = vec![0usize; n];
115 let mut converged = false;
116 let mut iters = 0;
117
118 for it in 1..=max_iter.max(1) {
119 iters = it;
120 let mut changed = false;
130 #[cfg(all(not(target_arch = "wasm32"), feature = "wgsl-forge"))]
133 {
134 let work = n.saturating_mul(k).saturating_mul(p);
135 let caps = crate::wgsl_forge::dispatch::caps();
136 if (caps.cuda || caps.wgpu) && work >= crate::wgsl_forge::dispatch::GEMM_GPU_THRESHOLD {
137 let dist =
138 crate::wgsl_forge::dispatch::pairwise_sq_dist_f64(x, ¢roids, n, k, p);
139 for i in 0..n {
140 let row = &dist[i * k..(i + 1) * k];
141 let mut best = 0;
142 let mut best_d = row[0];
143 for (c, &d) in row.iter().enumerate().skip(1) {
144 if d < best_d {
145 best_d = d;
146 best = c;
147 }
148 }
149 if labels[i] != best {
150 labels[i] = best;
151 changed = true;
152 }
153 }
154 } else {
155 for i in 0..n {
156 let (c, _) = nearest(¢roids, k, p, &x[i * p..(i + 1) * p]);
157 if labels[i] != c {
158 labels[i] = c;
159 changed = true;
160 }
161 }
162 }
163 }
164 #[cfg(not(all(not(target_arch = "wasm32"), feature = "wgsl-forge")))]
165 {
166 for i in 0..n {
167 let (c, _) = nearest(¢roids, k, p, &x[i * p..(i + 1) * p]);
168 if labels[i] != c {
169 labels[i] = c;
170 changed = true;
171 }
172 }
173 }
174 let mut sums = vec![0.0; k * p];
176 let mut counts = vec![0usize; k];
177 for i in 0..n {
178 let c = labels[i];
179 counts[c] += 1;
180 for j in 0..p {
181 sums[c * p + j] += x[i * p + j];
182 }
183 }
184 for c in 0..k {
185 if counts[c] > 0 {
186 for j in 0..p {
187 centroids[c * p + j] = sums[c * p + j] / counts[c] as f64;
188 }
189 }
190 }
191 if !changed && it > 1 {
192 converged = true;
193 break;
194 }
195 }
196
197 let mut inertia = 0.0;
199 for i in 0..n {
200 inertia += sq_dist(
201 ¢roids[labels[i] * p..(labels[i] + 1) * p],
202 &x[i * p..(i + 1) * p],
203 );
204 }
205
206 Ok(KMeansModel {
207 centroids,
208 labels,
209 inertia,
210 k,
211 p,
212 n_iter: iters,
213 converged,
214 })
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn recovers_three_separated_blobs() {
223 let mut x = Vec::new();
225 let centers = [(0.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
226 for &(cx, cy) in ¢ers {
227 for d in 0..5 {
228 x.push(cx + (d as f64 - 2.0) * 0.1);
229 x.push(cy + (d as f64 - 2.0) * 0.1);
230 }
231 }
232 let n = 15;
233 let m = fit(&x, n, 2, 3, 100, 1).unwrap();
234 assert!(m.converged);
235 for blob in 0..3 {
237 let base = blob * 5;
238 let l = m.labels[base];
239 assert!(
240 (base..base + 5).all(|i| m.labels[i] == l),
241 "blob {blob} not pure"
242 );
243 }
244 let mut used: Vec<usize> = m.labels.clone();
246 used.sort_unstable();
247 used.dedup();
248 assert_eq!(used.len(), 3);
249 assert!(m.inertia < 1.0, "inertia {}", m.inertia);
251 }
252
253 #[test]
254 fn single_cluster_centroid_is_the_mean() {
255 let x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; let m = fit(&x, 3, 2, 1, 50, 0).unwrap();
257 assert!((m.centroids[0] - 3.0).abs() < 1e-9); assert!((m.centroids[1] - 4.0).abs() < 1e-9); assert!(m.labels.iter().all(|&l| l == 0));
260 }
261
262 #[test]
263 fn guards() {
264 assert_eq!(
265 fit(&[1.0, 2.0], 1, 2, 3, 10, 0).unwrap_err(),
266 LearningError::InsufficientData
267 );
268 assert_eq!(
269 fit(&[1.0, 2.0, 3.0], 2, 2, 1, 10, 0).unwrap_err(),
270 LearningError::InvalidDimension
271 );
272 }
273}