Skip to content

Commit b69bbbb

Browse files
Fix bogoliubov_transform for spin-preserving Gaussian transformations (#1360)
Fixes #776. `bogoliubov_transform` checks whether the transformation matrix is spin-block-diagonal before it ever looks at the matrix shape, and that check only makes sense for a square N x N matrix. For the N x 2N matrix you get out of a general (non-particle-conserving) Bogoliubov transformation it ends up inspecting the wrong blocks, decides that a transform which doesn't actually mix spin is "block diagonal", and then slices it into pieces of the wrong shape. The matrix from the issue dies with: ``` ValueError: Bad shape for transformation_matrix. Expected (1, 1) or (1, 2) but got (1, 3). ``` ### What I changed I moved the block detection into a helper, `_spin_blocks`, that handles both shapes. For the N x 2N case, write the matrix as `W = (W1 W2)` (it acts on all the creation operators followed by all the annihilation operators). The transform leaves the two spin sectors alone exactly when both `W1` and `W2` are block diagonal, and in that case each sector is its own (N/2) x N Gaussian transform built from the matching diagonal blocks of `W1` and `W2`. The recursion that was already there handles the rest. As a bonus, for spin-symmetric systems (BCS-style pairing Hamiltonians, say) you now do two half-size Givens decompositions instead of one full-size one. ### The part that took some digging I didn't want to call this correct based on eigenstate energies alone, so I checked the circuit at the operator level: does it actually give `U a_p† U⁻¹ = b_p†` for every mode? That caught something the energy checks can't. Under Jordan-Wigner a spin-down ladder operator drags a Z string across all of the spin-up qubits, so it anticommutes with the spin-up parity operator. If the spin-up circuit flips spin-up parity (which happens whenever its Gaussian decomposition uses an odd number of particle-hole transforms), then splitting the sectors apart flips the sign of every spin-down operator. What makes it sneaky is that it's invisible to energy tests: starting from a fixed occupation state, that bad sign is just a global phase, so the state still comes out as the correct eigenstate with the correct energy even though the underlying unitary is wrong. So when the spin-up circuit flips parity I add a layer of Z gates on the spin-down qubits to put the signs back (`_preserves_parity` decides whether that's needed). With a fixed computational-basis initial state it's only a global phase, so I skip it there. I also ran into the general Gaussian decomposition not reliably diagonalizing exactly-block-structured matrices (`U† H U` comes out non-diagonal for some perfectly valid block inputs), so I went with always splitting these instead of trying to detect when it's safe to fall back. Splitting is the more correct path here, not just the faster one. ### Tests - `test_bogoliubov_transform_spin_block_gaussian_regression`: the actual matrix from #776, normalized so it satisfies `W1 W1† + W2 W2† = I`. This used to raise ValueError. - `test_bogoliubov_transform_spin_block_operator_algebra`: checks `U a_p† U⁻¹ = b_p†` on the full unitary, for both a parity-preserving and a parity-flipping spin-up sector. This is the one that fails without the sign fix. - `test_bogoliubov_transform_spin_block_gaussian`: eigenstate energies through the state-prep path. - `test_bogoliubov_transform_spin_mixing_gaussian_not_split`: a transform that genuinely mixes spin should still go through the general path rather than getting split. Outside the committed tests I ran a wider sweep (k = 2, 3, 4, real and complex, a range of seeds) cross-checking three things that each catch a sign error on their own: the operator algebra, whether `U† H U` is diagonal, and the eigenstate energies. Everything passes, including 27 of the parity-flipping cases. The full `circuits/` suite passes and black/pylint are clean.
1 parent e6af378 commit b69bbbb

2 files changed

Lines changed: 250 additions & 6 deletions

File tree

src/openfermion/circuits/primitives/bogoliubov_transform.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,14 @@ def bogoliubov_transform(
9292
initial_state = _occupied_orbitals(initial_state, n_qubits)
9393
initially_occupied_orbitals = cast(Optional[Sequence[int]], initial_state)
9494

95-
# If the transformation matrix is block diagonal with two blocks,
96-
# do each block separately
97-
if _is_spin_block_diagonal(transformation_matrix):
98-
up_block = transformation_matrix[: n_qubits // 2, : n_qubits // 2]
95+
# If the transformation matrix does not mix the two spin sectors, do each
96+
# sector separately. This is both faster (two half-size decompositions) and
97+
# more robust: the general fermionic Gaussian decomposition is numerically
98+
# unreliable on exactly block-structured matrices. See issue #776.
99+
spin_blocks = _spin_blocks(transformation_matrix)
100+
if spin_blocks is not None:
101+
up_block, down_block = spin_blocks
99102
up_qubits = qubits[: n_qubits // 2]
100-
101-
down_block = transformation_matrix[n_qubits // 2 :, n_qubits // 2 :]
102103
down_qubits = qubits[n_qubits // 2 :]
103104

104105
if initially_occupied_orbitals is None:
@@ -110,6 +111,17 @@ def bogoliubov_transform(
110111
i - n_qubits // 2 for i in initially_occupied_orbitals if i >= n_qubits // 2
111112
]
112113

114+
# Spin-down ladder operators carry a Jordan-Wigner Z string over all
115+
# spin-up qubits, so they anticommute with the spin-up parity operator.
116+
# If the spin-up circuit flips spin-up parity (an odd number of
117+
# particle-hole transformations) the factorized circuit would realize
118+
# the spin-down operators with a flipped sign. A layer of Z gates on the
119+
# spin-down qubits restores the documented action U a_p^dagger U^-1 =
120+
# b_p^dagger. When an initial computational basis state is fixed this
121+
# only contributes an irrelevant global phase, so it is skipped there.
122+
if initially_occupied_orbitals is None and not _preserves_parity(up_block):
123+
yield (cirq.Z(q) for q in down_qubits)
124+
113125
yield bogoliubov_transform(up_qubits, up_block, initial_state=up_orbitals)
114126
yield bogoliubov_transform(down_qubits, down_block, initial_state=down_orbitals)
115127
return
@@ -122,6 +134,38 @@ def bogoliubov_transform(
122134
yield _gaussian_basis_change(qubits, transformation_matrix, initially_occupied_orbitals)
123135

124136

137+
def _spin_blocks(matrix: numpy.ndarray) -> Optional[Tuple[numpy.ndarray, numpy.ndarray]]:
138+
r"""Split a transformation matrix into independent spin-sector transforms.
139+
140+
Returns a pair of transformation matrices (spin-up block, spin-down block)
141+
if the transformation does not mix the two spin sectors, assuming modes are
142+
ordered with all spin-up modes before all spin-down modes. Returns None if
143+
the transformation mixes the sectors.
144+
145+
For an $N \times N$ matrix the blocks are the diagonal blocks. An
146+
$N \times 2N$ matrix $W = (W_1 \; W_2)$ acts on the column vector of all
147+
creation operators followed by all annihilation operators, so it preserves
148+
the spin sectors if and only if both $W_1$ and $W_2$ are block diagonal;
149+
the sector transforms are then $(N/2) \times N$ matrices formed from the
150+
corresponding diagonal blocks of $W_1$ and $W_2$.
151+
"""
152+
n, m = matrix.shape
153+
if n % 2:
154+
return None
155+
k = n // 2
156+
if m == n:
157+
if _is_spin_block_diagonal(matrix):
158+
return matrix[:k, :k], matrix[k:, k:]
159+
return None
160+
left_block = matrix[:, :n]
161+
right_block = matrix[:, n:]
162+
if _is_spin_block_diagonal(left_block) and _is_spin_block_diagonal(right_block):
163+
up_block = numpy.concatenate([left_block[:k, :k], right_block[:k, :k]], axis=1)
164+
down_block = numpy.concatenate([left_block[k:, k:], right_block[k:, k:]], axis=1)
165+
return up_block, down_block
166+
return None
167+
168+
125169
def _is_spin_block_diagonal(matrix) -> bool:
126170
n = matrix.shape[0]
127171
if n % 2:
@@ -131,6 +175,30 @@ def _is_spin_block_diagonal(matrix) -> bool:
131175
return numpy.isclose(max_upper_right, 0.0) and numpy.isclose(max_lower_left, 0.0)
132176

133177

178+
def _preserves_parity(transformation_matrix: numpy.ndarray) -> bool:
179+
r"""Whether the Bogoliubov circuit for this block preserves fermion parity.
180+
181+
A particle-number-conserving (square) transformation uses only Givens
182+
rotations and always preserves parity. A general $N \times 2N$ Gaussian
183+
transformation preserves parity if and only if the orthogonal matrix that
184+
implements it on the Majorana operators lies in the identity component of
185+
O(2N), i.e. has determinant $+1$; the other component requires an odd
186+
number of particle-hole transformations, each an X gate that flips the
187+
parity. That determinant equals the determinant of the unitary
188+
Bogoliubov-de Gennes extension of $W = (W_1 \; W_2)$, namely
189+
$\begin{pmatrix} W_1 & W_2 \\ W_2^* & W_1^* \end{pmatrix}$, which is real and
190+
equal to $\pm 1$, so the parity is read off directly without running the
191+
full fermionic Gaussian decomposition.
192+
"""
193+
n, m = transformation_matrix.shape
194+
if m == n:
195+
return True
196+
w1 = transformation_matrix[:, :n]
197+
w2 = transformation_matrix[:, n:]
198+
bogoliubov_de_gennes = numpy.block([[w1, w2], [numpy.conjugate(w2), numpy.conjugate(w1)]])
199+
return numpy.isclose(numpy.linalg.det(bogoliubov_de_gennes), 1.0)
200+
201+
134202
def _occupied_orbitals(computational_basis_state: int, n_qubits) -> List[int]:
135203
"""Indices of ones in the binary expansion of an integer in big endian
136204
order. e.g. 010110 -> [1, 3, 4]"""

src/openfermion/circuits/primitives/bogoliubov_transform_test.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,182 @@ def test_spin_symmetric_bogoliubov_transform(
116116
numpy.testing.assert_allclose(quad_ham_sparse.dot(state), energy * state, atol=atol)
117117

118118

119+
def _independent_spin_sector_transform(n_spatial_orbitals, seed_up, seed_down, real):
120+
"""Assemble an N x 2N Bogoliubov transformation that does not mix spin.
121+
122+
Builds two independent non-particle-conserving quadratic Hamiltonians, one
123+
per spin sector, and places their diagonalizing transforms into a combined
124+
matrix. The matrix acts on the column vector of all creation operators
125+
followed by all annihilation operators, so each sector's creation
126+
(annihilation) block lands in the left (right) half, offset by the sector's
127+
position within that half.
128+
"""
129+
quad_ham_up = random_quadratic_hamiltonian(
130+
n_spatial_orbitals, conserves_particle_number=False, real=real, seed=seed_up
131+
)
132+
quad_ham_down = random_quadratic_hamiltonian(
133+
n_spatial_orbitals, conserves_particle_number=False, real=real, seed=seed_down
134+
)
135+
up_energies, up_matrix, up_constant = quad_ham_up.diagonalizing_bogoliubov_transform()
136+
down_energies, down_matrix, down_constant = quad_ham_down.diagonalizing_bogoliubov_transform()
137+
138+
n = n_spatial_orbitals
139+
n_qubits = 2 * n
140+
transformation_matrix = numpy.zeros((n_qubits, 2 * n_qubits), dtype=complex)
141+
transformation_matrix[:n, :n] = up_matrix[:, :n]
142+
transformation_matrix[:n, n_qubits : n_qubits + n] = up_matrix[:, n:]
143+
transformation_matrix[n:, n:n_qubits] = down_matrix[:, :n]
144+
transformation_matrix[n:, n_qubits + n :] = down_matrix[:, n:]
145+
return (
146+
transformation_matrix,
147+
(quad_ham_up, up_energies, up_constant),
148+
(quad_ham_down, down_energies, down_constant),
149+
)
150+
151+
152+
def test_bogoliubov_transform_spin_block_gaussian_regression():
153+
"""Regression test for issue #776.
154+
155+
A non-square transformation matrix that does not mix spin sectors used to
156+
be misidentified as a square spin-block-diagonal matrix, leading to bogus
157+
slicing and a ValueError when constructing the circuit.
158+
"""
159+
qubits = LineQubit.range(2)
160+
phase = -9.57167901e-01 - 2.89533435e-01j
161+
phase /= abs(phase)
162+
transformation_matrix = numpy.array([[phase, 0, 0, 0], [0, 1, 0, 0]], dtype=complex)
163+
164+
circuit = cirq.Circuit(
165+
bogoliubov_transform(qubits, transformation_matrix), cirq.I.on_each(*qubits)
166+
)
167+
168+
# The transformation is a phase rotation of mode 0, so it should leave
169+
# computational basis states invariant up to phase.
170+
state = circuit.final_state_vector(initial_state=0, qubit_order=qubits, dtype=numpy.complex128)
171+
expected_state = numpy.zeros(4, dtype=numpy.complex128)
172+
expected_state[0] = 1
173+
cirq.testing.assert_allclose_up_to_global_phase(state, expected_state, atol=1e-6)
174+
175+
176+
@pytest.mark.parametrize(
177+
'n_spatial_orbitals, real, up_orbitals, down_orbitals',
178+
[(2, True, [0], [0, 1]), (2, False, [1], []), (3, True, [0, 2], [1]), (3, False, [], [0, 2])],
179+
)
180+
def test_bogoliubov_transform_spin_block_gaussian(
181+
n_spatial_orbitals, real, up_orbitals, down_orbitals, atol=5e-5
182+
):
183+
"""A non-particle-conserving transform that does not mix spin sectors must
184+
prepare eigenstates of the combined Hamiltonian with the correct energy."""
185+
n_qubits = 2 * n_spatial_orbitals
186+
qubits = LineQubit.range(n_qubits)
187+
sim = cirq.Simulator(dtype=numpy.complex128)
188+
189+
transformation_matrix, up_data, down_data = _independent_spin_sector_transform(
190+
n_spatial_orbitals, 51624, 48201, real
191+
)
192+
quad_ham_up, up_energies, up_constant = up_data
193+
quad_ham_down, down_energies, down_constant = down_data
194+
195+
# Combined Hamiltonian with spin-down modes shifted to come after spin-up
196+
ferm_op = openfermion.get_fermion_operator(quad_ham_up)
197+
for term, coefficient in openfermion.get_fermion_operator(quad_ham_down).terms.items():
198+
shifted_term = tuple((index + n_spatial_orbitals, action) for index, action in term)
199+
ferm_op += openfermion.FermionOperator(shifted_term, coefficient)
200+
combined_ham_sparse = get_sparse_operator(ferm_op, n_qubits=n_qubits)
201+
202+
energy = (
203+
sum(up_energies[i] for i in up_orbitals)
204+
+ sum(down_energies[i] for i in down_orbitals)
205+
+ up_constant
206+
+ down_constant
207+
)
208+
occupied_orbitals = list(up_orbitals) + [i + n_spatial_orbitals for i in down_orbitals]
209+
initial_state = sum(2 ** (n_qubits - 1 - int(i)) for i in occupied_orbitals)
210+
211+
circuit = cirq.Circuit(
212+
bogoliubov_transform(qubits, transformation_matrix, initial_state=initial_state)
213+
)
214+
state = sim.simulate(
215+
circuit, initial_state=initial_state, qubit_order=qubits
216+
).final_state_vector
217+
218+
numpy.testing.assert_allclose(combined_ham_sparse.dot(state), energy * state, atol=atol)
219+
220+
221+
@pytest.mark.parametrize(
222+
'n_spatial_orbitals, seed_up, seed_down, real',
223+
[
224+
# The (2, 1000, ...) case has a parity-preserving spin-up sector while
225+
# the (2, 1001, ...) case has a parity-flipping one; the latter is the
226+
# regression guard for the spin-down Jordan-Wigner sign correction.
227+
(2, 1000, 2000, True),
228+
(2, 1001, 2001, True),
229+
(3, 1000, 2000, False),
230+
(3, 1001, 2001, True),
231+
],
232+
)
233+
def test_bogoliubov_transform_spin_block_operator_algebra(
234+
n_spatial_orbitals, seed_up, seed_down, real, atol=1e-6
235+
):
236+
"""The factorized circuit must implement the documented transformation
237+
exactly: U a_p^dagger U^-1 = b_p^dagger for every mode p, including the
238+
cross-sector Jordan-Wigner sign carried by spin-down operators. Eigenstate
239+
energies cannot detect a wrong sign on b_p^dagger, so this checks the full
240+
operator algebra of the circuit's unitary directly.
241+
"""
242+
n_qubits = 2 * n_spatial_orbitals
243+
qubits = LineQubit.range(n_qubits)
244+
transformation_matrix, _, _ = _independent_spin_sector_transform(
245+
n_spatial_orbitals, seed_up, seed_down, real
246+
)
247+
248+
circuit = cirq.Circuit(
249+
bogoliubov_transform(qubits, transformation_matrix), cirq.I.on_each(*qubits)
250+
)
251+
unitary = circuit.unitary(qubit_order=qubits)
252+
253+
def ladder(mode, action):
254+
return get_sparse_operator(
255+
openfermion.FermionOperator(((mode, action),)), n_qubits=n_qubits
256+
).toarray()
257+
258+
for p in range(n_qubits):
259+
transformed = unitary @ ladder(p, 1) @ unitary.conj().T
260+
expected = numpy.zeros_like(transformed)
261+
for q in range(n_qubits):
262+
expected += transformation_matrix[p, q] * ladder(q, 1)
263+
expected += transformation_matrix[p, n_qubits + q] * ladder(q, 0)
264+
numpy.testing.assert_allclose(transformed, expected, atol=atol)
265+
266+
267+
def test_bogoliubov_transform_spin_mixing_gaussian_not_split(atol=5e-5):
268+
"""A transform whose annihilation block mixes spin sectors must use the
269+
general procedure even if its creation block is spin-block-diagonal."""
270+
n_qubits = 4
271+
qubits = LineQubit.range(n_qubits)
272+
sim = cirq.Simulator(dtype=numpy.complex128)
273+
274+
# A pairing Hamiltonian whose antisymmetric part couples the two sectors
275+
quad_ham = random_quadratic_hamiltonian(
276+
n_qubits, conserves_particle_number=False, real=True, seed=63003
277+
)
278+
quad_ham_sparse = get_sparse_operator(quad_ham)
279+
energies, transformation_matrix, constant = quad_ham.diagonalizing_bogoliubov_transform()
280+
281+
occupied_orbitals = [0, 2]
282+
energy = sum(energies[i] for i in occupied_orbitals) + constant
283+
initial_state = sum(2 ** (n_qubits - 1 - int(i)) for i in occupied_orbitals)
284+
285+
circuit = cirq.Circuit(
286+
bogoliubov_transform(qubits, transformation_matrix, initial_state=initial_state)
287+
)
288+
state = sim.simulate(
289+
circuit, initial_state=initial_state, qubit_order=qubits
290+
).final_state_vector
291+
292+
numpy.testing.assert_allclose(quad_ham_sparse.dot(state), energy * state, atol=atol)
293+
294+
119295
@pytest.mark.parametrize(
120296
'n_qubits, conserves_particle_number', [(4, True), (4, False), (5, True), (5, False)]
121297
)

0 commit comments

Comments
 (0)