Skip to main content

torsh_graph/
classification.rs

1//! Graph-level classification networks
2//!
3//! Implementation of complete graph classification architectures
4//! for various graph-level prediction tasks as specified in TODO.md
5// Framework infrastructure - components designed for future use
6#![allow(dead_code)]
7/// Crate-local result alias: the error type defaults to [`TorshError`],
8/// so both `Result<T>` and `Result<T, OtherError>` stay valid.
9type Result<T, E = torsh_core::error::TorshError> = std::result::Result<T, E>;
10
11use crate::conv::{GATConv, GCNConv, GINConv, SAGEConv};
12use crate::parameter::Parameter;
13use crate::pool::global::{global_max_pool, global_mean_pool, GlobalAttentionPool};
14use crate::{GraphData, GraphLayer};
15use torsh_tensor::{
16    creation::{randn, zeros},
17    Tensor,
18};
19
20/// Graph classification model trait
21pub trait GraphClassifier {
22    /// Forward pass for graph classification
23    /// Forward pass producing graph-level logits
24    ///
25    /// # Errors
26    /// Returns an error when the graph does not match the classifier's shapes.
27    fn forward(&self, graph: &GraphData) -> Result<Tensor>;
28
29    /// Get model parameters
30    fn parameters(&self) -> Vec<Tensor>;
31
32    /// Get number of output classes
33    fn num_classes(&self) -> usize;
34}
35
36/// Multi-layer Graph Convolutional Network for graph classification
37pub struct GraphClassificationGCN {
38    /// GCN layers
39    layers: Vec<GCNConv>,
40    /// Final classification layer
41    classifier: Parameter,
42    /// Bias for classifier
43    bias: Option<Parameter>,
44    /// Pooling type
45    pooling_type: PoolingType,
46    /// Global attention pool (if used)
47    attention_pool: Option<GlobalAttentionPool>,
48    /// Number of classes
49    num_classes: usize,
50    /// Dropout rate
51    dropout: f32,
52}
53
54/// Pooling strategies for graph-level representations
55#[derive(Debug, Clone)]
56pub enum PoolingType {
57    Mean,
58    Max,
59    Sum,
60    Attention,
61}
62
63impl GraphClassificationGCN {
64    /// Create new graph classification GCN
65    pub fn new(
66        layer_dims: Vec<usize>,
67        num_classes: usize,
68        pooling_type: PoolingType,
69        dropout: f32,
70    ) -> Result<Self> {
71        if layer_dims.len() < 2 {
72            return Err(torsh_core::error::TorshError::InvalidArgument(
73                "layer_dims needs at least input and output dimensions".to_string(),
74            ));
75        }
76
77        let mut layers = Vec::new();
78        for i in 0..layer_dims.len() - 1 {
79            layers.push(GCNConv::new(layer_dims[i], layer_dims[i + 1], true)?);
80        }
81
82        let final_dim = layer_dims.last().ok_or_else(|| {
83            torsh_core::error::TorshError::InvalidArgument(
84                "layer_dims must not be empty".to_string(),
85            )
86        })?;
87        let classifier = Parameter::new(randn(&[*final_dim, num_classes])?);
88        let bias = Some(Parameter::new(zeros(&[num_classes])?));
89
90        let attention_pool = if matches!(pooling_type, PoolingType::Attention) {
91            Some(GlobalAttentionPool::new(*final_dim, *final_dim)?)
92        } else {
93            None
94        };
95
96        Ok(Self {
97            layers,
98            classifier,
99            bias,
100            pooling_type,
101            attention_pool,
102            num_classes,
103            dropout,
104        })
105    }
106
107    /// Apply pooling to get graph-level representation
108    fn pool_graph(&self, graph: &GraphData) -> Result<Tensor> {
109        match &self.pooling_type {
110            PoolingType::Mean => global_mean_pool(graph),
111            PoolingType::Max => global_max_pool(graph),
112            PoolingType::Sum => {
113                // Sum pooling
114                let num_features = graph.x.shape().dims()[1];
115                let mut sum_features = zeros(&[num_features])?;
116
117                for node in 0..graph.num_nodes {
118                    let node_feat = graph.x.slice_tensor(0, node, node + 1)?.squeeze_tensor(0)?;
119                    sum_features = sum_features.add(&node_feat)?;
120                }
121
122                Ok(sum_features)
123            }
124            PoolingType::Attention => {
125                if let Some(ref pool) = self.attention_pool {
126                    pool.forward(graph)
127                } else {
128                    global_mean_pool(graph) // Fallback
129                }
130            }
131        }
132    }
133}
134
135impl GraphClassifier for GraphClassificationGCN {
136    fn forward(&self, graph: &GraphData) -> Result<Tensor> {
137        let mut current_graph = graph.clone();
138
139        // Forward through GCN layers
140        for (i, layer) in self.layers.iter().enumerate() {
141            current_graph = layer.forward(&current_graph)?;
142
143            // Apply ReLU activation (except for last layer)
144            if i < self.layers.len() - 1 {
145                let zero_tensor = zeros(current_graph.x.shape().dims())?;
146                current_graph.x = current_graph.x.maximum(&zero_tensor)?;
147
148                // Apply dropout (simplified - would need proper training/eval mode)
149                if self.dropout > 0.0 {
150                    // Placeholder for dropout implementation
151                }
152            }
153        }
154
155        // Pool to graph-level representation
156        let graph_embedding = self.pool_graph(&current_graph)?;
157
158        // Classification layer
159        // Ensure graph_embedding is 2D for matrix multiplication
160        let graph_embedding_2d = if graph_embedding.shape().dims().len() == 1 {
161            graph_embedding.unsqueeze_tensor(0)?
162        } else if graph_embedding.shape().dims().len() == 2 {
163            graph_embedding
164        } else {
165            // Flatten to 2D if more than 2 dimensions
166            let total_features = graph_embedding.shape().dims().iter().product::<usize>();
167            graph_embedding.view(&[1, total_features as i32])?
168        };
169
170        let mut logits = graph_embedding_2d.matmul(&self.classifier.clone_data())?;
171
172        // Squeeze to 1D if needed
173        if logits.shape().dims().len() == 2 && logits.shape().dims()[0] == 1 {
174            logits = logits.squeeze_tensor(0)?;
175        }
176
177        // Add bias
178        if let Some(ref bias) = self.bias {
179            logits = logits.add(&bias.clone_data())?;
180        }
181
182        Ok(logits)
183    }
184
185    fn parameters(&self) -> Vec<Tensor> {
186        let mut params = Vec::new();
187
188        // GCN layer parameters
189        for layer in &self.layers {
190            params.extend(layer.parameters());
191        }
192
193        // Classifier parameters
194        params.push(self.classifier.clone_data());
195        if let Some(ref bias) = self.bias {
196            params.push(bias.clone_data());
197        }
198
199        // Attention pool parameters
200        if let Some(ref pool) = self.attention_pool {
201            params.extend(pool.parameters());
202        }
203
204        params
205    }
206
207    fn num_classes(&self) -> usize {
208        self.num_classes
209    }
210}
211
212/// Graph Attention Network for classification
213pub struct GraphClassificationGAT {
214    /// GAT layers
215    gat_layers: Vec<GATConv>,
216    /// Final classification layers
217    classifier: Vec<Parameter>,
218    /// Pooling strategy
219    pooling_type: PoolingType,
220    /// Global attention pool
221    attention_pool: Option<GlobalAttentionPool>,
222    /// Number of classes
223    num_classes: usize,
224}
225
226impl GraphClassificationGAT {
227    pub fn new(
228        input_dim: usize,
229        hidden_dim: usize,
230        num_heads: usize,
231        num_layers: usize,
232        num_classes: usize,
233        dropout: f32,
234    ) -> Result<Self> {
235        let mut gat_layers = Vec::new();
236
237        // First layer
238        gat_layers.push(GATConv::new(
239            input_dim, hidden_dim, num_heads, dropout, true,
240        )?);
241
242        // Hidden layers
243        for _ in 1..num_layers {
244            gat_layers.push(GATConv::new(
245                hidden_dim * num_heads,
246                hidden_dim,
247                num_heads,
248                dropout,
249                true,
250            )?);
251        }
252
253        // Classifier
254        let final_dim = hidden_dim * num_heads;
255        let classifier = vec![
256            Parameter::new(randn(&[final_dim, final_dim / 2])?),
257            Parameter::new(randn(&[final_dim / 2, num_classes])?),
258        ];
259
260        Ok(Self {
261            gat_layers,
262            classifier,
263            pooling_type: PoolingType::Attention,
264            attention_pool: Some(GlobalAttentionPool::new(final_dim, final_dim)?),
265            num_classes,
266        })
267    }
268}
269
270impl GraphClassifier for GraphClassificationGAT {
271    fn forward(&self, graph: &GraphData) -> Result<Tensor> {
272        let mut current_graph = graph.clone();
273
274        // Forward through GAT layers
275        for (i, layer) in self.gat_layers.iter().enumerate() {
276            current_graph = layer.forward(&current_graph)?;
277
278            // Apply activation (except for last layer)
279            if i < self.gat_layers.len() - 1 {
280                let zero_tensor = zeros(current_graph.x.shape().dims())?;
281                current_graph.x = current_graph.x.maximum(&zero_tensor)?;
282            }
283        }
284
285        // Pool to graph-level representation
286        let graph_embedding = if let Some(ref pool) = self.attention_pool {
287            pool.forward(&current_graph)?
288        } else {
289            global_mean_pool(&current_graph)?
290        };
291
292        // Two-layer classifier
293        // Ensure graph_embedding is 2D for matrix multiplication
294        let graph_embedding_2d = if graph_embedding.shape().dims().len() == 1 {
295            graph_embedding.unsqueeze_tensor(0)?
296        } else if graph_embedding.shape().dims().len() == 2 {
297            graph_embedding
298        } else {
299            // Flatten to 2D if more than 2 dimensions
300            let total_features = graph_embedding.shape().dims().iter().product::<usize>();
301            graph_embedding.view(&[1, total_features as i32])?
302        };
303
304        let mut hidden = graph_embedding_2d.matmul(&self.classifier[0].clone_data())?;
305
306        // Squeeze to 1D if needed
307        if hidden.shape().dims().len() == 2 && hidden.shape().dims()[0] == 1 {
308            hidden = hidden.squeeze_tensor(0)?;
309        }
310
311        // ReLU activation
312        let zero_tensor = zeros(hidden.shape().dims())?;
313        hidden = hidden.maximum(&zero_tensor)?;
314
315        // Final classification
316        let hidden_2d = if hidden.shape().dims().len() == 1 {
317            hidden.unsqueeze_tensor(0)?
318        } else {
319            hidden
320        };
321
322        let mut logits = hidden_2d.matmul(&self.classifier[1].clone_data())?;
323
324        // Squeeze to 1D if needed
325        if logits.shape().dims().len() == 2 && logits.shape().dims()[0] == 1 {
326            logits = logits.squeeze_tensor(0)?;
327        }
328
329        Ok(logits)
330    }
331
332    fn parameters(&self) -> Vec<Tensor> {
333        let mut params = Vec::new();
334
335        // GAT layer parameters
336        for layer in &self.gat_layers {
337            params.extend(layer.parameters());
338        }
339
340        // Classifier parameters
341        for layer in &self.classifier {
342            params.push(layer.clone_data());
343        }
344
345        // Attention pool parameters
346        if let Some(ref pool) = self.attention_pool {
347            params.extend(pool.parameters());
348        }
349
350        params
351    }
352
353    fn num_classes(&self) -> usize {
354        self.num_classes
355    }
356}
357
358/// Hierarchical Graph Classification Network
359/// Uses multiple scales of graph representations
360pub struct HierarchicalGraphClassifier {
361    /// Local feature extractors
362    local_layers: Vec<GCNConv>,
363    /// Global context layers
364    global_layers: Vec<SAGEConv>,
365    /// Cross-scale attention
366    attention_weights: Parameter,
367    /// Final classifier
368    classifier: Parameter,
369    /// Number of classes
370    num_classes: usize,
371}
372
373impl HierarchicalGraphClassifier {
374    pub fn new(
375        input_dim: usize,
376        local_hidden: usize,
377        global_hidden: usize,
378        num_classes: usize,
379    ) -> Result<Self> {
380        // Local feature extraction
381        let local_layers = vec![
382            GCNConv::new(input_dim, local_hidden, true)?,
383            GCNConv::new(local_hidden, local_hidden, true)?,
384        ];
385
386        // Global context modeling
387        let global_layers = vec![
388            SAGEConv::new(input_dim, global_hidden, true)?,
389            SAGEConv::new(global_hidden, global_hidden, true)?,
390        ];
391
392        // Cross-scale attention
393        let combined_dim = local_hidden + global_hidden;
394        let attention_weights = Parameter::new(randn(&[combined_dim, 1])?);
395
396        // Final classifier
397        let classifier = Parameter::new(randn(&[combined_dim, num_classes])?);
398
399        Ok(Self {
400            local_layers,
401            global_layers,
402            attention_weights,
403            classifier,
404            num_classes,
405        })
406    }
407}
408
409impl GraphClassifier for HierarchicalGraphClassifier {
410    fn forward(&self, graph: &GraphData) -> Result<Tensor> {
411        // Local pathway
412        let mut local_graph = graph.clone();
413        for layer in &self.local_layers {
414            local_graph = layer.forward(&local_graph)?;
415            let zero_tensor = zeros(local_graph.x.shape().dims())?;
416            local_graph.x = local_graph.x.maximum(&zero_tensor)?;
417        }
418        let local_repr = global_mean_pool(&local_graph)?;
419
420        // Global pathway
421        let mut global_graph = graph.clone();
422        for layer in &self.global_layers {
423            global_graph = layer.forward(&global_graph)?;
424            let zero_tensor = zeros(global_graph.x.shape().dims())?;
425            global_graph.x = global_graph.x.maximum(&zero_tensor)?;
426        }
427        let global_repr = global_mean_pool(&global_graph)?;
428
429        // Combine representations
430        // Ensure both representations are 1D first, then expand to 2D
431        let local_1d = if local_repr.shape().dims().len() == 1 {
432            local_repr
433        } else {
434            let flat = local_repr.shape().dims().iter().product::<usize>() as i32;
435            local_repr.view(&[flat])?
436        };
437        let global_1d = if global_repr.shape().dims().len() == 1 {
438            global_repr
439        } else {
440            let flat = global_repr.shape().dims().iter().product::<usize>() as i32;
441            global_repr.view(&[flat])?
442        };
443
444        // Concatenate the 1D tensors along feature dimension, then expand to 2D
445        let combined_1d = Tensor::cat(&[&local_1d, &global_1d], 0)?;
446        let combined = combined_1d.unsqueeze_tensor(0)?;
447
448        // Apply cross-scale attention
449        // Ensure combined tensor is 2D for matrix multiplication
450        let combined_2d = if combined.shape().dims().len() == 1 {
451            combined.unsqueeze_tensor(0)?
452        } else {
453            combined.clone()
454        };
455
456        let mut attention_scores = combined_2d.matmul(&self.attention_weights.clone_data())?;
457
458        // Squeeze to appropriate dimensions
459        if attention_scores.shape().dims().len() == 2 && attention_scores.shape().dims()[1] == 1 {
460            attention_scores = attention_scores.squeeze_tensor(1)?;
461        }
462
463        // Softmax attention
464        let exp_scores = attention_scores.exp()?;
465        let sum_exp = exp_scores.sum()?;
466        let normalized_scores = exp_scores.div_scalar(sum_exp.to_vec()?[0])?;
467
468        // Weighted combination - ensure proper broadcasting
469        let scores_expanded = normalized_scores.unsqueeze_tensor(1)?;
470        let combined_shape = combined_2d.shape();
471        let scores_broadcasted = scores_expanded.expand(combined_shape.dims())?;
472        let attended = combined_2d.mul(&scores_broadcasted)?.sum_dim(&[0], false)?; // Sum along batch dimension, keep feature dimension
473
474        // Classification
475        let attended_2d = if attended.shape().dims().len() == 1 {
476            attended.unsqueeze_tensor(0)?
477        } else {
478            attended
479        };
480
481        let mut logits = attended_2d.matmul(&self.classifier.clone_data())?;
482
483        // Squeeze to 1D if needed
484        if logits.shape().dims().len() == 2 && logits.shape().dims()[0] == 1 {
485            logits = logits.squeeze_tensor(0)?;
486        }
487
488        Ok(logits)
489    }
490
491    fn parameters(&self) -> Vec<Tensor> {
492        let mut params = Vec::new();
493
494        // Local pathway parameters
495        for layer in &self.local_layers {
496            params.extend(layer.parameters());
497        }
498
499        // Global pathway parameters
500        for layer in &self.global_layers {
501            params.extend(layer.parameters());
502        }
503
504        // Attention and classifier parameters
505        params.push(self.attention_weights.clone_data());
506        params.push(self.classifier.clone_data());
507
508        params
509    }
510
511    fn num_classes(&self) -> usize {
512        self.num_classes
513    }
514}
515
516/// Graph-level regression for continuous targets
517pub struct GraphRegressor {
518    /// Feature extraction layers
519    feature_layers: Vec<GINConv>,
520    /// Regression head
521    regressor: Parameter,
522    /// Bias
523    bias: Option<Parameter>,
524    /// Output dimension (1 for scalar regression, >1 for multivariate)
525    output_dim: usize,
526}
527
528impl GraphRegressor {
529    pub fn new(layer_dims: Vec<usize>, output_dim: usize, bias: bool) -> Result<Self> {
530        let mut feature_layers = Vec::new();
531        for i in 0..layer_dims.len() - 1 {
532            feature_layers.push(GINConv::new(
533                layer_dims[i],
534                layer_dims[i + 1],
535                0.0,
536                false,
537                true,
538            )?);
539        }
540
541        let final_dim = layer_dims.last().ok_or_else(|| {
542            torsh_core::error::TorshError::InvalidArgument(
543                "layer_dims must not be empty".to_string(),
544            )
545        })?;
546        let regressor = Parameter::new(randn(&[*final_dim, output_dim])?);
547        let bias_param = if bias {
548            Some(Parameter::new(zeros(&[output_dim])?))
549        } else {
550            None
551        };
552
553        Ok(Self {
554            feature_layers,
555            regressor,
556            bias: bias_param,
557            output_dim,
558        })
559    }
560
561    pub fn forward(&self, graph: &GraphData) -> Result<Tensor> {
562        let mut current_graph = graph.clone();
563
564        // Forward through feature layers
565        for layer in &self.feature_layers {
566            current_graph = layer.forward(&current_graph)?;
567
568            // ReLU activation
569            let zero_tensor = zeros(current_graph.x.shape().dims())?;
570            current_graph.x = current_graph.x.maximum(&zero_tensor)?;
571        }
572
573        // Pool to graph-level representation
574        let graph_embedding = global_mean_pool(&current_graph)?;
575
576        // Regression
577        let graph_embedding_2d = if graph_embedding.shape().dims().len() == 1 {
578            graph_embedding.unsqueeze_tensor(0)?
579        } else {
580            graph_embedding
581        };
582
583        let mut output = graph_embedding_2d.matmul(&self.regressor.clone_data())?;
584
585        // Squeeze to 1D if needed
586        if output.shape().dims().len() == 2 && output.shape().dims()[0] == 1 {
587            output = output.squeeze_tensor(0)?;
588        }
589
590        // Add bias if present
591        if let Some(ref bias) = self.bias {
592            output = output.add(&bias.clone_data())?;
593        }
594
595        Ok(output)
596    }
597
598    pub fn parameters(&self) -> Vec<Tensor> {
599        let mut params = Vec::new();
600
601        // Feature layer parameters
602        for layer in &self.feature_layers {
603            params.extend(layer.parameters());
604        }
605
606        // Regression parameters
607        params.push(self.regressor.clone_data());
608        if let Some(ref bias) = self.bias {
609            params.push(bias.clone_data());
610        }
611
612        params
613    }
614}
615
616/// Multi-task graph learning for simultaneous classification and regression
617pub struct MultiTaskGraphNetwork {
618    /// Shared feature extractor
619    shared_layers: Vec<GCNConv>,
620    /// Classification head
621    classifier: Parameter,
622    /// Regression head
623    regressor: Parameter,
624    /// Task-specific biases
625    classification_bias: Parameter,
626    regression_bias: Parameter,
627    /// Dimensions
628    num_classes: usize,
629    regression_dim: usize,
630}
631
632impl MultiTaskGraphNetwork {
633    pub fn new(layer_dims: Vec<usize>, num_classes: usize, regression_dim: usize) -> Result<Self> {
634        let mut shared_layers = Vec::new();
635        for i in 0..layer_dims.len() - 1 {
636            shared_layers.push(GCNConv::new(layer_dims[i], layer_dims[i + 1], true)?);
637        }
638
639        let final_dim = layer_dims.last().ok_or_else(|| {
640            torsh_core::error::TorshError::InvalidArgument(
641                "layer_dims must not be empty".to_string(),
642            )
643        })?;
644
645        let classifier = Parameter::new(randn(&[*final_dim, num_classes])?);
646        let regressor = Parameter::new(randn(&[*final_dim, regression_dim])?);
647
648        let classification_bias = Parameter::new(zeros(&[num_classes])?);
649        let regression_bias = Parameter::new(zeros(&[regression_dim])?);
650
651        Ok(Self {
652            shared_layers,
653            classifier,
654            regressor,
655            classification_bias,
656            regression_bias,
657            num_classes,
658            regression_dim,
659        })
660    }
661
662    pub fn forward(&self, graph: &GraphData) -> Result<(Tensor, Tensor)> {
663        let mut current_graph = graph.clone();
664
665        // Forward through shared layers
666        for layer in &self.shared_layers {
667            current_graph = layer.forward(&current_graph)?;
668
669            let zero_tensor = zeros(current_graph.x.shape().dims())?;
670            current_graph.x = current_graph.x.maximum(&zero_tensor)?;
671        }
672
673        // Pool to graph-level representation
674        let graph_embedding = global_mean_pool(&current_graph)?;
675
676        // Classification output
677        let graph_embedding_2d = if graph_embedding.shape().dims().len() == 1 {
678            graph_embedding.unsqueeze_tensor(0)?
679        } else {
680            graph_embedding.clone()
681        };
682
683        let mut classification_logits = graph_embedding_2d.matmul(&self.classifier.clone_data())?;
684
685        // Squeeze to 1D if needed
686        if classification_logits.shape().dims().len() == 2
687            && classification_logits.shape().dims()[0] == 1
688        {
689            classification_logits = classification_logits.squeeze_tensor(0)?;
690        }
691
692        let classification_logits =
693            classification_logits.add(&self.classification_bias.clone_data())?;
694
695        // Regression output
696        let graph_embedding_2d_reg = if graph_embedding.shape().dims().len() == 1 {
697            graph_embedding.unsqueeze_tensor(0)?
698        } else {
699            graph_embedding
700        };
701
702        let mut regression_output = graph_embedding_2d_reg.matmul(&self.regressor.clone_data())?;
703
704        // Squeeze to 1D if needed
705        if regression_output.shape().dims().len() == 2 && regression_output.shape().dims()[0] == 1 {
706            regression_output = regression_output.squeeze_tensor(0)?;
707        }
708
709        let regression_output = regression_output.add(&self.regression_bias.clone_data())?;
710
711        Ok((classification_logits, regression_output))
712    }
713
714    pub fn parameters(&self) -> Vec<Tensor> {
715        let mut params = Vec::new();
716
717        // Shared layer parameters
718        for layer in &self.shared_layers {
719            params.extend(layer.parameters());
720        }
721
722        // Task-specific parameters
723        params.push(self.classifier.clone_data());
724        params.push(self.regressor.clone_data());
725        params.push(self.classification_bias.clone_data());
726        params.push(self.regression_bias.clone_data());
727
728        params
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use crate::scirs2_integration::generation;
736
737    #[test]
738    fn test_graph_classification_gcn() {
739        let graph = generation::erdos_renyi(10, 0.3).expect("operation should succeed");
740        let classifier = GraphClassificationGCN::new(
741            vec![16, 32, 16], // layer dimensions
742            3,                // num_classes
743            PoolingType::Mean,
744            0.1, // dropout
745        )
746        .expect("operation should succeed");
747
748        let logits = classifier
749            .forward(&graph)
750            .expect("operation should succeed");
751        assert_eq!(logits.shape().dims(), &[3]);
752
753        let params = classifier.parameters();
754        assert!(params.len() > 0);
755        assert_eq!(classifier.num_classes(), 3);
756    }
757
758    #[test]
759    fn test_different_pooling_strategies() {
760        let graph = generation::complete(5).expect("operation should succeed");
761        let pooling_types = vec![
762            PoolingType::Mean,
763            PoolingType::Max,
764            PoolingType::Sum,
765            PoolingType::Attention,
766        ];
767
768        for pooling in pooling_types {
769            let classifier = GraphClassificationGCN::new(vec![16, 8], 2, pooling, 0.0)
770                .expect("operation should succeed");
771
772            let logits = classifier
773                .forward(&graph)
774                .expect("operation should succeed");
775            assert_eq!(logits.shape().dims(), &[2]);
776
777            let logit_vals = logits.to_vec().expect("conversion should succeed");
778            assert!(logit_vals.iter().all(|&x| x.is_finite()));
779        }
780    }
781
782    #[test]
783    fn test_graph_regressor() {
784        let graph = generation::barabasi_albert(8, 2).expect("operation should succeed");
785        let regressor = GraphRegressor::new(
786            vec![16, 12, 8],
787            2, // bivariate regression
788            true,
789        )
790        .expect("operation should succeed");
791
792        let output = regressor.forward(&graph).expect("operation should succeed");
793        assert_eq!(output.shape().dims(), &[2]);
794
795        let output_vals = output.to_vec().expect("conversion should succeed");
796        assert!(output_vals.iter().all(|&x| x.is_finite()));
797    }
798
799    #[test]
800    fn test_hierarchical_classifier() {
801        let graph = generation::watts_strogatz(12, 4, 0.3).expect("operation should succeed");
802        let classifier = HierarchicalGraphClassifier::new(
803            16, // input_dim
804            8,  // local_hidden
805            8,  // global_hidden
806            4,  // num_classes
807        )
808        .expect("operation should succeed");
809
810        let logits = classifier
811            .forward(&graph)
812            .expect("operation should succeed");
813        assert_eq!(logits.shape().dims(), &[4]);
814
815        let params = classifier.parameters();
816        assert!(params.len() > 0);
817    }
818
819    #[test]
820    fn test_multi_task_network() {
821        let graph = generation::erdos_renyi(6, 0.5).expect("operation should succeed");
822        let multi_task = MultiTaskGraphNetwork::new(
823            vec![16, 12],
824            3, // classification classes
825            2, // regression dimensions
826        )
827        .expect("operation should succeed");
828
829        let (class_logits, reg_output) = multi_task
830            .forward(&graph)
831            .expect("operation should succeed");
832
833        assert_eq!(class_logits.shape().dims(), &[3]);
834        assert_eq!(reg_output.shape().dims(), &[2]);
835
836        let class_vals = class_logits.to_vec().expect("conversion should succeed");
837        let reg_vals = reg_output.to_vec().expect("conversion should succeed");
838
839        assert!(class_vals.iter().all(|&x| x.is_finite()));
840        assert!(reg_vals.iter().all(|&x| x.is_finite()));
841    }
842
843    #[test]
844    fn test_parameter_count_consistency() {
845        let graph = generation::complete(4).expect("operation should succeed");
846
847        // Test that parameter access is consistent
848        let classifiers: Vec<Box<dyn GraphClassifier>> = vec![Box::new(
849            GraphClassificationGCN::new(vec![16, 8], 2, PoolingType::Mean, 0.0)
850                .expect("operation should succeed"),
851        )];
852
853        for classifier in classifiers {
854            let params1 = classifier.parameters();
855            let params2 = classifier.parameters();
856
857            assert_eq!(params1.len(), params2.len());
858
859            // Test forward pass consistency
860            let logits1 = classifier
861                .forward(&graph)
862                .expect("operation should succeed");
863            let logits2 = classifier
864                .forward(&graph)
865                .expect("operation should succeed");
866
867            let vals1 = logits1.to_vec().expect("conversion should succeed");
868            let vals2 = logits2.to_vec().expect("conversion should succeed");
869
870            for (v1, v2) in vals1.iter().zip(vals2.iter()) {
871                assert!((v1 - v2).abs() < 1e-6);
872            }
873        }
874    }
875}