<jupyter_start><jupyter_text>Quantum Circuit Simulator This work is written as a part of the application for the Quantum Open Source Foundation Mentorship program. In this project we are going to be implementing a Quantum Circuit Simulator from scratch. I have written several helper functions in order to have a modular structure that will help me track and fix bugs along the way. I will describe in detail how each function works and is relevant to our task.  First we are going to import the necessary functions. We are also going to define the text for the error message, in case the user chooses a gate that has not been implemented.<jupyter_code>import numpy as np
import random
import collections
from math import log2

ni_error = "ERROR: Choose one of the implemented gates"<jupyter_output><empty_output><jupyter_text>Get Ground State  In the template our first objective was to implement a 'get_ground_state' function that takes 'num_qubits' as an input and returns a vector with '1' as a first element and '0' for other elements. The 'ground_state' variable is comprised of two parts: &ensp; 1) First element that we can represent as a list with one element.  &ensp; 2) List of the length 'num_elements' - 1, where every element is equal to zero. We have generated it using list comprehension.<jupyter_code>def get_ground_state(num_qubits):
    num_elements = 2 ** num_qubits
    ground_state = [1] + [0 for i in range(num_elements - 1)]
    return ground_state<jupyter_output><empty_output><jupyter_text>To see if it works lets create a ground state with num_qubits = X:<jupyter_code>X = 3
get_ground_state(X)<jupyter_output><empty_output><jupyter_text>Make Operator  Second part of the tast is implementation of a general 'make_operator' for our solution. We want a function that takes 'total_qubits' in a quantum computer and generates a matrix operator of a given gate for a 'target_qubit' defined by the user. First we are going to implement a switch function for our gates. I have taken a time to hardcode unitaries for several popular gates. Additionally, I have added optional arguments for the bonus task of implementing parameterized gates. 'get_unitary' function outputs a string "error" in the case user chooses a gate that has not been implemented. This will be useful for adding error messages. Finally, we will also be converting the string to lower in case a user wants to type the name of a gate with capital letters.<jupyter_code>def get_unitary(gate_name, theta = 0, phi = 0, lam = 0):
    i_ = np.complex(0, 1)
    unitary_collection = {
        "x"   : np.array([[0, 1], [1, 0]]), 
        "y"   : np.array([[0, -i_], [i_, 0]]),
        "z"   : np.array([[1, 0], [0, -1]]),
        "s"   : np.array([[1, 0], [0, np.exp((i_ * np.pi)/2)]]),
        "sdg" : np.array([[1, 0], [0, np.exp(-1*((i_ * np.pi)/2))]]),
        "h"   : np.array([[1/np.sqrt(2), 1/np.sqrt(2)], [1/np.sqrt(2), -1/np.sqrt(2)]]),
        "t"   : np.array([[1, 0], [0, np.exp((i_ * np.pi)/4)]]),
        "tdg" : np.array([[1, 0], [0, np.exp(-1*((i_ * np.pi)/4))]]),
        "cx"  : np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]]),
        "cnot": np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]]),
        "u3"  : np.array([[np.cos(0.5*theta),-np.exp(1.0j*lam)*np.sin(0.5*theta)],
                        [np.exp(1.0j*phi)*np.sin(0.5*theta),np.exp(1.0j*(phi+lam))*np.cos(0.5*theta)]])
        }
    return unitary_collection.get(gate_name.lower(), ni_error)<jupyter_output><empty_output><jupyter_text>We can show it's functionality by applying it to get a unitary for any of the implemented gates:<jupyter_code>example_gate = "x"

get_unitary(example_gate.lower())<jupyter_output><empty_output><jupyter_text>We can see that the to generate a matrix operator we need to create  a list of the length log2total_qubits consisting entirely out of identity matrices. The we are going to put in the unitary_gate in place of a target qubit. After that we need to continuously apply a Kronecker product function from left to right.<jupyter_code>def generate_identity_list(total_qubits):
    I = np.identity(2)
    identity_list = [I] * int(log2(total_qubits))
    return identity_list<jupyter_output><empty_output><jupyter_text>Let's test it:<jupyter_code>tot_qub = 8

generate_identity_list(tot_qub)<jupyter_output><empty_output><jupyter_text>Running Kronecker product function applies 'np.kron' continuously on a list. Essentially, our entire task for implementing generalised quantum simulator now comes down to swapping the target gates in place of an identity gate.<jupyter_code>def runningkron(identity_list):
    count  = 0
    answer = 1
    while (count != len(identity_list)):
        answer = np.kron(answer, identity_list[count])
        count += 1
    return answer
total_qubits = 8
tester_list  = generate_identity_list(total_qubits)

runningkron(tester_list)<jupyter_output><empty_output><jupyter_text>CNOT gate esssentially deals with putting in the control and target qubits in the correct places. Then adding running krons to each other. We are going to generate two identity_lists for the left side and the right side. Observe that the right side will always contain a target. Therefore we can go ahead and replace the identiy matrix of the right identity_list with the Pauli-X gate at the target location. Then we are going to insert P0x0 and P1x1 at the control location in both identity_lists.<jupyter_code>def cx(total_qubits,control,target):
    X = np.array([[0, 1], [1, 0]])
    
    left  = generate_identity_list(total_qubits)
    right = generate_identity_list(total_qubits)
    
    left[control]  = np.array([[1, 0], [0, 0]])
    right[control] = np.array([[0, 0], [0, 1]])
    right[target]  = X

    return runningkron(left) + runningkron(right)<jupyter_output><empty_output><jupyter_text>In the task description we have used a CNOT(0, 2) gate in a 3-qubit circuit. After running the cell we can see that the results are identical to the one in the task description:<jupyter_code>tot_qub = 8
tester  = [0, 2]
control = tester[0]
target  = tester[1]

cx(tot_qub, control, target)<jupyter_output><empty_output><jupyter_text>We can now put all of our functions together and construct our 'make_operator' function. Take not of the error handling, we are raising an error message that was defined at the beginning of our program. We are taking into an account scenarios for when the user puts in an incorrect input. We are also extending our support for the additional input formats. User can enter their own unitary or they can put in a string corresponding to a gate name. To avoid getting a numpy error message "FutureWarning: elementwise comparison failed; returning scalar instead, but in the future will perform elementwise comparison" we are going to make a type comparison instead of a direct comparison. In case user chooses a "cx" we are activating a cx function described earlier. Otherwise we proceed with constructing an identity list and swapping in the gate at the target index. We apply running_kron and return a matrix operator.<jupyter_code>def make_operator(total_qubits, gate_unitary, target_qubit, **kwargs):
    # add ability to add gate_unitary in different formats
    if (type(gate_unitary) == str): 
        GU = get_unitary(gate_unitary, **kwargs)
    elif (type(gate_unitary) == list):
        GU = np.array(gate_unitary)
    elif (type(gate_unitary) == np.ndarray):
        GU = gate_unitary
    else:
        raise Exception(ni_error) 
    # get_unitary passes a str when the user inputs a gate that has not been implemented.
    if(type(GU) == str):
        raise Exception(ni_error)
    else:
        # we only use control qubit in cx gate, hence why we can define condition for switching to cx this way
        if(len(target_qubit) == 2):
            if((type(GU) == np.ndarray) & (len(GU) == 4)):
                controlqub = target_qubit[0]
                targetqub  = target_qubit[1]
                return cx(total_qubits, controlqub, targetqub)
            else:
                raise Exception(ni_error) 
        # if not cx the proceed normally
        elif(len(target_qubit) == 1):
            if((type(GU) == np.ndarray) & (len(GU) == 2)):
                identity_list = generate_identity_list(total_qubits)
                identity_list[target_qubit[0]] = GU
                return runningkron(identity_list)
            else:
                raise Exception(ni_error)
        else:
            raise Exception(ni_error)<jupyter_output><empty_output><jupyter_text>In the task description we have used an X gate acting on third qubit in 3-qubit circuit. After running the cell we can see that the results are identical to the one in the task description:<jupyter_code>tot_qub   = 8
gate_name = "x"
tester    = [2]

make_operator(tot_qub, gate_name, tester)<jupyter_output><empty_output><jupyter_text>Additionally, to check the correct flow of the program we are going to apply a CNOT gate again in the same scenario as when we tested just CNOT. After comparing the two we can see that they are exactly the same:<jupyter_code>tot_qub   = 8
gate_name = "cx"
tester    = [0,2]

make_operator(tot_qub, gate_name, tester)<jupyter_output><empty_output><jupyter_text>Run Program  We are ready to run our program. The key here is to extract the gate names and the targets from the circuit. This can be done by simply iterating through the circuit and assigning the gates and targets to the variables. We can generate matrix operators using our newly extracted variables. We need to make sure that we recieved an array and not the error message, so we are going to raise an exception in case we don't receive an array. We are going to continuosly apply a dot product for every gate until we recieve our answer:<jupyter_code>def run_program(initial_state, circuit, **kwargs):
    global global_1
    global global_2
    total_qubs = len(initial_state)
    ans = initial_state
    
    for line in circuit:
        gate  = line["gate"]
        targs = line["target"]
        params = line["params"] if (len(circuit[0]) > 2) else {"theta":0,"phi":0,"lam":0} # use ternary operator to pass params.
        matrix_operator = make_operator(total_qubs, gate, targs, **params)
        if(type(matrix_operator) != np.ndarray): # raise error in case we receive a str
            raise Exception(ni_error)
        ans = np.dot(matrix_operator, ans)
    return ans<jupyter_output><empty_output><jupyter_text>Let's use some arbitrary parameters and see our results on different input formats:<jupyter_code>test_qpu1  = get_ground_state(3)
test_circ1 = [
{ "gate": "X", "target": [0]}, 
{ "gate": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]], "target": [0, 1]}
]

run_program(test_qpu1, test_circ1)
test_qpu2  = get_ground_state(2)
test_circ2 = [
{ "gate": np.array([[0, 1], [1, 0]]), "target": [0]}, 
{ "gate": "Sdg", "target": [1]}
]

run_program(test_qpu2, test_circ2)<jupyter_output><empty_output><jupyter_text>Measure All We can utilize a choices() function from the random library. It lends itself well to our task. We will generate a list and are going to pull from it using the probability distribution that we get from our state_vector. Note that we are converting probability amplitudes into probabilities. We will also convert the index into a correct format:<jupyter_code>def measure_all(state_vector):
    n = int(log2(len(state_vector)))
    nums_list  = list(range(len(state_vector)))
    prob_dist  = [np.abs(i)**2 for i in state_vector] #convert probability amplitude 
    raw_index  = random.choices(nums_list, prob_dist)[0]
    form_index = bin(raw_index)[2:].zfill(n) #convert to binary and get rid of the first two chars, 
                                             #then fill it with leading zeroes
    return form_index<jupyter_output><empty_output><jupyter_text>Let's demonstrate it's use with an example :<jupyter_code>test_qpu3  = get_ground_state(2)
test_circ3 = [
{ "gate": "h", "target": [0]}, 
{ "gate": "x", "target": [1]}
]

test_final_state1 = run_program(test_qpu3, test_circ3)
measure_all(test_final_state1)<jupyter_output><empty_output><jupyter_text>Get Counts We will apply measure_all function on the state vector in the range num_shots. After which we are going to construct a dictionary using a Counter object. Finally we will sort by the key and convert it into a dictionary, to receive our final answer.<jupyter_code>def get_counts(state_vector, num_shots):
    keys = [measure_all(state_vector) for i in range(num_shots)]
    answer = dict(collections.Counter(keys)) #Use Counter object to construct a dictionary with indices and values 
    return dict(sorted(answer.items())) # sort and output the counts<jupyter_output><empty_output><jupyter_text>Let's use some arbitrary parameters to demonstrate the program in action:<jupyter_code>test_qpu4  = get_ground_state(4)
test_circ4 = [
{ "gate": "h", "target": [2]}, 
{ "gate": "cx", "target": [0,3]}
]

test_final_state2 = run_program(test_qpu4, test_circ4)

get_counts(test_final_state2, 10000)<jupyter_output><empty_output><jupyter_text>Result Let's use the suggestion in the task description to see if the program works as intended:<jupyter_code># Define program:

my_circuit = [
{ "gate": "h", "target": [0] }, 
{ "gate": "cx", "target": [0, 1] }
]


# Create "quantum computer" with 2 qubits (this is actually just a vector :) )

my_qpu = get_ground_state(2)


# Run circuit

final_state = run_program(my_qpu, my_circuit)


# Read results

counts = get_counts(final_state, 1000)

print(counts)

# Should print something like:
# {
#   "00": 502,
#   "11": 498
# }

# Voila!<jupyter_output>{'00': 510, '11': 490}<jupyter_text>We can see that the simulator produces the desired output.  Bonus Requirements   Parametric gates We can add the support for parameters by simply setting default values in the get_unitary function. We are going to unpack the contents of parameters dictionary using a ** operator. I have taken the liberty of setting default values to the ones described in the task description. We are going to observe our function at work. We will see that after rounding we will get the desired result. Observe that I have decided to put in an argument "lam" instead of "lambda". This was done because in python lambda is a keyword that signifies an anonymous function. Instead of reworking the existing code, I opted for a quick solution of slightly changing the name.<jupyter_code>test_params_1 = { "theta": 3.1415, "phi": 1.5708, "lam": -3.1415 }
bonus_qpu_1  = get_ground_state(2)
bonus_circ_1 = [{ "gate": "u3", "params": test_params_1, "target": [0] }]
bonus_final_state_1 = run_program(bonus_qpu_1, bonus_circ_1)

get_counts(bonus_final_state_1, 10000)<jupyter_output><empty_output><jupyter_text>Let's try with different parameters to see if the function indeed takes in the new values for an input.<jupyter_code>test_params_2 = { "theta": 1, "phi": 1, "lam": 1 }
bonus_qpu_2  = get_ground_state(2)
bonus_circ_2 = [{ "gate": "u3", "params": test_params_2, "target": [0] }]<jupyter_output><empty_output><jupyter_text>We have only changed the parameters and left everything else exactly the same. We got a different output, hence we achieved our goal.<jupyter_code>bonus_final_state_2 = run_program(bonus_qpu_2, bonus_circ_2)

get_counts(bonus_final_state_2, 10000)<jupyter_output><empty_output><jupyter_text>Finally, let's see if we are going to get the same values as the ones outlined in the task. We are going to unpack the dictionary within the make_operator function to get the values for theta, phi and lambda.<jupyter_code>test_params_3 = { "theta": 3.1415, "phi": 1.5708, "lam": -3.1415 }
test_operator_1 = make_operator(2,"u3",[0], **test_params_3)

print(test_operator_1)<jupyter_output>[[ 4.63267949e-05+0.00000000e+00j  9.99999995e-01+9.26535896e-05j]
 [-3.67320510e-06+9.99999999e-01j  4.46251166e-09-4.63267947e-05j]]<jupyter_text>While these numbers look like they could work, we want to get the version that will be simpler to understand and will match the one specified in the task description:```[[ 0+0j,  1+0j], [ 0+1j,  0+0j]]```We will need to round our output:<jupyter_code>def complex_rounder(num):
    return complex(round(num.real),round(num.imag))
roundedop = []
roundedop.append([complex_rounder(test_operator_1[0][0]), complex_rounder(test_operator_1[0][1])])
roundedop.append([complex_rounder(test_operator_1[1][0]), complex_rounder(test_operator_1[1][1])])<jupyter_output><empty_output><jupyter_text>While our numbers are not exactly the same as the ones in the example we see that our output is very close that might be attributed to a rounding method.<jupyter_code>print(np.array(roundedop))<jupyter_output>[[ 0.+0.j  1.+0.j]
 [-0.+1.j  0.-0.j]]<jupyter_text>Variational Quantum Algorithms  My implementation is not the same as specified in the task description. I have made several attempts to implement it the way it was specified, but I reasoned that it will require more source code alteration, which might cause the program to behave unpredictably.<jupyter_code>params = [3.1415, 1.5708]

globals()["global_1"] = params[0]
globals()["global_2"] = params[1]
bonus_qpu_3  = get_ground_state(2)
bonus_circ_3 = [{ "gate": "u3", "target": [0],"params": { "theta": global_1, "phi":global_2, "lam": -3.1415 }}]
bonus_final_state_3 = run_program(bonus_qpu_3, bonus_circ_3)

get_counts(bonus_final_state_3, 10000)<jupyter_output><empty_output><jupyter_text>We can confirm that this works, by changing the parameters:<jupyter_code>params = [1, 2]

globals()["global_1"] = params[0]
globals()["global_2"] = params[1]
bonus_qpu_3  = get_ground_state(2)
bonus_circ_3 = [{ "gate": "u3", "target": [0],"params": { "theta": global_1, "phi":global_2, "lam": -3.1415 }}]
bonus_final_state_3 = run_program(bonus_qpu_3, bonus_circ_3)

get_counts(bonus_final_state_3, 10000)<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.10183015288472269, 'fraction_numerical': 0.02750809061488673, 'mean_word_length': 4.540340030911901, 'pattern_counts': {'":': 53, '<': 110, '<?xml version=': 0, '>': 111, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 3, 'word_list_counts': {'cursed_substrings.json': 2, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '28', 'dir': 'jupyter-structured-clean-dedup', 'id': '212401', 'license': 'MIT', 'n_tokens_mistral': 6014, 'n_tokens_neox': 5674, 'n_words': 2037, 'path': 'Quantum Circuit Simulator.ipynb', 'repo_name': 'edenian/qosf2021', 'size': '17922'}