{ "cells": [ { "cell_type": "markdown", "id": "4f546a10", "metadata": {}, "source": [ "(samplex-io)=" ] }, { "cell_type": "markdown", "id": "2997d421", "metadata": {}, "source": [ "# Samplex Inputs and Outputs\n", "\n", "## Introduction\n", "\n", "{class}`~.Samplex` is the core type of the samplomatic library.\n", "A samplex represents a parametric probability distribution over the\n", "parameters of some template circuit, as well as other array-valued fields to use in\n", "post-processing data collected from executing the bound template circuit. Its central interface \n", "is the {meth}`~.Samplex.sample` method that draws from this distribution to produce a\n", "collection of arrays.\n", "\n", "This guide is about using the {meth}`~.Samplex.sample` interface, how to query and specify required inputs, and how to inspect expected outputs.\n", "The arrays supplied to and returned from {meth}`~.Samplex.sample` are strongly typed, in the\n", "sense that for any particular instance of a samplex, their names, types, and shapes are fixed and queryable before any sampling is performed.\n", "The various inputs and outputs present in a samplex depend on many factors, for example, whether measurement twirling is present or whether noise\n", "injection is required.\n", "For this reason, we can't generally expect two samplex instances to have the same inputs and outputs as each other.\n", "\n", "## Setup\n", "\n", "To begin, we construct a boxed-up circuit and build it into a samplex and template pair to use in the examples that follow.\n", "Note that while manually boxing up the circuit, we use the names `alpha`, `beta`, `ref1`, `ref2`, `mod_ref1`, `mod_ref2`, `mod_ref3`, and `conclude`, and moreover the circuit has four `Parameter`s.\n", "This guide shows how each of these play a role in the samplex inputs and outputs.\n", "See [the transpiler guide](./transpiler) for information about boxing up circuits automatically." ] }, { "cell_type": "code", "execution_count": null, "id": "07d0b65e", "metadata": { "tags": [ "remove-input", "remove-output" ] }, "outputs": [], "source": [ "# Without this cell, plotly outputs do not appear in the docs. We hide this cell from itself being\n", "# rendered in the docs by editing its metadata to contain the tags [\"remove-input\", \"remove-output\"]\n", "import plotly.io as pio\n", "\n", "pio.renderers.default = \"sphinx_gallery\"" ] }, { "cell_type": "code", "execution_count": null, "id": "0176921d", "metadata": {}, "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "from qiskit.circuit import ClassicalRegister, Parameter, QuantumCircuit, QuantumRegister\n", "from qiskit.quantum_info import Operator, Pauli, PauliLindbladMap\n", "\n", "from samplomatic import ChangeBasis, InjectNoise, Twirl, build\n", "\n", "# our circuit has two classical registers named alpha and beta\n", "circuit = QuantumCircuit(\n", " QuantumRegister(4), alpha := ClassicalRegister(3, \"alpha\"), beta := ClassicalRegister(1, \"beta\")\n", ")\n", "\n", "# the first box is only twirled\n", "with circuit.box([Twirl()]):\n", " for idx in range(4):\n", " circuit.rx(Parameter(f\"a{idx}\"), idx)\n", " circuit.cz(0, 1)\n", " circuit.cz(1, 2)\n", "\n", "# the second box is twirled, and has noise injected\n", "with circuit.box([Twirl(), InjectNoise(ref=\"ref1\", modifier_ref=\"mod_ref1\")]):\n", " circuit.h(1)\n", " circuit.cz(1, 2)\n", "\n", "# the third box is twirled, and has different noise injected\n", "with circuit.box([Twirl(), InjectNoise(ref=\"ref2\", modifier_ref=\"mod_ref2\")]):\n", " circuit.rx(0.1, range(4))\n", " circuit.cz(0, 1)\n", " circuit.cz(1, 2)\n", "\n", "# the fourth box is the same as the second, but with a different modifer ref\n", "with circuit.box([Twirl(), InjectNoise(ref=\"ref1\", modifier_ref=\"mod_ref3\")]):\n", " circuit.h(1)\n", " circuit.cz(1, 2)\n", "\n", "circuit.barrier()\n", "\n", "# the final two boxes twirl, and add a basis change in one case\n", "with circuit.box([Twirl(), ChangeBasis(ref=\"conclude\")]):\n", " circuit.measure(range(3), alpha)\n", "\n", "with circuit.box([Twirl()]):\n", " circuit.measure([3], beta)\n", "\n", "circuit.draw(\"mpl\")" ] }, { "cell_type": "markdown", "id": "9f8a3aa7", "metadata": {}, "source": [ "Next, we call {func}`~.build` on the boxed-up circuit to construct a template and samplex pair, and we see how each of the boxes is turned into a barrier-sandwich including `rz-sx-rz-sx-rz` fragments to implement dressing." ] }, { "cell_type": "code", "execution_count": null, "id": "daf30300", "metadata": {}, "outputs": [], "source": [ "template, samplex = build(circuit)\n", "\n", "template.draw(\"mpl\", fold=100)" ] }, { "cell_type": "markdown", "id": "c3540a34", "metadata": {}, "source": [ "Plotting the samplex DAG and hovering over the nodes we see that \n", " - All of the sampling nodes (stars) are responsible for generating randomizations for twirling, sampling from noise models, or injecting basis changes.\n", " - All of the collection nodes (bow ties) are responsible for rendering slices of outputs, which are either measurement flips or parameter angles.\n", " - All of the intermediate nodes (circles) are responsible for various kinds of data manipulation." ] }, { "cell_type": "code", "execution_count": null, "id": "f9febfb0", "metadata": {}, "outputs": [], "source": [ "samplex.draw()" ] }, { "cell_type": "markdown", "id": "50e7afc6", "metadata": {}, "source": [ "## Querying the required inputs and expected outputs\n", "\n", "The easiest way to see the required inputs and expected outputs is to print the {class}`~.Samplex` object. Array items are formatted as `'{name}' <{type}[{shape}...]>`, and the required inputs precede the optional inputs." ] }, { "cell_type": "code", "execution_count": null, "id": "b438811f", "metadata": {}, "outputs": [], "source": [ "print(samplex)" ] }, { "cell_type": "markdown", "id": "3a45dbea", "metadata": {}, "source": [ "The {meth}`~.Samplex.inputs` and {meth}`~.Samplex.outputs` of the samplex, as well as more detailed information like their names, shapes, types, and descriptions, can also be queried programatically. The return type of both of these methods is a {class}`~.TensorInterface`. For example, we list here all specifications of the input interface:" ] }, { "cell_type": "code", "execution_count": null, "id": "11348b44", "metadata": {}, "outputs": [], "source": [ "samplex.inputs().specs" ] }, { "cell_type": "markdown", "id": "46a95cda", "metadata": {}, "source": [ "Next, as an example, we choose to print some information about those output specifiers whose names contain the string `\"flips\"`." ] }, { "cell_type": "code", "execution_count": null, "id": "d764761d", "metadata": {}, "outputs": [], "source": [ "for spec in samplex.outputs().get_specs(\"flips\"):\n", " print(spec.name, spec.shape, spec.dtype)" ] }, { "cell_type": "markdown", "id": "6da925d5", "metadata": {}, "source": [ "## Binding input values and sampling\n", "\n", "In order to {meth}`~.Samplex.sample`, the samplex needs to be provided with values for at least those {meth}`~.Samplex.inputs` that are marked as required.\n", "These values are first bound to an input interface so that it can perform all necessary type checking (or type coercion in some cases) and shape analysis. \n", "If anything is wrong with the types or shapes, or if a required value is missing, a verbose error is raised.\n", "\n", "### Binding input\n", "\n", "In the following example, we pass the {meth}`~.Samplex.sample` method its bare minimum requirements, and use it to draw 3 randomizations from the samplex.\n", "The required bindings arising from the dressings are the Pauli Lindblad maps for noise injection and the basis change array. In addition to these, values for the four parameters in the original boxed-up circuit are required, which in this case we set to `np.linspace(0, 1, 4)`. The samplex will compose these parameters into the dressings, so that they effectively end up as modifications to the output parameter values of the samplex.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "674498e7", "metadata": {}, "outputs": [], "source": [ "inputs = samplex.inputs().bind(\n", " # bind() concatenates nested dicts with a '.' so that we can\n", " # set 'pauli_lindblad_maps.noise0/1' as follows. note that the\n", " # names 'ref1' and 'ref2' derive from the names in the InjectNoise\n", " # annotation.\n", " pauli_lindblad_maps={\n", " \"ref1\": PauliLindbladMap.identity(2),\n", " \"ref2\": PauliLindbladMap.identity(4),\n", " },\n", " # likewise, we can set all basis changes as follows, where\n", " # the name 'conclude' comes from our basis change annotation\n", " basis_changes={\"conclude\": [0, 1, 2]},\n", " # we must provide values for all parameters in the original circuit\n", " parameter_values=np.linspace(0, 1, 4),\n", ")\n", "\n", "outputs = samplex.sample(inputs, num_randomizations=3)\n", "print(outputs)" ] }, { "cell_type": "markdown", "id": "4a212742", "metadata": {}, "source": [ "Inputs can be bound to the interface through multiple calls to {meth}`~.TensorInterface.bind` or by direct item assignment.\n", "Despite the nested dictionary format that {meth}`~.TensorInterface.bind` allows for convenience, it is a flat mapping object, and supports all of the usual mapping syntax.\n", "Once all required inputs have been found, {attr}`~.TensorInterface.fully_bound` becomes true." ] }, { "cell_type": "code", "execution_count": null, "id": "898d2d00", "metadata": {}, "outputs": [], "source": [ "inputs = (\n", " samplex.inputs()\n", " .bind(\n", " pauli_lindblad_maps={\n", " \"ref1\": PauliLindbladMap.identity(2),\n", " \"ref2\": PauliLindbladMap.identity(4),\n", " }\n", " )\n", " .bind(parameter_values=np.linspace(0, 1, 4))\n", ")\n", "\n", "# once the final requirement is bound, fully_bound becomes True\n", "print(\"Fully bound?\", inputs.fully_bound)\n", "inputs[\"basis_changes.conclude\"] = [0, 2, 3]\n", "print(\"Fully bound?\", inputs.fully_bound)\n", "\n", "# setting optional values does not affect whether the interface is fully bound\n", "inputs[\"noise_scales.mod_ref1\"] = 3\n", "print(\"Fully bound?\", inputs.fully_bound)" ] }, { "cell_type": "markdown", "id": "af2782a0", "metadata": {}, "source": [ "All {class}`~.TensorInterface` objects, including the {class}`~.SamplexOutput` subclass, are mapping objects against data that has been bound to them.\n", "This means that we can get, for example, the template parameter values as follows, which in this case has shape `(3, 48)` since we asked for 3 randomizations and the template circuit has 48 parameters." ] }, { "cell_type": "code", "execution_count": null, "id": "de081625", "metadata": {}, "outputs": [], "source": [ "print(\"(num_randomizations, num_template_params) =\", outputs[\"parameter_values\"].shape)\n", "print(outputs[\"parameter_values\"][0, :])" ] }, { "cell_type": "markdown", "id": "611cbea9", "metadata": {}, "source": [ "### Specifying input as a dictionary\n", "\n", "Instead of providing a fully-bound {class}`~.TensorInterface`, we can specify the inputs as a standard dictionary, as is shown in the cell below. In this case, the {meth}`~.Samplex.sample` method internally binds the items of the dictionary to the samplex' input interface to validate all required inputs are present and compatible." ] }, { "cell_type": "code", "execution_count": null, "id": "ba9ac951", "metadata": {}, "outputs": [], "source": [ "inputs = {\n", " \"pauli_lindblad_maps\": {\n", " \"ref1\": PauliLindbladMap.identity(2),\n", " \"ref2\": PauliLindbladMap.identity(4),\n", " },\n", " \"basis_changes\": {\"conclude\": [0, 1, 2]},\n", " \"parameter_values\": np.linspace(0, 1, 4),\n", "}\n", "outputs = samplex.sample(inputs, num_randomizations=3)" ] }, { "cell_type": "markdown", "id": "aeae6d5c", "metadata": {}, "source": [ "### Input types\n", "\n", "Presently, there are two distinct interface value types, though this may grow:\n", " - Array-valued: These have a shape and a data type. Bound values are coerced into the data type if possible. Any dimension can be a free dimension.\n", " - Pauli Lindblad maps: These must be of type {class}`qiskit.quantum_info.PauliLindbladMap` and must act on the prescribed number of qubits. The number of terms in the map is a free dimension." ] }, { "cell_type": "code", "execution_count": null, "id": "482b839f", "metadata": {}, "outputs": [], "source": [ "# up to precision loss, these are equivalent via automatic type coersion of array inputs\n", "samplex.inputs()[\"parameter_values\"] = [1, 2, 8, 9]\n", "samplex.inputs()[\"parameter_values\"] = np.array([1, 2, 8, 9], dtype=np.float16)" ] }, { "cell_type": "markdown", "id": "23b10694", "metadata": {}, "source": [ "### Free dimensions" ] }, { "cell_type": "markdown", "id": "e2b10f7d", "metadata": {}, "source": [ "The {class}`~.TensorInterface` class has a notion of _free dimensions_, which are named integer sizes whose values are undetermined until some data has been bound to the interface that constrains them.\n", "All free dimensions of the same name must resolve to a consistent size when binding data or an error will be raised.\n", "For example, in the {meth}`~.Samplex.outputs` of any samplex, the array axis corresponding to randomizations is a free parameter that has the name `\"num_randomizations\"` by convention.\n", "When {func}`~.Samplex.sample` is called, it binds values to the outputs" ] }, { "cell_type": "code", "execution_count": null, "id": "1b5588dd", "metadata": {}, "outputs": [], "source": [ "print(\"All free dimensions:\", outputs.free_dimensions)\n", "print(\"Current constraints:\", outputs.bound_dimensions)" ] }, { "cell_type": "markdown", "id": "b0f4f7a3", "metadata": {}, "source": [ "Another common free-dimension scenario is the sharing of term constraints between Pauli lindblad map specifications and their modifiers.\n", "A {class}`qiskit.quantum_info.PauliLindbladMap` is an ordered sequence of terms, where each term contains a floating point rate and a sparse Pauli representation.\n", "The {func}`~.build` function adds a specifier `\"pauli_lindblad_maps.\"` for this type for each {class}`~.InjectNoise` annotation, and if it contains a {attr}`~.InjectNoise.modifier_ref`,\n", "then a `\"local_scales.\"` specifier is also added.\n", "Both of these share the free dimension called `\"num_terms_\"` that specifies how many Pauli Lindblad terms are present in the noise model, for the latter needs to zip against the rates of the former in order to scale them." ] }, { "cell_type": "code", "execution_count": null, "id": "6b8febe7", "metadata": {}, "outputs": [], "source": [ "# both the PauliLindbladMap and the local scales imply 2 terms, so that the free dimension\n", "# 'num_terms_noise2' is satisfied\n", "inputs = samplex.inputs().bind(\n", " pauli_lindblad_maps={\"ref2\": PauliLindbladMap.from_list([(\"XXYZ\", 0.1), (\"IIXX\", 0.2)])},\n", " local_scales={\"mod_ref2\": [1, 2]},\n", ")\n", "print(\"All free dimensions:\", inputs.free_dimensions)\n", "print(\"Current constraints:\", inputs.bound_dimensions)" ] }, { "cell_type": "markdown", "id": "4ae1cf61", "metadata": {}, "source": [ "## Qubit ordering convention\n", "\n", "Samplexes constructed from boxed-up circuits by the {func}`~.build` function can require array-valued inputs to {meth}`~.Samplex.sample` where one index corresponds to qubits of some box of the circuit.\n", "This section explains the ordering convention that should be used for such axes.\n", "In short: use the qubit order of the boxed-up circuit, restricted to the qubits of the box.\n", "\n", "As an example, suppose we are trying to figure out how to specify a Pauli Lindblad map for `\"noise1\"`.\n", "Then the order of qubits in the noise map is with respect to these qubits:" ] }, { "cell_type": "code", "execution_count": null, "id": "ff6b8981", "metadata": {}, "outputs": [], "source": [ "box_of_interest = circuit[1]\n", "qubit_ordering_convention = [qubit for qubit in circuit.qubits if qubit in box_of_interest.qubits]\n", "qubit_ordering_convention" ] }, { "cell_type": "markdown", "id": "7cb8cfbb", "metadata": {}, "source": [ "That is, if we want `` to be noisy with `X` noise, then we should define a Pauli Lindblad map like this one because it is at index `1` of `qubit_ordering_convention`." ] }, { "cell_type": "code", "execution_count": null, "id": "a0214a6c", "metadata": {}, "outputs": [], "source": [ "samplex.inputs().bind(\n", " pauli_lindblad_maps={\n", " \"ref1\": (noise1 := PauliLindbladMap.from_sparse_list([(\"X\", [1], 0.12)], num_qubits=2))\n", " }\n", ")\n", "noise1" ] }, { "cell_type": "markdown", "id": "13015694", "metadata": {}, "source": [ "The same holds true for basis changes. If we want to rotate into the +1 eigenstates of the operators X, Y, and Z for qubits 0, 1, and 2 of `circuit` respectively, then we should do so with the following array, recalling the symplectic convention {math}`I=0`, {math}`Z=1`, {math}`X=2`, and {math}`Y=3` is used in this library, as well as qiskit." ] }, { "cell_type": "code", "execution_count": null, "id": "e99ff659", "metadata": {}, "outputs": [], "source": [ "samplex.inputs().bind(basis_changes={\"conclude\": (basis_change := [2, 3, 1])})\n", "basis_change" ] }, { "cell_type": "markdown", "id": "7db1a4ed", "metadata": {}, "source": [ "Note that the qubit ordering convention is _not_ the order defined by:\n", " - `box_instruction.qubits`, where `box_instruction` is a {class}`qiskit.circuit.CircuitInstruction` whose operation is a {class}`qiskit.circuit.BoxOp`, because the order of this list of qubits may depend on the order in which instructions were added to the box context, which would be a confusing convention. For example, `with box(): circuit.x([0, 1])` and `with box(): circuit.x([1, 0])` would result in different orderings.\n", " - `box_instruction.operation.body.qubits` because these qubits might not even appear in the outer circuit; qubits inside of the actual body of `BoxOp` instructions (or any other control flow operation like `IfElseOp`) are only promised to have a consisent meaning within that scope. This would be an ill-defined convention, and hard to use in general." ] }, { "cell_type": "markdown", "id": "57d97f42", "metadata": {}, "source": [ "## Local testing with the template circuit\n", "\n", "If a {class}`~.Samplex` is constructed via the {func}`~.build` function, then the parameter values that it outputs should be directly compatible with the parameters of the associated template circuit.\n", "For example, we can bind values for one of the randomizations to the template for local inspection." ] }, { "cell_type": "code", "execution_count": null, "id": "563885fa", "metadata": {}, "outputs": [], "source": [ "inputs = samplex.inputs().bind(\n", " pauli_lindblad_maps={\n", " \"ref1\": PauliLindbladMap.identity(2),\n", " \"ref2\": PauliLindbladMap.identity(4),\n", " },\n", " basis_changes={\"conclude\": [0, 0, 0]},\n", " parameter_values=np.linspace(0, 1, 4),\n", ")\n", "\n", "outputs = samplex.sample(inputs, num_randomizations=3)\n", "\n", "bound_template = template.assign_parameters(outputs[\"parameter_values\"][2])\n", "bound_template.draw(\"mpl\", fold=1000)" ] }, { "cell_type": "markdown", "id": "d46c1d45", "metadata": {}, "source": [ "This enables a means to inspect that the samplex is outputing samples that are expected.\n", "For example, below, we cast the bound circuit to a {class}`qiskit.quantum_info.Operator` object and compose with the bitflip Paulis to visually inspect that all three randomizations are logically equivalent to the base circuit." ] }, { "cell_type": "code", "execution_count": null, "id": "492f3c01", "metadata": {}, "outputs": [], "source": [ "from samplomatic.utils import unbox\n", "\n", "# Operator requires that we unpack all of the boxes first\n", "unboxed = unbox(circuit).assign_parameters(inputs[\"parameter_values\"])\n", "# We want to plot unitary matrix amplitudes, so we need to remove the non-unitary measurements\n", "unboxed.remove_final_measurements()\n", "unboxed_unitary = Operator(unboxed)\n", "\n", "# Plot magnitudes of the unitary matrix represenation of the circuit\n", "plt.subplot(1, 4, 1)\n", "plt.imshow(np.abs(unboxed_unitary))\n", "plt.title(\"Base Circuit\")\n", "\n", "# For each randomization we sampled, plot the unitary matrix representation\n", "for idx in range(3):\n", " bound_template = template.assign_parameters(outputs[\"parameter_values\"][idx])\n", " bound_template.remove_final_measurements()\n", "\n", " # Our example does measurement twirling by compiling random bitflip gates before the\n", " # measurements, so we need to undo these at the operator level for each randomization.\n", " # We do this by casting them to Paulis and composing with the bound circuit's unitary\n", " alpha_flips = Pauli(([0, 0, 0], outputs[\"measurement_flips.alpha\"][idx, 0]))\n", " beta_flips = Pauli(([0], outputs[\"measurement_flips.beta\"][idx, 0]))\n", " flips = beta_flips ^ alpha_flips\n", " bound_unitary = Operator(bound_template) & flips\n", "\n", " # Plot magnitudes of the unitary matrix represenation of the circuit\n", " plt.subplot(1, 4, idx + 2)\n", " plt.imshow(np.abs(bound_unitary))\n", " plt.title(f\"Randomization {idx}\")\n", "\n", "plt.tight_layout()" ] }, { "cell_type": "markdown", "id": "6924386c", "metadata": {}, "source": [ "Along the same lines, here, we pass output parameter values and the template to a sampler to simulate 10,000 shots of all randomizations, and compare the resulting expectation values (only of the alpha register) to the expectation values of a simulation of the base circuit alone." ] }, { "cell_type": "code", "execution_count": null, "id": "9ed6e27e", "metadata": {}, "outputs": [], "source": [ "from qiskit.primitives import StatevectorEstimator as Estimator\n", "from qiskit.primitives import StatevectorSampler as Sampler\n", "from qiskit.primitives.containers import BitArray\n", "\n", "# simulate some expectation values direction from the base circuit\n", "estimator_job = Estimator().run([(unboxed, [\"IZII\", \"IIZI\", \"IIIZ\"])])\n", "evs = estimator_job.result()[0].data[\"evs\"]\n", "\n", "# get the sampler data from each randomization, and do bitflip correction\n", "sampler_job = Sampler().run([(template, outputs[\"parameter_values\"])], shots=10_000)\n", "alpha_data = sampler_job.result()[0].data[\"alpha\"]\n", "alpha_data ^= BitArray.from_bool_array(outputs[\"measurement_flips.alpha\"], \"little\")\n", "\n", "# compare the results\n", "print(\" EVs from base circuit:\", evs)\n", "print(\"EVs from randomizations:\", alpha_data.expectation_values([\"ZII\", \"IZI\", \"IIZ\"]))" ] } ], "metadata": { "kernelspec": { "display_name": ".venv", "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.11.1" } }, "nbformat": 4, "nbformat_minor": 5 }