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 that equivalent circuit, not the order the calls were made in, so
the incremental build and the one-call build of the composition agree on
parameter_mapping and on
the expectation value and gradient at any parameter vector. In Heisenberg each extension
therefore takes the low indices and lifts the ones already in the graph by
circuit.n_parameters.
Gate arrival order is the exception: it is not renumbered, so 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).