1use scirs2_core::ndarray_ext::Array2;
25use scirs2_core::random::rand_prelude::StdRng;
26use scirs2_core::random::{seeded_rng, CoreRandom};
27
28use super::aggregator::mean_aggregate;
29use super::sampler::sample_neighbours;
30
31type ForwardCache = (Vec<Vec<f64>>, Vec<Vec<f64>>, Vec<Vec<f64>>);
34
35#[derive(Debug, thiserror::Error)]
39pub enum GnnError {
40 #[error("dimension mismatch: {0}")]
41 DimMismatch(String),
42 #[error("empty graph: {0}")]
43 EmptyGraph(String),
44 #[error("invalid config: {0}")]
45 InvalidConfig(String),
46}
47
48pub type GnnResult<T> = Result<T, GnnError>;
49
50#[derive(Debug, Clone)]
54pub struct KgGraph {
55 pub num_nodes: usize,
57 pub edges: Vec<(usize, usize)>,
59 pub node_features: Array2<f64>,
61}
62
63#[derive(Debug, Clone)]
65pub struct EntityEmbeddings {
66 pub embeddings: Array2<f64>,
68 pub node_ids: Vec<String>,
70}
71
72#[derive(Debug, Clone)]
74pub struct GraphSageConfig {
75 pub input_dim: usize,
77 pub hidden_dim: usize,
79 pub output_dim: usize,
81 pub num_layers: usize,
83 pub dropout: f64,
85 pub k_neighbors: usize,
87 pub learning_rate: f64,
89}
90
91impl Default for GraphSageConfig {
92 fn default() -> Self {
93 Self {
94 input_dim: 64,
95 hidden_dim: 64,
96 output_dim: 64,
97 num_layers: 2,
98 dropout: 0.0,
99 k_neighbors: 10,
100 learning_rate: 0.01,
101 }
102 }
103}
104
105#[derive(Debug, Clone)]
107pub struct TrainingHistory {
108 pub epoch_losses: Vec<f64>,
110 pub final_loss: f64,
112}
113
114#[derive(Debug, Clone)]
120struct SageLayer {
121 w: Vec<Vec<f64>>,
123 b: Vec<f64>,
125 out_dim: usize,
126 in2_dim: usize, }
128
129impl SageLayer {
130 fn new_xavier(in_dim: usize, out_dim: usize, rng: &mut CoreRandom<StdRng>) -> Self {
132 let in2_dim = 2 * in_dim;
133 let fan_in = in2_dim;
134 let fan_out = out_dim;
135 let limit = (6.0_f64 / (fan_in + fan_out) as f64).sqrt();
136 let w: Vec<Vec<f64>> = (0..out_dim)
137 .map(|_| {
138 (0..in2_dim)
139 .map(|_| {
140 let u = rng.random_range(0.0_f64..1.0_f64);
141 u * 2.0 * limit - limit
142 })
143 .collect()
144 })
145 .collect();
146 let b = vec![0.0_f64; out_dim];
147 Self {
148 w,
149 b,
150 out_dim,
151 in2_dim,
152 }
153 }
154
155 fn forward(&self, self_h: &[f64], agg_h: &[f64]) -> Vec<f64> {
157 debug_assert_eq!(self_h.len() + agg_h.len(), self.in2_dim);
158 let mut out = vec![0.0_f64; self.out_dim];
159 for (i, row) in self.w.iter().enumerate() {
160 let dot: f64 = row[..self_h.len()]
161 .iter()
162 .zip(self_h.iter())
163 .map(|(w, x)| w * x)
164 .sum::<f64>()
165 + row[self_h.len()..]
166 .iter()
167 .zip(agg_h.iter())
168 .map(|(w, x)| w * x)
169 .sum::<f64>();
170 out[i] = (dot + self.b[i]).max(0.0); }
172 out
173 }
174
175 fn pre_activation(&self, self_h: &[f64], agg_h: &[f64]) -> Vec<f64> {
177 debug_assert_eq!(self_h.len() + agg_h.len(), self.in2_dim);
178 let mut out = vec![0.0_f64; self.out_dim];
179 for (i, row) in self.w.iter().enumerate() {
180 let dot: f64 = row[..self_h.len()]
181 .iter()
182 .zip(self_h.iter())
183 .map(|(w, x)| w * x)
184 .sum::<f64>()
185 + row[self_h.len()..]
186 .iter()
187 .zip(agg_h.iter())
188 .map(|(w, x)| w * x)
189 .sum::<f64>();
190 out[i] = dot + self.b[i];
191 }
192 out
193 }
194}
195
196pub struct GraphSageEncoder {
200 layer1: SageLayer,
201 layer2: SageLayer,
202 config: GraphSageConfig,
203 seed: u64,
204}
205
206impl GraphSageEncoder {
207 pub fn new(config: &GraphSageConfig) -> GnnResult<Self> {
209 Self::new_with_seed(config, 42)
210 }
211
212 pub fn new_with_seed(config: &GraphSageConfig, seed: u64) -> GnnResult<Self> {
214 if config.input_dim == 0 {
215 return Err(GnnError::InvalidConfig("input_dim must be > 0".into()));
216 }
217 if config.hidden_dim == 0 {
218 return Err(GnnError::InvalidConfig("hidden_dim must be > 0".into()));
219 }
220 if config.output_dim == 0 {
221 return Err(GnnError::InvalidConfig("output_dim must be > 0".into()));
222 }
223 let mut rng = seeded_rng(seed);
224 let layer1 = SageLayer::new_xavier(config.input_dim, config.hidden_dim, &mut rng);
225 let layer2 = SageLayer::new_xavier(config.hidden_dim, config.output_dim, &mut rng);
226 Ok(Self {
227 layer1,
228 layer2,
229 config: config.clone(),
230 seed,
231 })
232 }
233
234 pub fn encode(&self, graph: &KgGraph) -> GnnResult<EntityEmbeddings> {
241 if graph.num_nodes == 0 {
242 return Err(GnnError::EmptyGraph("graph has no nodes".into()));
243 }
244 let feat_rows = graph.node_features.nrows();
245 if feat_rows != graph.num_nodes {
246 return Err(GnnError::DimMismatch(format!(
247 "node_features has {} rows but num_nodes = {}",
248 feat_rows, graph.num_nodes
249 )));
250 }
251 let feat_dim = graph.node_features.ncols();
252 if feat_dim != self.config.input_dim {
253 return Err(GnnError::DimMismatch(format!(
254 "node_features has {} cols but config.input_dim = {}",
255 feat_dim, self.config.input_dim
256 )));
257 }
258
259 let input_h: Vec<Vec<f64>> = (0..graph.num_nodes)
260 .map(|i| graph.node_features.row(i).to_vec())
261 .collect();
262
263 let mut rng1 = seeded_rng(self.seed.wrapping_add(1));
264 let h1 = self.sage_layer_forward(&self.layer1, &input_h, graph, &mut rng1);
265
266 let mut rng2 = seeded_rng(self.seed.wrapping_add(2));
267 let h2 = self.sage_layer_forward(&self.layer2, &h1, graph, &mut rng2);
268
269 let out_dim = self.config.output_dim;
270 let mut embeddings = Array2::zeros((graph.num_nodes, out_dim));
271 for (i, row) in h2.iter().enumerate() {
272 for (j, &v) in row.iter().enumerate() {
273 embeddings[[i, j]] = v;
274 }
275 }
276
277 Ok(EntityEmbeddings {
278 embeddings,
279 node_ids: (0..graph.num_nodes).map(|i| i.to_string()).collect(),
280 })
281 }
282
283 fn sage_layer_forward(
285 &self,
286 layer: &SageLayer,
287 h_prev: &[Vec<f64>],
288 graph: &KgGraph,
289 rng: &mut CoreRandom<StdRng>,
290 ) -> Vec<Vec<f64>> {
291 let in_dim = if h_prev.is_empty() {
292 0
293 } else {
294 h_prev[0].len()
295 };
296 let zero_agg = vec![0.0_f64; in_dim];
297
298 (0..graph.num_nodes)
299 .map(|v| {
300 let neighbours = sample_neighbours(v, &graph.edges, self.config.k_neighbors, rng);
301 let agg = if neighbours.is_empty() {
302 zero_agg.clone()
303 } else {
304 let nb_embs: Vec<Vec<f64>> =
305 neighbours.iter().map(|&u| h_prev[u].clone()).collect();
306 mean_aggregate(&nb_embs)
307 };
308 layer.forward(&h_prev[v], &agg)
309 })
310 .collect()
311 }
312
313 pub fn train(&mut self, graph: &KgGraph, num_epochs: usize) -> GnnResult<TrainingHistory> {
317 if graph.num_nodes < 2 {
318 return Err(GnnError::EmptyGraph(
319 "need at least 2 nodes for training".into(),
320 ));
321 }
322 if graph.edges.is_empty() {
323 return Err(GnnError::EmptyGraph(
324 "no edges to form positive pairs".into(),
325 ));
326 }
327
328 let mut epoch_losses = Vec::with_capacity(num_epochs);
329 let mut rng = seeded_rng(self.seed.wrapping_add(100));
330
331 for _ in 0..num_epochs {
332 let input_h: Vec<Vec<f64>> = (0..graph.num_nodes)
334 .map(|i| graph.node_features.row(i).to_vec())
335 .collect();
336
337 let (h1, pre1, agg1) = self.forward_with_cache(
338 self.config.k_neighbors,
339 &self.layer1,
340 &input_h,
341 graph,
342 &mut rng,
343 );
344
345 let (h2, pre2, agg2) = self.forward_with_cache(
347 self.config.k_neighbors,
348 &self.layer2,
349 &h1,
350 graph,
351 &mut rng,
352 );
353
354 let pos_idx = {
356 let n = graph.edges.len();
357 let u = rng.random_range(0.0_f64..1.0_f64);
358 (u * n as f64) as usize % n
359 };
360 let (s, o_pos) = graph.edges[pos_idx];
361
362 let o_neg = self.sample_negative(s, graph, &mut rng);
364
365 let sim_pos = cosine_sim(&h2[s], &h2[o_pos]);
366 let sim_neg = cosine_sim(&h2[s], &h2[o_neg]);
367 let margin = 1.0_f64 - sim_pos + sim_neg;
368 let loss = margin.max(0.0);
369 epoch_losses.push(loss);
370
371 if loss <= 0.0 {
372 continue;
373 }
374
375 let (grad_s, grad_opos, grad_oneg) = cosine_sim_grads(&h2[s], &h2[o_pos], &h2[o_neg]);
377
378 let mut dl_dh2 = vec![vec![0.0_f64; self.config.output_dim]; graph.num_nodes];
379 add_vec(&mut dl_dh2[s], &grad_s);
380 add_vec(&mut dl_dh2[o_pos], &grad_opos);
381 add_vec(&mut dl_dh2[o_neg], &grad_oneg);
382
383 let (dw2, db2, dl_dh1) = backward_layer(
384 &self.layer2,
385 &dl_dh2,
386 &h1,
387 &pre2,
388 &agg2,
389 graph.num_nodes,
390 self.config.hidden_dim,
391 );
392
393 let (dw1, db1, _) = backward_layer(
394 &self.layer1,
395 &dl_dh1,
396 &input_h,
397 &pre1,
398 &agg1,
399 graph.num_nodes,
400 self.config.input_dim,
401 );
402
403 let lr = self.config.learning_rate;
404 apply_grad_2d(&mut self.layer2.w, &dw2, lr);
405 apply_grad_1d(&mut self.layer2.b, &db2, lr);
406 apply_grad_2d(&mut self.layer1.w, &dw1, lr);
407 apply_grad_1d(&mut self.layer1.b, &db1, lr);
408 }
409
410 let final_loss = epoch_losses.last().copied().unwrap_or(0.0);
411 Ok(TrainingHistory {
412 epoch_losses,
413 final_loss,
414 })
415 }
416
417 fn forward_with_cache(
421 &self,
422 k: usize,
423 layer: &SageLayer,
424 h_prev: &[Vec<f64>],
425 graph: &KgGraph,
426 rng: &mut CoreRandom<StdRng>,
427 ) -> ForwardCache {
428 let in_dim = if h_prev.is_empty() {
429 0
430 } else {
431 h_prev[0].len()
432 };
433 let zero_agg = vec![0.0_f64; in_dim];
434
435 let mut h_out = Vec::with_capacity(graph.num_nodes);
436 let mut pre_acts = Vec::with_capacity(graph.num_nodes);
437 let mut aggs = Vec::with_capacity(graph.num_nodes);
438
439 for v in 0..graph.num_nodes {
440 let neighbours = sample_neighbours(v, &graph.edges, k, rng);
441 let agg = if neighbours.is_empty() {
442 zero_agg.clone()
443 } else {
444 let nb_embs: Vec<Vec<f64>> =
445 neighbours.iter().map(|&u| h_prev[u].clone()).collect();
446 mean_aggregate(&nb_embs)
447 };
448 let pre = layer.pre_activation(&h_prev[v], &agg);
449 let out: Vec<f64> = pre.iter().map(|&z| z.max(0.0)).collect();
450 pre_acts.push(pre);
451 aggs.push(agg);
452 h_out.push(out);
453 }
454 (h_out, pre_acts, aggs)
455 }
456
457 fn sample_negative(&self, src: usize, graph: &KgGraph, rng: &mut CoreRandom<StdRng>) -> usize {
459 let neighbours: std::collections::HashSet<usize> = graph
460 .edges
461 .iter()
462 .filter_map(|&(s, d)| if s == src { Some(d) } else { None })
463 .collect();
464 for _ in 0..200 {
465 let u = rng.random_range(0.0_f64..1.0_f64);
466 let candidate = (u * graph.num_nodes as f64) as usize % graph.num_nodes;
467 if candidate != src && !neighbours.contains(&candidate) {
468 return candidate;
469 }
470 }
471 for c in 0..graph.num_nodes {
473 if c != src && !neighbours.contains(&c) {
474 return c;
475 }
476 }
477 (src + 1) % graph.num_nodes
478 }
479
480 pub fn compute_grad_and_param_for_test(&mut self, graph: &KgGraph) -> (f64, f64) {
485 let mut rng = seeded_rng(self.seed.wrapping_add(100));
486
487 let input_h: Vec<Vec<f64>> = (0..graph.num_nodes)
488 .map(|i| graph.node_features.row(i).to_vec())
489 .collect();
490 let (h1, pre1, agg1) = self.forward_with_cache(
491 self.config.k_neighbors,
492 &self.layer1,
493 &input_h,
494 graph,
495 &mut rng,
496 );
497 let (h2, pre2, agg2) =
498 self.forward_with_cache(self.config.k_neighbors, &self.layer2, &h1, graph, &mut rng);
499
500 let s = 0_usize;
501 let o_pos = 1_usize;
502 let o_neg = self.sample_negative(s, graph, &mut rng);
503
504 let margin = 1.0 - cosine_sim(&h2[s], &h2[o_pos]) + cosine_sim(&h2[s], &h2[o_neg]);
505 if margin <= 0.0 {
506 return (0.0, self.layer1.w[0][0]);
507 }
508
509 let (grad_s, grad_opos, grad_oneg) = cosine_sim_grads(&h2[s], &h2[o_pos], &h2[o_neg]);
510
511 let mut dl_dh2 = vec![vec![0.0_f64; self.config.output_dim]; graph.num_nodes];
512 add_vec(&mut dl_dh2[s], &grad_s);
513 add_vec(&mut dl_dh2[o_pos], &grad_opos);
514 add_vec(&mut dl_dh2[o_neg], &grad_oneg);
515
516 let (_dw2, _db2, dl_dh1) = backward_layer(
517 &self.layer2,
518 &dl_dh2,
519 &h1,
520 &pre2,
521 &agg2,
522 graph.num_nodes,
523 self.config.hidden_dim,
524 );
525
526 let (dw1, _db1, _) = backward_layer(
527 &self.layer1,
528 &dl_dh1,
529 &input_h,
530 &pre1,
531 &agg1,
532 graph.num_nodes,
533 self.config.input_dim,
534 );
535
536 (dw1[0][0], self.layer1.w[0][0])
537 }
538
539 pub fn compute_loss_with_perturb(&mut self, graph: &KgGraph, eps: f64) -> f64 {
542 self.layer1.w[0][0] += eps;
543
544 let mut rng = seeded_rng(self.seed.wrapping_add(100));
545 let input_h: Vec<Vec<f64>> = (0..graph.num_nodes)
546 .map(|i| graph.node_features.row(i).to_vec())
547 .collect();
548 let (h1, _, _) = self.forward_with_cache(
549 self.config.k_neighbors,
550 &self.layer1,
551 &input_h,
552 graph,
553 &mut rng,
554 );
555 let (h2, _, _) =
556 self.forward_with_cache(self.config.k_neighbors, &self.layer2, &h1, graph, &mut rng);
557
558 let o_neg = self.sample_negative(0, graph, &mut rng);
559 let sim_pos = cosine_sim(&h2[0], &h2[1]);
560 let sim_neg = cosine_sim(&h2[0], &h2[o_neg]);
561 let loss = (1.0 - sim_pos + sim_neg).max(0.0);
562
563 self.layer1.w[0][0] -= eps;
564 loss
565 }
566}
567
568fn cosine_sim(a: &[f64], b: &[f64]) -> f64 {
572 let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
573 let na: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
574 let nb: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
575 if na < 1e-12 || nb < 1e-12 {
576 0.0
577 } else {
578 dot / (na * nb)
579 }
580}
581
582fn cos_grad_a(a: &[f64], b: &[f64]) -> Vec<f64> {
584 let dim = a.len();
585 let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
586 let na: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
587 let nb: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
588 if na < 1e-12 || nb < 1e-12 {
589 return vec![0.0; dim];
590 }
591 let sim = dot / (na * nb);
592 a.iter()
593 .zip(b.iter())
594 .map(|(&ai, &bi)| bi / (na * nb) - ai * sim / (na * na))
595 .collect()
596}
597
598fn cos_grad_b(a: &[f64], b: &[f64]) -> Vec<f64> {
600 cos_grad_a(b, a)
601}
602
603fn cosine_sim_grads(h_s: &[f64], h_opos: &[f64], h_oneg: &[f64]) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
608 let dim = h_s.len();
609
610 let d_simpos_ds = cos_grad_a(h_s, h_opos);
612 let d_simneg_ds = cos_grad_a(h_s, h_oneg);
613 let mut grad_s = vec![0.0_f64; dim];
614 for i in 0..dim {
615 grad_s[i] = -d_simpos_ds[i] + d_simneg_ds[i];
616 }
617
618 let grad_opos: Vec<f64> = cos_grad_b(h_s, h_opos).into_iter().map(|g| -g).collect();
620
621 let grad_oneg = cos_grad_b(h_s, h_oneg);
623
624 (grad_s, grad_opos, grad_oneg)
625}
626
627fn add_vec(dst: &mut [f64], src: &[f64]) {
629 for (d, &s) in dst.iter_mut().zip(src.iter()) {
630 *d += s;
631 }
632}
633
634fn backward_layer(
638 layer: &SageLayer,
639 dl_dh: &[Vec<f64>], h_prev: &[Vec<f64>], pre_acts: &[Vec<f64>], aggs: &[Vec<f64>], num_nodes: usize,
644 in_dim: usize,
645) -> (Vec<Vec<f64>>, Vec<f64>, Vec<Vec<f64>>) {
646 let out_dim = layer.out_dim;
647 let in2 = layer.in2_dim;
648
649 let mut dw = vec![vec![0.0_f64; in2]; out_dim];
650 let mut db = vec![0.0_f64; out_dim];
651 let mut dl_dh_prev = vec![vec![0.0_f64; in_dim]; num_nodes];
652
653 for v in 0..num_nodes {
654 let d_pre: Vec<f64> = dl_dh[v]
656 .iter()
657 .zip(pre_acts[v].iter())
658 .map(|(&g, &z)| if z > 0.0 { g } else { 0.0 })
659 .collect();
660
661 let self_h = &h_prev[v];
662 let agg_h = &aggs[v];
663
664 for (i, &dp) in d_pre.iter().enumerate() {
665 for (j, &sh) in self_h.iter().enumerate() {
666 dw[i][j] += dp * sh;
667 }
668 for (j, &ah) in agg_h.iter().enumerate() {
669 dw[i][in_dim + j] += dp * ah;
670 }
671 db[i] += dp;
672 }
673
674 for (j, dh) in dl_dh_prev[v].iter_mut().enumerate() {
676 for (i, &dp) in d_pre.iter().enumerate() {
677 *dh += layer.w[i][j] * dp;
678 }
679 }
680 }
681
682 (dw, db, dl_dh_prev)
683}
684
685fn apply_grad_2d(w: &mut [Vec<f64>], raw_grad: &[Vec<f64>], lr: f64) {
687 let norm_sq: f64 = raw_grad
688 .iter()
689 .flat_map(|row| row.iter())
690 .map(|&g| g * g)
691 .sum();
692 let norm = norm_sq.sqrt();
693 let scale = if norm > 1.0 { 1.0 / norm } else { 1.0 };
694 for (row, grow) in w.iter_mut().zip(raw_grad.iter()) {
695 for (wi, &gi) in row.iter_mut().zip(grow.iter()) {
696 *wi -= lr * gi * scale;
697 }
698 }
699}
700
701fn apply_grad_1d(b: &mut [f64], raw_grad: &[f64], lr: f64) {
703 let norm_sq: f64 = raw_grad.iter().map(|&g| g * g).sum();
704 let norm = norm_sq.sqrt();
705 let scale = if norm > 1.0 { 1.0 / norm } else { 1.0 };
706 for (bi, &gi) in b.iter_mut().zip(raw_grad.iter()) {
707 *bi -= lr * gi * scale;
708 }
709}
710
711#[cfg(test)]
714mod tests {
715 use super::*;
716 use scirs2_core::ndarray_ext::Array2;
717
718 fn tiny_graph() -> KgGraph {
719 KgGraph {
720 num_nodes: 4,
721 edges: vec![(0, 1), (1, 2), (2, 3), (3, 0)],
722 node_features: Array2::zeros((4, 4)),
723 }
724 }
725
726 fn tiny_config() -> GraphSageConfig {
727 GraphSageConfig {
728 input_dim: 4,
729 hidden_dim: 4,
730 output_dim: 4,
731 num_layers: 2,
732 dropout: 0.0,
733 k_neighbors: 2,
734 learning_rate: 0.01,
735 }
736 }
737
738 #[test]
739 fn test_cosine_sim_identical() {
740 let v = vec![1.0, 2.0, 3.0];
741 let s = cosine_sim(&v, &v);
742 assert!((s - 1.0).abs() < 1e-10);
743 }
744
745 #[test]
746 fn test_cosine_sim_zero_vec() {
747 let a = vec![0.0, 0.0];
748 let b = vec![1.0, 0.0];
749 assert_eq!(cosine_sim(&a, &b), 0.0);
750 }
751
752 #[test]
753 fn test_forward_shape() {
754 let graph = tiny_graph();
755 let config = tiny_config();
756 let enc = GraphSageEncoder::new_with_seed(&config, 99).expect("construct");
757 let emb = enc.encode(&graph).expect("encode");
758 assert_eq!(emb.embeddings.nrows(), 4);
759 assert_eq!(emb.embeddings.ncols(), 4);
760 }
761
762 #[test]
763 fn test_reject_zero_dim() {
764 let mut cfg = tiny_config();
765 cfg.input_dim = 0;
766 assert!(GraphSageEncoder::new(&cfg).is_err());
767 }
768
769 #[test]
770 fn test_reject_feat_dim_mismatch() {
771 let config = tiny_config(); let enc = GraphSageEncoder::new_with_seed(&config, 1).expect("construct");
773 let bad_graph = KgGraph {
774 num_nodes: 2,
775 edges: vec![(0, 1)],
776 node_features: Array2::zeros((2, 8)),
777 };
778 assert!(enc.encode(&bad_graph).is_err());
779 }
780}