Source code for qbiocode.apps.quvine.reproducibility.split_generator

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

"""
Task Split Generator for QuVINE

Generates and saves task-specific data splits to ensure all methods use identical splits.
"""

import json
import pickle
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Set, Any, cast
import networkx as nx
import numpy as np
import torch


def _to_degree_dict(G: nx.Graph) -> Dict[Any, int]:
    """Create a plain degree dictionary compatible with static type checking."""
    graph_any = cast(Any, G)
    return {node: int(graph_any.degree[node]) for node in G.nodes()}


def _to_index_list(values: Any) -> List[int]:
    """Convert numpy arrays or iterables to plain Python int lists."""
    if hasattr(values, "tolist"):
        return [int(v) for v in values.tolist()]
    return [int(v) for v in values]

from .seed_manager import SeedManager
from .dataset_registry import DatasetRegistry


[docs] class SplitGenerator: """ Generates and saves task-specific splits for reproducible benchmarking. All splits are pre-generated once and saved to disk. Methods then load these pre-generated splits instead of creating their own. Supports three tasks: 1. Node Classification: train/val/test node indices + labels 2. Link Prediction: train/val/test edge splits + negative samples 3. Node Ranking: target nodes for ranking evaluation """
[docs] def __init__( self, output_dir: Path, seed_manager: SeedManager, registry: DatasetRegistry ): """ Initialize split generator. Parameters ---------- output_dir : Path Root directory for saving splits seed_manager : SeedManager Seed manager for reproducible split generation registry : DatasetRegistry Dataset registry to update with split paths """ self.output_dir = Path(output_dir) self.seed_manager = seed_manager self.registry = registry
[docs] def generate_all_splits( self, dataset_name: str, repetition_id: int, G: nx.Graph, tasks: List[str] = ["node_classification", "link_prediction", "node_ranking"], disease_nodes: Optional[Dict[str, Any]] = None, task_config: Optional[Dict[str, Any]] = None, split_base_dir: Optional[Path] = None, ) -> Dict[str, Path]: """ Generate all task splits for a dataset. Parameters ---------- dataset_name : str Dataset name repetition_id : int Repetition ID G : nx.Graph Graph to generate splits for tasks : List[str] Tasks to generate splits for Returns ------- Dict[str, Path] Mapping from task name to split file path """ split_paths = {} task_config = task_config or {} split_base_dir = Path(split_base_dir) if split_base_dir is not None else None for task in tasks: if task == "node_classification": split_path = self.generate_node_classification_split( dataset_name, repetition_id, G, disease_nodes=disease_nodes, split_base_dir=split_base_dir, **task_config.get("node_classification", {}) ) elif task == "link_prediction": split_path = self.generate_link_prediction_split( dataset_name, repetition_id, G, disease_nodes=disease_nodes, split_base_dir=split_base_dir, **task_config.get("link_prediction", {}) ) elif task == "node_ranking": split_path = self.generate_node_ranking_split( dataset_name, repetition_id, G, disease_nodes=disease_nodes, split_base_dir=split_base_dir, **task_config.get("node_ranking", {}) ) else: raise ValueError(f"Unknown task: {task}") split_paths[task] = split_path # Update registry self.registry.add_split(dataset_name, repetition_id, task, split_path) return split_paths
[docs] def generate_node_classification_split( self, dataset_name: str, repetition_id: int, G: nx.Graph, train_ratio: float = 0.6, val_ratio: float = 0.2, test_ratio: float = 0.2, min_class_size: int = 5, disease_nodes: Optional[Dict[str, Any]] = None, positive_class_definition: str = "disease_seed_or_target", negative_class_definition: str = "non_disease_nodes", split_strategy: str = "stratified", split_base_dir: Optional[Path] = None, ) -> Path: """ Generate node classification split with synthetic labels. Labels are generated using community detection (Louvain). Parameters ---------- dataset_name : str Dataset name repetition_id : int Repetition ID G : nx.Graph Graph train_ratio : float Training set ratio val_ratio : float Validation set ratio test_ratio : float Test set ratio min_class_size : int Minimum nodes per class Returns ------- Path Path to saved split file """ canonical_seed = self.seed_manager.get_seed(dataset_name, repetition_id) seed = self.seed_manager.get_component_seed( dataset_name, repetition_id, "node_classification_split" ) nodes = sorted(G.nodes()) if disease_nodes is not None: seed_nodes = set(map(str, disease_nodes.get("seed_node_ids", []))) target_nodes = set(map(str, disease_nodes.get("target_node_ids", []))) positive_nodes = seed_nodes | target_nodes labels = np.array([1 if str(node) in positive_nodes else 0 for node in nodes], dtype=int) pos_idx = np.where(labels == 1)[0] neg_idx = np.where(labels == 0)[0] if len(pos_idx) < 2 or len(neg_idx) < 2: raise ValueError("Insufficient class support for PPI node classification split generation.") from sklearn.model_selection import train_test_split train_idx, temp_idx = train_test_split( np.arange(len(nodes)), train_size=train_ratio, stratify=labels, random_state=seed ) val_size = val_ratio / (val_ratio + test_ratio) val_idx, test_idx = train_test_split( temp_idx, train_size=val_size, stratify=labels[temp_idx], random_state=seed + 1 ) split_data = { "dataset_name": dataset_name, "repetition_id": repetition_id, "task": "node_classification", "seed": seed, "canonical_seed": canonical_seed, "nodes": nodes, "labels": [int(v) for v in labels.tolist()], "train_idx": _to_index_list(train_idx), "val_idx": _to_index_list(val_idx), "test_idx": _to_index_list(test_idx), "num_classes": 2, "train_ratio": train_ratio, "val_ratio": val_ratio, "test_ratio": test_ratio, "positive_class_definition": positive_class_definition, "negative_class_definition": negative_class_definition, "split_seed": seed, "split_strategy": split_strategy, "class_distribution": { "train": { "positive": int(labels[train_idx].sum()), "negative": int(len(train_idx) - labels[train_idx].sum()), }, "validation": { "positive": int(labels[val_idx].sum()), "negative": int(len(val_idx) - labels[val_idx].sum()), }, "test": { "positive": int(labels[test_idx].sum()), "negative": int(len(test_idx) - labels[test_idx].sum()), }, }, "disease_seed_nodes": sorted(seed_nodes), "disease_target_nodes": sorted(target_nodes), "label_generation_method": "ppi_disease_labeling", } else: rng = np.random.default_rng(seed) from qbiocode.apps.quvine.evaluation.classification import generate_community_labels labels_dict = generate_community_labels( G, method='louvain', min_community_size=min_class_size, resolution=1.0 ) labels = np.array([labels_dict[node] for node in nodes]) from sklearn.model_selection import train_test_split train_idx, temp_idx = train_test_split( np.arange(len(nodes)), train_size=train_ratio, stratify=labels, random_state=seed ) val_size = val_ratio / (val_ratio + test_ratio) val_idx, test_idx = train_test_split( temp_idx, train_size=val_size, stratify=labels[temp_idx], random_state=seed + 1 ) split_data = { "dataset_name": dataset_name, "repetition_id": repetition_id, "task": "node_classification", "seed": seed, "canonical_seed": canonical_seed, "nodes": nodes, "labels": [int(v) for v in (labels.tolist() if hasattr(labels, 'tolist') else list(labels))], "train_idx": _to_index_list(train_idx), "val_idx": _to_index_list(val_idx), "test_idx": _to_index_list(test_idx), "num_classes": int(labels.max() + 1), "train_ratio": train_ratio, "val_ratio": val_ratio, "test_ratio": test_ratio, "split_seed": seed, "split_strategy": split_strategy, "label_generation_method": "louvain_community_detection" } # Save split with task-specific filename if split_base_dir is not None: # split_base_dir already includes the full path, don't add dataset_name/rep again split_dir = split_base_dir else: # Default: output_dir / dataset_name / rep_XX split_dir = self.output_dir / dataset_name / f"rep_{repetition_id:02d}" split_dir.mkdir(parents=True, exist_ok=True) # Save as pickle (primary format) split_path_pkl = split_dir / "node_classification_split.pkl" with open(split_path_pkl, 'wb') as f: pickle.dump(split_data, f) # Also save as JSON for inspection split_path_json = split_dir / "node_classification_split.json" with open(split_path_json, 'w') as f: json.dump(split_data, f, indent=2) return split_path_pkl
[docs] def generate_node_ranking_split( self, dataset_name: str, repetition_id: int, G: nx.Graph, n_targets: Optional[int] = None, target_selection: str = "high_degree", n_seeds: Optional[int] = None, seed_selection: str = "random", disease_nodes: Optional[Dict[str, Any]] = None, excluded_nodes: Optional[List[Any]] = None, selection_rule: Optional[str] = None, split_base_dir: Optional[Path] = None, ) -> Path: """ Generate node ranking split with seed and target nodes. For PPI networks, this should use disease-specific seeds/targets. For synthetic networks, generates synthetic seeds/targets. Parameters ---------- dataset_name : str Dataset name repetition_id : int Repetition ID G : nx.Graph Graph n_targets : int, optional Number of target nodes (default: 10% of nodes) target_selection : str Target selection strategy ('high_degree', 'random', 'central') n_seeds : int, optional Number of seed nodes (default: 5% of nodes) seed_selection : str Seed selection strategy ('random', 'high_degree') Returns ------- Path Path to saved split file """ canonical_seed = self.seed_manager.get_seed(dataset_name, repetition_id) seed = self.seed_manager.get_component_seed( dataset_name, repetition_id, "node_ranking" ) target_seed = self.seed_manager.get_component_seed( dataset_name, repetition_id, "node_ranking_target_selection" ) rng = np.random.default_rng(seed) target_rng = np.random.default_rng(target_seed) nodes = list(G.nodes()) n_nodes = len(nodes) excluded_nodes = excluded_nodes or [] if disease_nodes is not None: seed_nodes = [node for node in disease_nodes.get("seed_node_ids", []) if node in G.nodes()] target_nodes = [node for node in disease_nodes.get("target_node_ids", []) if node in G.nodes()] missing_seed_nodes = sorted(set(disease_nodes.get("seed_node_ids", [])) - set(seed_nodes)) missing_target_nodes = sorted(set(disease_nodes.get("target_node_ids", [])) - set(target_nodes)) if missing_seed_nodes or missing_target_nodes: raise ValueError( f"Disease nodes missing from graph. Missing seeds: {missing_seed_nodes}; " f"missing targets: {missing_target_nodes}" ) candidate_universe = [node for node in sorted(nodes) if node not in excluded_nodes] split_data = { "dataset_name": dataset_name, "repetition_id": repetition_id, "task": "node_ranking", "seed": seed, "canonical_seed": canonical_seed, "seed_nodes": seed_nodes, "target_nodes": target_nodes, "candidate_node_universe": candidate_universe, "excluded_nodes": excluded_nodes, "target_selection_seed": target_seed, "selection_rule": selection_rule or "use_preserved_disease_seed_and_target_nodes", "n_seeds": len(seed_nodes), "n_targets": len(target_nodes), "seed_selection": "disease_seed_nodes", "target_selection": "disease_target_nodes", "all_nodes": nodes } else: if n_targets is None: n_targets = max(10, int(0.1 * n_nodes)) if n_seeds is None: n_seeds = max(5, int(0.05 * n_nodes)) if seed_selection == "random": seed_nodes = rng.choice(nodes, size=n_seeds, replace=False).tolist() elif seed_selection == "high_degree": degrees = _to_degree_dict(G) sorted_nodes = sorted(nodes, key=lambda n: degrees[n], reverse=True) seed_nodes = sorted_nodes[:n_seeds] else: raise ValueError(f"Unknown seed selection strategy: {seed_selection}") candidate_targets = [n for n in nodes if n not in seed_nodes] if target_selection == "random": target_nodes = target_rng.choice(candidate_targets, size=min(n_targets, len(candidate_targets)), replace=False).tolist() elif target_selection == "high_degree": degrees = _to_degree_dict(G) sorted_candidates = sorted(candidate_targets, key=lambda n: degrees[n], reverse=True) target_nodes = sorted_candidates[:n_targets] elif target_selection == "central": centrality = nx.betweenness_centrality(G, k=min(100, n_nodes)) sorted_candidates = sorted(candidate_targets, key=lambda n: centrality.get(n, 0), reverse=True) target_nodes = sorted_candidates[:n_targets] else: raise ValueError(f"Unknown target selection strategy: {target_selection}") split_data = { "dataset_name": dataset_name, "repetition_id": repetition_id, "task": "node_ranking", "seed": seed, "canonical_seed": canonical_seed, "seed_nodes": seed_nodes, "target_nodes": target_nodes, "candidate_node_universe": sorted(nodes), "excluded_nodes": excluded_nodes, "target_selection_seed": target_seed, "selection_rule": selection_rule or "synthetic_selection", "n_seeds": len(seed_nodes), "n_targets": len(target_nodes), "seed_selection": seed_selection, "target_selection": target_selection, "all_nodes": nodes } # Save split with task-specific filename if split_base_dir is not None: # split_base_dir already includes the full path, don't add dataset_name/rep again split_dir = split_base_dir else: # Default: output_dir / dataset_name / rep_XX split_dir = self.output_dir / dataset_name / f"rep_{repetition_id:02d}" split_dir.mkdir(parents=True, exist_ok=True) # Save as pickle (primary format) split_path_pkl = split_dir / "node_ranking_split.pkl" with open(split_path_pkl, 'wb') as f: pickle.dump(split_data, f) # Also save as JSON for inspection split_path_json = split_dir / "node_ranking_split.json" with open(split_path_json, 'w') as f: json.dump(split_data, f, indent=2) return split_path_pkl
[docs] def load_split(self, split_path: Path) -> Dict[str, Any]: """ Load a pre-generated split from disk. Parameters ---------- split_path : Path Path to split file Returns ------- dict Split data """ # Try pickle first (faster) pkl_path = split_path.with_suffix('.pkl') if pkl_path.exists(): with open(pkl_path, 'rb') as f: return pickle.load(f) # Fall back to JSON with open(split_path, 'r') as f: return json.load(f)