Repository: rhayes777/PyAutoFit (python)

Issue:
Grid Search Result LimitList of parameter
A `GridSearchResult` has a `LimitList` type which provides list of lists of quantities that are mapped over, for example the upper limit of the prior of every grid search parameter:

```
    @property
    def upper_limits_lists(self) -> LimitLists:
        """
        The upper values for each grid square
        """
        return [
            [limit + self.step_size for limit in limits]
            for limits in self.lower_limits_lists
        ]
```

This `LimitList` structure is a key property of the `GridSearchResult`, it is represents the structure of all results performed for the `GridSearch`.

In `PyAutoLens`, I manually map over inferred results to plot the results of a grid search:

```
        instance_list = [
            None if samples.median_pdf() is None else samples.median_pdf()
            for samples in self.grid_search_result_with_subhalo.samples
        ]

        return [
                None if instance is None else instance.galaxies.subhalo.mass.mass_at_200
                for instance in instance_list
            ]
```

A much more concise API should be available, where I simply query the name of a parameter and quantity I want, and the `LimitList` corresponding to it is returned.

```
mass_at_200_list = self.grid_search_result.values(parameter="mass_at_200", quantity=samples.median_pdf())
```

I am not 100% sure on the exact API, but you should get the idea that for a `GridSearchResult` I should be able to get the `LimitList` for any parameter in `GridSearchResult.samples` and should be able to choose what attribute of the `Samples` I want (e.g. `median_pdf`, `max_log_likelihood`).


Pull request description:
feature/grid search attribute
Resolves #861

GridSearchResult now has a method called 'attribute_grid' which can be used to obtain a numpy array in the same dimensions as the grid search
```python
result.attribute_grid("centre")
```

This supports querying attributes deeper in the model using . delimited strings (or iterables)

```python
result.attribute_grid("gaussian.centre")
```

Values are taken from the highest log-likelihood instance. Currently SamplesSummary does not retain the median instance.


Patch:
diff --git a/autofit/non_linear/grid/grid_search/result.py b/autofit/non_linear/grid/grid_search/result.py
index 39023ede1..0bcea44ab 100644
--- a/autofit/non_linear/grid/grid_search/result.py
+++ b/autofit/non_linear/grid/grid_search/result.py
@@ -1,4 +1,4 @@
-from typing import List
+from typing import List, Union, Iterable
 
 import numpy as np
 
@@ -184,6 +184,32 @@ class GridSearchResult:
         """
         return self._list_to_native(lst=[sample for sample in self.samples])
 
+    def attribute_grid(self, attribute_path: Union[str, Iterable[str]]) -> np.ndarray:
+        """
+        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.
+
+        Parameters
+        ----------
+        attribute_path
+            The path to the attribute to get from the instance
+
+        Returns
+        -------
+        A numpy array of the attribute of the best instance from every search in the grid search.
+        """
+        if isinstance(attribute_path, str):
+            attribute_path = attribute_path.split(".")
+
+        attribute_list = []
+        for sample in self.samples:
+            attribute = sample.instance
+            for attribute_name in attribute_path:
+                attribute = getattr(attribute, attribute_name)
+            attribute_list.append(attribute)
+
+        return self._list_to_native(attribute_list)
+
     @property
     def log_likelihoods_native(self):
         """


Test patch:
diff --git a/test_autofit/non_linear/grid/test_result.py b/test_autofit/non_linear/grid/test_result.py
index 3745c8cce..9eede1dd9 100644
--- a/test_autofit/non_linear/grid/test_result.py
+++ b/test_autofit/non_linear/grid/test_result.py
@@ -1,6 +1,7 @@
 import pytest
 
 import autofit as af
+from autofit.non_linear.samples.summary import SamplesSummary
 
 
 @pytest.fixture(name="model")
@@ -10,11 +11,99 @@ def make_model():
     )
 
 
+@pytest.fixture(name="samples_1")
+def make_samples_1(model):
+    return SamplesSummary(
+        af.Sample(
+            1.0,
+            1.0,
+            1.0,
+            {
+                "centre": 1.0,
+                "normalization": 2.0,
+                "sigma": 3.0,
+            },
+        ),
+        model,
+    )
+
+
+@pytest.fixture(name="samples_2")
+def make_samples_2(model):
+    return SamplesSummary(
+        af.Sample(
+            1.0,
+            1.0,
+            1.0,
+            {
+                "centre": 2.0,
+                "normalization": 4.0,
+                "sigma": 6.0,
+            },
+        ),
+        model,
+    )
+
+
 @pytest.fixture(name="result")
-def make_result(model):
+def make_result(model, samples_1, samples_2):
     return af.GridSearchResult(
-        samples=[], lower_limits_lists=[[0.0], [0.5]], grid_priors=[model.centre]
+        samples=[samples_1, samples_2],
+        lower_limits_lists=[[0.0], [0.5]],
+        grid_priors=[model.centre],
+    )
+
+
+def test_instance_attribute_from_path(result):
+    assert (result.attribute_grid("centre") == [1.0, 2.0]).all()
+
+
+def test_higher_dimension_instance_attributes(model, samples_1, samples_2):
+    result = af.GridSearchResult(
+        samples=[samples_1, samples_2, 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()
+
+
+@pytest.fixture(name="deep_result")
+def make_deep_result(model, samples_1, samples_2):
+    model = af.Collection(
+        gaussian=af.Model(
+            af.Gaussian,
+            centre=af.UniformPrior(lower_limit=0.0, upper_limit=1.0),
+        )
+    )
+    samples = SamplesSummary(
+        af.Sample(
+            1.0,
+            1.0,
+            1.0,
+            {
+                "gaussian.centre": 1.0,
+                "gaussian.normalization": 2.0,
+                "gaussian.sigma": 3.0,
+            },
+        ),
+        model,
+    )
+    return af.GridSearchResult(
+        samples=[samples, samples],
+        lower_limits_lists=[[0.0], [0.5]],
+        grid_priors=[model.gaussian.centre],
+    )
+
+
+def test_paths(deep_result):
+    assert (deep_result.attribute_grid("gaussian.centre") == [1.0, 1.0]).all()
+
+
+def test_instances(deep_result):
+    assert (
+        deep_result.attribute_grid("gaussian")
+        == [af.Gaussian(1.0, 2.0, 3.0), af.Gaussian(1.0, 2.0, 3.0)]
+    ).all()
 
 
 @pytest.mark.parametrize(