Skip to main content

fin_primitives/clustering/
mod.rs

1//! Asset clustering using k-means on return correlations.
2//!
3//! Provides [`KMeans`] for general k-means clustering and [`CorrelationClusterer`]
4//! which builds an NxN Pearson correlation matrix from asset return series and
5//! clusters assets using the correlation rows as feature vectors.
6
7/// A named asset return series.
8#[derive(Debug, Clone)]
9pub struct AssetReturns {
10    /// Ticker symbol or asset identifier.
11    pub symbol: String,
12    /// Daily (or periodic) return observations.
13    pub returns: Vec<f64>,
14}
15
16/// Cluster assignment for a single asset.
17#[derive(Debug, Clone)]
18pub struct ClusterAssignment {
19    /// Asset identifier.
20    pub symbol: String,
21    /// Zero-based cluster index.
22    pub cluster_id: usize,
23    /// Euclidean distance from the asset's feature vector to its centroid.
24    pub distance_to_centroid: f64,
25}
26
27/// Output of a k-means run.
28#[derive(Debug, Clone)]
29pub struct KMeansResult {
30    /// Per-point cluster assignments.
31    pub assignments: Vec<ClusterAssignment>,
32    /// Final centroid positions (`k` vectors, each of length `d`).
33    pub centroids: Vec<Vec<f64>>,
34    /// Total within-cluster sum of squared distances (inertia).
35    pub inertia: f64,
36    /// Number of iterations performed before convergence or reaching `max_iter`.
37    pub iterations: u32,
38}
39
40/// Stateless k-means clustering over arbitrary feature vectors.
41pub struct KMeans;
42
43impl KMeans {
44    /// Fit k-means to `data` (slice of feature vectors, all same length).
45    ///
46    /// Uses a Linear Congruential Generator seeded with `seed` for reproducible
47    /// centroid initialisation.  Runs at most `max_iter` assignment/update cycles.
48    ///
49    /// # Panics
50    /// Panics if `data` is empty, `k` is zero, or `k` exceeds `data.len()`.
51    pub fn fit(data: &[Vec<f64>], k: usize, max_iter: u32, seed: u64) -> KMeansResult {
52        assert!(!data.is_empty(), "data must be non-empty");
53        assert!(k > 0, "k must be > 0");
54        assert!(k <= data.len(), "k must not exceed number of data points");
55
56        let n = data.len();
57        let d = data[0].len();
58
59        // --- Initialise centroids via LCG ---
60        let mut lcg = seed;
61        let mut centroid_indices: Vec<usize> = Vec::with_capacity(k);
62        while centroid_indices.len() < k {
63            lcg = lcg.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
64            let idx = (lcg >> 33) as usize % n;
65            if !centroid_indices.contains(&idx) {
66                centroid_indices.push(idx);
67            }
68        }
69        let mut centroids: Vec<Vec<f64>> = centroid_indices.iter().map(|&i| data[i].clone()).collect();
70
71        let mut assignments = vec![0usize; n];
72        let mut iterations = 0u32;
73
74        for _iter in 0..max_iter {
75            iterations += 1;
76            let mut changed = false;
77
78            // Assignment step
79            for (i, point) in data.iter().enumerate() {
80                let best = (0..k)
81                    .min_by(|&a, &b| {
82                        euclidean_sq(point, &centroids[a])
83                            .partial_cmp(&euclidean_sq(point, &centroids[b]))
84                            .unwrap_or(std::cmp::Ordering::Equal)
85                    })
86                    .unwrap_or(0);
87                if assignments[i] != best {
88                    assignments[i] = best;
89                    changed = true;
90                }
91            }
92
93            // Update step
94            let mut sums = vec![vec![0.0f64; d]; k];
95            let mut counts = vec![0usize; k];
96            for (i, point) in data.iter().enumerate() {
97                let c = assignments[i];
98                counts[c] += 1;
99                for (j, &v) in point.iter().enumerate() {
100                    sums[c][j] += v;
101                }
102            }
103            for c in 0..k {
104                if counts[c] > 0 {
105                    for j in 0..d {
106                        centroids[c][j] = sums[c][j] / counts[c] as f64;
107                    }
108                }
109            }
110
111            if !changed {
112                break;
113            }
114        }
115
116        // Build result
117        let inertia: f64 = data
118            .iter()
119            .zip(assignments.iter())
120            .map(|(p, &c)| euclidean_sq(p, &centroids[c]))
121            .sum();
122
123        // We need symbol names — placeholder; CorrelationClusterer will supply them.
124        let cluster_assignments: Vec<ClusterAssignment> = data
125            .iter()
126            .zip(assignments.iter())
127            .enumerate()
128            .map(|(i, (p, &c))| ClusterAssignment {
129                symbol: i.to_string(),
130                cluster_id: c,
131                distance_to_centroid: euclidean_sq(p, &centroids[c]).sqrt(),
132            })
133            .collect();
134
135        KMeansResult {
136            assignments: cluster_assignments,
137            centroids,
138            inertia,
139            iterations,
140        }
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Correlation-based clusterer
146// ---------------------------------------------------------------------------
147
148/// Clusters assets by their Pearson correlation profile.
149///
150/// Builds an N×N Pearson correlation matrix from a set of asset return series,
151/// then uses each asset's row of the correlation matrix as its feature vector
152/// for k-means clustering.
153pub struct CorrelationClusterer {
154    symbols: Vec<String>,
155    /// NxN correlation matrix stored row-major.
156    corr_matrix: Vec<Vec<f64>>,
157    last_result: Option<KMeansResult>,
158}
159
160impl CorrelationClusterer {
161    /// Build a [`CorrelationClusterer`] from a slice of asset return series.
162    ///
163    /// All series must have the same length; shorter series are ignored (not
164    /// panicked) but correlation between mismatched-length pairs is set to `0.0`.
165    pub fn from_returns(assets: &[AssetReturns]) -> Self {
166        let n = assets.len();
167        let mut corr_matrix = vec![vec![0.0f64; n]; n];
168
169        for i in 0..n {
170            corr_matrix[i][i] = 1.0;
171            for j in (i + 1)..n {
172                let c = pearson_correlation(&assets[i].returns, &assets[j].returns);
173                corr_matrix[i][j] = c;
174                corr_matrix[j][i] = c;
175            }
176        }
177
178        let symbols = assets.iter().map(|a| a.symbol.clone()).collect();
179        Self { symbols, corr_matrix, last_result: None }
180    }
181
182    /// Cluster assets into `k` groups using k-means on their correlation rows.
183    pub fn cluster(&mut self, k: usize, seed: u64) -> KMeansResult {
184        let mut result = KMeans::fit(&self.corr_matrix, k, 300, seed);
185
186        // Replace numeric placeholder symbols with actual asset symbols.
187        for (assign, sym) in result.assignments.iter_mut().zip(self.symbols.iter()) {
188            assign.symbol = sym.clone();
189        }
190
191        self.last_result = Some(result.clone());
192        result
193    }
194
195    /// Returns `(symbol, cluster_id)` pairs from the most recent `cluster` call.
196    ///
197    /// Returns an empty `Vec` if `cluster` has not been called yet.
198    pub fn cluster_labels(&self) -> Vec<(String, usize)> {
199        match &self.last_result {
200            Some(r) => r
201                .assignments
202                .iter()
203                .map(|a| (a.symbol.clone(), a.cluster_id))
204                .collect(),
205            None => Vec::new(),
206        }
207    }
208
209    /// Compute the mean silhouette coefficient for a given [`KMeansResult`].
210    ///
211    /// Silhouette score ∈ [-1, 1]; higher is better.  Returns `0.0` if there
212    /// is only one cluster or the result has fewer than 2 assignments.
213    pub fn silhouette_score(&self, result: &KMeansResult) -> f64 {
214        let n = result.assignments.len();
215        if n < 2 {
216            return 0.0;
217        }
218        let k = result.centroids.len();
219        if k == 1 {
220            return 0.0;
221        }
222
223        // Map symbol → row index in corr_matrix
224        let mut sil_sum = 0.0f64;
225        let mut count = 0usize;
226
227        for (i, assign) in result.assignments.iter().enumerate() {
228            // Find index of this symbol in self.symbols
229            let row_idx = match self.symbols.iter().position(|s| s == &assign.symbol) {
230                Some(idx) => idx,
231                None => continue,
232            };
233            let feat = &self.corr_matrix[row_idx];
234            let my_cluster = assign.cluster_id;
235
236            // a(i): mean distance to same-cluster points
237            let same_cluster_pts: Vec<usize> = result
238                .assignments
239                .iter()
240                .enumerate()
241                .filter(|(j, a)| *j != i && a.cluster_id == my_cluster)
242                .map(|(j, _)| j)
243                .collect();
244
245            let a = if same_cluster_pts.is_empty() {
246                0.0
247            } else {
248                let sum: f64 = same_cluster_pts
249                    .iter()
250                    .map(|&j| {
251                        let other_sym = &result.assignments[j].symbol;
252                        let other_idx = self
253                            .symbols
254                            .iter()
255                            .position(|s| s == other_sym)
256                            .unwrap_or(0);
257                        euclidean_sq(feat, &self.corr_matrix[other_idx]).sqrt()
258                    })
259                    .sum();
260                sum / same_cluster_pts.len() as f64
261            };
262
263            // b(i): min mean distance to any other cluster
264            let mut b = f64::INFINITY;
265            for c in 0..k {
266                if c == my_cluster {
267                    continue;
268                }
269                let other_pts: Vec<usize> = result
270                    .assignments
271                    .iter()
272                    .enumerate()
273                    .filter(|(_, a)| a.cluster_id == c)
274                    .map(|(j, _)| j)
275                    .collect();
276                if other_pts.is_empty() {
277                    continue;
278                }
279                let sum: f64 = other_pts
280                    .iter()
281                    .map(|&j| {
282                        let other_sym = &result.assignments[j].symbol;
283                        let other_idx = self
284                            .symbols
285                            .iter()
286                            .position(|s| s == other_sym)
287                            .unwrap_or(0);
288                        euclidean_sq(feat, &self.corr_matrix[other_idx]).sqrt()
289                    })
290                    .sum();
291                let mean_dist = sum / other_pts.len() as f64;
292                if mean_dist < b {
293                    b = mean_dist;
294                }
295            }
296
297            // Rousseeuw's definition: s(i) = 0 for a point alone in its cluster,
298            // and it is undefined (taken as 0) when no other cluster has members.
299            // Without this, b = +inf gives (inf - a) / inf = NaN.
300            let denom = a.max(b);
301            let s_i = if same_cluster_pts.is_empty() || !b.is_finite() || denom == 0.0 {
302                0.0
303            } else {
304                (b - a) / denom
305            };
306            sil_sum += s_i;
307            count += 1;
308        }
309
310        if count == 0 { 0.0 } else { sil_sum / count as f64 }
311    }
312}
313
314// ---------------------------------------------------------------------------
315// Internal helpers
316// ---------------------------------------------------------------------------
317
318fn euclidean_sq(a: &[f64], b: &[f64]) -> f64 {
319    a.iter().zip(b.iter()).map(|(&x, &y)| (x - y).powi(2)).sum()
320}
321
322fn pearson_correlation(a: &[f64], b: &[f64]) -> f64 {
323    let n = a.len().min(b.len());
324    if n < 2 {
325        return 0.0;
326    }
327    let mean_a = a[..n].iter().sum::<f64>() / n as f64;
328    let mean_b = b[..n].iter().sum::<f64>() / n as f64;
329    let mut cov = 0.0f64;
330    let mut var_a = 0.0f64;
331    let mut var_b = 0.0f64;
332    for i in 0..n {
333        let da = a[i] - mean_a;
334        let db = b[i] - mean_b;
335        cov += da * db;
336        var_a += da * da;
337        var_b += db * db;
338    }
339    let denom = (var_a * var_b).sqrt();
340    if denom == 0.0 { 0.0 } else { cov / denom }
341}
342
343// ---------------------------------------------------------------------------
344// Tests
345// ---------------------------------------------------------------------------
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn make_asset(symbol: &str, returns: Vec<f64>) -> AssetReturns {
352        AssetReturns { symbol: symbol.to_string(), returns }
353    }
354
355    // ---- KMeans ----
356
357    #[test]
358    fn test_kmeans_two_clear_clusters() {
359        // Points near 0 and points near 10 should split cleanly
360        let data: Vec<Vec<f64>> = vec![
361            vec![0.1], vec![0.2], vec![0.0],
362            vec![10.0], vec![10.1], vec![9.9],
363        ];
364        let result = KMeans::fit(&data, 2, 100, 42);
365        assert_eq!(result.centroids.len(), 2);
366        assert_eq!(result.assignments.len(), 6);
367        // Verify the two clusters exist
368        let c0 = result.assignments[0].cluster_id;
369        let c3 = result.assignments[3].cluster_id;
370        assert_ne!(c0, c3, "near-0 and near-10 must be in different clusters");
371    }
372
373    #[test]
374    fn test_kmeans_k_equals_n() {
375        let data: Vec<Vec<f64>> = vec![vec![1.0], vec![2.0], vec![3.0]];
376        let result = KMeans::fit(&data, 3, 10, 7);
377        assert_eq!(result.centroids.len(), 3);
378        assert_eq!(result.assignments.len(), 3);
379    }
380
381    #[test]
382    fn test_kmeans_inertia_non_negative() {
383        let data: Vec<Vec<f64>> = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]];
384        let result = KMeans::fit(&data, 2, 50, 1);
385        assert!(result.inertia >= 0.0);
386    }
387
388    #[test]
389    fn test_kmeans_iterations_bounded() {
390        let data: Vec<Vec<f64>> = (0..20).map(|i| vec![i as f64]).collect();
391        let result = KMeans::fit(&data, 3, 5, 99);
392        assert!(result.iterations <= 5);
393    }
394
395    // ---- pearson_correlation ----
396
397    #[test]
398    fn test_pearson_perfect_positive() {
399        let a = vec![1.0, 2.0, 3.0, 4.0];
400        let b = vec![2.0, 4.0, 6.0, 8.0];
401        let c = pearson_correlation(&a, &b);
402        assert!((c - 1.0).abs() < 1e-10);
403    }
404
405    #[test]
406    fn test_pearson_perfect_negative() {
407        let a = vec![1.0, 2.0, 3.0, 4.0];
408        let b = vec![4.0, 3.0, 2.0, 1.0];
409        let c = pearson_correlation(&a, &b);
410        assert!((c + 1.0).abs() < 1e-10);
411    }
412
413    #[test]
414    fn test_pearson_too_short() {
415        assert_eq!(pearson_correlation(&[1.0], &[1.0]), 0.0);
416        assert_eq!(pearson_correlation(&[], &[]), 0.0);
417    }
418
419    // ---- CorrelationClusterer ----
420
421    #[test]
422    fn test_correlation_clusterer_diagonal_ones() {
423        let assets = vec![
424            make_asset("A", vec![0.01, -0.01, 0.02, -0.02]),
425            make_asset("B", vec![0.02, -0.02, 0.04, -0.04]),
426            make_asset("C", vec![-0.01, 0.01, -0.02, 0.02]),
427        ];
428        let clusterer = CorrelationClusterer::from_returns(&assets);
429        // Diagonal must be 1.0
430        for i in 0..3 {
431            assert!((clusterer.corr_matrix[i][i] - 1.0).abs() < 1e-10);
432        }
433        // A and B are perfectly positively correlated
434        assert!((clusterer.corr_matrix[0][1] - 1.0).abs() < 1e-6);
435        // A and C are perfectly negatively correlated
436        assert!((clusterer.corr_matrix[0][2] + 1.0).abs() < 1e-6);
437    }
438
439    #[test]
440    fn test_correlation_clusterer_cluster_labels() {
441        let assets = vec![
442            make_asset("A", vec![0.01, 0.02, 0.03]),
443            make_asset("B", vec![0.01, 0.02, 0.03]),
444            make_asset("C", vec![-0.01, -0.02, -0.03]),
445        ];
446        let mut clusterer = CorrelationClusterer::from_returns(&assets);
447        let _result = clusterer.cluster(2, 42);
448        let labels = clusterer.cluster_labels();
449        assert_eq!(labels.len(), 3);
450        let symbols: Vec<&str> = labels.iter().map(|(s, _)| s.as_str()).collect();
451        assert!(symbols.contains(&"A"));
452        assert!(symbols.contains(&"B"));
453        assert!(symbols.contains(&"C"));
454    }
455
456    #[test]
457    fn test_cluster_labels_before_cluster_call() {
458        let assets = vec![make_asset("X", vec![0.01, 0.02])];
459        let clusterer = CorrelationClusterer::from_returns(&assets);
460        assert!(clusterer.cluster_labels().is_empty());
461    }
462
463    #[test]
464    fn test_silhouette_score_range() {
465        let assets = vec![
466            make_asset("A", vec![0.01, 0.02, 0.03, 0.04]),
467            make_asset("B", vec![0.01, 0.02, 0.03, 0.04]),
468            make_asset("C", vec![-0.03, -0.02, -0.01, 0.00]),
469            make_asset("D", vec![-0.03, -0.02, -0.01, 0.00]),
470        ];
471        let mut clusterer = CorrelationClusterer::from_returns(&assets);
472        let result = clusterer.cluster(2, 5);
473        let score = clusterer.silhouette_score(&result);
474        assert!(score >= -1.0 && score <= 1.0, "Silhouette must be in [-1, 1]");
475    }
476
477    #[test]
478    fn test_silhouette_single_cluster() {
479        let assets = vec![
480            make_asset("A", vec![0.01, 0.02]),
481            make_asset("B", vec![0.01, 0.02]),
482        ];
483        let mut clusterer = CorrelationClusterer::from_returns(&assets);
484        let result = clusterer.cluster(1, 1);
485        assert_eq!(clusterer.silhouette_score(&result), 0.0);
486    }
487}