Repository: CUQI-DTU/CUQIpy

Issue:
Add option to use scipy.optimize.least_squares in RegularizedLinearRTO
**General description of task**
- In regularized LinearRTO, when the constraint is a simple box/nonnegativity constraint, performance of FISTA might be inferior to Scipy.optimize.lsq_linear

**Definition of Done (Feature/change)**
- [x] Add wrapper to Scipy.optimize.lsq_linear under name `ScipyLinearLeastSquares`
- [x] Add option to use`ScipyLinearLSQ` in `RegularizedLinearRTO`. 
  - For now, `ScipyLinearLSQ` is **not** set as the default solver; It might be a good idea to try it on a few more problems before considering it as the default. I have created an issue to track this: #606 
  - Meanwhile, the user can choose to use `ScipyLinearLSQ` by **explicitly** setting flag `solver` to `ScipyLinearLSQ` when initializing the `RegularizedLinearRTO` sampler.
  - Note that it is decided after group meeting that we will use the solver's full/exact name, i.e., `ScipyLinearLSQ`, when setting `solver` flag of `RegularizedLinearRTO`
- [x] `ScipyLeastSquares` is renamed to `ScipyLSQ` for consistency

**Definition of Done (Mandatory)**
- [x] Documentation added (docstrings on all public methods/classes)
- [x] Unit tests added/updated (and passing!)
- [x] Code reviewed and approved by 2 CUQI-maintainers
- [x] Online documentation is rendered correctly (esp. math) 

**Defintion of Done (Optional)**

Discussion:
@chaozg: Hi @jakobsj , could you review the DoD here? After giving it further thought, I guess it's probably a good idea to see the use of the least square solver in more (different) sampling problems before we consider setting it as the default. What do you think?@jakobsj: OK, that sounds sensible.  For the `inner_solver` flag, I wonder if it wouldn't be better to use the actual name of the solver, that is, `ScipyLinearLeastSquares`, rather than an additional short hand `LSQ`.  It is longer, but eventually we may have many different solvers and I think there will be some unclarities around mappings between the short-hands and the full solver names, so I think simpler if simply requiring the full solver name specified. If you agree, can you update the DoD and request me again please@chaozg: Hi @jakobsj, thanks for the group discussion on the naming issue here. I updated the DoD and PR accordlingly. Could you have a look at the DoD here?@jakobsj: @chaozg Looks great, DoD approved!

Solution patch:
diff --git a/cuqi/experimental/algebra/_ast.py b/cuqi/experimental/algebra/_ast.py
index 65267a294..6d609e7b9 100644
--- a/cuqi/experimental/algebra/_ast.py
+++ b/cuqi/experimental/algebra/_ast.py
@@ -51,6 +51,11 @@ def __call__(self, **kwargs):
         """Evaluate node at a given parameter value. This will traverse the sub-tree originated at this node and evaluate it given the recorded operations."""
         pass
 
+    @abstractmethod
+    def condition(self, **kwargs):
+        """ Conditions the tree by replacing any VariableNode with a ValueNode if the variable is in the kwargs dictionary. """
+        pass
+
     @abstractmethod
     def __repr__(self):
         """String representation of the node. Used for printing the AST."""
@@ -129,6 +134,9 @@ class UnaryNode(Node, ABC):
     def __init__(self, child: Node):
         self.child = child
 
+    def condition(self, **kwargs):
+        return self.__class__(self.child.condition(**kwargs))
+
 
 class BinaryNode(Node, ABC):
     """Base class for all binary nodes in the abstract syntax tree.
@@ -155,6 +163,9 @@ def __init__(self, left: Node, right: Node):
         self.left = left
         self.right = right
 
+    def condition(self, **kwargs):
+        return self.__class__(self.left.condition(**kwargs), self.right.condition(**kwargs))
+
     def __repr__(self):
         return f"{self.left} {self.op_symbol} {self.right}"
 
@@ -205,6 +216,11 @@ def __call__(self, **kwargs):
             )
         return kwargs[self.name]
 
+    def condition(self, **kwargs):
+        if self.name in kwargs:
+            return ValueNode(kwargs[self.name])
+        return self
+
     def __repr__(self):
         return self.name
 
@@ -226,6 +242,9 @@ def __call__(self, **kwargs):
         """Returns the value of the node."""
         return self.value
 
+    def condition(self, **kwargs):
+        return self
+
     def __repr__(self):
         return str(self.value)
 
diff --git a/cuqi/experimental/algebra/_orderedset.py b/cuqi/experimental/algebra/_orderedset.py
index b91fedac1..f4021a24e 100644
--- a/cuqi/experimental/algebra/_orderedset.py
+++ b/cuqi/experimental/algebra/_orderedset.py
@@ -19,6 +19,13 @@ def add(self, item):
         """
         self.dict[item] = None
 
+    def remove(self, item):
+        """Remove an item from the set. 
+
+        If the item is not in the set, it raises a KeyError.
+        """
+        del self.dict[item]
+
     def __contains__(self, item):
         """Check if an item is in the set. 
 
@@ -47,6 +54,18 @@ def extend(self, other):
         for item in other:
             self.add(item)
 
+    def replace(self, old_item, new_item):
+        """Replace old_item with new_item at the same position, preserving order."""
+        if old_item not in self.dict:
+            raise KeyError(f"{old_item} not in set")
+        
+        items = list(self.dict.keys())  # Preserve order
+        index = items.index(old_item)  # Find position
+        items[index] = new_item  # Replace at the same position
+
+        # Reconstruct the ordered set with the new item in place
+        self.dict = dict.fromkeys(items)
+
     def __or__(self, other):
         """Return a new set that is the union of this set and another set.
 
@@ -57,3 +76,7 @@ def __or__(self, other):
         new_set = _OrderedSet(self.dict.keys())
         new_set.extend(other)
         return new_set
+
+    def __repr__(self):
+        """Return a string representation of the set."""
+        return "_OrderedSet({})".format(list(self.dict.keys()))
diff --git a/cuqi/experimental/algebra/_randomvariable.py b/cuqi/experimental/algebra/_randomvariable.py
index b2b771c65..b438fa096 100644
--- a/cuqi/experimental/algebra/_randomvariable.py
+++ b/cuqi/experimental/algebra/_randomvariable.py
@@ -5,8 +5,7 @@
 import operator
 import cuqi
 from cuqi.distribution import Distribution
-from copy import copy
-
+from copy import copy, deepcopy
 
 class RandomVariable:
     """ Random variable defined by a distribution with the option to apply algebraic operations on it.
@@ -210,7 +209,7 @@ def distributions(self) -> set:
     def parameter_names(self) -> str:
         """ Name of the parameter that the random variable can be evaluated at. """
         self._inject_name_into_distribution()
-        return [distribution.name for distribution in self.distributions] # Consider renaming .name to .par_name for distributions
+        return [distribution._name for distribution in self.distributions] # Consider renaming .name to .par_name for distributions
 
     @property
     def dim(self):
@@ -239,7 +238,57 @@ def expression(self):
     def is_transformed(self):
         """ Returns True if the random variable is transformed. """
         return not isinstance(self.tree, VariableNode)
-    
+
+    @property
+    def is_cond(self):
+        """ Returns True if the random variable is a conditional random variable. """
+        return any(dist.is_cond for dist in self.distributions)
+
+    def condition(self, *args, **kwargs):
+        """Condition the random variable on a given value. Only one of either positional or keyword arguments can be passed.
+        
+        Parameters
+        ----------
+        *args : Any
+            Positional arguments to condition the random variable on. The order of the arguments must match the order of the parameter names.
+
+        **kwargs : Any
+            Keyword arguments to condition the random variable on. The keys must match the parameter names.
+        
+        """
+
+        # Before conditioning, capture repr to ensure all variable names are injected
+        self.__repr__()
+
+        if args and kwargs:
+            raise ValueError("Cannot pass both positional and keyword arguments to RandomVariable")
+        
+        if args:
+            kwargs = self._parse_args_add_to_kwargs(args, kwargs)
+
+        # Create a deep copy of the random variable to ensure the original tree is not modified
+        new_variable = self._make_copy(deep=True)
+
+        for kwargs_name in list(kwargs.keys()):
+            value = kwargs.pop(kwargs_name)
+
+            # Condition the tree turning the variable into a constant
+            if kwargs_name in self.parameter_names:
+                new_variable._tree = new_variable.tree.condition(**{kwargs_name: value})
+            
+            # Condition the random variable on both the distribution parameter name and distribution conditioning variables
+            for dist in self.distributions:
+                if kwargs_name == dist.name:
+                    new_variable._remove_distribution(dist.name)
+                elif kwargs_name in dist.get_conditioning_variables():
+                    new_variable._replace_distribution(dist.name, dist(**{kwargs_name: value}))
+
+        # Check if any kwargs are left unprocessed
+        if kwargs:
+            raise ValueError(f"Conditioning variables {list(kwargs.keys())} not found in the random variable {self}")
+
+        return new_variable
+
     @property
     def _non_default_args(self) -> List[str]:
         """List of non-default arguments to distribution. This is used to return the correct
@@ -247,13 +296,31 @@ def _non_default_args(self) -> List[str]:
         """
         return self.parameter_names
 
+    def _replace_distribution(self, name, new_distribution):
+        """ Replace distribution with a given name with a new distribution in the same position of the ordered set. """
+        for dist in self.distributions:
+            if dist._name == name:
+                self._distributions.replace(dist, new_distribution)
+                break
+
+    def _remove_distribution(self, name):
+        """ Remove distribution with a given name from the set of distributions. """
+        for dist in self.distributions:
+            if dist._name == name:
+                self._distributions.remove(dist)
+                break
+
     def _inject_name_into_distribution(self, name=None):
         if len(self._distributions) == 1:
             dist = next(iter(self._distributions))
+
+            if dist._is_copy:
+                dist = dist._original_density
+
             if dist._name is None:
                 if name is None:
                     name = self.name
-                dist._name = name
+                dist.name = name # Inject using setter
     
     def _parse_args_add_to_kwargs(self, args, kwargs) -> dict:
         """ Parse args and add to kwargs if any. Arguments follow self.parameter_names order. """
@@ -293,8 +360,12 @@ def _is_copy(self):
         """ Returns True if this is a copy of another random variable, e.g. by conditioning. """
         return hasattr(self, '_original_variable') and self._original_variable is not None
 
-    def _make_copy(self):
-        """ Returns a shallow copy of the density keeping a pointer to the original. """
+    def _make_copy(self, deep=False) -> 'RandomVariable':
+        """ Returns a copy of the density keeping a pointer to the original. """
+        if deep:
+            new_variable = deepcopy(self)
+            new_variable._original_variable = self
+            return new_variable
         new_variable = copy(self)
         new_variable._distributions = copy(self.distributions)
         new_variable._tree = copy(self._tree)
diff --git a/cuqi/experimental/mcmc/_rto.py b/cuqi/experimental/mcmc/_rto.py
index 9858ed2f2..79aeb8c61 100644
--- a/cuqi/experimental/mcmc/_rto.py
+++ b/cuqi/experimental/mcmc/_rto.py
@@ -3,7 +3,7 @@
 from scipy.sparse.linalg import LinearOperator as scipyLinearOperator
 import numpy as np
 import cuqi
-from cuqi.solver import CGLS, FISTA, ADMM
+from cuqi.solver import CGLS, FISTA, ADMM, ScipyLinearLSQ
 from cuqi.experimental.mcmc import Sampler
 
 
@@ -168,6 +168,7 @@ class RegularizedLinearRTO(LinearRTO):
            Used when prior.proximal is callable.
     ADMM:  [2] Boyd et al. "Distributed optimization and statistical learning via the alternating direction method of multipliers."Foundations and Trends® in Machine learning, 2011.
            Used when prior.proximal is a list of penalty terms.
+    ScipyLinearLSQ: Wrapper for Scipy's lsq_linear for the Trust Region Reflective algorithm. Optionally used when the constraint is either "nonnegativity" or "box".
 
     Parameters
     ------------
@@ -178,7 +179,7 @@ class RegularizedLinearRTO(LinearRTO):
         Initial point for the sampler. *Optional*.
 
     maxit : int
-        Maximum number of iterations of the inner FISTA/ADMM solver. *Optional*.
+        Maximum number of iterations of the FISTA/ADMM/ScipyLinearLSQ solver. *Optional*.
 
     inner_max_it : int
         Maximum number of iterations of the CGLS solver used within the ADMM solver. *Optional*.
@@ -188,14 +189,20 @@ class RegularizedLinearRTO(LinearRTO):
         If stepsize is a float, then this stepsize is used.
 
     penalty_parameter : int
-        Penalty parameter of the inner ADMM solver. *Optional*.
+        Penalty parameter of the ADMM solver. *Optional*.
         See [2] or `cuqi.solver.ADMM`
 
     abstol : float
-        Absolute tolerance of the inner FISTA solver. *Optional*.
+        Absolute tolerance of the FISTA/ScipyLinearLSQ solver. *Optional*.
+    
+    inner_abstol : float
+        Tolerance parameter for ScipyLinearLSQ's inner solve of the unbounded least-squares problem. *Optional*.
     
     adaptive : bool
-        If True, FISTA is used as inner solver, otherwise ISTA is used. *Optional*.
+        If True, FISTA is used as solver, otherwise ISTA is used. *Optional*.
+    
+    solver : string
+        If set to "ScipyLinearLSQ", solver is set to cuqi.solver.ScipyLinearLSQ, otherwise FISTA/ISTA or ADMM is used. Note "ScipyLinearLSQ" can only be used with `RegularizedGaussian` of `box` or `nonnegativity` constraint. *Optional*.
 
     callback : callable, *Optional*
         If set this function will be called after every sample.
@@ -204,23 +211,41 @@ class RegularizedLinearRTO(LinearRTO):
         An example is shown in demos/demo31_callback.py.
         
     """
-    def __init__(self, target=None, initial_point=None, maxit=100, inner_max_it=10, stepsize="automatic", penalty_parameter=10, abstol=1e-10, adaptive=True, **kwargs):
+    def __init__(self, target=None, initial_point=None, maxit=100, inner_max_it=10, stepsize="automatic", penalty_parameter=10, abstol=1e-10, adaptive=True, solver=None, inner_abstol=None, **kwargs):
         
         super().__init__(target=target, initial_point=initial_point, **kwargs)
 
         # Other parameters
         self.stepsize = stepsize
-        self.abstol = abstol   
+        self.abstol = abstol
+        self.inner_abstol = inner_abstol
         self.adaptive = adaptive
         self.maxit = maxit
         self.inner_max_it = inner_max_it
         self.penalty_parameter = penalty_parameter
+        self.solver = solver
 
     def _initialize(self):
         super()._initialize()
-        if self._inner_solver == "FISTA":
+        if self.solver is None:
+            self.solver = "FISTA" if callable(self.proximal) else "ADMM"
+        if self.solver == "FISTA":
             self._stepsize = self._choose_stepsize()
 
+    @property
+    def solver(self):
+        return self._solver
+
+    @solver.setter
+    def solver(self, value):
+        if value == "ScipyLinearLSQ":
+            if (self.target.prior._preset == "nonnegativity" or self.target.prior._preset == "box"):
+                self._solver = value
+            else:
+                raise ValueError("ScipyLinearLSQ only supports RegularizedGaussian with box or nonnegativity constraint.")
+        else:
+            self._solver = value
+
     @property
     def proximal(self):
         return self.target.prior.proximal
@@ -229,7 +254,6 @@ def validate_target(self):
         super().validate_target()
         if not isinstance(self.target.prior, (cuqi.implicitprior.RegularizedGaussian, cuqi.implicitprior.RegularizedGMRF)):
             raise TypeError("Prior needs to be RegularizedGaussian or RegularizedGMRF")
-        self._inner_solver = "FISTA" if callable(self.proximal) else "ADMM"
 
     def _choose_stepsize(self):
         if isinstance(self.stepsize, str):
@@ -254,15 +278,25 @@ def prior(self):
     def step(self):
         y = self.b_tild + np.random.randn(len(self.b_tild))
 
-        if self._inner_solver == "FISTA":
+        if self.solver == "FISTA":
             sim = FISTA(self.M, y, self.proximal,
                         self.current_point, maxit = self.maxit, stepsize = self._stepsize, abstol = self.abstol, adaptive = self.adaptive)         
-        elif self._inner_solver == "ADMM":
+        elif self.solver == "ADMM":
             sim = ADMM(self.M, y, self.proximal,
-                        self.current_point, self.penalty_parameter, maxit = self.maxit, inner_max_it = self.inner_max_it, adaptive = self.adaptive)  
+                        self.current_point, self.penalty_parameter, maxit = self.maxit, inner_max_it = self.inner_max_it, adaptive = self.adaptive)
+        elif self.solver == "ScipyLinearLSQ":
+                A_op = sp.sparse.linalg.LinearOperator((sum([llh.dim for llh in self.likelihoods])+self.target.prior.dim, self.target.prior.dim),
+                                        matvec=lambda x: self.M(x, 1),
+                                        rmatvec=lambda x: self.M(x, 2)
+                                        )
+                sim = ScipyLinearLSQ(A_op, y, self.target.prior._box_bounds, 
+                                     max_iter = self.maxit,
+                                     lsmr_maxiter = self.inner_max_it, 
+                                     tol = self.abstol,
+                                     lsmr_tol = self.inner_abstol)
         else:
             raise ValueError("Choice of solver not supported.")
 
         self.current_point, _ = sim.solve()
         acc = 1
-        return acc
+        return acc
\ No newline at end of file
diff --git a/cuqi/implicitprior/_regularizedGaussian.py b/cuqi/implicitprior/_regularizedGaussian.py
index f07b364cb..5ef3cf3ca 100644
--- a/cuqi/implicitprior/_regularizedGaussian.py
+++ b/cuqi/implicitprior/_regularizedGaussian.py
@@ -113,10 +113,12 @@ def _parse_regularization_input_arguments(self, proximal, projector, constraint,
         elif (isinstance(constraint, str) and constraint.lower() == "nonnegativity"):
             self._proximal = lambda z, gamma: ProjectNonnegative(z)
             self._preset = "nonnegativity"
+            self._box_bounds = (np.ones(self.dim)*0, np.ones(self.dim)*np.inf)
         elif (isinstance(constraint, str) and constraint.lower() == "box"):
-            lower = optional_regularization_parameters["lower_bound"]
-            upper = optional_regularization_parameters["upper_bound"]
-            self._proximal = lambda z, _: ProjectBox(z, lower, upper)
+            self._box_lower = optional_regularization_parameters["lower_bound"]
+            self._box_upper = optional_regularization_parameters["upper_bound"]
+            self._box_bounds = (np.ones(self.dim)*self._box_lower, np.ones(self.dim)*self._box_upper)
+            self._proximal = lambda z, _: ProjectBox(z, self._box_lower, self._box_upper)
             self._preset = "box" # Not supported in Gibbs
         elif (isinstance(regularization, str) and regularization.lower() in ["l1"]):
             self._strength = optional_regularization_parameters["strength"]
diff --git a/cuqi/likelihood/_likelihood.py b/cuqi/likelihood/_likelihood.py
index c11d380d5..232f7d3c3 100644
--- a/cuqi/likelihood/_likelihood.py
+++ b/cuqi/likelihood/_likelihood.py
@@ -43,6 +43,14 @@ def name(self):
     def name(self, value):
         self.distribution.name = value
 
+    @property
+    def _name(self):
+        return self.distribution._name
+
+    @_name.setter
+    def _name(self, value):
+        self.distribution._name = value
+
     @property
     def FD_enabled(self):
         """ Return FD_enabled of the likelihood from the underlying distribution """
diff --git a/cuqi/solver/__init__.py b/cuqi/solver/__init__.py
index c3851ae65..de8c8f33f 100644
--- a/cuqi/solver/__init__.py
+++ b/cuqi/solver/__init__.py
@@ -2,7 +2,8 @@
     ScipyLBFGSB,
     ScipyMinimizer,
     ScipyMaximizer,
-    ScipyLeastSquares,
+    ScipyLSQ,
+    ScipyLinearLSQ,
     CGLS,
     LM,
     PDHG,
diff --git a/cuqi/solver/_solver.py b/cuqi/solver/_solver.py
index 5c7868c2d..12d1ceb32 100644
--- a/cuqi/solver/_solver.py
+++ b/cuqi/solver/_solver.py
@@ -164,7 +164,7 @@ def ngradfunc(*args,**kwargs):
 
 
 
-class ScipyLeastSquares(object):
+class ScipyLSQ(object):
     """Wrapper for :meth:`scipy.optimize.least_squares`.
 
     Solve nonlinear least-squares problems with bounds:
@@ -227,6 +227,44 @@ def solve(self):
             sol = solution['x']
         return sol, info
 
+class ScipyLinearLSQ(object):
+    """Wrapper for :meth:`scipy.optimize.lsq_linear`.
+
+    Solve linear least-squares problems with bounds:
+
+    .. math::
+    
+        \min \|A x - b\|_2^2
+
+    subject to :math:`lb <= x <= ub`.
+    
+    Parameters
+    ----------
+    A : ndarray, LinearOperator
+        Design matrix (system matrix).
+    b : ndarray
+        The right-hand side of the linear system.
+    bounds : 2-tuple of array_like or scipy.optimize Bounds
+        Bounds for variables. 
+    kwargs : Other keyword arguments passed to Scipy's `lsq_linear`. See documentation of `scipy.optimize.lsq_linear` for details.
+    """
+    def __init__(self, A, b, bounds=(-np.inf, np.inf), **kwargs):
+        self.A = A
+        self.b = b
+        self.bounds = bounds
+        self.kwargs = kwargs
+    
+    def solve(self):
+        """Runs optimization algorithm and returns solution and optimization information.
+
+        Returns
+        ----------
+        solution : Tuple
+            Solution found (array_like) and optimization information (dictionary).
+        """
+        res = opt.lsq_linear(self.A, self.b, bounds=self.bounds, **self.kwargs)
+        x = res.pop('x')
+        return x, res
 
 
 class CGLS(object):
diff --git a/demos/howtos/algebra.py b/demos/howtos/algebra.py
index 43deaf078..e7c5956ea 100644
--- a/demos/howtos/algebra.py
+++ b/demos/howtos/algebra.py
@@ -83,3 +83,41 @@
 BP.UQ(exact={"x": info.exactSolution})
 
 # %%
+# Conditioning on random variables (example 1)
+from cuqi.distribution import Gaussian
+from cuqi.experimental.algebra import RandomVariable
+
+x = RandomVariable(Gaussian(0, lambda s: s))
+y = RandomVariable(Gaussian(0, lambda d: d))
+
+z = x+y
+
+z.condition(x=1)
+
+# %%
+# Or conditioning on the variables s, or d
+z.condition(s=1)
+
+# %%
+# Conditioning on random variables (example 2)
+from cuqi.testproblem import Deconvolution1D
+from cuqi.distribution import Gaussian, Gamma, GMRF
+from cuqi.experimental.algebra import RandomVariable
+from cuqi.problem import BayesianProblem
+import numpy as np
+
+# Forward model
+A, y_obs, info = Deconvolution1D(dim=4).get_components()
+
+# Bayesian Problem (defined using Random Variables)
+d = RandomVariable(Gamma(1, 1e-4))
+s = RandomVariable(Gamma(1, 1e-4))
+x = RandomVariable(GMRF(np.zeros(A.domain_dim), d))
+y = RandomVariable(Gaussian(A @ x, 1/s))
+
+
+z = x+y
+
+z.condition(x=np.zeros(A.domain_dim))
+
+# %%


Test patch:
diff --git a/tests/test_solver.py b/tests/test_solver.py
index bdbb5ba3e..62e35cfc2 100644
--- a/tests/test_solver.py
+++ b/tests/test_solver.py
@@ -1,7 +1,7 @@
 import numpy as np
 import scipy as sp
 
-from cuqi.solver import ScipyLBFGSB, ScipyMinimizer, ScipyLeastSquares, CGLS, LM, FISTA, ADMM, ProximalL1, ProjectNonnegative
+from cuqi.solver import ScipyLBFGSB, ScipyMinimizer, ScipyLSQ, ScipyLinearLSQ, CGLS, LM, FISTA, ADMM, ProximalL1, ProjectNonnegative, ProjectBox
 from scipy.optimize import lsq_linear
 
 
@@ -54,16 +54,16 @@ def test_ScipyLBFGSB_with_gradient():
     sol_ref = np.array([1.0, 1.0, 1.0, 1.0, 1.0])
     assert np.allclose(sol, sol_ref)
 
-def test_ScipyLeastSquares_without_Jac():
+def test_ScipyLSQ_without_Jac():
     def fun_rosenbrock(x):
         return np.array([10 * (x[1] - x[0]**2), (1 - x[0])])
     x0 = np.array([2, 2])
-    solver = ScipyLeastSquares(fun_rosenbrock, x0)
+    solver = ScipyLSQ(fun_rosenbrock, x0)
     sol, _ = solver.solve()
     sol_ref = np.array([1, 1])
     assert np.allclose(sol, sol_ref)
 
-def test_ScipyLeastSquares_with_Jac():
+def test_ScipyLSQ_with_Jac():
     def fun_rosenbrock(x):
         return np.array([10 * (x[1] - x[0]**2), (1 - x[0])])
     def jac_rosenbrock(x):
@@ -71,11 +71,49 @@ def jac_rosenbrock(x):
             [-20 * x[0], 10],
             [-1, 0]])
     x0 = np.array([2, 2])
-    solver = ScipyLeastSquares(fun_rosenbrock, x0, jacfun=jac_rosenbrock)
+    solver = ScipyLSQ(fun_rosenbrock, x0, jacfun=jac_rosenbrock)
     sol, _ = solver.solve()
     sol_ref = np.array([1, 1])
     assert np.allclose(sol, sol_ref)
 
+def test_ScipyLinearLSQ_with_matrix():
+    rng = np.random.default_rng(seed = 1219)
+    m, n = 10, 5
+    A = rng.standard_normal((m, n))
+    b = rng.standard_normal(m)
+    res = lsq_linear(A, b, tol=1e-8)
+    ref_sol = res.x
+    sol, _ = ScipyLinearLSQ(A, b).solve()
+    assert np.allclose(sol, ref_sol, rtol=1e-10)
+
+def test_ScipyLinearLSQ_with_LinearOperator():
+    rng = np.random.default_rng(seed = 1219)
+    m, n = 10, 5
+    A = rng.standard_normal((m, n))
+    b = rng.standard_normal(m)
+    A_op = sp.sparse.linalg.LinearOperator((m, n),
+                                           matvec=lambda x: A @ x,
+                                           rmatvec=lambda x: A.T @ x
+                                           )
+    res = lsq_linear(A, b, tol=1e-8)
+    ref_sol = res.x
+    sol, _ = ScipyLinearLSQ(A_op, b).solve()
+    assert np.allclose(sol, ref_sol, rtol=1e-10)
+
+def test_ScipyLinearLSQ_against_FISTA():
+    A = np.array([[73,71,52],[87,74,46],[72,2,7],[80,89,71]])
+    b = np.array([49,67,68,20])
+    # solve with ScipyLinearLSQ
+    lb = np.zeros(3)
+    ub = lb + np.inf
+    sol_lsq, _ = ScipyLinearLSQ(A, b, (lb,ub)).solve()
+    # solve with FISTA
+    rng = np.random.default_rng(seed = 1219)
+    x0 = rng.standard_normal(3)
+    sol_fista, _ = FISTA(A, b, lambda x, _: ProjectNonnegative(x), x0, stepsize=1e-7, maxit=100000, abstol=1e-16, adaptive=True).solve()
+
+    assert np.allclose(sol_lsq, sol_fista, rtol=1e-8)
+
 def test_LM():
     # compare to MATLAB's original code solution
     t = np.arange(1, 10, 2)
diff --git a/tests/zexperimental/test_randomvariable.py b/tests/zexperimental/test_randomvariable.py
index 3c82d200b..3399c1acb 100644
--- a/tests/zexperimental/test_randomvariable.py
+++ b/tests/zexperimental/test_randomvariable.py
@@ -72,7 +72,6 @@ def test_randomvariable_returns_correct_parameter_name():
     z = RandomVariable(cuqi.distribution.Gaussian(0, 1))
     assert cuqi.utilities.get_non_default_args(z) == ["z"]
 
-@pytest.mark.xfail(reason="Conditional random variables are not yet implemented")
 @pytest.mark.parametrize("operations", [
     lambda x: x+1,
     lambda x: x**2,
@@ -258,7 +257,6 @@ def recursive_return_rv(rv, recursions):
     
     recursive_return_rv(h, 10)
 
-@pytest.mark.xfail(reason="Conditional random variables are not yet implemented")
 def test_rv_name_consistency():
 
     x = RandomVariable(cuqi.distribution.Gaussian(geometry=1))
@@ -306,13 +304,27 @@ def test_ensure_that_RV_evaluation_requires_all_parameters():
     with pytest.raises(ValueError, match=r"Expected arguments \['x', 'y'\], got arguments \{'x': 1\}"):
         z(x=1)
 
-@pytest.mark.xfail(reason="Conditional random variables are not yet implemented")
 def test_RV_sets_name_of_internal_conditional_density_if_par_name_not_set_and_does_not_set_original_density():
-    Z_s = cuqi.distribution.Gaussian(0, lambda s: s)
-    Z = Z_s(s=3)
-    z = RandomVariable(Z)
+    # Case 1
+    z = RandomVariable(cuqi.distribution.Gaussian(0, lambda s: s))
 
     assert z.name == 'z'
-    assert z.distribution.par_name == "z"
-    assert Z_s.par_name is None # Should not be set for the original density.
-    assert Z.par_name is None # Should not be set for the conditioned density.
+    assert z.distribution.name == "z"
+
+    # Case 2 (conditioned density. Should not be able to set name here)
+    z = RandomVariable(cuqi.distribution.Gaussian(0, lambda s: s)(s=3))
+
+    assert z.name == 'z'
+    assert z.distribution.name == "z"
+
+def test_RV_condition_maintains_parameter_name_order():
+
+    x = RandomVariable(cuqi.distribution.Gaussian(0, lambda s: s))
+    y = RandomVariable(cuqi.distribution.Gaussian(0, lambda d: d))
+
+    z = x+y
+
+    assert z.parameter_names == ['x', 'y']
+    assert z.condition(s=1).parameter_names == ['x', 'y']
+    assert z.condition(d=1).parameter_names == ['x', 'y']
+    assert z.condition(d=1, s=1).parameter_names == ['x', 'y']