Surrogate model persistence¶
We showed how to compile the surrogate models in the previous notebook. This notebook covers how to save and load compiled models to disk, as well as some diagnostics and tools for parameter sweeps.
Building the same ansatz as before¶
We'll reuse the exact small ansatz and observable from the previous notebook, so we
have a PauliSurrogateModel to work with without repeating the optimization loop.
import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit import ParameterVector
from qiskit.circuit.library import XXPlusYYGate
from qiskit.quantum_info import SparsePauliOp
n_qubits = 3
reps = 2
theta = ParameterVector("theta", n_qubits * reps)
phi = ParameterVector("phi", (n_qubits - 1) * reps)
qc = QuantumCircuit(n_qubits)
# HF State
qc.x(0)
qc.x(2)
it, ip = 0, 0
for layer in range(reps):
for q in range(n_qubits):
qc.rz(theta[it], q)
it += 1
for q in range(n_qubits - 1):
qc.append(XXPlusYYGate(phi[ip], 0.0), [q, q + 1])
ip += 1
observable = SparsePauliOp.from_list([("ZZI", 1.0), ("IZZ", 1.0), ("ZIZ", 0.5)])
from propaq.circuits import SurrogatePauliCircuit
from propaq.datatypes import PauliTermSum
from propaq.propagators import PauliSurrogatePropagator
from propaq.models import VariationalSurrogateModel
obs_term_sum = PauliTermSum.from_sparse_pauli_op(observable)
surrogate_circuit = SurrogatePauliCircuit.from_qiskit(qc)
model = PauliSurrogatePropagator().build(obs_term_sum, surrogate_circuit, initial_state=0)
variational_model = VariationalSurrogateModel(
model, surrogate_circuit.parameter_sources, surrogate_circuit.qiskit_parameters
)
Saving and loading a compiled model¶
save/load round-trip a model through a gzip-compressed binary file. A loaded model can be used for parameter evaluations immediately.
from propaq import PauliSurrogateModel
model.save("surrogate_model.pq")
loaded_model = PauliSurrogateModel.load("surrogate_model.pq")
rng = np.random.default_rng(0)
params = rng.uniform(-np.pi, np.pi, size=model.n_params).tolist()
print("Original model evaluate: ", model.evaluate(params))
print("Loaded model evaluate: ", loaded_model.evaluate(params))
print("Match:", model.evaluate(params) == loaded_model.evaluate(params))
Original model evaluate: -0.885823009035863 Loaded model evaluate: -0.885823009035863 Match: True
Model diagnostics: n_terms and n_monomials¶
n_terms refers to the number of terms left after structurally pruning strings with zero contribution to the expectation value. n_monomials refers to the number of trig factors in the symbolic history of the terms.
Note that n_monomials is often orders of magnitude larger than n_terms. It's a good proxy to understand how much computation a compiled model represents.
print("n_params: ", model.n_params)
print("n_terms: ", model.n_terms)
print("n_monomials:", model.n_monomials)
n_params: 10 n_terms: 3 n_monomials: 85
Batched evaluation¶
evaluate_batch evaluates many parameter assignments in one call, parallelized
across the assignments, instead of calling evaluate in a Python loop. This is the
natural fit for anything that needs the cost function at many points at once, such as
scanning a parameter, an ensemble of optimizer restarts, or (as here) just a batch
of random parameter sets.
import time
n_sets = 500
param_sets = [rng.uniform(-np.pi, np.pi, size=model.n_params).tolist() for _ in range(n_sets)]
t0 = time.perf_counter()
looped = [model.evaluate(p) for p in param_sets]
t1 = time.perf_counter()
batched = model.evaluate_batch(param_sets)
t2 = time.perf_counter()
print(f"Looped evaluate: {(t1 - t0) * 1000:.2f} ms for {n_sets} parameter sets")
print(f"evaluate_batch: {(t2 - t1) * 1000:.2f} ms for {n_sets} parameter sets")
print("Results match (within floating-point tolerance):", np.allclose(looped, batched))
print("Largest discrepancy:", max(abs(a - b) for a, b in zip(looped, batched)))
Looped evaluate: 24.46 ms for 500 parameter sets evaluate_batch: 3.42 ms for 500 parameter sets Results match (within floating-point tolerance): True Largest discrepancy: 2.220446049250313e-16