Skip to content

Training Parameterized Circuits

FlagQuantum edited this page Oct 20, 2026 · 6 revisions

Training Parameterized Circuits

Define trainable circuit parameters, construct a scalar loss, optimize with PyTorch, and evaluate the learned circuit.

Version: FlagQuantum 0.3.1. All three complete examples were verified on local CPU with Python 3.12.14, PyTorch 2.14.1, and macOS Apple Silicon. These examples use noiseless PyTorch statevector simulation, not sampled hardware measurements.

Start with Installation and Compatibility, Core Concepts, and Building and Simulating Circuits if you are new to the framework.

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. Define the Training Task

Our circuit applies a trainable Y rotation to qubit 0 and, in the two-qubit examples, a controlled-X from qubit 0 to qubit 1. The output we train is the Pauli-Z expectation on qubit 0:

$$z(\theta) = \langle Z_0\rangle = \cos(\theta).$$
Formula / mathematical operation Corresponding code excerpt Check / interpretation
$z(\theta)=\langle Z_0\rangle$ value = prediction() train_parameter.py executes RY and reads the Z expectation.
$z(\theta)=\cos\theta$ torch.cos(theta.detach()) Independent expression used by the gradient assertion.

We want this expectation to match the target $t=0.25$. Use a squared-error objective:

$$L(\theta) = (z(\theta)-t)^2.$$
Formula / mathematical operation Corresponding code excerpt Check / interpretation
$t=0.25$ target = torch.tensor(0.25) This target is an expectation value, not a probability.
$L=(z-t)^2$ loss = (value - target).square().mean() For the one-element circuit batch, mean returns the squared error.

The target is an expectation value, not a bit probability. A Pauli-Z expectation lies between −1 and +1. For this circuit, a target of 0.25 corresponds to probability 0.625 for qubit 0 being zero and 0.375 for it being one.

One solution is $\theta=\arccos(0.25)\approx1.318116$ radians. Other equivalent angles exist because the circuit is periodic. This analytical answer lets us check a small training example; it is not a claim that optimization is necessary for this particular problem.

2. Define Trainable Parameters

Two useful approaches are:

Approach Parameter ownership Optimizer input
A tensor parameter Create a torch.nn.Parameter and pass it directly to the gate. A list such as [theta]
A FlagQuantum module fq.Module owns parameters and passes them to a circuit-builder function. model.parameters()

For a fixed gate, a Python number is sufficient. For a differentiable gate, keep its parameter as a floating-point tensor throughout the forward computation. Do not convert it with .item(), float(...), or .detach() inside the training circuit.

fq.Module(..., n_parameters=1, init=torch.tensor([0.6])) creates one owned trainable parameter initialized to 0.6 radians. You do not need to set requires_grad=True on this initializer: the module registers the parameter for you. In the examples below, next(model.parameters()) retrieves the single owned parameter tensor; models with several parameter groups should use their named parameters explicitly.

Initialization Matters

For this objective,

$$\frac{dL}{d\theta}=-2(\cos(\theta)-0.25)\sin(\theta).$$
Formula / mathematical operation Corresponding code excerpt Check / interpretation
$dL/d\theta=-2(\cos\theta-t)\sin\theta$ loss.backward() Autograd populates theta.grad.
$g_{\mathrm{ref}}=-2(\cos\theta-t)\sin\theta$ expected_gradient = -2 * (torch.cos(theta.detach()) - target) * torch.sin(theta.detach()) Compare theta.grad with this analytical derivative before optimizer.step(), with atol=1e-6.

At an initial angle of zero, this derivative is zero even though the target has not been reached. We use a fixed nonzero initial angle of 0.6 for a reproducible example. A zero gradient can indicate a stationary initialization, not necessarily a broken autograd connection.

3. Construct a Scalar Loss and Take One Step

A loss must remain a real scalar tensor until backward completes. In PyTorch, (values - target).square().mean() computes the mean squared error and reduces the output to a scalar. For one circuit this is one squared error; for a batch, it averages the elementwise errors. Match target shapes intentionally when extending to batches.

The standard update order is:

  1. Clear accumulated gradients with optimizer.zero_grad().
  2. Execute a fresh forward pass and compute the loss.
  3. Call loss.backward() to calculate gradients.
  4. Call optimizer.step() to update parameters.

Backward calculates gradients; the optimizer applies them. Repeat the forward pass after an update to see its effect.

Example 1: Train an Explicit Tensor Parameter

Save as train_parameter.py:

import torch
import flagquantum as fq

theta = torch.nn.Parameter(torch.tensor(0.6))
optimizer = torch.optim.SGD([theta], lr=0.1)
target = torch.tensor(0.25)
options = fq.ExecutionOptions(mode="statevector", backend="pytorch", device="cpu")

def prediction():
    circuit = fq.Circuit(1).ry(0, theta)
    return fq.run(circuit, outputs=fq.expectation(fq.Z(0)), options=options).expectation()

optimizer.zero_grad()
value = prediction()
loss = (value - target).square().mean()
before = loss.item()
loss.backward()
expected_gradient = -2 * (torch.cos(theta.detach()) - target) * torch.sin(theta.detach())
assert torch.allclose(theta.grad, expected_gradient, atol=1e-6)
gradient = theta.grad.item()
optimizer.step()

with torch.no_grad():
    after = (prediction() - target).square().mean().item()

print("Trainable parameter:", theta.requires_grad)
print(f"Loss before step: {before:.6f}")
print(f"Parameter gradient: {gradient:.6f}")
print(f"Loss after step: {after:.6f}")
assert after < before
print("Parameter and gradient checks passed.")

Run python train_parameter.py. Expected output:

Trainable parameter: True
Loss before step: 0.331011
Parameter gradient: -0.649718
Loss after step: 0.288298
Parameter and gradient checks passed.

This uses SGD with learning rate 0.1. The analytical gradient assertion checks the derivative independently of the displayed decimals. The second forward pass runs after the update, so the two printed losses refer to different parameter values.

4. Train an fq.Module with a PyTorch Loop

fq.Module is a torch.nn.Module. Its circuit-builder function receives the current parameter tensor and returns an fq.Circuit. Build the circuit inside that function so later forward passes use updated parameters.

In FlagQuantum 0.3.1, the default module policy uses local PyTorch statevector execution and returns the Z expectation on qubit 0. model() returns a differentiable tensor, unlike fq.run(...), which returns an execution-result container. This module policy is separate from the outputs= requests used with fq.run.

Example 2: A Complete Adam Training Loop

Save as train_module_loop.py:

import torch
import flagquantum as fq

def build_circuit(parameters):
    return fq.Circuit(2).ry(0, parameters[0]).cx(0, 1)

model = fq.Module(
    build_circuit,
    n_parameters=1,
    init=torch.tensor([0.6]),
    device="cpu",
)
target = torch.tensor(0.25)
optimizer = torch.optim.Adam(model.parameters(), lr=0.05)
losses = []
model.train()

for step in range(1, 201):
    optimizer.zero_grad()
    values = model()
    loss = (values - target).square().mean()
    loss.backward()
    optimizer.step()
    losses.append(loss.detach().item())
    if step in (1, 50, 100, 200):
        print(f"Step {step:3d}: loss={losses[-1]:.6f}")

model.eval()
with torch.no_grad():
    final_prediction = model().mean().item()
    final_loss = (model() - target).square().mean().item()
trained_parameters = next(model.parameters()).detach().clone()
result = fq.run(
    build_circuit(trained_parameters),
    outputs=[fq.probabilities(), fq.expectation(fq.Z(0))],
    options=fq.ExecutionOptions(mode="statevector", backend="pytorch", device="cpu"),
)

print("Recorded steps:", len(losses))
print(f"Trained angle: {trained_parameters[0].item():.6f}")
print(f"Final <Z0>: {final_prediction:.6f}")
print(f"Recomputed final loss: {final_loss:.8f}")
print("Final probabilities:", [round(p.item(), 6) for p in result.probabilities[0]])
assert final_loss < 1e-6 and abs(final_prediction - target.item()) < 1e-3
assert abs(result.expectation().item() - final_prediction) < 1e-6
print("Training and final-evaluation checks passed.")

Run python train_module_loop.py. Output recorded in the verification environment:

Step   1: loss=0.331011
Step  50: loss=0.001047
Step 100: loss=0.000004
Step 200: loss=0.000000
Recorded steps: 200
Trained angle: 1.318130
Final <Z0>: 0.249987
Recomputed final loss: 0.00000000
Final probabilities: [0.624993, 0.0, 0.0, 0.375007]
Training and final-evaluation checks passed.

The loss is smaller than the printed precision, rather than mathematically zero. The last digits may vary across compatible environments. The script checks a loss below 1e-6, an expectation within 1e-3 of the target, and agreement between the module and a separate evaluation of the learned circuit.

Read the Training Results

  • Loss history: losses contains detached Python numbers for reporting. Detaching the recorded values avoids retaining every training graph.
  • Learned parameter: trained_parameters is a detached clone, so it is an independent snapshot suitable for a fixed-circuit evaluation. Do not replace the optimizer's registered parameter with this snapshot during training.
  • Final prediction and loss: these come from fresh forward passes after the final optimizer update.
  • Final probabilities: the vector is ordered 00, 01, 10, 11. For the trained two-qubit circuit, only 00 and 11 are populated.

The loop appends the loss calculated before each parameter update, even though the append occurs after optimizer.step(). The original loss tensor still represents that earlier forward pass. Recomputing the final loss is therefore the correct way to evaluate the final parameters.

model.train() and model.eval() set PyTorch module mode. model.eval() does not disable gradients. torch.no_grad() disables gradient recording for final evaluation; do not wrap the training forward pass in it.

5. Use fq.train for the Standard Lifecycle

When you do not need a custom update loop, fq.train handles forward execution, scalar-loss validation, gradient clearing, backward, optimizer updates, and loss recording. You still supply the PyTorch optimizer and objective.

The next script starts a new model with the same initializer and target. It can run independently of Example 2.

Example 3: Train and Inspect the Summary

Save as train_with_helper.py:

import torch
import flagquantum as fq

def build_circuit(parameters):
    return fq.Circuit(2).ry(0, parameters[0]).cx(0, 1)

model = fq.Module(build_circuit, n_parameters=1, init=torch.tensor([0.6]), device="cpu")
target = torch.tensor(0.25)
optimizer = torch.optim.Adam(model.parameters(), lr=0.05)
training = fq.train(
    model,
    optimizer=optimizer,
    objective=lambda values: (values - target).square().mean(),
    steps=200,
)

model.eval()
with torch.no_grad():
    final_prediction = model().mean().item()
    final_loss = (model() - target).square().mean().item()

print("Completed steps:", training.completed_steps)
print("Loss history length:", len(training.losses))
print("Optimizer:", training.optimizer)
print(f"First recorded loss: {training.losses[0]:.6f}")
print(f"Last recorded loss: {training.final_loss:.8f}")
print(f"Post-update <Z0>: {final_prediction:.6f}")
print(f"Post-update loss: {final_loss:.8f}")
assert training.completed_steps == len(training.losses) == 200
assert training.final_loss < training.losses[0]
assert final_loss < 1e-6 and abs(final_prediction - target.item()) < 1e-3
print("Training summary checks passed.")

Run python train_with_helper.py. Output recorded in the verification environment:

Completed steps: 200
Loss history length: 200
Optimizer: Adam
First recorded loss: 0.331011
Last recorded loss: 0.00000000
Post-update <Z0>: 0.249987
Post-update loss: 0.00000000
Training summary checks passed.
Training-result accessor Meaning in 0.3.1
training.completed_steps Number of completed optimization steps
training.losses Tuple of recorded scalar losses, one per completed step
training.final_loss Last recorded loss; equivalent to training.losses[-1]
training.optimizer Optimizer class name
training.last_execution Detached result of the final training forward execution
training.summary() A structured summary of the training run

The last recorded loss and last_execution correspond to the forward pass before the last optimizer update. They are not an extra post-update evaluation. Use the updated model for a fresh final prediction, as the script does. The returned training summary does not replace the model that owns the learned parameters.

For progress reporting, log_interval=N prints the first, last, and matching interval steps. A callback receives a one-based step number, scalar loss, and detached execution result after each optimizer step; the loss and result still describe that step's forward pass. Logged or detached callback values are for inspection rather than continuing the original autograd graph.

6. Choose an Objective for Your Goal

Goal Example scalar objective on model output values
Minimize the selected expectation values.mean()
Maximize the selected expectation -values.mean()
Match an expectation target (values - target).square().mean()
Use PyTorch mean squared error torch.nn.functional.mse_loss(values, target_tensor) with a deliberately matching target shape

Changing the objective changes the optimization task. It does not change which observable the module measures. If you need another observable or Hamiltonian, configure a supported module policy and workflow rather than relabeling the default Z output.

PyTorch optimizers such as SGD and Adam operate on the registered tensors. Create the optimizer after creating the model and selecting its device. Learning rate, initialization, circuit structure, and the objective all affect the trajectory; loss need not decrease at every step, and these small examples do not establish convergence for arbitrary circuits.

7. Troubleshooting and Reproducibility

Symptom What to check
Parameter gradient is missing Ensure the tensor is trainable and was used directly in the forward computation.
Loss is a Python float Keep .item() and float(...) for logging after constructing the tensor loss.
Gradient is zero at initialization Check the objective analytically when possible; this example has a stationary point at angle zero.
Parameters do not update Check optimizer ownership, learning rate, and that optimizer.step() runs after backward.
Gradients accumulate unexpectedly Clear them at the start of each independent step unless accumulation is intentional.
Repeated backward raises a graph error Build a fresh forward computation for each iteration rather than reusing a previous loss.
Loss oscillates or grows Inspect values and gradients, check the objective, and consider a smaller learning rate or a different initialization.
Final evaluation differs from the last logged loss Evaluate the final parameters explicitly; the log records the preceding forward pass.
CPU and accelerator runs differ slightly Record versions, precision, device, initialization, and optimizer settings; compare with appropriate tolerances.

For reproducibility, record the package versions, circuit, initial parameters, target, optimizer, learning rate, step count, precision, device, and any random seeds. The examples use fixed initialization and exact simulator expectations, without measurement-shot randomness.

Serialize model.state_dict() to save model state and include optimizer.state_dict() when optimizer state must also be restored. A scalar loss history alone is not enough to resume training faithfully; initialization and optimizer moments matter. An in-memory state dictionary can share tensor storage with the model, so serialize it or make an independent copy before continuing to train.

These examples establish local CPU differentiability only. Sample counts, remote providers, noisy execution, and alternative simulation methods require their own gradient support. Check the release capability catalog before changing the execution path.

Continue Learning and References

Clone this wiki locally