QSVT with non-symmetric phase factors (real polynomial- no LCU)

Hi, I have been following the two demos on implementing QSVT in Pennylane: QSVT in practice and How to implement QSVT on hardware using angles calculated using the `laurent` method in PyQSP. As I understand it (and as I will later demonstrate), this should produce a target polynomial that is purely real and thus should not require to layer in LCU with two controlled QSVT circuits to extract the real part as is the case in these two demos. It should only require extracting the upper-left block of the matrix representation of the bare QSVT circuit. However, when I compute this block as in the code example below, I cannot get it to match the exact polynomial transformed operator. If someone could point me to where I am going wrong, it would be greatly appreciated. I am not super familiar with Pennylane, so I may be misunderstanding some code conventions such as the wiring order.

from pyqsp.angle_sequence import QuantumSignalProcessingPhases
from pyqsp import angle_sequence, response
from pyqsp.poly import (polynomial_generators, PolyTaylorSeries, PolyOneOverX, PolyCosineTX, PolySineTX)
import numpy as np
from scipy.linalg import sinm, cosm

import pennylane as qp
import matplotlib.pyplot as plt


#### Define polynomial, calculate phase factors, and plot polynomial as sanity check #######

poly = PolyCosineTX().generate(tau=3, epsilon=10**-4, ensure_bounded=False)
target_func = lambda x: np.cos(3*x)

ang_seq = angle_sequence.QuantumSignalProcessingPhases(
        poly,
        method='laurent',
        chebyshev_basis=False,
        signal_operator="Wx")

# Convert for QSVT
angles_pyqsp = qp.transform_angles(ang_seq, "QSP", "QSVT")

# Verify that imaginary part of polynomial is actually zero.
response.PlotQSPResponse(
    ang_seq,
    pcoefs=poly,
    target=target_func,
    sym_qsp=False,
    simul_error_plot=False)

d = len(ang_seq) - 1
print(f'Number of phase factors: {len(ang_seq)}')
print(f'QSP degree d = {len(ang_seq) - 1}')
print(f'QSP angles: {ang_seq}')
print(f'QSVT angles: {angles_pyqsp}')

reflection_angles = []
reflection_angles.append(ang_seq[0] + (2*d - 1)*np.pi/4)
for _ in range(1,d):
    reflection_angles.append(ang_seq[_] - np.pi/2)
reflection_angles.append(ang_seq[d] - np.pi/4)
print(f'Reflection angles calculated explicitly from Wx convention QSP angles:')
print(reflection_angles)

######## Explicitly verify that the <+| * |+> block of the QSP matrix is correct ##########

def Wx(x):
    s = np.sqrt(1 - x**2)
    return np.array([
        [x, 1j * s],
        [1j * s, x]
    ], dtype=complex)

def Rz(phi):
    return np.array([
        [np.exp(1j * phi), 0],
        [0, np.exp(-1j * phi)]
    ], dtype=complex)

def qsp_matrix(x, phases):
    U = Rz(phases[0])

    Had = (1/np.sqrt(2)) * np.array([[1,1],
                                     [1,-1]])

    for phi in phases[1:]:
        #U = U @ Wx(x) @ Rz(phi)
        U = Rz(phi) @ Wx(x) @ U

    U = Had @ U @ Had
    return U

real_vals = []
imag_vals = []
points = np.linspace(start=-1, stop=1, num=200)
for point in points:

    mat_elem = qsp_matrix(point, ang_seq)[0,0]
    real_vals.append(np.real(mat_elem))
    imag_vals.append(np.imag(mat_elem))

plt.plot(points, real_vals, label='Re[<+|U_qsp|+>]')
plt.plot(points, imag_vals, label='Im[<+|U_qsp|+>]')
plt.legend()
plt.show()


########## Generate Hamiltonian to be block-encoded ##############

coeffs = np.array([0.2, -0.7, -0.6])
coeffs /= np.linalg.norm(coeffs, ord=1)  # Normalize the coefficients

obs = [qp.X(2), qp.X(2) @ qp.Z(3), qp.Z(2) @ qp.Y(3)]

H = qp.dot(coeffs, obs)

H_mat = qp.matrix(H, wire_order=[2, 3])

############ Generate circuit and check output with exact Cos(3*H) #########

control_wires = [0,1]
block_encode = qp.PrepSelPrep(H, control=control_wires)

projectors = [
    qp.PCPhase(angles_pyqsp[i], dim=2 ** len(H.wires), wires=control_wires + H.wires)
    for i in range(len(angles_pyqsp))
]

#projectors = [
#    qp.PCPhase(reflection_angles[i], dim=2 ** len(H.wires), wires=control_wires + H.wires)
#    for i in range(len(reflection_angles))
#]


dev = qp.device("default.qubit")
@qp.qnode(dev)
def circuit():

    qp.QSVT(block_encode, projectors)

    return qp.state()


matrix = qp.matrix(circuit, wire_order=control_wires + H.wires)()
sub_matrix = np.round(matrix[: 2 ** len(H.wires), : 2 ** len(H.wires)], 4)


# exact cos(3*H)
print(f'Cos(3*H):')
print(np.round(cosm(3*H_mat),4))
print()
# block-encoded P(H)
print('Block-encoded P(H):')
print(sub_matrix)

This produces the output:

Cos(3*H):
[[ 0.3483+0.j  0.    +0.j  0.    +0.j  0.8915+0.j]
[ 0.    +0.j -0.246 +0.j -0.8915+0.j  0.    +0.j]
[ 0.    +0.j -0.8915+0.j  0.3483+0.j  0.    +0.j]
[ 0.8915+0.j  0.    +0.j  0.    +0.j -0.246 +0.j]]

Block-encoded P(H):
[[ 0.3483-0.1478j  0.    -0.j      0.    +0.j      0.8914+0.17j  ]
[-0.    +0.j     -0.246 -0.2611j -0.8914-0.17j   -0.    -0.j    ]
[-0.    +0.j     -0.8914-0.17j    0.3483-0.1478j -0.    +0.j    ]
[ 0.8914+0.17j   -0.    +0.j      0.    -0.j     -0.246 -0.2611j]]

Output of qml.about() :

Name: pennylane
Version: 0.45.0
Summary: PennyLane is a cross-platform Python library for quantum computing, quantum machine learning, and quantum chemistry. Train a quantum computer the same way as a neural network.
Home-page: 
Author: 
License: 
Location: /home/joel/miniconda3/envs/qsp-pennylane/lib/python3.12/site-packages
Platform info:           Linux-6.14.5-300.fc42.x86_64-x86_64-with-glibc2.41
Python version:          3.12.0
Numpy version:           2.4.6
Scipy version:           1.17.1
JAX version:             None
Catalyst version:        None
Installed devices:
- lightning.qubit (pennylane_lightning-0.45.0)
- default.clifford (pennylane-0.45.0)
- default.gaussian (pennylane-0.45.0)
- default.mixed (pennylane-0.45.0)
- default.qubit (pennylane-0.45.0)
- default.qutrit (pennylane-0.45.0)
- default.qutrit.mixed (pennylane-0.45.0)
- default.tensor (pennylane-0.45.0)
- null.qubit (pennylane-0.45.0)
- reference.qubit (pennylane-0.45.0)

Any insight or help would be greatly appreciated.

EDIT: It does looks like ang_seqis symmetric (mod pi) so maybe PyQSP is doing something under the hood that I do not understand. In any case, the above code shows that the imaginary part of the target polynomial is zero, so I’m not quite sure this explains what is going wrong.

Hi @Joel , thanks for your questions and welcome to the Forum!

QSVT can be tricky, let me check and get back to you.

Hi @CatalinaAlbornoz I think I now have a better understanding of this, but some questions still remain. I think the challenge is theoretical rather than anything to do with pyqsp or Pennylane specifically. See corollary 5 of the work by Gilyen et. al: https://dl.acm.org/doi/10.1145/3313276.3316366. It guarantees that QSVT can block-encode a complex-valued polynomial P(x) whose real part Re[P(x)] is the target polynomial (let us call it F(x)), but there is no guarantee that one can make Im[P(x)] = 0. However, we know from theorem 10 in Grand Unification of Quantum Algorithms: https://journals.aps.org/prxquantum/abstract/10.1103/PRXQuantum.2.040203 that it is possible with the Wx convention of QSP to encode a real-valued polynomial in the <+| |+> block. We can also always convert to the reflection convention used by QSVT by an appropriate transformation of the phase factors, however the need to access the <+| |+> block (as opposed to the <0| * |0> block) remains. It’s unclear how one would do this conjugation by Hadamards in the QSVT framework when the block-encoding uses more than one qubit. One can of course use LCU with two controlled QSVT circuits, but this introduces additional overhead that would be preferable to avoid if possible.

I think my remaining question would be whether LCU is the only way to access the real part of the target polynomial (when not using symmetric phase factors), or whether there are other known methods.

Hi @Joel ,

Thanks for sharing your analysis here!

Regarding your LCU question, there are other options but LCU is usually the best one.

For a real target that violates |F(\pm1)|=1, the imaginary part is forced, so you must either (a) extract the real part (with LCU/Hadamard‑test, +1 auxiliary qubit), (b) reshape the target to satisfy the boundary condition, or (c) switch to GQSP on a unitary block‑encoding. There is no convention tweak that makes Im[P]=0 while keeping the bare single‑aux QSVT block.

Alternative b means rescaling your problem so the polynomial hits \pm1 at the endpoints: |F(\pm1)|=1. However this is not always possible for a fixed analytic target like cos(3x).

Alternative c works when your block‑encoding is of a unitary. GQSP uses general SU(2) rotations instead of Z‑reflections and lets you implement arbitrary complex/real polynomials with one aux qubit and no doubling. It’s the most overhead‑efficient alternative, but it targets the unitary/qubitization setting rather than reflection‑convention QSVT applied directly to a Hermitian H.

I hope this helps!

@CatalinaAlbornoz Yes, that makes sense, thank you!