Source code for mimiqcircuits.hamiltonian

#
# Copyright © 2022-2023 University of Strasbourg. All Rights Reserved.
# Copyright © 2023-2025 QPerfect. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

import mimiqcircuits as mc
import symengine as se
from mimiqcircuits.matrices import kronecker
from typing import Union
import numpy as np

[docs] class HamiltonianTerm: r"""Single term in a quantum Hamiltonian. A `HamiltonianTerm` represents a single tensor product of Pauli operators acting on a subset of qubits, scaled by a real-valued coefficient. .. math:: H_j = c_j \cdot P_j = c_j \cdot (P^{(1)} \otimes P^{(2)} \otimes \dots) where :math:`c_j` is a real coefficient and :math:`P_j` is a Pauli string such as ``X``, ``YZ``, etc. Args: coefficient (float): The scalar multiplier for this term. pauli (PauliString): The Pauli operator string (e.g., `"XY"`). qubits (tuple): The tuple of qubit indices this term acts on. Raises: ValueError: If any qubit index is negative. Examples: >>> from mimiqcircuits import * >>> HamiltonianTerm(0.5, PauliString("XZ"), (0, 1)) 0.5 * XZ @ q[0,1] """ def __init__( self, coefficient: Union[float, int], pauli: mc.PauliString, qubits: tuple ): invalid_qubits = [q for q in qubits if q < 0] if invalid_qubits: raise ValueError( f"Qubit indices must be ≥ 0. Invalid qubits: {invalid_qubits}" ) if len(set(qubits)) != len(qubits): raise ValueError(f"Duplicate qubits are not allowed: {qubits}") self.coefficient = coefficient self.pauli = pauli self.qubits = qubits
[docs] def get_operation(self): return self.pauli
[docs] def get_coefficient(self): return self.coefficient
[docs] def get_qubits(self): return self.qubits
def __repr__(self): qubit_str = ",".join(str(q) for q in self.qubits) return f"{self.coefficient} * {self.pauli} @ q[{qubit_str}]" def __str__(self): return self.__repr__()
[docs] class Hamiltonian: r"""A quantum Hamiltonian composed of multiple Pauli terms. This class models a Hamiltonian of the form: .. math:: H = \sum_j c_j \cdot P_j where each term is a `HamiltonianTerm` consisting of a coefficient and a Pauli string. Methods: - `push(...)`: Add a term by specifying its components. - `add_terms(...)`: Add a `HamiltonianTerm` object. - `num_qubits()`: Total number of qubits this Hamiltonian acts on. - `saveproto(...)`: Save to protobuf format. - `loadproto(...)`: Load from protobuf. Examples: >>> from mimiqcircuits import * >>> h = Hamiltonian() >>> h.push(1.0, PauliString("XX"), 0, 1) 2-qubit Hamiltonian with 1 terms: └── 1.0 * XX @ q[0,1] >>> h.push(1.0, PauliString("YY"), 0, 1) 2-qubit Hamiltonian with 2 terms: ├── 1.0 * XX @ q[0,1] └── 1.0 * YY @ q[0,1] >>> print(h) 2-qubit Hamiltonian with 2 terms: ├── 1.0 * XX @ q[0,1] └── 1.0 * YY @ q[0,1] """ def __init__(self, terms=None): if terms is None: terms = [] self.terms = list(terms)
[docs] def add_terms(self, term: HamiltonianTerm): self.terms.append(term) return self
[docs] def push(self, coefficient: float, pauli: mc.PauliString, *qubits: int): self.terms.append(HamiltonianTerm(coefficient, pauli, qubits)) return self
[docs] def num_terms(self): return len(self.terms)
[docs] def num_qubits(self): n = -1 for term in self.terms: if not term.qubits: continue m = max(term.qubits) if m > n: n = m return n + 1
def __iter__(self): return iter(self.terms) def __getitem__(self, idx): return self.terms[idx] def __len__(self): return len(self.terms) def __repr__(self): if not self.terms: return "empty Hamiltonian" out = f"{self.num_qubits()}-qubit Hamiltonian with {len(self.terms)} terms:\n" for i, term in enumerate(self.terms): prefix = "└── " if i == len(self.terms) - 1 else "├── " out += prefix + str(term) + "\n" return out.strip() def __str__(self): return self.__repr__()
[docs] def saveproto(self, file): from mimiqcircuits.proto.hamiltonianproto import toproto_hamiltonian data = toproto_hamiltonian(self).SerializeToString() if hasattr(file, "write"): return file.write(data) else: try: with open(file, "wb") as f: return f.write(data) except TypeError: raise ValueError( "Invalid file object. Should be a filename or a file-like object." ) except Exception as e: raise e
[docs] @staticmethod def loadproto(file): from mimiqcircuits.proto import hamiltonian_pb2 from mimiqcircuits.proto.hamiltonianproto import fromproto_hamiltonian if isinstance(file, str): with open(file, "rb") as f: proto = hamiltonian_pb2.Hamiltonian() proto.ParseFromString(f.read()) return fromproto_hamiltonian(proto) elif hasattr(file, "read"): proto = hamiltonian_pb2.Hamiltonian() proto.ParseFromString(file.read()) return fromproto_hamiltonian(proto) else: raise ValueError( "Invalid file object. Should be a filename or a file-like object." )
@staticmethod def _pauli_matrix(p: str): if p == "I": return mc.GateID().matrix() elif p == "X": return mc.GateX().matrix() elif p == "Y": return mc.GateY().matrix() elif p == "Z": return mc.GateZ().matrix() else: raise ValueError(f"Invalid Pauli character: {p}")
[docs] def matrix(self): """Return the symbolic matrix representation of the Hamiltonian using symengine.""" nq = self.num_qubits() dim = 2**nq H = se.zeros(dim, dim) for term in self.terms: coeff = term.get_coefficient() qubits = term.get_qubits() pauli_str = term.get_operation().pauli ps = np.argsort(qubits) qubits = [qubits[i] for i in ps] pstr = ''.join(pauli_str[i] for i in ps) i = 0 qidx = 0 if qubits[0] == 0: term_matrix = self._pauli_matrix(pstr[0]) qidx = 1 else: term_matrix = self._pauli_matrix("I") i += 1 while qidx < len(qubits): while i < qubits[qidx]: term_matrix = kronecker(term_matrix, self._pauli_matrix("I")) i += 1 term_matrix = kronecker(term_matrix, self._pauli_matrix(pstr[qidx])) i += 1 qidx += 1 while i < nq: term_matrix = kronecker(term_matrix, self._pauli_matrix("I")) i += 1 H += coeff * term_matrix return H
[docs] def evaluate(self, d: dict): """ Evaluate the symbolic coefficients of each term using the substitution dictionary `d`. Parameters: d (dict): Dictionary mapping symbols (e.g., {'theta': 0.5}) to values. Returns: Hamiltonian: A new Hamiltonian with evaluated (numerical or partially symbolic) coefficients. """ evaluated_terms = [] for term in self.terms: coeff = term.get_coefficient() if hasattr(coeff, "subs"): coeff = coeff.subs(d) evaluated_terms.append( HamiltonianTerm(coeff, term.get_operation(), term.get_qubits()) ) return Hamiltonian(evaluated_terms)
[docs] def push_expval(self, hamiltonian: Hamiltonian, *qubits: int, firstzvar=None): r"""Push an expectation value estimation circuit for a given Hamiltonian. This operation measures the expectation value of a Hamiltonian and stores the result in a Z-register, combining individual Pauli term evaluations. For each term :math:`c_j P_j`, the circuit performs: .. math:: \langle \psi | c_j P_j | \psi \rangle and sums the contributions in a new z-register. Args: hamiltonian (Hamiltonian): The Hamiltonian to evaluate. qubits (int): The qubit mapping to use. firstzvar (int, optional): Index of the first Z-register to use. Returns: Circuit: The modified circuit. Examples: >>> from mimiqcircuits import * >>> c = Circuit() >>> h = Hamiltonian() >>> h.push(1.0, PauliString("ZZ"), 0, 1) 2-qubit Hamiltonian with 1 terms: └── 1.0 * ZZ @ q[0,1] >>> c.push_expval(h, 1, 2) 3-qubit, 1-zvar circuit with 3 instructions: ├── ⟨ZZ⟩ @ q[1,2], z[0] ├── z[0] *= 1.0 └── z[0] += 0.0 <BLANKLINE> See Also: :class:`ExpectationValue`, :class:`Multiply`, :class:`Add` """ if len(qubits) != hamiltonian.num_qubits(): raise ValueError("Number of qubits does not match Hamiltonian.") if firstzvar is None: firstzvar = self.num_zvars() zvar = firstzvar for term in hamiltonian: self.push( mc.ExpectationValue(term.get_operation()), *[qubits[i] for i in term.get_qubits()], zvar, ) self.push(mc.Multiply(1, c=term.get_coefficient()), zvar) zvar += 1 self.push(mc.Add(zvar - firstzvar), *range(firstzvar, zvar)) return self
def _pauliexp(term: HamiltonianTerm, t, qubits: tuple): """Internal helper for Pauli exponentiation.""" p = term.get_operation() s = p.pauli param = term.get_coefficient() * t if s == "X": return mc.Instruction(mc.GateRX(param), qubits) elif s == "Y": return mc.Instruction(mc.GateRY(param), qubits) elif s == "Z": return mc.Instruction(mc.GateRZ(param), qubits) elif s == "XX": return mc.Instruction(mc.GateRXX(param), qubits) elif s == "YY": return mc.Instruction(mc.GateRYY(param), qubits) elif s == "ZZ": return mc.Instruction(mc.GateRZZ(param), qubits) elif s == "ZX": return mc.Instruction(mc.GateRZX(param), qubits) elif s == "XZ": return mc.Instruction(mc.GateRZX(param), (qubits[::-1])) else: return mc.Instruction(mc.RPauli(p, param), qubits)
[docs] def push_lietrotter(self, h: Hamiltonian, qubits: tuple, t: float, steps: int): r""" Apply a Lie-Trotter expansion of the Hamiltonian ``h`` to the circuit ``self`` for the qubits ``qubits`` over total time ``t`` with ``steps`` steps. The Lie-Trotter expansion is a first-order approximation of the time evolution operator for a Hamiltonian composed of non-commuting terms. It decomposes the exponential of a sum of operators into a product of exponentials: .. math:: e^{-i H t} \approx \left[ \prod_{j=1}^m e^{-i c_j P_j \Delta t} \right]^n where: - :math:`H = \sum_{j=1}^m c_j P_j` is the Hamiltonian - :math:`P_j` are Pauli strings - :math:`\Delta t = t / n` is the time step size - :math:`n` is the number of steps This method is particularly useful for simulating quantum systems and time-evolving quantum states in quantum algorithms such as VQE or QAOA. See Also: :func:`push_suzukitrotter`, :class:`GateDecl` Examples: >>> from mimiqcircuits import * >>> c = Circuit() >>> h = Hamiltonian() >>> h.push(1.0, PauliString("ZZ"), 0, 1) 2-qubit Hamiltonian with 1 terms: └── 1.0 * ZZ @ q[0,1] >>> c.push_lietrotter(h, (0, 1), t=1.0, steps=3) 2-qubit circuit with 3 instructions: ├── trotter(0.3333333333333333) @ q[0,1] ├── trotter(0.3333333333333333) @ q[0,1] └── trotter(0.3333333333333333) @ q[0,1] <BLANKLINE> """ if len(qubits) != h.num_qubits(): raise ValueError("Number of qubits does not match Hamiltonian.") tstep = t / steps Δt = se.Symbol("Δt") ch = mc.Circuit() for term in h: ch.push(_pauliexp(term, 2 * Δt, term.get_qubits())) decl = mc.GateDecl("trotter", (Δt,), ch) for _ in range(steps): self.push(decl(tstep), *qubits) return self
[docs] def push_suzukitrotter( self, h: Hamiltonian, qubits: tuple, t: float, steps: int, order: int = 2 ): # see e.g. [https://arxiv.org/pdf/quant-ph/0508139] # and [https://arxiv.org/abs/2211.02691] # and [https://arxiv.org/pdf/math-ph/0506007] r""" Apply Suzuki-Trotter expansion of the Hamiltonian ``h`` to the circuit ``self`` for the qubits ``qubits`` over time ``t`` with ``steps`` steps. The Suzuki-Trotter expansion approximates the time evolution operator of a quantum Hamiltonian using a sequence of exponentiated subterms. This is particularly useful for simulating quantum systems where the Hamiltonian is composed of non-commuting parts. The expansion performed is a ``2k``-th order expansion according to the Suzuki construction. The second-order expansion is given by: .. math:: e^{-i H t} \approx \left[ \prod_{j=1}^{m} e^{-i \frac{\Delta t}{2} H_j} \prod_{j=m}^{1} e^{-i \frac{\Delta t}{2} H_j} \right]^n where the Hamiltonian :math:`H` is a sum of terms: .. math:: H = \sum_{j=1}^{m} H_j and the Trotter step size is: .. math:: \Delta t = \frac{t}{n} Higher-order expansions follow the Suzuki recursion relation: .. math:: S_{2k}(\lambda) = [S_{2k-2}(p_k \lambda)]^2 \cdot S_{2k-2}((1 - 4p_k)\lambda) \cdot [S_{2k-2}(p_k \lambda)]^2 with: .. math:: p_k = \left(4 - 4^{1/(2k - 1)}\right)^{-1} See Also: :func:`push_lietrotter`, :class:`GateDecl` Examples: >>> from mimiqcircuits import * >>> c = Circuit() >>> h = Hamiltonian() >>> h.push(1.0, PauliString("XX"), 0, 1) 2-qubit Hamiltonian with 1 terms: └── 1.0 * XX @ q[0,1] >>> c.push_suzukitrotter(h, (0, 1), t=1.0, steps=5, order=2) 2-qubit circuit with 5 instructions: ├── suzukitrotter_2(0.2) @ q[0,1] ├── suzukitrotter_2(0.2) @ q[0,1] ├── suzukitrotter_2(0.2) @ q[0,1] ├── suzukitrotter_2(0.2) @ q[0,1] └── suzukitrotter_2(0.2) @ q[0,1] <BLANKLINE> """ if len(qubits) != h.num_qubits(): raise ValueError("Number of qubits does not match Hamiltonian.") if order < 2 or order % 2 != 0: raise ValueError( f"Suzuki-Trotter order must be an even integer ≥ 2. Got {order}" ) tstep = t / steps λ = se.Symbol("λ") # Build base expansion S2(λ) ch = mc.Circuit() for term in h: ch.push(_pauliexp(term, λ, term.get_qubits())) for term in reversed(h.terms): ch.push(_pauliexp(term, λ, term.get_qubits())) decls = [mc.GateDecl(f"suzukitrotter_2", (λ,), ch)] for k in range(2, order // 2 + 1): pk = 1 / (4 - 4 ** (1 / (2 * k - 1))) ck = mc.Circuit() ck.push(decls[-1](pk * λ), *qubits) ck.push(decls[-1](pk * λ), *qubits) ck.push(decls[-1]((1 - 4 * pk) * λ), *qubits) ck.push(decls[-1](pk * λ), *qubits) ck.push(decls[-1](pk * λ), *qubits) decls.append(mc.GateDecl(f"suzukitrotter_{2 * k}", (λ,), ck)) for _ in range(steps): self.push(decls[-1](tstep), *qubits) return self
[docs] def push_yoshidatrotter( self, h: Hamiltonian, qubits: tuple, t: float, steps: int, order: int = 4 ): # see e.g. [https://doi.org/10.1016/0375-9601(90)90092-3] for Yoshida's composition method # see e.g. [https://aiichironakano.github.io/phys516/Yoshida-symplectic-PLA00.pdf] r""" Apply Yoshida-Trotter expansion of the Hamiltonian ``h`` to the circuit ``self`` for the qubits ``qubits`` over time ``t`` with ``steps`` steps. The Yoshida-Trotter expansion approximates the time evolution operator of a quantum Hamiltonian using a symmetric composition of second-order Trotter formulas. This technique improves accuracy by canceling higher-order error terms in the Baker–Campbell–Hausdorff expansion. The Yoshida expansion performed is a ``2k``-th order expansion using the symmetric structure: .. math:: S_{2(k+1)}(t) = S_{2k}(w_1 \cdot t) \cdot S_{2k}(w_2 \cdot t) \cdot S_{2k}(w_1 \cdot t) where the weights are: .. math:: w_1 = \frac{1}{2 - 2^{1/(2k+1)}}, \quad w_2 = -\frac{2^{1/(2k+1)}}{2 - 2^{1/(2k+1)}} and the base case is the standard second-order Strang splitting: .. math:: S_2(\Delta t) = \prod_{j=1}^{m} e^{-i \Delta t H_j / 2} \prod_{j=m}^{1} e^{-i \Delta t H_j / 2} This method is particularly useful for simulating quantum systems where the Hamiltonian is composed of non-commuting parts, and is a computationally efficient alternative to full recursive Suzuki methods. See Also: :func:`push_suzukitrotter`, :class:`GateDecl` Args: h (Hamiltonian): The Hamiltonian object. qubits (tuple): Tuple of qubit indices. t (float): Total simulation time. steps (int): Number of Trotter steps to apply. order (int): Desired even expansion order (must be ≥ 2 and even). Returns: Circuit: The modified circuit. Examples: >>> from mimiqcircuits import * >>> c = Circuit() >>> h = Hamiltonian() >>> h.push(1.0, PauliString("XY"), 0, 1) 2-qubit Hamiltonian with 1 terms: └── 1.0 * XY @ q[0,1] >>> h.push(0.5, PauliString("Z"), 0) 2-qubit Hamiltonian with 2 terms: ├── 1.0 * XY @ q[0,1] └── 0.5 * Z @ q[0] >>> c.push_yoshidatrotter(h, (0, 1), t=1.0, steps=3, order=4) 2-qubit circuit with 3 instructions: ├── yoshida_4(0.3333333333333333) @ q[0,1] ├── yoshida_4(0.3333333333333333) @ q[0,1] └── yoshida_4(0.3333333333333333) @ q[0,1] <BLANKLINE> """ if len(qubits) != h.num_qubits(): raise ValueError("Number of qubits does not match Hamiltonian.") if order < 2 or order % 2 != 0: raise ValueError("Yoshida order must be an even integer ≥ 2.") λ = se.Symbol("λ") base = mc.Circuit() for term in h: base.push(_pauliexp(term, λ, term.get_qubits())) for term in reversed(h.terms): base.push(_pauliexp(term, λ, term.get_qubits())) decls = [mc.GateDecl(f"yoshida_2", (λ,), base)] for k in range(2, order // 2 + 1): alpha = 1 / (2 - 2 ** (1 / (2 * k - 1))) beta = -(2 ** (1 / (2 * k - 1))) / (2 - 2 ** (1 / (2 * k - 1))) ck = mc.Circuit() ck.push(decls[-1](alpha * λ), *qubits) ck.push(decls[-1](beta * λ), *qubits) ck.push(decls[-1](alpha * λ), *qubits) decls.append(mc.GateDecl(f"yoshida_{2 * k}", (λ,), ck)) tstep = t / steps for _ in range(steps): self.push(decls[-1](tstep), *qubits) return self
# Auto-register on Circuit mc.Circuit.push_suzukitrotter = push_suzukitrotter mc.Circuit.push_lietrotter = push_lietrotter mc.Circuit.push_expval = push_expval mc.Circuit.push_yoshidatrotter = push_yoshidatrotter __all__ = [ "HamiltonianTerm", "Hamiltonian", "push_expval", "push_lietrotter", "push_suzukitrotter", "push_yoshidatrotter", ]