Skip to content

Quantum linear solver with LCU of Chebyshev polynomials

View on GitHub

The code here can be integrated as part of a larger CFD solver, e.g., as in qc-cfd repository. In particular, instead of calling a classical solver, e.g., x = sparse.linalg.spsolve(mat_raw_scr, b_raw), one can call the quantum solver cheb_lcu_solver(mat_raw_scr, b_raw,...).

We implemented two versions for block-encoding, one based on Pauli decomposition of the matrix, and another one based on decomposing the matrix to a finite set of diagonals.

!pip install -qq "classiq[qsp]"
!pip install -qq "classiq[chemistry]"
import time

import matplotlib.pyplot as plt
import numpy as np
from cheb_utils import *
from classical_functions_be import *
from quantum_functions_be import *
from scipy import sparse

from classiq import *
from classiq.applications.chemistry.op_utils import qubit_op_to_pauli_terms

np.random.seed(53)
@qfunc
def my_reflect_about_zero(qba: QArray):
    reflect_about_zero(qba)
    RY(2 * np.pi, qba[0])


@qfunc
def walk_operator(
    block_enc: QCallable[QArray, QArray], block: QArray, data: QArray
) -> None:
    block_enc(block, data)
    my_reflect_about_zero(block)


@qfunc
def symmetrize_walk_operator(
    block_enc: QCallable[QNum, QArray], block: QNum, data: QArray
):
    my_reflect_about_zero(block)
    within_apply(
        lambda: walk_operator(block_enc, block, data),
        lambda: my_reflect_about_zero(block),
    )


@qfunc
def lcu_cheb(
    powers: CArray[CInt],
    inv_coeffs: CArray[CReal],
    block_enc: QCallable[QNum, QArray],
    mat_block: QNum,
    data: QArray,
    cheb_block: QArray,
) -> None:

    within_apply(
        lambda: inplace_prepare_state(inv_coeffs, 0.0, cheb_block),
        lambda: (
            Z(cheb_block[0]),
            repeat(
                powers.len,
                lambda i: control(
                    cheb_block[i],
                    lambda: power(
                        powers[i],
                        lambda: symmetrize_walk_operator(block_enc, mat_block, data),
                    ),
                ),
            ),
            my_reflect_about_zero(mat_block),
            walk_operator(block_enc, mat_block, data),
        ),
    )

Define functions to get properties of the block-encoding.

PAULI_TRIM_REL_TOL = 0.1


def get_pauli_sym_be(mat_raw_scr):
    """
    Get relevant block-encoding properties for `lcu_paulis_graycode` block encoding,
    with matrix symmetrization.

    Parameters
    ----------
    mat_raw_scr : scipy.sparse.spmatrix
        Square sparse matrix of shape (N, N), real or complex, to be block-encoded.

    Returns
    -------
    data_size : int
       Size of the data variable.
    block_size : int
        Size of the block variable.
    be_scaling_factor : float
        The scaling factor of the block-encoding unitary
    be_qfunc : qfunc
        Quantum function that implements the block encoding. Signature:
        be_qfunc(block: QNum, data: QNum) → None
    """

    rval = mat_raw_scr.data
    col = mat_raw_scr.indices
    rowstt = mat_raw_scr.indptr
    nr = mat_raw_scr.shape[0]
    data_size = int(np.log2(nr))

    # decompose to Paulis with symmetrizing
    paulis_list, transform_matrix = initialize_paulis_from_csr(
        rowstt, col, data_size, to_symmetrize=True
    )
    data_size += 1  # in the symmetric case the data size is increased by 1

    qubit_op = eval_pauli_op(paulis_list, transform_matrix, rval)
    qubit_op.compress(1e-12)
    hamiltonian = qubit_op_to_pauli_terms(qubit_op)
    hamiltonian_trimmed = trim_hamiltonian(
        hamiltonian, PAULI_TRIM_REL_TOL, jump_threshold=1.1
    )

    be_scaling_factor = sum(
        [np.abs(term.coefficient) for term in hamiltonian_trimmed.terms]
    )
    block_size = size = max(1, (len(hamiltonian_trimmed.terms) - 1).bit_length())

    hamiltonian_trimmed = hamiltonian_trimmed * (1 / be_scaling_factor)

    print(
        f"number of Paulis before/after trimming {len(hamiltonian.terms)}/{len(hamiltonian_trimmed.terms)}"
    )

    # Define block encoding function
    @qfunc
    def be_qfunc(block: QNum, data: QNum):
        lcu_paulis_graycode(hamiltonian_trimmed.terms, data, block)

    return data_size, block_size, be_scaling_factor, be_qfunc


def get_banded_diags_sym_be(mat_raw_scr):
    """
    Get relevant block-encoding properties for `block_encode_banded_sym` block encoding,

    Parameters
    ----------
    mat_raw_scr : scipy.sparse.spmatrix
        Square sparse matrix of shape (N, N), real or complex, to be block-encoded.

    Returns
    -------
    data_size : int
       Size of the data variable.
    block_size : int
        Size of the block variable.
    be_scaling_factor : float
        The scaling factor of the block-encoding unitary
    be_qfunc : qfunc
        Quantum function that implements the block encoding. Signature:
        be_qfunc(block: QNum, data: QNum) → None
    """
    offsets, diags, diags_maxima, prepare_norm = get_be_banded_data(mat_raw_scr)
    data_size = int(np.ceil(np.log2(len(diags[0])))) + 1

    # Calculate scaling factor and block size
    block_size = int(np.ceil(np.log2(len(offsets)))) + 3
    be_scaling_factor = 2 * prepare_norm

    # Define block encoding function
    @qfunc
    def be_qfunc(block: QNum, data: QNum):
        block_encode_banded_sym(
            offsets=offsets, diags=diags, prep_diag=diags_maxima, block=block, data=data
        )

    return data_size, block_size, be_scaling_factor, be_qfunc

The solvers were developed in the framework of exploring their performance in hybrid CFD schemes. For simplicity, it is assumed that all the properties of the matrices are known explicitly. In particular, we calculate its sigular values for identyfing the range in which we apply the inversion polnomial.

def get_eig_range(mat_raw_scr):
    mat_raw = mat_raw_scr.toarray()
    raw_size = mat_raw.shape[0]
    mat_sym = np.block(
        [
            [np.zeros([raw_size, raw_size]), np.transpose(mat_raw)],
            [mat_raw, np.zeros([raw_size, raw_size])],
        ]
    )
    w, v = np.linalg.eig(mat_sym)
    w_min = np.min(np.abs(w))
    w_max = np.max(np.abs(w))
    return w_min, w_max
def cheb_lcu_solver(
    mat_raw_scr,
    b_raw,
    log_poly_degree,
    be_method="banded",
    cheb_approx_type="numpy_interpolated",
    preferences=Preferences(),
    constraints=Constraints(),
    qmod_name=None,
):

    SCALE = 0.5
    b_norm = np.linalg.norm(b_raw)  # b normalization
    b_normalized = b_raw / b_norm
    data_size = max(1, (len(b_raw) - 1).bit_length()) + 1

    # Define block encoding
    if be_method == "pauli":
        data_size, block_size, be_scaling_factor, be_qfunc = get_pauli_sym_be(
            mat_raw_scr
        )
    if be_method == "banded":
        data_size, block_size, be_scaling_factor, be_qfunc = get_banded_diags_sym_be(
            mat_raw_scr
        )

    # Get eigenvalues range
    w_min, w_max = get_eig_range(mat_raw_scr / be_scaling_factor)

    # Calculate approximated Chebyshev polynomial
    poly_degree = 2 * 2**log_poly_degree
    pcoefs = get_cheb_coeff(
        w_min, poly_degree, w_max, scale=SCALE, method=cheb_approx_type, epsilon=0.01
    )
    odd_coef = pcoefs[1::2]

    # Calculate prep for Chebyshev LCU
    lcu_size_inv = len(odd_coef).bit_length() - 1
    print(f"Chebyshec LCU size: {lcu_size_inv} qubits.")
    normalization_inv = sum(np.abs(odd_coef))
    print(f"Normalization factor for inversion: {normalization_inv}")
    prepare_probs_inv = (np.abs(odd_coef) / normalization_inv).tolist()

    @qfunc
    def main(
        matrix_block: Output[QNum[block_size]],
        data: Output[QNum[data_size]],
        inv_block: Output[QNum[lcu_size_inv]],
    ):
        allocate(inv_block)
        allocate(matrix_block)
        allocate(data)

        data_array = QArray()
        within_apply(
            lambda: bind(data, data_array),
            lambda: (
                inplace_prepare_amplitudes(
                    b_normalized.tolist(), 0.0, data_array[0 : data_size - 1]
                ),
                X(data_array[data_size - 1]),
            ),
        )
        lcu_cheb(
            powers=[2**i for i in range(lcu_size_inv)],
            inv_coeffs=prepare_probs_inv,
            block_enc=be_qfunc,
            mat_block=matrix_block,
            data=data,
            cheb_block=inv_block,
        )

    if qmod_name is not None:
        write_qmod(main, qmod_name, symbolic_only=False)

    start_time_syn = time.time()
    qprog = synthesize(main, preferences=preferences, constraints=constraints)

    print("time to syn:", time.time() - start_time_syn)

    execution_preferences = ExecutionPreferences(
        num_shots=1,
        backend_preferences=ClassiqBackendPreferences(
            backend_name=ClassiqSimulatorBackendNames.SIMULATOR_STATEVECTOR
        ),
    )

    start_time_exe = time.time()
    with ExecutionSession(qprog, execution_preferences) as es:
        es.set_measured_state_filter("matrix_block", lambda state: state == 0.0)
        es.set_measured_state_filter("inv_block", lambda state: state == 0.0)
        results = es.sample()
    print("time to exe:", time.time() - start_time_exe)

    resulting_state = get_projected_state_vector(results, "data")

    normalization_factor = (
        (be_scaling_factor * SCALE * w_min) / b_norm / normalization_inv
    )

    return resulting_state / normalization_factor, qprog
import pathlib

path = (
    pathlib.Path(__file__).parent.resolve()
    if "__file__" in locals()
    else pathlib.Path(".")
)
mat_name = "nozzle_small_scr"
matfile = "matrices/" + mat_name + ".npz"
mat_raw_scr = sparse.load_npz(path / matfile)

b_raw = np.load(path / "matrices/b_nozzle_small.npy")
raw_mat_qubits = len(b_raw).bit_length() - 1  # matrix raw size

print(f"Raw matrix size: {raw_mat_qubits} qubits.")
Raw matrix size: 3 qubits.
# For faster results use the commented out preferences

prefs = Preferences()

# prefs = Preferences(
#     transpilation_option="none", optimization_level=0, debug_mode=False, qasm3=True
# )

log_poly_degree = 4
qsol_banded, qprog_cheb_lcu_banded = cheb_lcu_solver(
    mat_raw_scr,
    b_raw,
    log_poly_degree,
    be_method="banded",
    preferences=prefs,
    constraints=Constraints(optimization_parameter="width"),
    qmod_name="cheb_lcu_solver_banded_be",
)
show(qprog_cheb_lcu_banded)
For error 0.01, and given kappa, the needed polynomial degree is: 1293
Performing numpy Chebyshev interpolation, with degree 32
Chebyshec LCU size: 4 qubits.
Normalization factor for inversion: 0.15033564980341882
time to syn: 144.29120779037476
time to exe: 46.127877950668335
Quantum program link: https://platform.classiq.io/circuit/34wLR6QlTKZi3elCLfGNeQhgR6p
qsol_pauli, qprog_cheb_lcu_pauli = cheb_lcu_solver(
    mat_raw_scr,
    b_raw,
    log_poly_degree,
    be_method="pauli",
    preferences=prefs,
    constraints=Constraints(optimization_parameter="width"),
    qmod_name="cheb_lcu_solver_pauli_be",
)
show(qprog_cheb_lcu_pauli)
number of Paulis before/after trimming 24/20
For error 0.01, and given kappa, the needed polynomial degree is: 885
Performing numpy Chebyshev interpolation, with degree 32
Chebyshec LCU size: 4 qubits.
Normalization factor for inversion: 0.21061716904989833
time to syn: 93.29885578155518
time to exe: 13.074339151382446
Quantum program link: https://platform.classiq.io/circuit/34wLetuvacK2iDnxheo0G1lCAtg
mat_raw = mat_raw_scr.toarray()
expected_sol = np.linalg.solve(mat_raw, b_raw)
plt.plot(expected_sol, "o", label="classical")
ext_idx = np.argmax(np.abs(expected_sol))
correct_sign = np.sign(expected_sol[ext_idx]) / np.sign(qsol_banded[ext_idx])
qsol_banded *= correct_sign
plt.plot(
    qsol_banded[0 : len(b_raw)],
    ".",
    label=f"Cheb-LCU-inv; Banded BE; degree {2*2**log_poly_degree+1}",
)
correct_sign = np.sign(expected_sol[ext_idx]) / np.sign(qsol_pauli[ext_idx])
qsol_pauli *= correct_sign
plt.plot(
    qsol_pauli[0 : len(b_raw)],
    ".",
    label=f"Cheb-LCU-inv; Pauli BE; degree {2*2**log_poly_degree+1}",
)
[<matplotlib.lines.Line2D at 0x131110bd0>]

png

assert np.linalg.norm(qsol_banded[0 : len(b_raw)] - expected_sol) < 0.25
assert np.linalg.norm(qsol_pauli[0 : len(b_raw)] - expected_sol) < 0.2