Source code for qbiocode.apps.quvine.baselines.graphsage

# Copyright 2026, IBM Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Unsupervised GraphSAGE baseline.

Implements mean-aggregator GraphSAGE (Hamilton et al., 2017) in an
unsupervised setting using a graph-context (DeepWalk-style) loss.

Two backends are provided:
1. PyTorch  — full trainable GraphSAGE with unsupervised negative-sampling loss.
2. NumPy    — spectral mean-aggregation fallback (no training required).

Reference: Hamilton, W., Ying, R., & Leskovec, J. (2017).
           Inductive Representation Learning on Large Graphs. NeurIPS.
"""

import logging
import numpy as np
import networkx as nx
from typing import List, Optional

logger = logging.getLogger(__name__)

try:
    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    _TORCH = True
except ImportError:
    _TORCH = False


# ──────────────────────────────────────────────────────────────────────────────
# PyTorch implementation
# ──────────────────────────────────────────────────────────────────────────────

if _TORCH:

    class _SAGELayer(nn.Module):
        """Single mean-aggregator GraphSAGE layer."""

        def __init__(self, in_dim: int, out_dim: int):
            super().__init__()
            # Weight for [h_self || h_agg]
            self.W = nn.Linear(in_dim * 2, out_dim, bias=True)
            nn.init.xavier_uniform_(self.W.weight)

        def forward(self, H: "torch.Tensor", adj_sp: "torch.Tensor") -> "torch.Tensor":
            # adj_sp : (N, N) sparse or dense row-normalised adjacency
            H_agg = torch.mm(adj_sp, H)              # mean aggregation
            H_cat = torch.cat([H, H_agg], dim=1)     # self || neighbours
            H_out = F.relu(self.W(H_cat))
            return F.normalize(H_out, p=2, dim=1)    # L2 normalise per node

    class _GraphSAGETorch(nn.Module):
        """Stacked mean-aggregator GraphSAGE."""

        def __init__(self, in_dim: int, hidden_dim: int, out_dim: int, n_layers: int = 2):
            super().__init__()
            dims = [in_dim] + [hidden_dim] * (n_layers - 1) + [out_dim]
            self.layers = nn.ModuleList(
                [_SAGELayer(dims[i], dims[i + 1]) for i in range(n_layers)]
            )

        def forward(self, H: "torch.Tensor", adj_sp: "torch.Tensor") -> "torch.Tensor":
            for layer in self.layers:
                H = layer(H, adj_sp)
            return H

    def _run_graphsage_torch(
        G: nx.Graph,
        nodes: List,
        dimensions: int,
        hidden_dim: int,
        n_layers: int,
        epochs: int,
        lr: float,
        neg_samples: int,
        seed: int,
        device: str = "cpu",
    ) -> np.ndarray:
        """Train unsupervised GraphSAGE with PyTorch."""
        torch.manual_seed(seed)
        np.random.seed(seed)

        dev = torch.device("cuda" if torch.cuda.is_available() else "cpu") \
            if device == "auto" else torch.device(device)

        N = len(nodes)
        node_idx = {n: i for i, n in enumerate(nodes)}

        # ── Build row-normalised adjacency (dense) ─────────────────────────
        A = nx.to_numpy_array(G, nodelist=nodes, dtype=np.float32)
        D_inv = np.diag(1.0 / (A.sum(axis=1) + 1e-12))
        A_hat = (D_inv @ A).astype(np.float32)
        adj_t = torch.from_numpy(A_hat).to(dev)

        # ── Initial features: normalised degree + random projection ────────
        deg = np.array([G.degree(n) for n in nodes], dtype=np.float32)
        deg_norm = (deg - deg.mean()) / (deg.std() + 1e-12)

        in_dim = max(8, min(64, N // 4))
        rng = np.random.default_rng(seed)
        R = rng.standard_normal((N, in_dim)).astype(np.float32)
        # Smooth R over 1 hop so initial features carry graph structure
        H0_raw = A_hat @ R + 0.3 * deg_norm[:, None] * R
        H0 = (H0_raw / (np.linalg.norm(H0_raw, axis=1, keepdims=True) + 1e-12))
        H_t = torch.from_numpy(H0.astype(np.float32)).to(dev)

        # ── Model & optimiser ──────────────────────────────────────────────
        model = _GraphSAGETorch(in_dim, hidden_dim, dimensions, n_layers).to(dev)
        optimizer = torch.optim.Adam(model.parameters(), lr=lr)

        # ── Build positive pairs from edges ────────────────────────────────
        pos_pairs = [(node_idx[u], node_idx[v])
                     for u, v in G.edges()
                     if u in node_idx and v in node_idx]
        if len(pos_pairs) == 0:
            # Degenerate graph — return random embedding
            return rng.standard_normal((N, dimensions)).astype(np.float32)

        pos_u = torch.tensor([p[0] for p in pos_pairs], dtype=torch.long, device=dev)
        pos_v = torch.tensor([p[1] for p in pos_pairs], dtype=torch.long, device=dev)

        # ── Training loop with early stopping ──────────────────────────────
        model.train()
        best_loss = float('inf')
        patience_counter = 0
        patience = 15

        for epoch in range(epochs):
            optimizer.zero_grad()
            Z = model(H_t, adj_t)            # (N, dim) L2-normalised

            # Positive loss: want high similarity for edge pairs
            pos_sim = (Z[pos_u] * Z[pos_v]).sum(dim=1)
            pos_loss = F.binary_cross_entropy_with_logits(
                pos_sim, torch.ones_like(pos_sim)
            )

            # Negative loss: random non-edge pairs (limit samples for speed)
            num_neg = min(len(pos_pairs) * neg_samples, len(pos_pairs) * 5)
            neg_u_idx = torch.randint(0, N, (num_neg,), device=dev)
            neg_v_idx = torch.randint(0, N, (num_neg,), device=dev)
            # Filter self-loops (best-effort)
            valid = neg_u_idx != neg_v_idx
            neg_u_idx, neg_v_idx = neg_u_idx[valid], neg_v_idx[valid]

            neg_sim = (Z[neg_u_idx] * Z[neg_v_idx]).sum(dim=1)
            neg_loss = F.binary_cross_entropy_with_logits(
                neg_sim, torch.zeros_like(neg_sim)
            )

            loss = pos_loss + neg_loss
            loss.backward()
            optimizer.step()

            # Early stopping
            if loss.item() < best_loss:
                best_loss = loss.item()
                patience_counter = 0
            else:
                patience_counter += 1
                if patience_counter >= patience:
                    break

        model.eval()
        with torch.no_grad():
            Z_final = model(H_t, adj_t).cpu().numpy()
        return Z_final.astype(np.float64)


# ──────────────────────────────────────────────────────────────────────────────
# NumPy fallback (spectral mean aggregation — no training)
# ──────────────────────────────────────────────────────────────────────────────

def _run_graphsage_numpy(
    G: nx.Graph,
    nodes: List,
    dimensions: int,
    n_layers: int,
    seed: int,
) -> np.ndarray:
    """
    Spectral mean-aggregation GraphSAGE fallback (no PyTorch required).

    Applies n_layers rounds of symmetric mean aggregation on random
    initial features, then compresses to *dimensions* via truncated SVD.
    This approximates the linear (no non-linearity) limit of GraphSAGE.
    """
    N = len(nodes)
    A = nx.to_numpy_array(G, nodelist=nodes, dtype=np.float64)

    # Symmetric normalisation A_hat = D^{-1/2} A D^{-1/2}
    deg = A.sum(axis=1)
    d_inv_sqrt = np.where(deg > 0, 1.0 / np.sqrt(deg), 0.0)
    A_hat = d_inv_sqrt[:, None] * A * d_inv_sqrt[None, :]

    # Random initial features (graph-smoothed)
    rng = np.random.default_rng(seed)
    in_dim = max(16, min(128, N // 2))
    H = rng.standard_normal((N, in_dim))

    for _ in range(n_layers):
        H_agg = A_hat @ H
        H = np.concatenate([H, H_agg], axis=1)  # mean-concat (as in the paper)
        # Column-wise L2 normalise
        norms = np.linalg.norm(H, axis=0, keepdims=True)
        H = H / (norms + 1e-12)

    # Reduce to desired dimensionality with truncated SVD
    dims = min(dimensions, H.shape[1], N - 1)
    try:
        from scipy.sparse.linalg import ArpackError, svds
        U, s, _ = svds(H, k=dims)
        # svds returns ascending singular values — reverse to descending
        idx = np.argsort(s)[::-1]
        emb = U[:, idx] * s[idx]
    except (ArpackError, np.linalg.LinAlgError, ValueError) as exc:
        # Truncated SVD needs k < min(H.shape) and does not always converge.
        # The dense fallback is exact and returns the same quantity, so this is a
        # genuine alternative route rather than a degraded result -- but it is
        # O(N*d^2), so it is worth being able to see in a log.
        logger.debug("svds(k=%d) failed (%s); using dense SVD instead", dims, exc)
        U, s, _ = np.linalg.svd(H, full_matrices=False)
        emb = U[:, :dims] * s[:dims]

    return emb.astype(np.float64)


# ──────────────────────────────────────────────────────────────────────────────
# Public API
# ──────────────────────────────────────────────────────────────────────────────

[docs] def run_graphsage( graph: nx.Graph, nodes: List, dimensions: int = 64, hidden_dim: int = 128, n_layers: int = 2, epochs: int = 50, lr: float = 0.01, neg_samples: int = 5, seed: int = 42, device: str = "cpu", ) -> np.ndarray: """ Unsupervised GraphSAGE embedding. Uses the PyTorch backend when available; falls back to spectral mean aggregation otherwise. Parameters ---------- graph : nx.Graph nodes : list Canonical node ordering; embedding rows correspond to these nodes. dimensions : int Output embedding dimensionality. hidden_dim : int Hidden layer width (PyTorch backend only). n_layers : int Number of aggregation layers. epochs : int Training epochs (PyTorch backend only). lr : float Learning rate (PyTorch backend only). neg_samples : int Number of negative samples per positive edge (PyTorch backend only). seed : int Random seed. Returns ------- np.ndarray (len(nodes) × dimensions) """ if _TORCH: try: logger.info("GraphSAGE: using PyTorch backend") return _run_graphsage_torch( G=graph, nodes=nodes, dimensions=dimensions, hidden_dim=hidden_dim, n_layers=n_layers, epochs=epochs, lr=lr, neg_samples=neg_samples, seed=seed, device=device, ) except Exception as e: logger.warning(f"GraphSAGE PyTorch backend failed ({e}); falling back to NumPy") logger.info("GraphSAGE: using NumPy spectral-aggregation fallback") return _run_graphsage_numpy( G=graph, nodes=nodes, dimensions=dimensions, n_layers=n_layers, seed=seed, )