Repository: rhayes777/PyAutoFit
Pull request #881 (2023-11-19 16:46:30)

Feature/sensitivity priors
A user was not able to customize the priors given the parameter of the `perturb_model` in sensitivity mapping.

This PR adds an optional function `perturb_model_prior_func` that allows the user to do this.


Patch:
diff --git a/autofit/mapper/prior_model/abstract.py b/autofit/mapper/prior_model/abstract.py
index 1f5f466b9..ddefaab99 100644
--- a/autofit/mapper/prior_model/abstract.py
+++ b/autofit/mapper/prior_model/abstract.py
@@ -15,6 +15,7 @@ from autofit import exc
 from autofit.mapper import model
 from autofit.mapper.model import AbstractModel, frozen_cache
 from autofit.mapper.prior import GaussianPrior
+from autofit.mapper.prior import UniformPrior
 from autofit.mapper.prior.abstract import Prior
 from autofit.mapper.prior.deferred import DeferredArgument
 from autofit.mapper.prior.tuple_prior import TuplePrior
@@ -1000,6 +1001,39 @@ class AbstractPriorModel(AbstractModel):
             }
         )
 
+    def mapper_from_uniform_floats(
+        self, floats, b
+    ):
+        """
+        The widths of the new priors are the `floats` value minus and plus the input bound `b`.
+
+        Parameters
+        ----------
+        floats
+            A list of floats each containing the centre of the new uniform priors.
+        b
+            The bound value which is subtracted from each float to calculate the `lower_limit` and `upper_limit`
+            of each uniform prior.
+
+        Returns
+        -------
+        mapper: ModelMapper
+            A new model mapper with all priors replaced by uniform priors.
+        """
+
+        prior_tuples = self.prior_tuples_ordered_by_id
+        arguments = {}
+
+        for i, prior_tuple in enumerate(prior_tuples):
+            prior = prior_tuple.prior
+
+            new_prior = UniformPrior(lower_limit=floats[i] - b, upper_limit=floats[i] + b)
+            new_prior.id = prior.id
+            arguments[prior] = new_prior
+
+        return self.mapper_from_prior_arguments(arguments)
+
+
     def instance_from_prior_medians(self, ignore_prior_limits=False):
         """
         Returns a list of physical values from the median values of the priors.
diff --git a/autofit/non_linear/grid/grid_search/result.py b/autofit/non_linear/grid/grid_search/result.py
index c1e24f790..3b1bef042 100644
--- a/autofit/non_linear/grid/grid_search/result.py
+++ b/autofit/non_linear/grid/grid_search/result.py
@@ -209,7 +209,7 @@ class GridSearchResult:
 
     @as_grid_list
     def log_likelihoods(
-        self, relative_to_value: float = 0.0, remove_relative_zeros: bool = False
+        self, relative_to_value: float = 0.0,
     ) -> GridList:
         """
         The maximum log likelihood of every grid search on a NumPy array whose shape is the native dimensions of the
diff --git a/autofit/non_linear/grid/sensitivity.py b/autofit/non_linear/grid/sensitivity.py
index d36066de7..722261612 100644
--- a/autofit/non_linear/grid/sensitivity.py
+++ b/autofit/non_linear/grid/sensitivity.py
@@ -4,7 +4,7 @@ import os
 from copy import copy
 from itertools import count
 from pathlib import Path
-from typing import List, Generator, Callable, ClassVar, Union, Tuple
+from typing import List, Generator, Callable, ClassVar, Optional, Union, Tuple
 
 from autoconf.dictable import to_dict
 from autofit.mapper.model import ModelInstance
@@ -176,6 +176,7 @@ class Sensitivity:
         base_fit_cls: Callable,
         perturb_fit_cls: Callable,
         job_cls: ClassVar = Job,
+        perturb_model_prior_func : Optional[Callable] = None,
         number_of_steps: Union[Tuple[int], int] = 4,
         number_of_cores: int = 2,
         limit_scale: int = 1,
@@ -232,6 +233,8 @@ class Sensitivity:
         self.base_fit_cls = base_fit_cls
         self.perturb_fit_cls = perturb_fit_cls
 
+        self.perturb_model_prior_func = perturb_model_prior_func
+
         self.job_cls = job_cls
 
         self.number_of_steps = number_of_steps
@@ -396,6 +399,13 @@ class Sensitivity:
         for number, (perturb_instance, perturb_model, label) in enumerate(
             zip(self._perturb_instances, self._perturb_models, self._labels)
         ):
+
+            if self.perturb_model_prior_func is not None:
+                perturb_model = self.perturb_model_prior_func(
+                    perturb_instance=perturb_instance,
+                    perturb_model=perturb_model
+                )
+
             simulate_instance = copy(self.instance)
             simulate_instance.perturb = perturb_instance
 
diff --git a/autofit/non_linear/result.py b/autofit/non_linear/result.py
index d63d1bf3e..386892e58 100644
--- a/autofit/non_linear/result.py
+++ b/autofit/non_linear/result.py
@@ -100,6 +100,14 @@ class AbstractResult(ABC):
 
     def model_absolute(self, a: float) -> AbstractPriorModel:
         """
+        Returns a model where every free parameter is a `GaussianPrior` with `mean` the previous result's
+        inferred maximum log likelihood parameter values and `sigma` the input absolute value `a`.
+
+        For example, a previous result may infer a parameter to have a maximum log likelihood value of 2.
+
+        If this result is used for search chaining, `model_absolute(a=0.1)` will assign this free parameter
+        `GaussianPrior(mean=2.0, sigma=0.1)` in the new model, where `sigma` is linked to the input `a`.
+
         Parameters
         ----------
         a
@@ -114,6 +122,15 @@ class AbstractResult(ABC):
 
     def model_relative(self, r: float) -> AbstractPriorModel:
         """
+        Returns a model where every free parameter is a `GaussianPrior` with `mean` the previous result's
+        inferred maximum log likelihood parameter values and `sigma` a relative value from the result `r`.
+
+        For example, a previous result may infer a parameter to have a maximum log likelihood value of 2 and
+        an error at the input `sigma` of 0.5.
+
+        If this result is used for search chaining, `model_relative(r=0.1)` will assign this free parameter
+        `GaussianPrior(mean=2.0, sigma=0.5*0.1)` in the new model, where `sigma` is the inferred error times `r`.
+
         Parameters
         ----------
         r
@@ -126,6 +143,27 @@ class AbstractResult(ABC):
         """
         return self.samples.model_relative(r)
 
+    def model_bounded(self, b: float) -> AbstractPriorModel:
+        """
+        Returns a model where every free parameter is a `UniformPrior` with `lower_limit` and `upper_limit the previous
+        result's inferred maximum log likelihood parameter values minus and plus the bound `b`.
+
+        For example, a previous result may infer a parameter to have a maximum log likelihood value of 2.
+
+        If this result is used for search chaining, `model_bound(b=0.1)` will assign this free parameter
+        `UniformPrior(lower_limit=1.9, upper_limit=2.1)` in the new model.
+
+        Parameters
+        ----------
+        b
+            The size of the bounds of the uniform prior
+
+        Returns
+        -------
+        A model mapper created by taking results from this search and creating priors with the defined bounded
+        uniform priors.
+        """
+        return self.samples.model_bounded(b)
 
 class Result(AbstractResult):
     def __init__(self, samples: Samples):
diff --git a/autofit/non_linear/samples/interface.py b/autofit/non_linear/samples/interface.py
index 70ec1cd2a..c5c35e4e2 100644
--- a/autofit/non_linear/samples/interface.py
+++ b/autofit/non_linear/samples/interface.py
@@ -95,6 +95,14 @@ class SamplesInterface(ABC):
 
     def model_absolute(self, a: float) -> AbstractPriorModel:
         """
+        Returns a model where every free parameter is a `GaussianPrior` with `mean` the previous result's
+        inferred maximum log likelihood parameter values and `sigma` the input absolute value `a`.
+
+        For example, a previous result may infer a parameter to have a maximum log likelihood value of 2.
+
+        If this result is used for search chaining, `model_absolute(a=0.1)` will assign this free parameter
+        `GaussianPrior(mean=2.0, sigma=0.1)` in the new model, where `sigma` is linked to the input `a`.
+
         Parameters
         ----------
         a
@@ -111,6 +119,15 @@ class SamplesInterface(ABC):
 
     def model_relative(self, r: float) -> AbstractPriorModel:
         """
+        Returns a model where every free parameter is a `GaussianPrior` with `mean` the previous result's
+        inferred maximum log likelihood parameter values and `sigma` a relative value from the result `r`.
+
+        For example, a previous result may infer a parameter to have a maximum log likelihood value of 2 and
+        an error at the input `sigma` of 0.5.
+
+        If this result is used for search chaining, `model_relative(r=0.1)` will assign this free parameter
+        `GaussianPrior(mean=2.0, sigma=0.5*0.1)` in the new model, where `sigma` is the inferred error times `r`.
+
         Parameters
         ----------
         r
@@ -125,12 +142,38 @@ class SamplesInterface(ABC):
             self.gaussian_priors_at_sigma(sigma=self.sigma), r=r
         )
 
+    def model_bounded(self, b: float) -> AbstractPriorModel:
+        """
+        Returns a model where every free parameter is a `UniformPrior` with `lower_limit` and `upper_limit the previous
+        result's inferred maximum log likelihood parameter values minus and plus the bound `b`.
+
+        For example, a previous result may infer a parameter to have a maximum log likelihood value of 2.
+
+        If this result is used for search chaining, `model_bound(b=0.1)` will assign this free parameter
+        `UniformPrior(lower_limit=1.9, upper_limit=2.1)` in the new model.
+
+        Parameters
+        ----------
+        b
+            The size of the bounds of the uniform prior
+
+        Returns
+        -------
+        A model mapper created by taking results from this search and creating priors with the defined bounded
+        uniform priors.
+        """
+        return self.model.mapper_from_uniform_floats(
+            floats=self.max_log_likelihood(as_instance=False), b=b
+        )
+
     @property
     def sigma(self):
         return conf.instance["general"]["prior_passer"]["sigma"]
 
     def gaussian_priors_at_sigma(self, sigma: float) -> [List]:
         """
+        Returns `GaussianPrior`'s of every parameter in a fit for use with non-linear search chaining.
+
         `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:
 
diff --git a/docs/features/sensitivity_mapping.rst b/docs/features/sensitivity_mapping.rst
index 344d21cc5..8c0490122 100644
--- a/docs/features/sensitivity_mapping.rst
+++ b/docs/features/sensitivity_mapping.rst
@@ -8,8 +8,7 @@ quantify which model objectively gives the best-fit following the principles of
 
 However, a complex model may not be favoured by model comparison not because it is the 'wrong' model, but simply
 because the dataset being fitted is not of a sufficient quality for the more complex model to be favoured. Sensitivity
-mapping allows us to address what quality of data would be needed for the more complex model to be favoured or
-alternatively for what sets of model parameter values it would be favoured for data of a given quality.
+mapping addresses what quality of data would be needed for the more complex model to be favoured.
 
 In order to do this, sensitivity mapping involves us writing a function that uses the model(s) to simulate a dataset.
 We then use this function to simulate many datasets, for many different models, and fit each dataset using the same


Test patch:
diff --git a/test_autofit/mapper/model/test_model_mapper.py b/test_autofit/mapper/model/test_model_mapper.py
index 7fb6c795f..295972dac 100644
--- a/test_autofit/mapper/model/test_model_mapper.py
+++ b/test_autofit/mapper/model/test_model_mapper.py
@@ -565,6 +565,15 @@ class TestPriorReplacement:
         assert result.two.one.mean == 3
         assert result.two.two.mean == 4
 
+    def test__mapper_from_uniform_floats(self):
+        mapper = af.ModelMapper(mock_class=af.m.MockClassx2)
+        result = mapper.mapper_from_uniform_floats([10, 5], b=1.0)
+
+        assert isinstance(result.mock_class.one, af.UniformPrior)
+        assert {prior.id for prior in mapper.priors} == {
+            prior.id for prior in result.priors
+        }
+
 
 class TestArguments:
     def test_same_argument_name(self):
diff --git a/test_autofit/non_linear/grid/test_sensitivity/test_functionality.py b/test_autofit/non_linear/grid/test_sensitivity/test_functionality.py
index e9b5fb2b0..5fb46770d 100644
--- a/test_autofit/non_linear/grid/test_sensitivity/test_functionality.py
+++ b/test_autofit/non_linear/grid/test_sensitivity/test_functionality.py
@@ -67,6 +67,33 @@ class TestPerturbationModels:
         assert second.sigma.lower_limit == sl
         assert second.sigma.upper_limit == su
 
+    def test__perturb_models__prior_overwrite_via_perturb_model_prior_func(
+            self,
+            sensitivity,
+    ):
+        def perturb_model_prior_func(
+                perturb_instance,
+                perturb_model
+        ):
+
+            perturb_model.centre = af.UniformPrior(lower_limit=-7.0, upper_limit=4.0)
+
+            return perturb_model
+
+        sensitivity.perturb_model_prior_func = perturb_model_prior_func
+        jobs = sensitivity._make_jobs()
+        models = [
+            job.perturb_model
+            for job in jobs
+        ]
+
+        first, second, *_ = models
+
+        assert first is not second
+
+        assert first.centre.lower_limit == -7.0
+        assert first.centre.upper_limit == 4.0
+
     def test_physical(
             self,
             sensitivity
diff --git a/test_autofit/non_linear/result/test_result.py b/test_autofit/non_linear/result/test_result.py
index cc05c39d1..961e159d3 100644
--- a/test_autofit/non_linear/result/test_result.py
+++ b/test_autofit/non_linear/result/test_result.py
@@ -9,7 +9,7 @@ def make_result():
     mapper.component = af.m.MockClassx2Tuple
     # noinspection PyTypeChecker
     return af.Result(
-        samples=af.m.MockSamples(gaussian_tuples=[(0, 0), (1, 0)], model=mapper),
+        samples=af.m.MockSamples(max_log_likelihood_instance=[0, 1], gaussian_tuples=[(0, 0), (1, 0)], model=mapper),
     )
 
 
@@ -35,6 +35,16 @@ class TestResult:
         assert component.one_tuple.one_tuple_0.sigma == 0.0
         assert component.one_tuple.one_tuple_1.sigma == 1.0
 
+    def test_model_bounded(self, result):
+        component = result.model_bounded(b=1.0).component
+
+        print(component.one_tuple.one_tuple_0.lower_limit)
+
+        assert component.one_tuple.one_tuple_0.lower_limit == -1.0
+        assert component.one_tuple.one_tuple_1.lower_limit == 0.0
+        assert component.one_tuple.one_tuple_0.upper_limit == 1.0
+        assert component.one_tuple.one_tuple_1.upper_limit == 2.0
+
     def test_raises(self, result):
         with pytest.raises(af.exc.PriorException):
             result.model.mapper_from_gaussian_tuples(