qGANS & differentiable circuits

I am working on qGANs project where I have a real circuit, a generator and a discriminator. Below is a summary of the functionality of my code for a simple 1 qubit example:

  1. The real circuit generates a real state - |R>
  2. The generator circuit generates a fake state given some input parameters - |F>
  3. The discriminator performs measurements of expectation values on the real and the fake states.
  4. The discriminator first creates the hermitian phi where $$\phi = \alpha_0 X_0 + \alpha_1 Y_0 + \alpha_2 Z_0 + \alpha_3 I_0 $$ and the expectation value <F|$$\phi$$|F> is measured.
  5. The discriminator then creates hermitian psi where $$\psi = \beta_0 X_0 + \beta_1 Y_0 + \beta_2 Z_0 + \beta_3 I_0 $$ and the expectation value <R|$$\psi$$|R> is measured.
  6. Some other quantities are calculated such as term1 and term2 and a loss function is defined.
  7. The parameters to be tuned to minimise the loss function for the generator are the input parameters to the generator circuit
  8. The parameters to be tuned to minimise the loss for the discriminator are the $$\alpha$$'s & $$\beta$$'s

Below is a short version of my code:

import pennylane as qml
from pennylane import numpy as np
import tensorflow as tf
import itertools    
import functools
import operator
from openfermion import QubitOperator
from openfermion import get_sparse_operator
from sympy.physics.quantum.dagger import Dagger
from pennylane import qchem
from scipy.linalg import expm


n = 1 #no of qubits 

lamb = np.float(2)   
s = np.exp(-1 / (2 * lamb)) - 1
cst1 = (s / 2 + 1) ** 2

    
dev1 = qml.device('default.qubit', wires=1) #real 
dev2 = qml.device('default.qubit', wires=1) #generator

def real(real_weights, wires):
    
    qml.RX(real_weights[0], wires=0)
    qml.RY(real_weights[1], wires=0)
    qml.RZ(real_weights[2], wires=0)
    
def generator(gen_weights, wires):
    
    qml.RX(gen_weights[0], wires=0)
    qml.RY(gen_weights[1], wires=0)
    qml.RZ(gen_weights[2], wires=0)
    

def discriminator(disc_weights, real_weights, gen_weights):
    
    disc_weights_phi = disc_weights[:len(disc_weights)//2]
    disc_weights_psi = disc_weights[len(disc_weights)//2:]

    tuple_list_phi = [(weight, tup[0], tup[1]) for weight, tup in zip(disc_weights_phi, itertools.product(['X', 'Y', 'Z'], range(n)))]
    tuple_list_psi = [(weight, tup[0], tup[1]) for weight, tup in zip(disc_weights_psi, itertools.product(['X', 'Y', 'Z'], range(n)))]

    measurements_phi =  functools.reduce(operator.add, (weight * QubitOperator(f'{a}{n}') for weight, a, n in tuple_list_phi))
    measurements_psi =  functools.reduce(operator.add, (weight * QubitOperator(f'{a}{n}') for weight, a, n in tuple_list_psi))

    iden_phi =  functools.reduce(operator.add, ( (disc_weights_phi[len(disc_weights_phi)-1] ) * QubitOperator(" ") ))
    iden_psi =  functools.reduce(operator.add, ( (disc_weights_psi[len(disc_weights_psi)-1]) * QubitOperator(" ") ))

    phi_of = operator.add(iden_phi, measurements_phi) #phi in the openfermion manner 
    psi_of = operator.add(iden_psi, measurements_psi) #psi in openfermion manner 
    
    phi_matrix = get_sparse_operator(phi_of).todense()
    psi_matrix = get_sparse_operator(psi_of).todense()

    phi = qchem.convert_observable(phi_of)
    psi = qchem.convert_observable(psi_of)
    
    phi_cost = qml.VQECost(generator, phi, dev1, interface="tf")
    psi_cost = qml.VQECost(real, psi, dev2, interface="tf")

    phi_exp = phi_cost(gen_weights)
    psi_exp = psi_cost(real_weights)
    
    gen_sv = dev2.state
    real_sv = dev1.state
    
    A = expm(np.float(-1 / lamb) * phi_matrix)
    B = expm(np.float(1 / lamb) * psi_matrix)
    
    term1 = np.matmul(Dagger(gen_sv) , np.matmul(A, gen_sv))
    term2 = np.matmul(Dagger(real_sv), np.matmul(B, real_sv))
    
    regterm = (lamb / np.e * (cst1 * term1 * term2)).item()
    
    return psi_exp, phi_exp, regterm 

def disc_loss(disc_weights):
    
    psi_exp, phi_exp, regterm = discriminator(disc_weights, real_weights, gen_weights)
    loss = np.real(psi_exp - phi_exp - regterm)
    
    return -loss 


def gen_loss(gen_weights):
     
    generator(gen_weights)

    psi_exp , phi_exp , regterm = discriminator(disc_weights, real_weights, gen_weights)
    loss = np.real(psi_exp - phi_exp - regterm)

    return loss 


real_weights = np.random.uniform(0, 2*np.pi , n*3)
init_gen_weights = np.random.uniform(0, 2*np.pi , n*3)
init_disc_weights = np.random.uniform(0, 2 , 8) 

gen_weights = init_gen_weights
disc_weights = init_disc_weights

opt = tf.keras.optimizers.SGD(0.4)
opt.minimize(gen_loss, gen_weights)

opt = tf.keras.optimizers.SGD(0.4)
opt.minimize(disc_loss, disc_weights)

When I run the optimiser, I am getting: ValueError: Passed in object of type <class 'pennylane.numpy.tensor.tensor'>, not tf.Tensor error. I think this is because the generator circuit is not a qnode and hence not differentiable. But if I do define a qnode above the generator function, the VQE cost method here which I use to calculate the expectation values in the discriminator doesn’t like that. The same error arises for opt.minimize(disc_loss, disc_weights) . Any ideas on how to proceed. Thanks in advance.

Hi @Zohim_Chandani,

I believe the weights that are passed to opt.minimize must be TensorFlow variables. You can simply wrap the init_gen_weights and init_disc_weights with tf.Variable:

gen_weights = tf.Variable(init_gen_weights)
disc_weights = tf.Variable(init_disc_weights)

Also, to get the actual values from the variables you would need to extract them by writing e.g. disc_weights.value() inside of your discriminator function. Furthermore, as you also mentioned, you will need to wrap the functions containing the specific quantum circuits with a QNode, although these cannot contain any classical processing, so you need to be careful when choosing which function should be wrapped.

The QGan tutorial might be useful for you to check out as well, if you haven’t already done so. :slight_smile:

Let me know if this solves the issues you’re having!

Thank you for some helpful initial tips. I decided to approach the problem again from first principles as I realised that there isn’t a direct one to one mapping between my existing code and its implementation on PennyLane.

My current code looks like this:

import pennylane as qml
import numpy as np
import tensorflow as tf

#function to calculate fidelity between 2 states 
def fidelity(rsv, gsv):
    
    rsv_conj = np.conj(rsv)
    fid = np.real(sum(rsv_conj*gsv) * np.conj(sum(rsv_conj*gsv)))

    return fid


dev1 = qml.device('default.qubit', wires=1) #real
dev2 = qml.device('default.qubit', wires=1) #generator


@qml.qnode(dev1, interface="tf")
def real_circuit(weights):
    
    qml.RX(weights[0], wires=0)
    qml.RY(weights[1], wires=0)
    qml.RZ(weights[2], wires=0)

    return qml.expval(qml.PauliX(0)) #how can one also calculate Y(0), Z(0) and I(0)?

@qml.qnode(dev2, interface="tf")
def gen_circuit(weights):
    
    qml.RX(weights[0], wires=0)
    qml.RY(weights[1], wires=0)
    qml.RZ(weights[2], wires=0)

    return qml.expval(qml.PauliX(0)) #how can one also calculate Y(0), Z(0) and I(0)?

def discriminator(disc_weights, real_exp, gen_exp):
    
    psi = disc_weights[0] * real_exp #real_exp is the output from the real circuit 
    phi = disc_weights[1] * gen_exp #gen_exp is the output from the generator circuit 
    
    return psi, phi 
    

def disc_loss(disc_weights):
    
    psi, phi = discriminator(disc_weights, real_exp, gen_exp)
    loss = psi - phi 
    
    return -loss 


def gen_loss(gen_weights):
    
    gen_exp = gen_circuit(gen_weights)
    
    psi, phi = discriminator(disc_weights, real_exp, gen_exp)
    loss = psi - phi 

    return loss 

real_weights = np.random.uniform(0, 2*np.pi, 3)
real_exp = real_circuit(real_weights)
real_sv = dev1.state

init_gen_weights = np.random.uniform(0, 2*np.pi, 3)
gen_weights = tf.Variable(init_gen_weights)
gen_exp = gen_circuit(gen_weights)
gen_sv = dev2.state

init_disc_weights = np.random.uniform(0, 2*np.pi, 2)
disc_weights = tf.Variable(init_disc_weights)

print('initial fidelity is ' , fidelity(real_sv, gen_sv))
print('real weights ', real_weights)
print('initial gen weights are ', init_gen_weights)
print('initial disc weights are ', init_disc_weights)

opt = tf.keras.optimizers.SGD(0.4)

cost = lambda: disc_loss(disc_weights)

for step in range(50):
    opt.minimize(cost, disc_weights)
    if step % 5 == 0:
        cost_val = cost().numpy()
        print("Step {}: cost = {}".format(step, cost_val))

cost = lambda: gen_loss(gen_weights)

for step in range(50):
    opt.minimize(cost, gen_weights)
    if step % 5 == 0:
        cost_val = cost().numpy()
        print("Step {}: cost = {}".format(step, cost_val))

This works and outputs a loss which is minimised using the optimiser but is, of course, incorrect as a bunch of terms are missing which go into my calculation of the loss function.

My question now is: how can I also calculate the Y, Z and I expectation values as the output of the real and generator circuits?
I have looked at qml.map() and added the following section:

dev1 = qml.device('default.qubit', wires=1) #real

@qml.qnode(dev1, interface="tf")
def real_circuit(weights):
    
    qml.RX(weights[0], wires=0)
    qml.RY(weights[1], wires=0)
    qml.RZ(weights[2], wires=0)

    return qml.expval(qml.PauliX(0)) #how can one also calculate Y(0), Z(0) and I(0)?

obs_list = [qml.PauliX(0) , qml.PauliY(0), qml.PauliZ(0) , qml.Identity(0)]
qnodes = qml.map(real_circuit, obs_list, dev1, measure="expval")
real_weights = np.random.uniform(0, 2*np.pi, 3)
qnodes(real_weights)

but get the following error : QuantumFunctionError: Unknown quantum function parameter 'wires'.

Any ideas/pointers on how to solve this? Thanks once again.

Hi @Zohim_Chandani,

Your approach seems to be on track. You can use qml.map() to create a collection of QNodes (aka a QNodeCollection) containing all the QNodes returning the corresponding observable measurement in the obs_list.

When using qml.map() you only need to supply a template function, and not a QNode, skipping the decorator as well as the final measurement return (since qml.map() will create those for you; one for each observable in obs_list).

Also note that the template must have the following signature:

template(params, wires, **kwargs)

as described in the documentation for qml.map().


The following works for me:

dev1 = qml.device('default.qubit', wires=1) #real

def real_circuit(weights, wires, **kwargs):
    
    qml.RX(weights[0], wires=0)
    qml.RY(weights[1], wires=0)
    qml.RZ(weights[2], wires=0)

obs_list = [qml.PauliX(0) , qml.PauliY(0), qml.PauliZ(0) , qml.Identity(0)]
qnodes = qml.map(real_circuit, obs_list, dev1, measure="expval")
real_weights = np.random.uniform(0, 2*np.pi, 3)
qnodes(real_weights)

Let me know if you have any other issues or questions!

Once again, thank you for following up with my issues.

The problem I am now having is something to do with the conversion of a tf.Variable() to a numpy array using .numpy() and vice versa. I have also tried .value() but the qnodes() function doesn’t seem to accept that.
Minimising the disc_loss works but the gen_loss throws a ValueError: No gradients provided for any variable: ['Variable:0']. error.

I have provided my code below which may give you a better insight. Any feedback is much appreciated.

import pennylane as qml
import numpy as np
import tensorflow as tf


dev1 = qml.device('default.qubit', wires=1) #real
dev2 = qml.device('default.qubit', wires=1) #generator

obs_list = [qml.PauliX(0) , qml.PauliY(0), qml.PauliZ(0) , qml.Identity(0)]


def real_circuit(weights, wires, **kwargs):
    
    qml.RX(weights[0], wires=0)
    qml.RY(weights[1], wires=0)
    qml.RZ(weights[2], wires=0)

qnodes_real = qml.map(real_circuit, obs_list, dev1, measure="expval",  interface="tf")


def gen_circuit(weights, wires, **kwargs):
    
    qml.RX(weights[0], wires=0)
    qml.RY(weights[1], wires=0)
    qml.RZ(weights[2], wires=0)

qnodes_gen = qml.map(gen_circuit, obs_list, dev2, measure="expval",  interface="tf")


def discriminator(disc_weights, real_exp, gen_exp):
    
    psi = disc_weights[0] * real_exp[0] + disc_weights[1] * real_exp[1] + disc_weights[2] * real_exp[2] + disc_weights[3] * real_exp[3] 
    phi = disc_weights[4] * gen_exp[0] + disc_weights[5] * gen_exp[1] + disc_weights[6] * gen_exp[2] + disc_weights[7] * gen_exp[3] 
    
    return psi, phi 
    

def disc_loss(disc_weights):
    
    psi, phi = discriminator(disc_weights, real_exp, gen_exp)
    loss = psi - phi 
    
    return -loss 


def gen_loss(gen_weights):
    
    gen_exp = qnodes_gen(gen_weights.numpy())
    
    psi, phi = discriminator(disc_weights, real_exp, gen_exp)
    loss = psi - phi 

    return loss 

real_weights = np.random.uniform(0, 2*np.pi, 3)
real_exp = qnodes_real(real_weights)#calculate the expectation values from the real circuit 

init_gen_weights = np.random.uniform(0, 2*np.pi, 3)
gen_weights = tf.Variable(init_gen_weights)
gen_exp = qnodes_gen(gen_weights.numpy()) #calculate the expectation values from the generator 

init_disc_weights = np.random.uniform(0, 2*np.pi, 8)
disc_weights = tf.Variable(init_disc_weights)

print('real weights ', real_weights)
print('initial gen weights are ', init_gen_weights)
print('initial disc weights are ', init_disc_weights)

opt = tf.keras.optimizers.SGD(0.4)

cost = lambda: disc_loss(disc_weights)

for step in range(50):
    opt.minimize(cost, disc_weights)
    if step % 5 == 0:
        cost_val = cost().numpy()
        print("Step {}: cost = {}".format(step, cost_val))

cost = lambda: gen_loss(gen_weights)

for step in range(50):
    opt.minimize(cost, gen_weights)
    if step % 5 == 0:
        cost_val = cost().numpy()
        print("Step {}: cost = {}".format(step, cost_val))



Ignore the above issue I raised. The qnodes() function does accept `tf.Variable’ but for some reason yesterday, it was throwing up an error. The problem now seems to have been resolved. Thankyou.

2 Likes