Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
335239d
added first initial version to initialize a MPO from sum of operators…
thilomueller Apr 9, 2025
2b56979
improved function description
thilomueller Apr 9, 2025
7499c43
added option to truncate MPO at maximum bond dimension
thilomueller Apr 9, 2025
7abfddb
added test case for init_from_full_matrix
thilomueller Apr 9, 2025
731e186
added test case for init_from_sum_op
thilomueller Apr 9, 2025
6cffd1a
Merge remote-tracking branch 'origin/main' into MPO_generator
thilomueller Oct 15, 2025
1f2149e
implemented generic MPO construction following Hubig et al.
thilomueller Oct 16, 2025
445ddd3
removed redundant if-branch in bond-1 MPO helper function
thilomueller Oct 16, 2025
8caf79f
removed duplicate Hamiltonian matrix computation in init_from_terms t…
thilomueller Oct 16, 2025
1afc1aa
changed MPO product calculation to use tensordot and reshape instead …
thilomueller Oct 16, 2025
a1b7003
handle special case of single-site MPO in _mpo_sum helper function an…
thilomueller Oct 16, 2025
cc345de
🎨 pre-commit fixes
pre-commit-ci[bot] Oct 16, 2025
daee7c3
Merge branch 'munich-quantum-toolkit:main' into MPO_generator
thilomueller Oct 29, 2025
ccbc85a
added interface for sparse initialization of general MPO construction
thilomueller Oct 30, 2025
df6f8b3
added regex escape and type guards
thilomueller Oct 30, 2025
4c09173
Merge branch 'main' into MPO_generator
aaronleesander Nov 26, 2025
4bd7e62
Merge branch 'main' into MPO_generator
aaronleesander Jan 8, 2026
a4f8245
generic MPO construction using only Pauli strings
aaronleesander Jan 8, 2026
1976032
updated tests to match new ising and heisenberg forms
aaronleesander Jan 8, 2026
08a9aff
🎨 pre-commit fixes
pre-commit-ci[bot] Jan 8, 2026
21c1401
Merge branch 'main' into MPO_generator
aaronleesander Jan 9, 2026
e80ce0d
renamed Hamiltonian initialization methods
aaronleesander Jan 9, 2026
cee5ebb
renamed identity initialization method
aaronleesander Jan 9, 2026
f6528fc
renamed custom params, re-ordered methods for readability
aaronleesander Jan 9, 2026
2783a0f
🎨 pre-commit fixes
pre-commit-ci[bot] Jan 9, 2026
9869b6f
made ising and heisenberg classmethods, changed usage
aaronleesander Jan 9, 2026
edabf3b
Merge branch 'MPO_generator' of https://github.com/thilomueller/mqt-y…
aaronleesander Jan 9, 2026
50f6089
🎨 pre-commit fixes
pre-commit-ci[bot] Jan 9, 2026
72bab33
coupled transmon now changed to class method, usage changed
aaronleesander Jan 9, 2026
1350f7b
updated MPO usage
aaronleesander Jan 9, 2026
6e0ee7b
Merge branch 'MPO_generator' of https://github.com/thilomueller/mqt-y…
aaronleesander Jan 9, 2026
0a57dcf
🎨 pre-commit fixes
pre-commit-ci[bot] Jan 9, 2026
8a56571
ruff fixes and docstring updates
aaronleesander Jan 9, 2026
05f7098
mypy fixes
aaronleesander Jan 9, 2026
2788b7a
fixed missing variable assignment
aaronleesander Jan 9, 2026
05efc68
🎨 pre-commit fixes
pre-commit-ci[bot] Jan 9, 2026
b30d3a9
added check for OSError
aaronleesander Jan 9, 2026
ecd7cf9
added cpu count exception handling
aaronleesander Jan 9, 2026
067521d
updated changelog
aaronleesander Jan 9, 2026
3528220
updated example to include new Hamiltonian constructors
aaronleesander Jan 9, 2026
796d86e
fixed broken doc
aaronleesander Jan 9, 2026
a81565a
added new tests for coverage
aaronleesander Jan 12, 2026
0a24d72
Merge branch 'main' into MPO_generator
aaronleesander Jan 12, 2026
6689d0c
added more tests for coverage
aaronleesander Jan 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 281 additions & 0 deletions src/mqt/yaqs/core/data_structures/networks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,287 @@ def init_custom(self, tensors: list[NDArray[np.complex128]], *, transpose: bool
self.length = len(self.tensors)
self.physical_dimension = tensors[0].shape[0]

def init_from_terms(
self,
length: int,
terms: list[tuple[complex | float, list[str]]],
*,
physical_dimension: int = 2,
tol: float = 1e-12,
max_bond_dim: int | None = None,
n_sweeps: int = 2,
) -> None:
"""Generic MPO construction from a sum of Pauli strings.

Initializes the MPO representation of any arbitrary Hamiltonian that is a sum of Pauli strings.

Args:
terms: List of (coefficient, [op_0, op_1, ..., op_{L-1}]) where each op_i is one of {"I","X","Y","Z"}.
length: Number of sites.
physical_dimension: Physical dimension for each site. Defaults to qubit systems (dimension 2).
tol: SVD truncation threshold for compression.
max_bond_dim: Optional cap on virtual bond dimension.
n_sweeps: Number of left <-> right compression sweeps.

Notes:
This method builds each term as a bond-1 MPO from single-site blocks, then sums
them by block-diagonal stacking and performs local SVD compression. This follows
the approach introduced by Hubig et al.

Reference:
Hubig, C., McCulloch, I. P., and Schollwöck, U. (2017).
Generic construction of efficient matrix product operators. Phys. Rev. B, 95:035129.
"""
assert length >= 1
self.length = length
self.physical_dimension = physical_dimension

# Build a bond-1 MPO for each term
mpo_terms: list[list[np.ndarray]] = []
for coeff, labels in terms:
assert len(labels) == length, "Each term must specify an operator label for every site."
single = self._bond1_mpo_from_labels(labels, coeff, physical_dimension)
mpo_terms.append(single)

# Sum all term-MPOs together (block-diagonal on virtual bonds)
if not mpo_terms:
# Empty -> zero operator
self.tensors = [
np.zeros((physical_dimension, physical_dimension, 1, 1), dtype=complex) for _ in range(length)
]
else:
acc = mpo_terms[0]
for next_term in mpo_terms[1:]:
acc = self._mpo_sum(acc, next_term)
self.tensors = acc
Comment thread
aaronleesander marked this conversation as resolved.

# Local SVD compression sweeps
for _ in range(max(1, n_sweeps)):
self._compress_svd_sweep(direction="lr", tol=tol, max_bond_dim=max_bond_dim)
self._compress_svd_sweep(direction="rl", tol=tol, max_bond_dim=max_bond_dim)
Comment thread
aaronleesander marked this conversation as resolved.
Outdated

assert self.check_if_valid_mpo(), "MPO initialized wrong"

@staticmethod
def _label_to_op(label: str) -> np.ndarray:
"""Map a string label to the corresponding single-qubit Pauli operator.

This helper function converts a symbolic label representing the Pauli matrices
into its associated 2x2 complex NumPy array. It is used internally
when constructing MPOs from lists of operator labels.

Args:
label (str): Single-character operator label representing a Pauli matrix.

Returns:
np.ndarray: A 2x2 complex NumPy array representing the operator.

Raises:
ValueError: If the label is not one of 'X', 'Y', 'Z', or 'I'.
"""
if label == "I":
return np.eye(2, dtype=complex)
if label == "X":
return np.array([[0, 1], [1, 0]], dtype=complex)
if label == "Y":
return np.array([[0, -1j], [1j, 0]], dtype=complex)
if label == "Z":
return np.array([[1, 0], [0, -1]], dtype=complex)
msg = f"Unknown local operator label: {label!r}"
raise ValueError(msg)

Comment thread
thilomueller marked this conversation as resolved.
Outdated
def _bond1_mpo_from_labels(self, labels: list[str], coeff: complex, physical_dimension: int) -> list[np.ndarray]:
"""Create a bond-1 MPO for a single tensor-product operator term.

This helper function constructs the Matrix Product Operator (MPO) representation
of a single product term of local operators acting on each site,
with bond dimension 1 (i.e., a simple tensor product). It is used
as the basic building block in the (Hubig et al.) generic MPO construction.

Given a list of per-site operator labels, e.g. ["Z", "Z", "I", "I"] with coefficient J, the resulting MPO
represents the operator J * Z_1 ⊗ Z_2 ⊗ I_3 ⊗ I_4.

Each local tensor has shape (d, d, 1, 1), where d is the local
physical dimension (currently only d=2 is supported). The coefficient
is applied to the tensor of the first site to avoid overflow when summing many terms.

Args:
labels (list[str]): List of single-site operator labels of same length L. Each element must be one of
{"I", "X", "Y", "Z"} for qubits.
coeff (complex): Overall scalar coefficient multiplying the operator term.
physical_dimension (int): Local Hilbert space dimension. Currently must be 2.

Returns:
list[np.ndarray]: A list of L rank-4 tensors, each with shape (d, d, 1, 1), representing the bond-1 MPO.

Raises:
ValueError: If physical_dimension != 2 or an unsupported label is encountered.
"""
tensors: list[np.ndarray] = []
for label in labels:
if physical_dimension == 2:
op = self._label_to_op(label)
else:
msg = "Only operators with physical dimension = 2 are supported by _bond1_mpo_from_labels"
raise ValueError(msg)

tensor = np.zeros((physical_dimension, physical_dimension, 1, 1), dtype=complex)
tensor[:, :, 0, 0] = op
tensors.append(tensor)

# Apply the term coefficient on the first site to avoid overflow
tensors[0] = tensors[0].copy()
tensors[0][:, :, 0, 0] *= coeff
return tensors

@staticmethod
def _mpo_sum(A: list[np.ndarray], B: list[np.ndarray]) -> list[np.ndarray]: # noqa: N803
"""Block-diagonal sum of two MPOs of equal length and physical dimension.

This function implements the MPO addition rule described by Hubig et al.,
by forming the block-diagonal (direct-sum) combination of two MPO networks A
and B that act on the same number of sites with the same local physical dimension.

For each site k, the local tensor of the resulting MPO R is
constructed as:

- Left boundary (k = 0): concatenate along the right virtual bond: R[1] = (A[1] B[1])
This corresponds to horizontal stacking of the two boundary row vectors.

- Bulk sites (0 < k < L-1): create a block-diagonal matrix in the virtual bond space: R[k] = (A[k], 0; 0, B[k])
This increases both the left and right bond dimensions additively.

- Right boundary (k = L-1): concatenate along the left virtual bond: R[L] = (A[L]; B[L])
This corresponds to vertical stacking of the column vectors.

The physical legs remain unchanged. This operation exactly corresponds to
building the MPO for the sum of two operators, i.e. R = A + B

Args:
A (list[np.ndarray]): List of length L containing the MPO tensors for operator A.
B (list[np.ndarray]): List of length L containing the MPO tensors for operator B

Returns:
list[np.ndarray]: A list of length L containing the MPO tensors for the sum R = A + B.
"""
length = len(A)
assert length == len(B), "MPO lengths must match for sum."
# Special case: single-site MPO - just add the site tensors
if length == 1:
return [A[0] + B[0]]
out: list[np.ndarray] = []
for k in range(length):
a = A[k]
b = B[k]
phys_dim_a, phys_dim_b = a.shape[0], b.shape[0]
assert phys_dim_a == phys_dim_b, "Physical dimensions must match for MPO sum."
phys_dim = phys_dim_a
bond_dim_left_a, bond_dim_right_a = a.shape[2], a.shape[3]
bond_dim_left_b, bond_dim_right_b = b.shape[2], b.shape[3]

if k == 0:
# Left boundary: Concatenate along the right bond, i.e. R[0] = [ A[0] B[0] ]
c = np.zeros((phys_dim, phys_dim, 1, bond_dim_right_a + bond_dim_right_b), dtype=complex)
c[:, :, 0, :bond_dim_right_a] = a[:, :, 0, :]
c[:, :, 0, bond_dim_right_a:] = b[:, :, 0, :]
elif k == length - 1:
# Right boundary: Concatenate along the left bond, R[L] = [ A[L] ; B[L] ]
c = np.zeros((phys_dim, phys_dim, bond_dim_left_a + bond_dim_left_b, 1), dtype=complex)
c[:, :, :bond_dim_left_a, 0] = a[:, :, :, 0]
c[:, :, bond_dim_left_a:, 0] = b[:, :, :, 0]
else:
# Bulk sites: Create a block-diagonal matrix R = [[A 0]; [0, B]]
c = np.zeros(
(phys_dim, phys_dim, bond_dim_left_a + bond_dim_left_b, bond_dim_right_a + bond_dim_right_b),
dtype=complex,
)
c[:, :, :bond_dim_left_a, :bond_dim_right_a] = a
c[:, :, bond_dim_left_a:, bond_dim_right_a:] = b
out.append(c)
return out

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
@staticmethod
def _mpo_product(A: list[np.ndarray], B: list[np.ndarray]) -> list[np.ndarray]: # noqa: N803
"""Sitewise product R = A @ B while merging virtual bonds..

Args:
A (list[np.ndarray]): List of length L containing the MPO tensors for operator A.
B (list[np.ndarray]): List of length L containing the MPO tensors for operator B

Returns:
list[np.ndarray]: A list of length L containing the MPO tensors for the product R = A @ B.
"""
length = len(A)
out: list[np.ndarray] = []
for k in range(length):
a = A[k] # (sa, ta, la, ra)
b = B[k] # (tb, ub, lb, rb)
# Contract over A.t with B.s (tb)
theta = np.tensordot(a, b, axes=([1], [0])) # (sa, la, ra, ub, lb, rb)
theta = np.transpose(theta, (0, 3, 1, 4, 2, 5)) # (sa, ub, la, lb, ra, rb)
sa, ub, la, lb, ra, rb = theta.shape
c = theta.reshape(sa, ub, la * lb, ra * rb)
out.append(c)
return out

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
def _compress_svd_sweep(self, *, direction: str, tol: float, max_bond_dim: int | None) -> None:
"""Perform one local-SVD compression sweep (left->right or right->left).

This function reduces virtual bond dimensions by applying the SVD to each
neighboring tensor pair along the chain, truncating small singular values,
and redistributing factors back into the left/right site tensors.

Args:
direction (str): Sweep direction, either "lr" (left->right) or "rl" (right->left).
tol (float): Truncation threshold. Singular values S_i with S_i <= tol are discarded.
max_bond_dim (int | None): Optional hard cap on the kept rank after SVD.
If None, no explicit cap is applied.
"""
assert direction in {"lr", "rl"}
length = len(self.tensors)
rng = range(length - 1) if direction == "lr" else range(length - 2, -1, -1)

for k in rng:
# Shape of matrix A: (s,t,l,r)
# Shape of matrix B: (u,v,l',r') with l'==r
A = self.tensors[k] # noqa: N806
B = self.tensors[k + 1] # noqa: N806

phys_dim = A.shape[0]
bond_dim_left = A.shape[2]
bond_dim_right = B.shape[3]

# Contract on the shared virtual bond (A.r with B.l)
# result shape: (s,t,u,v,l,w)
theta = oe.contract("stlr,uvrw->stuvlw", A, B)

# Permute to make left group be next to each other: (l,s,t | u,v,w)
theta = np.transpose(theta, (4, 0, 1, 2, 3, 5))

# Reshape to matrix
matrix = theta.reshape(bond_dim_left * phys_dim * phys_dim, phys_dim * phys_dim * bond_dim_right)

# Apply SVD + truncation
U, S, Vh = np.linalg.svd(matrix, full_matrices=False) # noqa: N806
keep = int(np.sum(tol < S))
if max_bond_dim is not None:
keep = min(keep, max_bond_dim)
keep = max(1, keep) # keep at least one

U = U[:, :keep] # noqa: N806 # (bond_dim_L phys_dim^2) x bond_dim_trim
S = S[:keep] # noqa: N806 # bond_dim_trim
Vh = Vh[:keep, :] # noqa: N806 # bond_dim_trim x (phys_dim^2 bond_dim_R)

# Rebuild left tensor
UL = U.reshape(bond_dim_left, phys_dim, phys_dim, keep).transpose(1, 2, 0, 3) # noqa: N806

# Rebuild right tensor
SVh = (S[:, None] * Vh).reshape(keep, phys_dim, phys_dim, bond_dim_right) # noqa: N806
VR = SVh.transpose(1, 2, 0, 3) # noqa: N806

self.tensors[k] = UL
self.tensors[k + 1] = VR

Comment thread
thilomueller marked this conversation as resolved.
Outdated
def to_mps(self) -> MPS:
"""MPO to MPS conversion.

Expand Down
61 changes: 61 additions & 0 deletions tests/core/data_structures/test_networks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from __future__ import annotations

import copy
from functools import reduce
from typing import TYPE_CHECKING

import numpy as np
Expand Down Expand Up @@ -287,6 +288,66 @@ def test_init_custom() -> None:
assert np.allclose(original, created)


def test_init_from_terms_sum_of_pauli_strings() -> None:
"""Test that init_from_terms correctly sets up an MPO from a sum of Pauli strings.

This test generates a Hamiltonian from a list of sum operators, initializes an MPO from
that list, and verifies that the reconstructed matrix matches the original one.
"""
H_terms = [
(1.0 + 0j, ["Z", "Z", "I", "I"]),
(0.5 + 0j, ["X", "I", "X", "I"]),
(-0.2 + 0j, ["I", "Y", "Y", "I"]),
]
L = len(H_terms[0][1])

mpo = MPO()
mpo.init_from_terms(length=L, terms=H_terms, physical_dimension=2, tol=1e-10, max_bond_dim=None, n_sweeps=2)
H_matrix = mpo.to_matrix()

# Static tests
assert mpo.length == L
assert mpo.physical_dimension == 2
assert len(mpo.tensors) == L

# Validate on small N by comparing to reconstructed matrix
PAULI_OPS = {
"I": np.array([[1, 0], [0, 1]], dtype=complex),
"X": np.array([[0, 1], [1, 0]], dtype=complex),
"Y": np.array([[0, -1j], [1j, 0]], dtype=complex),
"Z": np.array([[1, 0], [0, -1]], dtype=complex),
}

H_ref = np.zeros_like(H_matrix)
for coeff, s in H_terms:
matrices = [PAULI_OPS[ch] for ch in s]
H_ref += coeff * reduce(np.kron, matrices)

assert np.allclose(H_matrix, H_ref)


def test_init_from_terms_single_site() -> None:
"""Test that init_from_terms correctly sets up an MPO from a single Pauli matrix.

This test generates MPO from a single Pauli matrix and verifies that the reconstructed
matrix matches exactly the input.
"""
H_terms = [(2.0 + 0j, ["Z"])]
L = 1

mpo = MPO()
mpo.init_from_terms(length=L, terms=H_terms, physical_dimension=2, tol=1e-10, max_bond_dim=None, n_sweeps=2)
H_matrix = mpo.to_matrix()

# Static tests
assert mpo.length == L
assert mpo.physical_dimension == 2
assert len(mpo.tensors) == L

Z = np.array([[1, 0], [0, -1]], dtype=complex)
assert np.allclose(H_matrix, 2 * Z)


def test_to_mps() -> None:
"""Test converting an MPO to an MPS.

Expand Down
Loading