{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Tutorial on operators (SPAM, gate, and layer operations)\n", "\n", "This tutorial explains the objects that represent state preparation, measurement, gate, and layer operations in pyGSTi. These objects form the essential components of `Model` objects in pyGSTi, and are therefore an important topic to understand if you're creating your own models or just need to know how to extract specific information from a `Model`. We use the term *operator* generically all such objects, even when gate or layer operators act on vectorized density matrices and are therefore *super-operators*. \n", "\n", "State preparations and POVM effects are represented as *vectors* in pyGSTi. For $n$ qubits, these can be either length-$2^n$ complex vectors representing pure states/projections or length-$4^n$ real vectors representing mixed states (in the Liouville picture, where we vectorize a $2^n\\times 2^n$ density matrix into a column or row vector). Gate and layer operations are represented as *linear maps* on the space of state vectors. As such these can be viewed as $2^n\\times 2^n$ complex matrices (in the pure-state case) or $4^n \\times 4^n$ real matrices (in the mixed-state case).\n", "\n", "State and effect vectors are subclasses of `SPAMVec` in pyGSTi. In both cases the vector is stored as a *column* vector even though effect (co-)vectors are perhaps more properly row vectors (this improves code reuse). Gate and layer operator objects are subclasses of `LinearOperator`. Together `SPAMVec` and `LinearOperator`, which are both derived from `ModelMember`, form the base for all of pyGSTi's model components. All `ModelMember` objects have a dimension given by their `dim` attribute, which for $n$ qubits is $2^n$ or $4^n$ (depending on whether pure or mixed state evolution is being considered).\n", "\n", "Let's begin with some familiar imports." ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "import pygsti\n", "import pygsti.objects as po\n", "import numpy as np" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Before getting into the pyGSTi objects, let's generate some example SPAM vectors and gate matrix. These are just NumPy arrays, and we use the `stdmx_to_ppvec` function to convert a standard $2^n \\times 2^n$ complex Hermitian densiy matrix to a length $4^n$ \"SPAM vector\" of real numbers giving the decomposition of this density matrix in the Pauli basis. The `gate_mx` describes how a 1-qubit $X(\\pi/2)$ rotation transforms a state vector in the Pauli basis." ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "[[0.70710678]\n", " [0. ]\n", " [0. ]\n", " [0.70710678]]\n", "float64\n" ] } ], "source": [ "gate_mx = np.array([[1, 0, 0, 0],\n", " [0, 1, 0, 0],\n", " [0, 0, 0, -1],\n", " [0, 0, 1, 0]],'d')\n", "density_mx0 = np.array([[1, 0],\n", " [0, 0]], complex)\n", "density_mx1 = np.array([[0, 0],\n", " [0, 1]], complex)\n", "spam_vec0 = pygsti.tools.stdmx_to_ppvec(density_mx0)\n", "spam_vec1 = pygsti.tools.stdmx_to_ppvec(density_mx1)\n", "\n", "print(spam_vec0) # just a numpy column vector \n", "print(spam_vec0.dtype) # of *real* numbers" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Dense operators\n", "\n", "The simplest kind of operators look very similar to numpy arrays (like those we created above) in which some of the elements are read-only. These operators derive from `DenseOperator` or `DenseSPAMVec` and hold a *dense* representation, meaning the a dense vector or matrix is stored in memory. **SPAM, gate, and layer operators have parameters which describe how they can be varied**, essentially the \"knobs\" which you can turn. `Model` objects also have *parameters* that are essentially inherited from their contained operators. How an operator is parameterized is particularly relevant for protocols which optimize a `Model` over its parameter space (e.g. Gate Set Tomography). Three common parameterizations are:\n", "- **static**: the object has *no* (zero) parameters, so the object cannot be changed at all. Static operators are like read-only NumPy arrays.\n", "- **full**: the object has one independent parameter for each element of its (dense) vector or matrix. Fully parameterized objects are like normal NumPy arrays.\n", "- **trace-preserving (TP)**: similar to full, except the top row of gate/layer matrices and the first element of state preparation vectors is fixed and these elements are therefore not parameters. (A POVM that is trace preserving must have all of its effect vectors sum to the identity.)\n", "\n", "Here's a 1-qubit example of creating dense-operator objects:" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ " object has 0 parameters\n", " object has 16 parameters\n", " object has 12 parameters\n", " object has 0 parameters\n", " object has 4 parameters\n", " object has 3 parameters\n", " object has 3 parameters\n", " object has 4 parameters\n" ] } ], "source": [ "#Operations\n", "staticOp = po.StaticDenseOp(gate_mx)\n", "fullOp = po.FullDenseOp(gate_mx)\n", "tpOp = po.TPDenseOp(gate_mx)\n", "\n", "#SPAM vectors\n", "staticSV = po.StaticSPAMVec(spam_vec0)\n", "fullSV = po.FullSPAMVec(spam_vec0)\n", "tpSV = po.TPSPAMVec(spam_vec0)\n", "\n", "#POVMs\n", "povm = po.UnconstrainedPOVM( {'outcomeA': staticSV, 'outcomeB':tpSV} )\n", "tppovm = po.TPPOVM( {'0': spam_vec0, '1': spam_vec1} )\n", "\n", "for op in (staticOp,fullOp,tpOp,staticSV,fullSV,tpSV,povm,tppovm):\n", " print(\"%s object has %d parameters\" % (str(type(op)), op.num_params()))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Although there are certain exceptions, the usual way you set the value of a `SPAMVec` or `LinearOperator` object is by setting the values of its parameters. Parameters must be real-valued and are typically allowed to range over all real numbers, so updating an operator's parameter-values is accomplished by passing a real-valued NumPy array of parameter values - a *parameter vector* - to the operators `from_vector` method. Note that the length of the parameter vector must match the operator's number of parameters (returned by `num_params` as demonstrated above). \n", "\n", "We'll now set new parameter values for several of the operators we created above. Since for dense operators there's a direct correspondence between parameters and matrix or vector elements, the parameter vector may be a flattened version of a 2d array of the parameterized element values." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "params = [ 1. 0. 0. 0. 0. 1. 0. 0. 0. 0. 0. -0.9 0. 0.\n", " 0.9 0. ]\n", "FullDenseOp with shape (4, 4)\n", " 1.00 0 0 0\n", " 0 1.00 0 0\n", " 0 0 0-0.90\n", " 0 0 0.90 0\n", "\n", "params = [ 0. 1. 0. 0. 0. 0. 0. -0.9 0. 0. 0.9 0. ]\n", "TPDenseOp with shape (4, 4)\n", " 1.00 0 0 0\n", " 0 1.00 0 0\n", " 0 0 0-0.90\n", " 0 0 0.90 0\n", "\n", "params = [0.70710678 0.70710678 0. 0. ]\n", "FullSPAMVec with dimension 4\n", " 0.71 0.71 0 0\n", "\n", "params = [0.70710678 0. 0. ]\n", "TPSPAMVec with dimension 4\n", " 0.71 0.71 0 0\n", "\n", "params = [0.70710678 0.6363961 0. 0. ]\n", "TPPOVM with effect vectors:\n", "0: FullSPAMVec with dimension 4\n", " 0.71 0.64 0 0\n", "\n", "1: ComplementSPAMVec with dimension 4\n", " 0.71-0.64 0 0\n", "\n", "\n" ] } ], "source": [ "new_mx = np.array([[1, 0, 0, 0],\n", " [0, 1, 0, 0],\n", " [0, 0, 0,-0.9],\n", " [0, 0, 0.9, 0]],'d')\n", "fullOp.from_vector(new_mx.flatten())\n", "print(\"params = \",fullOp.to_vector())\n", "print(fullOp)\n", "\n", "new_mx = np.array([[0, 1, 0, 0],\n", " [0, 0, 0,-0.9],\n", " [0, 0, 0.9, 0]],'d')\n", "tpOp.from_vector(new_mx.flatten())\n", "print(\"params = \",tpOp.to_vector())\n", "print(tpOp)\n", "\n", "\n", "new_vec = np.array([1/np.sqrt(2),1/np.sqrt(2),0,0],'d')\n", "fullSV.from_vector(new_vec)\n", "print(\"params = \",fullSV.to_vector())\n", "print(fullSV)\n", "\n", "new_vec = np.array([1/np.sqrt(2),0,0],'d')\n", "tpSV.from_vector(new_vec)\n", "print(\"params = \",tpSV.to_vector())\n", "print(tpSV)\n", "\n", "new_effect = np.array([1/np.sqrt(2),0.9*1/np.sqrt(2),0,0],'d')\n", "tppovm.from_vector(new_effect)\n", "print(\"params = \",tppovm.to_vector())\n", "print(tppovm)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Lindblad (CPTP-constrained) operations\n", "\n", "That a gate or layer operation is completely-positive and trace-preserving (CPTP) can be guaranteed if the operation is given by $\\hat{O} = \\exp{\\mathcal{L}}$ where $\\mathcal{L}$ takes the Lindblad form:\n", "$$\\mathcal{L}: \\rho \\rightarrow \\sum_i -i\\lambda_i[\\rho,B_i] + \\sum_{ij} \\eta_{ij} \\left( B_i \\rho B_j^\\dagger - \\frac{1}{2}\\left\\{ B_i^\\dagger B_j, \\rho \\right\\} \\right) $$\n", "where $B_i$ range over the non-identity elements of the ($n$-qubit) Pauli basis, $\\lambda_i$ is real, and $\\eta \\ge 0$ (i.e. the matrix $\\eta_{ij}$ is Hermitian and positive definite). We call the $\\lambda_i$ terms *Hamiltonian error* terms, and the (real) $\\lambda_i$s the *error rates* or *error coefficients*. Likewise, the $\\eta_{ij}$ terms are referred to generically as *non-Hamiltonian error* terms. In the special case where the $\\eta$ matrix is diagonal, the terms are called *Pauli stochastic error* terms and the (real) $\\eta_ii > 0$ are error rates. **Technical note:** While all maps of the above form ($\\hat{O}$) are CPTP, not all CPTP maps are of this form. $\\hat{O}$ is the form of all *infinitesimally-generated* CPTP maps. \n", "\n", "The `LindbladOp` class represents an operation $e^{\\mathcal{L}} U_0$, where $U_0$ is a unitary (super-)operator and $\\mathcal{L}$ takes the Lindblad form given above. `LindbladOp` objects contains a `LindbladErrorgen` object that encapsulates the Lindbladian exponent $\\mathcal{L}$. Lindblad operators are among the most complicated of all the operators in pyGSTi, so bear with us as we try to present things in an organized and comprehensible way. \n", "\n", "Let's start by making a `LindbladOp` from a dense gate matrix:" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "cptpOp = po.LindbladOp.from_operation_matrix(gate_mx)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "A `LindbladOp` does *not* hold a dense representation of its process matrix (it's not a `DenseOperator`), and so you cannot access it like a Numpy array. If you want a dense representation, you can either use a `LindbladDenseOp`, which is essentially a dense version of a `LindbladOp`, or you can call the `todense()` method (which works on dense operators too!):" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Lindblad Parameterized gate map with dim = 4, num params = 12\n", "\n", "dense representation = \n", " 1.0000 0 0 0\n", " 0 1.0000 0 0\n", " 0 0 0 -1.0000\n", " 0 0 1.0000 0\n", "\n" ] } ], "source": [ "print(cptpOp)\n", "print(\"dense representation = \")\n", "pygsti.tools.print_mx(cptpOp.todense()) # see this equals `gate_mx`" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now let's look at the parameters of `cptpOp`. By default, the $\\mathcal{L}$ of a `LindbladErrorgen` is parameterized such that $\\eta \\ge 0$ and the `LindbladOp` map is CPTP. There are several other ways a $\\mathcal{L}$ can be parameterized, and these are specified by the values of the `nonham_mode` and `param_mode` arguments of construction functions like `from_operation_matrix` we used above. Here's a quick rundown on these options:\n", "\n", "The value of `nonham_mode` dictates what elements of $\\eta$ are allowed to be nonzero. Allowed values are:\n", "- `\"all\"`: all $\\eta_{ij}$ elements can vary, possibly with constraints (see value of `param_mode`).\n", "- `\"diagonal\"` : $\\eta$ is *diagonal*; $\\eta_{ij} = 0$ when $i \\ne j$.\n", "- `\"diag_affine\"`: a special mode where we keep track of the diagonal elements of $\\eta$ *and* a set of affine-error coefficients.\n", "\n", "The value of `param_mode` determines how the non-zero parts of $\\eta$ relate to the *parameters* of the `LindbladErrorgen` object. Allowed values are:\n", "- `\"cptp\"`: the default, which constrains $\\mathcal{L}$ so that $e^{\\mathcal{L}}$ is guaranteed to be CPTP. When `nonham_mode` is set to `\"all\"` the parameters consist of 1) the $\\lambda_i$ and 2) the real and imaginary parts of $M$, the Cholesky decomposition of $\\eta$ ($\\eta = M M^\\dagger$). The reason we can't just use the real and imaginary parts of $\\eta_{ij}$ as parameters is that varying (without bounds, as many optimizers require) the real and imaginary parts of $\\eta_{ij}$ would not constrain $\\eta \\ge 0$. If `\"nonham_mode\"` is `\"diagonal\"` or `\"diag_affine\"` then the mapping is simpler and the parameters are just the squares of the the $\\eta_{ii}$ (and affine coefficients, in the `\"diag_affine\"` case, are unconstrained).\n", "- `\"depol`: works only when `\"nonham_mode\"` is `\"diagonal\"` or `\"diag_affine\"`, and associates all the $\\eta_{ii}^2$ values to the *same* parameter, which is the square of the depolarization rate.\n", "- `\"unconstrained\"`: parameters are exactly the $\\lambda_i$ and $\\eta_{ij}$ (separated into real and imaginary parts for off-diagonal $\\eta_{ij}$). This places no constrains on the positivity of $\\eta$. \n", "\n", "\n", "If that didn't all make sense, don't worry. We'll be working with just the default case where `nonham_mode = \"all\"` and `param_mode = \"cptp\"`. This gave our single-qubit `cptpOp` operator $12$ parameters: $3$ \"Hamiltonian\" (X,Y, and Z) + $9$ \"non-Hamiltonian\" (real and imaginary parts of the lower-triangular $3 \\times 3$ matrix $M$). \n", "\n", "Let's get these parameters using `cptpOp.to_vector()` and print them:" ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "params (12) = [ 1.11072073e+00 0.00000000e+00 0.00000000e+00 1.49011612e-08\n", " 0.00000000e+00 0.00000000e+00 0.00000000e+00 1.31504790e-08\n", " -2.32597260e-40 0.00000000e+00 5.54619323e-09 1.19237091e-08] \n", "\n" ] } ], "source": [ "print(\"params (%d) = \" % cptpOp.num_params(),cptpOp.to_vector(),'\\n')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "All 12 parameters are essentially 0 because `gate_mx` represents a (unitary) $X(\\pi/2)$ rotation and $U_0$ is automatically set to this unitary so that $\\exp\\mathcal{L} = \\mathbb{1}$. This means that all the error coefficients are zero, and this translates into all the parameters being zero. Note, however, that error coefficients are not always the same as parameters. The `get_errgen_coeffs` retrieves the error coefficients, which is often more useful than the raw parameter values: " ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Coefficients in (,) : value form:\n", "OrderedDict([(('H', 0), 1.1107207345395913),\n", " (('H', 1), 0.0),\n", " (('H', 2), 0.0),\n", " (('S', 0, 0), (2.2204460492503126e-16+0j)),\n", " (('S', 0, 1), 0j),\n", " (('S', 0, 2), 0j),\n", " (('S', 1, 0), 0j),\n", " (('S', 1, 1), (1.7293509746866568e-16+0j)),\n", " (('S', 1, 2), (7.29350974686657e-17+3.0587653832879784e-48j)),\n", " (('S', 2, 0), 0j),\n", " (('S', 2, 1), (7.29350974686657e-17-3.0587653832879784e-48j)),\n", " (('S', 2, 2), (1.7293509746866563e-16+0j))])\n", "\n", "Basis labels -> matrices mapping:\n", "OrderedDict([(0,\n", " array([[0. +0.j, 0.70710678+0.j],\n", " [0.70710678+0.j, 0. +0.j]])),\n", " (1,\n", " array([[0.+0.j , 0.-0.70710678j],\n", " [0.+0.70710678j, 0.+0.j ]])),\n", " (2,\n", " array([[ 0.70710678+0.j, 0. +0.j],\n", " [ 0. +0.j, -0.70710678+0.j]]))])\n" ] } ], "source": [ "import pprint\n", "coeff_dict, basis_dict = cptpOp.get_errgen_coeffs()\n", "print(\"Coefficients in (,) : value form:\"); pprint.pprint(coeff_dict)\n", "print(\"\\nBasis labels -> matrices mapping:\"); pprint.pprint(basis_dict)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "`get_errgen_coeffs` returns two dictionaries: the first maps a shorthand description of the error term to value of the term's coefficient (rate). This shorthand description is a tuple starting with `\"H\"`, `\"S\"`, or `\"A\"` to indicate the *type* of error term: Hamiltonian, non-Hamiltonian/stochastic, or affine. Additional elements in the tuple are basis-element labels (often integers), which reference basis matrices in the second second dictionary. Hamiltonian errors are described by a single basis element (the single index of $\\lambda_i$) whereas non-Hamiltonian errors are described by two basis elements (the two indices of $\\eta_{ij}$). (By placing the basis matrices in a separate dictionary we can avoid repeating the same matrices many times and avoid having to hash NumPy arrays.)\n", "\n", "We can also initialize a `LindbladErrorgen` using a pair of dictionaries in this format. Below we construct a `LindbladErrorgen` with \n", "$$\\mathcal{L} = 0.1 H_X + 0.1 S_X$$\n", "where $H_X: \\rho \\rightarrow -i[\\rho,X]$ and $S_X: \\rho \\rightarrow X\\rho X - \\rho$ are Hamiltonian and Pauli-stochastic errors, respectively. We then use this error generator to initialize a `LindbladOp` corresponding to $e^{\\mathcal{L}}U_0$, where $U_0$ is a $X(\\pi/2)$ rotation." ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Lindblad Parameterized gate map with dim = 4, num params = 2\n", "\n", " 1.0000 0 0 0\n", " 0 1.0000 0 0\n", " 0 0 -0.1275 -0.8958\n", " 0 0 0.8958 -0.1275\n", "\n" ] } ], "source": [ "sigmax = 1/np.sqrt(2) * np.array( [[0,1],\n", " [1,0]], 'd')\n", "errorgen = po.LindbladErrorgen(dim=4, Ltermdict={('H','X'): 0.1, ('S','X','X'): 0.1}, basisdict={'X': sigmax})\n", "cptpOp2 = po.LindbladOp(staticOp, errorgen)\n", "print(cptpOp2)\n", "pygsti.tools.print_mx(cptpOp2.todense())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can check that this `LindbladOp` has the right error generator coefficients. This time we do things slightly differently by accessing the `errorgen` member of the operator of the `LindbladOp`:" ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "(OrderedDict([(('H', 0), 0.1), (('S', 0, 0), (0.1+0j))]),\n", " OrderedDict([(0, array([[0. +0.j, 0.70710678+0.j],\n", " [0.70710678+0.j, 0. +0.j]]))]))" ] }, "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ "cptpOp2.errorgen.get_coeffs() # same as cptpOp2.get_errgen_coeffs()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Lindblad state preparations and POVMs\n", "\n", "It is possible to create \"Lindblad\" state preparations and POVMs using the `LindbladSPAMVec` and `LindbladPOVM` classes. These simply compose a $\\exp\\mathcal{L}$ factor (from a `LindbladErrorgen` or `LindbladOp`) with an existing \"base\" state preparation or POVM. That is, state preparations are $e^{\\mathcal{L}} |\\rho_0\\rangle\\rangle$, where $|\\rho_0\\rangle\\rangle$ represents a \"base\" pure state, and effect vectors are $\\langle\\langle E_i | e^{\\mathcal{L}}$ where $\\langle\\langle E_i|$ are the effects of a \"base\" POVM. " ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [], "source": [ "#Spam vectors and POVM\n", "cptpSpamVec = po.LindbladSPAMVec(staticSV, errorgen, 'prep') # staticSV is the \"base\" state preparation\n", "cptpPOVM = po.LindbladPOVM(po.LindbladOp(None, errorgen)) # by default uses the computational-basis POVM" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Embedded and Composed operations\n", "\n", "PyGSTi makes it possible to build up \"large\" (e.g. complex or many-qubit) operators from other \"smaller\" ones. We have already seen a modest example of this above when a `LindbladOp` was constructed from a `LindbladErrorgen` object. Two common and useful actions for building large operators are:\n", "1. **Composition**: A `ComposedOp` composes zero or more other operators, and therefore it's action is the sequential application of each of its *factors*. The dense representation of a `ComposedOp` is equal to the product (in reversed order!) of the dense representations of its factors. Note that a `ComposedOp` does not, however, require that its factors have dense representations - they can be *any* `LinearOperator` objects. Note, finally, that there exists a dense version of `ComposedOp` called `ComposedDenseOp`. The dense versions of operators can sometimes result in faster calculations when the system size (qubit number) is small.\n", "\n", "2. **Embedding**: An `EmbeddedOp` maps an operator on a subsystem of a state space to the full state space. For example, it could take a 1-qubit $X(\\pi/2)$ rotation and make a 3-qubit operation in which this operation is applied to the 2nd qubit. Embedded operators are very useful for constructing layer operations in multi-qubit models, where we naturally prefer to work with the lower-dimensional (typically 1- and 2-qubit) operations and need to build up $n$-qubit *layer* operations.\n", "\n", "### Composed operations\n", "We'll being by creating an operation that composes several of the dense operations we made earlier:" ] }, { "cell_type": "code", "execution_count": 12, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Composed gate of 3 factors:\n", "Factor 0:\n", "StaticDenseOp with shape (4, 4)\n", " 1.00 0 0 0\n", " 0 1.00 0 0\n", " 0 0 0-1.00\n", " 0 0 1.00 0\n", "Factor 1:\n", "TPDenseOp with shape (4, 4)\n", " 1.00 0 0 0\n", " 0 1.00 0 0\n", " 0 0 0-0.90\n", " 0 0 0.90 0\n", "Factor 2:\n", "FullDenseOp with shape (4, 4)\n", " 1.00 0 0 0\n", " 0 1.00 0 0\n", " 0 0 0-0.90\n", " 0 0 0.90 0\n", "\n", "Before interacting w/Model: 0 params\n" ] } ], "source": [ "composedOp = po.ComposedOp((staticOp,tpOp,fullOp))\n", "print(composedOp)\n", "print(\"Before interacting w/Model:\",composedOp.num_params(),\"params\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This all looks good except we may have expected that `composedOp.num_params()` would be $0+12+16=28$ (the sum of the parameter-counts of the factors). **What's going on?** To get the `ComposedOp` to \"realize\" how many parameters it has we'll need to add it to a `Model` and call the model's `.num_params()` to refresh the model's number of parameters. \n", "\n", "*Technical note:* `Model` objects are actually seen as owning the parameters, and more advanced operators like `ComposedOp` and `EmbeddedOp` must interact with a model before their parameters are allocated. \n", "\n", "We'll create a dummy `Model` object named `mdl`, and after we add `composedOp` to `mdl` and call `mdl.num_params()` things work like we expect them to:" ] }, { "cell_type": "code", "execution_count": 13, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "After: interacting w/Model: 28 params\n", "Factor 0 (StaticDenseOp) has 0 params\n", "Factor 1 (TPDenseOp) has 12 params\n", "Factor 2 (FullDenseOp) has 16 params\n" ] } ], "source": [ "mdl = po.ExplicitOpModel(['Q0']) # create a single-qubit model\n", "mdl.operations['Gcomposed'] = composedOp\n", "mdl.num_params()\n", "\n", "print(\"After: interacting w/Model:\",composedOp.num_params(),\"params\")\n", "for i,op in enumerate(composedOp.factorops):\n", " print(\"Factor %d (%s) has %d params\" % (i,op.__class__.__name__,op.num_params()))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Embedded operations\n", "Here's how to embed a single-qubit operator (`fullOp`, created above) into a 3-qubit state space, and have `fullOp` act on the second qubit (labelled `\"Q1\"`). Note that the parameters of an `EmbeddedOp` are just those of the underlying operator (the one that has been embedded)." ] }, { "cell_type": "code", "execution_count": 14, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Embedded gate with full dimension 64 and state space Q0(2)*Q1(2)*Q2(2)\n", " that embeds the following 4-dimensional gate into acting on the ['Q1'] space\n", "FullDenseOp with shape (4, 4)\n", " 1.00 0 0 0\n", " 0 1.00 0 0\n", " 0 0 0-0.90\n", " 0 0 0.90 0\n", "\n", "Dimension = 64 (3 qubits!)\n", "Number of parameters = 16\n" ] } ], "source": [ "embeddedOp = po.EmbeddedOp(['Q0','Q1','Q2'],['Q1'],fullOp)\n", "print(embeddedOp)\n", "print(\"Dimension =\",embeddedOp.dim, \"(%d qubits!)\" % (np.log2(embeddedOp.dim)/2))\n", "print(\"Number of parameters =\",embeddedOp.num_params())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Better together\n", "We can design even more complex operations using combinations of composed and embedded objects. For example, here's a 3-qubit operation that performs three separate 1-qubit operations (`staticOp`, `fullOp`, and `tpOp`) on each of the three qubits. (These three operations *happen* to all be $X(\\pi/2)$ gates because we're lazy and didn't bother to use `gate_mx` values in our examples above, but they *could* be entirely different.) The resulting `combinedOp` might represent a layer in which all three gates occur simultaneously. " ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Composed gate of 3 factors:\n", "Factor 0:\n", "Embedded gate with full dimension 64 and state space Q0(2)*Q1(2)*Q2(2)\n", " that embeds the following 4-dimensional gate into acting on the ['Q0'] space\n", "StaticDenseOp with shape (4, 4)\n", " 1.00 0 0 0\n", " 0 1.00 0 0\n", " 0 0 0-1.00\n", " 0 0 1.00 0\n", "Factor 1:\n", "Embedded gate with full dimension 64 and state space Q0(2)*Q1(2)*Q2(2)\n", " that embeds the following 4-dimensional gate into acting on the ['Q1'] space\n", "FullDenseOp with shape (4, 4)\n", " 1.00 0 0 0\n", " 0 1.00 0 0\n", " 0 0 0-0.90\n", " 0 0 0.90 0\n", "Factor 2:\n", "Embedded gate with full dimension 64 and state space Q0(2)*Q1(2)*Q2(2)\n", " that embeds the following 4-dimensional gate into acting on the ['Q2'] space\n", "TPDenseOp with shape (4, 4)\n", " 1.00 0 0 0\n", " 0 1.00 0 0\n", " 0 0 0-0.90\n", " 0 0 0.90 0\n", "\n", "Number of parameters = 28\n" ] } ], "source": [ "# use together\n", "mdl_3Q = po.ExplicitOpModel(['Q0','Q1','Q2'])\n", "combinedOp = po.ComposedOp( (po.EmbeddedOp(['Q0','Q1','Q2'],['Q0'],staticOp),\n", " po.EmbeddedOp(['Q0','Q1','Q2'],['Q1'],fullOp),\n", " po.EmbeddedOp(['Q0','Q1','Q2'],['Q2'],tpOp))\n", " )\n", "mdl_3Q.operations[(('Gstatic','Q0'),('Gfull','Q1'),('Gtp','Q2'))] = combinedOp\n", "mdl_3Q.num_params() # to recompute & allocate the model's parametes\n", "print(combinedOp)\n", "print(\"Number of parameters =\",combinedOp.num_params())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## More coming (soon?) ...\n", "While this tutorial covers the main ones, there're even more model-building-related objects that we haven't had time to cover here. We plan to update this tutorial, making it more comprehensive, in future versions of pyGSTi." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.7.0" } }, "nbformat": 4, "nbformat_minor": 2 }