monoprop

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.

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 present when it is created, and it reads through to the propagator it came from, which it keeps alive for as long as you hold it. A structural change to that graph invalidates the functional: calling it afterwards raises RuntimeError rather than returning a value for a circuit the propagator no longer holds. Build a new functional after such a call. The direct expectation_value path always reflects the current propagator.

A re-weight is the one mutation a functional survives: update_initial_operator leaves the graph, the operator's terms and their index where they are, so a live functional follows the new coefficients. The price is that a functional is a live view of those coefficients — two calls with the same parameters give two answers across a re-weight. Build the functional again after the last re-weight if you need a frozen value. Each functional carries the rule as a follows_weights attribute, so a caller holding one need not re-derive it from the picture and the threshold.

This is what every public mutating method does to a functional built before it ran:

MethodA call afterwards
build_graphraises
propagateraises
contract_partially with inplace=Trueraises
parameter_mappingraises
update_initial_operatoranswers for the new coefficients — except in the Schrödinger picture with a pare_threshold, where it raises, because the pared graph was selected from the coefficients the re-weight replaced
a rejected update_initial_operatorraises: a partitioned propagator applies the re-weight one partition at a time, so a term only one of them holds is rejected with its siblings already re-weighted
a build_graph or propagate that fails part-wayraises: a gate generator is bounds-checked only as the gate loop reaches it, so the gates before it are already committed
cutoff, cutoff_type, basis_change, lower_atol, upper_atolanswers, unchanged: these gate the next build and touch nothing a functional replays
contract_partially with inplace=Falseanswers, unchanged: it mutates nothing
a call that folds or appends nothing — contract_partially([], inplace=True), build_graph or propagate with no gatesanswers, unchanged: nothing moved, on any partition count

The table is executable — see the mutation table.

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).

On this page