QuVINE Tutorial — quantum graph embeddings inside QBioCode#

This tutorial mirrors the QProfiler tutorial, but for QuVINE (Quantum View-based Network Embeddings), now vendored into QBioCode as an in-tree app.

What is QuVINE (in QBioCode)?#

QProfiler characterizes a tabular dataset (samples × features) and benchmarks ML models on it. QuVINE works on a different modality — a graph — and produces node embeddings:

  • ``qbiocode.apps.quvine.embed`` — turn a networkx.Graph into a node-embedding matrix using classical or quantum walks (RWR / CTQW / DTQW) and a registry of comparison methods (node2vec, NetMF, APPNP, GAT, GraphGPS, quantum-calibrated filters, …).

  • ``qbiocode.evaluate_graph`` — the graph analogue of QProfiler’s evaluate(df, y): a one-row summary of ~80 graph-complexity metrics (spectral, topological, structural).

QuVINE ships inside QBioCode, behind the optional [quvine] extra (see Install below).

What this notebook does#

  1. Generate three graphs of increasing difficulty (small, so it runs quickly).

  2. Embed each with 12 methods and score a node-classification task over 5 iterations.

  3. Summarize each graph’s complexity (spectral gap, IPR, spectral degeneracy, …).

  4. Correlate those complexity measures with macro-F1 and visualize the relationships.


Install. QuVINE ships behind an optional extra, so a plain pip install qbiocode does not pull its dependencies (gensim, hiperwalk, node2vec, torch-geometric, python-louvain, ripser, omegaconf). Install it with:

pip install "qbiocode[quvine]"

Calling a quvine_* method without the extra raises an error naming the missing package and this exact command, rather than a bare ModuleNotFoundError.

1. Setup and imports#

[1]:
import os, warnings
warnings.filterwarnings("ignore")

# Pin OpenMP to a single thread BEFORE importing qbiocode. torch, xgboost and
# qiskit-aer each vendor their own copy of libomp, and on macOS whichever one
# initialises second crashes the process with a bare SIGSEGV -- no traceback,
# just a dead kernel -- the first time it opens a parallel region. The
# torch-backed methods below (appnp, gat_*, graphgps_*) hit it every time.
# See qbiocode/utils/_openmp.py for the full analysis.
os.environ.setdefault("OMP_NUM_THREADS", "1")

import numpy as np
import pandas as pd
import networkx as nx
import matplotlib.pyplot as plt
import seaborn as sns

from qbiocode.apps.quvine import embed, list_methods
from qbiocode import evaluate_graph
from qbiocode.apps.quvine.evaluation.classification import evaluate_node_classification

sns.set_style("whitegrid")
sns.set_context("notebook")

print("total QuVINE methods available:", len(list_methods()))
total QuVINE methods available: 83

2. Generate three graphs#

We build three stochastic block models (SBMs) with 3 planted communities of 20 nodes each (60 nodes total). They differ only in the between-community edge probability p_out: as p_out grows the communities blur together, so the graphs go from easy → medium → hard for a community-based node-classification task.

Because the communities are planted, we use block membership as the ground-truth labels — no label inference needed. QuVINE’s walk corpus needs string node ids, so we relabel to str.

[2]:
def make_sbm(p_out, seed=1, sizes=(50, 50, 50), p_in=0.28):
    k = len(sizes)
    probs = [[p_in if i == j else p_out for j in range(k)] for i in range(k)]
    G = nx.stochastic_block_model(list(sizes), probs, seed=seed)
    labels = {n: G.nodes[n]["block"] for n in G.nodes}          # planted community = label
    G = nx.relabel_nodes(G, {n: str(n) for n in G.nodes})
    labels = {str(n): b for n, b in labels.items()}
    return G, labels

# Same seed + p_in for all three; only p_out (between-community density) changes,
# so the difficulty gradient is a controlled comparison. All three are connected.
graphs = {
    "sbm_easy":   make_sbm(p_out=0.04),
    "sbm_medium": make_sbm(p_out=0.08),
    "sbm_hard":   make_sbm(p_out=0.14),
}

for name, (G, labels) in graphs.items():
    print(f"{name:11s} nodes={G.number_of_nodes():3d}  edges={G.number_of_edges():4d}  "
          f"classes={len(set(labels.values()))}  connected={nx.is_connected(G)}")
sbm_easy    nodes=150  edges=1368  classes=3  connected=True
sbm_medium  nodes=150  edges=1662  classes=3  connected=True
sbm_hard    nodes=150  edges=2040  classes=3  connected=True

3. Choose task and methods#

Task: node classification (predict each node’s community from its embedding).

Methods (12): three QuVINE walks, two quantum-calibrated spectral filters, two quantum-calibrated GAT variants, two quantum-calibrated GraphGPS variants, and three classical baselines.

[3]:
methods = [
    # QuVINE SGNS walks
    "quvine_rwr", "quvine_ctqw", "quvine_dtqw",
    # quantum-calibrated spectral filters
    "filter_rwr_heat", "filter_ctqw_heat",
    # quantum-calibrated GAT
    "gat_rwr_poly", "gat_ctqw_poly",
    # quantum-calibrated GraphGPS
    "graphgps_rwr_heat", "graphgps_ctqw_heat",
    # classical baselines
    "node2vec", "netmf", "appnp",
]
N_ITER = 5

# Small/fast embedding settings -- drop these overrides for full-quality runs.
fast = {
    "train":  {"embedding_dim": 32, "epochs": 10, "workers": 1},
    "walks":  {"num_walks": 8, "walk_length": 8},
    "views":  {"num_views": 4},
}
print(f"{len(methods)} methods x {len(graphs)} graphs x {N_ITER} iters = "
      f"{len(methods) * len(graphs) * N_ITER} embedding runs")
12 methods x 3 graphs x 5 iters = 180 embedding runs

4. Run the benchmark#

For every (graph, method, iteration) we embed the graph and evaluate node classification, recording macro-F1 and accuracy. Each iteration uses a different seed so embeddings and the train/test split vary — giving us a spread to average over.

[4]:
records = []
for gname, (G, labels) in graphs.items():
    node_list = list(G.nodes)
    for method in methods:
        for it in range(N_ITER):
            try:
                res = embed(G, method, overrides=fast, base_seed=42 + it)
                nc = evaluate_node_classification(
                    res.embedding, labels, res.node_order,
                    classifier="logistic", random_state=42 + it,
                )
                records.append({
                    "graph": gname, "method": method, "iter": it,
                    "macro_f1": nc.get("f1_macro"), "accuracy": nc.get("accuracy"),
                    "dim": res.dim, "embed_time_s": round(res.execution_time, 3),
                })
            except Exception as e:
                records.append({"graph": gname, "method": method, "iter": it,
                                "macro_f1": np.nan, "accuracy": np.nan, "error": str(e)[:80]})
    print(f"  done: {gname}")

results = pd.DataFrame(records)
print("collected", len(results), "rows")
results.head()
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 3773.96it/s]
<env>/lib/python3.12/site-packages/node2vec/edges.py:6: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
<env>/lib/python3.12/site-packages/node2vec/edges.py:6: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
<env>/lib/python3.12/site-packages/node2vec/edges.py:6: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
<env>/lib/python3.12/site-packages/node2vec/edges.py:6: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
<env>/lib/python3.12/site-packages/node2vec/edges.py:6: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
<env>/lib/python3.12/site-packages/node2vec/edges.py:6: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
<env>/lib/python3.12/site-packages/node2vec/edges.py:6: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
<env>/lib/python3.12/site-packages/node2vec/edges.py:6: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 111.60it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 102.82it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 109.99it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 106.54it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 107.02it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 109.45it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 101.22it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 103.81it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 3858.44it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 116.83it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 115.35it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 116.60it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 113.04it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 114.74it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 115.62it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 113.57it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 119.75it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 3865.88it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 116.67it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 116.06it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 117.78it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 118.36it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 117.76it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 114.31it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 118.09it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 117.87it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 3790.74it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 117.21it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 115.04it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 114.54it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 118.44it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 112.55it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 114.76it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 117.83it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 115.61it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 3875.67it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 116.23it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 116.73it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 116.70it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 116.67it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 116.28it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 113.61it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 110.67it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 112.14it/s]
  done: sbm_easy
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 2582.24it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 111.42it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 109.08it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 113.51it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 110.84it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 113.04it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 113.70it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 105.77it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 109.72it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 2664.39it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 107.43it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 106.05it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 99.61it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 108.91it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 107.86it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 112.70it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 110.20it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 101.85it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 2701.01it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 110.85it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 110.64it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 110.39it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 113.22it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 112.77it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 106.40it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 104.89it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 109.23it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 2632.50it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 113.13it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 114.48it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 111.96it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 108.74it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 113.34it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 115.53it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 103.52it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 114.13it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 2605.85it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 109.49it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 106.97it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 113.95it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 107.30it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 109.12it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 106.70it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 110.68it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 109.22it/s]
  done: sbm_medium
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 1801.87it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 105.88it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 108.36it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 107.68it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 103.32it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 104.57it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 103.50it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 106.54it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 105.21it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 1831.84it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 106.58it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 109.93it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 104.57it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 104.88it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 106.16it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 109.95it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 108.75it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 107.35it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 1834.96it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 106.49it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 106.17it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 107.88it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 105.83it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 101.44it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 108.55it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 108.38it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 107.77it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 1826.55it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 107.70it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 109.17it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 107.47it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 107.76it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 105.22it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 106.57it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 104.37it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 110.99it/s]
Computing transition probabilities: 100%|██████████| 150/150 [00:00<00:00, 1838.44it/s]
Generating walks (CPU: 1): 100%|██████████| 2/2 [00:00<00:00, 107.85it/s]
Generating walks (CPU: 2): 100%|██████████| 2/2 [00:00<00:00, 106.20it/s]
Generating walks (CPU: 3): 100%|██████████| 1/1 [00:00<00:00, 102.03it/s]
Generating walks (CPU: 4): 100%|██████████| 1/1 [00:00<00:00, 102.02it/s]
Generating walks (CPU: 5): 100%|██████████| 1/1 [00:00<00:00, 107.28it/s]
Generating walks (CPU: 6): 100%|██████████| 1/1 [00:00<00:00, 105.19it/s]
Generating walks (CPU: 7): 100%|██████████| 1/1 [00:00<00:00, 108.12it/s]
Generating walks (CPU: 8): 100%|██████████| 1/1 [00:00<00:00, 107.45it/s]
  done: sbm_hard
collected 180 rows
[4]:
graph method iter macro_f1 accuracy dim embed_time_s
0 sbm_easy quvine_rwr 0 0.932542 0.933333 32 2.161
1 sbm_easy quvine_rwr 1 1.000000 1.000000 32 2.160
2 sbm_easy quvine_rwr 2 0.909370 0.911111 32 2.046
3 sbm_easy quvine_rwr 3 0.977753 0.977778 32 2.019
4 sbm_easy quvine_rwr 4 0.977753 0.977778 32 2.080

Per-method summary (mean over graphs & iterations)#

[5]:
summary = (results.groupby("method")
           .agg(macro_f1_mean=("macro_f1", "mean"),
                macro_f1_std=("macro_f1", "std"),
                accuracy_mean=("accuracy", "mean"),
                embed_time_s=("embed_time_s", "mean"))
           .sort_values("macro_f1_mean", ascending=False)
           .round(3))
summary
[5]:
macro_f1_mean macro_f1_std accuracy_mean embed_time_s
method
netmf 0.923 0.095 0.923 0.014
filter_ctqw_heat 0.895 0.145 0.896 0.194
graphgps_ctqw_heat 0.873 0.166 0.876 1.163
gat_ctqw_poly 0.863 0.161 0.867 1.440
graphgps_rwr_heat 0.863 0.180 0.864 1.144
gat_rwr_poly 0.862 0.148 0.864 0.967
filter_rwr_heat 0.860 0.148 0.861 0.179
appnp 0.698 0.204 0.704 0.160
quvine_rwr 0.675 0.245 0.680 2.179
node2vec 0.662 0.266 0.665 0.509
quvine_dtqw 0.536 0.137 0.539 3.656
quvine_ctqw 0.330 0.067 0.335 3.254

Macro-F1 by method and graph difficulty#

[6]:
fig, ax = plt.subplots(figsize=(12, 5))
order = summary.index.tolist()
sns.barplot(data=results, x="method", y="macro_f1", hue="graph",
            order=order, hue_order=list(graphs), errorbar="sd", ax=ax)
ax.set_title("Node classification macro-F1 by method (bars = mean over 5 iters, whiskers = sd)")
ax.set_xlabel(""); ax.set_ylabel("macro-F1"); ax.set_ylim(0, 1.05)
ax.axhline(1/3, ls="--", c="gray", lw=1, label="random (3 classes)")
ax.set_xticklabels(ax.get_xticklabels(), rotation=35, ha="right")
ax.legend(bbox_to_anchor=(1.01, 1), loc="upper left")
plt.tight_layout(); plt.show()
../../_images/tutorials_QuVINE_example_quvine_12_0.png

5. Graph complexity metrics#

evaluate_graph returns ~80 metrics per graph. We pull a curated set spanning connectivity, spectral localization (IPR), spectral degeneracy, entropy, and community structure — the kinds of measures hypothesized to track how hard a graph is to embed/classify.

[7]:
complexity_cols = [
    "spectral_gap", "normalized_spectral_gap", "algebraic_connectivity",
    "spectral_degeneracy_fraction",                       # spectral degeneracy
    "inverse_participation_ratio", "ipr_low_mean", "ipr_high_mean",   # IPR / localization
    "von_neumann_entropy", "spectral_entropy",
    "modularity", "transitivity", "degree_gini", "avg_degree", "density",
]

rows = []
for gname, (G, _labels) in graphs.items():
    ev = evaluate_graph(G, name=gname)
    row = {"graph": gname}
    row.update({c: float(ev[c].iloc[0]) for c in complexity_cols if c in ev.columns})
    rows.append(row)

complexity = pd.DataFrame(rows).set_index("graph")
complexity.T.round(4)
[7]:
graph sbm_easy sbm_medium sbm_hard
spectral_gap 0.2887 0.4719 0.6253
normalized_spectral_gap 0.2887 0.4719 0.6253
algebraic_connectivity 5.2381 9.2314 12.8869
spectral_degeneracy_fraction 0.0000 0.0000 0.0000
inverse_participation_ratio 0.0194 0.0198 0.0195
ipr_low_mean 0.0195 0.0190 0.0194
ipr_high_mean 0.0194 0.0205 0.0197
von_neumann_entropy 7.1532 7.1608 7.1715
spectral_entropy 4.9788 4.9844 4.9888
modularity 0.4344 0.2780 0.1369
transitivity 0.1914 0.1827 0.1895
degree_gini 0.1141 0.1152 0.1066
avg_degree 18.2400 22.1600 27.2000
density 0.1224 0.1487 0.1826

Complexity fingerprint across the three graphs#

[8]:
# z-score each metric across the 3 graphs so they are visually comparable on one heatmap.
z = complexity.copy()
z = (z - z.mean()) / z.std(ddof=0)
fig, ax = plt.subplots(figsize=(9, 6))
sns.heatmap(z.T, annot=complexity.T.round(3), fmt="", cmap="vlag", center=0,
            cbar_kws={"label": "z-score across graphs"}, ax=ax)
ax.set_title("Graph complexity fingerprint (annotated with raw values)")
ax.set_xlabel(""); ax.set_ylabel("")
plt.tight_layout(); plt.show()
../../_images/tutorials_QuVINE_example_quvine_16_0.png

6. Correlate complexity with node-classification performance#

We merge each graph’s complexity metrics onto every result row and compute the Spearman correlation between each complexity metric and macro-F1 across all (graph, method, iteration) runs. A positive value means “higher metric ⟶ easier classification”.

With only three distinct graphs the complexity axis takes three values, so treat these correlations as illustrative of the trend (harder graphs ⟶ lower F1) rather than precise effect sizes.

[9]:
from scipy.stats import spearmanr

merged = results.merge(complexity.reset_index(), on="graph", how="left").dropna(subset=["macro_f1"])

# Drop metrics that are constant across the three graphs (no variance -> undefined correlation).
constant = [c for c in complexity_cols if merged[c].nunique() <= 1]
active = [c for c in complexity_cols if c not in constant]
if constant:
    print("constant across these graphs (skipped):", constant)

corr = []
for c in active:
    rho, pval = spearmanr(merged[c], merged["macro_f1"])
    corr.append({"metric": c, "spearman_rho": rho, "p_value": pval})
corr_df = pd.DataFrame(corr).sort_values("spearman_rho", ascending=False).reset_index(drop=True)
corr_df.round(3)
constant across these graphs (skipped): ['spectral_degeneracy_fraction']
[9]:
metric spearman_rho p_value
0 modularity 0.619 0.000
1 degree_gini 0.372 0.000
2 ipr_low_mean 0.248 0.001
3 transitivity 0.248 0.001
4 inverse_participation_ratio -0.248 0.001
5 ipr_high_mean -0.248 0.001
6 spectral_gap -0.619 0.000
7 normalized_spectral_gap -0.619 0.000
8 algebraic_connectivity -0.619 0.000
9 von_neumann_entropy -0.619 0.000
10 spectral_entropy -0.619 0.000
11 avg_degree -0.619 0.000
12 density -0.619 0.000

Correlation bar chart#

[10]:
fig, ax = plt.subplots(figsize=(9, 6))
colors = ["#2a9d8f" if v >= 0 else "#e76f51" for v in corr_df["spearman_rho"]]
ax.barh(corr_df["metric"], corr_df["spearman_rho"], color=colors)
ax.invert_yaxis()                                   # largest positive on top
ax.axvline(0, c="black", lw=1)
ax.set_title("Spearman correlation: graph complexity vs. macro-F1")
ax.set_xlabel("Spearman rho (macro-F1)"); ax.set_xlim(-1, 1)
for y, v in enumerate(corr_df["spearman_rho"]):
    ax.text(v + (0.02 if v >= 0 else -0.02), y, f"{v:.2f}",
            va="center", ha="left" if v >= 0 else "right", fontsize=8)
plt.tight_layout(); plt.show()
../../_images/tutorials_QuVINE_example_quvine_20_0.png

How performance tracks two key metrics#

[11]:
# Mean macro-F1 per graph vs. a connectivity metric (spectral gap) and a localization metric (IPR).
per_graph = (results.groupby("graph")["macro_f1"].mean()
             .to_frame("macro_f1").join(complexity))

fig, axes = plt.subplots(1, 2, figsize=(13, 5))
for ax, metric in zip(axes, ["spectral_gap", "inverse_participation_ratio"]):
    sns.scatterplot(data=per_graph, x=metric, y="macro_f1", hue=per_graph.index,
                    s=180, ax=ax, legend=(ax is axes[1]))
    for g, r in per_graph.iterrows():
        ax.annotate(g, (r[metric], r["macro_f1"]), xytext=(6, 4),
                    textcoords="offset points", fontsize=9)
    ax.set_title(f"mean macro-F1 vs. {metric}")
    ax.set_ylim(0, 1.05)
if axes[1].get_legend():
    axes[1].legend(title="graph", bbox_to_anchor=(1.01, 1), loc="upper left")
plt.tight_layout(); plt.show()
../../_images/tutorials_QuVINE_example_quvine_22_0.png

7. Command-line interface#

The same embedding is available as the quvine console script (installed with the [quvine] extra), mirroring qprofiler / qsage:

quvine --list-methods                                             # list all methods
quvine --edgelist edges.csv --method quvine_fused --output out/   # -> embedding.csv + meta.json

Integer node ids in the edge list are handled automatically (coerced to strings).

Summary#

In this tutorial you:

  1. ✅ Generated three graphs of increasing difficulty (planted-community SBMs)

  2. ✅ Embedded them with 12 methods — quantum walks, quantum-calibrated filters/GAT/GraphGPS, and classical baselines — over 5 iterations

  3. ✅ Scored a node-classification task (macro-F1) for each

  4. ✅ Summarized each graph’s complexity (spectral gap, IPR, spectral degeneracy, entropy, …) with evaluate_graph

  5. Correlated complexity with performance and visualized the trend

See Also#

  • QProfiler Tutorial — the tabular-data analogue of this workflow

  • list_methods() — the full QuVINE registry

  • Fused embeddings: embed(G, "quvine_fused") combines all walk kinds