Solving Ax=b with Variational quantum linear solver

Hello! If applicable, put your complete code example down below. Make sure that your code:

  • is 100% self-contained — someone can copy-paste exactly what is here and run it to
    reproduce the behaviour you are observing
  • includes comments
# Put code here

import torch
torch.cuda.is_available()

!pip install pennylane

# Pennylane
import pennylane as qml
from pennylane import numpy as np

# Plotting
import matplotlib.pyplot as plt

"""**Setting of the main hyper-parameters of the model**"""

# Set random seed for reproducibility
np.random.seed(0)

"""## Hyperparameters"""
n_qubits = 8  # Number of system qubits
n_shots = 10 ** 6  # Number of quantum measurements
tot_qubits = n_qubits + 1  # Addition of an ancillary qubit
ancilla_idx = n_qubits  # Index of the ancillary qubit (last position)
steps = 100  # Number of optimization steps
eta = 9e-3  # Adjusted learning rate (originally 0.8)
q_delta = 1e-4  # Initial spread of random quantum weights
n_layers = 8  # Number of layers in the variational circuit

"""**Circuits of the quantum linear problem**

We now define the unitary operations associated to the simple example presented in the introduction. Since we want to implement a Hadamard test, we need the unitary operations $A_{j}$ to be controlled by the state of an ancillary qubit.
"""

n = 256
A = 2 * np.eye(n) - np.eye(n, k=1) - np.eye(n, k=-1) - np.eye(n, k=2) - np.eye(n, k=-2)

LCU = qml.pauli_decompose(A)
LCU_coeffs, LCU_ops = LCU.terms()

# print(f"LCU decomposition:\n {LCU}")
# print(f"Coefficients:\n {LCU_coeffs}")
# print(f"Unitaries:\n {LCU_ops}")

c = np.array(LCU_coeffs)
# Enhanced U_b preparation
def U_b():
    """Proper state preparation for |b> = [1,1,1,1,1,1,1,1]/sqrt(8)"""
    for idx in range(n_qubits):
        qml.Hadamard(wires=idx)

# Enhanced CA implementation
# def CA(idx):
#     """Controlled versions of the unitary components"""
#     if idx == 0:
#         None  # Identity operation

#     elif idx == 1:
#       qml.CNOT(wires=[ancilla_idx, 2])

#     elif idx == 2:
#         qml.CNOT(wires=[ancilla_idx, 1])

#     elif idx == 3:
#         qml.CNOT(wires=[ancilla_idx, 1])
#         qml.CNOT(wires=[ancilla_idx, 2])

#     elif idx == 4:
#         qml.CY(wires=[ancilla_idx, 1])
#         qml.CY(wires=[ancilla_idx, 2])

#     elif idx == 5:
#         qml.CNOT(wires=[ancilla_idx, 0])
#         qml.CNOT(wires=[ancilla_idx, 1])

#     elif idx == 6:
#         qml.CNOT(wires=[ancilla_idx, 0])
#         qml.CNOT(wires=[ancilla_idx, 1])
#         qml.CNOT(wires=[ancilla_idx, 2])

#     elif idx == 7:
#         qml.CNOT(wires=[ancilla_idx, 0])
#         qml.CY(wires=[ancilla_idx, 1])
#         qml.CY(wires=[ancilla_idx, 2])

#     elif idx == 8:
#         qml.CY(wires=[ancilla_idx, 0])
#         qml.CNOT(wires=[ancilla_idx, 1])
#         qml.CY(wires=[ancilla_idx, 2])

#     elif idx == 9:
#         qml.CY(wires=[ancilla_idx, 0])
#         qml.CY(wires=[ancilla_idx, 1])

#     elif idx == 10:
#         qml.CY(wires=[ancilla_idx, 0])
#         qml.CY(wires=[ancilla_idx, 1])
#         qml.CNOT(wires=[ancilla_idx, 2])




def CA(idx):
    """Automatically parse and build controlled unitaries from Pauli strings"""
    if idx >= len(LCU_ops):
        raise ValueError(f"Index {idx} out of range for LCU operations")

    pauli_term = LCU_ops[idx]
    pauli_str = str(pauli_term)

    # Parse the Pauli string
    if '⊗' in pauli_str:
        terms = [term.strip() for term in pauli_str.split('⊗')]
    elif '@' in pauli_str:
        terms = [term.strip() for term in pauli_str.split('@')]
    else:
        terms = [pauli_str.strip()]

    for term in terms:
        # Extract Pauli operator and qubit index
        if '(' in term and ')' in term:
            pauli_name = term.split('(')[0].strip()
            qubit_idx = int(term.split('(')[1].split(')')[0])
        else:
            # Handle formats like "X0" or "I"
            pauli_name = term[0] if term[0] in ['I', 'X', 'Y', 'Z'] else term
            qubit_idx = int(term[1:]) if len(term) > 1 else 0

        # Apply controlled Pauli gate
        if pauli_name == 'X':
            qml.CNOT(wires=[ancilla_idx, qubit_idx])
        elif pauli_name == 'Y':
            qml.CY(wires=[ancilla_idx, qubit_idx])
        elif pauli_name == 'Z':
            qml.CZ(wires=[ancilla_idx, qubit_idx])
        # Identity (I) requires no operation

"""**Variational quantum circuit**"""

# # Enhanced variational circuit
# def variational_block(weights):
#     """More powerful ansatz"""
#     # Layer 1: Hadamards + Entanglement
#     for idx in range(n_qubits):
#         qml.Hadamard(wires=idx)
#     for i in range(n_qubits-1):
#         qml.CNOT(wires=[i, i+1])

#     # Layer 2: Parameterized rotations
#     for idx, w in enumerate(weights[:n_qubits]):
#         qml.RY(w, wires=idx)
#     for i in range(n_qubits-1):
#         qml.CNOT(wires=[i, i+1])

#     # Layer 3: Final rotations
#     for idx, w in enumerate(weights[n_qubits:2*n_qubits]):
#         qml.RZ(w, wires=idx)

def variational_block(weights):
    """
    Enhanced variational circuit with StronglyEntanglingLayers.
    Args:
        weights: Array of shape (n_layers, n_qubits, 3) for rotation parameters
    """
    # Initial Hadamard layer for superposition
    for idx in range(n_qubits):
        qml.Hadamard(wires=idx)

    # Strongly entangling layers for variational optimization
    qml.StronglyEntanglingLayers(weights, wires=range(n_qubits))

# def variational_block(weights):
#     # Apply RY rotations with the first set of parameters
#     for i in range(n_qubits):
#         qml.RY(weights[i], wires=i)

#     # Apply CNOT gates with adjacent qubits (cyclically connected) to create entanglement
#     for i in range(n_qubits):
#         qml.CNOT(wires=[(i - 1) % n_qubits, (i) % n_qubits])

"""**Hadamard test**"""

dev_mu = qml.device("lightning.qubit", wires=tot_qubits)

@qml.qnode(dev_mu, interface="autograd")
def local_hadamard_test(weights, l=None, lp=None, j=None, part=None):

    # First Hadamard gate applied to the ancillary qubit.
    qml.Hadamard(wires=ancilla_idx)

    # For estimating the imaginary part of the coefficient "mu", we must add a "-i"
    # phase gate.
    if part == "Im" or part == "im":
        qml.PhaseShift(-np.pi / 2, wires=ancilla_idx)

    # Variational circuit generating a guess for the solution vector |x>
    variational_block(weights)

    # Controlled application of the unitary component A_l of the problem matrix A.
    CA(l)

    # Adjoint of the unitary U_b associated to the problem vector |b>.
    # In this specific example Adjoint(U_b) = U_b.
    U_b()

    # Controlled Z operator at position j. If j = -1, apply the identity.
    if j != -1:
        qml.CZ(wires=[ancilla_idx, j])

    # Unitary U_b associated to the problem vector |b>.
    U_b()

    # Controlled application of Adjoint(A_lp).
    # In this specific example Adjoint(A_lp) = A_lp.
    CA(lp)

    # Second Hadamard gate applied to the ancillary qubit.
    qml.Hadamard(wires=ancilla_idx)

    # Expectation value of Z for the ancillary qubit.
    return qml.expval(qml.PauliZ(wires=ancilla_idx))

def mu(weights, l=None, lp=None, j=None):
    """Generates the coefficients to compute the "local" cost function C_L."""

    mu_real = local_hadamard_test(weights, l=l, lp=lp, j=j, part="Re")
    mu_imag = local_hadamard_test(weights, l=l, lp=lp, j=j, part="Im")

    return mu_real + 1.0j * mu_imag

"""**Local cost function**"""

def psi_norm(weights):
    """Returns the normalization constant <psi|psi>, where |psi> = A |x>."""
    norm = 0.0

    for l in range(0, len(c)):
        for lp in range(0, len(c)):
            norm = norm + c[l] * np.conj(c[lp]) * mu(weights, l, lp, -1)

    return abs(norm)

def cost_loc(weights):
    """Local version of the cost function. Tends to zero when A|x> is proportional to |b>."""
    mu_sum = 0.0

    for l in range(0, len(c)):
        for lp in range(0, len(c)):
            for j in range(0, n_qubits):
                mu_sum = mu_sum + c[l] * np.conj(c[lp]) * mu(weights, l, lp, j)

    mu_sum = abs(mu_sum)

    # Cost function C_L
    return 0.5 - 0.5 * mu_sum / (n_qubits * psi_norm(weights))


# def cost_loc(weights):
#     mu_sum = 0.0
#     for l in range(len(c)):
#         for lp in range(len(c)):
#             mu_sum += c[l] * np.conj(c[lp]) * mu(weights, l, lp, -1)
#     return 1 - abs(mu_sum)/(psi_norm(weights))

"""**Variational optimization**"""

# np.random.seed(rng_seed)
w = q_delta * np.random.randn(n_layers, n_qubits, 3, requires_grad=True)
# w = q_delta * np.random.randn(n_qubits, requires_grad=True)

# Optimization

# opt = qml.GradientDescentOptimizer(eta)
opt = qml.AdamOptimizer(eta)

cost_history = []
for it in range(steps):
    w, cost = opt.step_and_cost(cost_loc, w)
    cost_history.append(cost)
    if it % 10 == 0:
        print(f"Step {it:3d} Cost: {cost:.8f}")

import seaborn as sns
import matplotlib.pyplot as plt
plt.style.use("seaborn-v0_8")
plt.plot(cost_history, "g")
plt.ylabel("Cost function")
plt.xlabel("Optimization steps")
plt.show()

"""**Comparison of quantum and classical results**"""

# Id = np.identity(2)
# Z = np.array([[1, 0], [0, -1]])
# X = np.array([[0, 1], [1, 0]])

# A_0 = np.identity(8)
# A_1 = np.kron(np.kron(X, Z), Id)
# A_2 = np.kron(np.kron(X, Id), Id)

# A_num = c[0] * A_0 + c[1] * A_1 + c[2] * A_2
# b = np.ones(8) / np.sqrt(8)

# Id = np.identity(2)
# Z = np.array([[1, 0], [0, -1]])
# X = np.array([[0, 1], [1, 0]])
# Y = np.array([[0, -1j], [1j,  0]])

# A_0 = np.identity(8)
# A_1 = np.kron(np.kron(Id, Id), X)
# A_2 = np.kron(np.kron(Id, X), Id)
# A_3 = np.kron(Id, np.kron(X, X))
# A_4 = np.kron(Id, np.kron(Y, Y))
# A_5 = np.kron(np.kron(X, X), Id)
# A_6 = np.kron(np.kron(X, X), X)
# A_7 = np.kron(np.kron(X, Y), Y)
# A_8 = np.kron(np.kron(Y, X), Y)
# A_9 = np.kron(np.kron(Y, Y), Id)
# A_10 = np.kron(np.kron(Y, Y), X)


# A_num = c[0] * A_0 + c[1] * A_1 + c[2] * A_2 + c[3] * A_3 + c[4] * A_4 + c[5] * A_5 + c[6] * A_6 + c[7] * A_7 + c[8] * A_8 + c[9] * A_9 + c[10] * A_10
A_num = qml.matrix(LCU)
b = np.ones(n) / np.sqrt(n)
# b = np.array(b_normalized)

print("A = \n", A_num)
print("b = \n", b)

A_inv = np.linalg.inv(A_num)
x = np.dot(A_inv, b)

c_probs = (x / np.linalg.norm(x)) ** 2

"""**Preparation of the quantum solution**"""

dev_x = qml.device("lightning.qubit", wires=n_qubits, shots=n_shots)

@qml.qnode(dev_x, interface="autograd")
def prepare_and_sample(weights):

    # Variational circuit generating a guess for the solution vector |x>
    variational_block(weights)

    # We assume that the system is measured in the computational basis.
    # then sampling the device will give us a value of 0 or 1 for each qubit (n_qubits)
    # this will be repeated for the total number of shots provided (n_shots)
    return qml.sample()

raw_samples = prepare_and_sample(w)

# convert the raw samples (bit strings) into integers and count them
samples = []
for sam in raw_samples:
    samples.append(int("".join(str(bs) for bs in sam), base=2))

q_probs = np.bincount(samples) / n_shots

print("x_n^2 =\n", c_probs)

print("|<x|n>|^2=\n", q_probs)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7, 4))

ax1.bar(np.arange(0, 2 ** n_qubits), c_probs, color="blue")
ax1.set_xlim(-0.5, 2 ** n_qubits - 0.5)
ax1.set_xlabel("Vector space basis")
ax1.set_title("Classical probabilities")

ax2.bar(np.arange(0, 2 ** n_qubits), q_probs, color="green")
ax2.set_xlim(-0.5, 2 ** n_qubits - 0.5)
ax2.set_xlabel("Hilbert space basis")
ax2.set_title("Quantum probabilities")

plt.show()


vqls_512_512_5diagonal.py (11.2 KB)

If you want help with diagnosing an error, please put the full error message below:

# Put full error message here
A matrix with dimensions larger than 32 x 32 will run out of my processor RAM.

And, finally, make sure to include the versions of your packages. Specifically, show us the output of qml.about().

Hi @moslem_ym,

This is normal. You’re trying to use more RAM than the one available on your computer.

You can see the calculation for how much RAM you need in Forum thread 4829. Let us know if you have any questions after reading that thread!