This practical hybrid quantum-classical training template shows how to build, measure, and reproduce a variational quantum circuit (VQC) experiment with Qiskit, PennyLane, and PyTorch. It covers preprocessing, ansatz design, parameter initialization, cost functions, gradient choices, batching, shot budgets, noise-aware training, simulator-to-hardware decisions, and the measurements needed to compare runs fairly.
Overview
A hybrid quantum-classical workflow divides the job between a classical optimizer and a parameterized quantum circuit. The classical side stores trainable weights, prepares batches, evaluates the loss, and updates parameters. The quantum side encodes features, applies an ansatz, and returns expectation values or measurement probabilities. This arrangement is useful for small experiments such as variational quantum eigensolvers (VQE), quantum classifiers, and QAOA-style studies, but it also introduces extra cost: every circuit evaluation may require many shots, and hardware execution adds queueing, latency, and noise.
The most portable design uses a narrow interface:
- Inputs: a batch of normalized features and labels or target values.
- Circuit: a feature map followed by a parameterized ansatz.
- Observable: an expectation value or vector of measured probabilities.
- Loss: a classical function comparing circuit output with the target.
- Gradient and optimizer: parameter-shift, finite-difference, backpropagation, or a framework-supported automatic differentiation method.
- Evaluator: a simulator or hardware backend with an explicit shot count and noise configuration.
Keep these components separate. It lets you compare a PennyLane device with a Qiskit primitive or a PyTorch wrapper without changing the data split, ansatz, optimizer, or evaluation metric. Before moving to hardware, use the quantum simulator benchmarking guide and record circuit width, depth, gate counts, and execution time.
How to estimate training cost and outcome
A useful first estimate is the number of circuit evaluations required by one optimization step:
evaluations per step ≈ batches × gradient evaluations × observables
For a parameter-shift gradient, a parameter commonly requires two shifted circuit evaluations. If a circuit has P trainable parameters, a simple estimate is:
evaluations per step ≈ batches × (2P + 1)
The extra one represents the unshifted forward pass. This is only a planning model. Frameworks may group circuits, use analytic derivatives on a simulator, reuse evaluations, or calculate gradients in another way. Hardware workflows may also add calibration, mitigation, compilation, and retry overhead.
Shot demand can be estimated as:
shots per step ≈ evaluations per step × shots per circuit
For example, a circuit with 12 trainable parameters, four mini-batches, and 512 shots has an approximate parameter-shift demand of 4 × (2 × 12 + 1) = 100 circuit evaluations per step, or 51,200 shots. If training runs for 30 steps, the simple estimate is 1,536,000 shots. It does not predict wall-clock time because queueing, batching, simulator configuration, backend availability, and circuit compilation can dominate.
Use two budgets rather than one. The development budget uses a small number of shots or an exact simulator to catch shape, encoding, and gradient errors. The validation budget uses fixed held-out data, a declared shot count, and the target noise model or hardware backend. A low training loss from a noiseless simulator is not evidence that the same circuit will perform well with finite sampling.
Provider-agnostic repository layout
hybrid-vqc/
├── pyproject.toml
├── README.md
├── configs/
│ └── baseline.yaml
├── src/
│ ├── data.py
│ ├── model_interface.py
│ ├── train.py
│ ├── qiskit_model.py
│ ├── pennylane_model.py
│ └── torch_model.py
├── experiments/
│ └── run_baseline.py
├── tests/
│ └── test_shapes.py
└── results/
├── metrics.json
└── environment.txtPin package versions in the project environment and treat SDK integration code as an adapter. Quantum SDK APIs change more often than the mathematical workflow, so isolate imports, backend construction, transpilation options, and primitive calls in one file.
Inputs and assumptions
Data preprocessing
Start with a small, deterministic dataset. Remove or impute missing values, standardize classical features, and map the resulting values into the range expected by the feature map. Angle encoding often benefits from a bounded interval such as [−π, π], but the correct range depends on the circuit. Match the number of encoded features to the available qubits or define a documented feature-reuse strategy. Split data before tuning hyperparameters, and preserve the same train, validation, and test indices across SDKs.
Ansatz and initialization
Use a shallow hardware-compatible ansatz for the first experiment: one-qubit rotations followed by a limited entangling pattern. Record the qubit count, rotation gates, entanglers, depth, and parameter count. Initialize weights with a fixed seed and a small scale. Large random angles can make optimization unstable, while identical or zero initialization can create symmetry that limits learning.
Loss, batching, and gradients
For regression, mean squared error between a measured expectation value and a target is a straightforward baseline. For binary classification, map an expectation value from [−1, 1] to a probability and use a suitable classical loss, or compare logits with a numerically stable objective. Mini-batching reduces classical memory use but does not automatically reduce quantum work: each batch still requires circuit evaluations.
Parameter-shift gradients are attractive when the circuit and device support the required shift rule. They can be costly because they evaluate shifted circuits for each parameter. Finite differences are easy to implement and useful as a debugging reference, but the step size must be selected carefully and sampling noise can make the estimate unstable. Backpropagation or adjoint differentiation can be much faster on compatible simulators, but these methods may not transfer directly to hardware.
SDK comparison
| Stack | Gradient options | Shots and noise | Autodiff integration | Practical trade-off |
|---|---|---|---|---|
| Qiskit | Primitive- or estimator-based gradients, parameter-shift where supported, and finite differences as a fallback | Explicit shot settings; noise depends on simulator or provider backend | Works with Qiskit Machine Learning and can be wrapped by classical frameworks | Useful when the target is an IBM Quantum-compatible workflow; compilation and job latency must be measured separately |
| PennyLane | Parameter-shift, finite differences, backpropagation, and device-dependent differentiation | Shots can be set on the device; noise models and hardware plugins vary | Native autograd and interfaces for Torch and other supported frameworks | Often convenient for rapid device comparisons; differentiation method must be recorded with every result |
| PyTorch wrapper | Delegates differentiation to the quantum layer or uses a custom autograd function | Inherited from the selected quantum backend | Standard modules, optimizers, batching, and checkpointing | Best for integrating a quantum layer into a larger classical model; quantum calls can become the bottleneck |
Device limits are not fixed in this article because they vary by simulator configuration, provider account, backend availability, circuit topology, and SDK release. Treat qubit count, supported observables, maximum shots, transpilation behavior, and queue time as experiment inputs, not permanent properties.
Worked examples and code templates
PennyLane with a PyTorch interface
The following pattern keeps the quantum layer small and lets PyTorch manage the optimizer. Exact device names and plugin options should be checked against the installed PennyLane release.
import pennylane as qml
import torch
n_qubits, n_layers = 4, 2
dev = qml.device("default.qubit", wires=n_qubits, shots=None)
@qml.qnode(dev, interface="torch", diff_method="parameter-shift")
def circuit(x, weights):
qml.AngleEmbedding(x, wires=range(n_qubits), rotation="Y")
qml.StronglyEntanglingLayers(weights, wires=range(n_qubits))
return qml.expval(qml.PauliZ(0))
weights = torch.nn.Parameter(0.01 * torch.randn(n_layers, n_qubits, 3))
optimizer = torch.optim.Adam([weights], lr=0.01)
for x_batch, y_batch in train_loader:
optimizer.zero_grad()
predictions = torch.stack([circuit(x, weights) for x in x_batch])
loss = torch.mean((predictions - y_batch) ** 2)
loss.backward()
optimizer.step()For a finite-shot experiment, construct the device with a declared shot count and repeat the same validation run several times if you need to distinguish optimization changes from sampling variation.
Qiskit-style estimator workflow
Qiskit integrations commonly represent the ansatz as a parameterized circuit and evaluate an observable through an estimator primitive or a machine-learning connector. The adapter should expose only forward(x, weights) and loss(prediction, target) to the training loop.
# Illustrative adapter structure; primitive and gradient APIs vary by release.
class QiskitVQC:
def __init__(self, feature_map, ansatz, estimator, observable):
self.feature_map = feature_map
self.ansatz = ansatz
self.estimator = estimator
self.observable = observable
def forward(self, features, weights):
circuits = []
for row in features:
circuits.append(self.feature_map.assign_parameters(row) compose self.ansatz)
# Bind ansatz parameters and call the configured estimator here.
# Return one expectation value per input row.
return self.estimator_expectations(circuits, weights)The composition line is intentionally pseudocode because circuit composition and primitive signatures differ across Qiskit releases. In a real repository, add a version-pinned adapter and a unit test that verifies circuit width, parameter order, observable shape, and one deterministic simulator result. For a VQE experiment, compare this layer with the VQE tutorial and document the Hamiltonian, optimizer, ansatz, and stopping rule.
Finite-difference debugging reference
def finite_difference(loss_fn, theta, index, epsilon=1e-3):
plus = theta.copy(); plus[index] += epsilon
minus = theta.copy(); minus[index] -= epsilon
return (loss_fn(plus) - loss_fn(minus)) / (2 * epsilon)Use this only as a small-parameter diagnostic. Compare it with the SDK gradient on a noiseless simulator at fixed parameters. If the values disagree substantially, inspect parameter ordering, observable definitions, measurement conventions, and feature scaling before tuning the optimizer.
Noise-aware training and deployment decisions
Train first with an exact or high-shot simulator to validate the model, then introduce finite shots, a calibrated noise model, or hardware. Reduce circuit depth before adding sophisticated mitigation. Track training and validation loss, gradient norms, circuit depth, total shots, failed jobs, and elapsed time. Gradient clipping, smaller learning rates, fewer layers, and periodic rather than per-step hardware validation can make noisy experiments easier to interpret.
Measurement error mitigation may improve expectation estimates, but it adds calibration circuits and its own assumptions. Record whether mitigation was applied, which calibration data were used, and whether calibration was repeated after backend changes. For background, see the quantum error mitigation benchmarking workflow and the measurement and probability guide.
Move to hardware only when the simulator experiment has a defined baseline. The hardware run should use the same data split, initial weights, stopping rule, and evaluation metric. Changes to any of these make the comparison ambiguous. Use the hardware job submission checklist before sending production-scale batches.
Reproducibility checklist
- Record Python, Qiskit, PennyLane, PyTorch, compiler, and operating-system versions.
- Set seeds for Python, NumPy, PyTorch, SDK random generators, and data splitting.
- Save the exact train, validation, and test indices.
- Record feature scaling, angle ranges, qubit mapping, parameter order, and initialization values.
- Pin ansatz depth, entangling topology, transpilation settings, optimization level, and basis gates where applicable.
- Record shots, shot seeds, measurement grouping, observable definitions, and noise-model settings.
- Save optimizer type, learning rate, batch size, number of steps, stopping rule, and checkpoint policy.
- Separate simulator results, noisy-simulator results, and hardware results.
- Report wall-clock time, circuit evaluations, total shots, queue time when available, and failed or retried jobs.
- Repeat finite-shot validation enough times to show run-to-run variation rather than one lucky result.
When to recalculate and update the experiment
Recalculate the training estimate whenever you change the number of parameters, batches, observables, shots, gradient method, or optimizer schedule. Re-run the benchmark when a simulator, transpiler, primitive implementation, hardware backend, noise model, or provider connection changes. SDK releases can alter supported differentiation methods and integration syntax, so refresh adapter code and environment pins after a major release or during a planned three- to six-month maintenance cycle.
For each refresh, rerun one deterministic simulator baseline, one finite-shot simulator case, and one noise-aware or hardware case when access is available. Compare loss, generalization, gradient stability, circuit depth, total shots, and wall-clock time. Keep previous results instead of overwriting them; the resulting history is more useful than a single headline score. If the experiment no longer beats its classical baseline or the noise overwhelms the signal, treat that as a valid engineering conclusion and revise the ansatz, encoding, shot budget, or problem size before scaling.
Finally, use the hybrid quantum-classical machine-learning workflow as a broader planning reference, and consult the circuit resource estimation guide before committing to a new backend.