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

# 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.

"""
GCN-MF: Graph Convolutional Network with Matrix Factorization

A baseline method for disease gene prioritization that combines:
1. Graph Convolutional Networks (GCN) for learning node representations
2. Matrix Factorization for capturing latent features

Reference:
- Kipf & Welling (2017). Semi-Supervised Classification with Graph Convolutional Networks
- Matrix factorization approaches for biological network analysis
"""

import logging
import numpy as np
import scipy.sparse as sp
import networkx as nx

logger = logging.getLogger(__name__)

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


[docs] class GCNLayer(nn.Module): """Single Graph Convolutional Layer.""" def __init__(self, in_features, out_features, bias=True): super(GCNLayer, self).__init__() self.weight = nn.Parameter(torch.FloatTensor(in_features, out_features)) if bias: self.bias = nn.Parameter(torch.FloatTensor(out_features)) else: self.register_parameter('bias', None) self.reset_parameters()
[docs] def reset_parameters(self): """Initialize parameters.""" nn.init.xavier_uniform_(self.weight) if self.bias is not None: nn.init.zeros_(self.bias)
[docs] def forward(self, x, adj): """ Forward pass. Args: x: Node features [N, in_features] adj: Normalized adjacency matrix [N, N] (sparse or dense) Returns: Output features [N, out_features] """ support = torch.mm(x, self.weight) if getattr(adj, "is_sparse", False): output = torch.sparse.mm(adj, support) else: output = torch.mm(adj, support) if self.bias is not None: output = output + self.bias return output
[docs] class GCNMF(nn.Module): """ GCN-MF: Graph Convolutional Network with Matrix Factorization. Architecture: 1. GCN layers for learning graph-based representations 2. Matrix factorization component for latent features 3. Fusion of GCN and MF representations """
[docs] def __init__(self, n_nodes, input_dim, hidden_dim, output_dim, mf_dim=64, n_layers=2, dropout=0.5): """ Initialize GCN-MF model. Args: n_nodes: Number of nodes in the graph input_dim: Input feature dimension hidden_dim: Hidden layer dimension output_dim: Output dimension (number of classes) mf_dim: Matrix factorization embedding dimension n_layers: Number of GCN layers dropout: Dropout rate """ super(GCNMF, self).__init__() self.n_nodes = n_nodes self.mf_dim = mf_dim self.dropout = dropout # GCN layers self.gcn_layers = nn.ModuleList() self.gcn_layers.append(GCNLayer(input_dim, hidden_dim)) for _ in range(n_layers - 1): self.gcn_layers.append(GCNLayer(hidden_dim, hidden_dim)) # Matrix factorization embeddings self.node_embeddings = nn.Parameter(torch.FloatTensor(n_nodes, mf_dim)) nn.init.xavier_uniform_(self.node_embeddings) # Fusion layer self.fusion = nn.Linear(hidden_dim + mf_dim, hidden_dim) # Output layer self.output = nn.Linear(hidden_dim, output_dim)
[docs] def forward(self, x, adj, node_indices=None): """ Forward pass. Args: x: Node features [N, input_dim] adj: Normalized adjacency matrix [N, N] node_indices: Optional node indices for batch processing Returns: Output predictions [N, output_dim] """ # GCN forward pass (computes for all N nodes) h = x for i, gcn_layer in enumerate(self.gcn_layers): h = gcn_layer(h, adj) if i < len(self.gcn_layers) - 1: h = F.relu(h) h = F.dropout(h, p=self.dropout, training=self.training) # Slice both h and MF embeddings to keep shapes consistent if node_indices is not None: h = h[node_indices] mf_emb = self.node_embeddings[node_indices] else: mf_emb = self.node_embeddings # Fuse GCN and MF representations combined = torch.cat([h, mf_emb], dim=1) fused = F.relu(self.fusion(combined)) fused = F.dropout(fused, p=self.dropout, training=self.training) # Output out = self.output(fused) return out
[docs] def get_embeddings(self, x, adj): """ Get node embeddings (before classification layer). Args: x: Node features adj: Normalized adjacency matrix Returns: Node embeddings """ # GCN forward pass h = x for i, gcn_layer in enumerate(self.gcn_layers): h = gcn_layer(h, adj) if i < len(self.gcn_layers) - 1: h = F.relu(h) # Fuse with MF embeddings combined = torch.cat([h, self.node_embeddings], dim=1) fused = F.relu(self.fusion(combined)) return fused
[docs] def normalize_adjacency(adj): """ Normalize adjacency matrix: D^(-1/2) * A * D^(-1/2) Args: adj: Adjacency matrix (scipy sparse or numpy array) Returns: Normalized adjacency matrix """ if isinstance(adj, np.ndarray): adj = sp.csr_matrix(adj) # Add self-loops adj = adj + sp.eye(adj.shape[0]) # Compute D^(-1/2) rowsum = np.array(adj.sum(1)).flatten() d_inv_sqrt = np.power(rowsum, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0. d_mat_inv_sqrt = sp.diags(d_inv_sqrt) # Normalize: D^(-1/2) * A * D^(-1/2) adj_normalized = d_mat_inv_sqrt @ adj @ d_mat_inv_sqrt return adj_normalized
[docs] def precompute_quantum_diffusion(X, L, diffusion_type='heat', t_star=None, poly_coeffs=None): """ Precompute quantum-calibrated diffusion OFFLINE (before training). This function should be called ONCE before training to compute diffused features. The result is then passed to QuVINEGCNMF during training. Args: X: Original node features [N, input_dim] (numpy array or torch tensor) L: Laplacian matrix (scipy sparse or numpy array) diffusion_type: 'heat' or 'poly' t_star: Calibrated heat kernel time parameter (for heat diffusion) poly_coeffs: Calibrated polynomial coefficients (for polynomial diffusion) Returns: X_diffused: Diffused features [N, input_dim] (torch tensor) Example: >>> # Offline: Compute diffusion once >>> X_diffused_heat = precompute_quantum_diffusion(X, L, 'heat', t_star=0.5) >>> X_diffused_poly = precompute_quantum_diffusion(X, L, 'poly', poly_coeffs=coeffs) >>> >>> # Online: Use in training (no diffusion computation) >>> model = QuVINEGCNMF(n_nodes, input_dim, hidden_dim, output_dim) >>> output = model(X_diffused_heat, adj) # Fast! """ import scipy.sparse.linalg as spla if not TORCH_AVAILABLE: raise ImportError("PyTorch is required for precompute_quantum_diffusion. Install with: pip install torch") # Convert to numpy if needed if isinstance(X, torch.Tensor): X_np = X.detach().cpu().numpy() else: X_np = np.array(X) if diffusion_type == 'heat' and t_star is not None: logger.info(f"Precomputing heat kernel diffusion (t={t_star})...") Z_np = spla.expm_multiply((-t_star) * L, X_np) elif diffusion_type == 'poly' and poly_coeffs is not None: logger.info(f"Precomputing polynomial diffusion (degree={len(poly_coeffs)-1})...") from qbiocode.apps.quvine.embedding.quantum_filters import apply_polynomial_filter Z_np = apply_polynomial_filter(L, X_np, poly_coeffs) else: logger.info("No diffusion applied (identity)") Z_np = X_np logger.info("Diffusion precomputed successfully") return torch.from_numpy(Z_np).float()
[docs] class QuVINEGCNMF(GCNMF): """ QuVINE GCN-MF: GCN-MF with precomputed quantum-calibrated features. DESIGN PHILOSOPHY: - Diffusion is computed ONCE offline (before training) - Diffused features are passed as input - No CPU conversion or diffusion computation during training - Scalable and efficient for large graphs This model expects precomputed diffused features as input, combining: 1. Precomputed quantum-calibrated diffusion (offline) 2. GCN layers for learning representations (online) 3. Matrix factorization for latent features (online) """
[docs] def __init__(self, n_nodes, input_dim, hidden_dim, output_dim, mf_dim=64, n_layers=2, dropout=0.5): """ Initialize QuVINE GCN-MF model. Args: n_nodes: Number of nodes input_dim: Input feature dimension (should match diffused features) hidden_dim: Hidden layer dimension output_dim: Output dimension mf_dim: Matrix factorization dimension n_layers: Number of GCN layers dropout: Dropout rate Note: This model expects precomputed diffused features as input. Compute diffusion offline using:: X_diffused = apply_quantum_diffusion(X, L, t_star, poly_coeffs) Then pass X_diffused to forward(). """ super().__init__(n_nodes, input_dim, hidden_dim, output_dim, mf_dim, n_layers, dropout)
[docs] def forward(self, x, adj, node_indices=None): """ Forward pass with precomputed diffused features. Args: x: PRECOMPUTED diffused features [N, input_dim] (computed offline before training) adj: Normalized adjacency matrix [N, N] node_indices: Optional node indices Returns: Output predictions [N, output_dim] Note: x should be the result of offline diffusion: x = exp(-t*L) @ X_original (heat kernel) or x = polynomial_filter(L, coeffs) @ X_original (polynomial) """ # Simply use the precomputed diffused features # No diffusion computation here - it's done offline! return super().forward(x, adj, node_indices)
[docs] def get_embeddings(self, x, adj): """ Get node embeddings from precomputed diffused features. Args: x: PRECOMPUTED diffused features [N, input_dim] (computed offline before training) adj: Normalized adjacency matrix Returns: Node embeddings Note: x should already be diffused features from offline computation. No diffusion is applied here - it's done offline! """ # x is already precomputed diffused features return super().get_embeddings(x, adj)
[docs] def train_gcn_mf(model, x, y, adj, train_mask, val_mask, epochs=200, lr=0.01, weight_decay=5e-4, patience=20): """ Train GCN-MF model. Args: model: GCN-MF model x: Node features y: Labels adj: Normalized adjacency matrix train_mask: Training mask val_mask: Validation mask epochs: Number of training epochs lr: Learning rate weight_decay: Weight decay for regularization patience: Early stopping patience Returns: Dictionary with training history """ optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay) criterion = nn.CrossEntropyLoss() history = { 'train_loss': [], 'train_acc': [], 'val_acc': [], 'best_val_acc': 0.0, 'best_epoch': 0 } best_val_acc = 0.0 patience_counter = 0 for epoch in range(epochs): # Training model.train() optimizer.zero_grad() out = model(x, adj) loss = criterion(out[train_mask], y[train_mask]) loss.backward() optimizer.step() # Evaluation model.eval() with torch.no_grad(): out = model(x, adj) pred = out.argmax(dim=1) train_acc = (pred[train_mask] == y[train_mask]).float().mean().item() val_acc = (pred[val_mask] == y[val_mask]).float().mean().item() history['train_loss'].append(loss.item()) history['train_acc'].append(train_acc) history['val_acc'].append(val_acc) # Early stopping if val_acc > best_val_acc: best_val_acc = val_acc history['best_val_acc'] = best_val_acc history['best_epoch'] = epoch patience_counter = 0 else: patience_counter += 1 if patience_counter >= patience: print(f'Early stopping at epoch {epoch}') break if (epoch + 1) % 50 == 0: print(f'Epoch {epoch+1}/{epochs}: Loss={loss.item():.4f}, ' f'Train Acc={train_acc:.4f}, Val Acc={val_acc:.4f}') return history
[docs] def generate_quvine_gcnmf_embedding(G, q_targets, embedding_dim=128, diffusion_type='heat', t_star=None, poly_coeffs=None, K=4, ridge=1e-6, hidden_dim=64, mf_dim=64, n_layers=2, epochs=200, lr=0.01, weight_decay=5e-4, normalize_laplacian=True, random_state=42): """ Generate QuVINE GCN-MF embeddings (Heat or Poly variant). WORKFLOW: 1. Calibrate diffusion parameters using quantum walks (if not provided) 2. Precompute diffused features OFFLINE 3. Train GCN-MF model with diffused features 4. Extract final embeddings This is a HIGH-LEVEL wrapper that combines: - QuVINE quantum filter calibration (from quantum_filters.py) - Offline diffusion precomputation - GCN-MF training - Embedding extraction Args: G: NetworkX graph q_targets: List of quantum walk targets for calibration Format: [{'nodes': [...], 'center': int, 'pQ': array}, ...] embedding_dim: Final embedding dimension diffusion_type: 'heat' or 'poly' t_star: Pre-calibrated heat kernel time (optional, will calibrate if None) poly_coeffs: Pre-calibrated polynomial coefficients (optional) K: Polynomial degree (for poly diffusion) ridge: Ridge regularization for polynomial calibration hidden_dim: GCN hidden dimension mf_dim: Matrix factorization dimension n_layers: Number of GCN layers epochs: Training epochs lr: Learning rate weight_decay: Weight decay for regularization normalize_laplacian: Whether to normalize Laplacian random_state: Random seed Returns: embeddings: Node embeddings [N, embedding_dim] metadata: Dictionary with calibration info and training history Example: >>> from qbiocode.apps.quvine.baselines.gcn_mf import generate_quvine_gcnmf_embedding >>> >>> # Generate QuVINE GCN-MF (Heat) embeddings >>> embeddings_heat, meta_heat = generate_quvine_gcnmf_embedding( ... G, q_targets, embedding_dim=128, diffusion_type='heat' ... ) >>> >>> # Generate QuVINE GCN-MF (Poly) embeddings >>> embeddings_poly, meta_poly = generate_quvine_gcnmf_embedding( ... G, q_targets, embedding_dim=128, diffusion_type='poly', K=4 ... ) """ if not TORCH_AVAILABLE: raise ImportError("PyTorch is required for GCN-MF. Install with: pip install torch") # Import QuVINE quantum filters try: from qbiocode.apps.quvine.embedding.quantum_filters import ( calibrate_heat_kernel, calibrate_polynomial_filter, apply_heat_filter, apply_polynomial_filter ) except ImportError: raise ImportError("QuVINE quantum filters module not found. Ensure quantum_filters.py exists.") logger.info(f"Generating QuVINE GCN-MF embeddings ({diffusion_type} diffusion)...") # Set random seed np.random.seed(random_state) torch.manual_seed(random_state) # Get graph properties N = G.number_of_nodes() node_list = list(G.nodes()) node_to_idx = {node: idx for idx, node in enumerate(node_list)} # Get Laplacian L = nx.laplacian_matrix(G, nodelist=node_list).astype(float) if normalize_laplacian: from qbiocode.apps.quvine.embedding.quantum_filters import _normalize_laplacian L = _normalize_laplacian(L) # Generate random features for diffusion X = np.random.randn(N, embedding_dim).astype(np.float32) row_norms = np.linalg.norm(X, axis=1, keepdims=True) row_norms[row_norms == 0] = 1.0 X = X / row_norms metadata = { 'diffusion_type': diffusion_type, 'n_nodes': N, 'embedding_dim': embedding_dim, 'hidden_dim': hidden_dim, 'mf_dim': mf_dim, 'n_layers': n_layers } # STEP 1: Calibrate diffusion parameters (if not provided) if diffusion_type == 'heat': if t_star is None: logger.info("Calibrating heat kernel time parameter...") t_grid = np.linspace(0.1, 5.0, 50) best_loss, t_star = calibrate_heat_kernel( L, q_targets, t_grid, node_to_idx, loss='l2' ) logger.info(f"✓ Calibrated t_star = {t_star:.4f} (loss = {best_loss:.6f})") else: logger.info(f"Using provided t_star = {t_star:.4f}") metadata['t_star'] = t_star # STEP 2: Precompute diffused features OFFLINE logger.info("Precomputing heat kernel diffusion...") X_diffused = apply_heat_filter(L, X, t_star) elif diffusion_type == 'poly': if poly_coeffs is None: logger.info(f"Calibrating polynomial filter (degree K={K})...") poly_coeffs = calibrate_polynomial_filter( L, q_targets, node_to_idx, K=K, ridge=ridge ) logger.info(f"✓ Calibrated polynomial coefficients: {poly_coeffs}") else: logger.info(f"Using provided polynomial coefficients (degree={len(poly_coeffs)-1})") metadata['poly_coeffs'] = poly_coeffs.tolist() if hasattr(poly_coeffs, 'tolist') else poly_coeffs metadata['K'] = len(poly_coeffs) - 1 # STEP 2: Precompute diffused features OFFLINE logger.info("Precomputing polynomial diffusion...") X_diffused = apply_polynomial_filter(L, X, poly_coeffs) else: raise ValueError(f"Unknown diffusion_type: {diffusion_type}. Use 'heat' or 'poly'.") # Convert to torch tensors X_diffused_torch = torch.from_numpy(X_diffused).float() # Get adjacency matrix and normalize adj = nx.adjacency_matrix(G, nodelist=node_list) adj_normalized = normalize_adjacency(adj) # Convert to torch sparse tensor adj_coo = adj_normalized.tocoo() indices = torch.LongTensor(np.vstack([adj_coo.row, adj_coo.col])) values = torch.FloatTensor(adj_coo.data) shape = adj_coo.shape adj_torch = torch.sparse_coo_tensor(indices, values, shape) # STEP 3: Train Q-Caliber GCN-MF model logger.info("Training QuVINE GCN-MF model...") # Create model model = QuVINEGCNMF( n_nodes=N, input_dim=embedding_dim, hidden_dim=hidden_dim, output_dim=embedding_dim, # For unsupervised embedding mf_dim=mf_dim, n_layers=n_layers, dropout=0.5 ) # For unsupervised training, we use reconstruction loss # Train to reconstruct adjacency matrix from embeddings optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay) # Pre-sample balanced edges and non-edges for training edges = list(G.edges()) n_edges = len(edges) n_samples_per_class = min(500, n_edges) # Sample 500 positive and 500 negative # Sample positive edges (actual edges in graph) if n_samples_per_class == 0: raise ValueError("GCN-MF training requires at least one edge in the graph.") pos_edge_indices = np.random.choice(n_edges, size=n_samples_per_class, replace=False) pos_edges = [edges[i] for i in pos_edge_indices] # Sample negative edges (non-edges), avoiding duplicates neg_edges = set() attempts = 0 max_attempts = max(n_samples_per_class * 20, 100) while len(neg_edges) < n_samples_per_class and attempts < max_attempts: u = np.random.randint(0, N) v = np.random.randint(0, N) if u != v and not G.has_edge(node_list[u], node_list[v]): edge = (u, v) if u < v else (v, u) neg_edges.add(edge) attempts += 1 neg_edges = list(neg_edges) # Combine positive and negative samples all_edges = [(node_to_idx[u], node_to_idx[v]) for u, v in pos_edges] + neg_edges all_labels = [1.0] * len(pos_edges) + [0.0] * len(neg_edges) # Convert to tensors edge_indices_train = torch.LongTensor(all_edges).t() # Shape: (2, n_samples) true_adj_train = torch.FloatTensor(all_labels) model.train() best_loss = float('inf') patience_counter = 0 patience = 15 for epoch in range(epochs): optimizer.zero_grad() # Get embeddings embeddings = model.get_embeddings(X_diffused_torch, adj_torch) # Compute similarity scores for sampled edges scores = (embeddings[edge_indices_train[0]] * embeddings[edge_indices_train[1]]).sum(dim=1) # Binary cross-entropy loss loss = F.binary_cross_entropy_with_logits(scores, true_adj_train) 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: logger.info(f"Early stopping at epoch {epoch+1}") break if (epoch + 1) % 50 == 0: logger.info(f"Epoch {epoch+1}/{epochs}: Loss = {loss.item():.4f}") # STEP 4: Extract final embeddings model.eval() with torch.no_grad(): final_embeddings = model.get_embeddings(X_diffused_torch, adj_torch) final_embeddings = final_embeddings.cpu().numpy() logger.info(f"✓ QuVINE GCN-MF embeddings generated: shape {final_embeddings.shape}") metadata['final_embedding_shape'] = final_embeddings.shape return final_embeddings, metadata
[docs] def generate_qcaliber_gcnmf_heat_embedding(G, q_targets, embedding_dim=128, t_star=None, **kwargs): """ Generate QuVINE Heat+GCN-MF (HGCNMF) embeddings. Convenience wrapper for heat kernel variant. Args: G: NetworkX graph q_targets: Quantum walk targets for calibration embedding_dim: Embedding dimension t_star: Pre-calibrated time parameter (optional) **kwargs: Additional arguments for generate_qcaliber_gcnmf_embedding Returns: embeddings: Node embeddings [N, embedding_dim] metadata: Calibration and training info """ return generate_quvine_gcnmf_embedding( G, q_targets, embedding_dim=embedding_dim, diffusion_type='heat', t_star=t_star, **kwargs )
[docs] def generate_qcaliber_gcnmf_poly_embedding(G, q_targets, embedding_dim=128, poly_coeffs=None, K=4, ridge=1e-6, **kwargs): """ Generate QuVINE Polynomial+GCN-MF (PGCNMF) embeddings. Convenience wrapper for polynomial filter variant. Args: G: NetworkX graph q_targets: Quantum walk targets for calibration embedding_dim: Embedding dimension poly_coeffs: Pre-calibrated coefficients (optional) K: Polynomial degree ridge: Ridge regularization **kwargs: Additional arguments for generate_qcaliber_gcnmf_embedding Returns: embeddings: Node embeddings [N, embedding_dim] metadata: Calibration and training info """ return generate_quvine_gcnmf_embedding( G, q_targets, embedding_dim=embedding_dim, diffusion_type='poly', poly_coeffs=poly_coeffs, K=K, ridge=ridge, **kwargs )
[docs] def generate_baseline_gcnmf_embedding( G: nx.Graph, embedding_dim: int = 128, hidden_dim: int = 64, mf_dim: int = 64, n_layers: int = 2, epochs: int = 200, lr: float = 0.01, weight_decay: float = 5e-4, random_state: int = 42, device: str = "cpu", ) -> np.ndarray: """ Generate baseline GCN-MF embeddings (WITHOUT quantum calibration). This serves as a classical baseline to compare against QuVINE methods. Uses standard GCN-MF without any quantum-calibrated diffusion. Args: G: NetworkX graph embedding_dim: Final embedding dimension hidden_dim: GCN hidden dimension mf_dim: Matrix factorization dimension n_layers: Number of GCN layers epochs: Training epochs lr: Learning rate weight_decay: L2 regularization random_state: Random seed Returns: Node embeddings [N, embedding_dim] Example: >>> import networkx as nx >>> from qbiocode.apps.quvine.baselines.gcn_mf import generate_baseline_gcnmf_embedding >>> >>> G = nx.karate_club_graph() >>> embeddings = generate_baseline_gcnmf_embedding(G, embedding_dim=128) >>> print(embeddings.shape) # (34, 128) """ if not TORCH_AVAILABLE: raise ImportError("PyTorch is required for GCN-MF. Install with: pip install torch") import torch import torch.nn.functional as F import torch.optim as optim np.random.seed(random_state) torch.manual_seed(random_state) dev = torch.device("cuda" if torch.cuda.is_available() else "cpu") \ if device == "auto" else torch.device(device) N = G.number_of_nodes() node_list = list(G.nodes()) node_to_idx = {node: idx for idx, node in enumerate(node_list)} # Get adjacency matrix and normalize A = nx.adjacency_matrix(G, nodelist=node_list) A_norm = normalize_adjacency(A) # Convert to PyTorch tensors A_norm_torch = torch.FloatTensor(A_norm.toarray()).to(dev) # Generate random input features (no diffusion) X = np.random.randn(N, embedding_dim) row_norms = np.linalg.norm(X, axis=1, keepdims=True) row_norms[row_norms == 0] = 1.0 X = X / row_norms X_torch = torch.FloatTensor(X).to(dev) # Create baseline GCNMF model model = GCNMF( n_nodes=N, input_dim=embedding_dim, hidden_dim=hidden_dim, output_dim=embedding_dim, mf_dim=mf_dim, n_layers=n_layers ).to(dev) optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay) # Pre-sample balanced edges and non-edges for training edges = list(G.edges()) n_edges = len(edges) n_samples_per_class = min(500, n_edges) # Sample 500 positive and 500 negative # Sample positive edges (actual edges in graph) if n_samples_per_class == 0: raise ValueError("Baseline GCN-MF training requires at least one edge in the graph.") pos_edge_indices = np.random.choice(n_edges, size=n_samples_per_class, replace=False) pos_edges = [edges[i] for i in pos_edge_indices] # Sample negative edges (non-edges), respecting arbitrary node labels and avoiding duplicates neg_edges = set() attempts = 0 max_attempts = max(n_samples_per_class * 20, 100) while len(neg_edges) < n_samples_per_class and attempts < max_attempts: u_idx = np.random.randint(0, N) v_idx = np.random.randint(0, N) u = node_list[u_idx] v = node_list[v_idx] if u != v and not G.has_edge(u, v): edge = (u_idx, v_idx) if u_idx < v_idx else (v_idx, u_idx) neg_edges.add(edge) attempts += 1 neg_edges = list(neg_edges) # Combine positive and negative samples all_edges = [(node_to_idx[u], node_to_idx[v]) for u, v in pos_edges] + neg_edges all_labels = [1.0] * len(pos_edges) + [0.0] * len(neg_edges) # Convert to tensors edge_indices_train = torch.LongTensor(all_edges).t().to(dev) # Shape: (2, n_samples) true_adj_train = torch.FloatTensor(all_labels).to(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() embeddings = model.get_embeddings(X_torch, A_norm_torch) scores = (embeddings[edge_indices_train[0]] * embeddings[edge_indices_train[1]]).sum(dim=1) loss = F.binary_cross_entropy_with_logits(scores, true_adj_train) 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: logger.info(f"Early stopping at epoch {epoch+1}") break if (epoch + 1) % 50 == 0: logger.info(f"Baseline GCN-MF Epoch {epoch+1}/{epochs}, Loss: {loss.item():.4f}") # Extract final embeddings model.eval() with torch.no_grad(): final_embeddings = model.get_embeddings(X_torch, A_norm_torch) embeddings_np = final_embeddings.cpu().numpy() return embeddings_np
[docs] def generate_baseline_filter_embedding_wrapper( G: nx.Graph, filter_type: str = 'heat', t: float = 1.0, K: int = 4, embedding_dim: int = 128, normalize: bool = True, random_state: int = 42 ) -> np.ndarray: """ Generate baseline graph filter embeddings (WITHOUT quantum calibration). Wrapper for the baseline filter function in quantum_filters.py. This serves as a classical baseline to compare against QuVINE methods. Args: G: NetworkX graph filter_type: Type of filter ('heat' or 'poly') t: Time parameter for heat kernel (if filter_type='heat') K: Polynomial degree (if filter_type='poly') embedding_dim: Embedding dimension normalize: Whether to normalize Laplacian random_state: Random seed Returns: Node embeddings [N, embedding_dim] Example: >>> import networkx as nx >>> from qbiocode.apps.quvine.baselines.gcn_mf import generate_baseline_filter_embedding_wrapper >>> >>> G = nx.karate_club_graph() >>> >>> # Heat kernel baseline >>> embeddings_heat = generate_baseline_filter_embedding_wrapper( ... G, filter_type='heat', t=1.0, embedding_dim=128 ... ) >>> >>> # Polynomial filter baseline >>> embeddings_poly = generate_baseline_filter_embedding_wrapper( ... G, filter_type='poly', K=4, embedding_dim=128 ... ) """ from qbiocode.apps.quvine.embedding.quantum_filters import generate_baseline_filter_embedding return generate_baseline_filter_embedding( G=G, filter_type=filter_type, t=t, K=K, embedding_dim=embedding_dim, use_features=False, features=None, normalize=normalize, random_state=random_state )