Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quantum-kittens
GitHub Repository: quantum-kittens/platypus
Path: blob/main/notebooks/intro/visualizing-entanglement.ipynb
3855 views
Kernel: Python 3

Visualizing Entanglement

from hello_qiskit import run_puzzle

In the following set of exercises we will introduce and use a visualization for two qubit states. With this you'll be able to set up your own version of a famous experiment: proving the uniqueness of quantum mechanics through Bell's inequalities.

Getting to know your qubit

Exercise 1: Flipping a Qubit

To begin, we'll start with a single qubit. Run the following code cell to see how we visualize a qubit.

puzzle = run_puzzle(0)

Here you see two lines which represent the probabilities for different measurement outcomes. The vertical one represents standard measurements (also known as 'z measurements'), and the horizontal one represents the 'x measurements' discussed in the last section. In both cases, the length of the purple bar represents the probability of getting the outcome 1.

In the state above, we see no purple bar in the vertical line. This tells us that the probability of getting a 1 for a z measurement is zero. For the horizontal line, the purple bar takes up half of the line, and so the probability of getting a 1 for an x measurement is 1/21/2. With this information, we can indentify which state is being represented here: These results are exactly what we saw in the last section for the ∣0⟩|0\rangle state.

To see what the ∣1⟩|1\rangle state looks like, you can apply an x gate. Use the buttons below the visualization to do this. You'll need to first select the x gate, and then qubit q[0] that we are applying it to, and then finally press 'Apply operation'.

Try doing this three times to flip between the ∣0⟩|0\rangle state and the ∣1⟩|1\rangle state.

Exercise 2: Swapping the Axes

The state visualized below has a random outcome for a z measurement, but is certain to output a 0 for an x measurement. We can therefore identify it is the ∣+⟩|+\rangle.

You might notice that the ∣0⟩|0\rangle state and the ∣+⟩|+\rangle state have strong similarities. Specifically, whatever is true for one of these states when making z measurements is true for the other when making x measurements. The ∣1⟩|1\rangle state and the ∣−⟩|-\rangle state also have the same relationship.

With this in mind, let's think about the effects of the HH gate,

H∣0⟩=∣+⟩,H∣1⟩=∣−⟩H∣+⟩=∣0⟩,H∣−⟩=∣1⟩.H |0\rangle = |+\rangle, \quad H |1\rangle = |-\rangle\\H |+\rangle = |0\rangle, \quad H |-\rangle = |1\rangle.

This turns the ∣0⟩|0\rangle state into the ∣+⟩|+\rangle state and vice-versa, and also the the ∣1⟩|1\rangle state into the ∣−⟩|-\rangle state and vice-versa. Effectively, it reverses the roles of the z measurement and the x measurement. This leads to a simple effect in this visualization: it swaps the two lines.

Run the cell below, apply the h gate three times and look at this in action.

puzzle = run_puzzle(1)

Exercise 3: A Rotation

Now let's try a rotation around the y axis on the Bloch sphere, using the gate ry(pi/4). Apply four of these and see what happens.

puzzle = run_puzzle(2)

The effects here are not so easy to understand as the Hadamard. But there is a way to make it more intuitive. All we need to do is draw the two lines on top of each other and place a point where their levels intersect.

Since this is related to a concept known as the Bloch sphere, we'll use a command called 'Bloch' to make this happen. Try it out below, as well as a few more ry gates. You should see that the gate effectively rotates the point, with the bars on the lines changing accordingly.

puzzle = run_puzzle(3)

Before we move on, note that you can retrieve the Qiskit circuit object for the circuits we are creating by using the puzzle.get_circuit() method. You can do this for the full circuit, which includes the gates used to prepare the initial state of the puzzle.

puzzle.get_circuit(use_initializer=True).draw()

Or you can just get the circuit with the gates added by your own moves.

puzzle.get_circuit().draw()

Exercise 4: A Second Qubit

Much of the grid has been empty so far. Some of the space is reserved for a second qubit over on the left. Again this is represented with a vertical line for z measurement probabilities and a horizontal line for x measurements.

To get acquainted with this new qubit, use the x, h and ry(pi/4) two times each. You can also use the 'Bloch' command.

puzzle = run_puzzle(4)

Exercise 5: Two Qubits at Once

To visualize two qubits at once there is even more information that we need to keep track of. For this we add four new lines.

Of these new lines, the bottom one represents p(∣01⟩)+p(∣10⟩)p(|01\rangle)+p(|10\rangle): the probability that the qubits will give different results for a z measurement of both. Since the initial state below is ∣01⟩|01\rangle, this new line is purple to represent that they are certain to disagree.

The new line at the top represents the same thing but for x measurements on both qubits. Since the results of x measurements are completely random for the ∣01⟩|01\rangle state, whether they agree or disagree is therefore also random. That is why this line is half-filled in the initial state below.

Apply gates to make it certain that x measurements on each qubit will give the same result, and see how this affects the line at the top.

puzzle = run_puzzle(5)

The other two new lines also represent the probabilities of the results being different, but for the two cases of an x measurement on one qubit and a z measurement on the other.

With this in mind, we can predict how x and h gates will affect these new lines. For example, remember that the x gate flips the outcome of a z measurement on a qubit. If that outcome was certain to agree with one from another qubit, the x therefore also makes it certain to disagree and vice-versa. It will therefore affect all the lines along a row of the grid.

Try three x gates below to see this in action.

puzzle = run_puzzle(6)

As we saw before, the effect of h is to make it so that anything true of a z measurement before the h applies to the x measurement afterwards, and vice versa. For this reason it has the effect of swapping whole rows on the grid.

To see this, try three h gates below.

puzzle = run_puzzle(7)

This combination of rows becomes even more obvious when using the 'Bloch' command. Combining the rows means that three different pairs of lines are overlayed, and the ry gates lead to rotations for each.

To see this, use ry rotations to rotate both qubits to their ∣1⟩|1\rangle state. Try using the 'Bloch' command for each qubit when you perform the rotations.

puzzle = run_puzzle(8)

Exercise 6: Entangling Qubits

Since the new lines represent correlations, they become very important in describing entangled states.

The easiest gate to understand using this visualization is cz. Since this acts symmetrically, we won't need to choose which qubit to apply it to. It will just act on both the qubits.

Just as the h gate could be understood by swapping the positions of the lines (from one column to another), we can use a similar explanation for the cz. Specifically, it swaps the x measurement line of each qubit with the neighbouring line that respresents a correlation.

Try it out below with three cz gates.

puzzle = run_puzzle(9)

You may also have noticed that the line at the top changed too. To explain that we need to expand the visualization to incorporate something that we have been missing so far. There is also a concept of a 'y measurement', and we need to add lines to represent the possible outcomes of these in order to fully describe our qubit states.

puzzle = run_puzzle(10)

Here we've inserted two new rows and many new lines to represent the outcomes of y measurements, as well as all the correlations involving y measurements.

The important one to keep your eye on is the one in the middle, which describes the probability of getting different results when both qubits are measured in the y basis. This line is the one that swaps with the top one when we perform a cz. Do another three cz gates to see this in action.

Note that these new lines could also be incorporated into the 'Bloch' command. For that we'd create a sphere rather than a circle, and we would be able to see that the rx and rz gates are also rotations. Though we won't be including this in our visualization, you can create plot these spheres at any time using the following command.

puzzle.plot_spheres()

Exercise 7: Sandbox

Keeping track of all the information about y measurements makes the grid a bit too complicated. So let's go back to ignoring it.

Now you've learned everything we need to set up our experiment. Before we continue, feel free to play around in any way you like.

puzzle = run_puzzle(11)

Bell's Inequalities

2.1 Bell test for classical variables

Now we will investigate how quantum variables (based on qubits) differ from standard ones (based on bits).

We'll do this by creating a pair of variables, which we will call A and B. We aren't going to put any conditions on what these can be, or how they are initialized. So there are a lot of possibilities:

  • They could be any kind of variable, such as

    • integer

    • list

    • dictionary

    • ...

  • They could be initialized by any kind of process, such as

    • left empty

    • filled with a given set of values

    • generated by a given random process

      • independently applied to A and B

      • applied to A and B together, allowing for correlations between their randomness

If the variables are initialized by a random process, it means they'll have different values every time we run our program. This is perfectly fine. The only rule we need to obey is that the process of generating the randomness is the same for every run.

We'll use the function below to set up these variables. This currently has A and B defined as to be partially correlated random floating point numbers. But you can change it to whatever you want.

import random def setup_variables(): ### Replace this section with anything you want ### r = random.random() A = r*(2/3) B = r*(1/3) ### End of section ### return A, B

Our next job is to define a hashing function. This simply needs to take one of the variables as input, and then give a bit value as an output.

This function must also be capable of performing two different types of hash. So it needs to be able to chew on a variable and spit out a bit in two different ways. We'll therefore also need to tell the function what kind of hash we want to use.

To be consistent with the rest of the program, the two possible hash types should be called 'H' and 'V'. Also, the output must be in the form of a single value bit string: either '0' or '1'.

In the (fairly arbitrary) example given, the bits were created by comparing A and B to a certain value. The output is '1' if they are under that value, and '0' otherwise. The type of hash determines the value used.

def hash2bit(variable, hash_type): ### Replace this section with anything you want ### if hash_type == 'V': bit = (variable < 0.5) elif hash_type == 'H': bit = (variable < 0.25) bit = str(int(bit)) # Turn True or False into '1' and '0' ### End of section ### return bit

Once these are defined, there are four quantities we wish to calculate: P['HH'], P['HV'], P['VH'] and P['VV'].

Let's focus on P['HV'] as an example. This is the probability that the bit value derived from an 'H' type hash on A is different to that from a 'V' type hash on B. We will estimate this probability by sampling many times and determining the fraction of samples for which the corresponding bit values disagree.

The other probabilities are defined similarly: P['HH'] compares a 'H' type hash on both A and B, P['VV'] compares a V type hash on both, and P['VH'] compares a V type hash on A with a H type has on B.

These probabilities are calculated in the following function, which returns all the values of P in a dictionary. The parameter shots is the number of samples we'll use.

shots = 8192 def calculate_P(): P = {} for hashes in ['VV','VH','HV','HH']: # calculate each P[hashes] by sampling over `shots` samples P[hashes] = 0 for shot in range(shots): A, B = setup_variables() # hash type for variable `A` is the 1st character of `hashes` a = hash2bit(A, hashes[0]) # hash type for variable `B` is the 2nd character of `hashes` b = hash2bit(B, hashes[1]) P[hashes] += (a != b)/shots return P

Now let's actually calculate these values for the method we have chosen to set up and hash the variables.

P = calculate_P() print(P)

These values will vary slightly from one run to the next due to the fact that we only use a finite number of shots. To change them significantly, we need to change the way the variables are initiated, and/or the way the hash functions are defined.

No matter how these functions are defined, there are certain restrictions that the values of P will always obey.

For example, consider the case that P['HV'], P['VH'] and P['VV'] are all 0.0. The only way that this can be possible is for P['HH'] to also be 0.0.

To see why, we start by noting that P['HV']=0.0 is telling us that hash2bit(A, H) and hash2bit(B, V) were never different in any of the runs. So this means we can always expect them to be equal.

hash2bit(A, H) = hash2bit(B, V) (1)

From P['VV']=0.0 and P['VH']=0.0 we can similarly get

hash2bit(A, V) = hash2bit(B, V) (2) hash2bit(A, V) = hash2bit(B, H) (3)

Putting (1) and (2) together implies that

hash2bit(A, H) = hash2bit(A, V) (4)

Combining this with (3) gives

hash2bit(A, H) = hash2bit(B, H) (5)

And if these values are always equal, we'll never see a run in which they are different. This is exactly what we set out to prove: P['HH']=0.0.

More generally, we can use the values of P['HV'], P['VH'] and P['VV'] to set an upper limit on what P['HH'] can be. By adapting the CHSH inequality we find that

       \,\,\,\,\,\,\, P['HH']  ≤ \, \leq \, P['HV'] + P['VH'] + P['VV']

This is not just a special property of P['HH']. It's also true for all the others: each of these probabilities cannot be greater than the sum of the others.

To test whether this logic holds, we'll see how well the probabilities obey these inequalities. Note that we might get slight violations due to the fact that our P values aren't exact, but are estimations made using a limited number of samples.

def bell_test(P): sum_P = sum(P.values()) for hashes in P: bound = sum_P - P[hashes] print("The upper bound for P['"+hashes+"'] is "+str(bound)) print("The value of P['"+hashes+"'] is "+str(P[hashes])) if P[hashes]<=bound: print("The upper bound is obeyed :)\n") else: if P[hashes]-bound < 0.1: print("This seems to have gone over the upper bound, " "but only by a little bit :S\nProbably just rounding" " errors or statistical noise.\n") else: print("This has gone well over the upper bound :O !!!!!\n")
bell_test(P)

With the initialization and hash functions provided in this notebook, the value of P('HV') should be pretty much the same as the upper bound. Since the numbers are estimated statistically, and therefore are slightly approximate due to statistical noise, you might even see it go a tiny bit over. But you'll never see it significantly surpass the bound.

If you don't believe me, try it for yourself. Change the way the variables are initialized, and how the hashes are calculated, and try to get one of the bounds to be significantly broken.

Bell test for quantum variables

Now we are going to do the same thing all over again, except our variables A and B will be quantum variables. Specifically, they'll be the simplest kind of quantum variable: qubits.

When writing quantum programs, we have to set up our qubits and bits before we can use them. This is done by the function below. It defines a register of two bits, and assigns them as our variables A and B. It then sets up a register of two bits to receive the outputs, and assigns them as a and b.

Finally it uses these registers to set up an empty quantum program. This is called qc.

from qiskit import QuantumRegister, ClassicalRegister, QuantumCircuit def initialize_program(): qubit = QuantumRegister(2) A = qubit[0] B = qubit[1] bit = ClassicalRegister(2) a = bit[0] b = bit[1] qc = QuantumCircuit(qubit, bit) return A, B, a, b, qc

Before we start writing the quantum program to set up our variables, let's think about what needs to happen at the end of the program. This will be where we define the different hash functions, which turn our qubits into bits.

The simplest way to extract a bit from a qubit is through measurement. We'll use the z measurement as our V type hash and the x measurement as our H type hash.

Note that this function has more inputs than its classical counterpart. We have to tell it the bit on which to write the result, and the quantum program, qc, on which we write the gates.

def hash2bit(variable, hash_type, bit, qc): if hash_type == 'H': qc.h(variable) qc.measure(variable, bit)

Now it's time to set up the variables A and B. To write this program, you can use the grid below. You can either follow the suggested exercise, or do whatever you like. Once you are ready, just move on. The cell containing the setup_variables() function, will then use the program you wrote with the grid.

Note that our choice of visualisation means that the probabilities P['HH'], P['HV'], P['VH'] and P['VV'] will explicitly correspond to circles on our grid. For example, the circle at the very top tells us how likely the two X outputs would be to disagree. If this is purple, then P['HH']=1; if it is white then P['HH']=0.

One example of a state that will not obey the upper bound discussed earlier is one where P['HH']>0.5 and P['HV']=P['VH']=P['VV']<0.5. This means that the top line should be mostly purple, and the rest that describe correlations should be mostly white.

Find a way to create such a state below.

puzzle = run_puzzle(12)

Now the program as written above will be used to set up the quantum variables.

import numpy as np def setup_variables(A, B, qc): for line in puzzle.program: eval(line)

The values of P are calculated in the function below. In this, as in the puzzles in the rest of this notebook, this is done by running the job using Qiskit and getting results which tell us how many of the samples gave each possible output. The output is given as a bit string, string, which Qiskit numbers from right to left. This means that the value of a, which corresponds to bit[0] is the first from the right

a = string[-1]

and the value of b is right next to it at the second from the right

b = string[-2]

The number of samples for this bit string is provided by the dictionary of results, stats, as stats[string].

shots = 8192 from qiskit import assemble, transpile def calculate_P(backend): P = {} program = {} for hashes in ['VV','VH','HV','HH']: A, B, a, b, program[hashes] = initialize_program() setup_variables(A, B, program[hashes]) hash2bit(A, hashes[0], a, program[hashes]) hash2bit(B, hashes[1], b, program[hashes]) # submit jobs t_qcs = transpile(list(program.values()), backend) qobj = assemble(t_qcs, shots=shots) job = backend.run(qobj) # get the results for hashes in ['VV','VH','HV','HH']: stats = job.result().get_counts(program[hashes]) P[hashes] = 0 for string in stats.keys(): a = string[-1] b = string[-2] if a != b: P[hashes] += stats[string] / shots return P

Now it's time to choose and set up the actual device we are going to use. By default, we'll use a simulator. You could instead use a real cloud-based device by changing the backend accordingly.

puzzle.program
from qiskit import Aer backend = Aer.get_backend('aer_simulator')
P = calculate_P(backend) print(P)
bell_test(P)

If you prepared the state suggestion by the exercise, you will have found a significant violation of the upper bound for P['HH']. So what is going on here? The chain of logic we based the Bell test on obviously doesn't apply to quantum variables. But why?

The answer is that there is a hidden assumption in that logic. To see why, let's revisit point (4).

hash2bit(A, H) = hash2bit(A, V) (4)

Here we compare the value we'd get from an H type of hash of the variable A with that for a V type hash.

For classical variables, this is perfectly sensible. There is nothing stopping us from calculating both hashes and comparing the results. Even if calculating the hash of a variable changes the variable, that's not a problem. All we need to do is copy it beforehand and we can do both hashes without any issue.

The same is not true for quantum variables. The result of the hashes is not known until we actually do them. It's only then that the qubit actually decides what bit value to give. And once it decides the value for one type of hash, we can never determine what it would have decided if we had used another type of hash. We can't get around this by copying the quantum variables either, because quantum variables cannot be copied. This means there is no context in which the values hash2bit(A,H) and hash2bit(A,V) are well-defined at the same time, and so it is impossible to compare them.

Another hidden assumption is that hash2bit(A,hash) depends only on the type of hash chosen for variable A, and not the one chosen for variable B. This is also perfectly sensible, since this exactly the way we set up the hash2bit() function. However, the very fact that the upper bound was violated does seem to imply that each variable knows what hash is being done to the other, so they they can conspire to give very different behaviour when both have a H type hash.

Even so, we cannot say that our choice of hash on one qubit affects the outcome on the other. The effect is more subtle than that. For example, it is impossible to determine which variable is affecting which: You can change the order in which the hashes are done, or effectively do them at the same time, and you'll get the same results. What we can say is that the results are contextual: to fully understand results from one variable, it is sometimes required to look at what was done to another.

All this goes to show that quantum variables don't always follow the logic we are used to. They follow different rules, the rules of quantum mechanics, which will allow us to find ways of performing computation in new and different ways.