Repository: rhayes777/PyAutoFit
Pull request #866 (2023-11-06 14:47:09)

LimitLIst conversion working
`GridSearchResult` now passes most results around via a `LimitList` structure which offers a more concise API for converting results between list and numpy array.

I could not get the use of the `return_limit_list` decorator to work -- could you try and implement it to make the code a little bit more concise?


Patch:
diff --git a/autofit/__init__.py b/autofit/__init__.py
index 13ef92309..6b0483eab 100644
--- a/autofit/__init__.py
+++ b/autofit/__init__.py
@@ -14,6 +14,7 @@ from .graphical.declarative.factor.analysis import AnalysisFactor
 from .graphical.declarative.collection import FactorGraphModel
 from .graphical.declarative.factor.hierarchical import HierarchicalFactor
 from .graphical.laplace import LaplaceOptimiser
+from .non_linear.grid.grid_list import GridList
 from .non_linear.samples import SamplesMCMC
 from .non_linear.samples import SamplesNest
 from .non_linear.samples import Samples
diff --git a/autofit/non_linear/grid/grid_list.py b/autofit/non_linear/grid/grid_list.py
new file mode 100644
index 000000000..1e231b462
--- /dev/null
+++ b/autofit/non_linear/grid/grid_list.py
@@ -0,0 +1,75 @@
+from functools import wraps
+from typing import List, Tuple
+
+import numpy as np
+
+
+def as_grid_list(func):
+    """
+    Wrap functions with a function which converts the output list of grid search results to a `GridList` object.
+
+    Parameters
+    ----------
+    func
+        A function which computes and retrusn a list of grid search results.
+
+    Returns
+    -------
+        A function which converts a list of grid search results to a `GridList` object.
+    """
+
+    @wraps(func)
+    def wrapper(grid_search_result) -> List:
+        """
+        This decorator converts the output of a function which computes a list of grid search results to a `GridList`.
+
+        Parameters
+        ----------
+        grid_search_result
+            The instance of the `GridSearchResult` which is being operated on.
+
+        Returns
+        -------
+            The function output converted to a `GridList`.
+        """
+
+        values = func(grid_search_result)
+
+        return GridList(values=values, shape=grid_search_result.shape)
+
+    return wrapper
+
+
+class GridList(list):
+    def __init__(self, values: List, shape: Tuple):
+        """
+        Many quantities of a `GridSearchResult` are stored as lists of lists.
+
+        The number of lists corresponds to the dimensionality of the grid search and the number of elements
+        in each list corresponds to the number of steps in that grid search dimension.
+
+        This class provides a wrapper around lists of lists to provide some convenience methods for accessing
+        the values in the lists. For example, it provides a conversion of the list of list structure to a ndarray.
+
+        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 entries(corresponding to unit priors (0.0, 0.0)) are in the first
+        value (E.g. entry [0, 0]) of the NumPy array.
+
+        Parameters
+        ----------
+        values
+        """
+        super().__init__(values)
+
+        self.shape = shape
+
+    @property
+    def as_list(self) -> List:
+        return self
+
+    @property
+    def native(self) -> np.ndarray:
+        """
+        The list of lists as an ndarray.
+        """
+        return np.reshape(np.array(self), self.shape)
diff --git a/autofit/non_linear/grid/grid_search/result.py b/autofit/non_linear/grid/grid_search/result.py
index 0bcea44ab..c1e24f790 100644
--- a/autofit/non_linear/grid/grid_search/result.py
+++ b/autofit/non_linear/grid/grid_search/result.py
@@ -1,22 +1,24 @@
-from typing import List, Union, Iterable
+from typing import List, Optional, Union, Iterable
 
 import numpy as np
 
 from autofit import exc
+from autofit.non_linear.search.abstract_search import NonLinearSearch
+from autofit.non_linear.grid.grid_list import GridList, as_grid_list
 from autofit.mapper import model_mapper as mm
 from autofit.mapper.prior.abstract import Prior
 
 from autofit.non_linear.samples.interface import SamplesInterface
 
-LimitLists = List[List[float]]
-
 
+# noinspection PyTypeChecker
 class GridSearchResult:
     def __init__(
         self,
         samples: List[SamplesInterface],
-        lower_limits_lists: LimitLists,
+        lower_limits_lists: Union[List, GridList],
         grid_priors: List[Prior],
+        parent: Optional[NonLinearSearch] = None,
     ):
         """
         The sample of a grid search.
@@ -28,37 +30,44 @@ class GridSearchResult:
         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.samples = samples
-        self.no_dimensions = len(self.lower_limits_lists[0])
-        self.no_steps = len(self.lower_limits_lists)
+        self.no_dimensions = len(lower_limits_lists[0])
+        self.no_steps = len(lower_limits_lists)
+
+        self.lower_limits_lists = GridList(lower_limits_lists, self.shape)
+        self.samples = GridList(samples, self.shape) if samples is not None else None
         self.side_length = int(self.no_steps ** (1 / self.no_dimensions))
         self.step_size = 1 / self.side_length
         self.grid_priors = grid_priors
 
+        self.parent = parent
+
     @property
-    def physical_lower_limits_lists(self) -> LimitLists:
+    @as_grid_list
+    def physical_lower_limits_lists(self) -> GridList:
         """
         The lower physical values for each grid square
         """
         return self._physical_values_for(self.lower_limits_lists)
 
     @property
-    def physical_centres_lists(self) -> LimitLists:
+    @as_grid_list
+    def physical_centres_lists(self) -> GridList:
         """
         The middle physical values for each grid square
         """
         return self._physical_values_for(self.centres_lists)
 
     @property
-    def physical_upper_limits_lists(self) -> LimitLists:
+    @as_grid_list
+    def physical_upper_limits_lists(self) -> GridList:
         """
         The upper physical values for each grid square
         """
         return self._physical_values_for(self.upper_limits_lists)
 
     @property
-    def upper_limits_lists(self) -> LimitLists:
+    @as_grid_list
+    def upper_limits_lists(self) -> GridList:
         """
         The upper values for each grid square
         """
@@ -68,7 +77,8 @@ class GridSearchResult:
         ]
 
     @property
-    def centres_lists(self) -> LimitLists:
+    @as_grid_list
+    def centres_lists(self) -> List:
         """
         The centre values for each grid square
         """
@@ -79,7 +89,7 @@ class GridSearchResult:
             )
         ]
 
-    def _physical_values_for(self, unit_lists: LimitLists) -> LimitLists:
+    def _physical_values_for(self, unit_lists: GridList) -> List:
         """
         Compute physical values for lists of lists of unit hypercube
         values.
@@ -170,21 +180,8 @@ class GridSearchResult:
 
         return tuple(physical_step_sizes)
 
-    def _list_to_native(self, lst: List):
-        return np.reshape(np.array(lst), self.shape)
-
-    @property
-    def samples_native(self):
-        """
-        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 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=[sample for sample in self.samples])
-
-    def attribute_grid(self, attribute_path: Union[str, Iterable[str]]) -> np.ndarray:
+    @as_grid_list
+    def attribute_grid(self, attribute_path: Union[str, Iterable[str]]) -> GridList:
         """
         Get a list of the attribute of the best instance from every search in a numpy array with the native dimensions
         of the grid search.
@@ -208,10 +205,12 @@ class GridSearchResult:
                 attribute = getattr(attribute, attribute_name)
             attribute_list.append(attribute)
 
-        return self._list_to_native(attribute_list)
+        return attribute_list
 
-    @property
-    def log_likelihoods_native(self):
+    @as_grid_list
+    def log_likelihoods(
+        self, relative_to_value: float = 0.0, remove_relative_zeros: bool = False
+    ) -> GridList:
         """
         The maximum log likelihood of every grid search on a NumPy array whose shape is the native dimensions of the
         grid search.
@@ -219,20 +218,48 @@ class GridSearchResult:
         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 maximum likelihood (corresponding to unit priors (0.0, 0.0)) are in the first
         value (E.g. entry [0, 0]) of the NumPy array.
+
+        Parameters
+        ----------
+        relative_to_value
+            The value to subtract from every log likelihood, for example if Bayesian model comparison is performed
+            on the grid search and the subtracted value is the maximum log likelihood of a previous search.
         """
-        return self._list_to_native(
-            lst=[sample.log_likelihood for sample in self.samples]
-        )
+        return [sample.log_likelihood - relative_to_value for sample in self.samples]
 
-    @property
-    def log_evidences_native(self):
+    @as_grid_list
+    def log_evidences(self, relative_to_value: float = 0.0) -> GridList:
         """
-        The log evidence of every grid search on a NumPy array whose shape is the native dimensions of the grid search.
+        The maximum log evidence 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 log evidence (corresponding to unit priors (0.0, 0.0)) are in the first value (E.g.
-        entry [0, 0]) of the NumPy array.
+        that the first search's maximum evidence (corresponding to unit priors (0.0, 0.0)) are in the first
+        value (E.g. entry [0, 0]) of the NumPy array.
+
+        Parameters
+        ----------
+        relative_to_value
+            The value to subtract from every log likelihood, for example if Bayesian model comparison is performed
+            on the grid search and the subtracted value is the maximum log likelihood of a previous search.
         """
-        return self._list_to_native(
-            lst=[samples.log_evidence for samples in self.samples]
-        )
+        return [sample.log_evidence - relative_to_value for sample in self.samples]
+
+    def figure_of_merits(
+        self, use_log_evidences: bool, relative_to_value: float = 0.0
+    ) -> GridList:
+        """
+        Convenience method to get either the log likelihoods or log evidences of the grid search.
+
+        Parameters
+        ----------
+        use_log_evidences
+            If true, the log evidences are returned, otherwise the log likelihoods are returned.
+        relative_to_value
+            The value to subtract from every log likelihood, for example if Bayesian model comparison is performed
+            on the grid search and the subtracted value is the maximum log likelihood of a previous search.
+        """
+
+        if use_log_evidences:
+            return self.log_evidences(relative_to_value)
+        return self.log_likelihoods(relative_to_value)
diff --git a/docs/cookbooks/analysis.rst b/docs/cookbooks/analysis.rst
index 31e21795b..43d65188e 100644
--- a/docs/cookbooks/analysis.rst
+++ b/docs/cookbooks/analysis.rst
@@ -4,7 +4,7 @@ Analysis
 ========
 
 The ``Analysis`` class is the interface between the data and model, whereby a ``log_likelihood_function`` is defined
-and called by the non-linear search fitting to fit the model.
+and called by the non-linear search to fit the model.
 
 This cookbook provides an overview of how to use and extend ``Analysis`` objects in **PyAutoFit**.
 


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 922acc979..c407c6422 100644
--- a/test_autofit/non_linear/grid/test_optimizer_grid_search.py
+++ b/test_autofit/non_linear/grid/test_optimizer_grid_search.py
@@ -186,7 +186,7 @@ class TestGridNLOBehaviour:
     def test_results_10(self, grid_search_10_result):
         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)
+        assert grid_search_10_result.log_likelihoods().native.shape == (10, 10)
 
     def test_passes_attributes(self):
         search = af.DynestyStatic()
@@ -256,7 +256,7 @@ class TestGridSearchResult:
 
     def test__results_on_native_grid(self, grid_search_result):
         assert (
-            grid_search_result.samples_native
+            grid_search_result.samples.native
             == np.array(
                 [
                     [grid_search_result.samples[0], grid_search_result.samples[1]],
@@ -265,7 +265,7 @@ class TestGridSearchResult:
         ).all()
 
         assert (
-            grid_search_result.log_likelihoods_native
+            grid_search_result.log_likelihoods().native
             == np.array(
                 [
                     [1, 2],
@@ -306,6 +306,7 @@ def test_higher_dimensions(n_dimensions, n_steps):
         grid_priors=[],
         lower_limits_lists=total * [n_dimensions * [0.0]],
     )
+
     assert result.shape == shape
-    assert result.samples_native.shape == shape
-    assert result.log_likelihoods_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 9eede1dd9..253357a11 100644
--- a/test_autofit/non_linear/grid/test_result.py
+++ b/test_autofit/non_linear/grid/test_result.py
@@ -55,7 +55,7 @@ def make_result(model, samples_1, samples_2):
 
 
 def test_instance_attribute_from_path(result):
-    assert (result.attribute_grid("centre") == [1.0, 2.0]).all()
+    assert (result.attribute_grid("centre").native == [1.0, 2.0]).all()
 
 
 def test_higher_dimension_instance_attributes(model, samples_1, samples_2):
@@ -64,7 +64,7 @@ def test_higher_dimension_instance_attributes(model, samples_1, samples_2):
         lower_limits_lists=[[0.0, 0.0], [0.0, 0.5], [0.5, 0.0], [0.5, 0.5]],
         grid_priors=[model.centre, model.normalization],
     )
-    assert (result.attribute_grid("centre") == [[1.0, 2.0], [1.0, 2.0]]).all()
+    assert (result.attribute_grid("centre").native == [[1.0, 2.0], [1.0, 2.0]]).all()
 
 
 @pytest.fixture(name="deep_result")
@@ -96,12 +96,12 @@ def make_deep_result(model, samples_1, samples_2):
 
 
 def test_paths(deep_result):
-    assert (deep_result.attribute_grid("gaussian.centre") == [1.0, 1.0]).all()
+    assert (deep_result.attribute_grid("gaussian.centre").native == [1.0, 1.0]).all()
 
 
 def test_instances(deep_result):
     assert (
-        deep_result.attribute_grid("gaussian")
+        deep_result.attribute_grid("gaussian").native
         == [af.Gaussian(1.0, 2.0, 3.0), af.Gaussian(1.0, 2.0, 3.0)]
     ).all()
 
diff --git a/test_autofit/non_linear/grid/test_result_builder.py b/test_autofit/non_linear/grid/test_result_builder.py
index 92dc23baf..97cf9bec6 100644
--- a/test_autofit/non_linear/grid/test_result_builder.py
+++ b/test_autofit/non_linear/grid/test_result_builder.py
@@ -81,7 +81,7 @@ def test_gaps(result_builder, add_results, numbers, t1, t2, t3):
 
 def test_log_likelihoods(result_builder):
     assert (
-        result_builder().log_likelihoods_native
+        result_builder().log_likelihoods().native
         == [float("-inf"), float("-inf"), float("-inf")]
     ).all()