Repository: rhayes777/PyAutoFit (python)

Issue:
Clean up grid search results.csv output
The results.csv output of a grid search of non linear search from this script:

https://github.com/Jammy2211/autofit_workspace_test/blob/main/scripts/database/scrape/grid_search.py

Is the following:

```
"['index', 'gaussian_centre', 'likelihood_merit']"
  0.00,  0.00,-43.30
  1.00, 50.00,-43.32
```

Could we clean it up as follows:

1) Remove the "[ and ]" around the top bit.
2) Change `likelihood_merit` to `log_likelihood_increase`.
3) Add a column `log_evidence_increase` which uses the `log_evidence` value, if this is not `None`.



Pull request:
feature/grid search results
Resolves #854

Uses:
result.log_likelihood
result.samples.log_evidence

for log_likelihood_increase and log_evidence_increase respectively


Patch:
diff --git a/autofit/non_linear/grid/grid_search/__init__.py b/autofit/non_linear/grid/grid_search/__init__.py
index faec7cc40..6c065298b 100644
--- a/autofit/non_linear/grid/grid_search/__init__.py
+++ b/autofit/non_linear/grid/grid_search/__init__.py
@@ -22,14 +22,14 @@ class Sequential:
         for job_ in jobs:
             yield job_.perform()
 
-class GridSearch:
 
+class GridSearch:
     def __init__(
-            self,
-            search,
-            number_of_steps: int = 4,
-            number_of_cores: int = 1,
-            result_output_interval: int = 100,
+        self,
+        search,
+        number_of_steps: int = 4,
+        number_of_cores: int = 1,
+        result_output_interval: int = 100,
     ):
         """
         Performs a non linear optimiser search for each square in a grid. The dimensionality of the search depends on
@@ -50,15 +50,11 @@ class GridSearch:
 
         self._logger = None
 
-        self.logger.info(
-            "Creating grid search"
-        )
+        self.logger.info("Creating grid search")
 
         self.number_of_cores = number_of_cores or 1
 
-        self.logger.info(
-            f"Using {number_of_cores} core(s)"
-        )
+        self.logger.info(f"Using {number_of_cores} core(s)")
 
         self._result_output_interval = result_output_interval
 
@@ -76,9 +72,7 @@ class GridSearch:
     @property
     def logger(self):
         if not hasattr(self, "_logger") or self._logger is None:
-            self._logger = logging.getLogger(
-                f"GridSearch ({self.search.name})"
-            )
+            self._logger = logging.getLogger(f"GridSearch ({self.search.name})")
         return self._logger
 
     @property
@@ -131,16 +125,15 @@ class GridSearch:
         arguments = {}
         for value, grid_prior in zip(values, grid_priors):
             if (
-                    float("-inf") == grid_prior.lower_limit
-                    or float("inf") == grid_prior.upper_limit
+                float("-inf") == grid_prior.lower_limit
+                or float("inf") == grid_prior.upper_limit
             ):
                 raise exc.PriorException(
                     "Priors passed to the grid search must have definite limits"
                 )
             lower_limit = grid_prior.lower_limit + value * grid_prior.width
             upper_limit = (
-                    grid_prior.lower_limit
-                    + (value + self.step_size) * grid_prior.width
+                grid_prior.lower_limit + (value + self.step_size) * grid_prior.width
             )
             prior = p.UniformPrior(lower_limit=lower_limit, upper_limit=upper_limit)
             arguments[grid_prior] = prior
@@ -154,12 +147,12 @@ class GridSearch:
             yield model.mapper_from_partial_prior_arguments(arguments)
 
     def fit(
-            self,
-            model,
-            analysis,
-            grid_priors,
-            info: Optional[Dict] = None,
-            parent: Optional[NonLinearSearch] = None
+        self,
+        model,
+        analysis,
+        grid_priors,
+        info: Optional[Dict] = None,
+        parent: Optional[NonLinearSearch] = None,
     ):
         """
         Fit an analysis with a set of grid priors. The grid priors are priors associated with the model mapper
@@ -186,9 +179,7 @@ class GridSearch:
         if parent is not None:
             self.paths.parent = parent.paths
 
-        self.logger.info(
-            "Running grid search..."
-        )
+        self.logger.info("Running grid search...")
 
         process_class = Process if self.parallel else Sequential
         # noinspection PyArgumentList
@@ -197,16 +188,16 @@ class GridSearch:
             analysis=analysis,
             grid_priors=grid_priors,
             process_class=process_class,
-            info=info
+            info=info,
         )
 
     def _fit(
-            self,
-            model,
-            analysis,
-            grid_priors,
-            process_class=Union[Type[Process], Type[Sequential]],
-            info: Optional[Dict] = None
+        self,
+        model,
+        analysis,
+        grid_priors,
+        process_class=Union[Type[Process], Type[Sequential]],
+        info: Optional[Dict] = None,
     ):
         """
         Perform the grid search in parallel, with all the optimisation for each grid square being performed on a
@@ -227,65 +218,49 @@ class GridSearch:
         result: GridSearchResult
             The result of the grid search
         """
-        self.logger.info(
-            "...in parallel"
-        )
+        self.logger.info("...in parallel")
 
-        grid_priors = model.sort_priors_alphabetically(
-            set(grid_priors)
-        )
+        grid_priors = model.sort_priors_alphabetically(set(grid_priors))
         lists = self.make_lists(grid_priors)
 
-        builder = ResultBuilder(
-            lists=lists,
-            grid_priors=grid_priors
-        )
+        builder = ResultBuilder(lists=lists, grid_priors=grid_priors)
 
         self.save_metadata()
 
         def save_results():
-            self.paths.save_object(
-                "result",
-                builder()
-            )
+            self.paths.save_object("result", builder())
 
         def write_results():
-            self.logger.debug(
-                "Writing results"
-            )
+            self.logger.debug("Writing results")
 
             os.makedirs(self.paths.output_path, exist_ok=True)
 
+            is_evidence = any(row[-1] is not None for row in results_list)
+
             with open(self.paths.output_path / "results.csv", "w+") as f:
                 writer = csv.writer(f)
-                writer.writerow([
-                    ["index"]
-                    + list(map(model.name_for_prior, grid_priors))
-                    + ["likelihood_merit"]
-                ])
+                headers = [
+                    "index",
+                    *map(model.name_for_prior, grid_priors),
+                    "log_likelihood_increase",
+                ]
+                if is_evidence:
+                    headers.append("log_evidence")
+                writer.writerow(headers)
 
                 for results in results_list:
-                    writer.writerow([
-                        padding(f"{value:.2f}")
-                        for value in results
-                    ])
+                    if not is_evidence:
+                        results = results[:1]
+                    writer.writerow([padding(f"{value:.2f}") for value in results])
 
         results_list = []
 
         for i, job_result in enumerate(
-                process_class.run_jobs(
-                    self.make_jobs(
-                        model,
-                        analysis,
-                        grid_priors,
-                        info
-                    ),
-                    self.number_of_cores
-                )
-        ):
-            builder.add(
-                job_result
+            process_class.run_jobs(
+                self.make_jobs(model, analysis, grid_priors, info), self.number_of_cores
             )
+        ):
+            builder.add(job_result)
             results_list.append(job_result.result_list_row)
             write_results()
             if i % self._result_output_interval == 0:
@@ -297,17 +272,12 @@ class GridSearch:
         return builder()
 
     def save_metadata(self):
-
         self.paths.save_parent_identifier()
-        self.paths.save_unique_tag(
-            is_grid_search=True
-        )
+        self.paths.save_unique_tag(is_grid_search=True)
         self.paths.zip_remove_nuclear()
 
     def make_jobs(self, model, analysis, grid_priors, info: Optional[Dict] = None):
-        grid_priors = model.sort_priors_alphabetically(
-            set(grid_priors)
-        )
+        grid_priors = model.sort_priors_alphabetically(set(grid_priors))
         lists = self.make_lists(grid_priors)
 
         jobs = list()
@@ -320,19 +290,13 @@ class GridSearch:
                     grid_priors=grid_priors,
                     values=values,
                     index=index,
-                    info=info
+                    info=info,
                 )
             )
         return jobs
 
     def job_for_analysis_grid_priors_and_values(
-            self,
-            model,
-            analysis,
-            grid_priors,
-            values,
-            index,
-            info: Optional[Dict] = None
+        self, model, analysis, grid_priors, values, index, info: Optional[Dict] = None
     ):
         arguments = self.make_arguments(values=values, grid_priors=grid_priors)
         model = model.mapper_from_partial_prior_arguments(arguments=arguments)
@@ -360,16 +324,15 @@ class GridSearch:
             analysis=analysis,
             arguments=arguments,
             index=index,
-            info=info
+            info=info,
         )
 
     def search_instance(self, name_path):
-
         search_instance = self.search.copy_with_paths(
             self.paths.create_child(
                 name=name_path,
                 path_prefix=self.paths.path_prefix,
-                is_identifier_in_paths=False
+                is_identifier_in_paths=False,
             )
         )
 
@@ -381,7 +344,6 @@ class GridSearch:
                     pass
 
         if self.number_of_cores > 1:
-
             search_instance.number_of_cores = 1
 
         return search_instance
@@ -419,9 +381,7 @@ def grid(fitness, no_dimensions, step_size):
 
 
 def make_lists(
-        no_dimensions: int,
-        step_size: Union[Tuple[float], float],
-        centre_steps=True
+    no_dimensions: int, step_size: Union[Tuple[float], float], centre_steps=True
 ):
     """
         Returns a list of lists of floats covering every combination across no_dimensions of points of integer step size
@@ -441,28 +401,15 @@ def make_lists(
         A list of lists
     """
     if isinstance(step_size, float):
-        step_size = tuple(
-            step_size
-            for _
-            in range(no_dimensions)
-        )
+        step_size = tuple(step_size for _ in range(no_dimensions))
 
     if no_dimensions == 0:
         return [[]]
 
-    sub_lists = make_lists(
-        no_dimensions - 1,
-        step_size[1:],
-        centre_steps=centre_steps
-    )
+    sub_lists = make_lists(no_dimensions - 1, step_size[1:], centre_steps=centre_steps)
     step_size = step_size[0]
     return [
-        [
-            step_size * value + (
-                0.5 * step_size
-                if centre_steps
-                else 0)
-        ] + sub_list
+        [step_size * value + (0.5 * step_size if centre_steps else 0)] + sub_list
         for value in range(int((1 / step_size)))
         for sub_list in sub_lists
     ]
diff --git a/autofit/non_linear/grid/grid_search/job.py b/autofit/non_linear/grid/grid_search/job.py
index 0172010d9..c958befdb 100644
--- a/autofit/non_linear/grid/grid_search/job.py
+++ b/autofit/non_linear/grid/grid_search/job.py
@@ -21,7 +21,15 @@ class JobResult(AbstractJobResult):
 
 
 class Job(AbstractJob):
-    def __init__(self, search_instance, model, analysis, arguments, index, info: Optional[Dict] = None):
+    def __init__(
+        self,
+        search_instance,
+        model,
+        analysis,
+        arguments,
+        index,
+        info: Optional[Dict] = None,
+    ):
         """
         A job to be performed in parallel.
 
@@ -34,9 +42,7 @@ class Job(AbstractJob):
         arguments
             The grid search arguments
         """
-        super().__init__(
-            number=index
-        )
+        super().__init__(number=index)
         self.search_instance = search_instance
         self.analysis = analysis
         self.model = model
@@ -49,18 +55,18 @@ class Job(AbstractJob):
             model=self.model,
             analysis=self.analysis,
             info=self.info,
-            bypass_nuclear_if_on=True
+            bypass_nuclear_if_on=True,
         )
         result_list_row = [
             self.index,
             *[
                 prior.lower_limit
-                for prior
-                in self.model.sort_priors_alphabetically(
+                for prior in self.model.sort_priors_alphabetically(
                     self.arguments.values()
                 )
             ],
             result.log_likelihood,
+            result.samples.log_evidence,
         ]
 
         return JobResult(result, result_list_row, self.number)


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 fcf5118fd..f830f2fb6 100644
--- a/test_autofit/non_linear/grid/test_optimizer_grid_search.py
+++ b/test_autofit/non_linear/grid/test_optimizer_grid_search.py
@@ -1,3 +1,4 @@
+import csv
 import pickle
 
 import numpy as np
@@ -151,6 +152,41 @@ def empty_args():
     af.m.MockOptimizer.init_args = list()
 
 
+@pytest.fixture(name="sample_name_paths")
+def make_sample_name_paths():
+    return af.DirectoryPaths(name="sample_name")
+
+
+@pytest.fixture(name="grid_search_10_result")
+def make_grid_search_10_result(mapper, sample_name_paths):
+    grid_search = af.SearchGridSearch(
+        search=af.m.MockOptimizer(),
+        number_of_steps=10,
+    )
+    grid_search.search.paths = sample_name_paths
+    return grid_search.fit(
+        model=mapper,
+        analysis=af.m.MockAnalysis(),
+        grid_priors=[
+            mapper.component.one_tuple.one_tuple_0,
+            mapper.component.one_tuple.one_tuple_1,
+        ],
+    )
+
+
+def test_csv_headers(grid_search_10_result, sample_name_paths):
+    with open(sample_name_paths.output_path / "results.csv") as f:
+        reader = csv.reader(f)
+        headers = next(reader)
+
+    assert headers == [
+        "index",
+        "component_one_tuple_0",
+        "component_one_tuple_1",
+        "log_likelihood_increase",
+    ]
+
+
 class TestGridNLOBehaviour:
     def test_results(self, grid_search_05, mapper):
         result = grid_search_05.fit(
@@ -165,69 +201,10 @@ class TestGridNLOBehaviour:
         assert len(result.results) == 4
         assert result.no_dimensions == 2
 
-        grid_search = af.SearchGridSearch(
-            search=af.m.MockOptimizer(),
-            number_of_steps=10,
-        )
-        grid_search.search.paths = af.DirectoryPaths(name="sample_name")
-        result = grid_search.fit(
-            model=mapper,
-            analysis=af.m.MockAnalysis(),
-            grid_priors=[
-                mapper.component.one_tuple.one_tuple_0,
-                mapper.component.one_tuple.one_tuple_1,
-            ],
-        )
-
-        assert len(result.results) == 100
-        assert result.no_dimensions == 2
-        assert result.log_likelihoods_native.shape == (10, 10)
-
-    # def test_results_parallel(self, mapper, container):
-    #     grid_search = af.SearchGridSearch(
-    #         search=container.af.m.MockOptimizer,
-    #         number_of_steps=10,
-    #         paths=af.Paths(name="sample_name"),
-    #         parallel=True,
-    #     )
-    #     result = grid_search.fit(
-    #         container.af.m.MockAnalysis(),
-    #         mapper,
-    #         [mapper.component.one_tuple.one_tuple_0, mapper.component.one_tuple.one_tuple_1],
-    #     )
-    #
-    #     assert len(result.results) == 100
-    #     assert result.no_dimensions == 2
-    #     assert result.likelihood_merit_array.shape == (10, 10)
-
-    # def test_generated_models_with_instances(self, grid_search, container, mapper):
-    #     instance_component = mock.af.m.MockClassx2Tuple()
-    #     mapper.instance_component = instance_component
-    #
-    #     analysis = container.af.m.MockAnalysis()
-    #
-    #     grid_search.fit(analysis, mapper, [mapper.component.one_tuple.one_tuple_0])
-    #
-    #     for instance in container.fit_instances:
-    #         assert isinstance(instance.component, mock.af.m.MockClassx2Tuple)
-    #         assert instance.instance_component == instance_component
-    #
-    # def test_generated_models_with_instance_attributes(
-    #         self, grid_search, mapper, container
-    # ):
-    #     instance = 2.0
-    #     mapper.component.one_tuple.one_tuple_1 = instance
-    #
-    #     analysis = container.af.m.MockAnalysis()
-    #
-    #     grid_search.fit(analysis, mapper, [mapper.component.one_tuple.one_tuple_0])
-    #
-    #     assert len(container.fit_instances) > 0
-    #
-    #     for instance in container.fit_instances:
-    #         assert isinstance(instance.component, mock.af.m.MockClassx2Tuple)
-    #         # noinspection PyUnresolvedReferences
-    #         assert instance.component.centre[1] == 2
+    def test_results_10(self, grid_search_10_result):
+        assert len(grid_search_10_result.results) == 100
+        assert grid_search_10_result.no_dimensions == 2
+        assert grid_search_10_result.log_likelihoods_native.shape == (10, 10)
 
     def test_passes_attributes(self):
         search = af.DynestyStatic()