QuVINE on the CD4-vs-CD8 cell graph — a few-shot “centroid seed” study#
Uses QuVINE (Quantum View-based Network Embeddings) on pbmc5k_small_cd4_vs_cd8.h5ad — 500 cells, two view-graphs (rna_connectivities, protein_connectivities), with graph_seed / graph_eval splits.
Why this vehicle, and why few-shot. The earlier T-vs-monocyte task saturated: those cell types are so separable that raw logistic regression on the node features alone hits macro-F1 ≈ 1.0 with no graph at all, and centroid ranking recovered the whole class perfectly (recall@k was exactly k/|targets| for every method). A task the features already solve cannot discriminate graph-embedding methods. We fix this two ways:
Harder task — CD4 vs CD8. Both are T cells, so the classes are close (raw-LR only ≈0.95–0.97), leaving real headroom.
Few-shot seeding — we keep the seed centroid as the ranking anchor but shrink the number of labeled seeds per class to
kand sweep ``k``. With few anchors the centroid is noisy, so recovery must lean on the embedding’s structural spread — exactly where a quantum walk can beat a classical one. This is the “centroid seed” study: performance as a function of how many seeds seed the centroid.
Two complementary evals, both run few-shot (same ``k``-seed subset drives every arm):
Node classification — LR trained on the
kseed-node embeddings, predict eval. Includes a native graph label-spreading baseline that diffuses a soft-seed matrix over the graph (no embedding). Reported as a curve vs ``k``.Node ranking (centroid seed → target) — rank non-seed nodes by cosine similarity to the
k-seed centroid (seed_centroid_scores) and measure same-class eval recovery (recall@K / precision@K) against degree- and distance-matched null controls (SeedTargetEvaluator). This is the headline: the quantum walk directly, no downstream classifier.
Views. RNA-graph and protein-graph, embedded separately and as an early-fusion concat.
A subtlety worth stating: QuVINE’s embed(..., seeds=...) uses seeds as structural, label-free calibration anchors for the quantum walk. We calibrate on the full graph_seed node set once (identities only — no class labels leak), then vary only the labeled seeds used downstream. So the embeddings are fixed across the sweep; only the label budget shrinks.
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.
This notebook also reads an .h5ad fixture, so it needs anndata — part of the base install.
1. Configuration#
[1]:
# ============================ CONFIG ============================
QUICK = False # False = full training budget (fair to the quantum walks); slower (~5-10 min).
# Set True for a quick laptop demo. Either way QW_STEPS (below) controls the
# quantum-walk regime, which is the parameter that actually matters here.
TASK = "binary_cd4_vs_cd8" # harder vehicle: CD4 vs CD8 (both T cells -> close)
METHODS = ["node2vec", "quvine_rwr", "quvine_ctqw", "quvine_dtqw"]
# node2vec : PURE classical baseline (DeepWalk-style) -- the honest classical yardstick
# quvine_rwr : QuVINE SGNS on a CLASSICAL random-walk-with-restart view -> ABLATION
# (same pipeline as the quantum arms, only the walk is classical);
# NOT "the classical baseline" -- it is a hybrid control.
# quvine_ctqw/dtqw : QuVINE SGNS on continuous/discrete-time QUANTUM walk views
VIEWS = ["rna", "protein"] # modality graphs to embed (and an early-fusion concat)
EVAL_MODE = "both" # "classification" | "ranking" | "both"
N_REPEATS = 3 # embedding seeds -> confidence intervals over the embedding
K_VALUES = [20, 50, 100] # recall@K / precision@K retrieval cutoffs
# --- few-shot "centroid seed" sweep ---------------------------------------------
# Keep the seed CENTROID as the ranking anchor, but shrink the number of LABELED
# seeds per class to k_seeds and sweep it. Few anchors -> noisy centroid -> recovery
# leans on the embedding's structural spread (where quantum walks can separate).
# The SAME k-seed subset drives the ranking centroid, the LR classifier, and the
# diffusion baseline, so all arms see the same label budget.
SEEDS_PER_CLASS = [1, 3, 5, 10, 25] # few-shot k (auto-clipped to available seeds/class)
N_SUBSAMPLE = 3 # random seed draws per k -> CIs over the draw
K_HEADLINE = 50 # retrieval cutoff shown in the sweep curves
# --- quantum-walk regime (the parameter that matters) ---------------------------
# QW_STEPS = ctqw evolution time / dtqw coined-step count. Continuous- and discrete-time
# quantum walks spread BALLISTICALLY, so LARGE step counts over-mix: the walk relaxes
# toward its stationary distribution and same-class neighborhood structure is washed out
# (we verified this -- raising steps 10->30 widened the quantum deficit). We therefore keep
# the walk SMALL / localized. Classical node2vec & quvine_rwr ignore `steps` (they use
# num_walks/walk_length), so this choice tunes only the quantum arms -- it does not
# handicap the classical baseline.
QW_STEPS = 4
# QuVINE embedding knobs. Walk/training budget applied EQUALLY to every method; only the
# quantum arms consume QW_STEPS.
QUVINE_OVERRIDES = (
{"views": {"num_views": 2}, "walks": {"num_walks": 5, "walk_length": 6, "steps": QW_STEPS},
"train": {"epochs": 20, "workers": 1}, "workers": 1, "dimension": 32}
if QUICK else
{"views": {"num_views": 2}, "walks": {"num_walks": 10, "walk_length": 10, "steps": QW_STEPS},
"train": {"epochs": 50, "workers": 1}, "workers": 1, "dimension": 64}
)
print("QUICK:", QUICK, "| task:", TASK, "| methods:", METHODS, "| views:", VIEWS)
print("few-shot seeds/class:", SEEDS_PER_CLASS, "| subsamples:", N_SUBSAMPLE,
"| eval:", EVAL_MODE, "| emb repeats:", N_REPEATS, "| QW_STEPS:", QW_STEPS)
print("QuVINE overrides:", QUVINE_OVERRIDES)
QUICK: False | task: binary_cd4_vs_cd8 | methods: ['node2vec', 'quvine_rwr', 'quvine_ctqw', 'quvine_dtqw'] | views: ['rna', 'protein']
few-shot seeds/class: [1, 3, 5, 10, 25] | subsamples: 3 | eval: both | emb repeats: 3 | QW_STEPS: 4
QuVINE overrides: {'views': {'num_views': 2}, 'walks': {'num_walks': 10, 'walk_length': 10, 'steps': 4}, 'train': {'epochs': 50, 'workers': 1}, 'workers': 1, 'dimension': 64}
2. Imports & data#
[2]:
import warnings, time, os
warnings.filterwarnings("ignore")
os.environ.setdefault("OMP_NUM_THREADS", "1")
import numpy as np, pandas as pd, networkx as nx, anndata as ad
import scipy.sparse as sp
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import f1_score, accuracy_score, roc_auc_score
from qbiocode.apps.quvine import embed
from qbiocode.apps.quvine.evaluation.ranking import SeedTargetEvaluator, seed_centroid_scores
from qbiocode.utils import tutorial_data_path
sns.set_style("whitegrid")
# ---- Data path ----
# tutorial_data_path() is QBioCode's single resolution path for tutorial fixtures. It
# searches $QBC_DATA first, then every fixture directory of a source checkout -- located
# both from the installed package and by walking up from this notebook's directory, so it
# works for an editable install and for a normal install used inside a clone -- and raises
# a FileNotFoundError naming every directory it tried. Fixtures shared between tutorials
# are committed once and found from either tree.
H5AD = tutorial_data_path("pbmc5k_small_cd4_vs_cd8.h5ad")
adata = ad.read_h5ad(H5AD)
N = adata.n_obs
IDS = [f"c{i}" for i in range(N)] # STRING node ids (required by QuVINE SGNS)
# This "small" file has no precomputed label_int / Y_soft / class_order — reconstruct them.
lab = adata.obs[TASK].astype(str)
CLASS_ORDER = sorted(lab.unique()) # e.g. ['CD4_T', 'CD8_T']
lab2i = {c: i for i, c in enumerate(CLASS_ORDER)}
y = lab.map(lab2i).to_numpy().astype(int) # 0/1 aligned to CLASS_ORDER
seed_m = adata.obs["graph_seed"].to_numpy().astype(bool)
eval_m = adata.obs["graph_eval"].to_numpy().astype(bool)
# Soft-label matrix for the diffusion baseline: soft one-hot (alpha) on seeds, uniform on eval.
# (label_spread zeroes the eval rows before propagating, so only the seed rows matter.)
ALPHA = 0.9
Y_soft = np.full((N, len(CLASS_ORDER)), 1.0 / len(CLASS_ORDER), dtype=float)
Y_soft[seed_m] = 0.0
Y_soft[np.where(seed_m)[0], y[seed_m]] = ALPHA
def to_graph(adj):
adj = adj.tocoo(); G = nx.Graph(); G.add_nodes_from(IDS)
for i, j, w in zip(adj.row, adj.col, adj.data):
if i < j:
G.add_edge(IDS[i], IDS[j], weight=float(w))
return G
GRAPHS = {v: to_graph(adata.obsp[f"{v}_connectivities"]) for v in VIEWS}
ADJ = {v: adata.obsp[f"{v}_connectivities"].tocsr().astype(float) for v in VIEWS}
seed_nodes = [IDS[i] for i in np.where(seed_m)[0]] # calibration anchors (identities only)
# Per-class seed pools for the few-shot sweep; clip requested k to what's available.
seed_pool = {c: np.where(seed_m & (y == c))[0] for c in range(len(CLASS_ORDER))}
max_k = min(len(p) for p in seed_pool.values())
SEEDS_PER_CLASS = [k for k in SEEDS_PER_CLASS if k <= max_k]
print(f"{N} nodes | classes {CLASS_ORDER} | seeds {seed_m.sum()} eval {eval_m.sum()}")
for c, cn in enumerate(CLASS_ORDER):
print(f" {cn}: seed_pool={len(seed_pool[c])} eval={(eval_m & (y == c)).sum()}")
print(f" few-shot k (clipped, max={max_k}): {SEEDS_PER_CLASS}")
for v in VIEWS:
print(f" {v} graph: {GRAPHS[v].number_of_nodes()} nodes, {GRAPHS[v].number_of_edges()} edges")
500 nodes | classes ['CD4_T', 'CD8_T'] | seeds 143 eval 357
CD4_T: seed_pool=76 eval=174
CD8_T: seed_pool=67 eval=183
few-shot k (clipped, max=67): [1, 3, 5, 10, 25]
rna graph: 500 nodes, 5141 edges
protein graph: 500 nodes, 5470 edges
3. Compute QuVINE embeddings#
Each method is embedded on each view graph, for N_REPEATS seeds. Calibration seeds are our labeled seed nodes. An early-fusion ``concat`` view (RNA ⊕ protein, standardized) is added.
[3]:
def embed_view(method, view, base_seed):
r = embed(GRAPHS[view], method, seeds=seed_nodes, base_seed=base_seed,
overrides=QUVINE_OVERRIDES, verbose=False)
# rows already align to IDS order (graph built with add_nodes_from(IDS))
return np.asarray(r.embedding, dtype=float)
# EMB[(method, view, seed)] -> (N, d)
EMB = {}
t_all = time.time()
for method in METHODS:
for seed in range(N_REPEATS):
parts = {}
for view in VIEWS:
t = time.time()
EMB[(method, view, seed)] = embed_view(method, view, seed)
parts[view] = EMB[(method, view, seed)]
# early-fusion concat of the per-view embeddings (standardized)
fused = np.concatenate([StandardScaler().fit_transform(parts[v]) for v in VIEWS], axis=1)
EMB[(method, "concat", seed)] = fused
print(f" embedded {method} (all views x {N_REPEATS} seeds)")
ALL_VIEWS = VIEWS + ["concat"]
print(f"done: {len(EMB)} embeddings in {time.time()-t_all:.1f}s")
Computing transition probabilities: 100%|██████████| 500/500 [00:00<00:00, 2995.57it/s]
Generating walks (CPU: 1): 100%|██████████| 10/10 [00:00<00:00, 25.75it/s]
Computing transition probabilities: 100%|██████████| 500/500 [00:00<00:00, 2416.95it/s]
Generating walks (CPU: 1): 100%|██████████| 10/10 [00:00<00:00, 27.02it/s]
Computing transition probabilities: 100%|██████████| 500/500 [00:00<00:00, 3008.93it/s]
Generating walks (CPU: 1): 100%|██████████| 10/10 [00:00<00:00, 26.63it/s]
Computing transition probabilities: 100%|██████████| 500/500 [00:00<00:00, 2460.76it/s]
Generating walks (CPU: 1): 100%|██████████| 10/10 [00:00<00:00, 25.79it/s]
Computing transition probabilities: 100%|██████████| 500/500 [00:00<00:00, 2977.66it/s]
Generating walks (CPU: 1): 100%|██████████| 10/10 [00:00<00:00, 27.53it/s]
Computing transition probabilities: 100%|██████████| 500/500 [00:00<00:00, 2455.39it/s]
Generating walks (CPU: 1): 100%|██████████| 10/10 [00:00<00:00, 26.45it/s]
embedded node2vec (all views x 3 seeds)
embedded quvine_rwr (all views x 3 seeds)
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
embedded quvine_ctqw (all views x 3 seeds)
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'
embedded quvine_dtqw (all views x 3 seeds)
done: 36 embeddings in 290.2s
4. Node classification — few-shot vs seed budget#
Same two arm types as before, but now run few-shot: for each k in SEEDS_PER_CLASS we draw k labeled seeds per class (N_SUBSAMPLE random draws) and report a curve vs ``k``.
``diffusion(Y_soft)`` (no embedding) — label-spreading of the
k-seed soft matrix over each view graph (symmetric-normalized propagation), argmax on eval. The native graph baseline.embedding arms — logistic regression trained on the
kseed-node embeddings, predict eval.
If the curves separate at small k and converge as k grows, that’s the discriminating signal the saturated full-seed task couldn’t show.
[4]:
def label_spread(W, Y0, alpha=0.9, n_iter=40):
"""Symmetric-normalized label spreading (Zhou et al.): F = (1-a)Y0 + a S F."""
d = np.asarray(W.sum(1)).ravel()
dinv = 1.0 / np.sqrt(np.maximum(d, 1e-12))
S = sp.diags(dinv) @ W @ sp.diags(dinv)
F = Y0.copy()
for _ in range(n_iter):
F = (1 - alpha) * Y0 + alpha * (S @ F)
return F
def clf_scores(y_true, y_pred, y_proba):
return dict(macroF1=f1_score(y_true, y_pred, average="macro"),
accuracy=accuracy_score(y_true, y_pred),
auc=roc_auc_score(y_true, y_proba))
def draw_seeds(k, rng):
"""Draw k seed indices per class from the seed pools (few-shot subset)."""
return np.sort(np.concatenate([rng.choice(seed_pool[c], size=k, replace=False)
for c in range(len(CLASS_ORDER))]))
rows = []
for k in SEEDS_PER_CLASS:
for sub in range(N_SUBSAMPLE):
rng = np.random.default_rng(1000 * k + sub)
sidx = draw_seeds(k, rng) # this draw's labeled seeds
smask = np.zeros(N, bool); smask[sidx] = True
# (a) diffusion baseline: propagate the k-seed soft one-hot over each view graph
Y0 = np.zeros_like(Y_soft); Y0[sidx, y[sidx]] = ALPHA
for view in VIEWS:
F = label_spread(ADJ[view], Y0)
yp = F[eval_m].argmax(1)
ypr = F[eval_m][:, 1] / np.clip(F[eval_m].sum(1), 1e-12, None)
rows.append({"method": "diffusion(Y_soft)", "view": view, "emb_seed": 0,
"k_seeds": k, "sub": sub, **clf_scores(y[eval_m], yp, ypr)})
# (b) embedding arms: LR trained on the k seeds, predict eval
for (method, view, seed), Z in EMB.items():
sc = StandardScaler().fit(Z[sidx])
Xtr, Xte = sc.transform(Z[sidx]), sc.transform(Z[eval_m])
clf = LogisticRegression(max_iter=2000, class_weight="balanced").fit(Xtr, y[sidx])
yp = clf.predict(Xte); ypr = clf.predict_proba(Xte)[:, 1]
rows.append({"method": method, "view": view, "emb_seed": seed,
"k_seeds": k, "sub": sub, **clf_scores(y[eval_m], yp, ypr)})
clf_df = pd.DataFrame(rows)
clf_summary = (clf_df.groupby(["method", "view", "k_seeds"])[["macroF1", "auc"]]
.mean().round(3).unstack("k_seeds"))
print("Node classification macro-F1 on eval — mean over draws & emb seeds, by k seeds/class:")
clf_summary["macroF1"]
Node classification macro-F1 on eval — mean over draws & emb seeds, by k seeds/class:
[4]:
| k_seeds | 1 | 3 | 5 | 10 | 25 | |
|---|---|---|---|---|---|---|
| method | view | |||||
| diffusion(Y_soft) | protein | 0.801 | 0.864 | 0.889 | 0.914 | 0.897 |
| rna | 0.611 | 0.685 | 0.734 | 0.805 | 0.836 | |
| node2vec | concat | 0.569 | 0.793 | 0.838 | 0.901 | 0.928 |
| protein | 0.594 | 0.836 | 0.875 | 0.861 | 0.905 | |
| rna | 0.531 | 0.604 | 0.673 | 0.779 | 0.820 | |
| quvine_ctqw | concat | 0.530 | 0.772 | 0.879 | 0.891 | 0.945 |
| protein | 0.539 | 0.797 | 0.852 | 0.869 | 0.902 | |
| rna | 0.514 | 0.649 | 0.733 | 0.754 | 0.766 | |
| quvine_dtqw | concat | 0.494 | 0.797 | 0.821 | 0.869 | 0.922 |
| protein | 0.497 | 0.800 | 0.825 | 0.847 | 0.880 | |
| rna | 0.526 | 0.670 | 0.678 | 0.761 | 0.746 | |
| quvine_rwr | concat | 0.465 | 0.780 | 0.873 | 0.900 | 0.944 |
| protein | 0.502 | 0.795 | 0.845 | 0.885 | 0.901 | |
| rna | 0.461 | 0.669 | 0.712 | 0.761 | 0.782 |
4a. Classification — macro-F1 / AUC vs seed budget (concat view)#
[5]:
order = ["diffusion(Y_soft)"] + METHODS
view_show = "concat" if "concat" in ALL_VIEWS else VIEWS[0]
palette = dict(zip(order, sns.color_palette("tab10", len(order))))
fig, axes = plt.subplots(1, 2, figsize=(14, 4.8))
for ax, metric in zip(axes, ["macroF1", "auc"]):
sub = clf_df[(clf_df["view"] == view_show) & (clf_df["method"].isin(order))]
sns.lineplot(data=sub, x="k_seeds", y=metric, hue="method", hue_order=order,
marker="o", errorbar=("ci", 95), palette=palette, ax=ax)
ax.set_title(f"{metric} vs seed budget ({view_show} view)")
ax.set_xlabel("labeled seeds per class (k)"); ax.set_ylim(0.4, 1.02)
ax.set_xticks(SEEDS_PER_CLASS)
ax.axhline(0.5, ls="--", color="grey", lw=1)
ax.legend(fontsize=8, title=None)
plt.tight_layout(); plt.show()
5. Node ranking (centroid seed → target) — the headline#
For each class we build the seed centroid from just k labeled seeds, rank all non-seed nodes by cosine similarity to it, and measure recovery of the same-class eval nodes with recall@K / precision@K — sweeping k (the “centroid seed” budget). SeedTargetEvaluator also computes degree-matched and distance-matched null targets: the honest controls that separate “the walk found biology” from “the walk found hubs / nearby nodes”.
This is where a good embedding earns its keep: with k=1 the centroid is a single noisy anchor, so recall depends almost entirely on how well the embedding places same-class cells together.
[6]:
def rank_eval_fewshot(Z, view_graph, cls, seed_idx_cls, k_values, n_repeats):
"""Centroid-rank recovery of same-class eval nodes from a few-shot seed subset."""
seed_c = [IDS[i] for i in seed_idx_cls]
tgt_c = [IDS[i] for i in np.where(eval_m & (y == cls))[0]]
if not seed_c or not tgt_c:
return None
scores = np.asarray(seed_centroid_scores(Z, list(seed_idx_cls)), dtype=float)
ev = SeedTargetEvaluator(view_graph, seeds=seed_c, targets=tgt_c, nodes=IDS)
return ev.evaluate(scores, k_values=k_values, n_repeats=n_repeats)
rrows = []
if EVAL_MODE in ("ranking", "both"):
for (method, view, seed), Z in EMB.items():
gview = GRAPHS[VIEWS[0]] if view == "concat" else GRAPHS[view] # concat: structure from 1st view
for k in SEEDS_PER_CLASS:
for sub in range(N_SUBSAMPLE):
rng = np.random.default_rng(7 * (1000 * k + sub))
for cls, cname in enumerate(CLASS_ORDER):
sidx = rng.choice(seed_pool[cls], size=k, replace=False) # k seeds of this class
res = rank_eval_fewshot(Z, gview, cls, sidx, K_VALUES, n_repeats=5)
if res is None:
continue
for K in K_VALUES:
rrows.append({"method": method, "view": view, "emb_seed": seed,
"k_seeds": k, "sub": sub, "class": cname, "K": K,
"recall_true": res["true"]["recall"][K],
"recall_degctrl": res["degree_matched"]["recall"][K][0],
"recall_distctrl": res["distance_matched"]["recall"][K][0]})
rank_df = pd.DataFrame(rrows)
print(f"Ranking recall@{K_HEADLINE} — mean over draws, emb seeds & classes (concat), by k seeds/class:")
show = (rank_df[(rank_df.K == K_HEADLINE) & (rank_df.view == "concat")]
.groupby(["method", "k_seeds"])[["recall_true", "recall_degctrl", "recall_distctrl"]]
.mean().round(3))
display(show)
else:
rank_df = pd.DataFrame()
print("Ranking skipped (EVAL_MODE=%r)" % EVAL_MODE)
Ranking recall@50 — mean over draws, emb seeds & classes (concat), by k seeds/class:
| recall_true | recall_degctrl | recall_distctrl | ||
|---|---|---|---|---|
| method | k_seeds | |||
| node2vec | 1 | 0.187 | 0.051 | 0.070 |
| 3 | 0.194 | 0.049 | 0.092 | |
| 5 | 0.194 | 0.051 | 0.094 | |
| 10 | 0.210 | 0.045 | 0.073 | |
| 25 | 0.215 | 0.043 | 0.074 | |
| quvine_ctqw | 1 | 0.174 | 0.058 | 0.076 |
| 3 | 0.194 | 0.046 | 0.091 | |
| 5 | 0.200 | 0.048 | 0.083 | |
| 10 | 0.203 | 0.047 | 0.074 | |
| 25 | 0.219 | 0.041 | 0.072 | |
| quvine_dtqw | 1 | 0.157 | 0.067 | 0.085 |
| 3 | 0.187 | 0.050 | 0.097 | |
| 5 | 0.200 | 0.046 | 0.085 | |
| 10 | 0.201 | 0.049 | 0.078 | |
| 25 | 0.215 | 0.041 | 0.076 | |
| quvine_rwr | 1 | 0.167 | 0.061 | 0.078 |
| 3 | 0.199 | 0.043 | 0.088 | |
| 5 | 0.203 | 0.046 | 0.081 | |
| 10 | 0.203 | 0.050 | 0.077 | |
| 25 | 0.219 | 0.039 | 0.072 |
5a. Ranking — recall@K vs seed budget, against the null control (concat view)#
[7]:
if not rank_df.empty:
view_show = "concat" if "concat" in ALL_VIEWS else VIEWS[0]
d = rank_df[(rank_df["view"] == view_show) & (rank_df["K"] == K_HEADLINE)]
fig, ax = plt.subplots(figsize=(8.5, 5))
palette = dict(zip(METHODS, sns.color_palette("tab10", len(METHODS))))
sns.lineplot(data=d, x="k_seeds", y="recall_true", hue="method", hue_order=METHODS,
marker="o", errorbar=("ci", 95), palette=palette, ax=ax)
# shared degree-matched null band, averaged over methods
ctrl = d.groupby("k_seeds")["recall_degctrl"].mean()
ax.plot(ctrl.index, ctrl.values, "k--", label="degree-matched null")
ax.set_xlabel("labeled seeds per class (k)")
ax.set_ylabel(f"recall@{K_HEADLINE} (same-class eval recovered)")
ax.set_xticks(SEEDS_PER_CLASS)
ax.set_title(f"Centroid-seed recovery vs seed budget, {view_show} view")
ax.legend(fontsize=8); ax.grid(alpha=0.3)
plt.tight_layout(); plt.show()
6. Graph complexity lens#
A quick spectral read on each view graph: the normalized-Laplacian spectral gap (algebraic connectivity — small = slow-mixing / strongly clustered) and greedy modularity. The heat-kernel-as-low-pass view predicts that slow-mixing, high-modularity graphs benefit most from diffusion/quantum-walk smoothing — useful context for why one view may embed better.
[8]:
import scipy.sparse.linalg as sla
from networkx.algorithms.community import greedy_modularity_communities, modularity as nx_mod
def spectral_gap_modularity(G):
L = nx.normalized_laplacian_matrix(G).astype(float)
try:
ev = np.sort(sla.eigsh(L, k=4, which="SM", return_eigenvectors=False))
pos = ev[ev > 1e-9]; lam2 = float(pos[0]) if len(pos) else float(ev[1])
except Exception:
lam2 = float("nan")
mod = float(nx_mod(G, list(greedy_modularity_communities(G))))
return lam2, mod
k_max = max(SEEDS_PER_CLASS)
comp = []
for v in VIEWS:
lam2, mod = spectral_gap_modularity(GRAPHS[v])
sel = clf_df[(clf_df.view == v) & (clf_df.method.isin(METHODS)) & (clf_df.k_seeds == k_max)]
best = sel["macroF1"].max()
comp.append({"view": v, "spectral_gap": round(lam2, 4), "modularity": round(mod, 3),
f"best_embed_macroF1@k={k_max}": round(best, 3)})
pd.DataFrame(comp)
[8]:
| view | spectral_gap | modularity | best_embed_macroF1@k=25 | |
|---|---|---|---|---|
| 0 | rna | 0.0051 | 0.580 | 0.880 |
| 1 | protein | 0.0325 | 0.549 | 0.927 |
7. Quantum vs classical — advantage & which complexity features drive it#
Same idea as the QProfiler notebook, adapted to graphs, with a fair setup and two honest twists.
Fair comparison. Two things must be right for this to mean anything:
Enough walk budget — with
QUICK=True’s tiny params the quantum walks have too few steps to develop their structural spread, which sandbags them; we runQUICK=False(full params) applied equally to every method. (2) The right baseline —node2vecis the pure classical yardstick, whilequvine_rwris QuVINE’s own pipeline on a classical walk, i.e. a hybrid ablation, not “the classical baseline.” So the headline contrast is quantum walks (``quvine_ctqw``/``quvine_dtqw``) vs ``node2vec``, withquvine_rwrreported separately as the same-pipeline control.
A. Is there a quantum-walk advantage? Classification (§4) is close to saturated, so the ranking task (§5, recall@K) is where any advantage shows. We take the per-unit advantage Δrecall = recall(quantum) − recall(baseline) across each (view, emb_seed, sub, class) pairing unit and summarize it as a paired Cohen’s d_z — stratified by the few-shot seed budget ``k``, because the thesis is that the quantum walk’s structural spread should help most at small ``k`` (a noisy
one-anchor centroid). A paired two-group contrast is exactly where Cohen’s d is the right tool.
B. Which graph-complexity feature tracks that advantage? Graph complexity (§6) only varies per view — 2–3 points — far too few to correlate. To get real statistical support we run a node-subsample bootstrap: repeatedly keep a random 70 % of cells, and for each subsample recompute (i) a panel of graph-complexity features on the induced subgraph and (ii) the quantum-vs-node2vec ranking advantage from the already-computed, fixed node embeddings (no re-embedding). Correlating the
features against the advantage across subsamples points to which complexity properties favour the quantum walk.
Honest caveat: even with a fair budget, a quantum edge is not guaranteed in every regime — e.g. at
k=1a single noisy anchor can favour the classical embedding. We report each seed budget separately and explain the mechanism rather than hiding the unfavourable rows. Bootstrap CIs are also optimistic (subsamples overlap), so Part B flags candidate drivers; a panel of distinct graphs remains the gold standard.
[9]:
# ============================================================================
# Part A — Quantum vs classical on the RANKING task (headroom), paired Cohen's d_z
# quantum = quvine_ctqw + quvine_dtqw
# HEADLINE baseline = node2vec (pure classical) ABLATION = quvine_rwr (QuVINE, classical walk)
# metric = recall_true @ K_HEADLINE contrast axis = few-shot seed budget k
# ============================================================================
from scipy import stats
QUANTUM_M = ["quvine_ctqw", "quvine_dtqw"] # quantum walks
CLASSICAL_M = ["node2vec"] # PURE classical baseline (headline contrast)
ABLATION_M = ["quvine_rwr"] # QuVINE pipeline w/ classical walk (control)
def dz_ci(delta):
d = np.asarray(delta, float); nn = len(d)
m, sd = d.mean(), d.std(ddof=1)
dz = m / sd if sd > 1e-12 else 0.0
se = np.sqrt(1 / nn + dz ** 2 / (2 * nn))
p = stats.ttest_1samp(d, 0).pvalue if sd > 1e-12 else np.nan
return dict(mean_dRecall=m, cohens_dz=dz, ci_low=dz - 1.96 * se,
ci_high=dz + 1.96 * se, p=p, n=nn)
def advantage_table(baseline_methods, label):
"""Paired quantum-vs-baseline advantage on recall@K_HEADLINE, by few-shot k budget."""
rd = rank_df[rank_df["K"] == K_HEADLINE].copy()
rd = rd[rd["method"].isin(QUANTUM_M + baseline_methods)]
rd["arm"] = np.where(rd["method"].isin(QUANTUM_M), "quantum", "base")
piv = (rd.groupby(["view", "emb_seed", "k_seeds", "sub", "class", "arm"])["recall_true"]
.mean().unstack("arm"))
piv["delta"] = piv["quantum"] - piv["base"] # quantum-minus-classical recall delta
piv = piv.dropna(subset=["delta"]).reset_index()
rows = [{"stratum": f"k={k}", **dz_ci(piv.loc[piv["k_seeds"] == k, "delta"])}
for k in sorted(piv["k_seeds"].unique())]
rows.append({"stratum": "overall", **dz_ci(piv["delta"])})
t = pd.DataFrame(rows); t.insert(0, "vs", label)
return t
advQ = advantage_table(CLASSICAL_M, "node2vec (classical)") # headline
advAb = advantage_table(ABLATION_M, "quvine_rwr (ablation)") # control
print(f"HEADLINE — quantum walks (ctqw+dtqw) vs PURE classical node2vec, recall@{K_HEADLINE}")
print("(+) d_z => quantum recovers more same-class targets at that seed budget.")
display(advQ.round(3))
print("ABLATION — quantum walks vs quvine_rwr (QuVINE pipeline, classical walk):")
display(advAb.round(3))
# ---- forest plot: quantum recall gain over the PURE classical baseline, by seed budget ----
yv = np.arange(len(advQ))
cols = ["#4c72b0" if d >= 0 else "#c44e52" for d in advQ["cohens_dz"]]
xr = float(np.nanmax([advQ["ci_high"].abs().max(), advQ["ci_low"].abs().max(), 0.9]))
fig, ax = plt.subplots(figsize=(9, 0.55 * len(advQ) + 1.6))
for xb in (0.2, 0.5, 0.8):
for s in (-1, 1):
ax.axvline(s * xb, color="lightgrey", ls=":", lw=0.8)
ax.axvline(0, color="grey", ls="--", lw=1)
ax.hlines(yv, advQ["ci_low"], advQ["ci_high"], color=cols, lw=2.2, alpha=0.75)
ax.scatter(advQ["cohens_dz"], yv, s=80, color=cols, edgecolor="k", linewidth=0.5)
for yi, r in zip(yv, advQ.itertuples()):
star = "*" if (r.p == r.p and r.p < 0.05) else ""
ax.text(xr * 1.18, yi, f"Δrecall={r.mean_dRecall:+.3f} · d_z={r.cohens_dz:+.2f}{star}",
va="center", fontsize=8)
ax.set_yticks(yv); ax.set_yticklabels(advQ["stratum"])
ax.set_xlim(-xr * 1.15, xr * 2.0)
ax.set_xlabel("paired Cohen's d_z (→ quantum better · ← classical better) · * p<0.05")
ax.set_title(f"cd4_vs_cd8 — quantum walks vs pure classical (node2vec), recall@{K_HEADLINE} by seed budget",
fontsize=11)
plt.tight_layout(); plt.show()
HEADLINE — quantum walks (ctqw+dtqw) vs PURE classical node2vec, recall@50
(+) d_z => quantum recovers more same-class targets at that seed budget.
| vs | stratum | mean_dRecall | cohens_dz | ci_low | ci_high | p | n | |
|---|---|---|---|---|---|---|---|---|
| 0 | node2vec (classical) | k=1 | -0.013 | -0.487 | -0.769 | -0.205 | 0.001 | 54 |
| 1 | node2vec (classical) | k=3 | 0.002 | 0.121 | -0.146 | 0.389 | 0.376 | 54 |
| 2 | node2vec (classical) | k=5 | 0.002 | 0.116 | -0.152 | 0.383 | 0.399 | 54 |
| 3 | node2vec (classical) | k=10 | -0.004 | -0.250 | -0.521 | 0.021 | 0.072 | 54 |
| 4 | node2vec (classical) | k=25 | 0.004 | 0.395 | 0.118 | 0.672 | 0.005 | 54 |
| 5 | node2vec (classical) | overall | -0.002 | -0.093 | -0.213 | 0.026 | 0.127 | 270 |
ABLATION — quantum walks vs quvine_rwr (QuVINE pipeline, classical walk):
| vs | stratum | mean_dRecall | cohens_dz | ci_low | ci_high | p | n | |
|---|---|---|---|---|---|---|---|---|
| 0 | quvine_rwr (ablation) | k=1 | -0.001 | -0.051 | -0.318 | 0.216 | 0.711 | 54 |
| 1 | quvine_rwr (ablation) | k=3 | -0.005 | -0.315 | -0.588 | -0.042 | 0.025 | 54 |
| 2 | quvine_rwr (ablation) | k=5 | -0.002 | -0.137 | -0.405 | 0.131 | 0.319 | 54 |
| 3 | quvine_rwr (ablation) | k=10 | -0.001 | -0.090 | -0.357 | 0.177 | 0.510 | 54 |
| 4 | quvine_rwr (ablation) | k=25 | -0.002 | -0.177 | -0.445 | 0.092 | 0.200 | 54 |
| 5 | quvine_rwr (ablation) | overall | -0.002 | -0.150 | -0.270 | -0.030 | 0.015 | 270 |
[10]:
# ============================================================================
# Part B — Which GRAPH-complexity feature tracks the quantum recall gain?
# Node-subsample bootstrap: embeddings are per-node & FIXED, so per subsample we
# recompute (i) graph-complexity on the induced subgraph and (ii) quantum/classical
# ranking recall from the existing embeddings -- no re-embedding -> real n.
# quantum recall gain = recall(ctqw,dtqw) − recall(node2vec) [pure classical baseline]
# ============================================================================
import scipy.sparse.linalg as sla
from networkx.algorithms.community import greedy_modularity_communities, modularity as nx_mod
from scipy.stats import spearmanr
B, FRAC, K_B = 40, 0.7, K_HEADLINE
idx_all = np.arange(N)
def graph_complexity(G):
"""Panel of graph-complexity features on an induced subgraph (None if too small)."""
if G.number_of_nodes() < 20 or G.number_of_edges() < 5:
return None
f = {}
Gc = G.subgraph(max(nx.connected_components(G), key=len)).copy()
try:
L = nx.normalized_laplacian_matrix(Gc).astype(float)
ev = np.sort(sla.eigsh(L, k=min(4, Gc.number_of_nodes() - 1), which="SM",
return_eigenvectors=False))
pos = ev[ev > 1e-9]; f["spectral_gap"] = float(pos[0]) if len(pos) else np.nan
except Exception:
f["spectral_gap"] = np.nan
try:
f["modularity"] = float(nx_mod(G, list(greedy_modularity_communities(G))))
except Exception:
f["modularity"] = np.nan
f["avg_clustering"] = nx.average_clustering(G)
f["transitivity"] = nx.transitivity(G)
try:
f["assortativity"] = nx.degree_assortativity_coefficient(G)
except Exception:
f["assortativity"] = np.nan
f["edge_density"] = nx.density(G)
degs = np.array([d for _, d in G.degree()], float)
f["mean_degree"] = degs.mean()
f["degree_heterog"] = degs.std() / degs.mean() if degs.mean() > 0 else np.nan
return f
def _recall_at_k(scores, cand, targets, k):
if len(cand) == 0 or len(targets) == 0:
return np.nan
top = cand[np.argsort(-scores[cand])[:k]]
return len(set(top.tolist()) & set(targets.tolist())) / len(targets)
def _method_recall(method, view, S_mask, k):
"""mean recall@k over classes & embedding seeds for one method on subsample S."""
vals = []
for cls in range(len(CLASS_ORDER)):
seed_c = np.where(S_mask & seed_m & (y == cls))[0]
tgt_c = np.where(S_mask & eval_m & (y == cls))[0]
cand = np.where(S_mask & ~seed_m)[0]
if len(seed_c) < 1 or len(tgt_c) < 1:
return np.nan
for sd in range(N_REPEATS):
scores = np.asarray(seed_centroid_scores(EMB[(method, view, sd)], list(seed_c)), float)
vals.append(_recall_at_k(scores, cand, tgt_c, k))
return np.nanmean(vals) if vals else np.nan
rng = np.random.default_rng(20240517)
brows = []
for b in range(B):
keep = rng.choice(idx_all, size=int(FRAC * N), replace=False)
S_mask = np.zeros(N, bool); S_mask[keep] = True
keep_ids = [IDS[i] for i in keep]
for view in VIEWS: # rna, protein (concat has no own graph)
cf = graph_complexity(GRAPHS[view].subgraph(keep_ids).copy())
if cf is None:
continue
qr = np.nanmean([_method_recall(m, view, S_mask, K_B) for m in QUANTUM_M])
cr = np.nanmean([_method_recall(m, view, S_mask, K_B) for m in CLASSICAL_M]) # node2vec
if np.isnan(qr) or np.isnan(cr):
continue
brows.append({"b": b, "view": view, "quantum_gain": qr - cr, **cf})
boot = pd.DataFrame(brows)
FEATURES = ["spectral_gap", "modularity", "avg_clustering", "transitivity",
"assortativity", "edge_density", "mean_degree", "degree_heterog"]
crows = []
for feat in FEATURES:
d = boot[[feat, "quantum_gain"]].dropna()
if d[feat].nunique() > 2 and len(d) >= 5:
r, p = spearmanr(d[feat], d["quantum_gain"])
crows.append({"feature": feat, "spearman_r": r, "p": p, "n": len(d)})
corr = (pd.DataFrame(crows).assign(absr=lambda t: t["spearman_r"].abs())
.sort_values("absr").reset_index(drop=True))
print(f"bootstrap rows: {len(boot)} (B={B} x {len(VIEWS)} views) | recall@{K_B}")
print(f"quantum_gain = recall(ctqw,dtqw) − recall(node2vec), avg over classes & {N_REPEATS} emb-seeds")
display(corr.round(3))
# ---- plot: complexity feature vs quantum recall gain (bootstrap correlation) -----
yb = np.arange(len(corr))
vals = corr["spearman_r"].to_numpy()
cols = ["#4c72b0" if v >= 0 else "#c44e52" for v in vals]
fig, ax = plt.subplots(figsize=(8.5, 0.45 * len(corr) + 1.6))
ax.axvline(0, color="grey", ls="--", lw=1)
ax.barh(yb, vals, color=cols, edgecolor="k", linewidth=0.4, alpha=0.85)
for yi, r in zip(yb, corr.itertuples()):
star = "*" if (r.p == r.p and r.p < 0.05) else ""
ax.text((0.02 if r.spearman_r < 0 else -0.02), yi, f"p={r.p:.2f}{star}",
va="center", ha="left" if r.spearman_r < 0 else "right",
fontsize=7.5, color="dimgray")
ax.set_yticks(yb); ax.set_yticklabels(corr["feature"]); ax.set_xlim(-1.05, 1.05)
ax.set_xlabel("Spearman r (graph-complexity feature ↔ quantum recall gain)")
ax.set_title(f"cd4_vs_cd8 — graph-complexity drivers of PQ-walk advantage (bootstrap n={len(boot)})",
fontsize=11)
plt.tight_layout(); plt.show()
bootstrap rows: 80 (B=40 x 2 views) | recall@50
quantum_gain = recall(ctqw,dtqw) − recall(node2vec), avg over classes & 3 emb-seeds
| feature | spearman_r | p | n | absr | |
|---|---|---|---|---|---|
| 0 | modularity | 0.594 | 0.0 | 80 | 0.594 |
| 1 | degree_heterog | -0.656 | 0.0 | 80 | 0.656 |
| 2 | spectral_gap | -0.690 | 0.0 | 80 | 0.690 |
| 3 | transitivity | 0.717 | 0.0 | 80 | 0.717 |
| 4 | avg_clustering | 0.727 | 0.0 | 80 | 0.727 |
| 5 | edge_density | -0.727 | 0.0 | 80 | 0.727 |
| 6 | mean_degree | -0.727 | 0.0 | 80 | 0.727 |
| 7 | assortativity | 0.751 | 0.0 | 80 | 0.751 |
8. How to read this#
Quantum vs classical (§7) is the verdict. Part A asks whether the quantum walks beat the pure classical baseline ``node2vec`` on ranking recall (paired Cohen’s d_z, stratified by seed budget — watch the small-
krows), withquvine_rwrshown separately as the same-pipeline ablation; Part B asks which graph-complexity feature the quantum edge tracks, via a node-subsample bootstrap. Positived_z= quantum better; bootstrap bars whose CIs clear 0 flag candidate drivers.Ranking (§5) is the headline. With a few-shot centroid seed, recall@K that stays above the degree/distance-matched nulls means the embedding recovers same-class cells for real structural reasons, not by preferring hubs or near neighbors. Watch the small-``k`` end of the curve: at
k=1the centroid is one noisy anchor, so any separation between the quantum walks (quvine_ctqw/quvine_dtqw) and the pure classicalnode2vec(withquvine_rwras the same-pipeline ablation) is the quantum walk’s structural spread doing the work. Overlapping 95% CIs = no reproducible difference at this scale.Classification (§4) is the sanity/counterpoint arm: does the LR-on-embeddings advantage (or the no-embedding
diffusion(Y_soft)baseline) survive when the label budget is tiny? On the old T-vs-mono task every curve pinned at ~1.0; on CD4-vs-CD8 the small-kregime should spread them out.Complexity (§6) contextualizes which view the diffusion should help on (slow-mixing, high-modularity graphs benefit most from diffusion/quantum-walk smoothing).
To go further: QUICK=True gives a fast (but quantum-sandbagging) laptop run; raise N_REPEATS / N_SUBSAMPLE for tighter CIs; extend SEEDS_PER_CLASS toward the full pool to see where the curves converge; swap TASK to binary_lymphoid_vs_myeloid (intermediate difficulty) or add registry methods (gat_ctqw_heat, graphgps_dtqw_poly, filter_ctqw_heat, …) to METHODS (GAT/GraphGPS train a small net — slower). This tutorial is scoped to the harder cd4_vs_cd8
vehicle precisely because T-vs-mono saturated; the other pbmc5k_small_* graph files share the same layout and can be dropped in via TASK + H5AD.