Skip to main content

sklears_multioutput/
hierarchical.rs

1//! Hierarchical classification and graph neural network models
2//!
3//! This module provides algorithms for hierarchical multi-label classification and
4//! graph-based structured prediction tasks. It includes ontology-aware classifiers,
5//! cost-sensitive hierarchical methods, and graph neural networks.
6#![allow(non_snake_case)] // Standard ML notation: X for feature matrices, K for kernels
7
8// Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
9use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
10use scirs2_core::random::thread_rng;
11use scirs2_core::random::RandNormal;
12use sklears_core::{
13    error::{Result as SklResult, SklearsError},
14    traits::{Estimator, Fit, Predict, Untrained},
15    types::Float,
16};
17use std::collections::HashMap;
18
19/// Consistency enforcement strategies for hierarchical classification
20#[derive(Debug, Clone, Copy, PartialEq, Default)]
21pub enum ConsistencyEnforcement {
22    /// Post-processing approach that corrects predictions after classification
23    #[default]
24    PostProcessing,
25    /// Training-time approach that enforces constraints during optimization
26    ConstrainedTraining,
27    /// Bayesian inference approach using probabilistic dependencies
28    BayesianInference,
29}
30
31/// Ontology-Aware Hierarchical Classifier
32///
33/// A hierarchical multi-label classifier that incorporates domain ontology knowledge
34/// to ensure taxonomically consistent predictions. This method enforces that if a child
35/// concept is predicted, its parent concepts are also predicted according to the
36/// provided hierarchical structure.
37///
38/// # Examples
39///
40/// ```
41/// use sklears_multioutput::{OntologyAwareClassifier, ConsistencyEnforcement};
42/// use sklears_core::traits::{Predict, Fit};
43/// // Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
44/// use scirs2_core::ndarray::array;
45/// use std::collections::HashMap;
46///
47/// let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0]];
48/// let y = array![[1, 0, 1, 0], [0, 1, 0, 1], [1, 1, 0, 0], [0, 0, 1, 1]];
49///
50/// // Define ontology: child -> parent relationships
51/// let mut ontology = HashMap::new();
52/// ontology.insert(2, vec![0]); // concept 2 is child of concept 0
53/// ontology.insert(3, vec![1]); // concept 3 is child of concept 1
54///
55/// let classifier = OntologyAwareClassifier::new()
56///     .ontology(ontology)
57///     .consistency_enforcement(ConsistencyEnforcement::PostProcessing)
58///     .base_classifier_learning_rate(0.01);
59/// let trained_classifier = classifier.fit(&X.view(), &y).unwrap();
60/// let predictions = trained_classifier.predict(&X.view()).unwrap();
61/// ```
62#[derive(Debug, Clone)]
63pub struct OntologyAwareClassifier<S = Untrained> {
64    state: S,
65    ontology: HashMap<usize, Vec<usize>>,
66    consistency_enforcement: ConsistencyEnforcement,
67    base_classifier_learning_rate: Float,
68    max_iterations: usize,
69}
70
71/// Trained state for OntologyAwareClassifier
72#[derive(Debug, Clone)]
73pub struct OntologyAwareClassifierTrained {
74    weights: Array2<Float>,
75    biases: Array1<Float>,
76    ontology: HashMap<usize, Vec<usize>>,
77    consistency_enforcement: ConsistencyEnforcement,
78    n_features: usize,
79    n_labels: usize,
80}
81
82impl OntologyAwareClassifier<Untrained> {
83    /// Create a new OntologyAwareClassifier
84    pub fn new() -> Self {
85        Self {
86            state: Untrained,
87            ontology: HashMap::new(),
88            consistency_enforcement: ConsistencyEnforcement::PostProcessing,
89            base_classifier_learning_rate: 0.01,
90            max_iterations: 100,
91        }
92    }
93
94    /// Set the ontology (child -> parent relationships)
95    pub fn ontology(mut self, ontology: HashMap<usize, Vec<usize>>) -> Self {
96        self.ontology = ontology;
97        self
98    }
99
100    /// Set the consistency enforcement strategy
101    pub fn consistency_enforcement(mut self, enforcement: ConsistencyEnforcement) -> Self {
102        self.consistency_enforcement = enforcement;
103        self
104    }
105
106    /// Set the learning rate for the base classifier
107    pub fn base_classifier_learning_rate(mut self, learning_rate: Float) -> Self {
108        self.base_classifier_learning_rate = learning_rate;
109        self
110    }
111
112    /// Set the maximum number of iterations
113    pub fn max_iterations(mut self, max_iterations: usize) -> Self {
114        self.max_iterations = max_iterations;
115        self
116    }
117}
118
119impl Default for OntologyAwareClassifier<Untrained> {
120    fn default() -> Self {
121        Self::new()
122    }
123}
124
125impl Estimator for OntologyAwareClassifier<Untrained> {
126    type Config = ();
127    type Error = SklearsError;
128    type Float = Float;
129
130    fn config(&self) -> &Self::Config {
131        &()
132    }
133}
134
135impl Fit<ArrayView2<'_, Float>, Array2<i32>> for OntologyAwareClassifier<Untrained> {
136    type Fitted = OntologyAwareClassifier<OntologyAwareClassifierTrained>;
137
138    fn fit(
139        self,
140        X: &ArrayView2<'_, Float>,
141        y: &Array2<i32>,
142    ) -> SklResult<OntologyAwareClassifier<OntologyAwareClassifierTrained>> {
143        let (n_samples, n_features) = X.dim();
144        let n_labels = y.ncols();
145
146        if n_samples != y.nrows() {
147            return Err(SklearsError::InvalidInput(
148                "X and y must have the same number of samples".to_string(),
149            ));
150        }
151
152        // Initialize weights and biases
153        let mut weights = Array2::<Float>::zeros((n_features, n_labels));
154        let mut biases = Array1::<Float>::zeros(n_labels);
155
156        // Train base classifiers for each label
157        for iteration in 0..self.max_iterations {
158            let mut total_loss = 0.0;
159
160            for sample_idx in 0..n_samples {
161                let x = X.row(sample_idx);
162                let y_true = y.row(sample_idx);
163
164                // Forward pass
165                let logits = x.dot(&weights) + &biases;
166                let probabilities = logits.mapv(|x| 1.0 / (1.0 + (-x).exp()));
167
168                // Apply consistency constraints during training
169                let consistent_probabilities = match self.consistency_enforcement {
170                    ConsistencyEnforcement::ConstrainedTraining => {
171                        self.enforce_consistency_training(&probabilities)?
172                    }
173                    _ => probabilities.clone(),
174                };
175
176                // Calculate loss and gradients
177                for label_idx in 0..n_labels {
178                    let y_label = y_true[label_idx] as Float;
179                    let prob = consistent_probabilities[label_idx];
180                    let error = prob - y_label;
181
182                    total_loss += if y_label == 1.0 {
183                        -prob.ln()
184                    } else {
185                        -(1.0 - prob).ln()
186                    };
187
188                    // Update weights and biases
189                    for feat_idx in 0..n_features {
190                        weights[[feat_idx, label_idx]] -=
191                            self.base_classifier_learning_rate * error * x[feat_idx];
192                    }
193                    biases[label_idx] -= self.base_classifier_learning_rate * error;
194                }
195            }
196
197            if iteration > 0 && total_loss < 1e-6 {
198                break;
199            }
200        }
201
202        Ok(OntologyAwareClassifier {
203            state: OntologyAwareClassifierTrained {
204                weights,
205                biases,
206                ontology: self.ontology,
207                consistency_enforcement: self.consistency_enforcement,
208                n_features,
209                n_labels,
210            },
211            ontology: HashMap::new(),
212            consistency_enforcement: self.consistency_enforcement,
213            base_classifier_learning_rate: self.base_classifier_learning_rate,
214            max_iterations: self.max_iterations,
215        })
216    }
217}
218
219impl OntologyAwareClassifier<Untrained> {
220    /// Enforce consistency during training
221    fn enforce_consistency_training(
222        &self,
223        probabilities: &Array1<Float>,
224    ) -> SklResult<Array1<Float>> {
225        let mut consistent_probs = probabilities.clone();
226
227        // For training, we enforce that parent probabilities are at least as high as child probabilities
228        for (&child, parents) in &self.ontology {
229            if child < probabilities.len() {
230                for &parent in parents {
231                    if parent < probabilities.len() {
232                        let child_prob = probabilities[child];
233                        if consistent_probs[parent] < child_prob {
234                            consistent_probs[parent] = child_prob;
235                        }
236                    }
237                }
238            }
239        }
240
241        Ok(consistent_probs)
242    }
243}
244
245impl Predict<ArrayView2<'_, Float>, Array2<i32>>
246    for OntologyAwareClassifier<OntologyAwareClassifierTrained>
247{
248    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<i32>> {
249        let (n_samples, n_features) = X.dim();
250
251        if n_features != self.state.n_features {
252            return Err(SklearsError::InvalidInput(
253                "X has different number of features than training data".to_string(),
254            ));
255        }
256
257        let mut predictions = Array2::<i32>::zeros((n_samples, self.state.n_labels));
258
259        for sample_idx in 0..n_samples {
260            let x = X.row(sample_idx);
261
262            // Forward pass
263            let logits = x.dot(&self.state.weights) + &self.state.biases;
264            let probabilities = logits.mapv(|x| 1.0 / (1.0 + (-x).exp()));
265
266            // Apply consistency enforcement
267            let consistent_probs = match self.state.consistency_enforcement {
268                ConsistencyEnforcement::PostProcessing => {
269                    self.enforce_consistency_postprocessing(&probabilities)?
270                }
271                ConsistencyEnforcement::BayesianInference => {
272                    self.enforce_consistency_bayesian(&probabilities)?
273                }
274                _ => probabilities,
275            };
276
277            // Convert probabilities to binary predictions
278            for label_idx in 0..self.state.n_labels {
279                predictions[[sample_idx, label_idx]] = if consistent_probs[label_idx] > 0.5 {
280                    1
281                } else {
282                    0
283                };
284            }
285        }
286
287        Ok(predictions)
288    }
289}
290
291impl OntologyAwareClassifier<OntologyAwareClassifierTrained> {
292    /// Get the learned weights
293    pub fn weights(&self) -> &Array2<Float> {
294        &self.state.weights
295    }
296
297    /// Get the learned biases
298    pub fn biases(&self) -> &Array1<Float> {
299        &self.state.biases
300    }
301
302    /// Get the ontology
303    pub fn ontology(&self) -> &HashMap<usize, Vec<usize>> {
304        &self.state.ontology
305    }
306
307    /// Enforce consistency using post-processing
308    fn enforce_consistency_postprocessing(
309        &self,
310        probabilities: &Array1<Float>,
311    ) -> SklResult<Array1<Float>> {
312        let mut consistent_probs = probabilities.clone();
313
314        // Enforce hierarchical constraints: if child is predicted, parent must be predicted
315        for (&child, parents) in &self.state.ontology {
316            if child < probabilities.len() && probabilities[child] > 0.5 {
317                for &parent in parents {
318                    if parent < probabilities.len() {
319                        consistent_probs[parent] =
320                            consistent_probs[parent].max(probabilities[child]);
321                    }
322                }
323            }
324        }
325
326        Ok(consistent_probs)
327    }
328
329    /// Enforce consistency using Bayesian inference
330    fn enforce_consistency_bayesian(
331        &self,
332        probabilities: &Array1<Float>,
333    ) -> SklResult<Array1<Float>> {
334        let mut consistent_probs = probabilities.clone();
335
336        // Simple Bayesian consistency: P(parent|child) = 1 if child is predicted
337        for (&child, parents) in &self.state.ontology {
338            if child < probabilities.len() {
339                let child_prob = probabilities[child];
340                for &parent in parents {
341                    if parent < probabilities.len() {
342                        // Bayesian update: P(parent) = P(parent) + P(child) * P(parent|child)
343                        // Simplified: if child has high probability, parent should too
344                        consistent_probs[parent] = consistent_probs[parent].max(child_prob * 0.8);
345                    }
346                }
347            }
348        }
349
350        Ok(consistent_probs)
351    }
352}
353
354/// Cost strategy for hierarchical classification
355#[derive(Debug, Clone, Copy, PartialEq, Default)]
356pub enum CostStrategy {
357    /// Uniform misclassification costs
358    #[default]
359    Uniform,
360    /// Distance-based costs (closer nodes have lower cost)
361    DistanceBased,
362    /// Custom cost matrix
363    Custom,
364}
365
366/// Cost-Sensitive Hierarchical Classifier
367///
368/// A hierarchical multi-label classifier that incorporates misclassification costs
369/// and hierarchical relationships to optimize cost-sensitive predictions. This method
370/// can handle different cost strategies including uniform costs, distance-based costs,
371/// and custom cost matrices.
372///
373/// # Examples
374///
375/// ```
376/// use sklears_multioutput::{CostSensitiveHierarchicalClassifier, CostStrategy};
377/// use sklears_core::traits::{Predict, Fit};
378/// // Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
379/// use scirs2_core::ndarray::array;
380/// use std::collections::HashMap;
381///
382/// let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0]];
383/// let y = array![[1, 0, 1, 0], [0, 1, 0, 1], [1, 1, 0, 0], [0, 0, 1, 1]];
384///
385/// // Define hierarchical structure and costs
386/// let mut hierarchy = HashMap::new();
387/// hierarchy.insert(0, vec![2, 3]); // concept 0 has children 2, 3
388/// hierarchy.insert(1, vec![2, 3]); // concept 1 has children 2, 3
389///
390/// let classifier = CostSensitiveHierarchicalClassifier::new()
391///     .hierarchy(hierarchy)
392///     .cost_strategy(CostStrategy::DistanceBased)
393///     .learning_rate(0.01);
394/// let trained_classifier = classifier.fit(&X.view(), &y).unwrap();
395/// let predictions = trained_classifier.predict(&X.view()).unwrap();
396/// ```
397#[derive(Debug, Clone)]
398pub struct CostSensitiveHierarchicalClassifier<S = Untrained> {
399    state: S,
400    hierarchy: HashMap<usize, Vec<usize>>,
401    cost_strategy: CostStrategy,
402    cost_matrix: Option<Array2<Float>>,
403    learning_rate: Float,
404    max_iterations: usize,
405    lambda_hierarchy: Float,
406    lambda_cost: Float,
407}
408
409/// Trained state for CostSensitiveHierarchicalClassifier
410#[derive(Debug, Clone)]
411pub struct CostSensitiveHierarchicalClassifierTrained {
412    weights: Array2<Float>,
413    /// Hierarchical structure
414    hierarchy: HashMap<usize, Vec<usize>>,
415    #[allow(dead_code)]
416    cost_strategy: CostStrategy,
417    cost_matrix: Option<Array2<Float>>,
418    n_features: usize,
419    n_labels: usize,
420    #[allow(dead_code)]
421    lambda_hierarchy: Float,
422    #[allow(dead_code)]
423    lambda_cost: Float,
424}
425
426impl CostSensitiveHierarchicalClassifier<Untrained> {
427    /// Create a new CostSensitiveHierarchicalClassifier
428    pub fn new() -> Self {
429        Self {
430            state: Untrained,
431            hierarchy: HashMap::new(),
432            cost_strategy: CostStrategy::Uniform,
433            cost_matrix: None,
434            learning_rate: 0.01,
435            max_iterations: 100,
436            lambda_hierarchy: 1.0,
437            lambda_cost: 1.0,
438        }
439    }
440
441    /// Set the hierarchical structure (parent -> children relationships)
442    pub fn hierarchy(mut self, hierarchy: HashMap<usize, Vec<usize>>) -> Self {
443        self.hierarchy = hierarchy;
444        self
445    }
446
447    /// Set the cost strategy
448    pub fn cost_strategy(mut self, strategy: CostStrategy) -> Self {
449        self.cost_strategy = strategy;
450        self
451    }
452
453    /// Set a custom cost matrix
454    pub fn cost_matrix(mut self, cost_matrix: Array2<Float>) -> Self {
455        self.cost_matrix = Some(cost_matrix);
456        self
457    }
458
459    /// Set the learning rate
460    pub fn learning_rate(mut self, learning_rate: Float) -> Self {
461        self.learning_rate = learning_rate;
462        self
463    }
464
465    /// Set the maximum number of iterations
466    pub fn max_iterations(mut self, max_iterations: usize) -> Self {
467        self.max_iterations = max_iterations;
468        self
469    }
470
471    /// Set the hierarchical constraint weight
472    pub fn lambda_hierarchy(mut self, lambda: Float) -> Self {
473        self.lambda_hierarchy = lambda;
474        self
475    }
476
477    /// Set the cost constraint weight
478    pub fn lambda_cost(mut self, lambda: Float) -> Self {
479        self.lambda_cost = lambda;
480        self
481    }
482}
483
484impl Default for CostSensitiveHierarchicalClassifier<Untrained> {
485    fn default() -> Self {
486        Self::new()
487    }
488}
489
490impl Estimator for CostSensitiveHierarchicalClassifier<Untrained> {
491    type Config = ();
492    type Error = SklearsError;
493    type Float = Float;
494
495    fn config(&self) -> &Self::Config {
496        &()
497    }
498}
499
500impl Fit<ArrayView2<'_, Float>, Array2<i32>> for CostSensitiveHierarchicalClassifier<Untrained> {
501    type Fitted = CostSensitiveHierarchicalClassifier<CostSensitiveHierarchicalClassifierTrained>;
502
503    fn fit(
504        self,
505        X: &ArrayView2<'_, Float>,
506        y: &Array2<i32>,
507    ) -> SklResult<CostSensitiveHierarchicalClassifier<CostSensitiveHierarchicalClassifierTrained>>
508    {
509        let (n_samples, n_features) = X.dim();
510        let n_labels = y.ncols();
511
512        if n_samples != y.nrows() {
513            return Err(SklearsError::InvalidInput(
514                "X and y must have the same number of samples".to_string(),
515            ));
516        }
517
518        // Initialize cost matrix if not provided
519        let cost_matrix = match &self.cost_matrix {
520            Some(matrix) => matrix.clone(),
521            None => self.generate_cost_matrix(n_labels)?,
522        };
523
524        // Initialize weights
525        let mut weights = Array2::<Float>::zeros((n_features, n_labels));
526
527        // Training loop with cost-sensitive and hierarchical constraints
528        for _iteration in 0..self.max_iterations {
529            for sample_idx in 0..n_samples {
530                let x = X.row(sample_idx);
531                let y_true = y.row(sample_idx);
532
533                // Forward pass
534                let scores = x.dot(&weights);
535                let probabilities = scores.mapv(|x| 1.0 / (1.0 + (-x).exp()));
536
537                // Calculate gradients with cost-sensitive and hierarchical terms
538                for label_idx in 0..n_labels {
539                    let y_label = y_true[label_idx] as Float;
540                    let prob = probabilities[label_idx];
541
542                    // Standard logistic loss gradient
543                    let mut gradient = prob - y_label;
544
545                    // Add cost-sensitive term
546                    let cost_weight = cost_matrix[[label_idx, label_idx]];
547                    gradient *= cost_weight * self.lambda_cost;
548
549                    // Add hierarchical constraint term
550                    gradient += self.lambda_hierarchy
551                        * self.hierarchical_gradient(label_idx, &probabilities, &y_true)?;
552
553                    // Update weights
554                    for feat_idx in 0..n_features {
555                        weights[[feat_idx, label_idx]] -=
556                            self.learning_rate * gradient * x[feat_idx];
557                    }
558                }
559            }
560        }
561
562        Ok(CostSensitiveHierarchicalClassifier {
563            state: CostSensitiveHierarchicalClassifierTrained {
564                weights,
565                hierarchy: self.hierarchy,
566                cost_strategy: self.cost_strategy,
567                cost_matrix: Some(cost_matrix),
568                n_features,
569                n_labels,
570                lambda_hierarchy: self.lambda_hierarchy,
571                lambda_cost: self.lambda_cost,
572            },
573            hierarchy: HashMap::new(),
574            cost_strategy: self.cost_strategy,
575            cost_matrix: None,
576            learning_rate: self.learning_rate,
577            max_iterations: self.max_iterations,
578            lambda_hierarchy: self.lambda_hierarchy,
579            lambda_cost: self.lambda_cost,
580        })
581    }
582}
583
584impl CostSensitiveHierarchicalClassifier<Untrained> {
585    /// Generate cost matrix based on strategy
586    fn generate_cost_matrix(&self, n_labels: usize) -> SklResult<Array2<Float>> {
587        match self.cost_strategy {
588            CostStrategy::Uniform => Ok(Array2::eye(n_labels)),
589            CostStrategy::DistanceBased => {
590                let mut cost_matrix = Array2::<Float>::zeros((n_labels, n_labels));
591                // Simple distance-based costs (can be enhanced with actual hierarchy distances)
592                for i in 0..n_labels {
593                    for j in 0..n_labels {
594                        cost_matrix[[i, j]] = if i == j { 1.0 } else { 0.5 };
595                    }
596                }
597                Ok(cost_matrix)
598            }
599            CostStrategy::Custom => Err(SklearsError::InvalidInput(
600                "Custom cost strategy requires a cost matrix".to_string(),
601            )),
602        }
603    }
604
605    /// Calculate hierarchical gradient term
606    fn hierarchical_gradient(
607        &self,
608        label_idx: usize,
609        probabilities: &Array1<Float>,
610        y_true: &ArrayView1<i32>,
611    ) -> SklResult<Float> {
612        let mut gradient = 0.0;
613
614        // If this label has children, enforce that children can't be more probable than parent
615        if let Some(children) = self.hierarchy.get(&label_idx) {
616            for &child in children {
617                if child < probabilities.len() {
618                    let parent_prob = probabilities[label_idx];
619                    let child_prob = probabilities[child];
620                    let child_true = y_true[child] as Float;
621
622                    // Penalty if child probability exceeds parent probability when child is true
623                    if child_true > 0.5 && child_prob > parent_prob {
624                        gradient += child_prob - parent_prob;
625                    }
626                }
627            }
628        }
629
630        // If this label is a child, enforce consistency with parents
631        for (&parent, children) in &self.hierarchy {
632            if children.contains(&label_idx) && parent < probabilities.len() {
633                let parent_prob = probabilities[parent];
634                let child_prob = probabilities[label_idx];
635                let label_true = y_true[label_idx] as Float;
636
637                // Penalty if child is predicted but parent is not
638                if label_true > 0.5 && child_prob > parent_prob {
639                    gradient -= child_prob - parent_prob;
640                }
641            }
642        }
643
644        Ok(gradient)
645    }
646}
647
648impl Predict<ArrayView2<'_, Float>, Array2<i32>>
649    for CostSensitiveHierarchicalClassifier<CostSensitiveHierarchicalClassifierTrained>
650{
651    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<i32>> {
652        let (n_samples, n_features) = X.dim();
653
654        if n_features != self.state.n_features {
655            return Err(SklearsError::InvalidInput(
656                "X has different number of features than training data".to_string(),
657            ));
658        }
659
660        let mut predictions = Array2::<i32>::zeros((n_samples, self.state.n_labels));
661
662        for sample_idx in 0..n_samples {
663            let x = X.row(sample_idx);
664
665            // Forward pass
666            let scores = x.dot(&self.state.weights);
667            let probabilities = scores.mapv(|x| 1.0 / (1.0 + (-x).exp()));
668
669            // Apply hierarchical constraints and cost-sensitive thresholding
670            let final_predictions = self.apply_constraints(&probabilities)?;
671
672            for label_idx in 0..self.state.n_labels {
673                predictions[[sample_idx, label_idx]] = final_predictions[label_idx];
674            }
675        }
676
677        Ok(predictions)
678    }
679}
680
681impl CostSensitiveHierarchicalClassifier<CostSensitiveHierarchicalClassifierTrained> {
682    /// Get the learned weights
683    pub fn weights(&self) -> &Array2<Float> {
684        &self.state.weights
685    }
686
687    /// Get the cost matrix
688    pub fn cost_matrix(&self) -> Option<&Array2<Float>> {
689        self.state.cost_matrix.as_ref()
690    }
691
692    /// Apply constraints to get final predictions
693    fn apply_constraints(&self, probabilities: &Array1<Float>) -> SklResult<Array1<i32>> {
694        let mut binary_predictions = Array1::<i32>::zeros(probabilities.len());
695
696        // Convert probabilities to binary predictions with cost-sensitive thresholds
697        for i in 0..probabilities.len() {
698            let threshold = if let Some(cost_matrix) = &self.state.cost_matrix {
699                // Adjust threshold based on cost
700                let cost = cost_matrix[[i, i]];
701                0.5 / cost.max(0.1) // Higher cost = lower threshold
702            } else {
703                0.5
704            };
705
706            binary_predictions[i] = if probabilities[i] > threshold { 1 } else { 0 };
707        }
708
709        // Enforce hierarchical constraints
710        for (&parent, children) in &self.state.hierarchy {
711            if parent < binary_predictions.len() {
712                // If any child is predicted, parent must be predicted
713                let mut any_child_predicted = false;
714                for &child in children {
715                    if child < binary_predictions.len() && binary_predictions[child] == 1 {
716                        any_child_predicted = true;
717                        break;
718                    }
719                }
720                if any_child_predicted {
721                    binary_predictions[parent] = 1;
722                }
723            }
724        }
725
726        Ok(binary_predictions)
727    }
728}
729
730// Graph Neural Networks for Structured Output Prediction
731
732/// Aggregation functions for Graph Neural Networks
733#[derive(Debug, Clone, Copy, PartialEq)]
734pub enum AggregationFunction {
735    /// Mean aggregation
736    Mean,
737    /// Sum aggregation
738    Sum,
739    /// Max aggregation
740    Max,
741    /// Attention-based aggregation
742    Attention,
743}
744
745/// Message passing variants for Graph Neural Networks
746#[derive(Debug, Clone, Copy, PartialEq)]
747pub enum MessagePassingVariant {
748    /// Graph Convolutional Network (GCN)
749    GCN,
750    /// Graph Attention Network (GAT)
751    GAT,
752    /// GraphSAGE
753    GraphSAGE,
754    /// Graph Isomorphism Network (GIN)
755    GIN,
756}
757
758/// Graph Neural Network for Structured Output Prediction
759///
760/// A graph neural network implementation for multi-output prediction tasks where
761/// the outputs have structural relationships represented as a graph. This method
762/// can leverage node features, edge information, and graph topology to make
763/// predictions that respect the underlying graph structure.
764///
765/// # Examples
766///
767/// ```
768/// use sklears_multioutput::{GraphNeuralNetwork, MessagePassingVariant, AggregationFunction};
769/// use sklears_core::traits::{Predict, Fit};
770/// // Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
771/// use scirs2_core::ndarray::array;
772///
773/// // Node features and adjacency matrix
774/// let node_features = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0], [1.0, 3.0]];
775/// let adjacency = array![[0, 1, 1, 0, 0], [1, 0, 1, 1, 0], [1, 1, 0, 0, 1],
776///                        [0, 1, 0, 0, 1], [0, 0, 1, 1, 0]];
777/// let node_labels = array![[1, 0, 1], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 0]];
778///
779/// let gnn = GraphNeuralNetwork::new()
780///     .hidden_dim(16)
781///     .num_layers(2)
782///     .message_passing_variant(MessagePassingVariant::GCN)
783///     .aggregation_function(AggregationFunction::Mean);
784/// let trained_gnn = gnn.fit_graph(&adjacency.view(), &node_features.view(), &node_labels).unwrap();
785/// let predictions = trained_gnn.predict_graph(&adjacency.view(), &node_features.view()).unwrap();
786/// ```
787#[derive(Debug, Clone)]
788pub struct GraphNeuralNetwork<S = Untrained> {
789    state: S,
790    hidden_dim: usize,
791    num_layers: usize,
792    message_passing_variant: MessagePassingVariant,
793    aggregation_function: AggregationFunction,
794    learning_rate: Float,
795    max_iter: usize,
796    dropout_rate: Float,
797    random_state: Option<u64>,
798}
799
800/// Trained state for GraphNeuralNetwork
801#[derive(Debug, Clone)]
802pub struct GraphNeuralNetworkTrained {
803    /// Layer weights: Vec of (input_dim x output_dim) matrices
804    layer_weights: Vec<Array2<Float>>,
805    /// Layer biases: Vec of output_dim vectors
806    layer_biases: Vec<Array1<Float>>,
807    /// Attention weights for GAT (if applicable)
808    attention_weights: Option<Vec<Array2<Float>>>,
809    /// Model configuration
810    hidden_dim: usize,
811    num_layers: usize,
812    message_passing_variant: MessagePassingVariant,
813    #[allow(dead_code)]
814    aggregation_function: AggregationFunction,
815    n_features: usize,
816    #[allow(dead_code)]
817    n_outputs: usize,
818    #[allow(dead_code)]
819    dropout_rate: Float,
820}
821
822impl GraphNeuralNetwork<Untrained> {
823    /// Create a new GraphNeuralNetwork instance
824    pub fn new() -> Self {
825        Self {
826            state: Untrained,
827            hidden_dim: 32,
828            num_layers: 2,
829            message_passing_variant: MessagePassingVariant::GCN,
830            aggregation_function: AggregationFunction::Mean,
831            learning_rate: 0.01,
832            max_iter: 100,
833            dropout_rate: 0.0,
834            random_state: None,
835        }
836    }
837
838    /// Set the hidden dimension
839    pub fn hidden_dim(mut self, hidden_dim: usize) -> Self {
840        self.hidden_dim = hidden_dim;
841        self
842    }
843
844    /// Set the number of layers
845    pub fn num_layers(mut self, num_layers: usize) -> Self {
846        self.num_layers = num_layers;
847        self
848    }
849
850    /// Set the message passing variant
851    pub fn message_passing_variant(mut self, variant: MessagePassingVariant) -> Self {
852        self.message_passing_variant = variant;
853        self
854    }
855
856    /// Set the aggregation function
857    pub fn aggregation_function(mut self, function: AggregationFunction) -> Self {
858        self.aggregation_function = function;
859        self
860    }
861
862    /// Set the learning rate
863    pub fn learning_rate(mut self, learning_rate: Float) -> Self {
864        self.learning_rate = learning_rate;
865        self
866    }
867
868    /// Set the maximum number of iterations
869    pub fn max_iter(mut self, max_iter: usize) -> Self {
870        self.max_iter = max_iter;
871        self
872    }
873
874    /// Set the dropout rate
875    pub fn dropout_rate(mut self, dropout_rate: Float) -> Self {
876        self.dropout_rate = dropout_rate;
877        self
878    }
879
880    /// Set random state for reproducible results
881    pub fn random_state(mut self, random_state: u64) -> Self {
882        self.random_state = Some(random_state);
883        self
884    }
885}
886
887impl Default for GraphNeuralNetwork<Untrained> {
888    fn default() -> Self {
889        Self::new()
890    }
891}
892
893impl Estimator for GraphNeuralNetwork<Untrained> {
894    type Config = ();
895    type Error = SklearsError;
896    type Float = Float;
897
898    fn config(&self) -> &Self::Config {
899        &()
900    }
901}
902
903/// Fit method for Graph Neural Networks with graph structure
904impl GraphNeuralNetwork<Untrained> {
905    /// Fit the GNN using graph structure, node features, and node labels
906    pub fn fit_graph(
907        self,
908        adjacency: &ArrayView2<'_, i32>,
909        node_features: &ArrayView2<'_, Float>,
910        node_labels: &Array2<i32>,
911    ) -> SklResult<GraphNeuralNetwork<GraphNeuralNetworkTrained>> {
912        let (n_nodes, n_features) = node_features.dim();
913        let n_outputs = node_labels.ncols();
914
915        if adjacency.dim() != (n_nodes, n_nodes) {
916            return Err(SklearsError::InvalidInput(
917                "Adjacency matrix must be n_nodes x n_nodes".to_string(),
918            ));
919        }
920
921        if node_labels.nrows() != n_nodes {
922            return Err(SklearsError::InvalidInput(
923                "Node labels must have same number of rows as nodes".to_string(),
924            ));
925        }
926
927        // Initialize parameters
928        let mut rng_instance = thread_rng();
929        let (layer_weights, layer_biases, attention_weights) =
930            self.initialize_gnn_parameters(n_features, n_outputs, &mut rng_instance)?;
931
932        // Training loop (simplified gradient descent)
933        let mut weights = layer_weights;
934        let biases = layer_biases;
935        let attention_weights = attention_weights;
936
937        for _iteration in 0..self.max_iter {
938            // Forward pass
939            let (node_embeddings, _) = self.forward_pass_graph(
940                adjacency,
941                node_features,
942                &weights,
943                &biases,
944                &attention_weights,
945            )?;
946
947            // Compute loss and gradients (simplified)
948            let _predictions = node_embeddings.mapv(|x| if x > 0.0 { 1 } else { 0 });
949
950            // Simple gradient update (in practice, would use backpropagation)
951            for weight in &mut weights {
952                for i in 0..weight.nrows() {
953                    for j in 0..weight.ncols() {
954                        weight[[i, j]] *= 0.999; // Simple weight decay
955                    }
956                }
957            }
958        }
959
960        let trained_state = GraphNeuralNetworkTrained {
961            layer_weights: weights,
962            layer_biases: biases,
963            attention_weights,
964            hidden_dim: self.hidden_dim,
965            num_layers: self.num_layers,
966            message_passing_variant: self.message_passing_variant,
967            aggregation_function: self.aggregation_function,
968            n_features,
969            n_outputs,
970            dropout_rate: self.dropout_rate,
971        };
972
973        Ok(GraphNeuralNetwork {
974            state: trained_state,
975            hidden_dim: self.hidden_dim,
976            num_layers: self.num_layers,
977            message_passing_variant: self.message_passing_variant,
978            aggregation_function: self.aggregation_function,
979            learning_rate: self.learning_rate,
980            max_iter: self.max_iter,
981            dropout_rate: self.dropout_rate,
982            random_state: self.random_state,
983        })
984    }
985
986    /// Initialize GNN parameters
987    #[allow(clippy::type_complexity)]
988    fn initialize_gnn_parameters(
989        &self,
990        n_features: usize,
991        n_outputs: usize,
992        rng: &mut scirs2_core::random::CoreRandom,
993    ) -> SklResult<(
994        Vec<Array2<Float>>,
995        Vec<Array1<Float>>,
996        Option<Vec<Array2<Float>>>,
997    )> {
998        let mut layer_weights = Vec::new();
999        let mut layer_biases = Vec::new();
1000        let mut attention_weights = None;
1001
1002        // Input layer
1003        let input_dim = match self.message_passing_variant {
1004            MessagePassingVariant::GraphSAGE => n_features * 2, // Concatenated features
1005            _ => n_features,
1006        };
1007
1008        // Hidden layers
1009        let hidden_dim = match self.message_passing_variant {
1010            MessagePassingVariant::GraphSAGE => self.hidden_dim * 2, // Concatenated features
1011            _ => self.hidden_dim,
1012        };
1013
1014        // Initialize weights for each layer
1015        for layer_idx in 0..self.num_layers {
1016            let (in_dim, out_dim) = if layer_idx == 0 {
1017                (input_dim, self.hidden_dim)
1018            } else if layer_idx == self.num_layers - 1 {
1019                (hidden_dim, n_outputs)
1020            } else {
1021                (hidden_dim, self.hidden_dim)
1022            };
1023
1024            let normal_dist = RandNormal::new(0.0, (2.0 / in_dim as Float).sqrt())
1025                .expect("operation should succeed");
1026            let mut input_weight = Array2::<Float>::zeros((in_dim, out_dim));
1027            for i in 0..in_dim {
1028                for j in 0..out_dim {
1029                    input_weight[[i, j]] = rng.sample(normal_dist);
1030                }
1031            }
1032            let bias = Array1::<Float>::zeros(out_dim);
1033
1034            layer_weights.push(input_weight);
1035            layer_biases.push(bias);
1036        }
1037
1038        // Initialize attention weights for GAT
1039        if self.message_passing_variant == MessagePassingVariant::GAT {
1040            let mut att_weights = Vec::new();
1041            for layer_idx in 0..self.num_layers {
1042                let att_dim = if layer_idx == 0 {
1043                    n_features
1044                } else {
1045                    self.hidden_dim
1046                };
1047                let att_normal_dist = RandNormal::new(0.0, 0.1).expect("operation should succeed");
1048                let mut attention_weight = Array2::<Float>::zeros((att_dim * 2, 1));
1049                for i in 0..(att_dim * 2) {
1050                    attention_weight[[i, 0]] = rng.sample(att_normal_dist);
1051                }
1052                att_weights.push(attention_weight);
1053            }
1054            attention_weights = Some(att_weights);
1055        }
1056
1057        Ok((layer_weights, layer_biases, attention_weights))
1058    }
1059
1060    /// Forward pass through the graph neural network
1061    fn forward_pass_graph(
1062        &self,
1063        adjacency: &ArrayView2<'_, i32>,
1064        node_features: &ArrayView2<'_, Float>,
1065        weights: &[Array2<Float>],
1066        biases: &[Array1<Float>],
1067        attention_weights: &Option<Vec<Array2<Float>>>,
1068    ) -> SklResult<(Array2<Float>, Vec<Array2<Float>>)> {
1069        let _n_nodes = node_features.nrows();
1070        let mut current_embeddings = node_features.to_owned();
1071        let mut layer_outputs = Vec::new();
1072
1073        for layer_idx in 0..self.num_layers {
1074            let layer_output = match self.message_passing_variant {
1075                MessagePassingVariant::GCN => self.gcn_layer(
1076                    &current_embeddings,
1077                    adjacency,
1078                    &weights[layer_idx],
1079                    &biases[layer_idx],
1080                )?,
1081                MessagePassingVariant::GAT => {
1082                    let att_weights = attention_weights
1083                        .as_ref()
1084                        .expect("operation should succeed");
1085                    self.gat_layer(
1086                        &current_embeddings,
1087                        adjacency,
1088                        &weights[layer_idx],
1089                        &biases[layer_idx],
1090                        &att_weights[layer_idx],
1091                    )?
1092                }
1093                MessagePassingVariant::GraphSAGE => self.graphsage_layer(
1094                    &current_embeddings,
1095                    adjacency,
1096                    &weights[layer_idx],
1097                    &biases[layer_idx],
1098                )?,
1099                MessagePassingVariant::GIN => self.gin_layer(
1100                    &current_embeddings,
1101                    adjacency,
1102                    &weights[layer_idx],
1103                    &biases[layer_idx],
1104                )?,
1105            };
1106
1107            current_embeddings = layer_output.clone();
1108            layer_outputs.push(layer_output);
1109        }
1110
1111        Ok((current_embeddings, layer_outputs))
1112    }
1113
1114    /// Graph Convolutional Network layer
1115    fn gcn_layer(
1116        &self,
1117        node_embeddings: &Array2<Float>,
1118        adjacency: &ArrayView2<'_, i32>,
1119        weights: &Array2<Float>,
1120        bias: &Array1<Float>,
1121    ) -> SklResult<Array2<Float>> {
1122        let n_nodes = node_embeddings.nrows();
1123        let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
1124
1125        for i in 0..n_nodes {
1126            let mut aggregated = Array1::<Float>::zeros(node_embeddings.ncols());
1127            let mut degree = 0;
1128
1129            // Aggregate neighbor features
1130            for j in 0..n_nodes {
1131                if adjacency[[i, j]] == 1 {
1132                    aggregated += &node_embeddings.row(j).to_owned();
1133                    degree += 1;
1134                }
1135            }
1136
1137            // Add self-loop
1138            aggregated += &node_embeddings.row(i).to_owned();
1139            degree += 1;
1140
1141            // Normalize by degree
1142            if degree > 0 {
1143                aggregated /= degree as Float;
1144            }
1145
1146            // Apply linear transformation
1147            let transformed = aggregated.dot(weights) + bias;
1148            let activated = transformed.mapv(|x| x.max(0.0)); // ReLU activation
1149
1150            output.row_mut(i).assign(&activated);
1151        }
1152
1153        Ok(output)
1154    }
1155
1156    /// Graph Attention Network layer (simplified)
1157    fn gat_layer(
1158        &self,
1159        node_embeddings: &Array2<Float>,
1160        adjacency: &ArrayView2<'_, i32>,
1161        weights: &Array2<Float>,
1162        bias: &Array1<Float>,
1163        attention_weights: &Array2<Float>,
1164    ) -> SklResult<Array2<Float>> {
1165        let n_nodes = node_embeddings.nrows();
1166        let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
1167
1168        for i in 0..n_nodes {
1169            let mut attention_scores = Array1::<Float>::zeros(n_nodes);
1170            let mut valid_neighbors = Vec::new();
1171
1172            // Calculate attention scores
1173            for j in 0..n_nodes {
1174                if adjacency[[i, j]] == 1 || i == j {
1175                    // Concatenate node features for attention computation
1176                    let concat_features = Array1::from_iter(
1177                        node_embeddings
1178                            .row(i)
1179                            .iter()
1180                            .chain(node_embeddings.row(j).iter())
1181                            .cloned(),
1182                    );
1183
1184                    if concat_features.len() == attention_weights.nrows() {
1185                        let score = concat_features.dot(&attention_weights.column(0));
1186                        attention_scores[j] = score.exp();
1187                        valid_neighbors.push(j);
1188                    }
1189                }
1190            }
1191
1192            // Normalize attention scores
1193            let total_attention: Float = valid_neighbors.iter().map(|&j| attention_scores[j]).sum();
1194            if total_attention > 0.0 {
1195                for &j in &valid_neighbors {
1196                    attention_scores[j] /= total_attention;
1197                }
1198            }
1199
1200            // Aggregate features using attention weights
1201            let mut aggregated = Array1::<Float>::zeros(node_embeddings.ncols());
1202            for &j in &valid_neighbors {
1203                let weighted_features = &node_embeddings.row(j).to_owned() * attention_scores[j];
1204                aggregated += &weighted_features;
1205            }
1206
1207            // Apply linear transformation
1208            let transformed = aggregated.dot(weights) + bias;
1209            let activated = transformed.mapv(|x| x.max(0.0)); // ReLU activation
1210
1211            output.row_mut(i).assign(&activated);
1212        }
1213
1214        Ok(output)
1215    }
1216
1217    /// GraphSAGE layer (simplified)
1218    fn graphsage_layer(
1219        &self,
1220        node_embeddings: &Array2<Float>,
1221        adjacency: &ArrayView2<'_, i32>,
1222        weights: &Array2<Float>,
1223        bias: &Array1<Float>,
1224    ) -> SklResult<Array2<Float>> {
1225        let n_nodes = node_embeddings.nrows();
1226        let embedding_dim = node_embeddings.ncols();
1227        let output_dim = weights.ncols();
1228        let mut output = Array2::<Float>::zeros((n_nodes, output_dim));
1229
1230        for i in 0..n_nodes {
1231            // Aggregate neighbor features
1232            let mut neighbor_sum = Array1::<Float>::zeros(embedding_dim);
1233            let mut neighbor_count = 0;
1234
1235            for j in 0..n_nodes {
1236                if adjacency[[i, j]] == 1 && i != j {
1237                    neighbor_sum += &node_embeddings.row(j).to_owned();
1238                    neighbor_count += 1;
1239                }
1240            }
1241
1242            // Average pooling of neighbors
1243            if neighbor_count > 0 {
1244                neighbor_sum /= neighbor_count as Float;
1245            }
1246
1247            // Concatenate self and neighbor representations
1248            let self_features = node_embeddings.row(i).to_owned();
1249            let concatenated =
1250                Array1::from_iter(self_features.iter().chain(neighbor_sum.iter()).cloned());
1251
1252            // Apply linear transformation (note: weights should match concatenated dimension)
1253            if concatenated.len() == weights.nrows() {
1254                let transformed = concatenated.dot(weights) + bias;
1255                let activated = transformed.mapv(|x| x.max(0.0)); // ReLU activation
1256                output.row_mut(i).assign(&activated);
1257            }
1258        }
1259
1260        Ok(output)
1261    }
1262
1263    /// Graph Isomorphism Network layer
1264    fn gin_layer(
1265        &self,
1266        node_embeddings: &Array2<Float>,
1267        adjacency: &ArrayView2<'_, i32>,
1268        weights: &Array2<Float>,
1269        bias: &Array1<Float>,
1270    ) -> SklResult<Array2<Float>> {
1271        let n_nodes = node_embeddings.nrows();
1272        let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
1273        let epsilon = 0.0; // Learnable parameter, simplified as 0
1274
1275        for i in 0..n_nodes {
1276            // Sum neighbor features
1277            let mut neighbor_sum = Array1::<Float>::zeros(node_embeddings.ncols());
1278
1279            for j in 0..n_nodes {
1280                if adjacency[[i, j]] == 1 && i != j {
1281                    neighbor_sum += &node_embeddings.row(j).to_owned();
1282                }
1283            }
1284
1285            // GIN update: (1 + epsilon) * h_i + sum(h_j for j in neighbors)
1286            let updated = &node_embeddings.row(i).to_owned() * (1.0 + epsilon) + &neighbor_sum;
1287
1288            // Apply MLP (simplified as single linear layer)
1289            let transformed = updated.dot(weights) + bias;
1290            let activated = transformed.mapv(|x| x.max(0.0)); // ReLU activation
1291
1292            output.row_mut(i).assign(&activated);
1293        }
1294
1295        Ok(output)
1296    }
1297}
1298
1299impl GraphNeuralNetwork<GraphNeuralNetworkTrained> {
1300    /// Predict node labels using the trained GNN
1301    pub fn predict_graph(
1302        &self,
1303        adjacency: &ArrayView2<'_, i32>,
1304        node_features: &ArrayView2<'_, Float>,
1305    ) -> SklResult<Array2<i32>> {
1306        let (n_nodes, n_features) = node_features.dim();
1307
1308        if n_features != self.state.n_features {
1309            return Err(SklearsError::InvalidInput(
1310                "Node features have different dimensionality than training data".to_string(),
1311            ));
1312        }
1313
1314        if adjacency.dim() != (n_nodes, n_nodes) {
1315            return Err(SklearsError::InvalidInput(
1316                "Adjacency matrix must be n_nodes x n_nodes".to_string(),
1317            ));
1318        }
1319
1320        // Forward pass
1321        let (final_embeddings, _) = self.forward_pass_trained(adjacency, node_features)?;
1322
1323        // Convert to binary predictions
1324        let predictions = final_embeddings.mapv(|x| if x > 0.0 { 1 } else { 0 });
1325
1326        Ok(predictions)
1327    }
1328
1329    /// Get the hidden dimension
1330    pub fn hidden_dim(&self) -> usize {
1331        self.state.hidden_dim
1332    }
1333
1334    /// Get the number of layers
1335    pub fn num_layers(&self) -> usize {
1336        self.state.num_layers
1337    }
1338
1339    /// Forward pass for trained model
1340    fn forward_pass_trained(
1341        &self,
1342        adjacency: &ArrayView2<'_, i32>,
1343        node_features: &ArrayView2<'_, Float>,
1344    ) -> SklResult<(Array2<Float>, Vec<Array2<Float>>)> {
1345        let _n_nodes = node_features.nrows();
1346        let mut current_embeddings = node_features.to_owned();
1347        let mut layer_outputs = Vec::new();
1348
1349        for layer_idx in 0..self.state.num_layers {
1350            let layer_output = match self.state.message_passing_variant {
1351                MessagePassingVariant::GCN => {
1352                    self.gcn_layer_trained(&current_embeddings, adjacency, layer_idx)?
1353                }
1354                MessagePassingVariant::GAT => {
1355                    self.gat_layer_trained(&current_embeddings, adjacency, layer_idx)?
1356                }
1357                MessagePassingVariant::GraphSAGE => {
1358                    self.graphsage_layer_trained(&current_embeddings, adjacency, layer_idx)?
1359                }
1360                MessagePassingVariant::GIN => {
1361                    self.gin_layer_trained(&current_embeddings, adjacency, layer_idx)?
1362                }
1363            };
1364
1365            current_embeddings = layer_output.clone();
1366            layer_outputs.push(layer_output);
1367        }
1368
1369        Ok((current_embeddings, layer_outputs))
1370    }
1371
1372    /// GCN layer for trained model
1373    fn gcn_layer_trained(
1374        &self,
1375        node_embeddings: &Array2<Float>,
1376        adjacency: &ArrayView2<'_, i32>,
1377        layer_idx: usize,
1378    ) -> SklResult<Array2<Float>> {
1379        let weights = &self.state.layer_weights[layer_idx];
1380        let bias = &self.state.layer_biases[layer_idx];
1381        let n_nodes = node_embeddings.nrows();
1382        let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
1383
1384        for i in 0..n_nodes {
1385            let mut aggregated = Array1::<Float>::zeros(node_embeddings.ncols());
1386            let mut degree = 0;
1387
1388            // Aggregate neighbor features
1389            for j in 0..n_nodes {
1390                if adjacency[[i, j]] == 1 {
1391                    aggregated += &node_embeddings.row(j).to_owned();
1392                    degree += 1;
1393                }
1394            }
1395
1396            // Add self-loop
1397            aggregated += &node_embeddings.row(i).to_owned();
1398            degree += 1;
1399
1400            // Normalize by degree
1401            if degree > 0 {
1402                aggregated /= degree as Float;
1403            }
1404
1405            // Apply linear transformation
1406            let transformed = aggregated.dot(weights) + bias;
1407            let activated = if layer_idx == self.state.num_layers - 1 {
1408                // Output layer: sigmoid activation for binary classification
1409                transformed.mapv(|x| 1.0 / (1.0 + (-x).exp()))
1410            } else {
1411                // Hidden layers: ReLU activation
1412                transformed.mapv(|x| x.max(0.0))
1413            };
1414
1415            output.row_mut(i).assign(&activated);
1416        }
1417
1418        Ok(output)
1419    }
1420
1421    /// GAT layer for trained model
1422    fn gat_layer_trained(
1423        &self,
1424        node_embeddings: &Array2<Float>,
1425        adjacency: &ArrayView2<'_, i32>,
1426        layer_idx: usize,
1427    ) -> SklResult<Array2<Float>> {
1428        let weights = &self.state.layer_weights[layer_idx];
1429        let bias = &self.state.layer_biases[layer_idx];
1430        let attention_weights = self
1431            .state
1432            .attention_weights
1433            .as_ref()
1434            .expect("operation should succeed");
1435        let att_weights = &attention_weights[layer_idx];
1436
1437        let n_nodes = node_embeddings.nrows();
1438        let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
1439
1440        for i in 0..n_nodes {
1441            let mut attention_scores = Array1::<Float>::zeros(n_nodes);
1442            let mut valid_neighbors = Vec::new();
1443
1444            // Calculate attention scores
1445            for j in 0..n_nodes {
1446                if adjacency[[i, j]] == 1 || i == j {
1447                    let concat_features = Array1::from_iter(
1448                        node_embeddings
1449                            .row(i)
1450                            .iter()
1451                            .chain(node_embeddings.row(j).iter())
1452                            .cloned(),
1453                    );
1454
1455                    if concat_features.len() == att_weights.nrows() {
1456                        let score = concat_features.dot(&att_weights.column(0));
1457                        attention_scores[j] = score.exp();
1458                        valid_neighbors.push(j);
1459                    }
1460                }
1461            }
1462
1463            // Normalize attention scores
1464            let total_attention: Float = valid_neighbors.iter().map(|&j| attention_scores[j]).sum();
1465            if total_attention > 0.0 {
1466                for &j in &valid_neighbors {
1467                    attention_scores[j] /= total_attention;
1468                }
1469            }
1470
1471            // Aggregate features using attention weights
1472            let mut aggregated = Array1::<Float>::zeros(node_embeddings.ncols());
1473            for &j in &valid_neighbors {
1474                let weighted_features = &node_embeddings.row(j).to_owned() * attention_scores[j];
1475                aggregated += &weighted_features;
1476            }
1477
1478            // Apply linear transformation
1479            let transformed = aggregated.dot(weights) + bias;
1480            let activated = if layer_idx == self.state.num_layers - 1 {
1481                transformed.mapv(|x| 1.0 / (1.0 + (-x).exp()))
1482            } else {
1483                transformed.mapv(|x| x.max(0.0))
1484            };
1485
1486            output.row_mut(i).assign(&activated);
1487        }
1488
1489        Ok(output)
1490    }
1491
1492    /// GraphSAGE layer for trained model
1493    fn graphsage_layer_trained(
1494        &self,
1495        node_embeddings: &Array2<Float>,
1496        adjacency: &ArrayView2<'_, i32>,
1497        layer_idx: usize,
1498    ) -> SklResult<Array2<Float>> {
1499        let weights = &self.state.layer_weights[layer_idx];
1500        let bias = &self.state.layer_biases[layer_idx];
1501        let n_nodes = node_embeddings.nrows();
1502        let embedding_dim = node_embeddings.ncols();
1503        let output_dim = weights.ncols();
1504        let mut output = Array2::<Float>::zeros((n_nodes, output_dim));
1505
1506        for i in 0..n_nodes {
1507            // Aggregate neighbor features
1508            let mut neighbor_sum = Array1::<Float>::zeros(embedding_dim);
1509            let mut neighbor_count = 0;
1510
1511            for j in 0..n_nodes {
1512                if adjacency[[i, j]] == 1 && i != j {
1513                    neighbor_sum += &node_embeddings.row(j).to_owned();
1514                    neighbor_count += 1;
1515                }
1516            }
1517
1518            // Average pooling of neighbors
1519            if neighbor_count > 0 {
1520                neighbor_sum /= neighbor_count as Float;
1521            }
1522
1523            // Concatenate self and neighbor representations
1524            let self_features = node_embeddings.row(i).to_owned();
1525            let concatenated =
1526                Array1::from_iter(self_features.iter().chain(neighbor_sum.iter()).cloned());
1527
1528            // Apply linear transformation
1529            if concatenated.len() == weights.nrows() {
1530                let transformed = concatenated.dot(weights) + bias;
1531                let activated = if layer_idx == self.state.num_layers - 1 {
1532                    transformed.mapv(|x| 1.0 / (1.0 + (-x).exp()))
1533                } else {
1534                    transformed.mapv(|x| x.max(0.0))
1535                };
1536                output.row_mut(i).assign(&activated);
1537            }
1538        }
1539
1540        Ok(output)
1541    }
1542
1543    /// GIN layer for trained model
1544    fn gin_layer_trained(
1545        &self,
1546        node_embeddings: &Array2<Float>,
1547        adjacency: &ArrayView2<'_, i32>,
1548        layer_idx: usize,
1549    ) -> SklResult<Array2<Float>> {
1550        let weights = &self.state.layer_weights[layer_idx];
1551        let bias = &self.state.layer_biases[layer_idx];
1552        let n_nodes = node_embeddings.nrows();
1553        let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
1554        let epsilon = 0.0; // Simplified as 0
1555
1556        for i in 0..n_nodes {
1557            // Sum neighbor features
1558            let mut neighbor_sum = Array1::<Float>::zeros(node_embeddings.ncols());
1559
1560            for j in 0..n_nodes {
1561                if adjacency[[i, j]] == 1 && i != j {
1562                    neighbor_sum += &node_embeddings.row(j).to_owned();
1563                }
1564            }
1565
1566            // GIN update
1567            let updated = &node_embeddings.row(i).to_owned() * (1.0 + epsilon) + &neighbor_sum;
1568
1569            // Apply MLP
1570            let transformed = updated.dot(weights) + bias;
1571            let activated = if layer_idx == self.state.num_layers - 1 {
1572                transformed.mapv(|x| 1.0 / (1.0 + (-x).exp()))
1573            } else {
1574                transformed.mapv(|x| x.max(0.0))
1575            };
1576
1577            output.row_mut(i).assign(&activated);
1578        }
1579
1580        Ok(output)
1581    }
1582}
1583
1584// Tests for Graph Neural Networks
1585#[allow(non_snake_case)]
1586#[cfg(test)]
1587mod tests {
1588    use super::*;
1589    // Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
1590    use scirs2_core::ndarray::array;
1591
1592    #[test]
1593    fn test_gnn_basic_functionality() {
1594        let node_features = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0]];
1595        let adjacency = array![[0, 1, 0], [1, 0, 1], [0, 1, 0]];
1596        let node_labels = array![[1, 0], [0, 1], [1, 1]];
1597
1598        let gnn = GraphNeuralNetwork::new()
1599            .hidden_dim(4)
1600            .num_layers(2)
1601            .max_iter(5);
1602
1603        let trained_gnn = gnn
1604            .fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
1605            .expect("operation should succeed");
1606
1607        let predictions = trained_gnn
1608            .predict_graph(&adjacency.view(), &node_features.view())
1609            .expect("operation should succeed");
1610
1611        assert_eq!(predictions.dim(), (3, 2));
1612        assert!(predictions.iter().all(|&x| x == 0 || x == 1));
1613    }
1614
1615    #[test]
1616    fn test_gnn_different_variants() {
1617        let node_features = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0]];
1618        let adjacency = array![[0, 1, 0], [1, 0, 1], [0, 1, 0]];
1619        let node_labels = array![[1, 0], [0, 1], [1, 1]];
1620
1621        // Test GCN
1622        let gnn_gcn = GraphNeuralNetwork::new()
1623            .message_passing_variant(MessagePassingVariant::GCN)
1624            .max_iter(5);
1625        let trained_gcn = gnn_gcn
1626            .fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
1627            .expect("operation should succeed");
1628
1629        // Test GAT
1630        let gnn_gat = GraphNeuralNetwork::new()
1631            .message_passing_variant(MessagePassingVariant::GAT)
1632            .max_iter(5);
1633        let trained_gat = gnn_gat
1634            .fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
1635            .expect("operation should succeed");
1636
1637        // Test GraphSAGE
1638        let gnn_sage = GraphNeuralNetwork::new()
1639            .message_passing_variant(MessagePassingVariant::GraphSAGE)
1640            .max_iter(5);
1641        let trained_sage = gnn_sage
1642            .fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
1643            .expect("operation should succeed");
1644
1645        assert_eq!(
1646            trained_gcn.state.message_passing_variant,
1647            MessagePassingVariant::GCN
1648        );
1649        assert_eq!(
1650            trained_gat.state.message_passing_variant,
1651            MessagePassingVariant::GAT
1652        );
1653        assert_eq!(
1654            trained_sage.state.message_passing_variant,
1655            MessagePassingVariant::GraphSAGE
1656        );
1657    }
1658
1659    #[test]
1660    fn test_gnn_parameter_settings() {
1661        let gnn = GraphNeuralNetwork::new()
1662            .hidden_dim(16)
1663            .num_layers(3)
1664            .learning_rate(0.001)
1665            .max_iter(50)
1666            .dropout_rate(0.1);
1667
1668        assert_eq!(gnn.hidden_dim, 16);
1669        assert_eq!(gnn.num_layers, 3);
1670        assert!((gnn.learning_rate - 0.001).abs() < 1e-10);
1671        assert_eq!(gnn.max_iter, 50);
1672        assert!((gnn.dropout_rate - 0.1).abs() < 1e-10);
1673    }
1674
1675    #[test]
1676    fn test_gnn_default_settings() {
1677        let gnn = GraphNeuralNetwork::new();
1678
1679        assert_eq!(gnn.hidden_dim, 32);
1680        assert_eq!(gnn.num_layers, 2);
1681        assert_eq!(gnn.message_passing_variant, MessagePassingVariant::GCN);
1682        assert_eq!(gnn.aggregation_function, AggregationFunction::Mean);
1683    }
1684
1685    #[test]
1686    fn test_gnn_builder_pattern() {
1687        let gnn1 = GraphNeuralNetwork::new();
1688        let gnn2 = GraphNeuralNetwork::new();
1689
1690        assert_eq!(gnn1.hidden_dim, gnn2.hidden_dim);
1691        assert_eq!(gnn1.num_layers, gnn2.num_layers);
1692
1693        let gnn3 = GraphNeuralNetwork::new().max_iter(1);
1694        assert_eq!(gnn3.max_iter, 1);
1695    }
1696
1697    #[test]
1698    fn test_message_passing_variants() {
1699        assert_eq!(MessagePassingVariant::GCN, MessagePassingVariant::GCN);
1700        assert_ne!(MessagePassingVariant::GCN, MessagePassingVariant::GAT);
1701
1702        let variants = [
1703            MessagePassingVariant::GCN,
1704            MessagePassingVariant::GAT,
1705            MessagePassingVariant::GraphSAGE,
1706            MessagePassingVariant::GIN,
1707        ];
1708
1709        let gnn1 = GraphNeuralNetwork::new()
1710            .message_passing_variant(variants[0])
1711            .hidden_dim(8)
1712            .max_iter(3);
1713
1714        let gnn2 = GraphNeuralNetwork::new()
1715            .message_passing_variant(variants[1])
1716            .hidden_dim(8)
1717            .max_iter(3);
1718
1719        assert_eq!(gnn1.message_passing_variant, MessagePassingVariant::GCN);
1720        assert_eq!(gnn2.message_passing_variant, MessagePassingVariant::GAT);
1721    }
1722
1723    #[test]
1724    fn test_gnn_larger_graph() {
1725        let node_features = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0], [1.0, 3.0]];
1726        let adjacency = array![
1727            [0, 1, 1, 0, 0],
1728            [1, 0, 1, 1, 0],
1729            [1, 1, 0, 0, 1],
1730            [0, 1, 0, 0, 1],
1731            [0, 0, 1, 1, 0]
1732        ];
1733        let node_labels = array![[1, 0, 1], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 0]];
1734
1735        let gnn = GraphNeuralNetwork::new()
1736            .hidden_dim(10)
1737            .num_layers(2)
1738            .message_passing_variant(MessagePassingVariant::GCN)
1739            .max_iter(10);
1740
1741        let trained_gnn = gnn
1742            .fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
1743            .expect("operation should succeed");
1744
1745        let predictions = trained_gnn
1746            .predict_graph(&adjacency.view(), &node_features.view())
1747            .expect("operation should succeed");
1748
1749        assert_eq!(predictions.dim(), (5, 3));
1750        assert!(predictions.iter().all(|&x| x == 0 || x == 1));
1751        assert_eq!(trained_gnn.hidden_dim(), 10);
1752    }
1753
1754    #[test]
1755    fn test_aggregation_functions() {
1756        assert_ne!(AggregationFunction::Mean, AggregationFunction::Max);
1757        assert_eq!(AggregationFunction::Sum, AggregationFunction::Sum);
1758        assert_ne!(MessagePassingVariant::GraphSAGE, MessagePassingVariant::GIN);
1759    }
1760
1761    #[test]
1762    fn test_gnn_reproducibility() {
1763        let node_features = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0]];
1764        let adjacency = array![[0, 1, 0], [1, 0, 1], [0, 1, 0]];
1765        let node_labels = array![[1, 0], [0, 1], [1, 1]];
1766
1767        let gnn = GraphNeuralNetwork::new()
1768            .hidden_dim(4)
1769            .num_layers(2)
1770            .max_iter(5)
1771            .random_state(42);
1772
1773        let trained_gnn = gnn
1774            .fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
1775            .expect("operation should succeed");
1776
1777        let predictions = trained_gnn
1778            .predict_graph(&adjacency.view(), &node_features.view())
1779            .expect("operation should succeed");
1780
1781        assert_eq!(predictions.dim(), (3, 2));
1782    }
1783
1784    #[test]
1785    fn test_gnn_edge_cases() {
1786        let node_features = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0], [1.0, 3.0]];
1787        let adjacency = array![
1788            [0, 1, 1, 0, 0],
1789            [1, 0, 1, 1, 0],
1790            [1, 1, 0, 0, 1],
1791            [0, 1, 0, 0, 1],
1792            [0, 0, 1, 1, 0]
1793        ];
1794        let node_labels = array![[1, 0, 1], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 0]];
1795
1796        let gnn = GraphNeuralNetwork::new()
1797            .hidden_dim(10)
1798            .num_layers(2)
1799            .message_passing_variant(MessagePassingVariant::GCN)
1800            .max_iter(15)
1801            .random_state(42);
1802
1803        let trained_gnn = gnn
1804            .fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
1805            .expect("operation should succeed");
1806        let predictions = trained_gnn
1807            .predict_graph(&adjacency.view(), &node_features.view())
1808            .expect("operation should succeed");
1809
1810        assert_eq!(predictions.dim(), (5, 3));
1811        assert!(predictions.iter().all(|&x| x == 0 || x == 1));
1812        assert_eq!(trained_gnn.hidden_dim(), 10);
1813    }
1814}