Repository: Qiskit/qiskit — pull request #13319 (python)
Title: Add argument `assume_unitary` to ``Operator.power``.

<!--\r\n⚠️ If you do not respect this template, your pull request will be closed.\r\n⚠️ Your pull request title should be short detailed and understandable for all.\r\n⚠️ Also, please add a release note file using reno if the change needs to be\r\n  documented in the release notes.\r\n⚠️ If your pull request fixes an open issue, please link to the issue.\r\n\r\n- [x] I have added the tests to cover my changes.\r\n- [x] I have updated the documentation accordingly.\r\n- [x] I have read the CONTRIBUTING document.\r\n-->\r\n\r\n### Summary\r\n\r\nFixes #13307 following @jakelishman's suggestion in the description.\r\n\r\n### Details and comments\r\n\r\nAfter a quick round of profiling on my laptop (Python 10, Windows 11), the old (Schur-based) way to raise an operator to a power is 3x-5x faster than calling `scipy.linalg.fractional_matrix_power`, so it makes sense to use it when the operator is known to be unitary.\r\n\r\nI have renamed the old method to be called `Operator.power_if_unitary`, this **assumes** that the given operator is unitary. The `Operator.is_power` does not make this assumption (and calls `fractional_matrix_power`). The gate class uses the `power_if_unitary` method. \r\n\r\nP.S. In addition, for the `power` method and an integer power, I don't see much runtime difference when calling `fractional_matrix_power` and `matrix_power`. For the `power_if_unitary` method calling `matrix_power` is faster than calling the Schur-based implementation.

Resolved issues:
title: `Operator.power` does not handle non-unitary inputs correctly
body: ### Environment\n\n- **Qiskit version**: 1.2.4\n- **Python version**: 3.10\n- **Operating system**: macOS\n\n\n### What is happening?\n\nThe way `Operator.power` works, it's strongly assuming that the internal matrix is unitary, because it's assuming that it can calculate fractional matrix powers by taking the Schur decomposition taking the matrix power of the Schur form then applying the unitary transformation (ok so far), except it assumes that the matrix power of the Schur form is the power of its diagonal.  The Schur form is upper-triangular, but not necessarily diagonal (roughly, I think it's diagonal in the case that the input is a scaled unitary).  So our `Operator.power` code fails for non-unitary operators.\n\n### How can we reproduce the issue?\n\n```python\n>>> from qiskit.quantum_info import Operator\n>>> import scipy.linalg\n>>> # Not unitary.\n>>> data = [[1, 1], [0, -1]]\n>>> print(Operator(data).power(0.5).data)\n[[1.000000e+00+0.j 0.000000e+00+0.j]\n [0.000000e+00+0.j 6.123234e-17+1.j]]\n>>> print(scipy.linalg.fractional_matrix_power(data, 0.5)))\n[[1.00000000e+00+0.j  5.00000000e-01-0.5j]\n [0.00000000e+00+0.j  4.93096709e-17+1.j ]]\n```\n\n### What should happen?\n\nThe two should give (approximately) equal results (though see #13305 for branch-cut problems).\n\n### Any suggestions?\n\nThe ability to do fractional matrix powers of `Operator` is relatively recent (#11534), and it seems to have pulled the code from `Gate.power`, which _was_ empowered to make that assumption.\n\nWe probably should just use `scipy.linalg.fractional_matrix_power` for `Operator.power`, and specialise `Gate.power` to use a more explicit eigenvector/eigenvalue form, since it's allowed to assume that its input was unitary.

Hints:

Fix patch:
diff --git a/qiskit/circuit/gate.py b/qiskit/circuit/gate.py\nindex 2f1c09668252..122db0973124 100644\n--- a/qiskit/circuit/gate.py\n+++ b/qiskit/circuit/gate.py\n@@ -89,7 +89,9 @@ def power(self, exponent: float, annotated: bool = False):\n         from qiskit.circuit.library.generalized_gates.unitary import UnitaryGate\n \n         if not annotated:\n-            return UnitaryGate(Operator(self).power(exponent), label=f"{self.name}^{exponent}")\n+            return UnitaryGate(\n+                Operator(self).power(exponent, assume_unitary=True), label=f"{self.name}^{exponent}"\n+            )\n         else:\n             return AnnotatedOperation(self, PowerModifier(exponent))\n \ndiff --git a/qiskit/quantum_info/operators/operator.py b/qiskit/quantum_info/operators/operator.py\nindex 3b6ec8abff89..d549a130ae3a 100644\n--- a/qiskit/quantum_info/operators/operator.py\n+++ b/qiskit/quantum_info/operators/operator.py\n@@ -541,7 +541,9 @@ def compose(self, other: Operator, qargs: list | None = None, front: bool = Fals\n         ret._op_shape = new_shape\n         return ret\n \n-    def power(self, n: float, branch_cut_rotation=cmath.pi * 1e-12) -> Operator:\n+    def power(\n+        self, n: float, branch_cut_rotation=cmath.pi * 1e-12, assume_unitary=False\n+    ) -> Operator:\n         """Return the matrix power of the operator.\n \n         Non-integer powers of operators with an eigenvalue whose complex phase is :math:`\\\\pi` have\n@@ -572,6 +574,8 @@ def power(self, n: float, branch_cut_rotation=cmath.pi * 1e-12) -> Operator:\n                 complex plane.  This shifts the branch cut away from the common point of :math:`-1`,\n                 but can cause a different root to be selected as the principal root.  The rotation\n                 is anticlockwise, following the standard convention for complex phase.\n+            assume_unitary (bool): if ``True``, the operator is assumed to be unitary. In this case,\n+                for fractional powers we employ a faster implementation based on Schur's decomposition.\n \n         Returns:\n             Operator: the resulting operator ``O ** n``.\n@@ -579,6 +583,11 @@ def power(self, n: float, branch_cut_rotation=cmath.pi * 1e-12) -> Operator:\n         Raises:\n             QiskitError: if the input and output dimensions of the operator\n                          are not equal.\n+\n+        .. note::\n+            It is only safe to set the argument ``assume_unitary`` to ``True`` when the operator\n+            is unitary (or, more generally, normal). Otherwise, the function will return an\n+            incorrect output.\n         """\n         if self.input_dims() != self.output_dims():\n             raise QiskitError("Can only power with input_dims = output_dims.")\n@@ -588,11 +597,23 @@ def power(self, n: float, branch_cut_rotation=cmath.pi * 1e-12) -> Operator:\n         else:\n             import scipy.linalg\n \n-            ret._data = cmath.rect(\n-                1, branch_cut_rotation * n\n-            ) * scipy.linalg.fractional_matrix_power(\n-                cmath.rect(1, -branch_cut_rotation) * self.data, n\n-            )\n+            if assume_unitary:\n+                # Experimentally, for fractional powers this seems to be 3x faster than\n+                # calling scipy.linalg.fractional_matrix_power(self.data, exponent)\n+                decomposition, unitary = scipy.linalg.schur(\n+                    cmath.rect(1, -branch_cut_rotation) * self.data, output="complex"\n+                )\n+                decomposition_diagonal = decomposition.diagonal()\n+                decomposition_power = [pow(element, n) for element in decomposition_diagonal]\n+                unitary_power = unitary @ np.diag(decomposition_power) @ unitary.conj().T\n+                ret._data = cmath.rect(1, branch_cut_rotation * n) * unitary_power\n+            else:\n+                ret._data = cmath.rect(\n+                    1, branch_cut_rotation * n\n+                ) * scipy.linalg.fractional_matrix_power(\n+                    cmath.rect(1, -branch_cut_rotation) * self.data, n\n+                )\n+\n         return ret\n \n     def tensor(self, other: Operator) -> Operator:\ndiff --git a/releasenotes/notes/operator-power-assume-unitary-0a2f97ea9de91b49.yaml b/releasenotes/notes/operator-power-assume-unitary-0a2f97ea9de91b49.yaml\nnew file mode 100644\nindex 000000000000..780781c212a7\n--- /dev/null\n+++ b/releasenotes/notes/operator-power-assume-unitary-0a2f97ea9de91b49.yaml\n@@ -0,0 +1,6 @@\n+---\n+features_quantum_info:\n+  - |\n+    Added a new argument ``assume_unitary`` to :meth:`qiskit.quantum_info.Operator.power`.\n+    When ``True``, we use a faster method based on Schur's decomposition to raise an\n+    ``Operator`` to a fractional power.\n

Test patch:
diff --git a/test/python/quantum_info/operators/test_operator.py b/test/python/quantum_info/operators/test_operator.py\nindex d9423d0ec141..7bb0db110ac1 100644\n--- a/test/python/quantum_info/operators/test_operator.py\n+++ b/test/python/quantum_info/operators/test_operator.py\n@@ -522,8 +522,16 @@ def test_power_of_nonunitary(self):\n         expected = Operator([[1.0 + 0.0j, 0.5 - 0.5j], [0.0 + 0.0j, 0.0 + 1.0j]])\n         assert_allclose(powered.data, expected.data)\n \n-    @ddt.data(0.5, 1.0 / 3.0, 0.25)\n-    def test_root_stability(self, root):\n+    @ddt.data(\n+        (0.5, False),\n+        (1.0 / 3.0, False),\n+        (0.25, False),\n+        (0.5, True),\n+        (1.0 / 3.0, True),\n+        (0.25, True),\n+    )\n+    @ddt.unpack\n+    def test_root_stability(self, root, assume_unitary):\n         """Test that the root of operators that have eigenvalues that are -1 up to floating-point\n         imprecision stably choose the positive side of the principal-root branch cut."""\n         rng = np.random.default_rng(2024_10_22)\n@@ -540,17 +548,24 @@ def test_root_stability(self, root):\n         matrices = [basis.conj().T @ np.diag(eigenvalues) @ basis for basis in bases]\n         expected = [basis.conj().T @ np.diag(root_eigenvalues) @ basis for basis in bases]\n         self.assertEqual(\n-            [Operator(matrix).power(root) for matrix in matrices],\n+            [Operator(matrix).power(root, assume_unitary=assume_unitary) for matrix in matrices],\n             [Operator(single) for single in expected],\n         )\n \n-    def test_root_branch_cut(self):\n+    @ddt.data(True, False)\n+    def test_root_branch_cut(self, assume_unitary):\n         """Test that we can choose where the branch cut appears in the root."""\n         z_op = Operator(library.ZGate())\n         # Depending on the direction we move the branch cut, we should be able to select the root to\n         # be either of the two valid options.\n-        self.assertEqual(z_op.power(0.5, branch_cut_rotation=1e-3), Operator(library.SGate()))\n-        self.assertEqual(z_op.power(0.5, branch_cut_rotation=-1e-3), Operator(library.SdgGate()))\n+        self.assertEqual(\n+            z_op.power(0.5, branch_cut_rotation=1e-3, assume_unitary=assume_unitary),\n+            Operator(library.SGate()),\n+        )\n+        self.assertEqual(\n+            z_op.power(0.5, branch_cut_rotation=-1e-3, assume_unitary=assume_unitary),\n+            Operator(library.SdgGate()),\n+        )\n \n     def test_expand(self):\n         """Test expand method."""\n