Source code for qbiocode.apps.quvine.evaluation.link_prediction

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

"""
Link Prediction Evaluation Module

This module provides functions for evaluating node embeddings on link prediction tasks.
Includes edge sampling strategies, edge feature computation, and comprehensive evaluation metrics.

Edge Sampling Strategies:

1. Random negative sampling
2. Hard negatives: 2-hop node pairs (common neighbors but no edge)
3. Same-community non-edges
4. Stratified sampling by node degree

Edge Feature Computation:

1. Hadamard product: ``u ⊙ v``
2. Average: ``(u + v) / 2``
3. L1 distance: ``|u - v|``
4. L2 distance: ``||u - v||₂``
5. Concatenation: ``[u; v]``
6. Inner product (dot product): ``u · v``
7. Cosine similarity: ``(u · v) / (||u|| ||v||)``

Evaluation Metrics:

- AUC-ROC, AUC-PR
- Precision@K, Recall@K
- Hit@K, MRR (Mean Reciprocal Rank)
"""

import numpy as np
import networkx as nx
from typing import Dict, List, Tuple, Optional, Set
from sklearn.metrics import roc_auc_score, average_precision_score, f1_score
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
import warnings


def _make_bidi(edges) -> Set[Tuple]:
    """Return a bidirectional (both-direction) copy of an edge collection."""
    s = set(edges)
    s.update((v, u) for u, v in edges)
    return s


[docs] def sample_negative_edges( G: nx.Graph, n_samples: int, existing_edges: Optional[Set[Tuple[int, int]]] = None, strategy: str = 'random', seed: int = 42 ) -> List[Tuple[int, int]]: """ Sample negative edges (non-existent edges) from the graph. Args: G: NetworkX graph n_samples: Number of negative edges to sample existing_edges: Set of existing edges to avoid strategy: Sampling strategy ('random', 'hard_2hop', 'same_community') seed: Random seed Returns: List of negative edge tuples """ # A local generator, not np.random.seed(): reseeding the global RNG makes every # subsequent random draw in the calling process depend on this function having been # called, which silently couples unrelated results together. rng = np.random.default_rng(seed) nodes = list(G.nodes()) n_nodes = len(nodes) if existing_edges is None: existing_edges = set(G.edges()) existing_edges_bidirectional = _make_bidi(existing_edges) negative_edges = [] if strategy == 'random': # Random negative sampling max_attempts = n_samples * 10 attempts = 0 while len(negative_edges) < n_samples and attempts < max_attempts: u = nodes[rng.integers(n_nodes)] v = nodes[rng.integers(n_nodes)] if u != v and (u, v) not in existing_edges_bidirectional: negative_edges.append((u, v)) existing_edges_bidirectional.add((u, v)) existing_edges_bidirectional.add((v, u)) attempts += 1 elif strategy == 'hard_2hop': # Hard negatives: 2-hop node pairs (common neighbors but no edge) for u in nodes: u_neighbors = set(G.neighbors(u)) # Find 2-hop neighbors two_hop_neighbors = set() for neighbor in u_neighbors: two_hop_neighbors.update(G.neighbors(neighbor)) # Remove 1-hop neighbors and self two_hop_neighbors -= u_neighbors two_hop_neighbors.discard(u) # Sample from 2-hop neighbors candidates = [(u, v) for v in two_hop_neighbors if (u, v) not in existing_edges_bidirectional] if candidates: sample_size = min(len(candidates), max(1, n_samples // n_nodes)) sampled = rng.choice(len(candidates), size=sample_size, replace=False) for idx in sampled: if len(negative_edges) < n_samples: edge = candidates[idx] negative_edges.append(edge) existing_edges_bidirectional.add(edge) existing_edges_bidirectional.add((edge[1], edge[0])) elif strategy == 'same_community': # Same-community non-edges (requires community detection) try: import community as community_louvain communities = community_louvain.best_partition(G) except ImportError: warnings.warn("python-louvain not installed, falling back to random sampling") return sample_negative_edges(G, n_samples, existing_edges, 'random', seed) # Group nodes by community community_nodes = {} for node, comm in communities.items(): if comm not in community_nodes: community_nodes[comm] = [] community_nodes[comm].append(node) # Sample within communities for comm, comm_nodes in community_nodes.items(): if len(comm_nodes) < 2: continue # Sample pairs within community sample_size = max(1, n_samples // len(community_nodes)) attempts = 0 max_attempts = sample_size * 10 while len(negative_edges) < n_samples and attempts < max_attempts: u, v = rng.choice(comm_nodes, size=2, replace=False) if (u, v) not in existing_edges_bidirectional: negative_edges.append((u, v)) existing_edges_bidirectional.add((u, v)) existing_edges_bidirectional.add((v, u)) attempts += 1 else: raise ValueError(f"Unknown strategy: {strategy}") if len(negative_edges) < n_samples: warnings.warn(f"Could only sample {len(negative_edges)} negative edges (requested {n_samples})") return negative_edges[:n_samples]
[docs] def split_edges( G: nx.Graph, test_ratio: float = 0.2, val_ratio: float = 0.1, negative_sampling_strategy: str = 'random', seed: int = 42 ) -> Tuple[nx.Graph, List[Tuple[int, int]], List[Tuple[int, int]], List[Tuple[int, int]]]: """ Split graph edges into train/validation/test sets. Args: G: NetworkX graph test_ratio: Fraction of edges for testing val_ratio: Fraction of edges for validation negative_sampling_strategy: Strategy for sampling negative edges seed: Random seed Returns: Tuple of (train_graph, val_edges, test_edges, negative_edges) """ # Local generator rather than np.random.seed(seed) -- see sample_negative_edges. rng = np.random.default_rng(seed) edges = list(G.edges()) n_edges = len(edges) # Shuffle edges rng.shuffle(edges) # Split edges n_test = int(n_edges * test_ratio) n_val = int(n_edges * val_ratio) n_train = n_edges - n_test - n_val train_edges = edges[:n_train] val_edges = edges[n_train:n_train + n_val] test_edges = edges[n_train + n_val:] # Create training graph train_graph = nx.Graph() train_graph.add_nodes_from(G.nodes()) train_graph.add_edges_from(train_edges) # Ensure training graph is connected (if original was connected) if nx.is_connected(G) and not nx.is_connected(train_graph): warnings.warn("Training graph is disconnected after edge split. Consider reducing test_ratio.") # Sample negative edges (same number as test edges) negative_edges = sample_negative_edges( G, n_test, set(edges), strategy=negative_sampling_strategy, seed=seed ) return train_graph, val_edges, test_edges, negative_edges
[docs] def compute_edge_features( embeddings: np.ndarray, node_list: List[int], edges: List[Tuple[int, int]], method: str = 'hadamard' ) -> np.ndarray: """ Compute edge features from node embeddings. Args: embeddings: Node embedding matrix (n_nodes x embedding_dim) node_list: List of node IDs corresponding to embedding rows edges: List of edge tuples method: Feature computation method Returns: Edge feature matrix (n_edges x feature_dim) """ node_to_idx = {node: idx for idx, node in enumerate(node_list)} edge_features = [] for u, v in edges: if u not in node_to_idx or v not in node_to_idx: warnings.warn(f"Edge ({u}, {v}) contains unknown node(s). Skipping.") continue u_emb = embeddings[node_to_idx[u]] v_emb = embeddings[node_to_idx[v]] if method == 'hadamard': feature = u_emb * v_emb elif method == 'average': feature = (u_emb + v_emb) / 2 elif method == 'l1': feature = np.abs(u_emb - v_emb) elif method == 'l2': feature = (u_emb - v_emb) ** 2 elif method == 'concat': feature = np.concatenate([u_emb, v_emb]) elif method == 'inner_product' or method == 'dot': # Inner product (dot product) - returns scalar feature = np.array([np.dot(u_emb, v_emb)]) elif method == 'cosine': # Cosine similarity - returns scalar norm_u = np.linalg.norm(u_emb) norm_v = np.linalg.norm(v_emb) if norm_u > 0 and norm_v > 0: feature = np.array([np.dot(u_emb, v_emb) / (norm_u * norm_v)]) else: feature = np.array([0.0]) else: raise ValueError(f"Unknown method: {method}") edge_features.append(feature) return np.array(edge_features)
def _extract_edge_endpoints( embeddings: np.ndarray, node_list: List, edges: List[Tuple], node_to_idx: dict, ) -> Tuple[np.ndarray, np.ndarray]: """Return (U, V) embedding matrices for valid edges.""" U, V = [], [] for u, v in edges: if u not in node_to_idx or v not in node_to_idx: warnings.warn(f"Edge ({u}, {v}) contains unknown node(s). Skipping.") continue U.append(embeddings[node_to_idx[u]]) V.append(embeddings[node_to_idx[v]]) if not U: return np.empty((0, embeddings.shape[1])), np.empty((0, embeddings.shape[1])) return np.array(U), np.array(V) def _apply_edge_method(U: np.ndarray, V: np.ndarray, method: str) -> np.ndarray: """Vectorised edge feature from pre-extracted endpoint embeddings.""" if method == 'hadamard': return U * V elif method == 'average': return (U + V) / 2 elif method == 'l1': return np.abs(U - V) elif method == 'l2': return (U - V) ** 2 elif method in ('inner_product', 'dot'): return (U * V).sum(axis=1, keepdims=True) elif method == 'cosine': norms = np.linalg.norm(U, axis=1, keepdims=True) * np.linalg.norm(V, axis=1, keepdims=True) return np.where(norms > 0, (U * V).sum(axis=1, keepdims=True) / norms, 0.0) elif method == 'concat': return np.concatenate([U, V], axis=1) else: raise ValueError(f"Unknown method: {method}") def _classify_and_score( X_train: np.ndarray, y_train: np.ndarray, X_test: np.ndarray, y_test: np.ndarray, n_pos: int, n_neg: int, k_values: List[int], random_state: int, classifier: str = 'logistic', ) -> Dict[str, float]: """Scale, train classifier, and return link prediction metrics.""" scaler = StandardScaler() X_train_sc = scaler.fit_transform(X_train) X_test_sc = scaler.transform(X_test) if classifier == 'logistic': clf = LogisticRegression(max_iter=1000, random_state=random_state) elif classifier == 'random_forest': clf = RandomForestClassifier(n_estimators=100, random_state=random_state) else: raise ValueError(f"Unknown classifier: {classifier}") clf.fit(X_train_sc, y_train) y_pred = clf.predict(X_test_sc) y_scores = (clf.predict_proba(X_test_sc)[:, 1] if hasattr(clf, 'predict_proba') else clf.decision_function(X_test_sc)) out = { 'auc_roc': roc_auc_score(y_test, y_scores), 'auc_pr': average_precision_score(y_test, y_scores), 'f1': f1_score(y_test, y_pred, average='binary', zero_division=0), 'n_positive': n_pos, 'n_negative': n_neg, 'n_train': len(y_train), 'n_test': len(y_test), } sorted_idx = np.argsort(y_scores)[::-1] y_sorted = y_test[sorted_idx] for k in k_values: if k <= len(y_sorted): top_k = y_sorted[:k] out[f'precision@{k}'] = np.sum(top_k) / k out[f'recall@{k}'] = np.sum(top_k) / np.sum(y_test) if np.sum(y_test) > 0 else 0.0 out[f'hit@{k}'] = 1.0 if np.sum(top_k) > 0 else 0.0 pos_idx = np.where(y_sorted == 1)[0] out['mrr'] = float(np.mean(1.0 / (pos_idx + 1))) if len(pos_idx) > 0 else 0.0 return out
[docs] def evaluate_all_edge_feature_methods( embeddings: np.ndarray, node_list: List[int], positive_edges: List[Tuple[int, int]], negative_edges: List[Tuple[int, int]], k_values: List[int] = [10, 50, 100], random_state: int = 42, train_positive_edges: Optional[List[Tuple[int, int]]] = None, train_negative_edges: Optional[List[Tuple[int, int]]] = None, ) -> Dict[str, Dict[str, float]]: """ Evaluate link prediction using all edge feature methods. Args: embeddings: Node embedding matrix node_list: List of node IDs positive_edges: Test positive edges negative_edges: Test negative edges k_values: K values for metrics random_state: Random seed train_positive_edges: Training positive edges (no leakage when provided) train_negative_edges: Training negative edges (no leakage when provided) Returns: Dictionary mapping method names to evaluation results """ # Pre-extract endpoint embeddings once for each edge set (avoids rebuilding # node_to_idx and re-indexing embeddings for every one of the 6 methods). node_to_idx = {node: idx for idx, node in enumerate(node_list)} U_pos, V_pos = _extract_edge_endpoints(embeddings, node_list, positive_edges, node_to_idx) U_neg, V_neg = _extract_edge_endpoints(embeddings, node_list, negative_edges, node_to_idx) _empty_result = {'error': 'no_valid_features', 'auc_roc': 0.0, 'auc_pr': 0.0, 'f1': 0.0, 'n_positive': 0, 'n_negative': 0} if len(U_pos) == 0 or len(U_neg) == 0: return {m: _empty_result for m in ['hadamard', 'average', 'l1', 'l2', 'inner_product', 'cosine']} have_train = train_positive_edges is not None and train_negative_edges is not None if have_train: U_tp, V_tp = _extract_edge_endpoints(embeddings, node_list, train_positive_edges, node_to_idx) U_tn, V_tn = _extract_edge_endpoints(embeddings, node_list, train_negative_edges, node_to_idx) have_train = len(U_tp) > 0 and len(U_tn) > 0 if not have_train: warnings.warn("Train edge features empty; falling back to internal split.") y_test_all = np.repeat([1, 0], [len(U_pos), len(U_neg)]) y_train_fixed = np.repeat([1, 0], [len(U_tp), len(U_tn)]) if have_train else None results = {} for method in ['hadamard', 'average', 'l1', 'l2', 'inner_product', 'cosine']: try: X_test_all = np.vstack([ _apply_edge_method(U_pos, V_pos, method), _apply_edge_method(U_neg, V_neg, method), ]) if have_train: X_train = np.vstack([ _apply_edge_method(U_tp, V_tp, method), _apply_edge_method(U_tn, V_tn, method), ]) X_tr, y_tr, X_te, y_te = X_train, y_train_fixed, X_test_all, y_test_all else: X_tr, X_te, y_tr, y_te = train_test_split( X_test_all, y_test_all, test_size=0.3, random_state=random_state, stratify=y_test_all, ) results[method] = _classify_and_score( X_tr, y_tr, X_te, y_te, n_pos=len(U_pos), n_neg=len(U_neg), k_values=k_values, random_state=random_state, ) except Exception as e: warnings.warn(f"Link prediction with {method} failed: {e}") results[method] = {'error': str(e)} return results