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:

In [1]:

```
from qudotpy import qudot
```

*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.

In [2]:

```
print qudot.ZERO
```

In [3]:

```
print qudot.ZERO.ket
```

In [4]:

```
print qudot.ZERO.bra
```

In [5]:

```
print qudot.ONE
```

In [6]:

```
print qudot.ONE.ket
```

In [7]:

```
print qudot.ONE.bra
```

In [8]:

```
print qudot.PLUS
```

In [9]:

```
print qudot.PLUS.ket
```

In [10]:

```
print qudot.PLUS.bra
```

In [11]:

```
print qudot.MINUS
```

In [12]:

```
print qudot.MINUS.ket
```

In [13]:

```
print qudot.MINUS.bra
```

In [14]:

```
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:

- qudot.H: Hadamard Gate
- qudot.X: Pauli-X Gate
- qudot.Z: Pauli-Z Gate
- qudot.Y: Pauli-Y Gate
- qudot.CNOT: CNOT Gate

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.

In [15]:

```
print qudot.H.matrix
```

In [16]:

```
H_zero = qudot.apply_gate(qudot.H, qudot.ZERO)
print H_zero
print H_zero == qudot.PLUS
```

In [17]:

```
H_one = qudot.apply_gate(qudot.H, qudot.ONE)
print H_one
print H_one == qudot.MINUS
```

In [18]:

```
H_one = qudot.apply_gate(qudot.H, H_one)
print H_one
print H_one == qudot.ONE
```

YAY! Quantum computing works!

In [19]:

```
print qudot.X.matrix
```

In [20]:

```
print qudot.Y.matrix
```

In [21]:

```
print qudot.Y.dagger
```

In [22]:

```
X_zero = qudot.apply_gate(qudot.X, qudot.ZERO)
print X_zero
print X_zero == qudot.ONE
```

In [23]:

```
X_plus = qudot.apply_gate(qudot.X, qudot.PLUS)
print X_plus
print X_plus == qudot.PLUS
```

In [24]:

```
Z_zero = qudot.apply_gate(qudot.Z, qudot.ZERO)
print Z_zero
```

In [25]:

```
Z_one = qudot.apply_gate(qudot.Z, qudot.ONE)
print Z_one
```

Now the quantum computing books tell you that these identites are true:

- HXH = Z
- HZH = X
- HYH = -Y

Shall we test this? I think so:

In [26]:

```
test_1 = qudot.QuGate.init_from_mul([qudot.H, qudot.X, qudot.H])
print test_1 == qudot.Z
```

In [27]:

```
test_2 = qudot.QuGate.init_from_mul([qudot.H, qudot.Z, qudot.H])
print test_2 == qudot.X
```

In [28]:

```
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:

- num_qubits: this tells you how many qubits the state is built from. |01000> would be 5 qubits
- hilbert_dimension: this tells you the dimensionality of the associated Hilbert Space, a.k.a how many elements your state vector has

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_vectorinit 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

In [29]:

```
state_1 = qudot.QuState({"0000": .5, "0010": .5, "0100": .5, "0110": .5})
print state_1
```

In [30]:

```
print state_1.num_qubits
print state_1.hilbert_dimension
```

In [31]:

```
print state_1.ket
```

In [32]:

```
print state_1.bra
```

In [33]:

```
state_2 = qudot.QuState.init_from_state_list(["0000", "0010", "0100", "0110"])
print state_2
```

In [34]:

```
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:

In [35]:

```
bell_1 = qudot.QuState.init_from_state_list(["++","--"])
print bell_1
```

In [36]:

```
bell_2 = qudot.QuState({"++": qudot.ROOT2, "--": -qudot.ROOT2})
print bell_2
```

In [37]:

```
bell_3 = qudot.QuState.init_from_state_list(["-+", "+-"])
print bell_3
```

In [38]:

```
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:

In [39]:

```
super_state = qudot.QuState.init_superposition(1)
print super_state
```

In [40]:

```
super_state = qudot.QuState.init_superposition(2)
print super_state
```

In [41]:

```
super_state = qudot.QuState.init_superposition(5)
print super_state
```

And sometimes you just want a bunch of zeros:

In [42]:

```
zeros = qudot.QuState.init_zeros(1)
print zeros
```

In [43]:

```
zeros = qudot.QuState.init_zeros(2)
print zeros
```

In [44]:

```
zeros = qudot.QuState.init_zeros(4)
print zeros
```

In [45]:

```
zeros = qudot.QuState.init_zeros(9)
print zeros
```

The last way to initialize a QuState is with a raw column vector:

In [46]:

```
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

In [47]:

```
state = qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5})
print state
```

In [48]:

```
print state.possible_measurements()
```

In [49]:

```
# possible measurements of first qubit
print state.possible_measurements(qubit_index=1)
```

In [50]:

```
print state.possible_measurements(2)
```

In [51]:

```
print state.possible_measurements(3)
```

In [52]:

```
print state.possible_measurements(4)
```

In [53]:

```
state.measure()
print state
```

In [54]:

```
state = qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5})
print state
```

In [55]:

```
ensemble_10 = [ qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5}) for i in range(0, 10)]
```

In [56]:

```
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
```

In [57]:

```
print tally_measurements(ensemble_10)
```

Lets start to see how increasing the ensemble size changes things:

In [58]:

```
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
```

In [59]:

```
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
```

In [60]:

```
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
```

In [61]:

```
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:

- For 10 states: 60%
- For 100 states: 53%
- For 1000 states: 53.2%
- For 10000 states: 50.79%
- For 100000 states: 50.172%

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

In [62]:

```
state = qudot.QuState({"0000": qudot.ROOT2, "0010": .5, "0110": .5})
print state
```

Now if I apply an X gate to the first qubit:

In [63]:

```
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

In [64]:

```
state.apply_gate(qudot.X, [1])
print state
```

In [65]:

```
# apply to the whole state
state.apply_gate(qudot.X)
print state
```

In [66]:

```
# reverse
state.apply_gate(qudot.X)
print state
```

In [67]:

```
# apply to multiple qubits
state.apply_gate(qudot.X, [1, 3])
print state
```

In [68]:

```
state = qudot.QuState.init_zeros(2)
print state
```

In [69]:

```
state.apply_gate(qudot.H, [1])
state.apply_gate(qudot.CNOT)
print state
```

sweeeeeet! ship it!

In [70]:

```
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
```

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):

- |00> ---> |00> + |11> a.k.a Phi+
- |10> ---> |00> - |11> a.k.a Phi-
- |01> ---> |01> + |10> a.k.a Psi+
- |11> ---> |01> - |10> a.k.a Psi-

In [71]:

```
bell_circuit = qudot.QuCircuit([(qudot.H, [1]), (qudot.CNOT, None)])
```

In [72]:

```
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)
```

In [73]:

```
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)
```

In [74]:

```
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)
```

In [75]:

```
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:

In [76]:

```
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.