Skip to content

Hybrid Classical Quantum Models

FlagQuantum edited this page Oct 20, 2026 · 4 revisions

Hybrid Classical-Quantum Models

Combine a classical neural network with a parameterized quantum circuit, train all components with one PyTorch optimizer, and read the final predictions and learned parameters.

Version: FlagQuantum 0.3.1. The examples were verified on local CPU with Python 3.12.14, PyTorch 2.14.1, and macOS Apple Silicon. They use exact, noiseless statevector simulation and require no GPU or provider credentials.

Prepare your environment using Installation and Compatibility. Read Training Parameterized Circuits for the basic PyTorch training lifecycle.

Formula/code review — October 20, 2026: The equations are paired with code excerpts, and the local CPU examples were re-run with Python 3.12.14 and PyTorch 2.13.0. See the review record for executed commands, distributed checks, and untested hardware/provider paths. Earlier environment and output records are retained.

1. How the Classical and Quantum Parts Fit Together

This tutorial builds a small regressor with three trainable components:

Input → classical encoder → quantum circuit → expectation values → classical readout → prediction.

Component Role Input and output shapes
Classical encoder A linear layer and Tanh convert one input feature into two bounded rotation angles. (B, 1) → (B, 2)
Quantum layer A two-qubit circuit encodes those angles, applies CX and trainable rotations, then measures separate Z expectations. (B, 2) → (B, 2)
Classical readout A linear layer maps the two quantum features to a regression prediction. (B, 2) → (B, 1)

B is the number of examples in a batch. The quantum features are expectation values, not sampled bits or probabilities. Each Z expectation lies between −1 and +1; the linear readout can produce predictions outside that interval.

The training target is the simple function y = x². We use 17 points between −1 and +1 and evaluate the trained model at four additional points. This is an instructional regression example, not evidence of quantum advantage or broad predictive performance.

2. Define a Batched Quantum Layer

An fq.Module builder can accept two arguments: parameters and encoded_inputs.

  • encoded_inputs comes from the classical encoder. Each row contains one example's two data-dependent gate angles.
  • parameters contains the quantum layer's shared trainable rotations. The same two parameters are used across the batch.
  • fq.Circuit(2, bsz=encoded_inputs.shape[0]) creates a circuit batch matching the data batch.
  • Passing encoded_inputs[:, 0] and encoded_inputs[:, 1] to RY gives each batch entry its own encoded state.

The circuit applies input-dependent RY gates, a CX gate, and two trainable RY gates. We configure observable="z" and observable_qubits=(0, 1) to obtain two separate expectation features. Using a summed observable would produce a different feature shape.

Multiplying the encoder's Tanh outputs by torch.pi keeps its angles bounded by π in magnitude. Rotation angles are in radians.

Equations Beside the Hybrid Forward Pass

The following excerpts use HybridRegressor and the training scripts below. For each batch row, let $a=\pi\tanh(W_e x+b_e)$, $z_j=\langle Z_j\rangle_{U(\theta,a)}$, and $\hat y=W_rz+b_r$.

Formula Corresponding code Shape / validation
$a=\pi\tanh(W_e x+b_e)$ angles = torch.pi * self.encoder(inputs) (B, 1) to (B, 2); encoder includes Linear then Tanh.
$U_{\mathrm{enc}}=R_Y^{(0)}(a_0)R_Y^{(1)}(a_1)$ circuit.ry(0, encoded_inputs[:, 0])
circuit.ry(1, encoded_inputs[:, 1])
Encode each batch row's two input angles.
$U_{\mathrm{ent}}=\mathrm{CX}_{0\to1}$ circuit.cx(0, 1) Entangle after encoding.
$U_{\mathrm{var}}=R_Y^{(0)}(\theta_0)R_Y^{(1)}(\theta_1)$ circuit.ry(0, parameters[0])
circuit.ry(1, parameters[1])
Apply shared trainable angles after CX.
$z=(\langle Z_0\rangle,\langle Z_1\rangle)$ quantum_features = self.quantum(angles) Policy selects separate Z outputs, yielding (B, 2).
$\hat y=W_rz+b_r$ return self.readout(quantum_features) Linear readout yields (B, 1).
$L=B^{-1}\sum_b(\hat y_b-x_b^2)^2$ torch.nn.functional.mse_loss(predictions, targets) targets = inputs.square(); both have shape (B, 1).
$\nabla_{W_e,b_e,\theta,W_r,b_r}L$ loss.backward() check_hybrid_gradients.py asserts finite, nonzero aggregate gradients in every component.

The complete unitary is $U=U_{\mathrm{var}}U_{\mathrm{ent}}U_{\mathrm{enc}}$; matrix factors act right to left. Rotations on different qubits commute within each rotation layer. Shared quantum parameters parameters[0:2] are reused across all batch rows, while each row has its own encoded angles. A tensor detach between the encoder and quantum layer would remove the encoder's chain-rule gradient.

3. Create the Shared Model File

Create a folder for this tutorial. Save the following as hybrid_model.py. The two runnable scripts later in this page import this file; keep all three files in the same folder.

import torch
from torch import nn
import flagquantum as fq


def quantum_circuit(parameters, encoded_inputs):
    circuit = fq.Circuit(2, bsz=encoded_inputs.shape[0], device=encoded_inputs.device)
    circuit.ry(0, encoded_inputs[:, 0])
    circuit.ry(1, encoded_inputs[:, 1])
    circuit.cx(0, 1)
    circuit.ry(0, parameters[0])
    circuit.ry(1, parameters[1])
    return circuit


class HybridRegressor(nn.Module):
    def __init__(self):
        super().__init__()
        self.encoder = nn.Sequential(nn.Linear(1, 2), nn.Tanh())
        self.quantum = fq.Module(
            quantum_circuit,
            n_parameters=2,
            init=torch.tensor([0.2, -0.3]),
            device="cpu",
            policy=fq.RuntimePolicy(
                execution_options=fq.ExecutionOptions(
                    mode="statevector", backend="pytorch", device="cpu"
                ),
                observable="z",
                observable_qubits=(0, 1),
            ),
        )
        self.readout = nn.Linear(2, 1)

    def forward(self, inputs):
        angles = torch.pi * self.encoder(inputs)
        quantum_features = self.quantum(angles)
        return self.readout(quantum_features)

HybridRegressor inherits from torch.nn.Module. Assigning its encoder, quantum layer, and readout as child modules registers their parameters with the parent. Consequently, model.parameters() includes all three components.

The classical encoder transforms data into circuit inputs; it does not own the quantum parameters. The quantum parameters are initialized separately by fq.Module. They are shared trainable weights rather than a new set of weights for every data row.

4. Verify Gradients Before Training

The differentiable path is:

Loss → readout weights and quantum features → quantum parameters and encoded angles → encoder weights.

Do not detach the encoded angles or turn them into Python numbers before passing them to the quantum layer. That would interrupt the path back to the encoder. The model uses PyTorch operations throughout its forward computation.

Save the following as check_hybrid_gradients.py:

import torch
from hybrid_model import HybridRegressor

torch.manual_seed(7)
model = HybridRegressor()
inputs = torch.tensor([[-0.8], [-0.3], [0.3], [0.8]])
targets = inputs.square()

predictions = model(inputs)
loss = torch.nn.functional.mse_loss(predictions, targets)
loss.backward()

with torch.no_grad():
    angles = torch.pi * model.encoder(inputs)
    features = model.quantum(angles)

print("Input shape:", tuple(inputs.shape))
print("Encoded angle shape:", tuple(angles.shape))
print("Quantum feature shape:", tuple(features.shape))
print("Prediction shape:", tuple(predictions.shape))
print(f"Initial MSE: {loss.item():.6f}")
for name, layer in (("Encoder", model.encoder), ("Quantum", model.quantum), ("Readout", model.readout)):
    gradients = [parameter.grad for parameter in layer.parameters()]
    valid = all(gradient is not None and bool(torch.isfinite(gradient).all()) for gradient in gradients)
    nonzero = valid and sum(gradient.abs().sum().item() for gradient in gradients) > 0
    print(f"{name} has finite, nonzero gradients: {nonzero}")
    assert nonzero
print("Joint gradient checks passed.")

Run it from the folder containing the three files:

python check_hybrid_gradients.py

Output recorded in the verification environment:

Input shape: (4, 1)
Encoded angle shape: (4, 2)
Quantum feature shape: (4, 2)
Prediction shape: (4, 1)
Initial MSE: 1.058635
Encoder has finite, nonzero gradients: True
Quantum has finite, nonzero gradients: True
Readout has finite, nonzero gradients: True
Joint gradient checks passed.

The script checks that every registered parameter tensor receives finite gradients and that each component has a nonzero aggregate gradient magnitude. This is a check at the chosen initialization and batch, not a guarantee that every individual gradient element is nonzero at every step.

The extra forward calls under torch.no_grad() inspect intermediate shapes after backward. They are not used to construct the training loss.

5. Train All Components with One Optimizer

Use a standard PyTorch optimizer on the outer model:

torch.optim.Adam(model.parameters(), lr=0.03)

Passing only model.quantum.parameters() would update the quantum parameters while leaving the encoder and readout unoptimized. Passing model.parameters() includes all registered child parameters.

For this outer hybrid model, use a normal PyTorch training loop. In FlagQuantum 0.3.1, fq.train accepts an fq.Module, whereas the outer HybridRegressor is a general torch.nn.Module containing several components. Calling fq.train on just the inner quantum layer would not represent the complete hybrid training computation.

Save the following as train_hybrid.py:

import torch
from hybrid_model import HybridRegressor

torch.manual_seed(7)
model = HybridRegressor()
inputs = torch.linspace(-1.0, 1.0, 17).reshape(-1, 1)
targets = inputs.square()
optimizer = torch.optim.Adam(model.parameters(), lr=0.03)
initial_parameters = {name: value.detach().clone() for name, value in model.named_parameters()}
losses = []
model.train()

for step in range(1, 401):
    optimizer.zero_grad()
    predictions = model(inputs)
    loss = torch.nn.functional.mse_loss(predictions, targets)
    loss.backward()
    optimizer.step()
    losses.append(loss.detach().item())
    if step in (1, 100, 200, 400):
        print(f"Step {step:3d}: MSE={losses[-1]:.6f}")

model.eval()
test_inputs = torch.tensor([[-0.8], [-0.3], [0.3], [0.8]])
with torch.no_grad():
    train_mse = torch.nn.functional.mse_loss(model(inputs), targets).item()
    test_predictions = model(test_inputs)
    test_mse = torch.nn.functional.mse_loss(test_predictions, test_inputs.square()).item()

print(f"Post-update training MSE: {train_mse:.6f}")
print(f"Four-point evaluation MSE: {test_mse:.6f}")
for x, predicted in zip(test_inputs[:, 0], test_predictions[:, 0]):
    print(f"x={x.item():+.1f}, target={x.square().item():.3f}, prediction={predicted.item():.3f}")
for prefix, label in (("encoder.", "Encoder"), ("quantum.", "Quantum"), ("readout.", "Readout")):
    changed = any(not torch.equal(value.detach(), initial_parameters[name]) for name, value in model.named_parameters() if name.startswith(prefix))
    print(f"{label} parameters changed: {changed}")
    assert changed
print("Trained quantum angles:", [round(value.item(), 4) for value in next(model.quantum.parameters()).detach()])
assert train_mse < 1e-3 and test_mse < 1e-3
print("Hybrid training checks passed.")

Run:

python train_hybrid.py

Output recorded in the verification environment:

Step   1: MSE=1.112631
Step 100: MSE=0.111090
Step 200: MSE=0.001631
Step 400: MSE=0.000371
Post-update training MSE: 0.000370
Four-point evaluation MSE: 0.000199
x=-0.8, target=0.640, prediction=0.658
x=-0.3, target=0.090, prediction=0.085
x=+0.3, target=0.090, prediction=0.084
x=+0.8, target=0.640, prediction=0.661
Encoder parameters changed: True
Quantum parameters changed: True
Readout parameters changed: True
Trained quantum angles: [0.8617, 0.194]
Hybrid training checks passed.

The initial loss here differs from the gradient-check script because the scripts use different input batches. Each script creates a fresh model with seed 7.

The final digits may differ across compatible environments. The training script asserts that the final training MSE and four-point evaluation MSE are each below 1e-3, and checks that at least one parameter tensor in every component changed. A low error on four additional points is illustrative; it is not a full generalization evaluation.

What Happens During Each Step

  1. optimizer.zero_grad() clears gradients from the previous step.
  2. model(inputs) runs the encoder, circuit simulation, expectation readout, and final linear layer.
  3. mse_loss(predictions, targets) produces a scalar real loss. Both tensors have shape (17, 1), avoiding accidental broadcasting.
  4. loss.backward() propagates through the complete differentiable computation.
  5. optimizer.step() updates the classical and quantum parameters together.

The loss history stores detached Python numbers for reporting. Each recorded loss describes the forward pass before that step's optimizer update. The final MSE is recomputed using the updated parameters.

6. Read Predictions, Features, and Learned Weights

What you want How to access it
Regression predictions model(inputs), with shape (B, 1)
Encoded circuit angles torch.pi * model.encoder(inputs), with shape (B, 2)
Quantum expectation features model.quantum(angles), with shape (B, 2)
Shared quantum rotations next(model.quantum.parameters()) in this two-parameter example
All named weights model.named_parameters()
Registered model state model.state_dict()

The two quantum feature columns are the Z expectations of logical qubits 0 and 1, in that requested order. They are not the four amplitudes of a two-qubit statevector. See Qubit Ordering and Result Conventions when interpreting state or measurement arrays.

model.eval() sets evaluation mode; it does not itself disable gradients. Use torch.no_grad() for inference when gradient recording is unnecessary. Detaching weights for display or saving an independent snapshot is appropriate after training, but do not detach the intermediate features inside the training forward pass.

The reported quantum angles are shared model weights. For a particular input, the actual circuit also depends on the encoder's output for that input. Saving only the quantum angles therefore does not preserve the complete predictor: serialize the outer model's state and preserve its architecture and preprocessing. Include optimizer state when resuming training.

7. Extend the Model Carefully

  • More input features: change the encoder's input dimension and provide inputs with shape (B, feature_count).
  • More quantum features: select supported observables and adjust the readout dimension to match their output shape.
  • Classification: replace the regression head and objective with a suitable classifier. For example, a single-logit binary head can use BCEWithLogitsLoss with matching target shape; the raw expectation feature is not automatically a class probability.
  • Larger datasets: train over mini-batches while preserving a fresh forward/backward/update lifecycle for each batch.

Adding qubits or changing simulation method can change resource requirements and supported gradients. This page verifies only the shown two-qubit local CPU workflow. Consult the release capability catalog before changing the execution path.

Troubleshooting

Symptom What to check
Encoder receives no gradients Keep encoded angles attached to autograd; avoid .detach(), .item(), NumPy conversion, or rebuilding them as new tensors.
Only quantum weights change Give the optimizer model.parameters(), not just the inner layer's parameters.
Predictions have the wrong shape Preserve (B, 1) targets and predictions. With two Z observables, the quantum feature tensor is (B, 2).
Batch-size mismatch Set circuit bsz from the current encoded batch, not a fixed training-set size.
Readout dimension mismatch Match its input width to the number of returned quantum features.
Classical and quantum devices differ Keep inputs, classical layers, parameters, and selected execution device consistent. These examples use CPU throughout.
Loss stops improving Inspect initialization, gradient magnitudes, learning rate, encoder saturation, and model expressiveness. A hybrid architecture does not guarantee convergence.
Reloaded predictor behaves differently Restore encoder, quantum, and readout weights, along with the same preprocessing and architecture.

Continue Learning and References

Clone this wiki locally