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

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

import numpy as np
import random
import networkx as nx
from collections import defaultdict
import pandas as pd


[docs] class SeedTargetEvaluator: """ Evaluates seed→target prioritization on a subgraph. """ def __init__(self, subgraph, seeds, targets, nodes=None): self.subgraph = nx.Graph(subgraph) self.nodes = list(nodes) if nodes is not None else list(subgraph.nodes()) self.node2i = {v: i for i, v in enumerate(self.nodes)} self.i2node = {i: v for v, i in self.node2i.items()} self.seeds = set(seeds) & set(self.nodes) self.targets = set(targets) & set(self.nodes) if not self.seeds: raise ValueError("SeedTargetEvaluator requires at least one seed node in the subgraph.") self.seed_indices = {self.node2i[v] for v in self.seeds} self.target_indices = {self.node2i[v] for v in self.targets} self.candidate_indices = [ self.node2i[v] for v in self.nodes if v not in self.seeds ] self.candidate_nodes = [self.i2node[i] for i in self.candidate_indices] self.deg = dict(self.subgraph.degree()) self.dist = self._compute_min_distance_to_seeds() # --------------------------- # Internals # --------------------------- def _compute_min_distance_to_seeds(self): # Single O(V+E) multi-source BFS instead of O(|seeds|*(V+E)) individual calls. lengths = nx.multi_source_dijkstra_path_length(self.subgraph, self.seeds) return {v: lengths.get(v, float("inf")) for v in self.nodes} def _rank_indices(self, scores): return sorted( self.candidate_indices, key=lambda i: scores[i], reverse=True ) # --------------------------- # Metrics # ---------------------------
[docs] def recall_at_k(self, scores, target_indices, k): ranked = self._rank_indices(scores) top_k = ranked[:k] hits = sum(i in target_indices for i in top_k) return hits / max(len(target_indices), 1)
[docs] def precision_at_k(self, scores, target_indices, k): ranked = self._rank_indices(scores) top_k = ranked[:k] hits = sum(i in target_indices for i in top_k) return hits / k
# --------------------------- # Controls # ---------------------------
[docs] def degree_matched_targets(self, tolerance=0.1, random_state=None): if random_state is not None: random.seed(random_state) matched = set() candidates = set(self.candidate_nodes) - self.targets for t in self.targets: dt = self.deg[t] low = int((1 - tolerance) * dt) high = int((1 + tolerance) * dt) pool = [ v for v in candidates if low <= self.deg[v] <= high ] if pool: matched.add(random.choice(pool)) return matched
[docs] def distance_matched_targets(self, random_state=None): if random_state is not None: random.seed(random_state) matched = set() candidates = set(self.candidate_nodes) - self.targets bins = defaultdict(list) for v in candidates: bins[self.dist[v]].append(v) for t in self.targets: d = self.dist[t] if bins[d]: matched.add(random.choice(bins[d])) return matched
# --------------------------- # Main evaluation # ---------------------------
[docs] def evaluate(self, scores, k_values=[20, 50, 100], n_repeats=20, deg_tol=0.1): results = {} # True targets results["true"] = { "recall": { k: self.recall_at_k(scores, self.target_indices, k) for k in k_values }, "precision": { k: self.precision_at_k(scores, self.target_indices, k) for k in k_values } } # Degree-matched deg_recalls = {k: [] for k in k_values} deg_precisions = {k: [] for k in k_values} for r in range(n_repeats): dm = self.degree_matched_targets( tolerance=deg_tol, random_state=r ) dm_idx = {self.node2i[v] for v in dm} for k in k_values: deg_recalls[k].append(self.recall_at_k(scores, dm_idx, k)) deg_precisions[k].append(self.precision_at_k(scores, dm_idx, k)) results["degree_matched"] = { "recall": { k: (np.mean(deg_recalls[k]), np.std(deg_recalls[k])) for k in k_values }, "precision": { k: (np.mean(deg_precisions[k]), np.std(deg_precisions[k])) for k in k_values } } # Distance-matched dist_recalls = {k: [] for k in k_values} dist_precisions = {k: [] for k in k_values} for r in range(n_repeats): dm = self.distance_matched_targets(random_state=r) dm_idx = {self.node2i[v] for v in dm} for k in k_values: dist_recalls[k].append(self.recall_at_k(scores, dm_idx, k)) dist_precisions[k].append(self.precision_at_k(scores, dm_idx, k)) results["distance_matched"] = { "recall": { k: (np.mean(dist_recalls[k]), np.std(dist_recalls[k])) for k in k_values }, "precision": { k: (np.mean(dist_precisions[k]), np.std(dist_precisions[k])) for k in k_values } } return results
[docs] def evaluate_embeddings_ranking( scores_by_method, subgraph, seeds, targets, nodes, k_values=[30, 60, 90, 120], n_repeats=30, deg_tol=0.1, iteration=None, ): """Score every method's node ranking against the target set. Args: scores_by_method (Dict[str, np.ndarray]): Mapping from method name to node-level score vector. Returns: pd.DataFrame: One tidy row per method, k value and repeat. """ subgraph = nx.Graph(subgraph) valid_seeds = set(seeds) & set(subgraph.nodes()) valid_targets = set(targets) & set(subgraph.nodes()) if len(valid_seeds) == 0: raise ValueError("No seed nodes present in evaluation graph") if len(valid_targets) == 0: raise ValueError("No target nodes present in evaluation graph") st_eval = SeedTargetEvaluator( subgraph=subgraph, seeds=valid_seeds, targets=valid_targets, nodes=nodes, ) rows = [] for method, scores in scores_by_method.items(): res = st_eval.evaluate( scores=scores, k_values=k_values, n_repeats=n_repeats, deg_tol=deg_tol, ) for control in ["true", "degree_matched", "distance_matched"]: for metric in ["recall", "precision"]: for k in k_values: if control == "true": mean = res[control][metric][k] std = 0.0 else: mean, std = res[control][metric][k] rows.append({ "iteration": iteration, "method": method, "control": control, "metric": metric, "k": k, "mean": mean, "std": std, }) return pd.DataFrame(rows)
[docs] def l2_normalize(X, axis=1, eps=1e-12): norm = np.linalg.norm(X, axis=axis, keepdims=True) return X / (norm + eps)
[docs] def seed_centroid_scores(Z, seed_indices): """ Generates centroid scores based on seed node embeddings. Z: (n,e) embedding matrix aligned with subgraph nodes seed_indices: list of indices corresponding to seed nodes in Z Returns ------- scores: cosine similarity to seed centroid """ if len(seed_indices) == 0: return np.zeros(Z.shape[0], dtype=float) Z_norm = l2_normalize(Z, axis=1) Z_seed = Z_norm[seed_indices] centroid = Z_seed.mean(axis=0) centroid /= max(np.linalg.norm(centroid), 1e-12) scores = Z_norm @ centroid return scores
[docs] def max_seed_cosine_scores(Z, seed_indices, block=4096): """ Returns scores[v] = max_{s in seeds} cosine(z_v, z_s) Works for any embedding Z. block: compute in chunks to reduce memory if needed. """ Z = l2_normalize(Z) seed_idx = list(seed_indices) if len(seed_idx) == 0: return np.zeros(Z.shape[0], dtype=float) S = Z[seed_idx] # (m, e) scores = np.full(Z.shape[0], -np.inf, dtype=float) # chunked matmul: (block × e) @ (e × m) -> (block × m) for start in range(0, Z.shape[0], block): end = min(start + block, Z.shape[0]) sims = Z[start:end] @ S.T scores[start:end] = sims.max(axis=1) return scores