# 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.
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Dict, List
import networkx as nx
import numpy as np
import pandas as pd
import time
from hydra.core.hydra_config import HydraConfig
from omegaconf import DictConfig, OmegaConf
from qbiocode.apps.quvine.data.data_loader import load_graph, load_gwas_data
from qbiocode.apps.quvine.data.prepare import PrepareGraphConfig, prepare_graph
from qbiocode.apps.quvine.api.sgns import run_sgns
from qbiocode.apps.quvine.api.targets import build_quantum_targets
from qbiocode.apps.quvine.embedding.registry import EmbeddingStore
from qbiocode.apps.quvine.analysis.compare import compare_embeddings
from qbiocode.apps.quvine.analysis.analyze import normalize
# get_stats, draw_graph, plot_metric, plot_precision_recall, plot_metric_vs_k
# live in quvine.utils.utilities (imported via the wildcard import below).
# Registry system imports
from qbiocode.apps.quvine.baselines.registry import MethodRegistry
from qbiocode.apps.quvine.baselines.registration import register_all_methods
from qbiocode.apps.quvine.baselines.hyperparameter_loader import HyperparameterLoader, set_global_loader
from qbiocode.apps.quvine.fusion.fuse import fuse_embeddings
from qbiocode.apps.quvine.evaluation.ranking import (
seed_centroid_scores,
max_seed_cosine_scores,
evaluate_embeddings_ranking
)
from qbiocode.apps.quvine.utils.seed import set_global_seed
from qbiocode.apps.quvine.utils.utilities import *
[docs]
class Pipeline:
"""
End-to-end quvine pipeline.
Stages:
Graph Loading
Seed/Target Loading
Preprocessing
View Building
Walking
Embedding Training
Evaluation
"""
def __init__(self, cfg:DictConfig):
self.cfg = cfg
self.log = logging.getLogger(self.__class__.__name__)
self.run_dir = Path(cfg.runtime.output_dir)
if self.run_dir.exists():
if self.cfg.verbose:
print(f"Directory {self.run_dir} exists")
else:
self.run_dir.mkdir(parents=True, exist_ok=True)
self.n_iters = cfg.experiment.iterations
self.base_seed = cfg.experiment.base_seed
[docs]
def run(self):
self.log.info("Pipeline started (%d iterations)", self.n_iters)
#load graph data once
graph_data = self._load_graph()
if self.cfg.verbose:
print(get_stats(graph_data))
if self.cfg.gwas_target:
source, target = self._load_gwas_data(graph_data)
else:
source = None
target = None
## Preprocess graph
graph_data = self._preprocess_graph(
graph_data,
source,
target)
if self.cfg.draw.graph:
draw_graph(cfg=self.cfg,
G=graph_data,
source=source,
target=target)
all_results = []
for it in range(self.n_iters):
self.log.info("Iteration %d / %d", it + 1, self.n_iters)
self._set_iteration_seed(it)
res = self._run_single_iteration(it, graph_data, source, target)
all_results.append(res)
if self.cfg.evaluation.enabled:
# process and save evaluation results
self._save_evaluation_results(all_results=all_results, nodes=list(graph_data.nodes))
if self.cfg.save_embeddings:
# save and output embeddings
self._save_embeddings(all_results=all_results)
#-----------------
# One iteration
# ----------------
def _run_single_iteration(self, it, graph_data, source, target):
beg_time = time.time()
# SGNS walk embeddings (views -> walks -> corpus -> word2vec).
# Shared with the quvine.embed() API via the extracted SGNS core.
embeddings = run_sgns(
self.cfg,
graph_data,
it,
kinds=list(self.cfg.walks.kinds),
n_jobs=self.cfg.runtime.n_jobs,
chunk_size=self.cfg.runtime.chunk_size,
)
store = EmbeddingStore()
for name, Z in embeddings.items():
store.add(name, Z)
end_time = time.time()
time_taken = end_time - beg_time
if self.cfg.verbose:
print(f"Time taken for one QuVINE iteration {time_taken/60} minutes")
## baselines and quantum-calibrated downstream methods
q_targets = None
if source is not None and len(source) > 0:
max_support = getattr(self.cfg.baselines, "quantum_target_max_nodes", 64)
q_targets = build_quantum_targets(graph_data, source, max_support=max_support)
# Initialize hyperparameter loader if dataset name is available
dataset_name = getattr(self.cfg.data, 'name', None)
if dataset_name:
# Determine tuning directory based on dataset type
if hasattr(self.cfg.data, 'ppi') and self.cfg.data.ppi:
tuning_dir = "ppi_tuning_by_task"
elif hasattr(self.cfg.data, 'realworld') and self.cfg.data.realworld:
tuning_dir = "realworld_tuning_by_task"
else:
tuning_dir = "tuning_by_task"
loader = HyperparameterLoader(
tuning_dir=tuning_dir,
dataset_name=dataset_name
)
set_global_loader(loader)
if self.cfg.verbose:
print(f"Loaded hyperparameter tuning from {tuning_dir}/{dataset_name}")
# Create and populate method registry
beg_time = time.time()
registry = MethodRegistry(self.cfg, base_seed=self.base_seed, verbose=self.cfg.verbose)
register_all_methods(registry)
if self.cfg.verbose:
print(f"Registered {len(registry)} methods")
print(f" - {len(registry.list_methods('baseline'))} baseline methods")
print(f" - {len(registry.list_methods('quantum'))} quantum methods")
# Run all enabled methods
results = registry.run_all(
graph_data=graph_data,
q_targets=q_targets,
store=store
)
# Print summary
end_time = time.time()
total_time = end_time - beg_time
successful = sum(1 for r in results if r.success)
failed = len(results) - successful
if self.cfg.verbose:
print(f"\n{'='*60}")
print(f"Baseline Methods Summary:")
print(f" Total methods run: {len(results)}")
print(f" Successful: {successful}")
print(f" Failed: {failed}")
print(f" Total time: {total_time/60:.2f} minutes")
print(f"{'='*60}\n")
# Print individual method times
for result in results:
if result.success:
print(f" ✓ {result.name}: {result.execution_time/60:.2f} minutes")
else:
print(f" ✗ {result.name}: FAILED - {result.error}")
## compare embeddings
if self.cfg.analysis.enabled:
comparison_metrics = compare_embeddings(
store,
cca_components=self.cfg.analysis.cca_components,
knn_k=self.cfg.analysis.knn_k,
)
else:
comparison_metrics = None
## fuse embeddings
if self.cfg.fusion.enabled:
beg_time = time.time()
L = nx.normalized_laplacian_matrix(G=graph_data,
nodelist=graph_data.nodes).toarray().astype(np.float32)
fused_list, fuse_metric = fuse_embeddings(
store,
method=self.cfg.fusion.method,
k=self.cfg.fusion.k,
L=L
)
for i, Z_fused in enumerate(fused_list):
store.add(fuse_metric[i], Z_fused)
end_time = time.time()
time_taken = end_time - beg_time
if self.cfg.verbose:
print(f"Time taken for fusion {time_taken/60} minutes")
## target prioritization evaluation
if self.cfg.evaluation.enabled:
seed_indices = [
i for i, node in enumerate(graph_data.nodes)
if node in source
]
scores_by_method = {}
for name, Z in store.items():
if self.cfg.evaluation.centroid:
scores_by_method[f"{name}_centroid"] = seed_centroid_scores(
Z, seed_indices
)
if self.cfg.evaluation.max_seed:
scores_by_method[f"{name}_max"] = max_seed_cosine_scores(
Z, seed_indices
)
ranking_df = evaluate_embeddings_ranking(
scores_by_method=scores_by_method,
subgraph=graph_data,
seeds=source,
targets=target,
nodes=graph_data.nodes,
k_values=self.cfg.evaluation.k_values,
n_repeats=self.cfg.evaluation.n_repeats,
deg_tol=self.cfg.evaluation.deg_tol,
iteration=it,
)
# standard metadata for analysis
return {
"iteration": it,
"ranking_df": ranking_df,
"comparison": comparison_metrics
}
else:
return {
"iteration": it,
"embeddings": store,
"nodes": list(graph_data.nodes),
"comparison": comparison_metrics
}
#-----------------
# Preprocess
# ----------------
def _preprocess_graph(self, graph_data, source, target):
cfg_pg = PrepareGraphConfig(
subsample_nodes=self.cfg.preprocess.subsample.enabled,
max_nodes=self.cfg.preprocess.subsample.max_nodes,
radius=self.cfg.preprocess.subsample.radius,
sparsify_edges=self.cfg.preprocess.sparsify.enabled,
retain_ratio=self.cfg.preprocess.sparsify.retain_ratio,
max_degree=self.cfg.preprocess.sparsify.max_degree,
scoring=self.cfg.preprocess.sparsify.scoring,
verbose=self.cfg.verbose
)
graph_data = prepare_graph(
cfg_pg,
graph=graph_data,
seeds=source,
targets=target,
seed=self.cfg.seed
)
return graph_data
#-----------------
# Data Loading
# ----------------
def _load_graph(self):
self.log.info("Loading graph: %s", self.cfg.graph.name)
return load_graph(self.cfg)
def _load_gwas_data(self, graph_data):
self.log.info("Loading gwas data: %s", self.cfg.disease.name)
return load_gwas_data(self.cfg, graph_data)
def _set_iteration_seed(self, it):
seed = self.base_seed + it
set_global_seed(seed)
self.log.debug("Iteration seed set to %d", seed)
# The SGNS walk/view/corpus/word2vec core and quantum-target construction
# now live in quvine.api (sgns.py, targets.py) and are shared with embed().
def _save_evaluation_results(self, all_results, nodes):
ranking_df = self._post_process_ranking(all_results)
comparison_df = self._post_process_comparison(all_results)
out_dir = HydraConfig.get().runtime.output_dir
os.makedirs(out_dir, exist_ok=True)
self.log.info("Saving outputs to %s", out_dir)
ranking_path = os.path.join(out_dir, "ranking_results.csv")
ranking_df.to_csv(ranking_path, index=False)
comparison_path = os.path.join(out_dir, "embedding_comparison.csv")
comparison_df.to_csv(comparison_path, index=False)
cfg_path = os.path.join(out_dir, "config.yaml")
with open(cfg_path, "w") as f:
f.write(OmegaConf.to_yaml(self.cfg))
summary = {
"n_iterations": self.n_iters,
"n_nodes": len(nodes),
"walks": OmegaConf.to_container(self.cfg.walks.kinds, resolve=True),
}
with open(os.path.join(out_dir, "summary.json"), "w") as f:
json.dump(summary, f, indent=2)
if self.cfg.plots:
self._plot_all(
ranking_df=ranking_df,
out_dir=out_dir
)
self.log.info("All results saved to %s", out_dir)
def _post_process_ranking(self, all_results):
ranking_dfs = [
r["ranking_df"] for r in all_results
if r["ranking_df"] is not None
]
ranking_results_df = pd.concat(
ranking_dfs,
ignore_index=True
)
return ranking_results_df
def _post_process_comparison(self, all_results):
comparison_rows = []
for r in all_results:
it = r["iteration"]
for pair, metrics in r["comparison"].items():
for name, value in metrics.items():
comparison_rows.append({
"iteration": it,
"pair": pair,
"metric": name,
"value": value,
})
comparison_df = pd.DataFrame(comparison_rows)
return comparison_df
def _plot_all(self, ranking_df, out_dir):
plot_metric(cfg=self.cfg,
df=ranking_df,
metric='recall',
file_path=out_dir)
plot_metric(cfg=self.cfg,
df=ranking_df,
metric='precision',
file_path=out_dir)
plot_precision_recall(df=ranking_df,
control='true',
file_path=out_dir)
plot_precision_recall(df=ranking_df,
control='degree_matched',
file_path=out_dir)
plot_precision_recall(df=ranking_df,
control='distance_matched',
file_path=out_dir)
plot_metric_vs_k(df=ranking_df,
metric='recall',
control='true',
file_path=out_dir)
plot_metric_vs_k(df=ranking_df,
metric='precision',
control='true',
file_path=out_dir)
plot_metric_vs_k(df=ranking_df,
metric='recall',
control='degree_matched',
file_path=out_dir)
plot_metric_vs_k(df=ranking_df,
metric='precision',
control='degree_matched',
file_path=out_dir)
plot_metric_vs_k(df=ranking_df,
metric='recall',
control='distance_matched',
file_path=out_dir)
plot_metric_vs_k(df=ranking_df,
metric='precision',
control='distance_matched',
file_path=out_dir)
def _save_embeddings(self, all_results):
out_dir = HydraConfig.get().runtime.output_dir
emb_dir = os.path.join(out_dir, "embeddings")
os.makedirs(emb_dir, exist_ok=True)
self.log.info("Saving embeddings to %s", emb_dir)
comparison_df = self._post_process_comparison(all_results=all_results)
comparison_df.to_csv(os.path.join(emb_dir, "embedding_comparison.csv"), index=False)
for res in all_results:
iter_num = res["iteration"]
npz_payload = {
emb_name: emb.astype(np.float32, copy=False)
for emb_name, emb in res["embeddings"].items()
if emb is not None
}
npz_payload["nodes"] = np.asarray(res["nodes"])
np.savez_compressed(
os.path.join(emb_dir, f"embeddings_iter_{iter_num}.npz"),
**npz_payload,
)
self.log.debug(
"Saved iteration %d embeddings: %s",
iter_num,
list(npz_payload.keys()),
)