dense-Evolution / test_autodiff.py
Tatopenn's picture
Sync test_autodiff.py from v8.1.33
4cdc0c8 verified
Raw
History Blame Contribute Delete
11.7 kB
"""
Tests for dense_evolution.autodiff.circuit_to_energy_fn — the differentiable
VQE engine extracted from dashboard_core.py (_build_vqe_template /
_vqe_energy_fn) so it's reachable as public API, independent of Streamlit/
pandas/dashboard, and composable with the Qiskit/PennyLane interop bridge.
"""
import numpy as np
import pytest
import dense_evolution as de
from dense_evolution import autodiff
jax = pytest.importorskip("jax")
import jax.numpy as jnp
VQE_QASM = (
'OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; creg c[2]; '
'ry(0.5) q[0]; rx(0.5) q[1]; cx q[0],q[1]; rz(0.2) q[1]; cx q[0],q[1]; '
'ry(0.5) q[0]; rx(0.5) q[1]; measure q -> c;'
)
def _random_hamiltonian(n_qubits, seed=7):
rng = np.random.default_rng(seed)
values = np.sort(rng.uniform(-2.5, 2.5, 2 ** n_qubits))
return jnp.diag(jnp.array(values, dtype=jnp.float64))
class TestCircuitToEnergyFn:
def test_n_params_matches_parametric_gate_count(self):
circ = de.QASMParser().parse(VQE_QASM)
_, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
# ry, rx, rz, ry, rx = 5 parametric gates in VQE_QASM
assert n_params == 5
def test_gradient_matches_finite_difference(self):
circ = de.QASMParser().parse(VQE_QASM)
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
h_matrix = _random_hamiltonian(circ.n_qubits)
rng = np.random.default_rng(3)
theta0 = rng.uniform(-np.pi, np.pi, n_params)
energy_and_grad = jax.jit(jax.value_and_grad(energy_fn, argnums=0, has_aux=True))
(_, _), grad = energy_and_grad(jnp.asarray(theta0), h_matrix)
eps = 1e-6
fd_grad = np.zeros(n_params)
for i in range(n_params):
tp, tm = theta0.copy(), theta0.copy()
tp[i] += eps
tm[i] -= eps
(ep, _), _ = energy_and_grad(jnp.asarray(tp), h_matrix)
(em, _), _ = energy_and_grad(jnp.asarray(tm), h_matrix)
fd_grad[i] = float((ep - em) / (2 * eps))
np.testing.assert_allclose(np.asarray(grad), fd_grad, atol=1e-6)
def test_default_stato_zero_is_ground_state(self):
circ = de.QASMParser().parse('OPENQASM 2.0; include "qelib1.inc"; qreg q[2]; creg c[2]; measure q -> c;')
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
assert n_params == 0
h_matrix = jnp.diag(jnp.array([1.0, 2.0, 3.0, 4.0], dtype=jnp.float64))
energy, sv = energy_fn(jnp.array([]), h_matrix)
# empty circuit -> stays |00>, energy = h_matrix[0,0]
assert abs(float(energy) - 1.0) < 1e-9
assert abs(abs(complex(sv[0])) - 1.0) < 1e-9
def test_optimization_loop_standalone_energy_descends(self):
# Same correctness bar as test_dashboard_core.py's
# test_vqe_energy_trends_downward_over_epochs, but built directly on
# circuit_to_energy_fn with a hand-rolled Adam loop — no
# run_vqe_telemetry/dashboard_core involved at all. Demonstrates the
# public API is self-sufficient for real VQE outside the dashboard.
circ = de.QASMParser().parse(VQE_QASM)
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
h_matrix = _random_hamiltonian(circ.n_qubits, seed=11)
rng = np.random.default_rng(11)
theta = rng.uniform(-np.pi, np.pi, n_params)
m, v = np.zeros(n_params), np.zeros(n_params)
lr, beta1, beta2 = 0.1, 0.9, 0.999
energy_and_grad = jax.jit(jax.value_and_grad(energy_fn, argnums=0, has_aux=True))
energies = []
for epoch in range(1, 41):
(energy, _), grad = energy_and_grad(jnp.asarray(theta), h_matrix)
energies.append(float(energy))
grad = np.asarray(grad)
m = beta1 * m + (1 - beta1) * grad
v = beta2 * v + (1 - beta2) * (grad ** 2)
m_hat = m / (1 - beta1 ** epoch)
v_hat = v / (1 - beta2 ** epoch)
theta -= (lr / (np.sqrt(v_hat) + 1e-8)) * m_hat
assert np.mean(energies[-5:]) < np.mean(energies[:5])
def test_from_pennylane_circuit_is_now_differentiable(self):
# This is the gap found while auditing the interop bridge last
# turn: jax.grad through run_pennylane_circuit silently returns 0.0
# because from_pennylane bakes theta into a plain float. Composed
# through circuit_to_energy_fn instead (which works on the
# QASMCircuit from_pennylane returns, before that float baking
# matters), a real circuit must now show a non-zero gradient.
pennylane = pytest.importorskip("pennylane")
import pennylane as qml
dev = qml.device('default.qubit', wires=2)
@qml.qnode(dev)
def circuit(theta):
qml.RY(theta[0], wires=0)
qml.RX(theta[1], wires=1)
qml.CNOT(wires=[0, 1])
return qml.probs(wires=[0, 1])
theta0 = np.array([0.5, 0.5])
circ = de.from_pennylane(circuit, theta0)
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
assert n_params == 2
h_matrix = _random_hamiltonian(circ.n_qubits, seed=5)
def energy(theta):
e, _ = energy_fn(theta, h_matrix)
return e
grad = jax.grad(energy)(jnp.asarray(theta0))
assert float(jnp.linalg.norm(grad)) > 1e-6
class TestEnergyFnNoiseSpec:
"""circuit_to_energy_fn's `noise=` argument (registry.NoiseSpec, a
JAX PyTree): applies NoiseModel.apply_to_sv natively inside the same
traced computation as theta, instead of as an external Python-side
step the caller has to splice in around energy_fn. jax_key is a
pytree leaf (not an aux_data/static field), so it flows through
jit/grad/vmap the way any other JAX array does -- no external
key-management workaround, no OS-entropy fallback."""
def test_default_none_matches_pre_noise_behavior(self):
circ = de.QASMParser().parse(VQE_QASM)
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
h_matrix = _random_hamiltonian(circ.n_qubits)
theta = jnp.asarray(np.random.default_rng(1).uniform(-np.pi, np.pi, n_params))
e_no_arg, _ = energy_fn(theta, h_matrix)
e_explicit_none, _ = energy_fn(theta, h_matrix, None, None)
assert float(e_no_arg) == pytest.approx(float(e_explicit_none))
def test_same_key_is_reproducible(self):
circ = de.QASMParser().parse(VQE_QASM)
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
h_matrix = _random_hamiltonian(circ.n_qubits)
theta = jnp.asarray(np.random.default_rng(2).uniform(-np.pi, np.pi, n_params))
noise = de.NoiseSpec(model='depolarizing', p=0.15, jax_key=jax.random.PRNGKey(42))
e1, _ = energy_fn(theta, h_matrix, noise=noise)
e2, _ = energy_fn(theta, h_matrix, noise=noise)
assert float(e1) == pytest.approx(float(e2))
def test_noisy_energy_differs_from_ideal(self):
circ = de.QASMParser().parse(VQE_QASM)
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
h_matrix = _random_hamiltonian(circ.n_qubits)
theta = jnp.asarray(np.random.default_rng(3).uniform(-np.pi, np.pi, n_params))
e_ideal, _ = energy_fn(theta, h_matrix)
noise = de.NoiseSpec(model='depolarizing', p=0.3, jax_key=jax.random.PRNGKey(7))
e_noisy, _ = energy_fn(theta, h_matrix, noise=noise)
assert float(e_ideal) != pytest.approx(float(e_noisy))
def test_zero_probability_reduces_to_ideal_even_when_traced(self):
# p=0.0 passed as a *traced* pytree leaf (under jax.jit) used to
# crash with TracerBoolConversionError -- apply_to_sv's early
# `if p <= 0.0: return sv` shortcut couldn't take a Python bool
# of a tracer. Fixed to try/except around that optimization only;
# every channel's math already reduces to a no-op at p=0
# (`fire = r < p` is always False), so falling through instead of
# short-circuiting is still correct.
circ = de.QASMParser().parse(VQE_QASM)
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
h_matrix = _random_hamiltonian(circ.n_qubits)
theta = jnp.asarray(np.random.default_rng(4).uniform(-np.pi, np.pi, n_params))
e_ideal, _ = energy_fn(theta, h_matrix)
@jax.jit
def run(p):
noise = de.NoiseSpec(model='depolarizing', p=p, jax_key=jax.random.PRNGKey(1))
e, _ = energy_fn(theta, h_matrix, None, noise)
return e
e_zero = run(0.0)
assert float(e_zero) == pytest.approx(float(e_ideal))
def test_composable_with_jax_jit(self):
circ = de.QASMParser().parse(VQE_QASM)
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
h_matrix = _random_hamiltonian(circ.n_qubits)
theta = jnp.asarray(np.random.default_rng(5).uniform(-np.pi, np.pi, n_params))
noise = de.NoiseSpec(model='depolarizing', p=0.1, jax_key=jax.random.PRNGKey(3))
e_eager, _ = energy_fn(theta, h_matrix, None, noise)
e_jit, _ = jax.jit(energy_fn)(theta, h_matrix, None, noise)
assert float(e_eager) == pytest.approx(float(e_jit))
def test_composable_with_jax_grad_through_noise(self):
# gradient w.r.t. theta must flow through the noisy pipeline,
# not just the ideal one
circ = de.QASMParser().parse(VQE_QASM)
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
h_matrix = _random_hamiltonian(circ.n_qubits)
theta = jnp.asarray(np.random.default_rng(6).uniform(-np.pi, np.pi, n_params))
noise = de.NoiseSpec(model='depolarizing', p=0.1, jax_key=jax.random.PRNGKey(9))
def energy(t):
e, _ = energy_fn(t, h_matrix, None, noise)
return e
grad = jax.grad(energy)(theta)
assert float(jnp.linalg.norm(grad)) > 0.0
def test_composable_with_jax_vmap_over_keys(self):
# a batch of independent, reproducible noise realizations,
# natively -- no external Python loop managing keys
circ = de.QASMParser().parse(VQE_QASM)
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
h_matrix = _random_hamiltonian(circ.n_qubits)
theta = jnp.asarray(np.random.default_rng(8).uniform(-np.pi, np.pi, n_params))
keys = jax.random.split(jax.random.PRNGKey(0), 6)
def run(k):
noise = de.NoiseSpec(model='depolarizing', p=0.15, jax_key=k)
e, _ = energy_fn(theta, h_matrix, None, noise)
return e
batch = jax.vmap(run)(keys)
assert batch.shape == (6,)
# independent keys must not all collapse to the same value
assert float(jnp.std(batch)) > 0.0
def test_qubits_subset_still_works(self):
circ = de.QASMParser().parse(VQE_QASM)
energy_fn, n_params = de.circuit_to_energy_fn(circ, circ.n_qubits)
h_matrix = _random_hamiltonian(circ.n_qubits)
theta = jnp.asarray(np.random.default_rng(9).uniform(-np.pi, np.pi, n_params))
noise = de.NoiseSpec(model='bitflip', p=0.5, jax_key=jax.random.PRNGKey(2), qubits=[0])
e, sv = energy_fn(theta, h_matrix, None, noise)
assert sv.shape == (2 ** circ.n_qubits,)
class TestImportSafety:
def test_root_import_never_fails(self):
assert hasattr(de, 'circuit_to_energy_fn')
def test_missing_jax_raises_clear_importerror(self, monkeypatch):
monkeypatch.setattr(autodiff, 'HAS_JAX', False)
with pytest.raises(ImportError, match='JAX'):
autodiff.circuit_to_energy_fn(None, 1)