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.
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,108 @@
|
||||
# torch-vqc
|
||||
|
||||
Pure-torch variational quantum circuit (VQC) with autograd training — **~3000× faster** than PennyLane parameter-shift on 6-qubit StronglyEntanglingLayers.
|
||||
|
||||
## Why
|
||||
|
||||
PennyLane's `default.qubit` + parameter-shift gradient is the standard for VQC research, but it's painfully slow:
|
||||
- **Per gradient step**: 2 extra forward passes per parameter (~216 for a 6×6×3 StronglyEntanglingLayers)
|
||||
- **No batching**: samples processed one at a time in a Python loop
|
||||
- **Result**: training a small VQC classifier on 400 samples × 10 epochs takes minutes
|
||||
|
||||
`torch-vqc` re-implements the same circuit as explicit state-vector math in torch:
|
||||
- **Autograd backprop** replaces parameter-shift: one backward pass for the whole gradient
|
||||
- **Batched**: all samples in parallel via `einsum`/`index_select` on the state tensor
|
||||
- **Validated**: forward output matches PennyLane `default.qubit` to `1e-5`
|
||||
|
||||
Benchmark (6 qubits, 6 layers, 120 train samples × 3 epochs):
|
||||
|
||||
| Backend | Time | Speedup |
|
||||
|---|---|---|
|
||||
| PennyLane parameter-shift | ~99 s | 1× |
|
||||
| torch-vqc autograd | ~30 ms | **~3000×** |
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install torch-vqc
|
||||
# or for dev:
|
||||
git clone https://github.com/electron-rare/torch-vqc
|
||||
cd torch-vqc && pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
import torch
|
||||
from torch_vqc import torch_vqc_forward, TorchVQCRouter
|
||||
|
||||
# Low-level: run a circuit forward pass directly
|
||||
features = torch.randn(32, 10, dtype=torch.float64) # batch of 32, 10 input features
|
||||
weights = torch.randn(6, 6, 3, dtype=torch.float64) # (n_layers, n_qubits, 3)
|
||||
z_expvals = torch_vqc_forward(features, weights, n_qubits=6, n_layers=6)
|
||||
# → shape (32, 6), values in [-1, 1]
|
||||
|
||||
# High-level: VQC + classical head, ready to train
|
||||
model = TorchVQCRouter(
|
||||
n_qubits=4,
|
||||
n_layers=6,
|
||||
n_classes=10,
|
||||
input_dim=384, # enables learned projection (384 → n_qubits) before circuit
|
||||
weight_decay=1e-4, # L2 regularization
|
||||
lr=0.05,
|
||||
seed=0,
|
||||
)
|
||||
X = torch.randn(400, 384, dtype=torch.float64)
|
||||
y = torch.randint(0, 10, (400,))
|
||||
losses = model.train_batched(X, y, epochs=300)
|
||||
preds = model.predict(X)
|
||||
```
|
||||
|
||||
## What's included
|
||||
|
||||
| Module | Purpose |
|
||||
|---|---|
|
||||
| `torch_vqc.circuit.torch_vqc_forward` | Pure forward pass — AngleEmbedding + StronglyEntanglingLayers + PauliZ expectations, validated vs PennyLane at 1e-5 |
|
||||
| `torch_vqc.router.TorchVQCRouter` | `nn.Module` wrapping circuit + classical head, optional learned projection, full-batch SGD with `train_batched()` |
|
||||
|
||||
## Conventions (matched to PennyLane default.qubit)
|
||||
|
||||
- Wire 0 = most significant bit in the ket notation `|q₀ q₁ ... q_{N-1}⟩`
|
||||
- `Rot(φ, θ, ω) = RZ(ω) · RY(θ) · RZ(φ)` (PennyLane convention)
|
||||
- `AngleEmbedding` uses RX (PennyLane default), not RY — easy to miss
|
||||
- `StronglyEntanglingLayers` CNOT ring range `r_l = l % (N-1) + 1`
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pip install -e ".[test]"
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Ten tests covering:
|
||||
- Forward matches PennyLane (single + batched)
|
||||
- Training reduces loss, reaches non-trivial accuracy
|
||||
- Gradients flow through VQC weights
|
||||
- Speedup ≥ 20× vs PennyLane parameter-shift reference
|
||||
- Learned projection rescues architecture on tasks where truncation fails
|
||||
|
||||
## Background
|
||||
|
||||
This work was extracted from the [micro-kiki](https://github.com/genial-lab/micro-kiki) project, where it was used to execute a VQC reproducibility investigation in 2 hours of compute instead of the 3 days that PennyLane's parameter-shift would have required. See `docs/findings.md` for the full scientific context.
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 — see `LICENSE`.
|
||||
|
||||
## Citation
|
||||
|
||||
If you use this in academic work, cite as:
|
||||
|
||||
```bibtex
|
||||
@software{torch_vqc_2026,
|
||||
author = {L'électron rare},
|
||||
title = {torch-vqc: Pure-torch VQC with autograd training at 3000× PennyLane speedup},
|
||||
year = {2026},
|
||||
url = {https://github.com/electron-rare/torch-vqc}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
# Plan 6 — VQC Reproducibility: final findings
|
||||
|
||||
**Date:** 2026-04-19
|
||||
**Branch:** `poc/torch-vqc-mps`
|
||||
**Method:** Torch VQC (3000× faster than PennyLane parameter-shift) used to execute
|
||||
Plan 6 tasks 5-7 in minutes instead of days.
|
||||
|
||||
## TL;DR
|
||||
|
||||
**The Task 14 accuracy of 0.925 is not reproducible**, on identical code, identical
|
||||
data, with a rigorous modern VQC training loop. The "97% retention at 3× compression"
|
||||
claim in Paper A §4.4 is built on a non-reproducible reference number.
|
||||
|
||||
## Investigation trail
|
||||
|
||||
### Task A: git archaeology
|
||||
|
||||
- `quantum_router.py` at commit `8bdd376` (Task 14 timestamp, 18 apr 13:51) is
|
||||
**byte-identical** to HEAD. Same `QuantumRouterConfig` defaults.
|
||||
- `eval_text_jepa_vqc.py` at `a804daa` (first commit of script) is **byte-identical**
|
||||
to the version that produced Task 15.5. Same `n_qubits=4, n_layers=6`, same split.
|
||||
- Dataset `data/final/<domain>/train.jsonl` unchanged since before Task 14.
|
||||
|
||||
**Conclusion**: nothing in git explains the gap. Task 14 must have been run with
|
||||
uncommitted local modifications.
|
||||
|
||||
### Task B: n_qubits sweep (20 runs × 200 epochs in 22 min on Studio)
|
||||
|
||||
```
|
||||
n_qubits=4: test_acc = 0.082 ± 0.025
|
||||
n_qubits=6: test_acc = 0.080 ± 0.033
|
||||
n_qubits=8: test_acc = 0.076 ± 0.016
|
||||
n_qubits=10: test_acc = 0.058 ± 0.013
|
||||
```
|
||||
|
||||
All configurations stuck near chance (0.10 for 10 classes). More qubits does NOT
|
||||
help. The architectural limit is **VQC only sees `features[:n_qubits]`** — the
|
||||
first few dims of MiniLM mean-pooled embeddings don't carry domain-discriminating
|
||||
information.
|
||||
|
||||
### Task C: Task 14 config reconstruction
|
||||
|
||||
Decoded Task 14's reported `n_params=20` + `n_test=80`:
|
||||
|
||||
| n_classes × n/domain | n_test | nq=4 n_layers=0 params |
|
||||
|---|---|---|
|
||||
| 4 × 100 | 80 | **20** ✓✓ exact match |
|
||||
|
||||
So Task 14 was run with:
|
||||
- **4 classes** (not 10): dsp, electronics, emc, embedded (likely)
|
||||
- **n_layers=0** (no variational layers — just angle embedding + measurement + linear head)
|
||||
- **100 samples/domain** (400 total)
|
||||
|
||||
Reproducing this config with torch VQC:
|
||||
|
||||
```
|
||||
nq=4 nl=6 n_params= 92 → test_acc=0.212
|
||||
nq=4 nl=0 n_params= 20 → test_acc=0.200 ← Task 14 config reconstructed
|
||||
nq=2 nl=1 n_params= 18 → test_acc=0.200
|
||||
nq=3 nl=1 n_params= 25 → test_acc=0.200
|
||||
```
|
||||
|
||||
**Task 14's 0.925 is NOT reproducible.** Current rigorous run gives ~0.20 (chance
|
||||
for 4 classes).
|
||||
|
||||
## Implications
|
||||
|
||||
1. **Paper A §4.4** claim of "97% retention at 3× compression" compares Text-JEPA
|
||||
at 0.900 vs baseline at 0.925, where 0.925 is an irreproducible number. The
|
||||
ratio argument is invalid.
|
||||
|
||||
2. **Plan 6's kill criterion is inverted**: it was designed to detect non-determinism
|
||||
in the pipeline. Actual result: the pipeline IS deterministic (matched grid v3 +
|
||||
torch sweep confirm baseline stability at 0.19 ± 0.01). The gap is in the
|
||||
historical number, not the pipeline.
|
||||
|
||||
3. **The real finding**: 4-qubit VQC on top of MiniLM embeddings cannot do
|
||||
10-class routing. The information isn't in `features[:4]`. No amount of qubits
|
||||
or layers rescues this — you'd need a different encoding (PCA to n_qubits dims,
|
||||
or a learned projection).
|
||||
|
||||
## What this unlocks (mid-term)
|
||||
|
||||
With torch VQC at 3000× speedup, we can now actually do the experiments Paper A
|
||||
needs to be main-track:
|
||||
|
||||
- Learned projection layer (384 → n_qubits) instead of truncation: tractable to sweep
|
||||
- n_qubits × n_layers grid across 5 seeds: 15 min instead of 6 weeks
|
||||
- DataEmbedding ablation (MiniLM vs BERT vs domain-tuned): hours instead of quarter
|
||||
- Proper Bayesian bootstrap CIs on every reported number: trivial
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Retract §4.4 numerical claims** in Paper A draft — keep the conceptual
|
||||
argument but remove the "97% retention" line until we have a reproducible
|
||||
baseline.
|
||||
2. **Pivot Paper A** from "compression ratio" frame to "torch-native batched
|
||||
VQC training" frame — this is the real contribution: making VQC research
|
||||
cheap enough to iterate on.
|
||||
3. **Do real-projection experiments** before submitting anywhere.
|
||||
|
||||
## Update (2026-04-19, later): learned projection rescues the architecture
|
||||
|
||||
Added optional `input_dim` to `TorchVQCRouter` — inserts a learned linear layer
|
||||
`(input_dim → n_qubits)` followed by `π·tanh`, trained jointly. This replaces
|
||||
the naive `features[:n_qubits]` truncation with a class-discriminative projection.
|
||||
|
||||
Results on 10-class real data (500 samples, 400 train, 100 test, 300 epochs):
|
||||
|
||||
| Config | Test acc (trunc) | Test acc (+ proj) | Δ |
|
||||
|---|---|---|---|
|
||||
| nq=4, nl=6 | 0.090 | **0.300** | **+21 pt** (3× chance) |
|
||||
| nq=6, nl=6 | 0.070 | 0.190 | +12 pt |
|
||||
| nq=8, nl=6 | 0.110 | 0.200 | +9 pt |
|
||||
|
||||
**The architecture is rescued.** Best is counter-intuitively the smallest qubit
|
||||
count (nq=4 + proj = 0.300) — more qubits optimizer-harder on 400 samples, and
|
||||
the extra capacity overfits (train 0.40 / test 0.30 for nq=4+proj already).
|
||||
|
||||
This is the **honest main-track-publishable contribution**: torch-native batched
|
||||
VQC + learned projection makes quantum classifier research on real embeddings
|
||||
tractable, at 3000× speedup vs PennyLane. The "97% retention" claim in Paper A
|
||||
is still invalid — but the direction (VQC + compression) is alive again under
|
||||
a proper framing.
|
||||
|
||||
## Update 2 (2026-04-19, evening): 3-axis rescue ablation
|
||||
|
||||
36 runs on Studio (~5 min) over {data=50 vs 500/dom} × {wd=0, 1e-4, 1e-3} × {linear vs MLP proj} × 3 seeds, 10 classes, nq=4, nl=6, 300 epochs.
|
||||
|
||||
**Headlines**:
|
||||
- **Best single run: 0.390 test_acc** (50/dom + wd=1e-4 + linear + seed 0). Train=0.435, gap=0.045 — not memorizing.
|
||||
- **Median: 0.250**, range 0.170–0.390. Huge seed-to-seed variance → optimization landscape rugged.
|
||||
- **Linear always beats MLP** by 7.2 pt mean (likely `π·tanh` saturates MLP hidden units).
|
||||
- **10× more data adds only 3.8 pt** mean — 4-qubit info channel is the real bottleneck.
|
||||
- **wd=1e-4 linear is the sweet spot** (0.304 mean across data sizes).
|
||||
|
||||
**Upper bound estimate**: 4× chance (~0.40) appears to be the ceiling for this architecture on 10-class routing. A classical linear probe on MiniLM would hit 0.7-0.8. The VQC is NOT competitive classically — but is a rigorous quantum-ML benchmark now.
|
||||
|
||||
**Final recommendation for Paper A**:
|
||||
- Contribution #1 (methodological): torch-native batched VQC @ 3000× over PennyLane.
|
||||
- Contribution #2 (architectural): learned projection rescues VQC from uselessness on arbitrary pretrained embeddings.
|
||||
- Contribution #3 (empirical): thorough ablation showing linear proj + small wd is optimal, MLP and more data give diminishing returns due to information-capacity ceiling at 4 qubits.
|
||||
- **Drop any "competitive with classical" claim**. Frame as "enabling rigorous quantum-classifier research" rather than "winning".
|
||||
@@ -0,0 +1,51 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "torch-vqc"
|
||||
version = "0.1.0"
|
||||
description = "Pure-torch variational quantum circuit (VQC) with autograd training — ~3000× faster than PennyLane parameter-shift on 6-qubit StronglyEntanglingLayers."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "Apache-2.0" }
|
||||
authors = [{ name = "L'électron rare" }]
|
||||
keywords = ["quantum-machine-learning", "vqc", "pytorch", "autograd", "pennylane", "variational-quantum-circuit"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
"Topic :: Scientific/Engineering :: Physics",
|
||||
]
|
||||
dependencies = [
|
||||
"torch>=2.0",
|
||||
"numpy>=1.24",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=7.0",
|
||||
"pennylane>=0.40",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pennylane>=0.40",
|
||||
"ruff>=0.4",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/electron-rare/torch-vqc"
|
||||
Issues = "https://github.com/electron-rare/torch-vqc/issues"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/torch_vqc"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-v"
|
||||
@@ -0,0 +1,9 @@
|
||||
"""torch-vqc — pure-torch variational quantum circuit with autograd training.
|
||||
|
||||
3000× speedup over PennyLane parameter-shift on 6-qubit StronglyEntanglingLayers.
|
||||
"""
|
||||
from torch_vqc.circuit import torch_vqc_forward
|
||||
from torch_vqc.router import TorchVQCRouter
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["torch_vqc_forward", "TorchVQCRouter"]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Pure-torch VQC forward pass matching PennyLane's default.qubit.
|
||||
|
||||
Replaces PennyLane for our specific 6-qubit, 6-layer StronglyEntanglingLayers
|
||||
circuit. Enables MPS/CUDA execution + autograd-based gradients (vs parameter
|
||||
shift) + batched inference.
|
||||
|
||||
Conventions (matched to PennyLane default.qubit):
|
||||
- Wire 0 = most significant bit in |q_0 q_1 ... q_{N-1}⟩.
|
||||
- Basis index: idx = sum_i q_i · 2^{N-1-i}.
|
||||
- Rot(φ, θ, ω) = RZ(ω) · RY(θ) · RZ(φ).
|
||||
- StronglyEntanglingLayers ranges: r_l = l % (N-1) + 1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def _ry(theta: torch.Tensor, cdtype: torch.dtype) -> torch.Tensor:
|
||||
"""RY(θ) = [[cos(θ/2), -sin(θ/2)], [sin(θ/2), cos(θ/2)]]. θ scalar → (2, 2)."""
|
||||
c = torch.cos(theta / 2)
|
||||
s = torch.sin(theta / 2)
|
||||
zero = torch.zeros_like(c)
|
||||
row0 = torch.stack([torch.complex(c, zero), torch.complex(-s, zero)])
|
||||
row1 = torch.stack([torch.complex(s, zero), torch.complex(c, zero)])
|
||||
return torch.stack([row0, row1]).to(cdtype)
|
||||
|
||||
|
||||
def _rz(theta: torch.Tensor, cdtype: torch.dtype) -> torch.Tensor:
|
||||
"""RZ(θ) = diag(exp(-iθ/2), exp(iθ/2)). θ scalar → (2, 2)."""
|
||||
half = theta / 2
|
||||
cos_h = torch.cos(half)
|
||||
sin_h = torch.sin(half)
|
||||
zero_c = torch.complex(torch.zeros_like(cos_h), torch.zeros_like(cos_h))
|
||||
e_minus = torch.complex(cos_h, -sin_h) # exp(-iθ/2)
|
||||
e_plus = torch.complex(cos_h, sin_h) # exp(+iθ/2)
|
||||
row0 = torch.stack([e_minus, zero_c])
|
||||
row1 = torch.stack([zero_c, e_plus])
|
||||
return torch.stack([row0, row1]).to(cdtype)
|
||||
|
||||
|
||||
def _rot(phi: torch.Tensor, theta: torch.Tensor, omega: torch.Tensor, cdtype: torch.dtype) -> torch.Tensor:
|
||||
"""PennyLane convention: Rot(φ, θ, ω) = RZ(ω) @ RY(θ) @ RZ(φ)."""
|
||||
return _rz(omega, cdtype) @ _ry(theta, cdtype) @ _rz(phi, cdtype)
|
||||
|
||||
|
||||
def _ry_batched(theta: torch.Tensor, cdtype: torch.dtype) -> torch.Tensor:
|
||||
"""RY for a batch of angles. theta shape (B,) → (B, 2, 2)."""
|
||||
c = torch.cos(theta / 2)
|
||||
s = torch.sin(theta / 2)
|
||||
zero = torch.zeros_like(c)
|
||||
row0 = torch.stack([torch.complex(c, zero), torch.complex(-s, zero)], dim=-1)
|
||||
row1 = torch.stack([torch.complex(s, zero), torch.complex(c, zero)], dim=-1)
|
||||
return torch.stack([row0, row1], dim=-2).to(cdtype)
|
||||
|
||||
|
||||
def _rx_batched(theta: torch.Tensor, cdtype: torch.dtype) -> torch.Tensor:
|
||||
"""RX for a batch of angles. theta shape (B,) → (B, 2, 2).
|
||||
|
||||
RX(θ) = [[cos(θ/2), -i·sin(θ/2)], [-i·sin(θ/2), cos(θ/2)]].
|
||||
PennyLane AngleEmbedding default is RX.
|
||||
"""
|
||||
c = torch.cos(theta / 2)
|
||||
s = torch.sin(theta / 2)
|
||||
zero = torch.zeros_like(c)
|
||||
row0 = torch.stack([torch.complex(c, zero), torch.complex(zero, -s)], dim=-1)
|
||||
row1 = torch.stack([torch.complex(zero, -s), torch.complex(c, zero)], dim=-1)
|
||||
return torch.stack([row0, row1], dim=-2).to(cdtype)
|
||||
|
||||
|
||||
def _apply_1q(state: torch.Tensor, U: torch.Tensor, q: int, n: int) -> torch.Tensor:
|
||||
"""Apply a single-qubit gate U (shape (2,2)) on wire q. state: (B, 2^n)."""
|
||||
B = state.shape[0]
|
||||
s = state.reshape(B, 2**q, 2, 2**(n - q - 1))
|
||||
s = torch.einsum("ij,xajk->xaik", U, s)
|
||||
return s.reshape(B, 2**n)
|
||||
|
||||
|
||||
def _apply_1q_batched(state: torch.Tensor, U_batch: torch.Tensor, q: int, n: int) -> torch.Tensor:
|
||||
"""Apply a batched single-qubit gate. U_batch: (B, 2, 2), state: (B, 2^n)."""
|
||||
B = state.shape[0]
|
||||
s = state.reshape(B, 2**q, 2, 2**(n - q - 1))
|
||||
s = torch.einsum("xij,xajk->xaik", U_batch, s)
|
||||
return s.reshape(B, 2**n)
|
||||
|
||||
|
||||
def _apply_cnot(state: torch.Tensor, ctrl: int, targ: int, n: int) -> torch.Tensor:
|
||||
"""Apply CNOT(ctrl, targ) via index permutation. CNOT is an involution."""
|
||||
idx = torch.arange(2**n, device=state.device)
|
||||
q_c = (idx >> (n - 1 - ctrl)) & 1
|
||||
new_idx = idx ^ (q_c * (1 << (n - 1 - targ)))
|
||||
return state.index_select(-1, new_idx)
|
||||
|
||||
|
||||
def torch_vqc_forward(
|
||||
features: torch.Tensor,
|
||||
weights: torch.Tensor,
|
||||
n_qubits: int = 6,
|
||||
n_layers: int = 6,
|
||||
) -> torch.Tensor:
|
||||
"""Forward pass of the VQC matching PennyLane default.qubit output.
|
||||
|
||||
Args:
|
||||
features: Shape (>=n_qubits,) single sample, or (B, >=n_qubits) batched.
|
||||
Only the first n_qubits entries are used as RY embedding angles.
|
||||
weights: Shape (n_layers, n_qubits, 3). Same weights applied across batch.
|
||||
n_qubits: Number of wires.
|
||||
n_layers: Number of StronglyEntanglingLayers layers.
|
||||
|
||||
Returns:
|
||||
Tensor of expectation values <Z_i>, shape (n_qubits,) if unbatched input
|
||||
or (B, n_qubits) if batched input. Values in [-1, 1].
|
||||
"""
|
||||
batched = features.dim() == 2
|
||||
if not batched:
|
||||
features = features.unsqueeze(0)
|
||||
|
||||
B = features.shape[0]
|
||||
device = features.device
|
||||
cdtype = torch.complex128 if features.dtype == torch.float64 else torch.complex64
|
||||
|
||||
state = torch.zeros(B, 2**n_qubits, dtype=cdtype, device=device)
|
||||
state[:, 0] = 1.0
|
||||
|
||||
# AngleEmbedding = RX(features[i]) on wire i (PennyLane default is 'X')
|
||||
for i in range(n_qubits):
|
||||
theta = features[..., i] # (B,)
|
||||
U_batch = _rx_batched(theta, cdtype)
|
||||
state = _apply_1q_batched(state, U_batch, i, n_qubits)
|
||||
|
||||
# StronglyEntanglingLayers
|
||||
for l in range(n_layers):
|
||||
for q in range(n_qubits):
|
||||
phi = weights[l, q, 0]
|
||||
theta = weights[l, q, 1]
|
||||
omega = weights[l, q, 2]
|
||||
U = _rot(phi, theta, omega, cdtype)
|
||||
state = _apply_1q(state, U, q, n_qubits)
|
||||
r = l % (n_qubits - 1) + 1
|
||||
for q in range(n_qubits):
|
||||
state = _apply_cnot(state, q, (q + r) % n_qubits, n_qubits)
|
||||
|
||||
# Measure <Z_i> on each wire
|
||||
probs = (state.conj() * state).real # (B, 2^n)
|
||||
idx = torch.arange(2**n_qubits, device=device)
|
||||
real_dtype = torch.float64 if cdtype == torch.complex128 else torch.float32
|
||||
z_expvals = torch.zeros(B, n_qubits, dtype=real_dtype, device=device)
|
||||
for i in range(n_qubits):
|
||||
bit = (idx >> (n_qubits - 1 - i)) & 1
|
||||
sign = 1.0 - 2.0 * bit.to(real_dtype)
|
||||
z_expvals[:, i] = (probs * sign).sum(dim=-1)
|
||||
|
||||
if not batched:
|
||||
return z_expvals.squeeze(0)
|
||||
return z_expvals
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Torch-native VQC router: autograd training + batched inference.
|
||||
|
||||
Replaces PennyLane's parameter-shift gradient computation with torch autograd
|
||||
backprop through the explicit state-vector simulation in `torch_vqc.py`.
|
||||
|
||||
On 6-qubit, 6-layer StronglyEntanglingLayers, this yields 2-3 orders of
|
||||
magnitude speedup over `quantum_router.QuantumRouter.train` (per-sample SGD
|
||||
with 108 parameter-shift evaluations per gradient step).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from torch_vqc.circuit import torch_vqc_forward
|
||||
|
||||
|
||||
class TorchVQCRouter(nn.Module):
|
||||
"""VQC + classical head with autograd-based training.
|
||||
|
||||
Parameters:
|
||||
vqc_weights: (n_layers, n_qubits, 3) rotation angles
|
||||
linear_w: (n_qubits, n_classes) classical head weights
|
||||
linear_b: (n_classes,) classical head biases
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
n_qubits: int = 6,
|
||||
n_layers: int = 6,
|
||||
n_classes: int = 35,
|
||||
lr: float = 0.01,
|
||||
seed: int = 42,
|
||||
input_dim: int | None = None,
|
||||
hidden_dim: int | None = None,
|
||||
weight_decay: float = 0.0,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.n_qubits = n_qubits
|
||||
self.n_layers = n_layers
|
||||
self.n_classes = n_classes
|
||||
self.lr = lr
|
||||
self.weight_decay = weight_decay
|
||||
self.input_dim = input_dim
|
||||
self.hidden_dim = hidden_dim
|
||||
|
||||
# Match QuantumRouter init: vqc weights uniform on [0, 2π)
|
||||
rng = np.random.default_rng(seed)
|
||||
w_init = rng.uniform(0.0, 2 * math.pi, size=(n_layers, n_qubits, 3))
|
||||
self.vqc_weights = nn.Parameter(torch.from_numpy(w_init).double())
|
||||
|
||||
# Optional projection: linear (input_dim → n_qubits) OR MLP (input_dim → hidden_dim → n_qubits)
|
||||
# Without: circuit sees features[:n_qubits] only — severe bottleneck.
|
||||
# Linear: layer learns which n_qubits-D subspace carries class-discriminative signal.
|
||||
# MLP (hidden_dim set): non-linear compression for richer feature extraction.
|
||||
self.projection: nn.Module | None
|
||||
if input_dim is not None:
|
||||
if hidden_dim is not None:
|
||||
self.projection = nn.Sequential(
|
||||
nn.Linear(input_dim, hidden_dim),
|
||||
nn.ReLU(),
|
||||
nn.Linear(hidden_dim, n_qubits),
|
||||
).double()
|
||||
else:
|
||||
self.projection = nn.Linear(input_dim, n_qubits).double()
|
||||
else:
|
||||
self.projection = None
|
||||
|
||||
# Classical head: normal(0, 0.1) init, zero bias (match QuantumRouter)
|
||||
rng2 = np.random.default_rng(seed + 1)
|
||||
lw_init = rng2.standard_normal((n_qubits, n_classes)) * 0.1
|
||||
self.linear_w = nn.Parameter(torch.from_numpy(lw_init).double())
|
||||
self.linear_b = nn.Parameter(torch.zeros(n_classes, dtype=torch.float64))
|
||||
|
||||
def forward(self, features: torch.Tensor) -> torch.Tensor:
|
||||
"""Compute logits for a (B, feat_dim) batch of embeddings."""
|
||||
if features.dim() == 1:
|
||||
features = features.unsqueeze(0)
|
||||
if self.projection is not None:
|
||||
# Project input → n_qubits, bound to [-π, π] for rotations
|
||||
features = math.pi * torch.tanh(self.projection(features))
|
||||
qubits = torch_vqc_forward(
|
||||
features, self.vqc_weights, n_qubits=self.n_qubits, n_layers=self.n_layers
|
||||
)
|
||||
return qubits @ self.linear_w + self.linear_b
|
||||
|
||||
def train_batched(
|
||||
self,
|
||||
embeddings: torch.Tensor,
|
||||
labels: torch.Tensor,
|
||||
epochs: int = 10,
|
||||
) -> list[float]:
|
||||
"""Full-batch SGD over `epochs` passes. Returns per-epoch loss."""
|
||||
opt = torch.optim.SGD(self.parameters(), lr=self.lr, weight_decay=self.weight_decay)
|
||||
losses: list[float] = []
|
||||
for _ in range(epochs):
|
||||
opt.zero_grad()
|
||||
logits = self.forward(embeddings)
|
||||
loss = nn.functional.cross_entropy(logits, labels)
|
||||
loss.backward()
|
||||
opt.step()
|
||||
losses.append(loss.item())
|
||||
return losses
|
||||
|
||||
@torch.no_grad()
|
||||
def predict(self, embeddings: torch.Tensor) -> torch.Tensor:
|
||||
"""Argmax class prediction for a (B, feat_dim) batch."""
|
||||
logits = self.forward(embeddings)
|
||||
return logits.argmax(dim=-1)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""pytest config: add src/ to sys.path so tests work without pip install -e."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SRC = Path(__file__).resolve().parent.parent / "src"
|
||||
if str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Verify torch VQC forward matches PennyLane reference numerically.
|
||||
|
||||
TDD: this test must fail before torch_vqc.py exists, pass after.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pennylane as qml
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
|
||||
N_QUBITS = 6
|
||||
N_LAYERS = 6
|
||||
|
||||
|
||||
def _pennylane_reference(features: np.ndarray, weights: np.ndarray) -> np.ndarray:
|
||||
"""Run the identical circuit through PennyLane — ground truth."""
|
||||
dev = qml.device("default.qubit", wires=N_QUBITS)
|
||||
|
||||
@qml.qnode(dev)
|
||||
def circuit(w, f):
|
||||
qml.AngleEmbedding(f[:N_QUBITS], wires=range(N_QUBITS))
|
||||
qml.StronglyEntanglingLayers(w, wires=range(N_QUBITS))
|
||||
return [qml.expval(qml.PauliZ(i)) for i in range(N_QUBITS)]
|
||||
|
||||
return np.array(circuit(weights, features))
|
||||
|
||||
|
||||
def test_forward_matches_pennylane_single_sample():
|
||||
from torch_vqc.circuit import torch_vqc_forward
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
features = rng.uniform(0.0, 2 * np.pi, size=10).astype(np.float64)
|
||||
weights = rng.uniform(0.0, 2 * np.pi, size=(N_LAYERS, N_QUBITS, 3)).astype(np.float64)
|
||||
|
||||
ref = _pennylane_reference(features, weights) # shape (N_QUBITS,)
|
||||
|
||||
f_t = torch.from_numpy(features).double()
|
||||
w_t = torch.from_numpy(weights).double()
|
||||
out = torch_vqc_forward(f_t, w_t, n_qubits=N_QUBITS, n_layers=N_LAYERS)
|
||||
|
||||
assert out.shape == (N_QUBITS,), f"expected ({N_QUBITS},), got {tuple(out.shape)}"
|
||||
np.testing.assert_allclose(
|
||||
out.detach().cpu().numpy(),
|
||||
ref,
|
||||
atol=1e-5,
|
||||
err_msg=f"torch={out}, pennylane={ref}",
|
||||
)
|
||||
|
||||
|
||||
def test_forward_matches_pennylane_batched():
|
||||
from torch_vqc.circuit import torch_vqc_forward
|
||||
|
||||
rng = np.random.default_rng(1)
|
||||
B = 3
|
||||
features = rng.uniform(0.0, 2 * np.pi, size=(B, 10)).astype(np.float64)
|
||||
weights = rng.uniform(0.0, 2 * np.pi, size=(N_LAYERS, N_QUBITS, 3)).astype(np.float64)
|
||||
|
||||
refs = np.stack([_pennylane_reference(features[b], weights) for b in range(B)], axis=0)
|
||||
|
||||
f_t = torch.from_numpy(features).double()
|
||||
w_t = torch.from_numpy(weights).double()
|
||||
out = torch_vqc_forward(f_t, w_t, n_qubits=N_QUBITS, n_layers=N_LAYERS)
|
||||
|
||||
assert out.shape == (B, N_QUBITS), f"expected ({B}, {N_QUBITS}), got {tuple(out.shape)}"
|
||||
np.testing.assert_allclose(
|
||||
out.detach().cpu().numpy(),
|
||||
refs,
|
||||
atol=1e-5,
|
||||
err_msg=f"batched mismatch: torch={out}, pennylane={refs}",
|
||||
)
|
||||
|
||||
|
||||
def test_forward_different_seeds_different_outputs():
|
||||
"""Sanity check: distinct weights → distinct outputs (not constant function)."""
|
||||
from torch_vqc.circuit import torch_vqc_forward
|
||||
|
||||
rng = np.random.default_rng(42)
|
||||
features = rng.uniform(0.0, 2 * np.pi, size=10).astype(np.float64)
|
||||
w1 = rng.uniform(0.0, 2 * np.pi, size=(N_LAYERS, N_QUBITS, 3)).astype(np.float64)
|
||||
w2 = rng.uniform(0.0, 2 * np.pi, size=(N_LAYERS, N_QUBITS, 3)).astype(np.float64)
|
||||
|
||||
f_t = torch.from_numpy(features).double()
|
||||
o1 = torch_vqc_forward(f_t, torch.from_numpy(w1).double(), n_qubits=N_QUBITS, n_layers=N_LAYERS)
|
||||
o2 = torch_vqc_forward(f_t, torch.from_numpy(w2).double(), n_qubits=N_QUBITS, n_layers=N_LAYERS)
|
||||
|
||||
assert not torch.allclose(o1, o2, atol=1e-3), "different weights should produce different outputs"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Projection tests: learned 384→n_qubits layer rescues accuracy on hard tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
|
||||
def _make_hard_task(n_classes: int = 5, samples_per_class: int = 40, input_dim: int = 64, seed: int = 0):
|
||||
"""Class info lives ONLY in dims [input_dim-6 : input_dim], so truncation to dims [0:n_qubits] sees pure noise."""
|
||||
rng = np.random.default_rng(seed)
|
||||
class_centers = rng.uniform(0.3, np.pi - 0.3, size=(n_classes, 6)) # mild, in rotation range
|
||||
|
||||
X, y = [], []
|
||||
for c in range(n_classes):
|
||||
for _ in range(samples_per_class):
|
||||
x = rng.normal(0, 0.5, size=input_dim) # noise everywhere
|
||||
x[-6:] = class_centers[c] + rng.normal(0, 0.08, size=6) # signal in last 6 dims
|
||||
X.append(x)
|
||||
y.append(c)
|
||||
X = np.array(X, dtype=np.float64)
|
||||
y = np.array(y, dtype=np.int64)
|
||||
perm = rng.permutation(len(X))
|
||||
return X[perm], y[perm]
|
||||
|
||||
|
||||
def test_projection_beats_no_projection_on_hard_task():
|
||||
"""Signal in last 6 dims only: truncation sees pure noise, projection can find it."""
|
||||
from torch_vqc.router import TorchVQCRouter
|
||||
|
||||
X, y = _make_hard_task(n_classes=5, samples_per_class=40, input_dim=64, seed=3)
|
||||
X_t = torch.from_numpy(X).double()
|
||||
y_t = torch.from_numpy(y)
|
||||
|
||||
# Without projection: VQC sees X[:, :4] (first 4 dims = pure noise here)
|
||||
model_no_proj = TorchVQCRouter(n_qubits=4, n_layers=4, n_classes=5, lr=0.05, seed=0)
|
||||
model_no_proj.train_batched(X_t, y_t, epochs=200)
|
||||
with torch.no_grad():
|
||||
acc_no_proj = (model_no_proj.predict(X_t).numpy() == y).mean()
|
||||
|
||||
# With projection: a 4×64 layer can learn to pick from the informative last dims
|
||||
model_proj = TorchVQCRouter(
|
||||
n_qubits=4, n_layers=4, n_classes=5, lr=0.05, seed=0, input_dim=64
|
||||
)
|
||||
model_proj.train_batched(X_t, y_t, epochs=200)
|
||||
with torch.no_grad():
|
||||
acc_proj = (model_proj.predict(X_t).numpy() == y).mean()
|
||||
|
||||
print(f"\nNo projection acc = {acc_no_proj:.3f} (chance = {1/5:.3f})")
|
||||
print(f"With projection acc = {acc_proj:.3f}")
|
||||
assert acc_proj > acc_no_proj + 0.15, (
|
||||
f"projection should give ≥15pt boost, got {acc_no_proj:.3f} → {acc_proj:.3f}"
|
||||
)
|
||||
|
||||
|
||||
def test_projection_forward_shape():
|
||||
"""Projection doesn't break shape invariants."""
|
||||
from torch_vqc.router import TorchVQCRouter
|
||||
|
||||
model = TorchVQCRouter(n_qubits=6, n_layers=6, n_classes=10, input_dim=384, seed=0)
|
||||
x = torch.randn(32, 384, dtype=torch.float64)
|
||||
logits = model.forward(x)
|
||||
assert logits.shape == (32, 10), f"expected (32, 10), got {tuple(logits.shape)}"
|
||||
|
||||
|
||||
def test_projection_params_counted():
|
||||
"""Projection adds n_qubits × input_dim + n_qubits parameters."""
|
||||
from torch_vqc.router import TorchVQCRouter
|
||||
|
||||
no_proj = TorchVQCRouter(n_qubits=4, n_layers=6, n_classes=10, seed=0)
|
||||
with_proj = TorchVQCRouter(n_qubits=4, n_layers=6, n_classes=10, input_dim=384, seed=0)
|
||||
|
||||
n_no = sum(p.numel() for p in no_proj.parameters())
|
||||
n_yes = sum(p.numel() for p in with_proj.parameters())
|
||||
expected_extra = 4 * 384 + 4 # projection_w + projection_b
|
||||
assert n_yes - n_no == expected_extra, (
|
||||
f"expected {expected_extra} extra params, got {n_yes - n_no}"
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""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}×"
|
||||
Reference in New Issue
Block a user