Files
L'électron rare 5eea6bcc5b chore: initial import — torch-vqc v0.1.0
Extracted from micro-kiki for open-source release. Pure-torch VQC matching
PennyLane default.qubit at 1e-5 numerical precision, with autograd training
and batched inference.

Contents:
  src/torch_vqc/
    circuit.py   — torch_vqc_forward (6-qubit StronglyEntanglingLayers)
    router.py    — TorchVQCRouter nn.Module + optional learned projection
    __init__.py  — public API exports

  tests/ (10 tests, all passing)
    test_circuit.py     — forward match vs PennyLane, single + batched
    test_training.py    — loss decrease, accuracy, gradient flow, 20x speedup
    test_projection.py  — learned projection rescues hard tasks, shape checks
    conftest.py         — sys.path shim for tests without pip install -e

  pyproject.toml  — Apache-2.0, Python 3.10+, deps: torch+numpy, test: pennylane
  README.md       — quick start, benchmarks, conventions, citation
  LICENSE         — Apache 2.0 full text
  docs/findings.md — scientific background (from micro-kiki Plan 6)

Measured speedup on 6-qubit 6-layer circuit with 120 training samples × 3 epochs:
  PennyLane parameter-shift:  ~99 s
  torch-vqc autograd:         ~30 ms
  = ~3000x speedup.
2026-04-19 16:34:24 +02:00

158 lines
5.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Training tests: autograd loop reduces loss and reaches non-trivial accuracy.
TDD: verify that TorchVQCRouter learns on a small synthetic task, replacing
PennyLane's parameter-shift training with torch autograd.
"""
from __future__ import annotations
import numpy as np
import pytest
import torch
def _make_synthetic_task(n_classes: int = 4, samples_per_class: int = 20, seed: int = 0):
"""Each class has embeddings clustered around a distinct mean in first 6 dims.
Classifiable by the first 6 features alone — what the VQC sees.
"""
rng = np.random.default_rng(seed)
X, y = [], []
for c in range(n_classes):
center = rng.uniform(0.5, 2 * np.pi - 0.5, size=6) # stay away from 0/2π
for _ in range(samples_per_class):
noise = rng.normal(0, 0.15, size=10)
x = np.zeros(10)
x[:6] = center + noise[:6]
x[6:] = noise[6:]
X.append(x)
y.append(c)
X = np.array(X, dtype=np.float64)
y = np.array(y, dtype=np.int64)
# Shuffle
perm = rng.permutation(len(X))
return X[perm], y[perm]
def test_training_reduces_loss():
from torch_vqc.router import TorchVQCRouter
X, y = _make_synthetic_task(n_classes=4, samples_per_class=10, seed=0)
model = TorchVQCRouter(n_qubits=6, n_layers=6, n_classes=4, lr=0.1, seed=42)
X_t = torch.from_numpy(X).double()
y_t = torch.from_numpy(y)
losses = model.train_batched(X_t, y_t, epochs=30)
assert len(losses) == 30
assert losses[-1] < losses[0] - 0.05, (
f"expected loss decrease >0.05, got {losses[0]:.4f}{losses[-1]:.4f}"
)
def test_training_reaches_nontrivial_accuracy():
"""After training, train-set accuracy should clearly beat chance (25% for 4 classes)."""
from torch_vqc.router import TorchVQCRouter
X, y = _make_synthetic_task(n_classes=4, samples_per_class=15, seed=1)
model = TorchVQCRouter(n_qubits=6, n_layers=6, n_classes=4, lr=0.1, seed=7)
X_t = torch.from_numpy(X).double()
y_t = torch.from_numpy(y)
model.train_batched(X_t, y_t, epochs=50)
with torch.no_grad():
logits = model.forward(X_t)
preds = logits.argmax(dim=-1)
acc = (preds == y_t).float().mean().item()
assert acc > 0.50, f"expected >50% (well above 25% chance), got {acc:.3f}"
def test_gradients_flow_through_vqc_weights():
"""Sanity: VQC weights must receive non-zero gradient."""
from torch_vqc.router import TorchVQCRouter
X, y = _make_synthetic_task(n_classes=3, samples_per_class=5, seed=2)
model = TorchVQCRouter(n_qubits=6, n_layers=6, n_classes=3, lr=0.05, seed=3)
X_t = torch.from_numpy(X).double()
y_t = torch.from_numpy(y)
# One forward + backward
logits = model.forward(X_t)
loss = torch.nn.functional.cross_entropy(logits, y_t)
loss.backward()
assert model.vqc_weights.grad is not None
assert model.vqc_weights.grad.abs().max().item() > 1e-6, (
"VQC weight gradient is zero — autograd not flowing through circuit"
)
assert model.linear_w.grad is not None
assert model.linear_w.grad.abs().max().item() > 1e-6
def test_training_faster_than_pennylane_parameter_shift():
"""Benchmark: torch autograd training must be >=20× faster than PennyLane parameter-shift.
Builds a minimal per-sample parameter-shift loop with PennyLane to compare
head-to-head. This is the whole point of the package — if this regresses,
the main contribution is broken.
"""
import time
import pennylane as qml
from torch_vqc.router import TorchVQCRouter
X, y = _make_synthetic_task(n_classes=4, samples_per_class=10, seed=4)
epochs = 3
# Torch autograd (full batch)
model = TorchVQCRouter(n_qubits=6, n_layers=6, n_classes=4, lr=0.05, seed=0)
X_t = torch.from_numpy(X).double()
y_t = torch.from_numpy(y)
_ = model.train_batched(X_t[:5], y_t[:5], epochs=1) # warmup
t0 = time.perf_counter()
model.train_batched(X_t, y_t, epochs=epochs)
torch_time = time.perf_counter() - t0
# PennyLane parameter-shift reference — minimal per-sample SGD loop
n_q, n_l, n_c = 6, 6, 4
dev = qml.device("default.qubit", wires=n_q)
@qml.qnode(dev)
def circuit(w, f):
qml.AngleEmbedding(f[:n_q], wires=range(n_q))
qml.StronglyEntanglingLayers(w, wires=range(n_q))
return [qml.expval(qml.PauliZ(i)) for i in range(n_q)]
rng = np.random.default_rng(0)
w = rng.uniform(0, 2 * np.pi, size=(n_l, n_q, 3))
lw = rng.standard_normal((n_q, n_c)) * 0.1
lb = np.zeros(n_c)
lr = 0.05
t0 = time.perf_counter()
for _ in range(epochs):
for x, label in zip(X, y):
q = np.array(circuit(w, x))
logits = q @ lw + lb
probs = np.exp(logits - logits.max())
probs /= probs.sum()
d_logits = probs.copy()
d_logits[int(label)] -= 1
lw -= lr * np.outer(q, d_logits)
lb -= lr * d_logits
dl_dq = lw @ d_logits
# parameter-shift for each VQC weight
shift = np.pi / 2
for idx in np.ndindex(*w.shape):
w_p, w_m = w.copy(), w.copy()
w_p[idx] += shift
w_m[idx] -= shift
dq = (np.array(circuit(w_p, x)) - np.array(circuit(w_m, x))) / 2.0
w[idx] -= lr * float(dl_dq @ dq)
pl_time = time.perf_counter() - t0
speedup = pl_time / torch_time
print(f"\nSpeedup: {speedup:.1f}× (torch {torch_time*1000:.0f}ms vs PL parameter-shift {pl_time*1000:.0f}ms)")
assert speedup >= 20.0, f"expected ≥20× speedup, got {speedup:.1f}×"