Metadata-Version: 2.4
Name: qmedx
Version: 0.1.0
Summary: Quantum Medical-Imaging Classification Research Toolkit — variational quantum classifiers and hybrid classical-quantum models on pre-extracted medical imaging features.
License: MIT License
        
        Copyright (c) 2024 QmedX Contributors
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Homepage, https://github.com/AshrafBoussahi/QmedX
Project-URL: Repository, https://github.com/AshrafBoussahi/QmedX
Project-URL: Bug Tracker, https://github.com/AshrafBoussahi/QmedX/issues
Project-URL: Documentation, https://github.com/AshrafBoussahi/QmedX#readme
Keywords: quantum computing,quantum machine learning,variational quantum circuits,medical imaging,pennylane,hybrid quantum-classical,VQC,Rigetti,Cepheus
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Scientific/Engineering :: Physics
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pennylane>=0.36
Requires-Dist: scikit-learn>=1.4
Requires-Dist: numpy>=1.24
Requires-Dist: pandas>=2.0
Requires-Dist: matplotlib>=3.7
Provides-Extra: torch
Requires-Dist: torch>=2.0; extra == "torch"
Provides-Extra: lightning
Requires-Dist: pennylane-lightning>=0.36; extra == "lightning"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Provides-Extra: all
Requires-Dist: torch>=2.0; extra == "all"
Requires-Dist: pennylane-lightning>=0.36; extra == "all"
Requires-Dist: pytest>=8.0; extra == "all"
Requires-Dist: pytest-cov>=5.0; extra == "all"
Dynamic: license-file

<div align="center">

# ⚛️ QmedX

### Quantum Medical-Imaging Classification Research Toolkit

[![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/)
[![PennyLane](https://img.shields.io/badge/PennyLane-0.36%2B-8B5CF6?style=flat-square)](https://pennylane.ai/)
[![PyTorch](https://img.shields.io/badge/PyTorch-2.0%2B-EE4C2C?style=flat-square&logo=pytorch&logoColor=white)](https://pytorch.org/)
[![scikit-learn](https://img.shields.io/badge/scikit--learn-1.4%2B-F7931E?style=flat-square&logo=scikitlearn&logoColor=white)](https://scikit-learn.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-22C55E?style=flat-square)](LICENSE)
[![QPU: Rigetti Cepheus-1](https://img.shields.io/badge/QPU-Rigetti%20Cepheus--1-6366F1?style=flat-square)](https://www.rigetti.com/)

*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.*

</div>

---

## Table of Contents

- [Overview](#-overview)
- [Architecture at a Glance](#-architecture-at-a-glance)
- [Installation](#-installation)
- [Directory Structure](#-directory-structure)
- [Data Loading](#-data-loading)
- [Components](#-components)
  - [Normalizers](#normalizers)
  - [Reducers](#reducers)
  - [Encoders](#encoders)
  - [Ansatz Blocks](#ansatz-blocks)
  - [Trainers](#trainers)
  - [HybridQuantumClassifier](#hybridquantumclassifier)
  - [Geometry Metrics](#geometry-metrics)
- [Usage Examples](#-usage-examples)
  - [1. Quick Start — Experiment Pipeline](#1-quick-start--experiment-pipeline)
  - [2. Explicit Train / Test Splits](#2-explicit-train--test-splits)
  - [3. Hybrid Model with GPU Training](#3-hybrid-model-with-gpu-training)
  - [4. Component Introspection](#4-component-introspection)
  - [5. Geometry Analysis Across Models](#5-geometry-analysis-across-models)
- [Hardware Export — Rigetti Cepheus-1](#-hardware-export--rigetti-cepheus-1)
- [Running the Tests](#-running-the-tests)
- [Design Principles](#-design-principles)
- [Roadmap](#-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

```bash
# 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

```bash
# 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:

```python
import sys
sys.path.insert(0, "/path/to/QmedX")
import qmedx
```

### Verify

```python
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

```python
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

```python
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()`.

```python
from qmedx.normalize import L2, ISP, Compose
```

---

#### `L2` — Row-wise L2 Normalisation

Projects each sample onto the unit hypersphere in ℝᵈ. Output dimension is **unchanged**.

```python
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.

```python
norm = ISP()
X_lifted = norm.normalize(X)   # shape: (N, d+1)
```

---

#### `Compose` — Chaining Multiple Normalizers

```python
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()`.

```python
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 |

```python
# 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()`.

```python
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.

```python
# 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.

```python
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:

```python
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

```python
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.

```python
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)

```python
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`.

```python
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.

```python
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.

```python
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.

```python
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 |

```python
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

```python
# 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.

```python
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:

```python
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.

```python
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.

```python
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:

```python
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)

```python
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)

```bash
# 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

```bash
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) |

---

<div align="center">

Built for quantum-ML research on medical imaging.
Designed to be run, extended, and cited.

</div>
