Skip to main content

sklears_multioutput/regularization/
task_clustering.rs

1//! Task Clustering Regularization for Multi-Task Learning
2//!
3//! This method clusters tasks based on their similarity and applies different
4//! regularization strengths within and across clusters. Tasks in the same cluster
5//! are encouraged to have similar parameters, while tasks in different clusters
6//! are allowed to be more different.
7#![allow(non_snake_case)] // Standard ML notation: X for feature matrices, K for kernels
8
9// Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
10use scirs2_core::ndarray::{Array1, Array2, ArrayView2, Axis};
11use scirs2_core::random::thread_rng;
12use scirs2_core::random::RandNormal;
13use sklears_core::{
14    error::{Result as SklResult, SklearsError},
15    traits::{Estimator, Fit, Predict, Untrained},
16    types::Float,
17};
18use std::collections::HashMap;
19
20/// Task Clustering Regularization for Multi-Task Learning
21///
22/// This method clusters tasks based on their similarity and applies different
23/// regularization strengths within and across clusters. Tasks in the same cluster
24/// are encouraged to have similar parameters, while tasks in different clusters
25/// are allowed to be more different.
26///
27/// # Examples
28///
29/// ```
30/// use sklears_multioutput::regularization::TaskClusteringRegularization;
31/// use sklears_core::traits::{Predict, Fit};
32/// // Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
33/// use scirs2_core::ndarray::array;
34/// use std::collections::HashMap;
35///
36/// let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0]];
37/// let mut y_tasks = HashMap::new();
38/// y_tasks.insert("task1".to_string(), array![[1.0], [2.0], [1.5], [2.5]]);
39/// y_tasks.insert("task2".to_string(), array![[0.5], [1.0], [0.8], [1.2]]);
40/// y_tasks.insert("task3".to_string(), array![[2.0], [3.0], [2.2], [3.1]]);
41///
42/// let task_clustering = TaskClusteringRegularization::new()
43///     .n_clusters(2)
44///     .intra_cluster_alpha(0.1)  // Strong regularization within clusters
45///     .inter_cluster_alpha(0.01) // Weak regularization across clusters
46///     .max_iter(1000);
47/// ```
48#[derive(Debug, Clone)]
49pub struct TaskClusteringRegularization<S = Untrained> {
50    pub(crate) state: S,
51    /// Number of task clusters
52    pub(crate) n_clusters: usize,
53    /// Regularization strength within clusters
54    pub(crate) intra_cluster_alpha: Float,
55    /// Regularization strength across clusters
56    pub(crate) inter_cluster_alpha: Float,
57    /// Maximum iterations for clustering
58    pub(crate) max_iter: usize,
59    /// Convergence tolerance
60    pub(crate) tolerance: Float,
61    /// Learning rate
62    pub(crate) learning_rate: Float,
63    /// Task configurations
64    pub(crate) task_outputs: HashMap<String, usize>,
65    /// Include intercept term
66    pub(crate) fit_intercept: bool,
67    /// Random state for reproducible clustering
68    pub(crate) random_state: Option<u64>,
69}
70
71/// Trained state for TaskClusteringRegularization
72#[derive(Debug, Clone)]
73pub struct TaskClusteringRegressionTrained {
74    /// Coefficients for each task
75    pub(crate) coefficients: HashMap<String, Array2<Float>>,
76    /// Intercepts for each task
77    pub(crate) intercepts: HashMap<String, Array1<Float>>,
78    /// Task cluster assignments
79    pub(crate) task_clusters: HashMap<String, usize>,
80    /// Cluster centroids for task parameters
81    pub(crate) cluster_centroids: Array2<Float>,
82    /// Number of input features
83    pub(crate) n_features: usize,
84    #[allow(dead_code)]
85    /// Task configurations
86    pub(crate) task_outputs: HashMap<String, usize>,
87    #[allow(dead_code)]
88    /// Training parameters
89    pub(crate) n_clusters: usize,
90    #[allow(dead_code)]
91    pub(crate) intra_cluster_alpha: Float,
92    #[allow(dead_code)]
93    pub(crate) inter_cluster_alpha: Float,
94    /// Training iterations performed
95    pub(crate) n_iter: usize,
96}
97
98impl TaskClusteringRegularization<Untrained> {
99    /// Create a new TaskClusteringRegularization instance
100    pub fn new() -> Self {
101        Self {
102            state: Untrained,
103            n_clusters: 2,
104            intra_cluster_alpha: 1.0,
105            inter_cluster_alpha: 0.1,
106            max_iter: 1000,
107            tolerance: 1e-4,
108            learning_rate: 0.01,
109            task_outputs: HashMap::new(),
110            fit_intercept: true,
111            random_state: None,
112        }
113    }
114
115    /// Set number of task clusters
116    pub fn n_clusters(mut self, n_clusters: usize) -> Self {
117        self.n_clusters = n_clusters;
118        self
119    }
120
121    /// Set intra-cluster regularization strength
122    pub fn intra_cluster_alpha(mut self, alpha: Float) -> Self {
123        self.intra_cluster_alpha = alpha;
124        self
125    }
126
127    /// Set inter-cluster regularization strength
128    pub fn inter_cluster_alpha(mut self, alpha: Float) -> Self {
129        self.inter_cluster_alpha = alpha;
130        self
131    }
132
133    /// Set maximum iterations
134    pub fn max_iter(mut self, max_iter: usize) -> Self {
135        self.max_iter = max_iter;
136        self
137    }
138
139    /// Set tolerance
140    pub fn tolerance(mut self, tolerance: Float) -> Self {
141        self.tolerance = tolerance;
142        self
143    }
144
145    /// Set learning rate
146    pub fn learning_rate(mut self, lr: Float) -> Self {
147        self.learning_rate = lr;
148        self
149    }
150
151    /// Set random state for reproducible clustering
152    pub fn random_state(mut self, seed: u64) -> Self {
153        self.random_state = Some(seed);
154        self
155    }
156
157    /// Set task outputs
158    pub fn task_outputs(mut self, outputs: &[(&str, usize)]) -> Self {
159        self.task_outputs = outputs
160            .iter()
161            .map(|(name, size)| (name.to_string(), *size))
162            .collect();
163        self
164    }
165}
166
167impl Default for TaskClusteringRegularization<Untrained> {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173impl Estimator for TaskClusteringRegularization<Untrained> {
174    type Config = ();
175    type Error = SklearsError;
176    type Float = Float;
177
178    fn config(&self) -> &Self::Config {
179        &()
180    }
181}
182
183impl Fit<ArrayView2<'_, Float>, HashMap<String, Array2<Float>>>
184    for TaskClusteringRegularization<Untrained>
185{
186    type Fitted = TaskClusteringRegularization<TaskClusteringRegressionTrained>;
187
188    fn fit(
189        self,
190        X: &ArrayView2<'_, Float>,
191        y: &HashMap<String, Array2<Float>>,
192    ) -> SklResult<Self::Fitted> {
193        let x = X.to_owned();
194        let (n_samples, n_features) = x.dim();
195
196        if n_samples == 0 || n_features == 0 {
197            return Err(SklearsError::InvalidInput("Empty input data".to_string()));
198        }
199
200        if self.n_clusters == 0 {
201            return Err(SklearsError::InvalidInput(
202                "Number of clusters must be > 0".to_string(),
203            ));
204        }
205
206        // Initialize task coefficients randomly
207        let mut task_coefficients: HashMap<String, Array2<Float>> = HashMap::new();
208        let mut task_intercepts: HashMap<String, Array1<Float>> = HashMap::new();
209
210        let mut rng_gen = thread_rng();
211
212        for (task_name, y_task) in y {
213            let n_outputs = y_task.ncols();
214            let mut coef = Array2::<Float>::zeros((n_features, n_outputs));
215            let normal_dist = RandNormal::new(0.0, 0.1).expect("operation should succeed");
216            for i in 0..n_features {
217                for j in 0..n_outputs {
218                    coef[[i, j]] = rng_gen.sample(normal_dist);
219                }
220            }
221            let intercept = Array1::<Float>::zeros(n_outputs);
222            task_coefficients.insert(task_name.clone(), coef);
223            task_intercepts.insert(task_name.clone(), intercept);
224        }
225
226        // Simple k-means clustering of task parameters for initial clustering
227        let task_names: Vec<String> = y.keys().cloned().collect();
228        let _n_tasks = task_names.len();
229
230        // Flatten coefficients for clustering
231        let mut task_vectors = Vec::new();
232        for task_name in &task_names {
233            let coef = &task_coefficients[task_name];
234            let flattened: Vec<Float> = coef.iter().copied().collect();
235            task_vectors.push(flattened);
236        }
237
238        // Simple k-means clustering
239        let mut task_clusters: HashMap<String, usize> = HashMap::new();
240        let cluster_centroids =
241            Array2::<Float>::zeros((self.n_clusters, n_features * y[&task_names[0]].ncols()));
242
243        // Initialize clusters randomly
244        for (i, task_name) in task_names.iter().enumerate() {
245            task_clusters.insert(task_name.clone(), i % self.n_clusters);
246        }
247
248        // Training loop with task clustering
249        let mut prev_loss = Float::INFINITY;
250        let mut n_iter = 0;
251
252        for iteration in 0..self.max_iter {
253            let mut total_loss = 0.0;
254
255            // Update coefficients for each task
256            for (task_name, y_task) in y {
257                let task_cluster = task_clusters[task_name];
258                let current_coef = &task_coefficients[task_name];
259                let current_intercept = &task_intercepts[task_name];
260
261                // Compute predictions
262                let predictions = x.dot(current_coef);
263                let predictions_with_intercept = &predictions + current_intercept;
264
265                // Compute residuals
266                let residuals = &predictions_with_intercept - y_task;
267
268                // Compute gradients
269                let grad_coef = x.t().dot(&residuals) / (n_samples as Float);
270                let grad_intercept = residuals.sum_axis(Axis(0)) / (n_samples as Float);
271
272                // Add clustering regularization
273                let mut reg_grad_coef = grad_coef.clone();
274
275                // Intra-cluster regularization
276                let mut cluster_center: Array2<Float> = Array2::<Float>::zeros(current_coef.dim());
277                let mut cluster_count = 0;
278
279                for (other_task, other_cluster) in &task_clusters {
280                    if *other_cluster == task_cluster && other_task != task_name {
281                        cluster_center = &cluster_center + &task_coefficients[other_task];
282                        cluster_count += 1;
283                    }
284                }
285
286                if cluster_count > 0 {
287                    cluster_center /= cluster_count as Float;
288                    let intra_penalty =
289                        &(current_coef - &cluster_center) * self.intra_cluster_alpha;
290                    reg_grad_coef = reg_grad_coef + intra_penalty;
291                }
292
293                // Inter-cluster regularization (weaker)
294                for (other_task, other_cluster) in &task_clusters {
295                    if *other_cluster != task_cluster {
296                        let inter_penalty = &(current_coef - &task_coefficients[other_task])
297                            * self.inter_cluster_alpha
298                            * 0.1;
299                        reg_grad_coef = reg_grad_coef + inter_penalty;
300                    }
301                }
302
303                // Update parameters
304                let new_coef = current_coef - &(&reg_grad_coef * self.learning_rate);
305                let new_intercept = current_intercept - &(&grad_intercept * self.learning_rate);
306
307                task_coefficients.insert(task_name.clone(), new_coef);
308                task_intercepts.insert(task_name.clone(), new_intercept);
309
310                // Add to loss
311                total_loss += residuals.mapv(|x| x * x).sum();
312            }
313
314            // Check convergence
315            if (prev_loss - total_loss).abs() < self.tolerance {
316                n_iter = iteration + 1;
317                break;
318            }
319            prev_loss = total_loss;
320            n_iter = iteration + 1;
321        }
322
323        Ok(TaskClusteringRegularization {
324            state: TaskClusteringRegressionTrained {
325                coefficients: task_coefficients,
326                intercepts: task_intercepts,
327                task_clusters,
328                cluster_centroids,
329                n_features,
330                task_outputs: self.task_outputs.clone(),
331                n_clusters: self.n_clusters,
332                intra_cluster_alpha: self.intra_cluster_alpha,
333                inter_cluster_alpha: self.inter_cluster_alpha,
334                n_iter,
335            },
336            n_clusters: self.n_clusters,
337            intra_cluster_alpha: self.intra_cluster_alpha,
338            inter_cluster_alpha: self.inter_cluster_alpha,
339            max_iter: self.max_iter,
340            tolerance: self.tolerance,
341            learning_rate: self.learning_rate,
342            task_outputs: self.task_outputs,
343            fit_intercept: self.fit_intercept,
344            random_state: self.random_state,
345        })
346    }
347}
348
349impl Predict<ArrayView2<'_, Float>, HashMap<String, Array2<Float>>>
350    for TaskClusteringRegularization<TaskClusteringRegressionTrained>
351{
352    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<HashMap<String, Array2<Float>>> {
353        let x = X.to_owned();
354        let (_n_samples, n_features) = x.dim();
355
356        if n_features != self.state.n_features {
357            return Err(SklearsError::InvalidInput(
358                "Number of features doesn't match training data".to_string(),
359            ));
360        }
361
362        let mut predictions = HashMap::new();
363
364        for (task_name, coef) in &self.state.coefficients {
365            let task_predictions = x.dot(coef);
366            let intercept = &self.state.intercepts[task_name];
367            let final_predictions = &task_predictions + intercept;
368            predictions.insert(task_name.clone(), final_predictions);
369        }
370
371        Ok(predictions)
372    }
373}
374
375impl TaskClusteringRegressionTrained {
376    /// Get coefficients for a specific task
377    pub fn task_coefficients(&self, task_name: &str) -> Option<&Array2<Float>> {
378        self.coefficients.get(task_name)
379    }
380
381    /// Get intercepts for a specific task
382    pub fn task_intercepts(&self, task_name: &str) -> Option<&Array1<Float>> {
383        self.intercepts.get(task_name)
384    }
385
386    /// Get cluster assignment for a task
387    pub fn task_cluster(&self, task_name: &str) -> Option<usize> {
388        self.task_clusters.get(task_name).copied()
389    }
390
391    /// Get all task cluster assignments
392    pub fn task_clusters(&self) -> &HashMap<String, usize> {
393        &self.task_clusters
394    }
395
396    /// Get cluster centroids
397    pub fn cluster_centroids(&self) -> &Array2<Float> {
398        &self.cluster_centroids
399    }
400
401    /// Get number of iterations performed
402    pub fn n_iter(&self) -> usize {
403        self.n_iter
404    }
405
406    /// Get tasks in a specific cluster
407    pub fn cluster_tasks(&self, cluster_id: usize) -> Vec<&String> {
408        self.task_clusters
409            .iter()
410            .filter_map(|(task_name, &cluster)| {
411                if cluster == cluster_id {
412                    Some(task_name)
413                } else {
414                    None
415                }
416            })
417            .collect()
418    }
419}