Repository: rhayes777/PyAutoFit (python)

Issue:
Derived Quantities
Derived Parameters:

The most recent implemetation of derived parameters (latent variables) implements their calculation and output within
the `log_likelihood_function` of an `Analysis`.

I have decided this is not a good idea:

- It slows down the log likelihood function with outputs a user may not always want.
- Calculation of derived quantites may raise exceptions, making the likelihood implementation more complex.

The only benefit compared to the implementation below is that CPU time is saved because log likelihood calculations
are only performed once when the derived parameters are calculated.

I have formulated a new API for derived parameters, which will actually still maintain support for the above
implementation, but also allow for a more flexible and user-friendly API which outputs derived parameters at the end
of a fit.

**Outputting at end of Fit**

An `output_derived_quantities` method should be added to an analysis object which is called as follows:

```
analysis = al.Analysis(dataset=dataset)

result = search.fit(model=model, analysis=analysis)

analysis.output_derived_quantities(samples=result.samples)
```

The `output_derived_quantities` method would look something like this:

```
# This is a genetic Analysis method in the PyAutoFit source code

def output_derived_quantities(self, paths, samples) -> float:

    for sample in samples:

        instance = self.model.instance_from_sample(sample=sample)

        self.output_derived_quantity(instance=instance)

    paths.save_latent_variables(
             self.latent_variables,
             samples=samples,
         )


# This is a function in the Analysis class a user can customize

def output_derived_quantity(self, instance) -> float:

    self.save_derived_quantities(
        fwmh=instance.fwhm
    )
```

This still supports having the saving of latent variables in the `log_likelihood_function` because a user can simply add
`output_derived_quantity` to that function, with `paths.save_latent_variables` being called at the end of the fit.


**Customization**

High levels of customization are anticipated `output_derived_quantity` to be useful. For example, in autolens
a user will be specific on what the outputs are:

```
def output_derived_quantity(self, instance, einstein_mass=False, shear_magnitude=False) -> float:

    if einstein_mass:
        einstein_mass = func(instance)
    else:
        einstein_mass = None

    if shear_magnitude:
        shear_magnitude = func(instance.shear_magnitude)
    else:
        shear_magnitude = None

    self.save_derived_quantities(
        einstein_mass=einstein_mass,
        shear_magnitude=shear_magnitude
    )
```

This probably means that our high level API looks something like this:

```
analysis = al.Analysis(dataset=dataset)

result = search.fit(model=model, analysis=analysis)

analysis.output_derived_quantities(
    samples=result.samples,
    einstein_mass=True,
    shear_magnitude=False
    )
```

This API also makes it simple for a user to customize the derived quantities they want to output at the end of a fit,
without having to dig into the source code of the project:

```

class AnalysisCustom(Analysis):

    def output_derived_quantity(self, instance) -> float:
    
        super().output_derived_quantity(instance=instance)
        
        # User can put their favourite derived quantities here
        
        user_quantity = func(instance)
        
        # This should add on top of the derived quantities already output by the super class
    
        self.save_derived_quantities(
            user_quantity=user_quantity
        )

```

**Updating Latent Variables**

A benefit of this approach is that I expect users will want to change and update the derived quantities they output
after a fit is complete. This API supports this.


**Database**

The one thing I am unclear on is whether this API plays nicely with fits which write directly to a database via a 
session.

I think it must be OK, the point is when you called `output_derived_quantities` the updates to the paths object means
that the database is updated with the derived quantities.

Pull request description:
feature/latent variables
Resolve #966 although may inefficient

The user can now provide latent variables by overriding `compute_latent_variable` in the Analysis class.

```python
def compute_latent_variable(self, instance) -> Dict[str, float]:
    return {"my_variable": instance.my_variable}
```

If this method is not overridden no latent variable output is written. 

Currently this is called for every sample every time output is written. It is also called when creating a result. It may be desirable to map instances or samples to the latent variables dictionaries that were computed for them.


Patch:
diff --git a/autofit/aggregator/search_output.py b/autofit/aggregator/search_output.py
index df2f0c706..8e9e08459 100644
--- a/autofit/aggregator/search_output.py
+++ b/autofit/aggregator/search_output.py
@@ -264,7 +264,7 @@ class SearchOutput(AbstractSearchOutput):
         """
         The latent variables of the search, parsed from a CSV file.
         """
-        with open(self.files_path / "latent_variables.csv") as f:
+        with open(self.files_path / "latent.csv") as f:
             reader = csv.reader(f)
             headers = next(reader)
             from autofit.non_linear.analysis.latent_variables import LatentVariables
diff --git a/autofit/config/output.yaml b/autofit/config/output.yaml
index a9182824f..724f6aef8 100644
--- a/autofit/config/output.yaml
+++ b/autofit/config/output.yaml
@@ -1,13 +1,6 @@
 # Determines whether files saved by the search are output to the hard-disk. This is true both when saving to the
 # directory structure and when saving to database.
 
-# Files can be listed name: bool where the name is the name of the file without a suffix (e.g. model not model.json)
-# and bool is true or false.
-
-# If a given file is not listed then the default value is used.
-
-default: true # If true then files which are not explicitly listed here are output anyway. If false then they are not.
-
 ### Samples ###
 
 # The `samples.csv`file contains every sampled value of every free parameter with its log likelihood and weight.
@@ -22,17 +15,17 @@ default: true # If true then files which are not explicitly listed here are outp
 samples: true
 
 # The `samples.csv` file contains every accepted sampled value of every free parameter with its log likelihood and
-# weight. For certain searches, the majority of samples have a very low weight, which has no numerical impact on the
-# results of the model-fit. However, these samples are still output to the `samples.csv` file, taking up hard-disk space
-# and slowing down analysis of the samples (e.g. via the database).
+# weight. For certain searches, the majority of samples have a very low weight and have no numerical impact on the
+# results of the model-fit. However, these samples are still output to the `samples.csv` file, taking up hard-disk
+# space and slowing down analysis of the samples (e.g. via the database).
 
 # The `samples_weight_threshold` below specifies the threshold value of the weight such that samples with a weight
 # below this value are not output to the `samples.csv` file. This can be used to reduce the size of the `samples.csv`
 # file and speed up analysis of the samples.
 
-# Note that for many searches (e.g. MCMC) all samples have equal weight, and thus this threshold has no impact and
-# there is no simple way to save hard-disk space. However, for nested sampling, the majority of samples have a very
-# low weight and this threshold can be used to save hard-disk space.
+# For many searches (e.g. MCMC) all samples have an equal weight of 1.0, and this threshold therefore has no impact.
+# For these searches, there is no simple way to save hard-disk space. This input is more suited to nested sampling,
+# where the majority of samples have a very low weight..
 
 # Set value to empty (e.g. delete 1.0e-10 below) to disable this feature.
 
@@ -40,7 +33,11 @@ samples_weight_threshold: 1.0e-10
 
 ### Search Internal ###
 
-# The search internal folder which contains a saved state of the non-linear search, as a .pickle or .dill file.
+# The search internal folder which contains a saved state of the non-linear search in its internal reprsenetation,
+# as a .pickle or .dill file.
+
+# For example, for the nested sampling dynesty, this .dill file is the `DynestySampler` object which is used to
+# perform sampling, and it therefore contains all internal dynesty representations of the results, samples, weights, etc.
 
 # If the entry below is false, the folder is still output during the model-fit, as it is required to resume the fit
 # from where it left off. Therefore, settings `false` below does not impact model-fitting checkpointing and resumption.
@@ -49,12 +46,40 @@ samples_weight_threshold: 1.0e-10
 # The search internal folder file is often large, therefore deleting it after a fit is complete can significantly
 # reduce hard-disk space use.
 
-# The search internal representation (e.g. what you can load from the output .pickle file) may have additional
-# quantities specific to the non-linear search that you are interested in inspecting. Deleting the folder means this
-# information is list.
+# The search internal representation that can be loaded from the .dill file has many additional quantities specific to
+# the non-linear search that the standardized autofit forms do not. For example, for emcee, it contains information on
+# every walker. This information is required to do certain analyes and make certain plots, therefore deleting the
+# folder means this information is list.
 
 search_internal: false
 
+### Latent Variables ###
+
+# A latent variable is not a model parameter but can be derived from the model. Its value and errors may be of interest
+# and aid in the interpretation of a model-fit.
+
+# For example, for the simple 1D Gaussian example, it could be the full-width half maximum (FWHM) of the Gaussian. This
+# is not included in the model but can be easily derived from the Gaussian's sigma value.
+
+# By overwriting an Analysis class's `compute_latent_variable` method we can manually specify latent variables that
+# are calculated and output to a `latent.csv` file, which mirrors the `samples.csv` file. The `latent.csv` file has
+# the same weight resampling performed on the `samples.csv` file, controlled via the `samples_weight_threshold` above.
+
+# There may also be a `latent.results` and `latent_summary.json` files output, which the inputs below control whether
+# they are output and how often.
+
+# Outputting latent variables manually after a fit is complete is simple, just call
+# the `analysis.compute_all_latent_variables()` function.
+
+# For many use cases, the best set up may be to disable autofit latent variable output during the fit and perform it
+# manually after completing a successful model-fit. This will save computational run time by not computing latent
+# variables during a any model-fit which is unsuccessful.
+
+latent_during_fit: true # Whether to output the `latent.csv`, `latent.results` and `latent_summary.json` files during the fit when it performs on-the-fly output.
+latent_after_fit: true # If `latent_during_fit` is False, whether to output the `latent.csv`, `latent.results` and `latent_summary.json` files after the fit is complete.
+latent_csv: true # Whether to ouptut the `latent.csv` file.
+latent_results: true # Whether to output the `latent.results` file.
+
 # Other Files:
 
 covariance: true # `covariance.csv`: The [free parameters x free parameters] covariance matrix.
diff --git a/autofit/example/analysis.py b/autofit/example/analysis.py
index a86aa15a2..67134b338 100644
--- a/autofit/example/analysis.py
+++ b/autofit/example/analysis.py
@@ -1,6 +1,6 @@
 import os
 import matplotlib.pyplot as plt
-from typing import List, Optional
+from typing import Dict, List
 
 from autofit.jax_wrapper import numpy as np
 
@@ -60,13 +60,6 @@ class Analysis(af.Analysis):
         chi_squared_map = (residual_map / self.noise_map) ** 2.0
         log_likelihood = -0.5 * sum(chi_squared_map)
 
-        try:
-            self.save_latent_variables(
-                fwmh=instance.fwhm
-            )
-        except AttributeError:
-            pass
-
         return log_likelihood
 
     def visualize(self, paths: af.DirectoryPaths, instance: af.ModelInstance, during_analysis : bool):
@@ -176,4 +169,32 @@ class Analysis(af.Analysis):
         paths.save_json(name="data", object_dict=self.data.tolist(), prefix="dataset")
         paths.save_json(name="noise_map", object_dict=self.noise_map.tolist(), prefix="dataset")
 
+    def compute_latent_variable(self, instance) -> Dict[str, float]:
+        """
+        A latent variable is not a model parameter but can be derived from the model. Its value and errors may be
+        of interest and aid in the interpretation of a model-fit.
+
+        For example, for the simple 1D Gaussian example, it could be the full-width half maximum (FWHM) of the
+        Gaussian. This is not included in the model but can be easily derived from the Gaussian's sigma value.
 
+        By overwriting this method we can manually specify latent variables that are calculated and output to
+        a `latent.csv` file, which mirrors the `samples.csv` file.
+
+        In the example below, the `latent.csv` file will contain one column with the FWHM of every Gausian model
+        sampled by the non-linear search.
+
+        This function is called for every non-linear search sample, where the `instance` passed in corresponds to
+        each sample.
+
+        Parameters
+        ----------
+        instance
+            The instances of the model which the latent variable is derived from.
+
+        Returns
+        -------
+
+        """
+        return {
+            "fwhm": instance.fwhm
+        }
\ No newline at end of file
diff --git a/autofit/non_linear/analysis/analysis.py b/autofit/non_linear/analysis/analysis.py
index 73219f883..a082d7afd 100644
--- a/autofit/non_linear/analysis/analysis.py
+++ b/autofit/non_linear/analysis/analysis.py
@@ -1,7 +1,7 @@
 import logging
 from abc import ABC
 import os
-from typing import Optional
+from typing import Optional, Dict
 
 from autoconf import conf
 
@@ -11,6 +11,7 @@ from autofit.non_linear.paths.abstract import AbstractPaths
 from autofit.non_linear.paths.database import DatabasePaths
 from autofit.non_linear.paths.null import NullPaths
 from autofit.non_linear.result import Result
+from autofit.non_linear.samples.samples import Samples
 
 logger = logging.getLogger(__name__)
 
@@ -22,38 +23,46 @@ class Analysis(ABC):
     likelihood that some instance fits some data.
     """
 
-    @property
-    def latent_variables(self) -> Optional[LatentVariables]:
+    def compute_all_latent_variables(
+        self, samples: Samples
+    ) -> Optional[LatentVariables]:
         """
-        Custom quantities that are computed during the analysis.
+        Internal method that manages computation of latent variables from samples.
 
-        If no latent variables have been saved, this will return None.
+        Parameters
+        ----------
+        samples
+            The samples from the non-linear search.
+
+        Returns
+        -------
+        The computed latent variables or None if compute_latent_variable is not implemented.
         """
         try:
-            return self._latent_variables
-        except AttributeError:
+            latent_variables = LatentVariables()
+            model = samples.model
+            for sample in samples.sample_list:
+                latent_variables.add(
+                    **self.compute_latent_variable(sample.instance_for_model(model))
+                )
+            return latent_variables
+        except NotImplementedError:
             return None
 
-    def save_latent_variables(self, **kwargs: float):
+    def compute_latent_variable(self, instance) -> Dict[str, float]:
         """
-        Save latent variables that are computed during the analysis.
-
-        This should only be called once per a fit and must always be passed the same latent variables.
+        Override to compute latent variables from the instance.
 
         Parameters
         ----------
-        kwargs
-            The latent variables to save.
+        instance
+            An instance of the model.
 
-        Raises
-        ------
-        SamplesException
-            If the same latent variables are not passed to `add` each iteration.
+        Returns
+        -------
+        The computed latent variables.
         """
-        if not hasattr(self, "_latent_variables"):
-            # noinspection PyAttributeOutsideInit
-            self._latent_variables = LatentVariables()
-        self._latent_variables.add(**kwargs)
+        raise NotImplementedError()
 
     def with_model(self, model):
         """
@@ -174,7 +183,7 @@ class Analysis(ABC):
     def make_result(self, samples, search_internal=None):
         return Result(
             samples=samples,
-            latent_variables=self.latent_variables,
+            latent_variables=self.compute_all_latent_variables(samples),
             search_internal=search_internal,
         )
 
diff --git a/autofit/non_linear/paths/abstract.py b/autofit/non_linear/paths/abstract.py
index b66ca84fb..a9030d523 100644
--- a/autofit/non_linear/paths/abstract.py
+++ b/autofit/non_linear/paths/abstract.py
@@ -454,7 +454,7 @@ class AbstractPaths(ABC):
 
     @property
     def _latent_variables_file(self) -> Path:
-        return self._files_path / "latent_variables.csv"
+        return self._files_path / "latent.csv"
 
     @property
     def _covariance_file(self) -> Path:
diff --git a/autofit/non_linear/search/abstract_search.py b/autofit/non_linear/search/abstract_search.py
index a5e841a32..514ca320a 100644
--- a/autofit/non_linear/search/abstract_search.py
+++ b/autofit/non_linear/search/abstract_search.py
@@ -560,7 +560,7 @@ class NonLinearSearch(AbstractFactorOptimiser, ABC):
 
             self.post_fit_output(
                 bypass_nuclear_if_on=bypass_nuclear_if_on,
-                search_internal=result.search_internal
+                search_internal=result.search_internal,
             )
 
         return result
@@ -673,9 +673,7 @@ class NonLinearSearch(AbstractFactorOptimiser, ABC):
             during_analysis=False,
         )
 
-        result = analysis.make_result(
-            samples=samples, search_internal=search_internal
-        )
+        result = analysis.make_result(samples=samples, search_internal=search_internal)
 
         if self.is_master:
             analysis.save_results(paths=self.paths, result=result)
@@ -688,7 +686,9 @@ class NonLinearSearch(AbstractFactorOptimiser, ABC):
         return result
 
     def result_via_completed_fit(
-        self, analysis: Analysis, model: AbstractPriorModel,
+        self,
+        analysis: Analysis,
+        model: AbstractPriorModel,
     ) -> Result:
         """
         Returns the result of the non-linear search of a completed model-fit.
@@ -734,9 +734,7 @@ class NonLinearSearch(AbstractFactorOptimiser, ABC):
             except FileNotFoundError:
                 search_internal = None
 
-        result = analysis.make_result(
-            samples=samples, search_internal=search_internal
-        )
+        result = analysis.make_result(samples=samples, search_internal=search_internal)
 
         if self.is_master:
             self.logger.info(f"Fit Already Completed: skipping non-linear search.")
@@ -921,10 +919,11 @@ class NonLinearSearch(AbstractFactorOptimiser, ABC):
             return samples
 
         if self.is_master:
-
             samples_for_csv = samples
 
-            samples_weight_threshold = conf.instance["output"]["samples_weight_threshold"]
+            samples_weight_threshold = conf.instance["output"][
+                "samples_weight_threshold"
+            ]
 
             if samples_weight_threshold is not None:
                 samples_for_csv = samples_for_csv.samples_above_weight_threshold_from(
@@ -938,12 +937,18 @@ class NonLinearSearch(AbstractFactorOptimiser, ABC):
 
             self.paths.save_samples(samples=samples_for_csv)
 
-            latent_variables = analysis.latent_variables
-            if latent_variables:
-                self.paths.save_latent_variables(
-                    latent_variables,
-                    samples=samples,
-                )
+            if (
+                    (during_analysis and conf.instance["output"]["latent_during_fit"]) or
+                    (not during_analysis and conf.instance["output"]["latent_after_fit"])
+            ):
+
+                latent_variables = analysis.compute_all_latent_variables(samples_for_csv)
+
+                if latent_variables:
+                    self.paths.save_latent_variables(
+                        latent_variables,
+                        samples=samples,
+                    )
 
             if not self.skip_save_samples:
                 self.paths.save_json("samples_summary", to_dict(samples.summary()))
diff --git a/docs/cookbooks/analysis.rst b/docs/cookbooks/analysis.rst
index 43d65188e..e743c1408 100644
--- a/docs/cookbooks/analysis.rst
+++ b/docs/cookbooks/analysis.rst
@@ -13,6 +13,7 @@ This cookbook provides an overview of how to use and extend ``Analysis`` objects
 - **Example**: A simple example of an analysis class which can be adapted for you use-case.
 - **Customization**: Customizing an analysis class with different data inputs and editing the ``log_likelihood_function``.
 - **Visualization**: Adding a ``visualize`` method to the analysis so that model-specific visuals are output to hard-disk.
+- **Latent Variables**: Adding a `compute_latent_variable` method to the analysis to output latent variables to hard-disk.
 - **Custom Output**: Add methods which output model-specific results to hard-disk in the ``files`` folder (e.g. as .json files) to aid in the interpretation of results.
 
 Example
@@ -192,7 +193,7 @@ Function", are also automatically output during the model-fit on the fly.
     class Analysis(af.Analysis):
         def __init__(self, data, noise_map):
             """
-            We use the simpler Analysis class above for this example.
+            An Analysis class which illustrates visualization.
             """
             super().__init__()
 
@@ -303,6 +304,97 @@ Function", are also automatically output during the model-fit on the fly.
             plt.savefig(path.join(paths.image_path, f"model_fit.png"))
             plt.clf()
 
+Latent Variables
+----------------
+
+A latent variable is not a model parameter but can be derived from the model. Its value and errors may be of interest
+and aid in the interpretation of a model-fit.
+
+For example, for the simple 1D Gaussian example, it could be the full-width half maximum (FWHM) of the Gaussian.
+This is not included in the model but can be easily derived from the Gaussian's sigma value.
+
+By overwriting the Analysis class's ``compute_latent_variable`` method we can manually specify latent variables that
+are calculated. If the search has a ``name``, these are output to a ``latent.csv`` file, which mirrors
+the ``samples.csv`` file.
+
+There may also be a ``latent.results`` and ``latent_summary.json`` files output. The ``output.yaml`` config file
+contains settings customizing what files are output and how often.
+
+.. code-block:: python
+
+    class Analysis(af.Analysis):
+        def __init__(self, data, noise_map):
+            """
+            An Analysis class which illustrates latent variables.
+            """
+            super().__init__()
+
+            self.data = data
+            self.noise_map = noise_map
+
+        def log_likelihood_function(self, instance):
+            """
+            The `log_likelihood_function` is identical to the example above
+            """
+            xvalues = np.arange(self.data.shape[0])
+
+            model_data = instance.model_data_1d_via_xvalues_from(xvalues=xvalues)
+            residual_map = self.data - model_data
+            chi_squared_map = (residual_map / self.noise_map) ** 2.0
+            chi_squared = sum(chi_squared_map)
+            noise_normalization = np.sum(np.log(2 * np.pi * noise_map**2.0))
+            log_likelihood = -0.5 * (chi_squared + noise_normalization)
+
+            return log_likelihood
+
+        def compute_latent_variable(self, instance) -> Dict[str, float]:
+            """
+            A latent variable is not a model parameter but can be derived from the model. Its value and errors may be
+            of interest and aid in the interpretation of a model-fit.
+
+            For example, for the simple 1D Gaussian example, it could be the full-width half maximum (FWHM) of the
+            Gaussian. This is not included in the model but can be easily derived from the Gaussian's sigma value.
+
+            By overwriting this method we can manually specify latent variables that are calculated and output to
+            a `latent.csv` file, which mirrors the `samples.csv` file.
+
+            In the example below, the `latent.csv` file will contain one column with the FWHM of every Gausian model
+            sampled by the non-linear search.
+
+            This function is called for every non-linear search sample, where the `instance` passed in corresponds to
+            each sample.
+
+            Parameters
+            ----------
+            instance
+                The instances of the model which the latent variable is derived from.
+
+            Returns
+            -------
+            A dictionary mapping every latent variable name to its value.
+
+            """
+            return {
+                "fwhm": instance.fwhm
+            }
+
+Outputting latent variables manually after a fit is complete is simple, just call
+the ``analysis.compute_all_latent_variables()`` function.
+
+For many use cases, the best set up may be to disable autofit latent variable output during a fit via
+the ``output.yaml`` file and perform it manually after completing a successful model-fit. This will save computational
+run time by not computing latent variables during a any model-fit which is unsuccessful.
+
+.. code-block:: python
+
+    analysis = Analysis(data=data, noise_map=noise_map)
+
+    # You need to have run a fit to retrieve a result to do this.
+
+    analysis.compute_all_latent_variables(samples=result.samples)
+
+Analysing and interpreting latent variables is described in the result cookbook.
+
 Custom Output
 -------------
 


Test patch:
diff --git a/test_autofit/analysis/test_latent_variables.py b/test_autofit/analysis/test_latent_variables.py
index 11cffbb79..896eceb4b 100644
--- a/test_autofit/analysis/test_latent_variables.py
+++ b/test_autofit/analysis/test_latent_variables.py
@@ -2,16 +2,18 @@ import pytest
 import numpy as np
 
 import autofit as af
-from autofit import DirectoryPaths
+from autofit import DirectoryPaths, Samples
 from autofit.exc import SamplesException
 from autofit.non_linear.analysis.latent_variables import LatentVariables
 
 
 class Analysis(af.Analysis):
     def log_likelihood_function(self, instance):
-        self.save_latent_variables(centre=instance.centre)
         return 1.0
 
+    def compute_latent_variable(self, instance):
+        return {"fwhm": instance.fwhm}
+
 
 def test_latent_variables():
     latent_variables = LatentVariables()
@@ -45,16 +47,6 @@ def test_split_addition():
         latent_variables.add(intensity=2.0)
 
 
-def test_analysis_latent_variables():
-    analysis = Analysis()
-    instance = af.Gaussian()
-    analysis.log_likelihood_function(instance=instance)
-
-    latent_variables = analysis.latent_variables
-    assert latent_variables.names == ["centre"]
-    assert latent_variables.values == [[instance.centre]]
-
-
 @pytest.fixture(name="latent_variables")
 def make_latent_variables():
     return LatentVariables(names=["centre"], values=[[1.0]])
@@ -84,7 +76,8 @@ class MockSamples:
 def test_set_database_paths(session, latent_variables):
     database_paths = af.DatabasePaths(session)
     database_paths.save_latent_variables(
-        latent_variables=latent_variables, samples=MockSamples()
+        latent_variables=latent_variables,
+        samples=MockSamples(),
     )
     loaded = database_paths.load_latent_variables()
     assert loaded.names == ["centre"]
@@ -100,3 +93,25 @@ def test_iter(latent_variables):
 def test_minimise(latent_variables):
     latent_variables.minimise(0)
     assert latent_variables.values == [[1.0]]
+
+
+def test_compute_all_latent_variables():
+    analysis = Analysis()
+    latent_variables = analysis.compute_all_latent_variables(
+        Samples(
+            model=af.Model(af.Gaussian),
+            sample_list=[
+                af.Sample(
+                    log_likelihood=1.0,
+                    log_prior=0.0,
+                    weight=1.0,
+                    kwargs={
+                        "centre": 1.0,
+                        "normalization": 2.0,
+                        "sigma": 3.0,
+                    },
+                )
+            ],
+        ),
+    )
+    assert latent_variables.names == ["fwhm"]
diff --git a/test_autofit/non_linear/test_analysis.py b/test_autofit/non_linear/test_analysis.py
index 2d2b95199..f3f962381 100644
--- a/test_autofit/non_linear/test_analysis.py
+++ b/test_autofit/non_linear/test_analysis.py
@@ -100,7 +100,12 @@ def test_make_result():
     analysis_1 = Analysis()
     analysis_2 = Analysis()
 
-    result = (analysis_1 + analysis_2).make_result(samples=None)
+    result = (analysis_1 + analysis_2).make_result(
+        samples=af.Samples(
+            model=af.Model(af.Gaussian),
+            sample_list=[],
+        )
+    )
 
     assert len(result) == 2