Repository: rhayes777/PyAutoFit
Pull request #971 (2024-03-19 10:15:38)

feature/find groups
Another attempt at concise that separates out the variables that cause branches of the model to differ



Patch:
diff --git a/autofit/mapper/prior_model/abstract.py b/autofit/mapper/prior_model/abstract.py
index 14518ecb9..04695dc68 100644
--- a/autofit/mapper/prior_model/abstract.py
+++ b/autofit/mapper/prior_model/abstract.py
@@ -27,6 +27,7 @@ from autofit.mapper.prior_model.attribute_pair import (
     InstanceNameValue,
 )
 from autofit.mapper.prior_model.recursion import DynamicRecursionCache
+from autofit.mapper.prior_model.representative import find_groups
 from autofit.mapper.prior_model.util import PriorModelNameValue
 from autofit.text import formatter as frm
 from autofit.text.formatter import TextFormatter
@@ -1560,13 +1561,16 @@ class AbstractPriorModel(AbstractModel):
 
         formatter = TextFormatter(line_length=info_whitespace())
 
-        for t in self.path_instance_tuples_for_class(
-            (Prior, float, int, tuple, ConfigException), ignore_children=True
+        for t in find_groups(
+            [
+                t
+                for t in self.path_instance_tuples_for_class(
+                    (Prior, float, int, tuple, ConfigException), ignore_children=True
+                )
+                if t[0][-1] not in ("id", "item_number")
+            ],
+            limit=1,
         ):
-            name = t[0][-1]
-            if name in ("id", "item_number"):
-                continue
-
             if isinstance(t[1], ConfigException):
                 t = (t[0], "Prior Missing: Enter Manually or Add to Config")
 
@@ -1609,6 +1613,8 @@ class AbstractPriorModel(AbstractModel):
 
         formatter = TextFormatter(line_length=info_whitespace())
 
+        paths = []
+
         for t in self.path_instance_tuples_for_class(
             (
                 Prior,
@@ -1631,7 +1637,10 @@ class AbstractPriorModel(AbstractModel):
                 else:
                     name = type(obj).__name__
 
-                formatter.add(("model",) + path, f"{name} (N={n})")
+                paths.append((("model",) + path, f"{name} (N={n})"))
+
+        for group in find_groups(paths, limit=0):
+            formatter.add(*group)
 
         return formatter.text
 
diff --git a/autofit/mapper/prior_model/representative.py b/autofit/mapper/prior_model/representative.py
new file mode 100644
index 000000000..87efb30fc
--- /dev/null
+++ b/autofit/mapper/prior_model/representative.py
@@ -0,0 +1,48 @@
+from collections import defaultdict
+
+
+def find_groups(path_value_tuples, limit=1):
+    """
+    Groups path-value tuples by their path, up to a limit. This is used to group priors in the model representation.
+
+    If multiple paths share the same suffix they are grouped together.
+
+    Parameters
+    ----------
+    path_value_tuples
+        A list of tuples of paths and values in the model
+    limit
+        How far from the end of paths grouping should terminate. This can be used to prevent grouping of
+        priors and attributes.
+
+    Returns
+    -------
+    A list of tuples of paths and values, where paths are grouped by their suffix.
+    """
+    try:
+        maximum_path_length = max(len(path) for path, _ in path_value_tuples)
+    except ValueError:
+        return path_value_tuples
+    for position in range(maximum_path_length - limit):
+        path_value_tuples = _find_groups(path_value_tuples, position)
+    return path_value_tuples
+
+
+def _find_groups(path_value_tuples, position):
+    groups = defaultdict(list)
+    paths = []
+    for path, value in path_value_tuples:
+        try:
+            root_name = path[position]
+            before = path[:position]
+            after = path[position + 1 :]
+            groups[(tuple(before), tuple(after), value)].append(root_name)
+        except IndexError:
+            paths.append((path, value))
+
+    for (before, after, value), names in groups.items():
+        representative_key = (
+            f"{min(names)} - {max(names)}" if len(set(names)) > 1 else names[0]
+        )
+        paths.append(((*before, representative_key, *after), value))
+    return paths
diff --git a/autofit/text/formatter.py b/autofit/text/formatter.py
index ed55bb624..30bb807e5 100644
--- a/autofit/text/formatter.py
+++ b/autofit/text/formatter.py
@@ -3,7 +3,6 @@ import logging
 from typing import Tuple
 
 from autoconf import conf
-from autofit.text.representative import Representative
 from autofit.tools.util import open_
 
 logger = logging.getLogger(__name__)
@@ -25,12 +24,9 @@ class FormatNode:
     def items(self):
         return self._dict.items()
 
-    def groups(self):
-        return Representative.find_representatives(self.items())
-
     def list(self, indent=4, line_length=90):
         lines = []
-        for key, value in self.groups():
+        for key, value in self.items():
             indent_string = indent * " "
             if value.value is not None:
                 value_string = str(value.value)
diff --git a/autofit/text/representative.py b/autofit/text/representative.py
deleted file mode 100644
index 364cfcabe..000000000
--- a/autofit/text/representative.py
+++ /dev/null
@@ -1,124 +0,0 @@
-from collections import defaultdict
-from typing import Dict, List, Iterable
-
-import autofit as af
-
-
-class Representative:
-    def __init__(self, items: List[tuple]):
-        """
-        Collects together items that are the same except for ids. This is to make
-        the output of the info method more concise.
-
-        Parameters
-        ----------
-        items
-            A list of tuples of the form (key, object)
-        """
-        self.items = items
-
-    @property
-    def keys(self):
-        return [key for key, _ in self.items]
-
-    @property
-    def children(self):
-        return [obj for _, obj in self.items]
-
-    @property
-    def key(self) -> str:
-        """
-        A string representation of the range of keys in this representative.
-        """
-        keys = sorted(self.keys)
-        return f"{keys[0]} - {keys[-1]}"
-
-    @property
-    def representative(self):
-        """
-        The first object in the group represents the group.
-        """
-        return self.items[0][1]
-
-    def __getattr__(self, item):
-        return getattr(self.representative, item)
-
-    def __len__(self):
-        return len(self.representative)
-
-    @classmethod
-    def find_representatives(cls, items: Iterable[tuple], minimum: int = 4) -> list:
-        """
-        Find representatives in a list of items. This includes items from
-        the original list where there are not enough repetitions to form
-        a representative.
-
-        Parameters
-        ----------
-        items
-            A list of tuples of the form (key, object)
-        minimum
-            The minimum number of items that must be the same for a representative
-            to be formed.
-
-        Returns
-        -------
-        A list of representatives and items that are not part of a representative.
-        """
-        representative_dict: Dict[tuple, list] = defaultdict(list)
-        for key, obj in items:
-            blueprint = cls.get_blueprint(obj)
-            representative_dict[blueprint].append((key, obj))
-
-        representatives = []
-        for blueprint, items in representative_dict.items():
-            if len(items) >= minimum:
-                representative = Representative(items)
-                representatives.append((representative.key, representative))
-            else:
-                representatives.extend(items)
-
-        return representatives
-
-    @classmethod
-    def get_blueprint(cls, obj):
-        """
-        Get a blueprint for an object. This is a tuple of tuples of the form
-        (path, value) where path is a tuple of strings and value is a float, int,
-        tuple, or af.Prior.
-
-        Blueprints are unique per unique object in the model but are not sensitive
-        to ids.
-
-        Parameters
-        ----------
-        obj
-            The object to get a blueprint for.
-
-        Returns
-        -------
-        A blueprint for the object.
-        """
-        from autofit.text.formatter import FormatNode
-
-        if obj is None:
-            return None
-
-        if isinstance(obj, FormatNode):
-            return cls.get_blueprint(obj.value)
-        if isinstance(obj, (float, int, tuple, str)):
-            return obj
-        if isinstance(obj, af.Prior):
-            return type(obj), obj.parameter_string
-        if isinstance(obj, af.AbstractModel):
-            blueprint = tuple(
-                (path, cls.get_blueprint(value))
-                for path, value in obj.path_instance_tuples_for_class(
-                    (float, int, tuple, af.Prior), ignore_children=True
-                )
-                if path != ("id",)
-            )
-            if isinstance(obj, af.Model):
-                return blueprint + (obj.cls,)
-            return blueprint
-        raise ValueError(f"Cannot get blueprint for {obj} of type {type(obj)}")
diff --git a/autofit/text/samples_text.py b/autofit/text/samples_text.py
index d16c2af69..1b7e3098a 100644
--- a/autofit/text/samples_text.py
+++ b/autofit/text/samples_text.py
@@ -1,5 +1,6 @@
 import logging
 
+from autofit.mapper.prior_model.representative import find_groups
 from autofit.text import formatter as frm
 
 logger = logging.getLogger(__name__)
@@ -35,6 +36,8 @@ def summary(
 
     sigma_formatter = frm.TextFormatter(indent=indent, line_length=line_length)
 
+    paths = []
+
     for i, prior_path in enumerate(samples.model.unique_prior_paths):
         value_result = frm.value_result_string_from(
             parameter_name=parameter_names[i],
@@ -42,7 +45,10 @@ def summary(
             values_at_sigma=values_at_sigma[i],
         )
 
-        sigma_formatter.add(prior_path, value_result)
+        paths.append((prior_path, value_result))
+
+    for path, value in find_groups(paths):
+        sigma_formatter.add(path, value)
 
     return f"\n\nSummary ({sigma} sigma limits):\n\n{sigma_formatter.text}"
 
diff --git a/autofit/text/text_util.py b/autofit/text/text_util.py
index 14adf131d..bb7e815a0 100644
--- a/autofit/text/text_util.py
+++ b/autofit/text/text_util.py
@@ -1,6 +1,7 @@
 import datetime as dt
 
 from autoconf import conf
+from autofit.mapper.prior_model.representative import find_groups
 from autofit.text import formatter as frm, samples_text
 from autofit.tools.util import info_whitespace
 
@@ -55,11 +56,16 @@ def result_info_from(samples) -> str:
 
     max_log_likelihood_sample = samples.max_log_likelihood(as_instance=False)
 
+    paths = []
+
     for prior_path, value in zip(
         samples.model.unique_prior_paths,
         max_log_likelihood_sample,
     ):
-        formatter.add(prior_path, format_str().format(value))
+        paths.append((prior_path, value))
+
+    for path, value in find_groups(paths):
+        formatter.add(path, format_str().format(value))
     results += [formatter.text + "\n"]
 
     if hasattr(samples, "pdf_converged"):
@@ -85,8 +91,8 @@ def result_info_from(samples) -> str:
 
     formatter = frm.TextFormatter(line_length=info_whitespace())
 
-    for t in samples.model.path_float_tuples:
-        formatter.add(*t)
+    for path, value in find_groups(samples.model.path_float_tuples):
+        formatter.add(path, value)
 
     results += ["\n" + formatter.text]
 


Test patch:
diff --git a/test_autofit/mapper/info/test_representation.py b/test_autofit/mapper/info/test_representation.py
new file mode 100644
index 000000000..b455dbabf
--- /dev/null
+++ b/test_autofit/mapper/info/test_representation.py
@@ -0,0 +1,45 @@
+import autofit as af
+from autofit.mapper.prior_model.representative import find_groups
+
+
+def test_find_groups():
+    info_paths = [((i, 1), 2) for i in range(3)]
+    groups = find_groups(info_paths)
+    assert groups == [(("0 - 2", 1), 2)]
+
+
+def test_two_groups():
+    info_paths = [((i, 1), 2) for i in range(3)] + [((i, 2), 2) for i in range(3)]
+    groups = find_groups(info_paths)
+    assert groups == [(("0 - 2", 1), 2), (("0 - 2", 2), 2)]
+
+
+def test_equal_prior_values():
+    prior = af.UniformPrior(0.0, 1.0)
+    info_paths = [((i, 1), prior) for i in range(3)]
+    groups = find_groups(info_paths)
+    assert len(groups) == 1
+    ((path, _),) = groups
+    assert path == ("0 - 2", 1)
+
+
+def test_different_priors():
+    info_paths = [((i, 1), af.UniformPrior(0.0, i + 1)) for i in range(3)]
+    groups = find_groups(info_paths)
+    assert len(groups) == 3
+
+
+def test_exception():
+    info_paths = [((i, 0), 1) for i in range(3)] + [
+        ((0, 1), 2),
+        ((1, 1), 3),
+        ((2, 1), 4),
+    ]
+    groups = find_groups(info_paths)
+    assert groups == [(("0 - 2", 0), 1), ((0, 1), 2), ((1, 1), 3), ((2, 1), 4)]
+
+
+def test_prefix():
+    info_paths = [((0, i, 1), 2) for i in range(3)]
+    groups = find_groups(info_paths)
+    assert groups == [((0, "0 - 2", 1), 2)]
diff --git a/test_autofit/text/test_concise.py b/test_autofit/text/test_concise.py
index a18f55c49..5b28b11c9 100644
--- a/test_autofit/text/test_concise.py
+++ b/test_autofit/text/test_concise.py
@@ -3,7 +3,6 @@ import itertools
 import autofit as af
 import pytest
 
-from autofit.text.representative import Representative
 from autofit.text.text_util import result_info_from
 
 
@@ -14,15 +13,28 @@ def reset_ids():
 
 @pytest.fixture(name="collection")
 def make_collection():
-    return af.Collection([af.Model(af.Gaussian) for _ in range(20)])
+    centre = af.UniformPrior(0.0, 1.0)
+    normalization = af.UniformPrior(0.0, 1.0)
+    sigma = af.UniformPrior(0.0, 1.0)
+    return af.Collection(
+        [
+            af.Model(
+                af.Gaussian,
+                centre=centre,
+                normalization=normalization,
+                sigma=sigma,
+            )
+            for _ in range(20)
+        ]
+    )
 
 
 def test_model_info(collection):
     assert (
         collection.info
-        == """Total Free Parameters = 60
+        == """Total Free Parameters = 3
 
-model                                                                           Collection (N=60)
+model                                                                           Collection (N=3)
     0 - 9                                                                       Gaussian (N=3)
 
 0 - 9
@@ -32,22 +44,6 @@ model
     )
 
 
-def test_representative(collection):
-    ((key, representative),) = Representative.find_representatives(collection.items())
-
-    assert len(representative.children) == 20
-    assert key == "0 - 9"
-
-
-def test_get_blueprint():
-    assert Representative.get_blueprint(af.Model(af.Gaussian)) == (
-        (("centre",), (af.UniformPrior, "lower_limit = 0.0, upper_limit = 1.0")),
-        (("normalization",), (af.UniformPrior, "lower_limit = 0.0, upper_limit = 1.0")),
-        (("sigma",), (af.UniformPrior, "lower_limit = 0.0, upper_limit = 1.0")),
-        af.Gaussian,
-    )
-
-
 @pytest.fixture(name="samples")
 def make_samples(collection):
     parameters = [len(collection) * [1.0, 2.0, 3.0]]
@@ -73,12 +69,12 @@ def test_model_results(samples):
         == """Maximum Log Likelihood                                                          1.00000000
 Maximum Log Posterior                                                           1.00000000
 
-model                                                                           Collection (N=60)
+model                                                                           Collection (N=3)
     0 - 9                                                                       Gaussian (N=3)
 
 Maximum Log Likelihood Model:
 
-0 - 9
+19
     centre                                                                      1.000
     normalization                                                               2.000
     sigma                                                                       3.000
@@ -90,7 +86,7 @@ Maximum Log Likelihood Model:
 
 Summary (1.0 sigma limits):
 
-0 - 9
+19
     centre                                                                      1.00 (1.00, 1.00)
     normalization                                                               2.00 (2.00, 2.00)
     sigma                                                                       3.00 (3.00, 3.00)