Skip to main content

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

Python PennyLane PyTorch scikit-learn License: MIT QPU: Rigetti Cepheus-1

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

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_components is set automatically so that normalizer.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. Use AngularEncoding for 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_native uses 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
  • ReduceLROnPlateau learning-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.pth whenever 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 pins vqc.weights back 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:

  1. The (X, y) tuple is split 80/20 stratified.
  2. reducer.n_components is resolved so that normalizer.output_dim(n_components) == encoder.required_input_dim(n_qubits).
  3. Reducer is fit on X_train; both splits are transformed.
  4. Both splits are normalised.
  5. Geometry metrics are computed on the original (un-reduced) X_train.
  6. A PennyLane QNode is built using build_qnode(encoder, ansatz, n_qubits).
  7. Parameters are initialised uniformly in [-π, π].
  8. Parameters are trained via the hinge loss with AdamWTrainer.
  9. Test-set quantum accuracy is computed via predict_batch(qnode, params, X_te_n).
  10. A logistic-regression linear probe is run on the same encoder-ready features as the ablation baseline.
  11. 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

qmedx-0.1.0.tar.gz (71.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

qmedx-0.1.0-py3-none-any.whl (48.0 kB view details)

Uploaded Python 3

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

Hashes for qmedx-0.1.0.tar.gz
Algorithm Hash digest
SHA256 d471aaabdc0c68a93aa5bd9cb2d9c98c844640309db1f55110732b8aad06bdd2
MD5 29e76959bd52511dc9c16c86c65b003b
BLAKE2b-256 f7bc7a545525b71354fc04801cb38377036f35be9b753078da6606c3d08c3183

See more details on using hashes here.

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

Hashes for qmedx-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5efa0f0e79e62da4fc2ee4303017b74e23110c84044a38e84e9f751006ef7d0e
MD5 1aabc5155a1501adca580eba9b2a5cb0
BLAKE2b-256 0a1e0e81c7f5d3bf3e5afed96c6c3b3bd9d09f7351781c9069cb562200d0febb

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page