qiskit-community / qiskit-machine-learning

Quantum Machine Learning
https://qiskit-community.github.io/qiskit-machine-learning/
Apache License 2.0
657 stars 323 forks source link

TorchConnector with Custom Circuit #588

Closed mckunkel closed 1 year ago

mckunkel commented 1 year ago

What should we add?

I do not see how I can create a custom circuit and use TorchConnector. Would be great if this availability was created, and if it already exists, documentation on it.

adekusar-drl commented 1 year ago

Can you please clarify your question? You can create an instance of TorchConnector backed by a QNN that is created on top of your custom circuit. See the tutorial here: https://qiskit.org/documentation/machine-learning/tutorials/05_torch_connector.html

mckunkel commented 1 year ago

Greetings,

Unless I am mistaken, the example you provided is not a custom circuit, rather it relies on built-in feature map and ansatz modules, i.e.

feature_map = ZZFeatureMap(2)
ansatz = RealAmplitudes(2, reps=1)

What I am referring in the terms of custom circuit is that I have my own circuit, with a custom state preparation and a custom ansatz that is returned as a whole circuit, for example

def MKCircuit(numQubits, numClassical, setup, inputAngles):
    circuit = QuantumCircuit(numQubits + numClassical, numClassical)
    # Encoding the input data into the quantum state
    #the input data are the weights coming from the linear layer of a pytorch NN
    encodingsetup(circuit, setup, numQubits, inputAngles)
    ...
    ...
    ...
    #now measure the last qubit
    circuit.measure(numQubits, 0)
    return circuit

and I have custom forward and backward functions to train this, but I rather move toward a more modern TorchConnector.

This is what I am referring to as a custom circuit. I hope this clarifies my intention.

BR MK

adekusar-drl commented 1 year ago

I see the point, but what is preventing you from replacing a library circuit with your own? Like

feature_map = MKCircuit(....)
mckunkel commented 1 year ago

Greetings and apologies for my lateness in reply,

I have been trying a few different ways to do as you suggested with no avail. I believe it has something to do with my ansatz not working properly with TorchConnector.

Is there help on how to write custom feature_maps and custom ansatz to work with Torchconnector?

adekusar-drl commented 1 year ago

Is there help on how to write custom feature_maps and custom ansatz to work with Torchconnector?

The question is too broad. Basically, the tutorial covers the topic pretty well.

Here is a quick snippet with truly custom circuits.


import torch
from qiskit import QuantumCircuit
from qiskit.circuit import ParameterVector
from torch.nn import MSELoss

from qiskit_machine_learning.connectors import TorchConnector
from qiskit_machine_learning.neural_networks import EstimatorQNN

fm = QuantumCircuit(2)
input_parameters = ParameterVector("x", 2)
fm.ry(input_parameters[0], 0)
fm.ry(input_parameters[1], 1)
fm.cx(0, 1)

ansatz = QuantumCircuit(2)
weights = ParameterVector("w", 4)

ansatz.ry(weights[0], 0)
ansatz.rz(weights[1], 0)
ansatz.ry(weights[2], 1)
ansatz.rz(weights[3], 1)
ansatz.cx(0, 1)

qnn_qc = QuantumCircuit(2)
qnn_qc.compose(fm, inplace=True)
qnn_qc.compose(ansatz, inplace=True)

qnn = EstimatorQNN(circuit=qnn_qc, input_params=input_parameters, weight_params=weights)
model = TorchConnector(qnn)

features = torch.rand((20, 2))
labels = torch.randint(0, 2, (20, 1)) * 2. - 1

optimizer = torch.optim.Adam(model.parameters(), lr=0.1)
criterion = MSELoss()

for i in range(10):
    optimizer.zero_grad()
    loss = criterion(model(features), labels)
    print(f"Iteration: {i}, loss: {loss.item()}")
    loss.backward()
    optimizer.step()
mckunkel commented 1 year ago

I would like to start with that I appreciate the time you are investing in my question.

However, my question was very specific in the sense the custom feature maps and ansatzs are well covered for how a circuit can be created in how the package wants circuits to be constructed, but not for circuits you coined "truly custom".

Below is my circuit, that is truly custom. Currently I see no means on how to integrate this with TorchConnector, SamplerQNN or EstimatorQNN. Notice that my final observable, of a N qubit circuit in my setup, is the result of a measurement of a single ancillary qubit with a data encoding of the input parameter of the NN being used as rations and conditional rotations.

This is what I am regarding as a custom circuit.

For context, this circuit setup was used in the old method of hybrid MNST training by attaching it to a linear layer. It worked great, but was not able to fully utilize gpu running and trained rather slow on more sophisticated NN setups and backbones, such as Transformers.

def MKCircuit(numQubits):
    fm = QuantumCircuit(QuantumRegister(numQubits+1), ClassicalRegister(1))
    input_parameters = ParameterVector("theta", 3*numQubits)
    #performing the input data encoding as a RY and a RZ for
    #2 bits of data per qubit
    for i in range(numQubits):
        fm.ry(input_parameters[i], i)
        fm.rz(input_parameters[numQubits+i], i)
    fm.barrier()
    # the ansatz is that upon entangling each qubit to another input parameter
    # the circuit will train like a bounded 5D clock 
    ansatz = QuantumCircuit(QuantumRegister(numQubits+1), ClassicalRegister(1))
    for i in range(numQubits-1):
        ansatz.crx(input_parameters[2*numQubits+i], i, i+1)
    #dont forget to close the clock and entangle the last qubit to the first qubit    
    ansatz.crx(input_parameters[3*numQubits-1], numQubits-1,0) 

    # now if all works as calculated, the result of the 
    # 5D clock projected to this ancilla
    # qubit should train to the binary classification of my label map  
    ansatz.cx(numQubits-1 , numQubits) 
    #Only measure the ancilla qubit
    ansatz.measure(numQubits, 0)

    weights = ParameterVector("w", 4)

    qnn_qc = QuantumCircuit(QuantumRegister(numQubits+1), ClassicalRegister(1))
    qnn_qc.compose(fm, inplace=True)
    qnn_qc.compose(ansatz, inplace=True)
    ansatz = QuantumCircuit

    return qnn_qc, input_parameters, weights

circuit, _ , _ = MKCircuit(3)
circuit.draw()
adekusar-drl commented 1 year ago

It is still possible to wrap this circuit into TorchConnector. But I noticed that you have one single ParameterVector for both input data and trainable weights. I updated the code the way I thought it should look like. This is what I've got:

import torch
from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister
from qiskit.circuit import ParameterVector
from torch.nn import MSELoss

from qiskit_machine_learning.connectors import TorchConnector
from qiskit_machine_learning.neural_networks import EstimatorQNN, SamplerQNN

def MKCircuit(numQubits):
    fm = QuantumCircuit(QuantumRegister(numQubits+1), ClassicalRegister(1))
    input_parameters = ParameterVector("theta", 2*numQubits)
    #performing the input data encoding as a RY and a RZ for
    #2 bits of data per qubit
    for i in range(numQubits):
        fm.ry(input_parameters[i], i)
        fm.rz(input_parameters[numQubits+i], i)
    fm.barrier()
    # the ansatz is that upon entangling each qubit to another input parameter
    # the circuit will train like a bounded 5D clock
    weights = ParameterVector("w", numQubits)
    ansatz = QuantumCircuit(QuantumRegister(numQubits+1), ClassicalRegister(1))
    for i in range(numQubits-1):
        ansatz.crx(weights[i], i, i+1)
    #dont forget to close the clock and entangle the last qubit to the first qubit
    ansatz.crx(weights[-1], numQubits-1,0)

    # now if all works as calculated, the result of the
    # 5D clock projected to this ancilla
    # qubit should train to the binary classification of my label map
    ansatz.cx(numQubits-1 , numQubits)
    #Only measure the ancilla qubit
    ansatz.measure(numQubits, 0)

    # weights = ParameterVector("w", 4)

    qnn_qc = QuantumCircuit(QuantumRegister(numQubits+1), ClassicalRegister(1))
    qnn_qc.compose(fm, inplace=True)
    qnn_qc.compose(ansatz, inplace=True)

    return qnn_qc, input_parameters, weights

def train_custom(qnn_qc, input_parameters, weights):
    qnn = SamplerQNN(
        circuit=qnn_qc, 
        input_params=input_parameters, 
        weight_params=weights, 
        output_shape=2, interpret=lambda x: x
    )
    model = TorchConnector(qnn)

    features = torch.rand((20, 6))
    labels = torch.randint(0, 2, (20, 2)) * 2. - 1

    optimizer = torch.optim.Adam(model.parameters(), lr=0.1)
    criterion = MSELoss()

    for i in range(10):
        optimizer.zero_grad()
        output = model(features)
        loss = criterion(output, labels)
        print(f"Iteration: {i}, loss: {loss.item()}")
        loss.backward()
        optimizer.step()

if __name__ == '__main__':
    circuit, input_params, weights = MKCircuit(3)

    train_custom(circuit, input_params, weights)

Key points here is that you set output_shape=2 and interpret to the identity function. Thus, you tell the QNN that you are expecting a vector of two numbers as an output of the network as you measure only one qubit. And the identity does nothing with the measured results, just passes them as is. In general, you may transform the output somehow else and then interpret can be more sophisticated. Hope this helps.

mckunkel commented 1 year ago

Greetings,

This does indeed help in the construction of my circuit. I can now train the circuit you provided, but it does not seem to function as it did when it was implemented without torch connector. If you could clarify my interpretation then I think I would know how to proceed next.

I believe it is not training as it did before because now we are no longer utilizing the quantum parameter shifting technique on the inputs for gradient searching as I did before, but now it's using the weights in the ansatz to train the network and not the NN input.

For comparison, a simple training NN New:

class HybridNet(Module):
    def __init__(self, dim, numQubits):
        super().__init__()
        self.fc2 = Linear(dim, 2*numQubits)  # 2*numQubits-dimensional input to QNN
        self.qnn = TorchConnector(create_qnn(numQubits))  # Apply torch connector, weights chosen

    def forward(self, x):
        x = self.fc2(x)
        x = self.qnn(x)  # apply QNN
        return cat((x, 1 - x), -1)

old:

class HybridNet(Module):
    def __init__(self, dim, numQubits):
        super().__init__()
        self.fc2 = Linear(dim, 2*numQubits)  # 2*numQubits-dimensional input to QNN
        self.hybrid = Hybrid(numQubits, numClassical, backend, shots, shift, setup) # <--this has a forward and backward function with quantum parameter shift for gradient calculation

    def forward(self, x):
        x = self.fc2(x)
        x = self.hybrid(x)  # apply QNN
        return cat((x, 1 - x), -1)

On the same MNST data, with the same training code

# Define model, optimizer, and loss function
hybridModel = HybridNet(28*28,3)

optimizer = optim.Adam(hybridModel.parameters(), lr=0.001)
loss_func = NLLLoss()

# Start training
epochs = 20  # Set number of epochs
hybrid_loss_list = []  # Store loss history
hybridModel.train()  # Set model to training mode

for epoch in range(epochs):
    total_loss = []
    for batch_idx, (data, target) in enumerate(train_loader):
        optimizer.zero_grad(set_to_none=True)  # Initialize gradient
        # Flatten MNIST images into a 784 long vector
        data = data.view(data.shape[0], -1)
        output = hybridModel(data)  # Forward pass
        loss = loss_func(output, target)  # Calculate loss
        loss.backward()  # Backward pass
        optimizer.step()  # Optimize weights
        total_loss.append(loss.item())  # Store loss
    hybrid_loss_list.append(sum(total_loss) / len(total_loss))
    print("Training [{:.0f}%]\tLoss: {:.4f}".format(100.0 * (epoch + 1) / epochs, hybrid_loss_list[-1]))

Yields: New Methodology:

Training [5%]   Loss: -0.4932
Training [10%]  Loss: -0.4932
...
...
Training [95%]  Loss: -0.4933
Training [100%] Loss: -0.4933

Old Methodology:

Training [5%]   Loss: -0.8658
Training [10%]  Loss: -0.9484
Training [15%]  Loss: -0.9613
...
...
Training [95%]  Loss: -0.9847
Training [100%] Loss: -0.9856

Would this be the logical reasoning in why this circuit no longer trains as before?

Again, Many Thanks for all the help.

adekusar-drl commented 1 year ago

I don't have the whole script, but I guess you have to enable gradients wrt input data, like here:

    qnn = EstimatorQNN(
        circuit=qc,
        input_params=feature_map.parameters,
        weight_params=ansatz.parameters,
        input_gradients=True,
    )

I have not enabled them in the previous snippets since I had only an instance of the connector (=one layer) without additional layers, but when you have a hybrid network, this parameter input_gradients=True is essential to propagate gradients. This parameter must be set in hybrid case to get proper training.

Answering your questions - if you don't pass a gradient instance to QNN, and this is what I omitted in the snippets above, then parameter shift gradient is evaluated by default.

mckunkel commented 1 year ago

Greetings,

Many thanks for that information, unfortunately, I had that parameter enabled previously and the hybrid network did not converge as it had previously without TorchConnector.

If I train the below circuit without TorchConnector, i.e. with forward and backward functions in the NN module, then I get a convergence of the network. If I train the same circuit with SamplerQNN and TorchConnector, I see no convergence.

def MKCircuit(numQubits):
    fm = QuantumCircuit(QuantumRegister(numQubits+1), ClassicalRegister(1))
    input_parameters = ParameterVector("theta", 3*numQubits)
    #performing the input data encoding as a RY and a RZ for
    #3 bits of data per qubit
    for i in range(numQubits):
        fm.ry(input_parameters[i], i)
        fm.rz(input_parameters[numQubits+i], i)
    fm.barrier()
    for i in range(numQubits-1):
        fm.crx(input_parameters[2*numQubits+i], i, i+1)
    fm.crx(input_parameters[3*numQubits-1], numQubits-1,0)
    fm.barrier()
    # the ansatz is that upon entangling each qubit to another input parameter
    # the circuit will train like a bounded 5D clock
    weights = ParameterVector("w", numQubits)
    ansatz = QuantumCircuit(QuantumRegister(numQubits+1), ClassicalRegister(1))
    for i in range(numQubits):
        ansatz.ry(weights[i], i)
    # now if all works as calculated, the result of the
    # 5D clock projected to this ancilla
    # qubit should train to the binary classification of my label map
    #Only measure the ancilla qubit
    ansatz.barrier()

    ansatz.measure(numQubits, 0)

    qnn_qc = QuantumCircuit(QuantumRegister(numQubits+1), ClassicalRegister(1))
    qnn_qc.compose(fm, inplace=True)
    qnn_qc.compose(ansatz, inplace=True)

    return qnn_qc, input_parameters, weights

qnn_qc, input_parameters, weights = MKCircuit(4)
qnn_qc.draw()

Here is how I use SamplerQNN (EstimatorQNN does not function here)

# Define and create QNN
def create_qnn(numQubits):
    circuit, input_params, weights = MKCircuit(numQubits)
    # REMEMBER TO SET input_gradients=True FOR ENABLING HYBRID GRADIENT BACKPROP
    qnn = SamplerQNN(
        circuit=circuit, 
        input_params=input_params, 
        weight_params=weights, 
        input_gradients=True,
        output_shape=2, interpret=lambda x: x
    )
    return qnn

Here's my very simple NN structure that converged to 0.99 without TorchConnector and 0.5 with TorchConnector and SamplerQNN

class HybridNet(Module):
    def __init__(self, dim, numQubits):
        super().__init__()
        self.fc2 = Linear(dim, 3*numQubits)  # 3-dimensional input to QNN
        self.qnn = TorchConnector(create_qnn(numQubits))  # Apply torch connector, weights chosen

    def forward(self, x):
        x = self.fc2(x)
        x = self.qnn(x)  # apply QNN
        return cat((x, 1 - x), -1)

and here is the training script that derivative of already working examples

# Define model, optimizer, and loss function
hybridModel = HybridNet(28*28,12)
optimizer = optim.Adam(hybridModel.parameters(), lr=0.001)
loss_func = NLLLoss()

# Start training
epochs = 20  # Set number of epochs
hybrid_loss_list = []  # Store loss history
hybridModel.train()  # Set model to training mode

for epoch in range(epochs):
    total_loss = []
    for batch_idx, (data, target) in enumerate(train_loader):
        optimizer.zero_grad(set_to_none=True)  # Initialize gradient
        # Flatten MNIST images into a 784 long vector
        data = data.view(data.shape[0], -1)
        output = hybridModel(data)  # Forward pass
        loss = loss_func(output, target)  # Calculate loss
        loss.backward()  # Backward pass
        optimizer.step()  # Optimize weights
        total_loss.append(loss.item())  # Store loss
    hybrid_loss_list.append(sum(total_loss) / len(total_loss))
    print("Training [{:.0f}%]\tLoss: {:.4f}".format(100.0 * (epoch + 1) / epochs, hybrid_loss_list[-1]))

My previous method was a derivation of the hybrid network MSNT example found in the qiskit textbook. Any ideas why the network will not converge when I migrated over to TorchConnector and SamplerQNN? My only possible speculation is that "weights" are included in the circuit that I am using with TorchConnector where as on my previous method of training, I only used input parameters and back-propagated using quantum parameter shift.

Here is the data loader in case you wish to try it out

# Train Dataset
# -------------

# Set train shuffle seed (for reproducibility)
manual_seed(42)

batch_size = 1
n_samples = 100  # We will concentrate on the first 100 samples

# Use pre-defined torchvision function to load MNIST train data
X_train = datasets.MNIST(
    root="./data", train=True, download=True, transform=transforms.Compose([transforms.ToTensor()])
)

# Filter out labels (originally 0-9), leaving only labels 0 and 1
idx = np.append(
    np.where(X_train.targets == 0)[0][:n_samples], np.where(X_train.targets == 1)[0][:n_samples]
)
X_train.data = X_train.data[idx]
X_train.targets = X_train.targets[idx]

# Define torch dataloader with filtered data
train_loader = DataLoader(X_train, batch_size=batch_size, shuffle=True)
adekusar-drl commented 1 year ago

I guess something wrong with the circuit here. The measured qubit is not connected to other qubits.

image
adekusar-drl commented 1 year ago

@mckunkel do need more help in this issue?

mckunkel commented 1 year ago

@adekusar-drl I had thought I sent a comment back giving you accolades for all the wonderful insights and helping me get to an end state, but alas I think I hit enter and didn't notice it didn't go through.

Yes, this issue has been resolved and I thank you very much.

BR MK

adekusar-drl commented 1 year ago

Thanks, appreciated!