Closed josh146 closed 6 years ago
Currently, the user defined quantum function takes an arbitrary number of positional arguments:
def circuit(x,y,z):
Each of these can either be a variational parameter or input data, depending on how the function is defined in the cost function.
Sneaky edit: the following suggestions seem more apt for the cost function, not the quantum node/quantum function. Perhaps the quantum function should remain arbitrary and up to the user, and only the cost function is restricted.
Some suggestions are:
Use two positional arguments, each accepting an array. The first accepts only parameters, the second only input data.
def circuit(params, input_data):
While easier to code, perhaps too restrictive for the user, who is used to any combination of arguments in a user defined function?
Use positional arguments for the parameters to be varied, but use a keyword argument accepting an array for input data:
def circuit(x, y, z, *, input_data):
Support only keyword arguments, each accepting an array:
def circuit(*, parameters, input_data):
Downsides of 2. and 3. are that the user would have to use the (not commonly known) syntax *
for specifying keyword-only arguments.
Another factor in the design is how to get autograd to play nicely; we want the wrapped quantum function to be called exactly as it is defined, to avoid confusing the user.
At the moment, my qfunc
decorator works as follows:
def qfunc(device):
def qfunc_decorator(func):
qnode = QNode(func, device)
@wraps(func)
def wrapper(*args, **kwargs):
return qnode(*args, **kwargs)
return wrapper
return qfunc_decorator
i.e. if the function is defined with multiple positional arguments, it must be called with a single argument, that is unwrapped behind the scenes. This is confusing, but for some reason, the only way I could get autograd to work with multiple arguments; otherwise I get ArgNum
errors.
@smite, do you have any suggestions?
I am very much in favour of 1. However, we should keep in mind that a circuit might not take any data, or even any parameters. For example when the quantum node is already optimized and one wants to now optimize a classical node, the parameters of the quantum node are fixed. Or if the quantum node is part of the ML model but not trainable (think of the kernel methods where the quantum node takes data and computes kernel values that are part of a classically trained model).
Actually, thinking about it some more, do we need to put constraints on the arguments of the quantum function? Perhaps we would need constraints on the arguments to the cost function, since that is being passed on to the optimizer.
My suggestion is to leave the QNode fairly abstract, a R^n \to R^m mapping, with one input argument and one output argument, which are NumPy 1D arrays. The classical cost function can distinguish between trainable parameters and data. See for example tests/test_optimization.py
, especially the map_data()
function there.
Programmatically, there is not really any need to distinguish between data and parameters for quantum functions. Within the variational approach, these are all used the same way: as arguments to one-parameter gates. Later on, we can tell the optimizer which we are optimizing (and this retains the flexibility to 'freeze' some parameters while updating others). Let's remember that something being a "parameter" of the quantum circuit does not require that it is trainable/updatable. A parameter is simply something which determines which function from the class of possible functions is the one that is applied to the inputs.
However, there is an argument for making things conceptually clear for the user. Users might not be too familiar with the ideas developing around variational circuits, especially the notion of circuit gradients. Explicitly specifying which parts of the circuit are parameters will make it clearer what is happening with the automatic differentiation.
@smite I am thinking along the same lines, but does the input have to be a 1D array? Doesn't autograd support tuples, lists, nested inputs as well?
I think there could be two options. Make circuit(...) something that has fully user defined inputs, or use keyword arguments "weights" and "data" (which default to None). If it is user defined, do we get into trouble because computing the gradient of a QNode assumes that all arguments are trainable parameters?
By the way, can we call all trainable parameters simpy "weights"? That is clearly separated from circuit parameters, hyperparameters and has an intuitive meaning in ML...
@smite: an example based on my earlier comment: There is an neural network example in autograd where they structure the params into a list of tuples (rather than a 1D array). This does not impact the automatic differentiation in any way, but it makes things much easier to parse for anyone reading the code. Can we do something similar for a Qnode, where the first argument is the params, but it can come in any form that auto grad supports, e.g.:
def my_quantum_function(params):
# params is a list of (weights, bias) tuples,
# i.e., [(W_1, b_1),(W_2, b_2),(W_3, b_3),...]
for W, b in params:
Sgate(W)
Dgate(b)
...
This would certainly make things more flexible for the user
As @smite mentions above, we likely want to allow for a quantum node to output a vector in R^m. This has multiple use-cases:
A smart way to do this is for us to make measurements/expvals a separate abstraction. All the gates a user declares in my_quantum_function
get put in the same same circuit, but the user can declare multiple measurements that the device is responsible for evaluating expectation values of. The device should evaluate each of these separately (on the same input circuit), then the plugin stitches them together and returns the collection back to openqml as a list/array.
Sketch pseudocode:
def my_quantum_function(...):
# declare the gates of the circuit
RotX(...)
RotY(...)
CNOT(...)
# now declare the measurements that should be performed
# on the above circuit. The following code should return a
# 3-dimensional vector of expectation values
return Expectation([SigmaX, SigmaY, SigmaZ])
Two quick comments, if I may:
Maybe Expectation could have attributes "shots", "measurement_results" (listing all samples of the measurement) "expectation" (averaging over samples if shots>0, else the simulated exact value), "exact" (True if it is the exact value in case of simulations)
Sorry just contributing my two cents...
Some thoughts from my side:
circuit()
up to the user? The optimizer should only care about cost()
. A user should in principle even be able to define cost()
without referring to a circuit, no?*
syntax.Expectation()
can then be either a description of the statistics (e.g., expectation value and maybe variance), or a specimen from the statistics, i.e., a finite set of samples, then maybe could call it measurement_statistics()
?Quick comment on number 3: The dictionary idea is indeed important for more complex machine learning tasks, but if I am not mistaken this would cause major fixes because we cannot apply autograd directly to cost. I thought about this at length for the QMLT and came to the conclusion that within a realistic 5-year timeframe, having weights as nested arrays or tuples is enough for the type of experiments people do. In 5 years we could always wrap a more abstract argument type around everything. What do you think @cgogolin?
If this causes major headaches with autograd, then let's forget about it. I naively thought that it should be easy to serialize a dictionary into an array, then use autograd, and then de-serialize the array back into a dictionary.
@cgogolin I agree with your points 1, 2. We should be able to support both def circuit(x,y,z)
, def circuit(params)
, and any combination of the two (even including keyword arguments - these could be automatically excluded/included from autograd); the only constraint would be ensuring all work with autograd.
@co9olguy, regarding your proposal for expectation values - it makes sense to implement multiple expectation values on different wires, that can be run by a single hardware device:
def circuit():
...
qm.expectation.PauliZ(0)
qm.expectation.PauliZ(1)
qm.expectation.PauliZ(3)
which would then automatically return a vector to the user. (Or perhaps we could use a return statement? more of an interface decision).
However, for multiple expectation values on the same wire, I would rather the user define a new device/qnode altogether, like @mariaschuld does in her VQE example:
def ansatz(params):
qm.Rot(...)
qm.CNOT(...)
@qfunc
def circuit_meas_x(params):
ansatz(params)
qm.expectation.PauliX(1)
@qfunc
def circuit_meas_z(params):
ansatz(params)
qm.expectation.PauliZ(1)
This already works with the current refactor, and has the benefits that:
Thoughts?
Concerning multiple measurements on different wires: Keep in mind that some backends/plugins only support one measurement. IBM for example only allows to measure all qubits at once and only exactly one time. For this backend in the ProjectQ plugin is thus only have one observable available wich I would call "AllPauliZ" and I somehow need to "return" (i.e., save to self._out
) multiple values from/in execute()
. How shall I do that? What are the expected/legal types for self._out
after execute()
?
Good point. self._out
should ideally be a list, in order to support multiple measurements
I'm hearing arguments in favour of one Qnode <-> one measurement, and there are also suggestions (my own included) to have multiple measurements per Qnode.
As @cgogolin points out, we will be constrained by what the plugins/backends actually allow. Since our main qubit plugin only supports one measurement, I'm thinking it it is the path of least resistance to force Qnodes to only have one measurement for our initial version. Multiple measurements can be stitched together from multiple Qnode outputs.
Any contrary opinions on this particular point?
+1 for only one measurement, but keep in mind that even a sigle measurement can return multiple values.
Maybe we can speak about (maximum) one measurement per subsystem per QNode. We do not get into trouble with non-commuting measurements then.
Ok let's go with one Qnode <-> max one expectation value per subsystem as Maria suggested
Commit 8a3237b added the return statement return qm.expectation.PauliZ(1)
to circuit()
, but, as far as I can tell, PauliZ(1)
not does not actually return anything. Can you please update me about the outcome of the discussion on return statements in circuit()
?
@josh146 told me that the plan is to "enforce" the return
via documentation but leave it purely as sytactic sugar, i.e., it doesn't actually do anything. I think I like this very much! It only gets a bit subtle when when multiple values have to be returned. How is the syntax supposed to look like in this case?
Maybe like this?
def circuit():
...
return [qm.expectation.PauliZ(0), qm.expectation.PauliZ(1), qm.expectation.PauliZ(3)]
This relates to my question on the lifecycle of plugins. Are they expected to be able to cope with multiple calls to expectation()? Could it happen that if the user uses some fancy data type to wrap the multiple return values (instead of an
[...]array) that the calls to
expectation()` do not happen in the right order?
The way it currently works, where return
is simply syntactic convention (but not explicitly used in the codebase), this would also work fine with minimal adjustments. Consider the case above:
def circuit():
...
return [qm.expectation.PauliZ(0), qm.expectation.PauliZ(1), qm.expectation.PauliZ(3)]
On the function return, Python will evaluate the three expectation calls from left-to-right. The simplest solution is to modify device._observe
to act in a similar manner to device._queue
, and 'queue' the observable for each wire when called.
Since Python will always evaluate them from left-to-right, this will preserve the order provided by the user, and the plugin will return the resulting expectation values in the same order when looping through device._observe
.
I would suggest making the return statement functional. This can be done with the syntax
return [qm.expectation.PauliZ(0), qm.expectation.PauliZ(1), qm.expectation.PauliZ(3)]
qm.expectation.PauliZ(3)
returns an Expectation
instance, so the circuit-defining function returns a list of them. The caller can go through that list, and store in each Expectation
instance its index in the list. This enables Device.execute_queued()
to construct a corresponding Numpy array of the measured expectation values and return it.
Edit: The difference to Josh's suggestion above is that only the expectation values that are explicitly returned affect the output of the circuit.
@smite I am currently implementing exactly that!
Preliminarily implemented in a42497b84aae9d4f8559378d70cce2bf801f8b75. Note that this makes the return
statement required, simply by checking that it is provided. This is a temporary solution to #31.
Closing this issue as the API is largely established at this point. If there are any bugs, report them in separate issues
This issue is related to how we pass/call the quantum function associated with a
QNode
. As far as I can see, we want to provide maximum freedom to the user, but are constrained by autograd/design decisions/python decorators.From @mariaschuld: