Skip to content

Surrogate propagation

For a parameterized circuit, the branching structure of the back-propagated observable does not depend on the parameter values, only the coefficients do. The surrogate propagators propagate once, symbolically, producing a compiled model whose evaluation for any parameter assignment is a cheap calculation.

This is the right tool for variational workloads, parameter sweeps, and anything where the same circuit topology is evaluated many times.

flowchart LR
    A["parameterized circuit"] --> B["SurrogatePauliCircuit"]
    B --> C["PauliSurrogatePropagator.build()"]
    O["observable"] --> C
    C -->|"expensive, once"| D["PauliSurrogateModel"]
    D -->|"cheap, many times"| E1["evaluate(params₁)"]
    D --> E2["evaluate(params₂)"]
    D --> E3["evaluate(…)"]
    D -->|save / load| F[("model file")]

Building a model

from qiskit import QuantumCircuit
from qiskit.circuit import ParameterVector
from qiskit.quantum_info import SparsePauliOp

from propaq.circuits import SurrogatePauliCircuit
from propaq.datatypes import PauliTermSum
from propaq.propagators import PauliSurrogatePropagator

theta = ParameterVector("theta", 6)
qc = QuantumCircuit(3)
for i, q in enumerate([0, 1, 2, 0, 1, 2]):
    qc.rz(theta[i], q)

observable = PauliTermSum.from_sparse_pauli_op(
    SparsePauliOp.from_list([("ZZI", 1.0), ("IZZ", 1.0)])
)
circuit = SurrogatePauliCircuit.from_qiskit(qc)

model = PauliSurrogatePropagator().build(observable, circuit, initial_state=0)

print(model.n_terms, "terms /", model.n_monomials, "monomials")
print(model.evaluate([0.1] * 6))

build returns a PauliSurrogateModel. The Majorana counterpart is MajoranaSurrogatePropagator producing a MajoranaSurrogateModel.

evaluate takes a flat sequence of floats, one per surrogate parameter slot, which is not necessarily one per Qiskit Parameter (a single Qiskit parameter can appear in several rotations, and a rotation angle can be a ParameterExpression). Check circuit.n_params against qc.num_parameters to see the difference.

Variational models

VariationalSurrogateModel wraps a compiled model together with the circuit's parameter mapping, so you can evaluate it in terms of the Qiskit parameters directly. That makes it usable as a cost function without any manual index bookkeeping:

import numpy as np
from scipy.optimize import minimize

from propaq.models import VariationalSurrogateModel

variational_model = VariationalSurrogateModel(
    model,
    circuit.parameter_sources,
    circuit.qiskit_parameters,
)

rng = np.random.default_rng(42)
x0 = rng.uniform(-np.pi, np.pi, size=len(variational_model.parameters))

result = minimize(variational_model.evaluate, x0, method="COBYLA")
print("optimised cost:", result.fun)

variational_model.parameters gives the Qiskit Parameter objects in the order evaluate expects, so binding the optimiser's result back into the original circuit is a dict(zip(...)).

Truncation in the surrogate

Surrogate builds accept the same truncator pipeline as numerical propagators, plus two surrogate-only truncators:

Truncator Effect
FrequencyTruncator drops paths that have branched more than frequency times
Simplify lossless: collapses monomials sharing a canonical path into one
from propaq.truncation import CoefficientTruncator, FrequencyTruncator, Simplify

prop = PauliSurrogatePropagator(
    truncation=[
        Simplify(),
        FrequencyTruncator(frequency=6),
        CoefficientTruncator(coefficient=1e-8),
    ]
)

The two axes to watch are terms and monomials (symbolic coefficients). Frequency truncator removes terms that have branched too many times.

Persistence

Compiling is the expensive step, whereas evaluating is comparatively cheap. Save the model and reuse it:

model.save("ansatz.surrogate")

# later, in a different process
from propaq.models import PauliSurrogateModel

model = PauliSurrogateModel.load("ansatz.surrogate")
print(model.evaluate(params))

Files are gzip-compressed binary. See Notebook 05.

When not to use a surrogate

If you only need one expectation value for one parameter assignment, the numerical propagator is faster. You pay the symbolic build cost and get nothing back for it. The surrogate wins once the number of evaluations is large enough to amortize the build.

Surrogate models are not differentiable

The surrogate models are currently incompatible with autograd. We plan on implementing this in the future.

Worked examples