Repository: rhayes777/PyAutoFit
Pull request #1064 (2024-10-16 10:51:02)

feature/constant
Make constant parameters in the model into a distinct constant class to resolve the issue highlighted here: https://github.com/rhayes777/PyAutoFit/pull/1063#issuecomment-2408611041

This impacts on how these values are represented when serialised. All tests pass but there may still be unanticipated consequences as it's a fairly profound change



Patch:
diff --git a/autofit/__init__.py b/autofit/__init__.py
index c243f6edb..66adf0fe8 100644
--- a/autofit/__init__.py
+++ b/autofit/__init__.py
@@ -123,6 +123,7 @@ for type_ in (
     "Gaussian",
     "LogGaussian",
     "compound",
+    "Constant",
 ):
     register_parser(type_, ModelObject.from_dict)
 
diff --git a/autofit/mapper/identifier.py b/autofit/mapper/identifier.py
index 5a0f050c9..60a78a01a 100644
--- a/autofit/mapper/identifier.py
+++ b/autofit/mapper/identifier.py
@@ -93,6 +93,11 @@ class Identifier:
             An object
         """
         from .model_object import ModelObject
+        from autofit.mapper.prior.constant import Constant
+
+        if isinstance(value, Constant):
+            self.add_value_to_hash_list(value.value)
+            return
 
         if inspect.isclass(value):
             self.add_value_to_hash_list(get_class_path(value))
diff --git a/autofit/mapper/mock/mock_model.py b/autofit/mapper/mock/mock_model.py
index f5811973b..fe1ff914c 100644
--- a/autofit/mapper/mock/mock_model.py
+++ b/autofit/mapper/mock/mock_model.py
@@ -191,3 +191,14 @@ class Parameter:
 class WithString:
     def __init__(self, arg: "Parameter"):
         self.arg = arg
+
+
+class WithConstants:
+    def __init__(self, constant_1, constant_2):
+        self.constant_1 = constant_1
+        self.constant_2 = constant_2
+
+
+class ModelWithTupleConstant:
+    def __init__(self, constant=(0.0, 0.0)):
+        self.constant = constant
diff --git a/autofit/mapper/model_object.py b/autofit/mapper/model_object.py
index 05be46ef7..fd2fbd123 100644
--- a/autofit/mapper/model_object.py
+++ b/autofit/mapper/model_object.py
@@ -154,6 +154,7 @@ class ModelObject:
         from autofit.mapper.prior.tuple_prior import TuplePrior
         from autofit.mapper.prior.arithmetic.compound import Compound
         from autofit.mapper.prior.arithmetic.compound import ModifiedPrior
+        from .prior.constant import Constant
 
         if isinstance(d, list):
             return [
@@ -187,6 +188,8 @@ class ModelObject:
                     f"Could not find type for class path {class_path}. Defaulting to Collection placeholder."
                 )
                 instance = Collection()
+        elif type == "constant":
+            return Constant(value=d["value"])
         elif type_ == "collection":
             instance = Collection()
         elif type_ == "tuple_prior":
@@ -281,6 +284,7 @@ class ModelObject:
         from autofit.mapper.prior_model.prior_model import Model
         from autofit.mapper.prior.tuple_prior import TuplePrior
         from autofit.mapper.prior_model.array import Array
+        from autofit.mapper.prior.constant import Constant
 
         if isinstance(self, Collection):
             type_ = "collection"
diff --git a/autofit/mapper/prior/abstract.py b/autofit/mapper/prior/abstract.py
index 174bb4851..91605ed2c 100644
--- a/autofit/mapper/prior/abstract.py
+++ b/autofit/mapper/prior/abstract.py
@@ -7,6 +7,7 @@ from typing import Union, Tuple, Optional, Dict
 from autoconf import conf
 from autofit import exc, jax_wrapper
 from autofit.mapper.prior.arithmetic import ArithmeticMixin
+from autofit.mapper.prior.constant import Constant
 from autofit.mapper.prior.deferred import DeferredArgument
 from autofit.mapper.variable import Variable
 
@@ -247,9 +248,6 @@ class Prior(Variable, ABC, ArithmeticMixin):
             return loaded_ids[id_]
         except KeyError:
             pass
-
-        if prior_dict["type"] == "Constant":
-            return prior_dict["value"]
         if prior_dict["type"] == "Deferred":
             return DeferredArgument()
 
@@ -263,6 +261,7 @@ class Prior(Variable, ABC, ArithmeticMixin):
             "LogUniform": LogUniformPrior,
             "Gaussian": GaussianPrior,
             "LogGaussian": LogGaussianPrior,
+            "Constant": Constant,
         }
 
         # noinspection PyProtectedMember
diff --git a/autofit/mapper/prior/constant.py b/autofit/mapper/prior/constant.py
new file mode 100644
index 000000000..cf5fb205b
--- /dev/null
+++ b/autofit/mapper/prior/constant.py
@@ -0,0 +1,64 @@
+from autofit.mapper.model_object import ModelObject
+
+
+class Constant(ModelObject):
+    def __init__(self, value: float):
+        """
+        Represents a constant value in a model.
+
+        This is equivalent to a prior.
+
+        Note that two different instances of this class with the same value are not equal,
+        but are equal once instantiated or in comparison to a float or int.
+
+        Parameters
+        ----------
+        value
+            The constant value.
+        """
+        super().__init__()
+        self.value = value
+
+    def __str__(self):
+        return str(self.value)
+
+    def __eq__(self, other):
+        if isinstance(other, (int, float)):
+            return self.value == other
+        return super().__eq__(other)
+
+    def __hash__(self):
+        return self.id
+
+    def dict(self):
+        return {"type": "Constant", "value": self.value}
+
+    def __add__(self, other):
+        return self.value + other
+
+    def __sub__(self, other):
+        return self.value - other
+
+    def __mul__(self, other):
+        return self.value * other
+
+    def __truediv__(self, other):
+        return self.value / other
+
+    def __pow__(self, other):
+        return self.value**other
+
+    def __radd__(self, other):
+        return other + self.value
+
+    def __rsub__(self, other):
+        return other - self.value
+
+    def __rmul__(self, other):
+        return other * self.value
+
+    def __rtruediv__(self, other):
+        return other / self.value
+
+    def __rpow__(self, other):
+        return other**self.value
diff --git a/autofit/mapper/prior/tuple_prior.py b/autofit/mapper/prior/tuple_prior.py
index bb2c7bc42..148b3963e 100644
--- a/autofit/mapper/prior/tuple_prior.py
+++ b/autofit/mapper/prior/tuple_prior.py
@@ -8,6 +8,7 @@ from autofit.mapper.prior_model.attribute_pair import (
     InstanceNameValue,
 )
 from .abstract import Prior
+from .constant import Constant
 
 NameValue = Tuple[str, Union[Prior, float]]
 
@@ -52,10 +53,16 @@ class TuplePrior(ModelObject):
         """
         return list(
             sorted(
-                filter(
-                    lambda t: isinstance(t[1], float) and t[0] != "id",
-                    self.__dict__.items(),
-                ),
+                [
+                    (key, value)
+                    for key, value in self.__dict__.items()
+                    if key != "id" and isinstance(value, (int, float))
+                ]
+                + [
+                    (key, value.value)
+                    for key, value in self.__dict__.items()
+                    if isinstance(value, Constant)
+                ],
                 key=lambda tup: tup[0],
             )
         )
diff --git a/autofit/mapper/prior_model/abstract.py b/autofit/mapper/prior_model/abstract.py
index b4540910e..796301d68 100644
--- a/autofit/mapper/prior_model/abstract.py
+++ b/autofit/mapper/prior_model/abstract.py
@@ -17,6 +17,7 @@ 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.constant import Constant
 from autofit.mapper.prior.deferred import DeferredArgument
 from autofit.mapper.prior.tuple_prior import TuplePrior
 from autofit.mapper.prior.width_modifier import WidthModifier
@@ -457,6 +458,9 @@ class AbstractPriorModel(AbstractModel):
             obj.__init__(t)
         else:
             obj = t
+
+        if isinstance(obj, float):
+            return Constant(obj)
         return obj
 
     def take_attributes(self, source: object):
@@ -1189,7 +1193,9 @@ class AbstractPriorModel(AbstractModel):
     @property
     @cast_collection(InstanceNameValue)
     def direct_instance_tuples(self):
-        return self.direct_tuples_with_type(float)
+        return self.direct_tuples_with_type(float) + self.direct_tuples_with_type(
+            Constant
+        )
 
     @property
     @cast_collection(PriorModelNameValue)
@@ -1624,7 +1630,8 @@ class AbstractPriorModel(AbstractModel):
             [
                 t
                 for t in self.path_instance_tuples_for_class(
-                    (Prior, float, int, tuple, ConfigException), ignore_children=True
+                    (Prior, float, Constant, int, tuple, ConfigException),
+                    ignore_children=True,
                 )
                 if t[0][-1] not in ("id", "item_number")
             ],
@@ -1678,6 +1685,7 @@ class AbstractPriorModel(AbstractModel):
             (
                 Prior,
                 float,
+                Constant,
                 tuple,
             ),
             ignore_children=True,
diff --git a/autofit/mapper/prior_model/collection.py b/autofit/mapper/prior_model/collection.py
index 542f62edd..b89bba16a 100644
--- a/autofit/mapper/prior_model/collection.py
+++ b/autofit/mapper/prior_model/collection.py
@@ -4,6 +4,7 @@ from autofit.jax_wrapper import register_pytree_node_class
 
 from autofit.mapper.model import ModelInstance, assert_not_frozen
 from autofit.mapper.prior.abstract import Prior
+from autofit.mapper.prior.constant import Constant
 from autofit.mapper.prior_model.abstract import AbstractPriorModel
 
 
@@ -260,6 +261,8 @@ class Collection(AbstractPriorModel):
                 )
             elif isinstance(value, Prior):
                 value = arguments[value]
+            elif isinstance(value, Constant):
+                value = value.value
             setattr(result, key, value)
         return result
 
diff --git a/autofit/mapper/prior_model/prior_model.py b/autofit/mapper/prior_model/prior_model.py
index ab2f99745..7c20a3bf9 100644
--- a/autofit/mapper/prior_model/prior_model.py
+++ b/autofit/mapper/prior_model/prior_model.py
@@ -12,6 +12,7 @@ from autoconf.exc import ConfigException
 from autofit.mapper.model import assert_not_frozen
 from autofit.mapper.model_object import ModelObject
 from autofit.mapper.prior.abstract import Prior
+from autofit.mapper.prior.constant import Constant
 from autofit.mapper.prior.deferred import DeferredInstance
 from autofit.mapper.prior.tuple_prior import TuplePrior
 from autofit.mapper.prior_model.abstract import AbstractPriorModel
@@ -224,6 +225,8 @@ class Model(AbstractPriorModel):
             value = Model(value)
         if isinstance(value, int):
             value = float(value)
+        if isinstance(value, float):
+            value = Constant(value)
         return value
 
     @property
@@ -390,8 +393,12 @@ class Model(AbstractPriorModel):
                     ]
                     setattr(tuple_prior, key, value)
                     return
+
             except IndexError:
                 pass
+
+            if isinstance(value, float):
+                value = Constant(value)
         try:
             super().__setattr__(key, value)
         except AttributeError as e:
@@ -473,6 +480,11 @@ class Model(AbstractPriorModel):
             **prior_arguments,
         }
 
+        constructor_arguments = {
+            key: value.value if isinstance(value, Constant) else value
+            for key, value in constructor_arguments.items()
+        }
+
         if self.is_deferred_arguments:
             return DeferredInstance(self.cls, constructor_arguments)
 
@@ -495,6 +507,8 @@ class Model(AbstractPriorModel):
                         arguments,
                         ignore_assertions=ignore_assertions,
                     )
+                elif isinstance(value, Constant):
+                    value = value.value
                 elif isinstance(value, Prior):
                     value = arguments[value]
                 try:


Test patch:
diff --git a/test_autofit/analysis/test_free_parameter.py b/test_autofit/analysis/test_free_parameter.py
index 951828321..ab41ef6fa 100644
--- a/test_autofit/analysis/test_free_parameter.py
+++ b/test_autofit/analysis/test_free_parameter.py
@@ -136,6 +136,18 @@ def test_multiple_free_parameters(model, Analysis):
     assert first.sigma is not second.sigma
 
 
+def test_free_parameters_for_constants(combined_analysis):
+    model = af.Model(af.Gaussian, centre=1.0, sigma=1.0)
+
+    combined_analysis[0][model.centre] = 2
+    combined_analysis[0][model.sigma] = 3
+
+    new_model = combined_analysis.modify_model(model)
+
+    assert new_model[0].centre == 2
+    assert new_model[0].sigma == 3
+
+
 def test_add_free_parameter(combined_analysis):
     assert isinstance(combined_analysis, FreeParameterAnalysis)
 
diff --git a/test_autofit/config/priors/mock_model.yaml b/test_autofit/config/priors/mock_model.yaml
index 3c21fcfbf..7e8761902 100644
--- a/test_autofit/config/priors/mock_model.yaml
+++ b/test_autofit/config/priors/mock_model.yaml
@@ -419,3 +419,21 @@ MockWithTuple:
     width_modifier:
       type: Absolute
       value: 0.2
+
+
+WithConstants:
+  constant_1:
+    value: 1.0
+    type: Constant
+  constant_2:
+    value: 1.0
+    type: Constant
+
+
+ModelWithTupleConstant:
+  constant_0:
+    value: 1.0
+    type: Constant
+  constant_1:
+    value: 1.0
+    type: Constant
\ No newline at end of file
diff --git a/test_autofit/mapper/model/serialise/conftest.py b/test_autofit/mapper/model/serialise/conftest.py
index 1f98ec0be..48a49d5d6 100644
--- a/test_autofit/mapper/model/serialise/conftest.py
+++ b/test_autofit/mapper/model/serialise/conftest.py
@@ -1,5 +1,4 @@
 import pytest
-import autofit as af
 
 
 @pytest.fixture(name="model_dict")
@@ -24,5 +23,9 @@ def make_instance_dict():
     return {
         "class_path": "autofit.example.model.Gaussian",
         "type": "instance",
-        "arguments": {"centre": 0.0, "normalization": 0.1, "sigma": 0.01},
+        "arguments": {
+            "centre": {"type": "Constant", "value": 0.0},
+            "normalization": {"type": "Constant", "value": 0.1},
+            "sigma": {"type": "Constant", "value": 0.01},
+        },
     }
diff --git a/test_autofit/mapper/model/serialise/test_json.py b/test_autofit/mapper/model/serialise/test_json.py
index cacdc18a8..a5124a741 100644
--- a/test_autofit/mapper/model/serialise/test_json.py
+++ b/test_autofit/mapper/model/serialise/test_json.py
@@ -80,10 +80,15 @@ class TestToDict:
 
     def test_collection_instance(self, instance_dict):
         collection = af.Collection(gaussian=af.Gaussian())
-        print(collection.dict())
         assert collection.dict() == {
-            "arguments": {"gaussian": instance_dict},
             "type": "collection",
+            "arguments": {
+                "gaussian": {
+                    "type": "instance",
+                    "class_path": "autofit.example.model.Gaussian",
+                    "arguments": {"sigma": 0.01, "normalization": 0.1, "centre": 0.0},
+                }
+            },
         }
 
     @pytest.mark.parametrize(
diff --git a/test_autofit/mapper/test_constant.py b/test_autofit/mapper/test_constant.py
new file mode 100644
index 000000000..6f61307d9
--- /dev/null
+++ b/test_autofit/mapper/test_constant.py
@@ -0,0 +1,93 @@
+import pytest
+
+import autofit as af
+from autofit.mapper.mock.mock_model import (
+    WithConstants,
+    Parameter,
+    ModelWithTupleConstant,
+)
+from autofit.mapper.prior.constant import Constant
+
+
+@pytest.fixture
+def model():
+    return af.Model(WithConstants)
+
+
+def test_model(model):
+    assert model.constant_1 != model.constant_2
+
+    assert model.constant_1 == 1.0
+    assert model.constant_2 == 1.0
+
+
+def test_instance(model):
+    instance = model.instance_from_unit_vector([])
+
+    assert instance.constant_1 == 1.0
+    assert instance.constant_2 == 1.0
+
+    assert instance.constant_1 == instance.constant_2
+
+
+def test_collection(model):
+    collection = af.Collection(model, Constant(1.0))
+
+    instance = collection.instance_from_unit_vector([])
+    assert instance[0].constant_1 == 1.0
+    assert instance[0].constant_2 == 1.0
+
+    assert instance[1] == 1.0
+
+    assert isinstance(instance[0].constant_1, float)
+    assert isinstance(instance[1], float)
+
+
+def test_kwarg():
+    model = af.Model(Parameter, value=1.0)
+    assert model.value != af.Model(Parameter, value=1.0).value
+
+
+def test_tuple_constant():
+    model = af.Model(ModelWithTupleConstant)
+
+    assert model.constant.constant_0 != model.constant.constant_1
+
+    instance = model.instance_from_unit_vector([])
+    assert instance.constant[0] == instance.constant[1]
+
+
+def test_model_info(model):
+    assert (
+        model.info
+        == """Total Free Parameters = 0
+
+model                                                                           WithConstants (N=0)
+
+constant_1                                                                      1.0
+constant_2                                                                      1.0"""
+    )
+
+
+def test_set_constant(model):
+    model.constant_1 = 2.0
+    assert model.constant_1 == 2.0
+
+    model.constant_2 = 2.0
+    assert model.constant_2 == 2.0
+    assert model.constant_1 != model.constant_2
+
+
+def test_set_value_in_collection():
+    collection = af.Collection(1.0, 1.0)
+
+    assert collection[0] == 1.0
+    assert collection[1] == 1.0
+    assert collection[0] != collection[1]
+
+    collection[0] = 2.0
+    assert collection[0] == 2.0
+
+    collection[1] = 2.0
+    assert collection[1] == 2.0
+    assert collection[0] != collection[1]