Repository: CUQI-DTU/CUQIpy

Issue:
RV: Conditioning on Random Variables
- [x] Add support for basic conditioning on random variables (conditioning variables, etc.)
- [x] Error is thrown if conditioning is not possible
- [x] Repr correctly shows conditioning
- [x] Update HowTo
- [x] Unit tests

That is, it should be possible to have this:

```python
x = RandomVariable(Gaussian(0, lambda s: s))
```
Here x should report itself as a random variable with conditioning variables s.

Discussion:
@nabriis: @jakobsj DoD@jakobsj: @nabriis I don't fully understand in what sense we mean conditioning on random variables here, could you elaborate a bit please?  In the code example, a random variable is created from a distribution that involves a lambda function. I somehow imaging this task would involve allowing distributions or random variables depending again on another random variable, not a lambda function.

In terms of DoD, suggest adding a bullet for unit tests and for updating the rv howto (adding in previous PR) to demonstrate this conditioning.@jakobsj: @nabriis Following recent clarifications offline, this DoD is approved.

Solution patch:
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 0cf002c9c..9a0eb63c9 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -16,7 +16,7 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
-          pip install --upgrade pip setuptools==65.7.0
+          pip install --upgrade pip setuptools
           pip install -r requirements.txt
           pip install -r requirements-dev.txt
       - name: Sphinx build
diff --git a/cuqi/experimental/__init__.py b/cuqi/experimental/__init__.py
index 63d0626ea..30844cc48 100644
--- a/cuqi/experimental/__init__.py
+++ b/cuqi/experimental/__init__.py
@@ -1,3 +1,4 @@
 """ Experimental module for testing new features and ideas. """
 from . import mcmc
 from . import algebra
+from . import geometry
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/geometry/__init__.py b/cuqi/experimental/geometry/__init__.py
new file mode 100644
index 000000000..3264de1a4
--- /dev/null
+++ b/cuqi/experimental/geometry/__init__.py
@@ -0,0 +1 @@
+from ._productgeometry import _ProductGeometry
diff --git a/cuqi/experimental/geometry/_productgeometry.py b/cuqi/experimental/geometry/_productgeometry.py
new file mode 100644
index 000000000..1ff377327
--- /dev/null
+++ b/cuqi/experimental/geometry/_productgeometry.py
@@ -0,0 +1,181 @@
+from cuqi.geometry import Geometry
+import numpy as np
+
+class _ProductGeometry(Geometry):
+    """ A class for representing a product geometry. A product geometry
+    represents the product space of multiple geometries of type :class:`Geometry`.
+    See the example below for a product geometry of two geometries.
+
+    Parameters
+    ----------
+    \*geometries : cuqi.geometry.Geometry
+        The geometries to be combined into a product geometry. Each geometry
+        is passed as a comma-separated argument.
+
+    Example
+    -------
+    .. code-block:: python
+        import numpy as np
+        from cuqi.geometry import Continuous1D, Discrete
+        from cuqi.experimental.geometry import _ProductGeometry
+        geometry1 = Continuous1D(np.linspace(0, 1, 100))
+        geometry2 = Discrete(["sound_speed"])
+        product_geometry = _ProductGeometry(geometry1, geometry2)
+    """
+
+    def __init__(self, *geometries):
+        self.geometries = geometries
+
+    @property
+    def geometries(self):
+        """List of geometries that are combined to form the product geometry."""
+        return self._geometries
+    
+    @geometries.setter
+    def geometries(self, geometries):
+        # Check if all geometries are of type Geometry.
+        for g in geometries:
+            if not isinstance(g, Geometry):
+                raise TypeError(
+                    "All geometries must be of type Geometry. "
+                    "Received: {}".format(type(g))
+                )
+        self._geometries = geometries
+
+    @property
+    def fun_shape(self):
+        """Shape of the function representation. Returns a tuple, where
+        each element of the tuple is the shape of the function 
+        representation of each geometry."""
+        return tuple([g.fun_shape for g in self.geometries])
+    
+    @property
+    def fun_dim(self):
+        """Dimension of the function representation which is the sum of
+        the function representation dimensions of each geometry."""
+        return sum([g.fun_dim for g in self.geometries])
+
+    @property
+    def par_shape(self):
+        """Shape of the parameter representation. Returns a tuple, where
+        each element of the tuple is the shape of the parameter
+        representation of each geometry."""
+        return tuple([g.par_shape for g in self.geometries])
+    
+    @property
+    def par_dim(self):
+        """Dimension of the parameter representation which is the sum of
+        the parameter representation dimensions of each geometry."""
+        return sum(self.par_dim_list)
+    
+    @property
+    def par_dim_list(self):
+        """List of the parameter representation dimensions of each
+        geometry. This property is useful for indexing a stacked parameter
+        vector."""
+        return [g.par_dim for g in self.geometries]
+    
+    @property
+    def stacked_par_split_indices(self):
+        """Indices at which the stacked parameter vector should be split
+        to obtain the parameter vectors for each geometry. For example, if
+        the stacked parameter vector is [1, 2, 3, 4, 5, 6] and the parameter
+        vectors for each geometry are [1, 2], [3, 4], and [5, 6], then the
+        split indices are [2, 4]"""
+        return np.cumsum(self.par_dim_list[:-1])
+
+    @property
+    def number_of_geometries(self):
+        """Number of geometries in the product geometry."""
+        return len(self.geometries)
+    
+    def _split_par(self, par):
+        """Splits a stacked parameter vector into parameter vectors for each
+        geometry."""
+        return tuple(np.split(par, self.stacked_par_split_indices))
+    
+    def _plot(self, values, **kwargs):
+        """Plotting function for the product geometry."""
+        raise NotImplementedError(
+            f"Plotting not implemented for {self.__class__.__name__}.")
+
+    def par2fun(self, *pars):
+        """Converts parameter vector(s) into function values for each 
+        geometry. The parameter vector can be stacked (all parameters are
+        in one vector) or unstacked (one parameter vector corresponds to
+        each geometry). In all cases, the order of the parameter vectors
+        should follow the order of the geometries in the product, i.e., the
+        first parameter vector corresponds to the first geometry and so on."""
+
+        # If one argument is passed, then it is assumed that the parameter
+        # vector is stacked and split it.
+        # No effect if the parameter vector is already split and corresponds
+        # to one geometry.
+        if len(pars) == 1:
+            pars = self._split_par(pars[0])
+
+        # Convert parameter vectors to function values for each geometry.
+        funvals = []
+        for i, g in enumerate(self.geometries):
+            funval_i = g.par2fun(pars[i])
+            funvals.append(funval_i)
+        return tuple(funvals)
+
+    def fun2par(self, *funvals, stacked=False):
+        """Converts (multiple) function values into the corresponding
+        parameter vectors. If the flag stacked is set to True, then the
+        parameter vectors are stacked into one vector. Otherwise, the
+        parameter vectors are returned as a tuple. The order of function
+        values should follow the order of the geometries in the product,
+        i.e., the first function value corresponds to the first geometry
+        and so on."""
+
+        pars = []
+        for i, g in enumerate(self.geometries):
+            par_i = g.fun2par(funvals[i])
+            pars.append(par_i)
+
+        # stack parameters:
+        if stacked:
+            # if single sample
+            if len(pars[0].shape) == 1:
+                stacked_val = np.hstack(pars)
+            elif len(pars[0].shape) == 2:
+                stacked_val = np.vstack(pars)
+            else:
+                raise ValueError(
+                    "Cannot stack parameter vectors with more than 2 dimensions."
+                    )
+
+        return  stacked_val if stacked else tuple(pars)
+
+    def vec2fun(self, *funvecs):
+        """Maps function vector representation, if available, to function 
+        values. The order of the function vectors should follow the order of
+        the geometries in the product, i.e., the first function vector
+        corresponds to the first geometry and so on."""
+        funvals = []  
+        for i, g in enumerate(self.geometries):
+            funvals.append(g.vec2fun(funvecs[i]))
+
+        return tuple(funvals)
+    
+    def fun2vec(self, *funvals):
+        """Maps function values to a vector representation of the function
+        values, if available. The order of the function values should follow
+        the order of the geometries in the product, i.e., the first function
+        value corresponds to the first geometry and so on."""
+        funvecs = []
+        for i, g in enumerate(self.geometries):
+            funvecs.append(g.fun2vec(funvals[i]))
+        
+        return tuple(funvecs)
+        
+    
+    def __repr__(self) -> str:
+        """Representation of the product geometry."""
+        string = "{}(".format(self.__class__.__name__) + "\n"
+        for g in self.geometries:
+            string += "\t{}\n".format(g.__repr__())
+        string += ")"
+        return string
\ No newline at end of file
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/problem/_problem.py b/cuqi/problem/_problem.py
index 824a2d878..455fe7704 100644
--- a/cuqi/problem/_problem.py
+++ b/cuqi/problem/_problem.py
@@ -771,11 +771,11 @@ def gradfunc(x): return -density.gradient(x)
         if self._check_posterior(self, CMRF, must_have_gradient=True): # Use L-BFGS-B for CMRF prior as it has better performance for this multi-modal posterior
             if disp: print(f"Using scipy.optimize.L_BFGS_B on negative log of {density.__class__.__name__}")
             if disp: print("x0: ones vector")
-            solver = cuqi.solver.L_BFGS_B(func, x0, gradfunc=gradfunc)
+            solver = cuqi.solver.ScipyLBFGSB(func, x0, gradfunc=gradfunc)
         else:
             if disp: print(f"Using scipy.optimize.minimize on negative log of {density.__class__.__name__}")
             if disp: print("x0: ones vector")
-            solver = cuqi.solver.minimize(func, x0, gradfunc=gradfunc)
+            solver = cuqi.solver.ScipyMinimizer(func, x0, gradfunc=gradfunc)
 
         x_MAP, solver_info = solver.solve()
 
diff --git a/cuqi/solver/__init__.py b/cuqi/solver/__init__.py
index 5b9e11a19..c3851ae65 100644
--- a/cuqi/solver/__init__.py
+++ b/cuqi/solver/__init__.py
@@ -1,8 +1,8 @@
 from ._solver import (
-    L_BFGS_B,
-    minimize,
-    maximize,
-    LS,
+    ScipyLBFGSB,
+    ScipyMinimizer,
+    ScipyMaximizer,
+    ScipyLeastSquares,
     CGLS,
     LM,
     PDHG,
diff --git a/cuqi/solver/_solver.py b/cuqi/solver/_solver.py
index 98a52a389..5c7868c2d 100644
--- a/cuqi/solver/_solver.py
+++ b/cuqi/solver/_solver.py
@@ -15,7 +15,7 @@
     has_cholmod = False
 
 
-class L_BFGS_B(object):
+class ScipyLBFGSB(object):
     """Wrapper for :meth:`scipy.optimize.fmin_l_bfgs_b`.
 
     Minimize a function func using the L-BFGS-B algorithm.
@@ -30,14 +30,10 @@ class L_BFGS_B(object):
         Initial guess.
     gradfunc : callable f(x,*args), optional
         The gradient of func. 
-        If None, then the solver approximates the gradient.
+        If None, the solver approximates the gradient with a finite difference scheme.
     kwargs : keyword arguments passed to scipy's L-BFGS-B algorithm. See documentation for scipy.optimize.minimize
-
-    Methods
-    ----------
-    :meth:`solve`: Runs the solver and returns the solution and info about the optimization.
     """
-    def __init__(self,func,x0, gradfunc = None, **kwargs):
+    def __init__(self, func, x0, gradfunc = None, **kwargs):
         self.func= func
         self.x0 = x0
         self.gradfunc = gradfunc
@@ -83,7 +79,7 @@ def solve(self):
                 "nfev": solution[2]['funcalls']}
         return solution[0], info
 
-class minimize(object):
+class ScipyMinimizer(object):
     """Wrapper for :meth:`scipy.optimize.minimize`.
 
     Minimize a function func using scipy's optimize.minimize module.
@@ -115,12 +111,8 @@ class minimize(object):
         ‘trust-krylov’ 
         If not given, chosen to be one of BFGS, L-BFGS-B, SLSQP, depending if the problem has constraints or bounds.
     kwargs : keyword arguments passed to scipy's minimizer. See documentation for scipy.optimize.minimize
-
-    Methods
-    ----------
-    :meth:`solve`: Runs the solver and returns the solution and info about the optimization.
     """
-    def __init__(self,func,x0, gradfunc = None, method = None, **kwargs):
+    def __init__(self, func, x0, gradfunc = '2-point', method = None, **kwargs):
         self.func= func
         self.x0 = x0
         self.method = method
@@ -147,18 +139,20 @@ def solve(self):
         info = {"success": solution['success'],
                 "message": solution['message'],
                 "func": solution['fun'],
-                "grad": solution['jac'],
                 "nit": solution['nit'], 
                 "nfev": solution['nfev']}
+        # if gradfunc is callable, record the gradient in the info dict
+        if 'jac' in solution.keys():
+            info['grad'] = solution['jac']
         if isinstance(self.x0,CUQIarray):
             sol = CUQIarray(solution['x'],geometry=self.x0.geometry)
         else:
             sol = solution['x']
         return sol, info
 
-class maximize(minimize):
-    """Simply calls ::class:: cuqi.solver.minimize with -func."""
-    def __init__(self,func,x0, gradfunc = None, method = None, **kwargs):
+class ScipyMaximizer(ScipyMinimizer):
+    """Simply calls ::class:: cuqi.solver.ScipyMinimizer with -func."""
+    def __init__(self, func, x0, gradfunc = None, method = None, **kwargs):
         def nfunc(*args,**kwargs):
             return -func(*args,**kwargs)
         if gradfunc is not None:
@@ -170,7 +164,7 @@ def ngradfunc(*args,**kwargs):
 
 
 
-class LS(object):
+class ScipyLeastSquares(object):
     """Wrapper for :meth:`scipy.optimize.least_squares`.
 
     Solve nonlinear least-squares problems with bounds:
@@ -189,7 +183,7 @@ class LS(object):
         Initial guess.
     Jac : callable f(x,*args), optional
         The Jacobian of func. 
-        If None, then the solver approximates the Jacobian.
+        If not specified, the solver approximates the Jacobian with a finite difference scheme.
     loss: callable rho(x,*args)
         Determines the loss function
         'linear' : rho(z) = z. Gives a standard least-squares problem.
@@ -203,7 +197,7 @@ class LS(object):
         'dogbox', dogleg algorithm with rectangular trust regions, for small problems with bounds.
         'lm', Levenberg-Marquardt algorithm as implemented in MINPACK. Doesn't handle bounds and sparse Jacobians.
     """
-    def __init__(self, func, x0, jacfun=None, method='trf', loss='linear', tol=1e-6, maxit=1e4):
+    def __init__(self, func, x0, jacfun='2-point', method='trf', loss='linear', tol=1e-6, maxit=1e4):
         self.func = func
         self.x0 = x0
         self.jacfun = jacfun
diff --git a/demos/demo12_gradients.py b/demos/demo12_gradients.py
index afb9e74c4..9940df3ff 100644
--- a/demos/demo12_gradients.py
+++ b/demos/demo12_gradients.py
@@ -33,7 +33,7 @@
     # Solve posterior problem using BFGS
     def f(x): return -TP.posterior.logd(x)
     def gradf(x): return -TP.posterior.gradient(x)
-    solver = cuqi.solver.L_BFGS_B(f, x0, gradf)
+    solver = cuqi.solver.ScipyLBFGSB(f, x0, gradf)
     x_MAP, solution_info = solver.solve()
 print('relative error MAP:', np.linalg.norm(x_MAP-x_true)/np.linalg.norm(x_true))
 
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))
+
+# %%
diff --git a/demos/try09.py b/demos/try09.py
index 7051e1ca3..1b6d1c6e5 100644
--- a/demos/try09.py
+++ b/demos/try09.py
@@ -48,28 +48,28 @@ def posterior_logpdf_grad(x):
 print('relative error exact MAP:', np.linalg.norm(x_MAP_exact-x_true)/np.linalg.norm(x_true))
 
 #%% L_BFGS_B MAP
-solver = cuqi.solver.L_BFGS_B(posterior_logpdf, x0, gradfunc = posterior_logpdf_grad)
+solver = cuqi.solver.ScipyLBFGSB(posterior_logpdf, x0, gradfunc = posterior_logpdf_grad)
 x_MAP_LBFGS, info_MAP_LBFGS = solver.solve()
 print('relative error L-BFGS MAP:', np.linalg.norm(x_MAP_LBFGS-x_true)/np.linalg.norm(x_true))
 
-#%% L_BFGS_B MAP using minimize
-solver = cuqi.solver.minimize(posterior_logpdf, x0, gradfunc = posterior_logpdf_grad, method = "L-BFGS-B")
+#%% L_BFGS_B MAP using ScipyMinimizer
+solver = cuqi.solver.ScipyMinimizer(posterior_logpdf, x0, gradfunc = posterior_logpdf_grad, method = "L-BFGS-B")
 x_MAP_LBFGS2, info_MAP_LBFGS2 = solver.solve()
 print('relative error L-BFGS MAP:', np.linalg.norm(x_MAP_LBFGS2-x_true)/np.linalg.norm(x_true))
 
 #%% BFGS MAP without gradient input
-solver = cuqi.solver.minimize(posterior_logpdf, x0)
+solver = cuqi.solver.ScipyMinimizer(posterior_logpdf, x0)
 x_MAP_BFGS, info_MAP_BFGS = solver.solve()
 print('relative error BFGS MAP:', np.linalg.norm(x_MAP_BFGS-x_true)/np.linalg.norm(x_true))
 
 #%% BFGS MAP with gradient input
-solver = cuqi.solver.minimize(posterior_logpdf, x0, gradfunc = posterior_logpdf_grad)
+solver = cuqi.solver.ScipyMinimizer(posterior_logpdf, x0, gradfunc = posterior_logpdf_grad)
 x_MAP_BFGSgrad, info_MAP_BFGSgrad = solver.solve()
 print('relative error BFGS MAP:', np.linalg.norm(x_MAP_BFGSgrad-x_true)/np.linalg.norm(x_true))
 
 
 #%% SLSQP MAP
-solver = cuqi.solver.minimize(posterior_logpdf, x0, method = 'SLSQP')
+solver = cuqi.solver.ScipyMinimizer(posterior_logpdf, x0, method = 'SLSQP')
 x_MAP_SLSQP, info_MAP_SLSQP = solver.solve()
 print('relative error SLSQP MAP:', np.linalg.norm(x_MAP_SLSQP-x_true)/np.linalg.norm(x_true))
 
@@ -95,12 +95,12 @@ def likelihood_logpdf_grad(x):
     return grad
 
 # L_BFGS_B MAP
-solver = cuqi.solver.L_BFGS_B(likelihood_logpdf, x0, gradfunc = likelihood_logpdf_grad)
+solver = cuqi.solver.ScipyLBFGSB(likelihood_logpdf, x0, gradfunc = likelihood_logpdf_grad)
 x_ML_LBFGS, info_ML_LBFGS = solver.solve()
 print('relative error L-BFGS ML:', np.linalg.norm(x_MAP_LBFGS-x_true)/np.linalg.norm(x_true))
 
 # BFGS MAP
-solver = cuqi.solver.minimize(likelihood_logpdf, x0)
+solver = cuqi.solver.ScipyMinimizer(likelihood_logpdf, x0)
 x_ML_BFGS, info_ML_BFGS = solver.solve()
 print('relative error BFGS ML:', np.linalg.norm(x_MAP_BFGS-x_true)/np.linalg.norm(x_true))
 


Test patch:
diff --git a/tests/test_solver.py b/tests/test_solver.py
index 8092ee208..bdbb5ba3e 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 CGLS, LM, FISTA, ADMM, ProximalL1, ProjectNonnegative
+from cuqi.solver import ScipyLBFGSB, ScipyMinimizer, ScipyLeastSquares, CGLS, LM, FISTA, ADMM, ProximalL1, ProjectNonnegative
 from scipy.optimize import lsq_linear
 
 
@@ -20,6 +20,61 @@ def test_CGLS():
     # Compare
     assert np.allclose(sol, ref_sol, rtol=1e-3)
 
+def test_ScipyMinimizer_without_gradient():
+    solver = ScipyMinimizer(sp.optimize.rosen,
+                            np.array([1.3, 0.7, 0.8, 1.9, 1.2]),
+                            method='Nelder-Mead',
+                            tol=1e-6)
+    sol, _ = solver.solve()
+    sol_ref = np.array([1.0, 1.0, 1.0, 1.0, 1.0])
+    assert np.allclose(sol, sol_ref)
+
+def test_ScipyMinimizer_with_gradient():
+    solver = ScipyMinimizer(sp.optimize.rosen,
+                            np.array([1.3, 0.7, 0.8, 1.9, 1.2]),
+                            gradfunc=sp.optimize.rosen_der,
+                            method='BFGS',
+                            tol=1e-6)
+    sol, _ = solver.solve()
+    sol_ref = np.array([1.0, 1.0, 1.0, 1.0, 1.0])
+    assert np.allclose(sol, sol_ref)
+
+def test_ScipyLBFGSB_without_gradient():
+    solver = ScipyLBFGSB(sp.optimize.rosen,
+                         np.array([1.3, 0.7, 0.8, 1.9, 1.2]))
+    sol, _ = solver.solve()
+    sol_ref = np.array([1.0, 1.0, 1.0, 1.0, 1.0])
+    assert np.allclose(sol, sol_ref)
+
+def test_ScipyLBFGSB_with_gradient():
+    solver = ScipyLBFGSB(sp.optimize.rosen,
+                         np.array([1.3, 0.7, 0.8, 1.9, 1.2]),
+                         gradfunc=sp.optimize.rosen_der)
+    sol, _ = solver.solve()
+    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 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)
+    sol, _ = solver.solve()
+    sol_ref = np.array([1, 1])
+    assert np.allclose(sol, sol_ref)
+
+def test_ScipyLeastSquares_with_Jac():
+    def fun_rosenbrock(x):
+        return np.array([10 * (x[1] - x[0]**2), (1 - x[0])])
+    def jac_rosenbrock(x):
+        return np.array([
+            [-20 * x[0], 10],
+            [-1, 0]])
+    x0 = np.array([2, 2])
+    solver = ScipyLeastSquares(fun_rosenbrock, x0, jacfun=jac_rosenbrock)
+    sol, _ = solver.solve()
+    sol_ref = np.array([1, 1])
+    assert np.allclose(sol, sol_ref)
 
 def test_LM():
     # compare to MATLAB's original code solution
diff --git a/tests/zexperimental/test_productgeometry.py b/tests/zexperimental/test_productgeometry.py
new file mode 100644
index 000000000..cb0b711cf
--- /dev/null
+++ b/tests/zexperimental/test_productgeometry.py
@@ -0,0 +1,165 @@
+# %%
+import cuqi
+import pytest
+import numpy as np
+from cuqi.geometry import Continuous1D, Discrete, MappedGeometry, Continuous2D
+from cuqi.experimental.geometry import _ProductGeometry
+
+@pytest.fixture
+def product_geometry():
+    """fixture function to create a product geometry"""
+    geometry1 = Continuous1D(np.linspace(0, 1, 100))
+    geometry2 = MappedGeometry(
+        Discrete(["sound_speed"]), map=lambda x: x**2, imap=lambda x: np.sqrt(x)
+    )
+    geometry3 = Continuous2D((3,3))
+    product_geometry = _ProductGeometry(geometry1, geometry2, geometry3)
+    return product_geometry
+
+@pytest.fixture
+def par1(product_geometry):
+    """fixture function to create a parameter vector"""
+    geometry1, geometry2, geometry3 = product_geometry.geometries
+
+    g1_dim = geometry1.par_dim
+    g2_dim = geometry2.par_dim
+    g3_dim = geometry3.par_dim
+
+    par1 = np.ones(g1_dim+g2_dim+g3_dim)
+
+    par1[g1_dim:g1_dim+g2_dim] = 2
+    par1[g1_dim+g2_dim:] = 3
+    return par1
+
+@pytest.fixture
+def par2(product_geometry):
+    """fixture function to create a parameter vector"""
+    geometry1, geometry2, geometry3 = product_geometry.geometries
+
+    par2 = (np.ones(geometry1.par_dim),
+            np.ones(geometry2.par_dim)*2,
+            np.ones(geometry3.par_dim)*3)
+    return par2
+
+@pytest.fixture
+def par3(product_geometry):
+    """fixture function to create a parameter vector"""
+    geometry1, geometry2, geometry3 = product_geometry.geometries
+
+    g1_dim = geometry1.par_dim
+    g2_dim = geometry2.par_dim
+    g3_dim = geometry3.par_dim
+    
+    Ns = 5
+    par3 = np.ones((g1_dim+g2_dim+g3_dim, Ns))
+    par3[g1_dim:,:] = 2
+    par3[g1_dim+g2_dim:,:] = 3
+
+    return par3
+
+@pytest.fixture
+def par4(product_geometry):
+    """fixture function to create a parameter vector"""
+    geometry1, geometry2, geometry3 = product_geometry.geometries
+
+    Ns = 5
+    par4 = (np.ones((geometry1.par_dim, Ns)),
+            2*np.ones((geometry2.par_dim, Ns)),
+            3*np.ones((geometry3.par_dim, Ns)))
+    return par4
+
+
+def test_par2fun_single_sample(product_geometry, par1, par2):
+    """Test par2fun for single sample"""
+    geometry1, geometry2, geometry3 = product_geometry.geometries
+
+    fun1 = product_geometry.par2fun(par1)
+    fun2 = product_geometry.par2fun(par2[0], par2[1], par2[2])
+    fun2_a = geometry1.par2fun(par2[0])
+    fun2_b = geometry2.par2fun(par2[1])
+    fun2_c = geometry3.par2fun(par2[2])
+
+    assert np.allclose(len(fun1), 3)
+    assert np.allclose(len(fun1), len(fun2))
+    assert np.allclose(fun1[0], fun2[0])
+    assert np.allclose(fun1[1], fun2[1])
+    assert np.allclose(fun1[2], fun2[2])
+    assert np.allclose(fun2[0], fun2_a)
+    assert np.allclose(fun2[1], fun2_b)
+    assert np.allclose(fun2[2], fun2_c)
+
+def test_par2fun_multiple_samples(product_geometry, par3, par4):
+    """Test par2fun for multiple samples"""
+    geometry1, geometry2, geometry3 = product_geometry.geometries
+
+    fun3 = product_geometry.par2fun(par3)
+    fun4 = product_geometry.par2fun(par4[0], par4[1], par4[2])
+    fun4_a = geometry1.par2fun(par4[0])
+    fun4_b = geometry2.par2fun(par4[1])
+    fun4_c = geometry3.par2fun(par4[2])
+
+    assert(np.allclose(len(fun3), len(fun4)))
+    assert(np.allclose(len(fun3), 3))
+    assert(np.allclose(fun3[0], fun4[0]))
+    assert(np.allclose(fun3[1], fun4[1]))
+    assert(np.allclose(fun3[2], fun4[2]))
+    assert(np.allclose(fun4[0], fun4_a))
+    assert(np.allclose(fun4[1], fun4_b))
+    assert(np.allclose(fun4[2], fun4_c))
+
+def test_fun2par_single_sample(product_geometry, par2):
+    """Test fun2par for single sample"""
+
+    fun2 = product_geometry.par2fun(par2[0], par2[1], par2[2])
+    par_same_as_par2 = product_geometry.fun2par(fun2[0], fun2[1], fun2[2])
+
+    assert np.allclose(len(par2), len(par_same_as_par2))
+    assert np.allclose(len(par2), 3)
+    assert np.allclose(par2[0], par_same_as_par2[0])
+    assert np.allclose(par2[1], par_same_as_par2[1])
+    assert np.allclose(par2[2], par_same_as_par2[2])
+
+def test_fun2par_single_sample_stacked(product_geometry, par1):
+    """Test fun2par for single sample stacked"""
+
+    fun1 = product_geometry.par2fun(par1)
+    par_same_as_par1 = product_geometry.fun2par(fun1[0], fun1[1], fun1[2], stacked=True)
+
+    assert np.allclose(par1.shape, par_same_as_par1.shape)
+    assert np.allclose(par1, par_same_as_par1)
+
+def test_fun2par_multiple_samples(product_geometry, par4):
+    """Test fun2par for multiple samples"""
+
+    fun4 = product_geometry.par2fun(par4[0], par4[1], par4[2])
+    par_same_as_par4 = product_geometry.fun2par(fun4[0], fun4[1], fun4[2])
+
+    assert np.allclose(len(par4), len(par_same_as_par4))
+    assert np.allclose(len(par4), 3)
+    assert np.allclose(par4[0], par_same_as_par4[0])
+    assert np.allclose(par4[1], par_same_as_par4[1])
+    assert np.allclose(par4[2], par_same_as_par4[2])
+
+def test_fun2par_multiple_samples_stacked(product_geometry, par3):
+    """Test fun2par for multiple samples stacked"""
+
+    fun3 = product_geometry.par2fun(par3)
+
+    par_same_as_par3 = product_geometry.fun2par(fun3[0], fun3[1], fun3[2], stacked=True)
+
+    assert np.allclose(par3.shape, par_same_as_par3.shape)
+    assert np.allclose(par3, par_same_as_par3)
+
+def test_number_of_geometries(product_geometry):
+    """Test number of geometries"""
+    assert np.allclose(product_geometry.number_of_geometries, 3)
+
+def test_stacked_par_split_indices(product_geometry):
+    """Test stacked_par_split_indices are correct"""
+    assert np.allclose(product_geometry.stacked_par_split_indices, [100, 101])
+
+def test_input_not_geometry_fails():
+    """Test that passing input other than Geometry fails"""
+    with pytest.raises(TypeError,
+                       match="All geometries must be of type Geometry"):
+        _ProductGeometry(1, 2, 3)
\ No newline at end of file
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']