Repository: rhayes777/PyAutoFit (python)

Issue:
GirdSearchResult Redesign
A non-linear search of searches outputs a `GridSearchResult` as a .pickle file here:

```
        def save_results():
            self.paths.save_object(
                "result",
                builder()
            )
```

The results are output to a dedicated `files` folder in the grid search:

![image](https://github.com/rhayes777/PyAutoFit/assets/23455639/ff6641d6-6c10-401b-ac2b-d19fb868d1c9)

We should update this so that all quantities are output to human readable formats (e.g. .json).

**Reducing File Size**

An issue with the `GridSearchResult` is that it includes the list of `Result` objects for every cell in a grid search. This includes attributes which take up a lot of disk space (e.g. the `Samples` object).

For picking / output they take up too much disk space (and are accessible via the aggregator anyway).

However, certain `GridSearchResult` properties use attributes in the `results`. However, it only uses information contained in the `SamplesSummary` object. 

Therefore, can we also refactor the `GridSearchResult` to not take as input `results` but instead a `samples_summary_list`.

**Integration Test**

The following script has assertions which include attributes which use the `Samples`, so should help fixing the code:

https://github.com/Jammy2211/autofit_workspace_test/blob/main/scripts/database/scrape/grid_search.py






Pull request description:
feature/grid search result
Resolves #855

GridSearchResult now comprises SamplesSummaries
GridSearchResult is persisted as a JSON


Patch:
diff --git a/autofit/non_linear/grid/grid_search/__init__.py b/autofit/non_linear/grid/grid_search/__init__.py
index 6c065298b..0c2a5829e 100644
--- a/autofit/non_linear/grid/grid_search/__init__.py
+++ b/autofit/non_linear/grid/grid_search/__init__.py
@@ -5,6 +5,7 @@ import os
 from os import path
 from typing import List, Tuple, Union, Type, Optional, Dict
 
+from autoconf.dictable import to_dict
 from autofit import exc
 from autofit.mapper import prior as p
 from autofit.non_linear.search.abstract_search import NonLinearSearch
@@ -228,7 +229,7 @@ class GridSearch:
         self.save_metadata()
 
         def save_results():
-            self.paths.save_object("result", builder())
+            self.paths.save_json("result", to_dict(builder()))
 
         def write_results():
             self.logger.debug("Writing results")
diff --git a/autofit/non_linear/grid/grid_search/result.py b/autofit/non_linear/grid/grid_search/result.py
index 6a809dde5..39023ede1 100644
--- a/autofit/non_linear/grid/grid_search/result.py
+++ b/autofit/non_linear/grid/grid_search/result.py
@@ -5,31 +5,31 @@ import numpy as np
 from autofit import exc
 from autofit.mapper import model_mapper as mm
 from autofit.mapper.prior.abstract import Prior
-from autofit.non_linear.result import Result, Placeholder
+
+from autofit.non_linear.samples.interface import SamplesInterface
 
 LimitLists = List[List[float]]
 
 
 class GridSearchResult:
-
     def __init__(
-            self,
-            results: List[Result],
-            lower_limits_lists: LimitLists,
-            grid_priors: List[Prior]
+        self,
+        samples: List[SamplesInterface],
+        lower_limits_lists: LimitLists,
+        grid_priors: List[Prior],
     ):
         """
-        The result of a grid search.
+        The sample of a grid search.
 
         Parameters
         ----------
-        results
-            The results of the non linear optimizations performed at each grid step
+        samples
+            The samples of the non linear optimizations performed at each grid step
         lower_limits_lists
             A list of lists of values representing the lower bounds of the grid searched values at each step
         """
         self.lower_limits_lists = lower_limits_lists
-        self.results = results
+        self.samples = samples
         self.no_dimensions = len(self.lower_limits_lists[0])
         self.no_steps = len(self.lower_limits_lists)
         self.side_length = int(self.no_steps ** (1 / self.no_dimensions))
@@ -41,27 +41,21 @@ class GridSearchResult:
         """
         The lower physical values for each grid square
         """
-        return self._physical_values_for(
-            self.lower_limits_lists
-        )
+        return self._physical_values_for(self.lower_limits_lists)
 
     @property
     def physical_centres_lists(self) -> LimitLists:
         """
         The middle physical values for each grid square
         """
-        return self._physical_values_for(
-            self.centres_lists
-        )
+        return self._physical_values_for(self.centres_lists)
 
     @property
     def physical_upper_limits_lists(self) -> LimitLists:
         """
         The upper physical values for each grid square
         """
-        return self._physical_values_for(
-            self.upper_limits_lists
-        )
+        return self._physical_values_for(self.upper_limits_lists)
 
     @property
     def upper_limits_lists(self) -> LimitLists:
@@ -69,10 +63,7 @@ class GridSearchResult:
         The upper values for each grid square
         """
         return [
-            [
-                limit + self.step_size
-                for limit in limits
-            ]
+            [limit + self.step_size for limit in limits]
             for limits in self.lower_limits_lists
         ]
 
@@ -82,21 +73,13 @@ class GridSearchResult:
         The centre values for each grid square
         """
         return [
-            [
-                (upper + lower) / 2
-                for upper, lower
-                in zip(upper_limits, lower_limits)
-            ]
+            [(upper + lower) / 2 for upper, lower in zip(upper_limits, lower_limits)]
             for upper_limits, lower_limits in zip(
-                self.lower_limits_lists,
-                self.upper_limits_lists
+                self.lower_limits_lists, self.upper_limits_lists
             )
         ]
 
-    def _physical_values_for(
-            self,
-            unit_lists: LimitLists
-    ) -> LimitLists:
+    def _physical_values_for(self, unit_lists: LimitLists) -> LimitLists:
         """
         Compute physical values for lists of lists of unit hypercube
         values.
@@ -111,16 +94,7 @@ class GridSearchResult:
         A list of lists of physical values
         """
         return [
-            [
-                prior.value_for(
-                    limit
-                )
-                for prior, limit in
-                zip(
-                    self.grid_priors,
-                    limits
-                )
-            ]
+            [prior.value_for(limit) for prior, limit in zip(self.grid_priors, limits)]
             for limits in unit_lists
         ]
 
@@ -132,29 +106,28 @@ class GridSearchResult:
 
     def __getattr__(self, item: str) -> object:
         """
-        We default to getting attributes from the best result. This allows promises to reference best results.
+        We default to getting attributes from the best sample. This allows promises to reference best samples.
         """
-        return getattr(self.best_result, item)
+        return getattr(self.best_samples, item)
 
     @property
     def shape(self):
         return self.no_dimensions * (int(self.no_steps ** (1 / self.no_dimensions)),)
 
     @property
-    def best_result(self):
+    def best_samples(self):
         """
-        The best result of the grid search. That is, the result output by the non linear search that had the highest
+        The best sample of the grid search. That is, the sample output by the non linear search that had the highest
         maximum figure of merit.
 
         Returns
         -------
-        best_result: Result
+        best_sample: sample
         """
-        best_result = Placeholder()
-        for result in self.results:
-            if result > best_result:
-                best_result = result
-        return best_result
+        return max(
+            self.samples,
+            key=lambda sample: sample.log_likelihood,
+        )
 
     @property
     def best_model(self):
@@ -164,7 +137,7 @@ class GridSearchResult:
         best_model: mm.ModelMapper
             The model mapper instance associated with the highest figure of merit from the grid search
         """
-        return self.best_result.model
+        return self.best_sample.model
 
     @property
     def all_models(self):
@@ -174,17 +147,15 @@ class GridSearchResult:
         all_models: [mm.ModelMapper]
             All model mapper instances used in the grid search
         """
-        return [result.model for result in self.results]
+        return [sample.model for sample in self.samples]
 
     @property
     def physical_step_sizes(self):
-
         physical_step_sizes = []
 
         # TODO : Make this work for all dimensions in a less ugly way.
 
         for dim in range(self.no_dimensions):
-
             values = [value[dim] for value in self.physical_lower_limits_lists]
             diff = [abs(values[n] - values[n - 1]) for n in range(1, len(values))]
 
@@ -203,15 +174,15 @@ class GridSearchResult:
         return np.reshape(np.array(lst), self.shape)
 
     @property
-    def results_native(self):
+    def samples_native(self):
         """
-        The result of every grid search on a NumPy array whose shape is the native dimensions of the grid search.
+        The sample of every grid search on a NumPy array whose shape is the native dimensions of the grid search.
 
         For example, for a 2x2 grid search the shape of the Numpy array is (2,2) and it is numerically ordered such
-        that the first search's result (corresponding to unit priors (0.0, 0.0)) are in the first value (E.g. entry
+        that the first search's sample (corresponding to unit priors (0.0, 0.0)) are in the first value (E.g. entry
         [0, 0]) of the NumPy array.
         """
-        return self._list_to_native(lst=[result for result in self.results])
+        return self._list_to_native(lst=[sample for sample in self.samples])
 
     @property
     def log_likelihoods_native(self):
@@ -223,7 +194,9 @@ class GridSearchResult:
         that the first search's maximum likelihood (corresponding to unit priors (0.0, 0.0)) are in the first
         value (E.g. entry [0, 0]) of the NumPy array.
         """
-        return self._list_to_native(lst=[result.log_likelihood for result in self.results])
+        return self._list_to_native(
+            lst=[sample.log_likelihood for sample in self.samples]
+        )
 
     @property
     def log_evidences_native(self):
@@ -234,4 +207,6 @@ class GridSearchResult:
         that the first search's log evidence (corresponding to unit priors (0.0, 0.0)) are in the first value (E.g.
         entry [0, 0]) of the NumPy array.
         """
-        return self._list_to_native(lst=[result.samples.log_evidence for result in self.results])
+        return self._list_to_native(
+            lst=[samples.log_evidence for samples in self.samples]
+        )
diff --git a/autofit/non_linear/grid/grid_search/result_builder.py b/autofit/non_linear/grid/grid_search/result_builder.py
index 48c3cf941..5edefa021 100644
--- a/autofit/non_linear/grid/grid_search/result_builder.py
+++ b/autofit/non_linear/grid/grid_search/result_builder.py
@@ -1,5 +1,6 @@
 from typing import List, Union
 
+from autofit.non_linear.samples import Samples
 from autofit.database import Prior
 from autofit.non_linear.result import Result, Placeholder
 from .job import JobResult
@@ -29,7 +30,15 @@ class ResultBuilder:
         Generate a GridSearchResult with all results so far and placeholders
         where no result has been returned yet
         """
-        return GridSearchResult(self.results, self.lists, self.grid_priors)
+        return GridSearchResult(self.sample_summaries, self.lists, self.grid_priors)
+
+    @property
+    def sample_summaries(self) -> List[Union[Samples, Placeholder]]:
+        """
+        A list of results that have been returned with placeholders where no
+        result has been returned in the grid-search order.
+        """
+        return [samples.summary() for samples in self.samples]
 
     @property
     def results(self) -> List[Union[Result, Placeholder]]:
@@ -37,19 +46,31 @@ class ResultBuilder:
         A list of results that have been returned with placeholders where no
         result has been returned in the grid-search order.
         """
-        results = []
+        return [
+            samples
+            if isinstance(samples, Placeholder)
+            else Result(
+                samples=samples,
+            )
+            for samples in self.samples
+        ]
+
+    @property
+    def samples(self) -> List[Union[Samples, Placeholder]]:
+        """
+        A list of results that have been returned with placeholders where no
+        result has been returned in the grid-search order.
+        """
+        samples = []
         for number in range(len(self.lists)):
             try:
                 job_result = self._job_result_dict[number]
-                result = job_result.result
-                results.append(
-                    Result(
-                        samples=job_result.result.samples,
-                    )
+                samples.append(
+                    job_result.result.samples,
                 )
             except KeyError:
-                results.append(Placeholder())
-        return results
+                samples.append(Placeholder())
+        return samples
 
     def add(self, job_result: JobResult):
         """
diff --git a/autofit/non_linear/grid/sensitivity.py b/autofit/non_linear/grid/sensitivity.py
index 759cc040d..3996620f9 100644
--- a/autofit/non_linear/grid/sensitivity.py
+++ b/autofit/non_linear/grid/sensitivity.py
@@ -7,6 +7,7 @@ from pathlib import Path
 from typing import List, Generator, Callable, ClassVar, Type, Union, Tuple
 
 from autoconf import cached_property
+from autoconf.dictable import to_dict
 from autofit.mapper.model import ModelInstance
 from autofit.mapper.prior_model.abstract import AbstractPriorModel
 from autofit.non_linear.search.abstract_search import NonLinearSearch
@@ -305,7 +306,7 @@ class Sensitivity:
 
         result = SensitivityResult(results)
 
-        self.search.paths.save_object("result", result)
+        self.search.paths.save_json("result", to_dict(result))
 
         return SensitivityResult(results)
 
diff --git a/autofit/non_linear/mock/mock_result.py b/autofit/non_linear/mock/mock_result.py
index 6aea7569e..a8416849a 100644
--- a/autofit/non_linear/mock/mock_result.py
+++ b/autofit/non_linear/mock/mock_result.py
@@ -47,3 +47,7 @@ class MockResultGrid(Result):
     @property
     def log_likelihood(self):
         return self._log_likelihood
+
+    @property
+    def best_model(self):
+        return self.model
diff --git a/autofit/non_linear/result.py b/autofit/non_linear/result.py
index 9be8f991f..d63d1bf3e 100644
--- a/autofit/non_linear/result.py
+++ b/autofit/non_linear/result.py
@@ -1,3 +1,4 @@
+import logging
 from abc import ABC, abstractmethod
 import numpy as np
 
@@ -32,14 +33,18 @@ class Placeholder:
     def samples(self):
         return self
 
+    @property
+    def log_likelihood(self):
+        return -np.inf
+
+    def summary(self):
+        return self
 
-class AbstractResult(ABC):
-    def __init__(self):
-        self._instance = None
 
+class AbstractResult(ABC):
     @property
     def sigma(self):
-        return conf.instance["general"]["prior_passer"]["sigma"]
+        return self.samples.sigma
 
     @property
     @abstractmethod
@@ -83,9 +88,11 @@ class AbstractResult(ABC):
 
     @property
     def instance(self):
-        if self._instance is None:
-            self._instance = self.samples.max_log_likelihood()
-        return self._instance
+        try:
+            return self.samples.instance
+        except AttributeError as e:
+            logging.warning(e)
+            return None
 
     @property
     def max_log_likelihood_instance(self):
@@ -103,9 +110,7 @@ class AbstractResult(ABC):
         A model mapper created by taking results from this search and creating priors with the defined absolute
         width.
         """
-        return self.model.mapper_from_gaussian_tuples(
-            self.samples.gaussian_priors_at_sigma(sigma=self.sigma), a=a
-        )
+        return self.samples.model_absolute(a)
 
     def model_relative(self, r: float) -> AbstractPriorModel:
         """
@@ -119,9 +124,7 @@ class AbstractResult(ABC):
         A model mapper created by taking results from this search and creating priors with the defined relative
         width.
         """
-        return self.model.mapper_from_gaussian_tuples(
-            self.samples.gaussian_priors_at_sigma(sigma=self.sigma), r=r
-        )
+        return self.samples.model_relative(r)
 
 
 class Result(AbstractResult):
@@ -142,8 +145,6 @@ class Result(AbstractResult):
         samples
             The samples of the non-linear search
         """
-        super().__init__()
-
         self._samples = samples
 
         self.__model = None
diff --git a/autofit/non_linear/samples/interface.py b/autofit/non_linear/samples/interface.py
index f17ecc3d0..70ec1cd2a 100644
--- a/autofit/non_linear/samples/interface.py
+++ b/autofit/non_linear/samples/interface.py
@@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
 from functools import wraps
 from typing import Union, List, Tuple
 
+from autofit import conf
 from autofit.mapper.model import ModelInstance
 from autofit.mapper.prior_model.abstract import AbstractPriorModel
 from autofit.mapper.prior_model.abstract import Path
@@ -33,6 +34,13 @@ class SamplesInterface(ABC):
 
         self._paths = None
         self._names = None
+        self._instance = None
+
+    @property
+    def instance(self):
+        if self._instance is None:
+            self._instance = self.max_log_likelihood()
+        return self._instance
 
     @property
     def paths(self) -> List[Tuple[Path]]:
@@ -75,3 +83,68 @@ class SamplesInterface(ABC):
     @abstractmethod
     def max_log_likelihood_sample(self):
         pass
+
+    @property
+    def log_likelihood(self):
+        return self.max_log_likelihood_sample.log_likelihood
+
+    @property
+    @abstractmethod
+    def log_evidence(self):
+        pass
+
+    def model_absolute(self, a: float) -> AbstractPriorModel:
+        """
+        Parameters
+        ----------
+        a
+            The absolute width of gaussian priors
+
+        Returns
+        -------
+        A model mapper created by taking results from this search and creating priors with the defined absolute
+        width.
+        """
+        return self.model.mapper_from_gaussian_tuples(
+            self.gaussian_priors_at_sigma(sigma=self.sigma), a=a
+        )
+
+    def model_relative(self, r: float) -> AbstractPriorModel:
+        """
+        Parameters
+        ----------
+        r
+            The relative width of gaussian priors
+
+        Returns
+        -------
+        A model mapper created by taking results from this search and creating priors with the defined relative
+        width.
+        """
+        return self.model.mapper_from_gaussian_tuples(
+            self.gaussian_priors_at_sigma(sigma=self.sigma), r=r
+        )
+
+    @property
+    def sigma(self):
+        return conf.instance["general"]["prior_passer"]["sigma"]
+
+    def gaussian_priors_at_sigma(self, sigma: float) -> [List]:
+        """
+        `GaussianPrior`s of every parameter used to link its inferred values and errors to priors used to sample the
+        same (or similar) parameters in a subsequent search, where:
+
+        - The mean is given by maximum log likelihood model values.
+        - Their errors are omitted, as this information is not available from an search. When these priors are
+          used to link to another search, it will thus automatically use the prior config values.
+
+        Parameters
+        ----------
+        sigma
+            The sigma limit within which the PDF is used to estimate errors (e.g. sigma = 1.0 uses 0.6826 of the PDF).
+        """
+        return list(
+            map(
+                lambda vector: (vector, 0.0), self.max_log_likelihood(as_instance=False)
+            )
+        )
diff --git a/autofit/non_linear/samples/pdf.py b/autofit/non_linear/samples/pdf.py
index 4022cce0f..fc85f4148 100644
--- a/autofit/non_linear/samples/pdf.py
+++ b/autofit/non_linear/samples/pdf.py
@@ -68,6 +68,7 @@ class SamplesPDF(Samples):
             max_log_likelihood_sample=self.max_log_likelihood_sample,
             model=self.model,
             covariance_matrix=covariance_matrix,
+            log_evidence=self.log_evidence,
         )
 
     @classmethod
@@ -422,6 +423,7 @@ class SamplesPDF(Samples):
     def log_evidence(self):
         return None
 
+
 def marginalize(
     parameter_list: List, sigma: float, weight_list: Optional[List] = None
 ) -> Tuple[float, float, float]:
@@ -483,4 +485,3 @@ def quantile(x, q, weights=None):
         cdf /= cdf[-1]
         cdf = np.append(0, cdf)
         return np.interp(q, cdf, x[idx]).tolist()
-
diff --git a/autofit/non_linear/samples/samples.py b/autofit/non_linear/samples/samples.py
index 3b0f8df76..e2c7cf134 100644
--- a/autofit/non_linear/samples/samples.py
+++ b/autofit/non_linear/samples/samples.py
@@ -217,6 +217,10 @@ class Samples(SamplesInterface, ABC):
         self.samples_info = samples_info
         self.search_internal = search_internal
 
+    @property
+    def log_evidence(self):
+        return None
+
     @classmethod
     def from_csv(cls, paths, model: AbstractPriorModel):
         """
@@ -643,23 +647,3 @@ class Samples(SamplesInterface, ABC):
 
         copied.sample_list = [sample.subsample(path_map) for sample in self.sample_list]
         return copied
-
-    def gaussian_priors_at_sigma(self, sigma: float) -> [List]:
-        """
-        `GaussianPrior`s of every parameter used to link its inferred values and errors to priors used to sample the
-        same (or similar) parameters in a subsequent search, where:
-
-        - The mean is given by maximum log likelihood model values.
-        - Their errors are omitted, as this information is not available from an search. When these priors are
-          used to link to another search, it will thus automatically use the prior config values.
-
-        Parameters
-        ----------
-        sigma
-            The sigma limit within which the PDF is used to estimate errors (e.g. sigma = 1.0 uses 0.6826 of the PDF).
-        """
-        return list(
-            map(
-                lambda vector: (vector, 0.0), self.max_log_likelihood(as_instance=False)
-            )
-        )
diff --git a/autofit/non_linear/samples/summary.py b/autofit/non_linear/samples/summary.py
index 871269647..c34e248e3 100644
--- a/autofit/non_linear/samples/summary.py
+++ b/autofit/non_linear/samples/summary.py
@@ -1,3 +1,5 @@
+from typing import Optional
+
 import numpy as np
 from .interface import SamplesInterface
 from autofit.mapper.prior_model.abstract import AbstractPriorModel
@@ -9,7 +11,8 @@ class SamplesSummary(SamplesInterface):
         self,
         max_log_likelihood_sample: Sample,
         model: AbstractPriorModel,
-        covariance_matrix: np.ndarray = None,
+        covariance_matrix: Optional[np.ndarray] = None,
+        log_evidence: Optional[float] = None,
     ):
         """
         A summary of the results of a `NonLinearSearch` that has been run, including the maximum log likelihood
@@ -26,6 +29,7 @@ class SamplesSummary(SamplesInterface):
         super().__init__(model=model)
         self._max_log_likelihood_sample = max_log_likelihood_sample
         self._covariance_matrix = covariance_matrix
+        self._log_evidence = log_evidence
 
     def covariance_matrix(self) -> np.ndarray:
         return self._covariance_matrix
@@ -33,3 +37,7 @@ class SamplesSummary(SamplesInterface):
     @property
     def max_log_likelihood_sample(self):
         return self._max_log_likelihood_sample
+
+    @property
+    def log_evidence(self):
+        return self._log_evidence
diff --git a/docs/installation/overview.rst b/docs/installation/overview.rst
index c0c2c47d8..4607be4f0 100644
--- a/docs/installation/overview.rst
+++ b/docs/installation/overview.rst
@@ -3,7 +3,7 @@
 Overview
 ========
 
-**PyAutoFit** requires Python 3.8 - 3.11 and support the Linux, MacOS and Windows operating systems.
+**PyAutoFit** requires Python 3.8 - 3.10 and support the Linux, MacOS and Windows operating systems.
 
 **PyAutoFit** can be installed via the Python distribution `Anaconda <https://www.anaconda.com/>`_ or using
 `Pypi <https://pypi.org/>`_ to ``pip install`` **PyAutoFit** into your Python distribution.
diff --git a/paper/paper.md b/paper/paper.md
index 14b5fec55..b4a57e17a 100644
--- a/paper/paper.md
+++ b/paper/paper.md
@@ -137,7 +137,7 @@ taken without a local `PyAutoFit` installation.
 
 # Software Citations
 
-`PyAutoFit` is written in Python 3.8 - 3.11 [@python] and uses the following software packages:
+`PyAutoFit` is written in Python 3.8 - 3.10 [@python] and uses the following software packages:
 
 - `corner.py` https://github.com/dfm/corner.py [@corner]
 - `dynesty` https://github.com/joshspeagle/dynesty [@dynesty]


Test patch:
diff --git a/test_autofit/non_linear/grid/test_optimizer_grid_search.py b/test_autofit/non_linear/grid/test_optimizer_grid_search.py
index f830f2fb6..e4b21e4bc 100644
--- a/test_autofit/non_linear/grid/test_optimizer_grid_search.py
+++ b/test_autofit/non_linear/grid/test_optimizer_grid_search.py
@@ -11,7 +11,7 @@ from autofit import exc
 def test_unpickle_result():
     # noinspection PyTypeChecker
     result = af.GridSearchResult(
-        results=[af.Result(samples=None)],
+        samples=[af.Samples(model=af.Model(af.Gaussian), sample_list=[])],
         lower_limits_lists=[[1]],
         grid_priors=[],
     )
@@ -187,6 +187,10 @@ def test_csv_headers(grid_search_10_result, sample_name_paths):
     ]
 
 
+def test_output_result_json(grid_search_10_result, sample_name_paths):
+    assert isinstance(sample_name_paths.load_json("result"), dict)
+
+
 class TestGridNLOBehaviour:
     def test_results(self, grid_search_05, mapper):
         result = grid_search_05.fit(
@@ -198,11 +202,11 @@ class TestGridNLOBehaviour:
             ],
         )
 
-        assert len(result.results) == 4
+        assert len(result.samples) == 4
         assert result.no_dimensions == 2
 
     def test_results_10(self, grid_search_10_result):
-        assert len(grid_search_10_result.results) == 100
+        assert len(grid_search_10_result.samples) == 100
         assert grid_search_10_result.no_dimensions == 2
         assert grid_search_10_result.log_likelihoods_native.shape == (10, 10)
 
@@ -227,13 +231,13 @@ def make_grid_search_result():
 
     # noinspection PyTypeChecker
     return af.GridSearchResult(
-        results=[one, two], lower_limits_lists=[[1], [2]], grid_priors=[[1], [2]]
+        samples=[one, two], lower_limits_lists=[[1], [2]], grid_priors=[[1], [2]]
     )
 
 
 class TestGridSearchResult:
     def test_best_result(self, grid_search_result):
-        assert grid_search_result.best_result.log_likelihood == 2
+        assert grid_search_result.best_samples.log_likelihood == 2
 
     def test_attributes(self, grid_search_result):
         assert grid_search_result.model == 2
@@ -249,7 +253,7 @@ class TestGridSearchResult:
 
         # noinspection PyTypeChecker
         grid_search_result = af.GridSearchResult(
-            results=None,
+            samples=None,
             grid_priors=[
                 af.UniformPrior(lower_limit=-2.0, upper_limit=2.0),
                 af.UniformPrior(lower_limit=-3.0, upper_limit=3.0),
@@ -274,10 +278,10 @@ class TestGridSearchResult:
 
     def test__results_on_native_grid(self, grid_search_result):
         assert (
-            grid_search_result.results_native
+            grid_search_result.samples_native
             == np.array(
                 [
-                    [grid_search_result.results[0], grid_search_result.results[1]],
+                    [grid_search_result.samples[0], grid_search_result.samples[1]],
                 ]
             )
         ).all()
@@ -307,25 +311,23 @@ def test_higher_dimensions(n_dimensions, n_steps):
     total = n_steps**n_dimensions
     model = af.Model(af.Gaussian)
     result = af.GridSearchResult(
-        results=total
+        samples=total
         * [
-            af.Result(
-                af.SamplesPDF(
-                    model,
-                    [
-                        af.Sample(
-                            1.0,
-                            1.0,
-                            1.0,
-                            {"centre": 1.0, "sigma": 1.0, "normalization": 1.0},
-                        )
-                    ],
-                ),
-            )
+            af.SamplesPDF(
+                model,
+                [
+                    af.Sample(
+                        1.0,
+                        1.0,
+                        1.0,
+                        {"centre": 1.0, "sigma": 1.0, "normalization": 1.0},
+                    )
+                ],
+            ),
         ],
         grid_priors=[],
         lower_limits_lists=total * [n_dimensions * [0.0]],
     )
     assert result.shape == shape
-    assert result.results_native.shape == shape
+    assert result.samples_native.shape == shape
     assert result.log_likelihoods_native.shape == shape
diff --git a/test_autofit/non_linear/grid/test_result.py b/test_autofit/non_linear/grid/test_result.py
index a0db8b99f..3745c8cce 100644
--- a/test_autofit/non_linear/grid/test_result.py
+++ b/test_autofit/non_linear/grid/test_result.py
@@ -3,30 +3,17 @@ import pytest
 import autofit as af
 
 
-@pytest.fixture(
-    name="model"
-)
+@pytest.fixture(name="model")
 def make_model():
     return af.Model(
-        af.Gaussian,
-        centre=af.UniformPrior(
-            lower_limit=0.0,
-            upper_limit=1.0
-        )
+        af.Gaussian, centre=af.UniformPrior(lower_limit=0.0, upper_limit=1.0)
     )
 
 
-@pytest.fixture(
-    name="result"
-)
+@pytest.fixture(name="result")
 def make_result(model):
     return af.GridSearchResult(
-        results=[],
-        lower_limits_lists=[
-            [0.0],
-            [0.5]
-        ],
-        grid_priors=[model.centre]
+        samples=[], lower_limits_lists=[[0.0], [0.5]], grid_priors=[model.centre]
     )
 
 
@@ -36,58 +23,38 @@ def make_result(model):
         (1.0, 0.5),
         (2.0, 1.0),
         (4.0, 2.0),
-    ]
+    ],
 )
-def test_physical_lower_limits(
-        upper_limit,
-        physical_value,
-        model,
-        result
-):
-    result.grid_priors = [
-        af.UniformPrior(
-            lower_limit=0.0,
-            upper_limit=upper_limit
-        )
-    ]
-    assert result.physical_lower_limits_lists == [
-        [0.0],
-        [physical_value]
-    ]
+def test_physical_lower_limits(upper_limit, physical_value, model, result):
+    result.grid_priors = [af.UniformPrior(lower_limit=0.0, upper_limit=upper_limit)]
+    assert result.physical_lower_limits_lists == [[0.0], [physical_value]]
 
 
 def test_limits_lists(result):
-    assert result.lower_limits_lists == [
-        [0.0], [0.5]
-    ]
-    assert result.upper_limits_lists == [
-        [0.5], [1.0]
-    ]
+    assert result.lower_limits_lists == [[0.0], [0.5]]
+    assert result.upper_limits_lists == [[0.5], [1.0]]
 
 
-def test_physical_centres_lists(
-        result
-):
-    assert result.physical_centres_lists == [
-        [0.25], [0.75]
-    ]
+def test_physical_centres_lists(result):
+    assert result.physical_centres_lists == [[0.25], [0.75]]
 
 
-def test_physical_upper_limits_lists(
-        result
-):
-    assert result.physical_upper_limits_lists == [
-        [0.5], [1.0]
-    ]
+def test_physical_upper_limits_lists(result):
+    assert result.physical_upper_limits_lists == [[0.5], [1.0]]
 
 
-def test_log_uniform_prior(
-        result
-):
+def test_log_uniform_prior(result):
     result.grid_priors = [af.LogUniformPrior()]
 
-    assert result.physical_lower_limits_lists == [[1e-06], [pytest.approx(0.001, rel=0.01)]]
+    assert result.physical_lower_limits_lists == [
+        [1e-06],
+        [pytest.approx(0.001, rel=0.01)],
+    ]
     assert result.physical_centres_lists == [
-        [pytest.approx(3.1622776601683795e-05, rel=0.01)], [pytest.approx(0.03162277660168379, rel=0.01)]
+        [pytest.approx(3.1622776601683795e-05, rel=0.01)],
+        [pytest.approx(0.03162277660168379, rel=0.01)],
+    ]
+    assert result.physical_upper_limits_lists == [
+        [pytest.approx(0.001, rel=0.01)],
+        [pytest.approx(1.0, rel=0.01)],
     ]
-    assert result.physical_upper_limits_lists == [[pytest.approx(0.001, rel=0.01)], [pytest.approx(1.0, rel=0.01)]]
diff --git a/test_autofit/non_linear/grid/test_result_builder.py b/test_autofit/non_linear/grid/test_result_builder.py
index 5d09a685a..92dc23baf 100644
--- a/test_autofit/non_linear/grid/test_result_builder.py
+++ b/test_autofit/non_linear/grid/test_result_builder.py
@@ -5,38 +5,26 @@ from autofit import Result
 from autofit.non_linear.grid.grid_search import ResultBuilder
 from autofit.non_linear.grid.grid_search.job import JobResult
 from autofit.non_linear.grid.grid_search.result_builder import Placeholder
+from autofit.non_linear.samples.summary import SamplesSummary
 
 
-@pytest.fixture(
-    name="result_builder"
-)
+@pytest.fixture(name="result_builder")
 def make_result_builder():
-    return ResultBuilder(
-        lists=[[1], [2], [3]],
-        grid_priors=[]
-    )
+    return ResultBuilder(lists=[[1], [2], [3]], grid_priors=[])
 
 
-@pytest.fixture(
-    name="add_results"
-)
-def make_add_results(
-        result_builder
-):
+@pytest.fixture(name="add_results")
+def make_add_results(result_builder):
     def add_results(*numbers):
         for number in numbers:
             result_builder.add(
                 JobResult(
                     af.m.MockResult(
-                        search=af.m.MockSearch(
-                            name=str(number)
-                        ),
-                        model=af.Model(
-                            af.Gaussian
-                        )
+                        search=af.m.MockSearch(name=str(number)),
+                        model=af.Model(af.Gaussian),
                     ),
                     [],
-                    number
+                    number,
                 )
             )
 
@@ -46,45 +34,61 @@ def make_add_results(
 @pytest.mark.parametrize(
     "numbers, t1, t2, t3",
     [
-        (tuple(), Placeholder, Placeholder, Placeholder,),
-        ((0,), Result, Placeholder, Placeholder,),
-        ((1,), Placeholder, Result, Placeholder,),
-        ((2,), Placeholder, Placeholder, Result,),
-        ((1, 2,), Placeholder, Result, Result,),
-    ]
+        (
+            tuple(),
+            Placeholder,
+            Placeholder,
+            Placeholder,
+        ),
+        (
+            (0,),
+            SamplesSummary,
+            Placeholder,
+            Placeholder,
+        ),
+        (
+            (1,),
+            Placeholder,
+            SamplesSummary,
+            Placeholder,
+        ),
+        (
+            (2,),
+            Placeholder,
+            Placeholder,
+            SamplesSummary,
+        ),
+        (
+            (
+                1,
+                2,
+            ),
+            Placeholder,
+            SamplesSummary,
+            SamplesSummary,
+        ),
+    ],
 )
-def test_gaps(
-        result_builder,
-        add_results,
-        numbers,
-        t1, t2, t3
-):
+def test_gaps(result_builder, add_results, numbers, t1, t2, t3):
     add_results(*numbers)
     result = result_builder()
 
-    first, second, third = result.results
+    first, second, third = result.samples
     assert isinstance(first, t1)
     assert isinstance(second, t2)
     assert isinstance(third, t3)
 
 
-def test_log_likelihoods(
-        result_builder
-):
-    assert (result_builder().log_likelihoods_native == [
-        None, None, None
-    ]).all()
+def test_log_likelihoods(result_builder):
+    assert (
+        result_builder().log_likelihoods_native
+        == [float("-inf"), float("-inf"), float("-inf")]
+    ).all()
 
 
-def test_best_result(
-        result_builder,
-        add_results
-):
+def test_best_result(result_builder, add_results):
     add_results(1)
-    assert isinstance(
-        result_builder().best_result,
-        Result
-    )
+    assert isinstance(result_builder().best_samples, SamplesSummary)
 
 
 def test_placeholder_samples():
diff --git a/test_autofit/serialise/test_samples.py b/test_autofit/serialise/test_samples.py
index 9bc979642..372f5f7cc 100644
--- a/test_autofit/serialise/test_samples.py
+++ b/test_autofit/serialise/test_samples.py
@@ -62,6 +62,7 @@ def make_summary_dict():
                 "dtype": "float64",
                 "type": "ndarray",
             },
+            "log_evidence": None,
             "max_log_likelihood_sample": {
                 "arguments": {
                     "kwargs": {