Repository: tensorflow/quantum (language: python)

Issue:
New noise tutorial
The [original noise tutorial](https://github.com/tensorflow/quantum/pull/454) was written prior to the introduction of noisy ops into TFQ.  Now that we have these, a new tutorial is appropriate.  One idea is to base it on a well understood paper.  In the comments below, feel free to propose papers or other ideas on what this tutorial should look like.


Discussion / hints:
I'm not sure if any of these are really "seminal" or well established enough, but maybe they can help generate some ideas: 
- [Machine Learning of Noise-Resilient Quantum Circuits](https://journals.aps.org/prxquantum/pdf/10.1103/PRXQuantum.2.010324)
- [Machine learning of high dimensional data on a noisy quantum processor](https://arxiv.org/pdf/2101.09581.pdf)
- [Optimizing parametrized quantum circuits via noise-induced breaking of symmetries](https://arxiv.org/pdf/2011.08763v1.pdf)
- [Noise robustness and experimental demonstration of a quantum generative adversarial network for continuous distributions](https://arxiv.org/pdf/2006.01976.pdf)

Also, since every hardware experiment is noisy, maybe recreating experimental papers (w/ noise) is an idea:
- [Generation of High-Resolution Handwritten Digits with an Ion-Trap Quantum Computer](https://arxiv.org/pdf/2012.03924.pdf), [Experimental Quantum Generative Adversarial Networks for Image Generation](https://arxiv.org/pdf/2010.06201.pdf)
- [Hartree-Fock on a superconducting qubit quantum computer](https://arxiv.org/pdf/2004.04174.pdf)
- [Analyzing the Performance of Variational Quantum Factoring on a Superconducting Quantum Processor](https://arxiv.org/pdf/2012.07825.pdf)

Fix patch:
diff --git a/docs/_book.yaml b/docs/_book.yaml
index 49552bc2e..47daeba1b 100644
--- a/docs/_book.yaml
+++ b/docs/_book.yaml
@@ -48,6 +48,8 @@ upper_tabs:
         path: /quantum/tutorials/barren_plateaus
       - title: "Quantum CNN"
         path: /quantum/tutorials/qcnn
+      - title: "Noise"
+        path: /quantum/tutorials/noise
       - title: "Research tools"
         path: /quantum/tutorials/research_tools
       - title: "Quantum reinforcement learning"
diff --git a/docs/tutorials/images/noise_1.png b/docs/tutorials/images/noise_1.png
new file mode 100644
index 000000000..a24e62c65
Binary files /dev/null and b/docs/tutorials/images/noise_1.png differ
diff --git a/docs/tutorials/images/noise_2.png b/docs/tutorials/images/noise_2.png
new file mode 100644
index 000000000..15d02e808
Binary files /dev/null and b/docs/tutorials/images/noise_2.png differ
diff --git a/docs/tutorials/noise.ipynb b/docs/tutorials/noise.ipynb
new file mode 100644
index 000000000..a11e16f20
--- /dev/null
+++ b/docs/tutorials/noise.ipynb
@@ -0,0 +1,790 @@
+{
+  "nbformat": 4,
+  "nbformat_minor": 0,
+  "metadata": {
+    "colab": {
+      "name": "noise.ipynb",
+      "provenance": []
+    },
+    "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.8.8"
+    }
+  },
+  "cells": [
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "xLOXFOT5Q40E"
+      },
+      "source": [
+        "##### Copyright 2020 The TensorFlow Authors."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "cellView": "form",
+        "id": "iiQkM5ZgQ8r2"
+      },
+      "source": [
+        "#@title Licensed under the Apache License, Version 2.0 (the \"License\");\n",
+        "# you may not use this file except in compliance with the License.\n",
+        "# You may obtain a copy of the License at\n",
+        "#\n",
+        "# https://www.apache.org/licenses/LICENSE-2.0\n",
+        "#\n",
+        "# Unless required by applicable law or agreed to in writing, software\n",
+        "# distributed under the License is distributed on an \"AS IS\" BASIS,\n",
+        "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n",
+        "# See the License for the specific language governing permissions and\n",
+        "# limitations under the License."
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "UndbWF_UpN-X"
+      },
+      "source": [
+        "# Noise"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "i9Jcnb8bQQyd"
+      },
+      "source": [
+        "<table class=\"tfo-notebook-buttons\" align=\"left\">\n",
+        "  <td>\n",
+        "    <a target=\"_blank\" href=\"https://www.tensorflow.org/quantum/tutorials/noise\"><img src=\"https://www.tensorflow.org/images/tf_logo_32px.png\" />View on TensorFlow.org</a>\n",
+        "  </td>\n",
+        "  <td>\n",
+        "    <a target=\"_blank\" href=\"https://colab.research.google.com/github/tensorflow/quantum/blob/master/docs/tutorials/noise.ipynb\"><img src=\"https://www.tensorflow.org/images/colab_logo_32px.png\" />Run in Google Colab</a>\n",
+        "  </td>\n",
+        "  <td>\n",
+        "    <a target=\"_blank\" href=\"https://github.com/tensorflow/quantum/blob/master/docs/tutorials/noise.ipynb\"><img src=\"https://www.tensorflow.org/images/GitHub-Mark-32px.png\" />View source on GitHub</a>\n",
+        "  </td>\n",
+        "  <td>\n",
+        "    <a href=\"https://storage.googleapis.com/tensorflow_docs/quantum/docs/tutorials/noise.ipynb\"><img src=\"https://www.tensorflow.org/images/download_logo_32px.png\" />Download notebook</a>\n",
+        "  </td>\n",
+        "</table>"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "fHHaKIG06Iv_"
+      },
+      "source": [
+        "Noise is present in modern day quantum computers. Qubits are susceptible to interference from the surrounding environment, imperfect fabrication, TLS and sometimes even [gamma rays](https://arxiv.org/abs/2104.05219). Until large scale error correction is reached, the algorithms of today must be able to remain functional in the presence of noise. This makes testing algorithms under noise an important step for validating quantum algorithms / models will function on the quantum computers of today.\n",
+        "\n",
+        "In this tutorial you will explore the basics of noisy circuit simulation in TFQ via the high level `tfq.layers` API.\n",
+        "\n",
+        "## Setup"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "J2CRbYRqrLdt"
+      },
+      "source": [
+        "!pip install tensorflow==2.4.1 tensorflow-quantum"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "QStNslxBwgte"
+      },
+      "source": [
+        "!pip install -q git+https://github.com/tensorflow/docs"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "iRU07S4o8B52"
+      },
+      "source": [
+        "import random\n",
+        "import cirq\n",
+        "import sympy\n",
+        "import tensorflow_quantum as tfq\n",
+        "import tensorflow as tf\n",
+        "import numpy as np\n",
+        "# Plotting\n",
+        "import matplotlib.pyplot as plt\n",
+        "import tensorflow_docs as tfdocs\n",
+        "import tensorflow_docs.plots"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "CVnAGxZyruv8"
+      },
+      "source": [
+        "## 1. Understanding quantum noise\n",
+        "\n",
+        "### 1.1 Basic circuit noise\n",
+        "\n",
+        "Noise on a quantum computer impacts the bitstring samples you are able to measure from it. One intuitive way you can start to think about this is that a noisy quantum computer will \"insert\", \"delete\" or \"replace\" gates in random places like the diagram below:\n",
+        "\n",
+        "<img src=\"./images/noise_1.png\" width=700>\n",
+        "\n",
+        "Building off of this intuition, when dealing with noise, you are no longer using a single pure state $|\\psi \\rangle$ but instead dealing with an *ensemble* of all possible noisy realizations of your desired circuit: $\\rho = \\sum_j p_j |\\psi_j \\rangle \\langle \\psi_j |$ . Where $p_j$ gives the probability that the system is in $|\\psi_j \\rangle$ .\n",
+        "\n",
+        "Revisiting the above picture, if we knew beforehand that 90% of the time our system executed perfectly, or errored 10% of the time with just this one mode of failure, then our ensemble would be: \n",
+        "\n",
+        "$\\rho = 0.9 |\\psi_\\text{desired} \\rangle \\langle \\psi_\\text{desired}| + 0.1 |\\psi_\\text{noisy} \\rangle \\langle \\psi_\\text{noisy}| $\n",
+        "\n",
+        "If there was more than just one way that our circuit could error, then the ensemble $\\rho$ would contain more than just two terms (one for each new noisy realization that could happen). $\\rho$ is referred to as the [density matrix](https://en.wikipedia.org/wiki/Density_matrix) describing your noisy system.\n",
+        "\n",
+        "### 1.2 Using channels to model circuit noise\n",
+        "\n",
+        "Unfortunately in practice it's nearly impossible to know all the ways your circuit might error and their exact probabilities. A simplifying assumption you can make is that after each operation in your circuit there is some kind of [channel](https://quantumai.google/cirq/noise) that roughly captures how that operation might error. You can quickly create a circuit with some noise:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "Eu_vpHbfrQKQ"
+      },
+      "source": [
+        "def x_circuit(qubits):\n",
+        "  \"\"\"Produces an X wall circuit on `qubits`.\"\"\"\n",
+        "  return cirq.Circuit(cirq.X.on_each(*qubits))\n",
+        "\n",
+        "def make_noisy(circuit, p):\n",
+        "  \"\"\"Add a depolarization channel to all qubits in `circuit` before measurement.\"\"\"\n",
+        "  return circuit + cirq.Circuit(cirq.depolarize(p).on_each(*circuit.all_qubits()))\n",
+        "\n",
+        "my_qubits = cirq.GridQubit.rect(1, 2)\n",
+        "my_circuit = x_circuit(my_qubits)\n",
+        "my_noisy_circuit = make_noisy(my_circuit, 0.5)\n",
+        "my_circuit"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "1B7vmyPm_TQ7"
+      },
+      "source": [
+        "my_noisy_circuit"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "EejhXc2e9Cl8"
+      },
+      "source": [
+        "You can examine the noiseless density matrix $\\rho$ with:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "0QN9W69U8v_V"
+      },
+      "source": [
+        "rho = cirq.final_density_matrix(my_circuit)\n",
+        "np.round(rho, 3)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "RHHBeizr-DEo"
+      },
+      "source": [
+        "And the noisy density matrix $\\rho$ with:\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "zSD9H8SC9IJ1"
+      },
+      "source": [
+        "rho = cirq.final_density_matrix(my_noisy_circuit)\n",
+        "np.round(rho, 3)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "2YWiejLl-a0Z"
+      },
+      "source": [
+        "Comparing the two different $ \\rho $ 's you can see that the noise has impacted the amplitudes of the state (and consequently sampling probabilities). In the noiseless case you would always expect to sample the $ |11\\rangle $ state. But in the noisy state there is now a nonzero probability of sampling $ |00\\rangle $ or $ |01\\rangle $ or $ |10\\rangle $ as well:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "Z4uj-Zs0AE3n"
+      },
+      "source": [
+        "\"\"\"Sample from my_noisy_circuit.\"\"\"\n",
+        "def plot_samples(circuit):\n",
+        "  samples = cirq.sample(circuit + cirq.measure(*circuit.all_qubits(), key='bits'), repetitions=1000)\n",
+        "  freqs, _ = np.histogram(samples.data['bits'], bins=[i+0.01 for i in range(-1,2** len(my_qubits))])\n",
+        "  plt.figure(figsize=(10,5))\n",
+        "  plt.title('Noisy Circuit Sampling')\n",
+        "  plt.xlabel('Bitstring')\n",
+        "  plt.ylabel('Frequency')\n",
+        "  plt.bar([i for i in range(2** len(my_qubits))], freqs, tick_label=['00','01','10','11'])\n",
+        "\n",
+        "plot_samples(my_noisy_circuit)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "IpPh1Y0HEOWs"
+      },
+      "source": [
+        "Without any noise you will always get $|11\\rangle$:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "NRCOhTVpEJzz"
+      },
+      "source": [
+        "\"\"\"Sample from my_circuit.\"\"\"\n",
+        "plot_samples(my_circuit)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "EMbJBXAiT9GH"
+      },
+      "source": [
+        "If you increase the noise a little further it will become harder and harder to distinguish the desired behavior (sampling $|11\\rangle$ ) from the noise:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "D2Fg-FUdUJQx"
+      },
+      "source": [
+        "my_really_noisy_circuit = make_noisy(my_circuit, 0.75)\n",
+        "plot_samples(my_really_noisy_circuit)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "oV-0WV5Z7FQ8"
+      },
+      "source": [
+        "Note: Try experimenting with different channels in your circuit to generate noise. Common channels supported in both Cirq and TFQ can be found [here](https://github.com/quantumlib/Cirq/blob/master/cirq-core/cirq/ops/common_channels.py)"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "atzsYj5qScn0"
+      },
+      "source": [
+        "## 2. Basic noise in TFQ\n",
+        "With this understanding of how noise can impact circuit execution, you can explore how noise works in TFQ. TensorFlow Quantum uses monte-carlo / trajectory based simulation as an alternative to density matrix simulation. This is because the memory complexity of density matrix simulation limits large simulations to being <= 20 qubits with traditional full density matrix simulation methods. Monte-carlo / trajectory trades this cost in memory for additional cost in time. The `backend='noisy'` option available to all `tfq.layers.Sample`, `tfq.layers.SampledExpectation` and `tfq.layers.Expectation` (In the case of `Expectation` this does add a required `repetitions` parameter).\n",
+        "\n",
+        "### 2.1 Noisy sampling in TFQ\n",
+        "To recreate the above plots using TFQ and trajectory simulation you can use `tfq.layers.Sample`"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "byVI5nbNQ4_b"
+      },
+      "source": [
+        "\"\"\"Draw bitstring samples from `my_noisy_circuit`\"\"\"\n",
+        "bitstrings = tfq.layers.Sample(backend='noisy')(my_noisy_circuit, repetitions=1000)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "ncl0ruCZrd2s"
+      },
+      "source": [
+        "numeric_values = np.einsum('ijk,k->ij', bitstrings.to_tensor().numpy(), [1, 2])[0]\n",
+        "freqs, _ = np.histogram(numeric_values, bins=[i+0.01 for i in range(-1,2** len(my_qubits))])\n",
+        "plt.figure(figsize=(10,5))\n",
+        "plt.title('Noisy Circuit Sampling')\n",
+        "plt.xlabel('Bitstring')\n",
+        "plt.ylabel('Frequency')\n",
+        "plt.bar([i for i in range(2** len(my_qubits))], freqs, tick_label=['00','01','10','11'])"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "QfHq13RwuLlF"
+      },
+      "source": [
+        "### 2.2 Noisy sample based expectation\n",
+        "To do noisy sample based expectation calculation you can use `tfq.layers.SampleExpectation`:\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "ep45G-09rfrA"
+      },
+      "source": [
+        "some_observables = [cirq.X(my_qubits[0]), cirq.Z(my_qubits[0]), 3.0 * cirq.Y(my_qubits[1]) + 1]\n",
+        "some_observables"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "ur4iF_PGv0Xf"
+      },
+      "source": [
+        "Compute the noiseless expectation estimates via sampling from the circuit:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "jL6wJ3LCvNcn"
+      },
+      "source": [
+        "noiseless_sampled_expectation = tfq.layers.SampledExpectation(backend='noiseless')(\n",
+        "    my_circuit, operators=some_observables, repetitions=10000\n",
+        ")\n",
+        "noiseless_sampled_expectation.numpy()"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "c6hHgNtEv40i"
+      },
+      "source": [
+        "Compare those with the noisy versions:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "8U4Gm-LGvYqa"
+      },
+      "source": [
+        "noisy_sampled_expectation = tfq.layers.SampledExpectation(backend='noisy')(\n",
+        "    [my_noisy_circuit, my_really_noisy_circuit], operators=some_observables, repetitions=10000\n",
+        ")\n",
+        "noisy_sampled_expectation.numpy()"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "CqQ_2c7XwMku"
+      },
+      "source": [
+        "You can see that the noise has particularly impacted the $\\langle \\psi | Z | \\psi \\rangle$ accuracy, with `my_really_noisy_circuit` concentrating very quickly towards 0.\n",
+        "\n",
+        "### 2.3 Noisy analytic expectation calculation\n",
+        "Doing noisy analytic expectation calculations is nearly identical to above:\n",
+        "\n"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "pGXKlyCywAfj"
+      },
+      "source": [
+        "noiseless_analytic_expectation = tfq.layers.Expectation(backend='noiseless')(\n",
+        "    my_circuit, operators=some_observables\n",
+        ")\n",
+        "noiseless_analytic_expectation.numpy()"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "6FUkJ7aOyTlI"
+      },
+      "source": [
+        "noisy_analytic_expectation = tfq.layers.Expectation(backend='noisy')(\n",
+        "    [my_noisy_circuit, my_really_noisy_circuit], operators=some_observables, repetitions=10000\n",
+        ")\n",
+        "noisy_analytic_expectation.numpy()"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "5KHvORT42XFV"
+      },
+      "source": [
+        "## 3. Hybrid models and quantum data noise\n",
+        "Now that you have implemented some noisy circuit simulations in TFQ, you can experiment with how noise impacts quantum and hybrid quantum classical models, by comparing and contrasting their noisy vs noiseless performance. A good first check to see if a model or algorithm is robust to noise is to test under a circuit wide depolarizing model which looks something like this:\n",
+        "\n",
+        "<img src=\"./images/noise_2.png\" width=500>\n",
+        "\n",
+        "Where each time slice of the circuit (sometimes referred to as moment) has a depolarizing channel appended after each gate operation in that time slice. The depolarizing channel with apply one of $\\{X, Y, Z \\}$ with probability $p$ or apply nothing (keep the original operation) with probability $1-p$.\n",
+        "\n",
+        "### 3.1 Data\n",
+        "For this example you can use some prepared circuits in the `tfq.datasets` module as training data:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "_ZqVLEji2WUx"
+      },
+      "source": [
+        "qubits = cirq.GridQubit.rect(1, 8)\n",
+        "circuits, labels, pauli_sums, _ = tfq.datasets.xxz_chain(qubits, 'closed')\n",
+        "circuits[0]"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "MFgNU_nBGeTm"
+      },
+      "source": [
+        "Writing a small helper function will help to generate the data for the noisy vs noiseless case:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "zkQofAqqGibQ"
+      },
+      "source": [
+        "def get_data(qubits, depolarize_p=0.):\n",
+        "  \"\"\"Return quantum data circuits and labels in `tf.Tensor` form.\"\"\"\n",
+        "  circuits, labels, pauli_sums, _ = tfq.datasets.xxz_chain(qubits, 'closed')\n",
+        "  if depolarize_p >= 1e-5:\n",
+        "    circuits = [circuit.with_noise(cirq.depolarize(depolarize_p)) for circuit in circuits]\n",
+        "  tmp = list(zip(circuits, labels))\n",
+        "  random.shuffle(tmp)\n",
+        "  circuits_tensor = tfq.convert_to_tensor([x[0] for x in tmp])\n",
+        "  labels_tensor = tf.convert_to_tensor([x[1] for x in tmp])\n",
+        "\n",
+        "  return circuits_tensor, labels_tensor"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "FtJrfsLCF9Z3"
+      },
+      "source": [
+        "### 3.2 Define a model circuit\n",
+        "Now that you have quantum data in the form of circuits, you will need a circuit to model this data, like with the data you can write a helper function to generate this circuit optionally containing noise:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "TwryFaFIG2Ya"
+      },
+      "source": [
+        "def modelling_circuit(qubits, depth, depolarize_p=0.):\n",
+        "  \"\"\"A simple classifier circuit.\"\"\"\n",
+        "  dim = len(qubits)\n",
+        "  ret = cirq.Circuit(cirq.H.on_each(*qubits))\n",
+        "\n",
+        "  for i in range(depth):\n",
+        "    # Entangle layer.\n",
+        "    ret += cirq.Circuit(cirq.CX(q1, q2) for (q1, q2) in zip(qubits[::2], qubits[1::2]))\n",
+        "    ret += cirq.Circuit(cirq.CX(q1, q2) for (q1, q2) in zip(qubits[1::2], qubits[2::2]))\n",
+        "    # Learnable rotation layer.\n",
+        "    # i_params = sympy.symbols(f'layer-{i}-0:{dim}')\n",
+        "    param = sympy.Symbol(f'layer-{i}')\n",
+        "    single_qb = cirq.X\n",
+        "    if i % 2 == 1:\n",
+        "      single_qb = cirq.Y\n",
+        "    ret += cirq.Circuit(single_qb(q) ** param for q in qubits)\n",
+        "  \n",
+        "  if depolarize_p >= 1e-5:\n",
+        "    ret = ret.with_noise(cirq.depolarize(depolarize_p))\n",
+        "\n",
+        "  return ret, [op(q) for q in qubits for op in [cirq.X, cirq.Y, cirq.Z]]\n",
+        "\n",
+        "modelling_circuit(qubits, 3)[0]"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "U-ZMaCpJI9TH"
+      },
+      "source": [
+        "### 3.3 Model building and training\n",
+        "With your data and model circuit built, the final helper function you will need is one that can assemble both a noisy or a noiseless hybrid quantum `tf.keras.Model`:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "r09CT5N9DWa_"
+      },
+      "source": [
+        "def build_keras_model(qubits, depolarize_p=0.):\n",
+        "  \"\"\"Prepare a noisy hybrid quantum classical Keras model.\"\"\"\n",
+        "  spin_input = tf.keras.Input(shape=(), dtype=tf.dtypes.string)\n",
+        "\n",
+        "  circuit_and_readout = modelling_circuit(qubits, 4, depolarize_p)\n",
+        "  if depolarize_p >= 1e-5:\n",
+        "    quantum_model = tfq.layers.NoisyPQC(*circuit_and_readout, sample_based=False, repetitions=10)(spin_input)\n",
+        "  else:\n",
+        "    quantum_model = tfq.layers.PQC(*circuit_and_readout)(spin_input)\n",
+        "\n",
+        "  intermediate = tf.keras.layers.Dense(4, activation='sigmoid')(quantum_model)\n",
+        "  post_process = tf.keras.layers.Dense(1)(intermediate)\n",
+        "\n",
+        "  return tf.keras.Model(inputs=[spin_input], outputs=[post_process])"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "QbMtT7BZmhfm"
+      },
+      "source": [
+        "## 4. Compare performance\n",
+        "\n",
+        "### 4.1 Noiseless baseline\n",
+        "\n",
+        "With your data generation and model building code, you can now compare and contrast model performance in the noiseless and noisy settings, first you can run a reference noiseless training:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "QAgpq9c-EakW"
+      },
+      "source": [
+        "training_histories = dict()\n",
+        "depolarize_p = 0.\n",
+        "n_epochs = 50\n",
+        "phase_classifier = build_keras_model(qubits, depolarize_p)\n",
+        "\n",
+        "phase_classifier.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.02),\n",
+        "                   loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),\n",
+        "                   metrics=['accuracy'])\n",
+        "\n",
+        "\n",
+        "# Show the keras plot of the model\n",
+        "tf.keras.utils.plot_model(phase_classifier, show_shapes=True, dpi=70)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "9tKimWRMlVfL"
+      },
+      "source": [
+        "noiseless_data, noiseless_labels = get_data(qubits, depolarize_p)\n",
+        "training_histories['noiseless'] = phase_classifier.fit(x=noiseless_data,\n",
+        "                         y=noiseless_labels,\n",
+        "                         batch_size=16,\n",
+        "                         epochs=n_epochs,\n",
+        "                         validation_split=0.15,\n",
+        "                         verbose=1)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "A9oql6Synv3f"
+      },
+      "source": [
+        "And explore the results and accuracy:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "TG87YNUWKKLY"
+      },
+      "source": [
+        "loss_plotter = tfdocs.plots.HistoryPlotter(metric = 'loss', smoothing_std=10)\n",
+        "loss_plotter.plot(training_histories)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "O2ZwM18YUxxm"
+      },
+      "source": [
+        "acc_plotter = tfdocs.plots.HistoryPlotter(metric = 'accuracy', smoothing_std=10)\n",
+        "acc_plotter.plot(training_histories)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "JlOwBxvSnzid"
+      },
+      "source": [
+        "### 4.2 Noisy comparison\n",
+        "Now you can build a new model with noisy structure and compare to the above, the code is nearly identical:"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "0jy54uWpgwhi"
+      },
+      "source": [
+        "depolarize_p = 0.001\n",
+        "n_epochs = 50\n",
+        "noisy_phase_classifier = build_keras_model(qubits, depolarize_p)\n",
+        "\n",
+        "noisy_phase_classifier.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.02),\n",
+        "                   loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),\n",
+        "                   metrics=['accuracy'])\n",
+        "\n",
+        "\n",
+        "# Show the keras plot of the model\n",
+        "tf.keras.utils.plot_model(noisy_phase_classifier, show_shapes=True, dpi=70)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "r-vYU6S3oN-J"
+      },
+      "source": [
+        "Note: in the model diagram there is now a `tfq.layers.NoisyPQC` instead of a `tfq.layers.PQC` since the depolarization probability is no longer zero. Training will take significantly longer since noisy simulation is far more expensive than noiseless."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "210cLP5AoClJ"
+      },
+      "source": [
+        "noisy_data, noisy_labels = get_data(qubits, depolarize_p)\n",
+        "training_histories['noisy'] = noisy_phase_classifier.fit(x=noisy_data,\n",
+        "                         y=noisy_labels,\n",
+        "                         batch_size=16,\n",
+        "                         epochs=n_epochs,\n",
+        "                         validation_split=0.15,\n",
+        "                         verbose=1)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "eQ8pknNdohzy"
+      },
+      "source": [
+        "loss_plotter.plot(training_histories)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "metadata": {
+        "id": "nBtgnKWtuWRR"
+      },
+      "source": [
+        "acc_plotter.plot(training_histories)"
+      ],
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "r86TeFxlubls"
+      },
+      "source": [
+        "Success: The noisy model still managed to train under some mild depolarization noise. Try experimenting with different noise models to see how and when training might fail. Also look out for noisy functionality under `tfq.layers` and `tfq.noise`."
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/scripts/ci_validate_tutorials.sh b/scripts/ci_validate_tutorials.sh
index 5b5a6febf..3f593a88b 100755
--- a/scripts/ci_validate_tutorials.sh
+++ b/scripts/ci_validate_tutorials.sh
@@ -20,6 +20,8 @@ pip install jupyter nbformat==4.4.0 nbconvert==5.5.0 jupyter-client==6.1.12 ipyt
 pip install ipykernel==5.1.1
 # OpenAI Gym pip package needed for the quantum reinforcement learning tutorial
 pip install gym==0.18.0
+# tf_docs pip package needed for noise tutorial.
+pip install -q git+https://github.com/tensorflow/docs
 # Leave the quantum directory, otherwise errors may occur
 cd ..
 examples_output=$(python3 quantum/scripts/test_tutorials.py)


Test patch:
diff --git a/scripts/test_tutorials.py b/scripts/test_tutorials.py
index c4e9163b7..13df27aef 100644
--- a/scripts/test_tutorials.py
+++ b/scripts/test_tutorials.py
@@ -42,6 +42,8 @@ def test_notebook(self, path):
                 src = re.sub('NUM_EXAMPLES ?= ?.*', 'NUM_EXAMPLES = 10', src)
                 # For quantum_reinforcement_learning.ipynb to reduce runtime in test.
                 src = re.sub('n_episodes ?= ?.*', 'n_episodes = 50', src)
+                # For noise.ipynb to reduce runtime in test.
+                src = re.sub('n_epochs ?= ?.*', 'n_epochs = 2', src)
                 cell['source'] = src
 
         _ = nbconvert.preprocessors.execute.executenb(nb,