Repository: qutip/qutip

Issue:
Issue with optimize_pulse_unitary for large sparse matrices
I am using GRAPE to identify optimal pulse sequences to produce a desired unitary, and am running into an error when I use optimize_pulse_unitary for sparse matrices of size 1296x1296 and larger.  

For example, the following code works for N=2 but fails for N=>3:

```python
import numpy as np
from qutip import *
from scipy.sparse import csr_matrix
import qutip.logging_utils as logging
logger = logging.get_logger()
#Set this to None or logging.WARN for 'quiet' execution
log_level = logging.INFO
#QuTiP control modules
import qutip.control.pulseoptim as cpo
import math

# Set parameters
chi  = 2.2 * 2 * np.pi  # coupling strength
N = 3             # number of cavity states
n_ts = 21 # number of discrete time steps
evo_time = 4.      # Length of time
fid_err_targ = 1e-10 # Target fidelity error
max_iter = 0 # # Maximum iterations for the optimization algorithm
max_wall_time = 0 # Maximum (elapsed) time
min_grad = 1e-20 # Minimum gradient
p_type = 'SINE' # pulse type (RND|ZERO|LIN|SINE|SQUARE|SAW|TRIANGLE)
f_ext = "pulsedata.txt" # output file

# Set operators and Hamiltonians

# operators
a  = tensor(destroy(N), qeye(N), qeye(N), qeye(N), qeye(2), qeye(2), qeye(2), qeye(2))
b = tensor(qeye(N), destroy(N), qeye(N), qeye(N), qeye(2), qeye(2), qeye(2), qeye(2))
c  = tensor(qeye(N), qeye(N), destroy(N), qeye(N), qeye(2), qeye(2), qeye(2), qeye(2))
d = tensor(qeye(N), qeye(N), qeye(N),  destroy(N), qeye(2), qeye(2), qeye(2), qeye(2))
sm = tensor(qeye(N), qeye(N), qeye(N), qeye(N), destroy(2), qeye(2), qeye(2), qeye(2))
zm = tensor(qeye(N), qeye(N), qeye(N), qeye(N), qeye(2), destroy(2), qeye(2), qeye(2))
tm = tensor(qeye(N), qeye(N), qeye(N), qeye(N), qeye(2), qeye(2), destroy(2), qeye(2))
qm = tensor(qeye(N), qeye(N), qeye(N), qeye(N), qeye(2), qeye(2), qeye(2), destroy(2))
P_se = tensor(qeye(N), qeye(N), qeye(N), qeye(N), basis(2, 1) * basis(2, 1).dag(), qeye(2), qeye(2), qeye(2))
P_ze = tensor(qeye(N), qeye(N), qeye(N), qeye(N), qeye(2), basis(2, 1) * basis(2, 1).dag(), qeye(2), qeye(2))
P_te = tensor(qeye(N), qeye(N), qeye(N), qeye(N), qeye(2), qeye(2), basis(2, 1) * basis(2, 1).dag(), qeye(2))
P_qe = tensor(qeye(N), qeye(N), qeye(N), qeye(N), qeye(2), qeye(2), qeye(2), basis(2, 1) * basis(2, 1).dag())

# Hamiltonians
drift = chi * (a.dag() * a * P_se) + chi * (b.dag() * b * P_ze) + chi * (c.dag() * c * P_te) + chi * (d.dag() * d * P_qe)

row = np.array([1,1,2,2,3,3,3,3,4,4,4,4,5,5,6,6])
column = np.array([3,4,3,4,1,2,5,6,1,2,5,6,3,4,3,4])
data = np.array([-1,1,-1,-1,-1,-1,-1,-1,1,-1,-1,1,-1,-1,-1,1])

mapped=csr_matrix((data,(row,column)),shape=(N**4*2*2*2*2,N**4*2*2*2*2))
mapped=Qobj(mapped)

n_ctrls=4 # Controls
ctrls = [sm.dag()*a+sm*a.dag()+sm.dag()*b+sm*b.dag()+zm.dag()*b+zm*b.dag()+zm.dag()*c+zm*c.dag()+tm.dag()*c+tm*c.dag()+tm.dag()*d+tm*d.dag()+qm.dag()*d+qm*d.dag()+qm.dag()*a+qm*a.dag(),1j*(sm.dag()*a-sm*a.dag())+1j*(sm.dag()*b-sm*b.dag())+1j*(zm.dag()*b-zm*b.dag())+1j*(zm.dag()*c-zm*c.dag())+1j*(tm.dag()*c-tm*c.dag())+1j*(tm.dag()*d-tm*d.dag())+1j*(qm.dag()*d-qm*d.dag())+1j*(qm.dag()*a-qm*a.dag()),sm+sm.dag()+zm+zm.dag()+tm+tm.dag()+qm+qm.dag(), 1j*(sm-sm.dag())+1j*(zm-zm.dag())+1j*(tm-tm.dag())+1j*(qm-qm.dag())]

# Set optimization parameters
U_0=tensor(qeye(N), qeye(N), qeye(N), qeye(N), qeye(2), qeye(2), qeye(2), qeye(2)) # Initial gate
U_targ=mapped # Target gates

# Run the optimization

result = cpo.optimize_pulse_unitary(drift, ctrls, U_0, U_targ, n_ts, evo_time, fid_err_targ=fid_err_targ, min_grad=min_grad, max_iter=max_iter, max_wall_time=max_wall_time, out_file_ext=f_ext, init_pulse_type=p_type, log_level=log_level,gen_stats=True,dyn_params=dict(sparse_eigen_decomp=True))

```
For N=>3, instead of getting a printout of the optimized controls, I receive the following terminal output error:
```
Traceback (most recent call last):
  File "MinimalErrorExample.py", line 59, in <module>
    result = cpo.optimize_pulse_unitary(drift, ctrls, U_0, U_targ, n_ts, evo_time, fid_err_targ=fid_err_targ, min_grad=min_grad, max_iter=max_iter, max_wall_time=max_wall_time, out_file_ext=f_ext, init_pulse_type=p_type, log_level=log_level,gen_stats=True,dyn_params=dict(sparse_eigen_decomp=True))
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/pulseoptim.py", line 829, in optimize_pulse_unitary
    gen_stats=gen_stats)
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/pulseoptim.py", line 510, in optimize_pulse
    result = optim.run_optimization()
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/optimizer.py", line 970, in run_optimization
    result = self._create_result()
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/optimizer.py", line 375, in _create_result
    result.initial_fid_err = self.dynamics.fid_computer.get_fid_err()
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/fidcomp.py", line 448, in get_fid_err
    return np.abs(1 - self.get_fidelity())
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/fidcomp.py", line 458, in get_fidelity
    self.fid_norm_func(self.get_fidelity_prenorm())
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/fidcomp.py", line 474, in get_fidelity_prenorm
    dyn.compute_evolution()
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/dynamics.py", line 1421, in compute_evolution
    self.tslot_computer.recompute_evolution()
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/tslotcomp.py", line 299, in recompute_evolution
    prop_comp._compute_prop_grad(k, j)
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/propcomp.py", line 272, in _compute_prop_grad
    dyn._ensure_decomp_curr(k)
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/dynamics.py", line 1437, in _ensure_decomp_curr
    self._spectral_decomp(k)
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/control/dynamics.py", line 1654, in _spectral_decomp
    dims=self.dyn_dims)
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/qutip/qobj.py", line 292, in __init__
    _tmp = sp.csr_matrix(inpt, dtype=complex, copy=do_copy)
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/scipy/sparse/compressed.py", line 88, in __init__
    self._set_self(self.__class__(coo_matrix(arg1, dtype=dtype)))
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/scipy/sparse/coo.py", line 191, in __init__
    self.row, self.col = M.nonzero()
  File "/Users/msoley/opt/anaconda3/lib/python3.7/site-packages/scipy/sparse/base.py", line 287, in __bool__
    raise ValueError("The truth value of an array with more than one "
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all().

```

I am using the following QuTiP environment:
QuTiP Version:      4.4.1
Numpy Version:      1.21.0
Scipy Version:      1.4.1
Cython Version:     0.29.15
Matplotlib Version: 3.4.2
Python Version:     3.7.6
Number of CPUs:     2
BLAS Info:          OPENBLAS
OPENMP Installed:   False
INTEL MKL Ext:      False
Platform Info:      Darwin (x86_64)

Thank you for your help.



Discussion:

Resolving patch:
diff --git a/qutip/control/dynamics.py b/qutip/control/dynamics.py
--- a/qutip/control/dynamics.py
+++ b/qutip/control/dynamics.py
@@ -185,9 +185,7 @@ class Dynamics(object):
 
     oper_dtype : type
         Data type for internal dynamics generators, propagators and time
-        evolution operators. This can be ndarray or Qobj, or (in theory) any
-        other representaion that supports typical matrix methods (e.g. dot)
-        ndarray performs best for smaller quantum systems.
+        evolution operators. This can be ndarray or Qobj.
         Qobj may perform better for larger systems, and will also
         perform better when (custom) fidelity measures use Qobj methods
         such as partial trace.
@@ -386,7 +384,7 @@ def reset(self):
         self.time_depend_ctrl_dyn_gen = False
         # These internal attributes will be of the internal operator data type
         # used to compute the evolution
-        # Note this maybe ndarray, Qobj or some other depending on oper_dtype
+        # This will be either ndarray or Qobj
         self._drift_dyn_gen = None
         self._ctrl_dyn_gen = None
         self._phased_ctrl_dyn_gen = None
@@ -684,7 +682,7 @@ def _choose_oper_dtype(self):
             else:
                 ctrls = self.ctrl_dyn_gen
             for c in ctrls:
-               dg = dg + c
+                dg = dg + c
 
             N = dg.data.shape[0]
             n = dg.data.nnz
@@ -724,7 +722,15 @@ def _init_evo(self):
         self.sys_shape = self.initial.shape
         # Set the phase application method
         self._init_phase()
+
         self._set_memory_optimizations()
+        if self.sparse_eigen_decomp and self.sys_shape[0] <= 2:
+            raise ValueError(
+                "Single qubit pulse optimization dynamics cannot use sparse"
+                " eigenvector decomposition because of limitations in"
+                " scipy.linalg.eigsh. Pleae set sparse_eigen_decomp to False"
+                " or increase the size of the system.")
+
         n_ts = self.num_tslots
         n_ctrls = self.num_ctrls
         if self.oper_dtype == Qobj:
@@ -748,26 +754,10 @@ def _init_evo(self):
             else:
                 self._ctrl_dyn_gen = [ctrl.full()
                                         for ctrl in self.ctrl_dyn_gen]
-        elif self.oper_dtype == sp.csr_matrix:
-            self._initial = self.initial.data
-            self._target = self.target.data
-            if self.time_depend_drift:
-                self._drift_dyn_gen = [d.data for d in self.drift_dyn_gen]
-            else:
-                self._drift_dyn_gen = self.drift_dyn_gen.data
-
-            if self.time_depend_ctrl_dyn_gen:
-                self._ctrl_dyn_gen = np.empty([n_ts, n_ctrls], dtype=object)
-                for k in range(n_ts):
-                    for j in range(n_ctrls):
-                        self._ctrl_dyn_gen[k, j] = \
-                                    self.ctrl_dyn_gen[k, j].data
-            else:
-                self._ctrl_dyn_gen = [ctrl.data for ctrl in self.ctrl_dyn_gen]
         else:
-            logger.warn("Unknown option '{}' for oper_dtype. "
-                "Assuming that internal drift, ctrls, initial and target "
-                "have been set correctly".format(self.oper_dtype))
+            raise ValueError(
+                "Unknown oper_dtype {!r}. The oper_dtype may be qutip.Qobj or"
+                " numpy.ndarray.".format(self.oper_dtype))
 
         if self.cache_phased_dyn_gen:
             if self.time_depend_ctrl_dyn_gen:
@@ -1149,24 +1139,19 @@ def _get_onto_evo_target(self):
             #Target is operator
             targ = la.inv(self.target.full())
             if self.oper_dtype == Qobj:
-                self._onto_evo_target = Qobj(targ)
+                rev_dims = [self.target.dims[1], self.target.dims[0]]
+                self._onto_evo_target = Qobj(targ, dims=rev_dims)
             elif self.oper_dtype == np.ndarray:
                 self._onto_evo_target = targ
-            elif self.oper_dtype == sp.csr_matrix:
-                self._onto_evo_target = sp.csr_matrix(targ)
             else:
-                targ_cls = self._target.__class__
-                self._onto_evo_target = targ_cls(targ)
+                assert False, f"Unknown oper_dtype {self.oper_dtype!r}"
         else:
             if self.oper_dtype == Qobj:
                 self._onto_evo_target = self.target.dag()
             elif self.oper_dtype == np.ndarray:
                 self._onto_evo_target = self.target.dag().full()
-            elif self.oper_dtype == sp.csr_matrix:
-                self._onto_evo_target = self.target.dag().data
             else:
-                targ_cls = self._target.__class__
-                self._onto_evo_target = targ_cls(self.target.dag().full())
+                assert False, f"Unknown oper_dtype {self.oper_dtype!r}"
 
         return self._onto_evo_target
 
@@ -1570,18 +1555,19 @@ def _spectral_decomp(self, k):
             eig_val, eig_vec = sp_eigs(H.data, H.isherm,
                                        sparse=self.sparse_eigen_decomp)
             eig_vec = eig_vec.T
+            if self.sparse_eigen_decomp:
+                # when sparse=True, sp_eigs returns an ndarray where each
+                # element is a sparse matrix so we convert it into a sparse
+                # matrix we can later pass to Qobj(...)
+                eig_vec = sp.hstack(eig_vec)
 
         elif self.oper_dtype == np.ndarray:
             H = self._dyn_gen[k]
             # returns row vector of eigenvals, columns with the eigenvecs
             eig_val, eig_vec = eigh(H)
+
         else:
-            if sparse:
-                H = self._dyn_gen[k].toarray()
-            else:
-                H = self._dyn_gen[k]
-            # returns row vector of eigenvals, columns with the eigenvecs
-            eig_val, eig_vec = eigh(H)
+            assert False, f"Unknown oper_dtype {self.oper_dtype!r}"
 
         # assuming H is an nxn matrix, find n
         n = self.get_drift_dim()
@@ -1630,7 +1616,7 @@ def _spectral_decomp(self, k):
             if self._dyn_gen_eigenvectors_adj is not None:
                 self._dyn_gen_eigenvectors_adj[k] = \
                             self._dyn_gen_eigenvectors[k].dag()
-        else:
+        elif self.oper_dtype == np.ndarray:
             self._prop_eigen[k] = np.diagflat(prop_eig)
             self._dyn_gen_eigenvectors[k] = eig_vec
             # The _dyn_gen_eigenvectors_adj list is not used in
@@ -1638,6 +1624,8 @@ def _spectral_decomp(self, k):
             if self._dyn_gen_eigenvectors_adj is not None:
                 self._dyn_gen_eigenvectors_adj[k] = \
                             self._dyn_gen_eigenvectors[k].conj().T
+        else:
+            assert False, f"Unknown oper_dtype {self.oper_dtype!r}"
 
     def _get_dyn_gen_eigenvectors_adj(self, k):
         # The _dyn_gen_eigenvectors_adj list is not used in
@@ -1736,10 +1724,8 @@ def _get_omega(self):
             if self.oper_dtype == Qobj:
                 self._omega = Qobj(omg, dims=self.dyn_dims)
                 self._omega_qobj = self._omega
-            elif self.oper_dtype == sp.csr_matrix:
-                self._omega = sp.csr_matrix(omg)
             else:
-                 self._omega = omg
+                self._omega = omg
         return self._omega
 
     def _set_phase_application(self, value):
diff --git a/qutip/control/propcomp.py b/qutip/control/propcomp.py
--- a/qutip/control/propcomp.py
+++ b/qutip/control/propcomp.py
@@ -306,16 +306,11 @@ def _get_aug_mat(self, k, j):
             E = dyn._get_phased_ctrl_dyn_gen(k, j).data*dyn.tau[k]
             Z = sp.csr_matrix(dg.data.shape)
             aug = Qobj(sp.vstack([sp.hstack([A, E]), sp.hstack([Z, A])]))
-        elif dyn.oper_dtype == np.ndarray:
+        else:
             A = dg*dyn.tau[k]
             E = dyn._get_phased_ctrl_dyn_gen(k, j)*dyn.tau[k]
             Z = np.zeros(dg.shape)
             aug = np.vstack([np.hstack([A, E]), np.hstack([Z, A])])
-        else:
-            A = dg*dyn.tau[k]
-            E = dyn._get_phased_ctrl_dyn_gen(k, j)*dyn.tau[k]
-            Z = dg*0.0
-            aug = sp.vstack([sp.hstack([A, E]), sp.hstack([Z, A])])
         return aug
 
     def _compute_prop_grad(self, k, j, compute_prop=True):
@@ -388,7 +383,7 @@ def _compute_prop_grad(self, k, j, compute_prop=True):
                 prop_grad_dense = la.expm_frechet(A, E, compute_expm=False)
                 prop_grad = Qobj(prop_grad_dense,
                                             dims=dyn.dyn_dims)
-        elif dyn.oper_dtype == np.ndarray:
+        else:
             A = dyn._get_phased_dyn_gen(k)*dyn.tau[k]
             E = dyn._get_phased_ctrl_dyn_gen(k, j)*dyn.tau[k]
             if compute_prop:
@@ -396,19 +391,6 @@ def _compute_prop_grad(self, k, j, compute_prop=True):
             else:
                 prop_grad = la.expm_frechet(A, E,
                                                     compute_expm=False)
-        else:
-            # Assuming some sparse matrix
-            spcls = dyn._dyn_gen[k].__class__
-            A = (dyn._get_phased_dyn_gen(k)*dyn.tau[k]).toarray()
-            E = (dyn._get_phased_ctrl_dyn_gen(k, j)*dyn.tau[k]).toarray()
-            if compute_prop:
-                prop_dense, prop_grad_dense = la.expm_frechet(A, E)
-                prop = spcls(prop_dense)
-                prop_grad = spcls(prop_grad_dense)
-            else:
-                prop_grad_dense = la.expm_frechet(A, E, compute_expm=False)
-                prop_grad = spcls(prop_grad_dense)
-
         if compute_prop:
             return prop, prop_grad
         else:


Test patch:
diff --git a/qutip/tests/test_control_pulseoptim.py b/qutip/tests/test_control_pulseoptim.py
--- a/qutip/tests/test_control_pulseoptim.py
+++ b/qutip/tests/test_control_pulseoptim.py
@@ -13,6 +13,7 @@
 import tempfile
 import numpy as np
 import scipy.optimize
+import scipy.sparse as sp
 
 import qutip
 from qutip.control import pulseoptim as cpo
@@ -116,6 +117,7 @@
 @pytest.fixture(params=[
     pytest.param(None, id="default propagation"),
     pytest.param({'oper_dtype': qutip.Qobj}, id="Qobj propagation"),
+    pytest.param({'oper_dtype': np.ndarray}, id="ndarray propagation"),
 ])
 def propagation(request):
     return {'dyn_params': request.param}
@@ -165,6 +167,38 @@ def test_basic_optimization(self, system, propagation):
         result = _optimize_pulse(system)
         assert result.fid_err < system.kwargs['fid_err_targ']
 
+    def test_sparse_eigen_optimization(self, system, propagation):
+        system = _merge_kwargs(system, {
+            **propagation,
+            "dyn_params": {
+                **(propagation.get("dyn_params") or {}),
+                "sparse_eigen_decomp": True,
+            }
+        })
+        if system.system.dims == [[2], [2]]:
+            with pytest.raises(ValueError, match=(
+                    r"^Single qubit pulse optimization dynamics cannot use"
+                    r" sparse eigenvector decomposition")):
+                _optimize_pulse(system)
+        else:
+            result = _optimize_pulse(system)
+            assert result.fid_err < system.kwargs['fid_err_targ']
+
+    @pytest.mark.parametrize("oper_dtype", [
+        pytest.param(sp.csr_matrix, id="csr_matrix"),
+        pytest.param(list, id="list"),
+    ])
+    def test_invalid_oper_dtype(self, system, oper_dtype):
+        system = _merge_kwargs(system, {"dyn_params": {
+            "oper_dtype": oper_dtype,
+        }})
+        with pytest.raises(ValueError) as err:
+            _optimize_pulse(system)
+        assert str(err.value) == (
+            f"Unknown oper_dtype {oper_dtype!r}. The oper_dtype may be"
+            " qutip.Qobj or numpy.ndarray."
+        )
+
     def test_object_oriented_approach_and_gradient(self, system, propagation):
         """
         Test the object-oriented version of the optimiser, and ensure that the