diff --git a/qiskit_addon_mpf/backends/quimb_layers/evolver.py b/qiskit_addon_mpf/backends/quimb_layers/evolver.py index 479dd51..f088130 100644 --- a/qiskit_addon_mpf/backends/quimb_layers/evolver.py +++ b/qiskit_addon_mpf/backends/quimb_layers/evolver.py @@ -14,6 +14,8 @@ from __future__ import annotations +from quimb.tensor import MatrixProductState + from .. import quimb_tebd from .model import LayerModel @@ -34,7 +36,11 @@ class LayerwiseEvolver(quimb_tebd.TEBDEvolver): """ def __init__( - self, evolution_state: quimb_tebd.MPOState, layers: list[LayerModel], *args, **kwargs + self, + evolution_state: quimb_tebd.MPOState | MatrixProductState, + layers: list[LayerModel], + *args, + **kwargs, ) -> None: """Initialize a :class:`LayerwiseEvolver` instance. @@ -62,6 +68,11 @@ def step(self) -> None: """ dt = self._dt + # NOTE: support for MatrixProductState objects is only added for testing/debugging purposes! + # This is not meant for consumption by end-users of the `qiskit_addon_mpf.dynamic` module + # and its use is highly discouraged. + is_mps = isinstance(self._pt, MatrixProductState) + for layer in self.layers: self.H = layer for i in range(self.L): @@ -69,7 +80,10 @@ def step(self) -> None: gate = self._get_gate_from_ham(1.0, sites) if gate is None: continue - self._pt.gate_split_(gate, sites, conj=self.conjugate, **self.split_opts) + if is_mps: + self._pt.gate_split_(gate, sites, **self.split_opts) + else: + self._pt.gate_split_(gate, sites, conj=self.conjugate, **self.split_opts) self.t += dt self._err += float("NaN") diff --git a/qiskit_addon_mpf/backends/quimb_tebd/evolver.py b/qiskit_addon_mpf/backends/quimb_tebd/evolver.py index 7e7385e..8d6fe91 100644 --- a/qiskit_addon_mpf/backends/quimb_tebd/evolver.py +++ b/qiskit_addon_mpf/backends/quimb_tebd/evolver.py @@ -16,7 +16,7 @@ from typing import Literal, cast -from quimb.tensor import TEBD +from quimb.tensor import TEBD, MatrixProductState from .. import Evolver from .state import MPOState @@ -37,7 +37,9 @@ class TEBDEvolver(TEBD, Evolver): that API here. """ - def __init__(self, evolution_state: MPOState, *args, order: int = 2, **kwargs) -> None: + def __init__( + self, evolution_state: MPOState | MatrixProductState, *args, order: int = 2, **kwargs + ) -> None: """Initialize a :class:`TEBDEvolver` instance. Args: @@ -139,18 +141,31 @@ def sweep( if dt is not None: dt_frac *= dt / self._dt # pragma: no cover + # NOTE: support for MatrixProductState objects is only added for testing/debugging purposes! + # This is not meant for consumption by end-users of the `qiskit_addon_mpf.dynamic` module + # and its use is highly discouraged. + is_mps = isinstance(self._pt, MatrixProductState) + final_site_ind = self.L - 1 if direction == "right": for i in range(0, final_site_ind, 2): sites = (i, (i + 1) % self.L) gate = self._get_gate_from_ham(dt_frac, sites) - self._pt.gate_split_(gate, sites, conj=self.conjugate, **self.split_opts) + if is_mps: + # NOTE: we ignore coverage here because the logic is tested via the LayerEvolver + self._pt.gate_split_(gate, sites, **self.split_opts) # pragma: no cover + else: + self._pt.gate_split_(gate, sites, conj=self.conjugate, **self.split_opts) elif direction == "left": for i in range(1, final_site_ind, 2): sites = (i, (i + 1) % self.L) gate = self._get_gate_from_ham(dt_frac, sites) - self._pt.gate_split_(gate, sites, conj=self.conjugate, **self.split_opts) + if is_mps: + # NOTE: we ignore coverage here because the logic is tested via the LayerEvolver + self._pt.gate_split_(gate, sites, **self.split_opts) # pragma: no cover + else: + self._pt.gate_split_(gate, sites, conj=self.conjugate, **self.split_opts) else: # NOTE: it should not be possible to reach this but we do a sanity check to ensure that diff --git a/qiskit_addon_mpf/backends/tenpy_tebd/evolver.py b/qiskit_addon_mpf/backends/tenpy_tebd/evolver.py index 40fa5f1..a4c93d0 100644 --- a/qiskit_addon_mpf/backends/tenpy_tebd/evolver.py +++ b/qiskit_addon_mpf/backends/tenpy_tebd/evolver.py @@ -19,6 +19,7 @@ from tenpy import TEBDEngine, svd_theta from tenpy.linalg import np_conserved as npc +from tenpy.networks import MPS from .. import Evolver @@ -96,35 +97,51 @@ def update_bond(self, i: int, U_bond: npc.Array) -> float: """ i0, i1 = i - 1, i LOGGER.debug("Update sites (%d, %d)", i0, i1) + + # NOTE: support for MatrixProductState objects is only added for testing/debugging purposes! + # This is not meant for consumption by end-users of the `qiskit_addon_mpf.dynamic` module + # and its use is highly discouraged. + is_mps = isinstance(self.psi, MPS) + + leg_lbl = "v" if is_mps else "w" + left_leg = f"{leg_lbl}L" + right_leg = f"{leg_lbl}R" + p0s = ("p0",) if is_mps else ("p0", "p0*") + p1s = ("p1",) if is_mps else ("p1", "p1*") + ps = ("p",) if is_mps else ("p", "p*") + # Construct the theta matrix - C0 = self.psi.get_W(i0) - C1 = self.psi.get_W(i1) + C0 = self.psi.get_B(i0) if is_mps else self.psi.get_W(i0) + C1 = self.psi.get_B(i1) if is_mps else self.psi.get_W(i1) - C = npc.tensordot(C0, C1, axes=(["wR"], ["wL"])) - new_labels = ["wL", "p0", "p0*", "p1", "p1*", "wR"] + C = npc.tensordot(C0, C1, axes=([right_leg], [left_leg])) + new_labels = [left_leg, *p0s, *p1s, right_leg] C.iset_leg_labels(new_labels) + # apply U to C if self.conjugate: C = npc.tensordot(U_bond.conj(), C, axes=(["p0", "p1"], ["p0*", "p1*"])) # apply U else: C = npc.tensordot(U_bond, C, axes=(["p0*", "p1*"], ["p0", "p1"])) # apply U - C.itranspose(["wL", "p0", "p0*", "p1", "p1*", "wR"]) - theta = C.scale_axis(self.psi.Ss[i0], "wL") + + C.itranspose([left_leg, *p0s, *p1s, right_leg]) + + theta = C.scale_axis(self.psi.get_SL(i0) if is_mps else self.psi.Ss[i0], left_leg) # now theta is the same as if we had done # theta = self.psi.get_theta(i0, n=2) # theta = npc.tensordot(U_bond, theta, axes=(['p0*', 'p1*'], ['p0', 'p1'])) # apply U # but also have C which is the same except the missing "S" on the left # so we don't have to apply inverses of S (see below) - # theta = theta.combine_legs([("wL", "p0", "p0*"), ("p1", "p1*", "wR")], qconj=[+1, -1]) - theta = theta.combine_legs([[0, 1, 2], [3, 4, 5]], qconj=[+1, -1]) + theta = theta.combine_legs([(left_leg, *p0s), (*p1s, right_leg)], qconj=[+1, -1]) + # Perform the SVD and truncate the wavefunction U, S, V, trunc_err, renormalize = svd_theta( - theta, self.trunc_params, [None, None], inner_labels=["wR", "wL"] + theta, self.trunc_params, [None, None], inner_labels=[right_leg, left_leg] ) # Split tensor and update matrices - B_R = V.split_legs(1).ireplace_labels(["p1", "p1*"], ["p", "p*"]) + B_R = V.split_legs(1).ireplace_labels(p1s, ps) # In general, we want to do the following: # U = U.iscale_axis(S, 'vR') @@ -137,16 +154,29 @@ def update_bond(self, i: int, U_bond: npc.Array) -> float: # such that we obtain ``B_L = SL**-1 U S = SL**-1 U S V V^dagger = C V^dagger`` # here, C is the same as theta, but without the `S` on the very left # (Note: this requires no inverse if the MPS is initially in 'B' canonical form) + + def conj(labels: tuple[str, ...]): + """Conjugates a tuple of leg labels.""" + return tuple(lbl[:-1] if lbl[-1] == "*" else lbl + "*" for lbl in labels) + B_L = npc.tensordot( - C.combine_legs(("p1", "p1*", "wR"), pipes=theta.legs[1]), + C.combine_legs((*p1s, right_leg), pipes=theta.legs[1]), V.conj(), - axes=["(p1.p1*.wR)", "(p1*.p1.wR*)"], + axes=[f"({'.'.join(p1s)}.{right_leg})", f"({'.'.join(conj(p1s))}.{right_leg}*)"], ) - B_L.ireplace_labels(["wL*", "p0", "p0*"], ["wR", "p", "p*"]) + B_L.ireplace_labels([f"{left_leg}*", *p0s], [right_leg, *ps]) B_L /= renormalize # re-normalize to = 1 - self.psi.Ss[i1] = S - self.psi.set_W(i0, B_L) - self.psi.set_W(i1, B_R) - self._trunc_err_bonds[i] = self._trunc_err_bonds[i] + trunc_err + if is_mps: + self.psi.norm *= renormalize + self.psi.set_SR(i0, S) + self.psi.set_B(i0, B_L, form="B") + self.psi.set_B(i1, B_R, form="B") + else: + self.psi.Ss[i1] = S + self.psi.set_W(i0, B_L) + self.psi.set_W(i1, B_R) + + self._trunc_err_bonds[i0] = self._trunc_err_bonds[i0] + trunc_err + return cast(float, trunc_err) diff --git a/test/backends/quimb_layers/test_evolver.py b/test/backends/quimb_layers/test_evolver.py new file mode 100644 index 0000000..a8c9ada --- /dev/null +++ b/test/backends/quimb_layers/test_evolver.py @@ -0,0 +1,155 @@ +# This code is a Qiskit project. +# +# (C) Copyright IBM 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + + +import numpy as np +import pytest +from qiskit.circuit import QuantumCircuit +from qiskit.circuit.library import XXPlusYYGate +from qiskit_addon_mpf.backends import HAS_QUIMB + +if HAS_QUIMB: + from qiskit_addon_mpf.backends.quimb_layers import LayerModel, LayerwiseEvolver + from quimb.tensor import MPS_neel_state, SpinHam1D + + +def gen_ext_field_layer(n, hz): + qc = QuantumCircuit(n) + for q in range(n): + qc.rz(-hz[q], q) + return qc + + +def trotter_step(qc, q0, q1, Jxx, Jz): + qc.rzz(Jz, q0, q1) + qc.append(XXPlusYYGate(2.0 * Jxx), [q0, q1]) + + +def gen_odd_coupling_layer(n, Jxx, Jz, J): + qc = QuantumCircuit(n) + for q in range(0, n, 2): + trotter_step(qc, q, q + 1, J[q] * Jxx, J[q] * Jz) + return qc + + +def gen_even_coupling_layer(n, Jxx, Jz, J): + qc = QuantumCircuit(n) + for q in range(1, n - 1, 2): + q0 = q + q1 = (q + 1) % n + if q1 < q0: + qc.barrier() + trotter_step(qc, q0, q1, J[q0] * Jxx, J[q0] * Jz) + return qc + + +@pytest.mark.skipif(not HAS_QUIMB, reason="Quimb is required for these unittests") +class TestLayerwiseEvolver: + def test_compare_statevector(self): + """Test the time-evolution logic by comparing against an exact statevector simulation. + + The reference value against which is being compared here can be obtained from: + + .. code-block:: python + + odd_coupling_layer = gen_odd_coupling_layer(L, dt * Jxx, dt * Jz, J) + even_coupling_layer = gen_even_coupling_layer(L, dt * Jxx, dt * Jz, J) + onsite_layer = gen_ext_field_layer(L, dt * hz) + layers = [ + odd_coupling_layer, + even_coupling_layer, + onsite_layer, + onsite_layer, + even_coupling_layer, + odd_coupling_layer, + ] + + trotter_circ = QuantumCircuit(L) + for layer in layers: + trotter_circ = trotter_circ.compose(layer) + trotter_circ = trotter_circ.repeat(N) + + init_circ = QuantumCircuit(L) + init_circ.x(1) + init_circ.x(3) + + full_circ = init_circ.copy() + full_circ = full_circ.compose(trotter_circ) + + init_state_vec = Statevector(init_circ) + full_state_vec = Statevector(full_circ) + reference = full_state_vec.inner(init_state_vec) + """ + + np.random.seed(0) + + L = 4 + W = 0.5 + epsilon = 0.5 + J = np.random.rand(L - 1) + W * np.ones(L - 1) + Jz = 1.0 + Jxx = epsilon + hz = 0.000000001 * np.array([(-1) ** i for i in range(L)]) + + N = 10 + dt = 0.05 + + odd_coupling_layer = gen_odd_coupling_layer(L, Jxx, Jz, J) + even_coupling_layer = gen_even_coupling_layer(L, Jxx, Jz, J) + ext_field_layer = gen_ext_field_layer(L, hz) + + # Initialize the builder for a spin 1/2 chain + builder = SpinHam1D(S=1 / 2) + + # Add XX and YY couplings for neighboring sites + for i in range(L - 1): + builder[i, i + 1] += 2.0 * Jxx * J[i], "-", "+" + builder[i, i + 1] += 2.0 * Jxx * J[i], "+", "-" + + # Add ZZ couplings for neighboring sites + for i in range(L - 1): + builder[i, i + 1] += 4.0 * Jz * J[i], "Z", "Z" + + # Add the external Z-field (hz) to each site + for i in range(L): + builder[i] += -2.0 * hz[i], "Z" + + layers = [ + LayerModel.from_quantum_circuit(odd_coupling_layer, cyclic=False), + LayerModel.from_quantum_circuit(even_coupling_layer, cyclic=False), + LayerModel.from_quantum_circuit(ext_field_layer, keep_only_odd=True, cyclic=False), + LayerModel.from_quantum_circuit(ext_field_layer, keep_only_odd=False, cyclic=False), + LayerModel.from_quantum_circuit(ext_field_layer, keep_only_odd=False, cyclic=False), + LayerModel.from_quantum_circuit(ext_field_layer, keep_only_odd=True, cyclic=False), + LayerModel.from_quantum_circuit(even_coupling_layer, cyclic=False), + LayerModel.from_quantum_circuit(odd_coupling_layer, cyclic=False), + ] + + trunc_options = { + "max_bond": 100, + "cutoff": 1e-15, + "cutoff_mode": "rel", + "method": "svd", + "renorm": False, + } + + initial_state = MPS_neel_state(L) + mps_state = initial_state.copy() + mps_evo = LayerwiseEvolver( + evolution_state=mps_state, layers=layers, dt=dt, split_opts=trunc_options + ) + for _ in range(N): + mps_evo.step() + + np.testing.assert_almost_equal( + initial_state.overlap(mps_evo.pt), -0.2607402383827852 - 0.6343830867298741j + ) diff --git a/test/backends/tenpy_layers/test_evolver.py b/test/backends/tenpy_layers/test_evolver.py index 76ea648..93212e1 100644 --- a/test/backends/tenpy_layers/test_evolver.py +++ b/test/backends/tenpy_layers/test_evolver.py @@ -11,13 +11,45 @@ # that they have been altered from the originals. +import numpy as np import pytest from qiskit.circuit import QuantumCircuit +from qiskit.circuit.library import XXPlusYYGate from qiskit_addon_mpf.backends import HAS_TENPY if HAS_TENPY: from qiskit_addon_mpf.backends.tenpy_layers import LayerModel, LayerwiseEvolver - from qiskit_addon_mpf.backends.tenpy_tebd import MPOState + from qiskit_addon_mpf.backends.tenpy_tebd import MPOState, MPS_neel_state + + +def gen_ext_field_layer(n, hz): + qc = QuantumCircuit(n) + for q in range(n): + qc.rz(-hz[q], q) + return qc + + +def trotter_step(qc, q0, q1, Jxx, Jz): + qc.rzz(Jz, q0, q1) + qc.append(XXPlusYYGate(2.0 * Jxx), [q0, q1]) + + +def gen_odd_coupling_layer(n, Jxx, Jz, J): + qc = QuantumCircuit(n) + for q in range(0, n, 2): + trotter_step(qc, q, q + 1, J[q] * Jxx, J[q] * Jz) + return qc + + +def gen_even_coupling_layer(n, Jxx, Jz, J): + qc = QuantumCircuit(n) + for q in range(1, n - 1, 2): + q0 = q + q1 = (q + 1) % n + if q1 < q0: + qc.barrier() + trotter_step(qc, q0, q1, J[q0] * Jxx, J[q0] * Jz) + return qc @pytest.mark.skipif(not HAS_TENPY, reason="Tenpy is required for these unittests") @@ -37,3 +69,93 @@ def test_N_steps_guard(self): with pytest.raises(RuntimeError): algo.evolve(2, 0.1) + + def test_compare_statevector(self): + """Test the time-evolution logic by comparing against an exact statevector simulation. + + The reference value against which is being compared here can be obtained from: + + .. code-block:: python + + odd_coupling_layer = gen_odd_coupling_layer(L, dt * Jxx, dt * Jz, J) + even_coupling_layer = gen_even_coupling_layer(L, dt * Jxx, dt * Jz, J) + onsite_layer = gen_ext_field_layer(L, dt * hz) + layers = [ + odd_coupling_layer, + even_coupling_layer, + onsite_layer, + onsite_layer, + even_coupling_layer, + odd_coupling_layer, + ] + + trotter_circ = QuantumCircuit(L) + for layer in layers: + trotter_circ = trotter_circ.compose(layer) + trotter_circ = trotter_circ.repeat(N) + + init_circ = QuantumCircuit(L) + init_circ.x(1) + init_circ.x(3) + + full_circ = init_circ.copy() + full_circ = full_circ.compose(trotter_circ) + + init_state_vec = Statevector(init_circ) + full_state_vec = Statevector(full_circ) + reference = full_state_vec.inner(init_state_vec) + """ + + np.random.seed(0) + + L = 4 + W = 0.5 + epsilon = 0.5 + J = np.random.rand(L - 1) + W * np.ones(L - 1) + Jz = 1.0 + Jxx = epsilon + hz = 0.000000001 * np.array([(-1) ** i for i in range(L)]) + + N = 10 + dt = 0.05 + + odd_coupling_layer = gen_odd_coupling_layer(L, Jxx, Jz, J) + even_coupling_layer = gen_even_coupling_layer(L, Jxx, Jz, J) + ext_field_layer = gen_ext_field_layer(L, hz) + + model_opts = { + "bc_MPS": "finite", + "conserve": "Sz", + "sort_charge": False, + } + + layers = [ + LayerModel.from_quantum_circuit(odd_coupling_layer, **model_opts), + LayerModel.from_quantum_circuit(even_coupling_layer, **model_opts), + LayerModel.from_quantum_circuit(ext_field_layer, keep_only_odd=True, **model_opts), + LayerModel.from_quantum_circuit(ext_field_layer, keep_only_odd=False, **model_opts), + LayerModel.from_quantum_circuit(ext_field_layer, keep_only_odd=False, **model_opts), + LayerModel.from_quantum_circuit(ext_field_layer, keep_only_odd=True, **model_opts), + LayerModel.from_quantum_circuit(even_coupling_layer, **model_opts), + LayerModel.from_quantum_circuit(odd_coupling_layer, **model_opts), + ] + + trunc_options = { + "trunc_params": { + "chi_max": 100, + "svd_min": 1e-15, + "trunc_cut": None, + }, + "preserve_norm": False, + "order": 2, + } + + initial_state = MPS_neel_state(layers[0].lat) + mps_state = initial_state.copy() + mps_evo = LayerwiseEvolver(evolution_state=mps_state, layers=layers, options=trunc_options) + for _ in range(N): + mps_evo.run_evolution(1, dt) + + np.testing.assert_almost_equal( + mps_state.overlap(initial_state), -0.2607402383827852 - 0.6343830867298741j + )