Welcome to the QuDotPy Tutorial! This is meant to be an interactive tutorial where you learn about the QuDotPy library through examples. Please see the README file for installation instructions, the only external dependency is numpy. Once you have QuDotPy on your machine its time to start exploring quantum computation! This is not an introduction to quantum computing or quantum mechanics (Although that is something I am working on, stay tuned...). This is meant to show you how to use QuDotPy to DO quantum computing. QuDotPy can be used to explore basic qubits, build arbitrary quantum states, emulate measurement/collapse, apply quantum gates to quantum states, build your own gates and to build quantum circuits. So lets get started and import qudotpy:
from qudotpy import qudot
The most fundamental property of quantum computing is the qubit. This is a two level quantum system which is a bunch of fancy words for saying it is something that can have two possible values. You can call those two values what you like, but the standard is to call them |0> and |1> (The Computational Basis) or |+> and |-> (The Hadamard Basis). QuDotPy supports qubits through the QuBit class. We support both the computation and Hadamard basis. The QuBit class is meant to represent a single-qubit system. Multiple-qubit systems are described further down this tutorial. QuDotPy displays qubits using Dirac Notation. They are stored as numpy arrays and the underlying array can be access with the ket and bra properties.
print qudot.ZERO
print qudot.ZERO.ket
print qudot.ZERO.bra
print qudot.ONE
print qudot.ONE.ket
print qudot.ONE.bra
print qudot.PLUS
print qudot.PLUS.ket
print qudot.PLUS.bra
print qudot.MINUS
print qudot.MINUS.ket
print qudot.MINUS.bra
print qudot.PLUS == qudot.PLUS
print qudot.PLUS == qudot.ONE
print qudot.ONE != qudot.MINUS
print qudot.ONE != qudot.ONE
print qudot.ONE == qudot.QuBit("1")
print qudot.MINUS == qudot.QuBit("-")
Quantum gates are the main logical units in quantum computing, much like the logic gates of classical computing (AND, OR, XOR etc). QuDotPy has predefined gates:
Also, you can create custom quantum gates via the QuGate class. There are multiple ways to initialize a custom QuGate. You can initialize through a string representation of the gate, through a multiplication of existing gates, or through a tensor product of existing gates. For example, the string representation for the Z gate is ("1 0; 0 -1"). We do require that quantum gates are unitary transformations. This is again fancy talk that says the gate changes (transforms) the qubit into another qubit but preserves the angle between qubits. A QuGate is represented as a numpy matrix. You can access two properties: matrix and dagger. matrix returns the matrix representation of the gate and dagger returns the Hermitian of the matrix.
You apply a gate to a qubit or a quantum state (quantum states are covered later). To apply a gate to a qubit and return the result use the module level apply_gate method. This will apply the gate to the input and return a new state as output. Lastly, we override the == and != operators so you can test gate equality.
print qudot.H.matrix
H_zero = qudot.apply_gate(qudot.H, qudot.ZERO)
print H_zero
print H_zero == qudot.PLUS
H_one = qudot.apply_gate(qudot.H, qudot.ONE)
print H_one
print H_one == qudot.MINUS
But the Hadamard Gate is its own inverse, so we should be able to run the computation again and get back the original result
H_one = qudot.apply_gate(qudot.H, H_one)
print H_one
print H_one == qudot.ONE
YAY! Quantum computing works!
print qudot.X.matrix
print qudot.Y.matrix
print qudot.Y.dagger
X_zero = qudot.apply_gate(qudot.X, qudot.ZERO)
print X_zero
print X_zero == qudot.ONE
X_plus = qudot.apply_gate(qudot.X, qudot.PLUS)
print X_plus
print X_plus == qudot.PLUS
Z_zero = qudot.apply_gate(qudot.Z, qudot.ZERO)
print Z_zero
Z_one = qudot.apply_gate(qudot.Z, qudot.ONE)
print Z_one
Now the quantum computing books tell you that these identites are true:
Shall we test this? I think so:
test_1 = qudot.QuGate.init_from_mul([qudot.H, qudot.X, qudot.H])
print test_1 == qudot.Z
test_2 = qudot.QuGate.init_from_mul([qudot.H, qudot.Z, qudot.H])
print test_2 == qudot.X
minus_Y = qudot.QuGate.init_from_str('0 1j; -1j 0')
test_3 = qudot.QuGate.init_from_mul([qudot.H, qudot.Y, qudot.H])
print minus_Y == test_3
print test_3 == qudot.Y
Well, I guess they were right...
So far we have been working with single-qubit systems represented by the QuBit class, such as |0> or |+>. Now it's time to have some real fun with multiple-qubit systems. Multiple-qubit systems are combinations of single-qubit systems, a|001> + b|100> + c|111> is an example of such a system. QuDotPy has a class called QuState to represent such systems. QuState is the main workhorse of QuDotPy, it supports creating multiple-qubit states from various input, can make measurement predictions, can measure and collapse the state, and can apply a gate to the state. There are two basic properties that give you information about the size of the QuState:
So lets see QuState in action by initializing some quantum states. There are five different ways to init a QuState:
init from a state map QuState(state_map): This is the default initialization method as is the most extensible. The input is a map whose keys are the states and values are the probability amplitudes.
init from a list of states QuState.init_from_state_list([list_of_states]): This is a convenience class method that will create a QuState with the states specified in list_of_states and equal probability amplitudes.
init superposition QuState.init_superposition(dimension): This is a convenience class method that will create a QuState that is a superposition of all states in the Hilbert space of the specified dimension
init from vector QuState.init_from_vector(column_vector): This is a convenience class method that will create a QuState from the specified column_vector, which is expected to have the form of a numpy column_vector
init zeros QuState.init_zeros(num_bits): This is a convenience class method that will create a QuState which has just one state, the |0...n> state. The number of zeros is determined by num_bits
state_1 = qudot.QuState({"0000": .5, "0010": .5, "0100": .5, "0110": .5})
print state_1
print state_1.num_qubits
print state_1.hilbert_dimension
print state_1.ket
print state_1.bra
state_2 = qudot.QuState.init_from_state_list(["0000", "0010", "0100", "0110"])
print state_2
print state_1 == state_2
QuState is built on top of the computational basis. You can initialize with Hadamard elements |+>, |-> but under the scenes it will be converted to the computational basis. This actually lets us test something in quantum computing books. They claim that the bell states are the same in both computational and Hadamard basis.
So 1/sqrt(2)|++> + 1/sqrt(2)|--> = 1/sqrt(2)|00> + 1/sqrt(2)|++>
I find this hard to believe... I mean common. Let's test it:
bell_1 = qudot.QuState.init_from_state_list(["++","--"])
print bell_1
bell_2 = qudot.QuState({"++": qudot.ROOT2, "--": -qudot.ROOT2})
print bell_2
bell_3 = qudot.QuState.init_from_state_list(["-+", "+-"])
print bell_3
bell_4 = qudot.QuState({"-+": qudot.ROOT2, "+-": -qudot.ROOT2})
print bell_4
Let this be a lesson to you: Nature does not care about your intuition.....
Moving on, sometimes you want an even superposition of all states in a Hilbert space:
super_state = qudot.QuState.init_superposition(1)
print super_state
super_state = qudot.QuState.init_superposition(2)
print super_state
super_state = qudot.QuState.init_superposition(5)
print super_state
And sometimes you just want a bunch of zeros:
zeros = qudot.QuState.init_zeros(1)
print zeros
zeros = qudot.QuState.init_zeros(2)
print zeros
zeros = qudot.QuState.init_zeros(4)
print zeros
zeros = qudot.QuState.init_zeros(9)
print zeros
The last way to initialize a QuState is with a raw column vector:
state = qudot.QuState.init_from_vector([[qudot.ROOT2],
[0],
[0],
[qudot.ROOT2]])
print state
As we all know, the strangest thing about quantum mechanics is measurements. We know that we can only predict probabilistic outcomes of measurements. The QuState incorporates this into it's design. You can ask for the possible measurement of a state and you will get back a map where the key is the possible state and the value is the probability of getting that state. Also, you can ask about the possible measurements of a specific qubit. For example, if your state is a|0100> + b|1101> + c|1111> and you ask the for the possible measurements of qubit 2, you will get |1> with probability 1. Whereas qubit 1 can be |0> or |1>.
Also, you can perform a measurement on the state. A measurement will collapse the state. So after you perform a measurement the QuState will be in a definite state not a superposition. Note that the QuState is designed to respect the probabilities when collapsing. That means if you have an ensemble of QuStates, as the ensembles get larger then the probability of collapsing to a specified state will approach the |amplitude|^2
state = qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5})
print state
print state.possible_measurements()
# possible measurements of first qubit
print state.possible_measurements(qubit_index=1)
print state.possible_measurements(2)
print state.possible_measurements(3)
print state.possible_measurements(4)
state.measure()
print state
Now lets explore measuring ensembles. As we noted earlier, as our ensemble gets larger than the probability of measuring a specific state approaches its amplitude magnitude squared. However, if you have very few states then the probabilities will not be exact.
state = qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5})
print state
Here we expect to see state |0000> 50% of the time, state |0010> 25% of the time and state |0110> 25% of the time
ensemble_10 = [ qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5}) for i in range(0, 10)]
def tally_measurements(ensemble):
results_map = {}
for state in ensemble:
state.measure()
key = str(state)
if key in results_map:
results_map[key] = results_map[key] + 1
else:
results_map[key] = 1
return results_map
print tally_measurements(ensemble_10)
So, not perfect. We expected 5, 2.5, 2.5 :-) If you do not get this joke think about what quantum mechanics means!
Lets start to see how increasing the ensemble size changes things:
ensemble_100 = [ qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5}) for i in range(0, 100)]
print tally_measurements(ensemble_100)
del ensemble_100
ensemble_1000 = [ qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5}) for i in range(0, 1000)]
print tally_measurements(ensemble_1000)
del ensemble_1000
ensemble_10000 = [ qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5}) for i in range(0, 10000)]
print tally_measurements(ensemble_10000)
del ensemble_10000
ensemble_100000 = [ qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5}) for i in range(0, 100000)]
print tally_measurements(ensemble_100000)
del ensemble_100000
So hopefully this explains how your probabilities approach theory as your ensemble gets larger. Its basically the law of large numbers. This is a summary of the results for state |0000>. The theory predicts we should get this state 50% of the time, as the ensemble gets larger, we get closer to the theoretical result:
Ok great, now we are experts on QuStates! So far we have not done any actual quantum computing with QuStates. Let's remedy that! To do quantum computing we need to start applying gates to QuStates. A QuState has the apply_gate(qu_gate, qubit_list=None) method. This method applies a QuGate (such as qudot.X, qudot.Z) to the entire state OR to specific qubits of the state.
One important thing to note is that apply_gate will automatically scale your QuGate to the appropriate dimension. For example, qudot.X is a 2x2 matrix, but what if your state is 0.707|0000> + 0.5|0010> + 0.5|0110>? This would require qudot.X to be a 16x16 matrix. Don't worry! QuDotPy to the rescue! QuDotPy is smart, it recognizes this fact and tensors the gates with themselves until the appropriate dimension is reached. In a similar way, we are able to build larger matrices to apply a gate to a specific qubits. This way you can apply a QuGate to only the 1st and 3rd qubits if that is what your heart desires. Lets see this in action
state = qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5})
print state
Now if I apply an X gate to the first qubit:
state.apply_gate(qudot.X, [1])
print state
Yay! it worked. NOTE THAT THE STATE CHANGED! It does not return a new state! Lets reverse this
state.apply_gate(qudot.X, [1])
print state
# apply to the whole state
state.apply_gate(qudot.X)
print state
# reverse
state.apply_gate(qudot.X)
print state
# apply to multiple qubits
state.apply_gate(qudot.X, [1, 3])
print state
Now quantum computing books tell us that if we have the state |00> and we apply H to the first qubit, then pass the result through a CNOT gate we will get a bell state |00> + |11>. Is that true?
state = qudot.QuState.init_zeros(2)
print state
state.apply_gate(qudot.H, [1])
state.apply_gate(qudot.CNOT)
print state
sweeeeeet! ship it!
test_input = qudot.QuState.init_zeros(2)
test_input.apply_gate(qudot.H, [1])
test_input.apply_gate(qudot.CNOT)
test_input.apply_gate(qudot.H, [1])
print test_input
That does it for QuState! I think after these last two examples you see how to develop quantum circuits. However, QuDotPy makes it even easier to build circuits! It also allows you to step through a quantum circuit debugger style! Lets see how
Now you should have gotten the idea from last section that it will be very easy to implement a quantum circuit: just use QuState.apply_gate() repeatedly for your design. You are pretty much right, but we can do even better. We would like to abstract away the actual circuit from the input state. That way we can run the circuit on different input states and examine the output. Also, wouldn't it be cool if you can step through a quantum circuit like you step through a debugger in your code? I think so! That is basically how QuCircuit was born.
The main idea behind QuCircuit is that a quantum circuit can be thought of as a list of operations. Each operation tells you to apply a quantum gate to certain qubits or an entire state. So, QuCircuit is initialized with a list of tuples. Each tuple represents a single operation and has the form (QuGate, qubit_list or None). The first element of the tuple is the QuGate to apply. The second argument is either a qubit_list to apply the gate to or None, which will apply the QuGate to the entire state.
To run a QuCircuit you must first set the in_qu_state attribute. This gives the input state that the circuit will run on. Then you just call run_circuit() and in_qu_state will have the result. You can also step through the circuit one operation at a time using the step_circuit() method. This will return your current index on the operations list. You can always check on which operation you are on by inspecting the step_op_index attribute. Also, if you want to reset the circuit to the beginning you can call the reset_circuit() method.
Lets look at an example by making a circuit that produces Bell states. This circuit has the following mappings (excluding normalization constants):
bell_circuit = qudot.QuCircuit([(qudot.H, [1]), (qudot.CNOT, None)])
input_state = qudot.QuState.init_from_state_list(["00"])
print "in: " + str(input_state)
bell_circuit.in_qu_state = input_state
output = bell_circuit.run_circuit()
print "out: " + str(output)
input_state = qudot.QuState.init_from_state_list(["10"])
print "in: " + str(input_state)
bell_circuit.in_qu_state = input_state
output = bell_circuit.run_circuit()
print "out: " + str(output)
input_state = qudot.QuState.init_from_state_list(["01"])
print "in: " + str(input_state)
bell_circuit.in_qu_state = input_state
output = bell_circuit.run_circuit()
print "out: " + str(output)
input_state = qudot.QuState.init_from_state_list(["11"])
print "in: " + str(input_state)
bell_circuit.in_qu_state = input_state
output = bell_circuit.run_circuit()
print "out: " + str(output)
Ship it! now lets show an example of stepping through the circuit:
bell_circuit.in_qu_state = qudot.QuState.init_zeros(2)
print str(bell_circuit.step_op_index) + " " + str(bell_circuit.in_qu_state)
bell_circuit.step_circuit()
print str(bell_circuit.step_op_index) + " " + str(bell_circuit.in_qu_state)
bell_circuit.step_circuit()
print str(bell_circuit.step_op_index) + " " + str(bell_circuit.in_qu_state)
When the step_op_index goes back to 0, the circuit is done and back at the beginning.
I had a lot of fun both coding QuDotPy and writing this usage tutorial. I hope you will have just as much fun using it! If you find any errors/typos please email me at psakkaris@gmail.com
Perry Sakkaris