#![allow(non_snake_case)]
use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
use scirs2_core::random::thread_rng;
use scirs2_core::random::RandNormal;
use sklears_core::{
error::{Result as SklResult, SklearsError},
traits::{Estimator, Fit, Predict, Untrained},
types::Float,
};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum ConsistencyEnforcement {
#[default]
PostProcessing,
ConstrainedTraining,
BayesianInference,
}
#[derive(Debug, Clone)]
pub struct OntologyAwareClassifier<S = Untrained> {
state: S,
ontology: HashMap<usize, Vec<usize>>,
consistency_enforcement: ConsistencyEnforcement,
base_classifier_learning_rate: Float,
max_iterations: usize,
}
#[derive(Debug, Clone)]
pub struct OntologyAwareClassifierTrained {
weights: Array2<Float>,
biases: Array1<Float>,
ontology: HashMap<usize, Vec<usize>>,
consistency_enforcement: ConsistencyEnforcement,
n_features: usize,
n_labels: usize,
}
impl OntologyAwareClassifier<Untrained> {
pub fn new() -> Self {
Self {
state: Untrained,
ontology: HashMap::new(),
consistency_enforcement: ConsistencyEnforcement::PostProcessing,
base_classifier_learning_rate: 0.01,
max_iterations: 100,
}
}
pub fn ontology(mut self, ontology: HashMap<usize, Vec<usize>>) -> Self {
self.ontology = ontology;
self
}
pub fn consistency_enforcement(mut self, enforcement: ConsistencyEnforcement) -> Self {
self.consistency_enforcement = enforcement;
self
}
pub fn base_classifier_learning_rate(mut self, learning_rate: Float) -> Self {
self.base_classifier_learning_rate = learning_rate;
self
}
pub fn max_iterations(mut self, max_iterations: usize) -> Self {
self.max_iterations = max_iterations;
self
}
}
impl Default for OntologyAwareClassifier<Untrained> {
fn default() -> Self {
Self::new()
}
}
impl Estimator for OntologyAwareClassifier<Untrained> {
type Config = ();
type Error = SklearsError;
type Float = Float;
fn config(&self) -> &Self::Config {
&()
}
}
impl Fit<ArrayView2<'_, Float>, Array2<i32>> for OntologyAwareClassifier<Untrained> {
type Fitted = OntologyAwareClassifier<OntologyAwareClassifierTrained>;
fn fit(
self,
X: &ArrayView2<'_, Float>,
y: &Array2<i32>,
) -> SklResult<OntologyAwareClassifier<OntologyAwareClassifierTrained>> {
let (n_samples, n_features) = X.dim();
let n_labels = y.ncols();
if n_samples != y.nrows() {
return Err(SklearsError::InvalidInput(
"X and y must have the same number of samples".to_string(),
));
}
let mut weights = Array2::<Float>::zeros((n_features, n_labels));
let mut biases = Array1::<Float>::zeros(n_labels);
for iteration in 0..self.max_iterations {
let mut total_loss = 0.0;
for sample_idx in 0..n_samples {
let x = X.row(sample_idx);
let y_true = y.row(sample_idx);
let logits = x.dot(&weights) + &biases;
let probabilities = logits.mapv(|x| 1.0 / (1.0 + (-x).exp()));
let consistent_probabilities = match self.consistency_enforcement {
ConsistencyEnforcement::ConstrainedTraining => {
self.enforce_consistency_training(&probabilities)?
}
_ => probabilities.clone(),
};
for label_idx in 0..n_labels {
let y_label = y_true[label_idx] as Float;
let prob = consistent_probabilities[label_idx];
let error = prob - y_label;
total_loss += if y_label == 1.0 {
-prob.ln()
} else {
-(1.0 - prob).ln()
};
for feat_idx in 0..n_features {
weights[[feat_idx, label_idx]] -=
self.base_classifier_learning_rate * error * x[feat_idx];
}
biases[label_idx] -= self.base_classifier_learning_rate * error;
}
}
if iteration > 0 && total_loss < 1e-6 {
break;
}
}
Ok(OntologyAwareClassifier {
state: OntologyAwareClassifierTrained {
weights,
biases,
ontology: self.ontology,
consistency_enforcement: self.consistency_enforcement,
n_features,
n_labels,
},
ontology: HashMap::new(),
consistency_enforcement: self.consistency_enforcement,
base_classifier_learning_rate: self.base_classifier_learning_rate,
max_iterations: self.max_iterations,
})
}
}
impl OntologyAwareClassifier<Untrained> {
fn enforce_consistency_training(
&self,
probabilities: &Array1<Float>,
) -> SklResult<Array1<Float>> {
let mut consistent_probs = probabilities.clone();
for (&child, parents) in &self.ontology {
if child < probabilities.len() {
for &parent in parents {
if parent < probabilities.len() {
let child_prob = probabilities[child];
if consistent_probs[parent] < child_prob {
consistent_probs[parent] = child_prob;
}
}
}
}
}
Ok(consistent_probs)
}
}
impl Predict<ArrayView2<'_, Float>, Array2<i32>>
for OntologyAwareClassifier<OntologyAwareClassifierTrained>
{
fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<i32>> {
let (n_samples, n_features) = X.dim();
if n_features != self.state.n_features {
return Err(SklearsError::InvalidInput(
"X has different number of features than training data".to_string(),
));
}
let mut predictions = Array2::<i32>::zeros((n_samples, self.state.n_labels));
for sample_idx in 0..n_samples {
let x = X.row(sample_idx);
let logits = x.dot(&self.state.weights) + &self.state.biases;
let probabilities = logits.mapv(|x| 1.0 / (1.0 + (-x).exp()));
let consistent_probs = match self.state.consistency_enforcement {
ConsistencyEnforcement::PostProcessing => {
self.enforce_consistency_postprocessing(&probabilities)?
}
ConsistencyEnforcement::BayesianInference => {
self.enforce_consistency_bayesian(&probabilities)?
}
_ => probabilities,
};
for label_idx in 0..self.state.n_labels {
predictions[[sample_idx, label_idx]] = if consistent_probs[label_idx] > 0.5 {
1
} else {
0
};
}
}
Ok(predictions)
}
}
impl OntologyAwareClassifier<OntologyAwareClassifierTrained> {
pub fn weights(&self) -> &Array2<Float> {
&self.state.weights
}
pub fn biases(&self) -> &Array1<Float> {
&self.state.biases
}
pub fn ontology(&self) -> &HashMap<usize, Vec<usize>> {
&self.state.ontology
}
fn enforce_consistency_postprocessing(
&self,
probabilities: &Array1<Float>,
) -> SklResult<Array1<Float>> {
let mut consistent_probs = probabilities.clone();
for (&child, parents) in &self.state.ontology {
if child < probabilities.len() && probabilities[child] > 0.5 {
for &parent in parents {
if parent < probabilities.len() {
consistent_probs[parent] =
consistent_probs[parent].max(probabilities[child]);
}
}
}
}
Ok(consistent_probs)
}
fn enforce_consistency_bayesian(
&self,
probabilities: &Array1<Float>,
) -> SklResult<Array1<Float>> {
let mut consistent_probs = probabilities.clone();
for (&child, parents) in &self.state.ontology {
if child < probabilities.len() {
let child_prob = probabilities[child];
for &parent in parents {
if parent < probabilities.len() {
consistent_probs[parent] = consistent_probs[parent].max(child_prob * 0.8);
}
}
}
}
Ok(consistent_probs)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum CostStrategy {
#[default]
Uniform,
DistanceBased,
Custom,
}
#[derive(Debug, Clone)]
pub struct CostSensitiveHierarchicalClassifier<S = Untrained> {
state: S,
hierarchy: HashMap<usize, Vec<usize>>,
cost_strategy: CostStrategy,
cost_matrix: Option<Array2<Float>>,
learning_rate: Float,
max_iterations: usize,
lambda_hierarchy: Float,
lambda_cost: Float,
}
#[derive(Debug, Clone)]
pub struct CostSensitiveHierarchicalClassifierTrained {
weights: Array2<Float>,
hierarchy: HashMap<usize, Vec<usize>>,
#[allow(dead_code)]
cost_strategy: CostStrategy,
cost_matrix: Option<Array2<Float>>,
n_features: usize,
n_labels: usize,
#[allow(dead_code)]
lambda_hierarchy: Float,
#[allow(dead_code)]
lambda_cost: Float,
}
impl CostSensitiveHierarchicalClassifier<Untrained> {
pub fn new() -> Self {
Self {
state: Untrained,
hierarchy: HashMap::new(),
cost_strategy: CostStrategy::Uniform,
cost_matrix: None,
learning_rate: 0.01,
max_iterations: 100,
lambda_hierarchy: 1.0,
lambda_cost: 1.0,
}
}
pub fn hierarchy(mut self, hierarchy: HashMap<usize, Vec<usize>>) -> Self {
self.hierarchy = hierarchy;
self
}
pub fn cost_strategy(mut self, strategy: CostStrategy) -> Self {
self.cost_strategy = strategy;
self
}
pub fn cost_matrix(mut self, cost_matrix: Array2<Float>) -> Self {
self.cost_matrix = Some(cost_matrix);
self
}
pub fn learning_rate(mut self, learning_rate: Float) -> Self {
self.learning_rate = learning_rate;
self
}
pub fn max_iterations(mut self, max_iterations: usize) -> Self {
self.max_iterations = max_iterations;
self
}
pub fn lambda_hierarchy(mut self, lambda: Float) -> Self {
self.lambda_hierarchy = lambda;
self
}
pub fn lambda_cost(mut self, lambda: Float) -> Self {
self.lambda_cost = lambda;
self
}
}
impl Default for CostSensitiveHierarchicalClassifier<Untrained> {
fn default() -> Self {
Self::new()
}
}
impl Estimator for CostSensitiveHierarchicalClassifier<Untrained> {
type Config = ();
type Error = SklearsError;
type Float = Float;
fn config(&self) -> &Self::Config {
&()
}
}
impl Fit<ArrayView2<'_, Float>, Array2<i32>> for CostSensitiveHierarchicalClassifier<Untrained> {
type Fitted = CostSensitiveHierarchicalClassifier<CostSensitiveHierarchicalClassifierTrained>;
fn fit(
self,
X: &ArrayView2<'_, Float>,
y: &Array2<i32>,
) -> SklResult<CostSensitiveHierarchicalClassifier<CostSensitiveHierarchicalClassifierTrained>>
{
let (n_samples, n_features) = X.dim();
let n_labels = y.ncols();
if n_samples != y.nrows() {
return Err(SklearsError::InvalidInput(
"X and y must have the same number of samples".to_string(),
));
}
let cost_matrix = match &self.cost_matrix {
Some(matrix) => matrix.clone(),
None => self.generate_cost_matrix(n_labels)?,
};
let mut weights = Array2::<Float>::zeros((n_features, n_labels));
for _iteration in 0..self.max_iterations {
for sample_idx in 0..n_samples {
let x = X.row(sample_idx);
let y_true = y.row(sample_idx);
let scores = x.dot(&weights);
let probabilities = scores.mapv(|x| 1.0 / (1.0 + (-x).exp()));
for label_idx in 0..n_labels {
let y_label = y_true[label_idx] as Float;
let prob = probabilities[label_idx];
let mut gradient = prob - y_label;
let cost_weight = cost_matrix[[label_idx, label_idx]];
gradient *= cost_weight * self.lambda_cost;
gradient += self.lambda_hierarchy
* self.hierarchical_gradient(label_idx, &probabilities, &y_true)?;
for feat_idx in 0..n_features {
weights[[feat_idx, label_idx]] -=
self.learning_rate * gradient * x[feat_idx];
}
}
}
}
Ok(CostSensitiveHierarchicalClassifier {
state: CostSensitiveHierarchicalClassifierTrained {
weights,
hierarchy: self.hierarchy,
cost_strategy: self.cost_strategy,
cost_matrix: Some(cost_matrix),
n_features,
n_labels,
lambda_hierarchy: self.lambda_hierarchy,
lambda_cost: self.lambda_cost,
},
hierarchy: HashMap::new(),
cost_strategy: self.cost_strategy,
cost_matrix: None,
learning_rate: self.learning_rate,
max_iterations: self.max_iterations,
lambda_hierarchy: self.lambda_hierarchy,
lambda_cost: self.lambda_cost,
})
}
}
impl CostSensitiveHierarchicalClassifier<Untrained> {
fn generate_cost_matrix(&self, n_labels: usize) -> SklResult<Array2<Float>> {
match self.cost_strategy {
CostStrategy::Uniform => Ok(Array2::eye(n_labels)),
CostStrategy::DistanceBased => {
let mut cost_matrix = Array2::<Float>::zeros((n_labels, n_labels));
for i in 0..n_labels {
for j in 0..n_labels {
cost_matrix[[i, j]] = if i == j { 1.0 } else { 0.5 };
}
}
Ok(cost_matrix)
}
CostStrategy::Custom => Err(SklearsError::InvalidInput(
"Custom cost strategy requires a cost matrix".to_string(),
)),
}
}
fn hierarchical_gradient(
&self,
label_idx: usize,
probabilities: &Array1<Float>,
y_true: &ArrayView1<i32>,
) -> SklResult<Float> {
let mut gradient = 0.0;
if let Some(children) = self.hierarchy.get(&label_idx) {
for &child in children {
if child < probabilities.len() {
let parent_prob = probabilities[label_idx];
let child_prob = probabilities[child];
let child_true = y_true[child] as Float;
if child_true > 0.5 && child_prob > parent_prob {
gradient += child_prob - parent_prob;
}
}
}
}
for (&parent, children) in &self.hierarchy {
if children.contains(&label_idx) && parent < probabilities.len() {
let parent_prob = probabilities[parent];
let child_prob = probabilities[label_idx];
let label_true = y_true[label_idx] as Float;
if label_true > 0.5 && child_prob > parent_prob {
gradient -= child_prob - parent_prob;
}
}
}
Ok(gradient)
}
}
impl Predict<ArrayView2<'_, Float>, Array2<i32>>
for CostSensitiveHierarchicalClassifier<CostSensitiveHierarchicalClassifierTrained>
{
fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<i32>> {
let (n_samples, n_features) = X.dim();
if n_features != self.state.n_features {
return Err(SklearsError::InvalidInput(
"X has different number of features than training data".to_string(),
));
}
let mut predictions = Array2::<i32>::zeros((n_samples, self.state.n_labels));
for sample_idx in 0..n_samples {
let x = X.row(sample_idx);
let scores = x.dot(&self.state.weights);
let probabilities = scores.mapv(|x| 1.0 / (1.0 + (-x).exp()));
let final_predictions = self.apply_constraints(&probabilities)?;
for label_idx in 0..self.state.n_labels {
predictions[[sample_idx, label_idx]] = final_predictions[label_idx];
}
}
Ok(predictions)
}
}
impl CostSensitiveHierarchicalClassifier<CostSensitiveHierarchicalClassifierTrained> {
pub fn weights(&self) -> &Array2<Float> {
&self.state.weights
}
pub fn cost_matrix(&self) -> Option<&Array2<Float>> {
self.state.cost_matrix.as_ref()
}
fn apply_constraints(&self, probabilities: &Array1<Float>) -> SklResult<Array1<i32>> {
let mut binary_predictions = Array1::<i32>::zeros(probabilities.len());
for i in 0..probabilities.len() {
let threshold = if let Some(cost_matrix) = &self.state.cost_matrix {
let cost = cost_matrix[[i, i]];
0.5 / cost.max(0.1) } else {
0.5
};
binary_predictions[i] = if probabilities[i] > threshold { 1 } else { 0 };
}
for (&parent, children) in &self.state.hierarchy {
if parent < binary_predictions.len() {
let mut any_child_predicted = false;
for &child in children {
if child < binary_predictions.len() && binary_predictions[child] == 1 {
any_child_predicted = true;
break;
}
}
if any_child_predicted {
binary_predictions[parent] = 1;
}
}
}
Ok(binary_predictions)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AggregationFunction {
Mean,
Sum,
Max,
Attention,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MessagePassingVariant {
GCN,
GAT,
GraphSAGE,
GIN,
}
#[derive(Debug, Clone)]
pub struct GraphNeuralNetwork<S = Untrained> {
state: S,
hidden_dim: usize,
num_layers: usize,
message_passing_variant: MessagePassingVariant,
aggregation_function: AggregationFunction,
learning_rate: Float,
max_iter: usize,
dropout_rate: Float,
random_state: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct GraphNeuralNetworkTrained {
layer_weights: Vec<Array2<Float>>,
layer_biases: Vec<Array1<Float>>,
attention_weights: Option<Vec<Array2<Float>>>,
hidden_dim: usize,
num_layers: usize,
message_passing_variant: MessagePassingVariant,
#[allow(dead_code)]
aggregation_function: AggregationFunction,
n_features: usize,
#[allow(dead_code)]
n_outputs: usize,
#[allow(dead_code)]
dropout_rate: Float,
}
impl GraphNeuralNetwork<Untrained> {
pub fn new() -> Self {
Self {
state: Untrained,
hidden_dim: 32,
num_layers: 2,
message_passing_variant: MessagePassingVariant::GCN,
aggregation_function: AggregationFunction::Mean,
learning_rate: 0.01,
max_iter: 100,
dropout_rate: 0.0,
random_state: None,
}
}
pub fn hidden_dim(mut self, hidden_dim: usize) -> Self {
self.hidden_dim = hidden_dim;
self
}
pub fn num_layers(mut self, num_layers: usize) -> Self {
self.num_layers = num_layers;
self
}
pub fn message_passing_variant(mut self, variant: MessagePassingVariant) -> Self {
self.message_passing_variant = variant;
self
}
pub fn aggregation_function(mut self, function: AggregationFunction) -> Self {
self.aggregation_function = function;
self
}
pub fn learning_rate(mut self, learning_rate: Float) -> Self {
self.learning_rate = learning_rate;
self
}
pub fn max_iter(mut self, max_iter: usize) -> Self {
self.max_iter = max_iter;
self
}
pub fn dropout_rate(mut self, dropout_rate: Float) -> Self {
self.dropout_rate = dropout_rate;
self
}
pub fn random_state(mut self, random_state: u64) -> Self {
self.random_state = Some(random_state);
self
}
}
impl Default for GraphNeuralNetwork<Untrained> {
fn default() -> Self {
Self::new()
}
}
impl Estimator for GraphNeuralNetwork<Untrained> {
type Config = ();
type Error = SklearsError;
type Float = Float;
fn config(&self) -> &Self::Config {
&()
}
}
impl GraphNeuralNetwork<Untrained> {
pub fn fit_graph(
self,
adjacency: &ArrayView2<'_, i32>,
node_features: &ArrayView2<'_, Float>,
node_labels: &Array2<i32>,
) -> SklResult<GraphNeuralNetwork<GraphNeuralNetworkTrained>> {
let (n_nodes, n_features) = node_features.dim();
let n_outputs = node_labels.ncols();
if adjacency.dim() != (n_nodes, n_nodes) {
return Err(SklearsError::InvalidInput(
"Adjacency matrix must be n_nodes x n_nodes".to_string(),
));
}
if node_labels.nrows() != n_nodes {
return Err(SklearsError::InvalidInput(
"Node labels must have same number of rows as nodes".to_string(),
));
}
let mut rng_instance = thread_rng();
let (layer_weights, layer_biases, attention_weights) =
self.initialize_gnn_parameters(n_features, n_outputs, &mut rng_instance)?;
let mut weights = layer_weights;
let biases = layer_biases;
let attention_weights = attention_weights;
for _iteration in 0..self.max_iter {
let (node_embeddings, _) = self.forward_pass_graph(
adjacency,
node_features,
&weights,
&biases,
&attention_weights,
)?;
let _predictions = node_embeddings.mapv(|x| if x > 0.0 { 1 } else { 0 });
for weight in &mut weights {
for i in 0..weight.nrows() {
for j in 0..weight.ncols() {
weight[[i, j]] *= 0.999; }
}
}
}
let trained_state = GraphNeuralNetworkTrained {
layer_weights: weights,
layer_biases: biases,
attention_weights,
hidden_dim: self.hidden_dim,
num_layers: self.num_layers,
message_passing_variant: self.message_passing_variant,
aggregation_function: self.aggregation_function,
n_features,
n_outputs,
dropout_rate: self.dropout_rate,
};
Ok(GraphNeuralNetwork {
state: trained_state,
hidden_dim: self.hidden_dim,
num_layers: self.num_layers,
message_passing_variant: self.message_passing_variant,
aggregation_function: self.aggregation_function,
learning_rate: self.learning_rate,
max_iter: self.max_iter,
dropout_rate: self.dropout_rate,
random_state: self.random_state,
})
}
#[allow(clippy::type_complexity)]
fn initialize_gnn_parameters(
&self,
n_features: usize,
n_outputs: usize,
rng: &mut scirs2_core::random::CoreRandom,
) -> SklResult<(
Vec<Array2<Float>>,
Vec<Array1<Float>>,
Option<Vec<Array2<Float>>>,
)> {
let mut layer_weights = Vec::new();
let mut layer_biases = Vec::new();
let mut attention_weights = None;
let input_dim = match self.message_passing_variant {
MessagePassingVariant::GraphSAGE => n_features * 2, _ => n_features,
};
let hidden_dim = match self.message_passing_variant {
MessagePassingVariant::GraphSAGE => self.hidden_dim * 2, _ => self.hidden_dim,
};
for layer_idx in 0..self.num_layers {
let (in_dim, out_dim) = if layer_idx == 0 {
(input_dim, self.hidden_dim)
} else if layer_idx == self.num_layers - 1 {
(hidden_dim, n_outputs)
} else {
(hidden_dim, self.hidden_dim)
};
let normal_dist = RandNormal::new(0.0, (2.0 / in_dim as Float).sqrt())
.expect("operation should succeed");
let mut input_weight = Array2::<Float>::zeros((in_dim, out_dim));
for i in 0..in_dim {
for j in 0..out_dim {
input_weight[[i, j]] = rng.sample(normal_dist);
}
}
let bias = Array1::<Float>::zeros(out_dim);
layer_weights.push(input_weight);
layer_biases.push(bias);
}
if self.message_passing_variant == MessagePassingVariant::GAT {
let mut att_weights = Vec::new();
for layer_idx in 0..self.num_layers {
let att_dim = if layer_idx == 0 {
n_features
} else {
self.hidden_dim
};
let att_normal_dist = RandNormal::new(0.0, 0.1).expect("operation should succeed");
let mut attention_weight = Array2::<Float>::zeros((att_dim * 2, 1));
for i in 0..(att_dim * 2) {
attention_weight[[i, 0]] = rng.sample(att_normal_dist);
}
att_weights.push(attention_weight);
}
attention_weights = Some(att_weights);
}
Ok((layer_weights, layer_biases, attention_weights))
}
fn forward_pass_graph(
&self,
adjacency: &ArrayView2<'_, i32>,
node_features: &ArrayView2<'_, Float>,
weights: &[Array2<Float>],
biases: &[Array1<Float>],
attention_weights: &Option<Vec<Array2<Float>>>,
) -> SklResult<(Array2<Float>, Vec<Array2<Float>>)> {
let _n_nodes = node_features.nrows();
let mut current_embeddings = node_features.to_owned();
let mut layer_outputs = Vec::new();
for layer_idx in 0..self.num_layers {
let layer_output = match self.message_passing_variant {
MessagePassingVariant::GCN => self.gcn_layer(
¤t_embeddings,
adjacency,
&weights[layer_idx],
&biases[layer_idx],
)?,
MessagePassingVariant::GAT => {
let att_weights = attention_weights
.as_ref()
.expect("operation should succeed");
self.gat_layer(
¤t_embeddings,
adjacency,
&weights[layer_idx],
&biases[layer_idx],
&att_weights[layer_idx],
)?
}
MessagePassingVariant::GraphSAGE => self.graphsage_layer(
¤t_embeddings,
adjacency,
&weights[layer_idx],
&biases[layer_idx],
)?,
MessagePassingVariant::GIN => self.gin_layer(
¤t_embeddings,
adjacency,
&weights[layer_idx],
&biases[layer_idx],
)?,
};
current_embeddings = layer_output.clone();
layer_outputs.push(layer_output);
}
Ok((current_embeddings, layer_outputs))
}
fn gcn_layer(
&self,
node_embeddings: &Array2<Float>,
adjacency: &ArrayView2<'_, i32>,
weights: &Array2<Float>,
bias: &Array1<Float>,
) -> SklResult<Array2<Float>> {
let n_nodes = node_embeddings.nrows();
let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
for i in 0..n_nodes {
let mut aggregated = Array1::<Float>::zeros(node_embeddings.ncols());
let mut degree = 0;
for j in 0..n_nodes {
if adjacency[[i, j]] == 1 {
aggregated += &node_embeddings.row(j).to_owned();
degree += 1;
}
}
aggregated += &node_embeddings.row(i).to_owned();
degree += 1;
if degree > 0 {
aggregated /= degree as Float;
}
let transformed = aggregated.dot(weights) + bias;
let activated = transformed.mapv(|x| x.max(0.0));
output.row_mut(i).assign(&activated);
}
Ok(output)
}
fn gat_layer(
&self,
node_embeddings: &Array2<Float>,
adjacency: &ArrayView2<'_, i32>,
weights: &Array2<Float>,
bias: &Array1<Float>,
attention_weights: &Array2<Float>,
) -> SklResult<Array2<Float>> {
let n_nodes = node_embeddings.nrows();
let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
for i in 0..n_nodes {
let mut attention_scores = Array1::<Float>::zeros(n_nodes);
let mut valid_neighbors = Vec::new();
for j in 0..n_nodes {
if adjacency[[i, j]] == 1 || i == j {
let concat_features = Array1::from_iter(
node_embeddings
.row(i)
.iter()
.chain(node_embeddings.row(j).iter())
.cloned(),
);
if concat_features.len() == attention_weights.nrows() {
let score = concat_features.dot(&attention_weights.column(0));
attention_scores[j] = score.exp();
valid_neighbors.push(j);
}
}
}
let total_attention: Float = valid_neighbors.iter().map(|&j| attention_scores[j]).sum();
if total_attention > 0.0 {
for &j in &valid_neighbors {
attention_scores[j] /= total_attention;
}
}
let mut aggregated = Array1::<Float>::zeros(node_embeddings.ncols());
for &j in &valid_neighbors {
let weighted_features = &node_embeddings.row(j).to_owned() * attention_scores[j];
aggregated += &weighted_features;
}
let transformed = aggregated.dot(weights) + bias;
let activated = transformed.mapv(|x| x.max(0.0));
output.row_mut(i).assign(&activated);
}
Ok(output)
}
fn graphsage_layer(
&self,
node_embeddings: &Array2<Float>,
adjacency: &ArrayView2<'_, i32>,
weights: &Array2<Float>,
bias: &Array1<Float>,
) -> SklResult<Array2<Float>> {
let n_nodes = node_embeddings.nrows();
let embedding_dim = node_embeddings.ncols();
let output_dim = weights.ncols();
let mut output = Array2::<Float>::zeros((n_nodes, output_dim));
for i in 0..n_nodes {
let mut neighbor_sum = Array1::<Float>::zeros(embedding_dim);
let mut neighbor_count = 0;
for j in 0..n_nodes {
if adjacency[[i, j]] == 1 && i != j {
neighbor_sum += &node_embeddings.row(j).to_owned();
neighbor_count += 1;
}
}
if neighbor_count > 0 {
neighbor_sum /= neighbor_count as Float;
}
let self_features = node_embeddings.row(i).to_owned();
let concatenated =
Array1::from_iter(self_features.iter().chain(neighbor_sum.iter()).cloned());
if concatenated.len() == weights.nrows() {
let transformed = concatenated.dot(weights) + bias;
let activated = transformed.mapv(|x| x.max(0.0)); output.row_mut(i).assign(&activated);
}
}
Ok(output)
}
fn gin_layer(
&self,
node_embeddings: &Array2<Float>,
adjacency: &ArrayView2<'_, i32>,
weights: &Array2<Float>,
bias: &Array1<Float>,
) -> SklResult<Array2<Float>> {
let n_nodes = node_embeddings.nrows();
let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
let epsilon = 0.0;
for i in 0..n_nodes {
let mut neighbor_sum = Array1::<Float>::zeros(node_embeddings.ncols());
for j in 0..n_nodes {
if adjacency[[i, j]] == 1 && i != j {
neighbor_sum += &node_embeddings.row(j).to_owned();
}
}
let updated = &node_embeddings.row(i).to_owned() * (1.0 + epsilon) + &neighbor_sum;
let transformed = updated.dot(weights) + bias;
let activated = transformed.mapv(|x| x.max(0.0));
output.row_mut(i).assign(&activated);
}
Ok(output)
}
}
impl GraphNeuralNetwork<GraphNeuralNetworkTrained> {
pub fn predict_graph(
&self,
adjacency: &ArrayView2<'_, i32>,
node_features: &ArrayView2<'_, Float>,
) -> SklResult<Array2<i32>> {
let (n_nodes, n_features) = node_features.dim();
if n_features != self.state.n_features {
return Err(SklearsError::InvalidInput(
"Node features have different dimensionality than training data".to_string(),
));
}
if adjacency.dim() != (n_nodes, n_nodes) {
return Err(SklearsError::InvalidInput(
"Adjacency matrix must be n_nodes x n_nodes".to_string(),
));
}
let (final_embeddings, _) = self.forward_pass_trained(adjacency, node_features)?;
let predictions = final_embeddings.mapv(|x| if x > 0.0 { 1 } else { 0 });
Ok(predictions)
}
pub fn hidden_dim(&self) -> usize {
self.state.hidden_dim
}
pub fn num_layers(&self) -> usize {
self.state.num_layers
}
fn forward_pass_trained(
&self,
adjacency: &ArrayView2<'_, i32>,
node_features: &ArrayView2<'_, Float>,
) -> SklResult<(Array2<Float>, Vec<Array2<Float>>)> {
let _n_nodes = node_features.nrows();
let mut current_embeddings = node_features.to_owned();
let mut layer_outputs = Vec::new();
for layer_idx in 0..self.state.num_layers {
let layer_output = match self.state.message_passing_variant {
MessagePassingVariant::GCN => {
self.gcn_layer_trained(¤t_embeddings, adjacency, layer_idx)?
}
MessagePassingVariant::GAT => {
self.gat_layer_trained(¤t_embeddings, adjacency, layer_idx)?
}
MessagePassingVariant::GraphSAGE => {
self.graphsage_layer_trained(¤t_embeddings, adjacency, layer_idx)?
}
MessagePassingVariant::GIN => {
self.gin_layer_trained(¤t_embeddings, adjacency, layer_idx)?
}
};
current_embeddings = layer_output.clone();
layer_outputs.push(layer_output);
}
Ok((current_embeddings, layer_outputs))
}
fn gcn_layer_trained(
&self,
node_embeddings: &Array2<Float>,
adjacency: &ArrayView2<'_, i32>,
layer_idx: usize,
) -> SklResult<Array2<Float>> {
let weights = &self.state.layer_weights[layer_idx];
let bias = &self.state.layer_biases[layer_idx];
let n_nodes = node_embeddings.nrows();
let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
for i in 0..n_nodes {
let mut aggregated = Array1::<Float>::zeros(node_embeddings.ncols());
let mut degree = 0;
for j in 0..n_nodes {
if adjacency[[i, j]] == 1 {
aggregated += &node_embeddings.row(j).to_owned();
degree += 1;
}
}
aggregated += &node_embeddings.row(i).to_owned();
degree += 1;
if degree > 0 {
aggregated /= degree as Float;
}
let transformed = aggregated.dot(weights) + bias;
let activated = if layer_idx == self.state.num_layers - 1 {
transformed.mapv(|x| 1.0 / (1.0 + (-x).exp()))
} else {
transformed.mapv(|x| x.max(0.0))
};
output.row_mut(i).assign(&activated);
}
Ok(output)
}
fn gat_layer_trained(
&self,
node_embeddings: &Array2<Float>,
adjacency: &ArrayView2<'_, i32>,
layer_idx: usize,
) -> SklResult<Array2<Float>> {
let weights = &self.state.layer_weights[layer_idx];
let bias = &self.state.layer_biases[layer_idx];
let attention_weights = self
.state
.attention_weights
.as_ref()
.expect("operation should succeed");
let att_weights = &attention_weights[layer_idx];
let n_nodes = node_embeddings.nrows();
let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
for i in 0..n_nodes {
let mut attention_scores = Array1::<Float>::zeros(n_nodes);
let mut valid_neighbors = Vec::new();
for j in 0..n_nodes {
if adjacency[[i, j]] == 1 || i == j {
let concat_features = Array1::from_iter(
node_embeddings
.row(i)
.iter()
.chain(node_embeddings.row(j).iter())
.cloned(),
);
if concat_features.len() == att_weights.nrows() {
let score = concat_features.dot(&att_weights.column(0));
attention_scores[j] = score.exp();
valid_neighbors.push(j);
}
}
}
let total_attention: Float = valid_neighbors.iter().map(|&j| attention_scores[j]).sum();
if total_attention > 0.0 {
for &j in &valid_neighbors {
attention_scores[j] /= total_attention;
}
}
let mut aggregated = Array1::<Float>::zeros(node_embeddings.ncols());
for &j in &valid_neighbors {
let weighted_features = &node_embeddings.row(j).to_owned() * attention_scores[j];
aggregated += &weighted_features;
}
let transformed = aggregated.dot(weights) + bias;
let activated = if layer_idx == self.state.num_layers - 1 {
transformed.mapv(|x| 1.0 / (1.0 + (-x).exp()))
} else {
transformed.mapv(|x| x.max(0.0))
};
output.row_mut(i).assign(&activated);
}
Ok(output)
}
fn graphsage_layer_trained(
&self,
node_embeddings: &Array2<Float>,
adjacency: &ArrayView2<'_, i32>,
layer_idx: usize,
) -> SklResult<Array2<Float>> {
let weights = &self.state.layer_weights[layer_idx];
let bias = &self.state.layer_biases[layer_idx];
let n_nodes = node_embeddings.nrows();
let embedding_dim = node_embeddings.ncols();
let output_dim = weights.ncols();
let mut output = Array2::<Float>::zeros((n_nodes, output_dim));
for i in 0..n_nodes {
let mut neighbor_sum = Array1::<Float>::zeros(embedding_dim);
let mut neighbor_count = 0;
for j in 0..n_nodes {
if adjacency[[i, j]] == 1 && i != j {
neighbor_sum += &node_embeddings.row(j).to_owned();
neighbor_count += 1;
}
}
if neighbor_count > 0 {
neighbor_sum /= neighbor_count as Float;
}
let self_features = node_embeddings.row(i).to_owned();
let concatenated =
Array1::from_iter(self_features.iter().chain(neighbor_sum.iter()).cloned());
if concatenated.len() == weights.nrows() {
let transformed = concatenated.dot(weights) + bias;
let activated = if layer_idx == self.state.num_layers - 1 {
transformed.mapv(|x| 1.0 / (1.0 + (-x).exp()))
} else {
transformed.mapv(|x| x.max(0.0))
};
output.row_mut(i).assign(&activated);
}
}
Ok(output)
}
fn gin_layer_trained(
&self,
node_embeddings: &Array2<Float>,
adjacency: &ArrayView2<'_, i32>,
layer_idx: usize,
) -> SklResult<Array2<Float>> {
let weights = &self.state.layer_weights[layer_idx];
let bias = &self.state.layer_biases[layer_idx];
let n_nodes = node_embeddings.nrows();
let mut output = Array2::<Float>::zeros((n_nodes, weights.ncols()));
let epsilon = 0.0;
for i in 0..n_nodes {
let mut neighbor_sum = Array1::<Float>::zeros(node_embeddings.ncols());
for j in 0..n_nodes {
if adjacency[[i, j]] == 1 && i != j {
neighbor_sum += &node_embeddings.row(j).to_owned();
}
}
let updated = &node_embeddings.row(i).to_owned() * (1.0 + epsilon) + &neighbor_sum;
let transformed = updated.dot(weights) + bias;
let activated = if layer_idx == self.state.num_layers - 1 {
transformed.mapv(|x| 1.0 / (1.0 + (-x).exp()))
} else {
transformed.mapv(|x| x.max(0.0))
};
output.row_mut(i).assign(&activated);
}
Ok(output)
}
}
#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
#[test]
fn test_gnn_basic_functionality() {
let node_features = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0]];
let adjacency = array![[0, 1, 0], [1, 0, 1], [0, 1, 0]];
let node_labels = array![[1, 0], [0, 1], [1, 1]];
let gnn = GraphNeuralNetwork::new()
.hidden_dim(4)
.num_layers(2)
.max_iter(5);
let trained_gnn = gnn
.fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
.expect("operation should succeed");
let predictions = trained_gnn
.predict_graph(&adjacency.view(), &node_features.view())
.expect("operation should succeed");
assert_eq!(predictions.dim(), (3, 2));
assert!(predictions.iter().all(|&x| x == 0 || x == 1));
}
#[test]
fn test_gnn_different_variants() {
let node_features = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0]];
let adjacency = array![[0, 1, 0], [1, 0, 1], [0, 1, 0]];
let node_labels = array![[1, 0], [0, 1], [1, 1]];
let gnn_gcn = GraphNeuralNetwork::new()
.message_passing_variant(MessagePassingVariant::GCN)
.max_iter(5);
let trained_gcn = gnn_gcn
.fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
.expect("operation should succeed");
let gnn_gat = GraphNeuralNetwork::new()
.message_passing_variant(MessagePassingVariant::GAT)
.max_iter(5);
let trained_gat = gnn_gat
.fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
.expect("operation should succeed");
let gnn_sage = GraphNeuralNetwork::new()
.message_passing_variant(MessagePassingVariant::GraphSAGE)
.max_iter(5);
let trained_sage = gnn_sage
.fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
.expect("operation should succeed");
assert_eq!(
trained_gcn.state.message_passing_variant,
MessagePassingVariant::GCN
);
assert_eq!(
trained_gat.state.message_passing_variant,
MessagePassingVariant::GAT
);
assert_eq!(
trained_sage.state.message_passing_variant,
MessagePassingVariant::GraphSAGE
);
}
#[test]
fn test_gnn_parameter_settings() {
let gnn = GraphNeuralNetwork::new()
.hidden_dim(16)
.num_layers(3)
.learning_rate(0.001)
.max_iter(50)
.dropout_rate(0.1);
assert_eq!(gnn.hidden_dim, 16);
assert_eq!(gnn.num_layers, 3);
assert!((gnn.learning_rate - 0.001).abs() < 1e-10);
assert_eq!(gnn.max_iter, 50);
assert!((gnn.dropout_rate - 0.1).abs() < 1e-10);
}
#[test]
fn test_gnn_default_settings() {
let gnn = GraphNeuralNetwork::new();
assert_eq!(gnn.hidden_dim, 32);
assert_eq!(gnn.num_layers, 2);
assert_eq!(gnn.message_passing_variant, MessagePassingVariant::GCN);
assert_eq!(gnn.aggregation_function, AggregationFunction::Mean);
}
#[test]
fn test_gnn_builder_pattern() {
let gnn1 = GraphNeuralNetwork::new();
let gnn2 = GraphNeuralNetwork::new();
assert_eq!(gnn1.hidden_dim, gnn2.hidden_dim);
assert_eq!(gnn1.num_layers, gnn2.num_layers);
let gnn3 = GraphNeuralNetwork::new().max_iter(1);
assert_eq!(gnn3.max_iter, 1);
}
#[test]
fn test_message_passing_variants() {
assert_eq!(MessagePassingVariant::GCN, MessagePassingVariant::GCN);
assert_ne!(MessagePassingVariant::GCN, MessagePassingVariant::GAT);
let variants = [
MessagePassingVariant::GCN,
MessagePassingVariant::GAT,
MessagePassingVariant::GraphSAGE,
MessagePassingVariant::GIN,
];
let gnn1 = GraphNeuralNetwork::new()
.message_passing_variant(variants[0])
.hidden_dim(8)
.max_iter(3);
let gnn2 = GraphNeuralNetwork::new()
.message_passing_variant(variants[1])
.hidden_dim(8)
.max_iter(3);
assert_eq!(gnn1.message_passing_variant, MessagePassingVariant::GCN);
assert_eq!(gnn2.message_passing_variant, MessagePassingVariant::GAT);
}
#[test]
fn test_gnn_larger_graph() {
let node_features = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0], [1.0, 3.0]];
let adjacency = array![
[0, 1, 1, 0, 0],
[1, 0, 1, 1, 0],
[1, 1, 0, 0, 1],
[0, 1, 0, 0, 1],
[0, 0, 1, 1, 0]
];
let node_labels = array![[1, 0, 1], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 0]];
let gnn = GraphNeuralNetwork::new()
.hidden_dim(10)
.num_layers(2)
.message_passing_variant(MessagePassingVariant::GCN)
.max_iter(10);
let trained_gnn = gnn
.fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
.expect("operation should succeed");
let predictions = trained_gnn
.predict_graph(&adjacency.view(), &node_features.view())
.expect("operation should succeed");
assert_eq!(predictions.dim(), (5, 3));
assert!(predictions.iter().all(|&x| x == 0 || x == 1));
assert_eq!(trained_gnn.hidden_dim(), 10);
}
#[test]
fn test_aggregation_functions() {
assert_ne!(AggregationFunction::Mean, AggregationFunction::Max);
assert_eq!(AggregationFunction::Sum, AggregationFunction::Sum);
assert_ne!(MessagePassingVariant::GraphSAGE, MessagePassingVariant::GIN);
}
#[test]
fn test_gnn_reproducibility() {
let node_features = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0]];
let adjacency = array![[0, 1, 0], [1, 0, 1], [0, 1, 0]];
let node_labels = array![[1, 0], [0, 1], [1, 1]];
let gnn = GraphNeuralNetwork::new()
.hidden_dim(4)
.num_layers(2)
.max_iter(5)
.random_state(42);
let trained_gnn = gnn
.fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
.expect("operation should succeed");
let predictions = trained_gnn
.predict_graph(&adjacency.view(), &node_features.view())
.expect("operation should succeed");
assert_eq!(predictions.dim(), (3, 2));
}
#[test]
fn test_gnn_edge_cases() {
let node_features = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0], [1.0, 3.0]];
let adjacency = array![
[0, 1, 1, 0, 0],
[1, 0, 1, 1, 0],
[1, 1, 0, 0, 1],
[0, 1, 0, 0, 1],
[0, 0, 1, 1, 0]
];
let node_labels = array![[1, 0, 1], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 0]];
let gnn = GraphNeuralNetwork::new()
.hidden_dim(10)
.num_layers(2)
.message_passing_variant(MessagePassingVariant::GCN)
.max_iter(15)
.random_state(42);
let trained_gnn = gnn
.fit_graph(&adjacency.view(), &node_features.view(), &node_labels)
.expect("operation should succeed");
let predictions = trained_gnn
.predict_graph(&adjacency.view(), &node_features.view())
.expect("operation should succeed");
assert_eq!(predictions.dim(), (5, 3));
assert!(predictions.iter().all(|&x| x == 0 || x == 1));
assert_eq!(trained_gnn.hidden_dim(), 10);
}
}