Repository: quantumlib/Cirq

Issue:
Multi-qubit channels
In [cirq/ops/common_channels.py](https://github.com/quantumlib/Cirq/blob/master/cirq/ops/common_channels.py) cirq implements a number of common quantum operations. All of them act on single-qubits only, but some of them (e.g. depolarizing channel) have clear definitions for multiple qubits. We should generalize them to handle multiple qubits.


Discussion:
So, I think following Nielsen and Chuang, the formula for the depolariazation matrix is 

I / 2**n = \sum (P_i . rho . P_i) / 2**(2n)

Right? If so, maybe I could take a crack at this?
Like this?
https://github.com/quantumlib/Cirq/pull/3262

Resolving patch:
diff --git a/cirq/ops/common_channels.py b/cirq/ops/common_channels.py
--- a/cirq/ops/common_channels.py
+++ b/cirq/ops/common_channels.py
@@ -31,109 +31,196 @@
 class AsymmetricDepolarizingChannel(gate_features.SingleQubitGate):
     """A channel that depolarizes asymmetrically along different directions."""
 
-    def __init__(self, p_x: float, p_y: float, p_z: float) -> None:
+    def __init__(self,
+                 p_x: Optional[float] = None,
+                 p_y: Optional[float] = None,
+                 p_z: Optional[float] = None,
+                 error_probabilities: Optional[Dict[str, float]] = None
+                ) -> None:
         r"""The asymmetric depolarizing channel.
 
-        This channel applies one of four disjoint possibilities: nothing (the
-        identity channel) or one of the three pauli gates. The disjoint
-        probabilities of the three gates are p_x, p_y, and p_z and the
-        identity is done with probability 1 - p_x - p_y - p_z. The supplied
-        probabilities must be valid probabilities and the sum p_x + p_y + p_z
-        must be a valid probability or else this constructor will raise a
-        ValueError.
+        This channel applies one of 4**n disjoint possibilities: nothing (the
+        identity channel) or one of the 4**n - 1 pauli gates.
 
         This channel evolves a density matrix via
 
             $$
-            \rho \rightarrow (1 - p_x - p_y - p_z) \rho
-                    + p_x X \rho X + p_y Y \rho Y + p_z Z \rho Z
+            \sum_i p_i Pi \rho Pi
             $$
 
+        where i varies from 0 to 4**n-1 and Pi represents n-qubit Pauli operator
+        (including identity). The input \rho is the density matrix before the
+        depolarization.
+
         Args:
             p_x: The probability that a Pauli X and no other gate occurs.
             p_y: The probability that a Pauli Y and no other gate occurs.
             p_z: The probability that a Pauli Z and no other gate occurs.
+            error_probabilities: Dictionary of string (Pauli operator) to its
+                probability
+
+        Examples of calls:
+            * Single qubit: AsymmetricDepolarizingChannel(0.2, 0.1, 0.3)
+            * Single qubit: AsymmetricDepolarizingChannel(p_z=0.3)
+            * Two qubits: AsymmetricDepolarizingChannel(
+                                error_probabilities={'XX': 0.2})
 
         Raises:
             ValueError: if the args or the sum of args are not probabilities.
         """
+        if error_probabilities:
+            num_qubits = len(list(error_probabilities)[0])
+            for k in error_probabilities.keys():
+                if not set(k).issubset({'I', 'X', 'Y', 'Z'}):
+                    raise ValueError(f"{k} is not made solely of I, X, Y, Z.")
+                if len(k) != num_qubits:
+                    raise ValueError(f"{k} must have {num_qubits} Pauli gates.")
+            for k, v in error_probabilities.items():
+                value.validate_probability(v, f"p({k})")
+            value.validate_probability(sum(error_probabilities.values()),
+                                       'sum(error_probabilities)')
+            self._num_qubits = num_qubits
+            self._error_probabilities = error_probabilities
+        else:
+            p_x = 0.0 if p_x is None else p_x
+            p_y = 0.0 if p_y is None else p_y
+            p_z = 0.0 if p_z is None else p_z
 
-        self._p_x = value.validate_probability(p_x, 'p_x')
-        self._p_y = value.validate_probability(p_y, 'p_y')
-        self._p_z = value.validate_probability(p_z, 'p_z')
-        self._p_i = 1 - value.validate_probability(p_x + p_y + p_z,
-                                                   'p_x + p_y + p_z')
+            p_x = value.validate_probability(p_x, 'p_x')
+            p_y = value.validate_probability(p_y, 'p_y')
+            p_z = value.validate_probability(p_z, 'p_z')
+            p_i = 1 - value.validate_probability(p_x + p_y + p_z,
+                                                 'p_x + p_y + p_z')
+
+            self._num_qubits = 1
+            self._error_probabilities = {'I': p_i, 'X': p_x, 'Y': p_y, 'Z': p_z}
 
     def _mixture_(self) -> Sequence[Tuple[float, np.ndarray]]:
-        return ((self._p_i, protocols.unitary(identity.I)),
-                (self._p_x, protocols.unitary(pauli_gates.X)),
-                (self._p_y, protocols.unitary(pauli_gates.Y)),
-                (self._p_z, protocols.unitary(pauli_gates.Z)))
+        ps = []
+        for pauli in self._error_probabilities:
+            Pi = np.identity(1)
+            for gate in pauli:
+                if gate == 'I':
+                    Pi = np.kron(Pi, protocols.unitary(identity.I))
+                elif gate == 'X':
+                    Pi = np.kron(Pi, protocols.unitary(pauli_gates.X))
+                elif gate == 'Y':
+                    Pi = np.kron(Pi, protocols.unitary(pauli_gates.Y))
+                elif gate == 'Z':
+                    Pi = np.kron(Pi, protocols.unitary(pauli_gates.Z))
+            ps.append(Pi)
+        return tuple(zip(self._error_probabilities.values(), ps))
+
+    def _num_qubits_(self) -> int:
+        return self._num_qubits
 
     def _has_mixture_(self) -> bool:
         return True
 
     def _value_equality_values_(self):
-        return self._p_x, self._p_y, self._p_z
+        return self._num_qubits, hash(
+            tuple(sorted(self._error_probabilities.items())))
 
     def __repr__(self) -> str:
-        return 'cirq.asymmetric_depolarize(p_x={!r},p_y={!r},p_z={!r})'.format(
-            self._p_x, self._p_y, self._p_z
-        )
+        return ('cirq.asymmetric_depolarize(' +
+                f"error_probabilities={self._error_probabilities})")
 
     def __str__(self) -> str:
-        return 'asymmetric_depolarize(p_x={!r},p_y={!r},p_z={!r})'.format(
-            self._p_x, self._p_y, self._p_z
-        )
+        return ('asymmetric_depolarize(' +
+                f"error_probabilities={self._error_probabilities})")
 
     def _circuit_diagram_info_(self,
                                args: 'protocols.CircuitDiagramInfoArgs') -> str:
+        if self._num_qubits == 1:
+            if args.precision is not None:
+                return (f"A({self.p_x:.{args.precision}g}," +
+                        f"{self.p_y:.{args.precision}g}," +
+                        f"{self.p_z:.{args.precision}g})")
+            return f"A({self.p_x},{self.p_y},{self.p_z})"
         if args.precision is not None:
-            f = '{:.' + str(args.precision) + 'g}'
-            return 'A({},{},{})'.format(f, f, f).format(self._p_x, self._p_y,
-                                                        self._p_z)
-        return 'A({!r},{!r},{!r})'.format(self._p_x, self._p_y, self._p_z)
+            error_probabilities = [
+                f"{pauli}:{p:.{args.precision}g}"
+                for pauli, p in self._error_probabilities.items()
+            ]
+        else:
+            error_probabilities = [
+                f"{pauli}:{p}"
+                for pauli, p in self._error_probabilities.items()
+            ]
+        return f"A({', '.join(error_probabilities)})"
 
     @property
     def p_x(self) -> float:
         """The probability that a Pauli X and no other gate occurs."""
-        return self._p_x
+        if self._num_qubits != 1:
+            raise ValueError('num_qubits should be 1')
+        return self._error_probabilities.get('X', 0.0)
 
     @property
     def p_y(self) -> float:
         """The probability that a Pauli Y and no other gate occurs."""
-        return self._p_y
+        if self._num_qubits != 1:
+            raise ValueError('num_qubits should be 1')
+        return self._error_probabilities.get('Y', 0.0)
 
     @property
     def p_z(self) -> float:
         """The probability that a Pauli Z and no other gate occurs."""
-        return self._p_z
+        if self._num_qubits != 1:
+            raise ValueError('num_qubits should be 1')
+        return self._error_probabilities.get('Z', 0.0)
+
+    @property
+    def num_qubits(self) -> int:
+        """The number of qubits"""
+        return self._num_qubits
+
+    @property
+    def error_probabilities(self) -> Dict[str, float]:
+        """A dictionary from Pauli gates to probability"""
+        return self._error_probabilities
 
     def _json_dict_(self) -> Dict[str, Any]:
-        return protocols.obj_to_dict_helper(self, ['p_x', 'p_y', 'p_z'])
+        return protocols.obj_to_dict_helper(self, ['error_probabilities'])
 
 
-def asymmetric_depolarize(
-    p_x: float, p_y: float, p_z: float
-) -> AsymmetricDepolarizingChannel:
+def asymmetric_depolarize(p_x: Optional[float] = None,
+                          p_y: Optional[float] = None,
+                          p_z: Optional[float] = None,
+                          error_probabilities: Optional[Dict[str, float]] = None
+                         ) -> AsymmetricDepolarizingChannel:
     r"""Returns a AsymmetricDepolarizingChannel with given parameter.
 
-    This channel evolves a density matrix via
+        This channel applies one of 4**n disjoint possibilities: nothing (the
+        identity channel) or one of the 4**n - 1 pauli gates.
 
-        $$
-        \rho \rightarrow (1 - p_x - p_y - p_z) \rho
-                + p_x X \rho X + p_y Y \rho Y + p_z Z \rho Z
-        $$
+        This channel evolves a density matrix via
 
-    Args:
-        p_x: The probability that a Pauli X and no other gate occurs.
-        p_y: The probability that a Pauli Y and no other gate occurs.
-        p_z: The probability that a Pauli Z and no other gate occurs.
+            $$
+            \sum_i p_i Pi \rho Pi
+            $$
+
+        where i varies from 0 to 4**n-1 and Pi represents n-qubit Pauli operator
+        (including identity). The input \rho is the density matrix before the
+        depolarization.
+
+        Args:
+            p_x: The probability that a Pauli X and no other gate occurs.
+            p_y: The probability that a Pauli Y and no other gate occurs.
+            p_z: The probability that a Pauli Z and no other gate occurs.
+            error_probabilities: Dictionary of string (Pauli operator) to its
+                probability
+
+        Examples of calls:
+            * Single qubit: AsymmetricDepolarizingChannel(0.2, 0.1, 0.3)
+            * Single qubit: AsymmetricDepolarizingChannel(p_z=0.3)
+            * Two qubits: AsymmetricDepolarizingChannel(
+                                error_probabilities={'XX': 0.2})
 
     Raises:
         ValueError: if the args or the sum of the args are not probabilities.
     """
-    return AsymmetricDepolarizingChannel(p_x, p_y, p_z)
+    return AsymmetricDepolarizingChannel(p_x, p_y, p_z, error_probabilities)
 
 
 @value.value_equality
@@ -314,13 +401,11 @@ def _value_equality_values_(self):
 
     def __repr__(self) -> str:
         return 'cirq.generalized_amplitude_damp(p={!r},gamma={!r})'.format(
-            self._p, self._gamma
-        )
+            self._p, self._gamma)
 
     def __str__(self) -> str:
         return 'generalized_amplitude_damp(p={!r},gamma={!r})'.format(
-            self._p, self._gamma
-        )
+            self._p, self._gamma)
 
     def _circuit_diagram_info_(self,
                                args: 'protocols.CircuitDiagramInfoArgs') -> str:
@@ -343,9 +428,8 @@ def _json_dict_(self) -> Dict[str, Any]:
         return protocols.obj_to_dict_helper(self, ['p', 'gamma'])
 
 
-def generalized_amplitude_damp(
-    p: float, gamma: float
-) -> GeneralizedAmplitudeDampingChannel:
+def generalized_amplitude_damp(p: float, gamma: float
+                              ) -> GeneralizedAmplitudeDampingChannel:
     r"""
     Returns a GeneralizedAmplitudeDampingChannel with the given
     probabilities gamma and p.
@@ -992,9 +1076,8 @@ def _bit_flip(p: float) -> BitFlipChannel:
     return BitFlipChannel(p)
 
 
-def bit_flip(
-    p: Optional[float] = None
-) -> Union[common_gates.XPowGate, BitFlipChannel]:
+def bit_flip(p: Optional[float] = None
+            ) -> Union[common_gates.XPowGate, BitFlipChannel]:
     r"""
     Construct a BitFlipChannel that flips a qubit state
     with probability of a flip given by p. If p is None, return


Test patch:
diff --git a/cirq/ops/common_channels_test.py b/cirq/ops/common_channels_test.py
--- a/cirq/ops/common_channels_test.py
+++ b/cirq/ops/common_channels_test.py
@@ -12,6 +12,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import re
+
 import numpy as np
 import pytest
 
@@ -21,6 +23,12 @@
 Y = np.array( [[0, -1j], [1j, 0]])
 Z = np.array( [[1, 0], [0, -1]])
 
+no_precision = cirq.CircuitDiagramInfoArgs(known_qubits=None,
+                                           known_qubit_count=None,
+                                           use_unicode_characters=True,
+                                           precision=None,
+                                           qubit_map=None)
+
 round_to_6_prec = cirq.CircuitDiagramInfoArgs(known_qubits=None,
                                               known_qubit_count=None,
                                               use_unicode_characters=True,
@@ -67,8 +75,10 @@ def test_asymmetric_depolarizing_channel_repr():
 
 
 def test_asymmetric_depolarizing_channel_str():
-    assert (str(cirq.asymmetric_depolarize(0.1, 0.2, 0.3))
-            == 'asymmetric_depolarize(p_x=0.1,p_y=0.2,p_z=0.3)')
+    assert (
+        str(cirq.asymmetric_depolarize(0.1, 0.2, 0.3)) ==
+        "asymmetric_depolarize(error_probabilities={'I': 0.3999999999999999, " +
+        "'X': 0.1, 'Y': 0.2, 'Z': 0.3})")
 
 
 def test_asymmetric_depolarizing_channel_eq():
@@ -107,6 +117,10 @@ def test_asymmetric_depolarizing_channel_bigly_probability(p_x, p_y, p_z):
 
 def test_asymmetric_depolarizing_channel_text_diagram():
     a = cirq.asymmetric_depolarize(1 / 9, 2 / 9, 3 / 9)
+    assert (cirq.circuit_diagram_info(
+        a, args=no_precision) == cirq.CircuitDiagramInfo(
+            wire_symbols=('A(0.1111111111111111,0.2222222222222222,' +
+                          '0.3333333333333333)',)))
     assert (cirq.circuit_diagram_info(
         a, args=round_to_6_prec) == cirq.CircuitDiagramInfo(
             wire_symbols=('A(0.111111,0.222222,0.333333)',)))
@@ -504,3 +518,84 @@ def test_bit_flip_channel_text_diagram():
     assert (cirq.circuit_diagram_info(
         bf, args=round_to_2_prec) == cirq.CircuitDiagramInfo(
             wire_symbols=('BF(0.12)',)))
+
+
+def test_default_asymmetric_depolarizing_channel():
+    d = cirq.asymmetric_depolarize()
+    assert d.p_x == 0.0
+    assert d.p_y == 0.0
+    assert d.p_z == 0.0
+    assert d.num_qubits == 1
+
+
+def test_bad_error_probabilities_gate():
+    with pytest.raises(ValueError,
+                       match='AB is not made solely of I, X, Y, Z.'):
+        cirq.asymmetric_depolarize(error_probabilities={'AB': 1.0})
+    with pytest.raises(ValueError, match='Y must have 2 Pauli gates.'):
+        cirq.asymmetric_depolarize(error_probabilities={'IX': 0.8, 'Y': 0.2})
+
+
+def test_bad_probs():
+    with pytest.raises(ValueError, match=re.escape('p(X) was greater than 1.')):
+        cirq.asymmetric_depolarize(error_probabilities={'X': 1.1, 'Y': -0.1})
+    with pytest.raises(
+            ValueError,
+            match=re.escape('sum(error_probabilities) was greater than 1.')):
+        cirq.asymmetric_depolarize(error_probabilities={'X': 0.7, 'Y': 0.6})
+
+
+def test_multi_asymmetric_depolarizing_channel():
+    d = cirq.asymmetric_depolarize(error_probabilities={'II': 0.8, 'XX': 0.2})
+    np.testing.assert_almost_equal(
+        cirq.channel(d),
+        (np.sqrt(0.8) * np.eye(4), np.sqrt(0.2) * np.kron(X, X)))
+    assert cirq.has_channel(d)
+    np.testing.assert_equal(d._num_qubits_(), 2)
+    with pytest.raises(ValueError, match="num_qubits should be 1"):
+        assert d.p_x == 0.0
+    with pytest.raises(ValueError, match="num_qubits should be 1"):
+        assert d.p_y == 0.0
+    with pytest.raises(ValueError, match="num_qubits should be 1"):
+        assert d.p_z == 0.0
+
+
+def test_multi_asymmetric_depolarizing_mixture():
+    d = cirq.asymmetric_depolarize(error_probabilities={'II': 0.8, 'XX': 0.2})
+    assert_mixtures_equal(cirq.mixture(d),
+                          ((0.8, np.eye(4)), (0.2, np.kron(X, X))))
+    assert cirq.has_mixture(d)
+    np.testing.assert_equal(d._num_qubits_(), 2)
+
+
+def test_multi_asymmetric_depolarizing_channel_repr():
+    cirq.testing.assert_equivalent_repr(
+        cirq.AsymmetricDepolarizingChannel(error_probabilities={
+            'II': 0.8,
+            'XX': 0.2
+        }))
+
+
+def test_multi_asymmetric_depolarizing_channel_str():
+    assert (str(
+        cirq.asymmetric_depolarize(error_probabilities={
+            'II': 0.8,
+            'XX': 0.2
+        })
+    ) == ("asymmetric_depolarize(error_probabilities={'II': 0.8, 'XX': 0.2})"))
+
+
+def test_multi_asymmetric_depolarizing_channel_text_diagram():
+    a = cirq.asymmetric_depolarize(error_probabilities={
+        'II': 2 / 3,
+        'XX': 1 / 3
+    })
+    assert (cirq.circuit_diagram_info(
+        a, args=no_precision) == cirq.CircuitDiagramInfo(
+            wire_symbols=('A(II:0.6666666666666666, XX:0.3333333333333333)',)))
+    assert (cirq.circuit_diagram_info(
+        a, args=round_to_6_prec) == cirq.CircuitDiagramInfo(
+            wire_symbols=('A(II:0.666667, XX:0.333333)',)))
+    assert (cirq.circuit_diagram_info(
+        a, args=round_to_2_prec) == cirq.CircuitDiagramInfo(
+            wire_symbols=('A(II:0.67, XX:0.33)',)))
diff --git a/cirq/protocols/json_test_data/AsymmetricDepolarizingChannel.json b/cirq/protocols/json_test_data/AsymmetricDepolarizingChannel.json
--- a/cirq/protocols/json_test_data/AsymmetricDepolarizingChannel.json
+++ b/cirq/protocols/json_test_data/AsymmetricDepolarizingChannel.json
@@ -1,6 +1,9 @@
 {
   "cirq_type": "AsymmetricDepolarizingChannel",
-  "p_x": 0.1,
-  "p_y": 0.2,
-  "p_z": 0.3
+  "error_probabilities": {
+    "I": 0.3999999999999999,
+    "X": 0.1,
+    "Y": 0.2,
+    "Z": 0.3
+  }
 }
\ No newline at end of file
diff --git a/cirq/protocols/json_test_data/MultiAsymmetricDepolarizingChannel.json b/cirq/protocols/json_test_data/MultiAsymmetricDepolarizingChannel.json
new file mode 100644
--- /dev/null
+++ b/cirq/protocols/json_test_data/MultiAsymmetricDepolarizingChannel.json
@@ -0,0 +1,7 @@
+{
+  "cirq_type": "AsymmetricDepolarizingChannel",
+  "error_probabilities": {
+      "II": 0.8,
+      "XX": 0.2
+  }
+}
\ No newline at end of file
diff --git a/cirq/protocols/json_test_data/MultiAsymmetricDepolarizingChannel.repr b/cirq/protocols/json_test_data/MultiAsymmetricDepolarizingChannel.repr
new file mode 100644
--- /dev/null
+++ b/cirq/protocols/json_test_data/MultiAsymmetricDepolarizingChannel.repr
@@ -0,0 +1 @@
+cirq.asymmetric_depolarize(error_probabilities={'II':0.8, 'XX':0.2})
\ No newline at end of file