Expectation values and gradients
Replaying the propagated graph to evaluate expectation values and gradients, including paring and partial contraction.
Once the circuit has been propagated with
MajoranaPropagator.build_graph, the stored graph can be replayed at any
parameter vector without re-running the Majorana algebra — this is the basis of
variational workflows, where one circuit is evaluated at many parameter values.
Parameter values are given as a sequence of floats (list or numpy array) in
parameter-index order (values[i] is the angle for gates carrying param == i,
mapped by Circuit.resolved_mapping), or as the Circuit itself (its
parameters are used). To evaluate two independently-authored circuit halves
together, compose them with + and build the combined circuit in one
MajoranaPropagator.build_graph call — that keeps the gate order explicit rather
than leaving it to the picture.
Extending a graph across several calls
build_graph can also grow
one graph a block of gates at a time. Which circuit that adds up to is decided by the
simulation mode, because the Heisenberg picture consumes each
call's gates back-to-front:
sim.build_graph(a)
sim.build_graph(b) # Heisenberg: the circuit b + a; Schrödinger: a + bThe parameter axis follows the same order. In Heisenberg each extension takes the low indices
and lifts the ones already in the graph by circuit.n_parameters.
Gate arrival order is not renumbered. The two builds number their
gates differently even though their parameter axes match. That only matters if you re-wire the
graph by assigning a per-gate mapping — see the warning on
parameter_mapping.
An index or a gradient vector held across a build_graph call thus changes meaning in
the Heisenberg picture. Read the axis back from
parameter_mapping
after extending rather than remembering it. Schrödinger appends layers and indices
alike, so nothing is renumbered there.
seed_parameters is read on that same post-call axis, which makes it the parameter vector
of the equivalent composed circuit. It is needed only while extending a non-empty graph with
coefficient-informed truncation (lower_atol / upper_atol, see
Truncation and cutoffs) active: seeded there, the extension truncates
against the coefficients the existing layers really carry and reproduces the one-call build
exactly. On the pre-call axis the two blocks' angles swap places, which nothing raises on —
the graph just keeps a different set of terms.
Expectation value and gradient
The simplest path is to evaluate directly:
expval = sim.expectation_value(parameters)
expval, grad = sim.expectation_value_and_gradient(parameters)Shorter aliases are also available: sim.expval(parameters) and sim.expval_and_grad(parameters).
The gradient is returned in parameter-index order (grad[i] is the derivative with
respect to the angle at index i), and expectation_value_and_gradient computes
both quantities in one backward pass over the graph.
Reusable functionals
When the same graph is evaluated at many parameter values, build a functional once
and call it repeatedly. expectation_value_functional and
expectation_value_and_gradient_functional return callables that accept a
parameter vector:
expval_fn = sim.expectation_value_functional()
expval = expval_fn(parameters)
expval_grad_fn = sim.expectation_value_and_gradient_functional()
expval, grad = expval_grad_fn(parameters)Shorter aliases are also available: sim.expval_functional() and sim.expval_and_grad_functional().
A functional is built against the graph and initial-operator coefficients present when it is
created, so mutating either —
build_graph,
contract_partially,
update_initial_operator —
invalidates it: calling it afterwards raises RuntimeError rather than returning a value for state
the propagator no longer holds. Build a new functional after such a call. The direct
expectation_value path always
reflects the current operator.
Both functionals accept an optional pare_threshold — see Paring below.
Paring
Passing a pare_threshold to expectation_value_functional or
expectation_value_and_gradient_functional is an optional
speed-up: terms whose contribution to the expectation value falls below the
threshold are pared away, so they no longer have to be tracked through the graph
during replay. The stored graph itself is unchanged; only the replay skips the
negligible terms, so this can dramatically speed up replay cost for sparse graphs
(at the expense of some accuracy):
pared_expval_fn = sim.expectation_value_functional(pare_threshold=1e-7)Partial contraction
Where paring skips terms during replay, contract_partially permanently folds
a chosen set of gates into the operator, shrinking the graph that remains to be
replayed. The gates are contracted into the initial operator (Heisenberg picture)
or into the reference state (Schrödinger picture), and by default the simulator's
internal graph is updated in place:
# Fold the graph evaluated at these parameters into the operator, in place.
sim.contract_partially(parameters)This is useful when a prefix of the circuit is fixed, so its contribution is baked in once instead of replayed on every evaluation. Subsequent functionals only need to cover the remaining, shorter graph.
Pass inplace=False to leave the stored graph untouched and only return the
contracted operator coefficients, so the same graph can be reused with different
parameters:
coeffs = sim.contract_partially(parameters, inplace=False)To read the fully evolved operator as a MajoranaOperator (or PauliOperator for
PauliPropagator) — without modifying the
simulator — use MajoranaPropagator.evolved_operator
(or PauliPropagator.evolved_operator).
Reading individual terms
MajoranaPropagator.evolved_operator
decodes the whole evolved operator: it enumerates the operator index,
materialises a key per surviving term, and hands back a term dictionary.
Use evolved_operator_coefficients
when only a handful of terms are wanted; reading a few amplitudes out of an evolved
state in the Schrödinger picture, for example.
# The coefficients of these three terms alone, in this order.
terms = [Majorana(0, 1), Majorana(2, 3), Majorana(0, 3)]
coeffs = sim.evolved_operator_coefficients(terms, parameters)The terms are given as Majorana terms (or raw index tuples) for MajoranaPropagator or Pauli terms for
PauliPropagator. The returned NumPy array contains the corresponding coefficients, preserving the order.
This does not make the evolution faster. The graph contraction is identical and still the dominant cost; what disappears is the decode and dictionary build over every surviving term.
Per-term expectation values
term_expectation_values
decomposes an observable's expectation value into the contributions of its terms. For an observable
:
- Pass the list of terms .
- Get back the list of their expectation values , where
under the same truncated evolution as
expectation_value. - If needed, reconstruct .
All values together cost about as much as one
expectation_value call.
import numpy as np
from monoprop import Circuit, ExpGate, Pauli, PauliOperator, PauliPropagator
observable = PauliOperator(
{Pauli("ZZ", (0, 1)): 0.5, Pauli("XY", (1, 2)): -0.3, Pauli("Z", (3,)): 1.2}, 4
)
sim = PauliPropagator(observable, [], cutoff=2)
gates = [ExpGate(PauliOperator({Pauli(p, q): 1.0}, 4))
for p, q in [("X", (0,)), ("YY", (0, 1)), ("ZX", (1, 2)), ("XZ", (2, 3))]]
sim.build_graph(Circuit(gates, 4))
parameters = [0.1, 0.2, 0.3, 0.4]
terms = list(observable.terms)
coefficients = np.array(list(observable.terms.values()))
values = sim.term_expectation_values(terms, parameters) # [v_1, ..., v_m]
assert np.isclose(coefficients @ values, sim.expectation_value(parameters))The values come back in the order of the terms you pass.
Edge cases:
- In the Heisenberg picture, pass terms of . Any other term returns a value that is not , or raises if the evolution never created it. To evaluate a term outside , add it to with coefficient 0 before building the graph.
- In the Schrödinger picture, any term returns under the truncated evolved state, whether or not it is in .
- In the Heisenberg picture, the call raises once
propagateor an in-placecontract_partiallyhas absorbed gates into the operator, because the values would miss those gates. Query before contracting.