Quantum Medical-Imaging Classification Research Toolkit — variational quantum classifiers and hybrid classical-quantum models on pre-extracted medical imaging features.
Project description
⚛️ QmedX
Quantum Medical-Imaging Classification Research Toolkit
A modular, research-first framework for running variational quantum classifiers and hybrid classical-quantum models on pre-extracted medical imaging features — from laptop simulation to Rigetti QPU hardware.
Table of Contents
- Overview
- Architecture at a Glance
- Installation
- Directory Structure
- Data Loading
- Components
- Usage Examples
- Hardware Export — Rigetti Cepheus-1
- Running the Tests
- Design Principles
- Roadmap
🔬 Overview
QmedX is a research toolkit for studying variational quantum classifiers (VQCs) on pre-extracted medical-imaging features. It is built around three core research questions:
Does the intrinsic geometry of a backbone's feature space predict quantum advantage?
Which normalisation and encoding choices best exploit that geometry?
Can a hybrid classical-quantum model close the performance gap between simulation and real QPU hardware?
The package is intentionally agnostic about the imaging backbone — it consumes pre-extracted .npy feature arrays and handles everything downstream: normalisation, dimensionality reduction, quantum encoding, variational training, geometry analysis, and QPU hardware export.
Two End-to-End Workflows
| Workflow | Model | Trainer | Best For |
|---|---|---|---|
Experiment |
PennyLane QNode (pure-quantum) | AdamWTrainer (parameter-shift) |
QPU research, ablation studies, hardware export |
HybridQuantumClassifier |
Classical pre-processor → TorchLayer VQC → output head | TorchTrainer (AdamW + CrossEntropy) |
GPU training, multi-class, end-to-end gradients |
Both workflows share the same ansatz blocks, encoding strategies, normalisation pipeline, and geometry analysis tooling.
🏗️ Architecture at a Glance
╔═══════════════════════════════════════════════════════════════════════════╗
║ EXPERIMENT PIPELINE ║
║ (pure-quantum · parameter-shift gradients · hardware-exportable) ║
║ ║
║ Raw features (.npy) ║
║ │ ║
║ ▼ ║
║ Normalizer ──► Reducer ──► Encoder ──► QNode ──► AdamWTrainer ║
║ (L2 / ISP) (SRP/PCA/AE) (Angular/ (PennyLane (hinge loss) ║
║ Mottonen) VQC) (param-shift) ║
║ │ ║
║ ▼ ║
║ Geometry metrics · q_acc · lin_acc (linear probe ablation) ║
║ │ ║
║ ▼ ║
║ export_cepheus() ──► cepheus_payload.npz + cepheus_run.py ║
╚═══════════════════════════════════════════════════════════════════════════╝
╔═══════════════════════════════════════════════════════════════════════════╗
║ HYBRID PIPELINE ║
║ (end-to-end differentiable · GPU classical + CPU quantum) ║
║ ║
║ Raw features (batch, feature_dim) ║
║ │ ║
║ ▼ Classical pre-processor ║
║ LayerNorm → Dropout → Linear(d→h) → GELU → Dropout → Linear(h→n_qubits) ║
║ │ ║
║ ▼ Encoding squash ║
║ tanh(·) × π ← features squeezed into [-π, π] ║
║ │ ║
║ ▼ TorchLayer VQC (PennyLane, CPU-based, fully differentiable) ║
║ AngularEncoding: RY(xᵢ) · RZ(xᵢ) · RZ(xᵢ²) ║
║ StronglyEntangling: [rotation block → entangler] × layers ║
║ Measurements: ⟨Z₀⟩, ⟨Z₁⟩, ..., ⟨Zₙ₋₁⟩ ║
║ │ ║
║ ▼ Post-quantum normalisation ║
║ Quantum LayerNorm → Dropout → Linear(n_qubits→num_classes) ║
║ │ ║
║ ▼ ║
║ Logits (batch, num_classes) ║
╚═══════════════════════════════════════════════════════════════════════════╝
📦 Installation
Prerequisites
# Core (required for Experiment pipeline)
pip install pennylane>=0.36 scikit-learn>=1.4 numpy pandas matplotlib
# Required for HybridQuantumClassifier and TorchTrainer
pip install torch>=2.0
# Optional: faster PennyLane simulation
pip install pennylane-lightning
Install QmedX
# Clone the repository
git clone https://github.com/your-username/QmedX.git
cd QmedX
# Editable install — changes to source are immediately reflected
pip install -e .
Alternatively, add the repo root to your Python path without installing:
import sys
sys.path.insert(0, "/path/to/QmedX")
import qmedx
Verify
import qmedx
print(qmedx.__version__) # 0.1.0
🗂️ Directory Structure
QmedX/
│
├── qmedx/ # Main package
│ ├── __init__.py # Public API — re-exports all symbols
│ ├── data.py # Feature loading: load_features, load_train_test
│ ├── normalize.py # L2, ISP, Compose normalizers
│ ├── reduce.py # SparseRandomProjection, PCA, AutoEncoder, LinearLayer
│ ├── encode.py # AngularEncoding, MottonenEncoding
│ ├── ansatz.py # StronglyEntangling — 7 ansatz block types
│ ├── model.py # build_qnode(), predict_batch()
│ ├── train.py # AdamWTrainer, TorchTrainer, linear_probe()
│ ├── hybrid.py # HybridQuantumClassifier (nn.Module)
│ ├── experiment.py # Experiment — full pipeline orchestrator
│ ├── geometry.py # Geometry metrics + visualisation helpers
│ └── hardware.py # Rigetti Cepheus-1 QPU export
│
├── examples/
│ ├── run_experiment.py # Four Experiment pipeline examples
│ └── lung_experiment.py # Full hybrid model run on Lung dataset
│
├── tests/
│ ├── __init__.py
│ └── test_geometry.py # Unit tests for all geometry metrics
│
└── README.md
📂 Data Loading
QmedX never computes backbone features — it loads pre-extracted .npy arrays from disk.
Expected Directory Layout
Features Extracted/
├── Covid/
│ ├── dinov3_vits16/
│ │ ├── train/
│ │ │ ├── features.npy # float32, shape (N_train, feature_dim)
│ │ │ └── labels.npy # int64, shape (N_train,)
│ │ └── test/
│ │ ├── features.npy
│ │ └── labels.npy
│ └── resnet50/ ...
├── Lung/ ...
├── Skin/ ...
└── SkinAugmented/ ...
Setting the Feature Root
from qmedx.data import set_features_root
# Linux / WSL (default)
set_features_root("/mnt/c/Users/Ashra/Desktop/Features Extracted")
# Windows native path
set_features_root(r"C:\Users\Ashra\Desktop\Features Extracted")
# Or use an environment variable (persistent across sessions)
# export QMEDX_FEATURES_ROOT="/data/features"
Loading Data
from qmedx.data import load_features, load_train_test, list_models
# Load one split → (X, y)
X, y = load_features("Covid", "dinov3_vits16", split="train")
print(X.shape) # (N, 384)
# Load train and test splits at once → (X_train, y_train, X_test, y_test)
X_train, y_train, X_test, y_test = load_train_test("Lung", "dinov3_vitb16")
# Discover available backbones for a dataset
models = list_models("Skin")
print(models) # ['convnext_base', 'dinov3_vitb16', 'resnet50', ...]
Valid dataset names: "Covid", "Lung", "Skin", "SkinAugmented".
🧩 Components
Normalizers
Normalizers are applied after dimensionality reduction to prepare features for quantum encoding. All normalizers implement .normalize(X) and .about().
from qmedx.normalize import L2, ISP, Compose
L2 — Row-wise L2 Normalisation
Projects each sample onto the unit hypersphere in ℝᵈ. Output dimension is unchanged.
norm = L2()
X_norm = norm.normalize(X) # each row: ‖x‖₂ = 1
ISP — Inverse Stereographic Projection
Lifts each d-dimensional feature vector onto the surface of the unit sphere Sᵈ ⊂ ℝᵈ⁺¹ via the inverse stereographic map. The output dimension becomes d + 1.
Transformation formula:
x ∈ ℝᵈ ──► [ 2x₁/(1+‖x‖²), ..., 2xₐ/(1+‖x‖²), (‖x‖²-1)/(‖x‖²+1) ] ∈ Sᵈ
The extra coordinate encodes the "distance from the projection pole" and gives the quantum encoder a richer angular signal without extra qubits.
Why ISP? Stereographic projection preserves angles and maps bounded feature distributions to a compact spherical manifold — the natural domain for quantum state amplitudes. Pairing L2 → ISP is the recommended normalisation for Cepheus-1 hardware runs.
norm = ISP()
X_lifted = norm.normalize(X) # shape: (N, d+1)
Compose — Chaining Multiple Normalizers
norm = Compose([L2(), ISP()])
X_ready = norm.normalize(X) # L2 first, then ISP
# Dimension bookkeeping (used internally by Experiment)
print(norm.output_dim(7)) # 8 (ISP adds 1)
print(norm.about())
# {'name': 'Compose', 'steps': [{'name': 'L2Normalizer'}, {'name': 'ISPNormalizer'}]}
Reducers
Reducers compress high-dimensional backbone features down to the size required by the quantum encoder. All reducers expose fit(X, y), transform(X), fit_transform(X, y), and about().
from qmedx.reduce import SparseRandomProjection, PCA, AutoEncoder, LinearLayer
Auto-resolution: When using
Experiment,n_componentsis set automatically so thatnormalizer.output_dim(n_components) == encoder.required_input_dim(n_qubits). You never need to calculate this manually.
| Reducer | Supervised | Key Characteristic |
|---|---|---|
SparseRandomProjection |
No | Fastest; training-free; Johnson-Lindenstrauss distance preservation |
PCA |
No | Maximum-variance directions; supports explained_variance_ratio |
AutoEncoder |
No | MLP bottleneck; good for non-linear manifolds; no label bias |
LinearLayer |
Yes | Cross-entropy trained; direct ablation for quantum vs classical contribution |
# Fast random projection — recommended default
reducer = SparseRandomProjection(n_components=8)
X_tr_r = reducer.fit_transform(X_train)
X_te_r = reducer.transform(X_test)
# PCA with variance inspection
pca = PCA(n_components=16)
pca.fit(X_train)
cumvar = pca.explained_variance_ratio.cumsum()
print(f"Variance explained by 16 PCs: {cumvar[-1]:.3f}")
# Neural autoencoder (unsupervised)
ae = AutoEncoder(n_components=8, hidden=256, epochs=200, device="cuda")
ae.fit(X_train)
# Supervised linear projection (ablation: how much do labels help at compression?)
ll = LinearLayer(n_components=8, epochs=200, device="cpu")
ll.fit(X_train, y_train)
Encoders
Encoders translate classical feature vectors into quantum states by emitting PennyLane gates inside a QNode. All encoders implement apply(inputs, n_qubits), required_input_dim(n_qubits), and about().
from qmedx.encode import AngularEncoding, MottonenEncoding
AngularEncoding — Recommended for Hardware
Per-qubit feature map. For each qubit i:
RY(xᵢ) · RZ(xᵢ) · RZ(xᵢ²)
The quadratic RZ(xᵢ²) term introduces non-linearity in the angle space without additional qubits. Circuit depth is O(n) per encoding pass.
# Without re-uploading: encode once, then run all ansatz layers
encoder = AngularEncoding(reupload=False)
# With re-uploading: re-encode before every ansatz layer (Pérez-Salinas et al. 2020)
# Increases effective expressibility; standard choice for the hybrid model
encoder = AngularEncoding(reupload=True)
print(encoder.required_input_dim(8)) # 8 (one feature per qubit)
Data re-uploading means the encoding block is repeated before each of the L ansatz layers, effectively multiplying circuit expressibility. The trade-off is deeper circuits and slower simulation.
MottonenEncoding — Simulation Only
Loads a unit-norm feature vector of length 2**n_qubits directly into the quantum state amplitudes using Mottonen state preparation.
encoder = MottonenEncoding()
print(encoder.required_input_dim(3)) # 8 (2^3 = 8 amplitudes)
⚠️ Not recommended for hardware. Mottonen state preparation requires circuits with gate count exponential in
n_qubits, leading to extremely long compilation times and high error rates on QPU hardware. UseAngularEncodingfor any hardware run.
Ansatz Blocks
The StronglyEntangling ansatz builds one variational layer as:
[per-qubit rotation block on all qubits] → [two-qubit entangler]
Seven block types are available across two entangler families:
from qmedx.ansatz import StronglyEntangling
ansatz = StronglyEntangling(block="cepheus_native", layers=3)
Complete Block Reference
| Block | Params / Qubit | Rotation Gates | Entangler | Recommended Use |
|---|---|---|---|---|
"ry" |
1 | RY | CZ nearest-neighbor | Minimal circuit depth |
"ry_rz" |
2 | RY, RZ | CZ nearest-neighbor | Balanced depth / expressibility |
"rot" |
3 | RZ, RY, RZ (Euler) | CZ nearest-neighbor | Full SU(2) per qubit |
"cepheus_native" |
2 | RZ, RX(π/2), RZ | CZ nearest-neighbor | QPU hardware — zero decomposition |
"efficient_su2" |
3 | RZ, RY, RZ (Euler) | CNOT nearest-neighbor (i→i+1) | Mirrors Qiskit EfficientSU2 |
"circular" |
3 | RZ, RY, RZ (Euler) | CNOT ring (i→(i+1) mod n) | Periodic entanglement |
"fully_connected" |
3 | RZ, RY, RZ (Euler) | CNOT all-to-all (every pair i<j) | Maximum entanglement |
QPU note:
cepheus_nativeuses only CZ and RX(π/2) — the native two-qubit and single-qubit gates of Rigetti's Cepheus-1-108Q QPU. No compiler decomposition is required. The other CZ blocks (ry,ry_rz,rot) also compile cleanly. The CNOT-based blocks (efficient_su2,circular,fully_connected) require decomposition on Cepheus hardware and are best used for simulation and GPU-based hybrid training.
Ansatz Utilities
ansatz = StronglyEntangling(block="fully_connected", layers=4)
# Parameter tensor shape: (layers, n_qubits, params_per_qubit)
print(ansatz.param_shape(8)) # (4, 8, 3)
# Total trainable parameters in the VQC portion
print(ansatz.n_params(8)) # 96
# Emit gates for all layers inside a PennyLane QNode
ansatz.apply(params, n_qubits=8)
# Emit gates for a single layer only
ansatz.apply_single_layer(params[0], n_qubits=8)
# Serialisable metadata for logging and reproducibility
print(ansatz.about())
# {'name': 'StronglyEntangling', 'block': 'fully_connected', 'layers': 4}
Trainers
AdamWTrainer — PennyLane Parameter-Shift Training
Trains a plain PennyLane QNode using PennyLane's built-in AdamOptimizer with a hinge loss. Because PennyLane handles the gradient computation via the parameter-shift rule, this trainer is fully compatible with real QPU hardware.
from qmedx.train import AdamWTrainer
trainer = AdamWTrainer(
lr = 0.01,
epochs = 100,
batch_size = 32, # None → full-batch gradient descent
verbose = True,
random_state = 42,
)
trained_params, losses = trainer.train(qnode, init_params, X_train, y_train)
Loss: Mean hinge loss L = mean(max(0, 1 − y·⟨Z⟩)) with labels mapped {0, 1} → {−1, +1}.
TorchTrainer — Full PyTorch Training Loop
Production-grade training loop for HybridQuantumClassifier (or any nn.Module).
Features:
- AdamW optimiser with configurable weight decay
- Cross-entropy loss with label smoothing — multi-class ready
ReduceLROnPlateaulearning-rate scheduler (monitors validation loss)- Gradient clipping (max L2 norm on all parameters)
- Early stopping on validation macro-F1 with configurable patience and min-delta
- Best-model checkpointing — saves
best_model.pthwhenever val F1 improves - Full per-epoch metric history (loss, accuracy, macro-F1, weighted-F1, precision, recall, LR)
from qmedx.train import TorchTrainer
trainer = TorchTrainer(
lr = 1e-3, # initial learning rate
epochs = 100, # maximum training epochs
weight_decay = 1e-3, # AdamW weight decay (L2 regularisation)
label_smoothing = 0.05, # CrossEntropyLoss label smoothing
patience = 15, # early-stopping patience (epochs without improvement)
min_delta = 1e-4, # minimum val F1 improvement to reset patience
max_grad_norm = 1.0, # gradient clipping threshold
lr_factor = 0.5, # ReduceLROnPlateau reduction factor
lr_patience = 5, # epochs before scheduler reduces LR
min_lr = 1e-6, # LR floor for the scheduler
verbose = True, # print one line per epoch
)
# ── Training ────────────────────────────────────────────────────────────────
fit_result = trainer.fit(model, train_loader, val_loader, out_dir="runs/exp1/")
# fit_result contains:
# "history" — dict of lists: train_loss, val_loss, train_f1_macro,
# val_f1_macro, train_acc, val_acc, lr
# "epoch_rows" — list[dict], one per epoch (ready for CSV)
# "best_epoch" — int, epoch index of the best val F1
# "best_val_f1" — float
# "best_model_path" — str path to saved checkpoint, or None
# ── Evaluation ──────────────────────────────────────────────────────────────
eval_result = trainer.evaluate(model, test_loader)
# eval_result contains:
# "loss" — float
# "metrics" — dict: accuracy, f1_macro, f1_weighted, precision_macro, recall_macro
# "y_true" — list[int]
# "y_pred" — list[int]
# "y_prob" — np.ndarray shape (n, n_classes)
linear_probe — Classical Baseline
Fits a logistic-regression classifier on the same encoder-ready features fed to the VQC. The result serves as the classical ablation baseline — Experiment calls this automatically and reports the gap as quantum_gain.
from qmedx.train import linear_probe
acc = linear_probe(X_train, y_train, X_test, y_test, random_state=42)
print(f"Classical baseline: {acc:.4f}")
HybridQuantumClassifier
A PyTorch nn.Module that stacks a classical pre-processor with a differentiable variational quantum circuit backed by qml.qnn.TorchLayer. Gradients flow end-to-end through both classical and quantum layers via PennyLane's interface="torch" backend.
from qmedx.hybrid import HybridQuantumClassifier
from qmedx.ansatz import StronglyEntangling
import torch
ansatz = StronglyEntangling(block="efficient_su2", layers=4)
model = HybridQuantumClassifier(
feature_dim = 1024, # raw backbone feature dimension
n_qubits = 8, # VQC width and classical bottleneck size
ansatz = ansatz,
num_classes = 3, # supports binary and multi-class
data_reuploading = True, # re-encode features before every ansatz layer
input_dropout = 0.20, # dropout on the raw input
hidden_dropout = 0.30, # dropout inside the pre-processor MLP
head_dropout = 0.20, # dropout before the output linear layer
)
# Move to GPU — VQC weights automatically stay on CPU
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
# Forward pass (batched)
x = torch.randn(16, 1024).to(device)
logits = model(x) # shape: (16, 3)
Internal Architecture
Input (batch, feature_dim)
│
▼ Classical pre-processor
LayerNorm(feature_dim)
Dropout(input_dropout)
Linear(feature_dim → hidden_dim) hidden_dim = clamp(d//2, 32, 256)
GELU
Dropout(hidden_dropout)
Linear(hidden_dim → n_qubits)
│
▼ Squash to encoding range
tanh(·) × π → values in [-π, π]
│
▼ Variational Quantum Circuit [CPU — PennyLane default.qubit]
TorchLayer VQC
AngularEncoding: RY(xᵢ) · RZ(xᵢ) · RZ(xᵢ²) for i in range(n_qubits)
[if data_reuploading: encode before EACH layer]
StronglyEntangling: rotation block → entangler × layers
Measurements: [⟨Z₀⟩, ⟨Z₁⟩, ..., ⟨Zₙ₋₁⟩] → shape (batch, n_qubits)
│
▼ Post-quantum normalisation
LayerNorm(n_qubits)
│
▼ Output head
Dropout(head_dropout)
Linear(n_qubits → num_classes)
│
▼
Logits (batch, num_classes)
GPU / CPU Device Handling
PennyLane's default.qubit simulator is CPU-only. HybridQuantumClassifier handles the CPU/GPU data shuttle transparently so you can train classical layers on GPU without any manual device management:
forward()moves the pre-processed tensor to CPU before the VQC call, then returns the quantum output back to the original device. Gradients flow through both.cpu()and.to(device)calls — PyTorch tracks these as no-op differentiable operations.to()override pinsvqc.weightsback to CPU after any.to(device)call, preventing the"Expected all tensors to be on the same device"error.
This means you simply call model.to("cuda") as normal — no extra configuration required.
model = HybridQuantumClassifier(...).to("cuda")
# Classical layers: CUDA
# VQC weights: CPU (pinned automatically)
# Forward pass: GPU → CPU (VQC) → GPU seamlessly
Geometry Metrics
All metrics are computed on raw, un-reduced features and measure whether the pre-extracted representations have the intrinsic properties that favour quantum advantage. Experiment.run() calls compute_all() automatically and includes all metrics in the result dict.
from qmedx.geometry import (
effective_rank, twonn, fisher_ratio, svm_margin,
knn_consistency, reconstruction_loss, compute_all,
plot_geometry_summary, plot_separability,
)
Metric Reference
| Function | Output | Interpretation |
|---|---|---|
effective_rank(X) |
float |
Spectral entropy exp(H). Low value → embedding concentrated in a low-dimensional subspace — good for quantum compression |
twonn(X) |
float |
TwoNN intrinsic dimensionality (Facco et al. 2017) — ratio of 2nd-to-1st NN distances. Low → features live on a low-dim manifold and compress well into qubits |
fisher_ratio(X, y) |
float |
Mean pairwise Fisher discriminant ratio across all class pairs. High → class means are well-separated relative to intra-class variance |
svm_margin(X, y) |
dict |
svm_mean_margin and svm_min_margin — geometric margins of a LinearSVC (OvR). High margin → classes are linearly well-separated |
knn_consistency(X, y) |
dict |
knn_consistency_mean / std — average kNN accuracy over 10 random 80/20 splits. High → local neighbourhoods are label-homogeneous |
reconstruction_loss(X, k) |
float |
Fraction of variance NOT explained by the top k PCA directions. Low → the features compress well to k dimensions |
compute_all(X, y, n_qubits) |
dict |
All of the above in one flat dict, with n_qubits as the PCA truncation point |
import numpy as np
X = np.load("features.npy")
y = np.load("labels.npy")
metrics = compute_all(X, y, n_qubits=8)
print(f"Intrinsic dim (TwoNN) : {metrics['twonn']:.2f}")
print(f"Fisher ratio : {metrics['fisher_ratio']:.4f}")
print(f"kNN consistency : {metrics['knn_consistency_mean']:.4f}"
f" ± {metrics['knn_consistency_std']:.4f}")
print(f"SVM mean margin : {metrics['svm_mean_margin']:.4f}")
print(f"Reconstruction loss : {metrics['reconstruction_loss']:.4f}")
print(f"Effective rank : {metrics['effective_rank']:.2f}")
Visualisation
# Two-panel plot: Effective Rank + TwoNN bars, one bar per backbone model
plot_geometry_summary(
results = results_list, # list of dicts, each with a "model" key
dataset = "Covid",
highlight_model = "dinov3_vits16", # accented in orange; others in blue/purple
save_path = "covid_geometry.png",
)
# Three-panel separability plot: Fisher Ratio · SVM Margin · KNN Consistency
plot_separability(
results = results_list,
dataset = "Covid",
save_path = "covid_separability.png",
)
💡 Usage Examples
1. Quick Start — Experiment Pipeline
The Experiment class wires every component together and runs the complete pipeline in a single .run() call.
from qmedx.data import load_features
from qmedx.normalize import Compose, L2, ISP
from qmedx.reduce import SparseRandomProjection
from qmedx.encode import AngularEncoding
from qmedx.ansatz import StronglyEntangling
from qmedx.train import AdamWTrainer
from qmedx.experiment import Experiment
# Load training features
X, y = load_features("Covid", "dinov3_vits16", split="train")
exp = Experiment(
data = (X, y), # auto 80/20 stratified split
normalizer = Compose([L2(), ISP()]), # L2 then sphere projection
reducer = SparseRandomProjection(), # n_components auto-resolved
encoder = AngularEncoding(reupload=True),
ansatz = StronglyEntangling(
block = "cepheus_native", # hardware-native gates
layers = 3,
),
n_qubits = 8,
trainer = AdamWTrainer(lr=0.01, epochs=100, verbose=True),
)
result = exp.run()
# Scalar results
print(f"Quantum accuracy : {result['q_acc']:.4f}")
print(f"Linear probe acc : {result['lin_acc']:.4f}")
print(f"Quantum gain : {result['quantum_gain']:+.4f}")
# Geometry metrics (computed on raw X_train)
print(f"TwoNN : {result['twonn']:.2f}")
print(f"Fisher ratio : {result['fisher_ratio']:.4f}")
print(f"kNN consistency : {result['knn_consistency_mean']:.4f}")
# Persist to CSV (appends row; creates file if absent)
exp.append_to_csv("results/covid_experiment.csv")
What exp.run() does step by step:
- The
(X, y)tuple is split 80/20 stratified. reducer.n_componentsis resolved so thatnormalizer.output_dim(n_components) == encoder.required_input_dim(n_qubits).- Reducer is fit on
X_train; both splits are transformed. - Both splits are normalised.
- Geometry metrics are computed on the original (un-reduced)
X_train. - A PennyLane QNode is built using
build_qnode(encoder, ansatz, n_qubits). - Parameters are initialised uniformly in
[-π, π]. - Parameters are trained via the hinge loss with
AdamWTrainer. - Test-set quantum accuracy is computed via
predict_batch(qnode, params, X_te_n). - A logistic-regression linear probe is run on the same encoder-ready features as the ablation baseline.
- A flat result dict (geometry +
q_acc+lin_acc+quantum_gain+trained_params+losses+config) is returned.
2. Explicit Train / Test Splits
Pass a 4-tuple to Experiment to bypass the automatic 80/20 split:
from qmedx.data import load_train_test
from qmedx.normalize import L2
from qmedx.reduce import PCA
from qmedx.encode import AngularEncoding
from qmedx.ansatz import StronglyEntangling
from qmedx.train import AdamWTrainer
from qmedx.experiment import Experiment
X_train, y_train, X_test, y_test = load_train_test("Lung", "dinov3_vitb16")
exp = Experiment(
data = (X_train, y_train, X_test, y_test), # 4-tuple → no auto-split
normalizer = L2(),
reducer = PCA(),
encoder = AngularEncoding(reupload=False),
ansatz = StronglyEntangling(block="rot", layers=2),
n_qubits = 4,
trainer = AdamWTrainer(lr=0.005, epochs=50),
)
result = exp.run()
print(f"q_acc={result['q_acc']:.4f} lin_acc={result['lin_acc']:.4f} "
f"gain={result['quantum_gain']:+.4f}")
3. Hybrid Model with GPU Training
Use HybridQuantumClassifier + TorchTrainer for end-to-end differentiable training on high-dimensional features without the manual reduce → encode → train loop.
import numpy as np
import torch
from torch.utils.data import DataLoader, TensorDataset
from qmedx.ansatz import StronglyEntangling
from qmedx.hybrid import HybridQuantumClassifier
from qmedx.train import TorchTrainer
# ── Load pre-extracted features ────────────────────────────────────────────────
X_train = np.load("features/train/features.npy").astype(np.float32)
y_train = np.load("features/train/labels.npy").astype(np.int64)
X_val = np.load("features/val/features.npy").astype(np.float32)
y_val = np.load("features/val/labels.npy").astype(np.int64)
X_test = np.load("features/test/features.npy").astype(np.float32)
y_test = np.load("features/test/labels.npy").astype(np.int64)
def make_loader(X, y, shuffle):
return DataLoader(
TensorDataset(torch.from_numpy(X), torch.from_numpy(y)),
batch_size=256, shuffle=shuffle, num_workers=0,
)
train_loader = make_loader(X_train, y_train, shuffle=True)
val_loader = make_loader(X_val, y_val, shuffle=False)
test_loader = make_loader(X_test, y_test, shuffle=False)
# ── Build model ────────────────────────────────────────────────────────────────
feature_dim = X_train.shape[1]
num_classes = int(y_train.max()) + 1
ansatz = StronglyEntangling(block="fully_connected", layers=4)
model = HybridQuantumClassifier(
feature_dim = feature_dim,
n_qubits = 8,
ansatz = ansatz,
num_classes = num_classes,
data_reuploading = True,
input_dropout = 0.20,
hidden_dropout = 0.30,
head_dropout = 0.20,
)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device) # classical layers → GPU, VQC weights stay on CPU
total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Trainable parameters: {total_params:,}")
# ── Train ──────────────────────────────────────────────────────────────────────
trainer = TorchTrainer(
lr = 1e-3,
epochs = 100,
weight_decay = 1e-3,
label_smoothing = 0.05,
patience = 15,
min_delta = 1e-4,
max_grad_norm = 1.0,
verbose = True,
)
fit_result = trainer.fit(model, train_loader, val_loader, out_dir="runs/lung_exp/")
print(f"\nBest epoch : {fit_result['best_epoch']}")
print(f"Best val F1 : {fit_result['best_val_f1']:.4f}")
# ── Final evaluation on held-out test set ─────────────────────────────────────
eval_result = trainer.evaluate(model, test_loader)
m = eval_result["metrics"]
print(f"\nTest accuracy : {m['accuracy']:.4f}")
print(f"Test macro F1 : {m['f1_macro']:.4f}")
print(f"Test precision : {m['precision_macro']:.4f}")
print(f"Test recall : {m['recall_macro']:.4f}")
4. Component Introspection
Every component exposes .about() for serialisable metadata — useful for logging experiments to JSON or CSV.
from qmedx.normalize import Compose, L2, ISP
from qmedx.reduce import SparseRandomProjection
from qmedx.encode import AngularEncoding
from qmedx.ansatz import StronglyEntangling
norm = Compose([L2(), ISP()])
enc = AngularEncoding(reupload=True)
ans = StronglyEntangling(block="cepheus_native", layers=3)
n_qb = 8
# Serialisable descriptions
print(norm.about())
# {'name': 'Compose', 'steps': [{'name': 'L2Normalizer'}, {'name': 'ISPNormalizer'}]}
print(enc.about())
# {'name': 'AngularEncoding', 'reupload': True}
print(ans.about())
# {'name': 'StronglyEntangling', 'block': 'cepheus_native', 'layers': 3}
# Dimension bookkeeping
d = n_qb - 1 # 7 → ISP lifts to 8, matching n_qubits
print(f"Normalizer output dim : {norm.output_dim(d)}") # 8
print(f"Encoder requires : {enc.required_input_dim(n_qb)}") # 8
# Ansatz parameter accounting
print(f"Param tensor shape : {ans.param_shape(n_qb)}") # (3, 8, 2)
print(f"Total VQC params : {ans.n_params(n_qb)}") # 48
5. Geometry Analysis Across Models
Compute and visualise geometry metrics across all backbone models for one dataset:
from qmedx.data import load_features, list_models
from qmedx.geometry import compute_all, plot_geometry_summary, plot_separability
dataset = "Covid"
n_qubits = 8
results = []
for model_name in list_models(dataset):
X, y = load_features(dataset, model_name, split="train")
metrics = compute_all(X, y, n_qubits)
metrics["model"] = model_name
results.append(metrics)
print(f" {model_name:<35s} twonn={metrics['twonn']:.2f}"
f" fisher={metrics['fisher_ratio']:.3f}"
f" knn={metrics['knn_consistency_mean']:.3f}")
# Effective Rank + TwoNN summary (highlighted bar for best model)
plot_geometry_summary(
results = results,
dataset = dataset,
highlight_model = "dinov3_vits16",
save_path = f"{dataset}_geometry.png",
)
# Fisher Ratio, SVM Margin, kNN Consistency — all in one figure
plot_separability(
results = results,
dataset = dataset,
save_path = f"{dataset}_separability.png",
)
🔌 Hardware Export — Rigetti Cepheus-1
After training an Experiment, export the trained parameters and pre-processed test data as a self-contained hardware package for Rigetti's Cepheus-1-108Q QPU.
Step 1 — Export (on your training machine)
from qmedx.data import load_features
from qmedx.normalize import Compose, L2, ISP
from qmedx.reduce import SparseRandomProjection
from qmedx.encode import AngularEncoding
from qmedx.ansatz import StronglyEntangling
from qmedx.train import AdamWTrainer
from qmedx.experiment import Experiment
X, y = load_features("Covid", "dinov3_vits16")
exp = Experiment(
data = (X, y),
normalizer = Compose([L2(), ISP()]),
reducer = SparseRandomProjection(),
encoder = AngularEncoding(reupload=False),
ansatz = StronglyEntangling(block="cepheus_native", layers=3),
n_qubits = 8,
trainer = AdamWTrainer(lr=0.01, epochs=100),
)
exp.run()
# Writes two files:
# out/cepheus_export/cepheus_payload.npz — trained weights + encoder-ready test data
# out/cepheus_export/cepheus_run.py — standalone pyQuil evaluation script
exp.export_cepheus("out/cepheus_export/")
Step 2 — Run (inside a QCS environment)
# Copy both files to your QCS / Rigetti cloud environment, then:
pip install pyquil numpy pandas
# Open cepheus_run.py and set CEPHEUS_QPU_ID at the top:
# CEPHEUS_QPU_ID = "your-cepheus-1-qpu-string"
python cepheus_run.py
# → prints running accuracy per sample
# → saves cepheus_results.csv with per-sample predictions and ⟨Z⟩ values
What's in cepheus_run.py
The generated script is a fully self-contained, zero-dependency (except pyquil, numpy, pandas) evaluation script:
| Feature | Detail |
|---|---|
| Circuit reconstruction | Rebuilds the trained VQC from cepheus_payload.npz — no re-training |
| Angular encoding | Per-qubit RY(xᵢ) · RZ(xᵢ) · RZ(xᵢ²) using only Cepheus native gates |
| Ansatz | RZ(θ) · RX(π/2) · RZ(φ) rotation blocks + CZ nearest-neighbor entangler |
| T-REX mitigation | readout_twirl() — randomised bit-flip symmetrisation: for each sample, N rounds of random X-gate pre-flips are applied; each readout is XOR-corrected before averaging, cancelling systematic assignment errors |
| Shot budget | Configurable: N_SHOTS = 1000 shots × N_TREX_ROUNDS = 10 rounds per sample |
| Output | cepheus_results.csv with sample_idx, y_true, y_pred, z_mitigated, correct |
🧪 Running the Tests
cd QmedX
pip install pytest
pytest tests/ -v
Expected output:
tests/test_geometry.py::TestEffectiveRank::test_low_rank_gives_small_rank PASSED
tests/test_geometry.py::TestEffectiveRank::test_identity_gives_max_rank PASSED
tests/test_geometry.py::TestTwoNN::test_line_manifold PASSED
tests/test_geometry.py::TestFisherRatio::test_well_separated_classes PASSED
tests/test_geometry.py::TestFisherRatio::test_overlapping_classes PASSED
tests/test_geometry.py::TestSVMMargin::test_returns_expected_keys PASSED
tests/test_geometry.py::TestKNNConsistency::test_perfect_separation PASSED
tests/test_geometry.py::TestKNNConsistency::test_random_labels PASSED
tests/test_geometry.py::TestReconstructionLoss::test_zero_for_full_rank PASSED
tests/test_geometry.py::TestComputeAll::test_returns_all_keys PASSED
All tests use synthetic data (well-separated Gaussians, low-rank matrices, 1-D line manifolds) and do not require any external feature files.
🎯 Design Principles
1. Pre-extracted features only.
QmedX never re-trains a backbone. It consumes .npy arrays, keeping the code independent of any imaging framework and making experiments fast to iterate and easy to reproduce.
2. Two independent pipelines for two research questions.
The Experiment + AdamWTrainer pipeline answers "does quantum encoding provide advantage over a classical linear probe?" The HybridQuantumClassifier + TorchTrainer pipeline answers "how well can a differentiable quantum bottleneck learn end-to-end on a GPU?" Both share ansatz, encoding, and geometry components.
3. Hardware-first ansatz design.
The default block (cepheus_native) uses only the gates native to Rigetti's Cepheus-1-108Q QPU — no compiler decomposition is required, minimising gate errors. The export path generates a T-REX-mitigated pyQuil script that runs on QCS without any QmedX dependency.
4. Every component is replaceable.
Reducers share fit/transform, normalizers share normalize, encoders share apply/required_input_dim. Swap any component without touching the rest of the pipeline. The Experiment class handles all dimension bookkeeping automatically.
5. Geometry is a first-class citizen.
compute_all() is called on the original un-reduced feature space and its output is included in every Experiment result dict. The central research hypothesis — TwoNN intrinsic dimensionality predicts quantum compressibility — is testable directly from the results CSV without any additional post-processing.
🗺️ Roadmap
| Status | Feature |
|---|---|
| ✅ | AngularEncoding with data re-uploading |
| ✅ | MottonenEncoding (amplitude, simulation only) |
| ✅ | 7 ansatz block types — CZ (QPU-native) and CNOT families |
| ✅ | HybridQuantumClassifier with end-to-end PyTorch gradients |
| ✅ | TorchTrainer with early stopping, LR scheduling, and checkpointing |
| ✅ | Rigetti Cepheus-1 QPU export with T-REX readout mitigation |
| ✅ | Complete geometry metrics suite (TwoNN, Fisher, SVM, kNN, Effective Rank) |
| ✅ | Geometry visualisation (summary + separability plots) |
| 🔲 | ZZFeatureMap encoder (Havlíček et al. 2019) |
| 🔲 | Multi-dataset sweep CLI: qmedx sweep --datasets Covid,Lung --n-qubits 4,8 |
| 🔲 | Noise model simulation (depolarising + readout bit-flip) |
| 🔲 | IBM Quantum / Qiskit export path alongside Cepheus |
| 🔲 | SHAP-based feature-importance analysis on VQC outputs |
| 🔲 | Barren-plateau diagnostics (variance of gradients vs n_qubits) |
Built for quantum-ML research on medical imaging. Designed to be run, extended, and cited.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file qmedx-0.1.0.tar.gz.
File metadata
- Download URL: qmedx-0.1.0.tar.gz
- Upload date:
- Size: 71.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d471aaabdc0c68a93aa5bd9cb2d9c98c844640309db1f55110732b8aad06bdd2
|
|
| MD5 |
29e76959bd52511dc9c16c86c65b003b
|
|
| BLAKE2b-256 |
f7bc7a545525b71354fc04801cb38377036f35be9b753078da6606c3d08c3183
|
File details
Details for the file qmedx-0.1.0-py3-none-any.whl.
File metadata
- Download URL: qmedx-0.1.0-py3-none-any.whl
- Upload date:
- Size: 48.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5efa0f0e79e62da4fc2ee4303017b74e23110c84044a38e84e9f751006ef7d0e
|
|
| MD5 |
1aabc5155a1501adca580eba9b2a5cb0
|
|
| BLAKE2b-256 |
0a1e0e81c7f5d3bf3e5afed96c6c3b3bd9d09f7351781c9069cb562200d0febb
|