Circuit Tutorial

This tutorial will show you how to create and use Circuit objects, which represent (suprise, suprise) quantum circuits. Noteable among their features is the ability to interface pyGSTi with other quantum circuit standards (e.g., conversion to OpenQasm)

First let's get the usual imports out of the way.

In [ ]:
import pygsti
from pygsti.objects import Circuit
from pygsti.objects import Label as L

Labels

Let's begin by discussing gate and layer labels, which we'll use to build circuits.

Gate Labels

Gate labels represent a single gate within a circuit, like a CNOT operation between two qubits. A gate label has two parts: a str-type name and a tuple of line labels. Gate names typically begin with 'G' because this is expected when we parse circuits from text files. The line labels assign the gate to those lines in the circuit. For example, "Gx" or "Gcnot" are common gate names, and the integers 0 to $n$ might be the available line labels. We can make a proper gate label by creating a instance of the Label class:

In [ ]:
myGateLabel = L('Gcnot',(0,1))

But in nearly all scenarios it's also fine to use the Python tuple ('Gcnot',0,1) as shorthand - this will get converted into the Label object above as needed within pyGSTi.

In [ ]:
myJustAsGoodGateLabel = ('Gcnot',0,1)

As a special case, the tuple of line labels can be None. This is interpreted to mean that the gate acts on all the available lines. When just a string is used as a gate label it acts as though it's line labels are None. So these are also valid gate labels:

In [ ]:
mySpecialGateLabel = L('Gi')
myJustAsGoodSpecialGateLabel = 'Gi'

When dealing with actual Label objects you can access the name and line labels of a gate label via the .name and .sslbls (short for "state space labels", which are the same as line labels as we'll see) members:

In [ ]:
print("name = ", myGateLabel.name, " sslbls = ", myGateLabel.sslbls)
print("name = ", mySpecialGateLabel.name, " sslbls = ", mySpecialGateLabel.sslbls)

Simple enough; now let's move on to layer labels:

Layer labels

Layer labels represent an entire layer of a circuit. A layer label can either be a single gate label or a sequence of gate labels. In the former case, the layer is interpreted to have just a single gate in it. In the latter case, all of the gate labels comprising the layer label are interpreted as occurring simultaneously (in parallel) during the given circuit layer. Again, there's a proper way to make a layer label using a Label object, and a number of shorthand ways which are almost always equivalent:

In [ ]:
layerLabel1 = myGateLabel            # single-gate layer using Label object
layerLabel2 = myJustAsGoodGateLabel  # single-gate layer using tuple
layerLabel3 = 'Gi'                   # single-gate layer using a string
layerLabel4 = L( [L('Gx',0), L('Gcnot',(0,1))] ) # multi-gate layer as Label object, from Label objects
layerLabel5 = L( [('Gx',0),('Gcnot',0,1)] )      # multi-gate layer as Label object, from tuple objects
layerLabel6 = L( [('Gx',0),L('Gcnot',(0,1))] )   # multi-gate layer as Label object, from mixed objects
layerLabel7 = [('Gx',0),('Gcnot',0,1)]         # multi-gate layer as a list of tuples
layerLable8 = L( [] )  # *empty* gate layer - useful to mean the identity on all qubits
# etc, etc. -- anything reasonable works like it should

Notice that the same Label object used for gate labels is used for layer labels. This is natural when gates and layers are thought of more broadly as "operations" (e.g. a layer of an $n$-qubit circuit is just a $n$-qubit gate). Thus, you can access the .name and .sslbls of a layer too (though the name is given the default value "COMPOUND"):

In [ ]:
print("name = ", layerLabel5.name, " sslbls = ", layerLabel5.sslbls)

A couple tricks:

  • when you're not sure whether a layer Label object has a multiple gates or is just a single simple gate label, you can iterate over the .components member of a Label. This iterates over the gate labels for a multi-gate layer label and just over the label itself for a simple gate label. For example:
In [ ]:
print( list(L([('Gx',0),('Gcnot',0,1)]).components) )
print( list(L('Gx',0).components) )
  • you can use lbl.qubits as an alias for lbl.sslbls, and lbl.number_of_qubits instead of len(lbl.sslbls). These can improve code legibility when dealing a system of qubits (as opposed to qutrits, etc.). Beware: both of these quantities can be None, just like lbl.sslbls.
In [ ]:
lbl = L('Gcnot',(0,1))
print("The label %s applies to %d qubits: %s" % (str(lbl), lbl.number_of_qubits, str(lbl.qubits)))
lbl = L('Gi')
print("The label %s applies to %s qubits: %s" % (str(lbl), lbl.number_of_qubits, str(lbl.qubits)))

Circuits

The Circuit object encapsulates a quantum circuit as a sequence of layer labels, each of which contains zero or more non-identity gate lables. A Circuit has some number of labeled lines which should have a one-to-one correspondence with the factors $\mathcal{H}_i$ when the quantum-state space is written as a tensor product: $\mathcal{H}_1 \otimes \mathcal{H}_2 \cdots \otimes \mathcal{H}_n$. Line labels can be integers or strings (in the above examples we used the integers 0 and 1).

Construction

We initialize a Circuit with a sequence of layer labels, and either:

  • a sequence of line labels, as line_labels, or
  • the number of lines for the circuit, as num_lines, in which case the line labels are taken to be integers starting at 0.
In [ ]:
c = Circuit( [('Gx',0),('Gcnot',0,1),(),'Gall',('Gy',3)], line_labels=[0,1,2,3])
c2 = Circuit( [('Gx',0),('Gcnot',0,1),(),'Gall',('Gy',3)], num_lines=4) # equivalent to above
c3 = Circuit( [('Gx',0),('Gcnot',0,1),(),'Gall',('Gy',3)] ) # NOT equivalent, because line 2 is never used (see below)

print(c)   # You can print circuits to get a
print(c2)  #  text-art version.
print(c3)

In the case of 1 or 2 qubits, it can be more convenient to dispense with the line labels entirely and just equate gates with circuit layers and represent them with simple Python strings. If we initialize a Circuit without specifying the line labels (either by line_labels or by num_lines) and the layer labels don't contain any non-None line labels, then a Circuit is created which has a single special '*'-line which indicates that this circuit doesn't contain any explicit lines:

In [ ]:
c4 = Circuit( ('Gx','Gy','Gi') )
print(c4)

Using circuits with '*'-lines is fine (and is the default type of circuit for the "standard" 1- and 2-qubit modules located at pygsti.construction.std*); one just needs to be careful not to combine these circuits with other non-'*'-line circuits.

1Q Note: Particularly within the 1-qubit context the ordering direction is important. The elements of a Circuit are read from left-to-right, meaning the first (left-most) layer is performed first. This is very natural for experiments since one can read the operation sequence as a script, executing each gate as one reads from left to right. It's also natural for 2+ qubit circuits which similar to standard quantum circuit diagrams. However, for 1-qubit circuits, since we insist on "normal" matrix multiplication conventions, the fact that the ordering of matrix products is reversed from that of operation sequences may be confusing. For example, the circuit ('Ga','Gb','Gc'), in which Ga is performed first, corresponds to the matrix product $G_c G_b G_a$. The probability of this operation sequence for a SPAM label associated with the (column) vectors ($\rho_0$,$E_0$) is given by $E_0^T G_c G_b G_a \rho_0$, which can be interpreted as "prepare state 0 first, then apply gate A, then B, then C, and finally measure effect 0". While this nuance is typically hidden from the user (the Model functions which compute products and probabilities from Circuit objects perform the order reversal internally), it becomes very important if you plan to perform matrix products by hand.

Implied SPAM

A Circuit may optionally begin with an explicit state-preparation and end with an explicit measurement, but these may be omitted when used with Model objects which have only a single state-preparation and/or POVM. Usually state preparation and measurement operations are represented by "rho"- and "M"-prefixed str-type labels that therefore act on all circuit lines. For example:

In [ ]:
c5 = Circuit( ['rho010',('Gz',1),[('Gswap',0,1),('Gy',2)],'Mx'] , line_labels=[0,1,2])
print(c5)

Basic member access

The basic member variables and functions of a Circuit that you may be interested in are demonstrated below:

In [ ]:
print("depth = ", c.depth())    # circuit depth, i.e., the number of layers
print("tup = ", c.tup)          # circuit as a tuple of layer-labels (elements are *always* Label objects)
print("str = ", c.str)          # circuit as a single-line string
print("lines = ",c.line_labels) # tuple of line labels
print("#lines = ",c.number_of_lines()) #number of line labels
print("#multi-qubit gates = ", c.multi_q_gate_count())
c_copy = c.copy()               #copies the circuit

Indexing and Slicing

When a Circuit is indexed as if it were a tuple of layer-labels. The index must be an integer and a Label object is returned. Once can also access a particular gate label by providing a second index which is either a single line label, a tuple of line labels, or, if the line labels are integers, a slice of line labels:

In [ ]:
c = Circuit( [('Gx',0),[('Gcnot',0,1),('Gz',3)],(),'Gall',('Gy',3)], line_labels=[0,1,2,3])
print(c)
print('c[1] = ',c[1])       # layer
print('c[0,0] = ',c[0,0])   # gate at layer=0, line=0 (Gx)
print('c[0,2] = ',c[0,2])   # gate at layer=0, line=2 (nothing)
print('c[1,0] = ',c[1,0])   # gate at layer=0, line=0 (NOTE: nothing because CNOT doesn't *only* occupy line 0)
print('c[1,(0,1)] = ',c[1,(0,1)]) # gate at layer=0, lines=0&1 (Gcnot)
print('c[1,(0,1,3)] = ', c[1,(0,1,3)]) # layer-label restricted to lines 0,1,&3
print('c[1,0:3] = ', c[1,0:3]) # layer-label restricted to lines 0,1,&2 (line-label slices OK b/c ints)
print('c[3,0] = ',c[3,0])
print('c[3,:] = ',c[3,:]) # DEBUG!

If the first index is a tuple or slice of layer indices, a Circuit is returned which contains only the indexed layers. This indexing may be combined with the line-label indexing described above. Here are some examples:

In [ ]:
print(c)
print('c[1:3] = ');print(c[1:3])      # Circuit formed from layers 1 & 2 of original circuit
print('c[2:3] = ');print(c[1:2])      # Layer 1 but as a circuit (not the same as c[1], which is a Label)
print('c[0:2,(0,1)] = ');print(c[0:2,(0,1)]) # upper left "box" of circuit
print('c[(0,3,4),(0,3)] = ');print(c[(0,3,4),(0,3)]) #Note: gates only partially in the selected "box" are omitted

Editing Circuits

Circuits are by default created as read-only objects. This is because making them read-only allows them to be hashed (e.g. used as the keys of a dictionary) and there are many tasks that don't require them being editable. That said, it's easy to get an editable Circuit: just create one or make a copy of one with editable=True:

In [ ]:
ecircuit1 = Circuit( [('Gx',0),('Gcnot',0,1),(),'Gall',('Gy',3)], num_lines=4, editable=True)
ecircuit2 = c.copy(editable=True)
print(ecircuit1)

When a circuit is editable, you can perform additional operations that alter the circuit in place (see below). When you're done, call .done_editing() to change the Circuit into read-only mode. Once in read-only mode, a Circuit cannot be changed back into editable-mode, you must make an editable copy of the circuit.

As you may have guessed, you're allowed to assign the layers or labels of an editable circuit by indexing:

In [ ]:
ecircuit1[0,(2,3)] = ('Gcnot',2,3)
print(ecircuit1)

ecircuit1[2,1] = 'Gz' # interpreted as ('Gz',1) 
print(ecircuit1)

ecircuit1[2:4] = [[('Gx',1),('Gcnot',3,2)],('Gy',1)] #assigns to layers 2 & 3
print(ecircuit1)

There are also methods for inserting and removing lines and layers:

In [ ]:
ecircuit1.append_circuit( Circuit([('Gx',0),'Gi'], num_lines=4) )
print(ecircuit1)

ecircuit1.insert_circuit( Circuit([('Gx',0),('Gx',1),('Gx',2),('Gx',3)], num_lines=4), 1)
print(ecircuit1)

ecircuit1.insert_layer( L( (L('Gz',0),L('Gz',3)) ), 0) #expects something like a *label*
print(ecircuit1)

ecircuit1.delete_layers([9,10])
print(ecircuit1)

ecircuit1.insert_idling_lines(3, ['N1','N2'])
print(ecircuit1)

ecircuit1.delete_lines(['N1','N2'])
print(ecircuit1)

Finally, there are more complex methods which do fancy things to Circuits:

In [ ]:
ecircuit1.compress_depth()
print(ecircuit1)
In [ ]:
ecircuit1.change_gate_library({('Gx',0) : [('Gx2',0)]}, allow_unchanged_gates=True)
print(ecircuit1)
In [ ]:
ecircuit1.done_editing()

Circuits as tuples

In many ways Circuit objects behave as a tuple of layer labels. We've already shown how indexing and slicing mimic this behavior. You can also add circuits together and multiply them by integers:

In [ ]:
c2 = Circuit([('Gx',0),('Gx',1),('Gx',2),('Gx',3)], num_lines=4)
print(c)
print(c+c2)
print(c*2)

There are also methods to "parallelize" and "serialize" circuits, which are available to read-only circuits too because they return new Circuit objects and don't modify anything in place:

In [ ]:
c2 = Circuit([[('Gx',0),('Gx',1)],('Gx',2),('Gx',3)], num_lines=4)
print(c2)
print(c2.parallelize())
print(c2.serialize())

String representations

Circuit objects carry along with them a string representation, accessible via the .str member. This is intended to hold a compact human-readable expression for the circuit that can be parsed, using pyGSTi's standard circuit format and conventions, to reconstruct the circuit. This isn't quite true because the line-labels are not currently contained in the string representation, but this will likely change in future releases.

Here's how you can construct a Circuit with or from a string representation which thereafter illustrates how you can print different representation of a Circuit. Note that two Circuits may be equal even if their string representations are different.

In [ ]:
#Construction of a Circuit
c1 = Circuit( ('Gx','Gx') ) # from a tuple
c2 = Circuit( ('Gx','Gx'), stringrep="Gx^2" ) # from tuple and string representations (must match!)
c3 = Circuit( "Gx^2" ) # from just a string representation

#All of these are equivalent (even though their string representations aren't -- only tuples are compared)
assert(c1 == c2 == c3)

#Printing displays the Circuit representation
print("Printing as string (multi-line string rep)")
print("c1 = %s" % c1)
print("c2 = %s" % c2)
print("c3 = %s" % c3, end='\n\n')

#Printing displays the Circuit representation
print("Printing .str (single-line string rep)")
print("c1 = %s" % c1.str)
print("c2 = %s" % c2.str)
print("c3 = %s" % c3.str, end='\n\n')

#Casting to tuple displays the tuple representation
print("Printing tuple(.) (tuple rep)")
print("c1 =", tuple(c1))
print("c2 =", tuple(c2))
print("c3 =", tuple(c3), end='\n\n')

#Operations
assert(c1 == ('Gx','Gx')) #can compare with tuples
c4 = c1+c2 #addition (note this concatenates string reps)
c5 = c1*3  #integer-multplication (note this exponentiates in string rep)
print("c1 + c2 = ",c4.str, ", tuple = ", tuple(c4))
print("c1*3    = ",c5.str, ", tuple = ", tuple(c5), end='\n\n')

File I/O

Circuits can be saved to and read from their single-line string format, which uses square brackets to enclose each layer of the circuit. See the lines of MyCircuits.txt, which we read in below, for examples. Note that a Circuit's line labels are not included in their single-line-string format, and so to reliably import circuits the line labels should be supplied separately to the load_circuit_list function:

In [ ]:
circuitList = pygsti.io.load_circuit_list("../tutorial_files/MyCircuits.txt", line_labels=[0,1,2,3,4])
for c in circuitList:
    print(c)

Converting circuits external formats

Circuit objects can be easily converted to OpenQasm or Quil strings, using the convert_to_openqasm() and convert_to_quil() methods. This conversion is automatic for circuits that containing only gates with name that are in-built into pyGSTi (the docstring of pygsti.tools.internalgates.get_standard_gatename_unitaries()). This is with some exceptions in the case of Quil: currently not all of the in-built gate names can be converted to quil gate names automatically, but this will be fixed in the future.

For other gate names (or even more crucially, if you have re-purposed any of the gate names that pyGSTi knows for a different unitary), the desired gate name conversation must be specified as an optional argument for both convert_to_openqasm() and convert_to_quil().

Circuits with line labels that are integers or of the form 'Qinteger' are auto-converted to the corresponding integer. If either of these labelling conventions is used but the mapping should be different, or if the qubit labelling in the circuit is not of one of these two forms, the mapping should be handed to these conversion methods.

In [ ]:
label_lst = [ [L('Gh','Q0'),L('Gh','Q1')], L('Gcphase',('Q0','Q1')), [L('Gh','Q0'),L('Gh','Q1')]]
c = Circuit(label_lst, line_labels=['Q0','Q1'])

print(c)
openqasm = c.convert_to_openqasm()
print(openqasm)
In [ ]:
label_lst = [L('Gxpi2','Q0'),L('Gcnot',('Q0','Q1')),L('Gypi2','Q1')]
c2 = Circuit(label_lst, line_labels=['Q0','Q1'])
quil = c2.convert_to_quil()
print(quil)

Simulating circuits

Model objects in pyGSTi are able to simulate, or "generate the outcome probabilities for", circuits. To demonstrate, let's create a circuit and a model (see the tutorials on "explicit" models and "implicit" models for more information on model creation):

In [ ]:
clifford_circuit = Circuit([ [L('Gh','Q0'),L('Gh','Q1')],
                              L('Gcphase',('Q0','Q1')),
                             [L('Gh','Q0'),L('Gh','Q1')]],
                            line_labels=['Q0','Q1'])
model = pygsti.construction.build_localnoise_model(n_qubits=2, gate_names=('Gh','Gcphase'),
                                                   qubit_labels=['Q0','Q1'])

Then circuit outcome probabilities can be computed using either the model.probs(circuit) or circuit.simulate(model), whichever is more convenient:

In [ ]:
out1 = model.probs(clifford_circuit)
out2 = clifford_circuit.simulate(model)

The output is simply a dictionary of outcome probabilities:

In [ ]:
out1

The keys of the outcome dictionary out are things like ('00',) instead of just '00' because of possible intermediate outcomes. See the Instruments tutorial if you're interested in learning more about intermediate outcomes.

Computation of outcome probabilities may be done in a variety of ways, and Model objects are associated with a forward simulator that supplies the core computational routines for generating outcome probabilities. In the example above the simulation was performed by multiplying together process matrices. For more information on the types of forward simulators in pyGSTi and how to use them, see the forward simulators tutorial.

Conclusion

This concludes our detailed look into the Circuit object. If you're intersted in using circuits for specific applications, you might want to check out the tutorial on circuit lists or the tutorial on constructing GST circuits

In [ ]: